infernoflow 0.28.0 → 0.29.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.
@@ -62,6 +62,7 @@ const COMMAND_DESCRIPTIONS = {
62
62
  why: "Given a file or function name — show which capability it serves, scenarios, stability, and git history",
63
63
  impact: "Blast radius analysis — see every cap, scenario, and risk level affected before you change anything",
64
64
  scaffold: "Generate a new capability — source skeleton, contract registration, and placeholder scenario in one command",
65
+ explain: "AI narrative about a capability — what it does, why it exists, what's risky, and what to test",
65
66
  };
66
67
 
67
68
  const COMMAND_HANDLERS = {
@@ -117,6 +118,7 @@ const COMMAND_HANDLERS = {
117
118
  why: async (args) => (await import("../lib/commands/why.mjs")).whyCommand(args),
118
119
  impact: async (args) => (await import("../lib/commands/impact.mjs")).impactCommand(args),
119
120
  scaffold: async (args) => (await import("../lib/commands/scaffold.mjs")).scaffoldCommand(args),
121
+ explain: async (args) => (await import("../lib/commands/explain.mjs")).explainCommand(args),
120
122
  };
121
123
 
122
124
  function formatCommandsHelp() {
@@ -415,6 +417,11 @@ ${formatCommandsHelp()}
415
417
  --dry-run Preview what would be generated without writing files
416
418
  --json Machine-readable output including generated code
417
419
 
420
+ ${bold("explain options:")}
421
+ infernoflow explain <cap-id> AI narrative: what it does, risk, what to test
422
+ --dry-run Print the AI prompt only — no API call made
423
+ --json Machine-readable output (narrative, stability, scenarios)
424
+
418
425
  ${bold("Machine output:")}
419
426
  ${gray("status --json")}
420
427
  ${gray("check --json")}
@@ -0,0 +1,373 @@
1
+ /**
2
+ * infernoflow explain
3
+ *
4
+ * AI-generated narrative about a capability — what it does, why it exists,
5
+ * what's risky about it, and what to test before changing it.
6
+ *
7
+ * Synthesises: stability level, git history, scenarios, callers, services,
8
+ * source files — then calls the AI provider for a 3-5 sentence human narrative.
9
+ *
10
+ * Usage:
11
+ * infernoflow explain user-auth
12
+ * infernoflow explain payment-process --dry-run (print prompt only)
13
+ * infernoflow explain user-auth --json
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { execSync } from "node:child_process";
19
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
20
+
21
+ // ── helpers ───────────────────────────────────────────────────────────────────
22
+
23
+ function loadJson(p) {
24
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); }
25
+ catch { return null; }
26
+ }
27
+
28
+ function runGit(cmd, cwd) {
29
+ try {
30
+ return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe","pipe","pipe"] }).trim();
31
+ } catch { return ""; }
32
+ }
33
+
34
+ const LEVEL_ICON = { frozen: "🧊", stable: "〰️ ", experimental: "🌊" };
35
+ const LEVEL_COLOR = { frozen: red, stable: yellow, experimental: green };
36
+
37
+ function stability(cap) { return cap?.stability || "experimental"; }
38
+
39
+ // ── git helpers ───────────────────────────────────────────────────────────────
40
+
41
+ function getFirstCommit(filePath, cwd) {
42
+ if (!filePath) return null;
43
+ const rel = path.relative(cwd, path.resolve(cwd, filePath));
44
+ const log = runGit(
45
+ `git log --follow --format="%h|%aI|%ae|%s" -- ${JSON.stringify(rel)}`, cwd
46
+ );
47
+ if (!log) return null;
48
+ const lines = log.split("\n").filter(Boolean);
49
+ if (!lines.length) return null;
50
+ const [hash, date, author, ...subjectParts] = lines[lines.length - 1].split("|");
51
+ return {
52
+ hash: hash?.trim(),
53
+ date: date?.trim() ? new Date(date.trim()).toLocaleDateString() : "",
54
+ author: author?.trim(),
55
+ subject: subjectParts.join("|").trim(),
56
+ };
57
+ }
58
+
59
+ function getRecentHistory(filePath, cwd, limit = 5) {
60
+ if (!filePath) return [];
61
+ const rel = path.relative(cwd, path.resolve(cwd, filePath));
62
+ const log = runGit(
63
+ `git log --follow --format="%h|%aI|%ae|%s" -${limit} -- ${JSON.stringify(rel)}`, cwd
64
+ );
65
+ if (!log) return [];
66
+ return log.split("\n").filter(Boolean).map(line => {
67
+ const [hash, date, author, ...subjectParts] = line.split("|");
68
+ return {
69
+ hash: hash?.trim(),
70
+ date: date?.trim() ? new Date(date.trim()).toLocaleDateString() : "",
71
+ author: author?.trim(),
72
+ subject: subjectParts.join("|").trim(),
73
+ };
74
+ });
75
+ }
76
+
77
+ // ── scenario finder ───────────────────────────────────────────────────────────
78
+
79
+ function findScenarios(capId, infernoDir) {
80
+ const dir = path.join(infernoDir, "scenarios");
81
+ if (!fs.existsSync(dir)) return [];
82
+ const found = [];
83
+ for (const f of fs.readdirSync(dir)) {
84
+ if (!f.endsWith(".json")) continue;
85
+ try {
86
+ const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
87
+ const covered = s.capabilitiesCovered || s.capabilities || [];
88
+ if (covered.some(c => c.toLowerCase() === capId.toLowerCase())) {
89
+ found.push(s);
90
+ }
91
+ } catch {}
92
+ }
93
+ return found;
94
+ }
95
+
96
+ // ── prompt builder ────────────────────────────────────────────────────────────
97
+
98
+ function buildPrompt(capId, cap, scanEntry, graph, allCaps, scenarios, firstCommit, recentHistory) {
99
+ const level = stability(cap);
100
+ const files = scanEntry?.codeAnalysis?.sourceFiles || [];
101
+ const functions = scanEntry?.codeAnalysis?.functions || [];
102
+ const services = scanEntry?.codeAnalysis?.services || [];
103
+ const throws_ = scanEntry?.codeAnalysis?.throws || [];
104
+ const calls = scanEntry?.codeAnalysis?.calls || [];
105
+ const deps = graph?.deps?.[capId] || [];
106
+ const dependents = graph?.dependents?.[capId] || [];
107
+
108
+ const lines = [
109
+ `You are a senior engineer writing a brief, plain-English explanation of a software capability for a teammate who is about to modify it.`,
110
+ ``,
111
+ `Write 3–5 sentences covering:`,
112
+ ` 1. What this capability does and why it exists`,
113
+ ` 2. The most important thing to know before changing it (stability, callers, risk)`,
114
+ ` 3. What to test or verify after any modification`,
115
+ ``,
116
+ `Be concrete and direct. Do not use bullet points. Do not repeat the capability ID verbatim in every sentence.`,
117
+ ``,
118
+ `=== Capability: ${capId} ===`,
119
+ `Name: ${cap.name || cap.title || capId}`,
120
+ `Description: ${cap.description || "(none provided)"}`,
121
+ `Stability: ${level}`,
122
+ ];
123
+
124
+ if (files.length) lines.push(`Source files: ${files.join(", ")}`);
125
+ if (functions.length) lines.push(`Functions: ${functions.join(", ")}`);
126
+ if (services.length) lines.push(`External services used: ${services.join(", ")}`);
127
+ if (throws_.length) lines.push(`Can throw: ${throws_.join(", ")}`);
128
+ if (calls.length) lines.push(`Internal calls: ${calls.join(", ")}`);
129
+
130
+ if (deps.length) {
131
+ const depDetails = deps.map(d => {
132
+ const dc = allCaps.find(c => c.id === d);
133
+ return `${d} (${stability(dc)})`;
134
+ });
135
+ lines.push(`Calls capabilities: ${depDetails.join(", ")}`);
136
+ }
137
+
138
+ if (dependents.length) {
139
+ const depDetails = dependents.map(d => {
140
+ const dc = allCaps.find(c => c.id === d);
141
+ return `${d} (${stability(dc)})`;
142
+ });
143
+ lines.push(`Called by capabilities: ${depDetails.join(", ")}`);
144
+ }
145
+
146
+ if (scenarios.length) {
147
+ lines.push(`Test scenarios: ${scenarios.map(s => s.scenarioId || s.description || "unnamed").join(", ")}`);
148
+ } else {
149
+ lines.push(`Test scenarios: none registered`);
150
+ }
151
+
152
+ if (firstCommit) {
153
+ lines.push(`First introduced: ${firstCommit.date} by ${firstCommit.author} — "${firstCommit.subject}"`);
154
+ }
155
+
156
+ if (recentHistory.length) {
157
+ lines.push(`Recent changes:`);
158
+ for (const h of recentHistory.slice(0, 3)) {
159
+ lines.push(` ${h.date} — ${h.subject}`);
160
+ }
161
+ }
162
+
163
+ if (level === "frozen") {
164
+ lines.push(`IMPORTANT: This capability is FROZEN. Any modification requires explicit approval.`);
165
+ } else if (level === "stable") {
166
+ lines.push(`NOTE: This capability is STABLE. Prefer additive changes; avoid breaking the public API.`);
167
+ }
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ // ── AI caller ─────────────────────────────────────────────────────────────────
173
+
174
+ async function callAI(prompt, cwd) {
175
+ try {
176
+ const { callAI: call } = await import("../ai/providerRouter.mjs");
177
+ return await call(prompt, cwd);
178
+ } catch {
179
+ // Provider not available — return a structured fallback
180
+ return null;
181
+ }
182
+ }
183
+
184
+ // ── fallback narrative (no AI) ────────────────────────────────────────────────
185
+
186
+ function buildFallback(capId, cap, scanEntry, graph, allCaps, scenarios) {
187
+ const level = stability(cap);
188
+ const name = cap.name || cap.title || capId;
189
+ const services = scanEntry?.codeAnalysis?.services || [];
190
+ const dependents = graph?.dependents?.[capId] || [];
191
+ const deps = graph?.deps?.[capId] || [];
192
+
193
+ const parts = [];
194
+
195
+ // What it does
196
+ if (cap.description && cap.description !== "(none provided)") {
197
+ parts.push(`${name} — ${cap.description}.`);
198
+ } else {
199
+ parts.push(`${name} handles the ${capId} flow within this system.`);
200
+ }
201
+
202
+ // External services
203
+ if (services.length) {
204
+ parts.push(`It integrates with ${services.join(" and ")}.`);
205
+ }
206
+
207
+ // Dependencies
208
+ if (deps.length) {
209
+ parts.push(`It depends on: ${deps.join(", ")}.`);
210
+ }
211
+ if (dependents.length) {
212
+ const frozenDeps = dependents.filter(d => stability(allCaps.find(c => c.id === d)) === "frozen");
213
+ if (frozenDeps.length) {
214
+ parts.push(`⚠️ ${frozenDeps.join(", ")} depend${frozenDeps.length === 1 ? "s" : ""} on this — changing it may break frozen capabilities.`);
215
+ } else {
216
+ parts.push(`${dependents.join(", ")} depend${dependents.length === 1 ? "s" : ""} on this capability.`);
217
+ }
218
+ }
219
+
220
+ // Stability advice
221
+ if (level === "frozen") {
222
+ parts.push(`This capability is FROZEN — do not modify without explicit instruction.`);
223
+ } else if (level === "stable") {
224
+ parts.push(`This capability is stable — prefer additive changes and avoid breaking the existing API surface.`);
225
+ } else {
226
+ parts.push(`This capability is experimental — free to refactor as needed.`);
227
+ }
228
+
229
+ // Test advice
230
+ if (scenarios.length) {
231
+ parts.push(`Before shipping changes, run the registered scenarios: ${scenarios.map(s => s.scenarioId || "unnamed").join(", ")}.`);
232
+ } else {
233
+ parts.push(`No test scenarios are registered — consider adding one before making changes.`);
234
+ }
235
+
236
+ return parts.join(" ");
237
+ }
238
+
239
+ // ── printer ───────────────────────────────────────────────────────────────────
240
+
241
+ function printExplain(capId, cap, narrative, provider, dryRun) {
242
+ const level = stability(cap);
243
+ const icon = LEVEL_ICON[level] || "🌊";
244
+ const color = LEVEL_COLOR[level] || green;
245
+
246
+ console.log();
247
+ console.log(bold(` ${icon} ${color(capId)}`));
248
+ if (cap.name || cap.title) console.log(gray(` ${cap.name || cap.title}`));
249
+ console.log();
250
+
251
+ if (dryRun) {
252
+ console.log(yellow(" [dry-run] Prompt only — no AI call made"));
253
+ console.log();
254
+ return;
255
+ }
256
+
257
+ // Word-wrap narrative at ~80 chars
258
+ const words = narrative.split(" ");
259
+ let line = " ";
260
+ const lines = [];
261
+ for (const word of words) {
262
+ if (line.length + word.length > 82) { lines.push(line); line = " " + word; }
263
+ else line += (line === " " ? "" : " ") + word;
264
+ }
265
+ if (line.trim()) lines.push(line);
266
+
267
+ for (const l of lines) console.log(l);
268
+ console.log();
269
+
270
+ if (provider) {
271
+ console.log(gray(` ── via ${provider}`));
272
+ } else {
273
+ console.log(gray(" ── (AI provider not configured — showing structural summary)"));
274
+ console.log(gray(" Run: infernoflow setup to connect an AI provider"));
275
+ }
276
+ console.log();
277
+ }
278
+
279
+ // ── entry point ───────────────────────────────────────────────────────────────
280
+
281
+ export async function explainCommand(rawArgs) {
282
+ const args = (rawArgs || []).slice(1);
283
+ const dryRun = args.includes("--dry-run");
284
+ const jsonMode = args.includes("--json");
285
+
286
+ const capId = args.find(a => !a.startsWith("--"));
287
+
288
+ if (!capId) {
289
+ console.error(red("✗ Usage: infernoflow explain <capability-id> [--dry-run] [--json]"));
290
+ console.error(gray(" Example: infernoflow explain user-auth"));
291
+ process.exit(1);
292
+ }
293
+
294
+ const cwd = process.cwd();
295
+ const infernoDir = path.join(cwd, "inferno");
296
+
297
+ // Load capabilities
298
+ let allCaps = [];
299
+ const rawCaps = loadJson(path.join(infernoDir, "capabilities.json"));
300
+ if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
301
+
302
+ const cap = allCaps.find(c => c.id === capId);
303
+ if (!cap) {
304
+ console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
305
+ console.error(gray(" Run: infernoflow stability — to list all capability IDs"));
306
+ process.exit(1);
307
+ }
308
+
309
+ // Load scan + graph
310
+ const scanData = loadJson(path.join(infernoDir, "scan.json"));
311
+ const graph = loadJson(path.join(infernoDir, "graph.json"));
312
+ const scanEntry = scanData?.capabilities?.find(c => c.id === capId);
313
+
314
+ // Git history
315
+ const files = scanEntry?.codeAnalysis?.sourceFiles || [];
316
+ const firstCommit = getFirstCommit(files[0], cwd);
317
+ const recentHistory = getRecentHistory(files[0], cwd);
318
+
319
+ // Scenarios
320
+ const scenarios = findScenarios(capId, infernoDir);
321
+
322
+ // Build prompt
323
+ const prompt = buildPrompt(capId, cap, scanEntry, graph, allCaps, scenarios, firstCommit, recentHistory);
324
+
325
+ if (dryRun && !jsonMode) {
326
+ console.log(gray(`\n infernoflow explain → ${bold(capId)}`));
327
+ console.log(gray(" ──────────────────────────────────────────────────────────────"));
328
+ printExplain(capId, cap, "", null, true);
329
+ console.log(bold(" Prompt that would be sent to AI:"));
330
+ console.log();
331
+ console.log(prompt.split("\n").map(l => " " + l).join("\n"));
332
+ console.log();
333
+ return;
334
+ }
335
+
336
+ // Call AI
337
+ let narrative = null;
338
+ let provider = null;
339
+
340
+ if (!dryRun) {
341
+ try {
342
+ const result = await callAI(prompt, cwd);
343
+ if (result?.text) {
344
+ narrative = result.text.trim();
345
+ provider = result.provider;
346
+ }
347
+ } catch {}
348
+ }
349
+
350
+ // Fallback if no AI
351
+ if (!narrative) {
352
+ narrative = buildFallback(capId, cap, scanEntry, graph, allCaps, scenarios);
353
+ provider = null;
354
+ }
355
+
356
+ if (jsonMode) {
357
+ console.log(JSON.stringify({
358
+ capId,
359
+ name: cap.name || cap.title,
360
+ stability: stability(cap),
361
+ narrative,
362
+ provider: provider || "fallback",
363
+ sourceFiles: files,
364
+ scenarios: scenarios.map(s => s.scenarioId || s.description),
365
+ firstCommit,
366
+ }, null, 2));
367
+ return;
368
+ }
369
+
370
+ console.log(gray(`\n infernoflow explain → ${bold(capId)}`));
371
+ console.log(gray(" ──────────────────────────────────────────────────────────────"));
372
+ printExplain(capId, cap, narrative, provider, false);
373
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {