From 040b06f21b2ee1ddac94c2ecc6c77a967a74ad87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 25 Jun 2026 13:34:57 +0300 Subject: [PATCH] =?UTF-8?q?docs(secretary):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?B=20=E2=80=94=20=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=80=D0=BA=D0=B5=D1=80=20(11=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-25-secretary-worker-B.md | 939 ++++++++++++++++++ 1 file changed, 939 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-secretary-worker-B.md diff --git a/docs/superpowers/plans/2026-06-25-secretary-worker-B.md b/docs/superpowers/plans/2026-06-25-secretary-worker-B.md new file mode 100644 index 0000000..43c9e17 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-secretary-worker-B.md @@ -0,0 +1,939 @@ +# Secretary Background Worker (B) 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:** Move the entire secretary span-distillation off the synchronous Stop-hook into a single-writer background worker, so heavy models and full input run without the 900 s hook wall, and turn the `SECRETARY_FLUFFY` pipeline on for real. + +**Architecture:** The Stop-hook becomes LLM-free: it writes raw (Layer 1), enqueues closed spans into a per-theme on-disk queue, and fires a detached worker. The worker (`secretary-worker.mjs`) is the sole writer of `protocol.json`: it drains the queue span-by-span through the existing `distillSpan` (A pipeline) + `renderDoc` (C), holding a `proper-lockfile` lock with heartbeat so exactly one worker runs per theme. Cursor of "processed spans" lives in `_worker/cursor.json` (worker-owned); the hook only reads it to decide what to enqueue. + +**Tech Stack:** Node ≥20 ESM (`.mjs`), vitest 4, `proper-lockfile` (already a dependency), existing `secretary-*` modules. + +**Test runner:** `npx vitest run --config vitest.config.tools.mjs ` for one file; `npm run test:tools` for the full tools suite. **Run vitest via the PowerShell tool, NOT Git Bash** (Git Bash collapses the vitest harness in this repo — see memory `feedback_vitest_harness_collapse_vs_terminal`). + +**Commit mechanics:** The floor blocks agent `git commit`. After staging (`git add` works), hand the owner a single-line `git commit … -m "…" -- ` (`-m` BEFORE `--`) to paste in their terminal; answer `n` to any "Unlink of pack" prompt. Work on a branch `feat/secretary-worker-b`, not main. + +**Branch setup (do once before Task 1):** `git checkout -b feat/secretary-worker-b` (owner terminal if floor blocks; otherwise the worker-agent may create it). + +--- + +### Task 1: Ignore the worker scratch dir + +**Files:** +- Modify: `.gitignore` + +- [ ] **Step 1: Append ignore rule** + +Add to the end of `.gitignore`: + +``` +# Secretary background worker scratch (queue / lock / cursor) — per theme, never committed +docs/secretary/*/_worker/ +``` + +- [ ] **Step 2: Verify it is ignored** + +Run (PowerShell tool): `git check-ignore docs/secretary/x/_worker/queue.json` +Expected: prints the path (it is ignored). If nothing prints, the rule is wrong. + +- [ ] **Step 3: Commit** (owner terminal) + +``` +git add .gitignore +git commit -m "chore(secretary): ignore _worker scratch dirs" -- .gitignore +``` + +--- + +### Task 2: Theme lock wrapper over proper-lockfile + +A thin, testable wrapper. `proper-lockfile` auto-refreshes the lock mtime every `update` ms (the heartbeat) and treats a lock older than `stale` ms as reclaimable — so a slow 15-min model keeps its lock alive while a truly dead worker's lock is stolen. + +**Files:** +- Create: `tools/secretary-lock.mjs` +- Test: `tools/secretary-lock.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { acquireThemeLock, isThemeLocked } from './secretary-lock.mjs'; + +let dir; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'seclock-')); mkdirSync(join(dir, '_worker'), { recursive: true }); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +describe('secretary-lock', () => { + it('first acquire succeeds and returns a release fn; second acquire fails while held', async () => { + const release = await acquireThemeLock(dir); + expect(typeof release).toBe('function'); + expect(await isThemeLocked(dir)).toBe(true); + await expect(acquireThemeLock(dir)).rejects.toBeTruthy(); + await release(); + expect(await isThemeLocked(dir)).toBe(false); + }); + it('acquire returns null instead of throwing when graceful:true and already held', async () => { + const release = await acquireThemeLock(dir); + const second = await acquireThemeLock(dir, { graceful: true }); + expect(second).toBe(null); + await release(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-lock.test.mjs` +Expected: FAIL — `acquireThemeLock` is not exported / not a function. + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-lock.mjs +// Замок темы поверх proper-lockfile: один воркер на тему. Авто-обновление mtime (update) = пульс, +// устаревание (stale) = перехват мёртвого. Цель замка — папка _worker внутри темы. +import lockfile from 'proper-lockfile'; +import { join } from 'node:path'; + +const STALE_MS = Number(process.env.SECRETARY_LOCK_STALE_MS) || 30_000; +const UPDATE_MS = Number(process.env.SECRETARY_LOCK_UPDATE_MS) || 10_000; + +function target(workDir) { return join(workDir, '_worker'); } + +/** Взять замок темы. По умолчанию кидает при занятом (ELOCKED). graceful:true → вернуть null вместо броска. */ +export async function acquireThemeLock(workDir, { graceful = false } = {}) { + try { + return await lockfile.lock(target(workDir), { stale: STALE_MS, update: UPDATE_MS, realpath: false }); + } catch (e) { + if (graceful) return null; + throw e; + } +} + +/** Занят ли замок темы живым держателем. */ +export async function isThemeLocked(workDir) { + try { return await lockfile.check(target(workDir), { stale: STALE_MS, realpath: false }); } + catch { return false; } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-lock.test.mjs` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-lock.mjs tools/secretary-lock.test.mjs +git commit -m "feat(secretary): theme lock over proper-lockfile (heartbeat + stale steal)" -- tools/secretary-lock.mjs tools/secretary-lock.test.mjs +``` + +--- + +### Task 3: On-disk span queue + processed-cursor + +Queue and cursor live under `/_worker/`. Queue dedups by span `index`. Cursor is the highest fully-processed span index (worker-owned). All writes atomic via `writeFileAtomic` (already in `secretary-layer1.mjs`). + +**Files:** +- Create: `tools/secretary-queue.mjs` +- Test: `tools/secretary-queue.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { enqueueSpan, dequeueSpan, readCursor, writeCursor } from './secretary-queue.mjs'; + +let dir; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'secq-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +const job = (index) => ({ session: 's', span: { start: index * 10, end: index * 10 + 5, index, note: '' }, kind: 'span' }); + +describe('secretary-queue', () => { + it('enqueue then dequeue returns jobs in FIFO order', () => { + enqueueSpan(dir, job(0)); enqueueSpan(dir, job(1)); + expect(dequeueSpan(dir).span.index).toBe(0); + expect(dequeueSpan(dir).span.index).toBe(1); + expect(dequeueSpan(dir)).toBe(null); + }); + it('enqueue dedups by span index (no duplicate jobs)', () => { + enqueueSpan(dir, job(2)); enqueueSpan(dir, job(2)); + expect(dequeueSpan(dir).span.index).toBe(2); + expect(dequeueSpan(dir)).toBe(null); + }); + it('cursor defaults to -1 and round-trips', () => { + expect(readCursor(dir)).toBe(-1); + writeCursor(dir, 4); + expect(readCursor(dir)).toBe(4); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-queue.test.mjs` +Expected: FAIL — exports missing. + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-queue.mjs +// Очередь спанов и курсор обработки темы на диске (/_worker/). Дедуп по span.index. +// Курсор — наибольший ПОЛНОСТЬЮ обработанный индекс; пишет только воркер. Все записи атомарны. +import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeFileAtomic } from './secretary-layer1.mjs'; + +function wdir(workDir) { const d = join(workDir, '_worker'); mkdirSync(d, { recursive: true }); return d; } +function qpath(workDir) { return join(wdir(workDir), 'queue.json'); } +function cpath(workDir) { return join(wdir(workDir), 'cursor.json'); } + +function readQueue(workDir) { + try { const a = JSON.parse(readFileSync(qpath(workDir), 'utf-8')); return Array.isArray(a) ? a : []; } + catch { return []; } +} +function writeQueue(workDir, arr) { writeFileAtomic(qpath(workDir), JSON.stringify(arr)); } + +/** Поставить спан в очередь. Дедуп по span.index — повторная постановка того же спана игнорируется. */ +export function enqueueSpan(workDir, job) { + const arr = readQueue(workDir); + const idx = job && job.span ? job.span.index : undefined; + if (arr.some((j) => j.span && j.span.index === idx)) return; + arr.push(job); + writeQueue(workDir, arr); +} + +/** Снять первый спан (FIFO). null если очередь пуста. */ +export function dequeueSpan(workDir) { + const arr = readQueue(workDir); + if (!arr.length) return null; + const first = arr.shift(); + writeQueue(workDir, arr); + return first; +} + +/** Курсор обработки темы (наибольший обработанный индекс). -1 = ничего не обработано. */ +export function readCursor(workDir) { + try { const v = JSON.parse(readFileSync(cpath(workDir), 'utf-8')); return Number.isFinite(v.index) ? v.index : -1; } + catch { return -1; } +} +export function writeCursor(workDir, index) { writeFileAtomic(cpath(workDir), JSON.stringify({ index })); } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-queue.test.mjs` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-queue.mjs tools/secretary-queue.test.mjs +git commit -m "feat(secretary): on-disk span queue + processed cursor (dedup by index)" -- tools/secretary-queue.mjs tools/secretary-queue.test.mjs +``` + +--- + +### Task 4: Detached worker spawn + +**Files:** +- Create: `tools/secretary-spawn.mjs` +- Test: `tools/secretary-spawn.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { spawnWorker } from './secretary-spawn.mjs'; + +describe('secretary-spawn', () => { + it('spawns node with [worker, workDir], detached, stdio ignore, and unrefs', () => { + const calls = []; + let unrefed = false; + const fakeSpawn = (cmd, args, opts) => { calls.push({ cmd, args, opts }); return { unref: () => { unrefed = true; } }; }; + spawnWorker('C:/themes/alpha', { spawnImpl: fakeSpawn }); + expect(calls).toHaveLength(1); + expect(calls[0].args[1]).toBe('C:/themes/alpha'); + expect(calls[0].args[0]).toMatch(/secretary-worker\.mjs$/); + expect(calls[0].opts.detached).toBe(true); + expect(calls[0].opts.stdio).toBe('ignore'); + expect(unrefed).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-spawn.test.mjs` +Expected: FAIL — export missing. + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-spawn.mjs +// «Выстрел» фон-воркера: detached + unref → процесс переживает выход хука, хук его не ждёт. +// Идемпотентность по факту: если воркер уже крутится, новый упрётся в замок темы и выйдет. +import { spawn as realSpawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const WORKER = join(here, 'secretary-worker.mjs'); + +export function spawnWorker(workDir, { spawnImpl = realSpawn } = {}) { + const child = spawnImpl(process.execPath, [WORKER, workDir], { detached: true, stdio: 'ignore' }); + if (child && typeof child.unref === 'function') child.unref(); + return child; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-spawn.test.mjs` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-spawn.mjs tools/secretary-spawn.test.mjs +git commit -m "feat(secretary): detached worker spawn (unref, fake-injectable)" -- tools/secretary-spawn.mjs tools/secretary-spawn.test.mjs +``` + +--- + +### Task 5: Lift the 4000-char input cap into a knob (full input by default) + +The owner wants branches/trunk to see the **full** exchange (incl. heavy Perplexity research). Replace the hardcoded `slice(0, 4000)` with `SECRETARY_INPUT_CAP` (default `Infinity` = no truncation). When a finite cap is set, truncate with a visible marker. + +**Files:** +- Modify: `tools/secretary-harvest.mjs:91-94` +- Test: `tools/secretary-harvest.test.mjs` (append cases) + +- [ ] **Step 1: Write the failing test** (append to existing `secretary-harvest.test.mjs`) + +```js +import { renderExchangeText } from './secretary-harvest.mjs'; + +describe('renderExchangeText input cap', () => { + const bigSpan = () => ({ user: 'u', assistant: 'a', actions: [{ tool: 'perplexity', input: 'q', result: 'X'.repeat(9000) }] }); + it('default: no truncation — full result passes through', () => { + delete process.env.SECRETARY_INPUT_CAP; + const t = renderExchangeText(bigSpan()); + expect(t).toContain('X'.repeat(9000)); + expect(t).not.toContain('вырезано'); + }); + it('finite cap truncates the result with a marker', () => { + process.env.SECRETARY_INPUT_CAP = '100'; + const t = renderExchangeText(bigSpan()); + expect(t).toContain('X'.repeat(100)); + expect(t).not.toContain('X'.repeat(101)); + expect(t).toMatch(/вырезано \d+ знаков/); + delete process.env.SECRETARY_INPUT_CAP; + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-harvest.test.mjs` +Expected: FAIL — first test fails (current code slices at 4000, so `X`×9000 absent). + +- [ ] **Step 3: Write minimal implementation** — replace lines 90-94 of `secretary-harvest.mjs`: + +```js +/** Лимит на размер выдачи действия в промпте. По умолчанию полный (Infinity) — владелец хочет видеть + * тяжёлый research целиком; фон-воркер без таймаут-стены это выдерживает. Env урезает при желании. */ +function inputCap() { const v = process.env.SECRETARY_INPUT_CAP; const n = Number(v); return v && Number.isFinite(n) ? n : Infinity; } +function clamp(s) { + const cap = inputCap(); + if (!Number.isFinite(cap) || s.length <= cap) return s; + return s.slice(0, cap) + `…[вырезано ${s.length - cap} знаков]`; +} + +/** Текст обмена из спана {user, assistant, actions} (заменяет exchange(span) песочницы). */ +export function renderExchangeText(spanEx) { + const acts = ((spanEx.actions || []).map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${clamp(String(a.result))}` : ''}`).join('\n')) || '—'; + return `[ЮЗЕР]: ${spanEx.user || ''}\n[АССИСТЕНТ]: ${spanEx.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-harvest.test.mjs` +Expected: PASS (existing harvest tests + 2 new). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-harvest.mjs tools/secretary-harvest.test.mjs +git commit -m "feat(secretary): input cap knob, full exchange by default (SECRETARY_INPUT_CAP)" -- tools/secretary-harvest.mjs tools/secretary-harvest.test.mjs +``` + +--- + +### Task 6: Parallel-safe index write + +Two themes' workers both append to `docs/secretary/содержание.md`. Wrap the read-modify-write in a `proper-lockfile` lock on the index file so the per-theme line upserts never clobber each other. `upsertIndexEntry` (pure) is unchanged; we add `writeIndexSafely`. + +**Files:** +- Modify: `tools/secretary-index.mjs` +- Test: `tools/secretary-index.test.mjs` (append cases) + +- [ ] **Step 1: Write the failing test** (append) + +```js +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeIndexSafely } from './secretary-index.mjs'; + +let dir, idx; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'secidx-')); idx = join(dir, 'содержание.md'); writeFileSync(idx, ''); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +describe('writeIndexSafely', () => { + it('two concurrent upserts of different themes both survive', async () => { + await Promise.all([ + writeIndexSafely(idx, { slug: 'alpha', title: 'alpha', goal: 'g', status: 'открыто', date: 'd' }), + writeIndexSafely(idx, { slug: 'beta', title: 'beta', goal: 'g', status: 'открыто', date: 'd' }), + ]); + const md = readFileSync(idx, 'utf-8'); + expect(md).toContain('(alpha/protocol.md)'); + expect(md).toContain('(beta/protocol.md)'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-index.test.mjs` +Expected: FAIL — `writeIndexSafely` missing. + +- [ ] **Step 3: Write minimal implementation** — append to `secretary-index.mjs`: + +```js +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import lockfile from 'proper-lockfile'; +import { writeFileAtomic } from './secretary-layer1.mjs'; + +/** Безопасная к параллели запись строки темы в оглавление: read-modify-write под замком файла. + * Две темы пишут одно оглавление — замок сериализует, upsert по ключу-теме не теряет строк. + * proper-lockfile стат-ит цель → файл должен существовать ДО взятия замка (создаём пустой). */ +export async function writeIndexSafely(idxFile, entry, { lockImpl = lockfile } = {}) { + if (!existsSync(idxFile)) { try { writeFileSync(idxFile, ''); } catch { /* гонка создания — переживём */ } } + const release = await lockImpl.lock(idxFile, { stale: 15_000, retries: { retries: 10, minTimeout: 20, maxTimeout: 200 }, realpath: false }); + try { + const cur = existsSync(idxFile) ? readFileSync(idxFile, 'utf-8') : ''; + writeFileAtomic(idxFile, upsertIndexEntry(cur, entry)); + } finally { await release(); } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-index.test.mjs` +Expected: PASS (existing + 1 new). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-index.mjs tools/secretary-index.test.mjs +git commit -m "feat(secretary): parallel-safe index write under file lock" -- tools/secretary-index.mjs tools/secretary-index.test.mjs +``` + +--- + +### Task 7: The worker core (`runWorker`) + CLI + +`runWorker(workDir, deps)` is a pure-ish async function (all I/O via injectable deps) so it is fully testable without a real process or real LLM. It: takes the lock (exits if held), drains the queue, for each job skips if `index <= cursor` else reads raw → `assembleSpan` → `distillSpan` → writes `protocol.json` + `renderDoc` → `protocol.md` + safe index + advances cursor, then releases the lock. The thin CLI `secretary-worker.mjs` wires real deps (real `callModel` with 900 s timeout / 2 retries / max output). + +**Files:** +- Create: `tools/secretary-worker.mjs` +- Test: `tools/secretary-worker.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runWorker } from './secretary-worker.mjs'; +import { enqueueSpan, readCursor } from './secretary-queue.mjs'; +import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; + +let dir, secdir, workDir; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'secw-')); + secdir = join(dir, 'docs', 'secretary'); workDir = join(secdir, 'тема'); + mkdirSync(join(secdir, 'raw'), { recursive: true }); mkdirSync(workDir, { recursive: true }); + writeFileSync(join(workDir, 'protocol.json'), JSON.stringify(EMPTY_PROTOCOL())); + // одно сырьё с одним закрытым спаном (ход 1..1) + writeFileSync(join(secdir, 'raw', 's.log'), '=== ХОД turn=1 ===\n[ЮЗЕР]\nпривет\n[АССИСТЕНТ]\nответ\n=== КОНЕЦ ХОДА ===\n'); +}); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +const deps = (overrides = {}) => ({ + secdir, + // фейковый разбор спана: помечает протокол, не зовёт LLM + distill: async (proto) => ({ ...proto, subject: 'разобрано', steps: [{ turn: 1, text: 'шаг' }] }), + render: () => '# протокол', + now: () => 1000, + ...overrides, +}); + +describe('runWorker', () => { + it('drains one queued span: writes protocol, renders md, advances cursor', async () => { + enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); + await runWorker(workDir, deps()); + const proto = JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8')); + expect(proto.subject).toBe('разобрано'); + expect(readFileSync(join(workDir, 'protocol.md'), 'utf-8')).toContain('протокол'); + expect(readCursor(workDir)).toBe(0); + }); + it('skips a job whose index <= cursor (idempotent re-enqueue)', async () => { + let distillCalls = 0; + enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); + enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); // dedup → 1 job anyway + await runWorker(workDir, deps({ distill: async (p) => { distillCalls++; return p; } })); + expect(distillCalls).toBe(1); + }); + it('exits immediately if the theme lock is held', async () => { + let distillCalls = 0; + enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); + await runWorker(workDir, deps({ acquire: async () => null, distill: async (p) => { distillCalls++; return p; } })); + expect(distillCalls).toBe(0); + }); + it('degrades on distill throw: cursor still advances, protocol not corrupted', async () => { + enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); + await runWorker(workDir, deps({ distill: async () => { throw new Error('context window exceeded'); } })); + expect(readCursor(workDir)).toBe(0); // не зацикливается на ядовитом спане + expect(existsSync(join(workDir, 'protocol.json'))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-worker.test.mjs` +Expected: FAIL — `runWorker` missing. + +- [ ] **Step 3: Write minimal implementation** + +```js +#!/usr/bin/env node +// tools/secretary-worker.mjs +// Фон-воркер секретаря: ЕДИНСТВЕННЫЙ писатель протокола темы. Берёт замок темы (занят → выход), +// разбирает спаны из очереди по порядку (skip уже обработанных), пишет protocol.json/.md + оглавление, +// двигает курсор. Вне харнесс-таймаута: тяжёлые модели и полный вход — без 900с-стены. +import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { writeFileAtomic, realBoundariesFromRaw } from './secretary-layer1.mjs'; +import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +import { assembleSpan } from './secretary-span.mjs'; +import { distillSpan } from './secretary-distill.mjs'; +import { renderDoc } from './secretary-render-fluffy.mjs'; +import { writeIndexSafely } from './secretary-index.mjs'; +import { acquireThemeLock } from './secretary-lock.mjs'; +import { dequeueSpan, readCursor, writeCursor } from './secretary-queue.mjs'; +import { callAnthropicAPI } from './router-classifier.mjs'; + +function readProto(workDir) { + try { return JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8')); } catch { return EMPTY_PROTOCOL(); } +} + +/** Сердце воркера. Все эффекты через deps — тестируется без процесса и без LLM. + * deps: { secdir, distill(proto, spanEx, span, ctx), render(proto, opts), acquire(workDir), now() }. */ +export async function runWorker(workDir, deps) { + const { + secdir, + acquire = (wd) => acquireThemeLock(wd, { graceful: true }), + distill = (proto, spanEx, span, ctx) => distillSpan(proto, spanEx, span, ctx), + render = (proto, opts) => renderDoc(proto, opts), + now = () => Date.now(), + callModel = null, + } = deps || {}; + + const release = await acquire(workDir); + if (!release) return; // другой воркер уже крутится — он добьёт очередь + try { + const work = basename(workDir); + let job; + while ((job = dequeueSpan(workDir)) !== null) { + const span = job.span; + if (span.index <= readCursor(workDir)) continue; // уже обработан (идемпотентный повтор постановки) + try { + const rawFile = join(secdir, 'raw', `${job.session}.log`); + const rawText = existsSync(rawFile) ? readFileSync(rawFile, 'utf-8') : ''; + const spanEx = assembleSpan(rawText, span); + let proto = readProto(workDir); + proto = await distill(proto, spanEx, { ...span }, { callModel, session: job.session }); + mkdirSync(workDir, { recursive: true }); + writeFileAtomic(join(workDir, 'protocol.json'), JSON.stringify(proto, null, 2)); + const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' '); + writeFileAtomic(join(workDir, 'protocol.md'), render(proto, { work, date: stamp })); + await writeIndexSafely(join(secdir, 'содержание.md'), { + slug: work, title: work, + goal: (proto.subject && proto.subject.trim()) ? proto.subject.trim() : '(дело)', + status: proto.status || 'открыто', date: stamp, + }); + } catch { /* мягкая деградация: спан ядовит (окно модели/парс) — не зацикливаемся, идём дальше */ } + writeCursor(workDir, span.index); // курсор двигаем всегда (даже при деградации) — at-least-once без петли + } + } finally { await release(); } +} + +// Реальный callModel: 900с/вызов, 2 повтора, максимум вывода. Тяжёлые модели — в фоне, стены нет. +function realCallModel() { + const apiKey = process.env.SECRETARY_LLM_KEY; + if (!apiKey) return null; + return (msgs) => callAnthropicAPI(msgs, { + apiKey, + baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined, + model: process.env.SECRETARY_LLM_MODEL || undefined, + perAttemptTimeoutMs: Number(process.env.SECRETARY_LLM_TIMEOUT_MS) || 900_000, + maxRetries: Number(process.env.SECRETARY_LLM_RETRIES) || 2, + maxTokens: Number(process.env.SECRETARY_OUTPUT_MAX) || undefined, + }); +} + +const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-worker.mjs'); +if (isCli) { + const workDir = process.argv[2]; + const secdir = join(workDir, '..'); // docs/secretary/<тема> → docs/secretary + runWorker(workDir, { secdir, callModel: realCallModel() }).then(() => process.exit(0)).catch(() => process.exit(1)); +} +``` + +Note: `distill` default signature matches `distillSpan(proto, spanEx, {start,end,note,index}, {callModel,session,diag})`. The worker passes `{ callModel, session }`; `distillSpan` reads `callModel` from there. Verify `callAnthropicAPI` accepts `maxTokens` — if the param name differs, check `tools/router-classifier.mjs` around the options destructure (Task 7a below). + +- [ ] **Step 3a: Verify `maxTokens` knob name in `callAnthropicAPI`** + +Read `tools/router-classifier.mjs` near the options destructure (≈ line 490). If the output-tokens option is named differently (e.g. `max_tokens`), use that name in `realCallModel`. If there is no such option, drop the `maxTokens` line (output already defaults high) and note it. This is a read-and-match step, no guessing. + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-worker.test.mjs` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-worker.mjs tools/secretary-worker.test.mjs +git commit -m "feat(secretary): background worker core (sole writer, drain queue, degrade safely)" -- tools/secretary-worker.mjs tools/secretary-worker.test.mjs +``` + +--- + +### Task 8: Rewire the Stop-hook — no LLM, enqueue + fire + +The hook keeps Layer-1 raw writing and span-boundary computation, but instead of calling `distillSpan` and writing the protocol, it **enqueues** new closed spans (index > worker cursor) and fires the worker. Catch-up spans of dead sessions go into the queue too. Extract the enqueue decision into a pure, testable helper. + +**Files:** +- Modify: `tools/secretary-stop-hook.mjs` +- Create: `tools/secretary-enqueue.mjs` (pure helper) +- Test: `tools/secretary-enqueue.test.mjs` + +- [ ] **Step 1: Write the failing test** for the helper + +```js +import { describe, it, expect } from 'vitest'; +import { spansToEnqueue } from './secretary-enqueue.mjs'; + +describe('spansToEnqueue', () => { + const bounds = [1, 3, 5]; // спаны: [1..2]#0, [3..4]#1, [5..last]#2 + it('returns closed spans with index > cursor, as queue jobs', () => { + const jobs = spansToEnqueue({ rawBounds: bounds, lastTurn: 6, cursor: 0, session: 's' }); + expect(jobs.map((j) => j.span.index)).toEqual([1]); // #0 обработан, #2 ещё открыт + expect(jobs[0]).toMatchObject({ session: 's', kind: 'span', span: { start: 3, end: 4, index: 1 } }); + }); + it('closing mode also enqueues the last (open) span', () => { + const jobs = spansToEnqueue({ rawBounds: bounds, lastTurn: 6, cursor: 1, session: 's', closing: true }); + expect(jobs.map((j) => j.span.index)).toEqual([2]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-enqueue.test.mjs` +Expected: FAIL — export missing. + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-enqueue.mjs +// Чистый решатель: какие спаны поставить в очередь воркера на этом проходе хука. Курсор — обработанный +// воркером индекс. closing → добить последний открытый спан. Дедуп по index — на стороне очереди. +import { computeSpans, spansToDistill } from './secretary-span.mjs'; + +export function spansToEnqueue({ rawBounds, lastTurn, cursor, session, closing = false, note = '' }) { + const jobs = spansToDistill(rawBounds, lastTurn, cursor) + .map((s) => ({ session, kind: 'span', span: { ...s, note } })); + if (closing) { + const all = computeSpans(rawBounds, lastTurn).map((s, index) => ({ ...s, index })); + const lastOpen = all[all.length - 1]; + if (lastOpen && lastOpen.open && lastOpen.index > (Number.isFinite(cursor) ? cursor : -1)) + jobs.push({ session, kind: 'span', span: { start: lastOpen.start, end: lastOpen.end, index: lastOpen.index, note } }); + } + return jobs; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-enqueue.test.mjs` +Expected: PASS (2 tests). + +- [ ] **Step 5: Rewire `secretary-stop-hook.mjs`** + +Replace the body from the `try {` at line 60 through the closing-branch (lines ~60-178) with the enqueue+fire flow. Concretely: +- Keep lines 1-58 (imports, raw write, flag read) — but **add** imports: + ```js + import { enqueueSpan, readCursor } from './secretary-queue.mjs'; + import { spansToEnqueue } from './secretary-enqueue.mjs'; + import { spawnWorker } from './secretary-spawn.mjs'; + ``` + and **remove** now-unused imports: `EMPTY_PROTOCOL`, `renderDoc`, `upsertIndexEntry`, `distillSpan`, `assembleSpan`, `spansToDistill`, `computeSpans`, `mergeStepsPreservingText`, `prepareTurnFiles`, `callAnthropicAPI`, `formatReconcileLogLine` (verify each is unused after the rewrite; keep `realBoundariesFromRaw`, `spanInterruptNote`, `buildRawFromExchanges`, `assembleExchanges`, `writeFileAtomic`, `secretaryModeFileName`, `upsertSessionPointer`, `sanitize`). +- Replace the work block with: + ```js + const work = flag.work || 'general'; + try { + const workDir = join(secdir, work); + let rawText = ''; + try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; } + const bounds = realBoundariesFromRaw(rawText); + const cursor = readCursor(workDir); + + // Хвосты мёртвых сессий (догон) — тоже в очередь (хук LLM не зовёт). + if (Array.isArray(flag.catchUp) && flag.catchUp.length) { + const projDir = tp ? dirname(tp) : null; + for (const prev of flag.catchUp) { + if (!projDir || !prev || !prev.session) continue; + let prevTranscript = ''; + try { const prevTp = join(projDir, `${prev.session}.jsonl`); if (existsSync(prevTp)) prevTranscript = readFileSync(prevTp, 'utf-8'); } catch { prevTranscript = ''; } + if (!prevTranscript) continue; + const prevRaw = buildRawFromExchanges(assembleExchanges(prevTranscript), { session: prev.session, sanitize }); + try { mkdirSync(join(secdir, 'raw'), { recursive: true }); writeFileAtomic(join(secdir, 'raw', `${prev.session}.log`), prevRaw); } catch { /* ignore */ } + const prevBounds = realBoundariesFromRaw(prevRaw); + const prevLast = (prevRaw.match(/=== ХОД turn=/g) || []).length; + for (const job of spansToEnqueue({ rawBounds: prevBounds, lastTurn: prevLast, cursor: -1, session: prev.session, closing: true, note: spanInterruptNote(prevRaw, {}) })) + enqueueSpan(workDir, job); + } + writeFlag(session, { ...readFlag(session), catchUp: [] }); + } + + // Новые закрытые спаны живой сессии (+ последний открытый при закрытии). + for (const job of spansToEnqueue({ rawBounds: bounds, lastTurn: turn, cursor, session, closing })) + enqueueSpan(workDir, job); + + if (closing) writeFlag(session, { mode: 'off' }); + + // Выстрел воркера (детач). Если уже крутится — упрётся в замок и выйдет. + try { spawnWorker(workDir); } catch { /* выстрел вторичен — следующий ход повторит */ } + } catch { /* fail-quiet: сырьё уже записано */ } + process.exit(0); + ``` + + Note: `note` per-span for the live session is omitted here (kept simple); if span-interrupt notes are required per span, compute `spanInterruptNote(rawText, span)` inside the worker instead (it has the raw). Leave that to the worker if a note gap shows in testing. + +- [ ] **Step 6: Verify nothing else imports the removed symbols & run the focused hook check** + +There is no unit test harness for the hook process itself; instead confirm the module loads without error: +Run (PowerShell): `node -e "import('./tools/secretary-stop-hook.mjs').then(()=>console.log('ok'))"` — **but the floor blocks `node -e`.** Instead run the whole tools suite (Step 7) which imports every module; a broken import will surface as a load error there. + +- [ ] **Step 7: Run the full tools suite** + +Run (PowerShell): `npm run test:tools` +Expected: PASS (all prior + new; no module-load errors from the hook rewrite). + +- [ ] **Step 8: Commit** (owner terminal) + +``` +git add tools/secretary-enqueue.mjs tools/secretary-enqueue.test.mjs tools/secretary-stop-hook.mjs +git commit -m "feat(secretary): stop-hook enqueues + fires worker (no LLM, no protocol write)" -- tools/secretary-enqueue.mjs tools/secretary-enqueue.test.mjs tools/secretary-stop-hook.mjs +``` + +--- + +### Task 9: Flag toggle via runtime file (so activation needs no settings.json edit) + +`fluffyPipelineOn` should also read a runtime sentinel file, so B can be turned on/off without editing enforcement-sensitive `.claude/settings.json`. + +**Files:** +- Modify: `tools/secretary-flag.mjs` +- Test: `tools/secretary-flag.test.mjs` (append) + +- [ ] **Step 1: Write the failing test** (append) + +```js +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fluffyPipelineOn } from './secretary-flag.mjs'; + +describe('fluffyPipelineOn runtime file', () => { + let dir, file; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'secflag-')); file = join(dir, 'secretary-fluffy'); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + it('env wins when set', () => { expect(fluffyPipelineOn({ SECRETARY_FLUFFY: '1' }, file)).toBe(true); }); + it('runtime file present → on, even without env', () => { writeFileSync(file, ''); expect(fluffyPipelineOn({}, file)).toBe(true); }); + it('neither → off', () => { expect(fluffyPipelineOn({}, file)).toBe(false); }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-flag.test.mjs` +Expected: FAIL — `fluffyPipelineOn` ignores the file arg. + +- [ ] **Step 3: Write minimal implementation** — replace `fluffyPipelineOn` in `secretary-flag.mjs`: + +```js +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +function defaultFluffyFile() { return join(homedir(), '.claude', 'runtime', 'secretary-fluffy'); } + +/** Флаг конвейера «пушистое дерево». ВКЛ если env SECRETARY_FLUFFY=1|true ИЛИ есть рантайм-файл-сентинел. + * Рантайм-файл позволяет тогглить без правки settings.json. */ +export function fluffyPipelineOn(env = process.env, file = defaultFluffyFile()) { + if (env.SECRETARY_FLUFFY === '1' || env.SECRETARY_FLUFFY === 'true') return true; + try { return existsSync(file); } catch { return false; } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-flag.test.mjs` +Expected: PASS (existing flag tests + 3 new). + +- [ ] **Step 5: Run full suite** (the distill module calls `fluffyPipelineOn()` with no args — confirm default still works) + +Run (PowerShell): `npm run test:tools` +Expected: PASS. + +- [ ] **Step 6: Commit** (owner terminal) + +``` +git add tools/secretary-flag.mjs tools/secretary-flag.test.mjs +git commit -m "feat(secretary): fluffy flag also reads runtime sentinel file" -- tools/secretary-flag.mjs tools/secretary-flag.test.mjs +``` + +--- + +### Task 10: Tests for the previously-unverified mechanics (decision #8) + +НАХОДКИ decision #8 lists three behaviours never exercised by the 2-span test runs: **gravity** (a brainstorm candidate promoted into the trunk once the owner actually talks about that idea), **reopen / reverse cascade** (changing a load-bearing decision reopens what it auto-closed), **history compression** (long protocols fold/compress old material without losing the root). These exercise the A pipeline (`applyResults` / gardener), so the tests target those functions with crafted protocols — not the worker. + +**Files:** +- Test: `tools/secretary-mechanics.test.mjs` (new) + +- [ ] **Step 1: Read the A functions to target** + +Read `tools/secretary-apply.mjs` (`applyResults`, candidate accumulation/dedup, `clampSeverity`) and `tools/secretary-gardener.mjs` (`applyTend`, close-with-proof). Identify: how a candidate is represented (`{branch, опора, релевантность, born}`), how a branch closes (`status:'закрыт'` + `proof`), and whether any existing function promotes a candidate to a decision. **If no promotion path exists, gravity is currently unimplemented** — in that case this task's gravity test is a `it.todo` documenting the gap, and you add a follow-up note (do NOT silently skip; surface it). + +- [ ] **Step 2: Write the tests** (use real `applyResults`/`applyTend` signatures discovered in Step 1; the skeleton below shows intent — fill the exact inputs from the read) + +```js +import { describe, it, expect } from 'vitest'; +import { applyResults } from './secretary-apply.mjs'; +import { applyTend } from './secretary-gardener.mjs'; + +describe('mechanics: gravity (candidate → trunk when owner engages)', () => { + it('a candidate whose idea the owner later raised is promoted out of candidates', () => { + // Build a protocol with a candidate; feed a span/trunk-diff where the owner engaged that idea; + // assert it leaves p.candidates and appears in the trunk (decisions/will). + // If no promotion path exists in A, convert to it.todo and record the gap in the plan's follow-ups. + }); +}); + +describe('mechanics: reopen / reverse cascade', () => { + it('changing a load-bearing decision reopens a branch it had auto-closed', () => { + // Start with a closed branch (status:'закрыт', proof) tied to decision D; apply a tend/result that + // flips D; assert the branch returns to a live status (open/сужен), not silently lost. + }); +}); + +describe('mechanics: history compression', () => { + it('on a long protocol, struck/closed items fold but keep their source (turns/born)', () => { + // Build a protocol with many struck decisions + closed branches; run collapse/apply; + // assert old items remain (with turns/born intact), not deleted — folding hides visibility, not root. + }); +}); +``` + +- [ ] **Step 3: Run, watch them fail, implement or mark** + +Run (PowerShell): `npx vitest run --config vitest.config.tools.mjs tools/secretary-mechanics.test.mjs` +For each behaviour: if the A pipeline already supports it → make the test pass against real functions. If it does **not** (likely for gravity/reopen) → the minimal implementation is a small addition to `secretary-apply.mjs`/`secretary-gardener.mjs` (promote-candidate / reopen-on-decision-change), TDD'd here. Keep additions minimal; if an addition is non-trivial, mark the test `it.todo` with a one-line gap note and list it in "Follow-ups" at the plan end rather than building speculatively. + +- [ ] **Step 4: Run full suite** + +Run (PowerShell): `npm run test:tools` +Expected: PASS (todos are not failures). + +- [ ] **Step 5: Commit** (owner terminal) + +``` +git add tools/secretary-mechanics.test.mjs tools/secretary-apply.mjs tools/secretary-gardener.mjs +git commit -m "test(secretary): gravity / reopen / history-compression mechanics (decision #8)" -- tools/secretary-mechanics.test.mjs tools/secretary-apply.mjs tools/secretary-gardener.mjs +``` + +(If Steps only added tests, drop the two source files from the `git add`/commit.) + +--- + +### Task 11: Activation (owner-gated) + cleanup + +**Files:** +- (Runtime) `~/.claude/runtime/secretary-fluffy` sentinel +- Modify: `docs/secretary/протокол-наставника/прогон/НАХОДКИ.md` (mark B done), `docs/superpowers/specs/2026-06-25-secretary-fluffy-EPIC-status-handoff.md` (B → done) +- Delete: `.scratch/sec/*` (epic done — CLAUDE.md п.11) + +- [ ] **Step 1: Final full suite green** + +Run (PowerShell): `npm run test:tools` +Expected: PASS, exit 0. Record the count. + +- [ ] **Step 2: Turn the flag ON — OWNER DECISION** + +Do NOT flip silently. Present to the owner: "Включаю боевой конвейер? Создам файл-сентинел `~/.claude/runtime/secretary-fluffy` — после этого живой секретарь пойдёт по новому пути (фон-воркер)." On explicit yes, create the file (empty). The owner can remove it to revert instantly. + +- [ ] **Step 3: Smoke — one real closed span** + +With the flag on and `SECRETARY_LLM_KEY` set, run a tiny secretary session (one тема, 2 prompts to close a span), then confirm `docs/secretary/<тема>/protocol.md` updated and `_worker/cursor.json` advanced. This is owner-run (live keys) — present the steps; do not fabricate results. + +- [ ] **Step 4: Cleanup sandbox** + +Delete `.scratch/sec/` (proven engine ported; epic closed). Confirm nothing in `tools/` imports from `.scratch` (grep first). + +- [ ] **Step 5: Update epic docs + commit** (owner terminal) + +Mark B done in НАХОДКИ and the EPIC handoff. Commit (docs-only short-circuit applies): + +``` +git add docs/secretary/протокол-наставника/прогон/НАХОДКИ.md docs/superpowers/specs/2026-06-25-secretary-fluffy-EPIC-status-handoff.md +git commit -m "docs(secretary): mark sub-project B done, epic closed" -- docs/secretary/протокол-наставника/прогон/НАХОДКИ.md docs/superpowers/specs/2026-06-25-secretary-fluffy-EPIC-status-handoff.md +``` + +- [ ] **Step 6: Finish the branch** + +Use superpowers:finishing-a-development-branch to merge `feat/secretary-worker-b` → main (owner terminal) and back up to gitea (`git push gitea main`). + +--- + +## Follow-ups (fill during execution) +- Any `it.todo` from Task 10 (gravity/reopen) with a one-line gap note. +- Confirmed `maxTokens` option name from Task 7a (or note it was dropped). +- Per-span interrupt-note gap (Task 8 Step 5) if it surfaced — where it was resolved.