From 944a6594ec49f3bde80f19fc980e4fc820d94ecb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 15:42:09 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20apply=20plan=20review=20=E2=80=94=20ret?= =?UTF-8?q?ry=20status=20wording,=20TZ=20note,=20stale-sent=20and=20rename?= =?UTF-8?q?=20decisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1 Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-27-core-recording-lifecycle.md | 8 ++++---- openspec/changes/core-recording-lifecycle/design.md | 4 +++- .../specs/recording-lifecycle/spec.md | 2 +- .../specs/voice-note-format/spec.md | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md index 688c538..3184b76 100644 --- a/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md +++ b/docs/superpowers/plans/2026-09-27-core-recording-lifecycle.md @@ -20,7 +20,7 @@ - `MAX_ATTEMPTS = 5`; паузы после 1–4 неудачи: 30 с, 2 мин, 10 мин, 30 мин; зависший `sent` — строго больше 10 минут. - Пустой транскрипт → `failed` с `voice-error: no-speech`, в заметке `> [!info] В записи не распознано речи`. - Маркеры области: `%%voice-note:%%` … `%%/voice-note%%`. -- Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Нет связи, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. +- Тексты статусов (дословно): `> [!info] Распознаётся…`; `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)`; `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`; `> [!note] Запись прервана, сохранено M:SS`. - Строковые значения `voice-*` во frontmatter — в двойных кавычках (JSON-экранирование), числа и `true` — без кавычек. - Тесты запускаются с `TZ=Europe/Moscow` (UTC+3, без летнего времени) — выставляется в `vitest.config.ts`. - `npm test` — весь набор, зелёным считается только весь набор. @@ -1176,7 +1176,7 @@ 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).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n'); expect(out).not.toContain('Распознаётся'); expect(readVoiceFields(out)['voice-status']).toBe('retry'); }); @@ -1267,7 +1267,7 @@ 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})`; + return `> [!info] Не удалось отправить, повтор в ${at} (попытка ${r.attempts}/${MAX_ATTEMPTS})`; } case 'failed': { if (r.error === NO_SPEECH) return '> [!info] В записи не распознано речи'; @@ -1851,7 +1851,7 @@ describe('QueueRunner.tick', () => { 'voice-next-attempt': '2026-09-27T11:15:33.000Z', 'voice-error': 'offline', }); - expect(ctx.notes.files.get(path)).toContain('Нет связи, повтор в 14:15 (попытка 1/5)'); + expect(ctx.notes.files.get(path)).toContain('Не удалось отправить, повтор в 14:15 (попытка 1/5)'); ctx.env.advance(17_000); // 11:15:20 — рано await ctx.runner.tick(); diff --git a/openspec/changes/core-recording-lifecycle/design.md b/openspec/changes/core-recording-lifecycle/design.md index 31206ba..34ae93e 100644 --- a/openspec/changes/core-recording-lifecycle/design.md +++ b/openspec/changes/core-recording-lifecycle/design.md @@ -83,7 +83,7 @@ interface Env { now(): Date; random(): number; deviceId: string; } ### Тестовая инфраструктура (Task 1) -Шаблон obsidian-sample-plugin (esbuild, `manifest.json` с `isDesktopOnly: false`), vitest. Тест-страж сканирует `src/core/**/*.ts` и падает на импорт `obsidian`, `fs`, `path`, `child_process`, `node:*`. Фейки в `tests/fakes/`: in-memory `RecordingStore`/`NoteStore`, скриптуемый `Transcriber`, `Env` с управляемыми часами. +Шаблон 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` зелёный на чистом клоне. Если для эталона `.note.md` нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча. @@ -125,3 +125,5 @@ interface Env { now(): Date; random(): number; deviceId: string; } 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 минут уходит в повтор — один лишний запрос.** diff --git a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md index 9f8f402..a966f91 100644 --- a/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md +++ b/openspec/changes/core-recording-lifecycle/specs/recording-lifecycle/spec.md @@ -39,7 +39,7 @@ Перед отправкой запись MUST перейти в `sent` с отметкой времени начала отправки, записанной в заметку. Очередь MUST обрабатывать записи строго по одной. Результат провайдера — успех с текстом, временная ошибка или постоянная ошибка. Успех с непустым текстом MUST переводить запись в `inserted` с текстом в заметке. Успех с пустым (после обрезки пробелов) текстом MUST переводить запись в `failed` с причиной «нет речи»: это свойство записи, а не ошибка сервиса, повтор бесполезен. #### Scenario: Успешное распознавание -- **WHEN** провайдер вернул текст для записи в `saved` +- **WHEN** провайдер вернул текст, а запись в момент ответа в `sent` - **THEN** заметка содержит текст и статус `inserted` #### Scenario: В записи нет речи diff --git a/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md index 349fb64..fe0ef17 100644 --- a/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md +++ b/openspec/changes/core-recording-lifecycle/specs/voice-note-format/spec.md @@ -32,11 +32,11 @@ ### Requirement: Тело заметки до распознавания -Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Нет связи, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. +Новая заметка MUST содержать вложение аудио `![[<путь к аудио>]]`, затем управляемую область, ограниченную маркерами-комментариями `%%voice-note:%%` и `%%/voice-note%%`. Внутри области MUST отображаться состояние: для `saved`/`sent` — `> [!info] Распознаётся…`; для `retry` — `> [!info] Не удалось отправить, повтор в HH:mm (попытка N/5)` по локальному времени; для `failed` — `> [!warning] Не распознано: <причина>. Команда «Повторить распознавание»`, кроме причины «нет речи», для которой — `> [!info] В записи не распознано речи` (без слова «ошибка» и без призыва повторить). Для прерванной записи над областью MUST стоять строка `> [!note] Запись прервана, сохранено M:SS`, которая остаётся и после вставки текста. #### Scenario: Заметка в ожидании повтора - **WHEN** запись перешла в `retry` после второй неудачи со следующей попыткой в 14:17 по местному времени -- **THEN** область содержит `> [!info] Нет связи, повтор в 14:17 (попытка 2/5)` +- **THEN** область содержит `> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)` ### Requirement: Вставка транскрипта