infernoflow 0.7.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/bin/infernoflow.mjs +46 -59
- package/lib/commands/check.mjs +28 -3
- package/lib/commands/context.mjs +37 -1
- package/lib/commands/docGate.mjs +27 -3
- package/lib/commands/implement.mjs +103 -0
- package/lib/commands/status.mjs +47 -8
- package/lib/commands/suggest.mjs +135 -12
- package/lib/ui/prompts.mjs +127 -0
- package/package.json +3 -2
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
|
package/bin/infernoflow.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
});
|
package/lib/commands/check.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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));
|
package/lib/commands/context.mjs
CHANGED
|
@@ -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 {
|
|
@@ -42,9 +43,11 @@ export async function contextCommand(args) {
|
|
|
42
43
|
const decision = flag("--decision") || flag("-d");
|
|
43
44
|
const showOnly = has("--show") || has("-s");
|
|
44
45
|
const copyFlag = has("--copy") || has("-c");
|
|
46
|
+
const cursorFlag = has("--cursor");
|
|
47
|
+
const copilotFlag = has("--copilot");
|
|
45
48
|
const resetFlag= has("--reset");
|
|
46
49
|
|
|
47
|
-
console.log("\n "+bold("��� infernoflow — context"));
|
|
50
|
+
console.log("\n "+bold("��� infernoflow — context"));
|
|
48
51
|
console.log(" "+"─".repeat(50)+"\n");
|
|
49
52
|
|
|
50
53
|
if(!fs.existsSync(INFERNO_DIR)){
|
|
@@ -75,6 +78,10 @@ export async function contextCommand(args) {
|
|
|
75
78
|
const version = String(contract.policyVersion).replace(/^v/i,"");
|
|
76
79
|
const now = new Date().toLocaleDateString("en-GB",{day:"2-digit",month:"short",year:"numeric"});
|
|
77
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);
|
|
78
85
|
|
|
79
86
|
const capLines = capList.map(c=>"- **"+c.id+"** — "+c.title).join("\n");
|
|
80
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_";
|
|
@@ -96,6 +103,24 @@ export async function contextCommand(args) {
|
|
|
96
103
|
"## What I am working on right now","",workingLine,"","---","",
|
|
97
104
|
"## Intent — what I want to build next","",intentLine,"","---","",
|
|
98
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
|
+
"---",
|
|
99
124
|
"_Paste this block at the start of any new AI session._"
|
|
100
125
|
].join("\n");
|
|
101
126
|
|
|
@@ -106,6 +131,15 @@ export async function contextCommand(args) {
|
|
|
106
131
|
console.log(ok ? green(" ✔ Copied to clipboard — paste with Ctrl+V") : yellow(" ⚠ Clipboard copy failed — open inferno/CONTEXT.md manually"));
|
|
107
132
|
}
|
|
108
133
|
|
|
134
|
+
if (cursorFlag) {
|
|
135
|
+
fs.writeFileSync(".cursorrules", md, "utf8");
|
|
136
|
+
console.log(green(" ✔ Written to .cursorrules — Cursor loads this automatically"));
|
|
137
|
+
}
|
|
138
|
+
if (copilotFlag) {
|
|
139
|
+
if (!fs.existsSync(".github")) fs.mkdirSync(".github");
|
|
140
|
+
fs.writeFileSync(".github/copilot-instructions.md", md, "utf8");
|
|
141
|
+
console.log(green(" ✔ Written to .github/copilot-instructions.md — Copilot loads this automatically"));
|
|
142
|
+
}
|
|
109
143
|
console.log("\n "+bold("Context Summary"));
|
|
110
144
|
console.log(" "+"─".repeat(50));
|
|
111
145
|
console.log(" Project "+contract.policyId+" — v"+version);
|
|
@@ -114,6 +148,8 @@ export async function contextCommand(args) {
|
|
|
114
148
|
console.log(" Working on "+(state.working?cyan(state.working):gray("not set")));
|
|
115
149
|
console.log(" Intent "+(state.intent ?cyan(state.intent) :gray("not set")));
|
|
116
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");
|
|
117
153
|
|
|
118
154
|
if(copyFlag){
|
|
119
155
|
console.log(" "+bold("Ready to use:"));
|
package/lib/commands/docGate.mjs
CHANGED
|
@@ -13,8 +13,10 @@ const CODE_PREFIXES = [
|
|
|
13
13
|
];
|
|
14
14
|
|
|
15
15
|
export async function docGateCommand(opts = {}) {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
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
|
+
}
|
package/lib/commands/status.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 {
|
package/lib/commands/suggest.mjs
CHANGED
|
@@ -87,7 +87,90 @@ Rules:
|
|
|
87
87
|
- Keep it minimal and accurate`;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function
|
|
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.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
ok(`contract.json updated → policyVersion: v${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
463
|
+
applyChanges({ cwd, contract, capabilities, suggestion, version });
|
|
341
464
|
|
|
342
465
|
done("suggest complete!");
|
|
343
466
|
|
package/lib/ui/prompts.mjs
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infernoflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node
|
|
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",
|