infernoflow 0.12.0 → 0.14.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,388 @@
1
+ /**
2
+ * infernoflow team-sync
3
+ *
4
+ * Shared capability contract sync across a team.
5
+ * Uses a dedicated git branch (`inferno-contracts`) as the source of truth.
6
+ *
7
+ * Sub-commands:
8
+ * infernoflow team-sync push — push local contract to shared branch
9
+ * infernoflow team-sync pull — pull shared contract, detect conflicts
10
+ * infernoflow team-sync status — show diff between local and shared
11
+ * infernoflow team-sync init — create the shared branch if it doesn't exist
12
+ *
13
+ * Flags:
14
+ * --branch <name> Shared branch name (default: inferno-contracts)
15
+ * --remote <name> Git remote (default: origin)
16
+ * --json Machine-readable output
17
+ * --force Overwrite conflicts without prompting
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { execSync } from "node:child_process";
23
+ import { header, ok, warn, info, done, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
24
+
25
+ // ── git helpers ───────────────────────────────────────────────────────────────
26
+
27
+ function capture(cmd, cwd) {
28
+ try {
29
+ return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
30
+ } catch { return null; }
31
+ }
32
+
33
+ function run(cmd, cwd) {
34
+ execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
35
+ }
36
+
37
+ function currentBranch(cwd) {
38
+ return capture("git rev-parse --abbrev-ref HEAD", cwd) || "HEAD";
39
+ }
40
+
41
+ function currentUser(cwd) {
42
+ return capture("git config user.name", cwd) || capture("git config user.email", cwd) || "unknown";
43
+ }
44
+
45
+ function hasRemote(remote, cwd) {
46
+ return !!capture(`git remote get-url ${remote}`, cwd);
47
+ }
48
+
49
+ function branchExistsRemote(remote, branch, cwd) {
50
+ return !!capture(`git ls-remote --heads ${remote} refs/heads/${branch}`, cwd);
51
+ }
52
+
53
+ // ── capability helpers ────────────────────────────────────────────────────────
54
+
55
+ function parseCaps(jsonText) {
56
+ if (!jsonText) return [];
57
+ try {
58
+ const obj = JSON.parse(jsonText);
59
+ const raw = obj.capabilities || [];
60
+ return raw.map(c => typeof c === "string" ? { id: c, title: c } : c);
61
+ } catch { return []; }
62
+ }
63
+
64
+ function capsToMap(caps) {
65
+ return new Map(caps.map(c => [c.id, c]));
66
+ }
67
+
68
+ function detectConflicts(local, shared, base) {
69
+ // A conflict occurs when BOTH local and shared changed the same capability
70
+ // since the last sync (base).
71
+ const localMap = capsToMap(local);
72
+ const sharedMap = capsToMap(shared);
73
+ const baseMap = capsToMap(base);
74
+
75
+ const conflicts = [];
76
+ const localOnly = [];
77
+ const sharedOnly = [];
78
+
79
+ const allIds = new Set([...localMap.keys(), ...sharedMap.keys(), ...baseMap.keys()]);
80
+
81
+ for (const id of allIds) {
82
+ const localCap = localMap.get(id);
83
+ const sharedCap = sharedMap.get(id);
84
+ const baseCap = baseMap.get(id);
85
+
86
+ const localChanged = JSON.stringify(localCap) !== JSON.stringify(baseCap);
87
+ const sharedChanged = JSON.stringify(sharedCap) !== JSON.stringify(baseCap);
88
+
89
+ if (localChanged && sharedChanged && JSON.stringify(localCap) !== JSON.stringify(sharedCap)) {
90
+ conflicts.push({ id, local: localCap, shared: sharedCap, base: baseCap });
91
+ } else if (localCap && !sharedCap && !baseCap) {
92
+ localOnly.push(localCap); // added locally, not in shared yet
93
+ } else if (!localCap && sharedCap && !baseCap) {
94
+ sharedOnly.push(sharedCap); // added in shared, not locally yet
95
+ }
96
+ }
97
+
98
+ return { conflicts, localOnly, sharedOnly };
99
+ }
100
+
101
+ // ── shared branch operations ──────────────────────────────────────────────────
102
+
103
+ function readContractFromBranch(remote, branch, cwd) {
104
+ // Fetch the branch first
105
+ try { run(`git fetch ${remote} ${branch} --quiet`, cwd); } catch {}
106
+
107
+ const content = capture(`git show ${remote}/${branch}:inferno/contract.json`, cwd);
108
+ if (!content) return null;
109
+ try { return JSON.parse(content); } catch { return null; }
110
+ }
111
+
112
+ function readLastSyncBase(infernoDir) {
113
+ const basePath = path.join(infernoDir, ".team-sync-base.json");
114
+ if (!fs.existsSync(basePath)) return null;
115
+ try { return JSON.parse(fs.readFileSync(basePath, "utf8")); } catch { return null; }
116
+ }
117
+
118
+ function writeLastSyncBase(infernoDir, contract) {
119
+ const basePath = path.join(infernoDir, ".team-sync-base.json");
120
+ fs.writeFileSync(basePath, JSON.stringify(contract, null, 2), "utf8");
121
+ }
122
+
123
+ // ── sub-commands ──────────────────────────────────────────────────────────────
124
+
125
+ function initSharedBranch(cwd, remote, branch, infernoDir, asJson) {
126
+ if (!hasRemote(remote, cwd)) {
127
+ const msg = `Remote "${remote}" not found. Add it first: git remote add ${remote} <url>`;
128
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
129
+ warn(msg); process.exit(1);
130
+ }
131
+
132
+ if (branchExistsRemote(remote, branch, cwd)) {
133
+ if (asJson) { console.log(JSON.stringify({ ok: true, action: "already_exists", branch })); }
134
+ else { ok(`Shared branch ${bold(branch)} already exists on ${remote}`); }
135
+ return;
136
+ }
137
+
138
+ // Create orphan branch with just the contract
139
+ const contractPath = path.join(infernoDir, "contract.json");
140
+ if (!fs.existsSync(contractPath)) {
141
+ const msg = "inferno/contract.json not found — run: infernoflow init";
142
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
143
+ warn(msg); process.exit(1);
144
+ }
145
+
146
+ // Use a temp worktree approach: push contract.json to the branch
147
+ const tmpDir = path.join(infernoDir, ".team-sync-tmp");
148
+ try {
149
+ fs.mkdirSync(tmpDir, { recursive: true });
150
+ const contractContent = fs.readFileSync(contractPath, "utf8");
151
+ fs.writeFileSync(path.join(tmpDir, "contract.json"), contractContent);
152
+
153
+ // Create an empty tree commit on the shared branch
154
+ run(`git checkout --orphan ${branch}`, cwd);
155
+ run(`git rm -rf . --quiet 2>/dev/null || true`, cwd);
156
+ run(`git checkout ${currentBranch(cwd)} -- inferno/contract.json`, cwd);
157
+ run(`git add inferno/contract.json`, cwd);
158
+ run(`git commit -m "infernoflow: initialize shared contract branch"`, cwd);
159
+ run(`git push ${remote} ${branch}`, cwd);
160
+ run(`git checkout -`, cwd); // back to previous branch
161
+ } catch (err) {
162
+ // Restore original branch on failure
163
+ try { run(`git checkout -`, cwd); } catch {}
164
+ const msg = `Failed to create shared branch: ${err.message}`;
165
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
166
+ warn(msg); process.exit(1);
167
+ } finally {
168
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
169
+ }
170
+
171
+ if (asJson) { console.log(JSON.stringify({ ok: true, action: "created", branch, remote })); }
172
+ else { done(`Shared branch ${bold(branch)} created on ${bold(remote)}`); }
173
+ }
174
+
175
+ function pushToShared(cwd, remote, branch, infernoDir, asJson, force) {
176
+ const contractPath = path.join(infernoDir, "contract.json");
177
+ if (!fs.existsSync(contractPath)) {
178
+ const msg = "inferno/contract.json not found";
179
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
180
+ warn(msg); process.exit(1);
181
+ }
182
+
183
+ const localContract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
184
+ const user = currentUser(cwd);
185
+
186
+ // Stamp the push metadata
187
+ localContract._teamSync = {
188
+ pushedBy: user,
189
+ pushedAt: new Date().toISOString(),
190
+ fromBranch: currentBranch(cwd),
191
+ };
192
+
193
+ // Write updated contract
194
+ fs.writeFileSync(contractPath, JSON.stringify(localContract, null, 2), "utf8");
195
+
196
+ // Commit + push to shared branch
197
+ try {
198
+ run(`git fetch ${remote} ${branch} --quiet`, cwd);
199
+ // Use a temporary stash-push approach: push just the contract file
200
+ capture(`git stash --quiet`, cwd);
201
+ try {
202
+ run(`git checkout ${remote}/${branch} -- inferno/contract.json 2>/dev/null || git checkout ${remote}/${branch} inferno/contract.json`, cwd);
203
+ } catch {}
204
+ capture(`git stash pop --quiet`, cwd);
205
+
206
+ // Write the updated content
207
+ fs.writeFileSync(contractPath, JSON.stringify(localContract, null, 2), "utf8");
208
+ run(`git add inferno/contract.json`, cwd);
209
+ run(`git commit -m "infernoflow team-sync: push by ${user}"`, cwd);
210
+ run(`git push ${remote} HEAD:${branch}`, cwd);
211
+
212
+ // Save base snapshot
213
+ writeLastSyncBase(infernoDir, localContract);
214
+
215
+ if (asJson) { console.log(JSON.stringify({ ok: true, action: "pushed", remote, branch, user })); }
216
+ else { done(`Contract pushed to ${bold(remote + "/" + branch)} by ${bold(user)}`); }
217
+ } catch (err) {
218
+ const msg = `Push failed: ${err.message}`;
219
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
220
+ warn(msg);
221
+ info(`Try: git push ${remote} HEAD:${branch} --force (use --force flag)`);
222
+ process.exit(1);
223
+ }
224
+ }
225
+
226
+ function pullFromShared(cwd, remote, branch, infernoDir, asJson, force) {
227
+ const contractPath = path.join(infernoDir, "contract.json");
228
+
229
+ // Fetch remote contract
230
+ const sharedContract = readContractFromBranch(remote, branch, cwd);
231
+ if (!sharedContract) {
232
+ const msg = `Could not read contract from ${remote}/${branch}. Run: infernoflow team-sync init`;
233
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
234
+ warn(msg); process.exit(1);
235
+ }
236
+
237
+ const localContract = fs.existsSync(contractPath)
238
+ ? JSON.parse(fs.readFileSync(contractPath, "utf8")) : { capabilities: [] };
239
+
240
+ const baseContract = readLastSyncBase(infernoDir) || { capabilities: [] };
241
+
242
+ const localCaps = parseCaps(JSON.stringify(localContract));
243
+ const sharedCaps = parseCaps(JSON.stringify(sharedContract));
244
+ const baseCaps = parseCaps(JSON.stringify(baseContract));
245
+
246
+ const { conflicts, localOnly, sharedOnly } = detectConflicts(localCaps, sharedCaps, baseCaps);
247
+
248
+ if (conflicts.length > 0 && !force) {
249
+ if (asJson) {
250
+ console.log(JSON.stringify({ ok: false, error: "conflicts_detected", conflicts, hint: "Use --force to overwrite with remote version" }));
251
+ process.exit(1);
252
+ }
253
+ warn(`${conflicts.length} capability conflict${conflicts.length !== 1 ? "s" : ""} detected:\n`);
254
+ for (const c of conflicts) {
255
+ console.log(` ${red("✗")} ${bold(c.id)}`);
256
+ console.log(` local: ${gray(c.local?.title || "(removed)")}`);
257
+ console.log(` shared: ${gray(c.shared?.title || "(removed)")}`);
258
+ }
259
+ console.log();
260
+ warn("Resolve conflicts manually or use --force to take the shared version");
261
+ process.exit(1);
262
+ }
263
+
264
+ // Merge: take shared as base, apply localOnly additions
265
+ const merged = { ...sharedContract };
266
+ const mergedCaps = [...sharedCaps];
267
+ for (const cap of localOnly) mergedCaps.push(cap);
268
+ merged.capabilities = mergedCaps;
269
+ delete merged._teamSync;
270
+
271
+ fs.writeFileSync(contractPath, JSON.stringify(merged, null, 2), "utf8");
272
+ writeLastSyncBase(infernoDir, merged);
273
+
274
+ if (asJson) {
275
+ console.log(JSON.stringify({
276
+ ok: true, action: "pulled", remote, branch,
277
+ conflicts: conflicts.length,
278
+ localOnly: localOnly.length,
279
+ sharedOnly: sharedOnly.length,
280
+ }));
281
+ } else {
282
+ console.log();
283
+ ok("Contract updated from shared branch");
284
+ if (conflicts.length > 0) warn(`${conflicts.length} conflict(s) resolved with --force (shared version wins)`);
285
+ if (localOnly.length > 0) ok(`${localOnly.length} local capability(-ies) preserved`);
286
+ if (sharedOnly.length > 0) ok(`${sharedOnly.length} new capability(-ies) pulled from shared`);
287
+ if (conflicts.length === 0 && localOnly.length === 0 && sharedOnly.length === 0) {
288
+ info("Already in sync — no changes");
289
+ }
290
+ console.log();
291
+ }
292
+ }
293
+
294
+ function showStatus(cwd, remote, branch, infernoDir, asJson) {
295
+ const contractPath = path.join(infernoDir, "contract.json");
296
+
297
+ try { run(`git fetch ${remote} ${branch} --quiet`, cwd); } catch {}
298
+
299
+ const sharedContract = readContractFromBranch(remote, branch, cwd);
300
+ if (!sharedContract) {
301
+ const msg = `Shared branch ${remote}/${branch} not found. Run: infernoflow team-sync init`;
302
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
303
+ warn(msg); process.exit(1);
304
+ }
305
+
306
+ const localContract = fs.existsSync(contractPath)
307
+ ? JSON.parse(fs.readFileSync(contractPath, "utf8")) : { capabilities: [] };
308
+
309
+ const localCaps = parseCaps(JSON.stringify(localContract));
310
+ const sharedCaps = parseCaps(JSON.stringify(sharedContract));
311
+
312
+ const localMap = capsToMap(localCaps);
313
+ const sharedMap = capsToMap(sharedCaps);
314
+
315
+ const onlyLocal = localCaps.filter(c => !sharedMap.has(c.id));
316
+ const onlyShared = sharedCaps.filter(c => !localMap.has(c.id));
317
+ const inSync = onlyLocal.length === 0 && onlyShared.length === 0;
318
+ const pushedBy = sharedContract._teamSync?.pushedBy || "unknown";
319
+ const pushedAt = sharedContract._teamSync?.pushedAt || "unknown";
320
+
321
+ if (asJson) {
322
+ console.log(JSON.stringify({
323
+ ok: true, inSync,
324
+ local: localCaps.length, shared: sharedCaps.length,
325
+ onlyLocal: onlyLocal.map(c => c.id),
326
+ onlyShared: onlyShared.map(c => c.id),
327
+ lastPush: { by: pushedBy, at: pushedAt },
328
+ }));
329
+ return;
330
+ }
331
+
332
+ console.log();
333
+ console.log(` Shared branch ${bold(cyan(remote + "/" + branch))}`);
334
+ console.log(` Last push ${bold(pushedBy)} ${gray(pushedAt.slice(0, 19).replace("T", " "))}`);
335
+ console.log();
336
+
337
+ if (inSync) {
338
+ ok("Local and shared contracts are in sync");
339
+ } else {
340
+ if (onlyLocal.length) {
341
+ console.log(` ${yellow("→")} ${bold(onlyLocal.length)} local capability(-ies) not yet pushed:`);
342
+ for (const c of onlyLocal) console.log(` ${yellow("+")} ${c.id} ${gray(c.title)}`);
343
+ }
344
+ if (onlyShared.length) {
345
+ console.log(` ${cyan("←")} ${bold(onlyShared.length)} shared capability(-ies) not yet pulled:`);
346
+ for (const c of onlyShared) console.log(` ${cyan("+")} ${c.id} ${gray(c.title)}`);
347
+ }
348
+ console.log();
349
+ if (onlyLocal.length) info(`Run ${cyan("infernoflow team-sync push")} to share your changes`);
350
+ if (onlyShared.length) info(`Run ${cyan("infernoflow team-sync pull")} to get team changes`);
351
+ }
352
+
353
+ console.log();
354
+ }
355
+
356
+ // ── main ──────────────────────────────────────────────────────────────────────
357
+
358
+ export async function teamSyncCommand(rawArgs) {
359
+ const args = rawArgs.slice(1);
360
+ const asJson = args.includes("--json");
361
+ const force = args.includes("--force");
362
+
363
+ const branchIdx = args.indexOf("--branch");
364
+ const remoteIdx = args.indexOf("--remote");
365
+ const branch = branchIdx !== -1 ? args[branchIdx + 1] : "inferno-contracts";
366
+ const remote = remoteIdx !== -1 ? args[remoteIdx + 1] : "origin";
367
+
368
+ const sub = args.find(a => !a.startsWith("-")) || "status";
369
+
370
+ const cwd = process.cwd();
371
+ const infernoDir = path.join(cwd, "inferno");
372
+
373
+ if (!asJson) header("infernoflow team-sync");
374
+
375
+ if (!fs.existsSync(infernoDir)) {
376
+ const msg = "inferno/ not found — run: infernoflow init";
377
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
378
+ warn(msg); process.exit(1);
379
+ }
380
+
381
+ switch (sub) {
382
+ case "init": initSharedBranch(cwd, remote, branch, infernoDir, asJson); break;
383
+ case "push": pushToShared(cwd, remote, branch, infernoDir, asJson, force); break;
384
+ case "pull": pullFromShared(cwd, remote, branch, infernoDir, asJson, force); break;
385
+ case "status":
386
+ default: showStatus(cwd, remote, branch, infernoDir, asJson); break;
387
+ }
388
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * infernoflow version
3
+ *
4
+ * Smart semver bump recommendation based on capability changes since the last
5
+ * git tag (or a custom ref).
6
+ *
7
+ * Classification rules:
8
+ * MAJOR — any capability was REMOVED (breaking: callers lose functionality)
9
+ * MINOR — capabilities were ADDED (non-breaking: new surface area)
10
+ * PATCH — only metadata changed (title / description / status edits)
11
+ * NONE — no capability changes at all
12
+ *
13
+ * Usage:
14
+ * infernoflow version # recommend bump type + show next version
15
+ * infernoflow version --apply # apply recommended bump to package.json
16
+ * infernoflow version --ref v1.2.3 # compare against a specific ref
17
+ * infernoflow version --json # machine-readable output
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { execSync } from "node:child_process";
23
+ import { header, ok, warn, info, bold, cyan, gray, green, red, yellow, done } from "../ui/output.mjs";
24
+
25
+ // ── git helpers (shared with diff.mjs) ───────────────────────────────────────
26
+
27
+ function capture(cmd, cwd) {
28
+ try {
29
+ return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function lastTag(cwd) {
36
+ return capture("git describe --tags --abbrev=0", cwd) || null;
37
+ }
38
+
39
+ function fileAtRef(ref, relPath, cwd) {
40
+ return capture(`git show "${ref}:${relPath}"`, cwd);
41
+ }
42
+
43
+ // ── capability helpers ────────────────────────────────────────────────────────
44
+
45
+ function parseCaps(jsonText) {
46
+ if (!jsonText) return null;
47
+ try {
48
+ const obj = JSON.parse(jsonText);
49
+ const raw = obj.capabilities || [];
50
+ return raw.map(c => {
51
+ if (typeof c === "string") return { id: c, title: c };
52
+ return { id: c.id || c, title: c.title || c.id || String(c), status: c.status };
53
+ });
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function loadCapsFromDisk(infernoDir) {
60
+ const capsPath = path.join(infernoDir, "capabilities.json");
61
+ const contractPath = path.join(infernoDir, "contract.json");
62
+ if (fs.existsSync(capsPath)) return parseCaps(fs.readFileSync(capsPath, "utf8"));
63
+ if (fs.existsSync(contractPath)) return parseCaps(fs.readFileSync(contractPath, "utf8"));
64
+ return null;
65
+ }
66
+
67
+ function loadCapsAtRef(ref, infernoRelDir, cwd) {
68
+ const capsJson = fileAtRef(ref, `${infernoRelDir}/capabilities.json`, cwd);
69
+ if (capsJson) return parseCaps(capsJson);
70
+ const contractJson = fileAtRef(ref, `${infernoRelDir}/contract.json`, cwd);
71
+ return parseCaps(contractJson);
72
+ }
73
+
74
+ function diffCaps(before, after) {
75
+ const beforeMap = new Map(before.map(c => [c.id, c]));
76
+ const afterMap = new Map(after.map(c => [c.id, c]));
77
+
78
+ const added = after.filter(c => !beforeMap.has(c.id));
79
+ const removed = before.filter(c => !afterMap.has(c.id));
80
+
81
+ const changed = [];
82
+ for (const c of after) {
83
+ const old = beforeMap.get(c.id);
84
+ if (!old) continue;
85
+ const changes = [];
86
+ if (old.title !== c.title) changes.push({ field: "title", from: old.title, to: c.title });
87
+ if ((old.status || "") !== (c.status || "")) changes.push({ field: "status", from: old.status || "—", to: c.status || "—" });
88
+ if (changes.length) changed.push({ id: c.id, changes });
89
+ }
90
+
91
+ return { added, removed, changed };
92
+ }
93
+
94
+ // ── semver helpers ────────────────────────────────────────────────────────────
95
+
96
+ function classifyBump(diff) {
97
+ if (diff.removed.length > 0) return "major";
98
+ if (diff.added.length > 0) return "minor";
99
+ if (diff.changed.length > 0) return "patch";
100
+ return "none";
101
+ }
102
+
103
+ function applyBump(version, type) {
104
+ const parts = (version || "0.0.0").split(".").map(Number);
105
+ if (type === "major") { parts[0]++; parts[1] = 0; parts[2] = 0; }
106
+ else if (type === "minor") { parts[1]++; parts[2] = 0; }
107
+ else if (type === "patch") { parts[2]++; }
108
+ return parts.join(".");
109
+ }
110
+
111
+ function readPackageVersion(cwd) {
112
+ const pkgPath = path.join(cwd, "package.json");
113
+ if (!fs.existsSync(pkgPath)) return null;
114
+ try {
115
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || null;
116
+ } catch { return null; }
117
+ }
118
+
119
+ function writePackageVersion(cwd, newVersion) {
120
+ const pkgPath = path.join(cwd, "package.json");
121
+ const raw = fs.readFileSync(pkgPath, "utf8");
122
+ const data = JSON.parse(raw);
123
+ data.version = newVersion;
124
+ fs.writeFileSync(pkgPath, JSON.stringify(data, null, 2) + "\n", "utf8");
125
+ }
126
+
127
+ // ── reason builder ────────────────────────────────────────────────────────────
128
+
129
+ function buildReason(type, diff, ref) {
130
+ const lines = [];
131
+ if (type === "major") {
132
+ lines.push(`${diff.removed.length} capability removed — breaking change`);
133
+ for (const c of diff.removed.slice(0, 3)) lines.push(` - ${c.id}: ${c.title}`);
134
+ if (diff.removed.length > 3) lines.push(` … and ${diff.removed.length - 3} more`);
135
+ } else if (type === "minor") {
136
+ lines.push(`${diff.added.length} new capability added`);
137
+ for (const c of diff.added.slice(0, 3)) lines.push(` + ${c.id}: ${c.title}`);
138
+ if (diff.added.length > 3) lines.push(` … and ${diff.added.length - 3} more`);
139
+ } else if (type === "patch") {
140
+ lines.push(`${diff.changed.length} capability metadata updated`);
141
+ } else {
142
+ lines.push(`No capability changes since ${ref}`);
143
+ }
144
+ return lines;
145
+ }
146
+
147
+ // ── MCP-compatible JSON output ────────────────────────────────────────────────
148
+
149
+ function emitJson(payload) {
150
+ console.log(JSON.stringify(payload, null, 2));
151
+ }
152
+
153
+ // ── main command ──────────────────────────────────────────────────────────────
154
+
155
+ export async function versionCommand(rawArgs) {
156
+ const args = rawArgs.slice(1);
157
+ const asJson = args.includes("--json");
158
+ const apply = args.includes("--apply");
159
+
160
+ const refIdx = args.indexOf("--ref");
161
+ let ref = refIdx !== -1 ? args[refIdx + 1] : null;
162
+
163
+ const cwd = process.cwd();
164
+ const infernoDir = path.join(cwd, "inferno");
165
+
166
+ if (!asJson) header("infernoflow version");
167
+
168
+ // ── Validate ───────────────────────────────────────────────────────────────
169
+ if (!fs.existsSync(infernoDir)) {
170
+ if (asJson) { emitJson({ ok: false, error: "inferno_not_found" }); process.exit(1); }
171
+ warn("inferno/ not found — run: infernoflow init");
172
+ process.exit(1);
173
+ }
174
+
175
+ // ── Resolve ref ────────────────────────────────────────────────────────────
176
+ if (!ref) {
177
+ ref = lastTag(cwd);
178
+ if (!ref) {
179
+ const parentExists = capture("git rev-parse HEAD~1", cwd);
180
+ ref = parentExists ? "HEAD~1" : null;
181
+ }
182
+ }
183
+
184
+ if (!ref) {
185
+ const currentVersion = readPackageVersion(cwd) || "0.0.0";
186
+ if (asJson) {
187
+ emitJson({ ok: true, bump: "minor", current: currentVersion, next: applyBump(currentVersion, "minor"), reason: ["No git history — defaulting to minor for first release"], ref: null });
188
+ } else {
189
+ info("No git history found — defaulting to minor for first release");
190
+ ok(`Recommended: ${bold(cyan("minor"))} → ${bold(applyBump(currentVersion, "minor"))}`);
191
+ }
192
+ return;
193
+ }
194
+
195
+ // ── Load capabilities ──────────────────────────────────────────────────────
196
+ const current = loadCapsFromDisk(infernoDir);
197
+ const previous = loadCapsAtRef(ref, "inferno", cwd);
198
+
199
+ if (!current) {
200
+ if (asJson) { emitJson({ ok: false, error: "no_capabilities" }); process.exit(1); }
201
+ warn("No capabilities.json or contract.json found");
202
+ process.exit(1);
203
+ }
204
+
205
+ // If no previous snapshot, treat all current caps as new → minor
206
+ const prevCaps = previous || [];
207
+ const diff = diffCaps(prevCaps, current);
208
+ const bump = classifyBump(diff);
209
+
210
+ const currentVersion = readPackageVersion(cwd) || "0.0.0";
211
+ const nextVersion = bump === "none" ? currentVersion : applyBump(currentVersion, bump);
212
+ const reason = buildReason(bump, diff, ref);
213
+
214
+ // ── JSON output ────────────────────────────────────────────────────────────
215
+ if (asJson) {
216
+ emitJson({
217
+ ok: true,
218
+ bump,
219
+ current: currentVersion,
220
+ next: nextVersion,
221
+ ref,
222
+ reason,
223
+ diff: {
224
+ added: diff.added.length,
225
+ removed: diff.removed.length,
226
+ changed: diff.changed.length,
227
+ },
228
+ });
229
+ return;
230
+ }
231
+
232
+ // ── Human output ──────────────────────────────────────────────────────────
233
+ const bumpColor = bump === "major" ? red
234
+ : bump === "minor" ? green
235
+ : bump === "patch" ? yellow
236
+ : gray;
237
+
238
+ console.log();
239
+ console.log(` Current version ${bold(currentVersion)} ${gray("(" + ref + ")")}`);
240
+ console.log();
241
+
242
+ if (bump === "none") {
243
+ ok(`No capability changes — version stays at ${bold(currentVersion)}`);
244
+ } else {
245
+ console.log(` ${bold("Recommended bump:")} ${bumpColor(bold(bump.toUpperCase()))}`);
246
+ console.log();
247
+ for (const line of reason) {
248
+ const prefix = line.startsWith(" +") ? green(" +")
249
+ : line.startsWith(" -") ? red(" -")
250
+ : line.startsWith(" …") ? gray(" …")
251
+ : " ";
252
+ const text = line.replace(/^\s+[+\-…]\s?/, "");
253
+ if (line.startsWith(" ") && !line.startsWith(" …")) {
254
+ console.log(` ${line.trim()}`);
255
+ } else {
256
+ console.log(` ${gray(line)}`);
257
+ }
258
+ }
259
+ console.log();
260
+ console.log(` ${bold(currentVersion)} → ${bumpColor(bold(nextVersion))}`);
261
+ }
262
+
263
+ // ── Apply ──────────────────────────────────────────────────────────────────
264
+ if (apply && bump !== "none") {
265
+ const pkgPath = path.join(cwd, "package.json");
266
+ if (!fs.existsSync(pkgPath)) {
267
+ warn("No package.json found — skipping --apply");
268
+ } else {
269
+ writePackageVersion(cwd, nextVersion);
270
+ console.log();
271
+ done(`package.json updated → ${bold(nextVersion)}`);
272
+ }
273
+ } else if (apply && bump === "none") {
274
+ console.log();
275
+ info("No changes to apply — version unchanged");
276
+ } else if (bump !== "none") {
277
+ console.log();
278
+ info(`Run ${cyan("infernoflow version --apply")} to write ${nextVersion} to package.json`);
279
+ }
280
+
281
+ console.log();
282
+ }