context-mode 1.0.124 → 1.0.126

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 (52) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +3 -3
  6. package/build/adapters/claude-code/hooks.d.ts +22 -17
  7. package/build/adapters/claude-code/hooks.js +33 -24
  8. package/build/adapters/claude-code/index.d.ts +24 -1
  9. package/build/adapters/claude-code/index.js +67 -5
  10. package/build/adapters/codex/hooks.d.ts +13 -14
  11. package/build/adapters/codex/hooks.js +13 -14
  12. package/build/adapters/codex/index.js +19 -8
  13. package/build/adapters/types.d.ts +57 -0
  14. package/build/adapters/types.js +29 -0
  15. package/build/cli.js +38 -13
  16. package/build/db-base.d.ts +19 -2
  17. package/build/db-base.js +49 -15
  18. package/build/executor.js +40 -3
  19. package/build/runtime.d.ts +2 -1
  20. package/build/runtime.js +10 -0
  21. package/build/server.js +4 -2
  22. package/build/util/hook-config.d.ts +24 -1
  23. package/build/util/hook-config.js +39 -2
  24. package/build/util/plugin-cache-integrity.d.ts +37 -0
  25. package/build/util/plugin-cache-integrity.js +105 -0
  26. package/cli.bundle.mjs +141 -138
  27. package/configs/codex/hooks.json +1 -1
  28. package/hooks/core/routing.mjs +8 -4
  29. package/hooks/hooks.json +1 -1
  30. package/hooks/session-db.bundle.mjs +2 -2
  31. package/openclaw.plugin.json +1 -1
  32. package/package.json +2 -1
  33. package/scripts/plugin-cache-integrity.mjs +168 -0
  34. package/server.bundle.mjs +97 -94
  35. package/start.mjs +37 -0
  36. package/skills/UPSTREAM-CREDITS.md +0 -51
  37. package/skills/diagnose/SKILL.md +0 -122
  38. package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
  39. package/skills/grill-me/SKILL.md +0 -15
  40. package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
  41. package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
  42. package/skills/grill-with-docs/SKILL.md +0 -93
  43. package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
  44. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
  45. package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
  46. package/skills/improve-codebase-architecture/SKILL.md +0 -76
  47. package/skills/tdd/SKILL.md +0 -114
  48. package/skills/tdd/deep-modules.md +0 -33
  49. package/skills/tdd/interface-design.md +0 -31
  50. package/skills/tdd/mocking.md +0 -59
  51. package/skills/tdd/refactoring.md +0 -10
  52. package/skills/tdd/tests.md +0 -61
package/build/cli.js CHANGED
@@ -368,22 +368,47 @@ async function doctor() {
368
368
  (result.fix ? color.dim(`\n Run: ${result.fix}`) : ""));
369
369
  }
370
370
  }
371
- // Hook scripts exist
371
+ // Hook scripts exist — Algo-D1 protocol path takes precedence.
372
+ // Adapters that override `getHealthChecks` (claude-code today) get a
373
+ // direct `existsSync(join(pluginRoot, "hooks", scriptName))` per
374
+ // HOOK_SCRIPTS entry — no regex round-trip on a hook command, so the
375
+ // #548 doubled-path FAIL class can't surface. Adapters that don't
376
+ // override fall through to the legacy `getHookScriptPaths` flow which
377
+ // generates the hook config and parses each command via
378
+ // `extractHookScriptPath`. Post-D3 every adapter emits buildNodeCommand-
379
+ // shape, so the legacy flow is also safe — but the direct existsSync
380
+ // path is strictly preferable when the adapter offers it.
372
381
  p.log.step("Checking hook scripts...");
