infernoflow 0.18.0 → 0.20.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.
@@ -80,6 +80,92 @@ function runCheck(infernoDir) {
80
80
  }
81
81
  }
82
82
 
83
+ // ── Analytics data loaders ────────────────────────────────────────────────────
84
+
85
+ function loadAudit(infernoDir) {
86
+ const p = path.join(infernoDir, "audit.json");
87
+ if (!fs.existsSync(p)) return null;
88
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
89
+ }
90
+
91
+ function loadLinks(infernoDir) {
92
+ const p = path.join(infernoDir, "links.json");
93
+ if (!fs.existsSync(p)) return [];
94
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return []; }
95
+ }
96
+
97
+ /**
98
+ * Parse git log for inferno/ directory to build analytics:
99
+ * - capability velocity (caps added/removed per week)
100
+ * - contributor activity (commits per author)
101
+ * - health score trend (from check logs or heuristic via commit frequency)
102
+ */
103
+ function loadGitAnalytics(cwd, infernoDir) {
104
+ try {
105
+ // Commits touching inferno/ in past 90 days (iso date, author email, subject)
106
+ const raw = execSync(
107
+ `git log --since="90 days ago" --format="%aI|%ae|%s" -- inferno/`,
108
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 8000 }
109
+ ).trim();
110
+
111
+ if (!raw) return { velocity: [], contributors: [], healthTrend: [] };
112
+
113
+ const commits = raw.split("\n").filter(Boolean).map(line => {
114
+ const [date, email, ...subjectParts] = line.split("|");
115
+ return { date: new Date(date), email: email || "unknown", subject: subjectParts.join("|") };
116
+ });
117
+
118
+ // Bucket by ISO week (YYYY-Www)
119
+ function isoWeek(d) {
120
+ const dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
121
+ const day = dt.getUTCDay() || 7;
122
+ dt.setUTCDate(dt.getUTCDate() + 4 - day);
123
+ const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
124
+ const week = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7);
125
+ return `${dt.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
126
+ }
127
+
128
+ // Velocity: commits per week
129
+ const weekMap = new Map();
130
+ for (const c of commits) {
131
+ const w = isoWeek(c.date);
132
+ weekMap.set(w, (weekMap.get(w) || 0) + 1);
133
+ }
134
+ // Fill in the last 13 weeks
135
+ const velocity = [];
136
+ const now = new Date();
137
+ for (let i = 12; i >= 0; i--) {
138
+ const d = new Date(now);
139
+ d.setDate(d.getDate() - i * 7);
140
+ const w = isoWeek(d);
141
+ velocity.push({ week: w, commits: weekMap.get(w) || 0 });
142
+ }
143
+
144
+ // Contributors: unique authors, sorted by commit count
145
+ const authorMap = new Map();
146
+ for (const c of commits) {
147
+ const name = c.email.split("@")[0];
148
+ authorMap.set(name, (authorMap.get(name) || 0) + 1);
149
+ }
150
+ const contributors = [...authorMap.entries()]
151
+ .map(([name, count]) => ({ name, count }))
152
+ .sort((a, b) => b.count - a.count)
153
+ .slice(0, 8);
154
+
155
+ // Health trend: simple heuristic from commit density per week
156
+ // More commits → more drift activity. We mark weeks with >3 commits as "busy" (amber), 0 = stale, else ok
157
+ const healthTrend = velocity.map(v => ({
158
+ week: v.week,
159
+ score: v.commits === 0 ? 40 : v.commits <= 2 ? 75 : v.commits <= 5 ? 90 : 85,
160
+ label: v.commits === 0 ? "stale" : v.commits <= 2 ? "ok" : v.commits <= 5 ? "healthy" : "busy",
161
+ }));
162
+
163
+ return { velocity, contributors, healthTrend };
164
+ } catch {
165
+ return { velocity: [], contributors: [], healthTrend: [] };
166
+ }
167
+ }
168
+
83
169
  function gatherData(infernoDir) {
84
170
  const caps = loadCapabilities(infernoDir);
85
171
  const contract = loadContract(infernoDir);
@@ -87,19 +173,74 @@ function gatherData(infernoDir) {
87
173
  const agents = loadAgents(infernoDir);
88
174
  const hookLog = loadHookLog(infernoDir);
89
175
  const check = runCheck(infernoDir);
176
+ const audit = loadAudit(infernoDir);
177
+ const links = loadLinks(infernoDir);
90
178
  const sessions = profile?.recentSessions?.slice(-10) || [];
91
179
  const candidates = [
92
180
  ...(profile?.agentCandidates || []),
93
181
  ...(profile?.skillCandidates || []),
94
182
  ];
183
+ const cwd = path.dirname(infernoDir);
184
+ const analytics = loadGitAnalytics(cwd, infernoDir);
185
+
186
+ return { caps, contract, agents, hookLog, check, sessions, candidates, audit, links, analytics, infernoDir };
187
+ }
188
+
189
+ // ── HTML builder ──────────────────────────────────────────────────────────────
190
+
191
+ // ── SVG chart builders ────────────────────────────────────────────────────────
192
+
193
+ function barChart(values, labels, color = "#f97316", height = 80) {
194
+ const W = 600, H = height;
195
+ const n = values.length;
196
+ if (!n) return `<svg width="${W}" height="${H}"></svg>`;
197
+ const max = Math.max(...values, 1);
198
+ const bw = Math.floor(W / n) - 4;
199
+ const bars = values.map((v, i) => {
200
+ const bh = Math.max(2, Math.round((v / max) * (H - 20)));
201
+ const x = i * (W / n) + 2;
202
+ const y = H - bh - 10;
203
+ return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="${color}" rx="2" opacity="0.85"/>
204
+ <title>${labels[i]}: ${v}</title>`;
205
+ }).join("\n");
206
+ return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" xmlns="http://www.w3.org/2000/svg">${bars}</svg>`;
207
+ }
95
208
 
