From 9deeb98e1fc6511388b6e92c4644e63e2c2f8b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 23 Jun 2026 09:18:56 +0300 Subject: [PATCH] =?UTF-8?q?docs(secretary):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=C2=AB?= =?UTF-8?q?=D0=A5=D0=BE=D0=B4=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 задач TDD: parse step{user,assistant} + правило в запросе; buildStepLine принимает essence; reconcileTurn прокидывает step (транзитно); stop-hook использует модельную суть с фолбэком и не персистит step; выключение не затирает модельные «Шаги» (mergeStepsPreservingText, слияние по ходу). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-23-secretary-step-formulation.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-secretary-step-formulation.md diff --git a/docs/superpowers/plans/2026-06-23-secretary-step-formulation.md b/docs/superpowers/plans/2026-06-23-secretary-step-formulation.md new file mode 100644 index 0000000..bbc63de --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-secretary-step-formulation.md @@ -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`) и дописывает детерминированный «делал: »; 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).