infernoflow 0.13.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.
@@ -32,6 +32,9 @@ const COMMAND_DESCRIPTIONS = {
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
34
  version: "Smart semver bump recommendation based on capability changes (--apply to write)",
35
+ "pr-comment": "Post capability drift analysis as a GitHub PR comment (works in CI automatically)",
36
+ dashboard: "Launch local web dashboard on localhost:7337 — live contract health, capabilities, agents",
37
+ "team-sync": "Sync capability contract across a team via a shared git branch (push | pull | status | init)",
35
38
  };
36
39
 
37
40
  const COMMAND_HANDLERS = {
@@ -57,6 +60,9 @@ const COMMAND_HANDLERS = {
57
60
  synthesize: async (args) => (await import("../lib/commands/synthesize.mjs")).synthesizeCommand(args),
58
61
  agent: async (args) => (await import("../lib/commands/agent.mjs")).agentCommand(args),
59
62
  version: async (args) => (await import("../lib/commands/version.mjs")).versionCommand(args),
63
+ "pr-comment": async (args) => (await import("../lib/commands/prComment.mjs")).prCommentCommand(args),
64
+ dashboard: async (args) => (await import("../lib/commands/dashboard.mjs")).dashboardCommand(args),
65
+ "team-sync": async (args) => (await import("../lib/commands/teamSync.mjs")).teamSyncCommand(args),
60
66
  };
61
67
 
62
68
  function formatCommandsHelp() {
@@ -167,6 +173,14 @@ ${formatCommandsHelp()}
167
173
  --apply Write recommended version bump to package.json
168
174
  --json Machine-readable output
169
175
 
176
+ ${bold("pr-comment options:")}
177
+ --pr <number> PR number to comment on (auto-detected in GitHub Actions)
178
+ --repo <owner/repo> GitHub repository (auto-detected in GitHub Actions)
179
+ --token <ghp_...> GitHub token (auto-detected from GITHUB_TOKEN env var)
180
+ --ref <ref> Base ref to diff against (auto-detected from GITHUB_BASE_REF)
181
+ --dry-run Print the comment without posting it
182
+ --json Machine-readable output
183
+
170
184
  ${bold("Machine output:")}
171
185
  ${gray("status --json")}
172
186
  ${gray("check --json")}
@@ -0,0 +1,399 @@
1
+ /**
2
+ * infernoflow dashboard
3
+ *
4
+ * Launches a local web server on http://localhost:7337 showing:
5
+ * - Contract health status
6
+ * - Capability list with add/remove/change history
7
+ * - Drift timeline (last N sessions)
8
+ * - Agent activity log
9
+ * - Auto-refresh via SSE (server-sent events)
10
+ *
11
+ * Usage:
12
+ * infernoflow dashboard # open on port 7337
13
+ * infernoflow dashboard --port 8080 # custom port
14
+ * infernoflow dashboard --no-open # don't auto-open browser
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import * as http from "node:http";
20
+ import * as os from "node:os";
21
+ import { execSync } from "node:child_process";
22
+ import { fileURLToPath } from "node:url";
23
+ import { header, ok, info, warn, bold, cyan, gray } from "../ui/output.mjs";
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+
27
+ // ── data loaders ──────────────────────────────────────────────────────────────
28
+
29
+ function loadContract(infernoDir) {
30
+ const contractPath = path.join(infernoDir, "contract.json");
31
+ if (!fs.existsSync(contractPath)) return null;
32
+ try { return JSON.parse(fs.readFileSync(contractPath, "utf8")); } catch { return null; }
33
+ }
34
+
35
+ function loadCapabilities(infernoDir) {
36
+ for (const name of ["capabilities.json", "contract.json"]) {
37
+ const p = path.join(infernoDir, name);
38
+ if (!fs.existsSync(p)) continue;
39
+ try {
40
+ const obj = JSON.parse(fs.readFileSync(p, "utf8"));
41
+ const raw = obj.capabilities || [];
42
+ return raw.map(c => typeof c === "string" ? { id: c, title: c } : c);
43
+ } catch {}
44
+ }
45
+ return [];
46
+ }
47
+
48
+ function loadProfile(infernoDir) {
49
+ const p = path.join(infernoDir, "developer-profile.json");
50
+ if (!fs.existsSync(p)) return null;
51
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
52
+ }
53
+
54
+ function loadAgents(infernoDir) {
55
+ const agentsDir = path.join(infernoDir, "agents");
56
+ if (!fs.existsSync(agentsDir)) return [];
57
+ return fs.readdirSync(agentsDir)
58
+ .filter(f => f.endsWith(".json"))
59
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(agentsDir, f), "utf8")); } catch { return null; } })
60
+ .filter(Boolean);
61
+ }
62
+
63
+ function loadHookLog(infernoDir) {
64
+ const logPath = path.join(infernoDir, "HOOK.log");
65
+ if (!fs.existsSync(logPath)) return null;
66
+ try { return JSON.parse(fs.readFileSync(logPath, "utf8")); } catch { return null; }
67
+ }
68
+
69
+ function runCheck(infernoDir) {
70
+ try {
71
+ const out = execSync("npx infernoflow check --json", {
72
+ cwd: path.dirname(infernoDir),
73
+ encoding: "utf8",
74
+ timeout: 15_000,
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ });
77
+ return JSON.parse(out);
78
+ } catch (err) {
79
+ try { return JSON.parse(err.stdout || "{}"); } catch { return { status: "error", error: "check failed" }; }
80
+ }
81
+ }
82
+
83
+ function gatherData(infernoDir) {
84
+ const caps = loadCapabilities(infernoDir);
85
+ const contract = loadContract(infernoDir);
86
+ const profile = loadProfile(infernoDir);
87
+ const agents = loadAgents(infernoDir);
88
+ const hookLog = loadHookLog(infernoDir);
89
+ const check = runCheck(infernoDir);
90
+ const sessions = profile?.recentSessions?.slice(-10) || [];
91
+ const candidates = [
92
+ ...(profile?.agentCandidates || []),
93
+ ...(profile?.skillCandidates || []),
94
+ ];
95
+
96
+ return { caps, contract, agents, hookLog, check, sessions, candidates, infernoDir };
97
+ }
98
+
99
+ // ── HTML builder ──────────────────────────────────────────────────────────────
100
+
101
+ function buildHtml(data, projectName) {
102
+ const { caps, agents, check, sessions, candidates } = data;
103
+
104
+ const statusColor = check?.status === "ok" ? "#22c55e"
105
+ : check?.status === "warning" ? "#f59e0b"
106
+ : check?.status === "error" ? "#ef4444"
107
+ : "#6b7280";
108
+
109
+ const statusLabel = check?.status || "unknown";
110
+ const capCount = caps.length;
111
+ const agentCount = agents.length;
112
+ const issueCount = (check?.issues || []).length;
113
+
114
+ // Capability rows
115
+ const capRows = caps.map(c => {
116
+ const statusBadge = c.status ? `<span class="badge">${c.status}</span>` : "";
117
+ return `<tr>
118
+ <td><code>${esc(c.id)}</code></td>
119
+ <td>${esc(c.title || "")}${statusBadge}</td>
120
+ <td>${esc(c.since || "")}</td>
121
+ </tr>`;
122
+ }).join("\n");
123
+
124
+ // Agent rows
125
+ const agentRows = agents.map(a => {
126
+ const steps = (a.steps || []).map(s => typeof s === "string" ? s : s.command).join(" → ");
127
+ const conf = a.confidence ? `${Math.round(a.confidence * 100)}%` : "—";
128
+ return `<tr>
129
+ <td><strong>${esc(a.name)}</strong></td>
130
+ <td>${esc(a.description || steps)}</td>
131
+ <td><code>${esc(steps)}</code></td>
132
+ <td>${conf}</td>
133
+ </tr>`;
134
+ }).join("\n");
135
+
136
+ // Issues
137
+ const issueItems = (check?.issues || []).map(i =>
138
+ `<li class="issue">${esc(typeof i === "string" ? i : i.message || JSON.stringify(i))}</li>`
139
+ ).join("\n");
140
+
141
+ // Session timeline
142
+ const sessionItems = sessions.slice().reverse().map(s => {
143
+ const cmds = (s.commands || []).join(", ");
144
+ const date = s.startedAt ? new Date(s.startedAt).toLocaleString() : "unknown";
145
+ return `<div class="session-item">
146
+ <span class="session-date">${esc(date)}</span>
147
+ <span class="session-cmds">${esc(cmds || "no commands recorded")}</span>
148
+ </div>`;
149
+ }).join("\n");
150
+
151
+ // Candidate suggestions
152
+ const candidateItems = candidates.map(c =>
153
+ `<li class="candidate">${esc(c.name || c.id || "unnamed")}: ${esc(c.description || "")}</li>`
154
+ ).join("\n");
155
+
156
+ return `<!DOCTYPE html>
157
+ <html lang="en">
158
+ <head>
159
+ <meta charset="UTF-8">
160
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
161
+ <title>infernoflow — ${esc(projectName)}</title>
162
+ <style>
163
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
164
+ :root {
165
+ --bg: #0f1117; --surface: #1a1d27; --border: #2d3148;
166
+ --text: #e2e8f0; --muted: #64748b; --accent: #f97316;
167
+ --green: #22c55e; --yellow: #f59e0b; --red: #ef4444; --blue: #3b82f6;
168
+ }
169
+ body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 14px; line-height: 1.5; }
170
+ header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
171
+ header h1 { font-size: 18px; font-weight: 700; }
172
+ header .flame { font-size: 22px; }
173
+ header .project { color: var(--muted); font-size: 13px; }
174
+ header .live { margin-left: auto; font-size: 11px; color: var(--green); display: flex; align-items: center; gap: 4px; }
175
+ header .live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
176
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
177
+ main { max-width: 1100px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }
178
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
179
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px; }
180
+ .card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 8px; }
181
+ .card .value { font-size: 28px; font-weight: 700; }
182
+ .card .sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
183
+ .status-ok { color: var(--green); }
184
+ .status-warning { color: var(--yellow); }
185
+ .status-error { color: var(--red); }
186
+ section { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
187
+ section h2 { font-size: 13px; font-weight: 600; padding: 14px 18px; border-bottom: 1px solid var(--border); color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
188
+ table { width: 100%; border-collapse: collapse; }
189
+ th, td { padding: 10px 18px; text-align: left; border-bottom: 1px solid var(--border); }
190
+ th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); background: rgba(255,255,255,0.02); }
191
+ tr:last-child td { border-bottom: none; }
192
+ tr:hover td { background: rgba(255,255,255,0.03); }
193
+ code { font-family: monospace; font-size: 12px; background: rgba(255,255,255,0.07); padding: 1px 5px; border-radius: 4px; }
194
+ .badge { font-size: 10px; background: rgba(249,115,22,0.15); color: var(--accent); padding: 1px 6px; border-radius: 9px; margin-left: 6px; }
195
+ .issues-list, .candidates-list { list-style: none; padding: 14px 18px; display: flex; flex-direction: column; gap: 8px; }
196
+ .issue { color: var(--red); font-size: 13px; }
197
+ .candidate { color: var(--yellow); font-size: 13px; }
198
+ .empty { padding: 24px 18px; color: var(--muted); text-align: center; font-size: 13px; }
199
+ .session-item { display: flex; gap: 16px; align-items: baseline; padding: 9px 18px; border-bottom: 1px solid var(--border); }
200
+ .session-item:last-child { border-bottom: none; }
201
+ .session-date { font-size: 11px; color: var(--muted); white-space: nowrap; min-width: 140px; }
202
+ .session-cmds { font-size: 12px; color: var(--text); }
203
+ footer { text-align: center; color: var(--muted); font-size: 11px; padding: 24px; }
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <header>
208
+ <span class="flame">🔥</span>
209
+ <div>
210
+ <h1>infernoflow</h1>
211
+ <div class="project">${esc(projectName)}</div>
212
+ </div>
213
+ <div class="live">Live</div>
214
+ </header>
215
+ <main>
216
+
217
+ <!-- Stat cards -->
218
+ <div class="cards">
219
+ <div class="card">
220
+ <div class="label">Contract status</div>
221
+ <div class="value status-${statusLabel}" style="color:${statusColor}">${statusLabel.toUpperCase()}</div>
222
+ <div class="sub">${issueCount > 0 ? issueCount + " issue" + (issueCount !== 1 ? "s" : "") : "All checks passed"}</div>
223
+ </div>
224
+ <div class="card">
225
+ <div class="label">Capabilities</div>
226
+ <div class="value">${capCount}</div>
227
+ <div class="sub">tracked in contract</div>
228
+ </div>
229
+ <div class="card">
230
+ <div class="label">Agents</div>
231
+ <div class="value">${agentCount}</div>
232
+ <div class="sub">synthesized workflows</div>
233
+ </div>
234
+ <div class="card">
235
+ <div class="label">Sessions</div>
236
+ <div class="value">${sessions.length}</div>
237
+ <div class="sub">recent sessions logged</div>
238
+ </div>
239
+ </div>
240
+
241
+ ${issueCount > 0 ? `
242
+ <!-- Issues -->
243
+ <section>
244
+ <h2>⚠ Issues</h2>
245
+ <ul class="issues-list">${issueItems}</ul>
246
+ </section>` : ""}
247
+
248
+ <!-- Capabilities -->
249
+ <section>
250
+ <h2>Capabilities (${capCount})</h2>
251
+ ${capCount > 0 ? `
252
+ <table>
253
+ <thead><tr><th>ID</th><th>Title</th><th>Since</th></tr></thead>
254
+ <tbody>${capRows}</tbody>
255
+ </table>` : `<div class="empty">No capabilities found in inferno/capabilities.json</div>`}
256
+ </section>
257
+
258
+ <!-- Agents -->
259
+ <section>
260
+ <h2>Synthesized Agents (${agentCount})</h2>
261
+ ${agentCount > 0 ? `
262
+ <table>
263
+ <thead><tr><th>Name</th><th>Description</th><th>Steps</th><th>Confidence</th></tr></thead>
264
+ <tbody>${agentRows}</tbody>
265
+ </table>` : `<div class="empty">No agents yet — run <code>infernoflow synthesize</code> to generate them</div>`}
266
+ </section>
267
+
268
+ ${candidates.length > 0 ? `
269
+ <!-- Candidates -->
270
+ <section>
271
+ <h2>Workflow Candidates (${candidates.length})</h2>
272
+ <ul class="candidates-list">${candidateItems}</ul>
273
+ </section>` : ""}
274
+
275
+ <!-- Session timeline -->
276
+ <section>
277
+ <h2>Recent Sessions</h2>
278
+ ${sessions.length > 0 ? `<div>${sessionItems}</div>`
279
+ : `<div class="empty">No session data yet — sessions are logged automatically as you use infernoflow</div>`}
280
+ </section>
281
+
282
+ </main>
283
+ <footer>infernoflow dashboard · auto-refreshes every 10s · <a href="/" style="color:var(--muted)">refresh now</a></footer>
284
+ <script>
285
+ // SSE live reload
286
+ const es = new EventSource('/events');
287
+ es.onmessage = () => window.location.reload();
288
+ es.onerror = () => {};
289
+ </script>
290
+ </body>
291
+ </html>`;
292
+ }
293
+
294
+ function esc(str) {
295
+ return String(str || "")
296
+ .replace(/&/g, "&amp;")
297
+ .replace(/</g, "&lt;")
298
+ .replace(/>/g, "&gt;")
299
+ .replace(/"/g, "&quot;");
300
+ }
301
+
302
+ // ── HTTP server ───────────────────────────────────────────────────────────────
303
+
304
+ function startServer(infernoDir, port) {
305
+ const cwd = path.dirname(infernoDir);
306
+ const projectName = path.basename(cwd);
307
+ const sseClients = new Set();
308
+
309
+ // Watch inferno/ for changes → notify SSE clients
310
+ let watchTimer = null;
311
+ try {
312
+ fs.watch(infernoDir, { recursive: true }, () => {
313
+ clearTimeout(watchTimer);
314
+ watchTimer = setTimeout(() => {
315
+ for (const res of sseClients) {
316
+ try { res.write("data: reload\n\n"); } catch {}
317
+ }
318
+ }, 500);
319
+ });
320
+ } catch {}
321
+
322
+ const server = http.createServer((req, res) => {
323
+ // SSE endpoint
324
+ if (req.url === "/events") {
325
+ res.writeHead(200, {
326
+ "Content-Type": "text/event-stream",
327
+ "Cache-Control": "no-cache",
328
+ "Connection": "keep-alive",
329
+ });
330
+ sseClients.add(res);
331
+ req.on("close", () => sseClients.delete(res));
332
+ return;
333
+ }
334
+
335
+ // JSON API
336
+ if (req.url === "/api/data") {
337
+ const data = gatherData(infernoDir);
338
+ res.writeHead(200, { "Content-Type": "application/json" });
339
+ res.end(JSON.stringify(data, null, 2));
340
+ return;
341
+ }
342
+
343
+ // Dashboard HTML
344
+ try {
345
+ const data = gatherData(infernoDir);
346
+ const html = buildHtml(data, projectName);
347
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
348
+ res.end(html);
349
+ } catch (err) {
350
+ res.writeHead(500, { "Content-Type": "text/plain" });
351
+ res.end(`Error: ${err.message}`);
352
+ }
353
+ });
354
+
355
+ server.listen(port, "127.0.0.1", () => {});
356
+ return server;
357
+ }
358
+
359
+ function openBrowser(url) {
360
+ const platform = os.platform();
361
+ try {
362
+ if (platform === "darwin") execSync(`open "${url}"`, { stdio: "ignore" });
363
+ else if (platform === "win32") execSync(`start "" "${url}"`, { stdio: "ignore", shell: true });
364
+ else execSync(`xdg-open "${url}"`, { stdio: "ignore" });
365
+ } catch {}
366
+ }
367
+
368
+ // ── main ──────────────────────────────────────────────────────────────────────
369
+
370
+ export async function dashboardCommand(rawArgs) {
371
+ const args = rawArgs.slice(1);
372
+ const noOpen = args.includes("--no-open");
373
+ const portIdx = args.indexOf("--port");
374
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 7337;
375
+
376
+ const cwd = process.cwd();
377
+ const infernoDir = path.join(cwd, "inferno");
378
+
379
+ header("infernoflow dashboard");
380
+
381
+ if (!fs.existsSync(infernoDir)) {
382
+ warn("inferno/ not found — run: infernoflow init");
383
+ process.exit(1);
384
+ }
385
+
386
+ const url = `http://localhost:${port}`;
387
+
388
+ startServer(infernoDir, port);
389
+
390
+ ok(`Dashboard running → ${cyan(url)}`);
391
+ info("Auto-refreshes when inferno/ files change");
392
+ info("Press Ctrl+C to stop");
393
+ console.log();
394
+
395
+ if (!noOpen) openBrowser(url);
396
+
397
+ // Keep alive
398
+ await new Promise(() => {});
399
+ }
@@ -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
+ }
@@ -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,50 @@
1
+ # infernoflow PR capability analysis
2
+ #
3
+ # Posts a comment on every PR showing:
4
+ # - which capabilities were added, removed, or changed
5
+ # - recommended semver bump (major / minor / patch)
6
+ # - direct link to the drift details
7
+ #
8
+ # Setup:
9
+ # 1. Copy this file to .github/workflows/infernoflow-pr.yml
10
+ # 2. Ensure GITHUB_TOKEN has write access to pull requests
11
+ # (default GitHub Actions token works if repo settings allow it)
12
+ #
13
+ # That's it — infernoflow does the rest automatically.
14
+
15
+ name: infernoflow PR Analysis
16
+
17
+ on:
18
+ pull_request:
19
+ types: [opened, synchronize, reopened]
20
+
21
+ permissions:
22
+ pull-requests: write # required to post comments
23
+ contents: read
24
+
25
+ jobs:
26
+ capability-analysis:
27
+ name: Capability drift check
28
+ runs-on: ubuntu-latest
29
+
30
+ steps:
31
+ - name: Checkout PR branch
32
+ uses: actions/checkout@v4
33
+ with:
34
+ fetch-depth: 0 # full history needed for git diff
35
+
36
+ - name: Setup Node.js
37
+ uses: actions/setup-node@v4
38
+ with:
39
+ node-version: '20'
40
+
41
+ - name: Install infernoflow
42
+ run: npm install -g infernoflow@latest
43
+
44
+ - name: Post capability analysis comment
45
+ env:
46
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47
+ run: infernoflow pr-comment
48
+ # infernoflow auto-reads GITHUB_TOKEN, GITHUB_REPOSITORY,
49
+ # GITHUB_EVENT_PATH, and GITHUB_BASE_REF from the environment.
50
+ # No extra config needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {