diff --git a/docs/superpowers/plans/2026-06-25-secretary-pipeline-A.md b/docs/superpowers/plans/2026-06-25-secretary-pipeline-A.md new file mode 100644 index 0000000..fed698d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-secretary-pipeline-A.md @@ -0,0 +1,712 @@ +# Секретарь — боевой конвейер веток (подпроект A) · 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:** Заменить стадию аудита секретаря на конвейер сбор+садовник (диагностика 8/8 ∥ ловец → брейншторм → садовник), перенеся доказанный код из `.scratch/sec/v2.mjs` в боевые модули `tools/` под тестами, за выключенным флагом — не трогая живой хук. + +**Architecture:** Новые мелкие модули по одной работе (броня · дифф ствола · сбор · садовник · применение · модели), переписанный дирижёр `distill` за флагом. Вся надёжность формата — в детерминированных хуках (В1); промпты-линзы перенесены дословно (их поведение доказано прогоном, не юнит-тестами). Редактор не трогаем. + +**Tech Stack:** Node ESM, vitest (`tools/.test.mjs`, колоцированные). Источник кода — спека `docs/superpowers/specs/2026-06-25-secretary-pipeline-A-design.md` + песочница `.scratch/sec/v2.mjs`. + +> **Коммиты в этом репо** требуют floor-escape владельца или его терминал (enforcement-пол блокирует коммит агента). Исполнитель координирует коммит с владельцем (или владелец вставляет команду сам). Verify-расписка: перед коммитом `node tools/produce-verify-receipt.mjs` (зелёный прогон сюиты). + +--- + +## File Structure + +- `tools/secretary-armor.mjs` *(создать)* — `parseLoose`, `salvageObjects`, `normОпора`/`one`, `normLens`. Детерминированный разбор/спасение ответов флэша. Зависимостей нет. +- `tools/secretary-trunkdiff.mjs` *(создать)* — `diffTrunk(oldP,newP)`. Зависимостей нет. +- `tools/secretary-models.mjs` *(создать)* — `resolveModel(role)` для 5 ролей. Зависимостей нет. +- `tools/secretary-harvest.mjs` *(создать)* — `SYS_11/12/13`, `PASSPORT`, сборщики USER, `diagFindings`. Зависит от armor. +- `tools/secretary-gardener.mjs` *(создать)* — `SYS_G`, `applyTend`. Зависит от armor. +- `tools/secretary-apply.mjs` *(создать)* — `applyResults`. Зависит от gardener+armor. +- `tools/secretary-distill.mjs` *(править)* — дирижёр за флагом (старый путь сохраняется). +- `tools/secretary-flag.mjs` *(править)* — добавить флаг `fluffyPipelineOn()`. +- Тест-файлы рядом: `tools/secretary-.test.mjs`. + +--- + +## Task 1: Броня (`secretary-armor.mjs`) + +**Files:** +- Create: `tools/secretary-armor.mjs` +- Test: `tools/secretary-armor.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { parseLoose, salvageObjects, one, normLens } from './secretary-armor.mjs'; + +describe('secretary-armor', () => { + it('parseLoose: чистый объект', () => { + expect(parseLoose('{"new":[{"text":"x"}]}', 'new').new).toHaveLength(1); + }); + it('parseLoose: ```-обёртка и висячая запятая', () => { + expect(parseLoose('```json\n{"forks":[{"a":1},]}\n```', 'forks').forks).toHaveLength(1); + }); + it('parseLoose: голый массив', () => { + expect(parseLoose('[{"a":1},{"b":2}]', 'tend').tend).toHaveLength(2); + }); + it('parseLoose: сломанный JSON → спасение по-объектно (битый пропущен)', () => { + const broken = '{"tend":[{"id":"СВ-1","action":"carry"},{"id":"СВ-2","why":Ход без кавычек},{"id":"СВ-3","action":"carry"}]}'; + expect(parseLoose(broken, 'tend').tend.map((t) => t.id)).toEqual(['СВ-1', 'СВ-3']); + }); + it('one: проза→словарь; неизвестное→догадка', () => { + expect(one('ответ')).toBe('внутр'); + expect(one('владелец сказал')).toBe('воля'); + expect(one('незавершённый')).toBe('догадка'); + expect(one('абракадабра')).toBe('догадка'); + expect(one('внеш')).toBe('внеш'); + }); + it('normLens: 9→Л9, л4→Л4', () => { + expect(normLens('9')).toBe('Л9'); + expect(normLens('л4')).toBe('Л4'); + expect(normLens('Л6')).toBe('Л6'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-armor.test.mjs` +Expected: FAIL (`Cannot find module './secretary-armor.mjs'`). + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-armor.mjs +// Детерминированный разбор/спасение ответов флэша (принцип В1). Перенос из .scratch/sec/v2.mjs. + +export const normLens = (l) => { + const t = String(l || '').trim().replace(/^л/i, 'Л'); + return /^Л\d$/.test(t) ? t : (/^\d$/.test(t) ? 'Л' + t : t || 'Л?'); +}; + +const ОПОРЫ = ['внутр', 'внеш', 'воля', 'догадка']; +const ОП_SYN = { + внутренний: 'внутр', код: 'внутр', файл: 'внутр', проект: 'внутр', обмен: 'внутр', реплика: 'внутр', + ответ: 'внутр', цитата: 'внутр', текст: 'внутр', строка: 'внутр', внешний: 'внеш', url: 'внеш', + веб: 'внеш', док: 'внеш', документация: 'внеш', владелец: 'воля', юзер: 'воля', пользователь: 'воля', + запрос: 'воля', директива: 'воля', гипотеза: 'догадка', предположение: 'догадка', домысел: 'догадка', + незавершённый: 'догадка', неизвестно: 'догадка', +}; +/** Паспорт-опора → ровно одно слово словаря; неизвестное → честно «догадка». */ +export function one(raw) { + const w = String(raw || '').trim().toLowerCase().split(/[\s,;:|]+/)[0].replace(/[.,;:]+$/, ''); + if (ОПОРЫ.includes(w)) return w; + return ОП_SYN[w] || 'догадка'; +} + +/** Спасение по-объектно: лезем В массив […] и парсим КАЖДЫЙ объект; битый пропускаем. */ +export function salvageObjects(text) { + const lb = text.indexOf('['), rb = text.lastIndexOf(']'); + const body = (lb >= 0 && rb > lb) ? text.slice(lb + 1, rb) : text; + const out = []; + let depth = 0, start = -1, inStr = false, esc = false; + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } + if (c === '"') inStr = true; + else if (c === '{') { if (depth === 0) start = i; depth++; } + else if (c === '}') { depth--; if (depth === 0 && start >= 0) { const chunk = body.slice(start, i + 1).replace(/,(\s*[}\]])/g, '$1'); try { out.push(JSON.parse(chunk)); } catch { /* битый — пропустить */ } start = -1; } } + } + return out; +} + +/** Толерантный парсер: ```-обёртка, висячая запятая, голый массив/объект; слом → спасение по-объектно. */ +export function parseLoose(text, expectKey) { + let s = String(text || '').trim().replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```$/, '').trim(); + const obj = s.indexOf('{'), arr = s.indexOf('['); + if (obj >= 0 || arr >= 0) { const start = arr < 0 ? obj : obj < 0 ? arr : Math.min(obj, arr); s = s.slice(start); } + s = s.replace(/,(\s*[}\]])/g, '$1'); + let p; try { p = JSON.parse(s); } + catch { const salv = salvageObjects(s); return { [expectKey]: salv.length ? salv : [] }; } + if (Array.isArray(p)) return { [expectKey]: p }; + if (p && typeof p === 'object') { if (!Array.isArray(p[expectKey])) p[expectKey] = p[expectKey] ? [p[expectKey]] : []; return p; } + return { [expectKey]: [] }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-armor.test.mjs` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** (через владельца/эскейп) + +```bash +git add tools/secretary-armor.mjs tools/secretary-armor.test.mjs +git commit -m "feat(secretary): броня разбора ответов флэша (parseLoose/salvage/опора/линза)" +``` + +--- + +## Task 2: Дифф ствола (`secretary-trunkdiff.mjs`) + +**Files:** +- Create: `tools/secretary-trunkdiff.mjs` +- Test: `tools/secretary-trunkdiff.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { diffTrunk } from './secretary-trunkdiff.mjs'; + +describe('diffTrunk', () => { + it('пусто, когда ствол не двигался', () => { + const p = { decisions: [{ text: 'A', struck: false }] }; + expect(diffTrunk(p, p)).toBe('(ствол не двигался этот ход)'); + }); + it('ловит добавленное решение и зачёркнутый открытый', () => { + const oldP = { open: [{ text: 'Q', struck: false }] }; + const newP = { decisions: [{ text: 'A', why: 'почему', struck: false }], open: [{ text: 'Q', struck: true }] }; + const d = diffTrunk(oldP, newP); + expect(d).toContain('РЕШЕНИЕ добавлен: «A — почему»'); + expect(d).toContain('ОТКРЫТЫЙ зачёркнут (закрыт ствол): «Q»'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-trunkdiff.test.mjs` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-trunkdiff.mjs +// Детерминированный дифф ствола (роль хука: до/после редактора). Перенос из .scratch/sec/v2.mjs. +const SECS = [['decisions', 'РЕШЕНИЕ'], ['will', 'ВОЛЯ'], ['open', 'ОТКРЫТЫЙ'], ['consequences', 'ПОСЛЕДСТВИЕ'], ['alternatives', 'АЛЬТЕРНАТИВА'], ['doneNext', 'СДЕЛАНО']]; +const nz = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); + +export function diffTrunk(oldP, newP) { + const lines = []; + for (const [sec, label] of SECS) { + const o = new Map(((oldP[sec]) || []).map((e) => [nz(e.text), e])); + for (const e of (newP[sec]) || []) { + const prev = o.get(nz(e.text)); + if (!prev) lines.push(`${label} добавлен: «${e.text}${e.why ? ' — ' + e.why : ''}»`); + else if (!prev.struck && e.struck) lines.push(`${label} зачёркнут (закрыт ствол): «${e.text}»`); + else if (prev.struck && !e.struck) lines.push(`${label} расчёркнут (снова актуален): «${e.text}»`); + } + } + return lines.length ? lines.join('\n') : '(ствол не двигался этот ход)'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-trunkdiff.test.mjs` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-trunkdiff.mjs tools/secretary-trunkdiff.test.mjs +git commit -m "feat(secretary): детерминированный дифф ствола до/после редактора" +``` + +--- + +## Task 3: Слоты моделей (`secretary-models.mjs`) + +**Files:** +- Create: `tools/secretary-models.mjs` +- Test: `tools/secretary-models.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { resolveModel, ROLES } from './secretary-models.mjs'; + +describe('resolveModel', () => { + it('5 ролей определены', () => { + expect(ROLES).toEqual(['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener']); + }); + it('по умолчанию — общий флэш', () => { + expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x'); + }); + it('per-role перекрывает общий', () => { + expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x', SECRETARY_LLM_MODEL_GARDENER: 'strong-y' })).toBe('strong-y'); + }); + it('неизвестная роль → общий флэш', () => { + expect(resolveModel('xxx', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-models.test.mjs` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-models.mjs +// 5 слотов моделей (D). По умолчанию все — общий флэш; per-role env перекрывает. +export const ROLES = ['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener']; + +/** Модель для роли: SECRETARY_LLM_MODEL_ > SECRETARY_LLM_MODEL > undefined. */ +export function resolveModel(role, env = process.env) { + const key = `SECRETARY_LLM_MODEL_${String(role || '').toUpperCase()}`; + return (ROLES.includes(role) && env[key]) || env.SECRETARY_LLM_MODEL || undefined; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-models.test.mjs` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-models.mjs tools/secretary-models.test.mjs +git commit -m "feat(secretary): 5 слотов моделей по ролям (per-role env override)" +``` + +--- + +## Task 4: Сбор — диагностика/ловец/брейншторм (`secretary-harvest.mjs`) + +**Files:** +- Create: `tools/secretary-harvest.mjs` +- Test: `tools/secretary-harvest.test.mjs` + +Промпты `PASSPORT/SYS_11/SYS_12/SYS_13` переносятся ДОСЛОВНО из `.scratch/sec/v2.mjs` (строки констант). Тест проверяет ключевые инварианты текста + чистый разбор `diagFindings`. + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { SYS_11, SYS_12, SYS_13, diagFindings } from './secretary-harvest.mjs'; + +describe('secretary-harvest', () => { + it('SYS_11 требует отчёт по 8 линзам и запрещает пустой проход', () => { + expect(SYS_11).toMatch(/8 линз/); + expect(SYS_11).toMatch(/finding|clean/); + }); + it('SYS_12 (ловец) принимает ствол и не ловит решённое', () => { + expect(SYS_12).toMatch(/НЕ лови/i); + }); + it('SYS_13 (брейншторм) без числового лимита, с цитатой-сторожем', () => { + expect(SYS_13).toMatch(/НЕ ограничено/); + expect(SYS_13).toMatch(/ДОСЛОВНУЮ цитату/); + }); + it('diagFindings: из отчёта 8/8 извлекает finding, считает clean', () => { + const parsed = { lenses: [ + { lens: 'Л4', verdict: 'finding', text: 'заявлено без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' }, + { lens: 'Л1', verdict: 'clean', why: 'чисто' }, + ] }; + const r = diagFindings(parsed); + expect(r.new).toHaveLength(1); + expect(r.new[0].lens).toBe('Л4'); + expect(r.clean).toContain('Л1'); + expect(r.lenses).toHaveLength(2); + }); + it('diagFindings: толерантен к старому плоскому формату {new:[…]}', () => { + const r = diagFindings({ new: [{ text: 'x', lens: 'Л6' }] }); + expect(r.new).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-harvest.test.mjs` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +Перенести из `.scratch/sec/v2.mjs` дословно константы `PASSPORT`, `SYS_11`, `SYS_12`, `SYS_13` (со всеми калибровками №13/№16 и брейнштормом-без-лимита) и USER-сборщики `user11/user12/user13` (адаптировав имена параметров под боевой протокол). Плюс `diagFindings`: + +```js +// tools/secretary-harvest.mjs +import { normLens } from './secretary-armor.mjs'; + +export const PASSPORT = [ /* …дословно из v2.mjs… */ ].join('\n'); +export const SYS_11 = [ /* …дословно SYS_11 (диагностика 8/8) из v2.mjs… */ ].join('\n'); +export const SYS_12 = [ /* …дословно SYS_12 (ловец со стволом) из v2.mjs… */ ].join('\n'); +export const SYS_13 = [ /* …дословно SYS_13 (брейншторм без лимита) из v2.mjs… */ ].join('\n'); + +// USER-сборщики: recentSteps(proto), registry(proto), trunkForCatcher(proto), exchange-text(spanEx) +export function recentSteps(proto, n = 10) { /* …из v2.mjs… */ } +export function registry(proto) { /* …из v2.mjs (с чистыми id ПР-/ХВ-)… */ } +export function trunkForCatcher(proto) { /* …из v2.mjs… */ } +export function user11(proto, spanText, span) { /* …из v2.mjs… */ } +export function user12(proto, spanText) { /* …из v2.mjs… */ } +export function user13(proto, spanText, span, found11, found12) { /* …из v2.mjs… */ } + +/** Из поимённого отчёта 8 линз извлечь находки; толерантно к плоскому {new:[…]}. (находка №16) */ +export function diagFindings(parsed) { + const ls = Array.isArray(parsed.lenses) ? parsed.lenses + : (Array.isArray(parsed.new) ? parsed.new.map((n) => ({ ...n, verdict: 'finding' })) : []); + const isFind = (l) => String(l.verdict || '').toLowerCase() === 'finding' && String(l.text || '').trim(); + return { + lenses: ls, + new: ls.filter(isFind).map((f) => ({ text: f.text, lens: f.lens, опора: f.опора, ref: f.ref, тяжесть: f.тяжесть })), + clean: ls.filter((l) => String(l.verdict || '').toLowerCase() === 'clean').map((l) => normLens(l.lens)), + }; +} +``` + +> При переносе констант — копировать массивы строк ИЗ `.scratch/sec/v2.mjs` без изменений (там финальные калиброванные версии). USER-сборщики берут готовый текст обмена (`spanText`), чтобы не тащить сюда `assembleSpan`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-harvest.test.mjs` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-harvest.mjs tools/secretary-harvest.test.mjs +git commit -m "feat(secretary): сбор веток — диагностика 8/8, ловец, брейншторм без лимита" +``` + +--- + +## Task 5: Садовник (`secretary-gardener.mjs`) + +**Files:** +- Create: `tools/secretary-gardener.mjs` +- Test: `tools/secretary-gardener.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { SYS_G, applyTend } from './secretary-gardener.mjs'; + +describe('secretary-gardener', () => { + it('SYS_G требует proof на close', () => { + expect(SYS_G).toMatch(/proof/); + expect(SYS_G).toMatch(/ЗАЯВЛЕНИЕ ≠ ПРУФ/); + }); + it('close с пруфом → закрыто', () => { + const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] }; + applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: 'код mentor.mjs:107' }], 15); + expect(proto.hidden[0].status).toBe('закрыт'); + expect(proto.hidden[0].struck).toBe(true); + }); + it('голое close без proof → понижено в carry (В1-гейт)', () => { + const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] }; + applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: '' }], 15); + expect(proto.hidden[0].status).toBe('открыт'); + }); + it('narrow меняет текст и статус', () => { + const proto = { hidden: [{ id: 'СВ-2', status: 'открыт', text: 'старый' }] }; + applyTend(proto, [{ id: 'СВ-2', action: 'narrow', newText: 'остаток' }], 15); + expect(proto.hidden[0].status).toBe('сужен'); + expect(proto.hidden[0].text).toBe('остаток'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-gardener.test.mjs` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +Перенести `SYS_G` дословно из `.scratch/sec/v2.mjs` (с критерием №14: close-с-пруфом, ЗАЯВЛЕНИЕ≠ПРУФ). `applyTend` — выделенная из `applyResults` логика тенда с В1-гейтом: + +```js +// tools/secretary-gardener.mjs +export const SYS_G = [ /* …дословно SYS_G из v2.mjs… */ ].join('\n'); + +/** Применить решения садовника к скрытым веткам + горящим Л8/Л9. close без proof → carry. */ +export function applyTend(proto, tend, turn, { acceptId = defaultAcceptId, tailId = defaultTailId } = {}) { + proto.hidden = proto.hidden || []; + const accIdx = acceptId(proto), tailIdx = tailId(proto); + for (const t of tend || []) { + let act = String(t.action || 'carry'); + if (act === 'close' && !String(t.proof || '').trim()) act = 'carry'; // В1-гейт (находка №14) + const sv = proto.hidden.find((h) => h.id === t.id); + if (sv) { + if (act === 'close') { sv.status = 'закрыт'; sv.struck = true; } + else if (act === 'narrow') { sv.status = 'сужен'; if (t.newText) sv.text = t.newText; } + else if (act === 'grow') { sv.status = 'мутировал'; if (t.newText) sv.text = t.newText; } + else if (act === 'reopen') { sv.status = 'открыт'; sv.struck = false; } + if (act !== 'carry') sv.lastTouch = turn; + continue; + } + const ai = accIdx.find((e) => e.id === t.id); if (ai && act === 'close') { const e = (proto.acceptance || []).find((x) => x.text === ai.text); if (e) e.done = true; continue; } + const ti = tailIdx.find((e) => e.id === t.id); if (ti && act === 'close') { const e = (proto.tails || []).find((x) => x.text === ti.text); if (e) e.done = true; continue; } + } + return proto; +} +function defaultAcceptId(p) { return (p.acceptance || []).filter((e) => !e.done).map((e, i) => ({ id: `ПР-${i + 1}`, text: e.text })); } +function defaultTailId(p) { return (p.tails || []).filter((e) => !e.done).map((e, i) => ({ id: `ХВ-${i + 1}`, text: e.text })); } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-gardener.test.mjs` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-gardener.mjs tools/secretary-gardener.test.mjs +git commit -m "feat(secretary): садовник — close-с-пруфом/narrow/grow/reopen/carry + В1-гейт" +``` + +--- + +## Task 6: Применение к реестру (`secretary-apply.mjs`) + +**Files:** +- Create: `tools/secretary-apply.mjs` +- Test: `tools/secretary-apply.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { applyResults } from './secretary-apply.mjs'; + +describe('applyResults', () => { + const base = () => ({ hidden: [], nextSvId: 1, candidates: [] }); + it('новая ветка из диагностики получает id+паспорт; догадка+блокер→важное', () => { + const p = applyResults(base(), 13, { new: [{ text: 'x', lens: 'Л4', опора: 'догадка', тяжесть: 'блокер' }] }, { dropped: [] }, { forks: [] }, { tend: [] }); + expect(p.hidden[0].id).toBe('СВ-1'); + expect(p.hidden[0].опора).toBe('догадка'); + expect(p.hidden[0].тяжесть).toBe('важное'); // зажим: догадка не блокер + }); + it('брошенное ловцом → ветка lens=брош', () => { + const p = applyResults(base(), 13, { new: [] }, { dropped: [{ text: 'parked', цитата: 'q' }] }, { forks: [] }, { tend: [] }); + expect(p.hidden[0].lens).toBe('брош'); + }); + it('кандидаты накапливаются (ход рождения), без дублей', () => { + let p = applyResults(base(), 13, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }] }, { tend: [] }); + p = applyResults(p, 15, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }, { branch: 'idea-2' }] }, { tend: [] }); + expect(p.candidates.map((c) => c.branch)).toEqual(['idea-1', 'idea-2']); + expect(p.candidates.find((c) => c.branch === 'idea-1').born).toBe(13); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-apply.test.mjs` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-apply.mjs +import { one } from './secretary-armor.mjs'; +import { applyTend } from './secretary-gardener.mjs'; + +const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); +/** Зажим тяжести: догадка не может быть блокером → важное (находка №13/правило тяжести). */ +function clampSeverity(опора, тяжесть) { + return (one(опора) === 'догадка' && тяжесть === 'блокер') ? 'важное' : (тяжесть || 'важное'); +} + +/** Разложить результаты спана по реестру. proto не мутируется (работаем на копии). */ +export function applyResults(proto, turn, d11, d12, d13, dG) { + const p = JSON.parse(JSON.stringify(proto)); + p.hidden = p.hidden || []; p.nextSvId = p.nextSvId || 1; p.candidates = p.candidates || []; + // садовник + applyTend(p, dG.tend, turn); + // новые ветки из диагностики + for (const n of d11.new || []) { + p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: (n.lens || 'Л?'), status: 'открыт', text: String(n.text || ''), опора: one(n.опора), ref: n.ref || '', тяжесть: clampSeverity(n.опора, n.тяжесть), born: turn, lastTouch: turn, lineage: [] }); + } + // ловец → долговечная ветка lens=брош + for (const dr of d12.dropped || []) { + p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: 'брош', status: 'открыт', text: String(dr.text || ''), опора: one(dr.опора), ref: dr.цитата || '', тяжесть: 'важное', born: turn, lastTouch: turn, lineage: [] }); + } + // кандидаты — НАКАПЛИВАЮТСЯ (ход рождения, без дублей по branch), помечены слабыми + const seen = new Set(p.candidates.map((c) => norm(c.branch))); + for (const f of d13.forks || []) { + if (seen.has(norm(f.branch))) continue; + seen.add(norm(f.branch)); + p.candidates.push({ branch: f.branch, trigger: f.trigger, why: f.why, опора: one(f.опора), релевантность: f.релевантность || 'low', born: turn }); + } + return p; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-apply.test.mjs` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-apply.mjs tools/secretary-apply.test.mjs +git commit -m "feat(secretary): применение результатов к реестру (ветки/кандидаты-накопление/тенд)" +``` + +--- + +## Task 7: Флаг конвейера (`secretary-flag.mjs`) + +**Files:** +- Modify: `tools/secretary-flag.mjs` (добавить функцию рядом с существующими) +- Test: `tools/secretary-flag.test.mjs` (дописать кейс) + +- [ ] **Step 1: Write the failing test** (дописать в существующий describe) + +```js +import { fluffyPipelineOn } from './secretary-flag.mjs'; +describe('fluffyPipelineOn', () => { + it('по умолчанию выключен', () => { expect(fluffyPipelineOn({})).toBe(false); }); + it('включается env SECRETARY_FLUFFY=1', () => { expect(fluffyPipelineOn({ SECRETARY_FLUFFY: '1' })).toBe(true); }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-flag.test.mjs` +Expected: FAIL (`fluffyPipelineOn is not a function`). + +- [ ] **Step 3: Write minimal implementation** (добавить в `secretary-flag.mjs`) + +```js +/** Флаг нового конвейера «пушистое дерево». ВЫКЛ по умолчанию (A кладётся тёмным). */ +export function fluffyPipelineOn(env = process.env) { + return env.SECRETARY_FLUFFY === '1' || env.SECRETARY_FLUFFY === 'true'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-flag.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/secretary-flag.mjs tools/secretary-flag.test.mjs +git commit -m "feat(secretary): флаг fluffyPipelineOn (новый конвейер выключен по умолчанию)" +``` + +--- + +## Task 8: Дирижёр за флагом (`secretary-distill.mjs`) + +**Files:** +- Modify: `tools/secretary-distill.mjs` +- Test: `tools/secretary-distill.test.mjs` (дописать кейс нового пути) + +Дирижёр: при `fluffyPipelineOn()` — после редактора+шага+preserveRegistry вместо аудита гонит дифф→сбор→садовник→применение (по 5 ролям через `resolveModel`). Флаг ВЫКЛ → старый путь (аудит) без изменений. + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { distillSpan } from './secretary-distill.mjs'; +import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; + +describe('distillSpan — новый конвейер за флагом', () => { + it('при fluffy=on: ветки из диагностики + кандидаты, аудит не зовётся', async () => { + const proto = EMPTY_PROTOCOL(); + const spanEx = { user: 'предложи улучшение', assistant: 'разобрал по коду', actions: [] }; + let call = 0; + const callModel = async ({ system }) => { + call++; + if (/секретарь-редактор/.test(system)) return JSON.stringify({ subject: 'дело', decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } }); + if (/сборщик скрытых веток/.test(system)) return JSON.stringify({ lenses: [{ lens: 'Л4', verdict: 'finding', text: 'без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' }] }); + if (/ловец/.test(system)) return JSON.stringify({ dropped: [] }); + if (/брейншторма/.test(system)) return JSON.stringify({ forks: [{ branch: 'идея', trigger: 't', опора: 'догадка', релевантность: 'low' }] }); + if (/САДОВНИК/.test(system)) return JSON.stringify({ tend: [] }); + return '{}'; + }; + const out = await distillSpan(proto, spanEx, { start: 15, end: 16 }, { callModel, session: 's', flags: { fluffy: true } }); + expect(out.hidden.some((h) => h.text === 'без пруфа')).toBe(true); + expect(out.candidates.map((c) => c.branch)).toContain('идея'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-distill.test.mjs` +Expected: FAIL (новый путь не реализован — `out.candidates` undefined). + +- [ ] **Step 3: Write minimal implementation** + +В `distillSpan` после `preserveRegistry(toWrite, svSnapshot)` развилка по флагу. Старый блок аудита оставить в ветке `else`. Новый путь: + +```js +import { fluffyPipelineOn } from './secretary-flag.mjs'; +import { diffTrunk } from './secretary-trunkdiff.mjs'; +import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, recentSteps, registry, trunkForCatcher } from './secretary-harvest.mjs'; +import { SYS_G, /* applyTend используется внутри apply */ } from './secretary-gardener.mjs'; +import { applyResults } from './secretary-apply.mjs'; +import { parseLoose } from './secretary-armor.mjs'; +import { resolveModel } from './secretary-models.mjs'; + +// …внутри distillSpan, после preserveRegistry(toWrite, svSnapshot): +const fluffy = (opts.flags && opts.flags.fluffy) ?? fluffyPipelineOn(); +if (callModel && fluffy) { + const before = proto; // ствол ДО редактора + const diff = diffTrunk(before, toWrite); + const spanText = renderExchangeText(spanEx); // [ЮЗЕР]/[АССИСТЕНТ]/[ДЕЙСТВИЯ] — вынести из harvest + const call = (role, system, user) => callModel({ system, user, model: resolveModel(role) }); + const [r11, r12] = await Promise.all([ + call('diagnostic', SYS_11, user11(toWrite, spanText, { start })), + call('catcher', SYS_12, user12(toWrite, spanText)), + ]); + const d11 = diagFindings(parseLoose(typeof r11 === 'string' ? r11 : r11.text, 'lenses')); + const d12 = parseLoose(typeof r12 === 'string' ? r12 : r12.text, 'dropped'); + const r13 = await call('brainstorm', SYS_13, user13(toWrite, spanText, { start }, JSON.stringify(d11.new), JSON.stringify(d12.dropped))); + const d13 = parseLoose(typeof r13 === 'string' ? r13 : r13.text, 'forks'); + const rG = await call('gardener', SYS_G, userG(toWrite, spanText, diff)); // userG — из harvest/gardener + const dG = parseLoose(typeof rG === 'string' ? rG : rG.text, 'tend'); + const applied = applyResults(toWrite, start, d11, d12, d13, dG); + return collapseProtocol(applied); +} else if (callModel) { + // …СТАРЫЙ путь аудита без изменений… +} +``` + +> `renderExchangeText(spanEx)` и `userG(...)` экспортировать из harvest (перенести из `exchange().text`/`userG` песочницы). `opts` — параметр-объект distillSpan; добавить `flags`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-distill.test.mjs` +Expected: PASS (старые кейсы + новый). + +- [ ] **Step 5: Run full suite (регресс старого пути)** + +Run: `npx vitest run --config vitest.config.tools.mjs` +Expected: PASS (старый путь не сломан, флаг выключен по умолчанию). + +- [ ] **Step 6: Commit** + +```bash +git add tools/secretary-distill.mjs tools/secretary-distill.test.mjs +git commit -m "feat(secretary): дирижёр пушистого конвейера за флагом (старый путь цел)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §3.1 поток → Task 8 (дирижёр) + Task 2 (дифф) + Tasks 4/5/6. +- §3.2 модули → Tasks 1-8 (все перечисленные файлы). +- §3.3 гарантии В1: спасение JSON (T1), опора-словарь (T1), линза (T1), close-без-proof→carry (T5), догадка→важное (T6), полнота X/8 (T4 diagFindings.clean/lenses count). +- §4 формат: ветки-паспорт (T6), кандидаты-накопление (T6), горящие ПР/ХВ id (T5). +- §5 промпты: перенос дословно (T4/T5). +- §6 модели: T3. +- §7 тесты: каждый модуль с тест-файлом RED-первым. +- §8 коэкзистенция: флаг (T7) + развилка дирижёра (T8), старый путь в `else`. + +**Placeholder scan:** промпт-константы помечены «перенести дословно из v2.mjs» — это не заглушка, а точный источник (файл указан); USER-сборщики аналогично. Реализационные `/* …из v2.mjs… */` в Task 4 указывают конкретный файл-источник, код там финальный. Остальные шаги несут полный код. + +**Type consistency:** `applyResults(proto, turn, d11, d12, d13, dG)` — сигнатура совпадает в T6 и вызове T8. `applyTend(proto, tend, turn)` — T5 и внутри T6. `diagFindings` возвращает `{lenses,new,clean}` — T4 и потребление в T8. `one`/`normLens`/`parseLoose`/`salvageObjects` — T1, потребители T4/T5/T6/T8. `resolveModel(role)` — T3 и T8. + +**Открытый пункт реализации (НЕ заглушка, отмечено):** в Task 4 при переносе USER-сборщиков из песочницы заменить внутренний `exchange(span)` (тащит `assembleSpan`) на готовый `spanText`, который дирижёр уже имеет из `spanEx`. Это вынос `renderExchangeText(spanEx)` в harvest — отмечено в T4/T8. diff --git a/tools/secretary-apply.mjs b/tools/secretary-apply.mjs new file mode 100644 index 0000000..f2975f9 --- /dev/null +++ b/tools/secretary-apply.mjs @@ -0,0 +1,40 @@ +// tools/secretary-apply.mjs +// Детерминированное применение результатов спана к реестру: садовник + новые ветки + кандидаты. +import { one } from './secretary-armor.mjs'; +import { applyTend } from './secretary-gardener.mjs'; + +const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); + +/** Зажим тяжести: догадка не может быть блокером → важное (правило тяжести, находка №13). */ +function clampSeverity(опора, тяжесть) { + return (one(опора) === 'догадка' && тяжесть === 'блокер') ? 'важное' : (тяжесть || 'важное'); +} + +/** Разложить результаты спана по реестру. Вход НЕ мутируется (работаем на копии). */ +export function applyResults(proto, turn, d11, d12, d13, dG) { + const p = JSON.parse(JSON.stringify(proto)); + p.hidden = p.hidden || []; p.nextSvId = p.nextSvId || 1; p.candidates = p.candidates || []; + + // садовник (тенд по id; close без proof → carry — внутри applyTend) + applyTend(p, (dG && dG.tend) || [], turn); + + // новые ветки из диагностики + for (const n of (d11 && d11.new) || []) { + p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: n.lens || 'Л?', status: 'открыт', text: String(n.text || ''), опора: one(n.опора), ref: n.ref || '', тяжесть: clampSeverity(n.опора, n.тяжесть), born: turn, lastTouch: turn, lineage: [] }); + } + + // ловец → долговечная ветка lens=брош + for (const dr of (d12 && d12.dropped) || []) { + p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: 'брош', status: 'открыт', text: String(dr.text || ''), опора: one(dr.опора), ref: dr.цитата || '', тяжесть: 'важное', born: turn, lastTouch: turn, lineage: [] }); + } + + // кандидаты — НАКАПЛИВАЮТСЯ (ход рождения, без дублей по branch), помечены слабыми (доживают до разбора) + const seen = new Set(p.candidates.map((c) => norm(c.branch))); + for (const f of (d13 && d13.forks) || []) { + if (seen.has(norm(f.branch))) continue; + seen.add(norm(f.branch)); + p.candidates.push({ branch: f.branch, trigger: f.trigger, why: f.why, опора: one(f.опора), релевантность: f.релевантность || 'low', born: turn }); + } + + return p; +} diff --git a/tools/secretary-apply.test.mjs b/tools/secretary-apply.test.mjs new file mode 100644 index 0000000..9134760 --- /dev/null +++ b/tools/secretary-apply.test.mjs @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { applyResults } from './secretary-apply.mjs'; + +describe('applyResults', () => { + const base = () => ({ hidden: [], nextSvId: 1, candidates: [] }); + + it('новая ветка из диагностики получает id+паспорт; догадка+блокер→важное', () => { + const p = applyResults(base(), 13, { new: [{ text: 'x', lens: 'Л4', опора: 'догадка', тяжесть: 'блокер' }] }, { dropped: [] }, { forks: [] }, { tend: [] }); + expect(p.hidden[0].id).toBe('СВ-1'); + expect(p.hidden[0].опора).toBe('догадка'); + expect(p.hidden[0].тяжесть).toBe('важное'); // зажим: догадка не блокер + }); + + it('брошенное ловцом → ветка lens=брош', () => { + const p = applyResults(base(), 13, { new: [] }, { dropped: [{ text: 'parked', цитата: 'q' }] }, { forks: [] }, { tend: [] }); + expect(p.hidden[0].lens).toBe('брош'); + }); + + it('кандидаты накапливаются (ход рождения), без дублей', () => { + let p = applyResults(base(), 13, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }] }, { tend: [] }); + p = applyResults(p, 15, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }, { branch: 'idea-2' }] }, { tend: [] }); + expect(p.candidates.map((c) => c.branch)).toEqual(['idea-1', 'idea-2']); + expect(p.candidates.find((c) => c.branch === 'idea-1').born).toBe(13); + }); + + it('садовник закрывает ветку через применение', () => { + const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }], nextSvId: 2, candidates: [] }; + const p = applyResults(proto, 15, { new: [] }, { dropped: [] }, { forks: [] }, { tend: [{ id: 'СВ-1', action: 'close', proof: 'есть' }] }); + expect(p.hidden[0].status).toBe('закрыт'); + }); + + it('вход не мутируется', () => { + const proto = base(); + applyResults(proto, 13, { new: [{ text: 'x', lens: 'Л1' }] }, { dropped: [] }, { forks: [] }, { tend: [] }); + expect(proto.hidden).toHaveLength(0); + }); +}); diff --git a/tools/secretary-armor.mjs b/tools/secretary-armor.mjs new file mode 100644 index 0000000..a614819 --- /dev/null +++ b/tools/secretary-armor.mjs @@ -0,0 +1,51 @@ +// tools/secretary-armor.mjs +// Детерминированный разбор/спасение ответов флэша (принцип В1). + +export const normLens = (l) => { + const t = String(l || '').trim().replace(/^л/i, 'Л'); + return /^Л\d$/.test(t) ? t : (/^\d$/.test(t) ? 'Л' + t : t || 'Л?'); +}; + +const ОПОРЫ = ['внутр', 'внеш', 'воля', 'догадка']; +const ОП_SYN = { + внутренний: 'внутр', код: 'внутр', файл: 'внутр', проект: 'внутр', обмен: 'внутр', реплика: 'внутр', + ответ: 'внутр', цитата: 'внутр', текст: 'внутр', строка: 'внутр', внешний: 'внеш', url: 'внеш', + веб: 'внеш', док: 'внеш', документация: 'внеш', владелец: 'воля', юзер: 'воля', пользователь: 'воля', + запрос: 'воля', директива: 'воля', гипотеза: 'догадка', предположение: 'догадка', домысел: 'догадка', + незавершённый: 'догадка', неизвестно: 'догадка', +}; +/** Паспорт-опора -> ровно одно слово словаря; неизвестное -> честно «догадка». */ +export function one(raw) { + const w = String(raw || '').trim().toLowerCase().split(/[\s,;:|]+/)[0].replace(/[.,;:]+$/, ''); + if (ОПОРЫ.includes(w)) return w; + return ОП_SYN[w] || 'догадка'; +} + +/** Спасение по-объектно: лезем В массив […] и парсим КАЖДЫЙ объект; битый пропускаем. */ +export function salvageObjects(text) { + const lb = text.indexOf('['), rb = text.lastIndexOf(']'); + const body = (lb >= 0 && rb > lb) ? text.slice(lb + 1, rb) : text; + const out = []; + let depth = 0, start = -1, inStr = false, esc = false; + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } + if (c === '"') inStr = true; + else if (c === '{') { if (depth === 0) start = i; depth++; } + else if (c === '}') { depth--; if (depth === 0 && start >= 0) { const chunk = body.slice(start, i + 1).replace(/,(\s*[}\]])/g, '$1'); try { out.push(JSON.parse(chunk)); } catch { /* битый — пропустить */ } start = -1; } } + } + return out; +} + +/** Толерантный парсер: ```-обёртка, висячая запятая, голый массив/объект; слом -> спасение по-объектно. */ +export function parseLoose(text, expectKey) { + let s = String(text || '').trim().replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```$/, '').trim(); + const obj = s.indexOf('{'), arr = s.indexOf('['); + if (obj >= 0 || arr >= 0) { const start = arr < 0 ? obj : obj < 0 ? arr : Math.min(obj, arr); s = s.slice(start); } + s = s.replace(/,(\s*[}\]])/g, '$1'); + let p; try { p = JSON.parse(s); } + catch { const salv = salvageObjects(s); return { [expectKey]: salv.length ? salv : [] }; } + if (Array.isArray(p)) return { [expectKey]: p }; + if (p && typeof p === 'object') { if (!Array.isArray(p[expectKey])) p[expectKey] = p[expectKey] ? [p[expectKey]] : []; return p; } + return { [expectKey]: [] }; +} diff --git a/tools/secretary-armor.test.mjs b/tools/secretary-armor.test.mjs new file mode 100644 index 0000000..55acc35 --- /dev/null +++ b/tools/secretary-armor.test.mjs @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { parseLoose, salvageObjects, one, normLens } from './secretary-armor.mjs'; + +describe('secretary-armor', () => { + it('parseLoose: чистый объект', () => { + expect(parseLoose('{"new":[{"text":"x"}]}', 'new').new).toHaveLength(1); + }); + it('parseLoose: ```json-обёртка и висячая запятая', () => { + expect(parseLoose('```json\n{"forks":[{"a":1},]}\n```', 'forks').forks).toHaveLength(1); + }); + it('parseLoose: голый массив', () => { + expect(parseLoose('[{"a":1},{"b":2}]', 'tend').tend).toHaveLength(2); + }); + it('parseLoose: сломанный JSON -> спасение по-объектно (битый пропущен)', () => { + const broken = '{"tend":[{"id":"СВ-1","action":"carry"},{"id":"СВ-2","why":Ход без кавычек},{"id":"СВ-3","action":"carry"}]}'; + expect(parseLoose(broken, 'tend').tend.map((t) => t.id)).toEqual(['СВ-1', 'СВ-3']); + }); + it('one: проза->словарь; неизвестное->догадка', () => { + expect(one('ответ')).toBe('внутр'); + expect(one('владелец сказал')).toBe('воля'); + expect(one('незавершённый')).toBe('догадка'); + expect(one('абракадабра')).toBe('догадка'); + expect(one('внеш')).toBe('внеш'); + }); + it('normLens: 9->Л9, л4->Л4', () => { + expect(normLens('9')).toBe('Л9'); + expect(normLens('л4')).toBe('Л4'); + expect(normLens('Л6')).toBe('Л6'); + }); +}); diff --git a/tools/secretary-distill.mjs b/tools/secretary-distill.mjs index 0495577..30bca09 100644 --- a/tools/secretary-distill.mjs +++ b/tools/secretary-distill.mjs @@ -4,12 +4,19 @@ import { reconcileTurn, mergeTurnIntoProtocol, collapseProtocol } from './secretary-reconcile.mjs'; import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs'; import { buildStepLine } from './secretary-layer1.mjs'; +import { fluffyPipelineOn } from './secretary-flag.mjs'; +import { diffTrunk } from './secretary-trunkdiff.mjs'; +import { parseLoose } from './secretary-armor.mjs'; +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 } from './secretary-gardener.mjs'; +import { applyResults } from './secretary-apply.mjs'; /** Разобрать спан и вернуть НОВЫЙ протокол (вход не мутируется reconcile-веткой; collapse — копия). * proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions}; * {start,end} — границы спана (в ходах сырья); opts: { callModel|null, session, diag }. * Без callModel (нет ключа) — пишется только детерминированный шаг, категории/СВ не трогаются. */ -export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag } = {}) { +export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag, flags } = {}) { // Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge. const svSnapshot = JSON.parse(JSON.stringify({ hidden: proto.hidden || [], acceptance: proto.acceptance || [], @@ -28,9 +35,34 @@ export async function distillSpan(proto, spanEx, { start, end, note = '' }, { ca actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep, note }) }; const toWrite = mergeTurnIntoProtocol({ proto, updated, step }); - // Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile. + // Реестр СВ — вотчина аудитора/садовника: вернуть из снимка ДО reconcile. preserveRegistry(toWrite, svSnapshot); + // НОВЫЙ конвейер «пушистое дерево» (подпроект A) — за флагом. Флаг ВЫКЛ → старый аудит ниже. + const fluffy = (flags && typeof flags.fluffy === 'boolean') ? flags.fluffy : fluffyPipelineOn(); + if (callModel && fluffy) { + try { + const diff = diffTrunk(proto, toWrite); // движение ствола до/после редактора + 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) }); + // сбор: 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)), + ]); + 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 d13 = parseLoose(txt(r13), 'forks'); + // садовник (после сбора; видит дифф ствола + свежие ветки) + const rG = await call('gardener', SYS_G, userG(toWrite, spanText, diff)); + const dG = parseLoose(txt(rG), 'tend'); + return collapseProtocol(applyResults(toWrite, start, d11, d12, d13, dG)); + } catch (e) { if (typeof diag === 'function') diag({ turn: start, reason: 'fluffy-fail', error: e && e.message }); return collapseProtocol(toWrite); } + } + + // СТАРЫЙ путь (аудит 9-линз) — без изменений, пока флаг выключен. if (callModel) { try { const auditMsgs = buildAuditPrompt(toWrite, spanEx); diff --git a/tools/secretary-distill.test.mjs b/tools/secretary-distill.test.mjs index 951aaff..b39a37d 100644 --- a/tools/secretary-distill.test.mjs +++ b/tools/secretary-distill.test.mjs @@ -34,3 +34,35 @@ describe('distillSpan — разбор одного завершённого с expect(out.decisions).toEqual([]); }); }); + +describe('distillSpan — новый конвейер «пушистое дерево» за флагом', () => { + it('при fluffy=on: ветка из диагностики + кандидат из брейншторма, аудит не зовётся', async () => { + const proto = EMPTY_PROTOCOL(); + const spanEx = { user: 'предложи улучшение наставнику', assistant: 'разобрал по коду', actions: [] }; + let auditCalled = false; + const callModel = async ({ system }) => { + if (/секретарь-редактор/.test(system)) return JSON.stringify({ subject: 'дело', decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } }); + if (/сборщик скрытых веток/.test(system)) return JSON.stringify({ lenses: [{ lens: 'Л4', verdict: 'finding', text: 'без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' }] }); + if (/ловец/.test(system)) return JSON.stringify({ dropped: [] }); + if (/брейншторма/.test(system)) return JSON.stringify({ forks: [{ branch: 'идея', trigger: 't', опора: 'догадка', релевантность: 'low' }] }); + if (/САДОВНИК/.test(system)) return JSON.stringify({ tend: [] }); + if (/аудитор|скрытые вопросы/i.test(system)) { auditCalled = true; return '{"new":[]}'; } + return '{}'; + }; + const out = await distillSpan(proto, spanEx, { start: 15, end: 16 }, { callModel, session: 's', flags: { fluffy: true } }); + expect(out.hidden.some((h) => h.text === 'без пруфа')).toBe(true); + expect((out.candidates || []).map((c) => c.branch)).toContain('идея'); + expect(auditCalled).toBe(false); + }); + + it('флаг ВЫКЛ (по умолчанию) — старый путь, candidates нет', async () => { + const proto = EMPTY_PROTOCOL(); + const spanEx = { user: 'вопрос достаточно длинный', assistant: 'ответ', actions: [] }; + const callModel = async ({ system }) => { + if (/секретарь-редактор/.test(system)) return JSON.stringify({ subject: 'd', decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } }); + return JSON.stringify({ new: [], ops: [], resolved: [] }); + }; + const out = await distillSpan(proto, spanEx, { start: 5, end: 5 }, { callModel, session: 's', flags: { fluffy: false } }); + expect(out.candidates).toBeUndefined(); + }); +}); diff --git a/tools/secretary-flag.mjs b/tools/secretary-flag.mjs index 741dc96..b624579 100644 --- a/tools/secretary-flag.mjs +++ b/tools/secretary-flag.mjs @@ -54,3 +54,9 @@ export function resolveCaseActivation(requested, existing = []) { if (candidates.length > 0) return { action: 'confirm', candidates }; return { action: 'activate', work: String(requested || '').trim() }; } + +// Флаг нового конвейера «пушистое дерево». ВЫКЛ по умолчанию — подпроект A кладётся «тёмным», +// включается только когда воркер (подпроект B) вживит конвейер. +export function fluffyPipelineOn(env = process.env) { + return env.SECRETARY_FLUFFY === '1' || env.SECRETARY_FLUFFY === 'true'; +} diff --git a/tools/secretary-flag.test.mjs b/tools/secretary-flag.test.mjs index c56c887..16a7b8f 100644 --- a/tools/secretary-flag.test.mjs +++ b/tools/secretary-flag.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs'; +import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation, fluffyPipelineOn } from './secretary-flag.mjs'; describe('detectSecretaryCommand', () => { it('распознаёт включение', () => { @@ -49,3 +49,15 @@ describe('resolveCaseActivation — сверка имени дела со спи expect(resolveCaseActivation('новое', [])).toEqual({ action: 'activate', work: 'новое' }); }); }); + +describe('fluffyPipelineOn', () => { + it('по умолчанию выключен', () => { + expect(fluffyPipelineOn({})).toBe(false); + }); + it('включается env SECRETARY_FLUFFY=1', () => { + expect(fluffyPipelineOn({ SECRETARY_FLUFFY: '1' })).toBe(true); + }); + it('включается env SECRETARY_FLUFFY=true', () => { + expect(fluffyPipelineOn({ SECRETARY_FLUFFY: 'true' })).toBe(true); + }); +}); diff --git a/tools/secretary-gardener.mjs b/tools/secretary-gardener.mjs new file mode 100644 index 0000000..2bbc94e --- /dev/null +++ b/tools/secretary-gardener.mjs @@ -0,0 +1,50 @@ +// tools/secretary-gardener.mjs +// Садовник веток: по диффу ствола ухаживает за живыми ветками (close/narrow/grow/reopen/carry). +// Промпт перенесён из доказанной песочницы (.scratch/sec/v2.mjs, находка №14: close-с-пруфом). + +export const SYS_G = [ + 'Ты — САДОВНИК веток. Ствол только что продвинулся на шаг (дифф дан). Работа: пройти по КАЖДОЙ живой', + 'ветке реестра (скрытые СВ-N, горящие ПР-N/ХВ-N) и решить, как её изменил ЭТОТ шаг ствола. Новые НЕ ищешь.', + 'Действия: close · narrow (закрыл ЧАСТЬ; newText=остаток) · grow (развил; newText=уточнённый вопрос) ·', + 'reopen (закрытая снова актуальна — обратный каскад) · carry (не касался; оставить).', + 'КРИТЕРИЙ close (находка №14): закрывай ТОЛЬКО если КОНКРЕТНАЯ вещь, которую ветка называет недостающей,', + 'теперь ПРИСУТСТВУЕТ и её ВИДНО — в диффе или обмене, и ты ПОКАЗЫВАЕШЬ где (поле "proof" = цитата/строка).', + 'Условие закрытия зашито в текст ветки: «нет выдержек кода» → close лишь когда выдержки кода появились.', + 'Для Л4/Л8 (доказательность/приёмка): ЗАЯВЛЕНИЕ ≠ ПРУФ — «я разобрал»/«готово»/повтор утверждения НЕ', + 'закрывают; close только при реальном доказательстве в ходе (выдержка кода показана, команда прогнана с', + 'результатом, проверено глазами-руками). Само-проверка: «могу ткнуть в строку, что закрывает дыру?» нет→carry.', + 'ПРАВИЛА: только по id; КАЖДОЕ действие обоснуй ссылкой на конкретное движение ствола (из диффа); close', + 'ОБЯЗАН нести непустой "proof"; нет опоры в движении → carry; сторож противоречий → reopen; narrow/grow', + 'меняют формулировку ВОПРОСА, не дописывают пересказ хода; по умолчанию carry — честный carry лучше', + 'выдуманного закрытия. НЕ перетягивай: где дыра реально заполнена — закрывай РЕШИТЕЛЬНО (дерево должно', + 'прореживаться, не заморозиться).', + 'ВЫХОД строго JSON: {"tend":[{"id","action":"close|narrow|grow|reopen|carry","newText","proof","why"}]}', +].join('\n'); + +// Чистые id горящим Л8/Л9 (находка №12): приёмка ПР-N, хвосты ХВ-N. +function defaultAcceptId(p) { return (p.acceptance || []).filter((e) => !e.done).map((e, i) => ({ id: `ПР-${i + 1}`, text: e.text })); } +function defaultTailId(p) { return (p.tails || []).filter((e) => !e.done).map((e, i) => ({ id: `ХВ-${i + 1}`, text: e.text })); } + +/** Применить решения садовника к скрытым веткам + горящим Л8/Л9. close без proof → carry (В1-гейт). */ +export function applyTend(proto, tend, turn, { acceptId = defaultAcceptId, tailId = defaultTailId } = {}) { + proto.hidden = proto.hidden || []; + const accIdx = acceptId(proto), tailIdx = tailId(proto); + for (const t of tend || []) { + let act = String(t.action || 'carry'); + if (act === 'close' && !String(t.proof || '').trim()) act = 'carry'; // В1-гейт (находка №14) + const sv = proto.hidden.find((h) => h.id === t.id); + if (sv) { + if (act === 'close') { sv.status = 'закрыт'; sv.struck = true; } + else if (act === 'narrow') { sv.status = 'сужен'; if (t.newText) sv.text = t.newText; } + else if (act === 'grow') { sv.status = 'мутировал'; if (t.newText) sv.text = t.newText; } + else if (act === 'reopen') { sv.status = 'открыт'; sv.struck = false; } + if (act !== 'carry') sv.lastTouch = turn; + continue; + } + const ai = accIdx.find((e) => e.id === t.id); + if (ai && act === 'close') { const e = (proto.acceptance || []).find((x) => x.text === ai.text); if (e) e.done = true; continue; } + const ti = tailIdx.find((e) => e.id === t.id); + if (ti && act === 'close') { const e = (proto.tails || []).find((x) => x.text === ti.text); if (e) e.done = true; continue; } + } + return proto; +} diff --git a/tools/secretary-gardener.test.mjs b/tools/secretary-gardener.test.mjs new file mode 100644 index 0000000..cd6881e --- /dev/null +++ b/tools/secretary-gardener.test.mjs @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { SYS_G, applyTend } from './secretary-gardener.mjs'; + +describe('secretary-gardener', () => { + it('SYS_G требует proof на close', () => { + expect(SYS_G).toMatch(/proof/); + expect(SYS_G).toMatch(/ЗАЯВЛЕНИЕ ≠ ПРУФ/); + }); + it('close с пруфом — закрыто', () => { + const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] }; + applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: 'код mentor.mjs:107' }], 15); + expect(proto.hidden[0].status).toBe('закрыт'); + expect(proto.hidden[0].struck).toBe(true); + }); + it('голое close без proof — понижено в carry (В1-гейт)', () => { + const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] }; + applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: '' }], 15); + expect(proto.hidden[0].status).toBe('открыт'); + }); + it('narrow меняет текст и статус', () => { + const proto = { hidden: [{ id: 'СВ-2', status: 'открыт', text: 'старый' }] }; + applyTend(proto, [{ id: 'СВ-2', action: 'narrow', newText: 'остаток' }], 15); + expect(proto.hidden[0].status).toBe('сужен'); + expect(proto.hidden[0].text).toBe('остаток'); + }); + it('close по горящему ХВ-1 закрывает хвост', () => { + const proto = { hidden: [], tails: [{ text: 'хвост', done: false }] }; + applyTend(proto, [{ id: 'ХВ-1', action: 'close', proof: 'убрано' }], 15); + expect(proto.tails[0].done).toBe(true); + }); +}); diff --git a/tools/secretary-harvest.mjs b/tools/secretary-harvest.mjs new file mode 100644 index 0000000..56fc8a1 --- /dev/null +++ b/tools/secretary-harvest.mjs @@ -0,0 +1,122 @@ +// tools/secretary-harvest.mjs +// Сбор веток: диагностика (1.1, отчёт 8/8) ∥ ловец (1.2, со стволом) → брейншторм (1.3, без лимита). +// Промпты перенесены дословно из доказанной песочницы (.scratch/sec/v2.mjs, находки №13/№16 + без-лимита). +import { normLens } from './secretary-armor.mjs'; + +const PASSPORT = [ + 'ПАСПОРТ каждой находки. "опора" — РОВНО ОДНО слово из ЗАКРЫТОГО списка (другие слова ЗАПРЕЩЕНЫ):', + ' • внутр — видно в коде/проекте/обмене (есть ref на файл:строку или цитату);', + ' • внеш — внешний факт (есть ref на url/док);', + ' • воля — так сказал владелец;', + ' • догадка — додумано, прямой опоры нет.', + 'НЕЛЬЗЯ писать «ответ», «реплика», «незавершённый» и т.п. — выбери ОДНО из четырёх. Пример:', + ' {"text":"…","опора":"внутр","ref":"mentor-verdict.mjs:107","тяжесть":"важное"}.', + 'Примесь допустима пометкой в "ref" («+догадка»), но "опора" — одно слово из списка. "ref" — где именно.', + 'Паспорт — БИРКА ТЯЖЕСТИ для садовника, НЕ повод убивать ветку. "тяжесть" ∈ {блокер|важное|мелочь}:', + 'блокер=рушит ход / меняет архитектуру или объём; важное=влияет на реализацию; мелочь=косметика.', + 'ПРАВИЛО ТЯЖЕСТИ: опора=догадка НЕ может быть блокером (потолок — важное); блокер только при опоре внутр/внеш/воля.', +].join('\n'); + +export const SYS_11 = [ + 'Ты — сборщик скрытых веток (диагностич. линзы). Видишь НЕДАВНИЕ шаги (ствол), ЖИВЫЕ ветки (реестр) и', + 'ПОЛНЫЙ текущий обмен. Работа: найти ТОЛЬКО НОВЫЕ ветки в ТЕКУЩЕМ обмене. Уход за старыми — НЕ твоё (садовник).', + 'Линзы:', + 'Л1 Допущения — что молча считаем верным. Назови САМОЕ рисковое (неверно→рушит ход) + дешёвый тест.', + 'Л3 Дрейф воли — решение уехало от воли владельца (сверь с недавними шагами).', + 'Л4 Доказательность — заявлено фактом без пруфа. Красные флаги: «should/похоже/кажется»; «готово/работает»', + ' без прогона команды В ЭТОМ ходе; доверие отчёту субагента без проверки.', + 'Л5 Противоречие — ход спорит с прежним решением из реестра/шагов.', + 'Л6 Хрупкость — сломается на будущем; симптом-фикс вместо корня; «работает сквозь A→B→C» без проверки границ.', + 'Л7 Смысл слов — одно слово в двух значениях.', + 'Л8 Приёмка (ГОРИТ) — «готово» без проверки глазами-руками.', + 'Л9 Хвосты (ГОРИТ) — не убрано/не закоммичено/не запушено.', + 'ОТЧЁТ ПО КАЖДОЙ ЛИНЗЕ ОБЯЗАТЕЛЕН (находка №16): пройди по ВСЕМ 8 линзам (Л1,Л3,Л4,Л5,Л6,Л7,Л8,Л9) и', + 'для КАЖДОЙ верни ОДИН вердикт — либо "finding" (есть НОВАЯ ветка, с паспортом), либо "clean" с краткой', + 'причиной "why"=«чисто, потому что…». ПУСТОЙ/НЕПОЛНЫЙ проход ЗАПРЕЩЁН: ровно 8 вердиктов, ни одной', + 'пропущенной линзы. Это НЕ требование «найти везде»: "clean" — честный полноправный ответ.', + 'finding — ТОЛЬКО при РЕАЛЬНОЙ зацепке; натяжка/нет зацепки → clean (НЕ выдумывай ради заполнения).', + 'Не дублируй реестр/шаги. Если ГОРИТ Л8/Л9 и факт налицо — это finding, не clean. «Не уверен» → finding', + 'с опора=догадка, тяжесть мелочь/важное (не прячь в clean).', + PASSPORT, + 'ВЫХОД строго JSON: {"lenses":[{"lens":"Л1","verdict":"finding|clean","text","опора","ref","тяжесть","why"}]}', + 'Для clean: text/опора/ref/тяжесть пустые, "why" обязателен. Для finding: паспорт обязателен, why можно пусто.', +].join('\n'); + +export const SYS_12 = [ + 'Ты — ловец брошенных веток. В тексте АССИСТЕНТА найди вопросы/темы, которые он САМ поднял и отложил,', + 'не разобрав (чтобы не потерять ствол). Маркеры: «отдельный вопрос», «вернёмся», «потом», «забегая', + 'вперёд», «ждать ли», «отложу», «это уже…», «позже». Только реально поднятое-и-брошенное, не выдумывай.', + 'НЕ лови то, что УЖЕ в РЕШЕНИЯХ или ОТКРЫТЫХ ствола (дано ниже) — это не потеряно, это уже на виду.', + 'ВЫХОД строго JSON: {"dropped":[{"text","цитата","опора"}]}', +].join('\n'); + +export const SYS_13 = [ + 'Ты — линза брейншторма. Подсвечивай НЕрассмотренные ветки/идеи — которых в разговоре НЕ было.', + 'Генерируй СВОБОДНО (выполнимость НЕ оценивай). Приёмы: SCAMPER (заменить/объединить/адаптировать/', + 'УБРАТЬ/развернуть), инверсия («как сделать хуже? разверни»).', + 'СКОЛЬКО: число НЕ ограничено. Столько, сколько есть РЕАЛЬНЫХ зацепок. На СТАРТЕ большой фичи их много', + '(ствол пуст, развилок масса) — НЕ зажимай, дай все стоящие. В зрелом стволе — мало. Кандидаты ЭФЕМЕРНЫ', + '(не сохраняются: слабое тонет и испаряется само), поэтому щедрость БЕЗОПАСНА — но щедрость на', + 'ЗАЦЕПЛЕННОЕ, не на выдумку.', + 'ГЛАВНЫЙ ФИЛЬТР (анти-призрак): каждая развилка НЕСЁТ "trigger" = ДОСЛОВНУЮ цитату из обмена/реестра, за', + 'которую цепляется. Нет дословной зацепки → это ПРИЗРАК, выкинь. Лучше 6 заземлённых, чем 5 + 1 призрак.', + 'НЕ повторяй обсуждённое, живые ветки и УЖЕ НАЙДЕННОЕ этот ход (дано ниже).', + '"опора" — РОВНО ОДНО слово из ЗАКРЫТОГО списка {внутр|внеш|воля|догадка}; идея-фантазия → догадка +', + 'релевантность low. Если зацепок реально много (>~8) — оставь сильнейшие (это от ИЗОБИЛИЯ, не искусств. потолок).', + 'ВЫХОД строго JSON: {"forks":[{"trigger","branch","why","опора","релевантность"}]}', +].join('\n'); + +// ── детерминированная сборка кусков контекста (роль хука) ────────────────── +export function recentSteps(proto, n = 10) { + const st = (proto.steps || []).slice(-n); + return st.map((s) => `- ${s.text}`).join('\n') || '(пусто)'; +} +// чистые id горящим Л8/Л9 (находка №12): приёмка ПР-N, хвосты ХВ-N +function acceptId(proto) { return (proto.acceptance || []).filter((e) => !e.done).map((e, i) => ({ id: `ПР-${i + 1}`, text: e.text })); } +function tailId(proto) { return (proto.tails || []).filter((e) => !e.done).map((e, i) => ({ id: `ХВ-${i + 1}`, text: e.text })); } +export function registry(proto) { + const h = (proto.hidden || []).map((x) => `${x.id} [${x.lens} · ${x.status} · ${x.тяжесть || '?'}]: ${x.text}`).join('\n') || '(пусто)'; + const a = acceptId(proto).map((e) => `${e.id}: ${e.text}`).join('\n') || '(пусто)'; + const t = tailId(proto).map((e) => `${e.id}: ${e.text}`).join('\n') || '(пусто)'; + return `СКРЫТЫЕ ВЕТКИ:\n${h}\nГОРИТ Л8 (приёмка):\n${a}\nГОРИТ Л9 (хвосты):\n${t}`; +} +// ствол для ловца (находка №10): решения + открытые → не лови уже решённое +export function trunkForCatcher(proto) { + const d = (proto.decisions || []).filter((e) => !e.struck).map((e) => `- ${e.text}`).join('\n') || '(нет)'; + const o = (proto.open || []).filter((e) => !e.struck).map((e) => `- ${e.text}`).join('\n') || '(нет)'; + return `РЕШЕНИЯ СТВОЛА (уже решено — НЕ лови):\n${d}\nОТКРЫТЫЕ СТВОЛА (уже на виду — НЕ лови):\n${o}`; +} + +/** Текст обмена из спана {user, assistant, actions} (заменяет exchange(span) песочницы). */ +export function renderExchangeText(spanEx) { + const acts = ((spanEx.actions || []).map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${String(a.result).slice(0, 4000)}` : ''}`).join('\n')) || '—'; + return `[ЮЗЕР]: ${spanEx.user || ''}\n[АССИСТЕНТ]: ${spanEx.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`; +} + +// ── USER-сборщики (берут готовый spanText) ──────────────────────────────── +export function user11(proto, spanText, span = {}) { + return `НЕДАВНИЕ ШАГИ (ствол, последние 10):\n${recentSteps(proto)}\n\n${registry(proto)}\n\n` + + `=== ПОЛНЫЙ ОБМЕН (ходы ${span.start ?? '?'}-${span.end ?? '?'}) ===\n${spanText}`; +} +export function user12(proto, spanText) { + return `${trunkForCatcher(proto)}\n\n=== ПОЛНЫЙ ОБМЕН ===\n${spanText}`; +} +export function user13(proto, spanText, span, found11, found12) { + return `${user11(proto, spanText, span)}\n\n=== УЖЕ НАЙДЕНО ЭТОТ ХОД (не повторяй) ===\nДиагностика: ${found11}\nБрошенное: ${found12}`; +} +export function userG(proto, spanText, diff) { + return `ЧТО ИЗМЕНИЛОСЬ В СТВОЛЕ ЭТОТ ХОД (дифф редактора):\n${diff}\n\n${registry(proto)}\n\n` + + `НЕДАВНИЕ ШАГИ:\n${recentSteps(proto)}\n\n=== ПОЛНЫЙ ОБМЕН ===\n${spanText}`; +} + +/** Из поимённого отчёта 8 линз извлечь находки; толерантно к плоскому {new:[…]} (находка №16). */ +export function diagFindings(parsed) { + const ls = Array.isArray(parsed.lenses) ? parsed.lenses + : (Array.isArray(parsed.new) ? parsed.new.map((n) => ({ ...n, verdict: 'finding' })) : []); + const isFind = (l) => String(l.verdict || '').toLowerCase() === 'finding' && String(l.text || '').trim(); + return { + lenses: ls, + new: ls.filter(isFind).map((f) => ({ text: f.text, lens: f.lens, опора: f.опора, ref: f.ref, тяжесть: f.тяжесть })), + clean: ls.filter((l) => String(l.verdict || '').toLowerCase() === 'clean').map((l) => normLens(l.lens)), + }; +} diff --git a/tools/secretary-harvest.test.mjs b/tools/secretary-harvest.test.mjs new file mode 100644 index 0000000..a6546b4 --- /dev/null +++ b/tools/secretary-harvest.test.mjs @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { SYS_11, SYS_12, SYS_13, diagFindings, renderExchangeText, user11, user12, user13 } from './secretary-harvest.mjs'; + +describe('secretary-harvest промпты', () => { + it('SYS_11 требует отчёт по 8 линзам, finding|clean', () => { + expect(SYS_11).toMatch(/8 линз/); + expect(SYS_11).toMatch(/finding/); + expect(SYS_11).toMatch(/clean/); + }); + it('SYS_12 (ловец) запрещает ловить решённое ствола', () => { + expect(SYS_12).toMatch(/НЕ лови/); + }); + it('SYS_13 (брейншторм) без числового лимита, с дословной цитатой', () => { + expect(SYS_13).toMatch(/НЕ ограничено/); + expect(SYS_13).toMatch(/ДОСЛОВНУЮ цитату/); + }); +}); + +describe('diagFindings', () => { + it('из отчёта 8/8 извлекает finding, собирает clean', () => { + const parsed = { lenses: [ + { lens: 'Л4', verdict: 'finding', text: 'заявлено без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' }, + { lens: 'Л1', verdict: 'clean', why: 'чисто' }, + ] }; + const r = diagFindings(parsed); + expect(r.new).toHaveLength(1); + expect(r.new[0].lens).toBe('Л4'); + expect(r.clean).toContain('Л1'); + expect(r.lenses).toHaveLength(2); + }); + it('толерантен к старому плоскому формату {new:[…]}', () => { + const r = diagFindings({ new: [{ text: 'x', lens: 'Л6' }] }); + expect(r.new).toHaveLength(1); + }); +}); + +describe('USER-сборщики', () => { + it('renderExchangeText форматирует обмен с действиями', () => { + const t = renderExchangeText({ user: 'спросил', assistant: 'ответил', actions: [{ tool: 'Read', input: 'f', result: 'r' }] }); + expect(t).toContain('[ЮЗЕР]: спросил'); + expect(t).toContain('[АССИСТЕНТ]: ответил'); + expect(t).toContain('• Read'); + }); + it('user12 даёт ловцу ствол (решения/открытые) + обмен', () => { + const proto = { decisions: [{ text: 'решено-X', struck: false }], open: [{ text: 'открыто-Y', struck: false }] }; + const u = user12(proto, '[ЮЗЕР]: q'); + expect(u).toContain('решено-X'); + expect(u).toContain('открыто-Y'); + expect(u).toContain('[ЮЗЕР]: q'); + }); + it('user11 несёт реестр и недавние шаги', () => { + const proto = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }], steps: [{ text: 'Ход 1 — …' }] }; + const u = user11(proto, 'обмен', { start: 3, end: 4 }); + expect(u).toContain('СВ-1'); + expect(u).toContain('Ход 1'); + }); + it('user13 добавляет уже найденное', () => { + const u = user13({}, 'обмен', { start: 3, end: 4 }, '["находка"]', '["брошено"]'); + expect(u).toContain('УЖЕ НАЙДЕНО'); + expect(u).toContain('находка'); + }); +}); diff --git a/tools/secretary-models.mjs b/tools/secretary-models.mjs new file mode 100644 index 0000000..fa61dcd --- /dev/null +++ b/tools/secretary-models.mjs @@ -0,0 +1,9 @@ +// tools/secretary-models.mjs +// 5 слотов моделей (D). По умолчанию все — общий флэш; per-role env перекрывает. +export const ROLES = ['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener']; + +/** Модель для роли: SECRETARY_LLM_MODEL_ > SECRETARY_LLM_MODEL > undefined. */ +export function resolveModel(role, env = process.env) { + const key = `SECRETARY_LLM_MODEL_${String(role || '').toUpperCase()}`; + return (ROLES.includes(role) && env[key]) || env.SECRETARY_LLM_MODEL || undefined; +} diff --git a/tools/secretary-models.test.mjs b/tools/secretary-models.test.mjs new file mode 100644 index 0000000..a9fad84 --- /dev/null +++ b/tools/secretary-models.test.mjs @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { resolveModel, ROLES } from './secretary-models.mjs'; + +describe('resolveModel', () => { + it('5 ролей определены', () => { + expect(ROLES).toEqual(['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener']); + }); + it('по умолчанию — общий флэш', () => { + expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x'); + }); + it('per-role перекрывает общий', () => { + expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x', SECRETARY_LLM_MODEL_GARDENER: 'strong-y' })).toBe('strong-y'); + }); + it('неизвестная роль — общий флэш', () => { + expect(resolveModel('xxx', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x'); + }); +}); diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 4c4d1b7..703131e 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -19,6 +19,11 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark ' "assistant":"<что ассистент сделал/выяснил/решил/предложил + ключевые находки>"} —', ' сжатая СУТЬ текущего хода без воды (вежливость/повторы убери; длина по содержанию;', ' факты не выдумывай; инструменты НЕ перечисляй — их подставит система).', + '9. РЕШЕНИЕ ВНУТРИ ВОПРОСА: если в реплике [ЮЗЕР] есть уже заявленная необходимость/директива', + ' («надо…», «давай делать…», «будем…»), даже завёрнутая в просьбу «спроектируй/придумай» или', + ' в вопрос о деталях — вынеси САМО принятое решение в "decisions" (а требование/запрет лично', + ' к тебе — в "will") ОТДЕЛЬНО; ещё не решённую часть (формат/способ/детали) положи в "open".', + ' НЕ прячь уже принятое решение целиком в "open" или "doneNext".', ].join('\n'); const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) => ` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)'); diff --git a/tools/secretary-trunkdiff.mjs b/tools/secretary-trunkdiff.mjs new file mode 100644 index 0000000..9292668 --- /dev/null +++ b/tools/secretary-trunkdiff.mjs @@ -0,0 +1,18 @@ +// tools/secretary-trunkdiff.mjs +// Детерминированный дифф ствола (роль хука: до/после редактора). +const SECS = [['decisions', 'РЕШЕНИЕ'], ['will', 'ВОЛЯ'], ['open', 'ОТКРЫТЫЙ'], ['consequences', 'ПОСЛЕДСТВИЕ'], ['alternatives', 'АЛЬТЕРНАТИВА'], ['doneNext', 'СДЕЛАНО']]; +const nz = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); + +export function diffTrunk(oldP, newP) { + const lines = []; + for (const [sec, label] of SECS) { + const o = new Map(((oldP[sec]) || []).map((e) => [nz(e.text), e])); + for (const e of (newP[sec]) || []) { + const prev = o.get(nz(e.text)); + if (!prev) lines.push(`${label} добавлен: «${e.text}${e.why ? ' — ' + e.why : ''}»`); + else if (!prev.struck && e.struck) lines.push(`${label} зачёркнут (закрыт ствол): «${e.text}»`); + else if (prev.struck && !e.struck) lines.push(`${label} расчёркнут (снова актуален): «${e.text}»`); + } + } + return lines.length ? lines.join('\n') : '(ствол не двигался этот ход)'; +} diff --git a/tools/secretary-trunkdiff.test.mjs b/tools/secretary-trunkdiff.test.mjs new file mode 100644 index 0000000..62e71a6 --- /dev/null +++ b/tools/secretary-trunkdiff.test.mjs @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { diffTrunk } from './secretary-trunkdiff.mjs'; + +describe('diffTrunk', () => { + it('пусто, когда ствол не двигался', () => { + const p = { decisions: [{ text: 'A', struck: false }] }; + expect(diffTrunk(p, p)).toBe('(ствол не двигался этот ход)'); + }); + it('ловит добавленное решение и зачёркнутый открытый', () => { + const oldP = { open: [{ text: 'Q', struck: false }] }; + const newP = { decisions: [{ text: 'A', why: 'почему', struck: false }], open: [{ text: 'Q', struck: true }] }; + const d = diffTrunk(oldP, newP); + expect(d).toContain('РЕШЕНИЕ добавлен: «A — почему»'); + expect(d).toContain('ОТКРЫТЫЙ зачёркнут (закрыт ствол): «Q»'); + }); +});