infernoflow 0.43.12 → 0.44.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.
Files changed (49) hide show
  1. package/dist/bin/infernoflow.mjs +30 -33
  2. package/dist/lib/amp/io.mjs +8 -8
  3. package/dist/lib/cleanTree.mjs +12 -0
  4. package/dist/lib/commands/ai.mjs +2 -2
  5. package/dist/lib/commands/amp.mjs +4 -4
  6. package/dist/lib/commands/ask.mjs +2 -2
  7. package/dist/lib/commands/context.mjs +18 -18
  8. package/dist/lib/commands/doctor.mjs +2 -3
  9. package/dist/lib/commands/init.mjs +31 -32
  10. package/dist/lib/commands/log.mjs +13 -19
  11. package/dist/lib/commands/recap.mjs +3 -3
  12. package/dist/lib/commands/refresh.mjs +5 -0
  13. package/dist/lib/commands/status.mjs +6 -7
  14. package/dist/lib/commands/switch.mjs +5 -5
  15. package/dist/lib/commands/sync.mjs +41 -0
  16. package/dist/lib/git/branch.mjs +2 -0
  17. package/dist/lib/projectRoot.mjs +1 -0
  18. package/dist/lib/ruleFiles.mjs +9 -8
  19. package/dist/lib/upgradeCheck.mjs +1 -1
  20. package/dist/templates/cursor/inferno-mcp-server.mjs +170 -325
  21. package/package.json +13 -5
  22. package/dist/lib/commands/changelog.mjs +0 -21
  23. package/dist/lib/commands/ci.mjs +0 -3
  24. package/dist/lib/commands/claudeMd.mjs +0 -116
  25. package/dist/lib/commands/coverage.mjs +0 -2
  26. package/dist/lib/commands/demo.mjs +0 -113
  27. package/dist/lib/commands/diff.mjs +0 -5
  28. package/dist/lib/commands/explain.mjs +0 -8
  29. package/dist/lib/commands/feedback.mjs +0 -12
  30. package/dist/lib/commands/graph.mjs +0 -76
  31. package/dist/lib/commands/impact.mjs +0 -2
  32. package/dist/lib/commands/implement.mjs +0 -7
  33. package/dist/lib/commands/monorepo.mjs +0 -4
  34. package/dist/lib/commands/notify.mjs +0 -4
  35. package/dist/lib/commands/prImpact.mjs +0 -2
  36. package/dist/lib/commands/publish.mjs +0 -21
  37. package/dist/lib/commands/review.mjs +0 -24
  38. package/dist/lib/commands/run.mjs +0 -10
  39. package/dist/lib/commands/scaffold.mjs +0 -124
  40. package/dist/lib/commands/scan.mjs +0 -42
  41. package/dist/lib/commands/stability.mjs +0 -2
  42. package/dist/lib/commands/stats.mjs +0 -4
  43. package/dist/lib/commands/suggest.mjs +0 -62
  44. package/dist/lib/commands/syncAuto.mjs +0 -1
  45. package/dist/lib/commands/test.mjs +0 -6
  46. package/dist/lib/commands/theme.mjs +0 -18
  47. package/dist/lib/commands/upgrade.mjs +0 -20
  48. package/dist/lib/commands/watch.mjs +0 -7
  49. package/dist/lib/commands/why.mjs +0 -4
