obsidian-voice-note/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md
Andrey Epifancev 944a6594ec docs: apply plan review — retry status wording, TZ note, stale-sent and rename decisions
Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:42:09 +04:00

93 KiB
Raw Blame History

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:<id>%% … %%/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 <noreply@anthropic.com> и ссылкой 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<void>; read(path: string): Promise<ArrayBuffer> }
    • interface NoteStore { exists(path: string): Promise<boolean>; create(path: string, content: string): Promise<void>; list(): Promise<ListedNote[]>; process(path: string, fn: (content: string) => string): Promise<boolean> }
    • interface ListedNote { path: string; frontmatter: Record<string, unknown> }
    • type TranscribeResult = { ok: true; text: string } | { ok: false; kind: 'temporary' | 'permanent'; message: string }
    • interface Transcriber { transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult> }
    • interface Env { now(): Date; random(): number; readonly deviceId: string }
    • команды npm test, npm run build, npm run dev, npm run typecheck
  • Step 1: package.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:

{
  "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:

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:

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:

// Порты core. Реализации — в src/obsidian (позже) и tests/fakes.ts.

export interface RecordingStore {
  write(path: string, data: ArrayBuffer): Promise<void>;
  read(path: string): Promise<ArrayBuffer>;
}

export interface ListedNote {
  path: string;
  frontmatter: Record<string, unknown>;
}

export interface NoteStore {
  exists(path: string): Promise<boolean>;
  create(path: string, content: string): Promise<void>;
  // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
  // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
  // содержимое файлов (vault.read / cachedRead / adapter). Вызывается на каждом
  // tick, в том числе на телефоне. Данные могут отставать от диска — core
  // перепроверяет состояние внутри process().
  list(): Promise<ListedNote[]>;
  // Атомарное чтение-изменение-запись. false, если заметки нет.
  process(path: string, fn: (content: string) => string): Promise<boolean>;
}

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<TranscribeResult>;
}

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:

{
  "id": "voice-note",
  "name": "Voice Note",
  "version": "0.1.0",
  "minAppVersion": "1.5.0",
  "description": "Голосовые заметки: запись, распознавание внешним сервисом, текст в заметку.",
  "author": "Andrey Epifancev",
  "isDesktopOnly": false
}

src/main.ts:

import { Plugin } from 'obsidian';

// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
export default class VoiceNotePlugin extends Plugin {
  override async onload(): Promise<void> {}
}

esbuild.config.mjs:

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
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 <noreply@anthropic.com>"

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:

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:

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<string, string> = {
  '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
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 <noreply@anthropic.com>"

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:

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> = {}): 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:

// Персистентные состояния. 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
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 <noreply@anthropic.com>"

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<VoiceKey, string | undefined> — значения уже в YAML-виде
    • readVoiceFields(content: string): Record<string, string> — ключи voice-* из frontmatter текста, значения раскавычены
    • parseRecord(fields: Record<string, unknown>): VoiceRecord | null — принимает и строки (из readVoiceFields), и типизированные значения (из metadataCache)
    • patchFrontmatter(content: string, fields: Record<string, string | undefined>): string
  • Step 1: Написать падающие тесты

tests/note-frontmatter.test.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:

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<VoiceKey, string | undefined> {
  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<string, string> {
  const lines = content.split('\n');
  const range = frontmatterRange(lines);
  const fields: Record<string, string> = {};
  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<string, unknown>): 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, string | undefined>): 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
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 <noreply@anthropic.com>"

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:

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 на:

import { formatDuration, formatLocalTime } from './naming';
import { MAX_ATTEMPTS, NO_SPEECH, STATUSES, type Status, type VoiceRecord } from './record';

Дописать в конец файла:

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
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 <noreply@anthropic.com>"

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:

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<VoiceRecord> = {}): 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:

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
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 <noreply@anthropic.com>"

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<string, ArrayBuffer>), MemoryNotes (files: Map<string, string>, 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<string | null> — путь заметки или null для пустой записи
  • Step 1: Фейки портов

tests/fakes.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<string, ArrayBuffer>();

  async write(path: string, data: ArrayBuffer): Promise<void> {
    this.files.set(path, data.slice(0));
  }

  async read(path: string): Promise<ArrayBuffer> {
    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<string, string>();
  // Имитация отстающего metadataCache: если задано, list() возвращает это.
  staleList: ListedNote[] | null = null;
  listCalls = 0;

  async exists(path: string): Promise<boolean> {
    return this.files.has(path);
  }

  async create(path: string, content: string): Promise<void> {
    if (this.files.has(path)) throw new Error(`already exists: ${path}`);
    this.files.set(path, content);
  }

  async list(): Promise<ListedNote[]> {
    this.listCalls++;
    if (this.staleList) return this.staleList;
    return [...this.files]
      .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record<string, unknown> }))
      .filter((note) => 'voice-id' in note.frontmatter);
  }

  async process(path: string, fn: (content: string) => string): Promise<boolean> {
    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<TranscribeResult>);

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<TranscribeResult> {
    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:

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> = {}): 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:

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<string | null> {
  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
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 <noreply@anthropic.com>"

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<void>; dispose(): void } и приватный update(path, expect, change): Promise<VoiceRecord | null>, load(): Promise<Located[]> — используются в Task 9
  • Step 1: Написать падающие тесты

tests/queue.test.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<typeof setup>;

function rec(id: string, over: Partial<VoiceRecord> = {}): 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:

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<void> {
    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<void> {
    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<void> {
    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<VoiceRecord | null> {
    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<Located[]> {
    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
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 <noreply@anthropic.com>"

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<boolean> — true, если запись была в failed/retry и перезапущена
    • QueueRunner.claimAll(): Promise<number> — число переназначенных записей
  • Step 1: Написать падающие тесты

tests/queue-commands.test.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<typeof setup>;

function rec(id: string, over: Partial<VoiceRecord> = {}): 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 на:

import { claimable, due, isStaleSent, staleOwn, type Located } from './schedule';

Добавить в класс QueueRunner после dispose():

  // «Повторить распознавание»: failed/retry → saved, счётчик с нуля, запись
  // переходит текущему устройству (иначе чужую запись здесь не отправить).
  async retry(path: string): Promise<boolean> {
    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<number> {
    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
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 <noreply@anthropic.com>"

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-кейс = <name>.note.md + <name>.txt + <name>.{m4a|webm|ogg}».

  • Step 1: Написать падающий тест

tests/golden.test.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<string, string> = { 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' возвращает ровно один):

{
  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
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 <noreply@anthropic.com>"

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