infernoflow 0.12.0 → 0.13.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.
@@ -31,6 +31,7 @@ const COMMAND_DESCRIPTIONS = {
31
31
  "generate-skills": "Generate personalised Cursor rules + skill files from your developer profile",
32
32
  synthesize: "Auto-detect workflow patterns and synthesize reusable skills + agents",
33
33
  agent: "Manage and run auto-synthesized agents (list | run | show | delete)",
34
+ version: "Smart semver bump recommendation based on capability changes (--apply to write)",
34
35
  };
35
36
 
36
37
  const COMMAND_HANDLERS = {
@@ -55,6 +56,7 @@ const COMMAND_HANDLERS = {
55
56
  "generate-skills": async (args) => (await import("../lib/commands/generateSkills.mjs")).generateSkillsCommand(args),
56
57
  synthesize: async (args) => (await import("../lib/commands/synthesize.mjs")).synthesizeCommand(args),
57
58
  agent: async (args) => (await import("../lib/commands/agent.mjs")).agentCommand(args),
59
+ version: async (args) => (await import("../lib/commands/version.mjs")).versionCommand(args),
58
60
  };
59
61
 
60
62
  function formatCommandsHelp() {
@@ -160,6 +162,11 @@ ${formatCommandsHelp()}
160
162
  --response <json|@file> Provide AI response directly (use with --json)
161
163
  --apply Apply the response changes when using --json --response
162
164
 
165
+ ${bold("version options:")}
166
+ --ref <tag|commit> Compare against a specific ref (default: last git tag)
167
+ --apply Write recommended version bump to package.json
168
+ --json Machine-readable output
169
+
163
170
  ${bold("Machine output:")}
164
171
  ${gray("status --json")}
165
172
  ${gray("check --json")}
@@ -169,6 +176,8 @@ ${formatCommandsHelp()}
169
176
  ${gray('run "task" --json')}
170
177
  ${gray('suggest "what changed" --json')}
171
178
  ${gray('suggest "what changed" --json --response \'{"newCapabilities":[...]}\' --apply')}
179
+ ${gray("version --json")}
180
+ ${gray("version --apply")}
172
181
  `;
173
182
 
174
183
  // ── Silent behavior observation ───────────────────────────────────────────
@@ -119,9 +119,9 @@ export async function publishCommand(rawArgs) {
119
119
  const yes = args.includes("--yes") || args.includes("-y");
120
120
 
121
121
  const bumpIdx = args.indexOf("--bump");
122
- const bumpType = bumpIdx !== -1 ? (args[bumpIdx + 1] || "patch") : "patch";
122
+ let bumpType = bumpIdx !== -1 ? (args[bumpIdx + 1] || "patch") : null;
123
123
 
124
- if (!["patch", "minor", "major"].includes(bumpType)) {
124
+ if (bumpType && !["patch", "minor", "major"].includes(bumpType)) {
125
125
  console.error(` Invalid --bump value: ${bumpType}. Must be patch, minor, or major.`);
126
126
  process.exit(1);
127
127
  }
@@ -136,6 +136,29 @@ export async function publishCommand(rawArgs) {
136
136
  const pkgPath = path.join(PKG_ROOT, "package.json");
137
137
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
138
138
  const oldVersion = pkg.version;
139
+
140
+ // ── Auto-detect bump type from capability diff if not specified ───────────
141
+ if (!bumpType) {
142
+ try {
143
+ const { versionCommand: _vc, ...versionModule } = await import("./version.mjs");
144
+ // Use the JSON output to get the recommendation
145
+ const { execSync: _exec } = await import("node:child_process");
146
+ const result = _exec("node " + JSON.stringify(path.join(PKG_ROOT, "bin", "infernoflow.mjs")) + " version --json", {
147
+ cwd: PKG_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
148
+ });
149
+ const parsed = JSON.parse(result);
150
+ if (parsed.bump && parsed.bump !== "none") {
151
+ bumpType = parsed.bump;
152
+ info(`Auto-detected bump type: ${bold(cyan(bumpType))} (from capability diff)`);
153
+ } else {
154
+ bumpType = "patch";
155
+ info(`No capability changes detected — defaulting to ${bold("patch")}`);
156
+ }
157
+ } catch {
158
+ bumpType = "patch";
159
+ }
160
+ }
161
+
139
162
  const newVersion = bumpVersion(oldVersion, bumpType);
140
163
 
141
164
  console.log();
@@ -54,6 +54,7 @@ const MCP_TOOLS = [
54
54
  "infernoflow_synthesize",
55
55
  "infernoflow_agent_list",
56
56
  "infernoflow_agent_run",
57
+ "infernoflow_version",
57
58
  ];
58
59
 
59
60
  // ── Git hooks installer ───────────────────────────────────────────────────────
@@ -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
+ }
@@ -68,6 +68,14 @@ const TOOLS = [
68
68
  description: "Execute a saved workflow agent by name. Check infernoflow_agent_list first. Can replace multi-step manual workflows with one call.",
69
69
  inputSchema: { type: "object", properties: { name: { type: "string", description: "Agent name to run" } }, required: ["name"] }
70
70
  },
71
+ {
72
+ name: "infernoflow_version",
73
+ description: "Get the recommended semver bump (major/minor/patch) based on capability changes since the last git tag. CALL THIS AUTOMATICALLY when the developer asks about releasing, bumping version, or publishing. Returns bump type and next version number. Pass apply:true to write the bump to package.json.",
74
+ inputSchema: { type: "object", properties: {
75
+ apply: { type: "boolean", description: "If true, write the recommended version to package.json" },
76
+ ref: { type: "string", description: "Compare against a specific git ref (default: last tag)" }
77
+ }}
78
+ },
71
79
  {
72
80
  name: "infernoflow_run",
73
81
  description: "Generate a full infernoflow task prompt. Use infernoflow_implement instead for most cases — it's simpler. Use this for complex multi-step flows.",
@@ -576,6 +584,11 @@ function handleTool(id, name, input) {
576
584
  text = listAgents();
577
585
  } else if (name === "infernoflow_agent_run") {
578
586
  text = runAgent(input.name);
587
+ } else if (name === "infernoflow_version") {
588
+ const parts = ["version", "--json"];
589
+ if (input.ref) parts.push(`--ref "${input.ref}"`);
590
+ if (input.apply) parts.push("--apply");
591
+ text = runCmd(parts.join(" "));
579
592
  } else { return sendError(id, -32601, `Unknown tool: ${name}`); }
580
593
  sendResult(id, { content: [{ type: "text", text: text || "(no output)" }] });
581
594
  } catch (err) { sendError(id, -32000, err.message); }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.12.0",
4
- "description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
3
+ "version": "0.13.0",
4
+ "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "infernoflow": "dist/bin/infernoflow.mjs"
@@ -20,7 +20,8 @@
20
20
  "test": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs && node scripts/implement-smoke.mjs && node scripts/adopt-smoke.mjs && node scripts/pr-impact-smoke.mjs && node scripts/sync-smoke.mjs && node scripts/run-smoke.mjs",
21
21
  "test:help": "node bin/infernoflow.mjs --help",
22
22
  "build": "node build.mjs",
23
- "prepublishOnly": "node build.mjs"
23
+ "prepublishOnly": "node build.mjs",
24
+ "inferno:promote-draft": "node scripts/inferno-promote-draft.mjs"
24
25
  },
25
26
  "keywords": [
26
27
  "cli",
@@ -45,4 +46,4 @@
45
46
  "devDependencies": {
46
47
  "esbuild": "^0.28.0"
47
48
  }
48
- }
49
+ }