@claude-flow/cli 3.48.0 → 3.50.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 (58) hide show
  1. package/.claude/helpers/helpers.manifest.json +4 -4
  2. package/.claude/helpers/hook-handler.cjs +20 -4
  3. package/.claude/helpers/{memory.js → memory.cjs} +1 -1
  4. package/.claude/helpers/{router.js → router.cjs} +1 -1
  5. package/.claude/helpers/{session.js → session.cjs} +1 -1
  6. package/README.md +1 -53
  7. package/catalog-manifest.json +4 -4
  8. package/dist/src/commands/doctor.d.ts +19 -1
  9. package/dist/src/commands/doctor.js +88 -9
  10. package/dist/src/commands/hooks.js +7 -4
  11. package/dist/src/commands/index.js +2 -0
  12. package/dist/src/commands/init.js +20 -0
  13. package/dist/src/commands/memory.js +30 -7
  14. package/dist/src/commands/mods.d.ts +13 -0
  15. package/dist/src/commands/mods.js +126 -0
  16. package/dist/src/commands/plugins.js +44 -7
  17. package/dist/src/commands/policy.js +5 -2
  18. package/dist/src/commands/session.js +128 -21
  19. package/dist/src/commands/swarm.js +11 -11
  20. package/dist/src/index.js +10 -1
  21. package/dist/src/init/executor.js +11 -5
  22. package/dist/src/init/helper-companions.d.ts +3 -0
  23. package/dist/src/init/helper-companions.js +34 -0
  24. package/dist/src/init/helper-integrity.d.ts +21 -0
  25. package/dist/src/init/helper-integrity.js +62 -0
  26. package/dist/src/init/helper-refresh.d.ts +18 -11
  27. package/dist/src/init/helper-refresh.js +56 -13
  28. package/dist/src/init/helpers-generator.js +22 -10
  29. package/dist/src/mcp-tools/hooks-tools.d.ts +16 -3
  30. package/dist/src/mcp-tools/hooks-tools.js +87 -14
  31. package/dist/src/mcp-tools/memory-tools.d.ts +7 -0
  32. package/dist/src/mcp-tools/memory-tools.js +43 -6
  33. package/dist/src/mcp-tools/session-tools.d.ts +15 -0
  34. package/dist/src/mcp-tools/session-tools.js +236 -50
  35. package/dist/src/memory/memory-initializer.js +6 -3
  36. package/dist/src/mods/claude-installs.d.ts +29 -0
  37. package/dist/src/mods/claude-installs.js +83 -0
  38. package/dist/src/mods/install.d.ts +64 -0
  39. package/dist/src/mods/install.js +135 -0
  40. package/dist/src/mods/policy-projection.d.ts +39 -0
  41. package/dist/src/mods/policy-projection.js +65 -0
  42. package/dist/src/mods/probe.d.ts +28 -0
  43. package/dist/src/mods/probe.js +118 -0
  44. package/dist/src/plugins/manager.d.ts +37 -10
  45. package/dist/src/plugins/manager.js +106 -20
  46. package/dist/src/plugins/trust-policy.d.ts +61 -0
  47. package/dist/src/plugins/trust-policy.js +84 -0
  48. package/dist/src/services/policy-runtime.d.ts +6 -0
  49. package/dist/src/services/policy-runtime.js +42 -2
  50. package/node_modules/@claude-flow/codex/package.json +1 -1
  51. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +2 -6
  52. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  53. package/node_modules/@claude-flow/security/dist/policy/engine.js +35 -1
  54. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  55. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +18 -0
  56. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  57. package/node_modules/@claude-flow/security/package.json +1 -1
  58. package/package.json +2 -2
