feat(secretary): рендер пушистого дерева подпроекта C за флагом (renderFluffy + renderDoc + проводка stop-хук) + спека/план
This commit is contained in:
@@ -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/<name>.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('<details><summary>▸ решённое в стволе');
|
||||
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('<details><summary>▸ решённое в стволе (свёрнуто)</summary>', '');
|
||||
L.push(...struckLines);
|
||||
L.push('', '</details>', '');
|
||||
}
|
||||
|
||||
// 🌿 ЖИВЫЕ ВЕТКИ
|
||||
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('', `<details><summary>▸ свёрнуто ${weak.length} слабых (low)</summary>`, '');
|
||||
for (const c of weak) L.push(`- ${c.branch} — зацепка: ${c.trigger || '—'} \`${c.опора || 'догадка'}\`${srcLinks(c.born)}`);
|
||||
L.push('', '</details>');
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
|
||||
// ✅ РЕШЁННЫЕ ВЕТКИ (свёрнуто, с пруфом)
|
||||
if (closedB.length) {
|
||||
L.push(`<details><summary>▸ ✅ решённые ветки (${closedB.length})</summary>`, '');
|
||||
for (const h of closedB) L.push(`- ~~${h.text}~~${h.proof ? ` · пруф: ${h.proof}` : ''}${branchSrc(h)}`);
|
||||
L.push('', '</details>', '');
|
||||
}
|
||||
|
||||
// 🧭 ШАГИ
|
||||
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).
|
||||
@@ -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`.
|
||||
- Все секции документа по макету + глифы состояний + сквозные ссылки + сворачивание (`<details>`).
|
||||
- Проводка: на месте генерации `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` — в `<details>`. Каждый:
|
||||
ветка · зацепка (`trigger`) · `опора` · источник (`born`). Все живут до разбора, не стираются.
|
||||
6. **✅ Решённые ветки** — `hidden` где `status === 'закрыт'` → `<details>` с пруфом и источником.
|
||||
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`).
|
||||
**Сворачивание прячет ВИДНОСТЬ, не КОРЕНЬ:** свёрнутые элементы (зачёркнутый ствол, закрытые ветки, слабые
|
||||
кандидаты) внутри `<details>` НЕСУТ свои ссылки — развернул, корень на месте.
|
||||
|
||||
### 3.4 Граничные случаи
|
||||
- Пустой протокол (нет веток/кандидатов/шагов) → документ с заголовком и пустыми секциями «(пусто)», не падает.
|
||||
- Старые ветки без паспорта → «—» в колонке паспорта (формат A это допускает).
|
||||
- Нет `turns`/`born` у строки → ссылку опустить (не падать).
|
||||
|
||||
## 4. Тестирование (TDD — чистая функция)
|
||||
|
||||
Каждый кейс — детерминированный вход `protocol` → проверка фрагмента markdown:
|
||||
- Порядок секций (Ствол → Живые ветки → Горит → Кандидаты → Решённые → Шаги).
|
||||
- Зачёркнутое решение уходит в свёрнутый блок, живое — в основной.
|
||||
- Ветка `status:сужен` рисует ✂️; `status:закрыт` уходит в «✅ решённые».
|
||||
- Кандидат `релевантность:low` свёрнут (`<details>`); `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.
|
||||
@@ -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('<details><summary>▸ решённое в стволе (свёрнуто)</summary>', '');
|
||||
L.push(...struckLines);
|
||||
L.push('', '</details>', '');
|
||||
}
|
||||
|
||||
// 🌿 ЖИВЫЕ ВЕТКИ
|
||||
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('', `<details><summary>▸ свёрнуто ${weak.length} слабых (low)</summary>`, '');
|
||||
for (const c of weak) L.push(`- ${c.branch} — зацепка: ${c.trigger || '—'} \`${c.опора || 'догадка'}\`${srcLinks(c.born)}`);
|
||||
L.push('', '</details>');
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
|
||||
// ✅ РЕШЁННЫЕ ВЕТКИ (свёрнуто, с пруфом)
|
||||
if (closedB.length) {
|
||||
L.push(`<details><summary>▸ ✅ решённые ветки (${closedB.length})</summary>`, '');
|
||||
for (const h of closedB) L.push(`- ~~${h.text}~~${h.proof ? ` · пруф: ${h.proof}` : ''}${branchSrc(h)}`);
|
||||
L.push('', '</details>', '');
|
||||
}
|
||||
|
||||
// 🧭 ШАГИ
|
||||
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);
|
||||
}
|
||||
@@ -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('<details><summary>▸ решённое в стволе');
|
||||
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('🌳');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
// Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы).
|
||||
|
||||
Reference in New Issue
Block a user