claude-flow 3.48.0 → 3.49.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 (51) hide show
  1. package/.claude/.proven-config-version +1 -0
  2. package/.claude/helpers/hook-handler.cjs +7 -4
  3. package/.claude/helpers/memory.cjs +1 -1
  4. package/.claude/helpers/router.cjs +1 -1
  5. package/.claude/helpers/session.cjs +1 -1
  6. package/.claude/proven-config.json +42 -0
  7. package/.claude-plugin/marketplace.json +16 -1
  8. package/README.md +1 -53
  9. package/README.zh-CN.md +1 -53
  10. package/node_modules/@claude-flow/codex/package.json +1 -1
  11. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +2 -6
  12. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  13. package/node_modules/@claude-flow/security/dist/policy/engine.js +35 -1
  14. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  15. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +18 -0
  16. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  17. package/node_modules/@claude-flow/security/package.json +1 -1
  18. package/package.json +2 -2
  19. package/v3/@claude-flow/cli/README.md +3 -53
  20. package/v3/@claude-flow/cli/catalog-manifest.json +4 -4
  21. package/v3/@claude-flow/cli/dist/src/commands/doctor.d.ts +19 -1
  22. package/v3/@claude-flow/cli/dist/src/commands/doctor.js +69 -8
  23. package/v3/@claude-flow/cli/dist/src/commands/hooks.js +7 -4
  24. package/v3/@claude-flow/cli/dist/src/commands/memory.js +30 -7
  25. package/v3/@claude-flow/cli/dist/src/commands/plugins.js +44 -7
  26. package/v3/@claude-flow/cli/dist/src/commands/policy.js +5 -2
  27. package/v3/@claude-flow/cli/dist/src/commands/session.js +128 -21
  28. package/v3/@claude-flow/cli/dist/src/commands/swarm.js +11 -11
  29. package/v3/@claude-flow/cli/dist/src/index.js +10 -1
  30. package/v3/@claude-flow/cli/dist/src/init/executor.js +11 -5
  31. package/v3/@claude-flow/cli/dist/src/init/helper-companions.d.ts +3 -0
  32. package/v3/@claude-flow/cli/dist/src/init/helper-companions.js +34 -0
  33. package/v3/@claude-flow/cli/dist/src/init/helper-integrity.d.ts +21 -0
  34. package/v3/@claude-flow/cli/dist/src/init/helper-integrity.js +62 -0
  35. package/v3/@claude-flow/cli/dist/src/init/helper-refresh.d.ts +18 -11
  36. package/v3/@claude-flow/cli/dist/src/init/helper-refresh.js +56 -13
  37. package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +10 -10
  38. package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.d.ts +16 -3
  39. package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +87 -14
  40. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.d.ts +7 -0
  41. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +43 -6
  42. package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.d.ts +15 -0
  43. package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.js +236 -50
  44. package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +6 -3
  45. package/v3/@claude-flow/cli/dist/src/plugins/manager.d.ts +37 -10
  46. package/v3/@claude-flow/cli/dist/src/plugins/manager.js +106 -20
  47. package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.d.ts +61 -0
  48. package/v3/@claude-flow/cli/dist/src/plugins/trust-policy.js +84 -0
  49. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.d.ts +6 -0
  50. package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +27 -2
  51. package/v3/@claude-flow/cli/package.json +2 -2
