infernoflow 0.17.0 → 0.18.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.
@@ -40,6 +40,8 @@ const COMMAND_DESCRIPTIONS = {
40
40
  share: "Generate a public read-only HTML snapshot of your capability contract",
41
41
  watch: "Watch source files and run suggest automatically on save",
42
42
  ci: "CI-native check: GitHub Actions annotations, GitLab code quality, exit codes",
43
+ notify: "Post capability drift summary to Slack or Discord",
44
+ report: "Generate a weekly/monthly HTML or Markdown report of capability activity",
43
45
  };
44
46
 
45
47
  const COMMAND_HANDLERS = {
@@ -73,6 +75,8 @@ const COMMAND_HANDLERS = {
73
75
  share: async (args) => (await import("../lib/commands/share.mjs")).shareCommand(args),
74
76
  watch: async (args) => (await import("../lib/commands/watch.mjs")).watchCommand(args),
75
77
  ci: async (args) => (await import("../lib/commands/ci.mjs")).ciCommand(args),
78
+ notify: async (args) => (await import("../lib/commands/notify.mjs")).notifyCommand(args),
79
+ report: async (args) => (await import("../lib/commands/report.mjs")).reportCommand(args),
76
80
  };
77
81
 
78
82
  function formatCommandsHelp() {
@@ -220,6 +224,20 @@ ${formatCommandsHelp()}
220
224
  --dry-run Print what would run without executing
221
225
  --silent No output (for git hook use)
222
226
 
227
+ ${bold("notify options:")}
228
+ --slack <url> Slack incoming webhook URL
229
+ --discord <url> Discord webhook URL
230
+ --on-change Only notify if capabilities actually changed
231
+ --dry-run Print message without sending
232
+ --json Machine-readable result
233
+
234
+ ${bold("report options:")}
235
+ --format html|md Output format (default: html)
236
+ --since <period> 7d, 30d, 90d, or YYYY-MM-DD (default: 30d)
237
+ --out <path> Output file path (default: inferno/report.html)
238
+ --open Open HTML report in browser after generating
239
+ --json Machine-readable summary
240
+
223
241
  ${bold("ci options:")}
224
242
  --platform <name> github | gitlab | bitbucket | generic (auto-detected)
225
243
  --fail-on <level> error | warning (default: error)
@@ -0,0 +1,258 @@
1
+ /**
2
+ * infernoflow notify
3
+ *
4
+ * Post capability drift summaries to Slack or Discord.
5
+ * Runs automatically after significant capability changes (via git hook or CI).
6
+ * Can also be triggered manually.
7
+ *
8
+ * Usage:
9
+ * infernoflow notify Auto-detect channel from config
10
+ * infernoflow notify --slack <url> Post to Slack webhook URL
11
+ * infernoflow notify --discord <url> Post to Discord webhook URL
12
+ * infernoflow notify --dry-run Print message without sending
13
+ * infernoflow notify --json Machine-readable: { ok, platform, message }
14
+ * infernoflow notify --on-change Only notify if capabilities actually changed
15
+ *
16
+ * Config (inferno/notify.json):
17
+ * { "slack": "https://hooks.slack.com/...", "discord": "https://discord.com/api/webhooks/..." }
18
+ *
19
+ * Or set env vars:
20
+ * INFERNOFLOW_SLACK_WEBHOOK
21
+ * INFERNOFLOW_DISCORD_WEBHOOK
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import * as https from "node:https";
27
+ import * as http from "node:http";
28
+ import { spawnSync } from "node:child_process";
29
+ import { done, warn, info, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
30
+
31
+ // ── Config ────────────────────────────────────────────────────────────────────
32
+
33
+ function loadNotifyConfig(infernoDir, args) {
34
+ const configPath = path.join(infernoDir, "notify.json");
35
+ const fileConfig = fs.existsSync(configPath)
36
+ ? (() => { try { return JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { return {}; } })()
37
+ : {};
38
+
39
+ const slackIdx = args.indexOf("--slack");
40
+ const discordIdx = args.indexOf("--discord");
41
+
42
+ return {
43
+ slack: slackIdx !== -1 ? args[slackIdx + 1] : process.env.INFERNOFLOW_SLACK_WEBHOOK || fileConfig.slack,
44
+ discord: discordIdx !== -1 ? args[discordIdx + 1] : process.env.INFERNOFLOW_DISCORD_WEBHOOK || fileConfig.discord,
45
+ };
46
+ }
47
+
48
+ // ── Data loading ──────────────────────────────────────────────────────────────
49
+
50
+ function runJson(cmd, cwd) {
51
+ try {
52
+ const result = spawnSync(process.execPath, [
53
+ path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), "..", "bin", "infernoflow.mjs"),
54
+ ...cmd.split(" ").slice(1),
55
+ ], { cwd, encoding: "utf8", timeout: 20_000 });
56
+ const out = result.stdout?.trim();
57
+ if (out) return JSON.parse(out);
58
+ } catch {}
59
+ return null;
60
+ }
61
+
62
+ function buildSummary(checkResult, diffResult, contract) {
63
+ const status = checkResult?.status || "unknown";
64
+ const caps = (contract?.capabilities || []).length;
65
+ const version = contract?.policyVersion || "?";
66
+ const project = contract?.policyId || "project";
67
+ const added = diffResult?.added || [];
68
+ const removed = diffResult?.removed || [];
69
+ const changed = diffResult?.changed || [];
70
+
71
+ return { status, caps, version, project, added, removed, changed };
72
+ }
73
+
74
+ // ── Slack message builder ─────────────────────────────────────────────────────
75
+
76
+ function buildSlackMessage(summary) {
77
+ const { status, caps, version, project, added, removed, changed } = summary;
78
+ const statusEmoji = status === "ok" ? "✅" : status === "warning" ? "⚠️" : "❌";
79
+ const hasChanges = added.length || removed.length || changed.length;
80
+
81
+ const blocks = [
82
+ {
83
+ type: "header",
84
+ text: { type: "plain_text", text: `🔥 infernoflow — ${project} v${version}`, emoji: true },
85
+ },
86
+ {
87
+ type: "section",
88
+ fields: [
89
+ { type: "mrkdwn", text: `*Status*\n${statusEmoji} ${status.toUpperCase()}` },
90
+ { type: "mrkdwn", text: `*Capabilities*\n${caps} tracked` },
91
+ ],
92
+ },
93
+ ];
94
+
95
+ if (hasChanges) {
96
+ const lines = [];
97
+ if (added.length) lines.push(`✅ *${added.length}* added: ${added.slice(0, 3).join(", ")}${added.length > 3 ? ` +${added.length - 3} more` : ""}`);
98
+ if (removed.length) lines.push(`❌ *${removed.length}* removed: ${removed.slice(0, 3).join(", ")}${removed.length > 3 ? ` +${removed.length - 3} more` : ""}`);
99
+ if (changed.length) lines.push(`📝 *${changed.length}* changed`);
100
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } });
101
+ }
102
+
103
+ blocks.push({
104
+ type: "context",
105
+ elements: [{ type: "mrkdwn", text: `<https://github.com/ronmiz/infernoflow|infernoflow> · ${new Date().toLocaleString()}` }],
106
+ });
107
+
108
+ return { blocks };
109
+ }
110
+
111
+ // ── Discord message builder ───────────────────────────────────────────────────
112
+
113
+ function buildDiscordMessage(summary) {
114
+ const { status, caps, version, project, added, removed, changed } = summary;
115
+ const color = status === "ok" ? 0x4ade80 : status === "warning" ? 0xf97316 : 0xf87171;
116
+ const hasChanges = added.length || removed.length || changed.length;
117
+
118
+ const fields = [
119
+ { name: "Status", value: status.toUpperCase(), inline: true },
120
+ { name: "Capabilities", value: String(caps), inline: true },
121
+ { name: "Version", value: `v${version}`, inline: true },
122
+ ];
123
+
124
+ if (added.length) fields.push({ name: "✅ Added", value: added.slice(0,5).join(", ") + (added.length > 5 ? ` +${added.length-5}` : ""), inline: false });
125
+ if (removed.length) fields.push({ name: "❌ Removed", value: removed.slice(0,5).join(", ") + (removed.length > 5 ? ` +${removed.length-5}` : ""), inline: false });
126
+
127
+ return {
128
+ embeds: [{
129
+ title: `🔥 infernoflow — ${project}`,
130
+ description: hasChanges ? "Capability changes detected" : "Contract healthy",
131
+ color,
132
+ fields,
133
+ footer: { text: "infernoflow · " + new Date().toLocaleString() },
134
+ url: "https://github.com/ronmiz/infernoflow",
135
+ }],
136
+ };
137
+ }
138
+
139
+ // ── HTTP post ─────────────────────────────────────────────────────────────────
140
+
141
+ function postWebhook(url, payload) {
142
+ return new Promise((resolve, reject) => {
143
+ const body = JSON.stringify(payload);
144
+ const parsed = new URL(url);
145
+ const isHttps = parsed.protocol === "https:";
146
+ const lib = isHttps ? https : http;
147
+
148
+ const req = lib.request({
149
+ hostname: parsed.hostname,
150
+ port: parsed.port || (isHttps ? 443 : 80),
151
+ path: parsed.pathname + (parsed.search || ""),
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "User-Agent": "infernoflow-cli" },
154
+ }, (res) => {
155
+ let data = "";
156
+ res.on("data", d => (data += d));
157
+ res.on("end", () => resolve({ status: res.statusCode, body: data }));
158
+ });
159
+
160
+ req.on("error", reject);
161
+ req.write(body);
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ // ── Main ──────────────────────────────────────────────────────────────────────
167
+
168
+ export async function notifyCommand(rawArgs) {
169
+ const args = rawArgs.slice(1);
170
+ const jsonMode = args.includes("--json");
171
+ const dryRun = args.includes("--dry-run");
172
+ const onlyChange = args.includes("--on-change");
173
+ const cwd = process.cwd();
174
+ const infernoDir = path.join(cwd, "inferno");
175
+
176
+ if (!fs.existsSync(infernoDir)) {
177
+ const msg = "inferno/ not found. Run: infernoflow init";
178
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
179
+ process.exit(1);
180
+ }
181
+
182
+ const config = loadNotifyConfig(infernoDir, args);
183
+
184
+ if (!config.slack && !config.discord) {
185
+ const msg = "No webhook configured. Use --slack <url>, --discord <url>, or set INFERNOFLOW_SLACK_WEBHOOK / INFERNOFLOW_DISCORD_WEBHOOK.";
186
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
187
+ if (!jsonMode) {
188
+ console.log();
189
+ console.log(` ${gray("To configure permanently, create inferno/notify.json:")}`);
190
+ console.log(` ${cyan('{ "slack": "https://hooks.slack.com/...", "discord": "https://discord.com/api/webhooks/..." }')}`);
191
+ console.log();
192
+ }
193
+ process.exit(1);
194
+ }
195
+
196
+ // Load data
197
+ const contract = (() => {
198
+ for (const f of ["contract.json", "capabilities.json"]) {
199
+ const p = path.join(infernoDir, f);
200
+ if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {} }
201
+ }
202
+ return {};
203
+ })();
204
+ const checkResult = runJson("check --json", cwd);
205
+ const diffResult = runJson("diff --json", cwd);
206
+ const summary = buildSummary(checkResult, diffResult, contract);
207
+
208
+ // --on-change: skip if nothing changed
209
+ if (onlyChange && !summary.added.length && !summary.removed.length && !summary.changed.length) {
210
+ if (jsonMode) { console.log(JSON.stringify({ ok: true, skipped: true, reason: "no capability changes" })); }
211
+ else { info("No capability changes — skipping notification."); }
212
+ return;
213
+ }
214
+
215
+ const results = [];
216
+
217
+ // Slack
218
+ if (config.slack) {
219
+ const payload = buildSlackMessage(summary);
220
+ if (dryRun) {
221
+ if (!jsonMode) { info("Slack payload (dry run):"); console.log(JSON.stringify(payload, null, 2)); }
222
+ results.push({ platform: "slack", ok: true, dryRun: true });
223
+ } else {
224
+ try {
225
+ const resp = await postWebhook(config.slack, payload);
226
+ const ok = resp.status >= 200 && resp.status < 300;
227
+ if (!jsonMode) { ok ? done("Slack notification sent") : warn(`Slack returned ${resp.status}`); }
228
+ results.push({ platform: "slack", ok, status: resp.status });
229
+ } catch (err) {
230
+ if (!jsonMode) warn(`Slack failed: ${err.message}`);
231
+ results.push({ platform: "slack", ok: false, error: err.message });
232
+ }
233
+ }
234
+ }
235
+
236
+ // Discord
237
+ if (config.discord) {
238
+ const payload = buildDiscordMessage(summary);
239
+ if (dryRun) {
240
+ if (!jsonMode) { info("Discord payload (dry run):"); console.log(JSON.stringify(payload, null, 2)); }
241
+ results.push({ platform: "discord", ok: true, dryRun: true });
242
+ } else {
243
+ try {
244
+ const resp = await postWebhook(config.discord, payload);
245
+ const ok = resp.status >= 200 && resp.status < 300;
246
+ if (!jsonMode) { ok ? done("Discord notification sent") : warn(`Discord returned ${resp.status}`); }
247
+ results.push({ platform: "discord", ok, status: resp.status });
248
+ } catch (err) {
249
+ if (!jsonMode) warn(`Discord failed: ${err.message}`);
250
+ results.push({ platform: "discord", ok: false, error: err.message });
251
+ }
252
+ }
253
+ }
254
+
255
+ if (jsonMode) {
256
+ console.log(JSON.stringify({ ok: results.every(r => r.ok), results, summary }));
257
+ }
258
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * infernoflow report
3
+ *
4
+ * Generate a weekly/monthly HTML or Markdown report of capability changes,
5
+ * version history, drift events, and agent usage. Can be committed to the
6
+ * repo on a schedule or emailed to the team.
7
+ *
8
+ * Usage:
9
+ * infernoflow report Generate HTML report (last 30 days)
10
+ * infernoflow report --format md Markdown instead of HTML
11
+ * infernoflow report --since 7d Last 7 days
12
+ * infernoflow report --since 2024-01-01 Since a specific date
13
+ * infernoflow report --out report.html Custom output path
14
+ * infernoflow report --open Open in browser after generating
15
+ * infernoflow report --json Machine-readable summary
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { execSync, spawnSync } from "node:child_process";
21
+ import { done, warn, info, bold, cyan, gray } from "../ui/output.mjs";
22
+
23
+ // ── Git helpers ───────────────────────────────────────────────────────────────
24
+
25
+ function git(cmd, cwd) {
26
+ try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 15_000 }).trim(); }
27
+ catch { return ""; }
28
+ }
29
+
30
+ function parseSinceDuration(since) {
31
+ if (!since) return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
32
+ if (/^\d+d$/.test(since)) return new Date(Date.now() - parseInt(since) * 24 * 60 * 60 * 1000);
33
+ if (/^\d+w$/.test(since)) return new Date(Date.now() - parseInt(since) * 7 * 24 * 60 * 60 * 1000);
34
+ if (/^\d{4}-\d{2}-\d{2}$/.test(since)) return new Date(since);
35
+ return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
36
+ }
37
+
38
+ function getCommitsSince(cwd, since) {
39
+ const iso = since.toISOString();
40
+ const out = git(`git log --after="${iso}" --format="%H|%s|%an|%ad" --date=short`, cwd);
41
+ if (!out) return [];
42
+ return out.split("\n").filter(Boolean).map(line => {
43
+ const [hash, subject, author, date] = line.split("|");
44
+ return { hash: hash?.slice(0, 8), subject, author, date };
45
+ });
46
+ }
47
+
48
+ function getCapabilityHistory(infernoDir, cwd, since) {
49
+ const out = git(`git log --after="${since.toISOString()}" --format="%H|%ad" --date=short -- inferno/`, cwd);
50
+ if (!out) return [];
51
+ return out.split("\n").filter(Boolean).map(line => {
52
+ const [hash, date] = line.split("|");
53
+ return { hash: hash?.slice(0, 8), date };
54
+ });
55
+ }
56
+
57
+ function getVersionTags(cwd, since) {
58
+ const out = git(`git tag --sort=-version:refname`, cwd);
59
+ if (!out) return [];
60
+ return out.split("\n").filter(t => t.startsWith("v")).slice(0, 10).map(tag => {
61
+ const date = git(`git log -1 --format=%ad --date=short ${tag}`, cwd);
62
+ return { tag, date };
63
+ }).filter(t => !since || new Date(t.date) >= since);
64
+ }
65
+
66
+ // ── Data gathering ────────────────────────────────────────────────────────────
67
+
68
+ function gatherData(cwd, infernoDir, since) {
69
+ const contract = (() => {
70
+ for (const f of ["contract.json", "capabilities.json"]) {
71
+ const p = path.join(infernoDir, f);
72
+ if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {} }
73
+ }
74
+ return {};
75
+ })();
76
+
77
+ const caps = contract.capabilities || [];
78
+ const covered = caps.filter(c => typeof c === "object" && c.covered).length;
79
+
80
+ const commits = getCommitsSince(cwd, since);
81
+ const capCommits = getCapabilityHistory(infernoDir, cwd, since);
82
+ const versionTags = getVersionTags(cwd, since);
83
+
84
+ const agents = (() => {
85
+ const dir = path.join(infernoDir, "agents");
86
+ if (!fs.existsSync(dir)) return [];
87
+ return fs.readdirSync(dir).filter(f => f.endsWith(".json")).map(f => {
88
+ try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
89
+ catch { return null; }
90
+ }).filter(Boolean);
91
+ })();
92
+
93
+ const changelog = (() => {
94
+ const p = path.join(cwd, "CHANGELOG.md");
95
+ if (!fs.existsSync(p)) return null;
96
+ try {
97
+ const lines = fs.readFileSync(p, "utf8").split("\n");
98
+ const start = lines.findIndex(l => l.startsWith("## "));
99
+ if (start === -1) return null;
100
+ const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
101
+ return lines.slice(start, end === -1 ? start + 20 : end).join("\n");
102
+ } catch { return null; }
103
+ })();
104
+
105
+ return {
106
+ project: contract.policyId || path.basename(cwd),
107
+ version: contract.policyVersion || "?",
108
+ caps,
109
+ covered,
110
+ commits,
111
+ capCommits,
112
+ versionTags,
113
+ agents,
114
+ changelog,
115
+ since,
116
+ generatedAt: new Date().toLocaleString(),
117
+ };
118
+ }
119
+
120
+ // ── HTML report ───────────────────────────────────────────────────────────────
121
+
122
+ function buildHtmlReport(data) {
123
+ const { project, version, caps, covered, commits, capCommits, versionTags, agents, changelog, since, generatedAt } = data;
124
+ const sinceStr = since.toLocaleDateString();
125
+ const coverage = caps.length ? Math.round((covered / caps.length) * 100) : 0;
126
+
127
+ const capRows = caps.slice(0, 30).map(c => {
128
+ const id = typeof c === "string" ? c : c.id;
129
+ const cov = typeof c === "object" ? c.covered : undefined;
130
+ const badge = cov === true ? `<span style="color:#4ade80">✔</span>` : cov === false ? `<span style="color:#f87171">✗</span>` : `<span style="color:#475569">·</span>`;
131
+ return `<tr><td style="padding:4px 10px">${badge}</td><td style="padding:4px 10px;font-weight:600">${id}</td></tr>`;
132
+ }).join("");
133
+
134
+ const versionRows = versionTags.map(t =>
135
+ `<tr><td style="padding:4px 10px;font-weight:600">${t.tag}</td><td style="padding:4px 10px;color:#94a3b8">${t.date}</td></tr>`
136
+ ).join("") || `<tr><td colspan="2" style="padding:4px 10px;color:#475569">No version tags in this period</td></tr>`;
137
+
138
+ const agentRows = agents.map(a => {
139
+ const steps = (a.steps || []).map(s => typeof s === "string" ? s : s.command).join(" → ");
140
+ const conf = a.confidence ? Math.round(a.confidence * 100) + "%" : "—";
141
+ return `<tr><td style="padding:4px 10px;font-weight:600">${a.name}</td><td style="padding:4px 10px;color:#94a3b8">${conf}</td><td style="padding:4px 10px;color:#64748b;font-size:0.8em">${steps}</td></tr>`;
142
+ }).join("") || `<tr><td colspan="3" style="padding:4px 10px;color:#475569">No agents synthesized yet</td></tr>`;
143
+
144
+ return `<!doctype html>
145
+ <html lang="en">
146
+ <head>
147
+ <meta charset="UTF-8">
148
+ <meta name="viewport" content="width=device-width,initial-scale=1">
149
+ <title>infernoflow report — ${project}</title>
150
+ <style>
151
+ *{box-sizing:border-box;margin:0;padding:0}
152
+ body{background:#0f0f1a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;padding:0 0 3rem}
153
+ header{background:#1a1a2e;border-bottom:2px solid #f97316;padding:1.5rem 2rem;display:flex;justify-content:space-between;align-items:flex-end}
154
+ header h1{font-size:1.4rem;font-weight:700;color:#f97316}
155
+ header .meta{color:#64748b;font-size:0.8rem}
156
+ main{max-width:960px;margin:2rem auto;padding:0 1.5rem}
157
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:2rem}
158
+ .card{background:#1a1a2e;border:1px solid #2d2d4e;border-radius:8px;padding:1rem 1.25rem}
159
+ .card .n{font-size:2rem;font-weight:700;color:#f97316}
160
+ .card .l{font-size:0.72rem;color:#64748b;text-transform:uppercase;letter-spacing:0.06em;margin-top:2px}
161
+ .section{margin-bottom:2rem}
162
+ h2{font-size:0.78rem;text-transform:uppercase;letter-spacing:0.08em;color:#64748b;margin-bottom:0.6rem;padding-bottom:0.35rem;border-bottom:1px solid #2d2d4e}
163
+ table{width:100%;border-collapse:collapse;background:#1a1a2e;border:1px solid #2d2d4e;border-radius:6px;overflow:hidden;font-size:0.875rem}
164
+ tr:nth-child(even) td{background:#16162a}
165
+ .prog{background:#2d2d4e;border-radius:999px;height:8px;margin-top:6px}
166
+ .prog-bar{background:#f97316;border-radius:999px;height:100%;transition:width 0.3s}
167
+ pre{background:#1a1a2e;border:1px solid #2d2d4e;border-radius:6px;padding:1rem;font-size:0.78rem;white-space:pre-wrap;color:#94a3b8;overflow-x:auto}
168
+ footer{text-align:center;color:#334155;font-size:0.72rem;margin-top:3rem}
169
+ footer a{color:#f97316;text-decoration:none}
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <header>
174
+ <div>
175
+ <h1>🔥 infernoflow report — ${project}</h1>
176
+ <div class="meta">v${version} · ${sinceStr} → ${generatedAt}</div>
177
+ </div>
178
+ <div class="meta" style="text-align:right">${commits.length} commits · ${capCommits.length} contract updates</div>
179
+ </header>
180
+ <main>
181
+ <div class="grid">
182
+ <div class="card"><div class="n">${caps.length}</div><div class="l">capabilities</div></div>
183
+ <div class="card"><div class="n">${coverage}%</div><div class="l">coverage</div><div class="prog"><div class="prog-bar" style="width:${coverage}%"></div></div></div>
184
+ <div class="card"><div class="n">${commits.length}</div><div class="l">commits</div></div>
185
+ <div class="card"><div class="n">${capCommits.length}</div><div class="l">contract updates</div></div>
186
+ <div class="card"><div class="n">${versionTags.length}</div><div class="l">releases</div></div>
187
+ <div class="card"><div class="n">${agents.length}</div><div class="l">agents</div></div>
188
+ </div>
189
+
190
+ <div class="section">
191
+ <h2>Capabilities (${caps.length})</h2>
192
+ <table><tbody>${capRows}${caps.length > 30 ? `<tr><td colspan="2" style="padding:6px 10px;color:#475569">… and ${caps.length - 30} more</td></tr>` : ""}</tbody></table>
193
+ </div>
194
+
195
+ <div class="section">
196
+ <h2>Version history</h2>
197
+ <table><tbody>${versionRows}</tbody></table>
198
+ </div>
199
+
200
+ <div class="section">
201
+ <h2>Agents (${agents.length})</h2>
202
+ <table><tbody>${agentRows}</tbody></table>
203
+ </div>
204
+
205
+ ${changelog ? `<div class="section"><h2>Latest changelog</h2><pre>${changelog.replace(/</g, "&lt;")}</pre></div>` : ""}
206
+ </main>
207
+ <footer>Generated by <a href="https://github.com/ronmiz/infernoflow">infernoflow</a> on ${generatedAt}</footer>
208
+ </body>
209
+ </html>`;
210
+ }
211
+
212
+ // ── Markdown report ───────────────────────────────────────────────────────────
213
+
214
+ function buildMarkdownReport(data) {
215
+ const { project, version, caps, covered, commits, capCommits, versionTags, agents, changelog, since, generatedAt } = data;
216
+ const coverage = caps.length ? Math.round((covered / caps.length) * 100) : 0;
217
+
218
+ const lines = [
219
+ `# 🔥 infernoflow report — ${project}`,
220
+ ``,
221
+ `**Version:** v${version} · **Period:** ${since.toLocaleDateString()} → ${generatedAt}`,
222
+ ``,
223
+ `## Summary`,
224
+ ``,
225
+ `| Metric | Value |`,
226
+ `|---|---|`,
227
+ `| Capabilities | ${caps.length} |`,
228
+ `| Coverage | ${coverage}% |`,
229
+ `| Commits | ${commits.length} |`,
230
+ `| Contract updates | ${capCommits.length} |`,
231
+ `| Releases | ${versionTags.length} |`,
232
+ `| Agents | ${agents.length} |`,
233
+ ``,
234
+ `## Capabilities`,
235
+ ``,
236
+ ...caps.slice(0, 30).map(c => {
237
+ const id = typeof c === "string" ? c : c.id;
238
+ const cov = typeof c === "object" ? c.covered : undefined;
239
+ return `- ${cov === true ? "✅" : cov === false ? "❌" : "·"} **${id}**`;
240
+ }),
241
+ caps.length > 30 ? `- *… and ${caps.length - 30} more*` : "",
242
+ ``,
243
+ `## Version history`,
244
+ ``,
245
+ ...(versionTags.length
246
+ ? versionTags.map(t => `- **${t.tag}** — ${t.date}`)
247
+ : ["- *No releases in this period*"]),
248
+ ``,
249
+ `## Agents`,
250
+ ``,
251
+ ...(agents.length
252
+ ? agents.map(a => `- **${a.name}** (${a.confidence ? Math.round(a.confidence * 100) + "%" : "—"}) — ${a.description || ""}`)
253
+ : ["- *No agents synthesized yet*"]),
254
+ ``,
255
+ changelog ? `## Latest changelog\n\n\`\`\`\n${changelog}\n\`\`\`` : "",
256
+ ``,
257
+ `---`,
258
+ `*Generated by [infernoflow](https://github.com/ronmiz/infernoflow) on ${generatedAt}*`,
259
+ ];
260
+
261
+ return lines.filter(l => l !== undefined).join("\n");
262
+ }
263
+
264
+ // ── Main ──────────────────────────────────────────────────────────────────────
265
+
266
+ export async function reportCommand(rawArgs) {
267
+ const args = rawArgs.slice(1);
268
+ const jsonMode = args.includes("--json");
269
+ const openBrowser = args.includes("--open");
270
+ const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "html";
271
+ const sinceArg = args.includes("--since") ? args[args.indexOf("--since") + 1] : null;
272
+ const outArg = args.includes("--out") ? args[args.indexOf("--out") + 1] : null;
273
+ const cwd = process.cwd();
274
+ const infernoDir = path.join(cwd, "inferno");
275
+
276
+ if (!fs.existsSync(infernoDir)) {
277
+ const msg = "inferno/ not found. Run: infernoflow init";
278
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
279
+ process.exit(1);
280
+ }
281
+
282
+ if (!jsonMode) info(`Generating ${format} report…`);
283
+
284
+ const since = parseSinceDuration(sinceArg);
285
+ const data = gatherData(cwd, infernoDir, since);
286
+
287
+ const ext = format === "md" ? "md" : "html";
288
+ const outPath = outArg || path.join(infernoDir, `report.${ext}`);
289
+ const content = format === "md" ? buildMarkdownReport(data) : buildHtmlReport(data);
290
+
291
+ fs.writeFileSync(outPath, content, "utf8");
292
+
293
+ if (openBrowser && format === "html") {
294
+ try {
295
+ const cmd = process.platform === "win32" ? `start "" "${outPath}"`
296
+ : process.platform === "darwin" ? `open "${outPath}"`
297
+ : `xdg-open "${outPath}"`;
298
+ execSync(cmd, { stdio: "ignore" });
299
+ } catch {}
300
+ }
301
+
302
+ if (jsonMode) {
303
+ console.log(JSON.stringify({
304
+ ok: true,
305
+ file: outPath,
306
+ format,
307
+ project: data.project,
308
+ version: data.version,
309
+ capabilities: data.caps.length,
310
+ commits: data.commits.length,
311
+ agents: data.agents.length,
312
+ }));
313
+ return;
314
+ }
315
+
316
+ done(`Report generated`);
317
+ console.log(` ${cyan(outPath)}`);
318
+ console.log(` ${gray(data.caps.length + " capabilities · " + data.commits.length + " commits · " + data.agents.length + " agents")}`);
319
+ console.log();
320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {