infernoflow 0.8.0 → 0.10.1

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/README.md CHANGED
@@ -41,6 +41,65 @@ infernoflow check
41
41
  infernoflow doc-gate
42
42
  ```
43
43
 
44
+ ## Recommended Workflow
45
+
46
+ ```bash
47
+ # start a feature
48
+ infernoflow context --intent "add search to tasks" --working "frontend search UX"
49
+
50
+ # generate implementation prompt(s) for coding agent
51
+ infernoflow implement "add server-side task search endpoint" --mode both
52
+
53
+ # build code changes
54
+
55
+ # sync inferno contract with AI assistance
56
+ infernoflow suggest "added task search by title and due date"
57
+
58
+ # verify no drift
59
+ infernoflow status
60
+ infernoflow check
61
+ ```
62
+
63
+ ## Team SOP (Developer Workflow)
64
+
65
+ Use this checklist for every feature branch:
66
+
67
+ 1) **Set intent**
68
+ ```bash
69
+ infernoflow context --intent "what feature is being built" --working "current slice"
70
+ ```
71
+
72
+ 2) **Build code**
73
+ - Implement UI/API/tests as usual.
74
+
75
+ 3) **Sync contract with `suggest`**
76
+ ```bash
77
+ infernoflow suggest "plain-language description of what changed"
78
+ ```
79
+ - Paste generated prompt into your AI.
80
+ - Paste AI JSON back into terminal.
81
+ - Approve with `y` only after preview looks correct.
82
+
83
+ 4) **Validate before commit**
84
+ ```bash
85
+ infernoflow status
86
+ infernoflow check
87
+ ```
88
+
89
+ 5) **CI-safe checks**
90
+ ```bash
91
+ infernoflow status --json
92
+ infernoflow check --json
93
+ infernoflow doc-gate --json
94
+ ```
95
+
96
+ 6) **Definition of done**
97
+ - Capability changes are reflected in `inferno/contract.json`.
98
+ - New/changed capabilities exist in `inferno/capabilities.json`.
99
+ - Scenario coverage updated under `inferno/scenarios/`.
100
+ - `inferno/CHANGELOG.md` updated under `## Unreleased`.
101
+ - `infernoflow check` passes.
102
+
44
103
  ## Commands
45
104
 
46
105
  | Command | Description |
@@ -48,8 +107,10 @@ infernoflow doc-gate
48
107
  | `infernoflow init` | Interactive scaffold — creates `inferno/` in your project |
49
108
  | `infernoflow status` | At-a-glance health of your contract |
50
109
  | `infernoflow suggest` | Generate an AI prompt, apply capability updates |
110
+ | `infernoflow implement` | Generate implementation prompts for coding agents |
51
111
  | `infernoflow check` | Full validation: contract, capabilities, scenarios, changelog |
52
112
  | `infernoflow doc-gate` | Fails if code changed but docs weren't updated |
113
+ | `infernoflow context` | Build/persist AI session context for this project |
53
114
 
54
115
  ### Options
55
116
 
@@ -57,8 +118,14 @@ infernoflow doc-gate
57
118
  infernoflow init --force # overwrite existing files
58
119
  infernoflow init --yes # skip prompts, use defaults
59
120
  infernoflow suggest "..." # describe what changed
121
+ infernoflow implement "..." --mode both
122
+ infernoflow implement "..." --mode cursor
123
+ infernoflow implement "..." --mode generic
124
+ infernoflow implement "..." --mode both --copy
60
125
  infernoflow check --json # machine-readable output for CI
61
126
  infernoflow check --skip-doc-gate
127
+ infernoflow status --json # machine-readable status summary
128
+ infernoflow doc-gate --json # machine-readable doc-gate result
62
129
  ```
63
130
 
64
131
  ## `infernoflow suggest` — AI-powered updates
@@ -97,6 +164,41 @@ Proposed Changes
97
164
 
98
165
  Works with any AI — Claude, ChatGPT, GitHub Copilot, Cursor, or your own setup.
99
166
 
167
+ ## `infernoflow implement` — code-agent execution prompts
168
+
169
+ Generate coding prompts from your project context and inferno contract:
170
+
171
+ ```bash
172
+ infernoflow implement "add pagination to tasks" --mode both
173
+ ```
174
+
175
+ Modes:
176
+ - `--mode cursor`: Cursor-specific coding prompt
177
+ - `--mode generic`: generic prompt for any coding agent
178
+ - `--mode both`: print both sections (default)
179
+ - `--copy`: copy selected prompt output to clipboard
180
+
181
+ Recommended chain:
182
+ 1) `infernoflow context --intent "..."`
183
+ 2) `infernoflow implement "..."`
184
+ 3) run the coding agent and apply code changes
185
+ 4) `infernoflow suggest "..."`
186
+ 5) `infernoflow check`
187
+
188
+ ## Troubleshooting
189
+
190
+ - `Unknown command: suggest`:
191
+ - Run `infernoflow --help` and confirm `suggest` appears.
192
+ - If using `npx`, force a specific version: `npx infernoflow@latest --help`.
193
+ - `infernoflow: command not found`:
194
+ - Use `npx infernoflow ...` or install globally: `npm install -g infernoflow`.
195
+ - `npm publish` fails with existing version:
196
+ - Bump version first (`npm version patch|minor|major`) then publish.
197
+ - `status` or `check` fails due to missing inferno files:
198
+ - Run `infernoflow init` at project root.
199
+ - Windows/Git Bash path confusion:
200
+ - Prefer `node bin/infernoflow.mjs --help` from package root for local debugging.
201
+
100
202
  ## Why infernoflow?
101
203
 
102
204
  **The problem:** AI-assisted development moves fast. Code changes daily. But what does the system *actually do*? What changed? What's covered?
@@ -116,6 +218,17 @@ Works with any AI — Claude, ChatGPT, GitHub Copilot, Cursor, or your own setup
116
218
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
117
219
  ```
