infernoflow 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Shared error handling utilities for infernoflow commands.
3
+ *
4
+ * Provides consistent, friendly error messages with actionable hints.
5
+ */
6
+
7
+ import { warn, info, bold, cyan, gray, red } from "./output.mjs";
8
+
9
+ // ── Common error types ────────────────────────────────────────────────────────
10
+
11
+ export const ERR = {
12
+ NO_INFERNO_DIR: "inferno/ directory not found.",
13
+ NO_CONTRACT: "No contract.json or capabilities.json found.",
14
+ NO_GIT: "This directory is not a git repository.",
15
+ NO_NETWORK: "Network request failed.",
16
+ INVALID_JSON: "Invalid JSON in response.",
17
+ PERMISSION: "Permission denied.",
18
+ TIMEOUT: "Command timed out.",
19
+ };
20
+
21
+ // ── Error formatter ───────────────────────────────────────────────────────────
22
+
23
+ export function fatalError(message, hint, jsonMode = false) {
24
+ if (jsonMode) {
25
+ console.log(JSON.stringify({ ok: false, error: message, hint: hint || undefined }));
26
+ } else {
27
+ console.error();
28
+ console.error(` ${red("✗")} ${bold(message)}`);
29
+ if (hint) console.error(` ${gray(hint)}`);
30
+ console.error();
31
+ }
32
+ process.exit(1);
33
+ }
34
+
35
+ export function softWarn(message, hint, jsonMode = false) {
36
+ if (!jsonMode) {
37
+ warn(message);
38
+ if (hint) console.error(` ${gray(hint)}`);
39
+ }
40
+ }
41
+
42
+ // ── Pre-flight checks ─────────────────────────────────────────────────────────
43
+
44
+ import * as fs from "node:fs";
45
+ import * as path from "node:path";
46
+ import { execSync } from "node:child_process";
47
+
48
+ /**
49
+ * Ensure inferno/ exists. Exits with a friendly message if not.
50
+ */
51
+ export function requireInfernoDir(cwd, jsonMode = false) {
52
+ const infernoDir = path.join(cwd, "inferno");
53
+ if (!fs.existsSync(infernoDir)) {
54
+ fatalError(
55
+ ERR.NO_INFERNO_DIR,
56
+ "Run: infernoflow init (or: infernoflow setup for full setup)",
57
+ jsonMode
58
+ );
59
+ }
60
+ return infernoDir;
61
+ }
62
+
63
+ /**
64
+ * Ensure a git repo exists. Exits with a friendly message if not.
65
+ */
66
+ export function requireGitRepo(cwd, jsonMode = false) {
67
+ try {
68
+ execSync("git rev-parse --git-dir", { cwd, stdio: "ignore" });
69
+ } catch {
70
+ fatalError(
71
+ ERR.NO_GIT,
72
+ "Run: git init && git add . && git commit -m 'init'",
73
+ jsonMode
74
+ );
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read and parse a JSON file, with a friendly error on failure.
80
+ */
81
+ export function readJsonFile(filePath, label, jsonMode = false) {
82
+ if (!fs.existsSync(filePath)) return null;
83
+ try {
84
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
85
+ } catch (err) {
86
+ fatalError(
87
+ `Could not parse ${label}: ${err.message}`,
88
+ `Check the file for syntax errors: ${filePath}`,
89
+ jsonMode
90
+ );
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Read contract.json or capabilities.json, whichever exists.
96
+ */
97
+ export function readContract(infernoDir, jsonMode = false) {
98
+ for (const f of ["contract.json", "capabilities.json"]) {
99
+ const p = path.join(infernoDir, f);
100
+ const data = readJsonFile(p, f, jsonMode);
101
+ if (data) return data;
102
+ }
103
+ fatalError(ERR.NO_CONTRACT, "Run: infernoflow init", jsonMode);
104
+ }
105
+
106
+ /**
107
+ * Parse a semver string. Returns { major, minor, patch } or null.
108
+ */
109
+ export function parseSemver(v) {
110
+ const m = String(v || "").match(/^(\d+)\.(\d+)\.(\d+)/);
111
+ if (!m) return null;
112
+ return { major: +m[1], minor: +m[2], patch: +m[3], raw: m[0] };
113
+ }
114
+
115
+ /**
116
+ * Validate a URL string. Returns true if valid http/https URL.
117
+ */
118
+ export function isValidUrl(str) {
119
+ try {
120
+ const u = new URL(str);
121
+ return u.protocol === "http:" || u.protocol === "https:";
122
+ } catch { return false; }
123
+ }
124
+
125
+ /**
126
+ * Wrap an async command handler with top-level error catching.
127
+ * Prints a friendly message instead of a raw stack trace.
128
+ */
129
+ export function withErrorHandling(fn, jsonMode = false) {
130
+ return fn().catch(err => {
131
+ if (jsonMode) {
132
+ console.log(JSON.stringify({ ok: false, error: err.message }));
133
+ } else {
134
+ console.error();
135
+ console.error(` ${red("✗")} ${bold("Unexpected error:")} ${err.message}`);
136
+ if (process.env.DEBUG) console.error(err.stack);
137
+ else console.error(` ${gray("Set DEBUG=1 for a full stack trace.")}`);
138
+ console.error();
139
+ }
140
+ process.exit(1);
141
+ });
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {