feat(secretary): устойчивость к обрывам — источник=транскрипт, склейка продолжений, догон сессий

Секретарь перестал терять промпт владельца при обрыве (сбой API / ручной стоп /
жёсткий крах). Источник правды — транскрипт на диске: сырьё (Слой 1) пересобирается
из всего транскрипта на каждом завершении, а не дописывается по последнему обмену.

- classifyEntry/assembleExchanges: распознавание машинных меток (isApiErrorMessage,
  [Request interrupted by user] обе формы, isCompactSummary, isMeta) — метка не считается
  настоящим промптом; промпт после обрыва помечается продолжением (cont=1), хвост — tail=1.
- realBoundariesFromRaw: продолжение не открывает новый спан (одна работа не дробится).
- честные пометки спана: «(связь прерывалась — продолжено)» / «(прервана, не завершена)».
- stop-хук: пересборка сырья из транскрипта + догон недоразобранного хвоста прошлых
  (умерших) сессий дела при «включи секретаря <дело>» (_sessions.json, secretary-sessions).
- parseLastExchange → тонкая обёртка над assembleExchanges (без дубля логики).

Свод секретаря зелёный: 172 теста / 12 файлов.
Спека: docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md
План: docs/superpowers/plans/2026-06-23-secretary-interruption-resilience.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-24 04:19:16 +03:00
parent f1a2134d03
commit 90f1360065
13 changed files with 1686 additions and 82 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
# Финальная спека: секретарь — устойчивость к обрывам (источник = транскрипт)
> **Финал брейншторма** поверх хендофф-направления `2026-06-23-secretary-interruption-resilience-design.md`.
> Все открытые вопросы §6 хендоффа закрыты с владельцем (см. §A). Все машинные метки проверены Grep'ом
> по реальным транскриптам — строки указаны. Дисциплина дальше: `writing-plans` → TDD.
> Режим: штатный (стен нет; пол + проверка-перед-пушем). Ветка `main`, удалёнка `gitea`. Тетради дел НЕ коммитить.
---
## A. Закрытые решения владельца (источник правды по спорным точкам)
| Вопрос (§6 хендоффа) | Решение владельца |
|---|---|
| 6.1 источник правды | **Транскрипт — главный.** На каждом завершении ответа сырьё (Слой 1) пересобирается ИЗ ВСЕГО транскрипта, а не дописывается по последнему обмену. |
| 6.2 продолжение/отмена/уточнение | **Один склеенный кусок + честная пометка.** Модель смысл обрыва НЕ решает — всё механически, по меткам. |
| 6.3 крах между сессиями | **Догон при «включи секретаря `<дело>`».** Не на каждом старте; запуск — действием владельца, привязка по имени дела. |
| 6.4 связь со сделанным | Нарезка по спанам, `distillSpan`, `realBoundariesFromRaw` (meta=1) **остаются**; над ними встаёт транскрипт-сборщик. |
| 6.5 перестройка сырья | **Да** — сырьё всегда полно перестраивается из транскрипта, прерванные ходы не теряются. |
| 6.6 объём меток | Список ниже (§C), **проверен на реальных транскриптах**. |
| Правило после РУЧНОГО стопа | **Считать продолжением (склеивать).** Следующая реплика после любого обрыва (сбой API ИЛИ ручной стоп) по умолчанию = продолжение прежней работы. |
| §8 связанные дела | **Вне этой задачи** (контекст другой сессии). Записаны в §H, не реализуются. |
---
## B. Проблема и корень (доказано)
Секретарь захватывает работу на **Stop** (конец ответа) через `parseLastExchange`
([secretary-transcript.mjs:40](../../../tools/secretary-transcript.mjs)). При обрыве Stop хрупок:
- **Корень потери промпта.** Ручной стоп лежит в транскрипте как сообщение с ролью `user` и текстовым блоком:
```json
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]}}
```
(`6cead3ab` строки 334, 468, 1465). Текущий `isRealUserPrompt` ([secretary-transcript.mjs:18](../../../tools/secretary-transcript.mjs#L18))
принимает **любой** user-текст за настоящий промпт → метка-обрыв (и «продолжи» после сбоя) крадёт границу
настоящей просьбы владельца. Одна работа дробится на «я: продолжи».
- **Единственное, что переживает все обрывы, — файл транскрипта на диске** (`~/.claude/projects/<proj>/<session>.jsonl`),
Claude Code пишет его по ходу. Значит источник правды — транскрипт, а не Stop/сырьё.
---
## C. Машинные метки (проверено Grep'ом — НЕ по памяти)
Сборщик обязан распознавать и НЕ принимать за настоящий промпт владельца:
| Метка | Где на записи | Смысл | Доказательство (файл:строка) |
|---|---|---|---|
| `entry.isMeta === true` | верхний уровень записи | служебный ход (гейт/навык/контекст) | `ff1d4618` — 5 шт.; уже используется (`userIsMeta`) |
| `entry.isApiErrorMessage === true` | запись с `role:"assistant"`, текст `"API Error: …"` | сбой API, ответ лопнул | `ff1d4618:534` (без статуса), `ff1d4618:577` (`apiErrorStatus:529`) |
| текст user-блока `[Request interrupted by user]` **или** `[Request interrupted by user for tool use]` | `role:"user"`, `content[].type:"text"` | ручной стоп (ДВЕ формы!) | `6cead3ab:334`; `240e3972:530` и `42e79641:225` (форма «for tool use») |
| `entry.isCompactSummary === true` (+ `isVisibleInTranscriptOnly:true`) | служебная запись | выжимка при сжатии контекста | `69992620:4929`, `aca79163` |
**Важно:** `apiErrorStatus` присутствует НЕ всегда (на `ff1d4618:534` его нет) → опираться на `isApiErrorMessage`,
статус — только доп-инфо. Детект ручного стопа матчить по **префиксу** `[Request interrupted by user`
(чтобы покрыть обе формы и будущие вариации хвоста).
---
## D. Решение
### D1. Источник = транскрипт; сырьё пересобирается целиком
На каждом завершении ответа (Stop) секретарь:
1. читает **весь** транскрипт сессии (через `node:fs` в хуке — Read-инструмент закрыт, fs работает);
2. собирает из него последовательность **обменов** (ход = настоящий промпт владельца → ответ → действия),
распознавая метки §C: служебные/метки-обрывы/выжимки **не** считаются настоящим промптом;
3. перезаписывает сырьё (`docs/secretary/raw/<session>.log`) этой пересборкой (PII вырезается перед записью,
как сейчас — `sanitize`).
Сырьё всегда полное → ни один ход (включая прерванный) не пропадает. Нумерация ходов стабильна
(транскрипт append-only). Нарезка по спанам (`computeSpans`/`realBoundariesFromRaw`) и разбор (`distillSpan`)
работают поверх честного сырья **без изменения логики**.
### D2. Сегментация обменов и метки-обрывы
При сборке обменов:
- **Границей нового обмена** служит только настоящий промпт владельца (не meta, не метка-обрыв, не выжимка).
- Запись `isApiErrorMessage` (assistant) и `[Request interrupted by user…]` (user) — **не границы**; они
поглощаются текущим обменом как факт обрыва (помечают, что работа прерывалась).
- `isMeta` и `isCompactSummary` — пропускаются как служебные (не границы, не работа владельца).
### D3. Правило продолжения (структурно, БЕЗ слов)
**Настоящий промпт владельца, идущий сразу после метки-обрыва** (сбой API ИЛИ ручной стоп), **без
завершённого ответа между ними**, — это **продолжение**: он НЕ открывает новый спан, его работа склеивается
со спаном предыдущей настоящей просьбы. Распознаётся по позиции относительно метки, **не по тексту реплики**
(словарь «продолжалок» отвергнут владельцем).
- Реализация: ход-продолжение метится в сырье отдельным ярлычком (по образцу `meta=1`, напр. `cont=1`),
`realBoundariesFromRaw` исключает его из границ — как и meta. Спан настоящей просьбы поглощает ходы-продолжения.
- В протоколе спан получает честную пометку: **«(связь прерывалась — продолжено)»**.
- Хвост, прерванный и **не** продолженный (на момент краха), помечается **«(прервана, не завершена)»** —
обрывок не выдаётся за готовую работу.
### D4. Догон после жёсткого краха (между сессиями)
Жёсткий крах (свет/закрыл VS Code) машинной метки не оставляет; старая сессия умирает, новая получает другой
транскрипт. Догон — при **«включи секретаря `<дело>`»**:
1. секретарь по имени дела находит **прошлую сессию(и)** этого дела (указатель сессий хранится в папке дела —
см. D5);
2. находит её транскрипт `dirname(currentTranscriptPath)/<prevSession>.jsonl`;
3. пересобирает её сырьё из транскрипта (теперь уже с хвостом — D1) и доразбирает недоразобранные спаны
(по курсору) в тетрадь дела через тот же `distillSpan`.
Граница честности (§2.5 хендоффа): хвост появляется не мгновенно, а этим догоном на следующем включении дела.
### D5. Привязка сессии к делу
Чтобы догон знал, чей транскрипт перечитывать, в **папке дела** ведётся указатель сессий
(напр. `docs/secretary/<work>/_sessions.json`: список `{session, lastSpanCursor}`), обновляемый stop-хуком
на каждом ходе, пока дело активно. На re-активации этот указатель → список прошлых сессий для догона.
Сам прогон догона (вызовы модели) делает **stop-хук** на первом Stop после активации (prompt-хук лишь ставит
пометку «догнать сессии X»), чтобы вся LLM-машинерия осталась в одном месте.
---
## E. Контракт (что должно стать правдой)
- `parseLastExchange`/новый ассемблер распознаёт `isApiErrorMessage`, `isCompactSummary` и текст
`[Request interrupted by user…]`; НЕ берёт их за настоящий промпт; находит настоящие промпты как границы обменов.
- Сырьё пересобирается из всего транскрипта → прерванные ходы не теряются; настоящий промпт владельца сохранён
(он финализируется в момент отправки, до ответа).
- Ход-продолжение (после метки-обрыва) не создаёт новый спан; склеивается с предыдущей настоящей просьбой;
спан помечен «(связь прерывалась — продолжено)».
- Незавершённый прерванный хвост помечен «(прервана, не завершена)».
- Догон при re-активации дела дописывает недоразобранный хвост прошлой (умершей) сессии.
- Модель не решает смысл обрыва (детерминированно по меткам).
---
## F. Карта файлов (что трогать)
| Файл | Правка |
|---|---|
| `tools/secretary-transcript.mjs` | новый/расширенный ассемблер ВСЕГО транскрипта по обменам; классификация user-записи (настоящий промпт / meta / метка-обрыв / выжимка); флаг продолжения по позиции относительно метки. |
| `tools/secretary-stop-hook.mjs` | «пересобрать сырьё из всего транскрипта» вместо «дописать последний обмен»; обновлять указатель сессий дела (D5); выполнять догон (D4) при пометке от prompt-хука. |
| `tools/secretary-layer1.mjs` | `buildRawRecord` — ярлычок `cont=1` для хода-продолжения; `realBoundariesFromRaw` — исключать `cont=1` из границ (как `meta=1`). |
| `tools/secretary-prompt-hook.mjs` | на «включи секретаря `<дело>`» — поставить пометку «догнать прошлые сессии дела» по указателю (D5). |
| `tools/secretary-span.mjs` | без изменений логики; кормится честным сырьём. Рендер пометок «прерывалось/продолжено» и «не завершена». |
| `tools/secretary-distill.mjs` | без изменений логики (общий разбор спана). |
| `tools/secretary-protocol.mjs` (рендер) | показ честных пометок в спане. |
| `tools/secretary-*.test.mjs` | TDD-фикстуры на каждый вид обрыва (см. §G). |
---
## G. Тесты (TDD — фикстуры под каждый вид обрыва)
1. **Сбой API + продолжение:** транскрипт с `isApiErrorMessage` посередине работы и настоящим промптом-продолжением
после → один спан под настоящей первой просьбой, пометка «продолжено»; «продолжи»/реплика-продолжение НЕ
стала отдельным спаном.
2. **Ручной стоп (обе формы) + продолжение:** `[Request interrupted by user]` и `…for tool use` → то же склеивание.
3. **Прерван и не продолжен:** обрыв в конце транскрипта без продолжения → пометка «(прервана, не завершена)».
4. **Выжимка контекста:** `isCompactSummary` в транскрипте не становится ни промптом, ни работой.
5. **Служебный ход:** `isMeta` (регресс — не сломать существующее) не граница.
6. **Догон между сессиями:** сырьё прошлой сессии перестроено из её транскрипта; недоразобранный хвост дописан
в тетрадь дела на re-активации.
7. **Полнота:** пересборка сырья из транскрипта не теряет ни один ход (сверка число обменов ↔ настоящие промпты).
---
## H. Приёмка
- **Полный свод секретаря зелёный** (11 тест-файлов, команда из хендоффа §1, плюс новые тесты §G):
```
npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-span.test.mjs tools/secretary-distill.test.mjs
```
- **ЖИВОЙ прогон** с реальным `SECRETARY_LLM_KEY` (aitunnel, `SECRETARY_LLM_MODEL`), где есть обрыв:
(а) сбой API + «продолжи», (б) ручной стоп. Убедиться: настоящий промпт владельца **в тетради** (а не «продолжи»);
прерванная работа помечена честно; одна работа не раздроблена.
- Сверка: ничего из работы/диалога не потеряно (транскрипт ↔ тетрадь).
---
## I. Вне области (контекст §8 хендоффа — НЕ реализуется здесь)
Поднято в другой сессии, отдельные дела со своим брейнштормом/спекой — **не трогаем**:
- (A) дословное логирование промптов/ответов наставника/судьи/роутера (подсистема стены, не секретарь);
- (Б) качество модели секретаря (deepseek-v4-flash слабо поднимает «Решения/волю» — вопрос смены `SECRETARY_LLM_MODEL`).