118
220
 
221
+ ## Release Checklist
222
+
223
+ ```bash
224
+ npm test
225
+ npm pack --dry-run
226
+ node bin/infernoflow.mjs --help
227
+ node bin/infernoflow.mjs check --help
228
+ ```
229
+
230
+ Then bump version and publish.
231
+
119
232
  ## License
120
233
 
121
234
  MIT
@@ -1,7 +1,37 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { bold, gray, cyan, red } from "../lib/ui/output.mjs";
3
6
 
4
- const VERSION = "0.5.0";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
9
+ const VERSION = pkg.version || "0.0.0";
10
+ const COMMAND_DESCRIPTIONS = {
11
+ init: "Scaffold inferno/ in your project",
12
+ check: "Validate contract, capabilities, scenarios, changelog",
13
+ status: "Show contract health at a glance",
14
+ "doc-gate": "Fail if code changed but docs were not updated",
15
+ suggest: "Generate AI prompt + apply capability updates",
16
+ implement: "Generate code-agent implementation prompt(s)",
17
+ context: "Generate AI-ready context for new sessions",
18
+ };
19
+
20
+ const COMMAND_HANDLERS = {
21
+ init: async (args) => (await import("../lib/commands/init.mjs")).initCommand(args),
22
+ check: async (args) => (await import("../lib/commands/check.mjs")).checkCommand(args),
23
+ status: async (args) => (await import("../lib/commands/status.mjs")).statusCommand(args),
24
+ suggest: async (args) => (await import("../lib/commands/suggest.mjs")).suggestCommand(args),
25
+ implement: async (args) => (await import("../lib/commands/implement.mjs")).implementCommand(args),
26
+ context: async (args) => (await import("../lib/commands/context.mjs")).contextCommand(args),
27
+ "doc-gate": async (args) => (await import("../lib/commands/docGate.mjs")).docGateCommand(args),
28
+ };
29
+
30
+ function formatCommandsHelp() {
31
+ return Object.entries(COMMAND_DESCRIPTIONS)
32
+ .map(([name, desc]) => ` ${name.padEnd(13, " ")}${desc}`)
33
+ .join("\n");
34
+ }
5
35
 
6
36
  const HELP = `
7
37
  ${bold("🔥 infernoflow")} ${gray("v" + VERSION)}
@@ -11,12 +41,7 @@ const HELP = `
11
41
  infernoflow <command> [options]
12
42
 
13
43
  ${bold("Commands:")}
14
- init Scaffold inferno/ in your project
15
- check Validate contract, capabilities, scenarios, changelog
16
- status Show contract health at a glance
17
- doc-gate Fail if code changed but docs were not updated
18
- suggest Generate AI prompt + apply capability updates
19
- context Generate AI-ready context for new sessions
44
+ ${formatCommandsHelp()}
20
45
 
21
46
  ${bold("context options:")}
22
47
  --intent "..." What you plan to build next
@@ -26,12 +51,21 @@ const HELP = `
26
51
  --copy, -c Copy context to clipboard instantly
27
52
  --reset Clear all stored state
28
53
 
54
+ ${bold("implement options:")}
55
+ --mode <type> cursor | generic | both (default: both)
56
+ --copy, -c Copy generated prompt(s) to clipboard
57
+
29
58
  ${bold("Typical workflow:")}
30
59
  ${gray('1. infernoflow context --intent "what I want to build"')}
31
60
  ${gray("2. [paste inferno/CONTEXT.md into Claude / Cursor / Copilot]")}
32
61
  ${gray("3. [build the feature]")}
33
62
  ${gray('4. infernoflow suggest "what I built"')}
34
63
  ${gray("5. infernoflow check")}
64
+
65
+ ${bold("Machine output:")}
66
+ ${gray("status --json")}
67
+ ${gray("check --json")}
68
+ ${gray("doc-gate --json")}
35
69
  `;
36
70
 
37
71
  const [, , cmd, ...rest] = process.argv;
@@ -45,7 +79,7 @@ if (cmd === "--version" || cmd === "-v") {
45
79
  process.exit(0);
46
80
  }
47
81
 
48
- const commands = ["init", "check", "status", "doc-gate", "suggest", "context"];
82
+ const commands = Object.keys(COMMAND_HANDLERS);
49
83
 
