infernoflow 0.1.2 → 0.3.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,348 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as readline from "node:readline";
4
+ import { header, ok, fail, warn, info, done, section, nextSteps, bold, cyan, gray, yellow, green, red, errorAndExit } from "../ui/output.mjs";
5
+
6
+ // ── Helpers ──────────────────────────────────────────────────────────────────
7
+
8
+ function readJson(filePath) {
9
+ try { return JSON.parse(fs.readFileSync(filePath, "utf8")); }
10
+ catch { return null; }
11
+ }
12
+
13
+ function ask(rl, question) {
14
+ return new Promise(resolve => {
15
+ rl.question(question, answer => resolve(answer.trim()));
16
+ });
17
+ }
18
+
19
+ function toCapabilityId(str) {
20
+ // "send email" → "SendEmail", "send-email" → "SendEmail"
21
+ return str
22
+ .replace(/[-_]+/g, " ")
23
+ .split(" ")
24
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
25
+ .join("");
26
+ }
27
+
28
+ function buildPrompt({ description, contract, capabilities, scenarios }) {
29
+ const capsIds = contract.capabilities || [];
30
+ const capsDetail = (capabilities?.capabilities || [])
31
+ .map(c => ` - ${c.id}: ${c.title || c.id}`)
32
+ .join("\n");
33
+
34
+ const scenarioFiles = scenarios.map(s => {
35
+ const covered = (s.capabilitiesCovered || []).join(", ");
36
+ const steps = (s.steps || []).map(st => ` {action: "${st.action}", expect: "${st.expect}"}`).join("\n");
37
+ return ` File: ${s._file}\n capabilitiesCovered: [${covered}]\n steps:\n${steps}`;
38
+ }).join("\n\n");
39
+
40
+ return `You are a developer assistant for the infernoflow CLI tool.
41
+
42
+ Your job is to analyze a code change description and suggest updates to the infernoflow contract files.
43
+
44
+ ## Current contract state
45
+
46
+ policyId: ${contract.policyId}
47
+ policyVersion: ${contract.policyVersion}
48
+ capabilities: [${capsIds.join(", ")}]
49
+
50
+ ## Current capabilities registry
51
+ ${capsDetail || " (none)"}
52
+
53
+ ## Current scenarios
54
+ ${scenarioFiles || " (none)"}
55
+
56
+ ## Developer's description of what changed
57
+ "${description}"
58
+
59
+ ## Your task
60
+
61
+ Respond with ONLY a valid JSON object (no markdown, no explanation) in this exact format:
62
+
63
+ {
64
+ "summary": "one-line summary of what changed",
65
+ "newCapabilities": [
66
+ { "id": "CapabilityName", "title": "Human readable title", "reason": "why this is a new capability" }
67
+ ],
68
+ "removedCapabilities": ["CapabilityId"],
69
+ "updatedScenarios": [
70
+ {
71
+ "file": "existing_scenario_filename.json or new_scenario_name.json",
72
+ "isNew": false,
73
+ "capabilitiesCovered": ["CapabilityId1", "CapabilityId2"],
74
+ "stepsToAdd": [
75
+ { "action": "CapabilityId", "expect": "what should happen" }
76
+ ]
77
+ }
78
+ ],
79
+ "changelogEntry": "- Short description of the change for CHANGELOG.md"
80
+ }
81
+
82
+ Rules:
83
+ - Only suggest capabilities that are genuinely new behaviors the system gains
84
+ - Capability IDs must be PascalCase (e.g. SendEmail, not send_email)
85
+ - If nothing changed capability-wise, return empty arrays
86
+ - changelogEntry should start with "- "
87
+ - Keep it minimal and accurate`;
88
+ }
89
+
90
+ function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, version }) {
91
+ const infernoDir = path.join(cwd, "inferno");
92
+ const contractPath = path.join(infernoDir, "contract.json");
93
+ const capsPath = path.join(infernoDir, "capabilities.json");
94
+ const changelogPath = path.join(infernoDir, "CHANGELOG.md");
95
+ const scenariosDir = path.join(infernoDir, "scenarios");
96
+
97
+ const newCaps = suggestion.newCapabilities || [];
98
+ const removedCaps = suggestion.removedCapabilities || [];
99
+ const updatedScenarios = suggestion.updatedScenarios || [];
100
+ const changelogEntry = suggestion.changelogEntry || "";
101
+
102
+ let changed = false;
103
+
104
+ // ── contract.json ─────────────────────────────────────────────────────────
105
+ if (newCaps.length > 0 || removedCaps.length > 0) {
106
+ const updatedCaps = [
107
+ ...contract.capabilities.filter(c => !removedCaps.includes(c)),
108
+ ...newCaps.map(c => c.id)
109
+ ];
110
+ contract.capabilities = updatedCaps;
111
+ contract.policyVersion = (contract.policyVersion || 1) + 1;
112
+ fs.writeFileSync(contractPath, JSON.stringify(contract, null, 2) + "\n");
113
+ ok(`contract.json updated → policyVersion: v${contract.policyVersion}`);
114
+ changed = true;
115
+ }
116
+
117
+ // ── capabilities.json ─────────────────────────────────────────────────────
118
+ if (newCaps.length > 0 || removedCaps.length > 0) {
119
+ const reg = capabilities || { schemaVersion: 1, capabilities: [] };
120
+ reg.capabilities = reg.capabilities.filter(c => !removedCaps.includes(c.id));
121
+ for (const nc of newCaps) {
122
+ if (!reg.capabilities.find(c => c.id === nc.id)) {
123
+ reg.capabilities.push({ id: nc.id, title: nc.title, since: version });
124
+ }
125
+ }
126
+ fs.writeFileSync(capsPath, JSON.stringify(reg, null, 2) + "\n");
127
+ ok(`capabilities.json updated`);
128
+ }
129
+
130
+ // ── scenarios ─────────────────────────────────────────────────────────────
131
+ for (const us of updatedScenarios) {
132
+ const filePath = path.join(scenariosDir, us.file);
133
+ let scenario;
134
+
135
+ if (us.isNew || !fs.existsSync(filePath)) {
136
+ scenario = {
137
+ scenarioId: us.file.replace(".json", ""),
138
+ description: suggestion.summary || "",
139
+ capabilitiesCovered: us.capabilitiesCovered || [],
140
+ steps: us.stepsToAdd || []
141
+ };
142
+ fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2) + "\n");
143
+ ok(`Created scenario: ${cyan(us.file)}`);
144
+ } else {
145
+ scenario = readJson(filePath);
146
+ const existingCaps = new Set(scenario.capabilitiesCovered || []);
147
+ (us.capabilitiesCovered || []).forEach(c => existingCaps.add(c));
148
+ scenario.capabilitiesCovered = [...existingCaps];
149
+ scenario.steps = [...(scenario.steps || []), ...(us.stepsToAdd || [])];
150
+ fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2) + "\n");
151
+ ok(`Updated scenario: ${cyan(us.file)}`);
152
+ }
153
+ changed = true;
154
+ }
155
+
156
+ // ── CHANGELOG.md ──────────────────────────────────────────────────────────
157
+ if (changelogEntry && fs.existsSync(changelogPath)) {
158
+ let txt = fs.readFileSync(changelogPath, "utf8");
159
+ if (/##\s+Unreleased/i.test(txt)) {
160
+ txt = txt.replace(/(##\s+Unreleased[^\n]*\n)/i, `$1\n${changelogEntry}\n`);
161
+ fs.writeFileSync(changelogPath, txt);
162
+ ok(`CHANGELOG.md updated`);
163
+ changed = true;
164
+ }
165
+ }
166
+
167
+ return changed;
168
+ }
169
+
170
+ // ── Main ─────────────────────────────────────────────────────────────────────
171
+
172
+ export async function suggestCommand(args) {
173
+ const cwd = process.cwd();
174
+ const infernoDir = path.join(cwd, "inferno");
175
+
176
+ header("suggest");
177
+
178
+ // ── Check inferno/ exists ─────────────────────────────────────────────────
179
+ if (!fs.existsSync(infernoDir)) {
180
+ errorAndExit("inferno/ not found", "Run: infernoflow init");
181
+ }
182
+
183
+ const contractPath = path.join(infernoDir, "contract.json");
184
+ const capsPath = path.join(infernoDir, "capabilities.json");
185
+ const scenariosDir = path.join(infernoDir, "scenarios");
186
+
187
+ const contract = readJson(contractPath);
188
+ if (!contract) errorAndExit("contract.json not found or invalid");
189
+
190
+ const capabilities = readJson(capsPath);
191
+
192
+ // Load scenarios
193
+ const scenarios = [];
194
+ if (fs.existsSync(scenariosDir)) {
195
+ for (const f of fs.readdirSync(scenariosDir).filter(f => f.endsWith(".json"))) {
196
+ const s = readJson(path.join(scenariosDir, f));
197
+ if (s) scenarios.push({ ...s, _file: f });
198
+ }
199
+ }
200
+
201
+ // Get version from package.json
202
+ let version = "0.1.0";
203
+ const pkgPath = path.join(cwd, "package.json");
204
+ if (fs.existsSync(pkgPath)) {
205
+ const pkg = readJson(pkgPath);
206
+ if (pkg?.version) version = pkg.version;
207
+ }
208
+
209
+ // ── Get description from args or prompt ───────────────────────────────────
210
+ const descArg = args.filter(a => !a.startsWith("-")).slice(1).join(" ");
211
+ let description = descArg;
212
+
213
+ if (!description) {
214
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
215
+ console.log(gray(" Describe what changed in your code (e.g. 'added email notifications'):"));
216
+ description = await ask(rl, ` ${cyan(">")} `);
217
+ rl.close();
218
+ console.log();
219
+ }
220
+
221
+ if (!description) {
222
+ errorAndExit("No description provided", "Usage: infernoflow suggest \"what changed\"");
223
+ }
224
+
225
+ // ── Build prompt ──────────────────────────────────────────────────────────
226
+ const prompt = buildPrompt({ description, contract, capabilities, scenarios });
227
+
228
+ // ── Show prompt + instructions ────────────────────────────────────────────
229
+ section("Generated Prompt");
230
+ console.log();
231
+ console.log(gray("─".repeat(50)));
232
+ console.log(prompt);
233
+ console.log(gray("─".repeat(50)));
234
+ console.log();
235
+
236
+ info("Copy the prompt above and paste it into:");
237
+ console.log(` ${cyan("•")} Claude → https://claude.ai`);
238
+ console.log(` ${cyan("•")} ChatGPT → https://chatgpt.com`);
239
+ console.log(` ${cyan("•")} Copilot, Cursor, or any AI you use`);
240
+ console.log();
241
+ warn("The AI will respond with a JSON object.");
242
+ console.log();
243
+
244
+ // ── Get AI response ───────────────────────────────────────────────────────
245
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
246
+ console.log(gray(" Paste the AI's JSON response below, then press Enter twice:"));
247
+ console.log();
248
+
249
+ let jsonInput = "";
250
+ let emptyLines = 0;
251
+
252
+ await new Promise(resolve => {
253
+ rl2.on("line", line => {
254
+ if (line.trim() === "") {
255
+ emptyLines++;
256
+ if (emptyLines >= 2 && jsonInput.trim()) resolve();
257
+ } else {
258
+ emptyLines = 0;
259
+ jsonInput += line + "\n";
260
+ }
261
+ });
262
+ rl2.on("close", resolve);
263
+ });
264
+
265
+ rl2.close();
266
+
267
+ // ── Parse response ────────────────────────────────────────────────────────
268
+ let suggestion;
269
+ try {
270
+ const clean = jsonInput.trim().replace(/^```json?\n?/, "").replace(/\n?```$/, "");
271
+ suggestion = JSON.parse(clean);
272
+ } catch {
273
+ errorAndExit(
274
+ "Could not parse the AI response as JSON",
275
+ "Make sure you copied the full JSON response from the AI"
276
+ );
277
+ }
278
+
279
+ // ── Preview ───────────────────────────────────────────────────────────────
280
+ section("Proposed Changes");
281
+ console.log();
282
+
283
+ if (suggestion.summary) {
284
+ console.log(` ${bold("Summary:")} ${suggestion.summary}`);
285
+ console.log();
286
+ }
287
+
288
+ const newCaps = suggestion.newCapabilities || [];
289
+ const removedCaps = suggestion.removedCapabilities || [];
290
+ const updatedScenarios = suggestion.updatedScenarios || [];
291
+
292
+ if (newCaps.length === 0 && removedCaps.length === 0 && updatedScenarios.length === 0) {
293
+ ok("No capability changes detected — nothing to apply.");
294
+ console.log();
295
+ process.exit(0);
296
+ }
297
+
298
+ if (newCaps.length > 0) {
299
+ console.log(` ${green("+")} New capabilities:`);
300
+ newCaps.forEach(c => console.log(` ${green(c.id)} — ${gray(c.title)}`));
301
+ console.log();
302
+ }
303
+
304
+ if (removedCaps.length > 0) {
305
+ console.log(` ${red("-")} Removed capabilities:`);
306
+ removedCaps.forEach(c => console.log(` ${red(c)}`));
307
+ console.log();
308
+ }
309
+
310
+ if (updatedScenarios.length > 0) {
311
+ console.log(` ${cyan("~")} Scenario updates:`);
312
+ updatedScenarios.forEach(s => {
313
+ const tag = s.isNew ? green("[new]") : cyan("[update]");
314
+ console.log(` ${tag} ${s.file}`);
315
+ });
316
+ console.log();
317
+ }
318
+
319
+ if (suggestion.changelogEntry) {
320
+ console.log(` ${yellow("📝")} Changelog: ${gray(suggestion.changelogEntry)}`);
321
+ console.log();
322
+ }
323
+
324
+ // ── Confirm ───────────────────────────────────────────────────────────────
325
+ const rl3 = readline.createInterface({ input: process.stdin, output: process.stdout });
326
+ const answer = await ask(rl3, ` Apply these changes? ${gray("(y/n)")} `);
327
+ rl3.close();
328
+ console.log();
329
+
330
+ if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
331
+ warn("Cancelled — no changes made.");
332
+ console.log();
333
+ process.exit(0);
334
+ }
335
+
336
+ // ── Apply ─────────────────────────────────────────────────────────────────
337
+ section("Applying Changes");
338
+ console.log();
339
+
340
+ applyChanges({ cwd, contract, capabilities, scenarios, suggestion, version });
341
+
342
+ done("suggest complete!");
343
+
344
+ nextSteps([
345
+ cyan("infernoflow status") + " — verify the updated contract",
346
+ cyan("infernoflow check") + " — validate everything",
347
+ ]);
348
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { bold, gray, cyan, red, orange } from "../lib/ui/output.mjs";
5
+
6
+ const VERSION = "0.1.0";
7
+
8
+ const HELP = `
9
+ ${bold("🔥 infernoflow")} ${gray("v" + VERSION)}
10
+ ${gray("The forge for liquid code")}
11
+
12
+ ${bold("Usage:")}
13
+ infernoflow <command> [options]
14
+
15
+ ${bold("Commands:")}
16
+ init Scaffold inferno/ in your project (interactive)
17
+ check Validate contract, capabilities, scenarios, changelog
18
+ status Show contract health at a glance
19
+ doc-gate Fail if code changed but docs were not updated
20
+ suggest Generate AI prompt + apply capability updates
21
+
22
+ ${bold("Options:")}
23
+ init:
24
+ --force, -f Overwrite existing files
25
+ --yes, -y Skip prompts, use defaults
26
+
27
+ check:
28
+ --skip-doc-gate Skip the git doc-gate check
29
+ --json Machine-readable JSON output (for CI)
30
+
31
+ ${bold("Examples:")}
32
+ ${cyan("npx infernoflow init")}
33
+ ${cyan("infernoflow status")}
34
+ ${cyan("infernoflow check")}
35
+ ${cyan("infernoflow check --json")}
36
+ ${cyan("infernoflow doc-gate")}
37
+
38
+ ${bold("CI example:")}
39
+ ${gray("# In GitHub Actions:")}
40
+ ${gray("- run: npx infernoflow check --json")}
41
+ ${gray(" env:")}
42
+ ${gray(" BASE_SHA: ${{ github.event.pull_request.base.sha }}")}
43
+ ${gray(" HEAD_SHA: ${{ github.event.pull_request.head.sha }}")}
44
+ `;
45
+
46
+ const [,, cmd, ...rest] = process.argv;
47
+
48
+ if (!cmd || cmd === "--help" || cmd === "-h") {
49
+ console.log(HELP);
50
+ process.exit(0);
51
+ }
52
+
53
+ if (cmd === "--version" || cmd === "-v") {
54
+ console.log(VERSION);
55
+ process.exit(0);
56
+ }
57
+
58
+ const commands = ["init", "check", "status", "doc-gate", "suggest"];
59
+
60
+ if (!commands.includes(cmd)) {
61
+ console.error(red(`\nUnknown command: ${cmd}`));
62
+ console.error(gray("Run: infernoflow --help\n"));
63
+ process.exit(1);
64
+ }
65
+
66
+ const args = [cmd, ...rest];
67
+
68
+ switch (cmd) {
69
+ case "init":
70
+ import("../lib/commands/init.mjs")
71
+ .then(m => m.initCommand(args))
72
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
73
+ break;
74
+ case "check":
75
+ import("../lib/commands/check.mjs")
76
+ .then(m => m.checkCommand(args))
77
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
78
+ break;
79
+ case "status":
80
+ import("../lib/commands/status.mjs")
81
+ .then(m => m.statusCommand(args))
82
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
83
+ break;
84
+ case "suggest":
85
+ import("../lib/commands/suggest.mjs")
86
+ .then(m => m.suggestCommand(args))
87
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
88
+ break;
89
+ case "doc-gate":
90
+ import("../lib/commands/docGate.mjs")
91
+ .then(m => m.docGateCommand())
92
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
93
+ break;
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {