diff --git a/tools/secretary-distill.mjs b/tools/secretary-distill.mjs index d79bbbc..6896888 100644 --- a/tools/secretary-distill.mjs +++ b/tools/secretary-distill.mjs @@ -11,6 +11,7 @@ import { resolveModel } from './secretary-models.mjs'; import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG, renderExchangeText } from './secretary-harvest.mjs'; import { SYS_G, applyGardener } from './secretary-gardener.mjs'; import { applyHarvest } from './secretary-apply.mjs'; +import { slimProtocol } from './secretary-slim.mjs'; /** Разобрать спан и вернуть НОВЫЙ протокол (вход не мутируется reconcile-веткой; collapse — копия). * proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions}; @@ -47,19 +48,21 @@ export async function distillSpan(proto, spanEx, { start, end, note = '' }, { ca const spanText = renderExchangeText(spanEx); const txt = (r) => (typeof r === 'string' ? r : (r && r.text) || ''); const call = (role, system, user) => callModel({ system, user, model: resolveModel(role) }); + // ПОДАЧА ролям — из live-only проекции (надгробия не возим): мёртвое отсеяно, мутации ниже идут по полному proto. + const view = slimProtocol(toWrite); // сбор: 1.1 диагностика ∥ 1.2 ловец, затем 1.3 брейншторм (после них) const [r11, r12] = await Promise.all([ - call('diagnostic', SYS_11, user11(toWrite, spanText, { start, end })), - call('catcher', SYS_12, user12(toWrite, spanText)), + call('diagnostic', SYS_11, user11(view, spanText, { start, end })), + call('catcher', SYS_12, user12(view, spanText)), ]); const d11 = diagFindings(parseLoose(txt(r11), 'lenses')); const d12 = parseLoose(txt(r12), 'dropped'); - const r13 = await call('brainstorm', SYS_13, user13(toWrite, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped))); + const r13 = await call('brainstorm', SYS_13, user13(view, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped))); const d13 = parseLoose(txt(r13), 'forks'); // ЭТАП 1: урожай (новые ветки + рождение/заточка/полнота кандидатов) — садовник увидит свежую грядку const withHarvest = applyHarvest(toWrite, start, d11, d12, d13); - // ЭТАП 2: садовник на СВЕЖЕМ proto (видит дифф ствола + свежие ветки И кандидаты для моста «повысить») - const rG = await call('gardener', SYS_G, userG(withHarvest, spanText, diff)); + // ЭТАП 2: садовник — подача из СВЕЖЕЙ live-проекции (видит свежие живые ветки и кандидаты), без надгробий + const rG = await call('gardener', SYS_G, userG(slimProtocol(withHarvest), spanText, diff)); const dG = parseLoose(txt(rG), 'tend'); return applyGardener(withHarvest, start, dG); } catch (e) { if (typeof diag === 'function') diag({ turn: start, reason: 'fluffy-fail', error: e && e.message }); return collapseProtocol(toWrite); } diff --git a/tools/secretary-distill.test.mjs b/tools/secretary-distill.test.mjs index ba68682..7af5fc7 100644 --- a/tools/secretary-distill.test.mjs +++ b/tools/secretary-distill.test.mjs @@ -82,3 +82,38 @@ describe('distillSpan — новый конвейер «пушистое дер expect(out.candidates).toEqual([]); // кандидаты теперь первоклассны (всегда инициализированы, пусты в классике) }); }); + +describe('distillSpan — подача ролей без надгробий (slim)', () => { + it('catcher и gardener НЕ видят мёртвые ветки/кандидаты, editor видит полный', async () => { + const proto = { + ...EMPTY_PROTOCOL(), + hidden: [ + { id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ЖИВАЯ_ВЕТКА' }, + { id: 'СВ-2', lens: 'Л4', status: 'закрыт', text: 'МЁРТВАЯ_ВЕТКА' }, + ], + candidates: [ + { id: 'КД-1', status: 'жив', branch: 'ЖИВОЙ_КАНД' }, + { id: 'КД-2', status: 'отпал', branch: 'МЁРТВЫЙ_КАНД' }, + ], + }; + const seen = { editor: '', catcher: '', gardener: '' }; + const callModel = async ({ system, user }) => { + if (/секретарь-редактор/.test(system)) { seen.editor = user; return JSON.stringify({ subject: 'd', decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } }); } + if (/сборщик скрытых веток/.test(system)) return '{"lenses":[]}'; + if (/ловец/.test(system)) { seen.catcher = user; return '{"dropped":[]}'; } + if (/брейншторма/.test(system)) return '{"forks":[]}'; + if (/САДОВНИК/.test(system)) { seen.gardener = user; return '{"tend":[]}'; } + return '{}'; + }; + await distillSpan(proto, { user: 'просьба достаточно длинная', assistant: 'ок', actions: [] }, + { start: 1, end: 1 }, { callModel, session: 's', flags: { fluffy: true } }); + + expect(seen.catcher).not.toContain('МЁРТВАЯ_ВЕТКА'); + expect(seen.catcher).not.toContain('МЁРТВЫЙ_КАНД'); + expect(seen.catcher).not.toContain('НАДГРОБИЯ'); + expect(seen.gardener).not.toContain('МЁРТВАЯ_ВЕТКА'); + expect(seen.gardener).not.toContain('МЁРТВЫЙ_КАНД'); + expect(seen.catcher).toContain('ЖИВАЯ_ВЕТКА'); + expect(seen.gardener).toContain('ЖИВОЙ_КАНД'); + }); +}); diff --git a/tools/secretary-slim.mjs b/tools/secretary-slim.mjs new file mode 100644 index 0000000..40cfd3c --- /dev/null +++ b/tools/secretary-slim.mjs @@ -0,0 +1,22 @@ +// tools/secretary-slim.mjs +// Live-only проекция протокола для подачи в LLM: мёртвые ветки/кандидаты («надгробия») +// в неё не попадают совсем. Чистая, не мутирует вход. Полный протокол (источник истины) +// и архив строятся отдельно — здесь только то, что видит агент. + +// Терминальные статусы ветки = мертва, в подачу не идёт (кандидат: закрыт/отпал/мутировал/повышен). +const DEAD_BRANCH = new Set(['закрыт', 'отпал', 'мутировал']); + +/** Ветка жива, если статус не терминальный (нет статуса/«открыт» — живые). */ +export function isLiveBranch(h) { return !DEAD_BRANCH.has(String((h && h.status) || '')); } +/** Кандидат жив, если без статуса или «жив» (то же, что фильтрует candidatesForPrompt). */ +export function isLiveCandidate(c) { const s = String((c && c.status) || ''); return s === '' || s === 'жив'; } + +/** Копия протокола только из живого. Вход не мутируется. null/не-объект — как есть. */ +export function slimProtocol(proto) { + if (!proto || typeof proto !== 'object') return proto; + return { + ...proto, + hidden: (proto.hidden || []).filter(isLiveBranch), + candidates: (proto.candidates || []).filter(isLiveCandidate), + }; +} diff --git a/tools/secretary-slim.test.mjs b/tools/secretary-slim.test.mjs new file mode 100644 index 0000000..359b4cc --- /dev/null +++ b/tools/secretary-slim.test.mjs @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { slimProtocol, isLiveBranch, isLiveCandidate } from './secretary-slim.mjs'; + +const proto = () => ({ + subject: 'дело', status: 'открыто', nextSvId: 3, nextKdId: 4, + decisions: [{ text: 'D1', struck: false }], + open: [{ text: 'O1' }], will: [], consequences: [], doneNext: [], history: [], + steps: [{ turn: 1, text: 'шаг 1' }], + hidden: [ + { id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'живая ветка' }, + { id: 'СВ-2', lens: 'Л4', status: 'закрыт', text: 'мёртвая (закрыт)' }, + { id: 'СВ-3', lens: 'Л6', status: 'отпал', text: 'мёртвая (отпал)' }, + { id: 'СВ-4', lens: 'Л7', status: 'мутировал', text: 'мёртвая (мутировал)' }, + { id: 'СВ-5', lens: 'Л8', text: 'живая без статуса' }, + ], + candidates: [ + { id: 'КД-1', status: 'жив', branch: 'живой кандидат' }, + { id: 'КД-2', branch: 'живой без статуса' }, + { id: 'КД-3', status: 'отпал', branch: 'мёртвый (отпал)' }, + { id: 'КД-4', status: 'мутировал', branch: 'мёртвый (мутировал)' }, + { id: 'КД-5', status: 'закрыт', branch: 'мёртвый (закрыт)' }, + { id: 'КД-6', status: 'повышен', branch: 'мёртвый (повышен)' }, + ], + extraNewField: 'forward-safe', +}); + +describe('slimProtocol', () => { + it('оставляет только живые ветки (выкидывает закрыт/отпал/мутировал)', () => { + const s = slimProtocol(proto()); + expect(s.hidden.map((h) => h.id)).toEqual(['СВ-1', 'СВ-5']); + }); + it('оставляет только живых кандидатов (нет статуса или жив)', () => { + const s = slimProtocol(proto()); + expect(s.candidates.map((c) => c.id)).toEqual(['КД-1', 'КД-2']); + }); + it('переносит ствол/шаги/счётчики/новые поля как есть', () => { + const s = slimProtocol(proto()); + expect(s.decisions).toEqual([{ text: 'D1', struck: false }]); + expect(s.steps).toEqual([{ turn: 1, text: 'шаг 1' }]); + expect(s.nextSvId).toBe(3); + expect(s.nextKdId).toBe(4); + expect(s.subject).toBe('дело'); + expect(s.extraNewField).toBe('forward-safe'); + }); + it('не мутирует вход (мёртвые остаются в исходном proto)', () => { + const p = proto(); + slimProtocol(p); + expect(p.hidden).toHaveLength(5); + expect(p.candidates).toHaveLength(6); + }); + it('терпит пустой/кривой вход', () => { + expect(slimProtocol({}).hidden).toEqual([]); + expect(slimProtocol({}).candidates).toEqual([]); + expect(slimProtocol(null)).toEqual(null); + }); + it('предикаты живости', () => { + expect(isLiveBranch({ status: 'открыт' })).toBe(true); + expect(isLiveBranch({})).toBe(true); + expect(isLiveBranch({ status: 'закрыт' })).toBe(false); + expect(isLiveCandidate({ status: 'жив' })).toBe(true); + expect(isLiveCandidate({})).toBe(true); + expect(isLiveCandidate({ status: 'повышен' })).toBe(false); + }); +}); diff --git a/tools/secretary-worker.mjs b/tools/secretary-worker.mjs index a64b51c..43fd4eb 100644 --- a/tools/secretary-worker.mjs +++ b/tools/secretary-worker.mjs @@ -7,6 +7,7 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { join, basename } from 'node:path'; import { writeFileAtomic } from './secretary-layer1.mjs'; import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +import { slimProtocol } from './secretary-slim.mjs'; import { assembleSpan } from './secretary-span.mjs'; import { distillSpan } from './secretary-distill.mjs'; import { renderDoc } from './secretary-render-fluffy.mjs'; @@ -30,6 +31,7 @@ export async function runWorker(workDir, deps) { distill = (proto, spanEx, span, ctx) => distillSpan(proto, spanEx, span, ctx), render = (proto, opts) => renderDoc(proto, opts), archive = (proto) => renderArchive(proto), + slim = (p) => slimProtocol(p), writeArchive = fluffyPipelineOn(), // под fluffy-флагом пишем второй рендер (полный леджер) now = () => Date.now(), callModel = null, @@ -51,6 +53,7 @@ export async function runWorker(workDir, deps) { proto = await distill(proto, spanEx, { ...span }, { callModel, session: job.session }); mkdirSync(workDir, { recursive: true }); writeFileAtomic(join(workDir, 'protocol.json'), JSON.stringify(proto, null, 2)); + writeFileAtomic(join(workDir, 'protocol-slim.json'), JSON.stringify(slim(proto), null, 2)); // видимая live-проекция (что видит агент) const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' '); writeFileAtomic(join(workDir, 'protocol.md'), render(proto, { work, date: stamp })); if (writeArchive) writeFileAtomic(join(workDir, 'архив.md'), archive(proto)); // полный леджер (сырьё ТЗ) diff --git a/tools/secretary-worker.test.mjs b/tools/secretary-worker.test.mjs index a7fcccc..d0c7536 100644 --- a/tools/secretary-worker.test.mjs +++ b/tools/secretary-worker.test.mjs @@ -63,6 +63,21 @@ describe('runWorker', () => { await runWorker(workDir, deps({ writeArchive: false })); expect(existsSync(join(workDir, 'архив.md'))).toBe(false); }); + it('пишет protocol-slim.json только из живого; protocol.json — полный', async () => { + enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); + const distill = async (proto) => ({ + ...proto, subject: 'd', + hidden: [{ id: 'СВ-1', status: 'открыт', text: 'жив' }, { id: 'СВ-2', status: 'закрыт', text: 'мёртв' }], + candidates: [{ id: 'КД-1', status: 'жив', branch: 'жив' }, { id: 'КД-2', status: 'отпал', branch: 'мёртв' }], + }); + await runWorker(workDir, deps({ distill })); + const full = JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8')); + const slim = JSON.parse(readFileSync(join(workDir, 'protocol-slim.json'), 'utf-8')); + expect(full.hidden).toHaveLength(2); + expect(full.candidates).toHaveLength(2); + expect(slim.hidden.map((h) => h.id)).toEqual(['СВ-1']); + expect(slim.candidates.map((c) => c.id)).toEqual(['КД-1']); + }); }); describe('pickModel (per-role model forwarding)', () => {