50
84
  if (!commands.includes(cmd)) {
51
85
  console.error(red(`\nUnknown command: ${cmd}`));
@@ -54,54 +88,7 @@ if (!commands.includes(cmd)) {
54
88
  }
55
89
 
56
90
  const args = [cmd, ...rest];
57
-
58
- switch (cmd) {
59
- case "init":
60
- import("../lib/commands/init.mjs")
61
- .then((m) => m.initCommand(args))
62
- .catch((err) => {
63
- console.error(red("\nError: ") + err.message);
64
- process.exit(1);
65
- });
66
- break;
67
- case "check":
68
- import("../lib/commands/check.mjs")
69
- .then((m) => m.checkCommand(args))
70
- .catch((err) => {
71
- console.error(red("\nError: ") + err.message);
72
- process.exit(1);
73
- });
74
- break;
75
- case "status":
76
- import("../lib/commands/status.mjs")
77
- .then((m) => m.statusCommand(args))
78
- .catch((err) => {
79
- console.error(red("\nError: ") + err.message);
80
- process.exit(1);
81
- });
82
- break;
83
- case "suggest":
84
- import("../lib/commands/suggest.mjs")
85
- .then((m) => m.suggestCommand(args))
86
- .catch((err) => {
87
- console.error(red("\nError: ") + err.message);
88
- process.exit(1);
89
- });
90
- break;
91
- case "context":
92
- import("../lib/commands/context.mjs")
93
- .then((m) => m.contextCommand(args))
94
- .catch((err) => {
95
- console.error(red("\nError: ") + err.message);
96
- process.exit(1);
97
- });
98
- break;
99
- case "doc-gate":
100
- import("../lib/commands/docGate.mjs")
101
- .then((m) => m.docGateCommand())
102
- .catch((err) => {
103
- console.error(red("\nError: ") + err.message);
104
- process.exit(1);
105
- });
106
- break;
107
- }
91
+ COMMAND_HANDLERS[cmd](args).catch((err) => {
92
+ console.error(red("\nError: ") + err.message);
93
+ process.exit(1);
94
+ });
@@ -3,9 +3,12 @@ import * as path from "node:path";
3
3
  import { header, ok, fail, warn, info, section, done, errorAndExit, cyan, bold, red, green, yellow, gray } from "../ui/output.mjs";
4
4
  import { docGateCommand } from "./docGate.mjs";
5
5
 
6
- function readJson(filePath) {
6
+ function readJson(filePath, jsonOut = false) {
7
7
  try { return JSON.parse(fs.readFileSync(filePath, "utf8")); }
8
8
  catch (err) {
9
+ if (jsonOut) {
10
+ throw new Error(`Cannot parse ${path.basename(filePath)}`);
11
+ }
9
12
  errorAndExit(
10
13
  `Cannot parse ${path.basename(filePath)}`,
11
14
  `Check JSON syntax in: ${filePath}`
@@ -59,7 +62,18 @@ export async function checkCommand(args) {
59
62
  fail("contract.json not found", "Run: infernoflow init");
60
63
  errors.push("contract.json missing");
61
64
  } else {
62
- const contract = readJson(contractPath);
65
+ let contract;
66
+ try {
67
+ contract = readJson(contractPath, jsonOut);
68
+ } catch (err) {
69
+ errors.push(err.message);
70
+ if (!jsonOut) {
71
+ fail(err.message);
72
+ process.exit(1);
73
+ }
74
+ console.log(JSON.stringify({ ok: false, errors, warnings }, null, 2));
75
+ process.exit(1);
76
+ }
63
77
  const caps = contract.capabilities || [];
64
78
 
65
79
  if (!contract.policyId) { fail("policyId missing"); errors.push("policyId missing"); }
@@ -76,7 +90,18 @@ export async function checkCommand(args) {
76
90
  if (!fs.existsSync(capsPath)) {
77
91
  fail("capabilities.json not found"); errors.push("capabilities.json missing");
78
92
  } else {
79
- const registry = readJson(capsPath);
93
+ let registry;
94
+ try {
95
+ registry = readJson(capsPath, jsonOut);
96
+ } catch (err) {
97
+ errors.push(err.message);
98
+ if (!jsonOut) {
99
+ fail(err.message);
100
+ process.exit(1);
101
+ }
102
+ console.log(JSON.stringify({ ok: false, errors, warnings }, null, 2));
103
+ process.exit(1);
104
+ }
80
105
  const registryIds = new Set((registry.capabilities || []).map(c => c?.id).filter(Boolean));
81
106
 
82
107
  const missingInRegistry = caps.filter(c => !registryIds.has(c));
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execSync } from "node:child_process";
4
4
  import { bold, gray, cyan, red, green, yellow } from "../ui/output.mjs";
5
+ import { buildCursorImplementPrompt, buildGenericImplementPrompt } from "../ui/prompts.mjs";
5
6
 
6
7
  function copyToClipboard(text) {
7
8
  try {
@@ -77,6 +78,10 @@ export async function contextCommand(args) {
77
78
  const version = String(contract.policyVersion).replace(/^v/i,"");
78
79
  const now = new Date().toLocaleDateString("en-GB",{day:"2-digit",month:"short",year:"numeric"});
79
80
  const syncBadge = allInSync?"✓ validated":"⚠ out of sync";
81
+ const implementTask = state.intent || "describe the exact task to implement";
82
+ const implementInput = { task: implementTask, contract, caps: capabilities, scenarios: [], state };
83
+ const cursorPrompt = buildCursorImplementPrompt(implementInput);
84
+ const genericPrompt = buildGenericImplementPrompt(implementInput);
80
85
 
81
86
  const capLines = capList.map(c=>"- **"+c.id+"** — "+c.title).join("\n");
82
87
  const chgLines = recent.length>0 ? recent.map(e=>"### "+e.title+"\n"+e.items.map(i=>" - "+i).join("\n")).join("\n\n") : "_No recent changes_";
@@ -98,6 +103,24 @@ export async function contextCommand(args) {
98
103
  "## What I am working on right now","",workingLine,"","---","",
99
104
  "## Intent — what I want to build next","",intentLine,"","---","",
100
105
  "## Decisions & notes","",decLines,"","---",
106
+ "",
107
+ "## Implementation Prompt Seed","",
108
+ "Use this to start coding immediately with an agent:","",
109
+ "```bash",
110
+ `infernoflow implement "${implementTask}" --mode both`,
111
+ "```",
112
+ "",
113
+ "### Cursor Agent Prompt","",
114
+ "```text",
115
+ cursorPrompt,
116
+ "```",
117
+ "",
118
+ "### Generic Agent Prompt","",
119
+ "```text",
120
+ genericPrompt,
121
+ "```",
122
+ "",
123
+ "---",
101
124
  "_Paste this block at the start of any new AI session._"
102
125
  ].join("\n");
103
126
 
@@ -125,6 +148,8 @@ export async function contextCommand(args) {
125
148
  console.log(" Working on "+(state.working?cyan(state.working):gray("not set")));
126
149
  console.log(" Intent "+(state.intent ?cyan(state.intent) :gray("not set")));
127
150
  console.log(" Decisions "+(state.decisions?state.decisions.length:0)+" recorded\n");
151
+ console.log(" "+bold("Implementation Prompt"));
152
+ console.log(" "+cyan("→")+" Run "+cyan(`infernoflow implement "${implementTask}" --mode both`)+"\n");
128
153
 
129
154
  if(copyFlag){
130
155
  console.log(" "+bold("Ready to use:"));
@@ -13,8 +13,10 @@ const CODE_PREFIXES = [
13
13
  ];
14
14
 
15
15
  export async function docGateCommand(opts = {}) {
16
- const silent = opts?.silent || false;
17
- const captureExit = opts?.captureExit || false;
16
+ const fromArgs = Array.isArray(opts);
17
+ const silent = fromArgs ? false : (opts?.silent || false);
18
+ const captureExit = fromArgs ? false : (opts?.captureExit || false);
19
+ const jsonOut = fromArgs ? opts.includes("--json") : Boolean(opts?.json);
18
20
  const base = process.env.BASE_SHA || "HEAD~1";
19
21
  const head = process.env.HEAD_SHA || "HEAD";
20
22
 
@@ -23,11 +25,19 @@ export async function docGateCommand(opts = {}) {
23
25
  const out = sh(`git diff --name-only ${base}..${head}`);
24
26
  files = out ? out.split("\n").filter(Boolean) : [];
25
27
  } catch {
28
+ if (jsonOut) {
29
+ console.log(JSON.stringify({ ok: true, skipped: true, reason: "no_git_available" }, null, 2));
30
+ return;
31
+ }
26
32
  if (!silent) info(gray("doc-gate skipped (no git available)"));
27
33
  return;
28
34
  }
29
35
 
30
36
  if (files.length === 0) {
37
+ if (jsonOut) {
38
+ console.log(JSON.stringify({ ok: true, changedFiles: 0, changedCode: false, changedInferno: false }, null, 2));
39
+ return;
40
+ }
31
41
  if (!silent) ok("doc-gate: no changed files");
32
42
  return;
33
43
  }
@@ -36,6 +46,21 @@ export async function docGateCommand(opts = {}) {
36
46
  CODE_PREFIXES.some(p => f.startsWith(p) || f.includes("/" + p))
37
47
  );
38
48
  const changedInferno = files.some(f => f.startsWith("inferno/"));
49
+ const codeFiles = files.filter(f => CODE_PREFIXES.some(p => f.startsWith(p))).slice(0, 5);
50
+
51
+ if (jsonOut) {
52
+ const payload = {
53
+ ok: !(changedCode && !changedInferno),
54
+ changedFiles: files.length,
55
+ changedCode,
56
+ changedInferno,
57
+ sampleCodeFiles: codeFiles,
58
+ hint: changedCode && !changedInferno ? "Update at least one file in inferno/ before committing" : null,
59
+ };
60
+ console.log(JSON.stringify(payload, null, 2));
61
+ if (!payload.ok) process.exit(1);
62
+ return;
63
+ }
39
64
 
40
65
  if (changedCode && !changedInferno) {
41
66
  if (!silent) {
@@ -43,7 +68,6 @@ export async function docGateCommand(opts = {}) {
43
68
  "Code changed but inferno/ was NOT updated",
44
69
  "Update at least one file in inferno/ before committing"
45
70
  );
46
- const codeFiles = files.filter(f => CODE_PREFIXES.some(p => f.startsWith(p))).slice(0, 5);
47
71
  if (codeFiles.length) {
48
72
  console.log();
49
73
  codeFiles.forEach(f => console.log(" " + gray("• " + f)));
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { header, section, info, warn, cyan, gray, errorAndExit } from "../ui/output.mjs";
5
+ import {
6
+ loadImplementContext,
7
+ buildCursorImplementPrompt,
8
+ buildGenericImplementPrompt,
9
+ } from "../ui/prompts.mjs";
10
+
11
+ function getFlagValue(args, flag) {
12
+ const idx = args.indexOf(flag);
13
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
14
+ }
15
+
16
+ function extractTask(args) {
17
+ const skipNextFor = new Set(["--mode"]);
18
+ const parts = [];
19
+ for (let i = 0; i < args.length; i += 1) {
20
+ const token = args[i];
21
+ if (token.startsWith("-")) {
22
+ if (skipNextFor.has(token)) i += 1;
23
+ continue;
24
+ }
25
+ if (i === 0) continue; // command name
26
+ parts.push(token);
27
+ }
28
+ return parts.join(" ").trim();
29
+ }
30
+
31
+ function copyToClipboard(text) {
32
+ try {
33
+ const p = process.platform;
34
+ if (p === "win32") execSync("clip", { input: text });
35
+ else if (p === "darwin") execSync("pbcopy", { input: text });
36
+ else {
37
+ try { execSync("xclip -selection clipboard", { input: text }); }
38
+ catch { execSync("xsel --clipboard --input", { input: text }); }
39
+ }
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export async function implementCommand(args = []) {
47
+ header("implement");
48
+
49
+ const cwd = process.cwd();
50
+ const infernoDir = path.join(cwd, "inferno");
51
+ if (!fs.existsSync(infernoDir)) {
52
+ errorAndExit("inferno/ not found", "Run: infernoflow init");
53
+ }
54
+
55
+ const mode = (getFlagValue(args, "--mode") || "both").toLowerCase();
56
+ const copyFlag = args.includes("--copy") || args.includes("-c");
57
+ if (!["cursor", "generic", "both"].includes(mode)) {
58
+ errorAndExit("Invalid --mode value", "Use: --mode cursor|generic|both");
59
+ }
60
+
61
+ const rawTask = extractTask(args);
62
+ if (!rawTask) {
63
+ errorAndExit("No task provided", 'Usage: infernoflow implement "your task description"');
64
+ }
65
+
66
+ const context = loadImplementContext(cwd);
67
+ const cursorPrompt = buildCursorImplementPrompt({ task: rawTask, ...context });
68
+ const genericPrompt = buildGenericImplementPrompt({ task: rawTask, ...context });
69
+
70
+ info(`Task: ${cyan(rawTask)}`);
71
+ info(`Mode: ${cyan(mode)}`);
72
+ warn("If you hit model high-load/resource-exhausted, retry with Auto/another model.");
73
+
74
+ if (mode === "cursor" || mode === "both") {
75
+ section("Cursor Agent Prompt");
76
+ console.log();
77
+ console.log(gray("─".repeat(50)));
78
+ console.log(cursorPrompt);
79
+ console.log(gray("─".repeat(50)));
80
+ }
81
+
82
+ if (mode === "generic" || mode === "both") {
83
+ section("Generic Agent Prompt");
84
+ console.log();
85
+ console.log(gray("─".repeat(50)));
86
+ console.log(genericPrompt);
87
+ console.log(gray("─".repeat(50)));
88
+ }
89
+
90
+ if (copyFlag) {
91
+ const textToCopy =
92
+ mode === "cursor"
93
+ ? cursorPrompt
94
+ : mode === "generic"
95
+ ? genericPrompt
96
+ : `## Cursor Agent Prompt\n\n${cursorPrompt}\n\n## Generic Agent Prompt\n\n${genericPrompt}`;
97
+ const ok = copyToClipboard(textToCopy);
98
+ if (ok) info(`Copied ${mode} prompt${mode === "both" ? "s" : ""} to clipboard.`);
99
+ else warn("Clipboard copy failed. Copy from terminal output.");
100
+ }
101
+
102
+ console.log();
103
+ }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { header, ok, fail, warn, info, section, bold, cyan, yellow, gray, green, red, white } from "../ui/output.mjs";
3
+ import { header, ok, fail, warn, section, bold, cyan, yellow, gray, green, red, white } from "../ui/output.mjs";
4
4
 
5
5
  function timeAgo(ms) {
6
6
  const s = Math.floor((Date.now() - ms) / 1000);
@@ -23,13 +23,19 @@ function getCoverage(scenariosDir, caps) {
23
23
  return { covered: caps.filter(c => covered.has(c)), uncovered: caps.filter(c => !covered.has(c)) };
24
24
  }
25
25
 
26
- export async function statusCommand() {
26
+ export async function statusCommand(args = []) {
27
+ const asJson = args.includes("--json");
27
28
  const cwd = process.cwd();
28
29
  const infernoDir = path.join(cwd, "inferno");
29
-
30
- header("status");
30
+ if (!asJson) {
31
+ header("status");
32
+ }
31
33
 
32
34
  if (!fs.existsSync(infernoDir)) {
35
+ if (asJson) {
36
+ console.log(JSON.stringify({ ok: false, error: "inferno_not_found", hint: "Run: infernoflow init" }, null, 2));
37
+ process.exit(1);
38
+ }
33
39
  fail("inferno/ not found", `Run: infernoflow init`);
34
40
  console.log();
35
41
  process.exit(1);
@@ -37,6 +43,10 @@ export async function statusCommand() {
37
43
 
38
44
  const contractPath = path.join(infernoDir, "contract.json");
39
45
  if (!fs.existsSync(contractPath)) {
46
+ if (asJson) {
47
+ console.log(JSON.stringify({ ok: false, error: "contract_not_found" }, null, 2));
48
+ process.exit(1);
49
+ }
40
50
  fail("contract.json not found");
41
51
  console.log();
42
52
  process.exit(1);
@@ -48,6 +58,39 @@ export async function statusCommand() {
48
58
  const scenariosDir = path.join(infernoDir, "scenarios");
49
59
  const changelogPath = path.join(infernoDir, "CHANGELOG.md");
50
60
  const capsPath = path.join(infernoDir, "capabilities.json");
61
+ const { covered, uncovered } = getCoverage(scenariosDir, caps);
62
+
63
+ const hasChangelog = fs.existsSync(changelogPath) && /##\s+Unreleased/i.test(fs.readFileSync(changelogPath, "utf8"));
64
+ const driftReasons = [];
65
+ if (uncovered.length > 0) driftReasons.push(`${uncovered.length} capabilities without scenario coverage`);
66
+ if (!hasChangelog) driftReasons.push("CHANGELOG missing ## Unreleased section");
67
+ const allGood = driftReasons.length === 0;
68
+
69
+ if (asJson) {
70
+ const payload = {
71
+ ok: allGood,
72
+ driftReasons,
73
+ project: {
74
+ policyId: contract.policyId || null,
75
+ policyVersion: contract.policyVersion || null,
76
+ lastChange: timeAgo(stat.mtimeMs),
77
+ },
78
+ capabilities: {
79
+ total: caps.length,
80
+ uncovered,
81
+ },
82
+ changelog: {
83
+ hasUnreleased: hasChangelog,
84
+ },
85
+ };
86
+ console.log(JSON.stringify(payload, null, 2));
87
+ process.exit(allGood ? 0 : 1);
88
+ }
89
+
90
+ if (!allGood) {
91
+ section("Drift");
92
+ driftReasons.forEach((reason) => console.log(` ${yellow("⚠")} ${reason}`));
93
+ }
51
94
 
52
95
  // ── Project ─────────────────────────────────────────────────────
53
96
  section("Project");
@@ -66,8 +109,6 @@ export async function statusCommand() {
66
109
  } catch {}
67
110
  }
68
111
 
69
- const { covered, uncovered } = getCoverage(scenariosDir, caps);
70
-
71
112
  caps.forEach(cap => {
72
113
  const reg = capsRegistry[cap];
73
114
  const hasCoverage = covered.includes(cap);
@@ -122,8 +163,6 @@ export async function statusCommand() {
122
163
 
123
164
  // ── Health ────────────────────────────────────────────────────────
124
165
  console.log();
125
- const hasChangelog = fs.existsSync(changelogPath) && /##\s+Unreleased/i.test(fs.readFileSync(changelogPath, "utf8"));
126
- const allGood = uncovered.length === 0 && hasChangelog;
127
166
  if (allGood) {
128
167
  console.log(` ${green("●")} ${bold(green("ready"))} ${gray("— run infernoflow check for full validation")}`);
129
168
  } else {
@@ -87,7 +87,90 @@ Rules:
87
87
  - Keep it minimal and accurate`;
88
88
  }
89
89
 
90
- function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, version }) {
90
+ function validateSuggestion(suggestion) {
91
+ const errors = [];
92
+ if (!suggestion || typeof suggestion !== "object") {
93
+ return ["AI response must be a JSON object."];
94
+ }
95
+ if (suggestion.summary != null && typeof suggestion.summary !== "string") {
96
+ errors.push(`"summary" must be a string.`);
97
+ }
98
+ if (!Array.isArray(suggestion.newCapabilities)) {
99
+ errors.push(`"newCapabilities" must be an array.`);
100
+ }
101
+ if (!Array.isArray(suggestion.removedCapabilities)) {
102
+ errors.push(`"removedCapabilities" must be an array.`);
103
+ }
104
+ if (!Array.isArray(suggestion.updatedScenarios)) {
105
+ errors.push(`"updatedScenarios" must be an array.`);
106
+ }
107
+ if (suggestion.changelogEntry != null && typeof suggestion.changelogEntry !== "string") {
108
+ errors.push(`"changelogEntry" must be a string.`);
109
+ }
110
+
111
+ for (const c of suggestion.newCapabilities || []) {
112
+ if (!c || typeof c !== "object") {
113
+ errors.push(`Each item in "newCapabilities" must be an object.`);
114
+ continue;
115
+ }
116
+ if (typeof c.id !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(c.id)) {
117
+ errors.push(`newCapabilities[].id must be PascalCase (example: SendEmail).`);
118
+ }
119
+ if (typeof c.title !== "string" || !c.title.trim()) {
120
+ errors.push(`newCapabilities[].title must be a non-empty string.`);
121
+ }
122
+ }
123
+
124
+ for (const id of suggestion.removedCapabilities || []) {
125
+ if (typeof id !== "string" || !id.trim()) {
126
+ errors.push(`removedCapabilities[] must contain non-empty strings.`);
127
+ }
128
+ }
129
+
130
+ for (const s of suggestion.updatedScenarios || []) {
131
+ if (!s || typeof s !== "object") {
132
+ errors.push(`Each item in "updatedScenarios" must be an object.`);
133
+ continue;
134
+ }
135
+ if (typeof s.file !== "string" || !s.file.endsWith(".json")) {
136
+ errors.push(`updatedScenarios[].file must be a .json filename.`);
137
+ }
138
+ if (typeof s.isNew !== "boolean") {
139
+ errors.push(`updatedScenarios[].isNew must be boolean.`);
140
+ }
141
+ if (!Array.isArray(s.capabilitiesCovered) || !Array.isArray(s.stepsToAdd)) {
142
+ errors.push(`updatedScenarios[].capabilitiesCovered and stepsToAdd must be arrays.`);
143
+ }
144
+ }
145
+
146
+ return errors;
147
+ }
148
+
149
+ function detectSuggestionConflicts(contract, suggestion) {
150
+ const issues = [];
151
+ const existing = new Set(contract.capabilities || []);
152
+ const newIds = new Set((suggestion.newCapabilities || []).map((c) => c.id));
153
+ const removed = new Set(suggestion.removedCapabilities || []);
154
+
155
+ for (const id of newIds) {
156
+ if (removed.has(id)) {
157
+ issues.push(`Capability "${id}" appears in both newCapabilities and removedCapabilities.`);
158
+ }
159
+ if (existing.has(id)) {
160
+ issues.push(`Capability "${id}" already exists in contract capabilities.`);
161
+ }
162
+ }
163
+
164
+ for (const id of removed) {
165
+ if (!existing.has(id)) {
166
+ issues.push(`Capability "${id}" cannot be removed because it does not exist in contract.`);
167
+ }
168
+ }
169
+
170
+ return issues;
171
+ }
172
+
173
+ function applyChanges({ cwd, contract, capabilities, suggestion, version }) {
91
174
  const infernoDir = path.join(cwd, "inferno");
92
175
  const contractPath = path.join(infernoDir, "contract.json");
93
176
  const capsPath = path.join(infernoDir, "capabilities.json");
@@ -100,6 +183,8 @@ function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, vers
100
183
  const changelogEntry = suggestion.changelogEntry || "";
101
184
 
102
185
  let changed = false;
186
+ const writes = [];
187
+ const queueWrite = (filePath, content) => writes.push({ filePath, content });
103
188
 
104
189
  // ── contract.json ─────────────────────────────────────────────────────────
105
190
  if (newCaps.length > 0 || removedCaps.length > 0) {
@@ -107,23 +192,23 @@ function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, vers
107
192
  ...contract.capabilities.filter(c => !removedCaps.includes(c)),
108
193
  ...newCaps.map(c => c.id)
109
194
  ];
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}`);
195
+ const nextVersion = Number(contract.policyVersion || 1) + 1;
196
+ const contractUpdated = { ...contract, capabilities: updatedCaps, policyVersion: nextVersion };
197
+ queueWrite(contractPath, JSON.stringify(contractUpdated, null, 2) + "\n");
198
+ ok(`contract.json updated → policyVersion: v${nextVersion}`);
114
199
  changed = true;
115
200
  }
116
201
 
117
202
  // ── capabilities.json ─────────────────────────────────────────────────────
118
203
  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));
204
+ const reg = capabilities ? { ...capabilities } : { schemaVersion: 1, capabilities: [] };
205
+ reg.capabilities = (reg.capabilities || []).filter(c => !removedCaps.includes(c.id));
121
206
  for (const nc of newCaps) {
122
207
  if (!reg.capabilities.find(c => c.id === nc.id)) {
123
208
  reg.capabilities.push({ id: nc.id, title: nc.title, since: version });
124
209
  }
125
210
  }
126
- fs.writeFileSync(capsPath, JSON.stringify(reg, null, 2) + "\n");
211
+ queueWrite(capsPath, JSON.stringify(reg, null, 2) + "\n");
127
212
  ok(`capabilities.json updated`);
128
213
  }
129
214
 
@@ -139,7 +224,7 @@ function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, vers
139
224
  capabilitiesCovered: us.capabilitiesCovered || [],
140
225
  steps: us.stepsToAdd || []
141
226
  };
142
- fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2) + "\n");
227
+ queueWrite(filePath, JSON.stringify(scenario, null, 2) + "\n");
143
228
  ok(`Created scenario: ${cyan(us.file)}`);
144
229
  } else {
145
230
  scenario = readJson(filePath);
@@ -147,7 +232,7 @@ function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, vers
147
232
  (us.capabilitiesCovered || []).forEach(c => existingCaps.add(c));
148
233
  scenario.capabilitiesCovered = [...existingCaps];
149
234
  scenario.steps = [...(scenario.steps || []), ...(us.stepsToAdd || [])];
150
- fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2) + "\n");
235
+ queueWrite(filePath, JSON.stringify(scenario, null, 2) + "\n");
151
236
  ok(`Updated scenario: ${cyan(us.file)}`);
152
237
  }
153
238
  changed = true;
@@ -158,12 +243,35 @@ function applyChanges({ cwd, contract, capabilities, scenarios, suggestion, vers
158
243
  let txt = fs.readFileSync(changelogPath, "utf8");
159
244
  if (/##\s+Unreleased/i.test(txt)) {
160
245
  txt = txt.replace(/(##\s+Unreleased[^\n]*\n)/i, `$1\n${changelogEntry}\n`);
161
- fs.writeFileSync(changelogPath, txt);
246
+ queueWrite(changelogPath, txt);
162
247
  ok(`CHANGELOG.md updated`);
163
248
  changed = true;
164
249
  }
165
250
  }
166
251
 
252
+ const backups = new Map();
253
+ try {
254
+ for (const write of writes) {
255
+ if (fs.existsSync(write.filePath)) {
256
+ backups.set(write.filePath, fs.readFileSync(write.filePath, "utf8"));
257
+ } else {
258
+ backups.set(write.filePath, null);
259
+ }
260
+ const tmpPath = `${write.filePath}.tmp`;
261
+ fs.writeFileSync(tmpPath, write.content);
262
+ fs.renameSync(tmpPath, write.filePath);
263
+ }
264
+ } catch (err) {
265
+ for (const [filePath, content] of backups.entries()) {
266
+ if (content === null) {
267
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
268
+ } else {
269
+ fs.writeFileSync(filePath, content);
270
+ }
271
+ }
272
+ throw new Error(`Failed applying changes. Rolled back. Details: ${err.message}`);
273
+ }
274
+
167
275
  return changed;
168
276
  }
169
277
 
@@ -276,6 +384,21 @@ export async function suggestCommand(args) {
276
384
  );
277
385
  }
278
386
 
387
+ const validationErrors = validateSuggestion(suggestion);
388
+ if (validationErrors.length > 0) {
389
+ errorAndExit(
390
+ "AI response schema is invalid",
391
+ validationErrors[0] + (validationErrors.length > 1 ? ` (+${validationErrors.length - 1} more)` : "")
392
+ );
393
+ }
394
+ const conflictErrors = detectSuggestionConflicts(contract, suggestion);
395
+ if (conflictErrors.length > 0) {
396
+ errorAndExit(
397
+ "AI response contains conflicting capability operations",
398
+ conflictErrors[0] + (conflictErrors.length > 1 ? ` (+${conflictErrors.length - 1} more)` : "")
399
+ );
400
+ }
401
+
279
402
  // ── Preview ───────────────────────────────────────────────────────────────
280
403
  section("Proposed Changes");
281
404
  console.log();
@@ -337,7 +460,7 @@ export async function suggestCommand(args) {
337
460
  section("Applying Changes");
338
461
  console.log();
339
462
 
340
- applyChanges({ cwd, contract, capabilities, scenarios, suggestion, version });
463
+ applyChanges({ cwd, contract, capabilities, suggestion, version });
341
464
 
342
465
  done("suggest complete!");
343
466
 
@@ -1,6 +1,8 @@
1
1
  // Zero-dependency interactive prompts using readline
2
2
 
3
3
  import * as readline from "node:readline";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
4
6
 
5
7
  function ask(question, defaultVal = "") {
6
8
  return new Promise(resolve => {
@@ -18,3 +20,128 @@ export async function promptInit() {
18
20
  const caps = await ask("Capabilities (comma-separated)", "CreateTask, ReadTasks, UpdateTask, DeleteTask");
19
21
  return { policyId, capabilities: caps.split(",").map(c => c.trim()).filter(Boolean) };
20
22
  }
23
+
24
+ function readJson(filePath) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function loadImplementContext(cwd) {
33
+ const infernoDir = path.join(cwd, "inferno");
34
+ const contract = readJson(path.join(infernoDir, "contract.json")) || {};
35
+ const caps = readJson(path.join(infernoDir, "capabilities.json")) || { capabilities: [] };
36
+ const state = readJson(path.join(infernoDir, "context-state.json")) || {};
37
+ const scenariosDir = path.join(infernoDir, "scenarios");
38
+
39
+ const scenarios = [];
40
+ if (fs.existsSync(scenariosDir)) {
41
+ for (const fileName of fs.readdirSync(scenariosDir).filter((f) => f.endsWith(".json"))) {
42
+ const scenario = readJson(path.join(scenariosDir, fileName));
43
+ if (scenario) scenarios.push({ file: fileName, scenario });
44
+ }
45
+ }
46
+
47
+ return { contract, caps, state, scenarios };
48
+ }
49
+
50
+ function renderCaps(capsRegistry) {
51
+ const list = capsRegistry?.capabilities || [];
52
+ if (list.length === 0) return "- none";
53
+ return list.map((c) => `- ${c.id}: ${c.title || c.id}`).join("\n");
54
+ }
55
+
56
+ function renderScenarios(scenarios) {
57
+ if (!scenarios || scenarios.length === 0) return "- none";
58
+ return scenarios
59
+ .map(({ file, scenario }) => {
60
+ const covered = (scenario.capabilitiesCovered || []).join(", ") || "none";
61
+ return `- ${file}: covers [${covered}]`;
62
+ })
63
+ .join("\n");
64
+ }
65
+
66
+ function baseContextBlock({ contract, caps, scenarios, state }) {
67
+ const policy = contract?.policyId || "unknown-policy";
68
+ const version = contract?.policyVersion ?? "unknown";
69
+ const declared = (contract?.capabilities || []).join(", ") || "none";
70
+ const working = state?.working || "not set";
71
+ const intent = state?.intent || "not set";
72
+
73
+ return [
74
+ `Project policyId: ${policy}`,
75
+ `Policy version: ${version}`,
76
+ `Declared capabilities: [${declared}]`,
77
+ `Working on: ${working}`,
78
+ `Intent: ${intent}`,
79
+ "",
80
+ "Capabilities registry:",
81
+ renderCaps(caps),
82
+ "",
83
+ "Scenarios:",
84
+ renderScenarios(scenarios),
85
+ ].join("\n");
86
+ }
87
+
88
+ export function buildCursorImplementPrompt({ task, contract, caps, scenarios, state }) {
89
+ return [
90
+ "You are a Cursor coding agent working inside my repository.",
91
+ "Implement the task end-to-end with minimal reliable changes.",
92
+ "",
93
+ baseContextBlock({ contract, caps, scenarios, state }),
94
+ "",
95
+ `Task: ${task}`,
96
+ "",
97
+ "Requirements:",
98
+ "1) Propose smallest safe implementation.",
99
+ "2) Explain which files you changed and why.",
100
+ "3) Implement production-ready code.",
101
+ "4) Preserve backward compatibility unless explicitly requested.",
102
+ "5) Update tests or add smoke checks.",
103
+ "6) Provide run/verify commands.",
104
+ "7) If assumptions are needed, state briefly and proceed with sensible defaults.",
105
+ "",
106
+ "Output format:",
107
+ "- Plan (short)",
108
+ "- Code changes (by file)",
109
+ "- Tests updated/added",
110
+ "- Commands to run",
111
+ "- Acceptance checklist",
112
+ "",
113
+ "Quality bar:",
114
+ "- No TODO placeholders in final code",
115
+ "- Handle edge cases and errors",
116
+ "- Keep naming/style consistent",
117
+ "- Prefer simple maintainable solutions",
118
+ "",
119
+ "If model is overloaded (resource exhausted), retry with Auto/another model and continue deterministically.",
120
+ ].join("\n");
121
+ }
122
+
123
+ export function buildGenericImplementPrompt({ task, contract, caps, scenarios, state }) {
124
+ return [
125
+ "You are my senior software engineer pair.",
126
+ "Implement this task end-to-end in my project.",
127
+ "",
128
+ baseContextBlock({ contract, caps, scenarios, state }),
129
+ "",
130
+ `Goal: ${task}`,
131
+ "",
132
+ "Deliverables:",
133
+ "- Short implementation plan",
134
+ "- Exact file-level changes",
135
+ "- Test updates",
136
+ "- Verification commands",
137
+ "- Final acceptance checklist",
138
+ "",
139
+ "Constraints:",
140
+ "- Keep backward compatibility by default",
141
+ "- Make minimal reliable changes",
142
+ "- Handle edge cases and error states",
143
+ "- Keep output concise and actionable",
144
+ "",
145
+ "If you encounter temporary model high-load errors, retry and preserve the same output structure.",
146
+ ].join("\n");
147
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.8.0",
3
+ "version": "0.10.1",
4
4
  "description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "infernoflow": "./bin/infernoflow.mjs"
7
+ "infernoflow": "bin/infernoflow.mjs"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=18"
@@ -16,7 +16,8 @@
16
16
  "README.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "node bin/infernoflow.mjs --help"
19
+ "test": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs && node scripts/implement-smoke.mjs",
20
+ "test:help": "node bin/infernoflow.mjs --help"
20
21
  },
21
22
  "keywords": [
22
23
  "cli",
@@ -32,7 +33,7 @@
32
33
  "license": "MIT",
33
34
  "repository": {
34
35
  "type": "git",
35
- "url": "https://github.com/ronmiz/infernoflow.git"
36
+ "url": "git+https://github.com/ronmiz/infernoflow.git"
36
37
  },
37
38
  "homepage": "https://github.com/ronmiz/infernoflow#readme",
38
39
  "bugs": {