Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

phoenix inspect — interactive intent pipeline visualisation

New command: phoenix inspect [--port=N]

Serves a single-page web app showing the full provenance pipeline:

Spec Files → Clauses → Canonical Nodes → IUs → Generated Files

Features:
- 5-column pipeline view with all nodes at each stage
- Click any node to highlight its full causal chain across all columns
- Detail panel shows provenance trace (upstream ↑ and downstream ↓)
- Per-column search/filter
- Stats bar: spec count, clauses, canon nodes, IUs, files, edges, drift
- /data.json endpoint exposes raw pipeline data for external tools
- Dark theme, monospace, keyboard nav (Escape to close)

Also exposes collectInspectData() and renderInspectHTML() as public API
for programmatic access to the pipeline graph.

+690
+65
src/cli.ts
··· 50 50 // Scaffold 51 51 import { deriveServices, generateScaffold } from './scaffold.js'; 52 52 53 + // Inspect 54 + import { collectInspectData, renderInspectHTML, serveInspect } from './inspect.js'; 55 + 53 56 // LLM 54 57 import { resolveProvider, describeAvailability } from './llm/resolve.js'; 55 58 ··· 1136 1139 } 1137 1140 } 1138 1141 1142 + async function cmdInspect(args: string[]): Promise<void> { 1143 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 1144 + const machine = loadBootstrapState(phoenixDir); 1145 + const ius = loadIUs(phoenixDir); 1146 + const canonStore = new CanonicalStore(phoenixDir); 1147 + const canonNodes = canonStore.getAllNodes(); 1148 + const specStore = new SpecStore(phoenixDir); 1149 + const manifestManager = new ManifestManager(phoenixDir); 1150 + const manifest = manifestManager.load(); 1151 + 1152 + // Collect all clauses 1153 + const allClauses: Clause[] = []; 1154 + const specFiles = findSpecFiles(projectRoot); 1155 + for (const specFile of specFiles) { 1156 + const docId = relative(projectRoot, specFile); 1157 + allClauses.push(...specStore.getClauses(docId)); 1158 + } 1159 + 1160 + // Drift 1161 + let driftReport = null; 1162 + if (manifest.generated_at) { 1163 + driftReport = detectDrift(manifest, projectRoot); 1164 + } 1165 + 1166 + const projectName = basename(projectRoot); 1167 + const data = collectInspectData( 1168 + projectName, 1169 + machine.getState(), 1170 + allClauses, 1171 + canonNodes, 1172 + ius, 1173 + manifest, 1174 + driftReport, 1175 + ); 1176 + 1177 + const html = renderInspectHTML(data); 1178 + 1179 + // Parse --port flag 1180 + const portArg = args.find(a => a.startsWith('--port='))?.split('=')[1]; 1181 + const port = portArg ? parseInt(portArg, 10) : 0; // 0 = random 1182 + 1183 + const instance = serveInspect(html, port); 1184 + await instance.ready; 1185 + 1186 + console.log(); 1187 + console.log(bold('🔥 Phoenix Inspect')); 1188 + console.log(); 1189 + console.log(` ${cyan(`http://localhost:${instance.port}`)}`); 1190 + console.log(); 1191 + console.log(` ${dim(`${data.stats.specFiles} specs → ${data.stats.clauses} clauses → ${data.stats.canonNodes} canon → ${data.stats.ius} IUs → ${data.stats.generatedFiles} files`)}`); 1192 + console.log(` ${dim(`${data.stats.edgeCount} provenance edges`)}`); 1193 + console.log(); 1194 + console.log(dim(' Press Ctrl+C to stop.')); 1195 + 1196 + // Keep process alive 1197 + await new Promise(() => {}); 1198 + } 1199 + 1139 1200 function cmdVersion(): void { 1140 1201 console.log(`Phoenix VCS v${VERSION}`); 1141 1202 } ··· 1173 1234 ${cyan('cascade')} Show cascade failure effects 1174 1235 1175 1236 ${bold('Inspection:')} 1237 + ${cyan('inspect')} [--port=N] Interactive pipeline visualisation (opens browser) 1176 1238 ${cyan('graph')} Show provenance graph summary 1177 1239 ${cyan('bot')} "<command>" Route a bot command (e.g., "SpecBot: help") 1178 1240 ··· 1233 1295 break; 1234 1296 case 'cascade': 1235 1297 cmdCascade(); 1298 + break; 1299 + case 'inspect': 1300 + await cmdInspect(commandArgs); 1236 1301 break; 1237 1302 case 'graph': 1238 1303 cmdGraph();
+4
src/index.ts
··· 62 62 63 63 // Scaffold 64 64 export { deriveServices, generateScaffold } from './scaffold.js'; 65 + 66 + // Inspect 67 + export { collectInspectData, renderInspectHTML, serveInspect } from './inspect.js'; 68 + export type { InspectData } from './inspect.js'; 65 69 export type { ServiceDescriptor, ScaffoldResult } from './scaffold.js'; 66 70 67 71 // LLM
+621
src/inspect.ts
··· 1 + /** 2 + * Phoenix Inspect — interactive intent pipeline visualisation. 3 + * 4 + * Collects the full provenance graph and serves it as a single-page 5 + * HTML app with an interactive Sankey-style flow: 6 + * 7 + * Spec Files → Clauses → Canonical Nodes → IUs → Generated Files 8 + * 9 + * Each node is clickable to expand detail. Edges show the causal chain. 10 + */ 11 + 12 + import { createServer } from 'node:http'; 13 + import type { Clause } from './models/clause.js'; 14 + import type { CanonicalNode } from './models/canonical.js'; 15 + import type { ImplementationUnit } from './models/iu.js'; 16 + import type { DriftReport, DriftEntry, GeneratedManifest, RegenMetadata } from './models/manifest.js'; 17 + import { DriftStatus } from './models/manifest.js'; 18 + 19 + // ─── Data model passed to the HTML renderer ────────────────────────────────── 20 + 21 + export interface InspectData { 22 + projectName: string; 23 + systemState: string; 24 + specFiles: SpecFileInfo[]; 25 + clauses: ClauseInfo[]; 26 + canonNodes: CanonNodeInfo[]; 27 + ius: IUInfo[]; 28 + generatedFiles: GenFileInfo[]; 29 + edges: Edge[]; 30 + stats: PipelineStats; 31 + } 32 + 33 + export interface SpecFileInfo { 34 + id: string; 35 + path: string; 36 + clauseCount: number; 37 + } 38 + 39 + export interface ClauseInfo { 40 + id: string; 41 + docId: string; 42 + sectionPath: string; 43 + lineRange: string; 44 + preview: string; 45 + semhash: string; 46 + } 47 + 48 + export interface CanonNodeInfo { 49 + id: string; 50 + type: string; 51 + statement: string; 52 + tags: string[]; 53 + linkCount: number; 54 + } 55 + 56 + export interface IUInfo { 57 + id: string; 58 + name: string; 59 + kind: string; 60 + riskTier: string; 61 + canonCount: number; 62 + outputFiles: string[]; 63 + evidenceRequired: string[]; 64 + description: string; 65 + invariants: string[]; 66 + regenMeta?: RegenMetadata; 67 + } 68 + 69 + export interface GenFileInfo { 70 + path: string; 71 + iuId: string; 72 + iuName: string; 73 + contentHash: string; 74 + size: number; 75 + driftStatus: string; 76 + } 77 + 78 + export interface Edge { 79 + from: string; 80 + to: string; 81 + type: 'spec→clause' | 'clause→canon' | 'canon→iu' | 'iu→file' | 'canon→canon'; 82 + } 83 + 84 + export interface PipelineStats { 85 + specFiles: number; 86 + clauses: number; 87 + canonNodes: number; 88 + canonByType: Record<string, number>; 89 + ius: number; 90 + iusByRisk: Record<string, number>; 91 + generatedFiles: number; 92 + totalSize: number; 93 + driftClean: number; 94 + driftDirty: number; 95 + edgeCount: number; 96 + } 97 + 98 + // ─── Data collection ───────────────────────────────────────────────────────── 99 + 100 + export function collectInspectData( 101 + projectName: string, 102 + systemState: string, 103 + clauses: Clause[], 104 + canonNodes: CanonicalNode[], 105 + ius: ImplementationUnit[], 106 + manifest: GeneratedManifest, 107 + driftReport: DriftReport | null, 108 + ): InspectData { 109 + const edges: Edge[] = []; 110 + 111 + // Spec files 112 + const docMap = new Map<string, Clause[]>(); 113 + for (const c of clauses) { 114 + const list = docMap.get(c.source_doc_id) ?? []; 115 + list.push(c); 116 + docMap.set(c.source_doc_id, list); 117 + } 118 + const specFiles: SpecFileInfo[] = [...docMap.entries()].map(([docId, docClauses]) => ({ 119 + id: `spec:${docId}`, 120 + path: docId, 121 + clauseCount: docClauses.length, 122 + })); 123 + 124 + // Clauses + spec→clause edges 125 + const clauseInfos: ClauseInfo[] = clauses.map(c => { 126 + edges.push({ from: `spec:${c.source_doc_id}`, to: `clause:${c.clause_id}`, type: 'spec→clause' }); 127 + return { 128 + id: c.clause_id, 129 + docId: c.source_doc_id, 130 + sectionPath: c.section_path.join(' > '), 131 + lineRange: `L${c.source_line_range[0]}–${c.source_line_range[1]}`, 132 + preview: c.normalized_text.slice(0, 120).replace(/\n/g, ' '), 133 + semhash: c.clause_semhash.slice(0, 12), 134 + }; 135 + }); 136 + 137 + // Canon nodes + clause→canon edges + canon→canon edges 138 + const canonInfos: CanonNodeInfo[] = canonNodes.map(n => { 139 + for (const clauseId of n.source_clause_ids) { 140 + edges.push({ from: `clause:${clauseId}`, to: `canon:${n.canon_id}`, type: 'clause→canon' }); 141 + } 142 + for (const linkedId of n.linked_canon_ids) { 143 + edges.push({ from: `canon:${n.canon_id}`, to: `canon:${linkedId}`, type: 'canon→canon' }); 144 + } 145 + return { 146 + id: n.canon_id, 147 + type: n.type, 148 + statement: n.statement, 149 + tags: n.tags, 150 + linkCount: n.linked_canon_ids.length, 151 + }; 152 + }); 153 + 154 + // IUs + canon→iu edges 155 + const iuInfos: IUInfo[] = ius.map(iu => { 156 + const iuManifest = manifest.iu_manifests[iu.iu_id]; 157 + for (const canonId of iu.source_canon_ids) { 158 + edges.push({ from: `canon:${canonId}`, to: `iu:${iu.iu_id}`, type: 'canon→iu' }); 159 + } 160 + return { 161 + id: iu.iu_id, 162 + name: iu.name, 163 + kind: iu.kind, 164 + riskTier: iu.risk_tier, 165 + canonCount: iu.source_canon_ids.length, 166 + outputFiles: iu.output_files, 167 + evidenceRequired: iu.evidence_policy.required, 168 + description: iu.contract.description, 169 + invariants: iu.contract.invariants, 170 + regenMeta: iuManifest?.regen_metadata, 171 + }; 172 + }); 173 + 174 + // Generated files + iu→file edges 175 + const driftMap = new Map<string, DriftEntry>(); 176 + if (driftReport) { 177 + for (const e of driftReport.entries) driftMap.set(e.file_path, e); 178 + } 179 + const genFiles: GenFileInfo[] = []; 180 + for (const iuM of Object.values(manifest.iu_manifests)) { 181 + for (const [fp, entry] of Object.entries(iuM.files)) { 182 + edges.push({ from: `iu:${iuM.iu_id}`, to: `file:${fp}`, type: 'iu→file' }); 183 + const drift = driftMap.get(fp); 184 + genFiles.push({ 185 + path: fp, 186 + iuId: iuM.iu_id, 187 + iuName: iuM.iu_name, 188 + contentHash: entry.content_hash.slice(0, 12), 189 + size: entry.size, 190 + driftStatus: drift?.status ?? 'UNKNOWN', 191 + }); 192 + } 193 + } 194 + 195 + // Stats 196 + const canonByType: Record<string, number> = {}; 197 + for (const n of canonNodes) canonByType[n.type] = (canonByType[n.type] ?? 0) + 1; 198 + const iusByRisk: Record<string, number> = {}; 199 + for (const iu of ius) iusByRisk[iu.risk_tier] = (iusByRisk[iu.risk_tier] ?? 0) + 1; 200 + 201 + return { 202 + projectName, 203 + systemState, 204 + specFiles, 205 + clauses: clauseInfos, 206 + canonNodes: canonInfos, 207 + ius: iuInfos, 208 + generatedFiles: genFiles, 209 + edges, 210 + stats: { 211 + specFiles: specFiles.length, 212 + clauses: clauses.length, 213 + canonNodes: canonNodes.length, 214 + canonByType, 215 + ius: ius.length, 216 + iusByRisk, 217 + generatedFiles: genFiles.length, 218 + totalSize: genFiles.reduce((s, f) => s + f.size, 0), 219 + driftClean: driftReport?.clean_count ?? 0, 220 + driftDirty: (driftReport?.drifted_count ?? 0) + (driftReport?.missing_count ?? 0), 221 + edgeCount: edges.length, 222 + }, 223 + }; 224 + } 225 + 226 + // ─── HTML renderer ─────────────────────────────────────────────────────────── 227 + 228 + export function renderInspectHTML(data: InspectData): string { 229 + const json = JSON.stringify(data); 230 + return `<!DOCTYPE html> 231 + <html lang="en"> 232 + <head> 233 + <meta charset="utf-8"> 234 + <meta name="viewport" content="width=device-width,initial-scale=1"> 235 + <title>Phoenix · ${esc(data.projectName)}</title> 236 + <style> 237 + :root { 238 + --bg: #0f1117; --surface: #1a1d27; --surface2: #232730; 239 + --border: #2e3345; --text: #e1e4ed; --dim: #7a8194; 240 + --blue: #5b9cf4; --green: #4ade80; --yellow: #fbbf24; 241 + --orange: #fb923c; --red: #f87171; --purple: #a78bfa; 242 + --cyan: #22d3ee; 243 + --font: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; 244 + } 245 + * { margin:0; padding:0; box-sizing:border-box; } 246 + body { font-family:var(--font); background:var(--bg); color:var(--text); font-size:13px; line-height:1.6; } 247 + 248 + /* Header */ 249 + .header { background:var(--surface); border-bottom:1px solid var(--border); padding:16px 24px; display:flex; align-items:center; gap:16px; position:sticky; top:0; z-index:100; } 250 + .header h1 { font-size:18px; font-weight:700; color:var(--blue); } 251 + .header .state { font-size:11px; padding:3px 8px; border-radius:4px; background:var(--surface2); color:var(--yellow); border:1px solid var(--border); } 252 + .header .stats { margin-left:auto; display:flex; gap:16px; font-size:11px; color:var(--dim); } 253 + .header .stats span { color:var(--text); font-weight:600; } 254 + 255 + /* Pipeline */ 256 + .pipeline { display:flex; min-height:calc(100vh - 56px); } 257 + .column { flex:1; min-width:0; border-right:1px solid var(--border); display:flex; flex-direction:column; } 258 + .column:last-child { border-right:none; } 259 + .col-header { padding:10px 14px; background:var(--surface); border-bottom:1px solid var(--border); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:1px; color:var(--dim); display:flex; justify-content:space-between; position:sticky; top:56px; z-index:50; } 260 + .col-header .count { color:var(--blue); } 261 + .col-body { flex:1; overflow-y:auto; padding:8px; } 262 + 263 + /* Cards */ 264 + .card { background:var(--surface); border:1px solid var(--border); border-radius:6px; padding:10px 12px; margin-bottom:6px; cursor:pointer; transition:border-color .15s, background .15s; position:relative; } 265 + .card:hover { border-color:var(--blue); background:var(--surface2); } 266 + .card.highlighted { border-color:var(--cyan); background:#1a2a3a; box-shadow:0 0 12px rgba(34,211,238,.15); } 267 + .card.dimmed { opacity:.25; } 268 + .card-title { font-size:12px; font-weight:600; margin-bottom:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 269 + .card-sub { font-size:10px; color:var(--dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 270 + .card-body { font-size:11px; color:var(--dim); margin-top:4px; display:none; } 271 + .card.expanded .card-body { display:block; } 272 + 273 + /* Tags / badges */ 274 + .badge { display:inline-block; font-size:9px; font-weight:600; padding:1px 6px; border-radius:3px; text-transform:uppercase; letter-spacing:.5px; } 275 + .badge-req { background:#1e3a5f; color:var(--blue); } 276 + .badge-con { background:#3b1e1e; color:var(--red); } 277 + .badge-inv { background:#2d1e3f; color:var(--purple); } 278 + .badge-def { background:#1e2d1e; color:var(--green); } 279 + .badge-low { background:#1e2d1e; color:var(--green); } 280 + .badge-medium { background:#2d2a1e; color:var(--yellow); } 281 + .badge-high { background:#2d1e1e; color:var(--orange); } 282 + .badge-critical { background:#3b1e1e; color:var(--red); } 283 + .badge-clean { background:#1e2d1e; color:var(--green); } 284 + .badge-drifted { background:#3b1e1e; color:var(--red); } 285 + .badge-missing { background:#2d1e1e; color:var(--orange); } 286 + .tag { display:inline-block; font-size:9px; padding:1px 5px; border-radius:2px; background:var(--surface2); color:var(--dim); margin:1px; } 287 + 288 + /* Detail panel */ 289 + .detail-panel { position:fixed; right:0; top:56px; width:380px; height:calc(100vh - 56px); background:var(--surface); border-left:2px solid var(--blue); z-index:200; overflow-y:auto; padding:20px; transform:translateX(100%); transition:transform .2s ease; } 290 + .detail-panel.open { transform:translateX(0); } 291 + .detail-panel h2 { font-size:14px; margin-bottom:12px; color:var(--blue); } 292 + .detail-panel .close { position:absolute; top:12px; right:14px; background:none; border:none; color:var(--dim); cursor:pointer; font-size:16px; } 293 + .detail-panel .close:hover { color:var(--text); } 294 + .detail-section { margin-bottom:14px; } 295 + .detail-section h3 { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--dim); margin-bottom:4px; } 296 + .detail-section p, .detail-section li { font-size:12px; color:var(--text); line-height:1.5; } 297 + .detail-section ul { padding-left:16px; } 298 + .detail-section .mono { font-family:var(--font); font-size:11px; color:var(--cyan); word-break:break-all; } 299 + .provenance-chain { margin-top:8px; } 300 + .provenance-step { display:flex; align-items:flex-start; gap:8px; margin-bottom:8px; padding:6px 8px; background:var(--surface2); border-radius:4px; font-size:11px; } 301 + .provenance-step .arrow { color:var(--blue); font-weight:bold; flex-shrink:0; } 302 + .provenance-step .label { color:var(--dim); font-size:10px; } 303 + .provenance-step .value { color:var(--text); } 304 + 305 + /* Connection lines (CSS, no canvas) */ 306 + .flow-indicator { position:absolute; right:-4px; top:50%; width:8px; height:8px; border-radius:50%; background:var(--border); transform:translateY(-50%); } 307 + .card.highlighted .flow-indicator { background:var(--cyan); box-shadow:0 0 6px var(--cyan); } 308 + 309 + /* Search */ 310 + .search { padding:8px; border-bottom:1px solid var(--border); position:sticky; top:56px; z-index:50; background:var(--surface); } 311 + .search input { width:100%; background:var(--surface2); border:1px solid var(--border); color:var(--text); padding:6px 10px; border-radius:4px; font-size:12px; font-family:var(--font); } 312 + .search input:focus { outline:none; border-color:var(--blue); } 313 + </style> 314 + </head> 315 + <body> 316 + 317 + <div class="header"> 318 + <h1>🔥 Phoenix</h1> 319 + <div class="state">${esc(data.systemState)}</div> 320 + <div class="stats"> 321 + <div><span>${data.stats.specFiles}</span> specs</div> 322 + <div><span>${data.stats.clauses}</span> clauses</div> 323 + <div><span>${data.stats.canonNodes}</span> canon</div> 324 + <div><span>${data.stats.ius}</span> IUs</div> 325 + <div><span>${data.stats.generatedFiles}</span> files</div> 326 + <div><span>${data.stats.edgeCount}</span> edges</div> 327 + <div>${data.stats.driftDirty > 0 ? `<span style="color:var(--red)">${data.stats.driftDirty} drift</span>` : '<span style="color:var(--green)">clean</span>'}</div> 328 + </div> 329 + </div> 330 + 331 + <div class="pipeline" id="pipeline"> 332 + <!-- Columns rendered by JS --> 333 + </div> 334 + 335 + <div class="detail-panel" id="detail"> 336 + <button class="close" onclick="closeDetail()">✕</button> 337 + <div id="detail-content"></div> 338 + </div> 339 + 340 + <script> 341 + const DATA = ${json}; 342 + 343 + // Build lookup indices 344 + const edgeIndex = { forward: {}, backward: {} }; 345 + DATA.edges.forEach(e => { 346 + (edgeIndex.forward[e.from] = edgeIndex.forward[e.from] || []).push(e.to); 347 + (edgeIndex.backward[e.to] = edgeIndex.backward[e.to] || []).push(e.from); 348 + }); 349 + 350 + const allItems = {}; 351 + DATA.specFiles.forEach(s => allItems['spec:'+s.path] = { col:'spec', data:s }); 352 + DATA.clauses.forEach(c => allItems['clause:'+c.id] = { col:'clause', data:c }); 353 + DATA.canonNodes.forEach(n => allItems['canon:'+n.id] = { col:'canon', data:n }); 354 + DATA.ius.forEach(u => allItems['iu:'+u.id] = { col:'iu', data:u }); 355 + DATA.generatedFiles.forEach(f => allItems['file:'+f.path] = { col:'file', data:f }); 356 + 357 + function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } 358 + 359 + // Render columns 360 + function renderPipeline() { 361 + const p = document.getElementById('pipeline'); 362 + p.innerHTML = [ 363 + renderColumn('Spec Files', 'spec', DATA.specFiles.map(s => 364 + card('spec:'+s.path, s.path.split('/').pop(), s.clauseCount+' clauses', '') 365 + )), 366 + renderColumn('Clauses', 'clause', DATA.clauses.map(c => 367 + card('clause:'+c.id, c.sectionPath, c.lineRange+' · '+c.semhash+'…', c.preview) 368 + )), 369 + renderColumn('Canonical Nodes', 'canon', DATA.canonNodes.map(n => 370 + card('canon:'+n.id, 371 + '<span class="badge badge-'+n.type.toLowerCase().slice(0,3)+'">'+n.type+'</span> '+esc(n.statement.slice(0,60)), 372 + n.tags.slice(0,5).map(t=>'<span class="tag">'+esc(t)+'</span>').join('')+(n.linkCount?' · '+n.linkCount+' links':''), 373 + esc(n.statement)) 374 + )), 375 + renderColumn('Implementation Units', 'iu', DATA.ius.map(u => 376 + card('iu:'+u.id, 377 + esc(u.name)+' <span class="badge badge-'+u.riskTier+'">'+u.riskTier+'</span>', 378 + u.canonCount+' nodes · '+u.outputFiles.length+' file(s)', 379 + esc(u.description.slice(0,200))) 380 + )), 381 + renderColumn('Generated Files', 'file', DATA.generatedFiles.map(f => 382 + card('file:'+f.path, 383 + esc(f.path.split('/').pop())+' <span class="badge badge-'+f.driftStatus.toLowerCase()+'">'+f.driftStatus+'</span>', 384 + esc(f.iuName)+' · '+f.contentHash+'… · '+(f.size/1024).toFixed(1)+'KB', 385 + esc(f.path)) 386 + )), 387 + ].join(''); 388 + } 389 + 390 + function renderColumn(title, type, cards) { 391 + return '<div class="column"><div class="col-header"><span>'+title+'</span><span class="count">'+cards.length+'</span></div>' 392 + +'<div class="search"><input type="text" placeholder="Filter '+title.toLowerCase()+'…" oninput="filterColumn(this,\\''+type+'\\')"></div>' 393 + +'<div class="col-body" data-col="'+type+'">'+cards.join('')+'</div></div>'; 394 + } 395 + 396 + function card(id, title, sub, body) { 397 + return '<div class="card" data-id="'+esc(id)+'" onclick="selectCard(\\''+esc(id).replace(/'/g,"\\\\'")+'\\')">' 398 + +'<div class="card-title">'+title+'</div>' 399 + +'<div class="card-sub">'+sub+'</div>' 400 + +(body ? '<div class="card-body">'+body+'</div>' : '') 401 + +'<div class="flow-indicator"></div>' 402 + +'</div>'; 403 + } 404 + 405 + // Selection + highlighting 406 + let selectedId = null; 407 + 408 + function selectCard(id) { 409 + selectedId = id; 410 + // Collect all connected nodes (forward + backward, 2 hops) 411 + const connected = new Set([id]); 412 + const queue = [id]; 413 + for (let depth = 0; depth < 6; depth++) { 414 + const next = []; 415 + for (const n of queue) { 416 + for (const t of (edgeIndex.forward[n]||[])) { if (!connected.has(t)) { connected.add(t); next.push(t); } } 417 + for (const t of (edgeIndex.backward[n]||[])) { if (!connected.has(t)) { connected.add(t); next.push(t); } } 418 + } 419 + queue.length = 0; 420 + queue.push(...next); 421 + } 422 + 423 + document.querySelectorAll('.card').forEach(el => { 424 + const cid = el.dataset.id; 425 + el.classList.toggle('highlighted', connected.has(cid)); 426 + el.classList.toggle('dimmed', !connected.has(cid)); 427 + }); 428 + 429 + showDetail(id); 430 + } 431 + 432 + function clearSelection() { 433 + selectedId = null; 434 + document.querySelectorAll('.card').forEach(el => { 435 + el.classList.remove('highlighted','dimmed'); 436 + }); 437 + } 438 + 439 + // Filtering 440 + function filterColumn(input, colType) { 441 + const q = input.value.toLowerCase(); 442 + const col = document.querySelector('[data-col="'+colType+'"]'); 443 + col.querySelectorAll('.card').forEach(el => { 444 + const text = el.textContent.toLowerCase(); 445 + el.style.display = text.includes(q) ? '' : 'none'; 446 + }); 447 + } 448 + 449 + // Detail panel 450 + function showDetail(id) { 451 + const panel = document.getElementById('detail'); 452 + const content = document.getElementById('detail-content'); 453 + const item = allItems[id]; 454 + if (!item) return; 455 + 456 + let html = ''; 457 + const d = item.data; 458 + 459 + if (item.col === 'spec') { 460 + html = '<h2>📄 '+esc(d.path)+'</h2>' 461 + +'<div class="detail-section"><h3>Clauses</h3><p>'+d.clauseCount+' clauses extracted</p></div>'; 462 + } 463 + else if (item.col === 'clause') { 464 + html = '<h2>📋 Clause</h2>' 465 + +'<div class="detail-section"><h3>Section</h3><p>'+esc(d.sectionPath)+'</p></div>' 466 + +'<div class="detail-section"><h3>Source</h3><p>'+esc(d.docId)+' '+d.lineRange+'</p></div>' 467 + +'<div class="detail-section"><h3>Content</h3><p>'+esc(d.preview)+'</p></div>' 468 + +'<div class="detail-section"><h3>Semhash</h3><p class="mono">'+esc(d.semhash)+'…</p></div>'; 469 + } 470 + else if (item.col === 'canon') { 471 + html = '<h2>'+canonBadge(d.type)+' Canonical Node</h2>' 472 + +'<div class="detail-section"><h3>Statement</h3><p>'+esc(d.statement)+'</p></div>' 473 + +'<div class="detail-section"><h3>Tags</h3><p>'+d.tags.map(t=>'<span class="tag">'+esc(t)+'</span>').join(' ')+'</p></div>' 474 + +'<div class="detail-section"><h3>Links</h3><p>'+d.linkCount+' cross-references</p></div>' 475 + +'<div class="detail-section"><h3>ID</h3><p class="mono">'+esc(d.id)+'</p></div>'; 476 + } 477 + else if (item.col === 'iu') { 478 + html = '<h2>📦 '+esc(d.name)+'</h2>' 479 + +'<div class="detail-section"><h3>Risk Tier</h3><p><span class="badge badge-'+d.riskTier+'">'+d.riskTier+'</span></p></div>' 480 + +'<div class="detail-section"><h3>Description</h3><p>'+esc(d.description)+'</p></div>' 481 + +(d.invariants.length ? '<div class="detail-section"><h3>Invariants</h3><ul>'+d.invariants.map(i=>'<li>'+esc(i)+'</li>').join('')+'</ul></div>' : '') 482 + +'<div class="detail-section"><h3>Evidence Required</h3><p>'+d.evidenceRequired.join(', ')+'</p></div>' 483 + +'<div class="detail-section"><h3>Output</h3><p class="mono">'+d.outputFiles.map(esc).join('<br>')+'</p></div>' 484 + +'<div class="detail-section"><h3>Canon Nodes</h3><p>'+d.canonCount+' source nodes</p></div>' 485 + +(d.regenMeta ? '<div class="detail-section"><h3>Generation</h3><p>Model: '+esc(d.regenMeta.model_id)+'<br>Generated: '+esc(d.regenMeta.generated_at)+'</p></div>' : '') 486 + +'<div class="detail-section"><h3>ID</h3><p class="mono">'+esc(d.id)+'</p></div>'; 487 + } 488 + else if (item.col === 'file') { 489 + html = '<h2>📄 '+esc(d.path.split('/').pop())+'</h2>' 490 + +'<div class="detail-section"><h3>Path</h3><p class="mono">'+esc(d.path)+'</p></div>' 491 + +'<div class="detail-section"><h3>Status</h3><p><span class="badge badge-'+d.driftStatus.toLowerCase()+'">'+d.driftStatus+'</span></p></div>' 492 + +'<div class="detail-section"><h3>Source IU</h3><p>'+esc(d.iuName)+'</p></div>' 493 + +'<div class="detail-section"><h3>Content Hash</h3><p class="mono">'+esc(d.contentHash)+'…</p></div>' 494 + +'<div class="detail-section"><h3>Size</h3><p>'+(d.size/1024).toFixed(1)+' KB</p></div>'; 495 + } 496 + 497 + // Provenance chain 498 + html += renderProvenance(id); 499 + 500 + content.innerHTML = html; 501 + panel.classList.add('open'); 502 + } 503 + 504 + function renderProvenance(id) { 505 + // Trace backward to spec 506 + const chain = []; 507 + const visited = new Set(); 508 + function traceBack(nodeId) { 509 + if (visited.has(nodeId)) return; 510 + visited.add(nodeId); 511 + const parents = edgeIndex.backward[nodeId] || []; 512 + for (const p of parents) { 513 + const pItem = allItems[p]; 514 + if (pItem) chain.push({ id: p, col: pItem.col, label: describeNode(p) }); 515 + traceBack(p); 516 + } 517 + } 518 + // Trace forward to files 519 + function traceForward(nodeId) { 520 + if (visited.has(nodeId)) return; 521 + visited.add(nodeId); 522 + const children = edgeIndex.forward[nodeId] || []; 523 + for (const c of children) { 524 + if (c.startsWith('canon:') && id.startsWith('canon:')) continue; // skip canon→canon 525 + const cItem = allItems[c]; 526 + if (cItem) chain.push({ id: c, col: cItem.col, label: describeNode(c) }); 527 + traceForward(c); 528 + } 529 + } 530 + 531 + const backChain = []; 532 + const fwdChain = []; 533 + const bVisited = new Set([id]); 534 + function tb(nid) { for (const p of (edgeIndex.backward[nid]||[])) { if (!bVisited.has(p)) { bVisited.add(p); const it = allItems[p]; if(it) backChain.push({id:p,col:it.col,label:describeNode(p)}); tb(p); } } } 535 + const fVisited = new Set([id]); 536 + function tf(nid) { for (const c of (edgeIndex.forward[nid]||[])) { if (!fVisited.has(c) && !(c.startsWith('canon:')&&id.startsWith('canon:'))) { fVisited.add(c); const it = allItems[c]; if(it) fwdChain.push({id:c,col:it.col,label:describeNode(c)}); tf(c); } } } 537 + tb(id); tf(id); 538 + 539 + if (backChain.length === 0 && fwdChain.length === 0) return ''; 540 + 541 + let html = '<div class="detail-section"><h3>Provenance Chain</h3><div class="provenance-chain">'; 542 + const colIcon = { spec:'📄', clause:'📋', canon:'📐', iu:'📦', file:'⚡' }; 543 + for (const step of backChain.reverse()) { 544 + html += '<div class="provenance-step"><span class="arrow">↑</span><div><div class="label">'+(colIcon[step.col]||'')+' '+step.col+'</div><div class="value">'+esc(step.label)+'</div></div></div>'; 545 + } 546 + html += '<div class="provenance-step" style="border-left:2px solid var(--cyan);"><span class="arrow">●</span><div><div class="label" style="color:var(--cyan)">selected</div><div class="value">'+esc(describeNode(id))+'</div></div></div>'; 547 + for (const step of fwdChain) { 548 + html += '<div class="provenance-step"><span class="arrow">↓</span><div><div class="label">'+(colIcon[step.col]||'')+' '+step.col+'</div><div class="value">'+esc(step.label)+'</div></div></div>'; 549 + } 550 + html += '</div></div>'; 551 + return html; 552 + } 553 + 554 + function describeNode(id) { 555 + const item = allItems[id]; 556 + if (!item) return id; 557 + const d = item.data; 558 + if (item.col === 'spec') return d.path; 559 + if (item.col === 'clause') return d.sectionPath + ' ' + d.lineRange; 560 + if (item.col === 'canon') return d.statement.slice(0,80); 561 + if (item.col === 'iu') return d.name + ' (' + d.riskTier + ')'; 562 + if (item.col === 'file') return d.path; 563 + return id; 564 + } 565 + 566 + function canonBadge(type) { 567 + const cls = {REQUIREMENT:'req',CONSTRAINT:'con',INVARIANT:'inv',DEFINITION:'def'}[type]||'req'; 568 + return '<span class="badge badge-'+cls+'">'+type+'</span>'; 569 + } 570 + 571 + function closeDetail() { 572 + document.getElementById('detail').classList.remove('open'); 573 + clearSelection(); 574 + } 575 + 576 + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); }); 577 + document.addEventListener('click', e => { 578 + if (!e.target.closest('.card') && !e.target.closest('.detail-panel')) closeDetail(); 579 + }); 580 + 581 + renderPipeline(); 582 + </script> 583 + </body> 584 + </html>`; 585 + } 586 + 587 + function esc(s: string): string { 588 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 589 + } 590 + 591 + // ─── Server ────────────────────────────────────────────────────────────────── 592 + 593 + export function serveInspect( 594 + html: string, 595 + port: number, 596 + ): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } { 597 + const server = createServer((req, res) => { 598 + if (req.url === '/data.json') { 599 + // Also expose raw JSON for external tools 600 + res.writeHead(200, { 'Content-Type': 'application/json' }); 601 + const match = html.match(/const DATA = ({.*?});/s); 602 + res.end(match?.[1] ?? '{}'); 603 + } else { 604 + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 605 + res.end(html); 606 + } 607 + }); 608 + 609 + let actualPort = port; 610 + const ready = new Promise<void>(resolve => { 611 + server.listen(port, () => { 612 + const addr = server.address(); 613 + if (addr && typeof addr === 'object') actualPort = addr.port; 614 + result.port = actualPort; 615 + resolve(); 616 + }); 617 + }); 618 + 619 + const result = { server, port: actualPort, ready }; 620 + return result; 621 + }