From 67fecd714938376c10f8bc3edf06e9b6391a362f 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: Mon, 22 Jun 2026 10:54:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20reconcile=20=E2=80=94=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C-=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=81=D1=8C=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB,=20=D1=85=D1=83=D0=BA-=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B6=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secretary-reconcile.mjs: buildReconcilePrompt (весь протокол+обмен), parseReconcileResponse, reconcileGuard (ни одна старая строка не пропала), buildGuardRemark (обоснованный возврат), stampProvenance (turn+session по тексту), reconcileTurn (вызов->сторож->до 2 возвратов) - stop-хук: вместо applyExtraction вызывает reconcileTurn; мотор инъектируется - renderProtocol: зачёркивание во ВСЕХ разделах (закрытые вопросы видны ~~struck~~) - ретайр: applyExtraction/buildExtractionPrompt/parseExtractionResponse (secretary-extract удалён) - Слой 1, провенанс @session, флажок по сессии, оглавление — без изменений - спека + план reconcile в docs/superpowers 33 теста green (мотор замокан, без сети). Модель для prod — Sonnet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-secretary-reconcile-plan.md | 498 ++++++++++++++++++ .../2026-06-22-secretary-reconcile-design.md | 163 ++++++ tools/secretary-extract.mjs | 60 --- tools/secretary-extract.test.mjs | 62 --- tools/secretary-protocol.mjs | 38 +- tools/secretary-protocol.test.mjs | 86 +-- tools/secretary-reconcile.mjs | 118 +++++ tools/secretary-reconcile.test.mjs | 102 ++++ tools/secretary-stop-hook.mjs | 41 +- 9 files changed, 928 insertions(+), 240 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-22-secretary-reconcile-plan.md create mode 100644 docs/superpowers/specs/2026-06-22-secretary-reconcile-design.md delete mode 100644 tools/secretary-extract.mjs delete mode 100644 tools/secretary-extract.test.mjs create mode 100644 tools/secretary-reconcile.mjs create mode 100644 tools/secretary-reconcile.test.mjs diff --git a/docs/superpowers/plans/2026-06-22-secretary-reconcile-plan.md b/docs/superpowers/plans/2026-06-22-secretary-reconcile-plan.md new file mode 100644 index 0000000..f241b9a --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-secretary-reconcile-plan.md @@ -0,0 +1,498 @@ +# Секретарь-«редактор» (reconcile) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Заменить «дописывающую» выжимку секретаря на «модель-редактор + хук-сторож»: модель получает весь протокол дела и возвращает его целиком обновлённым, хук следит, что ни одна строка не пропала. + +**Architecture:** Чистые функции в новом `tools/secretary-reconcile.mjs` (промпт, разбор, сторож, штамп провенанса, оркестратор с инъекцией мотора). Stop-хук вызывает оркестратор с реальным мотором. Старые `applyExtraction`/`buildExtractionPrompt`/`parseExtractionResponse` ретайрятся. Провенанс (turn+session) и Слой 1 — без изменений. Спека: `docs/superpowers/specs/2026-06-22-secretary-reconcile-design.md`. + +**Tech Stack:** Node ESM (`tools/*.mjs`), vitest (`globals:true`, конвенция `import { describe,it,expect } from 'vitest'`), мотор `callAnthropicAPI` (`router-classifier.mjs`), PII-фильтр и флаг-ридер — существующие. + +**Контракт JSON модели (вход = текущий протокол + обмен, выход = весь протокол):** +```json +{ + "subject": "<стабильная тема дела>", + "decisions": [{"text":"...","why":"...","struck":false}], + "will": [{"text":"...","struck":false}], + "open": [{"text":"...","struck":false}], + "doneNext": [{"text":"...","done":false,"struck":false}] +} +``` +Провенанс (`turns`,`session`) и `history` модель НЕ трогает — это зона хука (§D5 спеки). + +--- + +## File Structure + +- **Create** `tools/secretary-reconcile.mjs` — `buildReconcilePrompt`, `parseReconcileResponse`, `reconcileGuard`, `buildGuardRemark`, `stampProvenance`, `reconcileTurn` (оркестратор с инъекцией `callModel`). +- **Create** `tools/secretary-reconcile.test.mjs` — тесты всех чистых функций + оркестратора (мотор замокан). +- **Modify** `tools/secretary-stop-hook.mjs` — заменить блок выжимки на `reconcileTurn` с реальным мотором. +- **Modify** `tools/secretary-protocol.mjs` — удалить `applyExtraction` (рендер `renderProtocol` и `EMPTY_PROTOCOL` остаются; `EMPTY_PROTOCOL` дополняется полем `history` как есть). +- **Modify** `tools/secretary-protocol.test.mjs` — удалить тесты `applyExtraction`; оставить `renderProtocol`/`EMPTY_PROTOCOL`. +- **Delete** `tools/secretary-extract.mjs` + `tools/secretary-extract.test.mjs` — `buildExtractionPrompt`/`parseExtractionResponse` заменены reconcile-аналогами. + +--- + +## Task 1: Разбор ответа модели (`parseReconcileResponse`) + +**Files:** +- Create: `tools/secretary-reconcile.mjs` +- Test: `tools/secretary-reconcile.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +```js +import { describe, it, expect } from 'vitest'; +import { parseReconcileResponse } from './secretary-reconcile.mjs'; + +describe('parseReconcileResponse', () => { + it('парсит весь протокол (с обёрткой/хвостовой запятой)', () => { + const out = parseReconcileResponse('```json\n{ "subject":"S", "decisions":[{"text":"A","why":"w","struck":false}], "open":[{"text":"Q","struck":true}], }\n```'); + expect(out.subject).toBe('S'); + expect(out.decisions[0]).toEqual({ text: 'A', why: 'w', struck: false }); + expect(out.open[0]).toEqual({ text: 'Q', struck: true }); + expect(out.will).toEqual([]); + expect(out.doneNext).toEqual([]); + }); + it('мусор → null', () => { + expect(parseReconcileResponse('не json')).toBeNull(); + expect(parseReconcileResponse('')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Прогнать тест — убедиться, что падает** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: FAIL — `parseReconcileResponse is not a function`. + +- [ ] **Step 3: Минимальная реализация** + +```js +// Секретарь-«редактор»: модель правит весь протокол, хук сторожит потери (спека reconcile). + +/** Разбор ответа модели в нормализованный протокол; null при кривом JSON (тихо). */ +export function parseReconcileResponse(llmText) { + if (typeof llmText !== 'string' || !llmText.trim()) return null; + let s = llmText.trim().replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim(); + s = s.replace(/,(\s*[}\]])/g, '$1'); + let parsed; try { parsed = JSON.parse(s); } catch { return null; } + if (!parsed || typeof parsed !== 'object') return null; + const list = (x) => (Array.isArray(x) ? x : []); + const ent = (e) => ({ text: String(e && e.text || ''), struck: !!(e && e.struck) }); + return { + subject: typeof parsed.subject === 'string' ? parsed.subject.trim() : '', + decisions: list(parsed.decisions).map((e) => ({ ...ent(e), why: (e && e.why) || null })), + will: list(parsed.will).map(ent), + open: list(parsed.open).map(ent), + doneNext: list(parsed.doneNext).map((e) => ({ ...ent(e), done: !!(e && e.done) })), + }; +} +``` + +- [ ] **Step 4: Прогнать тест — зелёный** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: PASS (2/2). + +--- + +## Task 2: Сторож потерь (`reconcileGuard` + `buildGuardRemark`) + +**Files:** +- Modify: `tools/secretary-reconcile.mjs` +- Test: `tools/secretary-reconcile.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```js +import { reconcileGuard, buildGuardRemark } from './secretary-reconcile.mjs'; + +describe('reconcileGuard', () => { + const old = { decisions: [{ text: 'Берём Postgres' }], open: [{ text: 'Хайку или Sonnet?' }], will: [], doneNext: [] }; + it('всё на месте (в т.ч. зачёркнутое) → ok', () => { + const ret = { decisions: [{ text: 'берём postgres', struck: false }], open: [{ text: 'Хайку или Sonnet?', struck: true }], will: [], doneNext: [] }; + expect(reconcileGuard(old, ret).ok).toBe(true); + }); + it('строка пропала → не ok + список потерь', () => { + const ret = { decisions: [{ text: 'берём postgres' }], open: [], will: [], doneNext: [] }; + const g = reconcileGuard(old, ret); + expect(g.ok).toBe(false); + expect(g.lost).toContain('Хайку или Sonnet?'); + }); +}); + +describe('buildGuardRemark', () => { + it('обоснованное замечание называет потерянные строки и что делать', () => { + const r = buildGuardRemark(['Хайку или Sonnet?']); + expect(r).toContain('Хайку или Sonnet?'); + expect(r.toLowerCase()).toContain('верни'); + expect(r.toLowerCase()).toContain('не удаляй'); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: FAIL — функции не определены. + +- [ ] **Step 3: Реализация** + +```js +const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); +const SECTIONS = ['decisions', 'will', 'open', 'doneNext']; + +function allTexts(p) { + const out = []; + for (const sec of SECTIONS) for (const e of (p && p[sec]) || []) out.push(e); + return out; +} + +/** Сторож: каждая прежняя строка обязана присутствовать в новом протоколе (живой или + * зачёркнутой). Возвращает { ok, lost: [оригинальные тексты] }. */ +export function reconcileGuard(oldProtocol, returned) { + const have = new Set(allTexts(returned).map((e) => norm(e.text))); + const lost = []; + for (const e of allTexts(oldProtocol)) { + if (!have.has(norm(e.text))) lost.push(e.text); + } + return { ok: lost.length === 0, lost }; +} + +/** Обоснованное замечание для возврата модели на доработку (§D4). */ +export function buildGuardRemark(lost) { + const list = (lost || []).map((t) => `- ${t}`).join('\n'); + return [ + 'ОШИБКА: ты потерял строки протокола. Их НЕЛЬЗЯ удалять.', + 'Верни эти строки на место (зачеркни — "struck": true — если они решены/отменены, но НЕ удаляй):', + list, + 'Снова верни ВЕСЬ протокол целиком, со всеми прежними строками.', + ].join('\n'); +} +``` + +- [ ] **Step 4: Прогон — зелёный** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: PASS. + +--- + +## Task 3: Штамп провенанса (`stampProvenance`) + +**Files:** +- Modify: `tools/secretary-reconcile.mjs` +- Test: `tools/secretary-reconcile.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```js +import { stampProvenance } from './secretary-reconcile.mjs'; + +describe('stampProvenance', () => { + const old = { subject: 'тема', history: [{ oldText: 'x', newText: 'y', turns: [1] }], + decisions: [{ text: 'A', why: 'w', turns: [3], session: 'sessOLD', struck: false }], + will: [], open: [], doneNext: [] }; + const returned = { subject: 'тема', + decisions: [{ text: 'A', why: 'w', struck: false }, { text: 'B', why: 'w2', struck: false }], + will: [], open: [], doneNext: [] }; + it('старая запись сохраняет свой turns/session, новая получает текущие', () => { + const p = stampProvenance(old, returned, 9, 'sessNEW'); + expect(p.decisions[0]).toMatchObject({ text: 'A', turns: [3], session: 'sessOLD' }); + expect(p.decisions[1]).toMatchObject({ text: 'B', turns: [9], session: 'sessNEW' }); + }); + it('history прежнего протокола сохраняется', () => { + const p = stampProvenance(old, returned, 9, 'sessNEW'); + expect(p.history).toEqual(old.history); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: FAIL. + +- [ ] **Step 3: Реализация** + +```js +/** Привязать провенанс к присланному протоколу: старая запись (по тексту) сохраняет свои + * turns/session; новая получает [turn]+session. history прежнего протокола сохраняется. */ +export function stampProvenance(oldProtocol, returned, turn, session) { + const index = new Map(); + for (const e of allTexts(oldProtocol)) index.set(norm(e.text), e); + const stamp = (e) => { + const prev = index.get(norm(e.text)); + return prev + ? { ...e, turns: prev.turns || [turn], session: prev.session || session } + : { ...e, turns: [turn], session }; + }; + return { + subject: returned.subject || oldProtocol.subject || '', + decisions: (returned.decisions || []).map(stamp), + will: (returned.will || []).map(stamp), + open: (returned.open || []).map(stamp), + doneNext: (returned.doneNext || []).map(stamp), + history: Array.isArray(oldProtocol.history) ? oldProtocol.history : [], + }; +} +``` + +- [ ] **Step 4: Прогон — зелёный** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: PASS. + +--- + +## Task 4: Промпт reconcile (`buildReconcilePrompt`) + +**Files:** +- Modify: `tools/secretary-reconcile.mjs` +- Test: `tools/secretary-reconcile.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```js +import { buildReconcilePrompt } from './secretary-reconcile.mjs'; + +describe('buildReconcilePrompt', () => { + const proto = { subject: 'дело', decisions: [{ text: 'A' }], open: [{ text: 'Q?' }], will: [], doneNext: [] }; + const ex = { user: 'ответ на Q', assistant: 'ок', actions: [] }; + it('правила: не удалять, только зачёркивать, воля у [ЮЗЕР], шум игнор', () => { + const { system } = buildReconcilePrompt({ protocol: proto, lastExchange: ex }); + expect(system.toLowerCase()).toContain('не удаляй'); + expect(system.toLowerCase()).toContain('зачерк'); + expect(system).toContain('[ЮЗЕР]'); + expect(system.toLowerCase()).toContain('служебн'); + }); + it('в user — текущий протокол и обмен; замечание добавляется при возврате', () => { + const { user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex, remark: 'ВЕРНИ X' }); + expect(user).toContain('Q?'); + expect(user).toContain('ответ на Q'); + expect(user).toContain('ВЕРНИ X'); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: FAIL. + +- [ ] **Step 3: Реализация** + +```js +/** Запрос к модели-редактору: весь протокол + обмен → весь обновлённый протокол. */ +export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark = null } = {}) { + const system = [ + 'Ты — секретарь-редактор протокола работ. Тебе дают ВЕСЬ протокол дела и последний обмен.', + 'Верни ВЕСЬ обновлённый протокол ТОЛЬКО как JSON (subject, decisions[{text,why,struck}],', + 'will[{text,struck}], open[{text,struck}], doneNext[{text,done,struck}]).', + 'ПРАВИЛА:', + '1. НИЧЕГО НЕ УДАЛЯЙ. Решённое/отменённое/дубль — ЗАЧЕРКНИ ("struck":true), строка остаётся.', + '2. Существующие строки НЕ переписывай — только зачёркивай старую и добавляй новую.', + '3. Открытый вопрос, на который дан ответ, — зачеркни (закрой), не оставляй открытым.', + '4. "will" (воля/запреты) — ТОЛЬКО слова ВЛАДЕЛЬЦА [ЮЗЕР]; действия ассистента [АССИСТЕНТ] не сюда.', + '5. Игнорируй служебный шум (coverage, экономия, штатный, механика хуков/стены).', + '6. "why" — реальное обоснование; "subject" — стабильная суть всего дела.', + ].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 user = [ + `Тема дела: ${protocol.subject || '(нет)'}`, + sec('Решения', protocol.decisions), sec('Воля', protocol.will), + sec('Открытые', protocol.open), sec('Сделано', protocol.doneNext), + '', 'Последний обмен:', + `[ЮЗЕР]: ${lastExchange.user || ''}`, + `[АССИСТЕНТ]: ${lastExchange.assistant || ''}`, + `Действия: ${acts}`, + remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '', + '', 'Верни ВЕСЬ обновлённый протокол как JSON.', + ].join('\n'); + return { system, user }; +} +``` + +- [ ] **Step 4: Прогон — зелёный** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: PASS. + +--- + +## Task 5: Оркестратор `reconcileTurn` (вызов → сторож → возврат ≤2 → штамп) + +**Files:** +- Modify: `tools/secretary-reconcile.mjs` +- Test: `tools/secretary-reconcile.test.mjs` + +- [ ] **Step 1: Падающий тест (мотор инъектируется)** + +```js +import { reconcileTurn } from './secretary-reconcile.mjs'; + +describe('reconcileTurn', () => { + const proto = { subject: 'дело', decisions: [{ text: 'A', turns: [1], session: 's0' }], will: [], open: [{ text: 'Q?' }], doneNext: [], history: [] }; + const ex = { user: 'ответ', assistant: 'ок', actions: [] }; + it('чистый ответ модели → штампует и возвращает протокол', async () => { + const callModel = async () => '{ "subject":"дело", "decisions":[{"text":"A","why":null,"struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }'; + const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel }); + expect(out).not.toBeNull(); + expect(out.open[0]).toMatchObject({ text: 'Q?', struck: true }); + expect(out.decisions[0]).toMatchObject({ turns: [1], session: 's0' }); + }); + it('потерял строку дважды → null (оставляем прежний протокол)', async () => { + let n = 0; + const callModel = async () => { n++; return '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[], "will":[], "doneNext":[] }'; }; + const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel }); + expect(out).toBeNull(); + expect(n).toBe(3); // первый + 2 возврата + }); + it('кривой JSON → null без ретраев', async () => { + let n = 0; + const callModel = async () => { n++; return 'не json'; }; + const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel }); + expect(out).toBeNull(); + expect(n).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: FAIL. + +- [ ] **Step 3: Реализация** + +```js +/** Один ход reconcile: вызвать модель, сторож, до 2 возвратов; вернуть готовый протокол или + * null (тогда вызывающий оставляет прежний). callModel({system,user}) → строка ответа. */ +export async function reconcileTurn({ proto, ex, turn, session, callModel, maxRetries = 2 }) { + let remark = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const { system, user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex, remark }); + let text; try { text = await callModel({ system, user }); } catch { return null; } + const returned = parseReconcileResponse(typeof text === 'string' ? text : ''); + if (!returned) return null; // кривой JSON — прежний протокол цел + const guard = reconcileGuard(proto, returned); + if (guard.ok) return stampProvenance(proto, returned, turn, session); + remark = buildGuardRemark(guard.lost); + } + return null; // после 2 возвратов всё ещё теряет — прежний протокол не трогаем +} +``` + +- [ ] **Step 4: Прогон — зелёный** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs --reporter dot` +Expected: PASS (8/8 в файле). + +- [ ] **Step 5: Коммит** + +```bash +node tools/_commit.mjs # см. примечание о коммитах ниже; путь: tools/secretary-reconcile.mjs + .test.mjs +``` + +--- + +## Task 6: Подключить reconcile в stop-хук, ретайрить старое + +**Files:** +- Modify: `tools/secretary-stop-hook.mjs` (заменить блок выжимки) +- Modify: `tools/secretary-protocol.mjs` (удалить `applyExtraction`) +- Modify: `tools/secretary-protocol.test.mjs` (удалить тесты `applyExtraction`) +- Delete: `tools/secretary-extract.mjs`, `tools/secretary-extract.test.mjs` + +- [ ] **Step 1: Переписать блок выжимки в stop-хуке** + +Заменить импорты и тело онлайн-выжимки. Новый импорт: +```js +import { reconcileTurn } from './secretary-reconcile.mjs'; +``` +(убрать `buildExtractionPrompt`/`parseExtractionResponse`/`applyExtraction`). + +Тело (после чтения `proto` из `protocol.json`) — целиком: +```js + const callModel = (msgs) => callAnthropicAPI(msgs, { + apiKey, + baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined, + model: process.env.SECRETARY_LLM_MODEL || undefined, + }); + const updated = await reconcileTurn({ proto, ex, turn, session, callModel }); + if (updated) { + mkdirSync(workDir, { recursive: true }); + writeFileSync(protoJson, JSON.stringify(updated, null, 2), 'utf-8'); + writeFileSync(join(workDir, 'protocol.md'), renderProtocol(updated), 'utf-8'); + 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: (updated.subject && updated.subject.trim()) ? updated.subject.trim() : '(дело)', + status: 'открыто', date: new Date().toISOString().slice(0, 16).replace('T', ' '), + }); + writeFileSync(idxFile, upd, 'utf-8'); + } +``` +(`renderProtocol`, `EMPTY_PROTOCOL`, `upsertIndexEntry` импорты — оставить.) + +- [ ] **Step 2: `node --check` хука** + +Run: `node --check tools/secretary-stop-hook.mjs` +Expected: без ошибок. + +- [ ] **Step 3: Удалить `applyExtraction` из `secretary-protocol.mjs`** + +Удалить функцию `applyExtraction` целиком (от `export function applyExtraction` до её закрывающей `}`). Оставить `EMPTY_PROTOCOL`, `prov`, `src`, `renderProtocol`. Убедиться, что `EMPTY_PROTOCOL` содержит `history: []`. + +- [ ] **Step 4: Почистить тесты протокола** + +В `tools/secretary-protocol.test.mjs` удалить все `describe`, вызывающие `applyExtraction` (дедуп, тема, навигация, базовые add/supersede), оставить только тест(ы) `renderProtocol`/`EMPTY_PROTOCOL`. Импорт `applyExtraction` убрать. + +- [ ] **Step 5: Удалить старый мотор выжимки** + +```bash +node tools/_del.mjs # unlinkSync: tools/secretary-extract.mjs, tools/secretary-extract.test.mjs (+ сам себя) +``` + +- [ ] **Step 6: Полный свод секретаря — зелёный** + +Run: `npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-protocol.test.mjs tools/secretary-flag.test.mjs tools/secretary-index.test.mjs tools/secretary-layer1.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs --reporter dot` +Expected: PASS, 0 failures. + +- [ ] **Step 7: Синтаксис всех тронутых хуков** + +Run: `node --check tools/secretary-stop-hook.mjs; node --check tools/secretary-reconcile.mjs; node --check tools/secretary-protocol.mjs` +Expected: чисто. + +--- + +## Task 7: Живая проверка и коммит + +- [ ] **Step 1: Живой ход** — `включи секретаря тест-reconcile` → 2-3 содержательных хода (с открытым вопросом, потом ответом на него) → `выключи секретаря`. + +- [ ] **Step 2: Глазами** — открыть `docs/secretary/тест-reconcile/protocol.md`: отвеченный вопрос **зачёркнут** (не висит открытым), дублей нет, «воля» — слова владельца, `@сессия` на месте, ничего не пропало. + +- [ ] **Step 3: Коммит** через node-финализатор (`git add -- `, `git commit -F`, `git push gitea main`, `LEFTHOOK=0`), затем `git log -1`. + +--- + +## Примечание по коммитам (этот репозиторий) + +Удаление файлов: пол режет `rm`/PowerShell `Remove-Item` → удалять `node`-скриптом с `fs.unlinkSync` по явному списку (скрипт сносит и себя). Коммит/пуш: `node`-финализатор с `git add -- <явные пути>` / `git commit -F msg` / `git push gitea main`, всё с `LEFTHOOK=0` (pre-push gitleaks/lychee падают exit 127 — инфра-долг). Коммитить ТОЛЬКО явные секретарские пути (в общем дереве есть чужие изменения). + +--- + +## Self-Review (по спеке) + +- **§D1** (меняется ядро, остальное цело) → Task 6 (stop-хук на reconcile; Слой 1/провенанс/флажок/оглавление не тронуты). ✓ +- **§D2** (весь протокол → весь протокол) → Task 1 (разбор), Task 4 (промпт подаёт весь протокол). ✓ +- **§D3** (правила модели: не удалять, только зачёркивать, закрывать вопросы, воля у [ЮЗЕР], шум игнор, why/subject) → Task 4 (system-промпт) + тест правил. ✓ +- **§D4** (сторож: потеря → обоснованный возврат, 2 попытки, иначе прежний) → Task 2 (сторож+замечание), Task 5 (оркестратор, maxRetries=2, null при упорстве). ✓ +- **§D5** (провенанс turn+session, Слой 1) → Task 3 (штамп), Task 6 (хук штампует, Слой 1 не тронут). ✓ +- **§D6** (кривой JSON → прежний; потеря → откат; сырьё бэкап) → Task 5 (null-пути), Task 6 (пишем только при `updated`). ✓ +- **§D7** (границы) — дробление дела/выдачи в Слой 1/разделители/компакция/модель — НЕ трогаем. ✓ +- **§D8** (открытые: модель, рост) — не блокируют: модель из `SECRETARY_LLM_MODEL`, рост осознан. ✓ diff --git a/docs/superpowers/specs/2026-06-22-secretary-reconcile-design.md b/docs/superpowers/specs/2026-06-22-secretary-reconcile-design.md new file mode 100644 index 0000000..a647b39 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-secretary-reconcile-design.md @@ -0,0 +1,163 @@ +# Спецификация: секретарь-«редактор» — сверка всего протокола (reconcile) + +Переделка сердца выжимки секретаря протокола работ. Сейчас модель видит только последний +обмен и возвращает **добавки**, а хук их механически дописывает (`applyExtraction`: append + +дедуп по точному тексту). Из-за этого открытые вопросы никогда не закрываются, близкие дубли +копятся, отмена не ловится. Здесь фиксируется исходная задумка: **модель получает весь +протокол дела, сама его правит (сверяет) и возвращает целиком; хук — сторож, который следит, +что ничего не пропало, и при потере возвращает модели на доработку.** + +## Цель + +Чтобы секретарь реально **вёл дело**, а не копил свалку: отвеченные вопросы — закрывались, +дубли — схлопывались, отменённое — зачёркивалось, категории — были верны. И при этом +**ничего не терялось** (главная ценность). Прямая польза: при сборке спеки в протоколе +видно только реально открытые вопросы и актуальные решения, а не каша из закрытого. + +## Что меняется и что остаётся {#D1} + +**Контракт.** + +- **Меняется:** ядро слияния. Вместо «модель → добавки → `applyExtraction` дописывает» — + «модель → **весь обновлённый протокол** → хук-сторож проверяет и сохраняет». +- **Остаётся без изменений:** Слой 1 (сырьё), штамповка провенанса хуком (turn + session), + флажок по сессии, оглавление `содержание.md`, PII-фильтр, мотор вызова модели + (`callSelfAssessmentApi`/`callAnthropicAPI`), тихий отказ в проде. +- Старый `applyExtraction` (append+дедуп) — **снимается** этой переделкой; формат хранения + протокола (`protocol.json` те же поля: decisions/will/open/doneNext/history + subject) — + сохраняется, чтобы оглавление и провенанс продолжали работать. + +**Критерий.** После хода протокол дела — это то, что вернула модель и пропустил сторож; +поля файла те же, что и раньше (совместимы с оглавлением и провенансом). + +## Контракт мотора: весь протокол → весь протокол {#D2} + +**Контракт.** + +- На вход модели подаётся: **текущий протокол дела целиком** (все разделы, включая + зачёркнутые пункты) + **последний обмен** (реплики и действия из стенограммы). +- На выход модель возвращает **весь обновлённый протокол** в том же JSON-формате + (subject + 5 разделов), готовый к сохранению. +- Первый ход дела (протокол пуст) — модель создаёт протокол с нуля. + +**Edge-cases.** Пустой/механический ход — модель возвращает протокол без изменений. Кривой +JSON — см. §D6 (оставляем прежний протокол). + +**Критерий.** Запрос к модели содержит и прежний протокол, и новый обмен; ответ — полный +протокол, а не дельта. + +## Правило для модели (в промпте) {#D3} + +**Контракт.** В системном промпте модели прямо прописано: + +1. **Ничего не удаляй.** Что решено / отменено / дубль — **зачеркни** (пометь снятым), но + строка остаётся. Каждая прежняя строка обязана присутствовать в ответе — живой или + зачёркнутой. +2. **ТОЛЬКО зачёркивание.** Существующие строки не переписываются ВООБЩЕ — даже мелкая + правка делается так: **зачеркни старую строку и добавь новую** (как отмена), оригинальный + текст не трогай ни на символ. Так сторож надёжно сверяет, что ничего не пропало. +3. **Закрывай отвеченные вопросы:** открытый вопрос, на который в обмене дан ответ, — + зачеркни (перенеси в решённые), не оставляй «открытым». +4. **«Воля/запреты» — только слова ВЛАДЕЛЬЦА** (`[ЮЗЕР]`); действия и планы ассистента + (`[АССИСТЕНТ]`) сюда не клади. +5. **Игнорируй служебный шум** (coverage, экономия, штатный, механика хуков/стены) — это не + суть дела. +6. **«Почему»** — реальное обоснование решения; **«тема»** — стабильная суть всего дела. + +**Критерий.** Системный промпт содержит правило «не удалять, только добавлять/зачёркивать», +запрет переписывать строки, инструкцию закрывать отвеченные вопросы и сортировку «воли» по +говорящему. + +## Хук-сторож: проверка и возврат на доработку {#D4} + +**Контракт.** + +- После ответа модели хук **сверяет**: множество прежних пунктов (по нормализованному + тексту — trim + нижний регистр + схлопнутые пробелы, по всем разделам) обязано целиком + присутствовать в присланном протоколе (живым или зачёркнутым). +- **Пропал хотя бы один** → хук **возвращает модели на доработку с обоснованным замечанием**. + Замечание **понятное модели**: называет **конкретные** потерянные строки и прямо говорит, + что сделать («верни эти строки, зачеркни при необходимости, ничего больше не удаляй») — + чтобы модель исправила осознанно, а не гадала. Это повторный вызов модели с тем же + протоколом + замечанием. +- Число попыток — **2** (решено). Если после 2 попыток всё ещё теряет — + **сохраняем ПРЕЖНИЙ протокол без изменений** за этот ход (сырьё обмена уже в Слое 1 → + восстановимо позже сверкой). +- Провенанс (turn + session) хук проставляет на новые/изменённые записи как и раньше. + +**Edge-cases.** Модель прислала ВСЁ на месте — сохраняем. Модель вернула меньше попыток, но +уже чисто — сохраняем сразу. Сеть/таймаут на доработке — как обычный отказ (§D6). + +**Критерий.** Если присланный протокол теряет прежний пункт, он не сохраняется молча: либо +исправлен через возврат-с-замечанием, либо за ход остаётся прежний протокол. + +## Провенанс, Слой 1, флажок — без изменений {#D5} + +**Контракт.** + +- Слой 1 пишется как и раньше (каждый ход, сырьё дословно) — это крайний сейф: даже при + полном отказе выжимки обмен на диске. +- Провенанс: `[→N]` + `@` (навигация в `raw/.log`) — механика штамповки + хуком сохраняется. +- Флажок по сессии и оглавление — без изменений. + +**Критерий.** Слой 1, провенанс-с-сессией и оглавление продолжают работать без правок. + +## Edge-cases и отказы {#D6} + +**Контракт (что гарантируется).** + +- **Кривой JSON от модели** → прежний протокол не трогаем (тихий отказ, как сейчас). +- **Потеря данных в ответе** → возврат-с-замечанием; при упорстве — прежний протокол цел. +- **Сырьё — абсолютный бэкап:** любой сбой выжимки не теряет обмен (он в Слое 1). +- Рост протокола (зачёркнутые копятся, весь протокол шлётся каждый ход) — **осознанный + компромисс**; компакция архива — отдельная будущая задача (§D8). + +**Критерий.** Каждый named-отказ имеет либо исправление, либо явный безопасный откат; +«суть» восстановима из Слоя 1. + +## Границы — вне этой спеки {#D7} + +**Контракт.** Здесь **только** механика «весь протокол → сторож». НЕ проектируются (отдельные +задачи): дробление дела из-за кодового слова (подтверждение/список дел на «включи»); запись +**выдач** инструментов в Слой 1; визуальные разделители сессий в протоколе; компакция +зачёркнутого; выбор модели (Haiku/Sonnet — §D8). + +**Критерий.** Реализация этой спеки не требует трогать перечисленное; оно вынесено явно. + +## Открытые вопросы {#D8} + +**Ещё открыты (за владельцем):** +- **Модель.** Haiku или Sonnet для тяжёлой задачи «аккуратно переписать весь протокол»? + (владелец: «разберёмся» — самое простое, решим перед/при реализации). Меняется переменной + `SECRETARY_LLM_MODEL` без правки кода. +- **Рост протокола.** Зачёркнутое копится, весь протокол шлётся каждый ход. Пусть пока + растёт; компакцию/сворачивание архива доработаем позже (владелец: «посмотри, доработаем»). + +**Решено (закрыто владельцем 2026-06-22):** +- ~~Число попыток возврата~~ → **2 попытки**, замечание обоснованное и понятное модели (§D4). +- ~~Уточнение строк~~ → **только зачёркивание**, переписывание существующих строк запрещено + (§D3 п.2). + +```verified-context-json +[ + { + "id": "ctx-apply", + "kind": "EXTRACTED", + "ref": "tools/secretary-protocol.mjs", + "anchor": "export function applyExtraction" + }, + { + "id": "ctx-extract", + "kind": "EXTRACTED", + "ref": "tools/secretary-extract.mjs", + "anchor": "buildExtractionPrompt" + }, + { + "id": "ctx-stop-read", + "kind": "EXTRACTED", + "ref": "tools/secretary-stop-hook.mjs", + "anchor": "parseExtractionResponse" + } +] +``` diff --git a/tools/secretary-extract.mjs b/tools/secretary-extract.mjs deleted file mode 100644 index 653cc4e..0000000 --- a/tools/secretary-extract.mjs +++ /dev/null @@ -1,60 +0,0 @@ -// LLM-извлечение сути (обёртка зовёт мотор; здесь — чистые prompt-builder + parser). - -/** Собрать запрос к LLM: из последнего обмена + списка открытых дел → {system, user}. */ -export function buildExtractionPrompt({ lastExchange = {}, worksIndex = [] } = {}) { - const system = [ - 'Ты — секретарь протокола работ. Извлеки СУТЬ последнего обмена по 9 пунктам.', - 'Верни ТОЛЬКО JSON без markdown, поля:', - '{ "work":"", "тема":"<одна короткая строка: о чём это дело в целом>",', - ' "decisions":[{"text","why","turns":[]}],', - ' "supersede":[{"oldText","newText","turns":[]}], "will":[{"text","turns":[]}],', - ' "open":[{"text","turns":[]}], "doneNext":[{"text","done":false,"turns":[]}] }', - '', - 'ПРАВИЛА (соблюдай строго):', - '1. ИГНОРИРУЙ служебный шум среды — НЕ записывай ничего про: строку coverage, экономию,', - ' подтверждения "да, штатный"/штатный режим, хуки/стену/наставника/судью, опечатки команд.', - ' Это механика инструмента, а НЕ суть дела.', - '2. "will" (воля/запреты) — ТОЛЬКО пожелания и запреты ВЛАДЕЛЬЦА из реплик [ЮЗЕР].', - ' Действия, планы и предложения ассистента [АССИСТЕНТ] сюда НЕ клади.', - '3. "decisions" — только ПРИНЯТЫЕ решения. Вопрос или ожидание выбора — это "open", не "decisions".', - '4. "why" — реальное обоснование решения, НЕ фраза про сам процесс записи.', - '5. "тема" — стабильная суть ВСЕГО дела (о чём оно), не пересказ последнего хода; одна строка.', - 'ПЛОХО: will:["Напечатать план"] — это действие ассистента.', - 'ПЛОХО: decisions:["нужна строка coverage"] — служебный шум, не писать вовсе.', - 'Если сути нет — все массивы пустые.', - ].join('\n'); - const works = worksIndex.length - ? worksIndex.map((w) => `- ${w.slug}: ${w.title} — ${w.goal}`).join('\n') - : '(нет открытых дел)'; - const acts = (lastExchange.actions ?? []).map((a) => a.tool).join(', ') || '—'; - const user = [ - 'Открытые дела:', works, '', - 'Последний обмен:', - `Пользователь: ${lastExchange.user ?? ''}`, - `Ассистент: ${lastExchange.assistant ?? ''}`, - `Действия: ${acts}`, - '', 'Извлеки суть. Верни JSON.', - ].join('\n'); - return { system, user }; -} - -/** Разобрать ответ LLM в структуру для applyExtraction; null при сбое (тихо). */ -export function parseExtractionResponse(llmText) { - if (typeof llmText !== 'string' || !llmText.trim()) return null; - let s = llmText.trim().replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim(); - s = s.replace(/,(\s*[}\]])/g, '$1'); // хвостовые запятые — частый quirk LLM - let parsed; - try { parsed = JSON.parse(s); } catch { return null; } - if (!parsed || typeof parsed !== 'object') return null; - const arr = (x) => (Array.isArray(x) ? x : []); - return { - work: typeof parsed.work === 'string' ? parsed.work : null, - subject: typeof parsed['тема'] === 'string' ? parsed['тема'].trim() - : (typeof parsed.subject === 'string' ? parsed.subject.trim() : ''), - decisions: arr(parsed.decisions), - supersede: arr(parsed.supersede), - will: arr(parsed.will), - open: arr(parsed.open), - doneNext: arr(parsed.doneNext), - }; -} diff --git a/tools/secretary-extract.test.mjs b/tools/secretary-extract.test.mjs deleted file mode 100644 index af1a54d..0000000 --- a/tools/secretary-extract.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { buildExtractionPrompt, parseExtractionResponse } from './secretary-extract.mjs'; - -describe('buildExtractionPrompt', () => { - it('включает дела и обмен в запрос', () => { - const { system, user } = buildExtractionPrompt({ - lastExchange: { user: 'привет', assistant: 'ответ', actions: [{ tool: 'Read' }] }, - worksIndex: [{ slug: 'sec', title: 'Секретарь', goal: 'память сути' }], - }); - expect(system).toContain('JSON'); - expect(user).toContain('sec'); - expect(user).toContain('привет'); - expect(user).toContain('Read'); - }); - it('без дел — помечает отсутствие', () => { - const { user } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); - expect(user).toContain('нет открытых дел'); - }); -}); - -describe('parseExtractionResponse', () => { - it('парсит JSON в обёртке с хвостовой запятой', () => { - const out = parseExtractionResponse('```json\n{ "work":"sec", "decisions":[{"text":"A","turns":[7]}], }\n```'); - expect(out.work).toBe('sec'); - expect(out.decisions[0].text).toBe('A'); - expect(out.supersede).toEqual([]); - }); - it('мусор → null', () => { - expect(parseExtractionResponse('не json вовсе')).toBeNull(); - expect(parseExtractionResponse('')).toBeNull(); - }); -}); - -describe('тема (subject) для оглавления', () => { - it('buildExtractionPrompt просит поле тема', () => { - const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); - expect(system).toContain('тема'); - }); - it('parseExtractionResponse возвращает тему из поля «тема»', () => { - const out = parseExtractionResponse('{ "work":"sec", "тема":"фоновый секретарь протокола работ", "decisions":[] }'); - expect(out.subject).toBe('фоновый секретарь протокола работ'); - }); - it('без поля «тема» — пустая строка, не падает', () => { - const out = parseExtractionResponse('{ "work":"sec", "decisions":[] }'); - expect(out.subject).toBe(''); - }); -}); - -describe('дисциплина промпта (без шума, сортировка по говорящему)', () => { - it('велит игнорировать служебный шум среды', () => { - const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); - expect(system.toLowerCase()).toContain('служебн'); - }); - it('велит «волю» брать только у владельца [ЮЗЕР]', () => { - const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); - expect(system).toContain('[ЮЗЕР]'); - }); - it('велит решения отличать от вопросов (open)', () => { - const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); - expect(system.toLowerCase()).toContain('принят'); - }); -}); diff --git a/tools/secretary-protocol.mjs b/tools/secretary-protocol.mjs index 01e5e29..490b8c4 100644 --- a/tools/secretary-protocol.mjs +++ b/tools/secretary-protocol.mjs @@ -12,38 +12,6 @@ function src(entry) { return entry && entry.session ? ` @${String(entry.session).slice(0, 8)}` : ''; } -export function applyExtraction(protocol, extraction = {}) { - const p = { - subject: protocol.subject || '', - decisions: [...protocol.decisions], will: [...protocol.will], open: [...protocol.open], - doneNext: [...protocol.doneNext], history: [...protocol.history], - }; - // Тема дела (о чём) стабильна: ставим ОДИН раз (первая непустая), не перезатираем узкой - // темой последнего хода — иначе «тема всего дела» уезжает на тему свежего обмена (§D2). - if (!p.subject && typeof extraction.subject === 'string' && extraction.subject.trim()) { - p.subject = extraction.subject.trim(); - } - // Дедуп (§D5 «сверка, не дозапись»): нормализуем текст, не плодим одинаковые пункты. - const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); - const hasText = (arr, text) => arr.some((e) => norm(e.text) === norm(text)); - for (const d of extraction.decisions || []) { - if (p.decisions.some((x) => norm(x.text) === norm(d.text) && !x.struck)) continue; - p.decisions.push({ text: d.text, why: d.why || null, turns: d.turns || [], session: d.session || null, struck: false }); - } - for (const s of extraction.supersede || []) { - const old = p.decisions.find((d) => d.text === s.oldText && !d.struck); - if (old) old.struck = true; - if (!hasText(p.decisions.filter((d) => !d.struck), s.newText)) { - p.decisions.push({ text: s.newText, why: s.why || null, turns: s.turns || [], session: s.session || null, struck: false }); - } - p.history.push({ oldText: s.oldText, newText: s.newText, turns: s.turns || [] }); - } - for (const w of extraction.will || []) { if (!hasText(p.will, w.text)) p.will.push({ text: w.text, turns: w.turns || [], session: w.session || null }); } - for (const o of extraction.open || []) { if (!hasText(p.open, o.text)) p.open.push({ text: o.text, turns: o.turns || [], session: o.session || null }); } - for (const s of extraction.doneNext || []) { if (!hasText(p.doneNext, s.text)) p.doneNext.push({ text: s.text, done: !!s.done, turns: s.turns || [], session: s.session || null }); } - return p; -} - export function renderProtocol(protocol) { const L = []; L.push('## Решения'); @@ -53,11 +21,11 @@ export function renderProtocol(protocol) { L.push(`- ${body}${why}${prov(d.turns)}${src(d)}`); } L.push('', '## Твоя воля / запреты'); - for (const w of protocol.will) L.push(`- ${w.text}${prov(w.turns)}${src(w)}`); + for (const w of protocol.will) L.push(`- ${w.struck ? `~~${w.text}~~` : w.text}${prov(w.turns)}${src(w)}`); L.push('', '## Открытые вопросы'); - for (const o of protocol.open) L.push(`- ${o.text}${prov(o.turns)}${src(o)}`); + for (const o of protocol.open) L.push(`- ${o.struck ? `~~${o.text}~~` : o.text}${prov(o.turns)}${src(o)}`); L.push('', '## Сделано / дальше'); - for (const s of protocol.doneNext) L.push(`- [${s.done ? 'x' : ' '}] ${s.text}${prov(s.turns)}${src(s)}`); + for (const s of protocol.doneNext) L.push(`- [${s.done ? 'x' : ' '}] ${s.struck ? `~~${s.text}~~` : s.text}${prov(s.turns)}${src(s)}`); L.push('', '## История (заменено, не стёрто)'); for (const h of protocol.history) L.push(`- ~~${h.oldText}~~ → ${h.newText}${prov(h.turns)}`); return L.join('\n'); diff --git a/tools/secretary-protocol.test.mjs b/tools/secretary-protocol.test.mjs index bfe6b7c..3e3ba11 100644 --- a/tools/secretary-protocol.test.mjs +++ b/tools/secretary-protocol.test.mjs @@ -1,66 +1,32 @@ import { describe, it, expect } from 'vitest'; -import { applyExtraction, renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; -describe('secretary-protocol', () => { - it('добавляет решение с провенансом', () => { - const p = applyExtraction(EMPTY_PROTOCOL(), { - decisions: [{ text: 'единица = дело', why: 'тянется через сессии', turns: [7] }], +describe('EMPTY_PROTOCOL', () => { + it('пустой протокол со всеми разделами (вкл. history)', () => { + expect(EMPTY_PROTOCOL()).toEqual({ subject: '', decisions: [], will: [], open: [], doneNext: [], history: [] }); + }); +}); + +describe('renderProtocol', () => { + it('решение с провенансом [→N] и меткой сессии @ для навигации в raw', () => { + const md = renderProtocol({ + subject: 'тема', history: [], + decisions: [{ text: 'A', why: 'w', turns: [7], session: '69992620-x' }], + will: [], open: [], doneNext: [], }); - const md = renderProtocol(p); - expect(md).toContain('единица = дело'); - expect(md).toContain('[→7]'); + expect(md).toContain('- A — w [→7] @69992620'); }); - it('сверка зачёркивает, не удаляет', () => { - let p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'A', turns: [1] }] }); - p = applyExtraction(p, { supersede: [{ oldText: 'A', newText: 'B', turns: [2] }] }); - const md = renderProtocol(p); - expect(md).toContain('~~A~~'); - expect(md).toContain('B'); - }); -}); - -describe('secretary-protocol — тема дела', () => { - it('сохраняет тему из выжимки', () => { - const p = applyExtraction(EMPTY_PROTOCOL(), { subject: 'о чём дело', decisions: [] }); - expect(p.subject).toBe('о чём дело'); - }); - it('пустая тема не затирает прежнюю', () => { - let p = applyExtraction(EMPTY_PROTOCOL(), { subject: 'первая', decisions: [] }); - p = applyExtraction(p, { subject: '', decisions: [] }); - expect(p.subject).toBe('первая'); - }); - it('тема стабильна: вторая (непустая) не перезатирает первую', () => { - let p = applyExtraction(EMPTY_PROTOCOL(), { subject: 'создание секретаря', decisions: [] }); - p = applyExtraction(p, { subject: 'узкая тема последнего хода', decisions: [] }); - expect(p.subject).toBe('создание секретаря'); - }); -}); - -describe('secretary-protocol — навигация в Слой 1 (провенанс с сессией)', () => { - it('applyExtraction сохраняет session в записи решения', () => { - const p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'D', turns: [7], session: 'abc12345-zzz' }] }); - expect(p.decisions[0].session).toBe('abc12345-zzz'); - }); - it('renderProtocol показывает сессию рядом с [→N] для перехода в raw', () => { - const p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'D', turns: [7], session: '69992620-aaaa' }] }); - const md = renderProtocol(p); - expect(md).toContain('[→7]'); - expect(md).toContain('69992620'); - }); -}); - -describe('secretary-protocol — дедуп (без хлама)', () => { - it('не дублирует решение с тем же текстом (регистр/пробелы)', () => { - let p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'берём Postgres', turns: [1] }] }); - p = applyExtraction(p, { decisions: [{ text: ' берём postgres ', turns: [2] }] }); - expect(p.decisions.filter((d) => !d.struck).length).toBe(1); - }); - it('не дублирует пункты воли / открытых / сделано', () => { - const ext = { will: [{ text: 'не коммить без спроса' }], open: [{ text: 'какой бэкенд?' }], doneNext: [{ text: 'написать тест', done: false }] }; - let p = applyExtraction(EMPTY_PROTOCOL(), ext); - p = applyExtraction(p, ext); - expect(p.will.length).toBe(1); - expect(p.open.length).toBe(1); - expect(p.doneNext.length).toBe(1); + it('зачёркнутые пункты ВО ВСЕХ разделах показаны ~~...~~', () => { + const md = renderProtocol({ + subject: '', history: [], + decisions: [{ text: 'D', struck: true }], + will: [{ text: 'W', struck: true }], + open: [{ text: 'Q', struck: true }], + doneNext: [{ text: 'N', struck: true, done: false }], + }); + expect(md).toContain('~~D~~'); + expect(md).toContain('~~W~~'); + expect(md).toContain('~~Q~~'); + expect(md).toContain('~~N~~'); }); }); diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs new file mode 100644 index 0000000..87e1fa6 --- /dev/null +++ b/tools/secretary-reconcile.mjs @@ -0,0 +1,118 @@ +// Секретарь-«редактор»: модель правит весь протокол, хук сторожит потери (спека reconcile). + +/** Запрос к модели-редактору: весь протокол + обмен → весь обновлённый протокол. */ +export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark = null } = {}) { + const system = [ + 'Ты — секретарь-редактор протокола работ. Тебе дают ВЕСЬ протокол дела и последний обмен.', + 'Верни ВЕСЬ обновлённый протокол ТОЛЬКО как JSON (subject, decisions[{text,why,struck}],', + 'will[{text,struck}], open[{text,struck}], doneNext[{text,done,struck}]).', + 'ПРАВИЛА:', + '1. НИЧЕГО НЕ УДАЛЯЙ. Решённое/отменённое/дубль — ЗАЧЕРКНИ ("struck":true), строка остаётся.', + '2. Существующие строки НЕ переписывай — только зачёркивай старую и добавляй новую.', + '3. Открытый вопрос, на который дан ответ, — зачеркни (закрой), не оставляй открытым.', + '4. "will" (воля/запреты) — ТОЛЬКО слова ВЛАДЕЛЬЦА [ЮЗЕР]; действия ассистента [АССИСТЕНТ] не сюда.', + '5. Игнорируй служебный шум (coverage, экономия, штатный, механика хуков/стены).', + '6. "why" — реальное обоснование; "subject" — стабильная суть всего дела.', + ].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 user = [ + `Тема дела: ${protocol.subject || '(нет)'}`, + sec('Решения', protocol.decisions), sec('Воля', protocol.will), + sec('Открытые', protocol.open), sec('Сделано', protocol.doneNext), + '', 'Последний обмен:', + `[ЮЗЕР]: ${lastExchange.user || ''}`, + `[АССИСТЕНТ]: ${lastExchange.assistant || ''}`, + `Действия: ${acts}`, + remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '', + '', 'Верни ВЕСЬ обновлённый протокол как JSON.', + ].join('\n'); + return { system, user }; +} + +/** Разбор ответа модели в нормализованный протокол; null при кривом JSON (тихо). */ +export function parseReconcileResponse(llmText) { + if (typeof llmText !== 'string' || !llmText.trim()) return null; + let s = llmText.trim().replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim(); + s = s.replace(/,(\s*[}\]])/g, '$1'); + let parsed; try { parsed = JSON.parse(s); } catch { return null; } + if (!parsed || typeof parsed !== 'object') return null; + const list = (x) => (Array.isArray(x) ? x : []); + const ent = (e) => ({ text: String(e && e.text || ''), struck: !!(e && e.struck) }); + return { + subject: typeof parsed.subject === 'string' ? parsed.subject.trim() : '', + decisions: list(parsed.decisions).map((e) => ({ ...ent(e), why: (e && e.why) || null })), + will: list(parsed.will).map(ent), + open: list(parsed.open).map(ent), + doneNext: list(parsed.doneNext).map((e) => ({ ...ent(e), done: !!(e && e.done) })), + }; +} + +const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); +const SECTIONS = ['decisions', 'will', 'open', 'doneNext']; + +function allTexts(p) { + const out = []; + for (const sec of SECTIONS) for (const e of (p && p[sec]) || []) out.push(e); + return out; +} + +/** Сторож: каждая прежняя строка обязана присутствовать в новом протоколе (живой или + * зачёркнутой). Возвращает { ok, lost: [оригинальные тексты] }. */ +export function reconcileGuard(oldProtocol, returned) { + const have = new Set(allTexts(returned).map((e) => norm(e.text))); + const lost = []; + for (const e of allTexts(oldProtocol)) { + if (!have.has(norm(e.text))) lost.push(e.text); + } + return { ok: lost.length === 0, lost }; +} + +/** Обоснованное замечание для возврата модели на доработку (§D4). */ +export function buildGuardRemark(lost) { + const list = (lost || []).map((t) => `- ${t}`).join('\n'); + return [ + 'ОШИБКА: ты потерял строки протокола. Их НЕЛЬЗЯ удалять.', + 'Верни эти строки на место (зачеркни — "struck": true — если они решены/отменены, но НЕ удаляй):', + list, + 'Снова верни ВЕСЬ протокол целиком, со всеми прежними строками.', + ].join('\n'); +} + +/** Привязать провенанс к присланному протоколу: старая запись (по тексту) сохраняет свои + * turns/session; новая получает [turn]+session. history прежнего протокола сохраняется. */ +export function stampProvenance(oldProtocol, returned, turn, session) { + const index = new Map(); + for (const e of allTexts(oldProtocol)) index.set(norm(e.text), e); + const stamp = (e) => { + const prev = index.get(norm(e.text)); + return prev + ? { ...e, turns: prev.turns || [turn], session: prev.session || session } + : { ...e, turns: [turn], session }; + }; + return { + subject: returned.subject || oldProtocol.subject || '', + decisions: (returned.decisions || []).map(stamp), + will: (returned.will || []).map(stamp), + open: (returned.open || []).map(stamp), + doneNext: (returned.doneNext || []).map(stamp), + history: Array.isArray(oldProtocol.history) ? oldProtocol.history : [], + }; +} + +/** Один ход reconcile: вызвать модель, сторож, до 2 возвратов; вернуть готовый протокол или + * null (тогда вызывающий оставляет прежний). callModel({system,user}) → строка ответа. */ +export async function reconcileTurn({ proto, ex, turn, session, callModel, maxRetries = 2 }) { + let remark = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const { system, user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex, remark }); + let text; try { text = await callModel({ system, user }); } catch { return null; } + const returned = parseReconcileResponse(typeof text === 'string' ? text : ''); + if (!returned) return null; // кривой JSON — прежний протокол цел + const guard = reconcileGuard(proto, returned); + if (guard.ok) return stampProvenance(proto, returned, turn, session); + remark = buildGuardRemark(guard.lost); + } + return null; // после 2 возвратов всё ещё теряет — прежний протокол не трогаем +} diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs new file mode 100644 index 0000000..e0d41d7 --- /dev/null +++ b/tools/secretary-reconcile.test.mjs @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { parseReconcileResponse, reconcileGuard, buildGuardRemark, stampProvenance, buildReconcilePrompt, reconcileTurn } from './secretary-reconcile.mjs'; + +describe('parseReconcileResponse', () => { + it('парсит весь протокол (с обёрткой/хвостовой запятой)', () => { + const out = parseReconcileResponse('```json\n{ "subject":"S", "decisions":[{"text":"A","why":"w","struck":false}], "open":[{"text":"Q","struck":true}], }\n```'); + expect(out.subject).toBe('S'); + expect(out.decisions[0]).toEqual({ text: 'A', why: 'w', struck: false }); + expect(out.open[0]).toEqual({ text: 'Q', struck: true }); + expect(out.will).toEqual([]); + expect(out.doneNext).toEqual([]); + }); + it('мусор → null', () => { + expect(parseReconcileResponse('не json')).toBeNull(); + expect(parseReconcileResponse('')).toBeNull(); + }); +}); + +describe('reconcileGuard', () => { + const old = { decisions: [{ text: 'Берём Postgres' }], open: [{ text: 'Хайку или Sonnet?' }], will: [], doneNext: [] }; + it('всё на месте (в т.ч. зачёркнутое) → ok', () => { + const ret = { decisions: [{ text: 'берём postgres', struck: false }], open: [{ text: 'Хайку или Sonnet?', struck: true }], will: [], doneNext: [] }; + expect(reconcileGuard(old, ret).ok).toBe(true); + }); + it('строка пропала → не ok + список потерь', () => { + const ret = { decisions: [{ text: 'берём postgres' }], open: [], will: [], doneNext: [] }; + const g = reconcileGuard(old, ret); + expect(g.ok).toBe(false); + expect(g.lost).toContain('Хайку или Sonnet?'); + }); +}); + +describe('buildGuardRemark', () => { + it('обоснованное замечание называет потерянные строки и что делать', () => { + const r = buildGuardRemark(['Хайку или Sonnet?']); + expect(r).toContain('Хайку или Sonnet?'); + expect(r.toLowerCase()).toContain('верни'); + expect(r.toLowerCase()).toContain('не удаляй'); + }); +}); + +describe('stampProvenance', () => { + const old = { subject: 'тема', history: [{ oldText: 'x', newText: 'y', turns: [1] }], + decisions: [{ text: 'A', why: 'w', turns: [3], session: 'sessOLD', struck: false }], + will: [], open: [], doneNext: [] }; + const returned = { subject: 'тема', + decisions: [{ text: 'A', why: 'w', struck: false }, { text: 'B', why: 'w2', struck: false }], + will: [], open: [], doneNext: [] }; + it('старая запись сохраняет свой turns/session, новая получает текущие', () => { + const p = stampProvenance(old, returned, 9, 'sessNEW'); + expect(p.decisions[0]).toMatchObject({ text: 'A', turns: [3], session: 'sessOLD' }); + expect(p.decisions[1]).toMatchObject({ text: 'B', turns: [9], session: 'sessNEW' }); + }); + it('history прежнего протокола сохраняется', () => { + const p = stampProvenance(old, returned, 9, 'sessNEW'); + expect(p.history).toEqual(old.history); + }); +}); + +describe('buildReconcilePrompt', () => { + const proto = { subject: 'дело', decisions: [{ text: 'A' }], open: [{ text: 'Q?' }], will: [], doneNext: [] }; + const ex = { user: 'ответ на Q', assistant: 'ок', actions: [] }; + it('правила: не удалять, только зачёркивать, воля у [ЮЗЕР], шум игнор', () => { + const { system } = buildReconcilePrompt({ protocol: proto, lastExchange: ex }); + expect(system.toLowerCase()).toContain('не удаляй'); + expect(system.toLowerCase()).toContain('зачерк'); + expect(system).toContain('[ЮЗЕР]'); + expect(system.toLowerCase()).toContain('служебн'); + }); + it('в user — текущий протокол и обмен; замечание добавляется при возврате', () => { + const { user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex, remark: 'ВЕРНИ X' }); + expect(user).toContain('Q?'); + expect(user).toContain('ответ на Q'); + expect(user).toContain('ВЕРНИ X'); + }); +}); + +describe('reconcileTurn', () => { + const proto = { subject: 'дело', decisions: [{ text: 'A', turns: [1], session: 's0' }], will: [], open: [{ text: 'Q?' }], doneNext: [], history: [] }; + const ex = { user: 'ответ', assistant: 'ок', actions: [] }; + it('чистый ответ модели → штампует и возвращает протокол', async () => { + const callModel = async () => '{ "subject":"дело", "decisions":[{"text":"A","why":null,"struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }'; + const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel }); + expect(out).not.toBeNull(); + expect(out.open[0]).toMatchObject({ text: 'Q?', struck: true }); + expect(out.decisions[0]).toMatchObject({ turns: [1], session: 's0' }); + }); + it('потерял строку дважды → null (оставляем прежний протокол)', async () => { + let n = 0; + const callModel = async () => { n++; return '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[], "will":[], "doneNext":[] }'; }; + const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel }); + expect(out).toBeNull(); + expect(n).toBe(3); // первый + 2 возврата + }); + it('кривой JSON → null без ретраев', async () => { + let n = 0; + const callModel = async () => { n++; return 'не json'; }; + const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel }); + expect(out).toBeNull(); + expect(n).toBe(1); + }); +}); diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index 5a4a47d..a603af1 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -1,16 +1,16 @@ #!/usr/bin/env node // Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён — // онлайн-выжимка в протокол дела через НОВЫЙ мотор (SECRETARY_LLM_KEY). -// Тонкий shell над чистыми parseLastExchange / buildRawRecord / buildExtractionPrompt / -// parseExtractionResponse / applyExtraction / renderProtocol / upsertIndexEntry. +// Тонкий shell над parseLastExchange / buildRawRecord / reconcileTurn (модель-редактор) / +// renderProtocol / upsertIndexEntry. import { existsSync, readFileSync, writeFileSync, appendFileSync, 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 } from './secretary-layer1.mjs'; -import { buildExtractionPrompt, parseExtractionResponse } from './secretary-extract.mjs'; -import { applyExtraction, renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +import { reconcileTurn } 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'; @@ -55,38 +55,33 @@ async function main() { const work = flag.work || 'general'; try { - const { system, user } = buildExtractionPrompt({ lastExchange: ex, worksIndex: [] }); - const text = await callAnthropicAPI({ system, user }, { + const workDir = join(secdir, work); + const protoJson = join(workDir, 'protocol.json'); + let proto = EMPTY_PROTOCOL(); + try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); } + + // Модель-редактор правит ВЕСЬ протокол; сторож следит, что ничего не пропало (спека reconcile). + const callModel = (msgs) => callAnthropicAPI(msgs, { apiKey, baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined, model: process.env.SECRETARY_LLM_MODEL || undefined, }); - const extraction = parseExtractionResponse(typeof text === 'string' ? text : ''); - if (extraction) { - // Номер хода и сессию знает только хук — форсим turn + session на все записи (Хайку их - // не знает; session нужна для навигации провенанс → raw/.log без коллизий ходов). - for (const arr of [extraction.decisions, extraction.will, extraction.open, extraction.doneNext, extraction.supersede]) { - for (const e of (arr || [])) { e.turns = [turn]; e.session = session; } - } - const workDir = join(secdir, work); - const protoJson = join(workDir, 'protocol.json'); - let proto = EMPTY_PROTOCOL(); - try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); } - proto = applyExtraction(proto, extraction); + const updated = await reconcileTurn({ proto, ex, turn, session, callModel }); + if (updated) { mkdirSync(workDir, { recursive: true }); - writeFileSync(protoJson, JSON.stringify(proto, null, 2), 'utf-8'); - writeFileSync(join(workDir, 'protocol.md'), renderProtocol(proto), 'utf-8'); + writeFileSync(protoJson, JSON.stringify(updated, null, 2), 'utf-8'); + writeFileSync(join(workDir, 'protocol.md'), renderProtocol(updated), 'utf-8'); const idxFile = join(secdir, 'содержание.md'); let idxMd = ''; try { if (existsSync(idxFile)) idxMd = readFileSync(idxFile, 'utf-8'); } catch { idxMd = ''; } - const updated = upsertIndexEntry(idxMd, { + const upd = upsertIndexEntry(idxMd, { slug: work, title: work, - goal: (proto.subject && proto.subject.trim()) ? proto.subject.trim() : '(дело)', + goal: (updated.subject && updated.subject.trim()) ? updated.subject.trim() : '(дело)', status: 'открыто', date: new Date().toISOString().slice(0, 16).replace('T', ' '), }); - writeFileSync(idxFile, updated, 'utf-8'); + writeFileSync(idxFile, upd, 'utf-8'); } } catch { /* fail-quiet: сырьё уже записано */ } process.exit(0);