From cb9a691931ecea74090e8a5d8689a9c04535c52a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:14:25 +0400 Subject: [PATCH] docs: implementation plan for core recording lifecycle; planning decisions Refs #1 Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-core-recording-lifecycle.md | 2528 +++++++++++++++++ .../core-recording-lifecycle/design.md | 12 + 2 files changed, 2540 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md diff --git a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md new file mode 100644 index 0000000..688c538 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md @@ -0,0 +1,2528 @@ +# 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 | diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 5086277..31206ba 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -113,3 +113,15 @@ interface Env { now(): Date; random(): number; deviceId: string; } 12. 2026-09-27 — `NoteStore.list()` отдаёт frontmatter-объект, а не текст заметки; core перепроверяет состояние внутри `process` по актуальному содержимому и пропускает запись, если она уже не в ожидаемом состоянии или не своего устройства. *Следствие правки 2: без чтения файлов текста нет, а `metadataCache` обновляется асинхронно.* **Без перепроверки — повторная отправка только что отправленной записи.** 13. 2026-09-27 — Исключение из `RecordingStore.read` или `Transcriber.transcribe` трактуется как временная ошибка со своим сообщением. *Классифицирует провайдер, но непредвиденный throw не должен ронять очередь.* **Если исключение на деле постоянное (например, аудиофайл удалён) — 5 бесполезных попыток за ~42 минуты, затем `failed`.** 14. 2026-09-27 — Причина «нет речи» хранится кодом `voice-error: no-speech`, а не текстом. *Рендер должен отличать её от обычной ошибки без разбора сообщений.* **Нулевая: значение внутреннее.** +15. 2026-09-27 (план) — Все строковые значения `voice-*` пишутся в двойных кавычках через `JSON.stringify`; числа (`voice-attempts`) и `true` — без кавычек. *JSON-строка — валидная YAML-строка: переводы строк, кавычки и двоеточия из сообщений провайдера экранируются в одну строку; id вида `20260927-...` не рискует распарситься YAML как число/дата.* **Кавычки видны в режиме исходника — косметика.** +16. 2026-09-27 (план) — Из `retry` запись переходит сразу в `sent`; промежуточное `saved` (на схеме «срок наступил → saved») не записывается. *Лишняя запись в файл без наблюдаемой разницы.* **Нулевая: `saved` и «`retry` с наступившим сроком» обрабатываются одинаково.** +17. 2026-09-27 (план) — «Повторить распознавание» переназначает запись текущему устройству. *Иначе `failed`-запись с телефона, повторённая на маке, вернулась бы в `saved` с устройством телефона, и мак её не отправил бы — команда выглядела бы сломанной.* **Если телефон одновременно тоже повторит ту же запись — один лишний запрос; текст не портится.** +18. 2026-09-27 (план) — Тесты идут с `TZ=Europe/Moscow` (задаётся в `vitest.config.ts`). *Имена заметок и строки статуса зависят от локального времени; UTC+3 без летнего времени даёт детерминизм и заметно отличается от UTC.* **Если CI окажется на Windows, где `TZ` игнорируется — тесты с локальным временем упадут; сейчас CI нет.** +19. 2026-09-27 (план) — id плагина `voice-note`; `npm run dev` после каждой сборки копирует `main.js` и `manifest.json` в `<тестовый vault>/.obsidian/plugins/voice-note/`, если vault есть на машине. *CLAUDE.md: «dev — сборка в тестовый vault».* **Смена id позже — переустановка плагина в vault; данные core живут во frontmatter, не теряются.** +20. 2026-09-27 (план) — Если маркеры области стёрты, а запись ещё не `inserted` (retry/failed), плагин обновляет только frontmatter, строку статуса в тело не дописывает; транскрипт при успехе дописывается в конец. *Не мусорить в заметке, которую пользователь сознательно переделал.* **Пользователь не видит в теле статус ожидания/ошибки такой заметки — он остаётся в Properties.** +21. 2026-09-27 (план) — Причина ошибки в callout сжимается в одну строку (пробельные символы → один пробел). *Многострочное сообщение ломает callout.* **Потеря переносов в тексте ошибки — косметика; полное сообщение остаётся во frontmatter.** +22. 2026-09-27 (план) — Готовые к отправке записи обрабатываются по возрастанию `voice-id` (старые первыми). *Предсказуемо; id по UTC упорядочен во времени.* **Нулевая.** +23. 2026-09-27 (план) — Нераспознаваемое `voice-next-attempt` — запись готова к отправке; отсутствующее или нераспознаваемое `voice-sent-at` у `sent` — отправка считается зависшей. *Ручная правка frontmatter не должна вешать запись навсегда.* **Один досрочный запрос.** +24. 2026-09-27 (план) — `QueueRunner.dispose()` отменяет `AbortSignal`, переданный провайдеру; прерванный запрос обрабатывается как любое исключение — временная ошибка. *Контракт `Transcriber` принимает signal; выгрузка плагина должна обрывать загрузку.* **Выгрузка посреди запроса тратит одну попытку из пяти.** +25. 2026-09-27 (план) — Golden-кейсы находятся по файлам `tests/fixtures/*.note.md`; аудио ищется как `.m4a`, `.webm`, `.ogg` с mime `audio/mp4`, `audio/webm`, `audio/ogg`. *Добавить кейс — положить три файла, без правки теста.* **Нулевая.** +26. 2026-09-27 (план) — Модели исполнителей: haiku — `naming`, `schedule` (механика по готовому коду); opus — `QueueRunner.tick` (асинхронность, гонки); sonnet — остальное. Режим — subagent-driven-development (10 задач > 5). *Правила Model Selection SDD и глобальный CLAUDE.md.* **Слабая модель на механической задаче — лишний круг ревью, не дефект в ветке.**