diff --git a/docs/superpowers/specs/2026-06-25-secretary-worker-B-design.md b/docs/superpowers/specs/2026-06-25-secretary-worker-B-design.md new file mode 100644 index 0000000..9d2f9b6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-secretary-worker-B-design.md @@ -0,0 +1,196 @@ +# Спека: секретарь — фоновый воркер (подпроект B) + +**Дата:** 2026-06-25 · **Статус:** дизайн утверждён владельцем, готов к writing-plans +**Источник дизайна:** `docs/secretary/протокол-наставника/прогон/НАХОДКИ.md` (раздел «ДИЗАЙН-РЕШЕНИЯ +25.06») + брейншторм-сессия 25.06 (этот документ её фиксирует и **правит решение №1**). +Опирается на подпроекты A (`2026-06-25-secretary-pipeline-A-design.md`) и C +(`2026-06-25-secretary-render-C-design.md`) — оба уже влиты в main. + +## 0. Поправка к НАХОДКАМ (решение №1 переписано) + +НАХОДКИ, решение №1, формулировали **гибрид**: «ствол на критическом пути (синхронно), ветки в фон». +В брейншторме 25.06 владелец потребовал **15-минутные таймауты для ВСЕХ ролей** (не только брейншторма) +и **снятие всех лимитов ввода/вывода**. Синхронный ствол этого не выдержит: stop-хук имеет харнесс-стену +**900с (= 15 мин)** (`.claude/settings.json`), и тяжёлая модель на стволе упёрлась бы в неё ровно так, как +владелец уже трижды налетал. Поэтому решение №1 заменено на: + +> **Всё в фоне.** Ни одна роль не бежит под 900с-стеной хука. Stop-хук не зовёт LLM и не пишет протокол. +> Весь разбор (ствол + ветки + садовник) делает фон-воркер; он же — единственный писатель протокола. + +Остальные 7 решений НАХОДОК сохраняются. Это **упрощает** архитектуру: «почтовый ящик» (был нужен, чтобы +развести двух писателей в гибриде) **больше не нужен** — писатель один (воркер). + +## 1. Контекст и цель + +Подпроект A собрал боевой конвейер «пушистого дерева» (редактор → ствол → ветки 1.1∥1.2→1.3 → садовник → +применение), C — рендер. Оба за флагом `SECRETARY_FLUFFY` (выключен). Но конвейер тяжёлый: брейншторм на +флэше 67–97с, а владелец будет ставить сильные модели (5 per-role слотов) и кормить полный research из +Перплексити. Синхронно в stop-хуке это упирается в 900с-стену и теряет протокол. + +**Цель B:** вынести **весь** разбор в фоновый процесс с очередью-одним-работником, чтобы (а) ход владельца +возвращался мгновенно, (б) тяжёлые модели и полный вход работали без таймаут-стены, (в) параллельные темы +не мешали друг другу, (г) включить флаг и оживить конвейер A+C на боевом секретаре. + +## 2. Объём + +**Входит в B:** +- Очередь/замок над `docs/secretary/<тема>/_worker/` (очередь спанов, файл-замок с пульсом). +- Фон-процесс `secretary-worker.mjs` — единственный писатель протокола; разбирает спаны по очереди. +- «Выстрел» воркера из stop-хука (detached spawn, Windows-safe). +- Переразводка stop-хука: **никакого LLM в хуке**; только сырьё Слой 1 + постановка спанов в очередь + + выстрел воркера. Догон мёртвых сессий — тоже через очередь. +- Снятие лимитов ввода/вывода (обрезка 4000 знаков → ручка, дефолт «полный»; выход — max_tokens модели). +- Таймауты 900с/роль + повторы; мягкая деградация при превышении окна модели. +- Безопасная к параллели запись оглавления `содержание.md`. +- Включение флага `SECRETARY_FLUFFY=1` (финальный шаг, с согласия владельца). +- Тесты на всё перечисленное (TDD-гейт), включая ранее непроверенную механику (решение №8). + +**НЕ входит (YAGNI):** +- «Режим разбора» — разговорный, через готовые функции A (`applyTend`/`applyResults`); нового кода нет. +- Демон/служба ОС. Воркер — короткоживущий процесс, запускается выстрелом из хука, умирает по опустошении + очереди. Никаких постоянно висящих процессов. +- Версии протокола. Один файл, один писатель (см. решение №2). + +## 3. Архитектура + +### 3.1 Поток данных + +**Stop-хук (синхронно, мгновенно, БЕЗ единого LLM-вызова):** +1. Пишет сырьё Слой 1 из всего транскрипта (как сегодня — переживает обрывы, PII вырезается). +2. Если тема включена/закрывается: вычисляет границы закрытых спанов (структурно из сырья, как сегодня), + ставит новые закрытые спаны (+ их `note` об обрыве) в очередь `_worker/queue.json`. +3. Если есть `catchUp` (хвосты мёртвых сессий) — ставит их спаны в ту же очередь. +4. «Выстреливает» воркера (`secretary-spawn.mjs`) и завершается. **Ход владельца вернулся за миллисекунды.** + +Хук **никогда не пишет** `protocol.json` и **не зовёт модель**. + +**Фон-воркер `secretary-worker.mjs <папка-темы>` (вне харнесс-таймаута):** +1. `acquireLock(workDir)` — замок темы занят живым воркером? → немедленный выход (тот добьёт очередь). +2. Цикл, пока очередь не пуста: + - `dequeue` — следующий спан по порядку; + - редактор: `reconcileTurn` (ствол) → merge → `preserveRegistry`; + - ветки: сбор 1.1∥1.2 → 1.3 брейншторм → садовник (последовательно, решение №3); + - `applyResults` → `collapseProtocol`; + - **записывает** `protocol.json` (атомарно) + `renderDoc` → `protocol.md` + обновляет `содержание.md`; + - обновляет курсор темы и пульс замка. +3. `releaseLock`, выход. + +Воркер = редактор = **единственный писатель** (решение №2 цело: писатель один, потому что воркер один). + +### 3.2 Ритм обновления + +На каждый спан — **одно обновление протокола**, где ствол и ветки этого спана приезжают вместе (воркер +пишет файл один раз в конце разбора спана). Протокол **догоняющий**: показывает разобранное на текущий +момент, последние спаны досчитываются в фоне. Лаг принят владельцем; протокол смотрится на разборе +(середина/конец диалога), не ежеходно. + +### 3.3 Очередь, замок, пульс (модуль `secretary-queue.mjs`) + +Над `docs/secretary/<тема>/_worker/`: +- `queue.json` — массив заданий `{ span:{start,end,index,note}, kind:'span'|'catchup', enqueuedAt }`. + `enqueue` дозаписывает; `dequeue` атомарно снимает первое (read-modify-write через `writeFileAtomic`). + Дедуп по `index` — повторная постановка того же спана не плодит дублей. +- `lock` — `{ pid, startedAt, beat }`. `acquireLock` создаёт, если файла нет ИЛИ пульс протух + (`now - beat > STALE_MS`, по умолчанию 30с). Живой замок (свежий пульс) → отказ. +- Пульс: воркер раз в `BEAT_MS` (~10с) обновляет `beat`. Так **медленная модель ≠ мёртвый воркер** — + долгая честная работа держит пульс, замок не перехватывают. `STALE_MS` заведомо > `BEAT_MS`×3. +- `releaseLock` — удаляет `lock`. + +`STALE_MS`/`BEAT_MS` — константы модуля, перебиваются env (для тестов и тюнинга). + +### 3.4 Выстрел воркера (модуль `secretary-spawn.mjs`) + +`spawnWorker(workDir, { spawnImpl = spawn } = {})` → +`spawnImpl(process.execPath, [workerPath, workDir], { detached: true, stdio: 'ignore' }).unref()`. +Detached + `unref` → процесс переживает выход хука, хук его не ждёт. `spawnImpl` инъектируется для теста. +Идемпотентность по факту: если воркер уже крутится, новый процесс упрётся в замок и выйдет (см. 3.3) — +лишний выстрел безвреден. + +### 3.5 Разрез distill (правка `secretary-distill.mjs`) + +`distillSpan` сегодня делает ствол **и** ветки в одной функции. Для B логика остаётся та же (воркер зовёт +её целиком — ему таймаут-стена не грозит), но выносится **детерминированная запись наружу**: воркер сам +пишет файл после `distillSpan`. Никакого деления на «trunk sync / branches async» (отменено вместе с +гибридом). `distillSpan` остаётся единой точкой разбора одного спана — общий код для воркера и (теоретич.) +пересборки. Меняется только то, **кто** и **где** её зовёт: раньше — хук синхронно, теперь — воркер в фоне. + +### 3.6 Лимиты и таймауты + +- **Вход без обрезки:** `renderExchangeText` (`secretary-harvest.mjs:92`) — `slice(0,4000)` заменить на + ручку `SECRETARY_INPUT_CAP` (по умолчанию `Infinity` = полный обмен, включая весь research). Текст + ассистента — тоже под ту же ручку (сейчас не клампится — формально цел, но ручка распространяется на всё). +- **Выход максимум:** `max_tokens` вызова = максимум модели; ручка `SECRETARY_OUTPUT_MAX` (env). +- **Таймаут вызова:** `perAttemptTimeoutMs` по умолчанию **900_000** (15 мин) всем ролям; `maxRetries` 2 + (ловит Windows-TLS-handshake). Ручки env: `SECRETARY_LLM_TIMEOUT_MS`, `SECRETARY_LLM_RETRIES`. +- **Жёсткий потолок только один — окно контекста модели.** Полный research его превысил → отказ API → + мягкая деградация: роль помечается «не досчитана: вход больше окна, кусок N» (в `diag` + видимо в + протоколе как пустая ветка с пометкой), спан закрывается, протокол цел, остальные роли отрабатывают. + +### 3.7 Параллельные темы и оглавление + +- Две темы — два независимых `_worker/`, два замка, два воркера → честная параллель, не пересекаются. +- `содержание.md` пишет воркер (раньше — хук). Общий файл → запись под `содержание.lock` (тот же + пульс-замок, отдельный файл рядом с оглавлением) вокруг read-modify-write `upsertIndexEntry`. Upsert + по ключу-теме идемпотентен → строчки тем не затирают друг друга. Модуль `secretary-index.mjs` получает + блокирующую обёртку записи (или это делает воркер через `secretary-queue`-замок — решить в плане). + +### 3.8 Включение флага + +`SECRETARY_FLUFFY=1` оживляет конвейер A+C. Финальный шаг B, **с явного согласия владельца**. Способ +(env-блок `.claude/settings.json` против рантайм-файла) — определить в плане; рекомендация: рантайм-файл +`~/.claude/runtime/secretary-fluffy` для тогглинга без правки enforcement-чувствительного settings.json +(тогда `fluffyPipelineOn` читает и env, и файл). До включения живой секретарь не тронут. + +## 4. Компоненты (карта файлов) + +**Создать:** +- `tools/secretary-queue.mjs` — `enqueue`/`dequeue`/`acquireLock`/`releaseLock`/`beat`/`isLocked`. +- `tools/secretary-worker.mjs` — тонкая CLI-обёртка над `runWorker(workDir, {callModel, now, spawnImpl})`. +- `tools/secretary-spawn.mjs` — `spawnWorker(workDir, {spawnImpl})`. +- Тест-файлы `*.test.mjs` к каждому. + +**Править:** +- `tools/secretary-stop-hook.mjs` — выкинуть синхронный разбор/запись протокола; оставить сырьё + + постановку спанов (вкл. catchUp) в очередь + выстрел воркера. Курсор темы двигает **воркер**, не хук + (или хук ставит, воркер подтверждает — решить в плане так, чтобы не было гонки курсора). +- `tools/secretary-harvest.mjs` — `renderExchangeText` cap → ручка `SECRETARY_INPUT_CAP` (дефолт полный). +- `tools/secretary-index.mjs` — запись оглавления под замок (безопасность параллельных тем). +- `tools/secretary-flag.mjs` — `fluffyPipelineOn` читает и рантайм-файл (для тогглинга), если выбран он. +- `.gitignore` — `docs/secretary/*/_worker/` (очередь/замок — рабочий мусор). + +## 5. Тестирование (TDD) + +- **Очередь:** enqueue→dequeue по порядку; дедуп по index; пустая очередь. +- **Замок:** второй `acquireLock` при живом пульсе — отказ; протухший пульс — перехват; `beat` держит + живого; `releaseLock` чистит. +- **Воркер (`runWorker` как чистая функция):** фейковый `callModel` + фейковые часы → разбирает спаны по + порядку, пишет протокол по одному обновлению на спан, держит пульс, по опустошении выходит и снимает + замок. Деградация: `callModel`, кидающий «context window» → роль помечена «не досчитана», протокол цел. +- **Выстрел:** фейковый `spawnImpl` → detached:true, unref вызван, аргументы `[worker, workDir]`. +- **Оглавление:** два «параллельных» upsert под замком не теряют строк. +- **Stop-хук:** на закрытом спане — ставит в очередь + стреляет (фейковый spawn), LLM не зовёт, протокол + не пишет; catchUp-спаны мёртвой сессии попадают в очередь. +- **Лимиты:** `SECRETARY_INPUT_CAP=Infinity` → полный обмен в промпте; конечная ручка → обрезка с пометкой. +- **Непроверенная механика (решение №8), каждое — кейс на воркере:** + - **Гравитация** — кандидат поднимается в ствол, когда владелец заговорил об идее (вход содержит её). + - **Reopen / обратный каскад** — смена несущего решения переоткрывает авто-закрытое. + - **Сжатие длинной истории** — на длинном протоколе старое сворачивается/сжимается без потери корня. + +## 6. Коэкзистенция и риски + +- Флаг ВЫКЛ (до §3.8) → секретарь работает по-старому (синхронный аудит в хуке), B-инфраструктура лежит + «тёмной». Включение — отдельный осознанный шаг. +- **Риск гонки курсора темы** между хуком (ставит спан) и воркером (разобрал спан) — развести явно в плане + (источник истины курсора — один; кандидат: курсор двигает только воркер после успешной записи, хук + ставит «сырые» границы в очередь). +- **Риск двух писателей** снят построением: один воркер на тему (замок), хук не пишет протокол. +- **Риск зависшего воркера** на 15-мин модели — снят пульсом (живой ≠ мёртвый). +- **Windows:** detached spawn + `unref`, файловые замки атомарны (`writeFileAtomic` + rename), пути с + кириллицей — как везде в проекте. + +## 7. Связь с подпроектами + +- A даёт конвейер (`distillSpan`, `applyResults`, harvest/gardener) — воркер его исполняет. +- C даёт `renderDoc` — воркер им рисует `protocol.md`. +- B — последний: оживляет флаг, выносит исполнение в фон, снимает таймаут-стену. После B эпик «пушистое + дерево» закрыт; песочница `.scratch/sec/*` прибирается (CLAUDE.md п.11).