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.
- package/dist/bin/infernoflow.mjs +18 -0
- package/dist/lib/commands/notify.mjs +258 -0
- package/dist/lib/commands/report.mjs +320 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -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, "<")}</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
|
+
}
|