@@ -0,0 +1,34 @@
1
+ /**
2
+ * CommonJS companion helpers that `hook-handler.cjs` requires but that are not
3
+ * in the signed critical set (#3555).
4
+ *
5
+ * They are CommonJS, so they ship as `.cjs`: a `.js` copy cannot load in a
6
+ * `"type":"module"` project. Installs made before the rename have only the
7
+ * `.js` copies, which the refreshed hook-handler no longer requires, so the
8
+ * refresh writes any missing `.cjs` companion from the CLI's own compiled
9
+ * generators (the trust root; no external file to verify). Stale `.js` copies
10
+ * are left in place and ignored. Existing `.cjs` files are never overwritten.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ export const COMMONJS_COMPANION_HELPERS = ['session.cjs', 'memory.cjs'];
15
+ export async function ensureCommonJsCompanions(helpersDir) {
16
+ const missing = COMMONJS_COMPANION_HELPERS.filter((name) => !fs.existsSync(path.join(helpersDir, name)));
17
+ if (missing.length === 0)
18
+ return [];
19
+ const gen = await import('./helpers-generator.js');
20
+ const content = {
21
+ 'session.cjs': gen.generateSessionManager,
22
+ 'memory.cjs': gen.generateMemoryHelper,
23
+ };
24
+ const written = [];
25
+ for (const name of missing) {
26
+ try {
27
+ fs.writeFileSync(path.join(helpersDir, name), content[name](), { encoding: 'utf-8', mode: 0o755 });
28
+ written.push(name);
29
+ }
30
+ catch { /* best-effort: hook-handler degrades gracefully without it */ }
31
+ }
32
+ return written;
33
+ }
34
+ //# sourceMappingURL=helper-companions.js.map
@@ -0,0 +1,21 @@
1
+ export interface IntegrityResult {
2
+ /** Critical helper names whose on-disk hash doesn't match the signed manifest
3
+ * (or that are missing from disk despite being part of the signed set). */
4
+ tampered: string[];
5
+ /** Set only when the package's OWN signed manifest can't be verified at all —
6
+ * there's no ground truth to check the installed files against, distinct
7
+ * from a clean `tampered: []` result. */
8
+ blocked?: string;
9
+ }
10
+ /**
11
+ * Re-verify each of `criticalHelpers` present on disk in `helpersDir` against
12
+ * `sourceDir`'s signed manifest. Read-only — safe to call without holding any
13
+ * refresh lock; callers only need to acquire one if `tampered` comes back
14
+ * non-empty and they intend to repair it.
15
+ *
16
+ * `sourceDir: null` (package source unresolvable) returns `{ tampered: [] }`
17
+ * — fails open rather than flagging every install as tampered when there is
18
+ * no ground truth at all to compare against.
19
+ */
20
+ export declare function verifyInstalledCriticalHelpers(helpersDir: string, sourceDir: string | null, criticalHelpers: readonly string[], pubkeyPemOverride?: string): IntegrityResult;
21
+ //# sourceMappingURL=helper-integrity.d.ts.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Post-install integrity re-verification for signed critical helpers (#3565).
3
+ *
4
+ * `helper-refresh.ts` verifies a helper's signature/hash once, at copy time,
5
+ * then stamps the target directory with the installed CLI version. Every
6
+ * later command short-circuited on "stamp already matches" with NO further
7
+ * verification — so a critical helper modified on disk after that point (a
8
+ * sibling package's postinstall, a stray older CLI process racing a write,
9
+ * direct tampering) stayed silently modified through any number of
10
+ * subsequent commands, forever. This module re-hashes the already-INSTALLED
11
+ * helpers against the signed manifest independent of the stamp, so callers
12
+ * can re-run it on every invocation rather than trusting a single past check.
13
+ *
14
+ * Deliberately has no opinion on WHAT to do about tampering (re-copy, warn,
15
+ * refuse) — that policy lives in helper-refresh.ts, which already owns the
16
+ * fail-closed verify-then-copy path this result feeds into to heal.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { verifyHelpersManifest, sha256Hex, HELPERS_MANIFEST_FILE, } from './helper-signing.js';
21
+ /**
22
+ * Re-verify each of `criticalHelpers` present on disk in `helpersDir` against
23
+ * `sourceDir`'s signed manifest. Read-only — safe to call without holding any
24
+ * refresh lock; callers only need to acquire one if `tampered` comes back
25
+ * non-empty and they intend to repair it.
26
+ *
27
+ * `sourceDir: null` (package source unresolvable) returns `{ tampered: [] }`
28
+ * — fails open rather than flagging every install as tampered when there is
29
+ * no ground truth at all to compare against.
30
+ */
31
+ export function verifyInstalledCriticalHelpers(helpersDir, sourceDir, criticalHelpers, pubkeyPemOverride) {
32
+ if (!sourceDir)
33
+ return { tampered: [] };
34
+ let trusted = null;
35
+ try {
36
+ trusted = verifyHelpersManifest(fs.readFileSync(path.join(sourceDir, HELPERS_MANIFEST_FILE), 'utf-8'), pubkeyPemOverride);
37
+ }
38
+ catch {
39
+ trusted = null;
40
+ }
41
+ if (!trusted)
42
+ return { tampered: [], blocked: 'signed helpers manifest missing or signature invalid' };
43
+ const tampered = [];
44
+ for (const name of criticalHelpers) {
45
+ const expected = trusted.files[name];
46
+ // Not part of THIS version's signed set (or source doesn't ship it) —
47
+ // nothing to compare the installed copy against.
48
+ if (!expected || !fs.existsSync(path.join(sourceDir, name)))
49
+ continue;
50
+ let actual = null;
51
+ try {
52
+ actual = sha256Hex(fs.readFileSync(path.join(helpersDir, name)));
53
+ }
54
+ catch {
55
+ actual = null;
56
+ }
57
+ if (actual !== expected)
58
+ tampered.push(name);
59
+ }
60
+ return { tampered };
61
+ }
62
+ //# sourceMappingURL=helper-integrity.js.map
@@ -10,6 +10,20 @@ interface RefreshOptions {
10
10
  lockRetryMsOverride?: number;
11
11
  malformedLockStaleMsOverride?: number;
12
12
  }
