fix(secretary): выключение не затирает модельные «Шаги» (слияние по ходу)

Task 5/5 плана. Ветка off prompt-hook звала buildStepsFromRaw, перезатирая
модельные формулировки шагов детерминированными. Новая mergeStepsPreservingText:
существующий шаг сохраняется, из сырья достраиваются только пропущенные ходы.
Свод секретаря 112/112.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-23 09:33:14 +03:00
parent b846c0c57f
commit c778d10d10
3 changed files with 25 additions and 3 deletions
+8
View File
@@ -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/вердикт) отброшены;
// «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер.
+15 -1
View File
@@ -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 = [];
+2 -2
View File
@@ -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 });