Zion Boggan zionboggan.com ↗

feat: branded, legible prompt-tree graph via --graph, with large-project summary mode

Emit a TreeTrace-branded Mermaid flowchart from treetrace --graph: dark Bark
canvas, Sapwood text, JetBrains Mono, and an opaque edge-label backing so edge
text stays legible over the spine line. The steered teal spine runs GOAL (root,
stadium) to RESULT (stadium tip); abandoned explorations are Branch-Dim dashed
detours, amber is reserved strictly for failure and correction-chain flags.
Labels truncate on a word boundary so they never end mid-word.

Add a summary mode for large projects so the whole project still reads at a
glance: once a tree exceeds 25 live nodes it renders spine-only, collapsing each
abandoned branch into a single dim N abandoned steps stub and folding routine
intermediate runs into N steps count stubs while keeping the goal, strategic
turns, failures, and result. Small trees render in full. --full and --summary
force a mode. The Mermaid renders natively on GitHub with zero runtime deps.

Adds tests for the brand theme, word-boundary truncation, summary collapse, the
abandoned stub, and the auto/forced thresholds.
3a1d40e   Zion Boggan committed on Jun 16, 2026 (6 days ago)
src/cli.js +51 -0
@@ -7,6 +7,7 @@ import { classifyPrompts } from './extract.js';
import { buildTree } from './tree.js';
import { scanText, resolveFindings, applyDecisions, shadowScan } from './redact.js';
import { renderMarkdown } from './render-md.js';
+import { renderMermaid, isSummaryByDefault } from './render-mermaid.js';
import { renderJson } from './render-json.js';
import { renderHandoff } from './handoff.js';
import { renderReportMarkdown, renderTerminalSummary } from './report.js';
@@ -38,6 +39,8 @@ Usage:
treetrace --lessons write and print lessons Markdown
treetrace --evals write and print eval JSONL
treetrace --memory write and print compact agent memory
+ treetrace --graph write a branded Mermaid prompt-tree graph (PROMPT_TREE_GRAPH.md)
+ large projects auto-summarize; --full / --summary force a mode
treetrace --security print a security-focused report for this session
treetrace mcp start a read-only MCP server over stdio
@@ -106,6 +109,24 @@ export async function main(argv) {
return;
}
+ if (opts.graph) {
+ const graphOpts = { ...renderOpts, summary: opts.graphSummary, full: opts.graphFull };
+ const mermaid = renderMermaid(tree, graphOpts);
+ const summarized =
+ graphOpts.summary === true ||
+ (graphOpts.full !== true && isSummaryByDefault(tree));
+ const graphDoc = wrapMermaidDoc(mermaid, projectName, summarized);
+ const graphPath = resolve(projectDir, opts.out || 'PROMPT_TREE_GRAPH.md');
+ assertClean(graphDoc, decisions, 'PROMPT_TREE_GRAPH.md');
+ mkdirSync(projectDir, { recursive: true });
+ mkdirSync(ttDir, { recursive: true });
+ writeFileSync(graphPath, graphDoc);
+ writeFileSync(decisionsPath, JSON.stringify(decisions, null, 2));
+ process.stdout.write(graphDoc);
+ log(c.green(`✓ prompt-tree graph for ${projectName} -> ${relativeish(graphPath, projectDir)}`));
+ return;
+ }
+
const md = renderMarkdown(tree, renderOpts);
const json = renderJson(tree, renderOpts);
const jsonText = JSON.stringify(json, null, 2);
@@ -384,6 +405,30 @@ export function assertClean(rendered, decisions, label) {
}
}
+export function wrapMermaidDoc(mermaid, projectName, summarized = false) {
+ const intro = summarized
+ ? [
+ 'Goal at the top, the steered progression of prompts as the bright spine, with',
+ 'abandoned explorations and routine intermediate steps folded into dim count stubs',
+ 'so the whole project still reads at a glance. Renders on GitHub and any Mermaid',
+ 'viewer. Pass --full for every node.',
+ ]
+ : [
+ 'Goal at the top, the winning progression of prompts as the bright spine, abandoned',
+ 'explorations as dimmed dotted side detours. Renders on GitHub and any Mermaid viewer.',
+ ];
+ return [
+ `# Prompt Tree Graph: ${projectName}`,
+ '',
+ ...intro,
+ '',
+ '```mermaid',
+ mermaid,
+ '```',
+ '',
+ ].join('\n');
+}
+
function summaryLine(stats, projectName) {
const bits = [
c.bold(plural(stats.promptCount, 'prompt')),
@@ -458,6 +503,9 @@ export function parseArgs(argv) {
lessons: false,
evals: false,
memory: false,
+ graph: false,
+ graphSummary: false,
+ graphFull: false,
security: false,
mcp: false,
titlesOnly: false,
@@ -497,6 +545,9 @@ export function parseArgs(argv) {
case '--lessons': opts.lessons = true; break;
case '--evals': opts.evals = true; break;
case '--memory': opts.memory = true; break;
+ case '--graph': case '--mermaid': opts.graph = true; break;
+ case '--summary': opts.graph = true; opts.graphSummary = true; break;
+ case '--full': opts.graph = true; opts.graphFull = true; break;
case '--security': opts.security = true; break;
case 'mcp': case '--mcp': opts.mcp = true; break;
case '--titles-only': opts.titlesOnly = true; break;
src/render-mermaid.js +469 -0
@@ -0,0 +1,469 @@
+import { analyzeTree, isStrategicDirection, latestByTime } from './analyze.js';
+
+// Relationship shown on the edge into each node, keyed by the child's kind. Mirrors
+// the relationships emitted in render-json.js so the graph and the JSON agree.
+const RELATIONSHIP_BY_KIND = {
+ direction: 'refines',
+ correction: 'corrects',
+ 'scope-change': 'expands',
+ checkpoint: 'checkpoint',
+ question: 'asks',
+ root: 'refines',
+};
+
+const MAX_LABEL = 60;
+
+// Above this many live (non-abandoned) nodes a full graph stops being legible, so the
+// renderer automatically switches to summary mode: spine-only, with abandoned branches
+// folded into a single dim stub and routine intermediate runs folded into counts.
+const SUMMARY_THRESHOLD = 25;
+
+// Brand init theme: dark Bark canvas, Sapwood text, Canopy edge lines, JetBrains Mono.
+// edgeLabelBackground is the opaque Bark canvas color so an edge label occludes the spine
+// line behind its glyphs (legible text) while staying visually invisible (no grey box).
+// Text and nodes always read in front of the lines.
+const INIT =
+ "%%{init: {'theme':'base','themeVariables':{" +
+ "'background':'#0B1210'," +
+ "'primaryColor':'#121A17'," +
+ "'primaryTextColor':'#EDF7F2'," +
+ "'primaryBorderColor':'#0CA08A'," +
+ "'lineColor':'#5BF0B8'," +
+ "'tertiaryColor':'#0B1210'," +
+ "'edgeLabelBackground':'#0B1210'," +
+ "'fontFamily':'JetBrains Mono, ui-monospace, monospace'," +
+ "'fontSize':'13px'" +
+ "}}}%%";
+
+// classDef block, brand-tuned. Loam surfaces, Sapwood text, teal strokes.
+// spine = the steered teal progression (Rootstock #0CA08A stroke)
+// goal = Rootstock root of the trace; result = Canopy #5BF0B8 steered tip
+// failure = amber #F0B86A, STRICTLY for failure / correction-chain flags
+// abandoned = Branch-Dim #34493F dashed, never recolored to an accent
+const CLASS_DEFS = [
+ 'classDef node fill:#121A17,stroke:#243430,color:#EDF7F2;',
+ 'classDef spine fill:#121A17,stroke:#0CA08A,color:#EDF7F2;',
+ 'classDef goal fill:#0E1714,stroke:#0CA08A,stroke-width:2px,color:#EDF7F2;',
+ 'classDef result fill:#0F221C,stroke:#5BF0B8,stroke-width:2.5px,color:#5BF0B8;',
+ 'classDef failure fill:#1A140C,stroke:#F0B86A,color:#F0B86A;',
+ 'classDef correction fill:#121A17,stroke:#0CA08A,color:#EDF7F2;',
+ 'classDef abandoned fill:#0E1411,stroke:#34493F,color:#8FA8A0,stroke-dasharray:3 3;',
+];
+
+const KIND_CLASS = {
+ root: 'spine',
+ direction: 'spine',
+ correction: 'correction',
+ 'scope-change': 'spine',
+ question: 'spine',
+ checkpoint: 'spine',
+};
+
+// Render a tree as a TreeTrace-branded Mermaid `flowchart TD`. The spine is the
+// non-abandoned path that reached the result (the "good prompts"); abandoned explorations
+// hang off it as dimmed dotted detours. Large projects collapse to a spine-only summary so
+// the whole project still reads at a glance. Zero dependencies: pure string assembly.
+//
+// opts.summary forces summary mode; opts.full forces the full graph. Otherwise the renderer
+// switches to summary automatically once the tree exceeds SUMMARY_THRESHOLD live nodes.
+export function renderMermaid(tree, opts = {}) {
+ const { nodes } = tree;
+ const analysis = analyzeTree(tree);
+
+ const root = nodes.find((n) => n.kind === 'root') || nodes[0] || null;
+ const result = pickResult(nodes);
+
+ const liveCount = nodes.filter((n) => n.status !== 'abandoned').length;
+ const summary = opts.summary === true
+ ? true
+ : opts.full === true
+ ? false
+ : liveCount > SUMMARY_THRESHOLD;
+
+ return summary
+ ? renderSummary(tree, analysis, root, result)
+ : renderFull(tree, analysis, root, result);
+}
+
+// Whether a tree of this size renders as a summary under the automatic threshold. Exposed
+// so the CLI and tests can describe the behavior without re-deriving the rule.
+export function isSummaryByDefault(tree) {
+ const live = (tree.nodes || []).filter((n) => n.status !== 'abandoned').length;
+ return live > SUMMARY_THRESHOLD;
+}
+
+export const SUMMARY_NODE_THRESHOLD = SUMMARY_THRESHOLD;
+
+// -- full graph ----------------------------------------------------------------
+function renderFull(tree, analysis, root, result) {
+ const { nodes } = tree;
+ const lines = [];
+ lines.push(INIT);
+ lines.push('flowchart TD');
+ for (const def of CLASS_DEFS) lines.push(` ${def}`);
+ lines.push('');
+
+ // Node declarations.
+ for (const n of nodes) {
+ const id = nodeId(n);
+ const label = mermaidLabel(nodeText(n, root, result));
+ lines.push(` ${id}${shapeOpen(n, root, result)}"${label}"${shapeClose(n, root, result)}`);
+ }
+ lines.push('');
+
+ // Tree edges (parent -> child), labelled by relationship. Abandoned edges are dotted.
+ for (const n of nodes) {
+ if (!n.parent) continue;
+ const rel = RELATIONSHIP_BY_KIND[n.kind] || 'refines';
+ const abandoned = n.status === 'abandoned';
+ const arrow = abandoned ? '-.->' : '-->';
+ lines.push(` ${nodeId(n.parent)} ${arrow}|${rel}| ${nodeId(n)}`);
+ }
+ lines.push('');
+
+ // Correction-chain overlay: failure -.-> correction, amber, labelled with the failure
+ // type + confidence so the diagram carries the real signal, not just topology.
+ const chains = analysis.correctionChains || [];
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const chainEdges = [];
+ const failureIds = new Set();
+ for (const chain of chains) {
+ const from = byId.get(chain.failureNodeId);
+ const to = byId.get(chain.correctionNodeId);
+ if (!from || !to || from === to) continue;
+ failureIds.add(from.id);
+ const conf = confLabel(from);
+ const lbl = conf ? `${chain.failureType} ${conf}` : 'fixes';
+ chainEdges.push(` ${nodeId(from)} -.->|"${mermaidLabel(lbl)}"| ${nodeId(to)}`);
+ }
+ if (chainEdges.length) {
+ lines.push(' %% correction chains (failure -> correction), amber flag');
+ lines.push(...chainEdges);
+ lines.push('');
+ }
+
+ // Class assignments. A node can carry several (kind + spine + goal/result + failure).
+ const assigns = [];
+ for (const n of nodes) {
+ const classes = classesFor(n, root, result, failureIds);
+ if (classes.length) assigns.push(` class ${nodeId(n)} ${classes.join(',')};`);
+ }
+ lines.push(...assigns);
+ lines.push('');
+
+ // Amber failure-flag chain edges first (they follow the tree edges in declaration
+ // order), then the brighter Canopy spine.
+ const treeEdgeCount = nodes.filter((n) => n.parent).length;
+ if (chainEdges.length) {
+ const chainIdx = chainEdges.map((_, i) => treeEdgeCount + i);
+ lines.push(` linkStyle ${chainIdx.join(',')} stroke:#F0B86A,stroke-width:1.5px;`);
+ }
+ const spineLinks = spineLinkIndexes(nodes);
+ if (spineLinks.length) {
+ lines.push(` linkStyle ${spineLinks.join(',')} stroke:#5BF0B8,stroke-width:2.5px;`);
+ }
+
+ return trimTrailing(lines).join('\n');
+}
+
+// -- summary graph -------------------------------------------------------------
+// "Track the WHOLE project" even when it is large: render the steered spine only, collapse
+// each abandoned branch into a single dim "N abandoned steps" stub, keep every failure-
+// flagged node, and fold routine intermediate steps (plain direction/checkpoint/question
+// runs with no signal) into a single "N steps" stub so the spine stays readable.
+function renderSummary(tree, analysis, root, result) {
+ const { nodes } = tree;
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const childrenOf = new Map();
+ for (const n of nodes) {
+ if (!n.parent) continue;
+ const arr = childrenOf.get(n.parent.id) || [];
+ arr.push(n);
+ childrenOf.set(n.parent.id, arr);
+ }
+
+ // Failure-flagged nodes (kept individually so the signal survives collapse).
+ const chains = analysis.correctionChains || [];
+ const failureIds = new Set();
+ const chainTarget = new Map(); // failureId -> { to, type }
+ for (const chain of chains) {
+ const from = byId.get(chain.failureNodeId);
+ const to = byId.get(chain.correctionNodeId);
+ if (!from || !to || from === to) continue;
+ failureIds.add(from.id);
+ chainTarget.set(from.id, { to, type: chain.failureType });
+ }
+
+ // A spine node is kept verbatim if it is the goal, the result, a strategic turn
+ // (direction/scope-change/correction), or a failure-flagged node. Everything else on the
+ // live path is "routine" and gets folded into a count.
+ const isKept = (n) =>
+ n === root ||
+ (result && n === result) ||
+ failureIds.has(n.id) ||
+ n.kind === 'direction' ||
+ n.kind === 'scope-change' ||
+ n.kind === 'correction';
+
+ const lines = [];
+ lines.push(INIT);
+ lines.push('flowchart TD');
+ for (const def of CLASS_DEFS) lines.push(` ${def}`);
+ lines.push('');
+
+ const nodeDecls = [];
+ const edges = []; // { fromId, toId, rel, dotted, spine }
+ const assigns = [];
+ const stubAssigns = [];
+ let stubSeq = 0;
+
+ const liveChildren = (n) =>
+ (childrenOf.get(n.id) || []).filter((c) => c.status !== 'abandoned');
+ const abandonedChildren = (n) =>
+ (childrenOf.get(n.id) || []).filter((c) => c.status === 'abandoned');
+
+ const emittedNode = new Set();
+ const emitNode = (n) => {
+ if (emittedNode.has(n.id)) return;
+ emittedNode.add(n.id);
+ const label = mermaidLabel(nodeText(n, root, result));
+ nodeDecls.push(` ${nodeId(n)}${shapeOpen(n, root, result)}"${label}"${shapeClose(n, root, result)}`);
+ const classes = classesFor(n, root, result, failureIds);
+ if (classes.length) assigns.push(` class ${nodeId(n)} ${classes.join(',')};`);
+ };
+
+ // Count the whole abandoned subtree hanging off a live node, then emit one dim stub.
+ const sizeOfSubtree = (n) => {
+ let count = 1;
+ for (const c of childrenOf.get(n.id) || []) count += sizeOfSubtree(c);
+ return count;
+ };
+ const emitAbandonedStub = (liveParent) => {
+ const roots = abandonedChildren(liveParent);
+ if (!roots.length) return;
+ let total = 0;
+ for (const r of roots) total += sizeOfSubtree(r);
+ const stubId = `A${stubSeq++}`;
+ const label = `${total} abandoned ${total === 1 ? 'step' : 'steps'}`;
+ nodeDecls.push(` ${stubId}["${label}"]`);
+ stubAssigns.push(` class ${stubId} abandoned;`);
+ edges.push({ fromId: nodeId(liveParent), toId: stubId, rel: 'dropped', dotted: true, spine: false });
+ };
+
+ emitNode(root);
+ emitAbandonedStub(root);
+
+ // Descend the live spine from an anchor. Fold any linear run of routine live nodes into
+ // a single "N steps" count stub, then continue from the next kept anchor.
+ const visitFrom = (anchor) => {
+ const kids = liveChildren(anchor);
+ if (!kids.length) return;
+
+ // Partition direct live children into routine vs kept.
+ const keptKids = kids.filter((c) => isKept(c));
+ const routineKids = kids.filter((c) => !isKept(c));
+
+ // Fold each routine child (and its linear routine continuation) into a count stub.
+ for (const start of routineKids) {
+ const routine = [];
+ let cur = start;
+ let keptNext = null;
+ while (cur && !isKept(cur)) {
+ routine.push(cur);
+ emitAbandonedStub(cur);
+ const ck = liveChildren(cur);
+ const nextKept = ck.find((c) => isKept(c));
+ if (nextKept) { keptNext = nextKept; break; }
+ cur = ck.length === 1 ? ck[0] : null;
+ if (!cur && ck.length > 1) {
+ // Multiple routine forks: stop folding here, draw them from this stub.
+ break;
+ }
+ }
+ const count = routine.length;
+ const stubId = `S${stubSeq++}`;
+ const label = `${count} ${count === 1 ? 'step' : 'steps'}`;
+ nodeDecls.push(` ${stubId}["${label}"]`);
+ stubAssigns.push(` class ${stubId} node;`);
+ edges.push({ fromId: nodeId(anchor), toId: stubId, rel: 'then', dotted: false, spine: true });
+ if (keptNext) {
+ emitNode(keptNext);
+ emitAbandonedStub(keptNext);
+ edges.push({
+ fromId: stubId,
+ toId: nodeId(keptNext),
+ rel: RELATIONSHIP_BY_KIND[keptNext.kind] || 'refines',
+ dotted: false,
+ spine: true,
+ });
+ visitFrom(keptNext);
+ }
+ }
+
+ // Draw kept children directly off the anchor and recurse.
+ for (const child of keptKids) {
+ emitNode(child);
+ emitAbandonedStub(child);
+ edges.push({
+ fromId: nodeId(anchor),
+ toId: nodeId(child),
+ rel: RELATIONSHIP_BY_KIND[child.kind] || 'refines',
+ dotted: false,
+ spine: true,
+ });
+ visitFrom(child);
+ }
+ };
+ visitFrom(root);
+
+ // Correction-chain overlay among kept failure nodes (amber).
+ const chainEdges = [];
+ for (const fid of failureIds) {
+ const from = byId.get(fid);
+ const t = chainTarget.get(fid);
+ if (!from || !t || !emittedNode.has(from.id) || !emittedNode.has(t.to.id)) continue;
+ const conf = confLabel(from);
+ const lbl = conf ? `${t.type} ${conf}` : 'fixes';
+ chainEdges.push({ fromId: nodeId(from), toId: nodeId(t.to), label: mermaidLabel(lbl) });
+ }
+
+ lines.push(...nodeDecls);
+ lines.push('');
+
+ for (const e of edges) {
+ const arrow = e.dotted ? '-.->' : '-->';
+ lines.push(` ${e.fromId} ${arrow}|${e.rel}| ${e.toId}`);
+ }
+ for (const e of chainEdges) {
+ lines.push(` ${e.fromId} -.->|"${e.label}"| ${e.toId}`);
+ }
+ lines.push('');
+
+ lines.push(...assigns);
+ lines.push(...stubAssigns);
+ lines.push('');
+
+ // Style amber chain edges (declared after the tree edges) and the Canopy spine.
+ if (chainEdges.length) {
+ const chainIdx = chainEdges.map((_, i) => edges.length + i);
+ lines.push(` linkStyle ${chainIdx.join(',')} stroke:#F0B86A,stroke-width:1.5px;`);
+ }
+ const spineIdx = edges.map((e, i) => (e.spine ? i : -1)).filter((i) => i >= 0);
+ if (spineIdx.length) {
+ lines.push(` linkStyle ${spineIdx.join(',')} stroke:#5BF0B8,stroke-width:2.5px;`);
+ }
+
+ return trimTrailing(lines).join('\n');
+}
+
+// -- shared helpers ------------------------------------------------------------
+function classesFor(node, root, result, failureIds) {
+ if (node.status === 'abandoned') return ['abandoned'];
+ const out = [];
+ out.push(KIND_CLASS[node.kind] || 'spine');
+ if (failureIds && failureIds.has(node.id)) out.push('failure');
+ if (node === root) out.push('goal');
+ if (result && node === result) out.push('result');
+ return out;
+}
+
+// Pick the "result": the latest live, strategic forward direction -- the same node the
+// agent-memory "Next:" line resolves to. Degrades to the last non-abandoned node, and to
+// null if the tree is empty, so the caller can label it neutrally.
+function pickResult(nodes) {
+ const live = nodes.filter((n) => n.status !== 'abandoned');
+ if (!live.length) return null;
+ const strategic = live.filter(
+ (n) =>
+ (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') &&
+ isStrategicDirection(n)
+ );
+ const latest = latestByTime(strategic);
+ if (latest) return latest;
+ return live[live.length - 1];
+}
+
+function confLabel(node) {
+ const sig = (node.failureSignals || [])[0];
+ if (!sig || typeof sig.confidence !== 'number') return '';
+ return sig.confidence.toFixed(2);
+}
+
+function nodeText(node, root, result) {
+ let prefix = '';
+ if (node === root) prefix = 'GOAL: ';
+ else if (result && node === result) prefix = 'RESULT: ';
+ return prefix + truncateWord(node.title || node.text || node.id, MAX_LABEL);
+}
+
+// Truncate on a WORD boundary so labels never end mid-word ("forecast f..."). Collapse
+// whitespace, and if over budget back off to the last full word that fits (leaving room
+// for the single-char ellipsis). Falls back to a hard cut only for one long token.
+function truncateWord(s, n = MAX_LABEL) {
+ if (!s) return '';
+ const one = String(s).replace(/\s+/g, ' ').trim();
+ if (one.length <= n) return one;
+ const budget = n - 1;
+ const slice = one.slice(0, budget);
+ const lastSpace = slice.lastIndexOf(' ');
+ const head = lastSpace > Math.floor(budget * 0.5) ? slice.slice(0, lastSpace) : slice;
+ return `${head.trimEnd()}…`;
+}
+
+// Stable, Mermaid-safe node id derived from the tree id (node_001 -> N001).
+function nodeId(node) {
+ const m = /(\d+)\s*$/.exec(String(node.id || ''));
+ return m ? `N${m[1]}` : `N_${String(node.id || 'x').replace(/[^A-Za-z0-9_]/g, '')}`;
+}
+
+// Escape characters that break Mermaid node labels. Labels are wrapped in double quotes,
+// so the main hazards are the quote itself and the HTML-ish chars Mermaid interprets.
+function mermaidLabel(text) {
+ return String(text == null ? '' : text)
+ .replace(/\r?\n/g, ' ')
+ .replace(/"/g, '&#39;')
+ .replace(/[<]/g, '&lt;')
+ .replace(/[>]/g, '&gt;')
+ .replace(/\|/g, '&#124;')
+ .replace(/`/g, '&#96;')
+ .replace(/[{}]/g, (m) => (m === '{' ? '&#123;' : '&#125;'))
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+// GOAL (root) and RESULT (final) get stadium terminals ([...]) so the trace endpoints
+// read as distinct vs. the rectangular intermediate steps. Endpoint terminals win over
+// kind-shape (question/checkpoint).
+function shapeOpen(node, root, result) {
+ if (node === root || (result && node === result)) return '([';
+ if (node.kind === 'question') return '{{';
+ if (node.kind === 'checkpoint') return '[/';
+ return '[';
+}
+function shapeClose(node, root, result) {
+ if (node === root || (result && node === result)) return '])';
+ if (node.kind === 'question') return '}}';
+ if (node.kind === 'checkpoint') return '/]';
+ return ']';
+}
+
+// Indexes (0-based, in tree-edge declaration order) of edges whose CHILD is on the spine
+// AND whose parent is non-abandoned -- i.e. the contiguous winning progression.
+function spineLinkIndexes(nodes) {
+ const idxs = [];
+ let edgeIndex = 0;
+ for (const n of nodes) {
+ if (!n.parent) continue;
+ const onSpine = n.status !== 'abandoned' && n.parent.status !== 'abandoned';
+ if (onSpine) idxs.push(edgeIndex);
+ edgeIndex++;
+ }
+ return idxs;
+}
+
+// Drop trailing blank lines so the document never ends on a stray newline group.
+function trimTrailing(lines) {
+ const out = lines.slice();
+ while (out.length && out[out.length - 1] === '') out.pop();
+ return out;
+}
test/treetrace.test.js +200 -1
@@ -10,6 +10,7 @@ import { classifyPrompts } from '../src/extract.js';
import { buildTree } from '../src/tree.js';
import { scanText, applyDecisions, shadowScan, maskFor, resolveFindings } from '../src/redact.js';
import { renderMarkdown, promptPack } from '../src/render-md.js';
+import { renderMermaid, isSummaryByDefault, SUMMARY_NODE_THRESHOLD } from '../src/render-mermaid.js';
import { renderJson } from '../src/render-json.js';
import { renderHandoff } from '../src/handoff.js';
import { renderReportMarkdown, renderTerminalSummary } from '../src/report.js';
@@ -22,7 +23,7 @@ import {
isRiskyCommand,
mentionsTestSkip,
} from '../src/analyze.js';
-import { main, parseArgs } from '../src/cli.js';
+import { main, parseArgs, wrapMermaidDoc } from '../src/cli.js';
import { mungePath } from '../src/discover.js';
import { sha256, escapeMd } from '../src/util.js';
import { detectHallucinations, renderHallucinationsJson } from '../src/hallucinate.js';
@@ -1623,3 +1624,201 @@ test('NEGATIVE CORPUS (release gate): benign inputs produce zero security/failur
rmSync(dir, { recursive: true, force: true });
}
});
+
+test('mermaid: renders a branded flowchart with goal, result, and spine styling', async () => {
+ const { tree } = await fixtureTree();
+ const out = renderMermaid(tree, { projectName: 'weather-dashboard' });
+
+ // Branded init theme leads, then the top-down flowchart and class scaffolding.
+ assert.ok(out.startsWith("%%{init:"), 'must lead with a Mermaid init directive');
+ assert.match(out, /'background':'#0B1210'/, 'dark Bark canvas background');
+ assert.match(out, /'edgeLabelBackground':'#0B1210'/, 'opaque edge-label backing for legibility');
+ assert.match(out, /JetBrains Mono/, 'JetBrains Mono brand font');
+ assert.match(out, /^flowchart TD$/m, 'declares a top-down flowchart');
+ assert.match(out, /classDef spine fill:#121A17,stroke:#0CA08A/, 'brand spine class (teal)');
+ assert.match(out, /classDef abandoned [^\n]*stroke:#34493F[^\n]*stroke-dasharray/, 'Branch-Dim dashed abandoned class');
+ assert.match(out, /classDef failure [^\n]*stroke:#F0B86A/, 'amber failure class');
+
+ // Goal = root, stadium-shaped and annotated; result annotated; both on the spine.
+ assert.match(out, /N001\(\["GOAL: /, 'root node is a stadium labelled GOAL');
+ assert.match(out, /class N001 [^\n]*goal/, 'root carries the goal class');
+ assert.match(out, /RESULT: /, 'a result node is annotated');
+ assert.match(out, /class \w+ [^\n]*result/, 'a node carries the result class');
+ assert.match(out, /\(\["RESULT: /, 'the result node is a stadium terminal');
+
+ // Spine links are tinted Canopy and thickened.
+ assert.match(out, /class N001 [^\n]*spine/, 'root is on the spine');
+ assert.match(out, /linkStyle [\d,]+ stroke:#5BF0B8,stroke-width:2\.5px;/, 'spine links are Canopy-tinted');
+
+ // Edges carry relationship labels from the tree, including the correction.
+ assert.match(out, /N001 -->\|refines\| N002/, 'root refines into the first direction');
+ assert.match(out, /-->\|corrects\| /, 'correction edge labelled');
+
+ // Node-declaration lines must not leak raw angle brackets into labels (entity-encoded).
+ const labelLines = out.split('\n').filter((l) => /^ (N\w+|A\d+|S\d+)(\[|\(\[|\{\{)"/.test(l));
+ assert.ok(labelLines.length >= 4, 'each prompt is declared as a node');
+ for (const line of labelLines) {
+ const label = line.match(/"([^"]*)"/)[1];
+ assert.ok(!/[<>]/.test(label.replace(/&lt;|&gt;/g, '')), `unescaped angle bracket in label: ${line}`);
+ }
+});
+
+test('mermaid: labels truncate on a word boundary, never mid-word', () => {
+ const root = {
+ id: 'node_001', kind: 'root', status: 'accepted', parent: null, actions: [],
+ title: 'Build a resilient weather dashboard with hourly forecast charts and radar layers everywhere',
+ text: 'Build a resilient weather dashboard with hourly forecast charts and radar layers everywhere',
+ };
+ const out = renderMermaid({ nodes: [root] }, { projectName: 'demo' });
+ const label = out.match(/N001\(\["GOAL: ([^"]*)"\]\)/)[1];
+ assert.ok(label.endsWith('…'), `label should end with a single-char ellipsis: ${label}`);
+ // The character before the ellipsis must be a full word, not a cut-off fragment: the
+ // visible body (sans ellipsis) is a prefix of the source ending at a word in the source.
+ const body = label.slice(0, -1);
+ assert.ok(/\w$/.test(body), 'body ends on a word character (no trailing space)');
+ assert.ok(root.title.startsWith(body), 'body is a clean prefix of the source');
+ assert.ok(/(^|\s)$/.test(root.title.slice(body.length, body.length + 1)) || root.title.length === body.length,
+ `truncation landed mid-word: "${body}|${root.title.slice(body.length, body.length + 8)}"`);
+});
+
+test('mermaid: abandoned branches render as dimmed dotted detours off the spine', () => {
+ // Synthetic tree: root -> good direction -> result; root -> abandoned detour.
+ const mk = (id, kind, title, status) => ({
+ id,
+ kind,
+ title,
+ text: title,
+ status: status || 'accepted',
+ ts: `2026-06-01T10:0${id.slice(-1)}:00.000Z`,
+ parent: null,
+ actions: [],
+ });
+ const root = mk('node_001', 'root', 'Build the thing');
+ const good = mk('node_002', 'direction', 'Refine the good approach');
+ const result = mk('node_003', 'direction', 'Ship the chosen design');
+ const dead = mk('node_004', 'direction', 'Try a heavy approach we drop', 'abandoned');
+ good.parent = root;
+ result.parent = good;
+ dead.parent = root;
+ const tree = { nodes: [root, good, result, dead] };
+
+ const out = renderMermaid(tree, { projectName: 'demo' });
+
+ // Abandoned node is classed abandoned (not spine) and its edge is dotted.
+ assert.match(out, /class N004 abandoned;/, 'abandoned node carries only the abandoned class');
+ assert.ok(!/class N004 [^\n]*spine/.test(out), 'abandoned node is not on the spine');
+ assert.match(out, /N001 -\.->\|refines\| N004/, 'abandoned branch uses a dotted edge');
+
+ // Live nodes stay on the spine; the dotted detour edge is excluded from spine linkStyle.
+ assert.match(out, /class N002 [^\n]*spine/, 'good direction on spine');
+ assert.match(out, /class N003 [^\n]*result/, 'last live direction is the result');
+ // Spine links are the two live edges (indexes 0 and 1), not the abandoned edge (index 2).
+ assert.match(out, /linkStyle 0,1 stroke/, 'only live edges are thickened');
+});
+
+test('mermaid: wrapMermaidDoc emits a fenced mermaid block that renders on GitHub', () => {
+ const doc = wrapMermaidDoc('flowchart TD\n N001["x"]', 'demo');
+ assert.ok(doc.includes('```mermaid\n'), 'opens a mermaid fence');
+ assert.ok(doc.trimEnd().endsWith('```'), 'closes the fence');
+ assert.ok(doc.includes('flowchart TD'), 'contains the diagram');
+ const summaryDoc = wrapMermaidDoc('flowchart TD\n N001["x"]', 'demo', true);
+ assert.match(summaryDoc, /count stubs/, 'summary doc explains the folding');
+ assert.match(summaryDoc, /--full/, 'summary doc points at --full to expand');
+});
+
+// Build a linear live spine of `liveDirections` direction nodes off a root, with a small
+// abandoned detour, so we can exercise the summary collapse deterministically.
+function bigTree(liveDirections, withAbandoned = true) {
+ const nodes = [];
+ const root = {
+ id: 'node_001', kind: 'root', status: 'accepted', parent: null, actions: [],
+ title: 'Build the whole product', text: 'Build the whole product',
+ ts: '2026-06-01T10:00:00.000Z',
+ };
+ nodes.push(root);
+ let prev = root;
+ for (let k = 2; k <= liveDirections + 1; k++) {
+ // Alternate direction (strategic, kept) with checkpoint (routine, folded).
+ const kind = k % 3 === 0 ? 'checkpoint' : 'direction';
+ const n = {
+ id: `node_${String(k).padStart(3, '0')}`, kind, status: 'accepted', parent: prev,
+ title: `Strategic move number ${k} in the plan`, text: `Strategic move number ${k} in the plan`,
+ ts: `2026-06-01T10:${String(k).padStart(2, '0')}:00.000Z`, actions: [],
+ };
+ nodes.push(n);
+ prev = n;
+ }
+ if (withAbandoned) {
+ const dead1 = {
+ id: 'node_900', kind: 'direction', status: 'abandoned', parent: root, actions: [],
+ title: 'Heavy approach we dropped', text: 'Heavy approach we dropped',
+ ts: '2026-06-01T10:05:00.000Z',
+ };
+ const dead2 = {
+ id: 'node_901', kind: 'direction', status: 'abandoned', parent: dead1, actions: [],
+ title: 'Follow-up on the dropped approach', text: 'Follow-up on the dropped approach',
+ ts: '2026-06-01T10:06:00.000Z',
+ };
+ nodes.push(dead1, dead2);
+ }
+ return { nodes };
+}
+
+test('mermaid: small trees render in full, large trees auto-summarize', () => {
+ const small = bigTree(4);
+ assert.equal(isSummaryByDefault(small), false, 'a 5-live-node tree renders in full');
+ const smallOut = renderMermaid(small, { projectName: 'demo' });
+ // Every live node is declared individually in full mode (N004 is a plain box).
+ assert.match(smallOut, /N004\[/, 'full mode declares each live node');
+ assert.ok(!/\d+ steps"/.test(smallOut), 'full mode has no count stubs');
+
+ const big = bigTree(SUMMARY_NODE_THRESHOLD + 5);
+ assert.equal(isSummaryByDefault(big), true, 'over the threshold auto-summarizes');
+ const bigOut = renderMermaid(big, { projectName: 'demo' });
+ assert.match(bigOut, /^flowchart TD$/m, 'summary is still a valid flowchart');
+ assert.match(bigOut, /\(\["GOAL: /, 'GOAL stadium preserved in summary');
+ assert.match(bigOut, /RESULT: /, 'RESULT preserved in summary');
+ // Routine intermediate steps fold into count stubs; the summary is smaller than full.
+ assert.match(bigOut, /\d+ steps?"/, 'routine steps fold into a count stub');
+ const fullOut = renderMermaid(big, { projectName: 'demo', full: true });
+ assert.ok(bigOut.split('\n').length < fullOut.split('\n').length, 'summary is more compact than full');
+ assert.match(fullOut, /N0\d\d\[/, 'forcing --full declares each node even on a big tree');
+});
+
+test('mermaid: summary folds abandoned branches into one dim count stub', () => {
+ const big = bigTree(SUMMARY_NODE_THRESHOLD + 3, true);
+ const out = renderMermaid(big, { projectName: 'demo', summary: true });
+ // The two-node abandoned subtree collapses to a single "2 abandoned steps" stub.
+ assert.match(out, /A\d+\["2 abandoned steps"\]/, 'abandoned subtree folds into a counted stub');
+ assert.match(out, /class A\d+ abandoned;/, 'the stub keeps the dim abandoned class');
+ // The individual abandoned node ids are not declared in the summary.
+ assert.ok(!/N900\[/.test(out) && !/N901\[/.test(out), 'individual abandoned nodes are not drawn');
+ // Word-boundary truncation still applies to kept labels.
+ assert.ok(!/[A-Za-z][A-Za-z]/.test(out), 'no mid-word ellipsis in any label');
+});
+
+test('mermaid: --summary forces summary mode even on a small tree', () => {
+ const small = bigTree(3);
+ const forced = renderMermaid(small, { projectName: 'demo', summary: true });
+ // Forcing summary on a tiny tree still produces a valid flowchart with the GOAL/RESULT.
+ assert.match(forced, /^flowchart TD$/m, 'forced summary is a valid flowchart');
+ assert.match(forced, /\(\["GOAL: /, 'forced summary keeps the GOAL');
+});
+
+test('cli: --graph writes PROMPT_TREE_GRAPH.md with a mermaid flowchart', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'treetrace-graph-'));
+ try {
+ await main(['--file', FIXTURE, '--dir', dir, '--graph', '--redact-auto', '--quiet']);
+ const p = join(dir, 'PROMPT_TREE_GRAPH.md');
+ assert.ok(existsSync(p), 'PROMPT_TREE_GRAPH.md must be written');
+ const text = readFileSync(p, 'utf8');
+ assert.ok(text.includes('```mermaid'), 'contains a mermaid fence');
+ assert.ok(text.includes('flowchart TD'), 'contains a flowchart');
+ assert.ok(/GOAL: /.test(text), 'annotates the goal');
+ // Redaction gate still holds: the planted secret must not leak into the graph.
+ assert.ok(!text.includes('sk-ant-api03-FAKEFAKEFAKEFAKEFAKEFAKE1234'), 'secret stays redacted');
+ assert.ok(!text.includes('hunter2pass'), 'embedded credential stays redacted');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});