116 lines
6.2 KiB
JavaScript
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);
|
|
}
|