@@ -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;',
@@ -1358,7 +1358,7 @@ const [,, command, ...args] = process.argv;
1358
1358
  if (command && commands[command]) {
1359
1359
  commands[command](...args);
1360
1360
  } else {
1361
- console.log('Usage: session.js <start|restore|end|status>');
1361
+ console.log('Usage: session.cjs <start|restore|end|status>');
1362
1362
  console.log(\`Platform: \${platform}\`);
1363
1363
  console.log(\`Data dir: \${SESSION_DIR}\`);
1364
1364
  }
@@ -1376,9 +1376,9 @@ export function generateHelpers(options) {
1376
1376
  helpers['pre-commit'] = generatePreCommitHook();
1377
1377
  helpers['post-commit'] = generatePostCommitHook();
1378
1378
  // Cross-platform Node.js scripts
1379
- helpers['session.js'] = generateCrossPlatformSessionManager();
1380
- helpers['router.js'] = generateAgentRouter();
1381
- helpers['memory.js'] = generateMemoryHelper();
1379
+ helpers['session.cjs'] = generateCrossPlatformSessionManager();
1380
+ helpers['router.cjs'] = generateAgentRouter();
1381
+ helpers['memory.cjs'] = generateMemoryHelper();
1382
1382
  // Windows-specific scripts
1383
1383
  helpers['daemon-manager.ps1'] = generateWindowsDaemonManager();
1384
1384
  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
@@ -42,7 +42,15 @@ const MAX_QUERY_LENGTH = 4096;
42
42
  // validateMemoryInput. Imported by sanitizeMemoryKey so write-side sanitization
43
43
  // and read-side rejection can never drift apart (the symmetry bug behind #1884).
44
44
  const DANGEROUS_KEY_CHARS = /[;&|`$(){}[\]<>!#\\\0]|\.\.[/\\]/g;
45
- const DANGEROUS_KEY_PATTERN = /[;&|`$(){}[\]<>!#\\\0]|\.\.[/\\]/;
45
+ export const DANGEROUS_KEY_PATTERN = /[;&|`$(){}[\]<>!#\\\0]|\.\.[/\\]/;
46
+ /**
47
+ * #3570: the one key rule for every memory write path (MCP store, CLI store,
48
+ * import). Plain `/` stays legal (`probe/x`); traversal and shell metacharacters
49
+ * do not. Returns the error message, or null when the key is acceptable.
50
+ */
51
+ export function memoryKeyError(key) {
52
+ return DANGEROUS_KEY_PATTERN.test(key) ? 'Key contains disallowed characters' : null;
53
+ }
46
54
  function validateMemoryInput(key, value, query, namespace) {
47
55
  if (key && key.length > MAX_KEY_LENGTH) {
48
56
  throw new Error(`Key exceeds maximum length of ${MAX_KEY_LENGTH} characters`);
@@ -54,9 +62,9 @@ function validateMemoryInput(key, value, query, namespace) {
54
62
  throw new Error(`Query exceeds maximum length of ${MAX_QUERY_LENGTH} characters`);
55
63
  }
56
64
  // Reject path traversal and shell metacharacters in keys/namespaces (#1425)
57
- if (key && DANGEROUS_KEY_PATTERN.test(key)) {
58
- throw new Error('Key contains disallowed characters');
59
- }
65
+ const keyError = key ? memoryKeyError(key) : null;
66
+ if (keyError)
67
+ throw new Error(keyError);
60
68
  if (namespace && DANGEROUS_KEY_PATTERN.test(namespace)) {
61
69
  throw new Error('Namespace contains disallowed characters');
62
70
  }
@@ -413,6 +421,11 @@ export const memoryTools = [
413
421
  };
414
422
  }
415
423
  validateMemoryInput(key, value, undefined, namespace);
424
+ // #3570: a namespace written here must be exportable and purgeable, so it
425
+ // passes the same validator export and purge use.
426
+ const vNs = validateIdentifier(namespace, 'namespace');
427
+ if (!vNs.valid)
428
+ throw new Error(vNs.error);
416
429
  const startTime = performance.now();
417
430
  try {
418
431
  const result = await storeEntry({
@@ -1542,6 +1555,9 @@ export const memoryTools = [
1542
1555
  await ensureInitialized(dbPath);
1543
1556
  const { storeEntry } = await getMemoryFunctions();
1544
1557
  const t0 = Date.now();
1558
+ // Values are re-embedded on import; count the vectors actually written
1559
+ // rather than reporting a constant 0 next to entries that show a vector.
1560
+ let vectors = 0;
1545
1561
  const inputPath = String(input.inputPath ?? '');
1546
1562
  if (!inputPath || !existsSync(inputPath))
1547
1563
  return { error: `File not found: ${inputPath || '(empty)'}` };
@@ -1559,6 +1575,24 @@ export const memoryTools = [
1559
1575
  if (!v.valid)
1560
1576
  throw new Error(v.error);
1561
1577
  }
1578
+ // #3570: validate every entry's namespace up front so a bad file writes nothing.
1579
+ if (!nsOverride) {
1580
+ for (const e of entries) {
1581
+ if (e && typeof e.key === 'string' && e.namespace !== undefined) {
1582
+ const v = validateIdentifier(String(e.namespace), 'namespace');
1583
+ if (!v.valid)
1584
+ throw new Error(v.error);
1585
+ }
1586
+ }
1587
+ }
1588
+ // #3570 follow-up: keys get the same up-front, all-or-nothing check.
1589
+ for (const e of entries) {
1590
+ if (e && typeof e.key === 'string') {
1591
+ const keyError = memoryKeyError(e.key);
1592
+ if (keyError)
1593
+ throw new Error(`${keyError}: ${JSON.stringify(e.key)}`);
1594
+ }
1595
+ }
1562
1596
  let imported = 0;
1563
1597
  let skipped = 0;
1564
1598
  for (const e of entries) {
@@ -1569,8 +1603,11 @@ export const memoryTools = [
1569
1603
  const value = typeof e.value === 'string' ? e.value : JSON.stringify(e.value ?? null);
1570
1604
  try {
1571
1605
  const result = await storeEntry({ key: e.key, value, namespace: nsOverride ?? e.namespace ?? 'default', upsert: input.merge !== false, dbPath });
1572
- if (result.success)
1606
+ if (result.success) {
1573
1607
  imported++;
1608
+ if (result.embedding)
1609
+ vectors++;
1610
+ }
1574
1611
  else
1575
1612
  skipped++;
1576
1613
  }
@@ -1580,7 +1617,7 @@ export const memoryTools = [
1580
1617
  }
1581
1618
  return {
1582
1619
  inputPath,
1583
- imported: { entries: imported, vectors: 0, patterns: 0 },
1620
+ imported: { entries: imported, vectors, patterns: 0 },
1584
1621
  skipped,
1585
1622
  duration: Date.now() - t0,
1586
1623
  };
@@ -4,5 +4,20 @@
4
4
  * Tool definitions for session management with file persistence.
5
5
  */
6
6
  import { type MCPTool } from './types.js';
7
+ /**
8
+ * #3573: what session_save learned about memory, so callers can say
9
+ * "not included" or "no store" instead of printing an invented 0.
10
+ */
11
+ export interface MemoryCaptureReport {
12
+ requested: boolean;
13
+ status: 'not-requested' | 'captured' | 'no-store' | 'error';
14
+ entries: number;
15
+ sources: {
16
+ memoryDb: number;
17
+ agentdb: number;
18
+ legacyJson: number;
19
+ };
20
+ error?: string;
21
+ }
7
22
  export declare const sessionTools: MCPTool[];
8
23
  //# sourceMappingURL=session-tools.d.ts.map