infernoflow 0.10.6 → 0.10.8
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 +63 -2
- package/bin/infernoflow.mjs +14 -0
- package/lib/ai/localProvider.mjs +88 -0
- package/lib/commands/adopt.mjs +768 -712
- package/lib/commands/implement.mjs +103 -103
- package/lib/commands/init.mjs +12 -1
- package/lib/commands/prImpact.mjs +157 -0
- package/lib/commands/run.mjs +227 -0
- package/lib/commands/suggest.mjs +42 -12
- package/lib/commands/syncAuto.mjs +96 -0
- package/package.json +2 -2
- package/templates/ci/github-inferno-check.yml +33 -0
- package/templates/scripts/inferno-install-hooks.mjs +36 -0
|
@@ -1,103 +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
|
+
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/init.mjs
CHANGED
|
@@ -65,7 +65,11 @@ function upsertScripts(cwd, silent = false) {
|
|
|
65
65
|
const toAdd = {
|
|
66
66
|
"inferno:check": "infernoflow check",
|
|
67
67
|
"inferno:status": "infernoflow status",
|
|
68
|
-
"inferno:gate": "infernoflow doc-gate"
|
|
68
|
+
"inferno:gate": "infernoflow doc-gate",
|
|
69
|
+
"inferno:impact": "infernoflow pr-impact --json",
|
|
70
|
+
"inferno:sync": "infernoflow sync --auto --json",
|
|
71
|
+
"inferno:run": "infernoflow run \"sync check\" --json",
|
|
72
|
+
"inferno:hooks": "node scripts/inferno-install-hooks.mjs"
|
|
69
73
|
};
|
|
70
74
|
for (const [k, v] of Object.entries(toAdd)) {
|
|
71
75
|
if (!pkg.scripts[k]) { pkg.scripts[k] = v; changed = true; }
|
|
@@ -167,6 +171,7 @@ export async function initCommand(args) {
|
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
const infernoDir = path.join(cwd, "inferno");
|
|
174
|
+
const workflowsDir = path.join(cwd, ".github", "workflows");
|
|
170
175
|
if (fs.existsSync(infernoDir) && !force) {
|
|
171
176
|
if (silent) {
|
|
172
177
|
console.log(JSON.stringify({ ok: false, error: "inferno_exists", hint: "Use --force to overwrite" }, null, 2));
|
|
@@ -303,6 +308,12 @@ export async function initCommand(args) {
|
|
|
303
308
|
const srcScript = path.join(templates, "scripts", "inferno-doc-gate.mjs");
|
|
304
309
|
const dstScript = path.join(cwd, "scripts", "inferno-doc-gate.mjs");
|
|
305
310
|
copyFile(srcScript, dstScript, force, silent);
|
|
311
|
+
const srcHookScript = path.join(templates, "scripts", "inferno-install-hooks.mjs");
|
|
312
|
+
const dstHookScript = path.join(cwd, "scripts", "inferno-install-hooks.mjs");
|
|
313
|
+
copyFile(srcHookScript, dstHookScript, force, silent);
|
|
314
|
+
const srcWorkflow = path.join(templates, "ci", "github-inferno-check.yml");
|
|
315
|
+
const dstWorkflow = path.join(workflowsDir, "infernoflow-check.yml");
|
|
316
|
+
copyFile(srcWorkflow, dstWorkflow, force, silent);
|
|
306
317
|
|
|
307
318
|
upsertScripts(cwd, silent);
|
|
308
319
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { header, section, ok, warn, fail, gray, cyan, yellow } from "../ui/output.mjs";
|
|
5
|
+
|
|
6
|
+
const CODE_PREFIXES = ["src/", "frontend/", "backend/", "app/", "pages/", "components/", "lib/", "api/", "server/", "Controllers/"];
|
|
7
|
+
|
|
8
|
+
function sh(cmd) {
|
|
9
|
+
return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString("utf8").trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readJson(filePath, fallback = null) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readFile(filePath, fallback = "") {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(filePath, "utf8");
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getChangedFiles(base, head) {
|
|
29
|
+
const out = base && head
|
|
30
|
+
? sh(`git diff --name-only ${base}..${head}`)
|
|
31
|
+
: sh("git diff --name-only HEAD");
|
|
32
|
+
return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildCapabilityHints(cwd) {
|
|
36
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
37
|
+
const contract = readJson(path.join(infernoDir, "contract.json"), { capabilities: [] });
|
|
38
|
+
const registry = readJson(path.join(infernoDir, "capabilities.json"), { capabilities: [] });
|
|
39
|
+
const titleById = new Map((registry.capabilities || []).map((c) => [c.id, c.title || c.id]));
|
|
40
|
+
return (contract.capabilities || []).map((id) => {
|
|
41
|
+
const title = titleById.get(id) || id;
|
|
42
|
+
const keywords = new Set(
|
|
43
|
+
`${id} ${title}`
|
|
44
|
+
.replace(/([A-Z])/g, " $1")
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.split(/[^a-z0-9]+/)
|
|
47
|
+
.filter((k) => k.length >= 4)
|
|
48
|
+
);
|
|
49
|
+
return { id, title, keywords: Array.from(keywords) };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function inferImpactedCapabilities(cwd, changedCodeFiles) {
|
|
54
|
+
const hints = buildCapabilityHints(cwd);
|
|
55
|
+
const impacted = [];
|
|
56
|
+
for (const hint of hints) {
|
|
57
|
+
const matched = [];
|
|
58
|
+
for (const rel of changedCodeFiles) {
|
|
59
|
+
const abs = path.join(cwd, rel);
|
|
60
|
+
const text = readFile(abs, "").toLowerCase();
|
|
61
|
+
if (!text) continue;
|
|
62
|
+
if (hint.keywords.some((k) => text.includes(k))) {
|
|
63
|
+
matched.push(rel);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (matched.length) {
|
|
67
|
+
impacted.push({ id: hint.id, title: hint.title, matchedFiles: matched.slice(0, 5) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return impacted;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function prImpactCommand(args = []) {
|
|
74
|
+
const asJson = args.includes("--json");
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
const base = process.env.BASE_SHA || null;
|
|
77
|
+
const head = process.env.HEAD_SHA || null;
|
|
78
|
+
|
|
79
|
+
let changedFiles = [];
|
|
80
|
+
try {
|
|
81
|
+
changedFiles = getChangedFiles(base, head);
|
|
82
|
+
} catch {
|
|
83
|
+
const payload = { ok: true, skipped: true, reason: "no_git_available" };
|
|
84
|
+
if (asJson) {
|
|
85
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
header("pr-impact");
|
|
89
|
+
warn("git not available; cannot compute PR impact");
|
|
90
|
+
console.log();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const changedCodeFiles = changedFiles.filter((f) => CODE_PREFIXES.some((p) => f.startsWith(p)));
|
|
95
|
+
const changedInfernoFiles = changedFiles.filter((f) => f.startsWith("inferno/"));
|
|
96
|
+
const impactedCapabilities = inferImpactedCapabilities(cwd, changedCodeFiles);
|
|
97
|
+
const inferredBehaviorChange = changedCodeFiles.length > 0;
|
|
98
|
+
const missingInfernoUpdate = inferredBehaviorChange && changedInfernoFiles.length === 0;
|
|
99
|
+
const confidence = impactedCapabilities.length > 0 ? "high" : inferredBehaviorChange ? "medium" : "low";
|
|
100
|
+
const reasonCodes = [];
|
|
101
|
+
if (inferredBehaviorChange) reasonCodes.push("CODE_CHANGED");
|
|
102
|
+
if (missingInfernoUpdate) reasonCodes.push("INFERNO_NOT_UPDATED");
|
|
103
|
+
if (impactedCapabilities.length > 0) reasonCodes.push("CAPABILITY_HINT_MATCH");
|
|
104
|
+
if (!reasonCodes.length) reasonCodes.push("NO_BEHAVIOR_SIGNAL");
|
|
105
|
+
|
|
106
|
+
const payload = {
|
|
107
|
+
ok: !missingInfernoUpdate,
|
|
108
|
+
base: base || "HEAD",
|
|
109
|
+
head: head || "WORKTREE",
|
|
110
|
+
changedFiles,
|
|
111
|
+
changedCodeFiles,
|
|
112
|
+
changedInfernoFiles,
|
|
113
|
+
inferredBehaviorChange,
|
|
114
|
+
impactedCapabilities,
|
|
115
|
+
confidence,
|
|
116
|
+
reasonCodes,
|
|
117
|
+
recommendations: missingInfernoUpdate
|
|
118
|
+
? ["Run infernoflow suggest \"describe behavior change\" and update inferno/", "Run infernoflow check --json"]
|
|
119
|
+
: ["Run infernoflow check --json to validate final state"],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (asJson) {
|
|
123
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
124
|
+
process.exit(payload.ok ? 0 : 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
header("pr-impact");
|
|
128
|
+
|
|
129
|
+
section("Diff Scope");
|
|
130
|
+
ok(`Changed files: ${cyan(String(changedFiles.length))}`);
|
|
131
|
+
ok(`Code files: ${cyan(String(changedCodeFiles.length))}`);
|
|
132
|
+
ok(`Inferno files: ${cyan(String(changedInfernoFiles.length))}`);
|
|
133
|
+
|
|
134
|
+
section("Capability Impact");
|
|
135
|
+
if (impactedCapabilities.length === 0) {
|
|
136
|
+
warn("No capability hints matched changed code files");
|
|
137
|
+
} else {
|
|
138
|
+
impactedCapabilities.forEach((c) => {
|
|
139
|
+
console.log(` ${cyan("•")} ${c.id} ${gray(`(${c.title})`)}`);
|
|
140
|
+
c.matchedFiles.slice(0, 3).forEach((f) => console.log(` ${gray("- " + f)}`));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
section("Doc Sync");
|
|
145
|
+
if (missingInfernoUpdate) {
|
|
146
|
+
fail("Code changed but inferno/ was not updated", "Run infernoflow suggest and then infernoflow check");
|
|
147
|
+
} else {
|
|
148
|
+
ok("No immediate inferno drift signal from changed files");
|
|
149
|
+
}
|
|
150
|
+
ok(`Confidence: ${cyan(confidence)}`);
|
|
151
|
+
|
|
152
|
+
section("Suggested Next");
|
|
153
|
+
payload.recommendations.forEach((r) => console.log(` ${yellow("→")} ${r}`));
|
|
154
|
+
console.log();
|
|
155
|
+
process.exit(payload.ok ? 0 : 1);
|
|
156
|
+
}
|
|
157
|
+
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { generateWithLocalModel } from "../ai/localProvider.mjs";
|
|
6
|
+
import {
|
|
7
|
+
buildPrompt,
|
|
8
|
+
loadSuggestContext,
|
|
9
|
+
parseSuggestionJson,
|
|
10
|
+
validateSuggestion,
|
|
11
|
+
detectSuggestionConflicts,
|
|
12
|
+
applyChanges,
|
|
13
|
+
} from "./suggest.mjs";
|
|
14
|
+
import { header, section, ok, warn, fail, info, gray } from "../ui/output.mjs";
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
const binPath = path.resolve(__dirname, "..", "..", "bin", "infernoflow.mjs");
|
|
19
|
+
|
|
20
|
+
function runCliJson(args) {
|
|
21
|
+
try {
|
|
22
|
+
const out = execFileSync(process.execPath, [binPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
23
|
+
return { ok: true, data: JSON.parse(out) };
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const stdout = err?.stdout?.toString?.() || "";
|
|
26
|
+
try {
|
|
27
|
+
return { ok: false, data: JSON.parse(stdout) };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ok: false, data: { ok: false, errors: ["command_failed"] } };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stageEvent(asJson, events, stage, status, details = {}) {
|
|
35
|
+
const ev = { ts: new Date().toISOString(), stage, status, ...details };
|
|
36
|
+
events.push(ev);
|
|
37
|
+
if (asJson) return;
|
|
38
|
+
const text = `${stage}: ${status}`;
|
|
39
|
+
if (status === "ok") ok(text);
|
|
40
|
+
else if (status === "warn") warn(text);
|
|
41
|
+
else if (status === "fail") fail(text);
|
|
42
|
+
else info(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshotInferno(cwd) {
|
|
46
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
47
|
+
const targets = [];
|
|
48
|
+
const walk = (dir) => {
|
|
49
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const p = path.join(dir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) walk(p);
|
|
52
|
+
else targets.push(p);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
if (fs.existsSync(infernoDir)) walk(infernoDir);
|
|
56
|
+
const snapshot = new Map();
|
|
57
|
+
targets.forEach((filePath) => snapshot.set(filePath, fs.readFileSync(filePath, "utf8")));
|
|
58
|
+
return snapshot;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function restoreSnapshot(cwd, snapshot) {
|
|
62
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
63
|
+
if (fs.existsSync(infernoDir)) {
|
|
64
|
+
const existing = [];
|
|
65
|
+
const walk = (dir) => {
|
|
66
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
67
|
+
const p = path.join(dir, entry.name);
|
|
68
|
+
if (entry.isDirectory()) walk(p);
|
|
69
|
+
else existing.push(p);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk(infernoDir);
|
|
73
|
+
existing.forEach((filePath) => {
|
|
74
|
+
if (!snapshot.has(filePath)) fs.unlinkSync(filePath);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
for (const [filePath, content] of snapshot.entries()) {
|
|
78
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
79
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeRunArtifact(cwd, artifact) {
|
|
84
|
+
const runsDir = path.join(cwd, "inferno", "runs");
|
|
85
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
86
|
+
const filePath = path.join(runsDir, `${Date.now()}.json`);
|
|
87
|
+
fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
88
|
+
return path.relative(cwd, filePath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runCommand(args = []) {
|
|
92
|
+
const asJson = args.includes("--json");
|
|
93
|
+
const dryRun = args.includes("--dry-run");
|
|
94
|
+
const noRollback = args.includes("--no-rollback");
|
|
95
|
+
const task = args.filter((a) => !a.startsWith("-")).slice(1).join(" ").trim() || "sync check";
|
|
96
|
+
const cwd = process.cwd();
|
|
97
|
+
const events = [];
|
|
98
|
+
|
|
99
|
+
if (!asJson) header("run");
|
|
100
|
+
stageEvent(asJson, events, "init", "info", { task, dryRun, noRollback });
|
|
101
|
+
|
|
102
|
+
// detect
|
|
103
|
+
const impact = runCliJson(["pr-impact", "--json"]);
|
|
104
|
+
stageEvent(asJson, events, "detect", impact.data?.ok ? "ok" : "warn", { confidence: impact.data?.confidence || "low" });
|
|
105
|
+
|
|
106
|
+
const ctx = loadSuggestContext(cwd);
|
|
107
|
+
if (!ctx?.contract) {
|
|
108
|
+
const payload = { ok: false, error: "inferno_missing", events };
|
|
109
|
+
if (asJson) console.log(JSON.stringify(payload, null, 2));
|
|
110
|
+
else fail("inferno/ missing or invalid");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// propose
|
|
115
|
+
const prompt = buildPrompt({
|
|
116
|
+
description: task,
|
|
117
|
+
contract: ctx.contract,
|
|
118
|
+
capabilities: ctx.capabilities,
|
|
119
|
+
scenarios: ctx.scenarios,
|
|
120
|
+
});
|
|
121
|
+
let suggestion;
|
|
122
|
+
try {
|
|
123
|
+
const raw = await generateWithLocalModel(prompt);
|
|
124
|
+
suggestion = parseSuggestionJson(raw);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const payload = { ok: false, error: "local_model_failed", reason: String(err.message || err), events };
|
|
127
|
+
if (asJson) console.log(JSON.stringify(payload, null, 2));
|
|
128
|
+
else fail(`local model failed`, err.message);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
stageEvent(asJson, events, "propose", "ok", {
|
|
132
|
+
newCapabilities: (suggestion.newCapabilities || []).length,
|
|
133
|
+
removedCapabilities: (suggestion.removedCapabilities || []).length,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const schemaErrors = validateSuggestion(suggestion);
|
|
137
|
+
const conflictErrors = detectSuggestionConflicts(ctx.contract, suggestion);
|
|
138
|
+
if (schemaErrors.length || conflictErrors.length) {
|
|
139
|
+
const payload = {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: "invalid_suggestion",
|
|
142
|
+
issues: [...schemaErrors, ...conflictErrors],
|
|
143
|
+
events,
|
|
144
|
+
};
|
|
145
|
+
if (asJson) console.log(JSON.stringify(payload, null, 2));
|
|
146
|
+
else fail("suggestion invalid", payload.issues[0]);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const snapshot = snapshotInferno(cwd);
|
|
151
|
+
let rolledBack = false;
|
|
152
|
+
let applyChanged = false;
|
|
153
|
+
let validationPassed = false;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
stageEvent(asJson, events, "apply", "info", { dryRun: true });
|
|
158
|
+
} else {
|
|
159
|
+
applyChanged = applyChanges({
|
|
160
|
+
cwd,
|
|
161
|
+
contract: ctx.contract,
|
|
162
|
+
capabilities: ctx.capabilities,
|
|
163
|
+
suggestion,
|
|
164
|
+
version: ctx.version,
|
|
165
|
+
quiet: asJson,
|
|
166
|
+
});
|
|
167
|
+
stageEvent(asJson, events, "apply", "ok", { changed: applyChanged });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let check = runCliJson(["check", "--json"]);
|
|
171
|
+
if (process.env.INFERNO_TEST_FORCE_VALIDATE_FAIL === "1") {
|
|
172
|
+
check = { ok: false, data: { ok: false, errors: ["forced_validation_failure"] } };
|
|
173
|
+
}
|
|
174
|
+
if (!check.ok || !check.data?.ok) {
|
|
175
|
+
throw new Error(`validation_failed:${(check.data?.errors || []).join(",")}`);
|
|
176
|
+
}
|
|
177
|
+
validationPassed = true;
|
|
178
|
+
stageEvent(asJson, events, "validate", "ok");
|
|
179
|
+
} catch (err) {
|
|
180
|
+
stageEvent(asJson, events, "validate", "fail", { reason: String(err.message || err) });
|
|
181
|
+
if (!dryRun && !noRollback) {
|
|
182
|
+
restoreSnapshot(cwd, snapshot);
|
|
183
|
+
rolledBack = true;
|
|
184
|
+
stageEvent(asJson, events, "rollback", "ok");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const artifact = {
|
|
189
|
+
task,
|
|
190
|
+
dryRun,
|
|
191
|
+
noRollback,
|
|
192
|
+
rolledBack,
|
|
193
|
+
applyChanged,
|
|
194
|
+
suggestionSummary: suggestion.summary || "",
|
|
195
|
+
touchedCapabilities: [
|
|
196
|
+
...(suggestion.newCapabilities || []).map((c) => c.id),
|
|
197
|
+
...(suggestion.removedCapabilities || []),
|
|
198
|
+
],
|
|
199
|
+
events,
|
|
200
|
+
};
|
|
201
|
+
const artifactPath = writeRunArtifact(cwd, artifact);
|
|
202
|
+
|
|
203
|
+
const payload = {
|
|
204
|
+
ok: validationPassed,
|
|
205
|
+
mode: "run",
|
|
206
|
+
task,
|
|
207
|
+
dryRun,
|
|
208
|
+
rolledBack,
|
|
209
|
+
applyChanged,
|
|
210
|
+
artifactPath,
|
|
211
|
+
events,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (asJson) {
|
|
215
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
216
|
+
process.exit(payload.ok ? 0 : 1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
section("Result");
|
|
220
|
+
info(`task: ${gray(task)}`);
|
|
221
|
+
info(`artifact: ${gray(artifactPath)}`);
|
|
222
|
+
if (payload.ok) ok("run completed");
|
|
223
|
+
else warn("run rolled back after failed validation");
|
|
224
|
+
console.log();
|
|
225
|
+
process.exit(payload.ok ? 0 : 1);
|
|
226
|
+
}
|
|
227
|
+
|