From 9c8dbfde3559fcc3bd57703567d79517c567bd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 22 Jun 2026 11:34:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=20=C2=AB=D0=A8=D0=B0=D0=B3=D0=B8=20(=D0=A1=D0=BB?= =?UTF-8?q?=D0=BE=D0=B9=201)=C2=BB=20=E2=80=94=20=D0=B2=D1=81=D0=B5=20?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D1=8B=20=D1=87=D0=B5=D0=BB=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D0=BE=20+=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D1=8B=D1=80=D1=8C=D1=91=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=86?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildStepLine: кратко «спросил -> ответил» (служебные строки экономия/coverage/вердикт отброшены) - protocol.steps: хук ведёт по строке на КАЖДЫЙ ход; рендер — список + одна ссылка raw в конце - reconcile (stampProvenance) сохраняет steps (модель их не трогает) - stop-хук добавляет шаг текущего хода 41 тест green, exit=0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/secretary-layer1.mjs | 14 ++++++++++++++ tools/secretary-layer1.test.mjs | 15 ++++++++++++++- tools/secretary-protocol.mjs | 26 ++++++++++---------------- tools/secretary-protocol.test.mjs | 19 ++++++++++++++----- tools/secretary-reconcile.mjs | 1 + tools/secretary-stop-hook.mjs | 5 ++++- 6 files changed, 57 insertions(+), 23 deletions(-) diff --git a/tools/secretary-layer1.mjs b/tools/secretary-layer1.mjs index 711231c..e0b0cb6 100644 --- a/tools/secretary-layer1.mjs +++ b/tools/secretary-layer1.mjs @@ -11,3 +11,17 @@ export function buildRawRecord({ turn, time, session, user, assistant, actions = lines.push('=== КОНЕЦ ХОДА ===', ''); return lines.join('\n'); } + +// Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: кратко «спросил → ответил». +// Служебные строки (экономия/coverage/вердикт) из ответа отбрасываются; длинное усекается. +export function buildStepLine({ turn, user, assistant } = {}) { + const gist = (s) => { + const t = String(s ?? '').replace(/\s+/g, ' ').trim(); + return t.length > 140 ? `${t.slice(0, 140)}…` : t; + }; + const cleanA = String(assistant ?? '').split('\n') + .filter((l) => !/^\s*(экономия:|coverage:|вердикт:)/i.test(l)).join(' '); + const u = gist(user) || '(без вопроса)'; + const a = gist(cleanA) || '(без ответа)'; + return `Ход ${turn}: ${u} → ${a}`; +} diff --git a/tools/secretary-layer1.test.mjs b/tools/secretary-layer1.test.mjs index 52d4952..b838a85 100644 --- a/tools/secretary-layer1.test.mjs +++ b/tools/secretary-layer1.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildRawRecord } from './secretary-layer1.mjs'; +import { buildRawRecord, buildStepLine } from './secretary-layer1.mjs'; describe('buildRawRecord', () => { it('содержит заголовок с turn, реплики и действие', () => { @@ -18,3 +18,16 @@ describe('buildRawRecord', () => { expect(rec).not.toContain('[ДЕЙСТВИЕ]'); }); }); + +describe('buildStepLine', () => { + it('кратко: спросил → ответил, без служебных строк', () => { + const s = buildStepLine({ turn: 5, user: 'сделай флажок по сессии', assistant: 'экономия: 100%\nГотово, сделал флажок' }); + expect(s).toContain('Ход 5'); + expect(s).toContain('сделай флажок по сессии'); + expect(s).toContain('Готово, сделал флажок'); + expect(s).not.toContain('экономия'); + }); + it('пустой вопрос → (без вопроса)', () => { + expect(buildStepLine({ turn: 2, user: '', assistant: 'a' })).toContain('(без вопроса)'); + }); +}); diff --git a/tools/secretary-protocol.mjs b/tools/secretary-protocol.mjs index acb8b60..efd55fe 100644 --- a/tools/secretary-protocol.mjs +++ b/tools/secretary-protocol.mjs @@ -3,7 +3,7 @@ export function EMPTY_PROTOCOL() { return { subject: '', status: 'открыто', decisions: [], alternatives: [], consequences: [], - will: [], open: [], doneNext: [], history: [], + will: [], open: [], doneNext: [], history: [], steps: [], }; } @@ -18,20 +18,14 @@ function src(entry) { const line = (e) => `${e.struck ? `~~${e.text}~~` : e.text}${prov(e.turns)}${src(e)}`; -// Шаги (Слой 1): уникальные (сессия, ход) из всех корзин → ссылка в raw/.log. -function stepsIndex(p) { - const seen = new Map(); - for (const sec of ['decisions', 'alternatives', 'consequences', 'will', 'open', 'doneNext']) { - for (const e of (p[sec] || [])) { - const sess = e.session || ''; - for (const t of (e.turns || [])) { - const key = `${sess}#${t}`; - if (!seen.has(key)) seen.set(key, { turn: t, session: sess }); - } - } - } - return [...seen.values()].sort((a, b) => a.turn - b.turn) - .map(({ turn, session }) => `- [→${turn}]${session ? ` raw/${session}.log` : ''}`); +// Шаги (Слой 1): человекочитаемая строка на КАЖДЫЙ ход («спросил → ответил»), в конце — +// ссылка(и) на сырьё для подробностей. Шаги ведёт хук (по ходу), не модель. +function stepsSection(p) { + const steps = (p.steps || []).slice().sort((a, b) => (a.turn || 0) - (b.turn || 0)); + const L = steps.map((s) => `- ${s.text}`); + const sessions = [...new Set(steps.map((s) => s.session).filter(Boolean))]; + if (sessions.length) L.push('', ...sessions.map((s) => `Подробно (дословно): raw/${s}.log`)); + return L; } // Полная форма протокола (§D7): шапка «Дело» + 8 корзин (2–9) + навигация Шаги→Слой 1. @@ -60,6 +54,6 @@ export function renderProtocol(protocol, opts = {}) { L.push('', '## История (заменено, не стёрто)'); for (const h of protocol.history || []) L.push(`- ~~${h.oldText}~~ → ${h.newText}${prov(h.turns)}`); L.push('', '## Шаги (Слой 1)'); - for (const s of stepsIndex(protocol)) L.push(s); + for (const s of stepsSection(protocol)) L.push(s); return L.join('\n'); } diff --git a/tools/secretary-protocol.test.mjs b/tools/secretary-protocol.test.mjs index e39c228..972b103 100644 --- a/tools/secretary-protocol.test.mjs +++ b/tools/secretary-protocol.test.mjs @@ -6,7 +6,7 @@ describe('EMPTY_PROTOCOL', () => { expect(EMPTY_PROTOCOL()).toEqual({ subject: '', status: 'открыто', decisions: [], alternatives: [], consequences: [], - will: [], open: [], doneNext: [], history: [], + will: [], open: [], doneNext: [], history: [], steps: [], }); }); }); @@ -51,10 +51,19 @@ describe('renderProtocol — 9 категорий + шаги', () => { }); for (const t of ['~~D~~', '~~A~~', '~~C~~', '~~W~~', '~~Q~~', '~~N~~']) expect(md).toContain(t); }); - it('раздел Шаги (Слой 1) со ссылками в raw по ходам', () => { - const md = renderProtocol(proto); + it('раздел Шаги (Слой 1): человекочитаемые строки на КАЖДЫЙ ход + ссылка на сырьё в конце', () => { + const md = renderProtocol({ + subject: '', status: 'открыто', history: [], + decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], + steps: [ + { turn: 1, session: '69992620-x', text: 'Спросил про оглавление → ответил: тема + время' }, + { turn: 2, session: '69992620-x', text: 'Попросил флажок по сессии → сделал' }, + ], + }); expect(md).toContain('## Шаги (Слой 1)'); - expect(md).toContain('raw/69992620-x.log'); - expect(md).toContain('→7'); + expect(md).toContain('Спросил про оглавление → ответил: тема + время'); + expect(md).toContain('Попросил флажок по сессии → сделал'); + expect(md).toContain('Подробно (дословно): raw/69992620-x.log'); + expect(md).not.toContain('[→1] raw/'); // не ссылка в каждой строке }); }); diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 8aebf93..03a5322 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -107,6 +107,7 @@ export function stampProvenance(oldProtocol, returned, turn, session) { open: (returned.open || []).map(stamp), doneNext: (returned.doneNext || []).map(stamp), history: Array.isArray(oldProtocol.history) ? oldProtocol.history : [], + steps: Array.isArray(oldProtocol.steps) ? oldProtocol.steps : [], }; } diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index fc7de80..684717b 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -8,7 +8,7 @@ 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 { buildRawRecord, buildStepLine } from './secretary-layer1.mjs'; import { reconcileTurn } from './secretary-reconcile.mjs'; import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; import { upsertIndexEntry } from './secretary-index.mjs'; @@ -69,6 +69,9 @@ async function main() { const updated = await reconcileTurn({ proto, ex, turn, session, callModel }); if (updated) { const stamp = new Date().toISOString().slice(0, 16).replace('T', ' '); + // Шаги (Слой 1) ведёт хук: по строке на ход «спросил → ответил» (модель их не трогает). + updated.steps = [...(Array.isArray(updated.steps) ? updated.steps : []), + { turn, session, text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant }) }]; mkdirSync(workDir, { recursive: true }); writeFileSync(protoJson, JSON.stringify(updated, null, 2), 'utf-8'); writeFileSync(join(workDir, 'protocol.md'), renderProtocol(updated, { work, date: stamp }), 'utf-8');