@@ -2,19 +2,127 @@ import { execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
+ import { createRequire } from "node:module";
6
+ import { pathToFileURL, fileURLToPath } from "node:url";
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ /**
11
+ * Find the root of the infernoflow package, regardless of how this file was
12
+ * launched. Tried in order:
13
+ * 1. Walk UP from this file's own location looking for a package.json with
14
+ * name=infernoflow. This works whether the template is run from inside
15
+ * infernoflow-pkg/, from a project's .cursor/ copy (via require.resolve),
16
+ * or from a test temp dir.
17
+ * 2. require.resolve("infernoflow/package.json") — works if infernoflow is
18
+ * in node_modules of the CWD or one of its parents.
19
+ * Returns null if neither finds infernoflow.
20
+ */
21
+ function findInfernoflowRoot() {
22
+ let dir = path.dirname(fileURLToPath(import.meta.url));
23
+ while (true) {
24
+ const pj = path.join(dir, "package.json");
25
+ if (fs.existsSync(pj)) {
26
+ try {
27
+ const meta = JSON.parse(fs.readFileSync(pj, "utf8"));
28
+ if (meta && meta.name === "infernoflow") return dir;
29
+ } catch {}
30
+ }
31
+ const parent = path.dirname(dir);
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ try {
36
+ return path.dirname(require.resolve("infernoflow/package.json"));
37
+ } catch {}
38
+ return null;
39
+ }
40
+
41
+ const INFERNOFLOW_ROOT = findInfernoflowRoot();
5
42
 
6
43
  function send(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); }
7
44
  function sendResult(id, result) { send({ jsonrpc: "2.0", id, result }); }
8
45
  function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
9
46
 
47
+ // ── Infernoflow resolution ─────────────────────────────────────────────────
48
+ // Avoid `npx infernoflow`. npx may resolve to a different (registry-fetched)
49
+ // version than what the user installed, which silently breaks subcommands.
50
+ // Resolve a deterministic location once at startup, in priority order:
51
+ // 1. infernoflow installed in the project's node_modules (npm i / npm link)
52
+ // 2. `where`/`which` the global binary
53
+ // Returns null if nothing is found; runCmd surfaces a clear error in that case.
54
+ function resolveInfernoflowBin() {
55
+ if (INFERNOFLOW_ROOT) {
56
+ for (const c of [
57
+ path.join(INFERNOFLOW_ROOT, "dist", "bin", "infernoflow.mjs"),
58
+ path.join(INFERNOFLOW_ROOT, "bin", "infernoflow.mjs"),
59
+ ]) if (fs.existsSync(c)) return c;
60
+ }
61
+ try {
62
+ const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
63
+ const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
64
+ const first = out.split(/\r?\n/)[0];
65
+ if (first && fs.existsSync(first)) return first;
66
+ } catch {}
67
+ return null;
68
+ }
69
+
70
+ const INFERNOFLOW_BIN = resolveInfernoflowBin();
71
+
72
+ // In-process AMP I/O loader. When available, amp_write / amp_read bypass the
73
+ // CLI entirely — no subprocess, no version skew, no flag-mapping field loss.
74
+ // Falls back to shell-out via runCmd() if the AMP layer can't be loaded.
75
+ let ampIo = null;
76
+ let refreshRuleFiles = null;
77
+ if (INFERNOFLOW_ROOT) {
78
+ try {
79
+ for (const c of [
80
+ path.join(INFERNOFLOW_ROOT, "lib", "amp", "io.mjs"),
81
+ path.join(INFERNOFLOW_ROOT, "dist", "lib", "amp", "io.mjs"),
82
+ ]) {
83
+ if (fs.existsSync(c)) { ampIo = await import(pathToFileURL(c).href); break; }
84
+ }
85
+ for (const c of [
86
+ path.join(INFERNOFLOW_ROOT, "lib", "ruleFiles.mjs"),
87
+ path.join(INFERNOFLOW_ROOT, "dist", "lib", "ruleFiles.mjs"),
88
+ ]) {
89
+ if (fs.existsSync(c)) { refreshRuleFiles = (await import(pathToFileURL(c).href)).refreshRuleFilesFromMemory; break; }
90
+ }
91
+ } catch { /* swallow — fallback path handles it */ }
92
+ }
93
+
94
+ // ── Clean-tree policy: regenerate rule files ONCE at boot ──────────────────
95
+ // Historically, every amp_write rewrote CLAUDE.md / .cursorrules. That
96
+ // dirtied tracked files dozens of times per session and blocked git
97
+ // checkout. Now we regenerate exactly once when the MCP server starts —
98
+ // enough for the next AI session to boot warm — and never again during
99
+ // the session. amp_read serves runtime queries; rule-file content is
100
+ // for cold-start injection only.
101
+ if (refreshRuleFiles) {
102
+ try { refreshRuleFiles(process.cwd()); } catch { /* non-fatal */ }
103
+ }
104
+
10
105
  /**
11
106
  * Run the infernoflow CLI. Returns either the stdout string OR a structured
12
107
  * error object so call sites can decide whether to surface it via JSON-RPC
13
108
  * sendError() instead of returning gibberish text to the agent.
14
109
  */
15
110
  function runCmd(args, env = {}) {
111
+ if (!INFERNOFLOW_BIN) {
112
+ return {
113
+ __error: true,
114
+ message: "infernoflow not installed — install it locally (`npm i infernoflow`) or globally (`npm i -g infernoflow`)",
115
+ stderr: "",
116
+ stdout: "",
117
+ status: 127,
118
+ };
119
+ }
16
120
  try {
17
- return execSync(`npx infernoflow ${args}`, {
121
+ const isMjs = INFERNOFLOW_BIN.toLowerCase().endsWith(".mjs");
122
+ const cmd = isMjs
123
+ ? `"${process.execPath}" "${INFERNOFLOW_BIN}" ${args}`
124
+ : `"${INFERNOFLOW_BIN}" ${args}`;
125
+ return execSync(cmd, {
18
126
  encoding: "utf8",
19
127
  cwd: process.cwd(),
20
128
  timeout: 30000,
@@ -36,26 +144,27 @@ function isCmdError(result) {
36
144
  return typeof result === "object" && result !== null && result.__error === true;
37
145
  }
38
146
 
147
+ // ── MCP tool surface after Phase 4 truth audit ────────────────────────────
148
+ // Mission: session memory. The off-mission contract-iteration tools
149
+ // (infernoflow_run / _apply / _implement / _review / _scan_ui) were cut —
150
+ // they duplicated CLI commands that are themselves gone, and the fragile
151
+ // env-var subprocess handoff for _apply was a recurring bug source.
152
+ // What remains is everything an AI agent needs to capture, query, and
153
+ // hand off memory between sessions, plus the two read-only contract
154
+ // helpers that pair cleanly with the kept CLI surface.
39
155
  const TOOLS = [
40
- { name: "infernoflow_run", description: "Generate an infernoflow task prompt. Returns the prompt — respond to it with JSON, then call infernoflow_apply.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to build" } }, required: ["task"] } },
41
- { name: "infernoflow_apply", description: "Apply an infernoflow suggestion JSON returned by the agent. Call this after responding to infernoflow_run.", inputSchema: { type: "object", properties: { json: { type: "string", description: "The JSON suggestion from the agent" } }, required: ["json"] } },
42
- { name: "infernoflow_check", description: "Validate infernoflow contract and capabilities", inputSchema: { type: "object", properties: {} } },
43
- { name: "infernoflow_status", description: "Show contract health at a glance", inputSchema: { type: "object", properties: {} } },
44
- { name: "infernoflow_context", description: "Generate AI-ready context", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
45
- { name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes. Compares git-changed files to the capability registry and returns suggestions for contract updates.", inputSchema: { type: "object", properties: { sinceCommits: { type: "number", description: "How many commits back to check (default: 1)" } } } },
46
- { name: "infernoflow_implement", description: "Generate a structured code implementation prompt for a task. Uses the contract and stack context to produce step-by-step coding instructions for the agent.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to implement" }, mode: { type: "string", enum: ["cursor", "generic", "both"], description: "Prompt style (default: both)" } }, required: ["task"] } },
47
- { name: "infernoflow_scan_ui", description: "Scan components and styles for UI changes vs the stored contract. Returns new/changed components, design token changes, and suggested contract updates.", inputSchema: { type: "object", properties: {} } },
48
- { name: "infernoflow_review", description: "Pre-merge capability drift check. Compares all changed files in the current branch against the capability contract and reports drift risk before you merge.", inputSchema: { type: "object", properties: { branch: { type: "string", description: "Branch to compare against (default: main)" } } } },
49
-
50
- // ── AMP-spec MCP tools (per docs/protocol/PROTOCOL.md §7.3) ────────────────
51
- // These are the standard names any AMP-compliant MCP server should expose.
52
- // They're thin wrappers around the existing infernoflow_* tools so AMP-only
53
- // clients don't need to know the infernoflow_ vendor prefix.
156
+ // ── AMP-spec memory tools (the product) ──────────────────────────────────
54
157
  { name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string" }, limit: { type: "number" } } } },
55
158
  { name: "amp_write", description: "AMP: log a new entry. Required: type + msg. Optional: file, line, tags.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string" }, file: { type: "string" }, line: { type: "number" }, tags: { type: "array", items: { type: "string" } } }, required: ["type","msg"] } },
56
- { name: "amp_handoff", description: "AMP: generate the handoff document for the next AI session. format=markdown|json (default: markdown).", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["markdown","json"] } } } },
57
159
  { name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
160
+ { name: "amp_handoff", description: "AMP: generate the handoff document for the next AI session. format=markdown|json (default: markdown).", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["markdown","json"] } } } },
58
161
  { name: "amp_health", description: "AMP: get the session health score (0-100, A-F grade).", inputSchema: { type: "object", properties: {} } },
