# Core Recording Lifecycle Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Чистая подсистема `src/core/`: жизненный цикл голосовой записи, формат заметки, планировщик повторов и очередь отправки поверх портов — плюс тестовая инфраструктура проекта. **Architecture:** Чистые функции (`record.ts`, `naming.ts`, `note.ts`, `schedule.ts`) + два модуля, работающие через порты (`capture.ts`, `queue.ts`). Порты (`RecordingStore`, `NoteStore`, `Transcriber`, `Env`) реализует позже обёртка Obsidian; в тестах — in-memory фейки. Frontmatter и тело заметки меняются одной операцией `NoteStore.process`. **Tech Stack:** TypeScript (strict), esbuild (шаблон obsidian-sample-plugin), vitest, Node 24 только для тестов и сборки. **Spec:** `openspec/changes/core-recording-lifecycle/` — `proposal.md`, `design.md` (включая «Decisions made without discussion»), `specs/recording-lifecycle/spec.md`, `specs/voice-note-format/spec.md`. Issue: #1. Ветка: `feat/1-core-recording-lifecycle`. ## Global Constraints - `src/core/**` MUST NOT импортировать `obsidian`, `fs`, `path`, `child_process`, `node:*` (проверяет `tests/core-boundary.test.ts`). - Рантайм — только Web API; `manifest.json`: `"isDesktopOnly": false`. - Папки: аудио `voice/source/`, заметки `voice/inbox/`. - `voice-id` = `YYYYMMDD-HHMMSS-xxxx` по **UTC**; имя заметки `YYYY-MM-DD HH-mm-ss.md` по **локальному** времени; коллизия → ` (2)`, ` (3)`… - Расширения: `audio/mp4`→`m4a`, `audio/webm`→`webm`, `audio/ogg`→`ogg`, иначе `bin` (mime без параметров, регистр игнорируется). - `MAX_ATTEMPTS = 5`; паузы после 1–4 неудачи: 30 с, 2 мин, 10 мин, 30 мин; зависший `sent` — строго больше 10 минут. - Пустой транскрипт → `failed` с `voice-error: no-speech`, в заметке `> [!info] В записи не распознано речи`. - Маркеры области: `%%voice-note:%%` … `%%/voice-note%%`. - Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. - Строковые значения `voice-*` во frontmatter — в двойных кавычках (JSON-экранирование), числа и `true` — без кавычек. - Тесты запускаются с `TZ=Europe/Moscow` (UTC+3, без летнего времени) — выставляется в `vitest.config.ts`. - `npm test` — весь набор, зелёным считается только весь набор. - Любое решение, принятое исполнителем без обсуждения, дописывается в `openspec/changes/core-recording-lifecycle/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). - Коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 ` и ссылкой `Refs #1`. ## Review Focus 1. Транскрипт содержит `$&`, `$'`, `%%` или `---` — должен вставиться дословно (Task 5: тест «special replacement patterns»). 2. Сообщение ошибки провайдера с переводами строк, кавычками и двоеточиями — frontmatter остаётся валидным однострочным, callout однострочный (Task 4: тест «round-trips hostile error text»; Task 5: тест «collapses multi-line error»). 3. `metadataCache` отстаёт: `list()` показывает `saved`, а в файле уже `sent`/`inserted` — повторной отправки нет (Task 8: тесты «stale list»). 4. Заметку удалили между `list()` и отправкой — пропуск без исключения, остальные записи обрабатываются (Task 8: тест «note deleted before send»). 5. `voice-next-attempt` испорчен руками (не дата) — запись считается готовой к отправке, а не зависает навсегда (Task 6: тест «unparsable nextAttempt is due»). ## File Structure | Файл | Ответственность | |---|---| | `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json` | сборка, типы, тесты | | `src/main.ts` | пустой плагин, чтобы сборка работала (обёртка — другая issue) | | `src/core/ports.ts` | интерфейсы портов и `TranscribeResult` | | `src/core/naming.ts` | id, пути, расширения, форматирование времени/длительности | | `src/core/record.ts` | `VoiceRecord`, константы, `transition` | | `src/core/note.ts` | frontmatter `voice-*` (чтение, разбор, патч) и тело заметки (начальное, рендер, вставка) | | `src/core/schedule.ts` | `due`, `isStaleSent`, `staleOwn`, `claimable` | | `src/core/capture.ts` | `saveCapture` | | `src/core/queue.ts` | `QueueRunner` | | `tests/core-boundary.test.ts` | страж границы core | | `tests/fakes.ts` | in-memory порты, `TestEnv` | | `tests/*.test.ts` | по одному на модуль + `golden.test.ts` | | `tests/fixtures/test-01.note.md` | эталон итоговой заметки | --- ### Task 1: Scaffold, boundary guard, ports **Model:** sonnet **Files:** - Create: `package.json`, `tsconfig.json`, `vitest.config.ts`, `esbuild.config.mjs`, `manifest.json`, `src/main.ts`, `src/core/ports.ts`, `tests/core-boundary.test.ts` **Interfaces:** - Consumes: — - Produces: `src/core/ports.ts`: - `interface RecordingStore { write(path: string, data: ArrayBuffer): Promise; read(path: string): Promise }` - `interface NoteStore { exists(path: string): Promise; create(path: string, content: string): Promise; list(): Promise; process(path: string, fn: (content: string) => string): Promise }` - `interface ListedNote { path: string; frontmatter: Record }` - `type TranscribeResult = { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }` - `interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise }` - `interface Env { now(): Date; random(): number; readonly deviceId: string }` - команды `npm test`, `npm run build`, `npm run dev`, `npm run typecheck` - [ ] **Step 1: package.json и зависимости** ```json { "name": "obsidian-voice-note", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc --noEmit && node esbuild.config.mjs production", "typecheck": "tsc --noEmit", "test": "vitest run" } } ``` Run: `npm install -D typescript esbuild vitest obsidian @types/node` Expected: `node_modules/` создан, в `package.json` появились `devDependencies`, создан `package-lock.json`. - [ ] **Step 2: tsconfig.json, vitest.config.ts** `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], "types": ["node"], "strict": true, "noEmit": true, "isolatedModules": true, "skipLibCheck": true, "noImplicitOverride": true }, "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] } ``` `vitest.config.ts`: ```ts import { defineConfig } from 'vitest/config'; // Детерминированное локальное время для имён заметок и строк статуса: // UTC+3 без перехода на летнее время. Воркеры vitest наследуют env. process.env.TZ = 'Europe/Moscow'; export default defineConfig({ test: { include: ['tests/**/*.test.ts'], }, }); ``` - [ ] **Step 3: Написать падающий тест-страж** `tests/core-boundary.test.ts`: ```ts import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const CORE = fileURLToPath(new URL('../src/core/', import.meta.url)); const FORBIDDEN = /(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"](?:obsidian|fs|path|child_process|node:[^'"]*)(?:\/[^'"]*)?['"]/; function coreFiles(): string[] { if (!existsSync(CORE)) return []; return readdirSync(CORE, { recursive: true, encoding: 'utf8' }) .filter((f) => f.endsWith('.ts')) .map((f) => join(CORE, f)); } describe('src/core boundary', () => { it('detects forbidden imports', () => { expect(FORBIDDEN.test(`import { Plugin } from 'obsidian';`)).toBe(true); expect(FORBIDDEN.test(`import { readFile } from "fs/promises";`)).toBe(true); expect(FORBIDDEN.test(`import x from 'node:path';`)).toBe(true); expect(FORBIDDEN.test(`const cp = require('child_process');`)).toBe(true); expect(FORBIDDEN.test(`import { transition } from './record';`)).toBe(false); }); it('has files to check', () => { expect(coreFiles().length).toBeGreaterThan(0); }); it('imports neither obsidian nor Node modules', () => { const offenders = coreFiles().filter((f) => FORBIDDEN.test(readFileSync(f, 'utf8'))); expect(offenders).toEqual([]); }); }); ``` - [ ] **Step 4: Убедиться, что тест падает** Run: `npm test` Expected: FAIL в `has files to check` (`expected 0 to be greater than 0`). - [ ] **Step 5: ports.ts** `src/core/ports.ts`: ```ts // Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts. export interface RecordingStore { write(path: string, data: ArrayBuffer): Promise; read(path: string): Promise; } export interface ListedNote { path: string; frontmatter: Record; } export interface NoteStore { exists(path: string): Promise; create(path: string, content: string): Promise; // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter. // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать // содержимое файлов (vault.read / cachedRead / adapter). Вызывается на каждом // tick, в том числе на телефоне. Данные могут отставать от диска — core // перепроверяет состояние внутри process(). list(): Promise; // Атомарное чтение-изменение-запись. false, если заметки нет. process(path: string, fn: (content: string) => string): Promise; } export type TranscribeResult = | { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }; export interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise; } export interface Env { now(): Date; random(): number; readonly deviceId: string; } ``` - [ ] **Step 6: Убедиться, что тест проходит** Run: `npm test` Expected: PASS, 3 теста. - [ ] **Step 7: Сборка: manifest, main.ts, esbuild** `manifest.json`: ```json { "id": "voice-note", "name": "Voice Note", "version": "0.1.0", "minAppVersion": "1.5.0", "description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.", "author": "Andrey Epifancev", "isDesktopOnly": false } ``` `src/main.ts`: ```ts import { Plugin } from 'obsidian'; // Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала. export default class VoiceNotePlugin extends Plugin { override async onload(): Promise {} } ``` `esbuild.config.mjs`: ```js import esbuild from 'esbuild'; import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; import { builtinModules } from 'node:module'; import process from 'node:process'; const prod = process.argv[2] === 'production'; const VAULT = '/Users/andrey.epifancev/Library/Mobile Documents/iCloud~md~obsidian/Documents/voice-note-test'; const PLUGIN_DIR = `${VAULT}/.obsidian/plugins/voice-note`; // В dev-режиме после каждой сборки копируем плагин в тестовый vault, если он есть на этой машине. const copyToVault = { name: 'copy-to-vault', setup(build) { build.onEnd((result) => { if (result.errors.length > 0 || !existsSync(VAULT)) return; mkdirSync(PLUGIN_DIR, { recursive: true }); for (const file of ['main.js', 'manifest.json']) copyFileSync(file, `${PLUGIN_DIR}/${file}`); }); }, }; const context = await esbuild.context({ entryPoints: ['src/main.ts'], bundle: true, external: [ 'obsidian', 'electron', '@codemirror/autocomplete', '@codemirror/collab', '@codemirror/commands', '@codemirror/language', '@codemirror/lint', '@codemirror/search', '@codemirror/state', '@codemirror/view', '@lezer/common', '@lezer/highlight', '@lezer/lr', ...builtinModules, ], format: 'cjs', target: 'es2018', logLevel: 'info', sourcemap: prod ? false : 'inline', treeShaking: true, outfile: 'main.js', minify: prod, plugins: prod ? [] : [copyToVault], }); if (prod) { await context.rebuild(); process.exit(0); } else { await context.watch(); } ``` - [ ] **Step 8: Проверить сборку и типы** Run: `npm run build && ls -la main.js` Expected: exit 0, `main.js` существует (он в `.gitignore`). Run: `npm test` Expected: PASS. - [ ] **Step 9: Commit** ```bash git add package.json package-lock.json tsconfig.json vitest.config.ts esbuild.config.mjs manifest.json src/main.ts src/core/ports.ts tests/core-boundary.test.ts git commit -m "chore: scaffold build and vitest, add core ports and boundary guard Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: naming **Model:** haiku **Files:** - Create: `src/core/naming.ts`, `tests/naming.test.ts` **Interfaces:** - Consumes: — - Produces: - `SOURCE_DIR = 'voice/source'`, `INBOX_DIR = 'voice/inbox'` - `makeId(startedAt: Date, random: () => number): string` - `extFromMime(mime: string): string` - `audioPath(id: string, mime: string): string` - `notePath(startedAt: Date, n: number): string` (n=1 — без суффикса) - `formatDuration(ms: number): string` → `M:SS` - `formatLocalTime(iso: string): string` → `HH:mm` по локальному времени - [ ] **Step 1: Написать падающие тесты** `tests/naming.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { audioPath, extFromMime, formatDuration, formatLocalTime, makeId, notePath, } from '../src/core/naming'; const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве function seq(...values: number[]): () => number { return () => values.shift() ?? 0; } describe('test environment', () => { it('runs in UTC+3', () => { expect(T.getHours()).toBe(14); }); }); describe('makeId', () => { it('uses UTC time and 4 hex chars', () => { expect(makeId(T, () => 0)).toBe('20260927-111503-0000'); expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811'); }); it('clamps random() === 1 to f', () => { expect(makeId(T, () => 1)).toBe('20260927-111503-ffff'); }); it('does not depend on the local date', () => { const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000'); expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md'); }); }); describe('extFromMime', () => { it.each([ ['audio/mp4', 'm4a'], ['audio/webm', 'webm'], ['audio/webm;codecs=opus', 'webm'], ['AUDIO/MP4', 'm4a'], ['audio/ogg; codecs=opus', 'ogg'], ['audio/x-foo', 'bin'], ['', 'bin'], ])('%s → %s', (mime, ext) => { expect(extFromMime(mime)).toBe(ext); }); }); describe('paths', () => { it('audioPath', () => { expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a'); }); it('notePath uses local time and suffix from 2', () => { expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md'); expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); }); }); describe('formatting', () => { it.each([ [0, '0:00'], [47_000, '0:47'], [125_400, '2:05'], [-5, '0:00'], ])('formatDuration(%d) = %s', (ms, text) => { expect(formatDuration(ms)).toBe(text); }); it('formatLocalTime', () => { expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/naming.test.ts` Expected: FAIL — `Failed to resolve import "../src/core/naming"`. - [ ] **Step 3: Реализация** `src/core/naming.ts`: ```ts export const SOURCE_DIR = 'voice/source'; export const INBOX_DIR = 'voice/inbox'; const pad = (n: number) => String(n).padStart(2, '0'); // Время в id — UTC: порядок id не зависит от пояса устройства. export function makeId(startedAt: Date, random: () => number): string { const d = startedAt; const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`; const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`; let hex = ''; for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); return `${date}-${time}-${hex}`; } const EXT: Record = { 'audio/mp4': 'm4a', 'audio/webm': 'webm', 'audio/ogg': 'ogg', }; export function extFromMime(mime: string): string { const base = mime.split(';')[0].trim().toLowerCase(); return EXT[base] ?? 'bin'; } export function audioPath(id: string, mime: string): string { return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; } // Имя заметки — для человека, поэтому в локальном времени. export function notePath(startedAt: Date, n: number): string { const d = startedAt; const name = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`; } export function formatDuration(ms: number): string { const total = Math.max(0, Math.round(ms / 1000)); return `${Math.floor(total / 60)}:${pad(total % 60)}`; } export function formatLocalTime(iso: string): string { const d = new Date(iso); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS (все файлы). - [ ] **Step 5: Commit** ```bash git add src/core/naming.ts tests/naming.test.ts git commit -m "feat(core): record ids, file paths, time formatting Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: record state machine **Model:** sonnet **Files:** - Create: `src/core/record.ts`, `tests/record.test.ts` **Interfaces:** - Consumes: — - Produces: - `type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'`, `STATUSES: readonly Status[]` - `MAX_ATTEMPTS = 5`, `BACKOFF_MS: readonly number[]` (`[30_000, 120_000, 600_000, 1_800_000]`), `SENT_STALE_MS = 600_000`, `NO_SPEECH = 'no-speech'` - `interface VoiceRecord { id; status; device; audio (путь без [[ ]]); mime; attempts: number; nextAttempt?: string; sentAt?: string; error?: string; interrupted?: boolean }` - `type RecordEvent = {type:'send'} | {type:'success'} | {type:'temporary';message} | {type:'permanent';message} | {type:'retry-now';device} | {type:'claim';device}` - `class InvalidTransition extends Error` - `transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord` — чистая, бросает `InvalidTransition` - [ ] **Step 1: Написать падающие тесты** `tests/record.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { BACKOFF_MS, InvalidTransition, MAX_ATTEMPTS, transition, type RecordEvent, type Status, type VoiceRecord, } from '../src/core/record'; const NOW = new Date('2026-09-27T11:15:03.000Z'); const base: VoiceRecord = Object.freeze({ id: '20260927-111503-0000', status: 'saved', device: 'd1', audio: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4', attempts: 0, }); const sent = (over: Partial = {}): VoiceRecord => Object.freeze({ ...base, status: 'sent' as const, sentAt: NOW.toISOString(), ...over }); describe('transition', () => { it('send: saved → sent with sentAt', () => { expect(transition(base, { type: 'send' }, NOW)).toEqual({ ...base, status: 'sent', sentAt: '2026-09-27T11:15:03.000Z', }); }); it('send: retry → sent, drops nextAttempt, keeps attempts and error', () => { const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 1, nextAttempt: 'x', error: 'timeout' }); expect(transition(r, { type: 'send' }, NOW)).toEqual({ ...base, status: 'sent', attempts: 1, error: 'timeout', sentAt: NOW.toISOString(), }); }); it('success: sent → inserted, clears transient keys, keeps attempts', () => { expect(transition(sent({ error: 'old', attempts: 2 }), { type: 'success' }, NOW)).toEqual({ ...base, status: 'inserted', attempts: 2, }); }); it('temporary: backoff 30s, 2m, 10m, 30m after failures 1..4', () => { const got = [0, 1, 2, 3].map((a) => transition(sent({ attempts: a }), { type: 'temporary', message: 'timeout' }, NOW), ); expect(got.map((r) => r.status)).toEqual(['retry', 'retry', 'retry', 'retry']); expect(got.map((r) => r.attempts)).toEqual([1, 2, 3, 4]); expect(got.map((r) => Date.parse(r.nextAttempt!) - NOW.getTime())).toEqual([...BACKOFF_MS]); expect(got.every((r) => r.sentAt === undefined && r.error === 'timeout')).toBe(true); }); it('temporary: fifth failure → failed with last message', () => { expect( transition(sent({ attempts: MAX_ATTEMPTS - 1 }), { type: 'temporary', message: 'e5' }, NOW), ).toEqual({ ...base, status: 'failed', attempts: 5, error: 'e5' }); }); it('permanent: sent → failed immediately', () => { expect(transition(sent(), { type: 'permanent', message: '401 Unauthorized' }, NOW)).toEqual({ ...base, status: 'failed', attempts: 1, error: '401 Unauthorized', }); }); it('retry-now: failed → saved, attempts 0, device reassigned', () => { const r = Object.freeze({ ...base, status: 'failed' as const, attempts: 5, error: '401' }); expect(transition(r, { type: 'retry-now', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); }); it('retry-now: retry → saved, drops nextAttempt', () => { const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); expect(transition(r, { type: 'retry-now', device: 'd1' }, NOW)).toEqual(base); }); it('claim: retry → saved, new device, attempts kept', () => { const r = Object.freeze({ ...base, status: 'retry' as const, attempts: 2, nextAttempt: 'x', error: 't' }); expect(transition(r, { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2', attempts: 2, error: 't', }); }); it('claim: sent → saved, drops sentAt', () => { expect(transition(sent(), { type: 'claim', device: 'd2' }, NOW)).toEqual({ ...base, device: 'd2' }); }); const invalid: [Status, RecordEvent][] = [ ['inserted', { type: 'send' }], ['failed', { type: 'send' }], ['sent', { type: 'send' }], ['saved', { type: 'success' }], ['retry', { type: 'success' }], ['saved', { type: 'temporary', message: 'x' }], ['inserted', { type: 'permanent', message: 'x' }], ['saved', { type: 'retry-now', device: 'd1' }], ['inserted', { type: 'retry-now', device: 'd1' }], ['failed', { type: 'claim', device: 'd2' }], ['inserted', { type: 'claim', device: 'd2' }], ]; it.each(invalid)('rejects %s + %o', (status, event) => { const r = Object.freeze({ ...base, status }); expect(() => transition(r, event, NOW)).toThrow(InvalidTransition); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/record.test.ts` Expected: FAIL — `Failed to resolve import "../src/core/record"`. - [ ] **Step 3: Реализация** `src/core/record.ts`: ```ts // Персистентные состояния. created и transcribed живут только в памяти и сюда не входят. export type Status = 'saved' | 'sent' | 'retry' | 'failed' | 'inserted'; export const STATUSES: readonly Status[] = ['saved', 'sent', 'retry', 'failed', 'inserted']; export const MAX_ATTEMPTS = 5; // Пауза перед повтором после 1-й, 2-й, 3-й и 4-й временной неудачи. export const BACKOFF_MS: readonly number[] = [30_000, 120_000, 600_000, 1_800_000]; // sent старше этого считается прерванной отправкой. export const SENT_STALE_MS = 600_000; // Код причины в voice-error для пустого транскрипта. export const NO_SPEECH = 'no-speech'; export interface VoiceRecord { id: string; status: Status; device: string; audio: string; // путь в vault, без [[ ]] mime: string; attempts: number; nextAttempt?: string; // ISO UTC sentAt?: string; // ISO UTC error?: string; interrupted?: boolean; } export type RecordEvent = | { type: 'send' } | { type: 'success' } | { type: 'temporary'; message: string } | { type: 'permanent'; message: string } | { type: 'retry-now'; device: string } | { type: 'claim'; device: string }; export class InvalidTransition extends Error { constructor(from: Status, event: RecordEvent['type']) { super(`invalid transition: ${event} from ${from}`); this.name = 'InvalidTransition'; } } type Transient = 'nextAttempt' | 'sentAt' | 'error'; function without(record: VoiceRecord, ...keys: Transient[]): VoiceRecord { const copy = { ...record }; for (const key of keys) delete copy[key]; return copy; } export function transition(record: VoiceRecord, event: RecordEvent, now: Date): VoiceRecord { const from = record.status; switch (event.type) { case 'send': if (from !== 'saved' && from !== 'retry') break; return { ...without(record, 'nextAttempt'), status: 'sent', sentAt: now.toISOString() }; case 'success': if (from !== 'sent') break; return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'inserted' }; case 'temporary': { if (from !== 'sent') break; const attempts = record.attempts + 1; const next = { ...without(record, 'sentAt', 'nextAttempt'), attempts, error: event.message }; if (attempts >= MAX_ATTEMPTS) return { ...next, status: 'failed' }; const at = new Date(now.getTime() + BACKOFF_MS[attempts - 1]).toISOString(); return { ...next, status: 'retry', nextAttempt: at }; } case 'permanent': if (from !== 'sent') break; return { ...without(record, 'sentAt', 'nextAttempt'), status: 'failed', attempts: record.attempts + 1, error: event.message, }; case 'retry-now': if (from !== 'failed' && from !== 'retry') break; return { ...without(record, 'nextAttempt', 'sentAt', 'error'), status: 'saved', attempts: 0, device: event.device, }; case 'claim': if (from !== 'saved' && from !== 'retry' && from !== 'sent') break; return { ...without(record, 'nextAttempt', 'sentAt'), status: 'saved', device: event.device }; } throw new InvalidTransition(from, event.type); } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/core/record.ts tests/record.test.ts git commit -m "feat(core): voice record state machine with backoff Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: note frontmatter (read, parse, patch) **Model:** sonnet **Files:** - Create: `src/core/note.ts`, `tests/note-frontmatter.test.ts` **Interfaces:** - Consumes: из `record.ts`: `STATUSES`, `Status`, `VoiceRecord`. - Produces (в `src/core/note.ts`): - `VOICE_KEYS` (порядок ключей), `type VoiceKey` - `recordToFields(r: VoiceRecord): Record` — значения уже в YAML-виде - `readVoiceFields(content: string): Record` — ключи `voice-*` из frontmatter текста, значения раскавычены - `parseRecord(fields: Record): VoiceRecord | null` — принимает и строки (из `readVoiceFields`), и типизированные значения (из `metadataCache`) - `patchFrontmatter(content: string, fields: Record): string` - [ ] **Step 1: Написать падающие тесты** `tests/note-frontmatter.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note'; import type { VoiceRecord } from '../src/core/record'; const rec: VoiceRecord = { id: '20260927-111503-0000', status: 'retry', device: 'd1', audio: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/webm;codecs=opus', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 'timeout', interrupted: true, }; describe('recordToFields + patchFrontmatter', () => { it('writes keys in fixed order with quoted strings', () => { expect(patchFrontmatter('', recordToFields(rec))).toBe( [ '---', 'voice-id: "20260927-111503-0000"', 'voice-status: "retry"', 'voice-device: "d1"', 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"', 'voice-mime: "audio/webm;codecs=opus"', 'voice-attempts: 2', 'voice-next-attempt: "2026-09-27T11:17:03.000Z"', 'voice-error: "timeout"', 'voice-interrupted: true', '---', '', ].join('\n'), ); }); it('round-trips through readVoiceFields + parseRecord', () => { const content = patchFrontmatter('', recordToFields(rec)); expect(parseRecord(readVoiceFields(content))).toEqual(rec); }); it('round-trips hostile error text as a single line', () => { const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' }; const content = patchFrontmatter('', recordToFields(hostile)); const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:')); expect(errorLines).toHaveLength(1); expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0); expect(parseRecord(readVoiceFields(content))).toEqual(hostile); }); }); describe('patchFrontmatter', () => { const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n'; it('replaces in place and keeps user keys and order', () => { expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe( '---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n', ); }); it('appends new keys at the end of the block', () => { expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe( '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n', ); }); it('removes keys set to undefined, ignores absent ones', () => { expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe( '---\ntitle: Идея\ntags: [idea]\n---\nbody\n', ); }); it('creates a block when there is none, body untouched', () => { expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n'); }); it('does not match a longer key with the same prefix', () => { expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe( '---\nvoice-idx: 1\nvoice-id: "x"\n---\n', ); }); }); describe('readVoiceFields', () => { it('reads only the frontmatter block', () => { const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n'; expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' }); }); it('returns {} without frontmatter', () => { expect(readVoiceFields('voice-id: "a"\n')).toEqual({}); }); it('accepts plain and single-quoted values', () => { expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({ 'voice-id': 'abc', 'voice-mime': 'audio/mp4', }); }); }); describe('parseRecord', () => { const typed = { 'voice-id': '20260927-111503-0000', 'voice-status': 'saved', 'voice-device': 'd1', 'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]', 'voice-mime': 'audio/mp4', 'voice-attempts': 0, }; it('accepts metadataCache-style typed values', () => { expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({ id: '20260927-111503-0000', status: 'saved', device: 'd1', audio: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4', attempts: 3, interrupted: true, }); }); it('defaults missing attempts to 0', () => { const { 'voice-attempts': _omit, ...rest } = typed; expect(parseRecord(rest)?.attempts).toBe(0); }); it.each([ ['missing id', { ...typed, 'voice-id': undefined }], ['bad status', { ...typed, 'voice-status': 'foo' }], ['negative attempts', { ...typed, 'voice-attempts': -1 }], ['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }], ['object audio', { ...typed, 'voice-audio': { a: 1 } }], ])('returns null for %s', (_name, fields) => { expect(parseRecord(fields)).toBeNull(); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/note-frontmatter.test.ts` Expected: FAIL — `Failed to resolve import "../src/core/note"`. - [ ] **Step 3: Реализация** `src/core/note.ts`: ```ts import { STATUSES, type Status, type VoiceRecord } from './record'; // Порядок, в котором ключи появляются в новой заметке. export const VOICE_KEYS = [ 'voice-id', 'voice-status', 'voice-device', 'voice-audio', 'voice-mime', 'voice-attempts', 'voice-next-attempt', 'voice-sent-at', 'voice-error', 'voice-interrupted', ] as const; export type VoiceKey = (typeof VOICE_KEYS)[number]; // Строки — всегда в двойных кавычках: JSON-строка является валидной YAML-строкой // и экранирует переводы строк, кавычки и двоеточия в одну строку. function yaml(value: string | number): string { return typeof value === 'string' ? JSON.stringify(value) : String(value); } export function recordToFields(r: VoiceRecord): Record { return { 'voice-id': yaml(r.id), 'voice-status': yaml(r.status), 'voice-device': yaml(r.device), 'voice-audio': yaml(`[[${r.audio}]]`), 'voice-mime': yaml(r.mime), 'voice-attempts': yaml(r.attempts), 'voice-next-attempt': r.nextAttempt === undefined ? undefined : yaml(r.nextAttempt), 'voice-sent-at': r.sentAt === undefined ? undefined : yaml(r.sentAt), 'voice-error': r.error === undefined ? undefined : yaml(r.error), 'voice-interrupted': r.interrupted ? 'true' : undefined, }; } // [первая строка внутри блока, индекс закрывающего ---] function frontmatterRange(lines: string[]): [number, number] | null { if (lines[0] !== '---') return null; const end = lines.indexOf('---', 1); return end === -1 ? null : [1, end]; } function unquote(raw: string): string { if (raw.startsWith('"')) { try { const value: unknown = JSON.parse(raw); if (typeof value === 'string') return value; } catch { // не JSON — вернём как есть } } if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { return raw.slice(1, -1).replace(/''/g, "'"); } return raw; } const VOICE_LINE = /^(voice-[a-z-]+):[ \t]*(.*)$/; export function readVoiceFields(content: string): Record { const lines = content.split('\n'); const range = frontmatterRange(lines); const fields: Record = {}; if (!range) return fields; for (let i = range[0]; i < range[1]; i++) { const match = VOICE_LINE.exec(lines[i]); if (match) fields[match[1]] = unquote(match[2].trim()); } return fields; } function str(value: unknown): string | undefined { if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); return undefined; } export function parseRecord(fields: Record): VoiceRecord | null { const id = str(fields['voice-id']); const status = str(fields['voice-status']); const device = str(fields['voice-device']); const audio = str(fields['voice-audio']); const mime = str(fields['voice-mime']); const attempts = Number(str(fields['voice-attempts']) ?? '0'); if (!id || !device || !audio || !mime) return null; if (!STATUSES.includes(status as Status)) return null; if (!Number.isInteger(attempts) || attempts < 0) return null; const record: VoiceRecord = { id, status: status as Status, device, audio: audio.replace(/^\[\[/, '').replace(/\]\]$/, ''), mime, attempts, }; const nextAttempt = str(fields['voice-next-attempt']); const sentAt = str(fields['voice-sent-at']); const error = str(fields['voice-error']); if (nextAttempt) record.nextAttempt = nextAttempt; if (sentAt) record.sentAt = sentAt; if (error) record.error = error; if (str(fields['voice-interrupted']) === 'true') record.interrupted = true; return record; } // Меняет только перечисленные ключи; undefined — удалить строку. // Остальные строки frontmatter и их порядок не трогаются. export function patchFrontmatter(content: string, fields: Record): string { const lines = content.split('\n'); let range = frontmatterRange(lines); if (!range) { lines.unshift('---', '---'); range = [1, 1]; } const start = range[0]; let end = range[1]; for (const key of Object.keys(fields)) { const value = fields[key]; let index = -1; for (let i = start; i < end; i++) { if (lines[i].startsWith(`${key}:`)) { index = i; break; } } if (value === undefined) { if (index !== -1) { lines.splice(index, 1); end--; } } else if (index !== -1) { lines[index] = `${key}: ${value}`; } else { lines.splice(end, 0, `${key}: ${value}`); end++; } } return lines.join('\n'); } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/core/note.ts tests/note-frontmatter.test.ts git commit -m "feat(core): read, parse and patch voice-* frontmatter Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: note body (initial note, status region, transcript insertion) **Model:** sonnet **Files:** - Modify: `src/core/note.ts` (дописать в конец) - Create: `tests/note-body.test.ts` **Interfaces:** - Consumes: `recordToFields`, `patchFrontmatter`, `readVoiceFields` (Task 4); `MAX_ATTEMPTS`, `NO_SPEECH`, `VoiceRecord` (Task 3); `formatDuration`, `formatLocalTime` (Task 2). - Produces: - `MARKER_END = '%%/voice-note%%'`, `markerStart(id: string): string` - `statusLine(r: VoiceRecord): string` - `initialNote(record: VoiceRecord, durationMs: number): string` - `renderNote(content: string, record: VoiceRecord, text?: string): string` — `text` обязателен при `record.status === 'inserted'` - [ ] **Step 1: Написать падающие тесты** `tests/note-body.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { initialNote, readVoiceFields, renderNote } from '../src/core/note'; import { NO_SPEECH, type VoiceRecord } from '../src/core/record'; const saved: VoiceRecord = { id: '20260927-111503-0000', status: 'saved', device: 'd1', audio: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4', attempts: 0, }; const FM = '---\n' + 'voice-id: "20260927-111503-0000"\n' + 'voice-status: "saved"\n' + 'voice-device: "d1"\n' + 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' + 'voice-mime: "audio/mp4"\n' + 'voice-attempts: 0\n' + '---\n'; const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n'; const inserted: VoiceRecord = { ...saved, status: 'inserted' }; describe('initialNote', () => { it('builds frontmatter, embed and status region', () => { expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`); }); it('adds interrupted line above the region', () => { const note = initialNote({ ...saved, interrupted: true }, 47_000); expect(note).toContain('voice-interrupted: true\n---\n'); expect(note).toContain( '![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:', ); }); }); describe('renderNote: status region', () => { it('retry shows local time and attempt', () => { const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' }; const out = renderNote(initialNote(saved, 0), r); expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n'); expect(out).not.toContain('Распознаётся'); expect(readVoiceFields(out)['voice-status']).toBe('retry'); }); it('failed shows reason and the command', () => { const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' }); expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n'); }); it('no-speech is not an error', () => { const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH }); expect(out).toContain('> [!info] В записи не распознано речи\n'); expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/); }); it('collapses multi-line error into one callout line', () => { const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' }); expect(out).toContain('> [!warning] Не распознано: a b. Команда'); }); it('without markers updates frontmatter only', () => { const content = `${FM}моя заметка\n`; const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' }); expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n')); }); }); describe('renderNote: transcript', () => { it('replaces the region and keeps user text around it', () => { const content = `${initialNote(saved, 0)}купить молоко\n`; expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe( `${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`, ); }); it('keeps the interrupted line', () => { const r = { ...saved, interrupted: true }; const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст'); expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n'); }); it('appends at the end when markers were removed', () => { const content = `${FM}заметка без маркеров\n\n\n`; expect(renderNote(content, inserted, 'текст')).toBe( `${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`, ); }); it('is a no-op when already inserted', () => { const once = renderNote(initialNote(saved, 0), inserted, 'первый'); expect(renderNote(once, inserted, 'второй')).toBe(once); }); it('inserts special replacement patterns verbatim', () => { const text = "цена $& и $' и $1 и %%voice-note%% и ---"; expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`); }); it('keeps user properties in place', () => { const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id'); const out = renderNote(content, inserted, 'x'); expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/note-body.test.ts` Expected: FAIL — `initialNote is not a function` / отсутствующий экспорт. - [ ] **Step 3: Реализация — дописать в `src/core/note.ts`** Заменить первую строку импорта в `src/core/note.ts` на: ```ts import { formatDuration, formatLocalTime } from './naming'; import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record'; ``` Дописать в конец файла: ```ts export const MARKER_END = '%%/voice-note%%'; export const markerStart = (id: string) => `%%voice-note:${id}%%`; export function statusLine(r: VoiceRecord): string { switch (r.status) { case 'retry': { const at = r.nextAttempt ? formatLocalTime(r.nextAttempt) : '—'; return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; } case 'failed': { if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; const reason = (r.error ?? 'неизвестная причина').replace(/\s+/g, ' ').trim(); return `> [!warning] Не распознано: ${reason}. Команда «Повторить распознавание»`; } default: return '> [!info] Распознаётся…'; } } function region(r: VoiceRecord): string { return `${markerStart(r.id)}\n${statusLine(r)}\n${MARKER_END}`; } export function initialNote(record: VoiceRecord, durationMs: number): string { const interrupted = record.interrupted ? `> [!note] Запись прервана, сохранено ${formatDuration(durationMs)}\n\n` : ''; return ( patchFrontmatter('', recordToFields(record)) + `![[${record.audio}]]\n\n${interrupted}${region(record)}\n` ); } // Обновляет frontmatter и управляемую область одной операцией. // Склейка через slice, не String.replace: в транскрипте могут быть $&, $' и т.п. export function renderNote(content: string, record: VoiceRecord, text?: string): string { if (record.status === 'inserted' && readVoiceFields(content)['voice-status'] === 'inserted') { return content; } const patched = patchFrontmatter(content, recordToFields(record)); const start = patched.indexOf(markerStart(record.id)); const end = start === -1 ? -1 : patched.indexOf(MARKER_END, start); if (record.status === 'inserted') { const body = (text ?? '').trim(); if (end === -1) return `${patched.replace(/\s+$/, '')}\n\n${body}\n`; return patched.slice(0, start) + body + patched.slice(end + MARKER_END.length); } if (end === -1) return patched; return patched.slice(0, start) + region(record) + patched.slice(end + MARKER_END.length); } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/core/note.ts tests/note-body.test.ts git commit -m "feat(core): note body with status region and transcript insertion Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 6: schedule **Model:** haiku **Files:** - Create: `src/core/schedule.ts`, `tests/schedule.test.ts` **Interfaces:** - Consumes: `SENT_STALE_MS`, `VoiceRecord` (Task 3). - Produces: - `interface Located { path: string; record: VoiceRecord }` - `isStaleSent(r: VoiceRecord, now: Date): boolean` - `due(items: readonly Located[], now: Date, deviceId: string): Located[]` — отсортировано по `record.id` - `staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[]` - `claimable(items: readonly Located[], now: Date, deviceId: string): Located[]` - [ ] **Step 1: Написать падающие тесты** `tests/schedule.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import type { VoiceRecord } from '../src/core/record'; import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule'; const NOW = new Date('2026-09-27T11:15:03.000Z'); const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); function item(id: string, over: Partial = {}): Located { return { path: `voice/inbox/${id}.md`, record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }, }; } const ids = (xs: Located[]) => xs.map((x) => x.record.id); describe('due', () => { it('takes own saved and retry whose time has come, oldest id first', () => { const items = [ item('c', { status: 'retry', nextAttempt: NOW.toISOString() }), item('a'), item('b', { status: 'retry', nextAttempt: later(1) }), item('d', { device: 'other' }), item('e', { status: 'sent', sentAt: ago(0) }), item('f', { status: 'failed' }), item('g', { status: 'inserted' }), ]; expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']); }); it('unparsable nextAttempt is due', () => { expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']); }); }); describe('isStaleSent', () => { it.each([ ['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false], ['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true], ['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false], ['no sentAt', { status: 'sent' as const }, true], ['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true], ['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false], ])('%s → %s', (_name, over, expected) => { expect(isStaleSent(item('a', over).record, NOW)).toBe(expected); }); }); describe('staleOwn', () => { it('only own stale sent', () => { const items = [ item('a', { status: 'sent', sentAt: ago(700_000) }), item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }), item('c', { status: 'sent', sentAt: ago(1_000) }), ]; expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']); }); }); describe('claimable', () => { it("others' saved, retry and stale sent only", () => { const o = { device: 'other' }; const items = [ item('a', o), item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }), item('c', { ...o, status: 'sent', sentAt: ago(700_000) }), item('d', { ...o, status: 'sent', sentAt: ago(1_000) }), item('e', { ...o, status: 'failed' }), item('f', { ...o, status: 'inserted' }), item('g'), ]; expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/schedule.test.ts` Expected: FAIL — `Failed to resolve import "../src/core/schedule"`. - [ ] **Step 3: Реализация** `src/core/schedule.ts`: ```ts import { SENT_STALE_MS, type VoiceRecord } from './record'; export interface Located { path: string; record: VoiceRecord; } const time = (iso?: string) => (iso ? Date.parse(iso) : NaN); // Отсутствующее или испорченное время — не повод зависнуть навсегда. export function isStaleSent(r: VoiceRecord, now: Date): boolean { if (r.status !== 'sent') return false; const t = time(r.sentAt); return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS; } export function due(items: readonly Located[], now: Date, deviceId: string): Located[] { return items .filter(({ record: r }) => { if (r.device !== deviceId) return false; if (r.status === 'saved') return true; if (r.status !== 'retry') return false; const t = time(r.nextAttempt); return Number.isNaN(t) || t <= now.getTime(); }) .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); } export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] { return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now)); } export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] { return items.filter( ({ record: r }) => r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), ); } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/core/schedule.ts tests/schedule.test.ts git commit -m "feat(core): due, stale-sent and claimable selection Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 7: fakes and saveCapture **Model:** sonnet **Files:** - Create: `tests/fakes.ts`, `src/core/capture.ts`, `tests/capture.test.ts` **Interfaces:** - Consumes: порты (Task 1); `makeId`, `audioPath`, `notePath` (Task 2); `VoiceRecord` (Task 3); `initialNote`, `readVoiceFields` (Tasks 4–5). - Produces: - `tests/fakes.ts`: `MemoryRecordings` (`files: Map`), `MemoryNotes` (`files: Map`, `staleList: ListedNote[] | null`, `listCalls: number`), `type Step`, `ScriptedTranscriber` (`steps: Step[]`, `calls: {audio, mime, signal}[]`), `TestEnv` (`time`, `deviceId`, `random`, `now()`, `advance(ms)`) - `src/core/capture.ts`: `interface Capture { chunks: ArrayBuffer[]; mime: string; startedAt: Date; endedAt: Date; interrupted: boolean }`, `interface CaptureDeps { recordings: RecordingStore; notes: NoteStore; env: Env }`, `saveCapture(deps: CaptureDeps, capture: Capture): Promise` — путь заметки или `null` для пустой записи - [ ] **Step 1: Фейки портов** `tests/fakes.ts`: ```ts import { readVoiceFields } from '../src/core/note'; import type { Env, ListedNote, NoteStore, RecordingStore, TranscribeResult, Transcriber, } from '../src/core/ports'; export class MemoryRecordings implements RecordingStore { files = new Map(); async write(path: string, data: ArrayBuffer): Promise { this.files.set(path, data.slice(0)); } async read(path: string): Promise { const data = this.files.get(path); if (!data) throw new Error(`no such file: ${path}`); return data.slice(0); } } export class MemoryNotes implements NoteStore { files = new Map(); // Имитация отстающего metadataCache: если задано, list() возвращает это. staleList: ListedNote[] | null = null; listCalls = 0; async exists(path: string): Promise { return this.files.has(path); } async create(path: string, content: string): Promise { if (this.files.has(path)) throw new Error(`already exists: ${path}`); this.files.set(path, content); } async list(): Promise { this.listCalls++; if (this.staleList) return this.staleList; return [...this.files] .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) .filter((note) => 'voice-id' in note.frontmatter); } async process(path: string, fn: (content: string) => string): Promise { const content = this.files.get(path); if (content === undefined) return false; this.files.set(path, fn(content)); return true; } } export type Step = | TranscribeResult | Error | ((audio: ArrayBuffer, mime: string) => Promise); export class ScriptedTranscriber implements Transcriber { calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = []; constructor(public steps: Step[] = []) {} async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { this.calls.push({ audio, mime, signal }); const step = this.steps.shift(); if (step === undefined) throw new Error('ScriptedTranscriber: no more steps'); if (step instanceof Error) throw step; if (typeof step === 'function') return step(audio, mime); return step; } } export class TestEnv implements Env { constructor( public time = new Date('2026-09-27T11:15:03.000Z'), public deviceId = 'test-device', public random: () => number = () => 0, ) {} now(): Date { return new Date(this.time); } advance(ms: number): void { this.time = new Date(this.time.getTime() + ms); } } ``` - [ ] **Step 2: Написать падающие тесты saveCapture** `tests/capture.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { saveCapture, type Capture } from '../src/core/capture'; import { initialNote, readVoiceFields } from '../src/core/note'; import { MemoryNotes, MemoryRecordings, TestEnv } from './fakes'; const T = new Date('2026-09-27T11:15:03.000Z'); const buf = (...bytes: number[]) => new Uint8Array(bytes).buffer; function setup() { const env = new TestEnv(T); const notes = new MemoryNotes(); const recordings = new MemoryRecordings(); return { env, notes, recordings, deps: { env, notes, recordings } }; } const capture = (over: Partial = {}): Capture => ({ chunks: [buf(1, 2), buf(3, 4, 5)], mime: 'audio/mp4', startedAt: T, endedAt: new Date(T.getTime() + 47_000), interrupted: false, ...over, }); describe('saveCapture', () => { it('writes concatenated audio and a saved note', async () => { const { deps, notes, recordings } = setup(); const path = await saveCapture(deps, capture()); expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); const audio = recordings.files.get('voice/source/20260927-111503-0000.m4a'); expect(new Uint8Array(audio!)).toEqual(new Uint8Array([1, 2, 3, 4, 5])); expect(notes.files.get(path!)).toBe( initialNote( { id: '20260927-111503-0000', status: 'saved', device: 'test-device', audio: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4', attempts: 0, }, 47_000, ), ); }); it.each([[[]], [[new ArrayBuffer(0)]], [[new ArrayBuffer(0), new ArrayBuffer(0)]]])( 'creates nothing for empty data %#', async (chunks) => { const { deps, notes, recordings } = setup(); expect(await saveCapture(deps, capture({ chunks, interrupted: true }))).toBeNull(); expect(notes.files.size).toBe(0); expect(recordings.files.size).toBe(0); }, ); it('marks interrupted recordings with duration', async () => { const { deps, notes } = setup(); const path = await saveCapture(deps, capture({ interrupted: true })); const content = notes.files.get(path!)!; expect(readVoiceFields(content)['voice-interrupted']).toBe('true'); expect(content).toContain('> [!note] Запись прервана, сохранено 0:47'); }); it('adds (2), (3) on name collision', async () => { const { deps, notes } = setup(); notes.files.set('voice/inbox/2026-09-27 14-15-03.md', 'чужая'); expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); expect(notes.files.get('voice/inbox/2026-09-27 14-15-03.md')).toBe('чужая'); }); it('keeps full mime and picks extension from it', async () => { const { deps, notes, recordings } = setup(); const path = await saveCapture(deps, capture({ mime: 'audio/webm;codecs=opus' })); expect(recordings.files.has('voice/source/20260927-111503-0000.webm')).toBe(true); expect(readVoiceFields(notes.files.get(path!)!)['voice-mime']).toBe('audio/webm;codecs=opus'); }); }); ``` - [ ] **Step 3: Убедиться, что тесты падают** Run: `npx vitest run tests/capture.test.ts` Expected: FAIL — `Failed to resolve import "../src/core/capture"`. - [ ] **Step 4: Реализация** `src/core/capture.ts`: ```ts import { audioPath, makeId, notePath } from './naming'; import { initialNote } from './note'; import type { Env, NoteStore, RecordingStore } from './ports'; import type { VoiceRecord } from './record'; export interface Capture { chunks: ArrayBuffer[]; mime: string; startedAt: Date; endedAt: Date; interrupted: boolean; } export interface CaptureDeps { recordings: RecordingStore; notes: NoteStore; env: Env; } // Сохраняет запись до любой сети. Возвращает путь заметки или null, если данных нет. export async function saveCapture(deps: CaptureDeps, capture: Capture): Promise { const size = capture.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); if (size === 0) return null; const bytes = new Uint8Array(size); let offset = 0; for (const chunk of capture.chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; } const id = makeId(capture.startedAt, () => deps.env.random()); const record: VoiceRecord = { id, status: 'saved', device: deps.env.deviceId, audio: audioPath(id, capture.mime), mime: capture.mime, attempts: 0, }; if (capture.interrupted) record.interrupted = true; await deps.recordings.write(record.audio, bytes.buffer); let n = 1; while (await deps.notes.exists(notePath(capture.startedAt, n))) n++; const path = notePath(capture.startedAt, n); await deps.notes.create(path, initialNote(record, capture.endedAt.getTime() - capture.startedAt.getTime())); return path; } ``` - [ ] **Step 5: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS (включая страж границы: `capture.ts` не импортирует запрещённое). - [ ] **Step 6: Commit** ```bash git add tests/fakes.ts src/core/capture.ts tests/capture.test.ts git commit -m "feat(core): save captured audio and initial note; in-memory port fakes Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 8: QueueRunner.tick **Model:** opus **Files:** - Create: `src/core/queue.ts`, `tests/queue.test.ts` **Interfaces:** - Consumes: `NoteStore`, `RecordingStore`, `Transcriber`, `TranscribeResult`, `Env` (Task 1); `transition`, `NO_SPEECH`, `VoiceRecord` (Task 3); `readVoiceFields`, `parseRecord`, `renderNote`, `initialNote` (Tasks 4–5); `due`, `staleOwn`, `isStaleSent`, `Located` (Task 6); фейки (Task 7). - Produces: - `interface QueueDeps { recordings: RecordingStore; notes: NoteStore; transcriber: Transcriber; env: Env }` - `INTERRUPTED_SEND = 'отправка прервана'` - `class QueueRunner { constructor(deps: QueueDeps); tick(): Promise; dispose(): void }` и приватный `update(path, expect, change): Promise`, `load(): Promise` — используются в Task 9 - [ ] **Step 1: Написать падающие тесты** `tests/queue.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { initialNote, readVoiceFields } from '../src/core/note'; import { QueueRunner } from '../src/core/queue'; import type { VoiceRecord } from '../src/core/record'; import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; const T0 = new Date('2026-09-27T11:15:03.000Z'); const ok = (text: string): Step => ({ ok: true, text }); const temporary = (message: string): Step => ({ ok: false, kind: 'temporary', message }); const permanent = (message: string): Step => ({ ok: false, kind: 'permanent', message }); function setup(steps: Step[] = []) { const env = new TestEnv(T0); const notes = new MemoryNotes(); const recordings = new MemoryRecordings(); const transcriber = new ScriptedTranscriber(steps); const runner = new QueueRunner({ recordings, notes, transcriber, env }); return { env, notes, recordings, transcriber, runner }; } type Ctx = ReturnType; function rec(id: string, over: Partial = {}): VoiceRecord { return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; } function seed(ctx: Ctx, record: VoiceRecord, path = `voice/inbox/${record.id}.md`): string { ctx.notes.files.set(path, initialNote(record, 0)); ctx.recordings.files.set(record.audio, new Uint8Array([1, 2, 3]).buffer); return path; } const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); describe('QueueRunner.tick', () => { it('sends a saved record and inserts the trimmed transcript', async () => { const ctx = setup([ok(' привет ')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(new Uint8Array(ctx.transcriber.calls[0].audio)).toEqual(new Uint8Array([1, 2, 3])); expect(ctx.transcriber.calls[0].mime).toBe('audio/mp4'); expect(fields(ctx, path)['voice-status']).toBe('inserted'); expect(fields(ctx, path)['voice-sent-at']).toBeUndefined(); expect(ctx.notes.files.get(path)).toContain('\n\nпривет\n'); expect(ctx.notes.files.get(path)).not.toContain('%%voice-note'); }); it('network lost, then back: retry at +30s, then inserted', async () => { const ctx = setup([temporary('offline'), ok('текст')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-next-attempt': '2026-09-27T11:15:33.000Z', 'voice-error': 'offline', }); expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); ctx.env.advance(17_000); // 11:15:20 — рано await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); ctx.env.advance(13_000); // 11:15:33 — пора await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(2); expect(fields(ctx, path)['voice-status']).toBe('inserted'); }); it('five temporary failures → failed with the last message', async () => { const ctx = setup([temporary('e1'), temporary('e2'), temporary('e3'), temporary('e4'), temporary('e5')]); const path = seed(ctx, rec('a')); for (let i = 0; i < 5; i++) { await ctx.runner.tick(); ctx.env.advance(3_600_000); } await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(5); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '5', 'voice-error': 'e5' }); }); it('permanent error → failed at once', async () => { const ctx = setup([permanent('401 Unauthorized')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-attempts': '1' }); expect(ctx.notes.files.get(path)).toContain('Не распознано: 401 Unauthorized'); }); it('whitespace-only transcript → failed no-speech', async () => { const ctx = setup([ok(' \n\t ')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'failed', 'voice-error': 'no-speech' }); expect(ctx.notes.files.get(path)).toContain('> [!info] В записи не распознано речи'); }); it('exception from transcriber is a temporary failure', async () => { const ctx = setup([new Error('socket hang up')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-error': 'socket hang up' }); }); it('missing audio file is a temporary failure', async () => { const ctx = setup([]); const path = seed(ctx, rec('a')); ctx.recordings.files.clear(); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(fields(ctx, path)['voice-status']).toBe('retry'); }); it("does not send other devices' records", async () => { const ctx = setup([]); const path = seed(ctx, rec('a', { device: 'iphone' })); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(fields(ctx, path)['voice-status']).toBe('saved'); }); it('own sent older than 10 min → retry without a request; younger untouched', async () => { const ctx = setup([]); const stale = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); const fresh = seed(ctx, rec('b', { status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); const before = ctx.notes.files.get(fresh); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(fields(ctx, stale)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1', 'voice-error': 'отправка прервана' }); expect(ctx.notes.files.get(fresh)).toBe(before); }); it('processes records one at a time, oldest first', async () => { let inFlight = 0; let maxInFlight = 0; const order: string[] = []; const slow: Step = async (audio) => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); order.push(String(new Uint8Array(audio)[0])); await new Promise((resolve) => setTimeout(resolve, 5)); inFlight--; return { ok: true, text: 't' }; }; const ctx = setup([slow, slow, slow]); for (const [id, byte] of [['c', 3], ['a', 1], ['b', 2]] as const) { seed(ctx, rec(id)); ctx.recordings.files.set(`voice/source/${id}.m4a`, new Uint8Array([byte]).buffer); } await ctx.runner.tick(); expect(maxInFlight).toBe(1); expect(order).toEqual(['1', '2', '3']); }); it('tick during tick runs exactly one extra pass', async () => { const ctx: Ctx = setup(); ctx.transcriber.steps.push(async () => { void ctx.runner.tick(); void ctx.runner.tick(); void ctx.runner.tick(); return { ok: true, text: 't' }; }); seed(ctx, rec('a')); await ctx.runner.tick(); expect(ctx.notes.listCalls).toBe(2); expect(ctx.transcriber.calls).toHaveLength(1); }); it('note deleted during the request: result dropped, next record processed', async () => { const ctx: Ctx = setup(); const first = seed(ctx, rec('a')); const second = seed(ctx, rec('b')); ctx.transcriber.steps.push(async () => { ctx.notes.files.delete(first); return { ok: true, text: 'один' }; }, ok('два')); await ctx.runner.tick(); expect(ctx.notes.files.has(first)).toBe(false); expect(fields(ctx, second)['voice-status']).toBe('inserted'); }); it('note deleted before send: skipped without a request', async () => { const ctx = setup([ok('два')]); const second = seed(ctx, rec('b')); ctx.notes.staleList = [ { path: 'voice/inbox/gone.md', frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }, { path: second, frontmatter: fields(ctx, second) }, ]; await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, second)['voice-status']).toBe('inserted'); }); it('stale list says saved, file says sent: no second request', async () => { const ctx = setup([]); const path = seed(ctx, rec('a', { status: 'sent', sentAt: '2026-09-27T11:15:00.000Z' })); ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; const before = ctx.notes.files.get(path); await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(0); expect(ctx.notes.files.get(path)).toBe(before); }); it('stale list says saved, file says inserted: untouched', async () => { const ctx = setup([ok('x')]); const path = seed(ctx, rec('a')); await ctx.runner.tick(); const done = ctx.notes.files.get(path); ctx.notes.staleList = [{ path, frontmatter: readVoiceFields(initialNote(rec('a'), 0)) }]; await ctx.runner.tick(); expect(ctx.transcriber.calls).toHaveLength(1); expect(ctx.notes.files.get(path)).toBe(done); }); it('finds records outside voice/inbox', async () => { const ctx = setup([ok('x')]); const path = seed(ctx, rec('a'), 'projects/перенесённая.md'); await ctx.runner.tick(); expect(fields(ctx, path)['voice-status']).toBe('inserted'); }); it('ignores notes with a broken status', async () => { const ctx = setup([]); const path = seed(ctx, rec('a')); ctx.notes.files.set(path, ctx.notes.files.get(path)!.replace('"saved"', '"foo"')); await expect(ctx.runner.tick()).resolves.toBeUndefined(); expect(ctx.transcriber.calls).toHaveLength(0); }); it('dispose aborts the signal given to the transcriber', async () => { const ctx = setup([ok('x')]); seed(ctx, rec('a')); await ctx.runner.tick(); expect(ctx.transcriber.calls[0].signal.aborted).toBe(false); ctx.runner.dispose(); expect(ctx.transcriber.calls[0].signal.aborted).toBe(true); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/queue.test.ts` Expected: FAIL — `Failed to resolve import "../src/core/queue"`. - [ ] **Step 3: Реализация** `src/core/queue.ts`: ```ts import { parseRecord, readVoiceFields, renderNote } from './note'; import type { Env, NoteStore, RecordingStore, TranscribeResult, Transcriber } from './ports'; import { NO_SPEECH, transition, type VoiceRecord } from './record'; import { due, isStaleSent, staleOwn, type Located } from './schedule'; export interface QueueDeps { recordings: RecordingStore; notes: NoteStore; transcriber: Transcriber; env: Env; } export const INTERRUPTED_SEND = 'отправка прервана'; interface Change { record: VoiceRecord; text?: string; } export class QueueRunner { private running = false; private rerun = false; private readonly controller = new AbortController(); constructor(private readonly deps: QueueDeps) {} // Повторный вызов во время работы не запускает параллельный проход, // а просит ровно один дополнительный после текущего. async tick(): Promise { if (this.running) { this.rerun = true; return; } this.running = true; try { do { this.rerun = false; await this.pass(); } while (this.rerun); } finally { this.running = false; } } dispose(): void { this.controller.abort(); } private async pass(): Promise { const { env } = this.deps; const items = await this.load(); const now = env.now(); for (const item of staleOwn(items, now, env.deviceId)) { await this.update( item.path, (r) => r.id === item.record.id && r.device === env.deviceId && isStaleSent(r, now), (r) => ({ record: transition(r, { type: 'temporary', message: INTERRUPTED_SEND }, now) }), ); } for (const item of due(items, now, env.deviceId)) await this.send(item); } private async send(item: Located): Promise { const { recordings, transcriber, env } = this.deps; const now = env.now(); // Список мог устареть (metadataCache) — решение принимаем по актуальному файлу. const sent = await this.update( item.path, (r) => r.id === item.record.id && due([{ path: item.path, record: r }], now, env.deviceId).length > 0, (r) => ({ record: transition(r, { type: 'send' }, now) }), ); if (!sent) return; let result: TranscribeResult; try { const audio = await recordings.read(sent.audio); result = await transcriber.transcribe(audio, sent.mime, this.controller.signal); } catch (e) { result = { ok: false, kind: 'temporary', message: e instanceof Error ? e.message : String(e) }; } if (result.ok && result.text.trim() === '') { result = { ok: false, kind: 'permanent', message: NO_SPEECH }; } const done = env.now(); const final = result; await this.update( item.path, (r) => r.id === sent.id && r.status === 'sent', (r) => final.ok ? { record: transition(r, { type: 'success' }, done), text: final.text } : { record: transition(r, { type: final.kind, message: final.message }, done) }, ); } // Атомарно: перечитать запись из актуального содержимого, проверить ожидание, // применить изменение. null — заметки нет или запись не в ожидаемом состоянии. private async update( path: string, expect: (r: VoiceRecord) => boolean, change: (r: VoiceRecord) => Change, ): Promise { const box: { record: VoiceRecord | null } = { record: null }; const exists = await this.deps.notes.process(path, (content) => { const current = parseRecord(readVoiceFields(content)); if (!current || !expect(current)) return content; const next = change(current); box.record = next.record; return renderNote(content, next.record, next.text); }); return exists ? box.record : null; } private async load(): Promise { const located: Located[] = []; for (const { path, frontmatter } of await this.deps.notes.list()) { const record = parseRecord(frontmatter); if (record) located.push({ path, record }); } return located; } } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS. - [ ] **Step 5: Проверка типов** Run: `npm run typecheck` Expected: exit 0. - [ ] **Step 6: Commit** ```bash git add src/core/queue.ts tests/queue.test.ts git commit -m "feat(core): queue runner with retries, stale-sent recovery and re-entrancy guard Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 9: manual commands — retry and claimAll **Model:** sonnet **Files:** - Modify: `src/core/queue.ts` (методы в класс `QueueRunner`, импорт `claimable`) - Create: `tests/queue-commands.test.ts` **Interfaces:** - Consumes: `QueueRunner` с приватными `update`, `load`, `tick` (Task 8); `claimable` (Task 6); `transition` события `retry-now`, `claim` (Task 3). - Produces: - `QueueRunner.retry(path: string): Promise` — `true`, если запись была в `failed`/`retry` и перезапущена - `QueueRunner.claimAll(): Promise` — число переназначенных записей - [ ] **Step 1: Написать падающие тесты** `tests/queue-commands.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { initialNote, readVoiceFields } from '../src/core/note'; import { QueueRunner } from '../src/core/queue'; import type { VoiceRecord } from '../src/core/record'; import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv, type Step } from './fakes'; const T0 = new Date('2026-09-27T11:15:03.000Z'); function setup(steps: Step[] = []) { const env = new TestEnv(T0); const notes = new MemoryNotes(); const recordings = new MemoryRecordings(); const transcriber = new ScriptedTranscriber(steps); const runner = new QueueRunner({ recordings, notes, transcriber, env }); return { env, notes, recordings, transcriber, runner }; } type Ctx = ReturnType; function rec(id: string, over: Partial = {}): VoiceRecord { return { id, status: 'saved', device: 'test-device', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }; } function seed(ctx: Ctx, record: VoiceRecord): string { const path = `voice/inbox/${record.id}.md`; ctx.notes.files.set(path, initialNote(record, 0)); ctx.recordings.files.set(record.audio, new Uint8Array([1]).buffer); return path; } const fields = (ctx: Ctx, path: string) => readVoiceFields(ctx.notes.files.get(path)!); describe('QueueRunner.retry', () => { it('failed → sent again with attempts from zero', async () => { const ctx = setup([{ ok: false, kind: 'temporary', message: 'offline' }]); const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: '401' })); expect(await ctx.runner.retry(path)).toBe(true); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'retry', 'voice-attempts': '1' }); }); it('retry state: sends now without waiting', async () => { const ctx = setup([{ ok: true, text: 'x' }]); const path = seed(ctx, rec('a', { status: 'retry', attempts: 2, nextAttempt: '2026-09-27T12:00:00.000Z', error: 't' })); expect(await ctx.runner.retry(path)).toBe(true); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-attempts': '0' }); }); it("claims another device's failed record", async () => { const ctx = setup([{ ok: true, text: 'x' }]); const path = seed(ctx, rec('a', { status: 'failed', attempts: 5, error: 'e', device: 'iphone' })); expect(await ctx.runner.retry(path)).toBe(true); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device' }); }); it.each(['saved', 'inserted'] as const)('%s: no change, no request', async (status) => { const ctx = setup([]); const path = seed(ctx, rec('a', { status })); const before = ctx.notes.files.get(path); expect(await ctx.runner.retry(path)).toBe(false); expect(ctx.notes.files.get(path)).toBe(before); expect(ctx.transcriber.calls).toHaveLength(0); }); it('missing note → false', async () => { expect(await setup().runner.retry('voice/inbox/nope.md')).toBe(false); }); }); describe('QueueRunner.claimAll', () => { it("takes the phone's retry and sends it now, keeping attempts", async () => { const ctx = setup([{ ok: true, text: 'x' }]); const path = seed(ctx, rec('a', { device: 'iphone', status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); expect(await ctx.runner.claimAll()).toBe(1); expect(ctx.transcriber.calls).toHaveLength(1); expect(fields(ctx, path)).toMatchObject({ 'voice-status': 'inserted', 'voice-device': 'test-device', 'voice-attempts': '2' }); }); it('includes stale sent; leaves failed, inserted, fresh sent and own records', async () => { const ctx = setup([{ ok: true, text: 'x' }, { ok: true, text: 'y' }]); const o = { device: 'iphone' }; const saved = seed(ctx, rec('a', o)); const stale = seed(ctx, rec('b', { ...o, status: 'sent', sentAt: '2026-09-27T11:04:00.000Z' })); const fresh = seed(ctx, rec('c', { ...o, status: 'sent', sentAt: '2026-09-27T11:13:03.000Z' })); const failed = seed(ctx, rec('d', { ...o, status: 'failed', attempts: 5, error: 'e' })); const done = seed(ctx, rec('e', { ...o, status: 'inserted' })); const own = seed(ctx, rec('f', { status: 'retry', attempts: 1, nextAttempt: '2026-09-27T11:35:03.000Z', error: 't' })); const untouched = [fresh, failed, done, own].map((p) => ctx.notes.files.get(p)); expect(await ctx.runner.claimAll()).toBe(2); expect(fields(ctx, saved)['voice-status']).toBe('inserted'); expect(fields(ctx, stale)['voice-status']).toBe('inserted'); expect([fresh, failed, done, own].map((p) => ctx.notes.files.get(p))).toEqual(untouched); }); it('nothing to claim → 0', async () => { expect(await setup().runner.claimAll()).toBe(0); }); }); ``` - [ ] **Step 2: Убедиться, что тесты падают** Run: `npx vitest run tests/queue-commands.test.ts` Expected: FAIL — `ctx.runner.retry is not a function`. - [ ] **Step 3: Реализация** В `src/core/queue.ts` заменить импорт из `./schedule` на: ```ts import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule'; ``` Добавить в класс `QueueRunner` после `dispose()`: ```ts // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись // переходит текущему устройству (иначе чужую запись здесь не отправить). async retry(path: string): Promise { const { env } = this.deps; const changed = await this.update( path, (r) => r.status === 'failed' || r.status === 'retry', (r) => ({ record: transition(r, { type: 'retry-now', device: env.deviceId }, env.now()) }), ); if (!changed) return false; await this.tick(); return true; } // «Распознать все ожидающие здесь»: чужие saved/retry/зависшие sent → своё // устройство, статус saved (без ожидания повтора), счётчик сохраняется. async claimAll(): Promise { const { env } = this.deps; const now = env.now(); let claimed = 0; for (const item of claimable(await this.load(), now, env.deviceId)) { const changed = await this.update( item.path, (r) => r.id === item.record.id && claimable([{ path: item.path, record: r }], now, env.deviceId).length > 0, (r) => ({ record: transition(r, { type: 'claim', device: env.deviceId }, now) }), ); if (changed) claimed++; } await this.tick(); return claimed; } ``` - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/core/queue.ts tests/queue-commands.test.ts git commit -m "feat(core): manual retry and claim-all commands Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 10: golden test-01 **Model:** sonnet **Files:** - Create: `tests/golden.test.ts`, `tests/fixtures/test-01.note.md` **Interfaces:** - Consumes: `saveCapture` (Task 7), `QueueRunner` (Task 8), фейки (Task 7); фикстуры `tests/fixtures/test-01.m4a` (в git), `tests/fixtures/test-01.txt`. - Produces: правило «новый golden-кейс = `.note.md` + `.txt` + `.{m4a|webm|ogg}`». - [ ] **Step 1: Написать падающий тест** `tests/golden.test.ts`: ```ts import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { saveCapture } from '../src/core/capture'; import { QueueRunner } from '../src/core/queue'; import { MemoryNotes, MemoryRecordings, ScriptedTranscriber, TestEnv } from './fakes'; const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)); const MIME: Record = { m4a: 'audio/mp4', webm: 'audio/webm', ogg: 'audio/ogg' }; const cases = readdirSync(FIXTURES) .filter((f) => f.endsWith('.note.md')) .map((f) => f.slice(0, -'.note.md'.length)); function audioFor(name: string): { file: string; mime: string } { for (const ext of Object.keys(MIME)) { const file = `${FIXTURES}${name}.${ext}`; if (existsSync(file)) return { file, mime: MIME[ext] }; } throw new Error(`нет аудиофикстуры tests/fixtures/${name}.m4a (или .webm/.ogg) для эталона ${name}.note.md`); } function toArrayBuffer(buf: Buffer): ArrayBuffer { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; } describe('golden', () => { it('has at least one case', () => { expect(cases.length).toBeGreaterThan(0); }); for (const name of cases) { it(name, async () => { const { file, mime } = audioFor(name); const audio = toArrayBuffer(readFileSync(file)); const transcript = readFileSync(`${FIXTURES}${name}.txt`, 'utf8'); const expected = readFileSync(`${FIXTURES}${name}.note.md`, 'utf8'); const env = new TestEnv(); const notes = new MemoryNotes(); const recordings = new MemoryRecordings(); const transcriber = new ScriptedTranscriber([{ ok: true, text: transcript }]); const path = await saveCapture( { recordings, notes, env }, { chunks: [audio], mime, startedAt: env.now(), endedAt: new Date(env.now().getTime() + 47_000), interrupted: false }, ); await new QueueRunner({ recordings, notes, transcriber, env }).tick(); expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); expect(transcriber.calls).toHaveLength(1); expect(transcriber.calls[0].mime).toBe(mime); expect(Buffer.from(transcriber.calls[0].audio).equals(Buffer.from(audio))).toBe(true); expect(notes.files.get(path!)).toBe(expected); }); } }); ``` - [ ] **Step 2: Убедиться, что тест падает** Run: `npx vitest run tests/golden.test.ts` Expected: FAIL в `has at least one case` (`expected 0 to be greater than 0`). - [ ] **Step 3: Создать эталон** Эталон собирается из `test-01.txt`, чтобы не переписывать транскрипт руками (`$(cat …)` срезает завершающий перевод строки, `printf '%s\n'` возвращает ровно один): ```bash { printf '%s\n' \ '---' \ 'voice-id: "20260927-111503-0000"' \ 'voice-status: "inserted"' \ 'voice-device: "test-device"' \ 'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"' \ 'voice-mime: "audio/mp4"' \ 'voice-attempts: 0' \ '---' \ '![[voice/source/20260927-111503-0000.m4a]]' \ '' printf '%s\n' "$(cat tests/fixtures/test-01.txt)" } > tests/fixtures/test-01.note.md cat tests/fixtures/test-01.note.md ``` Expected: файл из 11 строк; последняя — транскрипт «Это тестовая запись для проверки распознавания речи с помощью модели WHISPER, для сравнения между двух конкретных моделей.» - [ ] **Step 4: Убедиться, что тесты проходят** Run: `npm test` Expected: PASS, весь набор. Если golden падает на сравнении заметки — не править эталон под код. Сравнить `notes.files.get(path)` с эталоном построчно, найти расхождение, проверить его против `specs/voice-note-format/spec.md` и исправить ту сторону, которая противоречит спеке. - [ ] **Step 5: Финальная проверка** Run: `npm run build && npm test && npm run typecheck` Expected: все три exit 0. - [ ] **Step 6: Commit** ```bash git add tests/golden.test.ts tests/fixtures/test-01.note.md git commit -m "test(core): golden test-01 end-to-end capture → queue → note Refs #1 Co-Authored-By: Claude Opus 5.5 " ``` --- ## Spec coverage | Требование | Задачи | |---|---| | recording-lifecycle / Состояния записи | 3, 8 | | Сохранение захваченного аудио | 7 | | Отправка и результат (по одной, пустой транскрипт) | 8 | | Повторы после временных ошибок | 3, 8 | | Восстановление после прерванной отправки | 6, 8 | | Принадлежность устройству | 6, 8 | | «Распознать все ожидающие здесь» | 6, 9 | | «Повторить распознавание» | 3, 9 | | Удалённая заметка отменяет запись / поиск вне inbox | 8 | | Повторный запуск очереди | 8 | | voice-note-format / Идентификатор и имена файлов | 2, 7 | | Ключи frontmatter | 4 | | Тело заметки до распознавания | 5 | | Вставка транскрипта | 5, 8 | | Golden-эталоны | 10 | | Граница core без obsidian/Node | 1 |