373
- const hookScriptPaths = getHookScriptPaths(adapter, pluginRoot);
374
- if (hookScriptPaths.length === 0) {
375
- p.log.success(color.green("Hook scripts: PASS") + color.dim(" no direct .mjs script paths to verify"));
382
+ const adapterHealthChecks = adapter.getHealthChecks?.(pluginRoot) ?? [];
383
+ if (adapterHealthChecks.length > 0) {
384
+ for (const hc of adapterHealthChecks) {
385
+ const result = hc.check();
386
+ if (result.status === "OK") {
387
+ p.log.success(color.green(`${hc.name}: PASS`) +
388
+ (result.detail ? color.dim(` — ${result.detail}`) : ""));
389
+ }
390
+ else {
391
+ p.log.error(color.red(`${hc.name}: FAIL`) +
392
+ (result.detail ? color.dim(` — ${result.detail}`) : ""));
393
+ }
394
+ }
376
395
  }
377
396
  else {
378
- for (const scriptPath of hookScriptPaths) {
379
- const absolutePath = resolve(pluginRoot, scriptPath);
380
- try {
381
- accessSync(absolutePath, constants.R_OK);
382
- p.log.success(color.green("Hook script exists: PASS") + color.dim(` — ${absolutePath}`));
383
- }
384
- catch {
385
- p.log.error(color.red("Hook script exists: FAIL") +
386
- color.dim(` — not found at ${absolutePath}`));
397
+ const hookScriptPaths = getHookScriptPaths(adapter, pluginRoot);
398
+ if (hookScriptPaths.length === 0) {
399
+ p.log.success(color.green("Hook scripts: PASS") + color.dim(" — no direct .mjs script paths to verify"));
400
+ }
401
+ else {
402
+ for (const scriptPath of hookScriptPaths) {
403
+ const absolutePath = resolve(pluginRoot, scriptPath);
404
+ try {
405
+ accessSync(absolutePath, constants.R_OK);
406
+ p.log.success(color.green("Hook script exists: PASS") + color.dim(` — ${absolutePath}`));
407
+ }
408
+ catch {
409
+ p.log.error(color.red("Hook script exists: FAIL") +
410
+ color.dim(` — not found at ${absolutePath}`));
411
+ }
387
412
  }
388
413
  }
389
414
  }
@@ -61,11 +61,28 @@ export declare class NodeSQLiteAdapter {
61
61
  * bundled SQLite always ships with FTS5.
62
62
  */
63
63
  export declare function nodeSqliteHasFts5(DatabaseSync: any): boolean;
64
+ /**
65
+ * Returns true when the current runtime ships a built-in SQLite binding:
66
+ * - Bun has `bun:sqlite` always
67
+ * - Node has `node:sqlite` since 22.5 (no flag since 22.13)
68
+ *
69
+ * Mirrors the helper in hooks/ensure-deps.mjs:61. Exported so the platform
70
+ * gate in loadDatabase() can be unit-tested without spawning a child
71
+ * process. `versionsOverride` and `bunOverride` are injection points for
72
+ * tests — production callers pass nothing.
73
+ *
74
+ * Widening the gate from `process.platform === "linux"` to this helper is
75
+ * required for Node 26 on macOS arm64 (#551): Node 26 removed
76
+ * `info.This()` from V8 PropertyCallbackInfo, breaking better-sqlite3
77
+ * 12.9.0's native compile. Using node:sqlite sidesteps the native addon
78
+ * entirely on every platform that has it.
79
+ */
80
+ export declare function hasModernSqlite(versionsOverride?: NodeJS.ProcessVersions, bunOverride?: unknown): boolean;
64
81
  /**
65
82
  * Lazy-load the SQLite driver for the current runtime.
66
83
  * Bun → bun:sqlite via BunSQLiteAdapter (issue #45).
67
- * Linux Node → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461).
68
- * Other Node (or Linux Node without FTS5) → better-sqlite3 (native addon).
84
+ * Modern Node (>= 22.5) → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461, #551).
85
+ * Other Node (or modern Node without FTS5) → better-sqlite3 (native addon).
69
86
  */
70
87
  export declare function loadDatabase(): typeof DatabaseConstructor;
71
88
  /**
package/build/db-base.js CHANGED
@@ -184,11 +184,39 @@ export function nodeSqliteHasFts5(DatabaseSync) {
184
184
  catch { /* probe never opened or already closed */ }
185
185
  }
186
186
  }
187
+ /**
188
+ * Returns true when the current runtime ships a built-in SQLite binding:
189
+ * - Bun has `bun:sqlite` always
190
+ * - Node has `node:sqlite` since 22.5 (no flag since 22.13)
191
+ *
192
+ * Mirrors the helper in hooks/ensure-deps.mjs:61. Exported so the platform
193
+ * gate in loadDatabase() can be unit-tested without spawning a child
194
+ * process. `versionsOverride` and `bunOverride` are injection points for
195
+ * tests — production callers pass nothing.
196
+ *
197
+ * Widening the gate from `process.platform === "linux"` to this helper is
198
+ * required for Node 26 on macOS arm64 (#551): Node 26 removed
199
+ * `info.This()` from V8 PropertyCallbackInfo, breaking better-sqlite3
200
+ * 12.9.0's native compile. Using node:sqlite sidesteps the native addon
201
+ * entirely on every platform that has it.
202
+ */
203
+ export function hasModernSqlite(versionsOverride, bunOverride) {
204
+ const bun = bunOverride !== undefined ? bunOverride : globalThis.Bun;
205
+ if (typeof bun !== "undefined" && bun !== null)
206
+ return true;
207
+ const versions = versionsOverride ?? process.versions;
208
+ const [majorStr, minorStr] = (versions.node ?? "0.0.0").split(".");
209
+ const major = Number(majorStr);
210
+ const minor = Number(minorStr);
211
+ if (!Number.isFinite(major) || !Number.isFinite(minor))
212
+ return false;
213
+ return major > 22 || (major === 22 && minor >= 5);
214
+ }
187
215
  /**
188
216
  * Lazy-load the SQLite driver for the current runtime.
189
217
  * Bun → bun:sqlite via BunSQLiteAdapter (issue #45).
190
- * Linux Node → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461).
191
- * Other Node (or Linux Node without FTS5) → better-sqlite3 (native addon).
218
+ * Modern Node (>= 22.5) → node:sqlite via NodeSQLiteAdapter when it ships FTS5 (#228, #461, #551).
219
+ * Other Node (or modern Node without FTS5) → better-sqlite3 (native addon).
192
220
  */
193
221
  export function loadDatabase() {
194
222
  if (!_Database) {
@@ -211,13 +239,19 @@ export function loadDatabase() {
211
239
  return adapter;
212
240
  };
213
241
  }
214
- else if (process.platform === "linux") {
215
- // Linux — try node:sqlite to avoid native addon SIGSEGV (nodejs/node#62515).
216
- // node:sqlite is built into Node >= 22.5, no flag needed since 22.13.
217
- // Probe FTS5 support before committing some Linux Node builds ship
218
- // node:sqlite without FTS5, which would silently break ctx_search (#461).
219
- // The probe runs at most once per process (cached via _Database below),
220
- // so the cost of opening an in-memory DatabaseSync is negligible.
242
+ else if (hasModernSqlite()) {
243
+ // Any Node >= 22.5 — try node:sqlite to avoid the native addon path
244
+ // entirely. Historically this was Linux-only (avoiding the Linux
245
+ // SIGSEGV per nodejs/node#62515, #228), but Node 26 also broke
246
+ // better-sqlite3's native compile on macOS arm64 by removing
247
+ // V8 `info.This()` (#551). The built-in `node:sqlite` ships its
248
+ // own SQLite, so it sidesteps both issues at once.
249
+ //
250
+ // Probe FTS5 support before committing — some Node builds ship
251
+ // node:sqlite without FTS5, which would silently break ctx_search
252
+ // (#461). The probe runs at most once per process (cached via
253
+ // _Database below), so the cost of an in-memory DatabaseSync is
254
+ // negligible.
221
255
  let DatabaseSync = null;
222
256
  try {
223
257
  // Array.join() prevents esbuild from resolving the specifier at bundle time
@@ -236,16 +270,16 @@ export function loadDatabase() {
236
270
  };
237
271
  }
238
272
  else {
239
- // node:sqlite missing or built without FTS5 — fall through to better-sqlite3.
240
- // Trade-off: this reintroduces the native-addon path that #228 routed
241
- // around (Linux SIGSEGV per nodejs/node#62515). Deliberate a visible
242
- // crash on the rare unstable build is preferable to a silent
243
- // "no such module: fts5" on every ctx_search call.
273
+ // node:sqlite missing or built without FTS5 — fall through to
274
+ // better-sqlite3. Trade-off: on Node 26 + macOS this may now hit
275
+ // the V8 ABI break (#551). A visible crash on the rare
276
+ // unstable build is preferable to silent "no such module: fts5"
277
+ // on every ctx_search call.
244
278
  _Database = require("better-sqlite3");
245
279
  }
246
280
  }
247
281
  else {
248
- // Non-Linux Node.jsuse better-sqlite3.
282
+ // Old Node (< 22.5) without bun:sqlite fall back to better-sqlite3.
249
283
  _Database = require("better-sqlite3");
250
284
  }
251
285
  }
package/build/executor.js CHANGED
@@ -23,6 +23,7 @@ const SCRIPT_EXT = {
23
23
  perl: "pl",
24
24
  r: "R",
25
25
  elixir: "exs",
26
+ csharp: "csx",
26
27
  };
27
28
  /** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
28
29
  export function buildScriptFilename(language, platform, shellPath) {
@@ -223,7 +224,7 @@ export class PolyglotExecutor {
223
224
  // .exe paths now (#506), but if it falls back to the bare "bun" string
224
225
  // on Windows that resolution typically goes through a `bun.cmd` shim
225
226
  // (npm i -g bun) which CreateProcess can't execute without cmd.exe.
226
- const needsShell = isWin && ["tsx", "ts-node", "elixir", "bun"].includes(cmd[0]);
227
+ const needsShell = isWin && ["tsx", "ts-node", "elixir", "bun", "dotnet-script"].includes(cmd[0]);
227
228
  // On Windows with Git Bash, pass the script as `bash -c "source /posix/path"`
228
229
  // rather than `bash /path/to/script.sh`. This avoids MSYS2 path mangling
229
230
  // while still allowing MSYS_NO_PATHCONV to protect non-ASCII paths in commands.
@@ -412,6 +413,30 @@ export class PolyglotExecutor {
412
413
  "R_PROFILE", // site-wide R profile
413
414
  "R_PROFILE_USER", // user R profile
414
415
  "R_HOME", // R installation override
416
+ // .NET / C# — runtime/startup hooks, additional deps
417
+ "DOTNET_STARTUP_HOOKS", // injects managed assemblies on startup
418
+ "DOTNET_ADDITIONAL_DEPS", // additional .deps.json injection
419
+ "DOTNET_SHARED_STORE", // shared assembly probe path injection
420
+ "DOTNET_ROOT", // arbitrary .NET runtime override
421
+ "DOTNET_ROOT(x86)", // 32-bit override
422
+ "DOTNET_HOST_PATH", // host binary substitution
423
+ // .NET / C# — profiler attach (loads arbitrary DLL into dotnet host)
424
+ // and IPC-based debugger/IL injection. PR #546 follow-up.
425
+ // learn.microsoft.com/en-us/dotnet/core/runtime-config/debugging-profiling
426
+ "CORECLR_PROFILER", // CLSID of profiler to attach
427
+ "CORECLR_PROFILER_PATH", // path to profiler DLL
428
+ "CORECLR_PROFILER_PATH_32", // 32-bit specific profiler DLL
429
+ "CORECLR_PROFILER_PATH_64", // 64-bit specific profiler DLL
430
+ "CORECLR_PROFILER_PATH_ARM32", // ARM32 specific profiler DLL
431
+ "CORECLR_PROFILER_PATH_ARM64", // ARM64 specific profiler DLL
432
+ "CORECLR_ENABLE_PROFILING", // gates profiler load
433
+ "DOTNET_PROFILER_PATH", // cross-platform alias
434
+ "DOTNET_PROFILER_PATH_32",
435
+ "DOTNET_PROFILER_PATH_64",
436
+ "DOTNET_PROFILER_PATH_ARM32",
437
+ "DOTNET_PROFILER_PATH_ARM64",
438
+ "DOTNET_DiagnosticPorts", // peer attach via diagnostic IPC
439
+ "DOTNET_BUNDLE_EXTRACT_BASE_DIR", // single-file extraction hijack
415
440
  // Dynamic linker — shared library injection
416
441
  "LD_PRELOAD", // loads .so before all others (Linux)
417
442
  "DYLD_INSERT_LIBRARIES", // macOS equivalent of LD_PRELOAD
@@ -431,10 +456,17 @@ export class PolyglotExecutor {
431
456
  "GIT_SSH_COMMAND", // arbitrary ssh command
432
457
  "GIT_ASKPASS", // arbitrary credential command
433
458
  ]);
434
- // Start with parent env, then strip dangerous vars and apply overrides
459
+ // Start with parent env, then strip dangerous vars and apply overrides.
460
+ // The `COMPlus_` prefix sweep covers every COMPlus_* synonym of the
461
+ // DOTNET_* runtime knobs (.NET back-compat alias — case-insensitive).
462
+ // PR #546 follow-up: closes the alias bypass for the explicit denylist
463
+ // entries above.
435
464
  const env = {};
436
465
  for (const [key, val] of Object.entries(process.env)) {
437
- if (val !== undefined && !DENIED.has(key) && !key.startsWith("BASH_FUNC_")) {
466
+ if (val !== undefined &&
467
+ !DENIED.has(key) &&
468
+ !key.startsWith("BASH_FUNC_") &&
469
+ !/^COMPlus_/i.test(key)) {
438
470
  env[key] = val;
439
471
  }
440
472
  }
@@ -508,6 +540,11 @@ export class PolyglotExecutor {
508
540
  return `FILE_CONTENT_PATH <- ${escaped}\nfile_path <- FILE_CONTENT_PATH\nFILE_CONTENT <- readLines(FILE_CONTENT_PATH, warn=FALSE, encoding="UTF-8")\nFILE_CONTENT <- paste(FILE_CONTENT, collapse="\\n")\n${code}`;
509
541
  case "elixir":
510
542
  return `file_content_path = ${escaped}\nfile_path = file_content_path\nfile_content = File.read!(file_content_path)\n${code}`;
543
+ case "csharp":
544
+ // .csx forbids `using` directives after any other top-level statement
545
+ // (CS1529). User code inside executeFile must use fully-qualified type
546
+ // names (e.g. `System.Text.Json.JsonDocument`) instead of `using`.
547
+ return `var FILE_CONTENT_PATH = ${escaped};\nvar file_path = FILE_CONTENT_PATH;\nvar FILE_CONTENT = System.IO.File.ReadAllText(FILE_CONTENT_PATH);\n${code}`;
511
548
  }
512
549
  }
513
550
  }
@@ -1,5 +1,5 @@
1
1
  export declare function isAllowlistedShell(shellPath: string): boolean;
2
- export type Language = "javascript" | "typescript" | "python" | "shell" | "ruby" | "go" | "rust" | "php" | "perl" | "r" | "elixir";
2
+ export type Language = "javascript" | "typescript" | "python" | "shell" | "ruby" | "go" | "rust" | "php" | "perl" | "r" | "elixir" | "csharp";
3
3
  export interface RuntimeInfo {
4
4
  command: string;
5
5
  available: boolean;
@@ -18,6 +18,7 @@ export interface RuntimeMap {
18
18
  perl: string | null;
19
19
  r: string | null;
20
20
  elixir: string | null;
21
+ csharp: string | null;
21
22
  }
22
23
  export declare function detectRuntimes(): RuntimeMap;
23
24
  export declare function hasBunRuntime(): boolean;
package/build/runtime.js CHANGED
@@ -232,6 +232,7 @@ export function detectRuntimes() {
232
232
  ? "r"
233
233
  : null,
234
234
  elixir: commandExists("elixir") ? "elixir" : null,
235
+ csharp: commandExists("dotnet-script") ? "dotnet-script" : null,
235
236
  };
236
237
  }
237
238
  export function hasBunRuntime() {
@@ -269,6 +270,8 @@ export function getRuntimeSummary(runtimes) {
269
270
  lines.push(` R: ${runtimes.r} (${getVersion(runtimes.r)})`);
270
271
  if (runtimes.elixir)
271
272
  lines.push(` Elixir: ${runtimes.elixir} (${getVersion(runtimes.elixir)})`);
273
+ if (runtimes.csharp)
274
+ lines.push(` C#: ${runtimes.csharp} (${getVersion(runtimes.csharp)})`);
272
275
  if (!bunPreferred) {
273
276
  lines.push("");
274
277
  lines.push(" Tip: Install Bun for 3-5x faster JS/TS execution → https://bun.sh");
@@ -295,6 +298,8 @@ export function getAvailableLanguages(runtimes) {
295
298
  langs.push("r");
296
299
  if (runtimes.elixir)
297
300
  langs.push("elixir");
301
+ if (runtimes.csharp)
302
+ langs.push("csharp");
298
303
  return langs;
299
304
  }
300
305
  export function buildCommand(runtimes, language, filePath) {
@@ -376,5 +381,10 @@ export function buildCommand(runtimes, language, filePath) {
376
381
  throw new Error("Elixir not available. Install elixir.");
377
382
  }
378
383
  return ["elixir", filePath];
384
+ case "csharp":
385
+ if (!runtimes.csharp) {
386
+ throw new Error("C# not available. Install dotnet-script via `dotnet tool install -g dotnet-script`.");
387
+ }
388
+ return [runtimes.csharp, filePath];
379
389
  }
380
390
  }
package/build/server.js CHANGED
@@ -929,11 +929,12 @@ server.registerTool("ctx_execute", {
929
929
  "perl",
930
930
  "r",
931
931
  "elixir",
932
+ "csharp",
932
933
  ])
933
934
  .describe("Runtime language"),
934
935
  code: z
935
936
  .string()
936
- .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), or IO.puts (Elixir) to output a summary to context."),
937
+ .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), IO.puts (Elixir), or Console.WriteLine (C#) to output a summary to context."),
937
938
  timeout: z
938
939
  .coerce.number()
939
940
  .optional()
@@ -1224,11 +1225,12 @@ server.registerTool("ctx_execute_file", {
1224
1225
  "perl",
1225
1226
  "r",
1226
1227
  "elixir",
1228
+ "csharp",
1227
1229
  ])
1228
1230
  .describe("Runtime language"),
1229
1231
  code: z
1230
1232
  .string()
1231
- .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts."),
1233
+ .describe("Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts/Console.WriteLine."),
1232
1234
  timeout: z
1233
1235
  .coerce.number()
1234
1236
  .optional()
@@ -1,4 +1,27 @@
1
- import type { HookAdapter } from "../adapters/types.js";
1
+ import { type HookAdapter } from "../adapters/types.js";
2
2
  export declare function getCommandsFromHookEntry(entry: unknown): string[];
3
+ /**
4
+ * Extract the hook script path from a hook command string.
5
+ *
6
+ * Post Algo-D2 this is a thin wrapper around `parseNodeCommand` with a
7
+ * single legacy fallback retained for stale-entry cleanup
8
+ * (`configureAllHooks` walks pre-v1.0.124 settings.json shapes that
9
+ * predate `buildNodeCommand`). The legacy branches are deliberately
10
+ * narrow:
11
+ *
12
+ * 1) Canonical: `"<nodePath>" "<scriptPath>.mjs"` — `parseNodeCommand`
13
+ * handles this; round-trips with `buildNodeCommand`.
14
+ * 2) Legacy quoted: `node "<scriptPath>.mjs"` — emitted by claude-code
15
+ * pre-D3. The script segment is fully quoted, no whitespace
16
+ * ambiguity.
17
+ * 3) Legacy unquoted: `node <scriptPath>.mjs` — only when the entire
18
+ * command is whitespace-safe (exactly two whitespace-separated
19
+ * tokens). The #548 wire shape — `node C:/Users/High Ground …` —
20
+ * contains internal whitespace so this branch refuses it. Returns
21
+ * `null` instead of grabbing the tail after the last whitespace.
22
+ *
23
+ * Anything else returns `null`, letting the doctor (Algo-D1) fall
24
+ * through to direct `existsSync` instead of trusting the regex.
25
+ */
3
26
  export declare function extractHookScriptPath(command: string): string | null;
4
27
  export declare function getHookScriptPaths(adapter: HookAdapter, pluginRoot: string): string[];
@@ -1,3 +1,4 @@
1
+ import { parseNodeCommand } from "../adapters/types.js";
1
2
  export function getCommandsFromHookEntry(entry) {
2
3
  const commands = [];
3
4
  if (entry && typeof entry === "object") {
@@ -17,9 +18,45 @@ export function getCommandsFromHookEntry(entry) {
17
18
  }
18
19
  return commands;
19
20
  }
21
+ /**
22
+ * Extract the hook script path from a hook command string.
23
+ *
24
+ * Post Algo-D2 this is a thin wrapper around `parseNodeCommand` with a
25
+ * single legacy fallback retained for stale-entry cleanup
26
+ * (`configureAllHooks` walks pre-v1.0.124 settings.json shapes that
27
+ * predate `buildNodeCommand`). The legacy branches are deliberately
28
+ * narrow:
29
+ *
30
+ * 1) Canonical: `"<nodePath>" "<scriptPath>.mjs"` — `parseNodeCommand`
31
+ * handles this; round-trips with `buildNodeCommand`.
32
+ * 2) Legacy quoted: `node "<scriptPath>.mjs"` — emitted by claude-code
33
+ * pre-D3. The script segment is fully quoted, no whitespace
34
+ * ambiguity.
35
+ * 3) Legacy unquoted: `node <scriptPath>.mjs` — only when the entire
36
+ * command is whitespace-safe (exactly two whitespace-separated
37
+ * tokens). The #548 wire shape — `node C:/Users/High Ground …` —
38
+ * contains internal whitespace so this branch refuses it. Returns
39
+ * `null` instead of grabbing the tail after the last whitespace.
40
+ *
41
+ * Anything else returns `null`, letting the doctor (Algo-D1) fall
42
+ * through to direct `existsSync` instead of trusting the regex.
43
+ */
20
44
  export function extractHookScriptPath(command) {
21
- const match = command.match(/(?:"([^"]+\.mjs)"|'([^']+\.mjs)'|(\S+\.mjs))/);
22
- return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
45
+ const parsed = parseNodeCommand(command);
46
+ if (parsed) {
47
+ return parsed.scriptPath.endsWith(".mjs") ? parsed.scriptPath : null;
48
+ }
49
+ // Legacy quoted: `node "/path/with spaces/x.mjs"` (pre-D3 claude-code emit).
50
+ const legacyQuoted = command.match(/^\s*node\s+"([^"]+\.mjs)"\s*$/);
51
+ if (legacyQuoted)
52
+ return legacyQuoted[1];
53
+ // Legacy unquoted: `node /path/x.mjs` — refuses internal whitespace
54
+ // by anchoring both tokens. The #548 ambiguous shape has 3+ tokens
55
+ // (spaces in the path) and falls through to `null`.
56
+ const legacyBare = command.match(/^\s*node\s+(\S+\.mjs)\s*$/);
57
+ if (legacyBare)
58
+ return legacyBare[1];
59
+ return null;
23
60
  }
24
61
  export function getHookScriptPaths(adapter, pluginRoot) {
25
62
  const paths = new Set();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * TypeScript surface for the start.mjs plugin-cache integrity helper.
3
+ *
4
+ * The actual logic lives in `scripts/plugin-cache-integrity.mjs` (raw
5
+ * `.mjs` so start.mjs can import it without a TS toolchain at boot —
6
+ * #550 fail-fast happens BEFORE any bundle is loaded). This module is
7
+ * the bridge that lets TS consumers (claude-code adapter's
8
+ * getHealthChecks for Algo-D5, the cli doctor surface) call the same
9
+ * function without duplicating the implementation.
10
+ *
11
+ * Single source of truth: scripts/plugin-cache-integrity.mjs. Boot
12
+ * fail-fast (Algo-D4) and doctor diagnostic (Algo-D5) agree
13
+ * byte-for-byte because they call the same exported function.
14
+ *
15
+ * Top-level dynamic import is used (not a static `import` from `.mjs`)
16
+ * because the project is ESM and `import` of a sibling `.mjs` from a
17
+ * `.ts` file relies on the bundler / loader resolving `.mjs`
18
+ * extensions, which esbuild can do but tsc-only typecheck cannot. The
19
+ * dynamic import is resolved by the runtime (Node ESM) regardless of
20
+ * how the consumer was bundled. Errors are caught and surfaced as a
21
+ * FAIL detail — the helper is required to ship in the npm tarball
22
+ * (package.json files[]); a missing helper means the install is
23
+ * fundamentally broken.
24
+ */
25
+ /**
26
+ * Run the integrity check synchronously. If the helper module is
27
+ * still loading (not yet cached) returns a FAIL with detail
28
+ * "integrity helper not yet loaded" — caller should retry once the
29
+ * doctor command's IO is complete. In practice the doctor is invoked
30
+ * many MS after module load so this fallback is defensive only.
31
+ */
32
+ export declare function checkPluginCacheIntegritySync(pluginRoot: string): {
33
+ status: "OK" | "FAIL";
34
+ detail: string;
35
+ };
36
+ /** Force-await the helper load. Tests use this to deflake the eager fire-and-forget. */
37
+ export declare function ensurePluginCacheIntegrityLoaded(): Promise<void>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * TypeScript surface for the start.mjs plugin-cache integrity helper.
3
+ *
4
+ * The actual logic lives in `scripts/plugin-cache-integrity.mjs` (raw
5
+ * `.mjs` so start.mjs can import it without a TS toolchain at boot —
6
+ * #550 fail-fast happens BEFORE any bundle is loaded). This module is
7
+ * the bridge that lets TS consumers (claude-code adapter's
8
+ * getHealthChecks for Algo-D5, the cli doctor surface) call the same
9
+ * function without duplicating the implementation.
10
+ *
11
+ * Single source of truth: scripts/plugin-cache-integrity.mjs. Boot
12
+ * fail-fast (Algo-D4) and doctor diagnostic (Algo-D5) agree
13
+ * byte-for-byte because they call the same exported function.
14
+ *
15
+ * Top-level dynamic import is used (not a static `import` from `.mjs`)
16
+ * because the project is ESM and `import` of a sibling `.mjs` from a
17
+ * `.ts` file relies on the bundler / loader resolving `.mjs`
18
+ * extensions, which esbuild can do but tsc-only typecheck cannot. The
19
+ * dynamic import is resolved by the runtime (Node ESM) regardless of
20
+ * how the consumer was bundled. Errors are caught and surfaced as a
21
+ * FAIL detail — the helper is required to ship in the npm tarball
22
+ * (package.json files[]); a missing helper means the install is
23
+ * fundamentally broken.
24
+ */
25
+ let cached = null;
26
+ let cachedError = null;
27
+ async function loadHelper() {
28
+ if (cached)
29
+ return cached;
30
+ if (cachedError)
31
+ return null;
32
+ try {
33
+ // Resolve relative to this compiled file. After tsc emits to
34
+ // build/util/plugin-cache-integrity.js, the helper sits at
35
+ // ../../scripts/plugin-cache-integrity.mjs. After esbuild bundles
36
+ // src/cli.ts to cli.bundle.mjs at the repo root, the same relative
37
+ // path resolves to ./scripts/plugin-cache-integrity.mjs. Both
38
+ // shapes are walked here.
39
+ const candidates = [
40
+ new URL("../../scripts/plugin-cache-integrity.mjs", import.meta.url),
41
+ new URL("./scripts/plugin-cache-integrity.mjs", import.meta.url),
42
+ ];
43
+ let lastErr = null;
44
+ for (const url of candidates) {
45
+ try {
46
+ const mod = (await import(url.href));
47
+ if (typeof mod?.assertPluginCacheIntegrity === "function") {
48
+ cached = mod;
49
+ return cached;
50
+ }
51
+ }
52
+ catch (err) {
53
+ lastErr = err;
54
+ }
55
+ }
56
+ cachedError =
57
+ lastErr instanceof Error ? lastErr.message : String(lastErr ?? "not found");
58
+ return null;
59
+ }
60
+ catch (err) {
61
+ cachedError = err instanceof Error ? err.message : String(err);
62
+ return null;
63
+ }
64
+ }
65
+ // Eagerly start the load on module init so the first synchronous
66
+ // check() call can hit the cache. The promise is unawaited
67
+ // intentionally — by the time any HealthCheck.check() runs (doctor
68
+ // command, well after MCP server boot), the import has resolved.
69
+ void loadHelper();
70
+ /**
71
+ * Run the integrity check synchronously. If the helper module is
72
+ * still loading (not yet cached) returns a FAIL with detail
73
+ * "integrity helper not yet loaded" — caller should retry once the
74
+ * doctor command's IO is complete. In practice the doctor is invoked
75
+ * many MS after module load so this fallback is defensive only.
76
+ */
77
+ export function checkPluginCacheIntegritySync(pluginRoot) {
78
+ if (cached) {
79
+ const result = cached.assertPluginCacheIntegrity({ pluginRoot });
80
+ if (result.ok) {
81
+ return {
82
+ status: "OK",
83
+ detail: `${pluginRoot} (all required runtime siblings present)`,
84
+ };
85
+ }
86
+ return {
87
+ status: "FAIL",
88
+ detail: `missing: ${result.missing.join(", ")}`,
89
+ };
90
+ }
91
+ if (cachedError) {
92
+ return {
93
+ status: "FAIL",
94
+ detail: `integrity helper unavailable: ${cachedError}`,
95
+ };
96
+ }
97
+ return {
98
+ status: "FAIL",
99
+ detail: "integrity helper not yet loaded",
100
+ };
101
+ }
102
+ /** Force-await the helper load. Tests use this to deflake the eager fire-and-forget. */
103
+ export async function ensurePluginCacheIntegrityLoaded() {
104
+ await loadHelper();
105
+ }