feat(secretary): reconcile — модель-редактор правит весь протокол, хук-сторож против потерь

- 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-22 10:54:20 +03:00
parent d44254a0e1
commit 67fecd7149
9 changed files with 928 additions and 240 deletions
@@ -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 -- <reconcile + stop-hook + protocol + удаления>`, `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`, рост осознан. ✓
@@ -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]` + `@<session>` (навигация в `raw/<session>.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"
}
]
```
-60
View File
@@ -1,60 +0,0 @@
// LLM-извлечение сути (обёртка зовёт мотор; здесь — чистые prompt-builder + parser).
/** Собрать запрос к LLM: из последнего обмена + списка открытых дел → {system, user}. */
export function buildExtractionPrompt({ lastExchange = {}, worksIndex = [] } = {}) {
const system = [
'Ты — секретарь протокола работ. Извлеки СУТЬ последнего обмена по 9 пунктам.',
'Верни ТОЛЬКО JSON без markdown, поля:',
'{ "work":"<slug дела или NEW>", "тема":"<одна короткая строка: о чём это дело в целом>",',
' "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),
};
}
-62
View File
@@ -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('принят');
});
});
+3 -35
View File
@@ -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');
+26 -60
View File
@@ -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~~');
});
});
+118
View File
@@ -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 возвратов всё ещё теряет — прежний протокол не трогаем
}
+102
View File
@@ -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);
});
});
+18 -23
View File
@@ -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/<session>.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);