infernoflow 0.24.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.
@@ -55,9 +55,12 @@ const COMMAND_DESCRIPTIONS = {
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
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",
58
59
  stability: "Show solid/liquid stability level for every capability (frozen/stable/experimental)",
59
60
  freeze: "Mark a capability as frozen (solid) — AI will not modify it without explicit instruction",
60
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",
61
64
  };
62
65
 
63
66
  const COMMAND_HANDLERS = {
@@ -106,9 +109,12 @@ const COMMAND_HANDLERS = {
106
109
  coverage: async (args) => (await import("../lib/commands/coverage.mjs")).coverageCommand(args),
107
110
  review: async (args) => (await import("../lib/commands/review.mjs")).reviewCommand(args),
108
111
  scan: async (args) => (await import("../lib/commands/scan.mjs")).scanCommand(args),
112
+ graph: async (args) => (await import("../lib/commands/graph.mjs")).graphCommand(args),
109
113
  stability: async (args) => (await import("../lib/commands/stability.mjs")).stabilityCommand(args),
110
114
  freeze: async (args) => (await import("../lib/commands/stability.mjs")).freezeCommand(args),
111
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),
112
118
  };
113
119
 
114
120
  function formatCommandsHelp() {
@@ -369,6 +375,11 @@ ${formatCommandsHelp()}
369
375
  --dry-run Print results without writing files
370
376
  --json Machine-readable scan output
371
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
+
372
383
  ${bold("stability / freeze / thaw options:")}
373
384
  infernoflow stability List all capabilities with their stability level
374
385
  infernoflow freeze <id> Mark capability as frozen (AI won't touch it)
@@ -382,6 +393,18 @@ ${formatCommandsHelp()}
382
393
  --dry-run Print the AI prompt only — no API call made
383
394
  --json Machine-readable output (affectedCaps, summary, provider)
384
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
+
385
408
  ${bold("Machine output:")}
386
409
  ${gray("status --json")}
387
410
  ${gray("check --json")}
@@ -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);