Files
brain/tools/secretary-render-fluffy.mjs
T

116 lines
6.2 KiB
JavaScript

// 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';
import { shortTitle } from './secretary-harvest.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 = { открыт: '🌿', сужен: '✂️', мутировал: '🔁', закрыт: '✅' };
// Важность ДЛЯ ВЛАДЕЛЬЦА: 🔴 high → 🟡 medium → ⚪ low. Терминальные статусы — в архив, не в пульт.
const IMP = { high: '🔴', medium: '🟡', low: '⚪' };
const IMPW = { high: 0, medium: 1, low: 2 };
const byImp = (a, b) => (IMPW[a.важность] ?? 1) - (IMPW[b.важность] ?? 1);
const TERMINAL = new Set(['закрыт', 'отпал', 'мутировал', 'сплавлен', 'повышен']);
const isLiveBranch = (h) => !TERMINAL.has(h.status);
const isLiveCand = (c) => !TERMINAL.has(c.status);
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*`, '');
// 🧠 БАЗА ЗНАНИЙ — добытые факты с источником в конце (между темой и стволом)
const know = (p.knowledge || []).filter((e) => e && e.text);
if (know.length) {
L.push('## 🧠 База знаний', '');
for (const e of know) L.push(`- ${e.text}${e.ref ? `${e.ref}` : ''}`);
L.push('');
}
// 🌳 СТВОЛ — живое наверху, зачёркнутое собираем в свёрнутый блок
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>', '');
}
// 🔥 ГОРИТ — приёмка Л8/хвосты Л9 (на виду всегда, даже спящие)
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('');
}
// 🌳 ТЕМЫ — живые ветки + кандидаты, ёлочка по теме, важное для владельца сверху (🔴→🟡→⚪).
// Мёртвое (закрыт/отпал/мутировал/сплавлен/повышен) тут НЕ показываем — оно живёт в архиве.
const liveB = (p.hidden || []).filter(isLiveBranch);
const liveC = (p.candidates || []).filter(isLiveCand);
const themeOf = (x) => x.тема || '(без темы)';
const all = [...liveB, ...liveC];
const weight = (arr) => arr.reduce((s, x) => s + (3 - (IMPW[x.важность] ?? 1)), 0);
const themes = [...new Set(all.map(themeOf))]
.sort((a, b) => weight(all.filter((x) => themeOf(x) === b)) - weight(all.filter((x) => themeOf(x) === a)));
L.push('## 🌳 Темы (🔴 важно для владельца → 🟡 → ⚪ мелочь)', '');
if (!themes.length) L.push('(нет живых веток и кандидатов)', '');
for (const th of themes) {
const bs = liveB.filter((x) => themeOf(x) === th).sort(byImp);
const cs = liveC.filter((x) => themeOf(x) === th).sort(byImp);
L.push(`### 📂 ${th} · веток ${bs.length}, идей ${cs.length}`);
for (const h of bs) L.push(`- ${IMP[h.важность] || '🟡'} 🌿 ${h.id} · ${h.title || shortTitle(h)}${branchSrc(h)}`);
for (const c of cs) L.push(`- ${IMP[c.важность] || '⚪'} ${c.gap ? '⚠️ ПРОБЕЛ-ПЛАСТ' : '💡'} ${c.id} · ${c.title || shortTitle(c)}${srcLinks(c.born)}`);
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);
}