Andrey Epifancev cbd76d5802 docs(openspec): SDD pre-flight rulings — implementer models, test helper duplication, decision logging
Refs #1

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

28 KiB
Raw Blame History

Design: core-recording-lifecycle

Context

Репозиторий пуст (каркас OpenSpec, одна фикстура test-01). Ограничения из CLAUDE.md: mobile-first (iPhone), isDesktopOnly: false, никакого Node API в рантайме, src/core/ без импортов obsidian, core покрыт vitest полностью, src/obsidian/ — тонкая обёртка без юнит-тестов. Vault синхронизируется через iCloud между маком и iPhone, плагин стоит на обоих.

Goals / Non-Goals

Goals:

  • Запись не теряется: аудио и заметка сохраняются до любой сети.
  • Вся логика состояний, повторов, устройств и формата заметки — в core и под тестами.
  • Обёртка сводится к реализации четырёх портов и вызову tick() по событиям.

Non-Goals:

  • Реальный провайдер, HTTP, коды ответа, переключение провайдеров (issue провайдера).
  • Команды, настройки, UI, MediaRecorder, реализация портов на app.vault (issue обёртки).
  • Периодический сброс записи на диск во время записи (отдельный spike на реальном iPhone: неизвестно, читается ли частичный mp4 из Safari MediaRecorder).
  • Конвертация форматов, удаление аудио, daily notes.

Decisions

Подход: чистое ядро + оркестратор на портах

Машина состояний, планировщик и формат заметки — чистые функции. Поверх них асинхронный QueueRunner в core работает через порты. Отвергнуто: (A) полностью чистый редьюсер с эффектами — оркестрация уехала бы в нетестируемую обёртку; (C) XState — лишняя зависимость и вес бандла для ~8 состояний.

Модули src/core/

Модуль Ответственность
record.ts тип VoiceRecord, Status, transition(record, event, now), MAX_ATTEMPTS = 5, BACKOFF_MS = [30s, 2m, 10m, 30m], SENT_STALE_MS = 10m, NO_SPEECH = 'no-speech'
schedule.ts due(records, now, deviceId), isStaleSent(record, now), claimable(records, now, deviceId)
note.ts parseRecord(fields), readVoiceFields(content), renderNote(content, record, text?), построчный патч ключей voice-* во frontmatter, initialNote(record)
naming.ts makeId(date, random), extFromMime, audioPath, notePath(date, n)
ports.ts интерфейсы портов (ниже)
capture.ts saveCapture(ports, capture)
queue.ts QueueRunner с tick(), retry(notePath), claimAll()

Порты

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>;
  // Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
  // КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
  // содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick,
  // в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет
  // состояние внутри process().
  list(): Promise<{ path: string; frontmatter: Record<string, unknown> }[]>;
  // атомарное чтение-изменение-запись; false, если заметки нет
  process(path: string, fn: (content: string) => string): Promise<boolean>;
}
interface Transcriber {
  transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult>;
}
type TranscribeResult =
  | { ok: true; text: string }
  | { ok: false; kind: 'temporary' | 'permanent'; message: string };
interface Env { now(): Date; random(): number; deviceId: string; }

Env объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. NoteStore.process в обёртке реализуется через vault.process. list() — только через metadataCache.getFileCache(file)?.frontmatter по наличию voice-id; поэтому порт отдаёт frontmatter-объект, а не текст заметки.

Frontmatter правит core, одной записью

Статус и тело должны меняться атомарно, иначе обрыв между processFrontMatter и process оставит inserted без текста или текст без статуса. Поэтому core сам построчно патчит только строки voice-*: … в блоке ---, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с :/кавычками экранируются в двойных кавычках.

Выполнение отправки

tick(): защита от повторного входа (флаг running + флаг rerun) → list() → parseRecord(frontmatter) → просроченные sent своего устройства превращаются во временную ошибку → due() → по одной: process (внутри функция заново читает voice-* из актуального content через readVoiceFields; если запись уже не в ожидаемом состоянии или не своего устройства — возвращает content без изменений и запись пропускается; иначе статус sent, voice-sent-at) → read аудио → transcribe → process (inserted + текст или retry/failed + область). Если process вернул false — запись отменена, идём дальше. Исключение из transcribe или read трактуется как временная ошибка с его сообщением.

Ручные команды

