infernoflow 0.17.0 → 0.19.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,342 @@
1
+ /**
2
+ * infernoflow link
3
+ *
4
+ * Link capabilities to tickets in Jira, Linear, or GitHub Issues.
5
+ * Stored in inferno/links.json — travels with the repo.
6
+ *
7
+ * Usage:
8
+ * infernoflow link --jira PROJ-123 --capability CreateTask
9
+ * infernoflow link --linear LIN-456 --capability FilterByTag
10
+ * infernoflow link --github 78 --capability ExportToCsv
11
+ * infernoflow link list Show all links
12
+ * infernoflow link status Show which caps have open tickets
13
+ * infernoflow link remove --capability CreateTask
14
+ * infernoflow link --json Machine-readable
15
+ *
16
+ * Config (inferno/integrations.json):
17
+ * {
18
+ * "jira": { "baseUrl": "https://myorg.atlassian.net", "token": "...", "email": "..." },
19
+ * "linear": { "apiKey": "lin_api_..." },
20
+ * "github": { "repo": "owner/repo", "token": "ghp_..." }
21
+ * }
22
+ *
23
+ * Env vars (override config):
24
+ * JIRA_BASE_URL, JIRA_TOKEN, JIRA_EMAIL
25
+ * LINEAR_API_KEY
26
+ * GITHUB_TOKEN, GITHUB_REPOSITORY
27
+ */
28
+
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ import * as https from "node:https";
32
+ import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
33
+
34
+ const LINKS_FILE = "links.json";
35
+ const CONFIG_FILE = "integrations.json";
36
+
37
+ // ── Storage ───────────────────────────────────────────────────────────────────
38
+
39
+ function readLinks(infernoDir) {
40
+ const p = path.join(infernoDir, LINKS_FILE);
41
+ if (!fs.existsSync(p)) return [];
42
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return []; }
43
+ }
44
+
45
+ function writeLinks(infernoDir, links) {
46
+ fs.writeFileSync(path.join(infernoDir, LINKS_FILE), JSON.stringify(links, null, 2) + "\n");
47
+ }
48
+
49
+ function readIntegrationConfig(infernoDir) {
50
+ const p = path.join(infernoDir, CONFIG_FILE);
51
+ if (!fs.existsSync(p)) return {};
52
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
53
+ }
54
+
55
+ // ── HTTP helper ───────────────────────────────────────────────────────────────
56
+
57
+ function httpsGet(url, headers = {}) {
58
+ return new Promise((resolve, reject) => {
59
+ const parsed = new URL(url);
60
+ https.get({
61
+ hostname: parsed.hostname,
62
+ path: parsed.pathname + (parsed.search || ""),
63
+ headers: { "User-Agent": "infernoflow-cli", "Accept": "application/json", ...headers },
64
+ }, (res) => {
65
+ let data = "";
66
+ res.on("data", d => (data += d));
67
+ res.on("end", () => {
68
+ try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
69
+ catch { resolve({ status: res.statusCode, body: data }); }
70
+ });
71
+ }).on("error", reject);
72
+ });
73
+ }
74
+
75
+ // ── Ticket fetchers ───────────────────────────────────────────────────────────
76
+
77
+ async function fetchJiraTicket(ticketId, config) {
78
+ const base = process.env.JIRA_BASE_URL || config.jira?.baseUrl;
79
+ const token = process.env.JIRA_TOKEN || config.jira?.token;
80
+ const email = process.env.JIRA_EMAIL || config.jira?.email;
81
+ if (!base || !token) return null;
82
+
83
+ try {
84
+ const creds = Buffer.from(`${email}:${token}`).toString("base64");
85
+ const resp = await httpsGet(`${base}/rest/api/3/issue/${ticketId}`, {
86
+ "Authorization": `Basic ${creds}`,
87
+ });
88
+ if (resp.status === 200) {
89
+ return {
90
+ id: ticketId,
91
+ title: resp.body.fields?.summary || ticketId,
92
+ status: resp.body.fields?.status?.name || "unknown",
93
+ url: `${base}/browse/${ticketId}`,
94
+ };
95
+ }
96
+ } catch {}
97
+ return { id: ticketId, title: ticketId, status: "unknown", url: null };
98
+ }
99
+
100
+ async function fetchLinearTicket(ticketId, config) {
101
+ const apiKey = process.env.LINEAR_API_KEY || config.linear?.apiKey;
102
+ if (!apiKey) return null;
103
+
104
+ try {
105
+ const query = JSON.stringify({
106
+ query: `{ issue(id: "${ticketId}") { title state { name } url } }`
107
+ });
108
+ const resp = await new Promise((resolve, reject) => {
109
+ const body = query;
110
+ const req = https.request({
111
+ hostname: "api.linear.app",
112
+ path: "/graphql",
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json", "Authorization": apiKey, "Content-Length": Buffer.byteLength(body) },
115
+ }, (res) => {
116
+ let data = "";
117
+ res.on("data", d => (data += d));
118
+ res.on("end", () => resolve(JSON.parse(data)));
119
+ });
120
+ req.on("error", reject);
121
+ req.write(body);
122
+ req.end();
123
+ });
124
+ const issue = resp.data?.issue;
125
+ if (issue) return { id: ticketId, title: issue.title, status: issue.state?.name || "unknown", url: issue.url };
126
+ } catch {}
127
+ return { id: ticketId, title: ticketId, status: "unknown", url: null };
128
+ }
129
+
130
+ async function fetchGithubIssue(issueNum, config) {
131
+ const token = process.env.GITHUB_TOKEN || config.github?.token;
132
+ const repo = process.env.GITHUB_REPOSITORY || config.github?.repo;
133
+ if (!repo) return null;
134
+
135
+ try {
136
+ const headers = { "Authorization": token ? `Bearer ${token}` : undefined };
137
+ const resp = await httpsGet(`https://api.github.com/repos/${repo}/issues/${issueNum}`, headers);
138
+ if (resp.status === 200) {
139
+ return {
140
+ id: `#${issueNum}`,
141
+ title: resp.body.title || `Issue #${issueNum}`,
142
+ status: resp.body.state || "unknown",
143
+ url: resp.body.html_url,
144
+ };
145
+ }
146
+ } catch {}
147
+ return { id: `#${issueNum}`, title: `Issue #${issueNum}`, status: "unknown", url: null };
148
+ }
149
+
150
+ // ── Sub-commands ──────────────────────────────────────────────────────────────
151
+
152
+ async function subcmdAdd(args, infernoDir, config) {
153
+ const jsonMode = args.includes("--json");
154
+ const capIdx = args.indexOf("--capability");
155
+ const jiraIdx = args.indexOf("--jira");
156
+ const linearIdx = args.indexOf("--linear");
157
+ const githubIdx = args.indexOf("--github");
158
+
159
+ const capability = capIdx !== -1 ? args[capIdx + 1] : null;
160
+ const jiraId = jiraIdx !== -1 ? args[jiraIdx + 1] : null;
161
+ const linearId = linearIdx !== -1 ? args[linearIdx + 1] : null;
162
+ const githubNum = githubIdx !== -1 ? args[githubIdx + 1] : null;
163
+
164
+ if (!capability) {
165
+ const msg = "Usage: infernoflow link --capability <id> --jira <TICKET> | --linear <ID> | --github <NUM>";
166
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
167
+ return;
168
+ }
169
+
170
+ let ticket = null;
171
+ let platform = null;
172
+
173
+ if (jiraId) {
174
+ if (!jsonMode) process.stdout.write(` Fetching Jira ${jiraId}… `);
175
+ ticket = await fetchJiraTicket(jiraId, config);
176
+ platform = "jira";
177
+ } else if (linearId) {
178
+ if (!jsonMode) process.stdout.write(` Fetching Linear ${linearId}… `);
179
+ ticket = await fetchLinearTicket(linearId, config);
180
+ platform = "linear";
181
+ } else if (githubNum) {
182
+ if (!jsonMode) process.stdout.write(` Fetching GitHub #${githubNum}… `);
183
+ ticket = await fetchGithubIssue(githubNum, config);
184
+ platform = "github";
185
+ } else {
186
+ const msg = "Specify --jira, --linear, or --github";
187
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
188
+ return;
189
+ }
190
+
191
+ const links = readLinks(infernoDir);
192
+ const existing = links.findIndex(l => l.capability === capability);
193
+
194
+ const link = {
195
+ capability,
196
+ platform,
197
+ ticketId: ticket?.id || jiraId || linearId || `#${githubNum}`,
198
+ title: ticket?.title || "",
199
+ status: ticket?.status || "unknown",
200
+ url: ticket?.url || null,
201
+ linkedAt: new Date().toISOString(),
202
+ };
203
+
204
+ if (existing !== -1) {
205
+ links[existing] = link;
206
+ } else {
207
+ links.push(link);
208
+ }
209
+
210
+ writeLinks(infernoDir, links);
211
+
212
+ if (!jsonMode) console.log(green("done"));
213
+
214
+ if (jsonMode) {
215
+ console.log(JSON.stringify({ ok: true, link }));
216
+ } else {
217
+ done(`Linked: ${bold(capability)} → ${cyan(link.ticketId)} (${link.status})`);
218
+ if (link.url) console.log(` ${gray(link.url)}`);
219
+ console.log();
220
+ }
221
+ }
222
+
223
+ async function subcmdList(args, infernoDir) {
224
+ const jsonMode = args.includes("--json");
225
+ const links = readLinks(infernoDir);
226
+
227
+ if (jsonMode) {
228
+ console.log(JSON.stringify({ ok: true, links }));
229
+ return;
230
+ }
231
+
232
+ if (!links.length) {
233
+ info("No links yet. Use: infernoflow link --capability <id> --jira <TICKET>");
234
+ return;
235
+ }
236
+
237
+ console.log();
238
+ console.log(` ${bold(`${links.length} capability link${links.length !== 1 ? "s" : ""}`)}`);
239
+ console.log();
240
+
241
+ const w = Math.max(...links.map(l => l.capability.length), 10) + 2;
242
+ for (const l of links) {
243
+ const statusColor = l.status?.toLowerCase() === "done" || l.status?.toLowerCase() === "closed"
244
+ ? green : l.status?.toLowerCase() === "in progress" ? yellow : gray;
245
+ console.log(` ${bold(l.capability.padEnd(w))} ${cyan(l.ticketId.padEnd(14))} ${statusColor(l.status || "unknown")}`);
246
+ if (l.title && l.title !== l.ticketId) console.log(` ${" ".repeat(w + 2)}${gray(l.title)}`);
247
+ }
248
+ console.log();
249
+ }
250
+
251
+ async function subcmdStatus(args, infernoDir) {
252
+ const jsonMode = args.includes("--json");
253
+ const links = readLinks(infernoDir);
254
+
255
+ // Load contract to find unlinked capabilities
256
+ let contract = null;
257
+ for (const f of ["contract.json", "capabilities.json"]) {
258
+ const p = path.join(infernoDir, f);
259
+ if (fs.existsSync(p)) { try { contract = JSON.parse(fs.readFileSync(p, "utf8")); break; } catch {} }
260
+ }
261
+ const allCaps = (contract?.capabilities || []).map(c => typeof c === "string" ? c : c.id);
262
+ const linkedIds = new Set(links.map(l => l.capability));
263
+ const unlinked = allCaps.filter(id => !linkedIds.has(id));
264
+
265
+ if (jsonMode) {
266
+ console.log(JSON.stringify({ ok: true, linked: links.length, unlinked: unlinked.length, links, unlinkedCapabilities: unlinked }));
267
+ return;
268
+ }
269
+
270
+ console.log();
271
+ console.log(` ${bold("Capability link status")}`);
272
+ console.log();
273
+
274
+ if (links.length) {
275
+ console.log(` ${gray("Linked:")}`);
276
+ for (const l of links) {
277
+ const icon = l.status?.toLowerCase() === "done" ? green("✔") : l.status?.toLowerCase() === "in progress" ? yellow("⟳") : gray("○");
278
+ console.log(` ${icon} ${bold(l.capability)} ${cyan(l.ticketId)} ${gray(l.status || "")}`);
279
+ }
280
+ console.log();
281
+ }
282
+
283
+ if (unlinked.length) {
284
+ console.log(` ${gray("Unlinked capabilities:")}`);
285
+ unlinked.forEach(id => console.log(` ${gray("·")} ${id}`));
286
+ console.log();
287
+ }
288
+
289
+ console.log(` ${green(String(links.length))} linked · ${gray(String(unlinked.length))} unlinked`);
290
+ console.log();
291
+ }
292
+
293
+ async function subcmdRemove(args, infernoDir) {
294
+ const jsonMode = args.includes("--json");
295
+ const capIdx = args.indexOf("--capability");
296
+ const capId = capIdx !== -1 ? args[capIdx + 1] : null;
297
+
298
+ if (!capId) {
299
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "Usage: infernoflow link remove --capability <id>" })); }
300
+ else { warn("Usage: infernoflow link remove --capability <id>"); }
301
+ return;
302
+ }
303
+
304
+ const links = readLinks(infernoDir);
305
+ const before = links.length;
306
+ const updated = links.filter(l => l.capability !== capId);
307
+
308
+ if (updated.length === before) {
309
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: `No link found for: ${capId}` })); }
310
+ else { warn(`No link found for: ${capId}`); }
311
+ return;
312
+ }
313
+
314
+ writeLinks(infernoDir, updated);
315
+ if (jsonMode) { console.log(JSON.stringify({ ok: true, removed: capId })); }
316
+ else { done(`Removed link for ${bold(capId)}`); console.log(); }
317
+ }
318
+
319
+ // ── Entry ─────────────────────────────────────────────────────────────────────
320
+
321
+ export async function linkCommand(rawArgs) {
322
+ const args = rawArgs.slice(1);
323
+ const cwd = process.cwd();
324
+ const infernoDir = path.join(cwd, "inferno");
325
+
326
+ if (!fs.existsSync(infernoDir)) {
327
+ const msg = "inferno/ not found. Run: infernoflow init";
328
+ if (args.includes("--json")) { console.log(JSON.stringify({ ok: false, error: msg })); }
329
+ else { warn(msg); }
330
+ process.exit(1);
331
+ }
332
+
333
+ const config = readIntegrationConfig(infernoDir);
334
+ const subcmd = args[0];
335
+
336
+ if (subcmd === "list") return subcmdList(args.slice(1), infernoDir);
337
+ if (subcmd === "status") return subcmdStatus(args.slice(1), infernoDir);
338
+ if (subcmd === "remove") return subcmdRemove(args.slice(1), infernoDir);
339
+
340
+ // Default: add a link
341
+ return subcmdAdd(args, infernoDir, config);
342
+ }