diff --git a/docs/superpowers/plans/2026-06-23-secretary-interruption-resilience.md b/docs/superpowers/plans/2026-06-23-secretary-interruption-resilience.md new file mode 100644 index 0000000..d8931dc --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-secretary-interruption-resilience.md @@ -0,0 +1,1080 @@ +# Secretary Interruption Resilience — 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:** Сделать секретаря устойчивым к обрывам: настоящий промпт владельца не теряется, прерванная работа помечается честно, одна логическая работа не дробится на «продолжи». + +**Architecture:** Источник правды — транскрипт на диске. На каждом завершении ответа сырьё (Слой 1) **пересобирается из всего транскрипта** (а не дописывается по последнему обмену). При сборке распознаются машинные метки (`isApiErrorMessage`, `[Request interrupted by user…]`, `isCompactSummary`, `isMeta`) — они не считаются настоящим промптом. Промпт сразу после метки-обрыва — структурно «продолжение» (без анализа слов), склеивается со спаном предыдущей просьбы. Нарезка по спанам и разбор (`distillSpan`) — без изменения логики. Жёсткий крах догоняется при повторном «включи секретаря `<дело>`». + +**Tech Stack:** Node ESM (`.mjs`), vitest. Файлы в `tools/`. Тесты — `import { describe, it, expect } from 'vitest'`, фикстуры строятся через `buildRawRecord` и JSON-строки транскрипта. + +**Спека:** [docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md](../specs/2026-06-23-secretary-interruption-resilience-spec.md) + +> **Замечание по коммитам (этот проект):** код-коммит проходит проверку-перед-пушем (`enforce-verify-gate`). Перед `git commit` шага «Commit» прогнать полный свод секретаря и `node tools/produce-verify-receipt.mjs`. Коммит — с согласия владельца (правило проекта). Шаги «Commit» оставлены по дисциплине TDD (частые коммиты). + +--- + +## Файловая структура (что и зачем) + +| Файл | Ответственность / правка | +|---|---| +| `tools/secretary-transcript.mjs` | **Новое:** `classifyEntry` (распознать вид записи), `assembleExchanges` (собрать ВСЕ обмены из транскрипта с флагами продолжения/хвоста), `buildRawFromExchanges` + `rebuildRawFromTranscript` (пересборка сырья). `parseLastExchange` → тонкая обёртка над `assembleExchanges().at(-1)` (старые тесты целы, мёртвого кода нет). | +| `tools/secretary-layer1.mjs` | `buildRawRecord` — ярлычки `cont=1` (продолжение) и `tail=1` (прерван-не-завершён) в заголовке, аддитивно к `meta=1`. `realBoundariesFromRaw` — исключить `cont=1` из границ (как `meta=1`). `spanInterruptNote(rawText, span)` — честная пометка спана. `buildStepLine` — необязательный параметр `note`. `buildStepsFromRaw` — проставить note. | +| `tools/secretary-distill.mjs` | Передать `note` в `buildStepLine` (живой путь разбора). Логика разбора без изменений. | +| `tools/secretary-stop-hook.mjs` | Пересобирать сырьё из всего транскрипта (overwrite) вместо append-последнего; обновлять указатель сессий дела; выполнять догон по пометке от prompt-хука. | +| `tools/secretary-prompt-hook.mjs` | На «включи секретаря `<дело>`» — поставить в флажок `catchUp` (прошлые сессии дела из `_sessions.json`). | +| `tools/secretary-sessions.mjs` | **Новый, чистый:** `upsertSessionPointer`, `prevSessionsForCatchUp` — учёт «какие сессии вели дело». | +| `tools/secretary-*.test.mjs` | TDD-фикстуры под каждый вид обрыва. | + +--- + +## Phase 1 — Распознавание видов записи и сборка обменов (`secretary-transcript.mjs`) + +### Task 1: `classifyEntry` — вид записи транскрипта + +**Files:** +- Modify: `tools/secretary-transcript.mjs` +- Test: `tools/secretary-transcript.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `tools/secretary-transcript.test.mjs`: + +```js +import { classifyEntry } from './secretary-transcript.mjs'; + +describe('classifyEntry — вид записи транскрипта', () => { + it('настоящий промпт владельца → real', () => { + expect(classifyEntry({ message: { role: 'user', content: 'сделай X' } })).toBe('real'); + }); + it('служебный ход (isMeta) → meta', () => { + expect(classifyEntry({ isMeta: true, message: { role: 'user', content: 'Stop hook feedback' } })).toBe('meta'); + }); + it('сбой API (isApiErrorMessage) → interrupt-api', () => { + expect(classifyEntry({ isApiErrorMessage: true, type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } })).toBe('interrupt-api'); + }); + it('ручной стоп (обе формы) → interrupt-stop', () => { + expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } })).toBe('interrupt-stop'); + expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] } })).toBe('interrupt-stop'); + }); + it('выжимка сжатия (isCompactSummary) → summary', () => { + expect(classifyEntry({ isCompactSummary: true, message: { role: 'user', content: 'итог...' } })).toBe('summary'); + }); + it('ответ ассистента → assistant; tool_result → tool_result', () => { + expect(classifyEntry({ message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } })).toBe('assistant'); + expect(classifyEntry({ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'r' }] } })).toBe('tool_result'); + }); +}); +``` + +- [ ] **Step 2: Прогнать тест — убедиться, что падает** + +Run: `npx vitest run tools/secretary-transcript.test.mjs -t classifyEntry` +Expected: FAIL — `classifyEntry is not a function`. + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-transcript.mjs` добавить (рядом с `isRealUserPrompt`): + +```js +// Текст user-контента (строка или массив text-блоков) — для классификации меток. +function userText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n'); + return ''; +} + +// Вид записи транскрипта для сборки обменов. Метки печатает Claude Code (не владелец) — +// распознаём структурно, опечатки в тексте владельца ни на что не влияют. +// Порядок проверок важен: метка-обрыв проверяется ДО real (она тоже role:user с text-блоком). +export function classifyEntry(entry) { + if (!entry) return 'skip'; + if (entry.isCompactSummary === true) return 'summary'; + if (entry.isApiErrorMessage === true) return 'interrupt-api'; + const m = entry.message; + if (!m) return 'skip'; + if (m.role === 'user') { + if (/^\s*\[Request interrupted by user/.test(userText(m.content))) return 'interrupt-stop'; + if (Array.isArray(m.content) && m.content.some((b) => b && b.type === 'tool_result')) return 'tool_result'; + if (isRealUserPrompt(m)) return entry.isMeta === true ? 'meta' : 'real'; + return 'skip'; + } + if (m.role === 'assistant') return 'assistant'; + return 'skip'; +} +``` + +- [ ] **Step 4: Прогнать тест — PASS** + +Run: `npx vitest run tools/secretary-transcript.test.mjs -t classifyEntry` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-transcript.mjs tools/secretary-transcript.test.mjs +git commit -m "feat(secretary): classifyEntry — распознавание машинных меток транскрипта" +``` + +--- + +### Task 2: `assembleExchanges` — сборка всех обменов с флагами обрыва + +**Files:** +- Modify: `tools/secretary-transcript.mjs` +- Test: `tools/secretary-transcript.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { assembleExchanges } from './secretary-transcript.mjs'; + +describe('assembleExchanges — обмены из всего транскрипта', () => { + it('два настоящих промпта → два обмена с накопленным ответом и действиями', () => { + const t = [ + { message: { role: 'user', content: 'первый' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'ответ1' }, { type: 'tool_use', id: 'a', name: 'Read', input: { f: 'x' } }] } }, + { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a', content: 'r' }] } }, + { message: { role: 'user', content: 'второй' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'ответ2' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['первый', 'второй']); + expect(ex[0].assistant).toBe('ответ1'); + expect(ex[0].actions).toEqual([{ tool: 'Read', input: '{"f":"x"}', result: 'r' }]); + expect(ex[0].isContinuation).toBe(false); + }); + + it('сбой API + следующий промпт → продолжение (isContinuation), не новый настоящий промпт', () => { + const t = [ + { message: { role: 'user', content: 'настоящая просьба' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'начал работу' }] } }, + { isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } }, + { message: { role: 'user', content: 'продолжи' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['настоящая просьба', 'продолжи']); + expect(ex[1].isContinuation).toBe(true); // «продолжи» — продолжение, не новая просьба + expect(ex[ex.length - 1].interruptedTail).toBe(false); // работа доведена → не хвост + }); + + it('ручной стоп + следующий промпт → продолжение (склеиваем по умолчанию)', () => { + const t = [ + { message: { role: 'user', content: 'просьба' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'работаю' }] } }, + { message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } }, + { message: { role: 'user', content: 'дальше давай' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['просьба', 'дальше давай']); + expect(ex[1].isContinuation).toBe(true); + }); + + it('прерван и НЕ продолжен (хвост в конце) → interruptedTail на последнем обмене', () => { + const t = [ + { message: { role: 'user', content: 'большая задача' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'делаю часть' }] } }, + { message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex).toHaveLength(1); + expect(ex[0].user).toBe('большая задача'); + expect(ex[0].interruptedTail).toBe(true); + }); + + it('выжимка сжатия не становится обменом', () => { + const t = [ + { message: { role: 'user', content: 'реальный' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } }, + { isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: 'СЖАТАЯ ВЫЖИМКА' } }, + { message: { role: 'user', content: 'после сжатия' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'дальше' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['реальный', 'после сжатия']); + expect(ex.some((x) => x.user.includes('ВЫЖИМКА'))).toBe(false); + }); + + it('служебный ход (meta) — отдельный обмен с userIsMeta', () => { + const t = [ + { message: { role: 'user', content: 'настоящий' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'a' }] } }, + { isMeta: true, message: { role: 'user', content: 'Stop hook feedback: x' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'b' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex).toHaveLength(2); + expect(ex[1].userIsMeta).toBe(true); + expect(ex[1].isContinuation).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Прогнать тест — убедиться, что падает** + +Run: `npx vitest run tools/secretary-transcript.test.mjs -t assembleExchanges` +Expected: FAIL — `assembleExchanges is not a function`. + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-transcript.mjs` добавить: + +```js +// Сборка ВСЕХ обменов из транскрипта. Обмен = настоящий промпт владельца (или служебный ход) +// → ответ ассистента + действия, до следующего настоящего промпта/служебного хода. +// Метки-обрывы (сбой API / ручной стоп) НЕ начинают обмен: промпт сразу после метки помечается +// продолжением (isContinuation), не открывает новый спан. Незавершённый прерванный хвост в конце — +// interruptedTail. Выжимки сжатия и прочее служебное — пропускаются. +export function assembleExchanges(transcriptText) { + const entries = parseLines(transcriptText); + const exchanges = []; + let cur = null; + let pendingInterrupt = false; // метка-обрыв видна, ждём следующий настоящий промпт + const push = () => { if (cur) exchanges.push(cur); }; + for (const e of entries) { + const kind = classifyEntry(e); + if (kind === 'real' || kind === 'meta') { + push(); + cur = { + user: userText(e.message.content), assistant: '', actions: [], results: {}, + userIsMeta: kind === 'meta', + isContinuation: kind === 'real' && pendingInterrupt, + interruptedTail: false, + time: e.timestamp || '', + }; + pendingInterrupt = false; + } else if (kind === 'assistant') { + if (!cur) continue; + const c = e.message.content; + if (Array.isArray(c)) { + for (const b of c) { + if (b && b.type === 'text' && b.text) cur.assistant += (cur.assistant ? '\n' : '') + b.text; + if (b && b.type === 'tool_use') cur.actions.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) }); + } + } else if (typeof c === 'string') { + cur.assistant += (cur.assistant ? '\n' : '') + c; + } + } else if (kind === 'tool_result') { + if (!cur) continue; + for (const b of e.message.content) { + if (b && b.type === 'tool_result' && b.tool_use_id != null) cur.results[b.tool_use_id] = resultText(b.content); + } + } else if (kind === 'interrupt-api' || kind === 'interrupt-stop') { + pendingInterrupt = true; + if (cur) cur.interruptedTail = true; // предварительно; снимется, если ниже есть продолжение + } + // 'summary' / 'skip' — игнор + } + push(); + // Хвостом остаётся только ПОСЛЕДНИЙ обмен: если ниже есть ещё обмен — работа так или иначе продолжилась. + for (let i = 0; i < exchanges.length - 1; i++) exchanges[i].interruptedTail = false; + // Привязка выдачи к действию по id; снять служебное поле results. + for (const ex of exchanges) { + ex.actions = ex.actions.map((a) => { + const out = { tool: a.tool, input: a.input }; + if (a.id != null && ex.results[a.id] != null) out.result = String(ex.results[a.id] ?? ''); + return out; + }); + delete ex.results; + } + return exchanges; +} +``` + +- [ ] **Step 4: Прогнать тест — PASS** + +Run: `npx vitest run tools/secretary-transcript.test.mjs -t assembleExchanges` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-transcript.mjs tools/secretary-transcript.test.mjs +git commit -m "feat(secretary): assembleExchanges — сборка обменов из всего транскрипта с флагами обрыва" +``` + +--- + +### Task 3: `parseLastExchange` → обёртка над `assembleExchanges` (без мёртвого кода) + +**Files:** +- Modify: `tools/secretary-transcript.mjs:40-85` +- Test: `tools/secretary-transcript.test.mjs` (существующие тесты `parseLastExchange` — регресс) + +- [ ] **Step 1: Заменить тело `parseLastExchange` на обёртку** + +Заменить функцию `parseLastExchange` ([secretary-transcript.mjs:40](../../../tools/secretary-transcript.mjs#L40)) на: + +```js +/** Последний обмен (совместимость со старым API). Тонкая обёртка над assembleExchanges. */ +export function parseLastExchange(transcriptText) { + const all = assembleExchanges(transcriptText); + const last = all[all.length - 1]; + if (!last) return { user: '', assistant: '', actions: [], userIsMeta: false }; + return { user: last.user, assistant: last.assistant, actions: last.actions, userIsMeta: last.userIsMeta }; +} +``` + +(Старые приватные `parseLines`/`isRealUserPrompt`/`resultText`/`userText` остаются — используются `assembleExchanges`/`classifyEntry`.) + +- [ ] **Step 2: Прогнать ВСЕ тесты транскрипта — регресс не должен сломаться** + +Run: `npx vitest run tools/secretary-transcript.test.mjs` +Expected: PASS (все прежние `parseLastExchange`-тесты зелёные на обёртке). + +- [ ] **Step 3: Commit** + +```bash +git add tools/secretary-transcript.mjs +git commit -m "refactor(secretary): parseLastExchange — обёртка над assembleExchanges (нет дубля логики)" +``` + +--- + +## Phase 2 — Ярлычки сырья и границы (`secretary-layer1.mjs`) + +### Task 4: `buildRawRecord` — ярлычки `cont=1` и `tail=1` + +**Files:** +- Modify: `tools/secretary-layer1.mjs:18-31` +- Test: `tools/secretary-layer1.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `tools/secretary-layer1.test.mjs` (в describe «метка служебного хода»): + +```js + it('buildRawRecord: продолжение помечается cont=1, незавершённый хвост — tail=1', () => { + const cont = buildRawRecord({ turn: 5, time: 't', session: 's', user: 'продолжи', assistant: 'a', isContinuation: true }); + expect(cont).toMatch(/=== ХОД turn=5[^\n]*cont=1[^\n]*===/); + const tail = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'задача', assistant: 'a', interruptedTail: true }); + expect(tail).toMatch(/=== ХОД turn=6[^\n]*tail=1[^\n]*===/); + }); + it('buildRawRecord: meta+cont вместе — оба ярлычка в заголовке', () => { + const rec = buildRawRecord({ turn: 7, time: 't', session: 's', user: 'u', assistant: 'a', userIsMeta: true, isContinuation: true }); + expect(rec).toMatch(/meta=1/); + expect(rec).toMatch(/cont=1/); + }); +``` + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-layer1.test.mjs -t "cont=1"` +Expected: FAIL (нет ярлычков). + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-layer1.mjs` заменить сигнатуру и заголовок `buildRawRecord`: + +```js +export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false, isContinuation = false, interruptedTail = false } = {}) { + const acts = Array.isArray(actions) ? actions : []; + // Структурные ярлычки хода в заголовке: meta=1 служебный, cont=1 продолжение после обрыва, + // tail=1 прерван-и-не-завершён. Границы спанов читают их структурно (не по тексту реплики). + const marks = [userIsMeta ? 'meta=1' : '', isContinuation ? 'cont=1' : '', interruptedTail ? 'tail=1' : ''] + .filter(Boolean).map((m) => ` · ${m}`).join(''); + const lines = [`=== ХОД turn=${turn} · ${time} · session=${session}${marks} ===`, + '[ЮЗЕР]', neutralizeMarkers(user), '[АССИСТЕНТ]', neutralizeMarkers(assistant)]; + for (const a of acts) { + lines.push(`[ДЕЙСТВИЕ] ${a.tool} in=${neutralizeMarkers(a.input ?? '')}`); + lines.push(`[ВЫДАЧА] ${a.tool}`, neutralizeMarkers(a.result ?? '')); + } + lines.push('=== КОНЕЦ ХОДА ===', ''); + return lines.join('\n'); +} +``` + +- [ ] **Step 4: Прогнать ВСЕ тесты layer1 — PASS (регресс meta=1 цел)** + +Run: `npx vitest run tools/secretary-layer1.test.mjs` +Expected: PASS (включая прежние `meta=1` и «обычный ход — без meta=1»). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-layer1.mjs tools/secretary-layer1.test.mjs +git commit -m "feat(secretary): ярлычки cont=1/tail=1 в заголовке хода сырья" +``` + +--- + +### Task 5: `realBoundariesFromRaw` — исключить `cont=1` из границ + +**Files:** +- Modify: `tools/secretary-layer1.mjs:61-69` +- Test: `tools/secretary-layer1.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +```js + it('realBoundariesFromRaw: ход-продолжение (cont=1) НЕ граница (склеивается к прошлой просьбе)', () => { + const raw = [ + buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба', assistant: 'a' }), + buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'b', isContinuation: true }), + buildRawRecord({ turn: 5, time: 't', session: 's', user: 'новая просьба', assistant: 'c' }), + ].join(''); + expect(realBoundariesFromRaw(raw)).toEqual([3, 5]); // ход 4 (cont) приклеен к спану 3 + }); +``` + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-layer1.test.mjs -t "cont=1.*НЕ граница"` +Expected: FAIL — вернёт `[3, 4, 5]`. + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-layer1.mjs` в `realBoundariesFromRaw` добавить проверку `cont=1`: + +```js +export function realBoundariesFromRaw(rawText) { + return splitRawIntoTurns(rawText).filter(({ block }) => { + const header = (block.match(/=== ХОД turn=\d+[^\n]*===/) || [''])[0]; + if (/·\s*meta=1/.test(header)) return false; // структурно служебный + if (/·\s*cont=1/.test(header)) return false; // продолжение после обрыва — не граница + const um = block.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/); + const u = (um ? um[1] : '').trim(); + return !/^Stop hook feedback/i.test(u) && !/^Base directory for this skill/i.test(u); // фолбэк по тексту + }).map((p) => p.turn); +} +``` + +- [ ] **Step 4: Прогнать ВСЕ тесты layer1 — PASS** + +Run: `npx vitest run tools/secretary-layer1.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-layer1.mjs tools/secretary-layer1.test.mjs +git commit -m "feat(secretary): cont=1 исключается из границ спанов (продолжение не дробит работу)" +``` + +--- + +## Phase 3 — Честные пометки спана (`secretary-layer1.mjs` + `secretary-distill.mjs`) + +### Task 6: `spanInterruptNote` + `buildStepLine(note)` + проводка в `buildStepsFromRaw` + +**Files:** +- Modify: `tools/secretary-layer1.mjs` (`buildStepLine`, `buildStepsFromRaw`, новый `spanInterruptNote`) +- Test: `tools/secretary-layer1.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { spanInterruptNote } from './secretary-layer1.mjs'; + +describe('честные пометки прерванного спана', () => { + const rawCont = [ + buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба длинная', assistant: 'начал' }), + buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'докончил', isContinuation: true }), + ].join(''); + const rawTail = [ + buildRawRecord({ turn: 7, time: 't', session: 's', user: 'большая задача длинная', assistant: 'часть', interruptedTail: true }), + ].join(''); + + it('spanInterruptNote: спан с cont → «продолжено»', () => { + expect(spanInterruptNote(rawCont, { start: 3, end: 4 })).toBe('(связь прерывалась — продолжено)'); + }); + it('spanInterruptNote: спан с tail → «прервана, не завершена»', () => { + expect(spanInterruptNote(rawTail, { start: 7, end: 7 })).toBe('(прервана, не завершена)'); + }); + it('spanInterruptNote: обычный спан → пусто', () => { + const raw = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'обычный длинный вопрос', assistant: 'ок' }); + expect(spanInterruptNote(raw, { start: 1, end: 1 })).toBe(''); + }); + it('buildStepLine с note приклеивает пометку в конец', () => { + const s = buildStepLine({ turn: 3, endTurn: 4, user: 'просьба длинная достаточно', assistant: 'ок', note: '(связь прерывалась — продолжено)' }); + expect(s.endsWith('(связь прерывалась — продолжено)')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-layer1.test.mjs -t "честные пометки"` +Expected: FAIL — `spanInterruptNote is not a function` / note игнорируется. + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-layer1.mjs`: + +(а) добавить `spanInterruptNote` (рядом с `realBoundariesFromRaw`): + +```js +// Честная пометка спана по структурным ярлычкам ходов в нём: tail (прервана-не-завершена) +// приоритетнее cont (продолжено). Без ярлычков — пусто. +export function spanInterruptNote(rawText, { start, end }) { + const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end); + const headers = blocks.map((b) => (b.block.match(/=== ХОД turn=\d+[^\n]*===/) || [''])[0]); + if (headers.some((h) => /·\s*tail=1/.test(h))) return '(прервана, не завершена)'; + if (headers.some((h) => /·\s*cont=1/.test(h))) return '(связь прерывалась — продолжено)'; + return ''; +} +``` + +(б) в `buildStepLine` принять `note` и приклеить в конец (после `span`): + +```js +export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null, note = '' } = {}) { +``` + +…и в конце функции заменить `return`: + +```js + const span = (endTurn != null && endTurn > turn) ? ` [вобрал ходы ${turn}-${endTurn}]` : ''; + const tail = note ? ` · ${note}` : ''; + return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}${tail}`; +} +``` + +(в) в `buildStepsFromRaw` проставить note из `spanInterruptNote` (в `return spans.map(...)`, в формировании `text`): + +```js + return { turn: start, session, + text: buildStepLine({ turn: start, endTurn: end, user: um ? um[1] : '', assistant: aAll, actions, + note: spanInterruptNote(rawText, { start, end }) }) }; +``` + +- [ ] **Step 4: Прогнать ВСЕ тесты layer1 — PASS** + +Run: `npx vitest run tools/secretary-layer1.test.mjs` +Expected: PASS (прежние `buildStepLine`-тесты без note — `tail` пустой, формат тот же). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-layer1.mjs tools/secretary-layer1.test.mjs +git commit -m "feat(secretary): честные пометки спана (продолжено / прервана-не-завершена)" +``` + +--- + +### Task 7: Проводка `note` в живой разбор (`secretary-distill.mjs`) + +**Files:** +- Modify: `tools/secretary-distill.mjs:12-28` +- Test: `tools/secretary-distill.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `tools/secretary-distill.test.mjs`: + +```js +import { distillSpan } from './secretary-distill.mjs'; +import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; + +describe('distillSpan — честная пометка спана в шаге (без модели)', () => { + it('передаёт note в шаг, когда спан помечен продолжением', async () => { + const proto = EMPTY_PROTOCOL(); + const spanEx = { user: 'настоящая просьба длинная', assistant: 'докончил', actions: [] }; + const out = await distillSpan(proto, spanEx, { start: 3, end: 4, note: '(связь прерывалась — продолжено)' }, { callModel: null }); + const step = out.steps.find((s) => s.turn === 3); + expect(step.text.endsWith('(связь прерывалась — продолжено)')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-distill.test.mjs -t "честная пометка"` +Expected: FAIL — note не доходит до шага. + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-distill.mjs` принять `note` из дескриптора спана и передать в `buildStepLine`: + +```js +export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag } = {}) { +``` + +…и в формировании `step`: + +```js + const step = { turn: start, session, + text: buildStepLine({ turn: start, endTurn: end, user: spanEx.user, assistant: spanEx.assistant, + actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep, note }) }; +``` + +- [ ] **Step 4: Прогнать тесты distill — PASS** + +Run: `npx vitest run tools/secretary-distill.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-distill.mjs tools/secretary-distill.test.mjs +git commit -m "feat(secretary): distillSpan проводит честную пометку обрыва в шаг" +``` + +--- + +## Phase 4 — Пересборка сырья из транскрипта (`secretary-transcript.mjs` + `secretary-stop-hook.mjs`) + +### Task 8: `buildRawFromExchanges` + `rebuildRawFromTranscript` + +**Files:** +- Modify: `tools/secretary-transcript.mjs` (импорт `buildRawRecord`, новые функции) +- Test: `tools/secretary-transcript.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { rebuildRawFromTranscript } from './secretary-transcript.mjs'; +import { realBoundariesFromRaw } from './secretary-layer1.mjs'; + +describe('rebuildRawFromTranscript — пересборка сырья (источник = транскрипт)', () => { + it('сбой API + продолжи → 2 хода, ход-продолжение помечен cont=1, граница одна', () => { + const t = [ + { message: { role: 'user', content: 'настоящая просьба' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'начал' }] } }, + { isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } }, + { message: { role: 'user', content: 'продолжи' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const raw = rebuildRawFromTranscript(t, { session: 's' }); + expect((raw.match(/=== ХОД turn=/g) || []).length).toBe(2); + expect(raw).toMatch(/=== ХОД turn=2[^\n]*cont=1/); + expect(realBoundariesFromRaw(raw)).toEqual([1]); // одна логическая работа, не «продолжи» + }); + it('пустой транскрипт → пустое сырьё', () => { + expect(rebuildRawFromTranscript('', { session: 's' })).toBe(''); + }); + it('sanitize применяется к каждой записи', () => { + const t = JSON.stringify({ message: { role: 'user', content: 'СЕКРЕТ' } }); + const raw = rebuildRawFromTranscript(t, { session: 's', sanitize: (x) => x.replace('СЕКРЕТ', '[вырезано]') }); + expect(raw).toContain('[вырезано]'); + expect(raw).not.toContain('СЕКРЕТ'); + }); +}); +``` + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-transcript.test.mjs -t rebuildRawFromTranscript` +Expected: FAIL — `rebuildRawFromTranscript is not a function`. + +- [ ] **Step 3: Реализовать минимально** + +В начало `tools/secretary-transcript.mjs` добавить импорт: + +```js +import { buildRawRecord } from './secretary-layer1.mjs'; +``` + +В конец файла добавить: + +```js +// Сырьё (Слой 1) из готовых обменов: ход = индекс обмена. Каждая запись санируется (PII) перед склейкой. +export function buildRawFromExchanges(exchanges, { session, sanitize = (x) => x } = {}) { + const recs = exchanges.map((ex, i) => sanitize(buildRawRecord({ + turn: i + 1, time: ex.time || '', session, + user: ex.user, assistant: ex.assistant, actions: ex.actions, + userIsMeta: ex.userIsMeta, isContinuation: ex.isContinuation, interruptedTail: ex.interruptedTail, + }))); + return recs.map((r) => r + '\n').join(''); +} + +// Полная пересборка сырья из текста транскрипта (источник правды переживает обрывы). +export function rebuildRawFromTranscript(transcriptText, { session, sanitize } = {}) { + return buildRawFromExchanges(assembleExchanges(transcriptText), { session, sanitize }); +} +``` + +> ⚠️ `secretary-layer1.mjs` импортирует `computeSpans` из `secretary-span.mjs`, а `secretary-span.mjs` импортирует `splitRawIntoTurns` из `secretary-layer1.mjs` (существующий цикл, рабочий). Новый импорт `secretary-transcript → secretary-layer1` односторонний (layer1 транскрипт не импортирует) — цикла не добавляет. + +- [ ] **Step 4: Прогнать ВСЕ тесты транскрипта — PASS** + +Run: `npx vitest run tools/secretary-transcript.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-transcript.mjs tools/secretary-transcript.test.mjs +git commit -m "feat(secretary): rebuildRawFromTranscript — сырьё пересобирается из всего транскрипта" +``` + +--- + +### Task 9: Stop-хук пересобирает сырьё (overwrite) + note в живой разбор + +**Files:** +- Modify: `tools/secretary-stop-hook.mjs:10-55`, `tools/secretary-stop-hook.mjs:106-112` + +> Хук не покрыт unit-тестами (склейка). Логика проверена в Task 1–8 как чистые функции. Здесь — интеграция. + +- [ ] **Step 1: Заменить захват и запись сырья** + +В `tools/secretary-stop-hook.mjs` импорт (строка ~18) дополнить: + +```js +import { parseLastExchange, assembleExchanges, buildRawFromExchanges } from './secretary-transcript.mjs'; +``` + +Заменить блок «Слой 1: всегда пишем сырьё» ([secretary-stop-hook.mjs:44-55](../../../tools/secretary-stop-hook.mjs#L44-L55)) на: + +```js + const ex = assembleExchanges(transcript); + const turn = ex.length; + + // Слой 1: ВСЕГДА пересобираем сырьё из всего транскрипта (переживает обрывы; PII вырезается). + try { + const rawContent = buildRawFromExchanges(ex, { session, sanitize }); + mkdirSync(join(secdir, 'raw'), { recursive: true }); + writeFileAtomic(rawFile, rawContent); + } catch { /* fail-quiet */ } +``` + +(Удалить прежние `const ex = parseLastExchange(...)`, `const turn = turnCount(rawFile) + 1;` и append-блок. Импорт `writeFileAtomic` уже есть; `appendFileSync` для сырья больше не нужен — оставить, если используется `_reconcile.log`-логикой ниже: да, `appendFileSync` используется для `reLog` — импорт НЕ удалять.) + +- [ ] **Step 2: Провести `note` в живой разбор спанов** + +В цикле разбора ([secretary-stop-hook.mjs:108-112](../../../tools/secretary-stop-hook.mjs#L108-L112)) добавить вычисление note. Сначала импорт (строка ~12) дополнить `spanInterruptNote`: + +```js +import { buildRawRecord, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles, spanInterruptNote } from './secretary-layer1.mjs'; +``` + +Цикл: + +```js + for (const span of list) { + const spanEx = assembleSpan(rawText, span); + const note = spanInterruptNote(rawText, span); + proto = await distillSpan(proto, { ...spanEx }, { ...span, note }, { callModel, session, diag: logReason }); + lastIndex = span.index; + } +``` + +> `distillSpan` принимает `{start,end,note}` (Task 7); `spanEx` без изменений. Параметр `note` берётся из ярлычков `cont=1`/`tail=1` пересобранного сырья. + +- [ ] **Step 3: Дымовой прогон хука вручную (без падения)** + +Run: +```bash +echo '{"session_id":"smoke","transcript_path":"NONEXISTENT"}' | node tools/secretary-stop-hook.mjs; echo "exit=$?" +``` +Expected: `exit=0` (fail-quiet на отсутствующем транскрипте; сырьё пустое). + +- [ ] **Step 4: Полный свод секретаря — зелёный** + +Run: +```bash +npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-span.test.mjs tools/secretary-distill.test.mjs +``` +Expected: PASS (все 11 файлов + новые тесты). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-stop-hook.mjs +git commit -m "feat(secretary): stop-хук пересобирает сырьё из транскрипта + честные пометки обрыва" +``` + +--- + +## Phase 5 — Догон после жёсткого краха (между сессиями) + +### Task 10: `secretary-sessions.mjs` — учёт сессий дела + +**Files:** +- Create: `tools/secretary-sessions.mjs` +- Create: `tools/secretary-sessions.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +`tools/secretary-sessions.test.mjs`: + +```js +import { describe, it, expect } from 'vitest'; +import { upsertSessionPointer, prevSessionsForCatchUp } from './secretary-sessions.mjs'; + +describe('secretary-sessions — учёт сессий дела', () => { + it('upsertSessionPointer добавляет новую сессию с курсором', () => { + const out = upsertSessionPointer([], { session: 's1', cursor: 2 }); + expect(out).toEqual([{ session: 's1', cursor: 2 }]); + }); + it('upsertSessionPointer обновляет курсор существующей', () => { + const out = upsertSessionPointer([{ session: 's1', cursor: 1 }], { session: 's1', cursor: 5 }); + expect(out).toEqual([{ session: 's1', cursor: 5 }]); + }); + it('prevSessionsForCatchUp — все сессии дела кроме текущей', () => { + const list = [{ session: 's1', cursor: 3 }, { session: 's2', cursor: 0 }]; + expect(prevSessionsForCatchUp(list, 's2')).toEqual([{ session: 's1', cursor: 3 }]); + }); + it('prevSessionsForCatchUp — пустой список → пусто', () => { + expect(prevSessionsForCatchUp([], 's2')).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-sessions.test.mjs` +Expected: FAIL — модуля нет. + +- [ ] **Step 3: Реализовать минимально** + +`tools/secretary-sessions.mjs`: + +```js +// Учёт «какие сессии вели дело» — для догона недоразобранного хвоста умершей сессии после краха. +// Хранится в папке дела (docs/secretary//_sessions.json), НЕ коммитится. + +/** Добавить/обновить указатель сессии {session, cursor}. cursor — последний разобранный spanCursor. */ +export function upsertSessionPointer(list, { session, cursor }) { + const out = (Array.isArray(list) ? list : []).map((e) => ({ ...e })); + const i = out.findIndex((e) => e.session === session); + if (i >= 0) out[i].cursor = cursor; + else out.push({ session, cursor }); + return out; +} + +/** Прошлые сессии дела (кроме текущей) — кандидаты на догон хвоста. */ +export function prevSessionsForCatchUp(list, currentSession) { + return (Array.isArray(list) ? list : []).filter((e) => e && e.session && e.session !== currentSession); +} +``` + +- [ ] **Step 4: Прогнать — PASS** + +Run: `npx vitest run tools/secretary-sessions.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-sessions.mjs tools/secretary-sessions.test.mjs +git commit -m "feat(secretary): secretary-sessions — учёт сессий дела для догона" +``` + +--- + +### Task 11: prompt-хук ставит `catchUp` при активации дела + +**Files:** +- Modify: `tools/secretary-prompt-hook.mjs` (`planActivation`, `main`) +- Test: `tools/secretary-prompt-hook.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `tools/secretary-prompt-hook.test.mjs`: + +```js + it('planActivation проставляет catchUp из прошлых сессий дела', () => { + const plan = planActivation({ + requested: 'наставник', existing: ['наставник'], startedAtTurn: 0, session: 's2', + sessionsOfCase: [{ session: 's1', cursor: 2 }, { session: 's2', cursor: 0 }], + }); + expect(plan.confirm).toBe(false); + expect(plan.flag.catchUp).toEqual([{ session: 's1', cursor: 2 }]); + }); + it('planActivation без прошлых сессий — catchUp пустой', () => { + const plan = planActivation({ requested: 'новое', existing: [], session: 's1', sessionsOfCase: [] }); + expect(plan.flag.catchUp).toEqual([]); + }); +``` + +> Если в файле нет импорта `planActivation` — он уже экспортируется из модуля; проверить импорт в шапке теста. + +- [ ] **Step 2: Прогнать — FAIL** + +Run: `npx vitest run tools/secretary-prompt-hook.test.mjs -t "catchUp"` +Expected: FAIL — `catchUp` не проставляется. + +- [ ] **Step 3: Реализовать минимально** + +В `tools/secretary-prompt-hook.mjs` импорт дополнить: + +```js +import { prevSessionsForCatchUp } from './secretary-sessions.mjs'; +``` + +`planActivation` — принять `sessionsOfCase` и положить `catchUp` в флажок: + +```js +export function planActivation({ requested, existing = [], startedAtTurn = 0, session, sessionsOfCase = [] } = {}) { + const res = resolveCaseActivation(requested, existing); + if (res.action === 'confirm') { + const context = `📒 Секретарь: имя дела «${requested}» похоже на существующее: ${res.candidates.join(', ')}.\n` + + 'Если это оно — повтори: «включи секретаря <точное-имя>». ' + + 'Если новое дело — повтори с именем, не совпадающим с этими.'; + return { confirm: true, candidates: res.candidates, context }; + } + return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session, + catchUp: prevSessionsForCatchUp(sessionsOfCase, session) } }; +} +``` + +В `main()` — прочитать `_sessions.json` дела и передать в `planActivation`. После `const requested = ...` (ветка `cmd === 'on'`): + +```js + const res0 = resolveCaseActivation(requested, listCases(secdir)); + let sessionsOfCase = []; + try { + const sf = join(secdir, res0.work || requested, '_sessions.json'); + if (existsSync(sf)) sessionsOfCase = JSON.parse(readFileSync(sf, 'utf-8')); + } catch { sessionsOfCase = []; } + const plan = planActivation({ + requested, existing: listCases(secdir), + startedAtTurn: turnCount(rawFile), session, sessionsOfCase, + }); +``` + +> `resolveCaseActivation` уже импортируется в prompt-хуке. `existsSync`/`readFileSync` уже импортированы. + +- [ ] **Step 4: Прогнать тесты prompt-хука — PASS** + +Run: `npx vitest run tools/secretary-prompt-hook.test.mjs` +Expected: PASS (прежние `planActivation`-тесты — `catchUp:[]` по умолчанию, не ломает их проверки на `mode/work/session`). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-prompt-hook.mjs tools/secretary-prompt-hook.test.mjs +git commit -m "feat(secretary): prompt-хук ставит catchUp прошлых сессий при активации дела" +``` + +--- + +### Task 12: Stop-хук обновляет `_sessions.json` и выполняет догон + +**Files:** +- Modify: `tools/secretary-stop-hook.mjs` + +> Интеграция; чистая логика догона переиспользует `assembleExchanges`/`buildRawFromExchanges`/`computeSpans`/`spansToDistill`/`assembleSpan`/`distillSpan` (все протестированы). + +- [ ] **Step 1: Импорты и хелпер локатора транскрипта** + +В `tools/secretary-stop-hook.mjs` импорт дополнить: + +```js +import { dirname } from 'node:path'; +import { upsertSessionPointer } from './secretary-sessions.mjs'; +``` + +> `join` уже импортирован из `node:path`; добавить `dirname` к нему: `import { join, dirname } from 'node:path';` + +- [ ] **Step 2: Догон прошлых сессий перед разбором текущей** + +В `main()`, сразу после чтения `flag` и определения `work`/`workDir`/`proto` (после строки с `proto`), добавить блок догона: + +```js + // Догон после жёсткого краха: разобрать недоразобранный хвост прошлых сессий этого дела. + if (Array.isArray(flag.catchUp) && flag.catchUp.length && apiKey) { + const projDir = tp ? dirname(tp) : null; + for (const prev of flag.catchUp) { + if (!projDir || !prev || !prev.session) continue; + const prevTp = join(projDir, `${prev.session}.jsonl`); + let prevTranscript = ''; + try { if (existsSync(prevTp)) prevTranscript = readFileSync(prevTp, 'utf-8'); } catch { prevTranscript = ''; } + if (!prevTranscript) continue; + const prevRaw = buildRawFromExchanges(assembleExchanges(prevTranscript), { session: prev.session, sanitize }); + const prevRawFile = join(secdir, 'raw', `${prev.session}.log`); + try { writeFileAtomic(prevRawFile, prevRaw); } catch { /* ignore */ } + const prevBounds = realBoundariesFromRaw(prevRaw); + const prevLast = (prevRaw.match(/=== ХОД turn=/g) || []).length; + for (const span of spansToDistill(prevBounds, prevLast, Number.isFinite(prev.cursor) ? prev.cursor : -1)) { + const spanEx = assembleSpan(prevRaw, span); + const note = spanInterruptNote(prevRaw, span); + proto = await distillSpan(proto, { ...spanEx }, { ...span, note }, { callModel, session: prev.session, diag: logReason }); + } + } + // Догон выполнен — снять пометку (прочие поля флажка целы). + writeFlag(session, { ...readFlag(session), catchUp: [] }); + } +``` + +> `callModel`/`logReason` определяются ниже по коду — перенести блок догона ПОСЛЕ их объявления (после `const callModel = …` и `const logReason = …`), но ДО цикла разбора текущих спанов. `spansToDistill` импортировать из `secretary-span.mjs` (добавить к существующему импорту `computeSpans, spansToDistill, assembleSpan`). + +- [ ] **Step 3: Обновлять указатель сессии дела после разбора** + +В ветке «обычный ход» (где `writeFlag(session, { ...readFlag(session), spanCursor: lastIndex })`) добавить запись `_sessions.json`: + +```js + } else { + writeFlag(session, { ...readFlag(session), spanCursor: lastIndex }); + // Указатель сессии дела (для догона будущих сессий после краха). + try { + const sf = join(workDir, '_sessions.json'); + let arr = []; + try { if (existsSync(sf)) arr = JSON.parse(readFileSync(sf, 'utf-8')); } catch { arr = []; } + writeFileAtomic(sf, JSON.stringify(upsertSessionPointer(arr, { session, cursor: lastIndex }))); + } catch { /* указатель вторичен */ } + } +``` + +- [ ] **Step 4: Дымовой прогон + полный свод** + +Run: +```bash +echo '{"session_id":"smoke2","transcript_path":"NONEXISTENT"}' | node tools/secretary-stop-hook.mjs; echo "exit=$?" +npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-span.test.mjs tools/secretary-distill.test.mjs tools/secretary-sessions.test.mjs +``` +Expected: `exit=0`; vitest PASS (12 файлов). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-stop-hook.mjs +git commit -m "feat(secretary): догон хвоста прошлых сессий + указатель _sessions.json" +``` + +--- + +## Phase 6 — Живая приёмка + +### Task 13: Живой прогон с реальным обрывом + +**Files:** нет правок кода — приёмка. + +- [ ] **Step 1: Полный свод зелёный (12 файлов)** + +Run: +```bash +npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-span.test.mjs tools/secretary-distill.test.mjs tools/secretary-sessions.test.mjs +``` +Expected: PASS, exit 0. + +- [ ] **Step 2: Живой прогон (а) — сбой API + «продолжи»** + +С реальным `SECRETARY_LLM_KEY` (aitunnel, `SECRETARY_LLM_MODEL`): включить секретаря на тестовое дело, спровоцировать/сымитировать обрыв API в середине работы, затем «продолжи» до конца. Убедиться в `docs/secretary/<дело>/protocol.md`: +- в «Шагах» стоит НАСТОЯЩИЙ промпт владельца (а не «продолжи»); +- спан помечен «(связь прерывалась — продолжено)»; +- одна работа НЕ раздроблена на два шага. + +- [ ] **Step 3: Живой прогон (б) — ручной стоп** + +Прервать работу вручную (Esc), затем продолжить. Убедиться: то же склеивание + честная пометка. + +- [ ] **Step 4: Сверка полноты** + +Grep по транскрипту тестовой сессии (Read закрыт) ↔ `protocol.md` / `raw/.log`: ничего из работы/диалога не потеряно. + +- [ ] **Step 5: Доклад владельцу** + +Показать `protocol.md` тестового дела и результаты сверки; получить «принято» перед финальным коммитом/пушем. + +--- + +## Self-Review (выполнено при написании плана) + +**Покрытие спеки:** +- §C метки (`isMeta`/`isApiErrorMessage`/`[Request interrupted by user…]` обе формы/`isCompactSummary`) → Task 1. +- §D1 источник=транскрипт, пересборка → Task 8, 9. +- §D2 сегментация, метки не границы → Task 2, 5. +- §D3 продолжение без слов + пометки → Task 2, 5, 6, 7. +- §D4/§D5 догон между сессиями + указатель → Task 10, 11, 12. +- §G тесты на каждый вид обрыва → Task 2 (api/stop/tail/summary/meta), Task 8 (пересборка), Task 12 (догон). +- §H приёмка → Task 13. + +**Плейсхолдеры:** нет — в каждом шаге реальный код/команда. + +**Согласованность имён:** `assembleExchanges`/`classifyEntry`/`buildRawFromExchanges`/`rebuildRawFromTranscript`/`spanInterruptNote`/`upsertSessionPointer`/`prevSessionsForCatchUp`; поля обмена `isContinuation`/`interruptedTail`/`userIsMeta`/`time`; ярлычки `meta=1`/`cont=1`/`tail=1`; флажок `catchUp`/`spanCursor` — единообразны во всех задачах. diff --git a/docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md b/docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md new file mode 100644 index 0000000..14083e2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md @@ -0,0 +1,173 @@ +# Финальная спека: секретарь — устойчивость к обрывам (источник = транскрипт) + +> **Финал брейншторма** поверх хендофф-направления `2026-06-23-secretary-interruption-resilience-design.md`. +> Все открытые вопросы §6 хендоффа закрыты с владельцем (см. §A). Все машинные метки проверены Grep'ом +> по реальным транскриптам — строки указаны. Дисциплина дальше: `writing-plans` → TDD. +> Режим: штатный (стен нет; пол + проверка-перед-пушем). Ветка `main`, удалёнка `gitea`. Тетради дел НЕ коммитить. + +--- + +## A. Закрытые решения владельца (источник правды по спорным точкам) + +| Вопрос (§6 хендоффа) | Решение владельца | +|---|---| +| 6.1 источник правды | **Транскрипт — главный.** На каждом завершении ответа сырьё (Слой 1) пересобирается ИЗ ВСЕГО транскрипта, а не дописывается по последнему обмену. | +| 6.2 продолжение/отмена/уточнение | **Один склеенный кусок + честная пометка.** Модель смысл обрыва НЕ решает — всё механически, по меткам. | +| 6.3 крах между сессиями | **Догон при «включи секретаря `<дело>`».** Не на каждом старте; запуск — действием владельца, привязка по имени дела. | +| 6.4 связь со сделанным | Нарезка по спанам, `distillSpan`, `realBoundariesFromRaw` (meta=1) **остаются**; над ними встаёт транскрипт-сборщик. | +| 6.5 перестройка сырья | **Да** — сырьё всегда полно перестраивается из транскрипта, прерванные ходы не теряются. | +| 6.6 объём меток | Список ниже (§C), **проверен на реальных транскриптах**. | +| Правило после РУЧНОГО стопа | **Считать продолжением (склеивать).** Следующая реплика после любого обрыва (сбой API ИЛИ ручной стоп) по умолчанию = продолжение прежней работы. | +| §8 связанные дела | **Вне этой задачи** (контекст другой сессии). Записаны в §H, не реализуются. | + +--- + +## B. Проблема и корень (доказано) + +Секретарь захватывает работу на **Stop** (конец ответа) через `parseLastExchange` +([secretary-transcript.mjs:40](../../../tools/secretary-transcript.mjs)). При обрыве Stop хрупок: + +- **Корень потери промпта.** Ручной стоп лежит в транскрипте как сообщение с ролью `user` и текстовым блоком: + ```json + {"type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]}} + ``` + (`6cead3ab` строки 334, 468, 1465). Текущий `isRealUserPrompt` ([secretary-transcript.mjs:18](../../../tools/secretary-transcript.mjs#L18)) + принимает **любой** user-текст за настоящий промпт → метка-обрыв (и «продолжи» после сбоя) крадёт границу + настоящей просьбы владельца. Одна работа дробится на «я: продолжи». +- **Единственное, что переживает все обрывы, — файл транскрипта на диске** (`~/.claude/projects//.jsonl`), + Claude Code пишет его по ходу. Значит источник правды — транскрипт, а не Stop/сырьё. + +--- + +## C. Машинные метки (проверено Grep'ом — НЕ по памяти) + +Сборщик обязан распознавать и НЕ принимать за настоящий промпт владельца: + +| Метка | Где на записи | Смысл | Доказательство (файл:строка) | +|---|---|---|---| +| `entry.isMeta === true` | верхний уровень записи | служебный ход (гейт/навык/контекст) | `ff1d4618` — 5 шт.; уже используется (`userIsMeta`) | +| `entry.isApiErrorMessage === true` | запись с `role:"assistant"`, текст `"API Error: …"` | сбой API, ответ лопнул | `ff1d4618:534` (без статуса), `ff1d4618:577` (`apiErrorStatus:529`) | +| текст user-блока `[Request interrupted by user]` **или** `[Request interrupted by user for tool use]` | `role:"user"`, `content[].type:"text"` | ручной стоп (ДВЕ формы!) | `6cead3ab:334`; `240e3972:530` и `42e79641:225` (форма «for tool use») | +| `entry.isCompactSummary === true` (+ `isVisibleInTranscriptOnly:true`) | служебная запись | выжимка при сжатии контекста | `69992620:4929`, `aca79163` | + +**Важно:** `apiErrorStatus` присутствует НЕ всегда (на `ff1d4618:534` его нет) → опираться на `isApiErrorMessage`, +статус — только доп-инфо. Детект ручного стопа матчить по **префиксу** `[Request interrupted by user` +(чтобы покрыть обе формы и будущие вариации хвоста). + +--- + +## D. Решение + +### D1. Источник = транскрипт; сырьё пересобирается целиком +На каждом завершении ответа (Stop) секретарь: +1. читает **весь** транскрипт сессии (через `node:fs` в хуке — Read-инструмент закрыт, fs работает); +2. собирает из него последовательность **обменов** (ход = настоящий промпт владельца → ответ → действия), + распознавая метки §C: служебные/метки-обрывы/выжимки **не** считаются настоящим промптом; +3. перезаписывает сырьё (`docs/secretary/raw/.log`) этой пересборкой (PII вырезается перед записью, + как сейчас — `sanitize`). + +Сырьё всегда полное → ни один ход (включая прерванный) не пропадает. Нумерация ходов стабильна +(транскрипт append-only). Нарезка по спанам (`computeSpans`/`realBoundariesFromRaw`) и разбор (`distillSpan`) +работают поверх честного сырья **без изменения логики**. + +### D2. Сегментация обменов и метки-обрывы +При сборке обменов: +- **Границей нового обмена** служит только настоящий промпт владельца (не meta, не метка-обрыв, не выжимка). +- Запись `isApiErrorMessage` (assistant) и `[Request interrupted by user…]` (user) — **не границы**; они + поглощаются текущим обменом как факт обрыва (помечают, что работа прерывалась). +- `isMeta` и `isCompactSummary` — пропускаются как служебные (не границы, не работа владельца). + +### D3. Правило продолжения (структурно, БЕЗ слов) +**Настоящий промпт владельца, идущий сразу после метки-обрыва** (сбой API ИЛИ ручной стоп), **без +завершённого ответа между ними**, — это **продолжение**: он НЕ открывает новый спан, его работа склеивается +со спаном предыдущей настоящей просьбы. Распознаётся по позиции относительно метки, **не по тексту реплики** +(словарь «продолжалок» отвергнут владельцем). + +- Реализация: ход-продолжение метится в сырье отдельным ярлычком (по образцу `meta=1`, напр. `cont=1`), + `realBoundariesFromRaw` исключает его из границ — как и meta. Спан настоящей просьбы поглощает ходы-продолжения. +- В протоколе спан получает честную пометку: **«(связь прерывалась — продолжено)»**. +- Хвост, прерванный и **не** продолженный (на момент краха), помечается **«(прервана, не завершена)»** — + обрывок не выдаётся за готовую работу. + +### D4. Догон после жёсткого краха (между сессиями) +Жёсткий крах (свет/закрыл VS Code) машинной метки не оставляет; старая сессия умирает, новая получает другой +транскрипт. Догон — при **«включи секретаря `<дело>`»**: +1. секретарь по имени дела находит **прошлую сессию(и)** этого дела (указатель сессий хранится в папке дела — + см. D5); +2. находит её транскрипт `dirname(currentTranscriptPath)/.jsonl`; +3. пересобирает её сырьё из транскрипта (теперь уже с хвостом — D1) и доразбирает недоразобранные спаны + (по курсору) в тетрадь дела через тот же `distillSpan`. + +Граница честности (§2.5 хендоффа): хвост появляется не мгновенно, а этим догоном на следующем включении дела. + +### D5. Привязка сессии к делу +Чтобы догон знал, чей транскрипт перечитывать, в **папке дела** ведётся указатель сессий +(напр. `docs/secretary//_sessions.json`: список `{session, lastSpanCursor}`), обновляемый stop-хуком +на каждом ходе, пока дело активно. На re-активации этот указатель → список прошлых сессий для догона. +Сам прогон догона (вызовы модели) делает **stop-хук** на первом Stop после активации (prompt-хук лишь ставит +пометку «догнать сессии X»), чтобы вся LLM-машинерия осталась в одном месте. + +--- + +## E. Контракт (что должно стать правдой) + +- `parseLastExchange`/новый ассемблер распознаёт `isApiErrorMessage`, `isCompactSummary` и текст + `[Request interrupted by user…]`; НЕ берёт их за настоящий промпт; находит настоящие промпты как границы обменов. +- Сырьё пересобирается из всего транскрипта → прерванные ходы не теряются; настоящий промпт владельца сохранён + (он финализируется в момент отправки, до ответа). +- Ход-продолжение (после метки-обрыва) не создаёт новый спан; склеивается с предыдущей настоящей просьбой; + спан помечен «(связь прерывалась — продолжено)». +- Незавершённый прерванный хвост помечен «(прервана, не завершена)». +- Догон при re-активации дела дописывает недоразобранный хвост прошлой (умершей) сессии. +- Модель не решает смысл обрыва (детерминированно по меткам). + +--- + +## F. Карта файлов (что трогать) + +| Файл | Правка | +|---|---| +| `tools/secretary-transcript.mjs` | новый/расширенный ассемблер ВСЕГО транскрипта по обменам; классификация user-записи (настоящий промпт / meta / метка-обрыв / выжимка); флаг продолжения по позиции относительно метки. | +| `tools/secretary-stop-hook.mjs` | «пересобрать сырьё из всего транскрипта» вместо «дописать последний обмен»; обновлять указатель сессий дела (D5); выполнять догон (D4) при пометке от prompt-хука. | +| `tools/secretary-layer1.mjs` | `buildRawRecord` — ярлычок `cont=1` для хода-продолжения; `realBoundariesFromRaw` — исключать `cont=1` из границ (как `meta=1`). | +| `tools/secretary-prompt-hook.mjs` | на «включи секретаря `<дело>`» — поставить пометку «догнать прошлые сессии дела» по указателю (D5). | +| `tools/secretary-span.mjs` | без изменений логики; кормится честным сырьём. Рендер пометок «прерывалось/продолжено» и «не завершена». | +| `tools/secretary-distill.mjs` | без изменений логики (общий разбор спана). | +| `tools/secretary-protocol.mjs` (рендер) | показ честных пометок в спане. | +| `tools/secretary-*.test.mjs` | TDD-фикстуры на каждый вид обрыва (см. §G). | + +--- + +## G. Тесты (TDD — фикстуры под каждый вид обрыва) + +1. **Сбой API + продолжение:** транскрипт с `isApiErrorMessage` посередине работы и настоящим промптом-продолжением + после → один спан под настоящей первой просьбой, пометка «продолжено»; «продолжи»/реплика-продолжение НЕ + стала отдельным спаном. +2. **Ручной стоп (обе формы) + продолжение:** `[Request interrupted by user]` и `…for tool use` → то же склеивание. +3. **Прерван и не продолжен:** обрыв в конце транскрипта без продолжения → пометка «(прервана, не завершена)». +4. **Выжимка контекста:** `isCompactSummary` в транскрипте не становится ни промптом, ни работой. +5. **Служебный ход:** `isMeta` (регресс — не сломать существующее) не граница. +6. **Догон между сессиями:** сырьё прошлой сессии перестроено из её транскрипта; недоразобранный хвост дописан + в тетрадь дела на re-активации. +7. **Полнота:** пересборка сырья из транскрипта не теряет ни один ход (сверка число обменов ↔ настоящие промпты). + +--- + +## H. Приёмка + +- **Полный свод секретаря зелёный** (11 тест-файлов, команда из хендоффа §1, плюс новые тесты §G): + ``` + npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-span.test.mjs tools/secretary-distill.test.mjs + ``` +- **ЖИВОЙ прогон** с реальным `SECRETARY_LLM_KEY` (aitunnel, `SECRETARY_LLM_MODEL`), где есть обрыв: + (а) сбой API + «продолжи», (б) ручной стоп. Убедиться: настоящий промпт владельца **в тетради** (а не «продолжи»); + прерванная работа помечена честно; одна работа не раздроблена. +- Сверка: ничего из работы/диалога не потеряно (транскрипт ↔ тетрадь). + +--- + +## I. Вне области (контекст §8 хендоффа — НЕ реализуется здесь) + +Поднято в другой сессии, отдельные дела со своим брейнштормом/спекой — **не трогаем**: +- (A) дословное логирование промптов/ответов наставника/судьи/роутера (подсистема стены, не секретарь); +- (Б) качество модели секретаря (deepseek-v4-flash слабо поднимает «Решения/волю» — вопрос смены `SECRETARY_LLM_MODEL`). diff --git a/tools/secretary-distill.mjs b/tools/secretary-distill.mjs index bc8d135..0495577 100644 --- a/tools/secretary-distill.mjs +++ b/tools/secretary-distill.mjs @@ -9,7 +9,7 @@ import { buildStepLine } from './secretary-layer1.mjs'; * proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions}; * {start,end} — границы спана (в ходах сырья); opts: { callModel|null, session, diag }. * Без callModel (нет ключа) — пишется только детерминированный шаг, категории/СВ не трогаются. */ -export async function distillSpan(proto, spanEx, { start, end }, { callModel, session, diag } = {}) { +export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag } = {}) { // Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge. const svSnapshot = JSON.parse(JSON.stringify({ hidden: proto.hidden || [], acceptance: proto.acceptance || [], @@ -25,7 +25,7 @@ export async function distillSpan(proto, spanEx, { start, end }, { callModel, se if (updated && 'step' in updated) delete updated.step; const step = { turn: start, session, text: buildStepLine({ turn: start, endTurn: end, user: spanEx.user, assistant: spanEx.assistant, - actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep }) }; + actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep, note }) }; const toWrite = mergeTurnIntoProtocol({ proto, updated, step }); // Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile. diff --git a/tools/secretary-distill.test.mjs b/tools/secretary-distill.test.mjs index 4f03db9..951aaff 100644 --- a/tools/secretary-distill.test.mjs +++ b/tools/secretary-distill.test.mjs @@ -2,6 +2,16 @@ import { describe, it, expect } from 'vitest'; import { distillSpan } from './secretary-distill.mjs'; import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +describe('distillSpan — честная пометка спана в шаге (без модели)', () => { + it('передаёт note в шаг, когда спан помечен продолжением', async () => { + const proto = EMPTY_PROTOCOL(); + const spanEx = { user: 'настоящая просьба длинная', assistant: 'докончил', actions: [] }; + const out = await distillSpan(proto, spanEx, { start: 3, end: 4, note: '(связь прерывалась — продолжено)' }, { callModel: null }); + const step = out.steps.find((s) => s.turn === 3); + expect(step.text.endsWith('(связь прерывалась — продолжено)')).toBe(true); + }); +}); + describe('distillSpan — разбор одного завершённого спана (reconcile + аудит)', () => { it('добавляет шаг спана, применяет reconcile и аудит', async () => { const proto = EMPTY_PROTOCOL(); diff --git a/tools/secretary-layer1.mjs b/tools/secretary-layer1.mjs index efd11fd..bf18cff 100644 --- a/tools/secretary-layer1.mjs +++ b/tools/secretary-layer1.mjs @@ -15,12 +15,13 @@ function neutralizeMarkers(s) { // Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи; // чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат. -export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false } = {}) { +export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false, isContinuation = false, interruptedTail = false } = {}) { const acts = Array.isArray(actions) ? actions : []; - // Структурная метка служебного хода (гейт-фидбек/навык/контекст) прямо в заголовке — чтобы - // границы спанов определялись честно по ярлычку isMeta, а не угадывались по тексту/номеру. - const meta = userIsMeta ? ' · meta=1' : ''; - const lines = [`=== ХОД turn=${turn} · ${time} · session=${session}${meta} ===`, + // Структурные ярлычки хода в заголовке: meta=1 служебный, cont=1 продолжение после обрыва, + // tail=1 прерван-и-не-завершён. Границы спанов читают их структурно (не по тексту реплики). + const marks = [userIsMeta ? 'meta=1' : '', isContinuation ? 'cont=1' : '', interruptedTail ? 'tail=1' : ''] + .filter(Boolean).map((m) => ` · ${m}`).join(''); + const lines = [`=== ХОД turn=${turn} · ${time} · session=${session}${marks} ===`, '[ЮЗЕР]', neutralizeMarkers(user), '[АССИСТЕНТ]', neutralizeMarkers(assistant)]; for (const a of acts) { lines.push(`[ДЕЙСТВИЕ] ${a.tool} in=${neutralizeMarkers(a.input ?? '')}`); @@ -62,12 +63,23 @@ export function realBoundariesFromRaw(rawText) { return splitRawIntoTurns(rawText).filter(({ block }) => { const header = (block.match(/=== ХОД turn=\d+[^\n]*===/) || [''])[0]; if (/·\s*meta=1/.test(header)) return false; // структурно служебный + if (/·\s*cont=1/.test(header)) return false; // продолжение после обрыва — не граница const um = block.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/); const u = (um ? um[1] : '').trim(); return !/^Stop hook feedback/i.test(u) && !/^Base directory for this skill/i.test(u); // фолбэк по тексту }).map((p) => p.turn); } +// Честная пометка спана по структурным ярлычкам ходов в нём: tail (прервана-не-завершена) +// приоритетнее cont (продолжено). Без ярлычков — пусто. +export function spanInterruptNote(rawText, { start, end }) { + const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end); + const headers = blocks.map((b) => (b.block.match(/=== ХОД turn=\d+[^\n]*===/) || [''])[0]); + if (headers.some((h) => /·\s*tail=1/.test(h))) return '(прервана, не завершена)'; + if (headers.some((h) => /·\s*cont=1/.test(h))) return '(связь прерывалась — продолжено)'; + return ''; +} + // Пересборка Шагов из сырья ПО СПАНАМ: одна строка на реальный промпт (склейка ходов спана). // realPromptTurns — авторитетные границы; null/пусто → фолбэк по sysLabel. export function buildStepsFromRaw(rawText, session, realPromptTurns = null) { @@ -87,7 +99,8 @@ export function buildStepsFromRaw(rawText, session, realPromptTurns = null) { .filter(Boolean).join(' '); const actions = blocks.flatMap((b) => [...b.matchAll(/\[ДЕЙСТВИЕ\]\s+(\S+)/g)].map((x) => x[1])); return { turn: start, session, - text: buildStepLine({ turn: start, endTurn: end, user: um ? um[1] : '', assistant: aAll, actions }) }; + text: buildStepLine({ turn: start, endTurn: end, user: um ? um[1] : '', assistant: aAll, actions, + note: spanInterruptNote(rawText, { start, end }) }) }; }); } @@ -101,7 +114,7 @@ export function mergeStepsPreservingText(existingSteps, rawText, session, realPr // Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …». // Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены; // «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер. -export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null } = {}) { +export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null, note = '' } = {}) { // Содержательная фраза: убираем ведущую нумерацию списка («1.»/«2)»), копим до ≥25 симв., // чтобы не выдать обрывок «Стоп.»; длинное усекаем. const firstSentence = (s) => { @@ -129,7 +142,8 @@ export function buildStepLine({ turn, endTurn = null, user, assistant, actions = const a = eA || firstSentence(cleanA) || '(без ответа)'; const did = [...new Set((actions || []).map((t) => String(t).trim()).filter(Boolean))].join(', ') || '—'; const span = (endTurn != null && endTurn > turn) ? ` [вобрал ходы ${turn}-${endTurn}]` : ''; - return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}`; + const tail = note ? ` · ${note}` : ''; + return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}${tail}`; } import { writeFileSync as _writeFileSync, renameSync as _renameSync } from 'node:fs'; diff --git a/tools/secretary-layer1.test.mjs b/tools/secretary-layer1.test.mjs index 9735785..9442c49 100644 --- a/tools/secretary-layer1.test.mjs +++ b/tools/secretary-layer1.test.mjs @@ -1,5 +1,30 @@ import { describe, it, expect } from 'vitest'; -import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText, realBoundariesFromRaw } from './secretary-layer1.mjs'; +import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText, realBoundariesFromRaw, spanInterruptNote } from './secretary-layer1.mjs'; + +describe('честные пометки прерванного спана', () => { + const rawCont = [ + buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба длинная', assistant: 'начал' }), + buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'докончил', isContinuation: true }), + ].join(''); + const rawTail = [ + buildRawRecord({ turn: 7, time: 't', session: 's', user: 'большая задача длинная', assistant: 'часть', interruptedTail: true }), + ].join(''); + + it('spanInterruptNote: спан с cont → «продолжено»', () => { + expect(spanInterruptNote(rawCont, { start: 3, end: 4 })).toBe('(связь прерывалась — продолжено)'); + }); + it('spanInterruptNote: спан с tail → «прервана, не завершена»', () => { + expect(spanInterruptNote(rawTail, { start: 7, end: 7 })).toBe('(прервана, не завершена)'); + }); + it('spanInterruptNote: обычный спан → пусто', () => { + const raw = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'обычный длинный вопрос', assistant: 'ок' }); + expect(spanInterruptNote(raw, { start: 1, end: 1 })).toBe(''); + }); + it('buildStepLine с note приклеивает пометку в конец', () => { + const s = buildStepLine({ turn: 3, endTurn: 4, user: 'просьба длинная достаточно', assistant: 'ок', note: '(связь прерывалась — продолжено)' }); + expect(s.endsWith('(связь прерывалась — продолжено)')).toBe(true); + }); +}); describe('обезвреживание маркеров на записи (от самозагрязнения лога)', () => { it('маркеры внутри текста реплик/действий не дают лишних структурных совпадений', () => { @@ -35,6 +60,17 @@ describe('метка служебного хода (meta=1) + структурн const rec = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'привет', assistant: 'a' }); expect(rec).not.toContain('meta=1'); }); + it('buildRawRecord: продолжение помечается cont=1, незавершённый хвост — tail=1', () => { + const cont = buildRawRecord({ turn: 5, time: 't', session: 's', user: 'продолжи', assistant: 'a', isContinuation: true }); + expect(cont).toMatch(/=== ХОД turn=5[^\n]*cont=1[^\n]*===/); + const tail = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'задача', assistant: 'a', interruptedTail: true }); + expect(tail).toMatch(/=== ХОД turn=6[^\n]*tail=1[^\n]*===/); + }); + it('buildRawRecord: meta+cont вместе — оба ярлычка в заголовке', () => { + const rec = buildRawRecord({ turn: 7, time: 't', session: 's', user: 'u', assistant: 'a', userIsMeta: true, isContinuation: true }); + expect(rec).toMatch(/meta=1/); + expect(rec).toMatch(/cont=1/); + }); it('realBoundariesFromRaw: служебные по meta=1 исключены (структурно, не по тексту)', () => { const raw = [ buildRawRecord({ turn: 7, time: 't', session: 's', user: 'настоящий 1', assistant: 'a' }), @@ -43,6 +79,14 @@ describe('метка служебного хода (meta=1) + структурн ].join(''); expect(realBoundariesFromRaw(raw)).toEqual([7, 9]); }); + it('realBoundariesFromRaw: ход-продолжение (cont=1) НЕ граница (склеивается к прошлой просьбе)', () => { + const raw = [ + buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба', assistant: 'a' }), + buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'b', isContinuation: true }), + buildRawRecord({ turn: 5, time: 't', session: 's', user: 'новая просьба', assistant: 'c' }), + ].join(''); + expect(realBoundariesFromRaw(raw)).toEqual([3, 5]); // ход 4 (cont) приклеен к спану 3 + }); it('realBoundariesFromRaw: фолбэк по тексту для старого сырья без меток', () => { const raw = [ '=== ХОД turn=7 · t · session=s ===', '[ЮЗЕР]', 'настоящий', '[АССИСТЕНТ]', 'a', '=== КОНЕЦ ХОДА ===', '', diff --git a/tools/secretary-prompt-hook.mjs b/tools/secretary-prompt-hook.mjs index 72b9bfe..fe00f14 100644 --- a/tools/secretary-prompt-hook.mjs +++ b/tools/secretary-prompt-hook.mjs @@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs'; +import { prevSessionsForCatchUp } from './secretary-sessions.mjs'; function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } function turnCount(rawFile) { @@ -25,7 +26,7 @@ function listCases(secdir) { // Решение хука на «включи»: активировать (флажок on) либо переспросить (имя похоже на // существующее дело). Чистая функция — вынесена ради теста; main() её исполняет с реальными fs. -export function planActivation({ requested, existing = [], startedAtTurn = 0, session } = {}) { +export function planActivation({ requested, existing = [], startedAtTurn = 0, session, sessionsOfCase = [] } = {}) { const res = resolveCaseActivation(requested, existing); if (res.action === 'confirm') { const context = `📒 Секретарь: имя дела «${requested}» похоже на существующее: ${res.candidates.join(', ')}.\n` @@ -33,7 +34,9 @@ export function planActivation({ requested, existing = [], startedAtTurn = 0, se + 'Если новое дело — повтори с именем, не совпадающим с этими.'; return { confirm: true, candidates: res.candidates, context }; } - return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } }; + // catchUp — прошлые сессии этого дела (кроме текущей): stop-хук догонит их недоразобранный хвост. + return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session, + catchUp: prevSessionsForCatchUp(sessionsOfCase, session) } }; } // Решение хука по команде секретаря. cmd: 'off' → перевести флажок в closing (с сохранением полей); @@ -60,9 +63,16 @@ function main() { if (cmd === 'on') { const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/); const requested = (m && m[1]) || 'general'; + // Прошлые сессии дела — из _sessions.json в папке дела (для догона после краха). + const res0 = resolveCaseActivation(requested, listCases(secdir)); + let sessionsOfCase = []; + try { + const sf = join(secdir, res0.work || requested, '_sessions.json'); + if (existsSync(sf)) sessionsOfCase = JSON.parse(readFileSync(sf, 'utf-8')); + } catch { sessionsOfCase = []; } const plan = planActivation({ requested, existing: listCases(secdir), - startedAtTurn: turnCount(rawFile), session, + startedAtTurn: turnCount(rawFile), session, sessionsOfCase, }); if (plan.confirm) { // Похоже на существующее дело — НЕ включаем, переспрашиваем (защита от дела-двойника). diff --git a/tools/secretary-prompt-hook.test.mjs b/tools/secretary-prompt-hook.test.mjs index dd9ca70..7956896 100644 --- a/tools/secretary-prompt-hook.test.mjs +++ b/tools/secretary-prompt-hook.test.mjs @@ -17,7 +17,7 @@ describe('planActivation — решение хука: активировать it('новое имя (нет похожих) — флажок on с work', () => { const r = planActivation({ requested: 'биллинг', existing: ['general'], startedAtTurn: 3, session: 's1' }); expect(r.confirm).toBe(false); - expect(r.flag).toEqual({ mode: 'on', startedAtTurn: 3, work: 'биллинг', session: 's1' }); + expect(r.flag).toEqual({ mode: 'on', startedAtTurn: 3, work: 'биллинг', session: 's1', catchUp: [] }); }); it('точное совпадение — флажок on с существующим именем', () => { const r = planActivation({ requested: 'general', existing: ['general'], startedAtTurn: 0, session: 's2' }); @@ -31,4 +31,16 @@ describe('planActivation — решение хука: активировать expect(r.candidates).toContain('создание-секретаря'); expect(r.context).toContain('создание-секретаря'); }); + it('проставляет catchUp из прошлых сессий дела', () => { + const plan = planActivation({ + requested: 'наставник', existing: ['наставник'], startedAtTurn: 0, session: 's2', + sessionsOfCase: [{ session: 's1', cursor: 2 }, { session: 's2', cursor: 0 }], + }); + expect(plan.confirm).toBe(false); + expect(plan.flag.catchUp).toEqual([{ session: 's1', cursor: 2 }]); + }); + it('без прошлых сессий — catchUp пустой', () => { + const plan = planActivation({ requested: 'новое', existing: [], session: 's1', sessionsOfCase: [] }); + expect(plan.flag.catchUp).toEqual([]); + }); }); diff --git a/tools/secretary-sessions.mjs b/tools/secretary-sessions.mjs new file mode 100644 index 0000000..e0ca107 --- /dev/null +++ b/tools/secretary-sessions.mjs @@ -0,0 +1,16 @@ +// Учёт «какие сессии вели дело» — для догона недоразобранного хвоста умершей сессии после краха. +// Хранится в папке дела (docs/secretary//_sessions.json), НЕ коммитится. + +/** Добавить/обновить указатель сессии {session, cursor}. cursor — последний разобранный spanCursor. */ +export function upsertSessionPointer(list, { session, cursor }) { + const out = (Array.isArray(list) ? list : []).map((e) => ({ ...e })); + const i = out.findIndex((e) => e.session === session); + if (i >= 0) out[i].cursor = cursor; + else out.push({ session, cursor }); + return out; +} + +/** Прошлые сессии дела (кроме текущей) — кандидаты на догон хвоста. */ +export function prevSessionsForCatchUp(list, currentSession) { + return (Array.isArray(list) ? list : []).filter((e) => e && e.session && e.session !== currentSession); +} diff --git a/tools/secretary-sessions.test.mjs b/tools/secretary-sessions.test.mjs new file mode 100644 index 0000000..401d8b9 --- /dev/null +++ b/tools/secretary-sessions.test.mjs @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { upsertSessionPointer, prevSessionsForCatchUp } from './secretary-sessions.mjs'; + +describe('secretary-sessions — учёт сессий дела', () => { + it('upsertSessionPointer добавляет новую сессию с курсором', () => { + const out = upsertSessionPointer([], { session: 's1', cursor: 2 }); + expect(out).toEqual([{ session: 's1', cursor: 2 }]); + }); + it('upsertSessionPointer обновляет курсор существующей', () => { + const out = upsertSessionPointer([{ session: 's1', cursor: 1 }], { session: 's1', cursor: 5 }); + expect(out).toEqual([{ session: 's1', cursor: 5 }]); + }); + it('prevSessionsForCatchUp — все сессии дела кроме текущей', () => { + const list = [{ session: 's1', cursor: 3 }, { session: 's2', cursor: 0 }]; + expect(prevSessionsForCatchUp(list, 's2')).toEqual([{ session: 's1', cursor: 3 }]); + }); + it('prevSessionsForCatchUp — пустой список → пусто', () => { + expect(prevSessionsForCatchUp([], 's2')).toEqual([]); + }); +}); diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index bd732e3..515a2ce 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -5,11 +5,12 @@ // Закрытые спаны разбираются один раз (курсор в флажке); при mode:'closing' добивается последний // открытый спан + нарезка сырья + гашение флажка. Разбор одного спана — общий distillSpan. import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; -import { parseLastExchange } from './secretary-transcript.mjs'; +import { assembleExchanges, buildRawFromExchanges } from './secretary-transcript.mjs'; import { secretaryModeFileName } from './secretary-flag.mjs'; -import { buildRawRecord, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles } from './secretary-layer1.mjs'; +import { upsertSessionPointer } from './secretary-sessions.mjs'; +import { writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles, spanInterruptNote } from './secretary-layer1.mjs'; import { formatReconcileLogLine } from './secretary-reconcile.mjs'; import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; import { upsertIndexEntry } from './secretary-index.mjs'; @@ -26,10 +27,6 @@ function readFlag(session) { function writeFlag(session, flag) { try { writeFileSync(flagPath(session), JSON.stringify(flag)); } catch { /* ignore */ } } -function turnCount(rawFile) { - if (!existsSync(rawFile)) return 0; - try { return (readFileSync(rawFile, 'utf-8').match(/=== ХОД turn=/g) || []).length; } catch { return 0; } -} async function main() { let ev = {}; @@ -41,17 +38,15 @@ async function main() { const secdir = join(process.cwd(), 'docs', 'secretary'); const rawFile = join(secdir, 'raw', `${session}.log`); - const ex = parseLastExchange(transcript); - const turn = turnCount(rawFile) + 1; + const ex = assembleExchanges(transcript); + const turn = ex.length; - // Слой 1: всегда пишем сырьё (PII вырезается перед записью); служебный ход помечаем meta=1. + // Слой 1: ВСЕГДА пересобираем сырьё из всего транскрипта (переживает обрывы; PII вырезается). + // Метки-обрывы не считаются настоящим промптом; продолжение помечается cont=1, хвост — tail=1. try { - const rec = sanitize(buildRawRecord({ - turn, time: new Date().toISOString(), session, - user: ex.user, assistant: ex.assistant, actions: ex.actions, userIsMeta: ex.userIsMeta, - })); + const rawContent = buildRawFromExchanges(ex, { session, sanitize }); mkdirSync(join(secdir, 'raw'), { recursive: true }); - appendFileSync(rawFile, rec + '\n', 'utf-8'); + writeFileAtomic(rawFile, rawContent); } catch { /* fail-quiet */ } // Тетрадь (Слой 2) — только если секретарь включён или закрывается. @@ -82,9 +77,6 @@ async function main() { list.push({ start: lastOpen.start, end: lastOpen.end, index: lastOpen.index }); } - // Обычный ход без новых закрытых спанов — тетрадь не трогаем (отставание на один промпт). - if (!list.length && !closing) { process.exit(0); } - const reLog = join(workDir, '_reconcile.log'); const logReason = (info) => { try { @@ -103,11 +95,44 @@ async function main() { }) : null; + // Догон после жёсткого краха: разобрать недоразобранный хвост прошлых (умерших) сессий этого дела. + // Сессия мертва → её последний (открытый) спан тоже финальный, поэтому берём ВСЕ спаны за курсором. + let didCatchUp = false; + if (Array.isArray(flag.catchUp) && flag.catchUp.length && callModel) { + 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; + const prevCursor = Number.isFinite(prev.cursor) ? prev.cursor : -1; + for (const span of computeSpans(prevBounds, prevLast).map((s, index) => ({ ...s, index }))) { + if (span.index <= prevCursor) continue; + const spanEx = assembleSpan(prevRaw, span); + const note = spanInterruptNote(prevRaw, span); + proto = await distillSpan(proto, spanEx, { ...span, note }, { callModel, session: prev.session, diag: logReason }); + } + } + writeFlag(session, { ...readFlag(session), catchUp: [] }); + didCatchUp = true; + } + + // Обычный ход без новых закрытых спанов и без догона — тетрадь не трогаем (отставание на промпт). + if (!list.length && !closing && !didCatchUp) { process.exit(0); } + // Разбор каждого завершённого спана по порядку (общий distillSpan: reconcile + аудит на ПОЛНОМ спане). let lastIndex = cursor; for (const span of list) { const spanEx = assembleSpan(rawText, span); - proto = await distillSpan(proto, spanEx, span, { callModel, session, diag: logReason }); + const note = spanInterruptNote(rawText, span); + proto = await distillSpan(proto, spanEx, { ...span, note }, { callModel, session, diag: logReason }); lastIndex = span.index; } @@ -142,6 +167,13 @@ async function main() { } else { // Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы). writeFlag(session, { ...readFlag(session), spanCursor: lastIndex }); + // Указатель сессии дела (для догона будущих сессий после краха). + try { + const sf = join(workDir, '_sessions.json'); + let arr = []; + try { if (existsSync(sf)) arr = JSON.parse(readFileSync(sf, 'utf-8')); } catch { arr = []; } + writeFileAtomic(sf, JSON.stringify(upsertSessionPointer(arr, { session, cursor: lastIndex }))); + } catch { /* указатель вторичен */ } } } catch { /* fail-quiet: сырьё уже записано */ } process.exit(0); diff --git a/tools/secretary-transcript.mjs b/tools/secretary-transcript.mjs index 52980b3..dbbbcd2 100644 --- a/tools/secretary-transcript.mjs +++ b/tools/secretary-transcript.mjs @@ -1,6 +1,7 @@ // Чистый разбор хвоста стенограммы: последний обмен (user + assistant + действия). // Схема сверена с observer-transcript-parser: entry.message.role / entry.message.content // (строка или массив блоков text/tool_use{name,input}). +import { buildRawRecord } from './secretary-layer1.mjs'; function parseLines(text) { const entries = []; @@ -23,6 +24,92 @@ function isRealUserPrompt(msg) { return false; } +// Текст user-контента (строка или массив text-блоков) — для классификации меток. +function userText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n'); + return ''; +} + +// Вид записи транскрипта для сборки обменов. Метки печатает Claude Code (не владелец) — +// распознаём структурно, опечатки в тексте владельца ни на что не влияют. +// Порядок проверок важен: метка-обрыв проверяется ДО real (она тоже role:user с text-блоком). +export function classifyEntry(entry) { + if (!entry) return 'skip'; + if (entry.isCompactSummary === true) return 'summary'; + if (entry.isApiErrorMessage === true) return 'interrupt-api'; + const m = entry.message; + if (!m) return 'skip'; + if (m.role === 'user') { + if (/^\s*\[Request interrupted by user/.test(userText(m.content))) return 'interrupt-stop'; + if (Array.isArray(m.content) && m.content.some((b) => b && b.type === 'tool_result')) return 'tool_result'; + if (isRealUserPrompt(m)) return entry.isMeta === true ? 'meta' : 'real'; + return 'skip'; + } + if (m.role === 'assistant') return 'assistant'; + return 'skip'; +} + +// Сборка ВСЕХ обменов из транскрипта. Обмен = настоящий промпт владельца (или служебный ход) +// → ответ ассистента + действия, до следующего настоящего промпта/служебного хода. +// Метки-обрывы (сбой API / ручной стоп) НЕ начинают обмен: промпт сразу после метки помечается +// продолжением (isContinuation), не открывает новый спан. Незавершённый прерванный хвост в конце — +// interruptedTail. Выжимки сжатия и прочее служебное — пропускаются. +export function assembleExchanges(transcriptText) { + const entries = parseLines(transcriptText); + const exchanges = []; + let cur = null; + let pendingInterrupt = false; // метка-обрыв видна, ждём следующий настоящий промпт + const push = () => { if (cur) exchanges.push(cur); }; + for (const e of entries) { + const kind = classifyEntry(e); + if (kind === 'real' || kind === 'meta') { + push(); + cur = { + user: userText(e.message.content), assistant: '', actions: [], results: {}, + userIsMeta: kind === 'meta', + isContinuation: kind === 'real' && pendingInterrupt, + interruptedTail: false, + time: e.timestamp || '', + }; + pendingInterrupt = false; + } else if (kind === 'assistant') { + if (!cur) continue; + const c = e.message.content; + if (Array.isArray(c)) { + for (const b of c) { + if (b && b.type === 'text' && b.text) cur.assistant += (cur.assistant ? '\n' : '') + b.text; + if (b && b.type === 'tool_use') cur.actions.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) }); + } + } else if (typeof c === 'string') { + cur.assistant += (cur.assistant ? '\n' : '') + c; + } + } else if (kind === 'tool_result') { + if (!cur) continue; + for (const b of e.message.content) { + if (b && b.type === 'tool_result' && b.tool_use_id != null) cur.results[b.tool_use_id] = resultText(b.content); + } + } else if (kind === 'interrupt-api' || kind === 'interrupt-stop') { + pendingInterrupt = true; + if (cur) cur.interruptedTail = true; // предварительно; снимется, если ниже есть продолжение + } + // 'summary' / 'skip' — игнор + } + push(); + // Хвостом остаётся только ПОСЛЕДНИЙ обмен: если ниже есть ещё обмен — работа так или иначе продолжилась. + for (let i = 0; i < exchanges.length - 1; i++) exchanges[i].interruptedTail = false; + // Привязка выдачи к действию по id; снять служебное поле results. + for (const ex of exchanges) { + ex.actions = ex.actions.map((a) => { + const out = { tool: a.tool, input: a.input }; + if (a.id != null && ex.results[a.id] != null) out.result = String(ex.results[a.id] ?? ''); + return out; + }); + delete ex.results; + } + return exchanges; +} + // Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков. // Без обрезки: секретарь должен видеть ПОЛНОЕ содержимое (линзы ловят ошибки/пропуски). function resultText(content) { @@ -34,52 +121,26 @@ function resultText(content) { return ''; } -/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }. - * result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела); - * без совпадения действие остаётся прежней формы {tool,input} — без ключа result. */ +/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}], userIsMeta }. + * Тонкая обёртка над assembleExchanges (источник правды о видах записи и метках) — без дубля логики. */ export function parseLastExchange(transcriptText) { - const entries = parseLines(transcriptText); - let u = -1; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i] && isRealUserPrompt(entries[i].message)) { u = i; break; } - } - const userContent = u >= 0 ? entries[u].message.content : ''; - const user = typeof userContent === 'string' - ? userContent - : (Array.isArray(userContent) - ? userContent.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n') - : ''); - // Структурный ярлычок: служебное сообщение (гейт-фидбек / загрузка навыка / контекст) помечено - // isMeta:true на самой записи транскрипта. Реальная просьба владельца — без него. Это честный - // разделитель «хозяин vs служебное» (не угадывание по тексту/номеру хода). - const userIsMeta = u >= 0 && entries[u].isMeta === true; - - let assistant = ''; - const raw = []; // {id, tool, input} — вызовы инструментов - const results = {}; // tool_use_id -> текст результата (из tool_result в сообщениях role:user) - for (let i = u + 1; i < entries.length; i++) { - const m = entries[i] && entries[i].message; - if (!m) continue; - const c = m.content; - if (m.role === 'assistant') { - if (Array.isArray(c)) { - for (const b of c) { - if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text; - if (b && b.type === 'tool_use') raw.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) }); - } - } else if (typeof c === 'string') { - assistant += (assistant ? '\n' : '') + c; - } - } else if (m.role === 'user' && Array.isArray(c)) { - for (const b of c) { - if (b && b.type === 'tool_result' && b.tool_use_id != null) results[b.tool_use_id] = resultText(b.content); - } - } - } - const actions = raw.map((a) => { - const out = { tool: a.tool, input: a.input }; - if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? ''); - return out; - }); - return { user, assistant, actions, userIsMeta }; + const all = assembleExchanges(transcriptText); + const last = all[all.length - 1]; + if (!last) return { user: '', assistant: '', actions: [], userIsMeta: false }; + return { user: last.user, assistant: last.assistant, actions: last.actions, userIsMeta: last.userIsMeta }; +} + +// Сырьё (Слой 1) из готовых обменов: ход = индекс обмена. Каждая запись санируется (PII) перед склейкой. +export function buildRawFromExchanges(exchanges, { session, sanitize = (x) => x } = {}) { + const recs = exchanges.map((ex, i) => sanitize(buildRawRecord({ + turn: i + 1, time: ex.time || '', session, + user: ex.user, assistant: ex.assistant, actions: ex.actions, + userIsMeta: ex.userIsMeta, isContinuation: ex.isContinuation, interruptedTail: ex.interruptedTail, + }))); + return recs.map((r) => r + '\n').join(''); +} + +// Полная пересборка сырья из текста транскрипта (источник правды переживает обрывы). +export function rebuildRawFromTranscript(transcriptText, { session, sanitize } = {}) { + return buildRawFromExchanges(assembleExchanges(transcriptText), { session, sanitize }); } diff --git a/tools/secretary-transcript.test.mjs b/tools/secretary-transcript.test.mjs index d57cf6f..6f1f210 100644 --- a/tools/secretary-transcript.test.mjs +++ b/tools/secretary-transcript.test.mjs @@ -1,5 +1,137 @@ import { describe, it, expect } from 'vitest'; -import { parseLastExchange } from './secretary-transcript.mjs'; +import { parseLastExchange, classifyEntry, assembleExchanges, rebuildRawFromTranscript } from './secretary-transcript.mjs'; +import { realBoundariesFromRaw } from './secretary-layer1.mjs'; + +describe('rebuildRawFromTranscript — пересборка сырья (источник = транскрипт)', () => { + it('сбой API + продолжи → 2 хода, ход-продолжение помечен cont=1, граница одна', () => { + const t = [ + { message: { role: 'user', content: 'настоящая просьба' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'начал' }] } }, + { isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } }, + { message: { role: 'user', content: 'продолжи' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const raw = rebuildRawFromTranscript(t, { session: 's' }); + expect((raw.match(/=== ХОД turn=/g) || []).length).toBe(2); + expect(raw).toMatch(/=== ХОД turn=2[^\n]*cont=1/); + expect(realBoundariesFromRaw(raw)).toEqual([1]); // одна логическая работа, не «продолжи» + }); + it('пустой транскрипт → пустое сырьё', () => { + expect(rebuildRawFromTranscript('', { session: 's' })).toBe(''); + }); + it('sanitize применяется к каждой записи', () => { + const t = JSON.stringify({ message: { role: 'user', content: 'СЕКРЕТ' } }); + const raw = rebuildRawFromTranscript(t, { session: 's', sanitize: (x) => x.replace('СЕКРЕТ', '[вырезано]') }); + expect(raw).toContain('[вырезано]'); + expect(raw).not.toContain('СЕКРЕТ'); + }); +}); + +describe('assembleExchanges — обмены из всего транскрипта', () => { + it('два настоящих промпта → два обмена с накопленным ответом и действиями', () => { + const t = [ + { message: { role: 'user', content: 'первый' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'ответ1' }, { type: 'tool_use', id: 'a', name: 'Read', input: { f: 'x' } }] } }, + { message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a', content: 'r' }] } }, + { message: { role: 'user', content: 'второй' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'ответ2' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['первый', 'второй']); + expect(ex[0].assistant).toBe('ответ1'); + expect(ex[0].actions).toEqual([{ tool: 'Read', input: '{"f":"x"}', result: 'r' }]); + expect(ex[0].isContinuation).toBe(false); + }); + + it('сбой API + следующий промпт → продолжение (isContinuation), не новый настоящий промпт', () => { + const t = [ + { message: { role: 'user', content: 'настоящая просьба' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'начал работу' }] } }, + { isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } }, + { message: { role: 'user', content: 'продолжи' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['настоящая просьба', 'продолжи']); + expect(ex[1].isContinuation).toBe(true); // «продолжи» — продолжение, не новая просьба + expect(ex[ex.length - 1].interruptedTail).toBe(false); // работа доведена → не хвост + }); + + it('ручной стоп + следующий промпт → продолжение (склеиваем по умолчанию)', () => { + const t = [ + { message: { role: 'user', content: 'просьба' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'работаю' }] } }, + { message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } }, + { message: { role: 'user', content: 'дальше давай' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['просьба', 'дальше давай']); + expect(ex[1].isContinuation).toBe(true); + }); + + it('прерван и НЕ продолжен (хвост в конце) → interruptedTail на последнем обмене', () => { + const t = [ + { message: { role: 'user', content: 'большая задача' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'делаю часть' }] } }, + { message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex).toHaveLength(1); + expect(ex[0].user).toBe('большая задача'); + expect(ex[0].interruptedTail).toBe(true); + }); + + it('выжимка сжатия не становится обменом', () => { + const t = [ + { message: { role: 'user', content: 'реальный' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } }, + { isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: 'СЖАТАЯ ВЫЖИМКА' } }, + { message: { role: 'user', content: 'после сжатия' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'дальше' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex.map((x) => x.user)).toEqual(['реальный', 'после сжатия']); + expect(ex.some((x) => x.user.includes('ВЫЖИМКА'))).toBe(false); + }); + + it('служебный ход (meta) — отдельный обмен с userIsMeta', () => { + const t = [ + { message: { role: 'user', content: 'настоящий' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'a' }] } }, + { isMeta: true, message: { role: 'user', content: 'Stop hook feedback: x' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'b' }] } }, + ].map((e) => JSON.stringify(e)).join('\n'); + const ex = assembleExchanges(t); + expect(ex).toHaveLength(2); + expect(ex[1].userIsMeta).toBe(true); + expect(ex[1].isContinuation).toBe(false); + }); +}); + +describe('classifyEntry — вид записи транскрипта', () => { + it('настоящий промпт владельца → real', () => { + expect(classifyEntry({ message: { role: 'user', content: 'сделай X' } })).toBe('real'); + }); + it('служебный ход (isMeta) → meta', () => { + expect(classifyEntry({ isMeta: true, message: { role: 'user', content: 'Stop hook feedback' } })).toBe('meta'); + }); + it('сбой API (isApiErrorMessage) → interrupt-api', () => { + expect(classifyEntry({ isApiErrorMessage: true, type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } })).toBe('interrupt-api'); + }); + it('ручной стоп (обе формы) → interrupt-stop', () => { + expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } })).toBe('interrupt-stop'); + expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] } })).toBe('interrupt-stop'); + }); + it('выжимка сжатия (isCompactSummary) → summary', () => { + expect(classifyEntry({ isCompactSummary: true, message: { role: 'user', content: 'итог...' } })).toBe('summary'); + }); + it('ответ ассистента → assistant; tool_result → tool_result', () => { + expect(classifyEntry({ message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } })).toBe('assistant'); + expect(classifyEntry({ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'r' }] } })).toBe('tool_result'); + }); +}); describe('parseLastExchange', () => { it('тащит последний user + assistant + действия', () => {