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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * infernoflow health
3
+ *
4
+ * Computes a weighted 0–100 health score for the capability contract.
5
+ * Breaks it down across five dimensions so you know exactly where to improve.
6
+ *
7
+ * Dimensions:
8
+ * Coverage % of capabilities that have descriptions (weight 25)
9
+ * Documentation % with at least one scenario/test (weight 20)
10
+ * Freshness How recently the contract was updated (weight 20)
11
+ * Completeness Version field, owner, tags present (weight 15)
12
+ * Drift risk Open issues from `infernoflow check` (weight 20)
13
+ *
14
+ * Usage:
15
+ * infernoflow health Print score + breakdown
16
+ * infernoflow health --json Machine-readable
17
+ * infernoflow health --fail-below 70 Exit 1 if score < 70 (CI gate)
18
+ * infernoflow health --watch Re-run every 30s (for terminals)
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { execSync } from "node:child_process";
24
+ import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
25
+
26
+ const WEIGHTS = {
27
+ coverage: 25,
28
+ documentation: 20,
29
+ freshness: 20,
30
+ completeness: 15,
31
+ drift: 20,
32
+ };
33
+
34
+ // ── Readers ───────────────────────────────────────────────────────────────────
35
+
36
+ function readContract(infernoDir) {
37
+ for (const f of ["contract.json", "capabilities.json"]) {
38
+ const p = path.join(infernoDir, f);
39
+ if (!fs.existsSync(p)) continue;
40
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function readScenarios(infernoDir) {
46
+ const scenDir = path.join(infernoDir, "scenarios");
47
+ if (!fs.existsSync(scenDir)) return [];
48
+ return fs.readdirSync(scenDir)
49
+ .filter(f => f.endsWith(".json"))
50
+ .flatMap(f => {
51
+ try {
52
+ const data = JSON.parse(fs.readFileSync(path.join(scenDir, f), "utf8"));
53
+ return data.capability ? [data.capability] : (data.capabilities || []);
54
+ } catch { return []; }
55
+ });
56
+ }
57
+
58
+ function runCheck(cwd) {
59
+ try {
60
+ const out = execSync("npx infernoflow check --json", {
61
+ cwd, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"],
62
+ });
63
+ return JSON.parse(out);
64
+ } catch (err) {
65
+ try { return JSON.parse(err.stdout || "{}"); } catch { return {}; }
66
+ }
67
+ }
68
+
69
+ function lastModifiedDaysAgo(infernoDir) {
70
+ const files = ["contract.json", "capabilities.json"]
71
+ .map(f => path.join(infernoDir, f))
72
+ .filter(fs.existsSync);
73
+ if (!files.length) return 999;
74
+ const mtime = Math.max(...files.map(f => fs.statSync(f).mtimeMs));
75
+ return (Date.now() - mtime) / (1000 * 60 * 60 * 24);
76
+ }
77
+
78
+ // ── Scorers ───────────────────────────────────────────────────────────────────
79
+
80
+ function scoreCoverage(caps) {
81
+ if (!caps.length) return { score: 0, detail: "No capabilities" };
82
+ const withDesc = caps.filter(c => c.description && c.description.length > 5).length;
83
+ const pct = Math.round((withDesc / caps.length) * 100);
84
+ return {
85
+ score: pct,
86
+ detail: `${withDesc}/${caps.length} capabilities have descriptions`,
87
+ pct,
88
+ };
89
+ }
90
+
91
+ function scoreDocumentation(caps, scenarioCaps) {
92
+ if (!caps.length) return { score: 0, detail: "No capabilities" };
93
+ const scenSet = new Set(scenarioCaps.map(s => String(s).toLowerCase()));
94
+ const withScen = caps.filter(c => scenSet.has(c.id.toLowerCase())).length;
95
+ const pct = Math.round((withScen / caps.length) * 100);
96
+ return {
97
+ score: Math.min(100, pct + (scenSet.size === 0 ? 0 : 10)), // bonus for having any scenarios
98
+ detail: `${withScen}/${caps.length} capabilities have test scenarios`,
99
+ pct,
100
+ };
101
+ }
102
+
103
+ function scoreFreshness(daysAgo) {
104
+ let score;
105
+ let label;
106
+ if (daysAgo <= 1) { score = 100; label = "updated today"; }
107
+ else if (daysAgo <= 3) { score = 95; label = `updated ${Math.round(daysAgo)}d ago`; }
108
+ else if (daysAgo <= 7) { score = 85; label = `updated ${Math.round(daysAgo)}d ago`; }
109
+ else if (daysAgo <= 14) { score = 70; label = `updated ${Math.round(daysAgo)}d ago`; }
110
+ else if (daysAgo <= 30) { score = 50; label = `updated ${Math.round(daysAgo)}d ago`; }
111
+ else if (daysAgo <= 60) { score = 30; label = `updated ${Math.round(daysAgo)}d ago — stale`; }
112
+ else { score = 10; label = `not updated in ${Math.round(daysAgo)}d — very stale`; }
113
+ return { score, detail: label };
114
+ }
115
+
116
+ function scoreCompleteness(caps, contract) {
117
+ if (!caps.length) return { score: 0, detail: "No capabilities" };
118
+ const hasVersion = !!(contract?.version || contract?.contractVersion);
119
+ const withTags = caps.filter(c => c.tags?.length).length;
120
+ const withOwner = caps.filter(c => c.owner).length;
121
+ const withSince = caps.filter(c => c.since).length;
122
+
123
+ const tagPct = Math.round((withTags / caps.length) * 100);
124
+ const ownerPct = Math.round((withOwner / caps.length) * 100);
125
+ const sincePct = Math.round((withSince / caps.length) * 100);
126
+
127
+ const score = Math.round(
128
+ (hasVersion ? 20 : 0) +
129
+ tagPct * 0.3 +
130
+ ownerPct * 0.3 +
131
+ sincePct * 0.2
132
+ );
133
+
134
+ return {
135
+ score: Math.min(100, score),
136
+ detail: `version: ${hasVersion ? "✓" : "✗"}, tags: ${tagPct}%, owner: ${ownerPct}%, since: ${sincePct}%`,
137
+ };
138
+ }
139
+
140
+ function scoreDrift(checkResult) {
141
+ const issues = checkResult?.issues || [];
142
+ const warnings = issues.filter(i => (i.severity || i.level || "error") === "warning").length;
143
+ const errors = issues.filter(i => (i.severity || i.level || "error") === "error").length;
144
+ const status = checkResult?.status;
145
+
146
+ if (status === "ok" || (!errors && !warnings)) return { score: 100, detail: "No issues found" };
147
+
148
+ const score = Math.max(0, 100 - (errors * 20) - (warnings * 8));
149
+ return {
150
+ score,
151
+ detail: `${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}`,
152
+ };
153
+ }
154
+
155
+ // ── Aggregate ─────────────────────────────────────────────────────────────────
156
+
157
+ function computeHealth(infernoDir, cwd) {
158
+ const contract = readContract(infernoDir);
159
+ const caps = (contract?.capabilities || []).map(c =>
160
+ typeof c === "string" ? { id: c, description: "", tags: [], owner: "", since: "" } : c
161
+ );
162
+ const scenarioCaps = readScenarios(infernoDir);
163
+ const daysAgo = lastModifiedDaysAgo(infernoDir);
164
+ const checkResult = runCheck(cwd);
165
+
166
+ const dimensions = {
167
+ coverage: scoreCoverage(caps),
168
+ documentation: scoreDocumentation(caps, scenarioCaps),
169
+ freshness: scoreFreshness(daysAgo),
170
+ completeness: scoreCompleteness(caps, contract),
171
+ drift: scoreDrift(checkResult),
172
+ };
173
+
174
+ const totalScore = Math.round(
175
+ Object.entries(dimensions).reduce((sum, [key, dim]) => {
176
+ return sum + (dim.score * WEIGHTS[key]) / 100;
177
+ }, 0)
178
+ );
179
+
180
+ return { totalScore, dimensions, caps, daysAgo, checkResult };
181
+ }
182
+
183
+ // ── Renderer ──────────────────────────────────────────────────────────────────
184
+
185
+ function scoreColor(score) {
186
+ if (score >= 80) return green;
187
+ if (score >= 60) return yellow;
188
+ return red;
189
+ }
190
+
191
+ function scoreGrade(score) {
192
+ if (score >= 90) return "A";
193
+ if (score >= 80) return "B";
194
+ if (score >= 70) return "C";
195
+ if (score >= 60) return "D";
196
+ return "F";
197
+ }
198
+
199
+ function barOf(score, width = 30) {
200
+ const filled = Math.round((score / 100) * width);
201
+ const empty = width - filled;
202
+ return "█".repeat(filled) + "░".repeat(empty);
203
+ }
204
+
205
+ function printReport(totalScore, dimensions) {
206
+ const col = scoreColor(totalScore);
207
+ const grade = scoreGrade(totalScore);
208
+
209
+ console.log();
210
+ console.log(` ${bold("🔥 infernoflow health score")}`);
211
+ console.log();
212
+ console.log(` ${col(bold(String(totalScore)))} / 100 ${col(bold(grade))} ${col(barOf(totalScore))}`);
213
+ console.log();
214
+
215
+ const dimNames = {
216
+ coverage: "Coverage ",
217
+ documentation: "Docs/Tests ",
218
+ freshness: "Freshness ",
219
+ completeness: "Completeness ",
220
+ drift: "Drift risk ",
221
+ };
222
+
223
+ for (const [key, dim] of Object.entries(dimensions)) {
224
+ const w = WEIGHTS[key];
225
+ const sc = dim.score;
226
+ const c = scoreColor(sc);
227
+ const bar = barOf(sc, 20);
228
+ const weighted = Math.round((sc * w) / 100);
229
+ console.log(
230
+ ` ${bold(dimNames[key])} ${c(String(sc).padStart(3))} ${c(bar)} ${gray(`×${w}% = ${weighted}pts ${dim.detail}`)}`
231
+ );
232
+ }
233
+ console.log();
234
+ }
235
+
236
+ function printTips(totalScore, dimensions) {
237
+ const tips = [];
238
+
239
+ if (dimensions.coverage.score < 70)
240
+ tips.push("Add descriptions to your capabilities in contract.json");
241
+ if (dimensions.documentation.score < 60)
242
+ tips.push("Create scenario files in inferno/scenarios/ for each capability");
243
+ if (dimensions.freshness.score < 70)
244
+ tips.push("Run `infernoflow suggest` to sync recent changes to the contract");
245
+ if (dimensions.completeness.score < 60)
246
+ tips.push("Add version, tags, owner, and since fields to your capabilities");
247
+ if (dimensions.drift.score < 80)
248
+ tips.push("Run `infernoflow check` and fix the reported issues");
249
+
250
+ if (tips.length) {
251
+ console.log(` ${bold("Tips to improve:")}`);
252
+ tips.forEach(t => console.log(` ${yellow("·")} ${t}`));
253
+ console.log();
254
+ }
255
+ }
256
+
257
+ // ── Entry ─────────────────────────────────────────────────────────────────────
258
+
259
+ export async function healthCommand(rawArgs) {
260
+ const args = rawArgs.slice(1);
261
+ const jsonMode = args.includes("--json");
262
+ const watchMode = args.includes("--watch");
263
+ const cwd = process.cwd();
264
+ const infernoDir = path.join(cwd, "inferno");
265
+
266
+ if (!fs.existsSync(infernoDir)) {
267
+ const msg = "inferno/ not found. Run: infernoflow init";
268
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
269
+ else warn(msg);
270
+ process.exit(1);
271
+ }
272
+
273
+ const failBelowIdx = args.indexOf("--fail-below");
274
+ const failBelow = failBelowIdx !== -1 ? parseInt(args[failBelowIdx + 1], 10) : null;
275
+
276
+ const intervalIdx = args.indexOf("--interval");
277
+ const intervalSecs = intervalIdx !== -1 ? parseInt(args[intervalIdx + 1], 10) : 30;
278
+
279
+ const runOnce = () => {
280
+ const { totalScore, dimensions } = computeHealth(infernoDir, cwd);
281
+
282
+ if (jsonMode) {
283
+ const dimFlat = Object.fromEntries(
284
+ Object.entries(dimensions).map(([k, v]) => [k, { score: v.score, detail: v.detail, weight: WEIGHTS[k] }])
285
+ );
286
+ console.log(JSON.stringify({ ok: true, score: totalScore, grade: scoreGrade(totalScore), dimensions: dimFlat }));
287
+ } else {
288
+ if (watchMode) process.stdout.write("\x1Bc"); // clear screen
289
+ printReport(totalScore, dimensions);
290
+ if (!watchMode) printTips(totalScore, dimensions);
291
+ }
292
+
293
+ if (failBelow !== null && totalScore < failBelow) {
294
+ if (!jsonMode) console.error(red(` ✗ Score ${totalScore} is below threshold ${failBelow} — failing.\n`));
295
+ process.exit(1);
296
+ }
297
+
298
+ return totalScore;
299
+ };
300
+
301
+ if (watchMode) {
302
+ if (!jsonMode) info(`Watching health score every ${intervalSecs}s — press Ctrl+C to stop`);
303
+ runOnce();
304
+ setInterval(runOnce, intervalSecs * 1000);
305
+ await new Promise(() => {});
306
+ } else {
307
+ runOnce();
308
+ }
309
+ }
@@ -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
+ }