162
+
163
+ // ── Read-only contract helpers ───────────────────────────────────────────
164
+ { name: "infernoflow_status", description: "Show project memory + contract health at a glance.", inputSchema: { type: "object", properties: {} } },
165
+ { name: "infernoflow_check", description: "Validate the capability contract (read-only).", inputSchema: { type: "object", properties: {} } },
166
+ { name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
167
+ { name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "number", description: "How many commits back to check (default: 1)" } } } },
59
168
  ];
60
169
 
61
170
  // ── git drift detection (inline — no external imports in this template file) ─
@@ -173,305 +282,11 @@ function detectGitDrift(sinceCommits) {
173
282
  return lines.join("\n");
174
283
  }
175
284
 
176
- // ── infernoflow_scan_ui ────────────────────────────────────────────────────
177
- function scanUi() {
178
- const cwd = process.cwd();
179
- const infernoDir = path.join(cwd, "inferno");
180
- const contractPath = path.join(infernoDir, "contract.json");
181
- if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
182
-
183
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
184
- const storedUi = contract.ui || {};
185
-
186
- // Collect style + component files
187
- const styleExts = /\.(css|scss|sass|less|ts|tsx|js|jsx|html)$/;
188
- const SKIP = new Set(["node_modules", ".git", "dist", "build", ".angular", ".next", "vendor", "coverage"]);
189
- const files = [];
190
- const walk = (dir) => {
191
- try {
192
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
193
- const full = path.join(dir, entry.name);
194
- if (entry.isDirectory()) { if (!SKIP.has(entry.name)) walk(full); }
195
- else if (styleExts.test(entry.name) && !entry.name.includes(".min.") && !entry.name.endsWith(".map")) files.push(full);
196
- }
197
- } catch {}
198
- };
199
- for (const root of ["src", "app", "frontend", "components", "styles"]) {
200
- const p = path.join(cwd, root);
201
- if (fs.existsSync(p)) walk(p);
202
- }
203
-
204
- // Extract current components from TS/TSX files
205
- const currentComponents = new Set();
206
- const currentTokens = new Set();
207
-
208
- for (const f of files) {
209
- const text = fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
210
- // Components
211
- for (const m of text.matchAll(/@Component[\s\S]*?class\s+([A-Z][A-Za-z0-9_]*Component)/g)) currentComponents.add(m[1].replace(/Component$/, ""));
212
- for (const m of text.matchAll(/export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9_]*)/g)) currentComponents.add(m[1]);
213
- // Design tokens
214
- for (const m of text.matchAll(/--([a-zA-Z][a-zA-Z0-9_-]*)\s*:/g)) currentTokens.add(`--${m[1]}`);
215
- }
216
-
217
- const storedComponents = new Set(storedUi.components || []);
218
- const storedTokens = new Set(storedUi.designTokens || []);
219
-
220
- const newComponents = [...currentComponents].filter(c => !storedComponents.has(c));
221
- const removedComponents = [...storedComponents].filter(c => !currentComponents.has(c));
222
- const newTokens = [...currentTokens].filter(t => !storedTokens.has(t));
223
- const removedTokens = [...storedTokens].filter(t => !currentTokens.has(t));
224
-
225
- const lines = ["## infernoflow UI scan report", ""];
226
-
227
- if (!newComponents.length && !removedComponents.length && !newTokens.length && !removedTokens.length) {
228
- lines.push("✔ No UI changes detected since last scan.");
229
- return lines.join("\n");
230
- }
231
-
232
- if (newComponents.length) {
233
- lines.push(`### New components (${newComponents.length})`);
234
- newComponents.slice(0, 15).forEach(c => lines.push(` + ${c}`));
235
- lines.push("");
236
- }
237
- if (removedComponents.length) {
238
- lines.push(`### Removed components (${removedComponents.length})`);
239
- removedComponents.slice(0, 10).forEach(c => lines.push(` - ${c}`));
240
- lines.push("");
241
- }
242
- if (newTokens.length) {
243
- lines.push(`### New design tokens (${newTokens.length})`);
244
- newTokens.slice(0, 10).forEach(t => lines.push(` + ${t}`));
245
- lines.push("");
246
- }
247
- if (removedTokens.length) {
248
- lines.push(`### Removed design tokens (${removedTokens.length})`);
249
- removedTokens.slice(0, 10).forEach(t => lines.push(` - ${t}`));
250
- lines.push("");
251
- }
252
-
253
- lines.push("### Suggested action");
254
- if (newComponents.length) {
255
- const newCaps = newComponents.slice(0, 5).map(c => `View${c}`).join(", ");
256
- lines.push(`Consider adding these capabilities: ${newCaps}`);
257
- lines.push(`Call infernoflow_run with task "add UI capabilities for new components: ${newComponents.slice(0,3).join(", ")}" to update the contract.`);
258
- }
259
-
260
- return lines.join("\n");
261
- }
262
-
263
- // ── infernoflow_review ─────────────────────────────────────────────────────
264
- function reviewDrift(baseBranch) {
265
- const cwd = process.cwd();
266
- const infernoDir = path.join(cwd, "inferno");
267
- const contractPath = path.join(infernoDir, "contract.json");
268
- if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
269
-
270
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
271
-
272
- // Get changed files vs base branch
273
- const runGit = (cmd) => { try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 15_000 }); } catch { return ""; } };
274
-
275
- const diffOutput = runGit(`git diff --name-only ${baseBranch}...HEAD`);
276
- const changedFiles = diffOutput.split("\n").map(l => l.trim()).filter(Boolean);
277
-
278
- if (!changedFiles.length) return `No changes detected vs ${baseBranch}. Safe to merge.`;
279
-
280
- // Categorise changed files
281
- const infraFiles = changedFiles.filter(f => /\.(json|yaml|yml|env|config|lock)$/.test(f) || f.includes("inferno/"));
282
- const sourceFiles = changedFiles.filter(f => /\.(ts|tsx|js|jsx|mjs|cs|py|go|java)$/.test(f));
283
- const styleFiles = changedFiles.filter(f => /\.(css|scss|sass|less)$/.test(f));
284
- const contractChanged = changedFiles.some(f => f.startsWith("inferno/"));
285
-
286
- // Keyword-based drift detection on changed source files
287
- const HEURISTICS = [
288
- { kw: ["search"], id: "SearchItems" }, { kw: ["filter"], id: "FilterItems" },
289
- { kw: ["auth", "login", "logout"], id: "Authentication" },
290
- { kw: ["create", "add", "new"], id: "CreateItem" },
291
- { kw: ["update", "edit", "patch"], id: "UpdateItem" },
292
- { kw: ["delete", "remove"], id: "DeleteItem" },
293
- { kw: ["list", "read", "fetch", "get"], id: "ReadItems" },
294
- { kw: ["due", "deadline"], id: "SetDueDate" },
295
- { kw: ["priority"], id: "SetPriority" },
296
- { kw: ["complete", "toggle"], id: "ToggleComplete" },
297
- { kw: ["export", "download"], id: "ExportData" },
298
- { kw: ["import", "upload"], id: "ImportData" },
299
- { kw: ["notify", "notification", "email"], id: "SendNotification" },
300
- { kw: ["payment", "checkout", "stripe"], id: "ProcessPayment" },
301
- ];
302
-
303
- const capHits = new Map();
304
- const registeredCaps = new Set(contract.capabilities || []);
305
-
306
- for (const file of sourceFiles) {
307
- const lower = file.toLowerCase();
308
- for (const rule of HEURISTICS) {
309
- if (rule.kw.some(k => lower.includes(k))) {
310
- if (!capHits.has(rule.id)) capHits.set(rule.id, []);
311
- capHits.get(rule.id).push(file);
312
- }
313
- }
314
- }
315
-
316
- const newCapSignals = [...capHits.entries()].filter(([id]) => !registeredCaps.has(id));
317
- const existingCapSignals = [...capHits.entries()].filter(([id]) => registeredCaps.has(id));
318
-
319
- const lines = [
320
- `## infernoflow PR review — drift check vs \`${baseBranch}\``,
321
- `Changed files: ${changedFiles.length} | Source: ${sourceFiles.length} | Styles: ${styleFiles.length} | Infra: ${infraFiles.length}`,
322
- "",
323
- ];
324
-
325
- // Risk assessment
326
- let riskLevel = "LOW";
327
- if (newCapSignals.length > 0) riskLevel = "MEDIUM";
328
- if (newCapSignals.length >= 3 || (newCapSignals.length >= 1 && !contractChanged)) riskLevel = "HIGH";
329
-
330
- const riskEmoji = riskLevel === "HIGH" ? "🔴" : riskLevel === "MEDIUM" ? "🟡" : "🟢";
331
- lines.push(`### ${riskEmoji} Drift risk: ${riskLevel}`);
332
- lines.push("");
333
-
334
- if (contractChanged) {
335
- lines.push("✔ inferno/ contract files were updated in this PR — good practice.");
336
- lines.push("");
337
- } else if (sourceFiles.length > 0) {
338
- lines.push("⚠ Source files changed but inferno/ contract was NOT updated.");
339
- lines.push(" Consider running: infernoflow_run to check if capabilities need updating.");
340
- lines.push("");
341
- }
342
-
343
- if (newCapSignals.length > 0) {
344
- lines.push(`### Possible new capabilities (not in contract):`);
345
- for (const [id, files] of newCapSignals.slice(0, 6)) {
346
- lines.push(` - **${id}** — suggested by: ${files.slice(0,2).join(", ")}`);
347
- }
348
- lines.push("");
349
- lines.push(`Suggested action: call infernoflow_run with task "review new capabilities: ${newCapSignals.slice(0,3).map(([id])=>id).join(', ')}"`);
350
- lines.push("");
351
- }
352
-
353
- if (existingCapSignals.length > 0) {
354
- lines.push(`### Existing capabilities touched:`);
355
- for (const [id, files] of existingCapSignals.slice(0, 6)) {
356
- lines.push(` - **${id}** — ${files.slice(0,2).join(", ")}`);
357
- }
358
- lines.push("");
359
- }
360
-
361
- if (styleFiles.length > 0) {
362
- lines.push(`### Style changes (${styleFiles.length} files) — run infernoflow_scan_ui to check UI contract`);
363
- styleFiles.slice(0, 5).forEach(f => lines.push(` - ${f}`));
364
- lines.push("");
365
- }
366
-
367
- if (riskLevel === "LOW" && !newCapSignals.length) {
368
- lines.push("✔ No new capability signals detected. Safe to merge (run infernoflow_check as final gate).");
369
- }
370
-
371
- return lines.join("\n");
372
- }
373
-
374
- function buildImplementPrompt(task, mode) {
375
- const cwd = process.cwd();
376
- const infernoDir = path.join(cwd, "inferno");
377
- const contractPath = path.join(infernoDir, "contract.json");
378
- const capsPath = path.join(infernoDir, "capabilities.json");
379
- const profilePath = path.join(infernoDir, "developer-profile.json");
380
-
381
- if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
382
-
383
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
384
- const caps = fs.existsSync(capsPath) ? JSON.parse(fs.readFileSync(capsPath, "utf8")) : {};
385
- const profile = fs.existsSync(profilePath) ? JSON.parse(fs.readFileSync(profilePath, "utf8")) : {};
386
-
387
- const capList = (caps.capabilities || []).map(c => ` - ${c.id}: ${c.title || c.id}`).join("\n");
388
- const stack = profile.stack || {};
389
- const stackLine = [stack.framework, stack.language, stack.projectType].filter(Boolean).join(" / ") || "unknown";
390
- const namingStyle = profile.namingStyle || "PascalCase";
391
-
392
- const cursorPrompt = `## Cursor Agent Implementation Prompt
393
- Task: "${task}"
394
- Project: ${contract.policyId} (${stackLine})
395
- Naming convention: ${namingStyle}
396
-
397
- ### Current capabilities
398
- ${capList || " (none registered)"}
399
-
400
- ### Implementation instructions
401
- 1. Implement "${task}" following the existing code patterns in this project
402
- 2. Use ${namingStyle} for any new identifiers, matching the existing capability naming
403
- 3. Keep changes minimal — only touch files relevant to this task
404
- 4. After implementing, call \`infernoflow_run\` with task "${task}" to update the contract
405
- 5. Then call \`infernoflow_check\` to validate everything is in sync
406
-
407
- ### Definition of done
408
- - Feature works as described
409
- - Contract updated via infernoflow_run → infernoflow_apply
410
- - infernoflow_check passes`;
411
-
412
- const genericPrompt = `## Implementation Prompt
413
- Task: "${task}"
414
- Project: ${contract.policyId}
415
- Stack: ${stackLine}
416
- Capabilities already in contract: ${(contract.capabilities || []).join(", ")}
417
-
418
- Implement the task above. When done, run:
419
- infernoflow suggest "${task}"
420
- infernoflow check`;
421
-
422
- if (mode === "cursor") return cursorPrompt;
423
- if (mode === "generic") return genericPrompt;
424
- return cursorPrompt + "\n\n---\n\n" + genericPrompt;
425
- }
426
-
427
- function buildPrompt(task) {
428
- const infernoDir = path.join(process.cwd(), "inferno");
429
- const contractPath = path.join(infernoDir, "contract.json");
430
- const capsPath = path.join(infernoDir, "capabilities.json");
431
- if (!fs.existsSync(contractPath)) return null;
432
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
433
- const caps = fs.existsSync(capsPath) ? JSON.parse(fs.readFileSync(capsPath, "utf8")) : {};
434
- const capList = (caps.capabilities || []).map(c => ` - ${c.id}: ${c.title || c.id}`).join("\n");
435
- return `You are a developer assistant for the infernoflow CLI tool.
436
- Analyze this task and suggest updates to the infernoflow contract files.
437
-
438
- ## Current contract
439
- policyId: ${contract.policyId}
440
- policyVersion: ${contract.policyVersion}
441
- capabilities: [${(contract.capabilities || []).join(", ")}]
442
-
443
- ## Capabilities registry
444
- ${capList || " (none)"}
445
-
446
- ## Task
447
- "${task}"
448
-
449
- ## Instructions
450
- Respond with ONLY a valid JSON object:
451
- {
452
- "summary": "one-line summary of what changed",
453
- "newCapabilities": [{ "id": "PascalCase", "title": "Human readable title", "reason": "why this is new" }],
454
- "removedCapabilities": [],
455
- "updatedScenarios": [],
456
- "changelogEntry": "- Short description for CHANGELOG.md"
457
- }`;
458
- }
459
-
460
285
  function handleTool(id, name, input) {
461
286
  try {
462
287
  let text = "";
463
- if (name === "infernoflow_run") {
464
- const prompt = buildPrompt(input.task);
465
- if (!prompt) { sendError(id, -32000, "inferno/ not found — run infernoflow init first"); return; }
466
- const promptFile = path.join(process.cwd(), "inferno", "agent-prompt.md");
467
- fs.writeFileSync(promptFile, prompt, "utf8");
468
- text = `## infernoflow task: "${input.task}"\n\n${prompt}\n\n---\nRespond with the JSON, then call **infernoflow_apply** with your JSON string.`;
469
- } else if (name === "infernoflow_apply") {
470
- const responseFile = path.join(process.cwd(), "inferno", "agent-response.json");
471
- let json = input.json.trim().replace(/^```json?\n?/, "").replace(/\n?```$/, "");
472
- fs.writeFileSync(responseFile, json, "utf8");
473
- text = runCmd(`run "apply"`, { INFERNO_AGENT_RESPONSE_FILE: responseFile, INFERNO_AGENT_AVAILABLE: "1" });
474
- } else if (name === "infernoflow_check") {
288
+ // ── Read-only contract helpers ─────────────────────────────────────────
289
+ if (name === "infernoflow_check") {
475
290
  text = runCmd("check");
476
291
  } else if (name === "infernoflow_status") {
477
292
  text = runCmd("status");
@@ -482,14 +297,8 @@ function handleTool(id, name, input) {
482
297
  text = runCmd("context " + parts.join(" "));
483
298
  } else if (name === "infernoflow_git_drift") {
484
299
  text = detectGitDrift(input.sinceCommits || 1);
485
- } else if (name === "infernoflow_implement") {
486
- text = buildImplementPrompt(input.task, input.mode || "both");
487
- } else if (name === "infernoflow_scan_ui") {
488
- text = scanUi();
489
- } else if (name === "infernoflow_review") {
490
- text = reviewDrift(input.branch || "main");
491
-
492
- // ── AMP-spec aliases ───────────────────────────────────────────────────
300
+
301
+ // ── AMP-spec memory tools ──────────────────────────────────────────────
493
302
  } else if (name === "amp_read") {
494
303
  const args = [];
495
304
  if (input.query) args.push(JSON.stringify(input.query));
@@ -497,11 +306,47 @@ function handleTool(id, name, input) {
497
306
  if (input.limit) args.push("--limit", String(input.limit));
498
307
  text = runCmd("ask " + args.join(" "));
499
308
  } else if (name === "amp_write") {
500
- const t = (input.type || "note").replace(/[^a-z]/g, "");
501
- const m = JSON.stringify(input.msg || "");
502
- const extras = [];
503
- if (input.file) extras.push("--source", JSON.stringify(input.file));
504
- text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
309
+ // Prefer in-process write: no subprocess, no `npx` version skew, and
310
+ // file/line/tags reach disk unchanged. The CLI fallback below is only
311
+ // used when infernoflow's AMP layer can't be imported.
312
+ if (ampIo) {
313
+ const entry = {
314
+ ts: new Date().toISOString(),
315
+ type: input.type || "note",
316
+ summary: input.msg || "",
317
+ agent: process.env.INFERNOFLOW_AGENT
318
+ || (process.env.CLAUDE_CODE_SESSION ? "claude"
319
+ : process.env.CURSOR_SESSION ? "cursor"
320
+ : process.env.COPILOT_SESSION ? "copilot"
321
+ : "claude"),
322
+ };
323
+ if (input.file) entry.file = input.file;
324
+ if (input.line) entry.line = input.line;
325
+ if (input.tags && input.tags.length) entry.tags = input.tags;
326
+ try {
327
+ const written = ampIo.appendEntry(process.cwd(), entry);
328
+ // NOTE: rule-file refresh deliberately NOT called here — clean-tree
329
+ // policy regenerates them once at MCP boot only. Doing it on every
330
+ // write dirties tracked files and blocks `git checkout`. Within a
331
+ // session, the agent uses amp_read for fresh queries; rule files
332
+ // are for cold-start injection of the *next* session.
333
+ text = `✔ Logged [${written.type}] ${written.id}\n msg: ${written.msg}` +
334
+ (written.file ? `\n file: ${written.file}${written.line ? ":" + written.line : ""}` : "") +
335
+ (written.tags ? `\n tags: ${written.tags.join(", ")}` : "");
336
+ } catch (err) {
337
+ return sendError(id, -32000, `amp_write failed (in-process): ${err.message}`);
338
+ }
339
+ } else {
340
+ // Fallback: shell-out. Pass --file/--line/--tags through the CLI so
341
+ // they're not silently dropped like in the original implementation.
342
+ const t = (input.type || "note").replace(/[^a-z]/g, "");
343
+ const m = JSON.stringify(input.msg || "");
344
+ const extras = [];
345
+ if (input.file) extras.push("--file", JSON.stringify(input.file));
346
+ if (input.line) extras.push("--line", String(input.line));
347
+ if (input.tags && input.tags.length) extras.push("--tags", JSON.stringify(input.tags.join(",")));
348
+ text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
349
+ }
505
350
  } else if (name === "amp_handoff") {
506
351
  // switch writes a file; we read it back to return the content
507
352
  const switchResult = runCmd("switch");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.43.12",
4
- "description": "Persistent memory for AI coding sessions \u2014 captures what agents can't infer from code alone. Works with Copilot, Cursor, Claude, and Windsurf.",
3
+ "version": "0.44.0",
4
+ "description": "Persistent memory for AI coding sessions captures what agents can't infer from code alone. Works with Copilot, Cursor, Claude, and Windsurf.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "infernoflow": "dist/bin/infernoflow.mjs"
@@ -16,13 +16,17 @@
16
16
  "README.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs && node scripts/implement-smoke.mjs && node scripts/adopt-smoke.mjs && node scripts/pr-impact-smoke.mjs && node scripts/sync-smoke.mjs && node scripts/run-smoke.mjs",
19
+ "test": "tsc --noEmit && vitest run",
20
+ "test:watch": "vitest",
21
+ "test:vitest": "vitest run",
22
+ "test:smoke": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs",
23
+ "test:install": "cross-env RUN_INSTALL_TESTS=1 vitest run tests/tarball-install.test.mjs",
20
24
  "test:help": "node bin/infernoflow.mjs --help",
25
+ "typecheck": "tsc --noEmit",
21
26
  "build": "node build.mjs",
22
27
  "prepublishOnly": "echo skipping build",
23
28
  "inferno:promote-draft": "node scripts/inferno-promote-draft.mjs"
24
29
  },
25
- "dependencies": {},
26
30
  "keywords": [
27
31
  "ai",
28
32
  "ai-memory",
@@ -56,6 +60,10 @@
56
60
  "url": "https://github.com/ronmiz/infernoflow/issues"
57
61
  },
58
62
  "devDependencies": {
59
- "esbuild": "^0.28.0"
63
+ "@types/node": "^25.9.0",
64
+ "cross-env": "^10.1.0",
65
+ "esbuild": "^0.28.0",
66
+ "typescript": "^6.0.3",
67
+ "vitest": "^4.1.6"
60
68
  }
61
69
  }
@@ -1,21 +0,0 @@
1
- import*as g from"node:fs";import*as S from"node:path";import{execSync as v}from"node:child_process";import{header as O,ok as R,fail as C,warn as m,info as b,done as G,bold as E,cyan as h,gray as f,green as F,yellow as L}from"../ui/output.mjs";function p(e,n){try{return v(e,{cwd:n,encoding:"utf8",stdio:["ignore","pipe","pipe"]}).trim()}catch{return null}}function y(e){return p("git describe --tags --abbrev=0",e)}function $(e,n){const o=e?`${e}..HEAD`:"",t=p(`git log ${o} --format=%H%x1f%s%x1f%b%x1e`,n);return t?t.split("").map(s=>s.trim()).filter(Boolean).map(s=>{const r=s.split("");return{hash:(r[0]||"").trim().slice(0,8),subject:(r[1]||"").trim(),body:(r[2]||"").trim()}}).filter(s=>s.subject):[]}function M(e,n){return p(`git log -1 --format=%ci "${e}"`,n)?.slice(0,10)||null}const w={feat:"Added",feature:"Added",add:"Added",fix:"Fixed",bugfix:"Fixed",hotfix:"Fixed",perf:"Changed",refactor:"Changed",change:"Changed",chore:"Changed",docs:"Changed",style:"Changed",test:"Changed",ci:"Changed",remove:"Removed",revert:"Removed",deprecate:"Removed"};function x(e){const n=e.match(/^(\w+)(?:\([^)]+\))?[!]?:\s*(.+)/);if(n){const t=n[1].toLowerCase();return{section:w[t]||"Changed",message:n[2].trim(),breaking:e.includes("!")}}const o=e.toLowerCase();for(const[t,s]of Object.entries(w))if(o.startsWith(t+" ")||o.startsWith(t+":"))return{section:s,message:e,breaking:!1};return{section:"Changed",message:e,breaking:!1}}function j(e){const n={Added:[],Fixed:[],Changed:[],Removed:[],Breaking:[]};for(const o of e){const{section:t,message:s,breaking:r}=x(o.subject);r&&n.Breaking.push(s),n[t].push(s)}for(const o of Object.keys(n))n[o]=[...new Set(n[o])];return n}function H(e,n){const o=["## Unreleased",""];n&&o.push(`> Changes since ${n}`,"");const t=["Breaking","Added","Fixed","Changed","Removed"];let s=!1;for(const r of t){const i=e[r];if(!(!i||!i.length)){s=!0,o.push(`### ${r}`);for(const c of i)o.push(`- ${c}`);o.push("")}}return s||o.push("- No significant changes",""),o.join(`
2
- `)}function k(e){return g.existsSync(e)?g.readFileSync(e,"utf8"):null}function D(e){const n=e.match(/^## Unreleased[\s\S]*?(?=\n## |\n---|\z)/im);return n?n[0].trim():null}function U(e,n){return/^## Unreleased/im.test(e)?e.replace(/^## Unreleased[\s\S]*?(?=\n## |\n---)/im,n+`
3
-
4
- `):/^# .+/im.test(e)?e.replace(/^(# .+\n)/im,`$1
5
- ${n}
6
-
7
- `):`${n}
8
-
9
- ${e}`}function J(e,n){const o=n.split(`
10
- `).filter(t=>t.startsWith("- ")).join(`
11
- `);return o?/^## Unreleased/im.test(e)?e.replace(/(^## Unreleased[\s\S]*?)(\n## )/im,`$1
12
- ${o}
13
- $2`):U(e,n):e}function W(e,n){const o=n||y(e),t=$(o,e);if(!t.length){b(`No commits since ${o||"beginning"}`);return}console.log(`
14
- ${E("Commits since")} ${h(o||"beginning")} ${f("("+t.length+")")}
15
- `);for(const s of t){const{section:r}=x(s.subject),i=r==="Added"?F:r==="Fixed"?L:f;console.log(` ${f(s.hash)} ${i(s.subject)}`)}console.log()}function B(e){const n=k(e);if(!n){C("CHANGELOG.md not found");return}const o=D(n);if(!o){m("No ## Unreleased section found in CHANGELOG.md");return}console.log(`
16
- `+o+`
17
- `)}async function T(e,n,o){const{ref:t,dryRun:s,append:r,asJson:i}=o,c=t||y(e),a=$(c,e);if(!a.length){if(i){console.log(JSON.stringify({ok:!0,ref:c,commits:0,message:"No new commits"}));return}m(`No commits found since ${c||"beginning of repo"}`),console.log();return}const d=j(a),l=H(d,c);if(i){console.log(JSON.stringify({ok:!0,ref:c,commits:a.length,sections:{breaking:d.Breaking,added:d.Added,fixed:d.Fixed,changed:d.Changed,removed:d.Removed},markdown:l},null,2));return}if(console.log(),console.log(f(" \u2500\u2500\u2500 Drafted entry \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")),l.split(`
18
- `).forEach(N=>console.log(" "+N)),console.log(f(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")),console.log(),b(`${a.length} commit${a.length>1?"s":""} since ${h(c||"beginning")}`),s){m("Dry run \u2014 CHANGELOG.md not modified"),console.log();return}let u=k(n);u||(u=`# Changelog
19
-
20
- `);const A=r?J(u,l):U(u,l);g.writeFileSync(n,A),R(`CHANGELOG.md updated ${f("("+(r?"appended":"replaced")+" ## Unreleased)")}`),console.log(),G("Changelog drafted \u2014 review and edit before your next release"),console.log(` Run ${h("infernoflow publish")} when ready to cut the release
21
- `)}async function P(e){const n=e.slice(1),o=n.find(l=>!l.startsWith("-"))||"update",t=n.includes("--dry-run"),s=n.includes("--append"),r=n.includes("--json"),i=n.indexOf("--ref"),c=i!==-1?n[i+1]:null,a=process.cwd(),d=S.join(a,"CHANGELOG.md");if(r||O("changelog "+o),o==="list"){W(a,c);return}if(o==="show"){B(d);return}if(o==="update"){await T(a,d,{ref:c,dryRun:t,append:s,asJson:r});return}C(`Unknown sub-command: ${o}`,"Use: update | show | list"),process.exit(1)}export{P as changelogCommand};
@@ -1,3 +0,0 @@
1
- import*as d from"node:fs";import*as p from"node:path";import{fileURLToPath as m}from"node:url";import{spawnSync as h}from"node:child_process";import"../ui/output.mjs";function b(){return process.env.GITHUB_ACTIONS==="true"?"github":process.env.GITLAB_CI==="true"?"gitlab":process.env.BITBUCKET_BUILD_NUMBER?"bitbucket":process.env.CIRCLECI==="true"?"circleci":process.env.JENKINS_URL?"jenkins":process.env.CI==="true"?"generic":"local"}function w(n,e){try{const[f,...t]=n.split(" "),o=h(process.execPath,[p.join(p.dirname(p.dirname(m(import.meta.url))),"..","bin","infernoflow.mjs"),...n.split(" ").slice(1)],{cwd:e,encoding:"utf8",timeout:3e4}).stdout?.trim();if(o)return JSON.parse(o)}catch{}return null}function P(n,e){try{return h(process.execPath,[p.join(p.dirname(p.dirname(m(import.meta.url))),"..","bin","infernoflow.mjs"),...n],{cwd:e,encoding:"utf8",timeout:3e4}).stdout?.trim()||""}catch{return""}}function $(n,e,f){const t=n?.status||"unknown",a=n?.issues||[],o=n?.capabilities||0,c=e?.added?.length||0,s=e?.removed?.length||0,l=e?.changed?.length||0;t==="error"?a.filter(r=>r.severity==="error").forEach(r=>{console.log(`::error::infernoflow: ${r.message}`)}):t==="warning"&&a.filter(r=>r.severity==="warning").forEach(r=>{console.log(`::warning::infernoflow: ${r.message}`)}),c>0&&console.log(`::notice::infernoflow: ${c} new capability${c!==1?"ies":"y"} added`),s>0&&console.log(`::warning::infernoflow: ${s} capability${s!==1?"ies":"y"} removed`);const u=process.env.GITHUB_STEP_SUMMARY;if(u){const i=["## \u{1F525} infernoflow CI report","",`${t==="ok"?"\u2705":t==="warning"?"\u26A0\uFE0F":"\u274C"} **Status:** ${t.toUpperCase()} \xB7 **Capabilities:** ${o}`,""];(c||s||l)&&(i.push("### Capability changes"),c&&i.push(`- \u2705 **${c}** added`),s&&i.push(`- \u274C **${s}** removed`),l&&i.push(`- \u{1F4DD} **${l}** changed`),i.push("")),a.length&&(i.push("### Issues"),a.forEach(g=>i.push(`- **${g.severity?.toUpperCase()||"INFO"}**: ${g.message}`)),i.push("")),i.push("---"),i.push("*Generated by [infernoflow](https://github.com/ronmiz/infernoflow)*");try{d.appendFileSync(u,i.join(`
2
- `)+`
3
- `)}catch{}}}function v(n,e){const t=(n?.issues||[]).map((o,c)=>({description:o.message||"infernoflow issue",fingerprint:Buffer.from(`infernoflow-${c}-${o.message}`).toString("hex").slice(0,40),severity:o.severity==="error"?"critical":"minor",location:{path:"inferno/contract.json",lines:{begin:1}}})),a=p.join(e,"gl-code-quality-report.json");d.writeFileSync(a,JSON.stringify(t,null,2)),console.log("infernoflow: GitLab code quality report written \u2192 gl-code-quality-report.json")}function y(n,e,f){const t=n?.status||"unknown",a=n?.capabilities||0,o=e?.added?.length||0,c=e?.removed?.length||0;console.log(`[infernoflow] platform=${f} status=${t} capabilities=${a} added=${o} removed=${c}`),n?.issues?.length&&n.issues.forEach(s=>{console.log(`[infernoflow] ${(s.severity||"info").toUpperCase()}: ${s.message}`)})}async function J(n){const e=n.slice(1),f=e.includes("--json"),t=e.includes("--platform")?e[e.indexOf("--platform")+1]:null,a=e.includes("--fail-on")?e[e.indexOf("--fail-on")+1]:"error",o=process.cwd(),c=p.join(o,"inferno");d.existsSync(c)||(console.log(f?JSON.stringify({ok:!1,error:"inferno/ not found"}):"[infernoflow] inferno/ not found \u2014 skipping CI check"),process.exit(0));const s=t||b();f||console.log(`[infernoflow] running CI check (platform: ${s})`);const l=w("check --json",o),u=w("diff --json",o),r=l?.status||"unknown";switch(s){case"github":$(l,u,a);break;case"gitlab":v(l,o),y(l,u,s);break;default:y(l,u,s)}f&&console.log(JSON.stringify({ok:r==="ok"||r==="warning",platform:s,status:r,capabilities:l?.capabilities||0,issues:l?.issues||[],diff:{added:u?.added||[],removed:u?.removed||[],changed:u?.changed||[]}}));const i=a==="warning"?r==="error"||r==="warning":r==="error";process.exit(i?1:0)}export{J as ciCommand};