From 2b6170313b48b9feaba3d42cce54d6baa6da9467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 23 Jun 2026 14:45:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20=D0=BD=D0=B0=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=81=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BC=20(=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0)=20+=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=BE=D0=B5=20=D1=81=D1=8B=D1=80=D1=8C=D1=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единица разбора — спан: реальный промпт владельца + вся активность ассистента до следующего реального промпта. Системные ходы (гейт-фидбек, загрузка навыка) приклеиваются к спану, не считаются отдельными. Разбор отложенный: закрытые спаны разбираются один раз (курсор в флажке сессии); reconcile и аудитор получают ПОЛНЫЙ склеенный спан (промпт + все ответы + все действия). - Слой 1: снят обрез вывода действий (полная картина), защита структурных меток. - Граница спана — событие UserPromptSubmit (prompt-hook метит realPromptTurns), фолбэк по sysLabel; выключение через mode:closing (финальный спан добивает Stop). - Калибровка скрытых вопросов: страж-ноп (не мутировать при неизменном тексте) + кап показа родословной (~~первая~~ → текущая, данные целы). - Шаги — по спанам («Ход (промпт) N [вобрал ходы X-Y]»); «висит N промптов». - Новый модуль secretary-span.mjs (computeSpans/spansToDistill/recordRealPrompt/ parseTurnBlock/assembleSpan). Свод секретаря зелёный (138 тестов), живой прогон на реальной модели подтвердил: Шаги по спанам, гейт-шум не плодит скрытые вопросы, находки выживают по одному раз. Спека/план: docs/superpowers/{specs,plans}/2026-06-23-secretary-span-redesign*. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-23-secretary-span-redesign.md | 1075 +++++++++++++++++ ...26-06-23-secretary-span-redesign-design.md | 261 ++++ tools/secretary-audit.mjs | 11 +- tools/secretary-audit.test.mjs | 25 + tools/secretary-layer1.mjs | 59 +- tools/secretary-layer1.test.mjs | 75 +- tools/secretary-prompt-hook.mjs | 60 +- tools/secretary-prompt-hook.test.mjs | 19 +- tools/secretary-protocol.mjs | 11 +- tools/secretary-protocol.test.mjs | 16 + tools/secretary-reconcile.mjs | 5 +- tools/secretary-reconcile.test.mjs | 7 + tools/secretary-span.mjs | 64 + tools/secretary-span.test.mjs | 98 ++ tools/secretary-stop-hook.mjs | 158 ++- tools/secretary-transcript.mjs | 8 +- tools/secretary-transcript.test.mjs | 6 +- 17 files changed, 1810 insertions(+), 148 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-23-secretary-span-redesign.md create mode 100644 docs/superpowers/specs/2026-06-23-secretary-span-redesign-design.md create mode 100644 tools/secretary-span.mjs create mode 100644 tools/secretary-span.test.mjs diff --git a/docs/superpowers/plans/2026-06-23-secretary-span-redesign.md b/docs/superpowers/plans/2026-06-23-secretary-span-redesign.md new file mode 100644 index 0000000..d469c4c --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-secretary-span-redesign.md @@ -0,0 +1,1075 @@ +# Секретарь: нарезка по спанам — план реализации + +> **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:** Перевести секретаря с разбора по каждому Stop-ходу на разбор по спанам (реальный промпт владельца + вся активность ассистента до следующего реального промпта), с отложенным разбором и полной картиной для модели. + +**Architecture:** Граница спана — событие `UserPromptSubmit` (срабатывает только на реальный промпт; запас — классификация по `sysLabel`). Сырьё Слоя 1 пишется по ходу, теперь с полным содержимым. На каждом Stop вычисляются спаны; закрытые (не последний) разбираются один раз (reconcile+аудит на склеенном куске), курсор продвигается. Спан-логика — в новом чистом модуле `secretary-span.mjs`. + +**Tech Stack:** Node.js (ESM `.mjs`), vitest (`import { describe, it, expect } from 'vitest'`). Без новых зависимостей. + +**Источник:** спека `docs/superpowers/specs/2026-06-23-secretary-span-redesign-design.md`. + +--- + +## Структура файлов + +**Новый модуль:** +- `tools/secretary-span.mjs` — чистая спан-логика: + - `computeSpans(realPromptTurns, lastTurn) → [{start,end,open}]` + - `spansToDistill(realPromptTurns, lastTurn, spanCursor) → [{start,end,index}]` + - `recordRealPrompt(flag, turn) → flag` (идемпотентно) + - `parseTurnBlock(block) → {turn, user, assistant, actions:[{tool,input,result}]}` + - `assembleSpan(rawText, {start,end}) → {user, assistant, actions}` +- `tools/secretary-span.test.mjs` — тесты к нему. + +**Меняем:** +- `tools/secretary-layer1.mjs` — `neutralizeMarkers` (защита структурных меток); `buildStepLine` (метка спана); `buildStepsFromRaw`/`mergeStepsPreservingText` (спан-осведомлённость). +- `tools/secretary-transcript.mjs` — снять обрез `MAX_RESULT_CHARS`. +- `tools/secretary-reconcile.mjs` — `buildReconcilePrompt` подаёт действия с содержимым. +- `tools/secretary-audit.mjs` — `buildAuditPrompt` подаёт действия; страж-ноп в `applyAudit`. +- `tools/secretary-protocol.mjs` — кап родословной в показе; «висит N промптов»; рендер «Ход (промпт) N». +- `tools/secretary-prompt-hook.mjs` — запись границы спана на обычном промпте; «off» → `mode:'closing'`. +- `tools/secretary-stop-hook.mjs` — отложенный разбор спанов + финализация при `closing`. + +**Соглашение нумерации:** один источник — номера ходов сырья (`raw/.log`, `turnCount`). Границы спанов — это номера ходов-начал. Провенанс `[→N]` и метки спанов используют их. + +**Свод (зелёный после каждой задачи):** +``` +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 +``` + +> **Коммиты:** репозиторий требует verify-расписку на код-коммит. Перед каждым коммитом: `node tools/produce-verify-receipt.mjs`, затем `git add`/`git commit`. Коммит — с согласия владельца (CLAUDE.md: ничего необратимого без разрешения). Группируем по чекпоинтам, отмеченным ниже. + +--- + +## Task 1: Слой 1 — полное содержимое + защита меток + +**Files:** +- Modify: `tools/secretary-transcript.mjs` (убрать обрез результата) +- Modify: `tools/secretary-layer1.mjs:4-8` (`neutralizeMarkers`) +- Test: `tools/secretary-transcript.test.mjs`, `tools/secretary-layer1.test.mjs` + +- [ ] **Step 1: Переписать тест обрезки в transcript на «полное содержимое»** + +В `tools/secretary-transcript.test.mjs` заменить тест `'длинный результат усечён и оканчивается маркером …'` (строки ~66-78) на: + +```javascript + it('длинный результат НЕ обрезается (полная картина для секретаря)', () => { + const big = 'x'.repeat(5000); + const t = [ + JSON.stringify({ message: { role: 'user', content: 'в' } }), + JSON.stringify({ message: { role: 'assistant', content: [ + { type: 'tool_use', id: 'tu_2', name: 'Read', input: {} }] } }), + JSON.stringify({ message: { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'tu_2', content: big }] } }), + ].join('\n'); + const ex = parseLastExchange(t); + expect(ex.actions[0].result).toBe(big); // целиком + expect(ex.actions[0].result.endsWith('…')).toBe(false); + }); +``` + +- [ ] **Step 2: Прогнать — тест падает** + +Run: `npx vitest run tools/secretary-transcript.test.mjs` +Expected: FAIL (старый код обрезает → `result` короче `big`). + +- [ ] **Step 3: Снять обрез в transcript** + +В `tools/secretary-transcript.mjs` удалить константу и функцию обрезки и её вызов. Было (строки ~27, ~36-39, ~81): +```javascript +const MAX_RESULT_CHARS = 1200; +... +function truncateResult(s) { + const t = String(s ?? ''); + return t.length > MAX_RESULT_CHARS ? t.slice(0, MAX_RESULT_CHARS) + '…' : t; +} +... + if (a.id != null && results[a.id] != null) out.result = truncateResult(results[a.id]); +``` +Стало — `truncateResult` удалить целиком, а привязку результата заменить на прямую: +```javascript + if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? ''); +``` + +- [ ] **Step 4: Прогнать transcript-тесты — зелено** + +Run: `npx vitest run tools/secretary-transcript.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Тест защиты структурных меток в сырье** + +В `tools/secretary-layer1.test.mjs`, в describe `'обезвреживание маркеров на записи …'`, добавить: + +```javascript + it('структурные метки внутри содержимого обезврежены (полный вывод не ломает разбор)', () => { + const rec = buildRawRecord({ + turn: 1, time: 't', session: 's', + user: 'u', assistant: 'a', + actions: [{ tool: 'Read', input: 'x', result: '[ДЕЙСТВИЕ] Edit\n[ВЫДАЧА] Edit\n[ЮЗЕР]\n[АССИСТЕНТ]' }], + }); + // в записи остаётся ровно один реальный набор маркеров действия (из buildRawRecord), + // подделки из result не считаются за структурные. + expect((rec.match(/^\[ДЕЙСТВИЕ\] /gm) || []).length).toBe(1); + expect((rec.match(/^\[ВЫДАЧА\] /gm) || []).length).toBe(1); + expect(rec).not.toMatch(/^\[ЮЗЕР\]\n\[АССИСТЕНТ\]$/m); + }); +``` + +- [ ] **Step 6: Прогнать — падает** + +Run: `npx vitest run tools/secretary-layer1.test.mjs` +Expected: FAIL (текущий `neutralizeMarkers` не трогает `[ДЕЙСТВИЕ]`/`[ВЫДАЧА]`/`[ЮЗЕР]`/`[АССИСТЕНТ]`). + +- [ ] **Step 7: Расширить `neutralizeMarkers`** + +В `tools/secretary-layer1.mjs` заменить функцию (строки 4-8) на: + +```javascript +function neutralizeMarkers(s) { + return String(s ?? '') + .replace(/=== ХОД turn=/g, '=≡ ХОД turn=') + .replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡=') + // структурные метки блока: ломаем только в начале строки (реальные ставит buildRawRecord), + // чтобы полный вывод действия не подделал границы при обратном разборе сырья. + // Вставляем пробел перед «]» → «[ЮЗЕР ]» уже не совпадёт с разбором «^[ЮЗЕР]». + .replace(/^\[(ЮЗЕР|АССИСТЕНТ|ДЕЙСТВИЕ|ВЫДАЧА)\]/gm, '[$1 ]'); +} +``` + +- [ ] **Step 8: Прогнать оба файла — зелено** + +Run: `npx vitest run tools/secretary-transcript.test.mjs tools/secretary-layer1.test.mjs` +Expected: PASS. + +--- + +## Task 2: `secretary-span.mjs` — `computeSpans` + `spansToDistill` + +**Files:** +- Create: `tools/secretary-span.mjs` +- Test: `tools/secretary-span.test.mjs` + +- [ ] **Step 1: Тесты на спаны** + +Create `tools/secretary-span.test.mjs`: + +```javascript +import { describe, it, expect } from 'vitest'; +import { computeSpans, spansToDistill } from './secretary-span.mjs'; + +describe('computeSpans', () => { + it('границы → отрезки; последний открыт', () => { + expect(computeSpans([3, 12, 15], 17)).toEqual([ + { start: 3, end: 11, open: false }, + { start: 12, end: 14, open: false }, + { start: 15, end: 17, open: true }, + ]); + }); + it('одна граница → один открытый спан', () => { + expect(computeSpans([3], 5)).toEqual([{ start: 3, end: 5, open: true }]); + }); + it('пустой список → нет спанов', () => { + expect(computeSpans([], 5)).toEqual([]); + }); + it('неотсортированные/дубли нормализуются', () => { + expect(computeSpans([12, 3, 3], 13)).toEqual([ + { start: 3, end: 11, open: false }, + { start: 12, end: 13, open: true }, + ]); + }); +}); + +describe('spansToDistill', () => { + it('закрытые спаны с индексом > курсора', () => { + expect(spansToDistill([3, 12, 15], 17, 0)).toEqual([ + { start: 3, end: 11, index: 0 }, + { start: 12, end: 14, index: 1 }, + ]); + }); + it('курсор уже прошёл первый закрытый — отдаём только второй', () => { + expect(spansToDistill([3, 12, 15], 17, 0).slice(1)).toEqual([{ start: 12, end: 14, index: 1 }]); + expect(spansToDistill([3, 12, 15], 17, 1)).toEqual([]); + }); + it('открытый спан не отдаётся', () => { + expect(spansToDistill([3, 12], 14, 0)).toEqual([{ start: 3, end: 11, index: 0 }]); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает (модуля нет)** + +Run: `npx vitest run tools/secretary-span.test.mjs` +Expected: FAIL (Cannot find module). + +- [ ] **Step 3: Реализовать** + +Create `tools/secretary-span.mjs`: + +```javascript +// Чистая спан-логика секретаря: границы спанов (реальные промпты владельца) → отрезки ходов. +// Без I/O. Нумерация — номера ходов сырья (raw/.log). + +/** Нормализованный, отсортированный список уникальных границ. */ +function norm(realPromptTurns) { + return [...new Set((realPromptTurns || []).map(Number).filter((n) => Number.isFinite(n)))] + .sort((a, b) => a - b); +} + +/** Отрезки спанов: [b_i .. b_{i+1}-1]; последний — до lastTurn, open:true. */ +export function computeSpans(realPromptTurns, lastTurn) { + const b = norm(realPromptTurns); + const out = []; + for (let i = 0; i < b.length; i++) { + const start = b[i]; + const isLast = i === b.length - 1; + const end = isLast ? lastTurn : b[i + 1] - 1; + out.push({ start, end, open: isLast }); + } + return out; +} + +/** Закрытые спаны с индексом строго больше курсора — их надо разобрать сейчас. */ +export function spansToDistill(realPromptTurns, lastTurn, spanCursor) { + const cur = Number.isFinite(spanCursor) ? spanCursor : -1; + return computeSpans(realPromptTurns, lastTurn) + .map((s, index) => ({ ...s, index })) + .filter((s) => !s.open && s.index > cur) + .map(({ start, end, index }) => ({ start, end, index })); +} +``` + +- [ ] **Step 4: Прогнать — зелено** + +Run: `npx vitest run tools/secretary-span.test.mjs` +Expected: PASS. + +--- + +## Task 3: `secretary-span.mjs` — `recordRealPrompt` + +**Files:** +- Modify: `tools/secretary-span.mjs` +- Test: `tools/secretary-span.test.mjs` + +- [ ] **Step 1: Тест** + +Добавить в `tools/secretary-span.test.mjs`: + +```javascript +import { recordRealPrompt } from './secretary-span.mjs'; + +describe('recordRealPrompt', () => { + it('добавляет границу, не дублирует, держит сортировку', () => { + let f = { mode: 'on', work: 'x' }; + f = recordRealPrompt(f, 3); + expect(f.realPromptTurns).toEqual([3]); + f = recordRealPrompt(f, 12); + expect(f.realPromptTurns).toEqual([3, 12]); + f = recordRealPrompt(f, 12); // дубль игнор + expect(f.realPromptTurns).toEqual([3, 12]); + expect(f.mode).toBe('on'); // прочие поля целы + }); + it('не мутирует вход', () => { + const f = { mode: 'on' }; + const out = recordRealPrompt(f, 1); + expect(f.realPromptTurns).toBeUndefined(); + expect(out.realPromptTurns).toEqual([1]); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-span.test.mjs` +Expected: FAIL (recordRealPrompt не экспортирован). + +- [ ] **Step 3: Реализовать** + +Добавить в `tools/secretary-span.mjs`: + +```javascript +/** Добавить границу спана в флажок (идемпотентно, сортировка). Вход не мутируется. */ +export function recordRealPrompt(flag, turn) { + const prev = Array.isArray(flag && flag.realPromptTurns) ? flag.realPromptTurns : []; + const set = new Set(prev); + set.add(Number(turn)); + return { ...flag, realPromptTurns: [...set].sort((a, b) => a - b) }; +} +``` + +- [ ] **Step 4: Прогнать — зелено** + +Run: `npx vitest run tools/secretary-span.test.mjs` +Expected: PASS. + +--- + +## Task 4: `secretary-span.mjs` — `parseTurnBlock` + `assembleSpan` + +**Files:** +- Modify: `tools/secretary-span.mjs` (импорт `splitRawIntoTurns` из layer1) +- Test: `tools/secretary-span.test.mjs` + +- [ ] **Step 1: Тесты разбора блока и сборки спана** + +Добавить в `tools/secretary-span.test.mjs`: + +```javascript +import { parseTurnBlock, assembleSpan } from './secretary-span.mjs'; +import { buildRawRecord } from './secretary-layer1.mjs'; + +describe('parseTurnBlock', () => { + it('тащит turn, user, assistant, действия с input/result', () => { + const block = buildRawRecord({ + turn: 4, time: 't', session: 's', user: 'вопрос', assistant: 'ответ', + actions: [{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ\nдве строки' }], + }); + const pt = parseTurnBlock(block); + expect(pt.turn).toBe(4); + expect(pt.user).toBe('вопрос'); + expect(pt.assistant).toBe('ответ'); + expect(pt.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ\nдве строки' }]); + }); +}); + +describe('assembleSpan', () => { + const raw = [ + buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящий промпт', assistant: 'первый ответ', + actions: [{ tool: 'Read', input: 'a', result: 'r1' }] }), + buildRawRecord({ turn: 4, time: 't', session: 's', user: 'Stop hook feedback: x', assistant: 'второй ответ', + actions: [{ tool: 'Grep', input: 'b', result: 'r2' }] }), + ].join(''); + it('склеивает обмен спана: user из start, assistant и actions со всех ходов', () => { + const ex = assembleSpan(raw, { start: 3, end: 4 }); + expect(ex.user).toBe('настоящий промпт'); + expect(ex.assistant).toContain('первый ответ'); + expect(ex.assistant).toContain('второй ответ'); + expect(ex.actions).toEqual([ + { tool: 'Read', input: 'a', result: 'r1' }, + { tool: 'Grep', input: 'b', result: 'r2' }, + ]); + }); + it('спан из одного хода', () => { + const ex = assembleSpan(raw, { start: 3, end: 3 }); + expect(ex.user).toBe('настоящий промпт'); + expect(ex.actions).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-span.test.mjs` +Expected: FAIL (parseTurnBlock/assembleSpan не экспортированы). + +- [ ] **Step 3: Реализовать** + +Добавить в `tools/secretary-span.mjs` (вверху — импорт): + +```javascript +import { splitRawIntoTurns } from './secretary-layer1.mjs'; + +/** Разбор одного блока хода сырья → {turn,user,assistant,actions}. Полное содержимое. + * Формат (buildRawRecord): [ЮЗЕР]\n…\n[АССИСТЕНТ]\n…\n([ДЕЙСТВИЕ] tool in=…\n[ВЫДАЧА] tool\n…)* */ +export function parseTurnBlock(block) { + const s = String(block || ''); + const turn = Number((s.match(/=== ХОД turn=(\d+)/) || [])[1]) || 0; + const um = s.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]\n/); + const am = s.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/); + const actions = []; + const re = /\[ДЕЙСТВИЕ\] (\S+) in=([\s\S]*?)\n\[ВЫДАЧА\] \S+\n([\s\S]*?)(?=\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/g; + let m; + while ((m = re.exec(s)) !== null) actions.push({ tool: m[1], input: m[2], result: m[3] }); + return { turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions }; +} + +/** Склейка обмена спана из сырья: user из хода-начала; assistant и actions — со всех ходов [start..end]. */ +export function assembleSpan(rawText, { start, end }) { + const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end); + const parsed = blocks.map((p) => parseTurnBlock(p.block)); + const startTurn = parsed.find((p) => p.turn === start) || parsed[0] || {}; + const assistant = parsed.map((p) => p.assistant).filter(Boolean).join('\n'); + const actions = parsed.flatMap((p) => p.actions); + return { user: startTurn.user || '', assistant, actions }; +} +``` + +- [ ] **Step 4: Прогнать — зелено** + +Run: `npx vitest run tools/secretary-span.test.mjs` +Expected: PASS. + +> **Чекпоинт-коммит 1** (после согласия владельца): `node tools/produce-verify-receipt.mjs` → `git add tools/secretary-span.mjs tools/secretary-span.test.mjs tools/secretary-transcript.mjs tools/secretary-transcript.test.mjs tools/secretary-layer1.mjs tools/secretary-layer1.test.mjs` → `git commit -m "feat(secretary): спан-логика + полное содержимое Слоя 1"`. + +--- + +## Task 5: `buildStepLine` — метка спана «Ход (промпт) N [вобрал ходы X-Y]» + +**Files:** +- Modify: `tools/secretary-layer1.mjs:72-100` (`buildStepLine`) +- Test: `tools/secretary-layer1.test.mjs` + +- [ ] **Step 1: Переписать тесты buildStepLine под спан-метку** + +В `tools/secretary-layer1.test.mjs` в describe `'buildStepLine'` заменить ожидания «Ход N» на «Ход (промпт) N» и добавить кейс «вобрал». Полный новый describe: + +```javascript +describe('buildStepLine', () => { + it('формат «Ход (промпт) N — …», без служебных строк', () => { + const s = buildStepLine({ turn: 5, user: 'сделай флажок.', assistant: 'экономия: 100%\nГотово.', actions: ['Edit', 'PowerShell', 'Edit'] }); + expect(s).toContain('Ход (промпт) 5 — я: сделай флажок.'); + expect(s).toContain('· ты: Готово.'); + expect(s).toContain('· делал: Edit, PowerShell'); + expect(s).not.toContain('экономия'); + }); + it('многоходовый спан показывает «[вобрал ходы X-Y]»', () => { + const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вопрос длинный достаточно', assistant: 'ответ' }); + expect(s).toContain('Ход (промпт) 12 [вобрал ходы 12-14] — я: вопрос длинный'); + }); + it('спан из одного хода — без «вобрал»', () => { + const s = buildStepLine({ turn: 7, endTurn: 7, user: 'короткий вопрос достаточно длинный', assistant: 'ок' }); + expect(s).not.toContain('вобрал'); + expect(s).toContain('Ход (промпт) 7 —'); + }); + it('пустой вопрос → (без вопроса); без действий → —', () => { + const s = buildStepLine({ turn: 2, user: '', assistant: 'a.' }); + expect(s).toContain('я: (без вопроса)'); + expect(s).toContain('делал: —'); + }); + it('убирает ведущую нумерацию и берёт содержательную фразу', () => { + const s = buildStepLine({ turn: 3, user: '1. содержание никчёмное, нужно о чём и где', assistant: 'Понял.' }); + expect(s).toContain('я: содержание никчёмное'); + expect(s).not.toContain('я: 1.'); + }); + it('служебный ход — метка вместо шума', () => { + expect(buildStepLine({ turn: 1, user: 'Stop hook feedback: coverage missing', assistant: '' })).toContain('я: (гейт проверки)'); + expect(buildStepLine({ turn: 2, user: 'Base directory for this skill: C:\\x\\skills\\writing-plans\\SKILL.md', assistant: 'x.' })).toContain('я: (навык: writing-plans)'); + }); + it('essence: модельная суть дословно + детерминированный «делал»', () => { + const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вода '.repeat(10), assistant: 'вода', actions: ['Read', 'Read', 'Grep'], + essence: { user: 'промпт не логируется?', assistant: 'достать можно: поймать или пересобрать' } }); + expect(s).toBe('Ход (промпт) 12 [вобрал ходы 12-14] — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep'); + }); + it('без essence — фолбэк firstSentence', () => { + const s = buildStepLine({ turn: 2, user: 'сделай флажок.', assistant: 'Готово.', essence: null }); + expect(s).toContain('я: сделай флажок'); + expect(s).toContain('ты: Готово'); + }); +}); +``` + +Также в describe `'buildStepsFromRaw …'` и `'mergeStepsPreservingText …'` обновить ожидания строк на «Ход (промпт) N» (см. Task 6, где эти функции меняются — там и поправим их тесты; здесь только buildStepLine). + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-layer1.test.mjs -t buildStepLine` +Expected: FAIL (сейчас «Ход N», нет endTurn). + +- [ ] **Step 3: Переписать `buildStepLine`** + +В `tools/secretary-layer1.mjs` заменить сигнатуру/конец функции (строки 72-100). Добавить параметр `endTurn`, изменить префикс: + +```javascript +export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null } = {}) { + const firstSentence = (s) => { + const t = String(s ?? '').replace(/\s+/g, ' ').trim().replace(/^\d+[.)]\s*/, ''); + let out = ''; + for (const p of t.split(/(?<=[.!?…])\s+/)) { out = out ? `${out} ${p}` : p; if (out.length >= 25) break; } + return out.length > 130 ? `${out.slice(0, 130)}…` : out; + }; + const sysLabel = (s) => { + const t = String(s ?? '').trim(); + if (/^Stop hook feedback/i.test(t)) return '(гейт проверки)'; + if (/^Base directory for this skill/i.test(t)) { + const sm = t.match(/skills[\\/]([a-zA-Z0-9-]+)/); + return `(навык: ${sm ? sm[1] : '—'})`; + } + return null; + }; + const cleanA = String(assistant ?? '').split('\n') + .filter((l) => !/^\s*(экономия:|coverage:|вердикт:)/i.test(l)).join(' '); + const clean1 = (s) => String(s ?? '').replace(/\s+/g, ' ').trim(); + const eU = essence && clean1(essence.user); + const eA = essence && clean1(essence.assistant); + const u = eU || sysLabel(user) || firstSentence(user) || '(без вопроса)'; + 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}`; +} +``` + +- [ ] **Step 4: Прогнать buildStepLine-тесты — зелено** + +Run: `npx vitest run tools/secretary-layer1.test.mjs -t buildStepLine` +Expected: PASS. + +--- + +## Task 6: `buildStepsFromRaw` / `mergeStepsPreservingText` — по спанам + +**Files:** +- Modify: `tools/secretary-layer1.mjs:51-67` (`buildStepsFromRaw`, `mergeStepsPreservingText`) +- Test: `tools/secretary-layer1.test.mjs` + +- [ ] **Step 1: Тесты на пересборку Шагов по спанам** + +В `tools/secretary-layer1.test.mjs` заменить describe `'buildStepsFromRaw — Шаг на КАЖДЫЙ ход …'` на: + +```javascript +describe('buildStepsFromRaw — Шаг на КАЖДЫЙ спан (пересборка на остановке)', () => { + const raw = [ + '=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'настоящий вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ раз', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', 'r', '=== КОНЕЦ ХОДА ===', '', + '=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'Stop hook feedback: y', '[АССИСТЕНТ]', 'ответ два', '[ДЕЙСТВИЕ] Grep in=z', '[ВЫДАЧА] Grep', 'r2', '=== КОНЕЦ ХОДА ===', '', + '=== ХОД turn=5 · t · session=s ===', '[ЮЗЕР]', 'второй настоящий вопрос длинный', '[АССИСТЕНТ]', 'ответ три', '=== КОНЕЦ ХОДА ===', '', + ].join('\n'); + it('границы [3,5] → два спана: 3 (вобрал 3-4) и 5', () => { + const steps = buildStepsFromRaw(raw, 's', [3, 5]); + expect(steps.map((x) => x.turn)).toEqual([3, 5]); + expect(steps[0].text).toContain('Ход (промпт) 3 [вобрал ходы 3-4] — я: настоящий вопрос'); + expect(steps[0].text).toContain('делал: Read, Grep'); // действия обоих ходов + expect(steps[1].text).toContain('Ход (промпт) 5'); + expect(steps[0].session).toBe('s'); + }); + it('без границ — фолбэк по sysLabel (реальный = не служебный)', () => { + const steps = buildStepsFromRaw(raw, 's', null); + expect(steps.map((x) => x.turn)).toEqual([3, 5]); // ход 4 (гейт) приклеен к 3 + }); +}); +``` + +Заменить describe `'mergeStepsPreservingText …'` на: + +```javascript +describe('mergeStepsPreservingText — выключение не затирает модельный текст (по спанам)', () => { + const raw = [ + '=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'привет достаточно длинный вопрос', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===', + '=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'второй вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '', + ].join('\n'); + it('существующий шаг спана сохраняется, пропущенный достраивается', () => { + const existing = [{ turn: 4, session: 's', text: 'Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }]; + const out = mergeStepsPreservingText(existing, raw, 's', [3, 4]); + expect(out.map((s) => s.turn)).toEqual([3, 4]); + expect(out.find((s) => s.turn === 4).text).toBe('Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —'); + expect(out.find((s) => s.turn === 3).text).toContain('Ход (промпт) 3 — я: привет'); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-layer1.test.mjs -t "по спанам"` +Expected: FAIL. + +- [ ] **Step 3: Реализовать спан-осведомлённость** + +В `tools/secretary-layer1.mjs` добавить импорт вверху (после строки 1): +```javascript +import { computeSpans } from './secretary-span.mjs'; +``` + +Заменить `buildStepsFromRaw` и `mergeStepsPreservingText` (строки 49-67) на: + +```javascript +// Реальные границы по фолбэку: ход реальный, если его [ЮЗЕР] не совпал с sysLabel-шаблонами. +// Экспортируется: stop-хук берёт её как запасной детект, если flag.realPromptTurns пуст. +export function realBoundariesFromRaw(rawText) { + return splitRawIntoTurns(rawText).filter(({ block }) => { + 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); +} + +// Пересборка Шагов из сырья ПО СПАНАМ: одна строка на реальный промпт (склейка ходов спана). +// realPromptTurns — авторитетные границы; null/пусто → фолбэк по sysLabel. +export function buildStepsFromRaw(rawText, session, realPromptTurns = null) { + const parts = splitRawIntoTurns(rawText); + if (!parts.length) return []; + const lastTurn = parts[parts.length - 1].turn; + const bounds = (Array.isArray(realPromptTurns) && realPromptTurns.length) + ? realPromptTurns : realBoundariesFromRaw(rawText); + const spans = computeSpans(bounds, lastTurn); + const byTurn = new Map(parts.map((p) => [p.turn, p.block])); + return spans.map(({ start, end }) => { + const blocks = []; + for (let t = start; t <= end; t++) if (byTurn.has(t)) blocks.push(byTurn.get(t)); + const startBlock = byTurn.get(start) || blocks[0] || ''; + const um = startBlock.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/); + const aAll = blocks.map((b) => (b.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/) || [, ''])[1]) + .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 }) }; + }); +} + +// Слияние Шагов при выключении: на КАЖДЫЙ спан берём существующий (модельный) шаг по turn-начала, +// иначе достраиваем детерминированно. Порядок — по сырью. +export function mergeStepsPreservingText(existingSteps, rawText, session, realPromptTurns = null) { + const have = new Map((Array.isArray(existingSteps) ? existingSteps : []).map((s) => [s.turn, s])); + return buildStepsFromRaw(rawText, session, realPromptTurns).map((r) => (have.has(r.turn) ? have.get(r.turn) : r)); +} +``` + +- [ ] **Step 4: Прогнать layer1 целиком — зелено** + +Run: `npx vitest run tools/secretary-layer1.test.mjs tools/secretary-span.test.mjs` +Expected: PASS (циклический импорт span↔layer1: layer1 импортирует `computeSpans`, span импортирует `splitRawIntoTurns` — ESM это выдерживает, обе функции не вызываются на этапе загрузки модуля). + +--- + +## Task 7: `buildReconcilePrompt` — подаёт действия с содержимым + +**Files:** +- Modify: `tools/secretary-reconcile.mjs:25,33-34` (формирование `acts`) +- Test: `tools/secretary-reconcile.test.mjs` + +- [ ] **Step 1: Тест** + +В `tools/secretary-reconcile.test.mjs`, в describe `'buildReconcilePrompt'`, добавить: + +```javascript + it('подаёт действия с содержимым (input/result), а не только имена', () => { + const ex = { user: 'u', assistant: 'a', actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'СОДЕРЖИМОЕ' }] }; + const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: ex }); + expect(user).toContain('Read'); + expect(user).toContain('{"f":"x"}'); + expect(user).toContain('СОДЕРЖИМОЕ'); + }); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs -t "содержимым"` +Expected: FAIL (сейчас только имена через `.map(a=>a.tool)`). + +- [ ] **Step 3: Реализовать** + +В `tools/secretary-reconcile.mjs` заменить строку 25: +```javascript + const acts = (lastExchange.actions || []).map((a) => a.tool).join(', ') || '—'; +``` +на формирование блока действий с содержимым и подстановку его в user. Заменить строку 25 на: +```javascript + const acts = ((lastExchange.actions || []).map((a) => + ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? `\n → ${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || '—'; +``` +И в массиве `user` (строка ~34) заменить `\`Действия: ${acts}\`,` на: +```javascript + `Действия (с содержимым):\n${acts}`, +``` + +- [ ] **Step 4: Прогнать reconcile-тесты — зелено** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs` +Expected: PASS (прочие тесты не зависят от формата `acts`). + +--- + +## Task 8: `buildAuditPrompt` — подаёт действия + +**Files:** +- Modify: `tools/secretary-audit.mjs:48-50` (user-строка промпта аудита) +- Test: `tools/secretary-audit.test.mjs` + +- [ ] **Step 1: Тест** + +В `tools/secretary-audit.test.mjs`, в describe `'buildAuditPrompt и LENSES'`, добавить: + +```javascript + it('подаёт действия обмена с содержимым (линзы видят, что делал ассистент)', () => { + const ex = { user: 'у', assistant: 'а', actions: [{ tool: 'Edit', input: '{"file":"f"}', result: 'ok' }] }; + const { user } = buildAuditPrompt({ hidden: [] }, ex); + expect(user).toContain('Edit'); + expect(user).toContain('{"file":"f"}'); + }); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-audit.test.mjs -t "действия обмена"` +Expected: FAIL (сейчас действия не шлются в аудит). + +- [ ] **Step 3: Реализовать** + +В `tools/secretary-audit.mjs` заменить формирование `user` (строки 48-50). Перед `const user = …` добавить сборку действий и вставить её в обмен: + +```javascript + const acts = ((ex.actions || []).map((a) => + ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${String(a.result).slice(0, 4000)}` : ''}`).join('\n')) || '—'; + const user = `СКРЫТЫЕ ВОПРОСЫ (твой реестр, действуй только над ними):\n${reg}\n\n` + + `КОНТЕКСТ ДЕЛА (без журнала ходов):\n${ctx}\n\n` + + `=== ОБМЕН ===\n[ЮЗЕР]: ${ex.user || ''}\n[АССИСТЕНТ]: ${ex.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`; +``` + +> Примечание: в reconcile действия идут БЕЗ среза (полная картина для категорий). В аудите вывод каждого действия мягко ограничен 4000 симв. в ПОКАЗЕ линзам (реестр СВ короткий, линзам не нужны мегабайты одного результата); это не трогает ни сырьё, ни reconcile. Если владелец захочет и тут без среза — снять `.slice`. + +- [ ] **Step 4: Прогнать audit-тесты — зелено** + +Run: `npx vitest run tools/secretary-audit.test.mjs` +Expected: PASS. + +--- + +## Task 9: `applyAudit` — страж-ноп (не мутировать, если не изменилось) + +**Files:** +- Modify: `tools/secretary-audit.mjs:96-99` (ветка `mutate`) +- Test: `tools/secretary-audit.test.mjs` + +- [ ] **Step 1: Тест** + +В `tools/secretary-audit.test.mjs`, после describe `'applyAudit — мутация'`, добавить: + +```javascript +describe('applyAudit — страж-ноп (не мутировать при неизменном тексте)', () => { + it('mutate с тем же текстом по норме НЕ растит родословную', () => { + const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'Вопрос про X', born: 1, lastTouch: 1, lineage: [] }], + acceptance: [], tails: [], nextSvId: 2 }; + applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: ' вопрос про x ' }] }, 5); + expect(p.hidden[0].lineage).toEqual([]); // не выросла + expect(p.hidden[0].text).toBe('Вопрос про X'); // текст не подменён мусором регистра + expect(p.hidden[0].lastTouch).toBe(5); // касание зафиксировано + expect(p.hidden[0].status).toBe('открыт'); // статус не дёрнут на «мутировал» + }); + it('mutate с реально новым текстом — как раньше (родословная растёт)', () => { + const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старая', born: 1, lastTouch: 1, lineage: [] }], + acceptance: [], tails: [], nextSvId: 2 }; + applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: 'реально другая' }] }, 7); + expect(p.hidden[0].text).toBe('реально другая'); + expect(p.hidden[0].lineage).toEqual([{ turn: 1, text: 'старая' }]); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-audit.test.mjs -t "страж-ноп"` +Expected: FAIL (текущий код растит lineage всегда). + +- [ ] **Step 3: Реализовать страж-ноп** + +В `tools/secretary-audit.mjs` заменить ветку `mutate` (строки 96-99): +```javascript + if (op.action === 'mutate' && op.newText) { + h.lineage.push({ turn: h.lastTouch, text: h.text }); + h.text = op.newText; h.status = 'мутировал'; + } +``` +на: +```javascript + if (op.action === 'mutate' && op.newText) { + const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); + if (norm(op.newText) !== norm(h.text)) { // страж-ноп: пустую пере-формулировку не пишем + h.lineage.push({ turn: h.lastTouch, text: h.text }); + h.text = op.newText; h.status = 'мутировал'; + } + } +``` + +- [ ] **Step 4: Прогнать audit-тесты — зелено** + +Run: `npx vitest run tools/secretary-audit.test.mjs` +Expected: PASS. + +> **Чекпоинт-коммит 2** (с согласия владельца): `node tools/produce-verify-receipt.mjs` → `git add tools/secretary-layer1.mjs tools/secretary-layer1.test.mjs tools/secretary-reconcile.mjs tools/secretary-reconcile.test.mjs tools/secretary-audit.mjs tools/secretary-audit.test.mjs` → `git commit -m "feat(secretary): Шаги/reconcile/аудит по спанам + страж-ноп"`. + +--- + +## Task 10: `renderProtocol` — кап родословной, «висит N промптов», «Ход (промпт) N» + +**Files:** +- Modify: `tools/secretary-protocol.mjs:48-57` (burn-счётчик), `:89-96` (рендер hidden) +- Test: `tools/secretary-protocol.test.mjs` + +- [ ] **Step 1: Тесты** + +В `tools/secretary-protocol.test.mjs` добавить: + +```javascript +describe('renderProtocol — калибровка показа скрытых вопросов', () => { + it('кап родословной: ~~первая~~ → текущая (середина скрыта, данные в JSON целы)', () => { + const p = { ...EMPTY_PROTOCOL(), + hidden: [{ id: 'СВ-1', lens: 'Л3', status: 'мутировал', text: 'нынешняя', born: 3, lastTouch: 15, + lineage: [{ turn: 3, text: 'первая' }, { turn: 9, text: 'средняя-1' }, { turn: 12, text: 'средняя-2' }] }] }; + const md = renderProtocol(p, { work: 'x', date: 'd' }); + expect(md).toContain('~~первая~~ → нынешняя'); + expect(md).not.toContain('средняя-1'); + expect(md).not.toContain('средняя-2'); + }); + it('«висит N промптов» считает спаны, прошедшие с born (не сырые ходы)', () => { + const p = { ...EMPTY_PROTOCOL(), + acceptance: [{ text: 'заявлено готово', born: 3, lastTouch: 3, done: false }] }; + // реальные промпты на ходах 3,12,15,22; текущий ход 31 → с born=3 прошло 3 промпта (12,15,22) + const md = renderProtocol(p, { work: 'x', date: 'd', turn: 31, realPromptTurns: [3, 12, 15, 22] }); + expect(md).toContain('висит 3 промптов'); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-protocol.test.mjs -t "калибровка показа"` +Expected: FAIL. + +- [ ] **Step 3: Реализовать** + +В `tools/secretary-protocol.mjs`: + +(а) Заменить функцию `burn` (строки 48-57) — счёт по спанам: +```javascript + const spanDist = (born) => { + const rp = Array.isArray(opts.realPromptTurns) ? opts.realPromptTurns : null; + if (rp && opts.turn) return rp.filter((t) => t > born && t <= opts.turn).length; + return opts.turn && born != null ? opts.turn - born : 0; // фолбэк (сырые ходы) + }; + const burn = (title, arr) => { + const live = (arr || []).filter((e) => !e.done); + if (!live.length) return; + L.push(title); + for (const e of live) { + const n = e.lastTouch != null && opts.turn && opts.turn > e.lastTouch ? spanDist(e.born) : 0; + const stale = n > 0 ? ` · висит ${n} промптов` : ''; + L.push(`- ${e.text}${e.born ? ` [→${e.born}]` : ''}${stale}`); + } + L.push(''); + }; +``` + +(б) Заменить рендер hidden (строки 90-96) — кап родословной: +```javascript + for (const h of (protocol.hidden || [])) { + const head = h.lineage && h.lineage.length + ? `~~${h.lineage[0].text}~~ → ${h.text}` // кап: только первая → текущая (данные в JSON целы) + : h.text; + const prov2 = ` [→${h.born}]` + (h.lastTouch && h.lastTouch !== h.born ? ` [${h.lastTouch}]` : ''); + L.push(`- ${h.id} [${h.lens} · ${h.status}]: ${head}${prov2}`); + } +``` + +- [ ] **Step 4: Прогнать protocol-тесты — зелено** + +Run: `npx vitest run tools/secretary-protocol.test.mjs` +Expected: PASS. ⚠️ Старый тест `'рендерит горящие блоки и скрытые вопросы с мутацией'` ждёт `~~старая~~ → новая` при `lineage:[{turn:1,text:'старая'}]` и `text:'новая'` — новый кап даёт ровно `~~старая~~ → новая` (один элемент lineage), значит проходит без правки. Если ждёт `висит` со старым форматом «ходов» — поправить на «промптов» (там `turn` не передаётся, блок «висит» пуст → не затронут). + +--- + +## Task 11: `secretary-prompt-hook.mjs` — запись границы + closing на off + +**Files:** +- Modify: `tools/secretary-prompt-hook.mjs` +- Test: `tools/secretary-prompt-hook.test.mjs` + +- [ ] **Step 1: Тест на чистую функцию решения по обычному промпту** + +Вынесем логику в чистую `planPromptTurn`. В `tools/secretary-prompt-hook.test.mjs` добавить: + +```javascript +import { planPromptTurn } from './secretary-prompt-hook.mjs'; + +describe('planPromptTurn — обычный промпт при включённом секретаре метит границу спана', () => { + it('cmd=null, секретарь on → дописать границу (turnCount+1)', () => { + const r = planPromptTurn({ cmd: null, flag: { mode: 'on', work: 'x', realPromptTurns: [3] }, turnCount: 11 }); + expect(r.flag.realPromptTurns).toEqual([3, 12]); + }); + it('cmd=null, секретарь off → ничего', () => { + const r = planPromptTurn({ cmd: null, flag: { mode: 'off' }, turnCount: 5 }); + expect(r.flag).toBeNull(); + }); + it('cmd=off → флажок mode:closing с сохранением полей', () => { + const r = planPromptTurn({ cmd: 'off', flag: { mode: 'on', work: 'дело', realPromptTurns: [3, 12], spanCursor: 0, session: 's' }, turnCount: 20 }); + expect(r.flag.mode).toBe('closing'); + expect(r.flag.work).toBe('дело'); + expect(r.flag.realPromptTurns).toEqual([3, 12]); + }); +}); +``` + +- [ ] **Step 2: Прогнать — падает** + +Run: `npx vitest run tools/secretary-prompt-hook.test.mjs -t planPromptTurn` +Expected: FAIL (нет экспорта). + +- [ ] **Step 3: Реализовать `planPromptTurn` и подключить в main** + +В `tools/secretary-prompt-hook.mjs` добавить импорт (после строки 9): +```javascript +import { recordRealPrompt } from './secretary-span.mjs'; +``` +Добавить чистую функцию (рядом с `planActivation`): +```javascript +// Решение хука на обычный промпт / выключение по отношению к границам спанов. +// cmd: 'on'|'off'|null; flag — текущий флажок; turnCount — число ходов в сырье. +// Возврат { flag: <новый флажок для записи> | null }. +export function planPromptTurn({ cmd, flag, turnCount }) { + if (cmd === 'off') { + // НЕ гасим сразу: финальный открытый спан разберёт ближайший Stop (у него таймаут 15 мин). + return { flag: { ...(flag || {}), mode: 'closing' } }; + } + if (cmd == null && flag && flag.mode === 'on') { + return { flag: recordRealPrompt(flag, turnCount + 1) }; + } + return { flag: null }; +} +``` +В `main()`: ветка `on` — без изменений. Заменить хвост `main` (обработку `off` и отсутствие команды). После вычисления `cmd`: +- Если `cmd` отсутствует (`!cmd`): прочитать флажок, вызвать `planPromptTurn({cmd:null, flag, turnCount})`, при `r.flag` — записать флажок, `exit 0`. +- Ветку `else if (cmd === 'off')`: заменить тело на установку `closing` (без нарезки — нарезку перенесём в stop-hook): +```javascript + } else if (cmd === 'off') { + let flag = {}; + try { flag = JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { flag = {}; } + const r = planPromptTurn({ cmd: 'off', flag, turnCount: turnCount(rawFile) }); + try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ } + } +``` +И в начале `main`, ПОСЛЕ `if (!cmd) { … }` — заменить ранний `process.exit(0)` на запись границы: +```javascript + if (!cmd) { + let flag = {}; + try { flag = JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { flag = {}; } + const r = planPromptTurn({ cmd: null, flag, turnCount: turnCount(rawFile) }); + if (r.flag) { try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ } } + process.exit(0); + } +``` +(`rawFile`/`secdir` вычисляются выше — поднять их объявление до этой ветки.) + +Удалить импорт `prepareTurnFiles, buildStepsFromRaw, mergeStepsPreservingText` и `renderProtocol` из prompt-hook (нарезка ушла в stop-hook) — но оставить, если main ветка `off` их больше не использует. Проверить, что неиспользуемых импортов не осталось (reviewer флагует мёртвые импорты). + +- [ ] **Step 4: Прогнать prompt-hook-тесты — зелено** + +Run: `npx vitest run tools/secretary-prompt-hook.test.mjs` +Expected: PASS (`planActivation`-тесты не затронуты). + +--- + +## Task 12: `secretary-stop-hook.mjs` — отложенный разбор спанов + финализация + +**Files:** +- Modify: `tools/secretary-stop-hook.mjs` (оркестрация) + +> Stop-хук — тонкий shell; чистая логика уже в `secretary-span.mjs` (тестируется). Здесь — проводка. Юнит-тестов у хука нет (как и сейчас); проверка — полным сводом (Task 13) + живым прогоном (Task 14). + +- [ ] **Step 1: Переписать оркестрацию stop-hook** + +В `tools/secretary-stop-hook.mjs`: + +(а) Импорты — добавить: +```javascript +import { spansToDistill, assembleSpan, computeSpans } from './secretary-span.mjs'; +import { prepareTurnFiles, mergeStepsPreservingText } from './secretary-layer1.mjs'; +``` + +(б) После записи сырья (строка ~50), заменить блок «Тетрадь (Слой 2)» так, чтобы: +- читать `flag` (как сейчас); если `flag.mode` не `on` и не `closing` → `exit 0`. +- прочитать `proto`, снимок реестра — как сейчас. +- определить `closing = flag.mode === 'closing'`. +- границы: `bounds = flag.realPromptTurns` (фолбэк — `realBoundariesFromRaw`, см. ниже); курсор `cursor = flag.spanCursor ?? -1`. +- список спанов к разбору: `spansToDistill(bounds, turn, cursor)`. Если `closing` — добавить и последний открытый спан как закрытый (force-close): взять `computeSpans(bounds, turn)`, последний элемент, если его `index > cursor`, дописать в список. +- для каждого спана по порядку: `ex = assembleSpan(rawFileText, span)`; `reconcileTurn(... ex ...)` (turn = span.start); шаг спана `buildStepLine({turn:span.start, endTurn:span.end, …, essence})`; `mergeTurnIntoProtocol`; `preserveRegistry`; аудит на `ex`; `applyAudit(..., span.start)`; `collapseProtocol`; продвинуть `cursor = span.index`. +- записать `proto`, `protocol.md` (передать `{ work, date, turn, realPromptTurns: bounds }` в `renderProtocol`), индекс. +- записать обратно `flag.spanCursor = cursor` (а при `closing` — финализация ниже). + +(в) Финализация при `closing` (после разбора всех спанов): +```javascript + if (closing) { + // Нарезка сырья на файлы ходов (по ходу) + Шаги по спанам. + const rawText = readFileSync(rawFile, 'utf-8'); + finalProto.steps = mergeStepsPreservingText(finalProto.steps, rawText, session, bounds); + const { files, steps } = prepareTurnFiles(rawText, finalProto); + const hodyDir = join(workDir, 'ходы'); + mkdirSync(hodyDir, { recursive: true }); + for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8'); + finalProto.steps = steps; + // переписать protocol.json/md уже со срезами-ссылками + writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2)); + writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); + // погасить флажок + try { writeFileSync(join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)), JSON.stringify({ mode: 'off' })); } catch { /* ignore */ } + } else { + // обычный ход: сохранить продвинутый курсор в флажок + try { + const f = readFlag(session); + writeFileSync(join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)), JSON.stringify({ ...f, spanCursor: cursor })); + } catch { /* ignore */ } + } +``` + +(г) Импортировать `writeFileSync` и `secretaryModeFileName` (последний уже импортируется через flag? — добавить из `./secretary-flag.mjs`). Добавить `realBoundariesFromRaw` — вынести её из layer1 в экспорт и импортировать сюда (она уже определена в Task 6; экспортировать её: `export function realBoundariesFromRaw`). Обновить импорт в stop-hook. + +> Точные строки расставит исполнитель по месту; контракт функций зафиксирован выше и в Task 2/4/6. Шаг-формулировку (`step`) reconcile использовать как `essence` для `buildStepLine` (как сейчас в строках 104-108, но на спан). + +- [ ] **Step 2: Синтаксис-проверка** + +Run: `node --check tools/secretary-stop-hook.mjs` +Expected: без ошибок. + +- [ ] **Step 3: Дымовой прогон хука вручную (без ключа — только Слой 1 и проводка)** + +Подготовить мини-транскрипт и вызвать хук с `SECRETARY_LLM_KEY` пустым; убедиться, что сырьё пишется и хук не падает. (Команда — по месту; ключевое: `process.exit(0)` и отсутствие исключений.) + +> Полная зелёность — Task 13; смысловая проверка разбора — Task 14 (живой прогон). + +--- + +## Task 13: Полный свод зелёный + +**Files:** все тесты секретаря. + +- [ ] **Step 1: Прогнать полный свод** + +Run: +``` +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 +``` +Expected: PASS (все). + +- [ ] **Step 2: Починить расхождения** + +Если какой-то существующий тест упал из-за смены формата (например, остаточные «Ход N» без «(промпт)») — поправить тест/код по контрактам выше. Зелёность = весь свод exit 0. + +> **Чекпоинт-коммит 3** (с согласия владельца): `node tools/produce-verify-receipt.mjs` → `git add tools/secretary-protocol.mjs tools/secretary-protocol.test.mjs tools/secretary-prompt-hook.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-stop-hook.mjs` → `git commit -m "feat(secretary): отложенный разбор по спанам (хуки) + калибровка показа"`. + +--- + +## Task 14: Живой прогон (приёмка) + +**Files:** новое дело секретаря (тетрадь, не код). + +- [ ] **Step 1: Включить секретаря на новое дело и прогнать несколько реальных промптов с гейт-петлёй** + +С реальным `SECRETARY_LLM_KEY` (и `SECRETARY_LLM_BASE_URL`/`MODEL`, если заданы): «включи секретаря <новое-имя>», затем 3-4 содержательных промпта, среди которых есть гейт-дёрганья (служебные ходы), затем «выключи секретаря». + +- [ ] **Step 2: Проверить тетрадь дела по критериям приёмки** + +Открыть `docs/secretary/<дело>/protocol.md`. Убедиться: +- (а) «Шаги» — по одному на реальный промпт («Ход (промпт) N», при склейке — «[вобрал ходы X-Y]»); +- (б) гейт-шум НЕ плодит скрытые вопросы (нет простыней-наслоений); +- (в) настоящие находки аудитора присутствуют по одному разу (надзор не оскоплён); +- (г) `docs/secretary/<дело>/ходы/turn-N.log` — все ходы дословно, с полным содержимым действий (обрез снят); +- (д) счётчики «висит N промптов». + +- [ ] **Step 3: Прибрать временные артефакты** + +Удалить превью-файлы задачи (`docs/secretary/протокол/protocol.preview-spans.md`, `.json` если есть), пробное дело Task 14 (по решению владельца), прочий мусор `.scratch/`. Не оставлять хвостов (CLAUDE.md п.11). + +> **Финальный коммит** (с согласия владельца): `node tools/produce-verify-receipt.mjs` → `git add` затронутого → `git commit` → `git push gitea main` (через скрипт-финализатор с `LEFTHOOK=0`, см. handoff). + +--- + +## Самопроверка плана (coverage спеки) + +- Граница спана по UserPromptSubmit + fallback sysLabel → Task 11 (`planPromptTurn`/`recordRealPrompt`), Task 6 (`realBoundariesFromRaw`), Task 12 (проводка). +- Отложенный разбор закрытых спанов, курсор → Task 2 (`spansToDistill`), Task 12. +- Полный кусок модели (reconcile+аудит видят действия) → Task 7, Task 8. +- Слой 1 полный (обрез снят) + защита меток → Task 1. +- Сборка куска из сырья → Task 4 (`assembleSpan`). +- Шаги по спанам «Ход (промпт) N [вобрал ходы X-Y]» → Task 5, Task 6. +- Провенанс/счётчик «висит N промптов» → Task 10. +- Калибровка СВ (страж-ноп + кап показа) → Task 9, Task 10. +- Хвост последнего спана (closing на off) → Task 11, Task 12. +- Приёмка живым прогоном → Task 14. diff --git a/docs/superpowers/specs/2026-06-23-secretary-span-redesign-design.md b/docs/superpowers/specs/2026-06-23-secretary-span-redesign-design.md new file mode 100644 index 0000000..c71509b --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-secretary-span-redesign-design.md @@ -0,0 +1,261 @@ +# Спека: секретарь — нарезка по реальным промптам владельца (спаны) + +**Дата:** 2026-06-23 · **Репозиторий:** `claude-brain` · **Ветка:** `main` +**Источник задачи:** `docs/secretary/_HANDOFF-span-redesign.md` +**Режим:** штатный (стен нет; пол + проверка-перед-пушем остаются). Процесс: эта спека → `writing-plans` → TDD. + +--- + +## Цель + +Секретарь сейчас разбирает разговор **по каждому Stop-ходу**. Системные впрыски +(гейт-фидбек, загрузка навыка, ожидания) считаются отдельными ходами — их текст летит в LLM +как «слова владельца». От этого reconcile деградирует, а аудитор по 6–10 раз пере-мутирует один +вопрос («Скрытые вопросы» превращаются в простыни наслоений — видно в текущей +`docs/secretary/протокол/protocol.md`). + +**Новая единица разбора — спан:** один реальный промпт владельца + ВСЯ активность ассистента до +следующего реального промпта. Системные ходы не отдельные единицы — они приклеиваются к спану. +Разбор спана **отложенный**: копим до прихода следующего реального промпта, затем разбираем +завершённый спан целиком. Модели (и reconcile, и аудитору) отдаётся **весь склеенный спан** +(промпт + все ответы + все действия с полным содержимым) — без склейки агент не поймёт работу. + +--- + +## Что НЕ трогаем (границы задачи) + +- **Дедуп протокола** (`collapseProtocol`/`canonicalClauses`) — готов, не трогаем. +- **Механизм `step`-формулировки** (поле `step{user,assistant}` в reconcile) — используем, переносим + на спан. +- **Изоляция реестра СВ от reconcile** (`preserveRegistry`, снимок ДО reconcile) — оставляем. +- **Существующие живые тетради** (по-ходовые) — оставляем как есть, ретро-конвертации НЕТ. +- **Пофайловая нарезка сырья** на `<дело>/ходы/turn-N.log` при выключении — остаётся **по ходу** + (страховка), меняется только витрина «Шаги». + +--- + +## Принцип нарезки (детально) + +### Что считается «реальным промптом» (граница спана) + +**Первичный сигнал — событие `UserPromptSubmit`.** Хук `secretary-prompt-hook.mjs` срабатывает +ТОЛЬКО когда владелец реально набрал текст. Служебные впрыски (гейт-фидбек, загрузка навыка, +авто-продолжения) это событие НЕ вызывают. Это и есть честная граница спана — не угадывание по +тексту. + +- При обычном промпте (не команда «включи/выключи»), пока секретарь включён, `prompt-hook` + дописывает номер хода-начала спана в авторитетный список границ. +- Номер хода-начала = `turnCount(rawFile) + 1` (та же формула, что в stop-хуке: при + `UserPromptSubmit` сырьё этого хода ещё не записано, его запишет ближайший Stop как turn N+1). + +**Запасной сигнал (fallback) — классификация по содержанию.** Если список границ недоступен/неполон +(секретарь включили в середине сессии; файл-флажок потерян), stop-хук вычисляет границы из сырья +сам: реальный промпт = ход, чей `[ЮЗЕР]` НЕ совпал с шаблонами `sysLabel` +(`^Stop hook feedback`, `^Base directory for this skill`). Это страховка, не основной путь. + +### Хранение границ и курсора + +Границы спанов и курсор «докуда разобрано» — **сессионные** (нумерация ходов в сыром логе +`raw/.log` начинается с 1 в каждой сессии; провенанс `[→N]` и так сессионный, с +разделителем «—— сессия X ——»). Храним в файле-флажке сессии +`~/.claude/runtime/secretary-mode-.json` (он уже существует и читается/пишется хуками): + +- `realPromptTurns: number[]` — авторитетный список ходов-начал спанов (пишет `prompt-hook`). +- `spanCursor: number` — индекс последнего разобранного спана (пишет `stop-hook`). + +### Спан: открытый и закрытый + +- Спаны = отрезки `[realPromptTurns[i] .. realPromptTurns[i+1]-1]`. +- Все спаны, кроме последнего, — **закрытые** (пришёл следующий реальный промпт). +- Последний спан (от последней границы до текущего хода) — **открытый**. +- Цена: тетрадь обновляется на один реальный промпт позади. Это ОК (проговорено с владельцем). + +--- + +## Отложенный разбор (stop-hook — главная переделка) + +На каждом Stop при включённом секретаре: + +1. **Слой 1 (сырьё) — пишем ВСЕГДА**, по каждому ходу, как сейчас (но с полным содержимым + действий — см. ниже «Слой 1 — полная картина»). +2. Вычисляем спаны (из `realPromptTurns` + fallback). Находим **закрытые** спаны с индексом + `> spanCursor`. +3. Для каждого нового закрытого спана по порядку: + - **Собираем спан** из сырья (полное содержимое): `user` = текст реального промпта (ход-начало); + `assistant` = склейка всех ответов ассистента по ходам спана; `actions` = все действия по + ходам спана (с полными `input`/`result`). + - **reconcile** по собранному спану (`reconcileTurn` на склеенном обмене; `buildReconcilePrompt` + теперь подаёт действия с содержимым, не только имена — см. §2.4 хендоффа). + - **одна строка «Шаги»** на спан (суть из `step` reconcile или фолбэк). + - **аудит** по собранному спану (`buildAuditPrompt` теперь подаёт действия) + страж-ноп + (см. «Калибровка СВ»). + - `collapseProtocol`, продвигаем `spanCursor`. +4. Пишем `protocol.json`/`protocol.md` + индекс. Если новых закрытых спанов нет — пишем только + сырьё, тетрадь не трогаем (отставание на один промпт). + +**Нумерация спана = номер его хода-начала** (совпадает с форматом `[→N]` в превью и с текущим +провенансом). Отдельный сквозной индекс спана не вводим. + +--- + +## Что отдаём модели (полный спан) + +Источник содержимого — **Слой 1 (сырьё), теперь полный**. Собранный обмен спана: + +- `user` — текст реального промпта (ход-начало спана); +- `assistant` — склейка ВСЕХ ответов ассистента за спан; +- `actions` — ВСЕ действия за спан, с полными `input` и `result`. + +`buildReconcilePrompt` и `buildAuditPrompt` обновляются: оба подают действия с содержимым +(сейчас reconcile шлёт только имена инструментов, аудит — не шлёт действий вовсе). Это сердце +задачи: секретарь обязан видеть всё, что делал ассистент, чтобы линзами ловить ошибки/пропуски. + +**Без обрезки.** Размер склейки не ограничиваем (решение владельца: «нужна полная картина»). +Предохранитель на гигантский вывод — отдельной задачей, если понадобится. + +--- + +## Слой 1 — полная картина (правка с разрешения владельца) + +Сейчас `parseLastExchange` обрезает каждый вывод действия до `MAX_RESULT_CHARS = 1200`. Это прячет +часть работы и от аудитора, и из черновика. + +- **Снимаем обрез** (`MAX_RESULT_CHARS`): сырьё хранит полные выводы действий. Владелец явно + разрешил задеть Слой 1: «нужна полная картина». +- Существующий тест `secretary-transcript.test.mjs` «длинный результат усечён … оканчивается + маркером …» — честно переписывается (полное содержимое, без обрезки). +- Запись сырья (`buildRawRecord`, append по ходу) и пофайловая нарезка `ходы/turn-N.log` — формат + не меняем, меняется только полнота содержимого. +- Цена: файлы-черновики крупнее (полные выводы). Для текстовых логов терпимо. + +--- + +## Шаги (витрина) — одна строка на спан + +Раздел «Шаги (Слой 1)» — одна строка на **реальный промпт (спан)**: + +``` +- Ход (промпт) N [вобрал ходы X-Y] — я: <суть промпта> · ты: <суть всего спана> · делал: <все инструменты спана> · <ссылка на сырьё> +``` + +- `N` — ход-начало спана. `[вобрал ходы X-Y]` — только когда спан охватил больше одного хода. +- Суть — модельная (`step`) по всему спану; фолбэк — детерминированная из первого/склейки. +- `делал` — объединённый список инструментов всех ходов спана. +- Сырьё (`<дело>/ходы/turn-N.log`) остаётся **по ходу**; ссылка ведёт на ход-начало спана. +- При «выключи секретаря» пересборка «Шагов» — **по спанам** (`buildStepsFromRaw`/ + `mergeStepsPreservingText` становятся спан-осведомлёнными), нарезка файлов — по ходу. + +--- + +## Провенанс и счётчики — по спанам + +- `born`/`lastTouch`/`turns` в корзинах и реестре СВ — номера ходов-начал спанов (как сейчас, но + спаны вместо сырых ходов). +- Счётчик «висит N ходов» в горящих блоках (Л8/Л9) → **«висит N промптов»**: считаем число + реальных промптов (спанов), прошедших с `born`, а не сырые ходы (иначе гейт-дёрганья врут цифру). + Источник счёта — `realPromptTurns`. + +--- + +## Калибровка «Скрытых вопросов» (надзор НЕ оскоплять) + +Группировка по спанам уже резко снижает спам (один разбор на промпт вместо разбора каждой +гейт-нагалки). Правило: **каждую НАСТОЯЩУЮ находку — сохранить ОДИН раз; резать только +дубли-наслоения и гейт-шум, НЕ под ноль.** Две точечные правки: + +1. **Страж-ноп в `applyAudit`** (`secretary-audit.mjs`): при `action === 'mutate'` НЕ мутировать, + если `newText` по норме (`trim().toLowerCase().replace(/\s+/g,' ')`) равен текущему `h.text`. + Тогда `lineage` не растёт пустыми пере-формулировками; `lastTouch` можно обновить (касание было). +2. **Кап родословной в показе** (`renderProtocol`, рендер `hidden`): показывать + `~~первая~~ → текущая` (первое звено `lineage` + текущий текст), середину прятать. **Данные + (полный `lineage`) в `protocol.json` НЕ трогаем** — режем только показ. + +**Контроль приёмки (что обязано выжить на живом прогоне):** в ручной модели мы случайно срезали +~4 живых вопроса (Л7 «память кругов» = функция vs данные; Л3 дрейф — просил реальное, дал +реконструкцию; Л4 «чистая функция» без пруфа; уход от coverage после gate-3). На живом прогоне +эти находки обязаны выживать (по одному разу). + +--- + +## Хвост последнего спана + +- При «выключи секретаря» (`prompt-hook off`): разобрать финальный **открытый** спан ДО нарезки + файлов и пересборки «Шагов». +- Аварийный вылет окна (нет «выключи»): последний открытый спан остаётся в сырье дословно + (данные целы), без выжимки. Триггера «закрытие сессии» в коде нет — лишний механизм ради редкого + случая не вводим (решение владельца: «попроще»). + +--- + +## Карта файлов и изменений + +| Файл | Что меняется | +|---|---| +| `tools/secretary-transcript.mjs` | Снять обрез `MAX_RESULT_CHARS` (полное содержимое в сырьё и в спан). | +| `tools/secretary-flag.mjs` | Хелперы записи/чтения `realPromptTurns` и `spanCursor` (если выносим из хуков). | +| `tools/secretary-prompt-hook.mjs` | На обычном промпте (вкл-секретарь) дописывать границу спана; на «off» — разобрать финальный спан; пересборка «Шагов» по спанам. | +| `tools/secretary-stop-hook.mjs` | Отложенная нарезка: вычислить спаны, разобрать закрытые (reconcile+аудит на склеенном спане), продвинуть курсор. | +| `tools/secretary-layer1.mjs` | Сборка спана из сырья (полная); `buildStepLine`/`buildStepsFromRaw`/`mergeStepsPreservingText` — спан-осведомлённые («Ход (промпт) N [вобрал ходы X-Y]»). | +| `tools/secretary-reconcile.mjs` | `buildReconcilePrompt` подаёт действия с содержимым; провенанс по спанам. | +| `tools/secretary-audit.mjs` | `buildAuditPrompt` подаёт действия; страж-ноп в `applyAudit`. | +| `tools/secretary-protocol.mjs` | Кап родословной в показе; «висит N промптов»; «Ход (промпт) N» в Шагах. | +| `tools/secretary-*.test.mjs` | TDD: тесты на сборку спана, нарезку, страж-ноп, кап показа, счётчик промптов, полное сырьё. | + +--- + +## Контракты ключевых функций (для TDD) + +- `assembleSpan(rawText, spanStartTurn, spanEndTurn, session) → { user, assistant, actions }` — + склейка обмена спана из сырья (полное содержимое; `user` из хода-начала; `assistant`/`actions` + объединены по ходам отрезка). Новая функция в `secretary-layer1.mjs`. +- `computeSpans(realPromptTurns, lastTurn) → [{ start, end, open }]` — отрезки спанов; последний + `open:true`. Чистая функция (где разместить — решит план). +- `recordRealPrompt(flag, turn) → flag'` — добавить границу в `realPromptTurns` (идемпотентно). +- `buildStepLine` / `buildStepsFromRaw` / `mergeStepsPreservingText` — теперь на спан: метка + «Ход (промпт) N [вобрал ходы X-Y]». +- `applyAudit` — `mutate` с равным по норме `newText` НЕ растит `lineage` (страж-ноп). +- `renderProtocol` (hidden) — кап показа `~~первая~~ → текущая`. +- `parseLastExchange` — без обрезки результата. + +--- + +## Edge-cases + +- **Несколько закрытых спанов за один Stop** (редко, но возможно при быстрой череде промптов) — + цикл по всем закрытым `> spanCursor`, по порядку. +- **Секретарь включён в середине сессии** — границы пишутся с момента «включи»; ранние ходы не + разбираются; первый спан = первый реальный промпт после «включи» (fallback по `sysLabel` + страхует, если список пуст). +- **Спан из одного хода** (промпт без гейт-петли) — `[вобрал ходы …]` не показываем. +- **Параллельные сессии** — `realPromptTurns`/`spanCursor` сессионные (файл-флажок по сессии), + не топчут друг друга. +- **Срыв reconcile на спане** — как сейчас: категории заморожены, но «Шаги» и сырьё целы; курсор + всё равно продвигается (спан не разберём дважды; срыв виден в `_reconcile.log`). + +--- + +## Приёмка + +- Полный свод секретаря — зелёный: + ``` + 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 + ``` +- **ЖИВОЙ прогон** с реальным `SECRETARY_LLM_KEY` на новом деле в несколько реальных промптов с + гейт-петлёй внутри: (а) «Шаги» — по одному на реальный промпт; (б) гейт-шум не плодит скрытые + вопросы; (в) настоящие находки аудитора выживают по одному разу (надзор не оскоплён); (г) Слой 1 + хранит все ходы дословно и теперь с полным содержимым действий. +- Ничего из работы/диалога не теряется — сверить со старой тетрадью. + +--- + +## Открытые вопросы §4 хендоффа — решения + +1. **Буфер спана** — отдельного файла НЕТ. Источник правды — Слой 1 (сырьё, теперь полное); + границы — `realPromptTurns` в файле-флажке сессии; курсор — `spanCursor` там же. Переживает + перезапуск (файлы на диске). +2. **Закрытие последнего спана** — при «выключи секретаря». SessionEnd-триггера в коде нет; не + вводим. +3. **Большой спан** — без обрезки (решение владельца). Предохранитель — отдельно при нужде. +4. **Счётчики** — «висит N промптов» по `realPromptTurns`, не по сырым ходам. +5. **Существующие тетради** — оставляем как есть. +6. **Калибровка lineage** — в этой же спеке (страж-ноп + кап показа). diff --git a/tools/secretary-audit.mjs b/tools/secretary-audit.mjs index 5f8ea1d..cdd8933 100644 --- a/tools/secretary-audit.mjs +++ b/tools/secretary-audit.mjs @@ -45,9 +45,11 @@ export function buildAuditPrompt(proto, ex) { `ВОЛЯ/ЗАПРЕТЫ:\n${fmt(proto.will, (w) => `- ${w.text}`)}`, `ЯВНЫЕ ОТКРЫТЫЕ ВОПРОСЫ:\n${fmt(proto.open, (o) => `- ${o.text}`)}`, ].filter(Boolean).join('\n\n'); + const acts = ((ex.actions || []).map((a) => + ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${String(a.result).slice(0, 4000)}` : ''}`).join('\n')) || '—'; const user = `СКРЫТЫЕ ВОПРОСЫ (твой реестр, действуй только над ними):\n${reg}\n\n` + `КОНТЕКСТ ДЕЛА (без журнала ходов):\n${ctx}\n\n` - + `=== ОБМЕН ===\n[ЮЗЕР]: ${ex.user || ''}\n[АССИСТЕНТ]: ${ex.assistant || ''}`; + + `=== ОБМЕН ===\n[ЮЗЕР]: ${ex.user || ''}\n[АССИСТЕНТ]: ${ex.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`; // callAnthropicAPI ждёт { system, user } (как reconcile), НЕ массив сообщений — иначе API 400. return { system, user }; } @@ -94,8 +96,11 @@ export function applyAudit(proto, parsed, turn) { const h = byId[op.id]; if (!h) continue; if (op.action === 'mutate' && op.newText) { - h.lineage.push({ turn: h.lastTouch, text: h.text }); - h.text = op.newText; h.status = 'мутировал'; + const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); + if (norm(op.newText) !== norm(h.text)) { // страж-ноп: пустую пере-формулировку не пишем + h.lineage.push({ turn: h.lastTouch, text: h.text }); + h.text = op.newText; h.status = 'мутировал'; + } } if (op.action === 'close') h.status = op.silent ? 'тихо-закрыт' : 'закрыт'; // partial: статус остаётся 'открыт' (или 'мутировал'), только lastTouch ниже diff --git a/tools/secretary-audit.test.mjs b/tools/secretary-audit.test.mjs index 433bb08..769d8a3 100644 --- a/tools/secretary-audit.test.mjs +++ b/tools/secretary-audit.test.mjs @@ -25,6 +25,25 @@ describe('applyAudit — мутация', () => { }); }); +describe('applyAudit — страж-ноп (не мутировать при неизменном тексте)', () => { + it('mutate с тем же текстом по норме НЕ растит родословную', () => { + const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'Вопрос про X', born: 1, lastTouch: 1, lineage: [] }], + acceptance: [], tails: [], nextSvId: 2 }; + applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: ' вопрос про x ' }] }, 5); + expect(p.hidden[0].lineage).toEqual([]); // не выросла + expect(p.hidden[0].text).toBe('Вопрос про X'); // текст не подменён мусором регистра + expect(p.hidden[0].lastTouch).toBe(5); // касание зафиксировано + expect(p.hidden[0].status).toBe('открыт'); // статус не дёрнут на «мутировал» + }); + it('mutate с реально новым текстом — как раньше (родословная растёт)', () => { + const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старая', born: 1, lastTouch: 1, lineage: [] }], + acceptance: [], tails: [], nextSvId: 2 }; + applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: 'реально другая' }] }, 7); + expect(p.hidden[0].text).toBe('реально другая'); + expect(p.hidden[0].lineage).toEqual([{ turn: 1, text: 'старая' }]); + }); +}); + // Task 4: закрытие, тихое закрытие, partial describe('applyAudit — close/partial', () => { it('close/тихое/partial выставляют статус', () => { @@ -99,6 +118,12 @@ describe('buildAuditPrompt и LENSES', () => { expect(user).toContain('решили B'); expect(user).toContain('явный вопрос X'); }); + it('подаёт действия обмена с содержимым (линзы видят, что делал ассистент)', () => { + const ex = { user: 'у', assistant: 'а', actions: [{ tool: 'Edit', input: '{"file":"f"}', result: 'ok' }] }; + const { user } = buildAuditPrompt({ hidden: [] }, ex); + expect(user).toContain('Edit'); + expect(user).toContain('{"file":"f"}'); + }); }); // Изоляция реестра от reconcile: версию reconcile игнорируем, берём снимок ДО reconcile diff --git a/tools/secretary-layer1.mjs b/tools/secretary-layer1.mjs index f8ff8ef..316f99c 100644 --- a/tools/secretary-layer1.mjs +++ b/tools/secretary-layer1.mjs @@ -1,10 +1,16 @@ +import { computeSpans } from './secretary-span.mjs'; + // Обезвреживание маркеров внутри полезного текста: если в реплике/действии встретились те же // строки-разделители (цитата хода, тест-фикстура), ломаем их, чтобы счётчик ходов и нарезка // не считали их за настоящие границы (самозагрязнение лога при чтении/цитировании самого лога). function neutralizeMarkers(s) { return String(s ?? '') .replace(/=== ХОД turn=/g, '=≡ ХОД turn=') - .replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡='); + .replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡=') + // структурные метки блока: ломаем только в начале строки (реальные ставит buildRawRecord), + // чтобы полный вывод действия не подделал границы при обратном разборе сырья. + // Пробел перед «]» → «[ЮЗЕР ]» уже не совпадёт с разбором «^[ЮЗЕР]». + .replace(/^\[(ЮЗЕР|АССИСТЕНТ|ДЕЙСТВИЕ|ВЫДАЧА)\]/gm, '[$1 ]'); } // Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи; @@ -46,30 +52,50 @@ export function prepareTurnFiles(rawText, protocol = {}) { return { files, steps }; } -// Пересборка Шагов из общего сырья: по строке на КАЖДЫЙ ход (хук пишет Шаг только во вкл-ходы, -// поэтому на остановке собираем все ходы из Слоя 1 — чтобы в Шагах не было пропусков). -export function buildStepsFromRaw(rawText, session) { - return splitRawIntoTurns(rawText).map(({ turn, block }) => { +// Реальные границы по фолбэку: ход реальный, если его [ЮЗЕР] не совпал с sysLabel-шаблонами. +// Экспортируется: stop-хук берёт её как запасной детект, если flag.realPromptTurns пуст. +export function realBoundariesFromRaw(rawText) { + return splitRawIntoTurns(rawText).filter(({ block }) => { const um = block.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/); - const am = block.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/); - const actions = [...block.matchAll(/\[ДЕЙСТВИЕ\]\s+(\S+)/g)].map((x) => x[1]); - return { turn, session, - text: buildStepLine({ turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions }) }; + 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); +} + +// Пересборка Шагов из сырья ПО СПАНАМ: одна строка на реальный промпт (склейка ходов спана). +// realPromptTurns — авторитетные границы; null/пусто → фолбэк по sysLabel. +export function buildStepsFromRaw(rawText, session, realPromptTurns = null) { + const parts = splitRawIntoTurns(rawText); + if (!parts.length) return []; + const lastTurn = parts[parts.length - 1].turn; + const bounds = (Array.isArray(realPromptTurns) && realPromptTurns.length) + ? realPromptTurns : realBoundariesFromRaw(rawText); + const spans = computeSpans(bounds, lastTurn); + const byTurn = new Map(parts.map((p) => [p.turn, p.block])); + return spans.map(({ start, end }) => { + const blocks = []; + for (let t = start; t <= end; t++) if (byTurn.has(t)) blocks.push(byTurn.get(t)); + const startBlock = byTurn.get(start) || blocks[0] || ''; + const um = startBlock.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/); + const aAll = blocks.map((b) => (b.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/) || [, ''])[1]) + .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 }) }; }); } -// Слияние «Шагов» при выключении: на КАЖДЫЙ ход из сырья берём существующий шаг (модельная -// формулировка) если он есть, иначе достраиваем детерминированно из сырья. Порядок — по сырью -// (хронология); модельный текст переживает выключение/нарезку. -export function mergeStepsPreservingText(existingSteps, rawText, session) { +// Слияние «Шагов» при выключении: на КАЖДЫЙ спан берём существующий (модельный) шаг по turn-начала, +// иначе достраиваем детерминированно. Порядок — по сырью. +export function mergeStepsPreservingText(existingSteps, rawText, session, realPromptTurns = null) { const have = new Map((Array.isArray(existingSteps) ? existingSteps : []).map((s) => [s.turn, s])); - return buildStepsFromRaw(rawText, session).map((r) => (have.has(r.turn) ? have.get(r.turn) : r)); + return buildStepsFromRaw(rawText, session, realPromptTurns).map((r) => (have.has(r.turn) ? have.get(r.turn) : r)); } // Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …». // Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены; // «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер. -export function buildStepLine({ turn, user, assistant, actions = [], essence = null } = {}) { +export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null } = {}) { // Содержательная фраза: убираем ведущую нумерацию списка («1.»/«2)»), копим до ≥25 симв., // чтобы не выдать обрывок «Стоп.»; длинное усекаем. const firstSentence = (s) => { @@ -96,7 +122,8 @@ export function buildStepLine({ turn, user, assistant, actions = [], essence = n const u = eU || sysLabel(user) || firstSentence(user) || '(без вопроса)'; const a = eA || firstSentence(cleanA) || '(без ответа)'; const did = [...new Set((actions || []).map((t) => String(t).trim()).filter(Boolean))].join(', ') || '—'; - return `Ход ${turn} — я: ${u} · ты: ${a} · делал: ${did}`; + const span = (endTurn != null && endTurn > turn) ? ` [вобрал ходы ${turn}-${endTurn}]` : ''; + return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}`; } 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 05f388c..9b02836 100644 --- a/tools/secretary-layer1.test.mjs +++ b/tools/secretary-layer1.test.mjs @@ -12,20 +12,37 @@ describe('обезвреживание маркеров на записи (от expect((rec.match(/=== ХОД turn=/g) || []).length).toBe(1); // только реальный заголовок expect((rec.match(/=== КОНЕЦ ХОДА ===/g) || []).length).toBe(1); // только реальный конец }); + it('структурные метки внутри содержимого обезврежены (полный вывод не ломает разбор)', () => { + const rec = buildRawRecord({ + turn: 1, time: 't', session: 's', + user: 'u', assistant: 'a', + actions: [{ tool: 'Read', input: 'x', result: '[ДЕЙСТВИЕ] Edit\n[ВЫДАЧА] Edit\n[ЮЗЕР]\n[АССИСТЕНТ]' }], + }); + // в записи остаётся ровно один реальный набор маркеров действия (из buildRawRecord), + // подделки из result не считаются за структурные. + expect((rec.match(/^\[ДЕЙСТВИЕ\] /gm) || []).length).toBe(1); + expect((rec.match(/^\[ВЫДАЧА\] /gm) || []).length).toBe(1); + expect(rec).not.toMatch(/^\[ЮЗЕР\]\n\[АССИСТЕНТ\]$/m); + }); }); -describe('buildStepsFromRaw — Шаг на КАЖДЫЙ ход (пересборка на остановке)', () => { +describe('buildStepsFromRaw — Шаг на КАЖДЫЙ спан (пересборка на остановке)', () => { const raw = [ - '=== ХОД turn=1 · t · session=s ===', '[ЮЗЕР]', 'привет', '[АССИСТЕНТ]', 'ответ раз два три', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', '', '=== КОНЕЦ ХОДА ===', '', - '=== ХОД turn=2 · t · session=s ===', '[ЮЗЕР]', 'второй вопрос достаточно длинный', '[АССИСТЕНТ]', 'второй ответ', '=== КОНЕЦ ХОДА ===', '', + '=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'настоящий вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ раз', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', 'r', '=== КОНЕЦ ХОДА ===', '', + '=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'Stop hook feedback: y', '[АССИСТЕНТ]', 'ответ два', '[ДЕЙСТВИЕ] Grep in=z', '[ВЫДАЧА] Grep', 'r2', '=== КОНЕЦ ХОДА ===', '', + '=== ХОД turn=5 · t · session=s ===', '[ЮЗЕР]', 'второй настоящий вопрос длинный', '[АССИСТЕНТ]', 'ответ три', '=== КОНЕЦ ХОДА ===', '', ].join('\n'); - it('по шагу на каждый ход, с сессией и инструментами', () => { - const steps = buildStepsFromRaw(raw, 's'); - expect(steps.map((s) => s.turn)).toEqual([1, 2]); + it('границы [3,5] → два спана: 3 (вобрал 3-4) и 5', () => { + const steps = buildStepsFromRaw(raw, 's', [3, 5]); + expect(steps.map((x) => x.turn)).toEqual([3, 5]); + expect(steps[0].text).toContain('Ход (промпт) 3 [вобрал ходы 3-4] — я: настоящий вопрос'); + expect(steps[0].text).toContain('делал: Read, Grep'); // действия обоих ходов + expect(steps[1].text).toContain('Ход (промпт) 5'); expect(steps[0].session).toBe('s'); - expect(steps[0].text).toContain('Ход 1 — я: привет'); - expect(steps[0].text).toContain('делал: Read'); - expect(steps[1].text).toContain('Ход 2 — я: второй вопрос'); + }); + it('без границ — фолбэк по sysLabel (реальный = не служебный)', () => { + const steps = buildStepsFromRaw(raw, 's', null); + expect(steps.map((x) => x.turn)).toEqual([3, 5]); // ход 4 (гейт) приклеен к 3 }); }); @@ -78,13 +95,22 @@ describe('buildRawRecord', () => { }); describe('buildStepLine', () => { - it('формат «Ход N — я: … · ты: … · делал: <инструменты>», без служебных строк', () => { + it('формат «Ход (промпт) N — …», без служебных строк', () => { const s = buildStepLine({ turn: 5, user: 'сделай флажок.', assistant: 'экономия: 100%\nГотово.', actions: ['Edit', 'PowerShell', 'Edit'] }); - expect(s).toContain('Ход 5 — я: сделай флажок.'); + expect(s).toContain('Ход (промпт) 5 — я: сделай флажок.'); expect(s).toContain('· ты: Готово.'); expect(s).toContain('· делал: Edit, PowerShell'); expect(s).not.toContain('экономия'); }); + it('многоходовый спан показывает «[вобрал ходы X-Y]»', () => { + const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вопрос длинный достаточно', assistant: 'ответ' }); + expect(s).toContain('Ход (промпт) 12 [вобрал ходы 12-14] — я: вопрос длинный'); + }); + it('спан из одного хода — без «вобрал»', () => { + const s = buildStepLine({ turn: 7, endTurn: 7, user: 'короткий вопрос достаточно длинный', assistant: 'ок' }); + expect(s).not.toContain('вобрал'); + expect(s).toContain('Ход (промпт) 7 —'); + }); it('пустой вопрос → (без вопроса); без действий → —', () => { const s = buildStepLine({ turn: 2, user: '', assistant: 'a.' }); expect(s).toContain('я: (без вопроса)'); @@ -99,30 +125,29 @@ describe('buildStepLine', () => { expect(buildStepLine({ turn: 1, user: 'Stop hook feedback: coverage missing', assistant: '' })).toContain('я: (гейт проверки)'); expect(buildStepLine({ turn: 2, user: 'Base directory for this skill: C:\\x\\skills\\writing-plans\\SKILL.md', assistant: 'x.' })).toContain('я: (навык: writing-plans)'); }); - it('essence: берёт модельную суть дословно + детерминированный «делал»', () => { - const s = buildStepLine({ turn: 12, user: 'длинная вода без точек '.repeat(10), - assistant: 'вода', actions: ['Read', 'Read', 'Grep'], + it('essence: модельную суть дословно + детерминированный «делал»', () => { + const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вода '.repeat(10), assistant: 'вода', actions: ['Read', 'Read', 'Grep'], essence: { user: 'промпт не логируется?', assistant: 'достать можно: поймать или пересобрать' } }); - expect(s).toBe('Ход 12 — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep'); + expect(s).toBe('Ход (промпт) 12 [вобрал ходы 12-14] — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep'); }); - it('без essence — прежний фолбэк (firstSentence)', () => { + it('без essence — фолбэк firstSentence', () => { const s = buildStepLine({ turn: 2, user: 'сделай флажок.', assistant: 'Готово.', essence: null }); expect(s).toContain('я: сделай флажок'); expect(s).toContain('ты: Готово'); }); }); -describe('mergeStepsPreservingText — выключение не затирает модельный текст', () => { +describe('mergeStepsPreservingText — выключение не затирает модельный текст (по спанам)', () => { const raw = [ - '=== ХОД turn=1 · t · session=s ===', '[ЮЗЕР]', 'привет', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===', - '=== ХОД turn=2 · t · session=s ===', '[ЮЗЕР]', 'вопрос', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '', + '=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'привет достаточно длинный вопрос', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===', + '=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'второй вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '', ].join('\n'); - it('существующий шаг сохраняется, пропущенный достраивается из сырья', () => { - const existing = [{ turn: 2, session: 's', text: 'Ход 2 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }]; - const out = mergeStepsPreservingText(existing, raw, 's'); - expect(out.map((s) => s.turn)).toEqual([1, 2]); - expect(out.find((s) => s.turn === 2).text).toBe('Ход 2 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —'); - expect(out.find((s) => s.turn === 1).text).toContain('Ход 1 — я: привет'); + it('существующий шаг спана сохраняется, пропущенный достраивается', () => { + const existing = [{ turn: 4, session: 's', text: 'Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }]; + const out = mergeStepsPreservingText(existing, raw, 's', [3, 4]); + expect(out.map((s) => s.turn)).toEqual([3, 4]); + expect(out.find((s) => s.turn === 4).text).toBe('Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —'); + expect(out.find((s) => s.turn === 3).text).toContain('Ход (промпт) 3 — я: привет'); }); }); diff --git a/tools/secretary-prompt-hook.mjs b/tools/secretary-prompt-hook.mjs index 702ba70..5f19915 100644 --- a/tools/secretary-prompt-hook.mjs +++ b/tools/secretary-prompt-hook.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node -// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря». -// Тонкий shell над чистым detectSecretaryCommand. Нарезка steps/ убрана: навигация идёт -// прямо в raw/.log по провенансу с сессией (метка @ рядом с [→N]). +// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря» И метит границы спанов. +// Реальный промпт владельца = срабатывание этого хука (служебные впрыски его не вызывают), поэтому +// здесь — авторитетный детект границы спана. Тяжёлый разбор/нарезка — в Stop-хуке (таймаут 15 мин). import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs'; -import { prepareTurnFiles, buildStepsFromRaw, mergeStepsPreservingText } from './secretary-layer1.mjs'; -import { renderProtocol } from './secretary-protocol.mjs'; +import { recordRealPrompt } from './secretary-span.mjs'; function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } function turnCount(rawFile) { @@ -37,6 +36,20 @@ export function planActivation({ requested, existing = [], startedAtTurn = 0, se return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } }; } +// Решение хука на обычный промпт / выключение по отношению к границам спанов. +// cmd: 'on'|'off'|null; flag — текущий флажок; turnCount — число ходов в сырье. +// Возврат { flag: <новый флажок для записи> | null }. +export function planPromptTurn({ cmd, flag, turnCount: tc }) { + if (cmd === 'off') { + // НЕ гасим сразу: финальный открытый спан разберёт ближайший Stop (у него таймаут 15 мин). + return { flag: { ...(flag || {}), mode: 'closing' } }; + } + if (cmd == null && flag && flag.mode === 'on') { + return { flag: recordRealPrompt(flag, tc + 1) }; + } + return { flag: null }; +} + function main() { let ev = {}; try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; } @@ -44,12 +57,20 @@ function main() { const session = ev.session_id || ev.sessionId || 'unknown'; const FLAG = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); const cmd = detectSecretaryCommand(prompt); - if (!cmd) { process.exit(0); } const secdir = join(process.cwd(), 'docs', 'secretary'); const rawFile = join(secdir, 'raw', `${session}.log`); try { mkdirSync(dirname(FLAG), { recursive: true }); } catch { /* ignore */ } + const readFlag = () => { try { return JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { return {}; } }; + + if (!cmd) { + // Обычный промпт: при включённом секретаре метим границу спана (реальный промпт владельца). + const r = planPromptTurn({ cmd: null, flag: readFlag(), turnCount: turnCount(rawFile) }); + if (r.flag) { try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ } } + process.exit(0); + } + if (cmd === 'on') { const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/); const requested = (m && m[1]) || 'general'; @@ -64,30 +85,9 @@ function main() { } try { writeFileSync(FLAG, JSON.stringify(plan.flag)); } catch { /* ignore */ } } else if (cmd === 'off') { - // Остановка: режем общий сырой лог на отдельные файлы ходов в «<дело>/ходы/» и проставляем - // в каждый Шаг ссылку «ходы/turn-N.log» (поднять один ход = открыть один маленький файл). - try { - let prevFlag = {}; - try { prevFlag = JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { prevFlag = {}; } - const work = prevFlag.work || 'general'; - const workDir = join(secdir, work); - const protoJson = join(workDir, 'protocol.json'); - if (existsSync(rawFile) && existsSync(protoJson)) { - const raw = readFileSync(rawFile, 'utf-8'); - const proto = JSON.parse(readFileSync(protoJson, 'utf-8')); - // Шаги — на КАЖДЫЙ ход из Слоя 1 (не только вкл-ходы), затем нарезка + ссылки. - proto.steps = mergeStepsPreservingText(proto.steps, raw, session); - const { files, steps } = prepareTurnFiles(raw, proto); - const hodyDir = join(workDir, 'ходы'); - mkdirSync(hodyDir, { recursive: true }); - for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8'); - proto.steps = steps; - const stamp = new Date().toISOString().slice(0, 16).replace('T', ' '); - writeFileSync(protoJson, JSON.stringify(proto, null, 2), 'utf-8'); - writeFileSync(join(workDir, 'protocol.md'), renderProtocol(proto, { work, date: stamp }), 'utf-8'); - } - } catch { /* fail-quiet: флажок всё равно гасим ниже */ } - try { writeFileSync(FLAG, JSON.stringify({ mode: 'off' })); } catch { /* ignore */ } + // Только метим mode:closing. Финальный спан разберёт + нарежет сырьё + погасит флажок Stop-хук. + const r = planPromptTurn({ cmd: 'off', flag: readFlag(), turnCount: turnCount(rawFile) }); + try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ } } process.exit(0); } diff --git a/tools/secretary-prompt-hook.test.mjs b/tools/secretary-prompt-hook.test.mjs index 26f67ee..9e93919 100644 --- a/tools/secretary-prompt-hook.test.mjs +++ b/tools/secretary-prompt-hook.test.mjs @@ -1,5 +1,22 @@ import { describe, it, expect } from 'vitest'; -import { planActivation } from './secretary-prompt-hook.mjs'; +import { planActivation, planPromptTurn } from './secretary-prompt-hook.mjs'; + +describe('planPromptTurn — обычный промпт при включённом секретаре метит границу спана', () => { + it('cmd=null, секретарь on → дописать границу (turnCount+1)', () => { + const r = planPromptTurn({ cmd: null, flag: { mode: 'on', work: 'x', realPromptTurns: [3] }, turnCount: 11 }); + expect(r.flag.realPromptTurns).toEqual([3, 12]); + }); + it('cmd=null, секретарь off → ничего', () => { + const r = planPromptTurn({ cmd: null, flag: { mode: 'off' }, turnCount: 5 }); + expect(r.flag).toBeNull(); + }); + it('cmd=off → флажок mode:closing с сохранением полей', () => { + const r = planPromptTurn({ cmd: 'off', flag: { mode: 'on', work: 'дело', realPromptTurns: [3, 12], spanCursor: 0, session: 's' }, turnCount: 20 }); + expect(r.flag.mode).toBe('closing'); + expect(r.flag.work).toBe('дело'); + expect(r.flag.realPromptTurns).toEqual([3, 12]); + }); +}); describe('planActivation — решение хука: активировать или переспросить', () => { it('новое имя (нет похожих) — флажок on с work', () => { diff --git a/tools/secretary-protocol.mjs b/tools/secretary-protocol.mjs index 0158d49..ebf7382 100644 --- a/tools/secretary-protocol.mjs +++ b/tools/secretary-protocol.mjs @@ -45,12 +45,19 @@ export function renderProtocol(protocol, opts = {}) { L.push(`**Дело:** ${opts.work} · **Статус:** ${protocol.status || 'открыто'} · ` + `**Дата:** ${opts.date || ''} · **Хозяин:** владелец · **Цель:** ${protocol.subject || ''}`, ''); } + // «висит N» — число реальных промптов (спанов), прошедших с born, а не сырых ходов. + const spanDist = (born) => { + const rp = Array.isArray(opts.realPromptTurns) ? opts.realPromptTurns : null; + if (rp && opts.turn) return rp.filter((t) => t > born && t <= opts.turn).length; + return opts.turn && born != null ? opts.turn - born : 0; // фолбэк (сырые ходы) + }; const burn = (title, arr) => { const live = (arr || []).filter((e) => !e.done); if (!live.length) return; L.push(title); for (const e of live) { - const stale = e.lastTouch != null && opts.turn && opts.turn > e.lastTouch ? ` · висит ${opts.turn - e.born} ходов` : ''; + const n = e.lastTouch != null && opts.turn && opts.turn > e.lastTouch ? spanDist(e.born) : 0; + const stale = n > 0 ? ` · висит ${n} промптов` : ''; L.push(`- ${e.text}${e.born ? ` [→${e.born}]` : ''}${stale}`); } L.push(''); @@ -89,7 +96,7 @@ export function renderProtocol(protocol, opts = {}) { L.push('', '## Скрытые вопросы (фон)'); for (const h of (protocol.hidden || [])) { const head = h.lineage && h.lineage.length - ? h.lineage.map((x) => `~~${x.text}~~`).join(' → ') + ' → ' + h.text + ? `~~${h.lineage[0].text}~~ → ${h.text}` // кап показа: только первая → текущая (данные в JSON целы) : h.text; const prov2 = ` [→${h.born}]` + (h.lastTouch && h.lastTouch !== h.born ? ` [${h.lastTouch}]` : ''); L.push(`- ${h.id} [${h.lens} · ${h.status}]: ${head}${prov2}`); diff --git a/tools/secretary-protocol.test.mjs b/tools/secretary-protocol.test.mjs index 2bb0cab..4c11e17 100644 --- a/tools/secretary-protocol.test.mjs +++ b/tools/secretary-protocol.test.mjs @@ -108,6 +108,22 @@ describe('renderProtocol — 9 категорий + шаги', () => { expect(md).toContain('Скрытые вопросы'); expect(md).toContain('~~старая~~ → новая'); // мутация зачёркиванием }); + it('кап родословной: ~~первая~~ → текущая (середина скрыта, данные в JSON целы)', () => { + const p = { ...EMPTY_PROTOCOL(), + hidden: [{ id: 'СВ-1', lens: 'Л3', status: 'мутировал', text: 'нынешняя', born: 3, lastTouch: 15, + lineage: [{ turn: 3, text: 'первая' }, { turn: 9, text: 'средняя-1' }, { turn: 12, text: 'средняя-2' }] }] }; + const md = renderProtocol(p, { work: 'x', date: 'd' }); + expect(md).toContain('~~первая~~ → нынешняя'); + expect(md).not.toContain('средняя-1'); + expect(md).not.toContain('средняя-2'); + }); + it('«висит N промптов» считает спаны, прошедшие с born (не сырые ходы)', () => { + const p = { ...EMPTY_PROTOCOL(), + acceptance: [{ text: 'заявлено готово', born: 3, lastTouch: 3, done: false }] }; + // реальные промпты на ходах 3,12,15,22; текущий ход 31 → с born=3 прошло 3 промпта (12,15,22) + const md = renderProtocol(p, { work: 'x', date: 'd', turn: 31, realPromptTurns: [3, 12, 15, 22] }); + expect(md).toContain('висит 3 промптов'); + }); it('Шаги: разделитель «—— сессия X ——» при смене сессии (не перед первой)', () => { const md = renderProtocol({ subject: '', status: 'открыто', history: [], diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index a8e088b..4c4d1b7 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -22,7 +22,8 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark ].join('\n'); const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) => ` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)'); - const acts = (lastExchange.actions || []).map((a) => a.tool).join(', ') || '—'; + const acts = ((lastExchange.actions || []).map((a) => + ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? `\n → ${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || '—'; const user = [ `Тема дела: ${protocol.subject || '(нет)'}`, sec('Решения', protocol.decisions), sec('Альтернативы', protocol.alternatives), @@ -31,7 +32,7 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark '', 'Последний обмен:', `[ЮЗЕР]: ${lastExchange.user || ''}`, `[АССИСТЕНТ]: ${lastExchange.assistant || ''}`, - `Действия: ${acts}`, + `Действия (с содержимым):\n${acts}`, remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '', '', 'Верни ВЕСЬ обновлённый протокол как JSON.', ].join('\n'); diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs index cfdbf0b..79e6284 100644 --- a/tools/secretary-reconcile.test.mjs +++ b/tools/secretary-reconcile.test.mjs @@ -117,6 +117,13 @@ describe('buildReconcilePrompt', () => { expect(system.toLowerCase()).toContain('step'); expect(system.toLowerCase()).toContain('суть'); }); + it('подаёт действия с содержимым (input/result), а не только имена', () => { + const ex = { user: 'u', assistant: 'a', actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'СОДЕРЖИМОЕ' }] }; + const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: ex }); + expect(user).toContain('Read'); + expect(user).toContain('{"f":"x"}'); + expect(user).toContain('СОДЕРЖИМОЕ'); + }); }); describe('reconcile — 9 категорий + стабильная тема', () => { diff --git a/tools/secretary-span.mjs b/tools/secretary-span.mjs new file mode 100644 index 0000000..1109f2e --- /dev/null +++ b/tools/secretary-span.mjs @@ -0,0 +1,64 @@ +// Чистая спан-логика секретаря: границы спанов (реальные промпты владельца) → отрезки ходов. +// Без I/O. Нумерация — номера ходов сырья (raw/.log). +import { splitRawIntoTurns } from './secretary-layer1.mjs'; + +/** Нормализованный, отсортированный список уникальных границ. */ +function norm(realPromptTurns) { + return [...new Set((realPromptTurns || []).map(Number).filter((n) => Number.isFinite(n)))] + .sort((a, b) => a - b); +} + +/** Отрезки спанов: [b_i .. b_{i+1}-1]; последний — до lastTurn, open:true. */ +export function computeSpans(realPromptTurns, lastTurn) { + const b = norm(realPromptTurns); + const out = []; + for (let i = 0; i < b.length; i++) { + const start = b[i]; + const isLast = i === b.length - 1; + const end = isLast ? lastTurn : b[i + 1] - 1; + out.push({ start, end, open: isLast }); + } + return out; +} + +/** Закрытые спаны с индексом строго больше курсора — их надо разобрать сейчас. + * Курсор «ничего не разобрано» = -1. */ +export function spansToDistill(realPromptTurns, lastTurn, spanCursor) { + const cur = Number.isFinite(spanCursor) ? spanCursor : -1; + return computeSpans(realPromptTurns, lastTurn) + .map((s, index) => ({ ...s, index })) + .filter((s) => !s.open && s.index > cur) + .map(({ start, end, index }) => ({ start, end, index })); +} + +/** Добавить границу спана в флажок (идемпотентно, сортировка). Вход не мутируется. */ +export function recordRealPrompt(flag, turn) { + const prev = Array.isArray(flag && flag.realPromptTurns) ? flag.realPromptTurns : []; + const set = new Set(prev); + set.add(Number(turn)); + return { ...flag, realPromptTurns: [...set].sort((a, b) => a - b) }; +} + +/** Разбор одного блока хода сырья → {turn,user,assistant,actions}. Полное содержимое. + * Формат (buildRawRecord): [ЮЗЕР]\n…\n[АССИСТЕНТ]\n…\n([ДЕЙСТВИЕ] tool in=…\n[ВЫДАЧА] tool\n…)* */ +export function parseTurnBlock(block) { + const s = String(block || ''); + const turn = Number((s.match(/=== ХОД turn=(\d+)/) || [])[1]) || 0; + const um = s.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]\n/); + const am = s.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/); + const actions = []; + const re = /\[ДЕЙСТВИЕ\] (\S+) in=([\s\S]*?)\n\[ВЫДАЧА\] \S+\n([\s\S]*?)(?=\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/g; + let m; + while ((m = re.exec(s)) !== null) actions.push({ tool: m[1], input: m[2], result: m[3] }); + return { turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions }; +} + +/** Склейка обмена спана из сырья: user из хода-начала; assistant и actions — со всех ходов [start..end]. */ +export function assembleSpan(rawText, { start, end }) { + const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end); + const parsed = blocks.map((p) => parseTurnBlock(p.block)); + const startTurn = parsed.find((p) => p.turn === start) || parsed[0] || {}; + const assistant = parsed.map((p) => p.assistant).filter(Boolean).join('\n'); + const actions = parsed.flatMap((p) => p.actions); + return { user: startTurn.user || '', assistant, actions }; +} diff --git a/tools/secretary-span.test.mjs b/tools/secretary-span.test.mjs new file mode 100644 index 0000000..79ee328 --- /dev/null +++ b/tools/secretary-span.test.mjs @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { computeSpans, spansToDistill, recordRealPrompt, parseTurnBlock, assembleSpan } from './secretary-span.mjs'; +import { buildRawRecord } from './secretary-layer1.mjs'; + +describe('computeSpans', () => { + it('границы → отрезки; последний открыт', () => { + expect(computeSpans([3, 12, 15], 17)).toEqual([ + { start: 3, end: 11, open: false }, + { start: 12, end: 14, open: false }, + { start: 15, end: 17, open: true }, + ]); + }); + it('одна граница → один открытый спан', () => { + expect(computeSpans([3], 5)).toEqual([{ start: 3, end: 5, open: true }]); + }); + it('пустой список → нет спанов', () => { + expect(computeSpans([], 5)).toEqual([]); + }); + it('неотсортированные/дубли нормализуются', () => { + expect(computeSpans([12, 3, 3], 13)).toEqual([ + { start: 3, end: 11, open: false }, + { start: 12, end: 13, open: true }, + ]); + }); +}); + +describe('spansToDistill', () => { + it('закрытые спаны с индексом > курсора', () => { + expect(spansToDistill([3, 12, 15], 17, -1)).toEqual([ + { start: 3, end: 11, index: 0 }, + { start: 12, end: 14, index: 1 }, + ]); + }); + it('курсор уже прошёл первый закрытый — отдаём только второй', () => { + expect(spansToDistill([3, 12, 15], 17, 0)).toEqual([{ start: 12, end: 14, index: 1 }]); + expect(spansToDistill([3, 12, 15], 17, 1)).toEqual([]); + }); + it('открытый спан не отдаётся', () => { + expect(spansToDistill([3, 12], 14, -1)).toEqual([{ start: 3, end: 11, index: 0 }]); + }); +}); + +describe('recordRealPrompt', () => { + it('добавляет границу, не дублирует, держит сортировку', () => { + let f = { mode: 'on', work: 'x' }; + f = recordRealPrompt(f, 3); + expect(f.realPromptTurns).toEqual([3]); + f = recordRealPrompt(f, 12); + expect(f.realPromptTurns).toEqual([3, 12]); + f = recordRealPrompt(f, 12); // дубль игнор + expect(f.realPromptTurns).toEqual([3, 12]); + expect(f.mode).toBe('on'); // прочие поля целы + }); + it('не мутирует вход', () => { + const f = { mode: 'on' }; + const out = recordRealPrompt(f, 1); + expect(f.realPromptTurns).toBeUndefined(); + expect(out.realPromptTurns).toEqual([1]); + }); +}); + +describe('parseTurnBlock', () => { + it('тащит turn, user, assistant, действия с input/result', () => { + const block = buildRawRecord({ + turn: 4, time: 't', session: 's', user: 'вопрос', assistant: 'ответ', + actions: [{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ\nдве строки' }], + }); + const pt = parseTurnBlock(block); + expect(pt.turn).toBe(4); + expect(pt.user).toBe('вопрос'); + expect(pt.assistant).toBe('ответ'); + expect(pt.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ\nдве строки' }]); + }); +}); + +describe('assembleSpan', () => { + const raw = [ + buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящий промпт', assistant: 'первый ответ', + actions: [{ tool: 'Read', input: 'a', result: 'r1' }] }), + buildRawRecord({ turn: 4, time: 't', session: 's', user: 'Stop hook feedback: x', assistant: 'второй ответ', + actions: [{ tool: 'Grep', input: 'b', result: 'r2' }] }), + ].join(''); + it('склеивает обмен спана: user из start, assistant и actions со всех ходов', () => { + const ex = assembleSpan(raw, { start: 3, end: 4 }); + expect(ex.user).toBe('настоящий промпт'); + expect(ex.assistant).toContain('первый ответ'); + expect(ex.assistant).toContain('второй ответ'); + expect(ex.actions).toEqual([ + { tool: 'Read', input: 'a', result: 'r1' }, + { tool: 'Grep', input: 'b', result: 'r2' }, + ]); + }); + it('спан из одного хода', () => { + const ex = assembleSpan(raw, { start: 3, end: 3 }); + expect(ex.user).toBe('настоящий промпт'); + expect(ex.actions).toHaveLength(1); + }); +}); diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index fa10098..df1ddb3 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -1,25 +1,29 @@ #!/usr/bin/env node -// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён — -// онлайн-выжимка в протокол дела через НОВЫЙ мотор (SECRETARY_LLM_KEY). -// Тонкий shell над parseLastExchange / buildRawRecord / reconcileTurn (модель-редактор) / -// mergeTurnIntoProtocol (шаг пишется всегда) / writeFileAtomic / renderProtocol / upsertIndexEntry. -import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs'; +// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён — отложенный +// разбор ПО СПАНАМ (реальный промпт + вся активность до следующего реального промпта). +// Закрытые спаны (не последний) разбираются один раз; курсор в флажке сессии. При mode:'closing' +// (после «выключи секретаря») добивается последний открытый спан + нарезка сырья + гашение флажка. +import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { parseLastExchange } from './secretary-transcript.mjs'; import { secretaryModeFileName } from './secretary-flag.mjs'; -import { buildRawRecord, buildStepLine, writeFileAtomic } from './secretary-layer1.mjs'; +import { buildRawRecord, buildStepLine, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles } from './secretary-layer1.mjs'; import { reconcileTurn, mergeTurnIntoProtocol, formatReconcileLogLine, collapseProtocol } from './secretary-reconcile.mjs'; import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; import { upsertIndexEntry } from './secretary-index.mjs'; import { sanitize } from './observer-pii-filter.mjs'; import { callAnthropicAPI } from './router-classifier.mjs'; import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs'; +import { computeSpans, spansToDistill, assembleSpan } from './secretary-span.mjs'; function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } +function flagPath(session) { return join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); } function readFlag(session) { - const f = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); - try { return JSON.parse(readFileSync(f, 'utf-8')); } catch { return { mode: 'off' }; } + try { return JSON.parse(readFileSync(flagPath(session), 'utf-8')); } catch { return { mode: 'off' }; } +} +function writeFlag(session, flag) { + try { writeFileSync(flagPath(session), JSON.stringify(flag)); } catch { /* ignore */ } } function turnCount(rawFile) { if (!existsSync(rawFile)) return 0; @@ -49,9 +53,10 @@ async function main() { appendFileSync(rawFile, rec + '\n', 'utf-8'); } catch { /* fail-quiet */ } - // Тетрадь (Слой 2) — только если секретарь включён. + // Тетрадь (Слой 2) — только если секретарь включён или закрывается. const flag = readFlag(session); - if (flag.mode !== 'on') { process.exit(0); } + if (flag.mode !== 'on' && flag.mode !== 'closing') { process.exit(0); } + const closing = flag.mode === 'closing'; const work = flag.work || 'general'; const apiKey = process.env.SECRETARY_LLM_KEY; @@ -61,86 +66,119 @@ async function main() { let proto = EMPTY_PROTOCOL(); try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); } - // Снимок реестра СВ ДО reconcile: reconcile переписывает весь протокол и корёжит скрытые - // вопросы (перенумеровывает). Реестром владеет ТОЛЬКО аудитор — вернём снимок после merge. - const svSnapshot = JSON.parse(JSON.stringify({ - hidden: proto.hidden || [], acceptance: proto.acceptance || [], - tails: proto.tails || [], nextSvId: proto.nextSvId || 1, - })); + // Сырьё целиком (только что дописали текущий ход) — источник для сборки спанов и фолбэк-границ. + let rawText = ''; + try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; } - // Видимый сигнал срыва reconcile — в лог дела (раньше тихий fail-quiet прятал причину). + // Границы спанов: авторитетные из флажка (пишет prompt-hook), иначе фолбэк по sysLabel из сырья. + const bounds = (Array.isArray(flag.realPromptTurns) && flag.realPromptTurns.length) + ? flag.realPromptTurns : realBoundariesFromRaw(rawText); + const cursor = Number.isFinite(flag.spanCursor) ? flag.spanCursor : -1; + + // Закрытые спаны к разбору; при закрытии добиваем и последний открытый (force-close). + const list = spansToDistill(bounds, turn, cursor); + if (closing) { + const all = computeSpans(bounds, turn).map((s, index) => ({ ...s, index })); + const lastOpen = all[all.length - 1]; + if (lastOpen && lastOpen.open && lastOpen.index > cursor) + list.push({ start: lastOpen.start, end: lastOpen.end, index: lastOpen.index }); + } + + // Обычный ход без новых закрытых спанов — тетрадь не трогаем (отставание на один промпт). + if (!list.length && !closing) { process.exit(0); } + + // Видимый сигнал срыва reconcile — в лог дела. const reLog = join(workDir, '_reconcile.log'); const logReason = (info) => { try { mkdirSync(workDir, { recursive: true }); const t = new Date().toISOString().slice(0, 19).replace('T', ' '); - appendFileSync(reLog, formatReconcileLogLine({ turn, time: t, ...info }) + '\n', 'utf-8'); + appendFileSync(reLog, formatReconcileLogLine({ time: t, ...info }) + '\n', 'utf-8'); } catch { /* лог вторичен */ } }; - - // Модель-редактор правит ВЕСЬ протокол; страж возвращает потерянные строки (reconcile не зависит - // от точности модели). Нет ключа — reconcile пропускаем (логируем no-key), но шаг хода ниже - // пишется ВСЁ РАВНО (целостность «Шагов»). const callModel = apiKey ? (msgs) => callAnthropicAPI(msgs, { apiKey, baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined, model: process.env.SECRETARY_LLM_MODEL || undefined, - perAttemptTimeoutMs: 300_000, // 5 минут на один ответ модели (секретарь пишет длинный протокол) - maxRetries: 0, // одна попытка, без ×5 повторов + perAttemptTimeoutMs: 300_000, + maxRetries: 0, }) : null; - let updated = null; - if (apiKey) { - updated = await reconcileTurn({ proto, ex, turn, session, callModel, diag: logReason }); - } else { - logReason({ reason: 'no-key' }); + // Разбор каждого завершённого спана по порядку: reconcile + аудит на ПОЛНОМ склеенном спане. + let lastIndex = cursor; + for (const span of list) { + const spanEx = assembleSpan(rawText, span); + + // Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge. + const svSnapshot = JSON.parse(JSON.stringify({ + hidden: proto.hidden || [], acceptance: proto.acceptance || [], + tails: proto.tails || [], nextSvId: proto.nextSvId || 1, + })); + + let updated = null; + if (apiKey) { + updated = await reconcileTurn({ proto, ex: spanEx, turn: span.start, session, callModel, diag: (i) => logReason({ turn: span.start, ...i }) }); + } else { + logReason({ turn: span.start, reason: 'no-key' }); + } + + const modelStep = (updated && updated.step) || null; + if (updated && 'step' in updated) delete updated.step; + const step = { turn: span.start, session, + text: buildStepLine({ turn: span.start, endTurn: span.end, user: spanEx.user, assistant: spanEx.assistant, + actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep }) }; + const toWrite = mergeTurnIntoProtocol({ proto, updated, step }); + + // Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile. + preserveRegistry(toWrite, svSnapshot); + + // Аудитор скрытых вопросов (9 линз) на ПОЛНОМ спане. + if (apiKey) { + try { + const auditMsgs = buildAuditPrompt(toWrite, spanEx); + const raw = await callModel(auditMsgs); + applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), span.start); + } catch (e) { logReason({ turn: span.start, reason: 'audit-fail', error: e && e.message }); } + } + + proto = collapseProtocol(toWrite); + lastIndex = span.index; } - // Шаг хода (Слой 1) ведёт хук детерминированно — пишется ВСЕГДА; протокол к записи через merge - // (при срыве reconcile категории заморожены, но перечень ходов не получает дыр). - // Модельная суть хода (если reconcile её вернул) — иначе фолбэк firstSentence в buildStepLine. - const modelStep = (updated && updated.step) || null; - if (updated && 'step' in updated) delete updated.step; // транзитное — в protocol.json не сохраняем - const step = { turn, session, - text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant, - actions: (ex.actions || []).map((a) => a.tool), essence: modelStep }) }; - const toWrite = mergeTurnIntoProtocol({ proto, updated, step }); - - // Вернуть реестр СВ из снимка (reconcile его НЕ владеет) — иначе он перенумеровывает СВ. - preserveRegistry(toWrite, svSnapshot); - - // Второй проход — аудитор скрытых вопросов (9 линз). Не зависит от reconcile. - if (apiKey) { - try { - const auditMsgs = buildAuditPrompt(toWrite, ex); - const raw = await callModel(auditMsgs); - applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), turn); - } catch (e) { logReason({ reason: 'audit-fail', error: e && e.message }); } - } - - // Самолечение дублей: финальный чокпоинт перед записью. Ловит ВСЕ исходы (reconcile-успех, - // срыв/без-ключа = прежний раздутый proto, уже накопленные дубли) — на выходе всегда чисто. - // Трогает только 6 корзин + Историю; реестр СВ (hidden/nextSvId), шаги, тема — нетронуты. - const finalProto = collapseProtocol(toWrite); - + const finalProto = proto; const stamp = new Date().toISOString().slice(0, 16).replace('T', ' '); mkdirSync(workDir, { recursive: true }); - // Атомарная запись (temp→rename): параллельная сессия не увидит полузаписанный файл. writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2)); - writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp })); + writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); const idxFile = join(secdir, 'содержание.md'); let idxMd = ''; try { if (existsSync(idxFile)) idxMd = readFileSync(idxFile, 'utf-8'); } catch { idxMd = ''; } const upd = upsertIndexEntry(idxMd, { slug: work, title: work, - goal: (toWrite.subject && toWrite.subject.trim()) ? toWrite.subject.trim() : '(дело)', - status: toWrite.status || 'открыто', + goal: (finalProto.subject && finalProto.subject.trim()) ? finalProto.subject.trim() : '(дело)', + status: finalProto.status || 'открыто', date: stamp, }); writeFileAtomic(idxFile, upd); + + if (closing) { + // Финализация: нарезка сырья на файлы ходов (по ходу) + Шаги по спанам, затем гашение флажка. + finalProto.steps = mergeStepsPreservingText(finalProto.steps, rawText, session, bounds); + const { files, steps } = prepareTurnFiles(rawText, finalProto); + const hodyDir = join(workDir, 'ходы'); + mkdirSync(hodyDir, { recursive: true }); + for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8'); + finalProto.steps = steps; + writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2)); + writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); + writeFlag(session, { mode: 'off' }); + } else { + // Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы). + writeFlag(session, { ...readFlag(session), spanCursor: lastIndex }); + } } catch { /* fail-quiet: сырьё уже записано */ } process.exit(0); } diff --git a/tools/secretary-transcript.mjs b/tools/secretary-transcript.mjs index 9510323..682189e 100644 --- a/tools/secretary-transcript.mjs +++ b/tools/secretary-transcript.mjs @@ -24,7 +24,7 @@ function isRealUserPrompt(msg) { } // Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков. -const MAX_RESULT_CHARS = 1200; +// Без обрезки: секретарь должен видеть ПОЛНОЕ содержимое (линзы ловят ошибки/пропуски). function resultText(content) { if (typeof content === 'string') return content; if (Array.isArray(content)) { @@ -33,10 +33,6 @@ function resultText(content) { } return ''; } -function truncateResult(s) { - const t = String(s ?? ''); - return t.length > MAX_RESULT_CHARS ? t.slice(0, MAX_RESULT_CHARS) + '…' : t; -} /** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }. * result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела); @@ -78,7 +74,7 @@ export function parseLastExchange(transcriptText) { } const actions = raw.map((a) => { const out = { tool: a.tool, input: a.input }; - if (a.id != null && results[a.id] != null) out.result = truncateResult(results[a.id]); + if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? ''); return out; }); return { user, assistant, actions }; diff --git a/tools/secretary-transcript.test.mjs b/tools/secretary-transcript.test.mjs index 04f41a4..7ae65a0 100644 --- a/tools/secretary-transcript.test.mjs +++ b/tools/secretary-transcript.test.mjs @@ -63,7 +63,7 @@ describe('parseLastExchange — захват выдачи инструмента const ex = parseLastExchange(t); expect(ex.actions[0].result).toBe('строка вывода'); }); - it('длинный результат усечён и оканчивается маркером …', () => { + it('длинный результат НЕ обрезается (полная картина для секретаря)', () => { const big = 'x'.repeat(5000); const t = [ JSON.stringify({ message: { role: 'user', content: 'в' } }), @@ -73,8 +73,8 @@ describe('parseLastExchange — захват выдачи инструмента { type: 'tool_result', tool_use_id: 'tu_2', content: big }] } }), ].join('\n'); const ex = parseLastExchange(t); - expect(ex.actions[0].result.length).toBeLessThan(big.length); - expect(ex.actions[0].result.endsWith('…')).toBe(true); + expect(ex.actions[0].result).toBe(big); // целиком + expect(ex.actions[0].result.endsWith('…')).toBe(false); }); it('без совпадающего id результат не привязывается — старая форма {tool,input} цела', () => { const t = [