From c778d10d10c3adceec780352581064c4e0e06920 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:33:14 +0300 Subject: [PATCH] =?UTF-8?q?fix(secretary):=20=D0=B2=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=C2=AB=D0=A8=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=C2=BB=20(=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=85=D0=BE=D0=B4=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5/5 плана. Ветка off prompt-hook звала buildStepsFromRaw, перезатирая модельные формулировки шагов детерминированными. Новая mergeStepsPreservingText: существующий шаг сохраняется, из сырья достраиваются только пропущенные ходы. Свод секретаря 112/112. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/secretary-layer1.mjs | 8 ++++++++ tools/secretary-layer1.test.mjs | 16 +++++++++++++++- tools/secretary-prompt-hook.mjs | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tools/secretary-layer1.mjs b/tools/secretary-layer1.mjs index a548e4d..f8ff8ef 100644 --- a/tools/secretary-layer1.mjs +++ b/tools/secretary-layer1.mjs @@ -58,6 +58,14 @@ export function buildStepsFromRaw(rawText, session) { }); } +// Слияние «Шагов» при выключении: на КАЖДЫЙ ход из сырья берём существующий шаг (модельная +// формулировка) если он есть, иначе достраиваем детерминированно из сырья. Порядок — по сырью +// (хронология); модельный текст переживает выключение/нарезку. +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)); +} + // Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …». // Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены; // «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер. diff --git a/tools/secretary-layer1.test.mjs b/tools/secretary-layer1.test.mjs index 72194ac..05f388c 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, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic } from './secretary-layer1.mjs'; +import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText } from './secretary-layer1.mjs'; describe('обезвреживание маркеров на записи (от самозагрязнения лога)', () => { it('маркеры внутри текста реплик/действий не дают лишних структурных совпадений', () => { @@ -112,6 +112,20 @@ describe('buildStepLine', () => { }); }); +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 — я: привет'); + }); +}); + describe('writeFileAtomic — запись через temp + rename (защита от полузаписи при параллельных сессиях)', () => { it('пишет во временный файл, затем переименовывает в целевой', () => { const calls = []; diff --git a/tools/secretary-prompt-hook.mjs b/tools/secretary-prompt-hook.mjs index aa869bf..702ba70 100644 --- a/tools/secretary-prompt-hook.mjs +++ b/tools/secretary-prompt-hook.mjs @@ -6,7 +6,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs'; -import { prepareTurnFiles, buildStepsFromRaw } from './secretary-layer1.mjs'; +import { prepareTurnFiles, buildStepsFromRaw, mergeStepsPreservingText } from './secretary-layer1.mjs'; import { renderProtocol } from './secretary-protocol.mjs'; function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } @@ -76,7 +76,7 @@ function main() { const raw = readFileSync(rawFile, 'utf-8'); const proto = JSON.parse(readFileSync(protoJson, 'utf-8')); // Шаги — на КАЖДЫЙ ход из Слоя 1 (не только вкл-ходы), затем нарезка + ссылки. - proto.steps = buildStepsFromRaw(raw, session); + proto.steps = mergeStepsPreservingText(proto.steps, raw, session); const { files, steps } = prepareTurnFiles(raw, proto); const hodyDir = join(workDir, 'ходы'); mkdirSync(hodyDir, { recursive: true });