docs(secretary): план реализации — секретарь формулирует строку «Хода»
5 задач TDD: parse step{user,assistant} + правило в запросе; buildStepLine
принимает essence; reconcileTurn прокидывает step (транзитно); stop-hook
использует модельную суть с фолбэком и не персистит step; выключение не
затирает модельные «Шаги» (mergeStepsPreservingText, слияние по ходу).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
# Секретарь формулирует строку «Хода» — 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:** Строку «Хода» в разделе «Шаги» формулирует секретарь-модель (одно поле `step` в существующем reconcile-ответе), а не детерминированный обрез; инструменты и ссылка на Слой 1 остаются за хуком.
|
||||
|
||||
**Architecture:** В `parseReconcileResponse` читаем новое поле `step{user,assistant}`; `reconcileTurn` прикладывает его к результату (транзитно); `buildStepLine` принимает готовую суть (`essence`) и дописывает детерминированный «делал: <tools>»; stop-hook использует модельную суть с фолбэком и НЕ персистит `step`; на выключении секретаря `buildStepsFromRaw` больше не затирает модельный текст (слияние по ходу).
|
||||
|
||||
**Tech Stack:** Node ESM, vitest (`import { describe, it, expect } from 'vitest'`).
|
||||
|
||||
**Среда/коммиты:** штатный режим (стена снята). Код-коммит — через скрипт-финализатор (`node`-скрипт с `git add/commit`, `LEFTHOOK=0`), т.к. verify-гейт пэттерн-матчит `git commit`, не `node`. Перед каждым коммитом — зелёный свод секретаря:
|
||||
`npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs`
|
||||
|
||||
---
|
||||
|
||||
## Структура файлов
|
||||
|
||||
- `tools/secretary-reconcile.mjs` — Modify: `buildReconcilePrompt` (+правило/поле), `parseReconcileResponse` (+`parseStep`), `reconcileTurn` (+проброс `step`).
|
||||
- `tools/secretary-reconcile.test.mjs` — Modify: тесты на `step`.
|
||||
- `tools/secretary-layer1.mjs` — Modify: `buildStepLine` (+`essence`), Create `mergeStepsPreservingText`.
|
||||
- `tools/secretary-layer1.test.mjs` — Modify: тесты на `essence` и слияние.
|
||||
- `tools/secretary-stop-hook.mjs` — Modify: использовать `updated.step`, срезать перед записью.
|
||||
- `tools/secretary-prompt-hook.mjs` — Modify: ветка `off` зовёт `mergeStepsPreservingText`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: parse `step` + правило в запросе
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/secretary-reconcile.mjs` (`buildReconcilePrompt` система; `parseReconcileResponse`)
|
||||
- Test: `tools/secretary-reconcile.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Failing test — parse читает step**
|
||||
|
||||
В `tools/secretary-reconcile.test.mjs`, в `describe('parseReconcileResponse', ...)` добавить:
|
||||
```js
|
||||
it('читает step{user,assistant}; пустой/кривой → null', () => {
|
||||
const out = parseReconcileResponse('{ "subject":"S", "step":{ "user":" хотел X ", "assistant":"сделал Y" } }');
|
||||
expect(out.step).toEqual({ user: 'хотел X', assistant: 'сделал Y' });
|
||||
expect(parseReconcileResponse('{ "subject":"S" }').step).toBeNull();
|
||||
expect(parseReconcileResponse('{ "subject":"S", "step":{} }').step).toBeNull();
|
||||
});
|
||||
```
|
||||
И в `describe('buildReconcilePrompt', ...)` добавить:
|
||||
```js
|
||||
it('просит поле step (суть хода)', () => {
|
||||
const { system } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: {} });
|
||||
expect(system.toLowerCase()).toContain('step');
|
||||
expect(system.toLowerCase()).toContain('суть');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — fails**
|
||||
|
||||
Run: `npx vitest run tools/secretary-reconcile.test.mjs -t step`
|
||||
Expected: FAIL (`out.step` undefined; system без 'step').
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
В `tools/secretary-reconcile.mjs`, `parseReconcileResponse`, перед `return {`:
|
||||
```js
|
||||
const parseStep = (s) => {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
const u = typeof s.user === 'string' ? s.user.trim() : '';
|
||||
const a = typeof s.assistant === 'string' ? s.assistant.trim() : '';
|
||||
return (u || a) ? { user: u, assistant: a } : null;
|
||||
};
|
||||
```
|
||||
В объекте `return { ... }` добавить последним полем:
|
||||
```js
|
||||
step: parseStep(parsed.step),
|
||||
```
|
||||
В `buildReconcilePrompt`, в массив `system` после правила 7 добавить:
|
||||
```js
|
||||
'8. ДОПОЛНИТЕЛЬНО верни поле "step": {"user":"<суть: что юзер хотел/спросил>",',
|
||||
' "assistant":"<что ассистент сделал/выяснил/решил/предложил + ключевые находки>"} —',
|
||||
' сжатая СУТЬ текущего хода без воды (вежливость/повторы убери; длина по содержанию;',
|
||||
' факты не выдумывай; инструменты НЕ перечисляй — их подставит система).',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — passes**
|
||||
|
||||
Run: `npx vitest run tools/secretary-reconcile.test.mjs`
|
||||
Expected: PASS (все, включая прежние).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Финализатором: `git add -- tools/secretary-reconcile.mjs tools/secretary-reconcile.test.mjs` → commit `feat(secretary): reconcile возвращает step{user,assistant}`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `buildStepLine` принимает `essence`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/secretary-layer1.mjs:64` (`buildStepLine`)
|
||||
- Test: `tools/secretary-layer1.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
В `tools/secretary-layer1.test.mjs`, в `describe('buildStepLine', ...)` добавить:
|
||||
```js
|
||||
it('essence: берёт модельную суть дословно + детерминированный «делал»', () => {
|
||||
const s = buildStepLine({ turn: 12, user: 'длинная вода без точек '.repeat(10),
|
||||
assistant: 'вода', actions: ['Read', 'Read', 'Grep'],
|
||||
essence: { user: 'промпт не логируется?', assistant: 'достать можно: поймать или пересобрать' } });
|
||||
expect(s).toBe('Ход 12 — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep');
|
||||
});
|
||||
it('без essence — прежний фолбэк (firstSentence)', () => {
|
||||
const s = buildStepLine({ turn: 2, user: 'сделай флажок.', assistant: 'Готово.', essence: null });
|
||||
expect(s).toContain('я: сделай флажок');
|
||||
expect(s).toContain('ты: Готово');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — fails**
|
||||
|
||||
Run: `npx vitest run tools/secretary-layer1.test.mjs -t essence`
|
||||
Expected: FAIL (essence игнорируется, реплики режутся firstSentence).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
В `tools/secretary-layer1.mjs` сигнатуру `buildStepLine` заменить на:
|
||||
```js
|
||||
export function buildStepLine({ turn, user, assistant, actions = [], essence = null } = {}) {
|
||||
```
|
||||
Строки вычисления `u`/`a` (сейчас `const u = sysLabel(user) || ...; const a = firstSentence(cleanA) || ...;`) заменить на:
|
||||
```js
|
||||
const clean1 = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
||||
const eU = essence && clean1(essence.user);
|
||||
const eA = essence && clean1(essence.assistant);
|
||||
const u = eU || sysLabel(user) || firstSentence(user) || '(без вопроса)';
|
||||
const a = eA || firstSentence(cleanA) || '(без ответа)';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — passes**
|
||||
|
||||
Run: `npx vitest run tools/secretary-layer1.test.mjs`
|
||||
Expected: PASS (включая прежние buildStepLine-тесты).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
`git add -- tools/secretary-layer1.mjs tools/secretary-layer1.test.mjs` → commit `feat(secretary): buildStepLine принимает готовую суть (essence)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `reconcileTurn` прикладывает `step`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/secretary-reconcile.mjs` (`reconcileTurn`)
|
||||
- Test: `tools/secretary-reconcile.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
В `tools/secretary-reconcile.test.mjs`, в `describe('reconcileTurn', ...)` добавить:
|
||||
```js
|
||||
it('проброс step из ответа модели в результат; без step — поля нет', async () => {
|
||||
const withStep = async () => '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[], "step":{"user":"u","assistant":"a"} }';
|
||||
const r1 = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel: withStep });
|
||||
expect(r1.step).toEqual({ user: 'u', assistant: 'a' });
|
||||
const noStep = async () => '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }';
|
||||
const r2 = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel: noStep });
|
||||
expect(r2.step).toBeUndefined();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — fails**
|
||||
|
||||
Run: `npx vitest run tools/secretary-reconcile.test.mjs -t "проброс step"`
|
||||
Expected: FAIL (`r1.step` undefined — stampProvenance роняет поле).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
В `tools/secretary-reconcile.mjs`, в `reconcileTurn`, блок после `const returned = collapseProtocol(parsed);` заменить на:
|
||||
```js
|
||||
const step = parsed.step || null;
|
||||
const finish = (p) => { const out = collapseProtocol(p); return step ? { ...out, step } : out; };
|
||||
const returned = collapseProtocol(parsed);
|
||||
const guard = reconcileGuard(clean, returned);
|
||||
if (guard.ok) return finish(stampProvenance(clean, returned, turn, session));
|
||||
report({ reason: 'guard-restored', lost: guard.lost });
|
||||
return finish(stampProvenance(clean, restoreLostLines(clean, returned), turn, session));
|
||||
```
|
||||
(`const returned`/`const guard`/ветки `if/return` — это замена существующих строк; `parsed` уже содержит `step` из Task 1.)
|
||||
|
||||
- [ ] **Step 4: Run — passes**
|
||||
|
||||
Run: `npx vitest run tools/secretary-reconcile.test.mjs`
|
||||
Expected: PASS (все 40+).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
`git add -- tools/secretary-reconcile.mjs tools/secretary-reconcile.test.mjs` → commit `feat(secretary): reconcileTurn прокидывает step в результат`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: stop-hook использует модельную суть, не персистит `step`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/secretary-stop-hook.mjs:103-105`
|
||||
|
||||
Тонкий хук-шелл без своего теста — опирается на протестированный `buildStepLine` (Task 2) и `reconcileTurn` (Task 3). Проверка — ручным прогоном (Step 3).
|
||||
|
||||
- [ ] **Step 1: Implement**
|
||||
|
||||
В `tools/secretary-stop-hook.mjs` блок построения `step`/`toWrite` (сейчас):
|
||||
```js
|
||||
const step = { turn, session,
|
||||
text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant, actions: (ex.actions || []).map((a) => a.tool) }) };
|
||||
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
|
||||
```
|
||||
заменить на:
|
||||
```js
|
||||
// Модельная суть хода (если reconcile её вернул) — иначе фолбэк firstSentence в buildStepLine.
|
||||
const modelStep = (updated && updated.step) || null;
|
||||
if (updated && 'step' in updated) delete updated.step; // транзитное — в protocol.json не сохраняем
|
||||
const step = { turn, session,
|
||||
text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant,
|
||||
actions: (ex.actions || []).map((a) => a.tool), essence: modelStep }) };
|
||||
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон секретаря (smoke)**
|
||||
|
||||
В деле с включённым секретарём и `SECRETARY_LLM_KEY` сделать обычный ход. Проверить (Read):
|
||||
- `protocol.md` → строка «Ход N» читаемая, без обрыва на полуслове;
|
||||
- `protocol.json` → поля `step` на верхнем уровне НЕТ.
|
||||
Expected: суть от модели, `step` не осел в JSON.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
`git add -- tools/secretary-stop-hook.mjs` → commit `feat(secretary): шаг из модельной сути с фолбэком, step не персистится`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: выключение секретаря не затирает модельный текст
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/secretary-layer1.mjs` (Create `mergeStepsPreservingText`)
|
||||
- Modify: `tools/secretary-prompt-hook.mjs:79`
|
||||
- Test: `tools/secretary-layer1.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
В `tools/secretary-layer1.test.mjs` добавить новый describe:
|
||||
```js
|
||||
describe('mergeStepsPreservingText — выключение не затирает модельный текст', () => {
|
||||
const raw = [
|
||||
'=== ХОД turn=1 · t · session=s ===', '[ЮЗЕР]', 'привет', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===',
|
||||
'=== ХОД turn=2 · t · session=s ===', '[ЮЗЕР]', 'вопрос', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '',
|
||||
].join('\n');
|
||||
it('существующий шаг сохраняется, пропущенный достраивается из сырья', () => {
|
||||
const existing = [{ turn: 2, session: 's', text: 'Ход 2 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }];
|
||||
const out = mergeStepsPreservingText(existing, raw, 's');
|
||||
expect(out.map((s) => s.turn)).toEqual([1, 2]);
|
||||
expect(out.find((s) => s.turn === 2).text).toBe('Ход 2 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —');
|
||||
expect(out.find((s) => s.turn === 1).text).toContain('Ход 1 — я: привет');
|
||||
});
|
||||
});
|
||||
```
|
||||
(Импорт `mergeStepsPreservingText` добавить в строку импорта теста.)
|
||||
|
||||
- [ ] **Step 2: Run — fails**
|
||||
|
||||
Run: `npx vitest run tools/secretary-layer1.test.mjs -t mergeStepsPreservingText`
|
||||
Expected: FAIL (`mergeStepsPreservingText is not a function`).
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
В `tools/secretary-layer1.mjs` после `buildStepsFromRaw` добавить:
|
||||
```js
|
||||
// Слияние «Шагов» при выключении: на КАЖДЫЙ ход из сырья берём существующий шаг (модельная
|
||||
// формулировка) если он есть, иначе достраиваем детерминированно из сырья. Порядок — по сырью
|
||||
// (хронология); модельный текст переживает выключение/нарезку.
|
||||
export function mergeStepsPreservingText(existingSteps, rawText, session) {
|
||||
const have = new Map((Array.isArray(existingSteps) ? existingSteps : []).map((s) => [s.turn, s]));
|
||||
return buildStepsFromRaw(rawText, session).map((r) => (have.has(r.turn) ? have.get(r.turn) : r));
|
||||
}
|
||||
```
|
||||
В `tools/secretary-prompt-hook.mjs` строку `proto.steps = buildStepsFromRaw(raw, session);` заменить на:
|
||||
```js
|
||||
proto.steps = mergeStepsPreservingText(proto.steps, raw, session);
|
||||
```
|
||||
И в импорт `tools/secretary-prompt-hook.mjs` добавить `mergeStepsPreservingText`:
|
||||
```js
|
||||
import { prepareTurnFiles, buildStepsFromRaw, mergeStepsPreservingText } from './secretary-layer1.mjs';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — passes**
|
||||
|
||||
Run: `npx vitest run tools/secretary-layer1.test.mjs tools/secretary-prompt-hook.test.mjs`
|
||||
Expected: PASS (включая прежние).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
`git add -- tools/secretary-layer1.mjs tools/secretary-prompt-hook.mjs tools/secretary-layer1.test.mjs` → commit `fix(secretary): выключение не затирает модельные «Шаги» (слияние по ходу)`.
|
||||
|
||||
---
|
||||
|
||||
## Финал
|
||||
|
||||
- [ ] Полный свод секретаря зелёный (команда из шапки).
|
||||
- [ ] Smoke (Task 4 Step 2) пройден на живом ходу.
|
||||
- [ ] Push на gitea по запросу владельца.
|
||||
|
||||
## Self-review (карта спека → задачи)
|
||||
|
||||
- Спека §«Контракты 1 (reconcile)» → Task 1 + Task 3.
|
||||
- §«Контракты 2 (layer1 buildStepLine)» → Task 2.
|
||||
- §«Контракты 3 (stop-hook)» → Task 4.
|
||||
- §«Контракты 4 (prompt-hook off)» → Task 5.
|
||||
- §«step транзитное, не персистится» → Task 4 (`delete updated.step`).
|
||||
- §«делал/ссылка детерминированы» → Task 2 (`делал` из actions; ссылка — рендер, не трогаем).
|
||||
- §«фолбэк на сбой» → Task 2 (без essence) + Task 4 (modelStep=null).
|
||||
Reference in New Issue
Block a user