claimAll(): для каждой записи из claimable() одной записью voice-device := текущее, voice-status := saved, удалить voice-next-attempt и voice-sent-at; voice-attempts сохраняется. Затем tick(). retry(notePath): только из failed/retry → saved, voice-attempts := 0, удалить voice-next-attempt/voice-error; затем tick().

Захват

saveCapture({ chunks: ArrayBuffer[], mime, startedAt, endedAt, interrupted }): суммарно 0 байт → null; иначе склейка → write аудио → подбор свободного имени заметки через exists → create с initialNote → возвращает путь. MediaRecorder и детекция обрыва — в обёртке.

Тестовая инфраструктура (Task 1)

Шаблон obsidian-sample-plugin (esbuild, manifest.json с isDesktopOnly: false), vitest. Тест-страж сканирует src/core/**/*.ts и падает на импорт obsidian, fs, path, child_process, node:*. Тесты запускаются с фиксированным TZ=Europe/Moscow (задаётся в vitest.config.ts): имена заметок и строка статуса retry зависят от локального времени, а golden-сравнение побайтное. Фейки в tests/fakes.ts: in-memory RecordingStore/NoteStore, скриптуемый Transcriber, Env с управляемыми часами.

Аудиофикстуры *.m4a лежат в git (test-01.m4a ~85 КБ), так что npm test зелёный на чистом клоне. Если для эталона <name>.note.md нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча.

Risks / Trade-offs

  • Лаг iCloud. Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято.
  • Самописный патч frontmatter. Не покрывает многострочные YAML-значения в ключах voice-*; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами.
  • Потеря при жёстком убийстве процесса во время записи. Принято до spike периодического сброса.
  • list() по всему vault. Дёшево только при реализации через metadataCache (закреплено в контракте порта). Читаются лишь заметки, которые реально обрабатываются, через process.
  • Отставание metadataCache. После записи кэш обновляется асинхронно, следующий tick может увидеть старый статус. Защита — перепроверка состояния внутри process по актуальному содержимому.

Decisions made without discussion

