infernoflow 0.23.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,7 +54,13 @@ const COMMAND_DESCRIPTIONS = {
54
54
  doctor: "Diagnose your infernoflow setup — checks Node, git, contract, AI providers, MCP, hooks",
55
55
  coverage: "Map test files to capabilities — show which caps have test coverage and which don't",
56
56
  review: "AI-powered capability impact review for staged or recent git changes",
57
- scan: "Deep AST scan — reads actual function bodies, extracts calls, DB ops, external services",
57
+ scan: "Deep AST scan — reads actual function bodies, extracts calls, DB ops, external services",
58
+ graph: "Build capability dependency graph — shows which caps call which, detects breaking changes",
59
+ stability: "Show solid/liquid stability level for every capability (frozen/stable/experimental)",
60
+ freeze: "Mark a capability as frozen (solid) — AI will not modify it without explicit instruction",
61
+ thaw: "Reset a capability to experimental (liquid) — free to evolve",
62
+ why: "Given a file or function name — show which capability it serves, scenarios, stability, and git history",
63
+ impact: "Blast radius analysis — see every cap, scenario, and risk level affected before you change anything",
58
64
  };
59
65
 
60
66
  const COMMAND_HANDLERS = {
@@ -102,7 +108,13 @@ const COMMAND_HANDLERS = {
102
108
  doctor: async (args) => (await import("../lib/commands/doctor.mjs")).doctorCommand(args),
103
109
  coverage: async (args) => (await import("../lib/commands/coverage.mjs")).coverageCommand(args),
104
110
  review: async (args) => (await import("../lib/commands/review.mjs")).reviewCommand(args),
105
- scan: async (args) => (await import("../lib/commands/scan.mjs")).scanCommand(args),
111
+ scan: async (args) => (await import("../lib/commands/scan.mjs")).scanCommand(args),
112
+ graph: async (args) => (await import("../lib/commands/graph.mjs")).graphCommand(args),
113
+ stability: async (args) => (await import("../lib/commands/stability.mjs")).stabilityCommand(args),
114
+ freeze: async (args) => (await import("../lib/commands/stability.mjs")).freezeCommand(args),
115
+ thaw: async (args) => (await import("../lib/commands/stability.mjs")).thawCommand(args),
116
+ why: async (args) => (await import("../lib/commands/why.mjs")).whyCommand(args),
117
+ impact: async (args) => (await import("../lib/commands/impact.mjs")).impactCommand(args),
106
118
  };
107
119
 
108
120
  function formatCommandsHelp() {
@@ -363,12 +375,36 @@ ${formatCommandsHelp()}
363
375
  --dry-run Print results without writing files
364
376
  --json Machine-readable scan output
365
377
 
378
+ ${bold("graph options:")}
379
+ --cap <id> Show dependency view for a single capability
380
+ --check Exit 1 if breaking dependency changes detected (CI gate)
381
+ --json Machine-readable graph output
382
+
383
+ ${bold("stability / freeze / thaw options:")}
384
+ infernoflow stability List all capabilities with their stability level
385
+ infernoflow freeze <id> Mark capability as frozen (AI won't touch it)
386
+ infernoflow freeze <id> --stable Mark as stable (careful, not forbidden)
387
+ infernoflow thaw <id> Reset to experimental (liquid — free to change)
388
+ --json Machine-readable stability list
389
+
366
390
  ${bold("review options:")}
367
391
  --unstaged Review all working-tree changes (not just staged)
368
392
  --last Review last commit (git diff HEAD~1)
369
393
  --dry-run Print the AI prompt only — no API call made
370
394
  --json Machine-readable output (affectedCaps, summary, provider)
371
395
 
396
+ ${bold("why options:")}
397
+ infernoflow why <file> Show capability for a source file
398
+ infernoflow why <functionName> Show capability for a function name
399
+ --function <name> Filter to a specific function when multiple caps match
400
+ --json Machine-readable output
401
+
402
+ ${bold("impact options:")}
403
+ infernoflow impact <cap-id> Show blast radius for a capability
404
+ --depth <n> Max transitive depth to traverse (default: 10)
405
+ --check Exit 1 if risk level is HIGH or CRITICAL (CI gate)
406
+ --json Machine-readable output
407
+
372
408
  ${bold("Machine output:")}
373
409
  ${gray("status --json")}
374
410
  ${gray("check --json")}
@@ -13,6 +13,7 @@ import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { readProfile } from "../learning/profile.mjs";
15
15
  import { ok, warn, info, bold, done } from "../ui/output.mjs";
16
+ import { buildStabilitySummary } from "./stability.mjs";
16
17
 
17
18
  // ── CLAUDE.md template ────────────────────────────────────────────────────────
18
19
  //
@@ -21,7 +22,7 @@ import { ok, warn, info, bold, done } from "../ui/output.mjs";
21
22
  // session. By putting infernoflow behavior instructions here, Claude becomes
22
23
  // the automatic executor of infernoflow — no developer involvement needed.
23
24
  //
24
- function buildClaudeMd(profile, contract) {
25
+ function buildClaudeMd(profile, contract, caps) {
25
26
  const framework = profile?.stack?.framework !== "unknown" ? profile.stack.framework : null;
26
27
  const language = profile?.stack?.language !== "unknown" ? profile.stack.language : null;
27
28
  const stackLine = framework
@@ -33,6 +34,8 @@ function buildClaudeMd(profile, contract) {
33
34
  ? `The contract currently tracks **${capCount} capabilities**.`
34
35
  : "";
35
36
 
37
+ const stabilitySummary = caps ? buildStabilitySummary(caps) : null;
38
+
36
39
  return `# infernoflow — Automatic AI Behavior
37
40
 
38
41
  > This file is managed by infernoflow. Edit the sections marked "customizable".
@@ -114,6 +117,8 @@ multi-step task repeatedly:
114
117
 
115
118
  ---
116
119
 
120
+ ${stabilitySummary ? stabilitySummary + "\n\n---" : ""}
121
+
117
122
  ## Rules
118
123
 
119
124
  | Rule | Why |
@@ -123,6 +128,7 @@ multi-step task repeatedly:
123
128
  | Never block the developer waiting for infernoflow | Run tools async when possible |
124
129
  | Always call \`infernoflow_context\` at session start | It keeps your context current |
125
130
  | Treat contract drift like a linting error — fix it silently | Not the developer's problem |
131
+ | Never modify frozen capabilities without explicit instruction | Stability contract must be respected |
126
132
 
127
133
  ---
128
134
 
@@ -156,13 +162,18 @@ multi-step task repeatedly:
156
162
  export function writeClaudeMd(cwd, infernoDir, { force = false } = {}) {
157
163
  const claudeMdPath = path.join(cwd, "CLAUDE.md");
158
164
 
159
- // Load project profile + contract for context
165
+ // Load project profile + contract + capabilities for context
160
166
  let profile = null;
161
167
  let contract = null;
168
+ let caps = null;
162
169
  try { profile = readProfile(infernoDir); } catch {}
163
170
  try { contract = JSON.parse(fs.readFileSync(path.join(infernoDir, "contract.json"), "utf8")); } catch {}
171
+ try {
172
+ const raw = JSON.parse(fs.readFileSync(path.join(infernoDir, "capabilities.json"), "utf8"));
173
+ caps = Array.isArray(raw) ? raw : (raw.capabilities || []);
174
+ } catch {}
164
175
 
165
- const newContent = buildClaudeMd(profile, contract);
176
+ const newContent = buildClaudeMd(profile, contract, caps);
166
177
 
167
178
  // If file exists and not forcing, preserve the customizable section
168
179
  if (fs.existsSync(claudeMdPath) && !force) {
@@ -18,7 +18,7 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import * as http from "node:http";
20
20
  import * as os from "node:os";
21
- import { execSync } from "node:child_process";
21
+ import { execSync, spawn } from "node:child_process";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { header, ok, info, warn, bold, cyan, gray } from "../ui/output.mjs";
24
24
 
@@ -166,6 +166,18 @@ function loadGitAnalytics(cwd, infernoDir) {
166
166
  }
167
167
  }
168
168
 
169
+ function loadScan(infernoDir) {
170
+ const p = path.join(infernoDir, "scan.json");
171
+ if (!fs.existsSync(p)) return null;
172
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
173
+ }
174
+
175
+ function loadGraph(infernoDir) {
176
+ const p = path.join(infernoDir, "graph.json");
177
+ if (!fs.existsSync(p)) return null;
178
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
179
+ }
180
+
169
181
  function gatherData(infernoDir) {
170
182
  const caps = loadCapabilities(infernoDir);
171
183
  const contract = loadContract(infernoDir);
@@ -182,8 +194,10 @@ function gatherData(infernoDir) {
182
194
  ];
183
195
  const cwd = path.dirname(infernoDir);
184
196
  const analytics = loadGitAnalytics(cwd, infernoDir);
197
+ const scan = loadScan(infernoDir);
198
+ const graph = loadGraph(infernoDir);
185
199
 
186
- return { caps, contract, agents, hookLog, check, sessions, candidates, audit, links, analytics, infernoDir };
200
+ return { caps, contract, agents, hookLog, check, sessions, candidates, audit, links, analytics, scan, graph, infernoDir };
187
201
  }
188
202
 
189
203
  // ── HTML builder ──────────────────────────────────────────────────────────────
@@ -525,13 +539,210 @@ function buildHtml(data, projectName) {
525
539
  <div class="empty">No audit data yet — run <code>infernoflow audit</code> to classify capabilities by security sensitivity</div>
526
540
  </section>`}
527
541
 
542
+
543
+ <!-- ── Command Center ────────────────────────────────────────────────────── -->
544
+ <section id="command-center">
545
+ <h2>🎛️ Command Center</h2>
546
+ <div class="cc-layout">
547
+ <!-- Left: capability list -->
548
+ <div class="cc-caps">
549
+ <h3>Capabilities</h3>
550
+ <div class="cc-cap-list" id="cc-cap-list">
551
+ ${data.caps.map(c => {
552
+ const stability = c.stability || "experimental";
553
+ const icon = stability === "frozen" ? "🧊" : stability === "stable" ? "〰️" : "🌊";
554
+ const scanEntry = data.scan?.capabilities?.find(s => s.id === c.id);
555
+ const files = scanEntry?.codeAnalysis?.sourceFiles || [];
556
+ return `<div class="cc-cap-row" onclick="capDetail('${esc(c.id)}')">
557
+ <span class="cc-icon">${icon}</span>
558
+ <div class="cc-cap-info">
559
+ <span class="cc-cap-id">${esc(c.id)}</span>
560
+ ${files.length ? `<span class="cc-cap-file">${esc(files[0])}</span>` : ""}
561
+ </div>
562
+ <span class="cc-stab cc-stab-${stability}" onclick="event.stopPropagation();cycleStability('${esc(c.id)}','${stability}')" title="Click to change stability">${stability}</span>
563
+ </div>`;
564
+ }).join("")}
565
+ ${data.caps.length === 0 ? `<div class="empty">No capabilities — run <code>infernoflow init</code></div>` : ""}
566
+ </div>
567
+ </div>
568
+
569
+ <!-- Middle: quick command buttons -->
570
+ <div class="cc-commands">
571
+ <h3>Quick Commands</h3>
572
+ <div class="cc-btn-grid">
573
+ <button class="cc-btn cc-btn-blue" onclick="runCmd('scan')">🔬 scan</button>
574
+ <button class="cc-btn cc-btn-blue" onclick="runCmd('graph')">🕸️ graph</button>
575
+ <button class="cc-btn cc-btn-blue" onclick="runCmd('stability')">💧 stability</button>
576
+ <button class="cc-btn cc-btn-blue" onclick="runCmd('check')">✅ check</button>
577
+ <button class="cc-btn cc-btn-orange" onclick="runCmd('doctor')">🩺 doctor</button>
578
+ <button class="cc-btn cc-btn-orange" onclick="runCmd('coverage')">📊 coverage</button>
579
+ <button class="cc-btn cc-btn-green" onclick="runCmd('status')">📡 status</button>
580
+ <button class="cc-btn cc-btn-green" onclick="runCmd('health')">❤️ health</button>
581
+ </div>
582
+
583
+ <h3 style="margin-top:18px">Capability Actions</h3>
584
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
585
+ <input id="cc-capinput" class="cc-input" placeholder="capability-id" style="flex:1;min-width:120px"/>
586
+ <button class="cc-btn cc-btn-blue" onclick="runCapCmd('why')">🔍 why</button>
587
+ <button class="cc-btn cc-btn-blue" onclick="runCapCmd('impact')">💥 impact</button>
588
+ <button class="cc-btn cc-btn-red" onclick="runCapCmd('freeze')">🧊 freeze</button>
589
+ <button class="cc-btn cc-btn-green" onclick="runCapCmd('thaw')">🌊 thaw</button>
590
+ </div>
591
+
592
+ <!-- Terminal output -->
593
+ <h3>Terminal Output</h3>
594
+ <div class="cc-terminal" id="cc-terminal">
595
+ <span class="cc-prompt">Ready. Click a command or capability to begin.</span>
596
+ </div>
597
+ </div>
598
+
599
+ <!-- Right: cap detail -->
600
+ <div class="cc-detail" id="cc-detail">
601
+ <h3>Capability Detail</h3>
602
+ <div class="empty" id="cc-detail-inner">Click a capability to see its impact analysis.</div>
603
+ </div>
604
+ </div>
605
+ </section>
606
+
528
607
  </main>
529
- <footer>infernoflow dashboard · auto-refreshes every 10s · <a href="/" style="color:var(--muted)">refresh now</a></footer>
608
+ <footer>infernoflow dashboard · auto-refreshes when inferno/ changes · <a href="/" style="color:var(--muted)">refresh now</a></footer>
609
+ <style>
610
+ /* Command Center styles */
611
+ .cc-layout { display:grid; grid-template-columns:220px 1fr 280px; gap:16px; margin-top:12px; min-height:420px; }
612
+ .cc-caps { background:var(--card); border-radius:8px; padding:12px; overflow:hidden; }
613
+ .cc-caps h3, .cc-commands h3, .cc-detail h3 { font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 10px 0; }
614
+ .cc-cap-list { display:flex; flex-direction:column; gap:4px; max-height:360px; overflow-y:auto; }
615
+ .cc-cap-row { display:flex; align-items:center; gap:8px; padding:7px 8px; border-radius:6px; cursor:pointer; transition:background .15s; }
616
+ .cc-cap-row:hover { background:rgba(255,255,255,.06); }
617
+ .cc-icon { font-size:14px; flex-shrink:0; }
618
+ .cc-cap-info { flex:1; min-width:0; }
619
+ .cc-cap-id { display:block; font-size:12px; font-weight:600; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
620
+ .cc-cap-file { display:block; font-size:10px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
621
+ .cc-stab { font-size:10px; padding:2px 6px; border-radius:10px; cursor:pointer; white-space:nowrap; flex-shrink:0; }
622
+ .cc-stab-frozen { background:#7f1d1d; color:#fca5a5; }
623
+ .cc-stab-stable { background:#78350f; color:#fcd34d; }
624
+ .cc-stab-experimental { background:#14532d; color:#86efac; }
625
+ .cc-commands { background:var(--card); border-radius:8px; padding:12px; display:flex; flex-direction:column; }
626
+ .cc-btn-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
627
+ .cc-btn { border:none; border-radius:6px; padding:8px 12px; font-size:12px; font-weight:600; cursor:pointer; transition:opacity .15s; }
628
+ .cc-btn:hover { opacity:.85; }
629
+ .cc-btn-blue { background:#1d4ed8; color:#fff; }
630
+ .cc-btn-orange { background:#c2410c; color:#fff; }
631
+ .cc-btn-green { background:#15803d; color:#fff; }
632
+ .cc-btn-red { background:#b91c1c; color:#fff; }
633
+ .cc-input { background:#1e2030; border:1px solid #374151; border-radius:6px; color:var(--fg); padding:7px 10px; font-size:12px; outline:none; }
634
+ .cc-input:focus { border-color:#3b82f6; }
635
+ .cc-terminal { background:#0d0f1a; border:1px solid #1e2030; border-radius:6px; padding:12px; font-family:monospace; font-size:11px; line-height:1.6; color:#a3e635; flex:1; min-height:180px; max-height:240px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; margin-top:4px; }
636
+ .cc-prompt { color:var(--muted); }
637
+ .cc-detail { background:var(--card); border-radius:8px; padding:12px; overflow-y:auto; }
638
+ .cc-detail-section { margin-bottom:14px; }
639
+ .cc-detail-section h4 { font-size:11px; color:var(--muted); margin:0 0 6px 0; text-transform:uppercase; letter-spacing:.05em; }
640
+ .cc-detail-row { display:flex; justify-content:space-between; font-size:12px; padding:3px 0; border-bottom:1px solid #1e2030; }
641
+ .cc-detail-dep { font-size:12px; padding:3px 0; }
642
+ .cc-risk-low { color:#22c55e; font-weight:700; }
643
+ .cc-risk-medium { color:#f59e0b; font-weight:700; }
644
+ .cc-risk-high { color:#ef4444; font-weight:700; }
645
+ .cc-risk-critical { color:#ef4444; font-weight:700; text-transform:uppercase; }
646
+ </style>
530
647
  <script>
531
648
  // SSE live reload
532
649
  const es = new EventSource('/events');
533
650
  es.onmessage = () => window.location.reload();
534
651
  es.onerror = () => {};
652
+
653
+ // ── Command runner ──────────────────────────────────────────────────────────
654
+ const terminal = document.getElementById('cc-terminal');
655
+
656
+ async function runCmd(command, args = []) {
657
+ terminal.textContent = '$ infernoflow ' + command + (args.length ? ' ' + args.join(' ') : '') + '\\n';
658
+ try {
659
+ const res = await fetch('/api/run', {
660
+ method: 'POST',
661
+ headers: { 'Content-Type': 'application/json' },
662
+ body: JSON.stringify({ command, args }),
663
+ });
664
+ const reader = res.body.getReader();
665
+ const dec = new TextDecoder();
666
+ while (true) {
667
+ const { done, value } = await reader.read();
668
+ if (done) break;
669
+ terminal.textContent += dec.decode(value);
670
+ terminal.scrollTop = terminal.scrollHeight;
671
+ }
672
+ } catch (e) {
673
+ terminal.textContent += '\\nError: ' + e.message;
674
+ }
675
+ }
676
+
677
+ function runCapCmd(command) {
678
+ const capId = document.getElementById('cc-capinput').value.trim();
679
+ if (!capId) { terminal.textContent = 'Enter a capability ID first.'; return; }
680
+ runCmd(command, [capId]);
681
+ }
682
+
683
+ // ── Capability detail panel ─────────────────────────────────────────────────
684
+ async function capDetail(capId) {
685
+ document.getElementById('cc-capinput').value = capId;
686
+ const detail = document.getElementById('cc-detail-inner');
687
+ detail.innerHTML = '<div class="empty">Loading…</div>';
688
+
689
+ try {
690
+ const [why, impact] = await Promise.all([
691
+ fetch('/api/cap/' + encodeURIComponent(capId) + '/why').then(r => r.json()),
692
+ fetch('/api/cap/' + encodeURIComponent(capId) + '/impact').then(r => r.json()),
693
+ ]);
694
+
695
+ const w = Array.isArray(why) ? why[0] : why;
696
+ const im = impact?.capId ? impact : null;
697
+
698
+ let html = '';
699
+
700
+ if (w) {
701
+ html += '<div class="cc-detail-section">';
702
+ html += '<h4>📍 ' + (w.name || w.capId) + '</h4>';
703
+ html += '<div class="cc-detail-row"><span>Stability</span><span class="cc-stab cc-stab-' + w.stability + '">' + w.stability + '</span></div>';
704
+ if (w.sourceFiles?.length) html += '<div class="cc-detail-row"><span>Files</span><span style="color:#7dd3fc">' + w.sourceFiles.join(', ') + '</span></div>';
705
+ if (w.services?.length) html += '<div class="cc-detail-row"><span>Uses</span><span style="color:#a78bfa">' + w.services.join(', ') + '</span></div>';
706
+ if (w.throws?.length) html += '<div class="cc-detail-row"><span>Throws</span><span style="color:#f97316">' + w.throws.join(', ') + '</span></div>';
707
+ html += '</div>';
708
+ }
709
+
710
+ if (im) {
711
+ const riskCls = 'cc-risk-' + im.risk;
712
+ html += '<div class="cc-detail-section">';
713
+ html += '<h4>💥 Impact</h4>';
714
+ html += '<div class="cc-detail-row"><span>Risk</span><span class="' + riskCls + '">' + im.risk.toUpperCase() + '</span></div>';
715
+ html += '<div class="cc-detail-row"><span>Direct deps</span><span>' + im.summary.directCount + '</span></div>';
716
+ html += '<div class="cc-detail-row"><span>Transitive</span><span>' + im.summary.transitiveCount + '</span></div>';
717
+ if (im.direct?.length) {
718
+ html += '<h4 style="margin-top:10px">Direct dependents</h4>';
719
+ im.direct.forEach(d => { html += '<div class="cc-detail-dep">→ <code>' + d + '</code></div>'; });
720
+ }
721
+ if (im.affectedScenarios?.length) {
722
+ html += '<h4 style="margin-top:10px">Scenarios at risk</h4>';
723
+ im.affectedScenarios.forEach(s => { html += '<div class="cc-detail-dep">⚠️ ' + s + '</div>'; });
724
+ }
725
+ html += '</div>';
726
+ }
727
+
728
+ if (!html) html = '<div class="empty">No data found for ' + capId + ' — run infernoflow scan first.</div>';
729
+ detail.innerHTML = html;
730
+ } catch (e) {
731
+ detail.innerHTML = '<div class="empty">Error: ' + e.message + '</div>';
732
+ }
733
+ }
734
+
735
+ // ── Stability cycle ─────────────────────────────────────────────────────────
736
+ async function cycleStability(capId, current) {
737
+ const next = current === 'experimental' ? 'stable' : current === 'stable' ? 'frozen' : 'experimental';
738
+ if (!confirm('Change ' + capId + ' from ' + current + ' → ' + next + '?')) return;
739
+ await fetch('/api/freeze', {
740
+ method: 'POST',
741
+ headers: { 'Content-Type': 'application/json' },
742
+ body: JSON.stringify({ capId, level: next }),
743
+ });
744
+ window.location.reload();
745
+ }
535
746
  </script>
536
747
  </body>
537
748
  </html>`;
@@ -586,6 +797,104 @@ function startServer(infernoDir, port) {
586
797
  return;
587
798
  }
588
799
 
800
+ // ── Command runner: POST /api/run { command, args[] } ─────────────────────
801
+ if (req.url === "/api/run" && req.method === "POST") {
802
+ let body = "";
803
+ req.on("data", chunk => { body += chunk; });
804
+ req.on("end", () => {
805
+ try {
806
+ const { command = "", args = [] } = JSON.parse(body);
807
+ const binPath = path.join(__dirname, "../../bin/infernoflow.mjs");
808
+ res.writeHead(200, {
809
+ "Content-Type": "text/plain; charset=utf-8",
810
+ "Transfer-Encoding": "chunked",
811
+ "Cache-Control": "no-cache",
812
+ });
813
+ const child = spawn(process.execPath, [binPath, command, ...args], {
814
+ cwd,
815
+ env: { ...process.env, FORCE_COLOR: "0" },
816
+ });
817
+ child.stdout.on("data", d => res.write(d));
818
+ child.stderr.on("data", d => res.write(d));
819
+ child.on("close", code => {
820
+ res.write(`\n[exit ${code}]\n`);
821
+ res.end();
822
+ });
823
+ child.on("error", err => {
824
+ res.write(`\nError spawning command: ${err.message}\n`);
825
+ res.end();
826
+ });
827
+ } catch (err) {
828
+ res.writeHead(400, { "Content-Type": "text/plain" });
829
+ res.end("Bad request: " + err.message);
830
+ }
831
+ });
832
+ return;
833
+ }
834
+
835
+ // ── Capability why: GET /api/cap/:id/why ──────────────────────────────────
836
+ const whyMatch = req.url?.match(/^\/api\/cap\/([^/]+)\/why$/);
837
+ if (whyMatch) {
838
+ const capId = decodeURIComponent(whyMatch[1]);
839
+ const binPath = path.join(__dirname, "../../bin/infernoflow.mjs");
840
+ let output = "";
841
+ const child = spawn(process.execPath, [binPath, "why", capId, "--json"], {
842
+ cwd, env: { ...process.env, FORCE_COLOR: "0" },
843
+ });
844
+ child.stdout.on("data", d => { output += d; });
845
+ child.stderr.on("data", () => {});
846
+ child.on("close", () => {
847
+ try {
848
+ res.writeHead(200, { "Content-Type": "application/json" });
849
+ res.end(output.trim() || "[]");
850
+ } catch {}
851
+ });
852
+ return;
853
+ }
854
+
855
+ // ── Capability impact: GET /api/cap/:id/impact ────────────────────────────
856
+ const impactMatch = req.url?.match(/^\/api\/cap\/([^/]+)\/impact$/);
857
+ if (impactMatch) {
858
+ const capId = decodeURIComponent(impactMatch[1]);
859
+ const binPath = path.join(__dirname, "../../bin/infernoflow.mjs");
860
+ let output = "";
861
+ const child = spawn(process.execPath, [binPath, "impact", capId, "--json"], {
862
+ cwd, env: { ...process.env, FORCE_COLOR: "0" },
863
+ });
864
+ child.stdout.on("data", d => { output += d; });
865
+ child.stderr.on("data", () => {});
866
+ child.on("close", () => {
867
+ try {
868
+ res.writeHead(200, { "Content-Type": "application/json" });
869
+ res.end(output.trim() || "{}");
870
+ } catch {}
871
+ });
872
+ return;
873
+ }
874
+
875
+ // ── Freeze/thaw: POST /api/freeze { capId, level } ───────────────────────
876
+ if (req.url === "/api/freeze" && req.method === "POST") {
877
+ let body = "";
878
+ req.on("data", chunk => { body += chunk; });
879
+ req.on("end", () => {
880
+ try {
881
+ const { capId, level } = JSON.parse(body);
882
+ const binPath = path.join(__dirname, "../../bin/infernoflow.mjs");
883
+ const cmd = level === "experimental" ? "thaw" : "freeze";
884
+ const args = level === "stable" ? [capId, "--stable"] : [capId];
885
+ const child = spawn(process.execPath, [binPath, cmd, ...args], { cwd });
886
+ child.on("close", () => {
887
+ res.writeHead(200, { "Content-Type": "application/json" });
888
+ res.end(JSON.stringify({ ok: true }));
889
+ });
890
+ } catch (err) {
891
+ res.writeHead(400, { "Content-Type": "application/json" });
892
+ res.end(JSON.stringify({ ok: false, error: err.message }));
893
+ }
894
+ });
895
+ return;
896
+ }
897
+
589
898
  // Dashboard HTML
590
899
  try {
591
900
  const data = gatherData(infernoDir);