infernoflow 0.10.23 → 0.10.26
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/CHANGELOG.md +13 -6
- package/dist/bin/infernoflow.mjs +135 -41
- package/dist/lib/adopters/angular.mjs +128 -1
- package/dist/lib/adopters/css.mjs +111 -1
- package/dist/lib/adopters/react.mjs +104 -1
- package/dist/lib/ai/ideDetection.mjs +31 -1
- package/dist/lib/ai/localProvider.mjs +88 -1
- package/dist/lib/ai/providerRouter.mjs +73 -1
- package/dist/lib/commands/adopt.mjs +869 -20
- package/dist/lib/commands/changelog.mjs +343 -21
- package/dist/lib/commands/check.mjs +179 -3
- package/dist/lib/commands/context.mjs +287 -31
- package/dist/lib/commands/diff.mjs +274 -5
- package/dist/lib/commands/docGate.mjs +81 -2
- package/dist/lib/commands/generateSkills.mjs +163 -38
- package/dist/lib/commands/implement.mjs +103 -7
- package/dist/lib/commands/init.mjs +394 -10
- package/dist/lib/commands/installCursorHooks.mjs +36 -1
- package/dist/lib/commands/installVsCodeCopilotHooks.mjs +37 -1
- package/dist/lib/commands/prImpact.mjs +157 -2
- package/dist/lib/commands/publish.mjs +293 -15
- package/dist/lib/commands/run.mjs +336 -8
- package/dist/lib/commands/setup.mjs +234 -4
- package/dist/lib/commands/status.mjs +172 -4
- package/dist/lib/commands/suggest.mjs +563 -21
- package/dist/lib/commands/syncAuto.mjs +96 -1
- package/dist/lib/cursorHooksInstall.mjs +60 -1
- package/dist/lib/draftToolingInstall.mjs +68 -7
- package/dist/lib/git/detect-drift.mjs +208 -4
- package/dist/lib/learning/adapt.mjs +101 -6
- package/dist/lib/learning/observe.mjs +114 -1
- package/dist/lib/learning/profile.mjs +212 -2
- package/dist/lib/ui/output.mjs +72 -6
- package/dist/lib/ui/prompts.mjs +147 -6
- package/dist/lib/vsCodeCopilotHooksInstall.mjs +42 -1
- package/package.json +47 -47
|
@@ -1,31 +1,287 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { bold, gray, cyan, red, green, yellow } from "../ui/output.mjs";
|
|
5
|
+
import { buildCursorImplementPrompt, buildGenericImplementPrompt } from "../ui/prompts.mjs";
|
|
6
|
+
import { detectDrift } from "../git/detect-drift.mjs";
|
|
7
|
+
|
|
8
|
+
function copyToClipboard(text) {
|
|
9
|
+
try {
|
|
10
|
+
const p = process.platform;
|
|
11
|
+
if (p === "win32") execSync("clip", { input: text });
|
|
12
|
+
else if (p === "darwin") execSync("pbcopy", { input: text });
|
|
13
|
+
else { try { execSync("xclip -selection clipboard", { input: text }); } catch { execSync("xsel --clipboard --input", { input: text }); } }
|
|
14
|
+
return true;
|
|
15
|
+
} catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INFERNO_DIR = "inferno";
|
|
19
|
+
const CONTEXT_FILE = path.join(INFERNO_DIR, "CONTEXT.md");
|
|
20
|
+
const STATE_FILE = path.join(INFERNO_DIR, "context-state.json");
|
|
21
|
+
|
|
22
|
+
function readJSON(f) { try { return JSON.parse(fs.readFileSync(f,"utf8")); } catch { return null; } }
|
|
23
|
+
function readFile(f) { try { return fs.readFileSync(f,"utf8"); } catch { return null; } }
|
|
24
|
+
function loadState() { const r=readFile(STATE_FILE); if(!r) return {}; try { return JSON.parse(r); } catch { return {}; } }
|
|
25
|
+
function saveState(s) { fs.writeFileSync(STATE_FILE,JSON.stringify(s,null,2),"utf8"); }
|
|
26
|
+
function fmtDate(iso) { if(!iso) return "unknown"; return new Date(iso).toLocaleDateString("en-GB",{day:"2-digit",month:"short",year:"numeric"}); }
|
|
27
|
+
function parseChangelog(txt,max) {
|
|
28
|
+
if(!txt) return [];
|
|
29
|
+
const entries=[]; let cur=null;
|
|
30
|
+
for(const line of txt.split("\n")) {
|
|
31
|
+
if(line.startsWith("## ")) { if(cur&&entries.length<max) entries.push(cur); if(entries.length>=max) break; cur={title:line.replace("## ","").trim(),items:[]}; }
|
|
32
|
+
else if(cur&&line.startsWith("- ")) cur.items.push(line.replace("- ","").trim());
|
|
33
|
+
}
|
|
34
|
+
if(cur&&entries.length<max) entries.push(cur);
|
|
35
|
+
return entries.filter(e=>e.items.length>0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function contextCommand(args) {
|
|
39
|
+
const has = (f) => args.includes(f);
|
|
40
|
+
const flag = (f) => { const i=args.indexOf(f); return i!==-1&&args[i+1]?args[i+1]:null; };
|
|
41
|
+
|
|
42
|
+
const intent = flag("--intent") || flag("-i");
|
|
43
|
+
const working = flag("--working") || flag("-w");
|
|
44
|
+
const decision = flag("--decision") || flag("-d");
|
|
45
|
+
const showOnly = has("--show") || has("-s");
|
|
46
|
+
const copyFlag = has("--copy") || has("-c");
|
|
47
|
+
const cursorFlag = has("--cursor");
|
|
48
|
+
const copilotFlag = has("--copilot");
|
|
49
|
+
const resetFlag= has("--reset");
|
|
50
|
+
const watchFlag = has("--watch");
|
|
51
|
+
const autoCommit = has("--auto-commit") || has("--auto-push");
|
|
52
|
+
const autoPush = has("--auto-push");
|
|
53
|
+
const watchInterval = parseInt(flag("--interval") || "30", 10) * 1000;
|
|
54
|
+
|
|
55
|
+
console.log("\n "+bold("��� infernoflow — context"));
|
|
56
|
+
console.log(" "+"─".repeat(50)+"\n");
|
|
57
|
+
|
|
58
|
+
if(!fs.existsSync(INFERNO_DIR)){
|
|
59
|
+
console.error(red(" ✘ inferno/ not found"));
|
|
60
|
+
console.error(gray(" → Run: infernoflow init\n"));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const contract = readJSON(path.join(INFERNO_DIR,"contract.json"));
|
|
65
|
+
const capabilities = readJSON(path.join(INFERNO_DIR,"capabilities.json"));
|
|
66
|
+
const changelog = readFile(path.join(INFERNO_DIR,"CHANGELOG.md"));
|
|
67
|
+
|
|
68
|
+
if(!contract||!capabilities){
|
|
69
|
+
console.error(red(" ✘ Missing contract.json or capabilities.json\n"));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let state = loadState();
|
|
74
|
+
if(resetFlag){ state={}; console.log(yellow(" ⚠ State reset\n")); }
|
|
75
|
+
if(intent) { state.intent=intent; state.intentUpdated=new Date().toISOString(); console.log(green(' ✔ Intent saved: "'+intent+'"')); }
|
|
76
|
+
if(working) { state.working=working; state.workingUpdated=new Date().toISOString(); console.log(green(' ✔ Working on: "'+working+'"')); }
|
|
77
|
+
if(decision) { if(!state.decisions) state.decisions=[]; state.decisions.push({text:decision,date:new Date().toISOString()}); console.log(green(' ✔ Decision recorded: "'+decision+'"')); }
|
|
78
|
+
if(intent||working||decision) saveState(state);
|
|
79
|
+
|
|
80
|
+
const capList = capabilities.capabilities||[];
|
|
81
|
+
const allInSync = capList.length===(contract.capabilities||[]).length;
|
|
82
|
+
const recent = parseChangelog(changelog,3);
|
|
83
|
+
const version = String(contract.policyVersion).replace(/^v/i,"");
|
|
84
|
+
const now = new Date().toLocaleDateString("en-GB",{day:"2-digit",month:"short",year:"numeric"});
|
|
85
|
+
const syncBadge = allInSync?"✓ validated":"⚠ out of sync";
|
|
86
|
+
const implementTask = state.intent || "describe the exact task to implement";
|
|
87
|
+
const implementInput = { task: implementTask, contract, caps: capabilities, scenarios: [], state };
|
|
88
|
+
const cursorPrompt = buildCursorImplementPrompt(implementInput);
|
|
89
|
+
const genericPrompt = buildGenericImplementPrompt(implementInput);
|
|
90
|
+
|
|
91
|
+
const capLines = capList.map(c=>"- **"+c.id+"** — "+c.title).join("\n");
|
|
92
|
+
const chgLines = recent.length>0 ? recent.map(e=>"### "+e.title+"\n"+e.items.map(i=>" - "+i).join("\n")).join("\n\n") : "_No recent changes_";
|
|
93
|
+
const intentLine = state.intent ? state.intent+" _("+fmtDate(state.intentUpdated)+")_" : "_Not set — run: infernoflow context --intent \"...\"_";
|
|
94
|
+
const workingLine = state.working ? state.working+" _("+fmtDate(state.workingUpdated)+")_" : "_Not set — run: infernoflow context --working \"...\"_";
|
|
95
|
+
const decLines = state.decisions&&state.decisions.length>0 ? state.decisions.slice(-5).map(d=>"- "+d.text+" _("+fmtDate(d.date)+")_").join("\n") : "_No decisions recorded_";
|
|
96
|
+
|
|
97
|
+
const md = [
|
|
98
|
+
"# Project Context — "+contract.policyId+" v"+version,
|
|
99
|
+
"> Generated by infernoflow | "+now+" | "+syncBadge,
|
|
100
|
+
"","---","",
|
|
101
|
+
"## What this system does","",capLines,"","---","",
|
|
102
|
+
"## Recent changes","",chgLines,"","---","",
|
|
103
|
+
"## Current state","",
|
|
104
|
+
"- **Capabilities:** "+capList.length,
|
|
105
|
+
"- **Version:** v"+version,
|
|
106
|
+
"- **Sync:** "+syncBadge,
|
|
107
|
+
"","---","",
|
|
108
|
+
"## What I am working on right now","",workingLine,"","---","",
|
|
109
|
+
"## Intent — what I want to build next","",intentLine,"","---","",
|
|
110
|
+
"## Decisions & notes","",decLines,"","---",
|
|
111
|
+
"",
|
|
112
|
+
"## Implementation Prompt Seed","",
|
|
113
|
+
"Use this to start coding immediately with an agent:","",
|
|
114
|
+
"```bash",
|
|
115
|
+
`infernoflow implement "${implementTask}" --mode both`,
|
|
116
|
+
"```",
|
|
117
|
+
"",
|
|
118
|
+
"### Cursor Agent Prompt","",
|
|
119
|
+
"```text",
|
|
120
|
+
cursorPrompt,
|
|
121
|
+
"```",
|
|
122
|
+
"",
|
|
123
|
+
"### Generic Agent Prompt","",
|
|
124
|
+
"```text",
|
|
125
|
+
genericPrompt,
|
|
126
|
+
"```",
|
|
127
|
+
"",
|
|
128
|
+
"---",
|
|
129
|
+
"_Paste this block at the start of any new AI session._"
|
|
130
|
+
].join("\n");
|
|
131
|
+
|
|
132
|
+
if(!showOnly){ fs.writeFileSync(CONTEXT_FILE,md,"utf8"); console.log(green("\n ✔ Context written → "+CONTEXT_FILE)); }
|
|
133
|
+
|
|
134
|
+
if(copyFlag){
|
|
135
|
+
const ok=copyToClipboard(md);
|
|
136
|
+
console.log(ok ? green(" ✔ Copied to clipboard — paste with Ctrl+V") : yellow(" ⚠ Clipboard copy failed — open inferno/CONTEXT.md manually"));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (cursorFlag) {
|
|
140
|
+
fs.writeFileSync(".cursorrules", md, "utf8");
|
|
141
|
+
console.log(green(" ✔ Written to .cursorrules — Cursor loads this automatically"));
|
|
142
|
+
}
|
|
143
|
+
if (copilotFlag) {
|
|
144
|
+
if (!fs.existsSync(".github")) fs.mkdirSync(".github");
|
|
145
|
+
fs.writeFileSync(".github/copilot-instructions.md", md, "utf8");
|
|
146
|
+
console.log(green(" ✔ Written to .github/copilot-instructions.md — Copilot loads this automatically"));
|
|
147
|
+
}
|
|
148
|
+
console.log("\n "+bold("Context Summary"));
|
|
149
|
+
console.log(" "+"─".repeat(50));
|
|
150
|
+
console.log(" Project "+contract.policyId+" — v"+version);
|
|
151
|
+
console.log(" Capabilities "+capList.length+" registered");
|
|
152
|
+
console.log(" Sync "+(allInSync?green("✓ in sync"):yellow("⚠ check needed")));
|
|
153
|
+
console.log(" Working on "+(state.working?cyan(state.working):gray("not set")));
|
|
154
|
+
console.log(" Intent "+(state.intent ?cyan(state.intent) :gray("not set")));
|
|
155
|
+
console.log(" Decisions "+(state.decisions?state.decisions.length:0)+" recorded\n");
|
|
156
|
+
console.log(" "+bold("Implementation Prompt"));
|
|
157
|
+
console.log(" "+cyan("→")+" Run "+cyan(`infernoflow implement "${implementTask}" --mode both`)+"\n");
|
|
158
|
+
|
|
159
|
+
if(copyFlag){
|
|
160
|
+
console.log(" "+bold("Ready to use:"));
|
|
161
|
+
console.log(" "+cyan("→")+" Paste into Claude / Cursor / Copilot with "+cyan("Ctrl+V")+"\n");
|
|
162
|
+
} else {
|
|
163
|
+
console.log(" "+bold("Ready to use:"));
|
|
164
|
+
console.log(" "+cyan("1.")+" Open "+cyan("inferno/CONTEXT.md"));
|
|
165
|
+
console.log(" "+cyan("2.")+" Copy everything");
|
|
166
|
+
console.log(" "+cyan("3.")+" Paste at the start of your next AI session");
|
|
167
|
+
console.log(" "+gray(" tip: use --copy to skip steps 1-2 automatically")+"\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Watch mode ────────────────────────────────────────────────────────────
|
|
171
|
+
if (watchFlag) {
|
|
172
|
+
const modeLabel = autoPush ? "auto-push" : autoCommit ? "auto-commit" : "watch";
|
|
173
|
+
console.log(" " + cyan("👁 Watch mode active") + gray(
|
|
174
|
+
` — polling every ${watchInterval / 1000}s` +
|
|
175
|
+
(autoPush ? " · will commit + push on change" : autoCommit ? " · will commit on change" : "")
|
|
176
|
+
));
|
|
177
|
+
console.log(" " + gray("Press Ctrl+C to stop\n"));
|
|
178
|
+
|
|
179
|
+
let lastChangedFiles = "";
|
|
180
|
+
let lastCommittedContent = null;
|
|
181
|
+
|
|
182
|
+
// ── git helpers ──────────────────────────────────────────────────────
|
|
183
|
+
function gitRun(cmd) {
|
|
184
|
+
try {
|
|
185
|
+
execSync(cmd, { cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
186
|
+
return true;
|
|
187
|
+
} catch { return false; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function gitIsCleanFor(filePath) {
|
|
191
|
+
// Returns true if the file has no staged/unstaged changes (nothing to commit)
|
|
192
|
+
try {
|
|
193
|
+
const out = execSync(`git status --porcelain "${filePath}"`, {
|
|
194
|
+
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
|
|
195
|
+
}).trim();
|
|
196
|
+
return out === "";
|
|
197
|
+
} catch { return true; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function commitContext(contextPath, affectedCaps, changedCount) {
|
|
201
|
+
const caps = affectedCaps.length > 0 ? affectedCaps.slice(0, 3).join(", ") : `${changedCount} files`;
|
|
202
|
+
const msg = `chore: update context [${caps}]`;
|
|
203
|
+
const staged = gitRun(`git add "${contextPath}"`);
|
|
204
|
+
if (!staged) return { ok: false, reason: "git add failed" };
|
|
205
|
+
if (gitIsCleanFor(contextPath)) return { ok: false, reason: "nothing to commit" };
|
|
206
|
+
const committed = gitRun(`git commit -m "${msg}"`);
|
|
207
|
+
if (!committed) return { ok: false, reason: "git commit failed (lock?)" };
|
|
208
|
+
return { ok: true, msg };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pushContext() {
|
|
212
|
+
const ok = gitRun("git push");
|
|
213
|
+
return ok;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── poll loop ────────────────────────────────────────────────────────
|
|
217
|
+
const poll = async () => {
|
|
218
|
+
try {
|
|
219
|
+
const cwd = process.cwd();
|
|
220
|
+
const drift = detectDrift(cwd, { sinceCommits: 1 });
|
|
221
|
+
const changedKey = drift.changedFiles.sort().join("|");
|
|
222
|
+
|
|
223
|
+
if (changedKey === lastChangedFiles) return;
|
|
224
|
+
lastChangedFiles = changedKey;
|
|
225
|
+
if (drift.changedFiles.length === 0) return;
|
|
226
|
+
|
|
227
|
+
// Update "working" field
|
|
228
|
+
const affected = drift.affectedCapabilities.map(c => c.id);
|
|
229
|
+
const newWorking = affected.length > 0
|
|
230
|
+
? `Working on: ${affected.join(", ")} (${drift.changedFiles.length} files changed)`
|
|
231
|
+
: `${drift.changedFiles.length} files changed — no capability match yet`;
|
|
232
|
+
|
|
233
|
+
const currentState = loadState();
|
|
234
|
+
if (currentState.working !== newWorking) {
|
|
235
|
+
currentState.working = newWorking;
|
|
236
|
+
currentState.workingUpdated = new Date().toISOString();
|
|
237
|
+
saveState(currentState);
|
|
238
|
+
|
|
239
|
+
// Regenerate CONTEXT.md silently
|
|
240
|
+
await contextCommand(args.filter(a => a !== "--watch" && a !== "--auto-commit" && a !== "--auto-push"));
|
|
241
|
+
|
|
242
|
+
const newContent = readFile(CONTEXT_FILE);
|
|
243
|
+
const ts = new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
244
|
+
|
|
245
|
+
process.stderr.write(
|
|
246
|
+
`\n ${green("✔")} [${ts}] Context updated — ${affected.length} capabilities affected\n` +
|
|
247
|
+
` ${gray(drift.changedFiles.slice(0, 3).join(", ") + (drift.changedFiles.length > 3 ? ` +${drift.changedFiles.length - 3} more` : ""))}\n`
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Auto-commit if enabled and content actually changed
|
|
251
|
+
if (autoCommit && newContent !== lastCommittedContent) {
|
|
252
|
+
lastCommittedContent = newContent;
|
|
253
|
+
const result = commitContext(CONTEXT_FILE, affected, drift.changedFiles.length);
|
|
254
|
+
if (result.ok) {
|
|
255
|
+
process.stderr.write(` ${green("✔")} Committed: ${gray(result.msg)}\n`);
|
|
256
|
+
if (autoPush) {
|
|
257
|
+
const pushed = pushContext();
|
|
258
|
+
process.stderr.write(
|
|
259
|
+
pushed
|
|
260
|
+
? ` ${green("✔")} Pushed to origin\n`
|
|
261
|
+
: ` ${yellow("⚠")} Push failed — will retry next change\n`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
process.stderr.write(` ${yellow("⚠")} Commit skipped: ${gray(result.reason)}\n`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// Silent — watch mode never crashes
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Poll immediately then on interval
|
|
275
|
+
await poll();
|
|
276
|
+
const timer = setInterval(poll, watchInterval);
|
|
277
|
+
|
|
278
|
+
process.on("SIGINT", () => {
|
|
279
|
+
clearInterval(timer);
|
|
280
|
+
process.stderr.write("\n " + gray("Watch stopped.\n\n"));
|
|
281
|
+
process.exit(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Prevent Node from exiting
|
|
285
|
+
await new Promise(() => {});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -1,5 +1,274 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow diff
|
|
3
|
+
*
|
|
4
|
+
* Compare capabilities between the current working tree and a git ref.
|
|
5
|
+
* Defaults to the most recent git tag; falls back to HEAD~1 if no tags exist.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* infernoflow diff # vs last tag
|
|
9
|
+
* infernoflow diff --ref v0.10.18 # vs specific tag / commit
|
|
10
|
+
* infernoflow diff --ref HEAD~5 # vs 5 commits ago
|
|
11
|
+
* infernoflow diff --json # machine-readable output
|
|
12
|
+
* infernoflow diff --summary # one-liner count only
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { header, ok, fail, warn, info, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
|
|
19
|
+
|
|
20
|
+
// ── git helpers ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function capture(cmd, cwd) {
|
|
23
|
+
try {
|
|
24
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lastTag(cwd) {
|
|
31
|
+
// Try to find the most recent reachable tag
|
|
32
|
+
const tag = capture("git describe --tags --abbrev=0", cwd);
|
|
33
|
+
if (tag) return tag;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fileAtRef(ref, relPath, cwd) {
|
|
38
|
+
// Returns file content at a git ref, or null if not found
|
|
39
|
+
const content = capture(`git show "${ref}:${relPath}"`, cwd);
|
|
40
|
+
return content;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function currentBranch(cwd) {
|
|
44
|
+
return capture("git rev-parse --abbrev-ref HEAD", cwd) || "HEAD";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function refDescription(cwd, ref) {
|
|
48
|
+
// Human label: "v0.10.18 (2026-04-10)" or "HEAD~1"
|
|
49
|
+
const date = capture(`git log -1 --format=%ci "${ref}"`, cwd);
|
|
50
|
+
const short = date ? date.slice(0, 10) : null;
|
|
51
|
+
return short ? `${ref} ${gray("(" + short + ")")}` : ref;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── capability helpers ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function parseCaps(jsonText) {
|
|
57
|
+
// Accepts capabilities.json OR contract.json — both have a "capabilities" array
|
|
58
|
+
if (!jsonText) return null;
|
|
59
|
+
try {
|
|
60
|
+
const obj = JSON.parse(jsonText);
|
|
61
|
+
const raw = obj.capabilities || [];
|
|
62
|
+
// capabilities.json: array of objects { id, title, ... }
|
|
63
|
+
// contract.json: array of id strings
|
|
64
|
+
return raw.map(c => {
|
|
65
|
+
if (typeof c === "string") return { id: c, title: c };
|
|
66
|
+
return { id: c.id || c, title: c.title || c.id || String(c), since: c.since, status: c.status };
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadCapsAtRef(ref, infernoRelDir, cwd) {
|
|
74
|
+
// Try capabilities.json first, fall back to contract.json
|
|
75
|
+
const capsJson = fileAtRef(ref, `${infernoRelDir}/capabilities.json`, cwd);
|
|
76
|
+
if (capsJson) return parseCaps(capsJson);
|
|
77
|
+
const contractJson = fileAtRef(ref, `${infernoRelDir}/contract.json`, cwd);
|
|
78
|
+
return parseCaps(contractJson);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadCapsFromDisk(infernoDir) {
|
|
82
|
+
const capsPath = path.join(infernoDir, "capabilities.json");
|
|
83
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
84
|
+
if (fs.existsSync(capsPath)) {
|
|
85
|
+
return parseCaps(fs.readFileSync(capsPath, "utf8"));
|
|
86
|
+
}
|
|
87
|
+
if (fs.existsSync(contractPath)) {
|
|
88
|
+
return parseCaps(fs.readFileSync(contractPath, "utf8"));
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── diff logic ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function diffCaps(before, after) {
|
|
96
|
+
const beforeMap = new Map(before.map(c => [c.id, c]));
|
|
97
|
+
const afterMap = new Map(after.map(c => [c.id, c]));
|
|
98
|
+
|
|
99
|
+
const added = after.filter(c => !beforeMap.has(c.id));
|
|
100
|
+
const removed = before.filter(c => !afterMap.has(c.id));
|
|
101
|
+
|
|
102
|
+
const changed = [];
|
|
103
|
+
for (const c of after) {
|
|
104
|
+
const old = beforeMap.get(c.id);
|
|
105
|
+
if (!old) continue;
|
|
106
|
+
const changes = [];
|
|
107
|
+
if (old.title !== c.title) changes.push({ field: "title", from: old.title, to: c.title });
|
|
108
|
+
if (old.status !== c.status && (old.status || c.status)) changes.push({ field: "status", from: old.status || "—", to: c.status || "—" });
|
|
109
|
+
if (changes.length) changed.push({ id: c.id, changes });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { added, removed, changed };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── rendering ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function renderAdded(caps) {
|
|
118
|
+
if (!caps.length) return;
|
|
119
|
+
console.log(`\n ${bold(green("+ Added"))} ${gray("(" + caps.length + ")")}`);
|
|
120
|
+
for (const c of caps) {
|
|
121
|
+
const since = c.since ? gray(" since " + c.since) : "";
|
|
122
|
+
console.log(` ${green("+")} ${bold(c.id)} ${gray(c.title)}${since}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderRemoved(caps) {
|
|
127
|
+
if (!caps.length) return;
|
|
128
|
+
console.log(`\n ${bold(red("- Removed"))} ${gray("(" + caps.length + ")")}`);
|
|
129
|
+
for (const c of caps) {
|
|
130
|
+
console.log(` ${red("-")} ${bold(c.id)} ${gray(c.title)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderChanged(items) {
|
|
135
|
+
if (!items.length) return;
|
|
136
|
+
console.log(`\n ${bold(yellow("~ Changed"))} ${gray("(" + items.length + ")")}`);
|
|
137
|
+
for (const item of items) {
|
|
138
|
+
console.log(` ${yellow("~")} ${bold(item.id)}`);
|
|
139
|
+
for (const ch of item.changes) {
|
|
140
|
+
console.log(` ${gray(ch.field + ":")} ${red(ch.from)} → ${green(ch.to)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderUnchanged(count) {
|
|
146
|
+
if (count === 0) return;
|
|
147
|
+
console.log(`\n ${gray(" Unchanged " + count)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderSummaryLine(result, refLabel) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (result.added.length) parts.push(green("+" + result.added.length + " added"));
|
|
153
|
+
if (result.removed.length) parts.push(red("-" + result.removed.length + " removed"));
|
|
154
|
+
if (result.changed.length) parts.push(yellow("~" + result.changed.length + " changed"));
|
|
155
|
+
if (!parts.length) parts.push(gray("no changes"));
|
|
156
|
+
console.log(` ${parts.join(" ")} ${gray("vs " + refLabel)}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export async function diffCommand(rawArgs) {
|
|
162
|
+
const args = rawArgs.slice(1);
|
|
163
|
+
|
|
164
|
+
const asJson = args.includes("--json");
|
|
165
|
+
const summary = args.includes("--summary");
|
|
166
|
+
|
|
167
|
+
const refIdx = args.indexOf("--ref");
|
|
168
|
+
let ref = refIdx !== -1 ? args[refIdx + 1] : null;
|
|
169
|
+
|
|
170
|
+
const cwd = process.cwd();
|
|
171
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
172
|
+
const infernoRelDir = "inferno"; // relative for git show
|
|
173
|
+
|
|
174
|
+
if (!asJson) header("diff");
|
|
175
|
+
|
|
176
|
+
// ── Validate inferno/ exists ─────────────────────────────────────────────
|
|
177
|
+
if (!fs.existsSync(infernoDir)) {
|
|
178
|
+
if (asJson) {
|
|
179
|
+
console.log(JSON.stringify({ ok: false, error: "inferno_not_found" }));
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
fail("inferno/ not found", "Run: infernoflow init");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Resolve ref ──────────────────────────────────────────────────────────
|
|
187
|
+
if (!ref) {
|
|
188
|
+
ref = lastTag(cwd);
|
|
189
|
+
if (!ref) {
|
|
190
|
+
// No tags — fall back to HEAD~1
|
|
191
|
+
const parentExists = capture("git rev-parse HEAD~1", cwd);
|
|
192
|
+
if (parentExists) {
|
|
193
|
+
ref = "HEAD~1";
|
|
194
|
+
} else {
|
|
195
|
+
if (asJson) {
|
|
196
|
+
console.log(JSON.stringify({ ok: false, error: "no_ref", hint: "No git tags found and no parent commit. Use --ref <commit>" }));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
fail("No git tags found", "Create a tag first: git tag v0.1.0 or use --ref <commit>");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Load capabilities ────────────────────────────────────────────────────
|
|
206
|
+
const current = loadCapsFromDisk(infernoDir);
|
|
207
|
+
if (!current) {
|
|
208
|
+
if (asJson) {
|
|
209
|
+
console.log(JSON.stringify({ ok: false, error: "no_capabilities_found" }));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
fail("No capabilities.json or contract.json found in inferno/");
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const previous = loadCapsAtRef(ref, infernoRelDir, cwd);
|
|
217
|
+
if (!previous) {
|
|
218
|
+
if (asJson) {
|
|
219
|
+
console.log(JSON.stringify({ ok: false, error: "ref_not_found", ref, hint: "Does inferno/capabilities.json exist at that ref?" }));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
fail(`Could not read capabilities at ${ref}`, "The inferno/ directory may not exist at that ref");
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Compute diff ─────────────────────────────────────────────────────────
|
|
227
|
+
const result = diffCaps(previous, current);
|
|
228
|
+
const unchanged = current.length - result.added.length - result.changed.length;
|
|
229
|
+
|
|
230
|
+
// ── JSON output ──────────────────────────────────────────────────────────
|
|
231
|
+
if (asJson) {
|
|
232
|
+
console.log(JSON.stringify({
|
|
233
|
+
ok: true,
|
|
234
|
+
ref,
|
|
235
|
+
current: current.length,
|
|
236
|
+
previous: previous.length,
|
|
237
|
+
added: result.added,
|
|
238
|
+
removed: result.removed,
|
|
239
|
+
changed: result.changed,
|
|
240
|
+
unchanged,
|
|
241
|
+
}, null, 2));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Human output ─────────────────────────────────────────────────────────
|
|
246
|
+
const refLabel = refDescription(cwd, ref);
|
|
247
|
+
console.log();
|
|
248
|
+
console.log(` Comparing ${bold(cyan("current"))} vs ${bold(refLabel)}`);
|
|
249
|
+
console.log(` ${gray(current.length + " capabilities now / " + previous.length + " before")}`);
|
|
250
|
+
|
|
251
|
+
if (summary) {
|
|
252
|
+
renderSummaryLine(result, ref);
|
|
253
|
+
console.log();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const hasAny = result.added.length || result.removed.length || result.changed.length;
|
|
258
|
+
|
|
259
|
+
if (!hasAny) {
|
|
260
|
+
console.log();
|
|
261
|
+
ok("No capability changes since " + ref);
|
|
262
|
+
console.log();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
renderAdded(result.added);
|
|
267
|
+
renderRemoved(result.removed);
|
|
268
|
+
renderChanged(result.changed);
|
|
269
|
+
renderUnchanged(unchanged);
|
|
270
|
+
|
|
271
|
+
console.log();
|
|
272
|
+
renderSummaryLine(result, ref);
|
|
273
|
+
console.log();
|
|
274
|
+
}
|