13
+ interface RefreshResult {
14
+ refreshed: boolean;
15
+ from?: string;
16
+ to?: string;
17
+ blocked?: string;
18
+ /** Set when `refreshed` was caused by #3565 healing, not a version bump —
19
+ * the stamp already matched but one or more critical helpers failed
20
+ * on-disk integrity verification and were re-copied from the verified
21
+ * source. */
22
+ healed?: boolean;
23
+ /** Critical helper names that failed integrity verification, present only
24
+ * alongside `healed: true`. */
25
+ tampered?: string[];
26
+ }
13
27
  /**
14
28
  * ruflo-owned helpers that carry hook logic (or the render surface for the
15
29
  * funnel disclosure row) and must track the package version. Adding to this
@@ -19,6 +33,8 @@ interface RefreshOptions {
19
33
  export declare const CRITICAL_HELPERS: string[];
20
34
  /** Installed @claude-flow/cli version — the value the helpers are stamped with. */
21
35
  export declare function getInstalledCliVersion(): string;
36
+ /** Locate the in-package `.claude/helpers` dir (the copy source). Null if not found. */
37
+ export declare function findPackageHelpersDir(): string | null;
22
38
  /**
23
39
  * On CLI startup, refresh critical helpers if their stamp is older than the
24
40
  * installed CLI version. Two passes:
@@ -70,17 +86,8 @@ export declare function getInstalledCliVersion(): string;
70
86
  * compat with pre-3.31.3 callers). If the global pass ran, its own result is
71
87
  * carried in the optional `global` field.
72
88
  */