96
- return { caps, contract, agents, hookLog, check, sessions, candidates, infernoDir };
209
+ function lineChart(values, color = "#3b82f6", height = 80) {
210
+ const W = 600, H = height;
211
+ const n = values.length;
212
+ if (n < 2) return `<svg width="${W}" height="${H}"></svg>`;
213
+ const max = Math.max(...values, 1);
214
+ const min = Math.min(...values, 0);
215
+ const range = max - min || 1;
216
+ const pts = values.map((v, i) => {
217
+ const x = Math.round((i / (n - 1)) * (W - 20)) + 10;
218
+ const y = Math.round(H - 10 - ((v - min) / range) * (H - 20));
219
+ return `${x},${y}`;
220
+ }).join(" ");
221
+ return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" xmlns="http://www.w3.org/2000/svg">
222
+ <polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
223
+ ${values.map((v, i) => {
224
+ const [px, py] = pts.split(" ")[i].split(",");
225
+ return `<circle cx="${px}" cy="${py}" r="4" fill="${color}"><title>${v}</title></circle>`;
226
+ }).join("")}
227
+ </svg>`;
228
+ }
229
+
230
+ function heatRow(name, count, maxCount) {
231
+ const pct = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0;
232
+ const fill = pct > 70 ? "#f97316" : pct > 40 ? "#f59e0b" : pct > 10 ? "#3b82f6" : "#2d3148";
233
+ return `<div class="heat-row">
234
+ <span class="heat-name">${esc(name)}</span>
235
+ <div class="heat-bar-wrap"><div class="heat-bar" style="width:${pct}%;background:${fill}"></div></div>
236
+ <span class="heat-count">${count}</span>
237
+ </div>`;
97
238
  }
98
239
 
99
240
  // ── HTML builder ──────────────────────────────────────────────────────────────
100
241
 
101
242
  function buildHtml(data, projectName) {
102
- const { caps, agents, check, sessions, candidates } = data;
243
+ const { caps, agents, check, sessions, candidates, audit, links, analytics } = data;
103
244
 
104
245
  const statusColor = check?.status === "ok" ? "#22c55e"
105
246
  : check?.status === "warning" ? "#f59e0b"
@@ -153,6 +294,29 @@ function buildHtml(data, projectName) {
153
294
  `<li class="candidate">${esc(c.name || c.id || "unnamed")}: ${esc(c.description || "")}</li>`
154
295
  ).join("\n");
155
296
 
297
+ // ── Analytics ─────────────────────────────────────────────────────────────
298
+ const vel = analytics?.velocity || [];
299
+ const contribs = analytics?.contributors || [];
300
+ const trend = analytics?.healthTrend || [];
301
+
302
+ const velValues = vel.map(v => v.commits);
303
+ const velLabels = vel.map(v => v.week);
304
+ const velChart = barChart(velValues, velLabels, "#f97316", 90);
305
+
306
+ const trendValues = trend.map(t => t.score);
307
+ const trendChart = lineChart(trendValues, "#3b82f6", 80);
308
+
309
+ const maxContrib = contribs.length ? Math.max(...contribs.map(c => c.count)) : 1;
310
+ const heatRows = contribs.length
311
+ ? contribs.map(c => heatRow(c.name, c.count, maxContrib)).join("\n")
312
+ : `<div class="empty">No git history in inferno/ yet</div>`;
313
+
314
+ // Audit summary card
315
+ const auditStats = audit?.stats || null;
316
+ const auditHigh = auditStats?.high ?? "—";
317
+ const auditMedium = auditStats?.medium ?? "—";
318
+ const linkedCount = links.length;
319
+
156
320
  return `<!DOCTYPE html>
157
321
  <html lang="en">
158
322
  <head>
@@ -200,6 +364,22 @@ function buildHtml(data, projectName) {
200
364
  .session-item:last-child { border-bottom: none; }
201
365
  .session-date { font-size: 11px; color: var(--muted); white-space: nowrap; min-width: 140px; }
202
366
  .session-cmds { font-size: 12px; color: var(--text); }
367
+ /* Analytics */
368
+ .chart-wrap { padding: 16px 18px; }
369
+ .chart-label { font-size: 11px; color: var(--muted); margin-top: 6px; text-align: center; }
370
+ .analytics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
371
+ .heat-row { display: flex; align-items: center; gap: 10px; padding: 6px 18px; border-bottom: 1px solid var(--border); }
372
+ .heat-row:last-child { border-bottom: none; }
373
+ .heat-name { min-width: 110px; font-size: 12px; color: var(--text); font-family: monospace; }
374
+ .heat-bar-wrap { flex: 1; height: 10px; background: var(--border); border-radius: 5px; overflow: hidden; }
375
+ .heat-bar { height: 100%; border-radius: 5px; transition: width 0.3s; }
376
+ .heat-count { font-size: 12px; color: var(--muted); min-width: 30px; text-align: right; }
377
+ .audit-tags { display: flex; gap: 8px; padding: 14px 18px; flex-wrap: wrap; }
378
+ .tag { font-size: 12px; padding: 4px 10px; border-radius: 9px; font-weight: 600; }
379
+ .tag-high { background: rgba(239,68,68,0.15); color: #ef4444; }
380
+ .tag-medium { background: rgba(245,158,11,0.15); color: #f59e0b; }
381
+ .tag-low { background: rgba(34,197,94,0.15); color: #22c55e; }
382
+ .tag-link { background: rgba(59,130,246,0.15); color: #3b82f6; }
203
383
  footer { text-align: center; color: var(--muted); font-size: 11px; padding: 24px; }
204
384
  </style>
205
385
  </head>
@@ -236,6 +416,17 @@ function buildHtml(data, projectName) {
236
416
  <div class="value">${sessions.length}</div>
237
417
  <div class="sub">recent sessions logged</div>
238
418
  </div>
419
+ ${auditStats ? `
420
+ <div class="card">
421
+ <div class="label">Security surface</div>
422
+ <div class="value" style="color:${auditHigh > 0 ? "var(--red)" : "var(--green)"}">${auditHigh}</div>
423
+ <div class="sub">${auditHigh} high · ${auditMedium} medium risk caps</div>
424
+ </div>` : ""}
425
+ <div class="card">
426
+ <div class="label">Linked tickets</div>
427
+ <div class="value" style="color:var(--blue)">${linkedCount}</div>
428
+ <div class="sub">caps linked to Jira/Linear/GitHub</div>
429
+ </div>
239
430
  </div>
240
431
 
241
432
  ${issueCount > 0 ? `
@@ -279,6 +470,61 @@ function buildHtml(data, projectName) {
279
470
  : `<div class="empty">No session data yet — sessions are logged automatically as you use infernoflow</div>`}
280
471
  </section>
281
472
 
473
+ <!-- Analytics: velocity + health trend -->
474
+ ${vel.length > 0 ? `
475
+ <div class="analytics-grid">
476
+ <section>
477
+ <h2>📈 Capability Velocity (13 weeks)</h2>
478
+ <div class="chart-wrap">
479
+ ${velChart}
480
+ <div class="chart-label">Commits touching inferno/ per week</div>
481
+ </div>
482
+ </section>
483
+ <section>
484
+ <h2>💚 Health Score Trend</h2>
485
+ <div class="chart-wrap">
486
+ ${trendChart}
487
+ <div class="chart-label">Heuristic health score over last 13 weeks</div>
488
+ </div>
489
+ </section>
490
+ </div>` : ""}
491
+
492
+ <!-- Contributor heatmap -->
493
+ ${contribs.length > 0 ? `
494
+ <section>
495
+ <h2>👥 Contributor Heatmap (90 days)</h2>
496
+ ${heatRows}
497
+ </section>` : ""}
498
+
499
+ <!-- Audit surface map (if audit.json exists) -->
500
+ ${auditStats ? `
501
+ <section>
502
+ <h2>🔐 Security Surface (last audit)</h2>
503
+ <div class="audit-tags">
504
+ <span class="tag tag-high">🔴 ${auditStats.high} HIGH</span>
505
+ <span class="tag tag-medium">🟡 ${auditStats.medium} MEDIUM</span>
506
+ <span class="tag tag-low">🟢 ${auditStats.low} LOW</span>
507
+ ${linkedCount > 0 ? `<span class="tag tag-link">🔗 ${linkedCount} linked to tickets</span>` : ""}
508
+ </div>
509
+ ${audit.capabilities ? `
510
+ <table>
511
+ <thead><tr><th>Severity</th><th>Capability</th><th>Tags</th></tr></thead>
512
+ <tbody>
513
+ ${audit.capabilities.filter(c => c.severity === "high" || c.severity === "medium").slice(0, 10).map(c => `
514
+ <tr>
515
+ <td style="color:${c.severity === "high" ? "var(--red)" : "var(--yellow)"}">${c.severity}</td>
516
+ <td><code>${esc(c.id)}</code></td>
517
+ <td>${esc((c.tags || []).join(", "))}</td>
518
+ </tr>`).join("")}
519
+ </tbody>
520
+ </table>` : ""}
521
+ <div style="padding:8px 18px;font-size:11px;color:var(--muted)">Run <code>infernoflow audit</code> to refresh · Last run: ${esc(audit.runAt ? new Date(audit.runAt).toLocaleString() : "unknown")}</div>
522
+ </section>` : `
523
+ <section>
524
+ <h2>🔐 Security Surface</h2>
525
+ <div class="empty">No audit data yet — run <code>infernoflow audit</code> to classify capabilities by security sensitivity</div>
526
+ </section>`}
527
+
282
528
  </main>
283
529
  <footer>infernoflow dashboard · auto-refreshes every 10s · <a href="/" style="color:var(--muted)">refresh now</a></footer>
284
530
  <script>
@@ -0,0 +1,239 @@
1
+ /**
2
+ * infernoflow export
3
+ *
4
+ * Export the capability contract to external formats so it can travel
5
+ * outside the repo — into API docs, service catalogs, spreadsheets, wikis.
6
+ *
7
+ * Formats:
8
+ * openapi OpenAPI 3.1 JSON (stubs for each capability)
9
+ * backstage Backstage catalog-info.yaml
10
+ * csv Spreadsheet-ready CSV
11
+ * markdown Confluence/Notion-ready Markdown table
12
+ * json Clean JSON (normalised, no internal infernoflow fields)
13
+ *
14
+ * Usage:
15
+ * infernoflow export --format openapi
16
+ * infernoflow export --format backstage --out catalog-info.yaml
17
+ * infernoflow export --format csv --out capabilities.csv
18
+ * infernoflow export --format markdown
19
+ * infernoflow export --format json --out contract-export.json
20
+ */
21
+
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { done, warn, info, bold, cyan, gray } from "../ui/output.mjs";
25
+
26
+ // ── Contract reader ───────────────────────────────────────────────────────────
27
+
28
+ function readContract(infernoDir) {
29
+ for (const f of ["contract.json", "capabilities.json"]) {
30
+ const p = path.join(infernoDir, f);
31
+ if (!fs.existsSync(p)) continue;
32
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function normaliseCaps(contract) {
38
+ const raw = contract?.capabilities || [];
39
+ return raw.map(c => {
40
+ if (typeof c === "string") return { id: c, description: "", tags: [], status: "active" };
41
+ return {
42
+ id: c.id || c.name || "unknown",
43
+ description: c.description || "",
44
+ tags: c.tags || [],
45
+ status: c.status || "active",
46
+ since: c.since || "",
47
+ owner: c.owner || "",
48
+ };
49
+ });
50
+ }
51
+
52
+ function readMeta(infernoDir) {
53
+ const pkgPath = path.join(path.dirname(infernoDir), "package.json");
54
+ try {
55
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
56
+ return { name: pkg.name || "my-service", version: pkg.version || "0.0.0", description: pkg.description || "" };
57
+ } catch {
58
+ return { name: path.basename(path.dirname(infernoDir)), version: "0.0.0", description: "" };
59
+ }
60
+ }
61
+
62
+ // ── Formatters ────────────────────────────────────────────────────────────────
63
+
64
+ function toOpenApi(caps, meta) {
65
+ const paths = {};
66
+ for (const cap of caps) {
67
+ const route = `/${cap.id.replace(/_/g, "-")}`;
68
+ paths[route] = {
69
+ get: {
70
+ operationId: cap.id,
71
+ summary: cap.description || cap.id,
72
+ tags: cap.tags.length ? cap.tags : [meta.name],
73
+ parameters: [],
74
+ responses: {
75
+ "200": { description: "Success", content: { "application/json": { schema: { type: "object" } } } },
76
+ "401": { description: "Unauthorized" },
77
+ "500": { description: "Internal Server Error" },
78
+ },
79
+ },
80
+ };
81
+ }
82
+
83
+ return JSON.stringify({
84
+ openapi: "3.1.0",
85
+ info: {
86
+ title: meta.name,
87
+ version: meta.version,
88
+ description: meta.description || `Capability contract for ${meta.name}`,
89
+ },
90
+ paths,
91
+ components: { schemas: {}, securitySchemes: {} },
92
+ }, null, 2);
93
+ }
94
+
95
+ function toBackstage(caps, meta) {
96
+ const tags = [...new Set(caps.flatMap(c => c.tags))].slice(0, 10);
97
+ const lines = [
98
+ `apiVersion: backstage.io/v1alpha1`,
99
+ `kind: Component`,
100
+ `metadata:`,
101
+ ` name: ${meta.name}`,
102
+ ` description: "${(meta.description || meta.name).replace(/"/g, '\\"')}"`,
103
+ ` annotations:`,
104
+ ` infernoflow/capability-count: "${caps.length}"`,
105
+ ` infernoflow/generated-at: "${new Date().toISOString()}"`,
106
+ tags.length ? ` tags:\n${tags.map(t => ` - ${t}`).join("\n")}` : "",
107
+ `spec:`,
108
+ ` type: service`,
109
+ ` lifecycle: production`,
110
+ ` owner: team-default`,
111
+ ` providesApis: []`,
112
+ `---`,
113
+ `# Capabilities`,
114
+ ...caps.map(c => [
115
+ `# ${c.id}`,
116
+ `# ${c.description || "(no description)"}`,
117
+ `# tags: ${c.tags.join(", ") || "none"}`,
118
+ `# status: ${c.status}`,
119
+ ].join("\n")),
120
+ ].filter(Boolean);
121
+ return lines.join("\n") + "\n";
122
+ }
123
+
124
+ function toCsv(caps) {
125
+ const header = ["id", "description", "tags", "status", "since", "owner"];
126
+ const rows = caps.map(c => [
127
+ c.id,
128
+ (c.description || "").replace(/"/g, '""'),
129
+ c.tags.join("|"),
130
+ c.status,
131
+ c.since,
132
+ c.owner,
133
+ ].map(v => `"${v}"`).join(","));
134
+ return [header.join(","), ...rows].join("\n") + "\n";
135
+ }
136
+
137
+ function toMarkdown(caps, meta) {
138
+ const lines = [
139
+ `# ${meta.name} — Capability Contract`,
140
+ ``,
141
+ `> Generated by infernoflow on ${new Date().toISOString().slice(0, 10)}`,
142
+ `> ${caps.length} capabilities tracked`,
143
+ ``,
144
+ `| Capability | Description | Tags | Status |`,
145
+ `|---|---|---|---|`,
146
+ ...caps.map(c =>
147
+ `| \`${c.id}\` | ${c.description || ""} | ${c.tags.join(", ") || "—"} | ${c.status} |`
148
+ ),
149
+ ``,
150
+ ];
151
+ return lines.join("\n");
152
+ }
153
+
154
+ function toCleanJson(caps, meta) {
155
+ return JSON.stringify({
156
+ name: meta.name,
157
+ version: meta.version,
158
+ exportedAt: new Date().toISOString(),
159
+ capabilities: caps,
160
+ }, null, 2) + "\n";
161
+ }
162
+
163
+ // ── Entry ─────────────────────────────────────────────────────────────────────
164
+
165
+ const FORMAT_EXT = {
166
+ openapi: "openapi.json",
167
+ backstage: "catalog-info.yaml",
168
+ csv: "capabilities.csv",
169
+ markdown: "capabilities.md",
170
+ json: "capabilities-export.json",
171
+ };
172
+
173
+ export async function exportCommand(rawArgs) {
174
+ const args = rawArgs.slice(1);
175
+ const jsonMode = args.includes("--json");
176
+ const cwd = process.cwd();
177
+ const infernoDir = path.join(cwd, "inferno");
178
+
179
+ if (!fs.existsSync(infernoDir)) {
180
+ const msg = "inferno/ not found. Run: infernoflow init";
181
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
182
+ else warn(msg);
183
+ process.exit(1);
184
+ }
185
+
186
+ const fmtIdx = args.indexOf("--format");
187
+ const format = fmtIdx !== -1 ? args[fmtIdx + 1] : null;
188
+ const outIdx = args.indexOf("--out");
189
+ const outArg = outIdx !== -1 ? args[outIdx + 1] : null;
190
+
191
+ const validFormats = Object.keys(FORMAT_EXT);
192
+
193
+ if (!format || !validFormats.includes(format)) {
194
+ const msg = `Usage: infernoflow export --format <${validFormats.join("|")}> [--out <path>]`;
195
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
196
+ else {
197
+ warn(msg);
198
+ console.log();
199
+ console.log(` ${gray("Available formats:")}`);
200
+ console.log(` ${bold("openapi")} — OpenAPI 3.1 JSON with a stub path per capability`);
201
+ console.log(` ${bold("backstage")} — Backstage catalog-info.yaml component definition`);
202
+ console.log(` ${bold("csv")} — Spreadsheet-ready CSV`);
203
+ console.log(` ${bold("markdown")} — Confluence/Notion table`);
204
+ console.log(` ${bold("json")} — Clean normalised JSON`);
205
+ console.log();
206
+ }
207
+ return;
208
+ }
209
+
210
+ const contract = readContract(infernoDir);
211
+ if (!contract) {
212
+ const msg = "No contract.json or capabilities.json found.";
213
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
214
+ else warn(msg);
215
+ process.exit(1);
216
+ }
217
+
218
+ const caps = normaliseCaps(contract);
219
+ const meta = readMeta(infernoDir);
220
+ const outPath = outArg || path.join(cwd, FORMAT_EXT[format]);
221
+
222
+ let output;
223
+ switch (format) {
224
+ case "openapi": output = toOpenApi(caps, meta); break;
225
+ case "backstage": output = toBackstage(caps, meta); break;
226
+ case "csv": output = toCsv(caps); break;
227
+ case "markdown": output = toMarkdown(caps, meta); break;
228
+ case "json": output = toCleanJson(caps, meta); break;
229
+ }
230
+
231
+ fs.writeFileSync(outPath, output);
232
+
233
+ if (jsonMode) {
234
+ console.log(JSON.stringify({ ok: true, format, file: outPath, capabilities: caps.length }));
235
+ } else {
236
+ done(`Exported ${bold(String(caps.length))} capabilities → ${cyan(path.relative(cwd, outPath))}`);
237
+ console.log();
238
+ }
239
+ }