docs(secretary): план реализации B — фоновый воркер (11 задач TDD)

This commit is contained in:
Дмитрий
2026-06-25 13:34:57 +03:00
parent 8bdcb77444
commit 040b06f21b
@@ -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 <file>` 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 "…" -- <paths>` (`-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 `<workDir>/_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
// Очередь спанов и курсор обработки темы на диске (<workDir>/_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.