infernoflow 0.44.19 → 0.45.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.
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } 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";
@@ -37,6 +37,19 @@ function walkUpForInfernoflow(startFile) {
37
37
  return null;
38
38
  }
39
39
 
40
+ /**
41
+ * Locate the global `infernoflow` launcher(s) on PATH WITHOUT a shell.
42
+ * `where` (Windows) / `which` (POSIX) are real executables, so execFileSync
43
+ * with an argument array is enough. Returns [] when nothing is found.
44
+ */
45
+ function lookupOnPath(name) {
46
+ try {
47
+ const finder = process.platform === "win32" ? "where" : "which";
48
+ const out = execFileSync(finder, [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false });
49
+ return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
50
+ } catch { return []; }
51
+ }
52
+
40
53
  function findInfernoflowRoot() {
41
54
  // 1. Walk up from this template's own location.
42
55
  // Works when the template runs from inside infernoflow-pkg/ or from a
@@ -56,9 +69,7 @@ function findInfernoflowRoot() {
56
69
  // and the user's project doesn't depend on infernoflow locally. Without
57
70
  // this branch the MCP server boots with v0.0.0-unknown.
58
71
  try {
59
- const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
60
- const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
61
- for (const candidate of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
72
+ for (const candidate of lookupOnPath("infernoflow")) {
62
73
  if (!fs.existsSync(candidate)) continue;
63
74
  const binDir = path.dirname(candidate);
64
75
  // Windows layout: <npm-prefix>/infernoflow.cmd + <npm-prefix>/node_modules/infernoflow/
@@ -89,20 +100,40 @@ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code
89
100
  // Resolve a deterministic location once at startup, in priority order:
90
101
  // 1. infernoflow installed in the project's node_modules (npm i / npm link)
91
102
  // 2. `where`/`which` the global binary
92
- // Returns null if nothing is found; runCmd surfaces a clear error in that case.
103
+ // Returns null if nothing is found; runCli surfaces a clear error in that case.
104
+ // SECURITY: this must always resolve to the CLI's JavaScript entry point
105
+ // (infernoflow.mjs), which we run as `node <file> ...args` with NO shell.
106
+ // We never execute the npm `.cmd` / shell-script wrapper: running those needs
107
+ // a shell, and a shell turns tool arguments into commands.
93
108
  function resolveInfernoflowBin() {
94
- if (INFERNOFLOW_ROOT) {
109
+ const fromRoot = (root) => {
95
110
  for (const c of [
96
- path.join(INFERNOFLOW_ROOT, "dist", "bin", "infernoflow.mjs"),
97
- path.join(INFERNOFLOW_ROOT, "bin", "infernoflow.mjs"),
111
+ path.join(root, "dist", "bin", "infernoflow.mjs"),
112
+ path.join(root, "bin", "infernoflow.mjs"),
98
113
  ]) if (fs.existsSync(c)) return c;
114
+ return null;
115
+ };
116
+ if (INFERNOFLOW_ROOT) {
117
+ const hit = fromRoot(INFERNOFLOW_ROOT);
118
+ if (hit) return hit;
119
+ }
120
+ for (const candidate of lookupOnPath("infernoflow")) {
121
+ if (!fs.existsSync(candidate)) continue;
122
+ // POSIX: the bin entry is usually a symlink straight to infernoflow.mjs.
123
+ try {
124
+ const real = fs.realpathSync(candidate);
125
+ if (/\.m?js$/i.test(real)) return real;
126
+ } catch {}
127
+ // Windows / wrapper layouts: find the package next to the wrapper.
128
+ const binDir = path.dirname(candidate);
129
+ for (const layout of [
130
+ path.join(binDir, "node_modules", "infernoflow"),
131
+ path.join(binDir, "..", "lib", "node_modules", "infernoflow"),
132
+ ]) {
133
+ const hit = fromRoot(layout);
134
+ if (hit) return hit;
135
+ }
99
136
  }
100
- try {
101
- const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
102
- const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
103
- const first = out.split(/\r?\n/)[0];
104
- if (first && fs.existsSync(first)) return first;
105
- } catch {}
106
137
  return null;
107
138
  }
108
139
 
@@ -110,7 +141,7 @@ const INFERNOFLOW_BIN = resolveInfernoflowBin();
110
141
 
111
142
  // In-process AMP I/O loader. When available, amp_write / amp_read bypass the
112
143
  // CLI entirely — no subprocess, no version skew, no flag-mapping field loss.
113
- // Falls back to shell-out via runCmd() if the AMP layer can't be loaded.
144
+ // Falls back to the CLI via runCli() if the AMP layer can't be loaded.
114
145
  let ampIo = null;
115
146
  let refreshRuleFiles = null;
116
147
  let harvestSnapshot = null;
@@ -211,8 +242,16 @@ try {
211
242
  * Run the infernoflow CLI. Returns either the stdout string OR a structured
212
243
  * error object so call sites can decide whether to surface it via JSON-RPC
213
244
  * sendError() instead of returning gibberish text to the agent.
245
+ *
246
+ * SECURITY: `args` is an ARRAY of separate arguments, passed straight to
247
+ * `node infernoflow.mjs` with execFileSync and no shell. Tool input must never
248
+ * be concatenated into a command string — that allowed command injection
249
+ * through tool arguments (fixed in 0.44.20).
214
250
  */
215
- function runCmd(args, env = {}) {
251
+ function runCli(args, env = {}) {
252
+ if (!Array.isArray(args) || !args.every(a => typeof a === "string")) {
253
+ return { __error: true, message: "internal: runCli expects an array of strings", stderr: "", stdout: "", status: 1 };
254
+ }
216
255
  if (!INFERNOFLOW_BIN) {
217
256
  return {
218
257
  __error: true,
@@ -223,15 +262,13 @@ function runCmd(args, env = {}) {
223
262
  };
224
263
  }
225
264
  try {
226
- const isMjs = INFERNOFLOW_BIN.toLowerCase().endsWith(".mjs");
227
- const cmd = isMjs
228
- ? `"${process.execPath}" "${INFERNOFLOW_BIN}" ${args}`
229
- : `"${INFERNOFLOW_BIN}" ${args}`;
230
- return execSync(cmd, {
265
+ return execFileSync(process.execPath, [INFERNOFLOW_BIN, ...args], {
231
266
  encoding: "utf8",
232
267
  cwd: PROJECT_DIR,
233
268
  timeout: 30000,
234
269
  env: { ...process.env, ...env },
270
+ windowsHide: true,
271
+ shell: false,
235
272
  });
236
273
  } catch (err) {
237
274
  return {
@@ -244,7 +281,7 @@ function runCmd(args, env = {}) {
244
281
  }
245
282
  }
246
283
 
247
- /** True if a runCmd() result is actually a structured error. */
284
+ /** True if a runCli() result is actually a structured error. */
248
285
  function isCmdError(result) {
249
286
  return typeof result === "object" && result !== null && result.__error === true;
250
287
  }
@@ -259,36 +296,97 @@ function isCmdError(result) {
259
296
  // helpers that pair cleanly with the kept CLI surface.
260
297
  const TOOLS = [
261
298
  // ── AMP-spec memory tools (the product) ──────────────────────────────────
262
- { 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" } } } },
263
- { name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", 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" } }, detail: { type: "string", description: "Optional rich body (Tier-2). Stored in the consolidated details store; NOT injected into rule files. Put the long-form context here; keep 'msg' to one summary sentence." } }, required: ["type","msg"] } },
264
- { 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"] } },
265
- { name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string" }, note: { type: "string", description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
299
+ { name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string", maxLength: 1000 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 200 } } } },
300
+ { name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string", maxLength: 2000 }, file: { type: "string", maxLength: 1000 }, line: { type: "integer", minimum: 1, maximum: 10000000 }, tags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 100 } }, detail: { type: "string", maxLength: 200000, description: "Optional rich body (Tier-2). Stored in the consolidated details store; NOT injected into rule files. Put the long-form context here; keep 'msg' to one summary sentence." } }, required: ["type","msg"] } },
301
+ { name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 500 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
302
+ { name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string", maxLength: 200 }, note: { type: "string", maxLength: 200000, description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
266
303
  { 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"] } } } },
267
304
  { name: "amp_health", description: "AMP: get the session health score (0-100, A-F grade).", inputSchema: { type: "object", properties: {} } },
268
305
 
269
306
  // ── Read-only contract helpers ───────────────────────────────────────────
270
307
  { name: "infernoflow_status", description: "Show project memory + contract health at a glance.", inputSchema: { type: "object", properties: {} } },
271
308
  { name: "infernoflow_check", description: "Validate the capability contract (read-only).", inputSchema: { type: "object", properties: {} } },
272
- { name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
273
- { 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)" } } } },
309
+ { name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string", maxLength: 1000 }, working: { type: "string", maxLength: 1000 } } } },
310
+ { 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: "integer", minimum: 1, maximum: 100, description: "How many commits back to check (default: 1, max: 100)" } } } },
274
311
  ];
275
312
 
313
+ // ── Input validation ─────────────────────────────────────────────────────────
314
+ // SECURITY: tool arguments come from the model, and the model can be steered by
315
+ // anything it reads (a README, an issue, a memory entry pulled from git). The
316
+ // inputSchema above is advisory to the client only — nothing enforces it — so
317
+ // we enforce it here, before any argument reaches the CLI, git or the disk.
318
+ // Unknown properties are dropped (not rejected) so a chatty client still works.
319
+ const TOOL_SCHEMAS = new Map(TOOLS.map(t => [t.name, t.inputSchema]));
320
+
321
+ function checkValue(key, v, spec) {
322
+ const t = spec.type;
323
+ if (t === "string") {
324
+ if (typeof v !== "string") return `'${key}' must be a string`;
325
+ if (spec.maxLength && v.length > spec.maxLength) return `'${key}' is too long (max ${spec.maxLength} characters)`;
326
+ if (v.includes("\u0000")) return `'${key}' must not contain NUL characters`;
327
+ } else if (t === "integer" || t === "number") {
328
+ if (typeof v !== "number" || !Number.isFinite(v)) return `'${key}' must be a number`;
329
+ if (t === "integer" && !Number.isInteger(v)) return `'${key}' must be a whole number`;
330
+ if (spec.minimum !== undefined && v < spec.minimum) return `'${key}' must be >= ${spec.minimum}`;
331
+ if (spec.maximum !== undefined && v > spec.maximum) return `'${key}' must be <= ${spec.maximum}`;
332
+ } else if (t === "array") {
333
+ if (!Array.isArray(v)) return `'${key}' must be an array`;
334
+ if (spec.maxItems && v.length > spec.maxItems) return `'${key}' has too many items (max ${spec.maxItems})`;
335
+ for (const item of v) { const e = checkValue(`${key}[]`, item, spec.items || {}); if (e) return e; }
336
+ }
337
+ if (spec.enum && !spec.enum.includes(v)) return `'${key}' must be one of: ${spec.enum.join(", ")}`;
338
+ return null;
339
+ }
340
+
341
+ /** Returns { ok: true, value } with only known, valid properties, or { ok: false, error }. */
342
+ function validateToolInput(name, input) {
343
+ const schema = TOOL_SCHEMAS.get(name);
344
+ if (!schema) return { ok: false, error: `Unknown tool: ${name}` };
345
+ if (input === undefined || input === null) input = {};
346
+ if (typeof input !== "object" || Array.isArray(input)) return { ok: false, error: "arguments must be an object" };
347
+ const props = schema.properties || {};
348
+ const value = {};
349
+ for (const [key, spec] of Object.entries(props)) {
350
+ const v = input[key];
351
+ if (v === undefined || v === null) continue;
352
+ const err = checkValue(key, v, spec);
353
+ if (err) return { ok: false, error: `Invalid arguments for ${name}: ${err}` };
354
+ value[key] = v;
355
+ }
356
+ for (const req of schema.required || []) {
357
+ if (value[req] === undefined || value[req] === "") return { ok: false, error: `Invalid arguments for ${name}: '${req}' is required` };
358
+ }
359
+ return { ok: true, value };
360
+ }
361
+
362
+ /**
363
+ * Free text that is handed to the CLI as an argument. The CLI's own parser
364
+ * treats any argument equal to a flag name (e.g. "--watch", "--auto-push") as
365
+ * that flag, even when it arrives as a separate argv entry. Strip leading
366
+ * dashes so tool text can never be read as a CLI option.
367
+ */
368
+ function asCliText(v) {
369
+ return String(v ?? "").replace(/^[\s-]+/, "");
370
+ }
371
+
276
372
  // ── git drift detection (inline — no external imports in this template file) ─
277
373
  function detectGitDrift(sinceCommits) {
278
374
  const cwd = PROJECT_DIR;
279
375
  const infernoDir = path.join(cwd, "inferno");
280
376
 
281
- const runGit = (cmd) => {
282
- try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 10_000 }); }
377
+ // SECURITY: git is run with an argument array and no shell.
378
+ const runGit = (args) => {
379
+ try { return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false }); }
283
380
  catch { return ""; }
284
381
  };
382
+ const n = Number.isInteger(sinceCommits) && sinceCommits >= 1 && sinceCommits <= 100 ? sinceCommits : 1;
285
383
 
286
384
  const changedSet = new Set();
287
385
  const addLines = (out) => out.split("\n").map(l => l.trim()).filter(Boolean).forEach(f => changedSet.add(f));
288
386
 
289
- addLines(runGit("git diff --name-only HEAD"));
290
- addLines(runGit(`git diff --name-only HEAD~${sinceCommits} HEAD`));
291
- addLines(runGit("git ls-files --others --exclude-standard"));
387
+ addLines(runGit(["diff", "--name-only", "HEAD"]));
388
+ addLines(runGit(["diff", "--name-only", `HEAD~${n}`, "HEAD"]));
389
+ addLines(runGit(["ls-files", "--others", "--exclude-standard"]));
292
390
 
293
391
  const changedFiles = Array.from(changedSet).sort();
294
392
  if (!changedFiles.length) return "No changed files detected since last commit.";
@@ -388,29 +486,35 @@ function detectGitDrift(sinceCommits) {
388
486
  return lines.join("\n");
389
487
  }
390
488
 
391
- function handleTool(id, name, input) {
489
+ function handleTool(id, name, rawInput) {
392
490
  try {
491
+ const checked = validateToolInput(name, rawInput);
492
+ if (!checked.ok) {
493
+ const code = checked.error.startsWith("Unknown tool") ? -32601 : -32602;
494
+ return sendError(id, code, checked.error);
495
+ }
496
+ const input = checked.value;
393
497
  let text = "";
394
498
  // ── Read-only contract helpers ─────────────────────────────────────────
395
499
  if (name === "infernoflow_check") {
396
- text = runCmd("check");
500
+ text = runCli(["check"]);
397
501
  } else if (name === "infernoflow_status") {
398
- text = runCmd("status");
502
+ text = runCli(["status"]);
399
503
  } else if (name === "infernoflow_context") {
400
- const parts = [];
401
- if (input.intent) parts.push(`--intent "${input.intent}"`);
402
- if (input.working) parts.push(`--working "${input.working}"`);
403
- text = runCmd("context " + parts.join(" "));
504
+ const args = ["context"];
505
+ if (input.intent) args.push("--intent", asCliText(input.intent));
506
+ if (input.working) args.push("--working", asCliText(input.working));
507
+ text = runCli(args);
404
508
  } else if (name === "infernoflow_git_drift") {
405
- text = detectGitDrift(input.sinceCommits || 1);
509
+ text = detectGitDrift(input.sinceCommits ?? 1);
406
510
 
407
511
  // ── AMP-spec memory tools ──────────────────────────────────────────────
408
512
  } else if (name === "amp_read") {
409
- const args = [];
410
- if (input.query) args.push(JSON.stringify(input.query));
513
+ const args = ["ask"];
514
+ if (input.query) args.push(asCliText(input.query));
411
515
  if (input.type) args.push("--type", input.type);
412
516
  if (input.limit) args.push("--limit", String(input.limit));
413
- text = runCmd("ask " + args.join(" "));
517
+ text = runCli(args);
414
518
  } else if (name === "amp_write") {
415
519
  // Prefer in-process write: no subprocess, no `npx` version skew, and
416
520
  // file/line/tags reach disk unchanged. The CLI fallback below is only
@@ -437,7 +541,10 @@ function handleTool(id, name, input) {
437
541
  // write dirties tracked files and blocks `git checkout`. Within a
438
542
  // session, the agent uses amp_read for fresh queries; rule files
439
543
  // are for cold-start injection of the *next* session.
440
- text = `✔ Logged [${written.type}] ${written.id}\n msg: ${written.msg}` +
544
+ // D3 (0.45.0): always say which store was written, so a wrong store is visible.
545
+ text = (typeof ampIo.describeStore === "function" ? ampIo.describeStore(PROJECT_DIR) + "\n" : "") +
546
+ `✔ Logged [${written.type}] ${written.id}\n msg: ${written.msg}` +
547
+ (written.meta && written.meta.redacted ? `\n ⚠ secrets redacted: ${written.meta.redacted.join(", ")}` : "") +
441
548
  (written.file ? `\n file: ${written.file}${written.line ? ":" + written.line : ""}` : "") +
442
549
  (written.tags ? `\n tags: ${written.tags.join(", ")}` : "") +
443
550
  (written.meta && written.meta.detailRef ? `\n detail: ${written.meta.detailRef}` : "");
@@ -445,15 +552,13 @@ function handleTool(id, name, input) {
445
552
  return sendError(id, -32000, `amp_write failed (in-process): ${err.message}`);
446
553
  }
447
554
  } else {
448
- // Fallback: shell-out. Pass --file/--line/--tags through the CLI so
449
- // they're not silently dropped like in the original implementation.
450
- const t = (input.type || "note").replace(/[^a-z]/g, "");
451
- const m = JSON.stringify(input.msg || "");
452
- const extras = [];
453
- if (input.file) extras.push("--file", JSON.stringify(input.file));
454
- if (input.line) extras.push("--line", String(input.line));
455
- if (input.tags && input.tags.length) extras.push("--tags", JSON.stringify(input.tags.join(",")));
456
- text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
555
+ // Fallback: run the CLI (argument array, no shell). Pass
556
+ // --file/--line/--tags through so they're not silently dropped.
557
+ const args = ["log", asCliText(input.msg), "--type", input.type || "note"];
558
+ if (input.file) args.push("--file", asCliText(input.file));
559
+ if (input.line) args.push("--line", String(input.line));
560
+ if (input.tags && input.tags.length) args.push("--tags", input.tags.map(asCliText).join(","));
561
+ text = runCli(args);
457
562
  }
458
563
  } else if (name === "amp_bookmark") {
459
564
  // A bookmark is a `note` entry tagged "bookmark"; the optional `note`
@@ -475,23 +580,26 @@ function handleTool(id, name, input) {
475
580
  if (input.note && String(input.note).trim()) {
476
581
  entry.detail = String(input.note);
477
582
  } else if (harvestSnapshot) {
478
- try { const snap = harvestSnapshot(PROJECT_DIR); if (snap) entry.detail = snap; } catch { /* best-effort */ }
583
+ // Automatic transcript snapshots stay on this machine (gitignored store).
584
+ try { const snap = harvestSnapshot(PROJECT_DIR); if (snap) { entry.detail = snap; entry.detailLocal = true; } } catch { /* best-effort */ }
479
585
  }
480
586
  try {
481
587
  const written = ampIo.appendEntry(PROJECT_DIR, entry);
482
- text = `🔖 Bookmark saved: ${written.msg} (${written.id})` +
588
+ text = (typeof ampIo.describeStore === "function" ? ampIo.describeStore(PROJECT_DIR) + "\n" : "") +
589
+ `🔖 Bookmark saved: ${written.msg} (${written.id})` +
590
+ (written.meta && written.meta.redacted ? `\n ⚠ secrets redacted: ${written.meta.redacted.join(", ")}` : "") +
483
591
  (written.meta && written.meta.detailRef ? `\n context: ${written.meta.detailRef}` : "");
484
592
  } catch (err) {
485
593
  return sendError(id, -32000, `amp_bookmark failed (in-process): ${err.message}`);
486
594
  }
487
595
  } else {
488
- const l = JSON.stringify(input.label || "");
489
- const extras = input.note ? `--note ${JSON.stringify(input.note)}` : "";
490
- text = runCmd(`bookmark ${l} ${extras}`);
596
+ const args = ["bookmark", asCliText(input.label)];
597
+ if (input.note) args.push("--note", asCliText(input.note));
598
+ text = runCli(args);
491
599
  }
492
600
  } else if (name === "amp_handoff") {
493
601
  // switch writes a file; we read it back to return the content
494
- const switchResult = runCmd("switch");
602
+ const switchResult = runCli(["switch"]);
495
603
  if (isCmdError(switchResult)) {
496
604
  return sendError(id, -32000, `infernoflow switch failed: ${switchResult.message}\n${switchResult.stderr || switchResult.stdout || ""}`.trim());
497
605
  }
@@ -508,20 +616,20 @@ function handleTool(id, name, input) {
508
616
  text = "(handoff generated; could not read back: " + err.message + ")";
509
617
  }
510
618
  } else if (name === "amp_search") {
511
- const args = [JSON.stringify(input.query || "")];
619
+ const args = ["ask", asCliText(input.query)];
512
620
  if (input.type) args.push("--type", input.type);
513
- text = runCmd("ask " + args.join(" "));
621
+ text = runCli(args);
514
622
  } else if (name === "amp_health") {
515
- const recap = runCmd("recap --json");
623
+ const recap = runCli(["recap", "--json"]);
516
624
  if (isCmdError(recap)) {
517
- text = runCmd("status");
625
+ text = runCli(["status"]);
518
626
  } else {
519
- text = recap.trim() || runCmd("status");
627
+ text = recap.trim() || runCli(["status"]);
520
628
  }
521
629
 
522
630
  } else { return sendError(id, -32601, `Unknown tool: ${name}`); }
523
631
 
524
- // Central error check — if any runCmd() call produced a structured error,
632
+ // Central error check — if any runCli() call produced a structured error,
525
633
  // surface it as a real JSON-RPC error so the calling AI sees a proper
526
634
  // failure instead of garbled stderr text mixed into a "successful" reply.
527
635
  if (isCmdError(text)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.44.19",
3
+ "version": "0.45.0",
4
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": {