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,361 @@
1
+ /**
2
+ * infernoflow pr-comment
3
+ *
4
+ * Posts a capability drift analysis as a GitHub PR comment.
5
+ * Designed to run in CI (GitHub Actions) on pull_request events.
6
+ *
7
+ * Auto-reads context from GitHub Actions environment variables:
8
+ * GITHUB_TOKEN — required for posting comments
9
+ * GITHUB_REPOSITORY — e.g. "owner/repo"
10
+ * GITHUB_EVENT_PATH — path to the event JSON (contains PR number)
11
+ * GITHUB_SHA — current commit SHA
12
+ * GITHUB_BASE_REF — base branch name (e.g. "main")
13
+ *
14
+ * Usage:
15
+ * infernoflow pr-comment # auto-detect from CI env
16
+ * infernoflow pr-comment --pr 42 # explicit PR number
17
+ * infernoflow pr-comment --repo owner/r # explicit repo
18
+ * infernoflow pr-comment --token ghp_... # explicit token
19
+ * infernoflow pr-comment --dry-run # print comment without posting
20
+ * infernoflow pr-comment --json # machine-readable result
21
+ */
22
+
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import * as https from "node:https";
26
+ import { execSync } from "node:child_process";
27
+ import { header, ok, warn, info, done, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
28
+
29
+ // ── git helpers ───────────────────────────────────────────────────────────────
30
+
31
+ function capture(cmd, cwd) {
32
+ try {
33
+ return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
34
+ } catch { return null; }
35
+ }
36
+
37
+ function lastTag(cwd) {
38
+ return capture("git describe --tags --abbrev=0", cwd) || null;
39
+ }
40
+
41
+ function fileAtRef(ref, relPath, cwd) {
42
+ return capture(`git show "${ref}:${relPath}"`, cwd);
43
+ }
44
+
45
+ // ── capability helpers ────────────────────────────────────────────────────────
46
+
47
+ function parseCaps(jsonText) {
48
+ if (!jsonText) return null;
49
+ try {
50
+ const obj = JSON.parse(jsonText);
51
+ const raw = obj.capabilities || [];
52
+ return raw.map(c => {
53
+ if (typeof c === "string") return { id: c, title: c };
54
+ return { id: c.id || c, title: c.title || c.id || String(c), status: c.status };
55
+ });
56
+ } catch { return null; }
57
+ }
58
+
59
+ function loadCapsFromDisk(infernoDir) {
60
+ for (const name of ["capabilities.json", "contract.json"]) {
61
+ const p = path.join(infernoDir, name);
62
+ if (fs.existsSync(p)) return parseCaps(fs.readFileSync(p, "utf8"));
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function loadCapsAtRef(ref, cwd) {
68
+ for (const name of ["capabilities.json", "contract.json"]) {
69
+ const content = fileAtRef(ref, `inferno/${name}`, cwd);
70
+ if (content) return parseCaps(content);
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function diffCaps(before, after) {
76
+ const beforeMap = new Map(before.map(c => [c.id, c]));
77
+ const afterMap = new Map(after.map(c => [c.id, c]));
78
+ const added = after.filter(c => !beforeMap.has(c.id));
79
+ const removed = before.filter(c => !afterMap.has(c.id));
80
+ const changed = [];
81
+ for (const c of after) {
82
+ const old = beforeMap.get(c.id);
83
+ if (!old) continue;
84
+ const changes = [];
85
+ if (old.title !== c.title) changes.push({ field: "title", from: old.title, to: c.title });
86
+ if ((old.status || "") !== (c.status || "")) changes.push({ field: "status", from: old.status || "—", to: c.status || "—" });
87
+ if (changes.length) changed.push({ id: c.id, changes });
88
+ }
89
+ return { added, removed, changed };
90
+ }
91
+
92
+ function classifyBump(diff) {
93
+ if (diff.removed.length > 0) return "major";
94
+ if (diff.added.length > 0) return "minor";
95
+ if (diff.changed.length > 0) return "patch";
96
+ return "none";
97
+ }
98
+
99
+ // ── comment builder ───────────────────────────────────────────────────────────
100
+
101
+ function buildComment(diff, bump, ref, currentVersion, nextVersion) {
102
+ const lines = [];
103
+
104
+ // Header
105
+ const bumpEmoji = bump === "major" ? "🔴" : bump === "minor" ? "🟡" : bump === "patch" ? "🟢" : "✅";
106
+ const bumpLabel = bump === "none" ? "No capability changes" : `${bump.toUpperCase()} bump recommended`;
107
+ lines.push(`## 🔥 infernoflow — Capability Analysis`);
108
+ lines.push(``);
109
+ lines.push(`${bumpEmoji} **${bumpLabel}**${bump !== "none" ? ` · \`${currentVersion}\` → \`${nextVersion}\`` : ""}`);
110
+ lines.push(``);
111
+
112
+ // Summary table
113
+ const hasChanges = diff.added.length || diff.removed.length || diff.changed.length;
114
+ if (!hasChanges) {
115
+ lines.push(`> No capability changes detected since \`${ref}\`. Contract is in sync.`);
116
+ } else {
117
+ lines.push(`| Change | Count |`);
118
+ lines.push(`|--------|-------|`);
119
+ if (diff.added.length) lines.push(`| ➕ Added | ${diff.added.length} |`);
120
+ if (diff.removed.length) lines.push(`| ❌ Removed | ${diff.removed.length} |`);
121
+ if (diff.changed.length) lines.push(`| ✏️ Modified | ${diff.changed.length} |`);
122
+ lines.push(``);
123
+
124
+ // Detail sections
125
+ if (diff.added.length) {
126
+ lines.push(`<details><summary>➕ Added capabilities (${diff.added.length})</summary>`);
127
+ lines.push(``);
128
+ for (const c of diff.added) lines.push(`- \`${c.id}\` — ${c.title}`);
129
+ lines.push(``);
130
+ lines.push(`</details>`);
131
+ lines.push(``);
132
+ }
133
+
134
+ if (diff.removed.length) {
135
+ lines.push(`<details><summary>❌ Removed capabilities (${diff.removed.length}) — breaking change</summary>`);
136
+ lines.push(``);
137
+ for (const c of diff.removed) lines.push(`- \`${c.id}\` — ${c.title}`);
138
+ lines.push(``);
139
+ lines.push(`</details>`);
140
+ lines.push(``);
141
+ }
142
+
143
+ if (diff.changed.length) {
144
+ lines.push(`<details><summary>✏️ Modified capabilities (${diff.changed.length})</summary>`);
145
+ lines.push(``);
146
+ for (const item of diff.changed) {
147
+ lines.push(`- \`${item.id}\``);
148
+ for (const ch of item.changes) lines.push(` - ${ch.field}: \`${ch.from}\` → \`${ch.to}\``);
149
+ }
150
+ lines.push(``);
151
+ lines.push(`</details>`);
152
+ lines.push(``);
153
+ }
154
+ }
155
+
156
+ // Bump recommendation
157
+ if (bump === "major") {
158
+ lines.push(`> ⚠️ **Breaking change detected.** Capabilities were removed. Consider a major version bump.`);
159
+ lines.push(`> Run \`infernoflow version --apply\` to update \`package.json\`.`);
160
+ } else if (bump === "minor") {
161
+ lines.push(`> ℹ️ New capabilities added. A minor version bump is recommended.`);
162
+ lines.push(`> Run \`infernoflow version --apply\` to update \`package.json\`.`);
163
+ }
164
+
165
+ lines.push(``);
166
+ lines.push(`---`);
167
+ lines.push(`<sub>Generated by [infernoflow](https://github.com/ronmiz/infernoflow) · compared against \`${ref}\`</sub>`);
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ // ── GitHub API ────────────────────────────────────────────────────────────────
173
+
174
+ function githubRequest(method, pathname, body, token) {
175
+ return new Promise((resolve, reject) => {
176
+ const data = body ? JSON.stringify(body) : null;
177
+ const req = https.request({
178
+ hostname: "api.github.com",
179
+ path: pathname,
180
+ method,
181
+ headers: {
182
+ "Authorization": `Bearer ${token}`,
183
+ "Accept": "application/vnd.github+json",
184
+ "Content-Type": "application/json",
185
+ "User-Agent": "infernoflow-cli",
186
+ ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}),
187
+ },
188
+ }, (res) => {
189
+ let raw = "";
190
+ res.on("data", chunk => raw += chunk);
191
+ res.on("end", () => {
192
+ try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
193
+ catch { resolve({ status: res.statusCode, body: raw }); }
194
+ });
195
+ });
196
+ req.on("error", reject);
197
+ if (data) req.write(data);
198
+ req.end();
199
+ });
200
+ }
201
+
202
+ async function findExistingComment(repo, prNumber, token) {
203
+ // Look for a previous infernoflow comment to update instead of creating a new one
204
+ const res = await githubRequest("GET", `/repos/${repo}/issues/${prNumber}/comments?per_page=100`, null, token);
205
+ if (res.status !== 200 || !Array.isArray(res.body)) return null;
206
+ return res.body.find(c => c.body && c.body.includes("🔥 infernoflow — Capability Analysis")) || null;
207
+ }
208
+
209
+ async function postComment(repo, prNumber, body, token) {
210
+ // Update existing comment if found (avoids spam on multiple pushes)
211
+ const existing = await findExistingComment(repo, prNumber, token);
212
+ if (existing) {
213
+ return githubRequest("PATCH", `/repos/${repo}/issues/comments/${existing.id}`, { body }, token);
214
+ }
215
+ return githubRequest("POST", `/repos/${repo}/issues/${prNumber}/comments`, { body }, token);
216
+ }
217
+
218
+ // ── env helpers ───────────────────────────────────────────────────────────────
219
+
220
+ function readGithubEventPr() {
221
+ const eventPath = process.env.GITHUB_EVENT_PATH;
222
+ if (!eventPath || !fs.existsSync(eventPath)) return null;
223
+ try {
224
+ const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
225
+ return event.pull_request?.number || event.number || null;
226
+ } catch { return null; }
227
+ }
228
+
229
+ function applyBump(version, type) {
230
+ const parts = (version || "0.0.0").split(".").map(Number);
231
+ if (type === "major") { parts[0]++; parts[1] = 0; parts[2] = 0; }
232
+ else if (type === "minor") { parts[1]++; parts[2] = 0; }
233
+ else if (type === "patch") { parts[2]++; }
234
+ return parts.join(".");
235
+ }
236
+
237
+ function readPackageVersion(cwd) {
238
+ const p = path.join(cwd, "package.json");
239
+ if (!fs.existsSync(p)) return "0.0.0";
240
+ try { return JSON.parse(fs.readFileSync(p, "utf8")).version || "0.0.0"; } catch { return "0.0.0"; }
241
+ }
242
+
243
+ // ── main ──────────────────────────────────────────────────────────────────────
244
+
245
+ export async function prCommentCommand(rawArgs) {
246
+ const args = rawArgs.slice(1);
247
+ const dryRun = args.includes("--dry-run");
248
+ const asJson = args.includes("--json");
249
+
250
+ const prIdx = args.indexOf("--pr");
251
+ const repoIdx = args.indexOf("--repo");
252
+ const tokenIdx = args.indexOf("--token");
253
+ const refIdx = args.indexOf("--ref");
254
+
255
+ const cwd = process.cwd();
256
+ const infernoDir = path.join(cwd, "inferno");
257
+
258
+ if (!asJson) header("infernoflow pr-comment");
259
+
260
+ // ── Resolve inputs ────────────────────────────────────────────────────────
261
+ const token = tokenIdx !== -1 ? args[tokenIdx + 1] : process.env.GITHUB_TOKEN;
262
+ const repo = repoIdx !== -1 ? args[repoIdx + 1] : process.env.GITHUB_REPOSITORY;
263
+ const prNumber = prIdx !== -1 ? parseInt(args[prIdx + 1], 10)
264
+ : readGithubEventPr();
265
+
266
+ let ref = refIdx !== -1 ? args[refIdx + 1] : null;
267
+ if (!ref) ref = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : null;
268
+ if (!ref) ref = lastTag(cwd);
269
+ if (!ref) {
270
+ const parentExists = capture("git rev-parse HEAD~1", cwd);
271
+ ref = parentExists ? "HEAD~1" : null;
272
+ }
273
+
274
+ // ── Validate ──────────────────────────────────────────────────────────────
275
+ if (!fs.existsSync(infernoDir)) {
276
+ const msg = "inferno/ not found — run: infernoflow init";
277
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
278
+ warn(msg); process.exit(1);
279
+ }
280
+
281
+ if (!dryRun && !token) {
282
+ const msg = "No GitHub token found. Set GITHUB_TOKEN env var or use --token";
283
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
284
+ warn(msg); process.exit(1);
285
+ }
286
+
287
+ if (!dryRun && !repo) {
288
+ const msg = "No repository found. Set GITHUB_REPOSITORY env var or use --repo owner/repo";
289
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
290
+ warn(msg); process.exit(1);
291
+ }
292
+
293
+ if (!dryRun && !prNumber) {
294
+ const msg = "No PR number found. Use --pr <number> or run in GitHub Actions on pull_request event";
295
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
296
+ warn(msg); process.exit(1);
297
+ }
298
+
299
+ // ── Load capabilities and compute diff ───────────────────────────────────
300
+ const current = loadCapsFromDisk(infernoDir);
301
+ const previous = ref ? loadCapsAtRef(ref, cwd) : null;
302
+
303
+ if (!current) {
304
+ const msg = "No capabilities.json or contract.json found in inferno/";
305
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
306
+ warn(msg); process.exit(1);
307
+ }
308
+
309
+ const diff = diffCaps(previous || [], current);
310
+ const bump = classifyBump(diff);
311
+ const currentVersion = readPackageVersion(cwd);
312
+ const nextVersion = bump !== "none" ? applyBump(currentVersion, bump) : currentVersion;
313
+
314
+ // ── Build comment ─────────────────────────────────────────────────────────
315
+ const commentBody = buildComment(diff, bump, ref || "HEAD", currentVersion, nextVersion);
316
+
317
+ // ── Dry run ───────────────────────────────────────────────────────────────
318
+ if (dryRun) {
319
+ if (asJson) {
320
+ console.log(JSON.stringify({ ok: true, dryRun: true, bump, currentVersion, nextVersion, comment: commentBody }));
321
+ } else {
322
+ console.log();
323
+ info("DRY RUN — comment that would be posted:");
324
+ console.log();
325
+ console.log(commentBody);
326
+ console.log();
327
+ }
328
+ return;
329
+ }
330
+
331
+ // ── Post comment ──────────────────────────────────────────────────────────
332
+ if (!asJson) info(`Posting to ${bold(repo)} PR #${prNumber}...`);
333
+
334
+ try {
335
+ const result = await postComment(repo, prNumber, commentBody, token);
336
+
337
+ if (result.status === 200 || result.status === 201) {
338
+ const commentUrl = result.body?.html_url || "";
339
+ if (asJson) {
340
+ console.log(JSON.stringify({ ok: true, bump, currentVersion, nextVersion, prNumber, repo, commentUrl }));
341
+ } else {
342
+ ok(`Comment posted → ${cyan(commentUrl || `PR #${prNumber}`)}`);
343
+ if (bump !== "none") {
344
+ console.log();
345
+ info(`Recommended bump: ${bold(bump.toUpperCase())} ${currentVersion} → ${nextVersion}`);
346
+ }
347
+ console.log();
348
+ }
349
+ } else {
350
+ const msg = `GitHub API error ${result.status}: ${JSON.stringify(result.body)}`;
351
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); }
352
+ else { warn(msg); }
353
+ process.exit(1);
354
+ }
355
+ } catch (err) {
356
+ const msg = `Failed to post comment: ${err.message}`;
357
+ if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); }
358
+ else { warn(msg); }
359
+ process.exit(1);
360
+ }
361
+ }
@@ -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 ───────────────────────────────────────────────────────