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:
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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('принят');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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~~');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 возвратов всё ещё теряет — прежний протокол не трогаем
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user