Формат: дата — решение. Обоснование. Цена ошибки. Пометка «пересмотрено» — решение скорректировано человеком на ревью.

  1. 2026-09-27 — Пустой (после обрезки пробелов) транскрипт переводит запись в failed с причиной no-speech; в заметке > [!info] В записи не распознано речи, без слова «ошибка» и без призыва повторить. Аудио остаётся. Повтор тишины бесполезен; это свойство записи, а не сбой сервиса (формулировка пересмотрена на ревью). Если провайдер изредка возвращает пустоту на нормальной записи — пользователь увидит «нет речи» и должен сам запустить «Повторить распознавание»; потери данных нет.
  2. 2026-09-27 — Очередь ищет записи по voice-id во всём vault, а не только в voice/inbox/. Пользователь может перенести заметку до распознавания. Пересмотрено: NoteStore.list() обязан работать через metadataCache без чтения файлов, это записано в контракт порта. Если обёртка нарушит контракт — тормоза на телефоне на каждом tick; ловится ревью обёртки, в core не проверяемо.
  3. 2026-09-27 — Добавлены ключи voice-mime (исходный mime с параметрами) и voice-sent-at (ISO UTC). Провайдеру нужен точный mime, а не восстановленный по расширению; зависший sent нужно отличать от идущего. Лишние поля во frontmatter — косметика; без них — неверный mime у провайдера или вечный sent.
  4. 2026-09-27 — Порог зависшего sent — 10 минут (пересмотрено с 5). Автоматически касается только своих записей, чужие — только через claimAll. Двухминутная запись через агрегатор на плохом канале может идти дольше 5 минут. Слишком мал — дубль запроса (текст не портится: вставка идемпотентна); слишком велик — запись после убийства Obsidian ждёт повтора до 10 минут.
  5. 2026-09-27 — claimAll сохраняет voice-attempts, меняет только устройство, статус (в saved) и удаляет voice-next-attempt/voice-sent-at. «Повторить распознавание» работает для failed и retry и сбрасывает счётчик в 0. claimAll — про «где», а не «заново»; ручной повтор — осознанный перезапуск. Если claimAll должен был давать 5 свежих попыток — запись с телефона уйдёт в failed раньше; исправляется ручным повтором.
  6. 2026-09-27 — Неизвестный mime сохраняется с расширением .bin, запись не отвергается. Сохранить аудио важнее красивого расширения; принимать ли формат, решает провайдер. Файл .bin не проигрывается встроенным плеером Obsidian; аудио при этом не потеряно.
  7. 2026-09-27 — При переходе в inserted удаляются voice-next-attempt, voice-sent-at, voice-error; voice-attempts остаётся. Итоговая заметка чище; счётчик полезен как история. Косметика.
  8. 2026-09-27 — Frontmatter правит сам core построчно, только ключи voice-*, без YAML-библиотеки; статус и тело меняются одной операцией process. Два вызова (processFrontMatter + process) не атомарны: обрыв между ними даёт inserted без текста. Самописный патч может сломаться на экзотическом YAML в ключах voice-* — плагин такой не пишет; сохранность чужих ключей закрыта тестами.
  9. 2026-09-27 — Golden-сравнение побайтное при фиксированном Env (время, случайность, устройство). Нет аудиофикстуры для эталона — тест падает с понятным сообщением, а не пропускается. Пересмотрено на ревью: tests/fixtures/*.m4a убран из .gitignore, test-01.m4a закоммичен, чтобы на чистом клоне тесты были зелёными. Молчаливый skip дал бы ложно-зелёный прогон. Нулевая: фикстура в репозитории; падение возможно только если добавить эталон без аудио — это и должно быть ошибкой.
  10. 2026-09-27 — В .gitignore добавлен .claude/worktrees/. Хук guard-main запретил правку .git/info/exclude в main; вложенные worktree иначе видны как неотслеживаемые. Нулевая.
  11. 2026-09-27 — voice-id строится по UTC (пересмотрено на ревью, было локальное время); имя заметки в inbox — по локальному времени. Имя аудиофайла следует voice-id, т.е. тоже UTC. Порядок id не зависит от пояса устройства; имя заметки — для человека. Имена аудио и заметки одной записи расходятся на смещение пояса — можно спутать при ручном поиске; связь всё равно хранится ссылкой voice-audio.
  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. Слабая модель на механической задаче — лишний круг ревью, не дефект в ветке.
  27. 2026-09-27 (план, зафиксировано на ревью) — Зависший sent засчитывается как попытка (временная ошибка «отправка прервана»). Иначе запись, на которой Obsidian каждый раз выгружается, повторялась бы бесконечно. Пять выгрузок Obsidian подряд во время отправки дадут failed без единого ответа сервиса; спасает ручной повтор.
  28. 2026-09-27 (план, зафиксировано на ревью) — Результат запроса пишется по пути, полученному из list(). Если заметку переименовали или перенесли между первым process (sent) и вторым (результат), второй process не находит файл и результат отбрасывается. Порт NoteStore адресует заметки путём; отслеживание переименований — усложнение обёртки ради редкого случая. Запись остаётся в sent, через 10 минут уходит в повтор — один лишний запрос.
  29. 2026-09-27 (исполнение, контроллер) — Модели исполнителей пересмотрены по правилам Model Selection SDD: haiku — задачи 2, 3, 4, 5, 6, 7, 9 (в плане полный код и тесты, 1–2 файла); sonnet — задача 1 (установка зависимостей, сборка), задача 8 (самый большой асинхронный модуль, при падении тестов нужна отладка) и задача 10 (генерация эталона и разбор расхождений со спекой). Ревьюеры задач — sonnet, для задачи 8 — opus; финальное ревью — opus. Заменяет решение 26 и строки Model в плане. Перенос готового кода — дешёвый уровень; opus — для эскалации в цикле исправлений и финального ревью. Лишний круг исправлений, если слабая модель ошибётся при переносе; дефект в ветку не попадает — каждая задача проходит ревью.
  30. 2026-09-27 (исполнение, контроллер) — Почти одинаковые тестовые помощники (setup/rec/seed/fields) в tests/queue.test.ts и tests/queue-commands.test.ts реализуются как в плане, без выноса в tests/fakes.ts. Это небольшая тестовая обвязка, а не логика; помощники уже различаются (сигнатура seed, байты аудио); превентивная правка меняла бы дословный текст двух задач. ~20 строк дублированной настройки тестов, которые надо держать в согласии; вынос позже механический.
  31. 2026-09-27 (исполнение, контроллер) — Решения исполнителей задач дописываются ими в этот раздел в коммите задачи (продолжая нумерацию, пометка «исполнение»); рулинги контроллера — отдельными docs-коммитами с пометкой «исполнение, контроллер». Требование глобальных ограничений плана и CLAUDE.md; задачи идут строго последовательно, конфликтов нумерации нет. Шум в нумерации/формате раздела, правится руками.