73
- export declare function autoRefreshHelpersIfStale(cwd: string, opts?: RefreshOptions): Promise<{
74
- refreshed: boolean;
75
- from?: string;
76
- to?: string;
77
- blocked?: string;
78
- global?: {
79
- refreshed: boolean;
80
- from?: string;
81
- to?: string;
82
- blocked?: string;
83
- };
89
+ export declare function autoRefreshHelpersIfStale(cwd: string, opts?: RefreshOptions): Promise<RefreshResult & {
90
+ global?: RefreshResult;
84
91
  }>;
85
92
  export {};
86
93
  //# sourceMappingURL=helper-refresh.d.ts.map
@@ -20,6 +20,8 @@ import { fileURLToPath } from 'url';
20
20
  import { createRequire } from 'module';
21
21
  import * as semver from 'semver';
22
22
  import { verifyHelpersManifest, sha256Hex, HELPERS_MANIFEST_FILE, } from './helper-signing.js';
23
+ import { ensureCommonJsCompanions } from './helper-companions.js';
24
+ import { verifyInstalledCriticalHelpers } from './helper-integrity.js';
23
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
26
  /**
25
27
  * Walk up from `startDir` to the nearest ancestor whose `package.json` names
@@ -71,10 +73,11 @@ export const CRITICAL_HELPERS = [
71
73
  // statusline.cjs is here so the funnel disclosure row (ADR-301) reaches
72
74
  // existing installs on the next `ruflo` command, not only fresh `ruflo init`.
73
75
  'statusline.cjs',
74
- // router.js is loaded by hook-handler.cjs to label each prompt with an agent.
76
+ // router.cjs is loaded by hook-handler.cjs to label each prompt with an agent.
75
77
  // Without it here, installs kept the pre-#2257 substring router forever
76
- // ("latest" -> tester). ADR-389 / #3401.
77
- 'router.js',
78
+ // ("latest" -> tester). ADR-389 / #3401. It is CommonJS, so it ships as
79
+ // `.cjs`: a `.js` copy cannot load in a `"type":"module"` project (#3555).
80
+ 'router.cjs',
78
81
  ];
79
82
  function errorCode(error) {
80
83
  return typeof error === 'object' && error !== null && 'code' in error
@@ -230,7 +233,7 @@ export function getInstalledCliVersion() {
230
233
  }
231
234
  }
232
235
  /** Locate the in-package `.claude/helpers` dir (the copy source). Null if not found. */
233
- function findPackageHelpersDir() {
236
+ export function findPackageHelpersDir() {
234
237
  const candidates = [];
235
238
  try {
236
239
  const esmRequire = createRequire(import.meta.url);
@@ -313,7 +316,7 @@ async function writeCriticalHelpers(helpersDir, version, opts = {}) {
313
316
  'hook-handler.cjs': gen.generateHookHandler(),
314
317
  'intelligence.cjs': gen.generateIntelligenceStub(),
315
318
  'auto-memory-hook.mjs': gen.generateAutoMemoryHook(),
316
- 'router.js': gen.generateAgentRouter(), // ADR-389
319
+ 'router.cjs': gen.generateAgentRouter(), // ADR-389 / #3555
317
320
  // Fallback needs the same generator inputs `ruflo init` uses. We match the
318
321
  // hardcoded default (maxAgents 15) because the fallback fires when the
319
322
  // installed package is unresolvable — no way to read the user's project
@@ -339,9 +342,12 @@ async function writeCriticalHelpers(helpersDir, version, opts = {}) {
339
342
  }
340
343
  /**
341
344
  * On CLI startup: if an initialized project's critical helpers are stamped older
342
- * than the installed CLI version, silently re-copy them. Fast path is a single
343
- * stamp read + string compare (sub-ms); the copy runs at most once per version
344
- * bump. Best-effort, never throws. No-op outside a ruflo project (requires an
345
+ * than the installed CLI version, silently re-copy them. Fast path is a stamp
346
+ * read + string compare, PLUS a re-hash of each installed critical helper
347
+ * against the signed manifest (#3565) — still sub-ms for the handful of small
348
+ * files involved, and lock-free unless a mismatch is found. The copy runs at
349
+ * most once per version bump; the heal-on-tamper path can run on any call.
350
+ * Best-effort, never throws. No-op outside a ruflo project (requires an
345
351
  * existing hook-handler.cjs — never creates files in an unrelated directory).
346
352
  *
347
353
  * FORWARD-ONLY (never downgrades): refreshing on any mere INEQUALITY, rather
@@ -365,6 +371,9 @@ async function writeCriticalHelpers(helpersDir, version, opts = {}) {
365
371
  * `pubkeyPemOverride` let a test build its own tiny, throwaway-keypair-
366
372
  * signed fixture and get real, deterministic coverage of the verify → hash →
367
373
  * copy logic without depending on that.
374
+ *
375
+ * #3565: also re-verifies on every stamp-match call (see helper-integrity.ts)
376
+ * and heals a mismatch via this same verify-then-copy path.
368
377
  */
369
378
  async function refreshOneHelpersDirLocked(helpersDir, version, opts) {
370
379
  if (!fs.existsSync(path.join(helpersDir, 'hook-handler.cjs')))
@@ -377,7 +386,9 @@ async function refreshOneHelpersDirLocked(helpersDir, version, opts) {
377
386
  // on this repo (CLAUDE.md "Concurrent-session helper corruption"). The
378
387
  // existing semver.gte guard below still fires for normal installs — this
379
388
  // is the escape hatch for the small set of users editing helpers directly.
380
- // Applies to whichever dir this call is refreshing (project or global).
389
+ // Applies to whichever dir this call is refreshing (project or global),
390
+ // and also exempts it from the #3565 integrity re-check below — a
391
+ // deliberate local edit is not "tampering".
381
392
  if (fs.existsSync(path.join(helpersDir, '.LOCKED'))) {
382
393
  return { refreshed: false, blocked: '.LOCKED marker present — refresh skipped (delete to re-enable)' };
383
394
  }
@@ -386,8 +397,28 @@ async function refreshOneHelpersDirLocked(helpersDir, version, opts) {
386
397
  stamped = fs.readFileSync(path.join(helpersDir, HELPERS_STAMP_FILE), 'utf-8').trim();
387
398
  }
388
399
  catch { /* pre-feature: unstamped */ }
389
- if (stamped === version)
390
- return { refreshed: false }; // up to date — fast path
400
+ if (stamped === version) {
401
+ const source = opts.sourceDirOverride ?? findPackageHelpersDir();
402
+ const integrity = verifyInstalledCriticalHelpers(helpersDir, source, CRITICAL_HELPERS, opts.pubkeyPemOverride);
403
+ if (integrity.blocked)
404
+ return { refreshed: false, blocked: integrity.blocked };
405
+ if (integrity.tampered.length === 0)
406
+ return { refreshed: false }; // up to date AND verified intact
407
+ // Tampering detected post-install — heal via the same fail-closed
408
+ // verify-then-copy path a version-bump refresh already uses.
409
+ await opts.beforeWriteOverride?.();
410
+ const healRes = await writeCriticalHelpers(helpersDir, version, {
411
+ sourceDirOverride: opts.sourceDirOverride,
412
+ pubkeyPemOverride: opts.pubkeyPemOverride,
413
+ });
414
+ if (healRes.blocked)
415
+ return { refreshed: false, blocked: healRes.blocked };
416
+ if (healRes.wrote)
417
+ await ensureCommonJsCompanions(helpersDir); // #3555
418
+ return healRes.wrote
419
+ ? { refreshed: true, healed: true, tampered: integrity.tampered, from: stamped, to: version }
420
+ : { refreshed: false };
421
+ }
391
422
  if (stamped && semver.valid(stamped) && semver.valid(version) && semver.gte(stamped, version)) {
392
423
  // Stamped version is already >= what this binary reports — refreshing
393
424
  // would silently DOWNGRADE the helpers. Skip, untouched.
@@ -400,6 +431,8 @@ async function refreshOneHelpersDirLocked(helpersDir, version, opts) {
400
431
  });
401
432
  if (res.blocked)
402
433
  return { refreshed: false, blocked: res.blocked };
434
+ if (res.wrote)
435
+ await ensureCommonJsCompanions(helpersDir); // #3555
403
436
  return res.wrote ? { refreshed: true, from: stamped || '(unstamped)', to: version } : { refreshed: false };
404
437
  }
405
438
  async function refreshOneHelpersDir(helpersDir, version, opts) {
@@ -412,8 +445,18 @@ async function refreshOneHelpersDir(helpersDir, version, opts) {
412
445
  return { refreshed: false, blocked: '.LOCKED marker present — refresh skipped (delete to re-enable)' };
413
446
  }
414
447
  try {
415
- if (fs.readFileSync(path.join(helpersDir, HELPERS_STAMP_FILE), 'utf-8').trim() === version)
416
- return { refreshed: false };
448
+ if (fs.readFileSync(path.join(helpersDir, HELPERS_STAMP_FILE), 'utf-8').trim() === version) {
449
+ // #3565: re-hash before trusting a stamp match (helper-integrity.ts).
450
+ // Read-only — no lock needed unless something's actually wrong; the
451
+ // (rare) heal path below re-verifies under lock.
452
+ const source = opts.sourceDirOverride ?? findPackageHelpersDir();
453
+ const integrity = verifyInstalledCriticalHelpers(helpersDir, source, CRITICAL_HELPERS, opts.pubkeyPemOverride);
454
+ if (integrity.blocked)
455
+ return { refreshed: false, blocked: integrity.blocked };
456
+ if (integrity.tampered.length === 0)
457
+ return { refreshed: false };
458
+ // else: tampering found — fall through to acquire the lock and heal.
459
+ }
417
460
  }
418
461
  catch { /* unstamped: continue to the locked path */ }
419
462
  const releaseLock = await acquireRefreshLock(helpersDir, opts);
@@ -242,7 +242,7 @@ const [,, command, ...args] = process.argv;
242
242
  if (command && commands[command]) {
243
243
  commands[command](...args);
244
244
  } else {
245
- console.log('Usage: session.js <start|restore|end|status|update|metric> [args]');
245
+ console.log('Usage: session.cjs <start|restore|end|status|update|metric> [args]');
246
246
  }
247
247
 
248
248
  module.exports = commands;
@@ -336,7 +336,7 @@ if (require.main === module) {
336
336
  const result = routeTask(task);
337
337
  console.log(JSON.stringify(result, null, 2));
338
338
  } else {
339
- console.log('Usage: router.js <task description>');
339
+ console.log('Usage: router.cjs <task description>');
340
340
  console.log('\\nAvailable agents:', Object.keys(AGENT_CAPABILITIES).join(', '));
341
341
  }
342
342
  }
@@ -427,7 +427,7 @@ const value = valueParts.join(' ');
427
427
  if (command && commands[command]) {
428
428
  commands[command](key, value);
429
429
  } else {
430
- console.log('Usage: memory.js <get|set|delete|clear|keys> [key] [value]');
430
+ console.log('Usage: memory.cjs <get|set|delete|clear|keys> [key] [value]');
431
431
  }
432
432
 
433
433
  module.exports = commands;
@@ -504,9 +504,9 @@ export function generateHookHandler() {
504
504
  ' return null;',
505
505
  '}',
506
506
  '',
507
- "const router = safeRequire(path.join(helpersDir, 'router.js'));",
508
- "const session = safeRequire(path.join(helpersDir, 'session.js'));",
509
- "const memory = safeRequire(path.join(helpersDir, 'memory.js'));",
507
+ "const router = safeRequire(path.join(helpersDir, 'router.cjs'));",
508
+ "const session = safeRequire(path.join(helpersDir, 'session.cjs'));",
509
+ "const memory = safeRequire(path.join(helpersDir, 'memory.cjs'));",
510
510
  "const intelligence = safeRequire(path.join(helpersDir, 'intelligence.cjs'));",
511
511
  '',
512
512
  'const [,, command, ...args] = process.argv;',
@@ -530,7 +530,19 @@ export function generateHookHandler() {
530
530
  ' });',
531
531
  '}',
532
532
  '',
533
+ // ADR-404: same handshake as the shipped hook-handler.cjs, so a helper
534
+ // regenerated by the refresh fallback still hands route/post-edit to the mod.
535
+ '// ADR-404: the ruflo mod names the events it runs in-process in',
536
+ '// RUFLO_MODS_OWNS; only side-effect events hand over, guards always run.',
537
+ "const MOD_OWNABLE_EVENTS = new Set(['route', 'post-edit']);",
538
+ 'function ownedByMod(cmd, env) {',
539
+ ' env = env || process.env;',
540
+ ' if (!MOD_OWNABLE_EVENTS.has(cmd)) return false;',
541
+ " return String(env.RUFLO_MODS_OWNS || '').split(',').some(function (owned) { return owned.trim() === cmd; });",
542
+ '}',
543
+ '',
533
544
  'async function main() {',
545
+ ' if (ownedByMod(command)) return;',
534
546
  ' let stdinData = "";',
535
547
  ' try { stdinData = await readStdin(); } catch (e) { /* ignore */ }',
536
548
  ' let hookInput = {};',
@@ -1358,7 +1370,7 @@ const [,, command, ...args] = process.argv;
1358
1370
  if (command && commands[command]) {
1359
1371
  commands[command](...args);
1360
1372
  } else {
1361
- console.log('Usage: session.js <start|restore|end|status>');
1373
+ console.log('Usage: session.cjs <start|restore|end|status>');
1362
1374
  console.log(\`Platform: \${platform}\`);
1363
1375
  console.log(\`Data dir: \${SESSION_DIR}\`);
1364
1376
  }
@@ -1376,9 +1388,9 @@ export function generateHelpers(options) {
1376
1388
  helpers['pre-commit'] = generatePreCommitHook();
1377
1389
  helpers['post-commit'] = generatePostCommitHook();
1378
1390
  // Cross-platform Node.js scripts
1379
- helpers['session.js'] = generateCrossPlatformSessionManager();
1380
- helpers['router.js'] = generateAgentRouter();
1381
- helpers['memory.js'] = generateMemoryHelper();
1391
+ helpers['session.cjs'] = generateCrossPlatformSessionManager();
1392
+ helpers['router.cjs'] = generateAgentRouter();
1393
+ helpers['memory.cjs'] = generateMemoryHelper();
1382
1394
  // Windows-specific scripts
1383
1395
  helpers['daemon-manager.ps1'] = generateWindowsDaemonManager();
1384
1396
  helpers['daemon-manager.cmd'] = generateWindowsBatchWrapper();
@@ -12,11 +12,24 @@ import { type RouterEmbedderKind } from '../ruvector/router-embedder.js';
12
12
  export declare function scrubReasoningBlocks(text: string): string;
13
13
  /** Test hook: drop the cached semantic index so the next route rebuilds it. */
14
14
  export declare function resetSemanticRouterForTests(): void;
15
- /** Exported for tests. */
16
- export declare function suggestAgentsForTask(task: string): {
15
+ /**
16
+ * Confidence for a task nothing matched (#3567). It must rank below every real
17
+ * signal: keyword hits are >= 0.8, learned outcomes >= 0.7, and a semantic
18
+ * match is only eligible above 0.4. Matches the helper router's 0.3 fall-through.
19
+ */
20
+ export declare const NO_MATCH_CONFIDENCE = 0.3;
21
+ export interface TaskAgentSuggestion {
17
22
  agents: string[];
18
23
  confidence: number;
19
- };
24
+ /** false when no keyword or learned pattern matched; the agents are a default, not a decision. */
25
+ matched: boolean;
26
+ reason?: 'no-match-default';
27
+ note?: string;
28
+ }
29
+ /** True when the task has at least one letter or digit in any script. */
30
+ export declare function hasRoutableText(task: string): boolean;
31
+ /** Exported for tests. */
32
+ export declare function suggestAgentsForTask(task: string): TaskAgentSuggestion;
20
33
  export declare const hooksPreEdit: MCPTool;
21
34
  export declare const hooksPostEdit: MCPTool;
22
35
  export declare const hooksPreCommand: MCPTool;
@@ -722,12 +722,41 @@ const KEYWORD_MATCHERS = Object.entries(KEYWORD_PATTERNS).map(([keyword, result]
722
722
  const body = /[\s/]/.test(keyword) ? escaped : `${escaped}(?:s|es|ing|ed)?`;
723
723
  return { regex: new RegExp(`\\b${body}\\b`, 'i'), result };
724
724
  });
725
+ /**
726
+ * Confidence for a task nothing matched (#3567). It must rank below every real
727
+ * signal: keyword hits are >= 0.8, learned outcomes >= 0.7, and a semantic
728
+ * match is only eligible above 0.4. Matches the helper router's 0.3 fall-through.
729
+ */
730
+ export const NO_MATCH_CONFIDENCE = 0.3;
731
+ /** True when the task has at least one letter or digit in any script. */
732
+ export function hasRoutableText(task) {
733
+ return /[\p{L}\p{N}]/u.test(task);
734
+ }
735
+ // Keyword matchers are English regexes; letters outside Latin script can never hit them.
736
+ const NON_LATIN_LETTER = /(?![A-Za-zÀ-ɏ])\p{L}/u;
737
+ function noMatchNote(task) {
738
+ if (!hasRoutableText(task))
739
+ return 'Task has no words to match.';
740
+ if (NON_LATIN_LETTER.test(task)) {
741
+ return 'Keyword matchers are English-only, so non-English text cannot match them.';
742
+ }
743
+ return 'No keyword or learned pattern matched.';
744
+ }
725
745
  /** Exported for tests. */
726
746
  export function suggestAgentsForTask(task) {
747
+ const noMatch = () => ({
748
+ agents: ['coder', 'researcher', 'tester'],
749
+ confidence: NO_MATCH_CONFIDENCE,
750
+ matched: false,
751
+ reason: 'no-match-default',
752
+ note: noMatchNote(task),
753
+ });
754
+ if (!hasRoutableText(task))
755
+ return noMatch();
727
756
  // Check static keyword patterns first
728
757
  for (const { regex, result } of KEYWORD_MATCHERS) {
729
758
  if (regex.test(task)) {
730
- return result;
759
+ return { ...result, matched: true };
731
760
  }
732
761
  }
733
762
  // Check runtime-learned patterns from successful task outcomes
@@ -747,11 +776,10 @@ export function suggestAgentsForTask(task) {
747
776
  }
748
777
  // Require at least 2 keyword overlap to prevent false positives
749
778
  if (bestAgent && bestOverlap >= 2) {
750
- return { agents: [bestAgent], confidence: Math.min(0.6 + bestOverlap * 0.05, 0.85) };
779
+ return { agents: [bestAgent], confidence: Math.min(0.6 + bestOverlap * 0.05, 0.85), matched: true };
751
780
  }
752
781
  }
753
- // Default fallback
754
- return { agents: ['coder', 'researcher', 'tester'], confidence: 0.7 };
782
+ return noMatch();
755
783
  }
756
784
  function assessCommandRisk(command) {
757
785
  const warnings = [];
@@ -1205,6 +1233,7 @@ async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride
1205
1233
  let agents;
1206
1234
  let confidence;
1207
1235
  let matchedPattern = '';
1236
+ let noMatchDetail = null;
1208
1237
  // Both static and learned patterns are gated on the same similarity
1209
1238
  // score. Learned patterns additionally require support/reliability as a
1210
1239
  // quality guard, but do NOT need a higher score bar — a learned pattern
@@ -1212,7 +1241,9 @@ async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride
1212
1241
  // (#2864: a 25pp higher threshold made a top-scoring learned-researcher
1213
1242
  // match at 0.57 lose to a static match at 0.52, discarding the learned
1214
1243
  // store's output on the majority of routes).
1215
- const eligibleSemantic = semanticResult.find((match) => {
1244
+ // A task with no letters or digits has nothing for the embedder to mean;
1245
+ // any similarity it scores is noise, so it never becomes a semantic match.
1246
+ const eligibleSemantic = !hasRoutableText(task) ? undefined : semanticResult.find((match) => {
1216
1247
  if (match.score <= 0.4)
1217
1248
  return false;
1218
1249
  const learned = match.intent.startsWith('learned-') || match.metadata.source === 'learned';
@@ -1232,9 +1263,16 @@ async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride
1232
1263
  const suggestion = suggestAgentsForTask(task);
1233
1264
  agents = suggestion.agents;
1234
1265
  confidence = suggestion.confidence;
1235
- matchedPattern = 'keyword-fallback';
1236
1266
  routingMethod = 'keyword';
1237
- backendInfo = 'keyword matching';
1267
+ if (suggestion.matched) {
1268
+ matchedPattern = 'keyword-fallback';
1269
+ backendInfo = 'keyword matching';
1270
+ }
1271
+ else {
1272
+ matchedPattern = 'no-match-default';
1273
+ backendInfo = 'keyword matching (no match)';
1274
+ noMatchDetail = suggestion.note ?? 'No pattern matched.';
1275
+ }
1238
1276
  }
1239
1277
  // Determine complexity
1240
1278
  const taskLower = task.toLowerCase();
@@ -1252,6 +1290,9 @@ async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride
1252
1290
  throughput: routingLatencyMs > 0 ? `${Math.round(1000 / routingLatencyMs)} routes/s` : 'N/A',
1253
1291
  },
1254
1292
  matchedPattern,
1293
+ // #3567: callers branch on `matched`; a no-match result is a default, not a decision.
1294
+ matched: noMatchDetail === null,
1295
+ ...(noMatchDetail !== null ? { reason: 'no-match-default', note: noMatchDetail } : {}),
1255
1296
  semanticMatches: semanticResult.slice(0, 3).map(r => ({
1256
1297
  pattern: r.intent,
1257
1298
  score: Math.round(r.score * 100) / 100,
@@ -1261,15 +1302,18 @@ async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride
1261
1302
  confidence: Math.round(confidence * 100) / 100,
1262
1303
  reason: routingMethod.startsWith('semantic')
1263
1304
  ? `Semantic similarity to "${matchedPattern}" pattern (${Math.round(confidence * 100)}%)`
1264
- : `Task contains keywords matching ${agents[0]} specialization`,
1305
+ : noMatchDetail !== null
1306
+ ? `Nothing matched; default suggestion only. ${noMatchDetail}`
1307
+ : `Task contains keywords matching ${agents[0]} specialization`,
1265
1308
  },
1266
1309
  alternativeAgents: agents.slice(1).map((agent, i) => ({
1267
1310
  type: agent,
1268
- confidence: Math.round((confidence - (0.1 * (i + 1))) * 100) / 100,
1269
- reason: `Alternative agent for ${agent} capabilities`,
1311
+ confidence: Math.max(0, Math.round((confidence - (0.1 * (i + 1))) * 100) / 100),
1312
+ reason: noMatchDetail !== null ? 'Default suggestion (nothing matched)' : `Alternative agent for ${agent} capabilities`,
1270
1313
  })),
1271
1314
  estimatedMetrics: {
1272
- successProbability: Math.round(confidence * 100) / 100,
1315
+ // No pattern means no basis for a success estimate.
1316
+ successProbability: noMatchDetail !== null ? null : Math.round(confidence * 100) / 100,
1273
1317
  estimatedDuration: complexity === 'high' ? '2-4 hours' : complexity === 'medium' ? '30-60 min' : '10-30 min',
1274
1318
  complexity,
1275
1319
  },
@@ -1506,12 +1550,15 @@ export const hooksPreTask = {
1506
1550
  return {
1507
1551
  taskId,
1508
1552
  description,
1553
+ agentsMatched: suggestion.matched,
1509
1554
  suggestedAgents: suggestion.agents.map((agent, i) => ({
1510
1555
  type: agent,
1511
1556
  confidence: suggestion.confidence - (0.05 * i),
1512
- reason: i === 0
1513
- ? `Primary agent for ${agent} tasks based on learned patterns`
1514
- : `Alternative agent with ${agent} capabilities`,
1557
+ reason: !suggestion.matched
1558
+ ? 'Default suggestion (nothing matched)'
1559
+ : i === 0
1560
+ ? `Primary agent for ${agent} tasks based on learned patterns`
1561
+ : `Alternative agent with ${agent} capabilities`,
1515
1562
  })),
1516
1563
  complexity,
1517
1564
  estimatedDuration: complexity === 'high' ? '2-4 hours' : complexity === 'medium' ? '30-60 min' : '10-30 min',
@@ -1856,8 +1903,34 @@ export const hooksExplain = {
1856
1903
  catch {
1857
1904
  // File unreadable; leave as null
1858
1905
  }
1906
+ // #3567: when nothing matched, say so instead of explaining a match that did not happen.
1907
+ if (!suggestion.matched) {
1908
+ return {
1909
+ task,
1910
+ matched: false,
1911
+ reason: 'no-match-default',
1912
+ explanation: `No keyword or learned pattern matched this task. ${suggestion.note ?? ''} ` +
1913
+ `"${suggestion.agents[0]}" is a default suggestion, not a routing decision.`.trim(),
1914
+ factors: [
1915
+ { factor: 'Keyword Match', weight: 0.4, value: null, impact: 'No keyword matched' },
1916
+ { factor: 'Historical Success', weight: 0.3, value: historicalSuccess, impact: historicalNote },
1917
+ { factor: 'Agent Availability', weight: 0.2, value: null, impact: 'Agent availability tracking not implemented' },
1918
+ { factor: 'Task Complexity', weight: 0.1, value: task.length > 100 ? 0.8 : 0.3, impact: 'Complexity assessment' },
1919
+ ],
1920
+ patterns: matchedPatterns,
1921
+ decision: {
1922
+ agent: suggestion.agents[0],
1923
+ confidence: suggestion.confidence,
1924
+ reasoning: [
1925
+ 'No pattern matched; the agent is a default suggestion',
1926
+ `Confidence ${(suggestion.confidence * 100).toFixed(0)}% is below every real match`,
1927
+ ],
1928
+ },
1929
+ };
1930
+ }
1859
1931
  return {
1860
1932
  task,
1933
+ matched: true,
1861
1934
  explanation: `The routing decision was made based on keyword analysis of the task description. ` +
1862
1935
  `The task contains keywords that match the "${suggestion.agents[0]}" specialization with ${(suggestion.confidence * 100).toFixed(0)}% confidence.`,
1863
1936
  factors: [
@@ -10,5 +10,12 @@
10
10
  * @module v3/cli/mcp-tools/memory-tools
11
11
  */
12
12
  import type { MCPTool } from './types.js';
13
+ export declare const DANGEROUS_KEY_PATTERN: RegExp;
14
+ /**
15
+ * #3570: the one key rule for every memory write path (MCP store, CLI store,
16
+ * import). Plain `/` stays legal (`probe/x`); traversal and shell metacharacters
17
+ * do not. Returns the error message, or null when the key is acceptable.
18
+ */
19
+ export declare function memoryKeyError(key: string): string | null;
13
20
  export declare const memoryTools: MCPTool[];
14
21
  //# sourceMappingURL=memory-tools.d.ts.map