diff --git a/docs/superpowers/plans/2026-06-25-secretary-render-C.md b/docs/superpowers/plans/2026-06-25-secretary-render-C.md new file mode 100644 index 0000000..bfcdd86 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-secretary-render-C.md @@ -0,0 +1,336 @@ +# Секретарь — рендер пушистого дерева (подпроект C) · 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:** Детерминированный рендер `renderFluffy(protocol)` → читаемый `protocol.md` по утверждённому макету (ствол · живые ветки · кандидаты · горит · шаги, со сквозными ссылками и сворачиванием), проведённый в stop-хук за флагом. + +**Architecture:** Чистая функция-рендер (без LLM) в новом модуле + тонкая развилка `renderDoc` (флаг ON → fluffy, OFF → старый `renderProtocol`), которой stop-хук заменяет прямой вызов рендера в двух местах. Следует стилю существующего `renderProtocol` (массив строк → join). + +**Tech Stack:** Node ESM, vitest (`tools/.test.mjs`). Источник дизайна — спека `docs/superpowers/specs/2026-06-25-secretary-render-C-design.md` + макет `docs/secretary/протокол-наставника/прогон/ФИНАЛЬНЫЙ-ВИД-макет.md`. + +> **Коммиты** требуют floor-escape владельца или его терминал. Перед коммитом — `node tools/produce-verify-receipt.mjs` (зелёная сюита). + +--- + +## File Structure + +- `tools/secretary-render-fluffy.mjs` *(создать)* — `renderFluffy(protocol, opts)` (рендер) + `renderDoc(protocol, opts, env)` (развилка по флагу). Зависит от `secretary-layer1` (`turnFileRef`), `secretary-protocol` (`renderProtocol`), `secretary-flag` (`fluffyPipelineOn`). +- `tools/secretary-stop-hook.mjs` *(править)* — заменить два вызова `renderProtocol(finalProto, …)` (строки ~143 и ~165) на `renderDoc(finalProto, …)`; добавить импорт. +- Тесты рядом: `tools/secretary-render-fluffy.test.mjs`. + +--- + +## Task 1: Рендер пушистого дерева (`secretary-render-fluffy.mjs`) + +**Files:** +- Create: `tools/secretary-render-fluffy.mjs` +- Test: `tools/secretary-render-fluffy.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { renderFluffy } from './secretary-render-fluffy.mjs'; + +const base = () => ({ subject: 'дело', status: 'открыто', decisions: [], will: [], open: [], consequences: [], doneNext: [], hidden: [], acceptance: [], tails: [], candidates: [], steps: [] }); + +describe('renderFluffy', () => { + it('пустой протокол не падает, даёт заголовок и секции', () => { + const md = renderFluffy(base()); + expect(md).toContain('# 📋 Протокол: дело'); + expect(md).toContain('## 🌳 Ствол'); + expect(md).toContain('## 🧭 Шаги'); + }); + it('живое решение наверху, зачёркнутое — в свёрнутом блоке, оба со ссылкой', () => { + const p = base(); + p.decisions = [{ text: 'живое', why: 'потому', struck: false, turns: [5] }, { text: 'старое', struck: true, turns: [3] }]; + const md = renderFluffy(p); + expect(md).toMatch(/- живое — потому.*ходы\/turn-5\.log/); + expect(md).toContain('
▸ решённое в стволе'); + expect(md).toMatch(/~~старое~~.*ходы\/turn-3\.log/); + }); + it('живая ветка: глиф состояния + паспорт + источник born→lastTouch', () => { + const p = base(); + p.hidden = [{ id: 'СВ-7', lens: 'Л6', status: 'сужен', text: 'граф хрупкость', опора: 'догадка', тяжесть: 'мелочь', born: 12, lastTouch: 15 }]; + const md = renderFluffy(p); + expect(md).toContain('## 🌿 Живые ветки'); + expect(md).toMatch(/✂️ сужен/); + expect(md).toMatch(/догадка · мелочь/); + expect(md).toMatch(/turn-12\.log.*turn-15\.log/); + }); + it('закрытая ветка уходит в свёрнутые «решённые» с пруфом', () => { + const p = base(); + p.hidden = [{ id: 'СВ-5', lens: 'Л4', status: 'закрыт', text: 'без пруфа', proof: 'код:107', born: 12, lastTouch: 13 }]; + const md = renderFluffy(p); + expect(md).toContain('✅ решённые ветки'); + expect(md).toMatch(/~~без пруфа~~.*пруф: код:107/); + expect(md).not.toMatch(/## 🌿 Живые ветки[\s\S]*без пруфа \|/); // не в живой таблице + }); + it('кандидаты: релевантные наверху, low — свёрнуты, все с источником', () => { + const p = base(); + p.candidates = [ + { branch: 'сильная', опора: 'внутр', релевантность: 'medium', born: 15 }, + { branch: 'слабая', trigger: 'цитата', опора: 'догадка', релевантность: 'low', born: 15 }, + ]; + const md = renderFluffy(p); + expect(md).toMatch(/- сильная `внутр · medium`.*turn-15\.log/); + expect(md).toContain('▸ свёрнуто 1 слабых'); + expect(md).toMatch(/- слабая .*turn-15\.log/); + }); + it('горящие Л8/Л9 показаны с источником', () => { + const p = base(); + p.acceptance = [{ text: 'не проверено', born: 3, done: false }]; + p.tails = [{ text: 'не убрано', born: 3, done: false }]; + const md = renderFluffy(p); + expect(md).toContain('## 🔥 Горит'); + expect(md).toMatch(/Приёмка \(Л8\):.*не проверено.*turn-3\.log/); + expect(md).toMatch(/Хвост \(Л9\):.*не убрано/); + }); + it('шаги со ссылкой на файл хода', () => { + const p = base(); + p.steps = [{ turn: 15, text: 'Ход 15 — я: …' }]; + const md = renderFluffy(p); + expect(md).toMatch(/- \[Ход 15\]\(ходы\/turn-15\.log\) — Ход 15/); + }); + it('ветка без паспорта рисует «—»', () => { + const p = base(); + p.hidden = [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старая', born: 3 }]; + const md = renderFluffy(p); + expect(md).toMatch(/🌿 открыт \| — \|/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-render-fluffy.test.mjs` +Expected: FAIL (`Cannot find module './secretary-render-fluffy.mjs'`). + +- [ ] **Step 3: Write minimal implementation** + +```js +// tools/secretary-render-fluffy.mjs +// Детерминированный рендер пушистого дерева (без LLM): протокол → markdown по макету. +import { turnFileRef } from './secretary-layer1.mjs'; + +// Сквозные ссылки на источник: [ход N](ходы/turn-N.log). +function srcLinks(turns) { + const ts = (Array.isArray(turns) ? turns : (turns != null ? [turns] : [])).filter((t) => t != null); + if (!ts.length) return ''; + return ' — ' + ts.map((t) => `[ход ${t}](${turnFileRef(t)})`).join(' → '); +} +function branchSrc(h) { + const ts = [h.born]; + if (h.lastTouch != null && h.lastTouch !== h.born) ts.push(h.lastTouch); + return srcLinks(ts.filter((t) => t != null)); +} +const GLYPH = { открыт: '🌿', сужен: '✂️', мутировал: '🔁', закрыт: '✅' }; + +export function renderFluffy(protocol, opts = {}) { + const p = protocol || {}; + const L = []; + L.push(`# 📋 Протокол: ${p.subject || '(без темы)'}`); + L.push(`*статус: ${p.status || 'открыто'}${opts.date ? ' · ' + opts.date : ''} · каждая строка тянется до ходы/turn-N.log*`, ''); + + // 🌳 СТВОЛ — живое наверху, зачёркнутое собираем в свёрнутый блок + L.push('## 🌳 Ствол', ''); + const struckLines = []; + const trunkSec = (title, arr, { why = false, done = false } = {}) => { + const list = arr || []; + L.push(`**${title}**`); + const live = list.filter((e) => !e.struck); + if (!live.length) L.push('- (пусто)'); + for (const e of live) { + const w = why && e.why ? ` — ${e.why}` : ''; + const box = done ? `[${e.done ? 'x' : ' '}] ` : ''; + L.push(`- ${box}${e.text}${w}${srcLinks(e.turns)}`); + } + L.push(''); + for (const e of list.filter((x) => x.struck)) { + const w = why && e.why ? ` — ${e.why}` : ''; + struckLines.push(`- ~~${e.text}~~${w}${srcLinks(e.turns)}`); + } + }; + trunkSec('Решения', p.decisions, { why: true }); + trunkSec('Воля владельца', p.will); + trunkSec('Открытые вопросы', p.open); + trunkSec('Последствия / цена', p.consequences); + trunkSec('Сделано / дальше', p.doneNext, { done: true }); + if (struckLines.length) { + L.push('
▸ решённое в стволе (свёрнуто)', ''); + L.push(...struckLines); + L.push('', '
', ''); + } + + // 🌿 ЖИВЫЕ ВЕТКИ + const hidden = p.hidden || []; + const liveB = hidden.filter((h) => h.status !== 'закрыт'); + const closedB = hidden.filter((h) => h.status === 'закрыт'); + L.push('## 🌿 Живые ветки', ''); + if (!liveB.length) L.push('(нет)', ''); + else { + L.push('| Ветка | Линза | Состояние | Паспорт | Источник |', '|---|---|---|---|---|'); + for (const h of liveB) { + const glyph = GLYPH[h.status] || ''; + const pass = h.опора ? `${h.опора}${h.тяжесть ? ' · ' + h.тяжесть : ''}` : '—'; + const src = branchSrc(h).replace(/^ — /, ''); + L.push(`| ${h.text} | ${h.lens || ''} | ${glyph} ${h.status} | ${pass} | ${src} |`); + } + L.push(''); + } + + // 🔥 ГОРИТ + const acc = (p.acceptance || []).filter((e) => !e.done); + const tails = (p.tails || []).filter((e) => !e.done); + if (acc.length || tails.length) { + L.push('## 🔥 Горит', ''); + for (const e of acc) L.push(`- **Приёмка (Л8):** ${e.text}${srcLinks(e.born)}`); + for (const e of tails) L.push(`- **Хвост (Л9):** ${e.text}${srcLinks(e.born)}`); + L.push(''); + } + + // 💡 КАНДИДАТЫ — релевантные наверху, low свёрнуты + const cands = p.candidates || []; + if (cands.length) { + const strong = cands.filter((c) => c.релевантность !== 'low'); + const weak = cands.filter((c) => c.релевантность === 'low'); + L.push('## 💡 Кандидаты (брейншторм — живут до разбора)', ''); + for (const c of strong) L.push(`- ${c.branch} \`${c.опора || 'догадка'} · ${c.релевантность || '?'}\`${srcLinks(c.born)}`); + if (weak.length) { + L.push('', `
▸ свёрнуто ${weak.length} слабых (low)`, ''); + for (const c of weak) L.push(`- ${c.branch} — зацепка: ${c.trigger || '—'} \`${c.опора || 'догадка'}\`${srcLinks(c.born)}`); + L.push('', '
'); + } + L.push(''); + } + + // ✅ РЕШЁННЫЕ ВЕТКИ (свёрнуто, с пруфом) + if (closedB.length) { + L.push(`
▸ ✅ решённые ветки (${closedB.length})`, ''); + for (const h of closedB) L.push(`- ~~${h.text}~~${h.proof ? ` · пруф: ${h.proof}` : ''}${branchSrc(h)}`); + L.push('', '
', ''); + } + + // 🧭 ШАГИ + L.push('## 🧭 Шаги', ''); + const steps = (p.steps || []).slice().sort((a, b) => (a.turn || 0) - (b.turn || 0)); + for (const s of steps) { + const link = s.turn != null ? `[Ход ${s.turn}](${turnFileRef(s.turn)})` : 'Ход'; + L.push(`- ${link} — ${s.text}`); + } + return L.join('\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-render-fluffy.test.mjs` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** (через владельца/эскейп) + +```bash +git add tools/secretary-render-fluffy.mjs tools/secretary-render-fluffy.test.mjs +git commit -m "feat(secretary): рендер пушистого дерева (renderFluffy)" +``` + +--- + +## Task 2: Развилка по флагу `renderDoc` + проводка в stop-хук + +**Files:** +- Modify: `tools/secretary-render-fluffy.mjs` (добавить `renderDoc`) +- Modify: `tools/secretary-stop-hook.mjs` (импорт + 2 замены на строках ~143 и ~165) +- Test: `tools/secretary-render-fluffy.test.mjs` (дописать кейсы `renderDoc`) + +- [ ] **Step 1: Write the failing test** (дописать в `secretary-render-fluffy.test.mjs`) + +```js +import { renderDoc } from './secretary-render-fluffy.mjs'; + +describe('renderDoc — развилка по флагу', () => { + const proto = { subject: 'дело', status: 'открыто', decisions: [{ text: 'A', struck: false }], will: [], open: [], consequences: [], doneNext: [], hidden: [], acceptance: [], tails: [], candidates: [], steps: [] }; + it('флаг ON → пушистый рендер (🌳 Ствол)', () => { + expect(renderDoc(proto, {}, { SECRETARY_FLUFFY: '1' })).toContain('## 🌳 Ствол'); + }); + it('флаг OFF (по умолчанию) → старый renderProtocol (## Решения, без 🌳)', () => { + const md = renderDoc(proto, {}, {}); + expect(md).toContain('## Решения'); + expect(md).not.toContain('🌳'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tools/secretary-render-fluffy.test.mjs` +Expected: FAIL (`renderDoc is not a function`). + +- [ ] **Step 3: Write minimal implementation** (добавить в `secretary-render-fluffy.mjs`) + +```js +import { renderProtocol } from './secretary-protocol.mjs'; +import { fluffyPipelineOn } from './secretary-flag.mjs'; + +/** Развилка вида по флагу: ON → пушистый, OFF → старый. env инъектируем для теста. */ +export function renderDoc(protocol, opts = {}, env = process.env) { + return fluffyPipelineOn(env) ? renderFluffy(protocol, opts) : renderProtocol(protocol, opts); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tools/secretary-render-fluffy.test.mjs` +Expected: PASS (8 + 2 = 10 tests). + +- [ ] **Step 5: Проводка в stop-хук** + +В `tools/secretary-stop-hook.mjs`: + +1. Добавить импорт (рядом с импортом `renderProtocol`, строка ~15): + +```js +import { renderDoc } from './secretary-render-fluffy.mjs'; +``` + +2. Заменить ОБА вызова рендера (строки ~143 и ~165) — было: + +```js +writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); +``` + +стало (оба места): + +```js +writeFileAtomic(join(workDir, 'protocol.md'), renderDoc(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); +``` + +> Импорт `renderProtocol` в stop-хуке оставить (используется? — если после замены он больше нигде не вызывается в файле, убрать из импорта во избежание dead-import; проверить grep'ом `renderProtocol` по файлу перед удалением). + +- [ ] **Step 6: Регресс — полная сюита** + +Run: `npx vitest run --config vitest.config.tools.mjs` +Expected: PASS (старый путь цел; флаг по умолчанию OFF → stop-хук пишет старый вид как раньше). + +- [ ] **Step 7: Commit** + +```bash +git add tools/secretary-render-fluffy.mjs tools/secretary-render-fluffy.test.mjs tools/secretary-stop-hook.mjs +git commit -m "feat(secretary): развилка renderDoc по флагу + проводка в stop-хук" +``` + +--- + +## Self-Review + +**Spec coverage (против `2026-06-25-secretary-render-C-design.md`):** +- §3.1 модуль `renderFluffy` + проводка → Task 1 (рендер) + Task 2 (`renderDoc` + stop-хук). +- §3.2 секции (заголовок/ствол/живые ветки/горит/кандидаты/решённые/шаги) → Task 1 (все, с тестами). +- §3.3 прослеживаемость (`turnFileRef`, born→lastTouch, свёрнутое с корнями) → Task 1 (`srcLinks`/`branchSrc`, тесты на ссылки в свёрнутых). +- §3.4 граничные (пустой прото, без паспорта «—», без turns — ссылку опустить) → Task 1 (тесты: пустой, без паспорта; `srcLinks` пустой при отсутствии turns). +- §4 тесты → каждый кейс под тестом; проводка тестируется через `renderDoc`. +- §5 коэкзистенция (флаг OFF → старый) → Task 2 (`renderDoc` + регресс-прогон). + +**Placeholder scan:** заглушек нет; код полный в каждом шаге. Замечание про возможный dead-import `renderProtocol` помечено как проверка grep'ом (не заглушка — условное действие с критерием). + +**Type consistency:** `renderFluffy(protocol, opts)` — Task 1 и вызов в `renderDoc` (Task 2). `renderDoc(protocol, opts, env)` — Task 2 и stop-хук (вызов без env → дефолт `process.env`). `srcLinks(turns)`/`branchSrc(h)` — определены и используются в Task 1. `turnFileRef` — из `secretary-layer1` (существует). Поля протокола (`hidden.status/опора/тяжесть/born/lastTouch/proof`, `candidates.релевантность/trigger/опора/born`, `steps.turn/text`) — совпадают с форматом A (§4 спеки A). diff --git a/docs/superpowers/specs/2026-06-25-secretary-render-C-design.md b/docs/superpowers/specs/2026-06-25-secretary-render-C-design.md new file mode 100644 index 0000000..249887b --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-secretary-render-C-design.md @@ -0,0 +1,93 @@ +# Спека: секретарь — рендер пушистого дерева (подпроект C) + +**Дата:** 2026-06-25 · **Статус:** дизайн утверждён владельцем, готов к writing-plans +**Источник дизайна:** макет `docs/secretary/протокол-наставника/прогон/ФИНАЛЬНЫЙ-ВИД-макет.md` · +`НАХОДКИ.md` (раздел ДИЗАЙН-РЕШЕНИЯ 25.06: рабочий поток, кандидаты, прослеживаемость) · +формат протокола из спеки A (`2026-06-25-secretary-pipeline-A-design.md` §4). + +## 1. Контекст и цель + +Подпроект A наполняет протокол «пушистым» содержанием (ствол + живые ветки с состояниями + кандидаты + +горящие Л8/Л9). Но читать это нечем: старый `renderProtocol()` (`secretary-protocol.mjs`) рисует +ОБОДРАННЫЙ вид — без кандидатов, без состояний веток (open/сужен/закрыт), без ссылок-на-источник, без +сворачивания. Цель C — **детерминированный рендер `renderFluffy(protocol)`**, который превращает протокол +в читаемый документ-`protocol.md` по утверждённому макету: ствол · живые ветки · кандидаты · горит · шаги, +со сквозными ссылками до файлов-нарезок `ходы/turn-N.log` и сворачиванием решённого/слабого. Без LLM — +чистая функция. Это делает выход A ЧИТАЕМЫМ (ради чего всё и затевалось). + +## 2. Объём + +**Входит в C:** +- Модуль `secretary-render-fluffy.mjs` с чистой функцией `renderFluffy(protocol, opts) → markdown`. +- Все секции документа по макету + глифы состояний + сквозные ссылки + сворачивание (`
`). +- Проводка: на месте генерации `protocol.md` — флаг `fluffyPipelineOn()` ON → `renderFluffy`, OFF → + старый `renderProtocol` (как есть). Тот же флаг, что в A. +- Тесты на детерминированный рендер (TDD-гейт). + +**НЕ входит:** +- **«Режим разбора»** (закрыть/отбросить/оставить на чекпойнте) — РАЗГОВОРНЫЙ: владелец читает документ, + называет решения, контроллер применяет их ГОТОВЫМИ функциями A (`applyTend`/`applyResults`). Нового кода + под разбор НЕТ (YAGNI). +- Изменение формата протокола (он задан в A). +- Гравитация/продвижение кандидата в ствол (это B/будущее). + +## 3. Архитектура + +### 3.1 Модуль +- `secretary-render-fluffy.mjs` *(создать)* — `renderFluffy(protocol, opts={})`. Зависимостей на LLM нет; + опционально мелкий помощник ссылок. Следует стилю существующего `renderProtocol` (массив строк → join). +- Проводка во write-site `protocol.md` *(править)* — точную точку (в `secretary-index.mjs` / + `secretary-stop-hook.mjs`, где композится тетрадь) пин в плане чтением кода; развилка по + `fluffyPipelineOn()`. + +### 3.2 Структура документа (по макету) +Порядок секций: +1. **Заголовок** — `subject`, `status`, диапазон ходов, штамп «каждая строка → ходы/turn-N.log». +2. **🌳 Ствол** — Решения · Воля · Открытые вопросы · Последствия/цена. Живые строки наверху; зачёркнутые + (`struck:true`) — в свёрнутый блок «решённое (свёрнуто)». Каждая строка + ссылка из `turns[]`. +3. **🌿 Живые ветки** — `hidden` где `status !== 'закрыт'` → таблица: ветка · линза · состояние + (🌿open / ✂️сужен / 🔁мутировал) · паспорт (`опора·тяжесть`, «—» если нет) · источник (`born→lastTouch`). +4. **🔥 Горит** — `acceptance` (Л8) + `tails` (Л9) где `!done` → список с источником (`born`). +5. **💡 Кандидаты** — релевантные (`релевантность !== 'low'`) наверху; `low` — в `
`. Каждый: + ветка · зацепка (`trigger`) · `опора` · источник (`born`). Все живут до разбора, не стираются. +6. **✅ Решённые ветки** — `hidden` где `status === 'закрыт'` → `
` с пруфом и источником. +7. **🧭 Шаги** — каждый шаг → `[Ход N](ходы/turn-N.log)` + текст шага. + +### 3.3 Сквозная прослеживаемость (жёсткое правило) +Каждая строка несёт ссылку на источник: `[ход N](ходы/turn-N.log)`. Источник: +- ствол/шаги — из `turns[]` (или `turn` шага); +- ветки — из `born` (и `lastTouch`, если отличается); +- кандидаты/горящие — из `born`. +Помощник `turnLinks(turns)` собирает `[ход N](ходы/turn-N.log)` (через `turnFileRef` из `secretary-layer1`). +**Сворачивание прячет ВИДНОСТЬ, не КОРЕНЬ:** свёрнутые элементы (зачёркнутый ствол, закрытые ветки, слабые +кандидаты) внутри `
` НЕСУТ свои ссылки — развернул, корень на месте. + +### 3.4 Граничные случаи +- Пустой протокол (нет веток/кандидатов/шагов) → документ с заголовком и пустыми секциями «(пусто)», не падает. +- Старые ветки без паспорта → «—» в колонке паспорта (формат A это допускает). +- Нет `turns`/`born` у строки → ссылку опустить (не падать). + +## 4. Тестирование (TDD — чистая функция) + +Каждый кейс — детерминированный вход `protocol` → проверка фрагмента markdown: +- Порядок секций (Ствол → Живые ветки → Горит → Кандидаты → Решённые → Шаги). +- Зачёркнутое решение уходит в свёрнутый блок, живое — в основной. +- Ветка `status:сужен` рисует ✂️; `status:закрыт` уходит в «✅ решённые». +- Кандидат `релевантность:low` свёрнут (`
`); `medium`/`high` показан в основном. +- Сквозная ссылка: решение с `turns:[3]` → строка содержит `ходы/turn-3.log`; ветка `born:12,lastTouch:15` + → `turn-12.log` и `turn-15.log`; свёрнутый закрытый — тоже со ссылкой. +- Горящие Л8/Л9 показаны с источником. +- Пустой протокол не падает, отдаёт заголовок. +- Проводка: при `SECRETARY_FLUFFY=1` write-site зовёт `renderFluffy`, при выкл — `renderProtocol`. + +## 5. Коэкзистенция + +Флаг `fluffyPipelineOn()` (из A). OFF (по умолчанию) → `renderProtocol` (старый вид), ничего не меняется. +ON → `renderFluffy`. Включение флага — в подпроекте B (вместе с воркером). C кладётся рядом, проводка +готова, но дефолт — старый рендер. + +## 6. Связь с подпроектами +- Опирается на **формат протокола A** (§4 спеки A): читает `hidden` (опора/ref/тяжесть/status), `candidates`, + `acceptance`/`tails`, `steps`, ствол. +- **B** включит флаг (тогда живой секретарь начнёт писать пушистый `protocol.md`). +- Разбор (закрыть/отбросить) — разговорный, через готовые `applyTend`/`applyResults` A. diff --git a/tools/secretary-render-fluffy.mjs b/tools/secretary-render-fluffy.mjs new file mode 100644 index 0000000..d3671fc --- /dev/null +++ b/tools/secretary-render-fluffy.mjs @@ -0,0 +1,118 @@ +// tools/secretary-render-fluffy.mjs +// Детерминированный рендер пушистого дерева (без LLM): протокол → markdown по макету. +import { turnFileRef } from './secretary-layer1.mjs'; +import { renderProtocol } from './secretary-protocol.mjs'; +import { fluffyPipelineOn } from './secretary-flag.mjs'; + +// Сквозные ссылки на источник: [ход N](ходы/turn-N.log). +function srcLinks(turns) { + const ts = (Array.isArray(turns) ? turns : (turns != null ? [turns] : [])).filter((t) => t != null); + if (!ts.length) return ''; + return ' — ' + ts.map((t) => `[ход ${t}](${turnFileRef(t)})`).join(' → '); +} +function branchSrc(h) { + const ts = [h.born]; + if (h.lastTouch != null && h.lastTouch !== h.born) ts.push(h.lastTouch); + return srcLinks(ts.filter((t) => t != null)); +} +const GLYPH = { открыт: '🌿', сужен: '✂️', мутировал: '🔁', закрыт: '✅' }; + +export function renderFluffy(protocol, opts = {}) { + const p = protocol || {}; + const L = []; + L.push(`# 📋 Протокол: ${p.subject || '(без темы)'}`); + L.push(`*статус: ${p.status || 'открыто'}${opts.date ? ' · ' + opts.date : ''} · каждая строка тянется до ходы/turn-N.log*`, ''); + + // 🌳 СТВОЛ — живое наверху, зачёркнутое собираем в свёрнутый блок + L.push('## 🌳 Ствол', ''); + const struckLines = []; + const trunkSec = (title, arr, { why = false, done = false } = {}) => { + const list = arr || []; + L.push(`**${title}**`); + const live = list.filter((e) => !e.struck); + if (!live.length) L.push('- (пусто)'); + for (const e of live) { + const w = why && e.why ? ` — ${e.why}` : ''; + const box = done ? `[${e.done ? 'x' : ' '}] ` : ''; + L.push(`- ${box}${e.text}${w}${srcLinks(e.turns)}`); + } + L.push(''); + for (const e of list.filter((x) => x.struck)) { + const w = why && e.why ? ` — ${e.why}` : ''; + struckLines.push(`- ~~${e.text}~~${w}${srcLinks(e.turns)}`); + } + }; + trunkSec('Решения', p.decisions, { why: true }); + trunkSec('Воля владельца', p.will); + trunkSec('Открытые вопросы', p.open); + trunkSec('Последствия / цена', p.consequences); + trunkSec('Сделано / дальше', p.doneNext, { done: true }); + if (struckLines.length) { + L.push('
▸ решённое в стволе (свёрнуто)', ''); + L.push(...struckLines); + L.push('', '
', ''); + } + + // 🌿 ЖИВЫЕ ВЕТКИ + const hidden = p.hidden || []; + const liveB = hidden.filter((h) => h.status !== 'закрыт'); + const closedB = hidden.filter((h) => h.status === 'закрыт'); + L.push('## 🌿 Живые ветки', ''); + if (!liveB.length) L.push('(нет)', ''); + else { + L.push('| Ветка | Линза | Состояние | Паспорт | Источник |', '|---|---|---|---|---|'); + for (const h of liveB) { + const glyph = GLYPH[h.status] || ''; + const pass = h.опора ? `${h.опора}${h.тяжесть ? ' · ' + h.тяжесть : ''}` : '—'; + const src = branchSrc(h).replace(/^ — /, ''); + L.push(`| ${h.text} | ${h.lens || ''} | ${glyph} ${h.status} | ${pass} | ${src} |`); + } + L.push(''); + } + + // 🔥 ГОРИТ + const acc = (p.acceptance || []).filter((e) => !e.done); + const tails = (p.tails || []).filter((e) => !e.done); + if (acc.length || tails.length) { + L.push('## 🔥 Горит', ''); + for (const e of acc) L.push(`- **Приёмка (Л8):** ${e.text}${srcLinks(e.born)}`); + for (const e of tails) L.push(`- **Хвост (Л9):** ${e.text}${srcLinks(e.born)}`); + L.push(''); + } + + // 💡 КАНДИДАТЫ — релевантные наверху, low свёрнуты + const cands = p.candidates || []; + if (cands.length) { + const strong = cands.filter((c) => c.релевантность !== 'low'); + const weak = cands.filter((c) => c.релевантность === 'low'); + L.push('## 💡 Кандидаты (брейншторм — живут до разбора)', ''); + for (const c of strong) L.push(`- ${c.branch} \`${c.опора || 'догадка'} · ${c.релевантность || '?'}\`${srcLinks(c.born)}`); + if (weak.length) { + L.push('', `
▸ свёрнуто ${weak.length} слабых (low)`, ''); + for (const c of weak) L.push(`- ${c.branch} — зацепка: ${c.trigger || '—'} \`${c.опора || 'догадка'}\`${srcLinks(c.born)}`); + L.push('', '
'); + } + L.push(''); + } + + // ✅ РЕШЁННЫЕ ВЕТКИ (свёрнуто, с пруфом) + if (closedB.length) { + L.push(`
▸ ✅ решённые ветки (${closedB.length})`, ''); + for (const h of closedB) L.push(`- ~~${h.text}~~${h.proof ? ` · пруф: ${h.proof}` : ''}${branchSrc(h)}`); + L.push('', '
', ''); + } + + // 🧭 ШАГИ + L.push('## 🧭 Шаги', ''); + const steps = (p.steps || []).slice().sort((a, b) => (a.turn || 0) - (b.turn || 0)); + for (const s of steps) { + const link = s.turn != null ? `[Ход ${s.turn}](${turnFileRef(s.turn)})` : 'Ход'; + L.push(`- ${link} — ${s.text}`); + } + return L.join('\n'); +} + +/** Развилка вида по флагу: ON → пушистый, OFF → старый renderProtocol. env инъектируем для теста. */ +export function renderDoc(protocol, opts = {}, env = process.env) { + return fluffyPipelineOn(env) ? renderFluffy(protocol, opts) : renderProtocol(protocol, opts); +} diff --git a/tools/secretary-render-fluffy.test.mjs b/tools/secretary-render-fluffy.test.mjs new file mode 100644 index 0000000..a44adf4 --- /dev/null +++ b/tools/secretary-render-fluffy.test.mjs @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { renderFluffy, renderDoc } from './secretary-render-fluffy.mjs'; + +const base = () => ({ subject: 'дело', status: 'открыто', decisions: [], will: [], open: [], consequences: [], doneNext: [], hidden: [], acceptance: [], tails: [], candidates: [], steps: [] }); + +describe('renderFluffy', () => { + it('пустой протокол не падает, даёт заголовок и секции', () => { + const md = renderFluffy(base()); + expect(md).toContain('# 📋 Протокол: дело'); + expect(md).toContain('## 🌳 Ствол'); + expect(md).toContain('## 🧭 Шаги'); + }); + it('живое решение наверху, зачёркнутое — в свёрнутом блоке, оба со ссылкой', () => { + const p = base(); + p.decisions = [{ text: 'живое', why: 'потому', struck: false, turns: [5] }, { text: 'старое', struck: true, turns: [3] }]; + const md = renderFluffy(p); + expect(md).toMatch(/- живое — потому.*ходы\/turn-5\.log/); + expect(md).toContain('
▸ решённое в стволе'); + expect(md).toMatch(/~~старое~~.*ходы\/turn-3\.log/); + }); + it('живая ветка: глиф состояния + паспорт + источник born→lastTouch', () => { + const p = base(); + p.hidden = [{ id: 'СВ-7', lens: 'Л6', status: 'сужен', text: 'граф хрупкость', опора: 'догадка', тяжесть: 'мелочь', born: 12, lastTouch: 15 }]; + const md = renderFluffy(p); + expect(md).toContain('## 🌿 Живые ветки'); + expect(md).toMatch(/✂️ сужен/); + expect(md).toMatch(/догадка · мелочь/); + expect(md).toMatch(/turn-12\.log.*turn-15\.log/); + }); + it('закрытая ветка уходит в свёрнутые «решённые» с пруфом', () => { + const p = base(); + p.hidden = [{ id: 'СВ-5', lens: 'Л4', status: 'закрыт', text: 'без пруфа', proof: 'код:107', born: 12, lastTouch: 13 }]; + const md = renderFluffy(p); + expect(md).toContain('✅ решённые ветки'); + expect(md).toMatch(/~~без пруфа~~.*пруф: код:107/); + }); + it('кандидаты: релевантные наверху, low — свёрнуты, все с источником', () => { + const p = base(); + p.candidates = [ + { branch: 'сильная', опора: 'внутр', релевантность: 'medium', born: 15 }, + { branch: 'слабая', trigger: 'цитата', опора: 'догадка', релевантность: 'low', born: 15 }, + ]; + const md = renderFluffy(p); + expect(md).toMatch(/- сильная `внутр · medium`.*turn-15\.log/); + expect(md).toContain('▸ свёрнуто 1 слабых'); + expect(md).toMatch(/- слабая .*turn-15\.log/); + }); + it('горящие Л8/Л9 показаны с источником', () => { + const p = base(); + p.acceptance = [{ text: 'не проверено', born: 3, done: false }]; + p.tails = [{ text: 'не убрано', born: 3, done: false }]; + const md = renderFluffy(p); + expect(md).toContain('## 🔥 Горит'); + expect(md).toMatch(/Приёмка \(Л8\):.*не проверено.*turn-3\.log/); + expect(md).toMatch(/Хвост \(Л9\):.*не убрано/); + }); + it('шаги со ссылкой на файл хода', () => { + const p = base(); + p.steps = [{ turn: 15, text: 'Ход 15 — я: …' }]; + const md = renderFluffy(p); + expect(md).toMatch(/- \[Ход 15\]\(ходы\/turn-15\.log\) — Ход 15/); + }); + it('ветка без паспорта рисует «—»', () => { + const p = base(); + p.hidden = [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старая', born: 3 }]; + const md = renderFluffy(p); + expect(md).toMatch(/🌿 открыт \| — \|/); + }); +}); + +describe('renderDoc — развилка по флагу', () => { + const proto = { subject: 'дело', status: 'открыто', decisions: [{ text: 'A', struck: false }], will: [], open: [], consequences: [], doneNext: [], hidden: [], acceptance: [], tails: [], candidates: [], steps: [] }; + it('флаг ON → пушистый рендер (🌳 Ствол)', () => { + expect(renderDoc(proto, {}, { SECRETARY_FLUFFY: '1' })).toContain('## 🌳 Ствол'); + }); + it('флаг OFF (по умолчанию) → старый renderProtocol (## Решения, без 🌳)', () => { + const md = renderDoc(proto, {}, {}); + expect(md).toContain('## Решения'); + expect(md).not.toContain('🌳'); + }); +}); diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index 515a2ce..30603ce 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -12,7 +12,8 @@ import { secretaryModeFileName } from './secretary-flag.mjs'; import { upsertSessionPointer } from './secretary-sessions.mjs'; import { writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles, spanInterruptNote } from './secretary-layer1.mjs'; import { formatReconcileLogLine } from './secretary-reconcile.mjs'; -import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; +import { renderDoc } from './secretary-render-fluffy.mjs'; import { upsertIndexEntry } from './secretary-index.mjs'; import { sanitize } from './observer-pii-filter.mjs'; import { callAnthropicAPI } from './router-classifier.mjs'; @@ -140,7 +141,7 @@ async function main() { const stamp = new Date().toISOString().slice(0, 16).replace('T', ' '); mkdirSync(workDir, { recursive: true }); writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2)); - writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); + writeFileAtomic(join(workDir, 'protocol.md'), renderDoc(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); const idxFile = join(secdir, 'содержание.md'); let idxMd = ''; @@ -162,7 +163,7 @@ async function main() { for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8'); finalProto.steps = steps; writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2)); - writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); + writeFileAtomic(join(workDir, 'protocol.md'), renderDoc(finalProto, { work, date: stamp, turn, realPromptTurns: bounds })); writeFlag(session, { mode: 'off' }); } else { // Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы).