@claude-flow/cli 3.42.5 → 3.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,8 @@ import { getProjectCwd } from './types.js';
9
9
  import { validateIdentifier, validateText, validatePath } from './validate-input.js';
10
10
  import { checkCommandLoop, recordCommandOutcome } from './tool-loop-guardrail.js';
11
11
  import { buildLearnedRoutingPatterns, } from '../services/learned-routing.js';
12
+ import { applyTypesafeRouting, getTypesafeRouter } from '../ruvector/typesafe-router.js';
13
+ import { DEFAULT_ROUTER_EMBEDDER, embedForRouter, resolveRouterEmbedder, } from '../ruvector/router-embedder.js';
12
14
  // Real vector search functions - lazy loaded to avoid circular imports
13
15
  let searchEntriesFn = null;
14
16
  /**
@@ -107,46 +109,16 @@ async function getMoERouter() {
107
109
  // Tries native VectorDb first (16k+ routes/s HNSW), falls back to pure JS (47k routes/s cosine)
108
110
  let semanticRouter = null;
109
111
  let nativeVectorDb = null;
110
- let semanticRouterInitialized = false;
111
112
  let routerBackend = 'none';
112
113
  // Pre-computed embeddings for common task patterns (cached)
113
114
  const TASK_PATTERN_EMBEDDINGS = new Map();
114
- function generateSimpleEmbedding(text, dimension = 384) {
115
- // Simple deterministic embedding based on character codes
116
- // This is for routing purposes where we need consistent, fast embeddings
117
- const embedding = new Float32Array(dimension);
118
- const normalized = text.toLowerCase().replace(/[^a-z0-9\s]/g, '');
119
- const words = normalized.split(/\s+/).filter(w => w.length > 0);
120
- // Combine word-level and character-level features
121
- for (let i = 0; i < dimension; i++) {
122
- let value = 0;
123
- // Word-level features
124
- for (let w = 0; w < words.length; w++) {
125
- const word = words[w];
126
- for (let c = 0; c < word.length; c++) {
127
- const charCode = word.charCodeAt(c);
128
- value += Math.sin((charCode * (i + 1) + w * 17 + c * 23) * 0.0137);
129
- }
130
- }
131
- // Character-level features
132
- for (let c = 0; c < text.length; c++) {
133
- value += Math.cos((text.charCodeAt(c) * (i + 1) + c * 7) * 0.0073);
134
- }
135
- embedding[i] = value / Math.max(1, text.length);
136
- }
137
- // Normalize
138
- let norm = 0;
139
- for (let i = 0; i < dimension; i++) {
140
- norm += embedding[i] * embedding[i];
141
- }
142
- norm = Math.sqrt(norm);
143
- if (norm > 0) {
144
- for (let i = 0; i < dimension; i++) {
145
- embedding[i] /= norm;
146
- }
147
- }
148
- return embedding;
149
- }
115
+ // ADR-390: the router's embedder (MiniLM or the historical hash). Pattern and
116
+ // query vectors always come from the SAME embedder; `routerEmbedder` records the
117
+ // one that actually built the current index (after any degradation to hash).
118
+ let routerEmbedder = DEFAULT_ROUTER_EMBEDDER;
119
+ let routerEmbedderReason;
120
+ let routerRequestedEmbedder = null;
121
+ let routerInitPromise = null;
150
122
  // ── Runtime routing outcome persistence ──────────────────────────────
151
123
  // Closes the learning loop: post-task records outcomes → route loads them.
152
124
  const ROUTING_OUTCOMES_PATH = join(resolve('.'), '.claude-flow/routing-outcomes.json');
@@ -190,7 +162,8 @@ function saveRoutingOutcomes(outcomes) {
190
162
  // The prior singleton cache made the learned store inert until restart.
191
163
  semanticRouter = null;
192
164
  nativeVectorDb = null;
193
- semanticRouterInitialized = false;
165
+ routerInitPromise = null;
166
+ routerRequestedEmbedder = null;
194
167
  routerBackend = 'none';
195
168
  TASK_PATTERN_EMBEDDINGS.clear();
196
169
  }
@@ -269,15 +242,65 @@ const TASK_PATTERNS = {
269
242
  agents: ['memory-specialist', 'architect', 'coder'],
270
243
  },
271
244
  };
245
+ /** Wrap hooks_route so the opt-in typesafe router (src/ruvector/typesafe-router.ts) can override the legacy pick. */
246
+ function withTypesafeRouting(legacy) {
247
+ return async (params) => applyTypesafeRouting(params, (await legacy(params)), TASK_PATTERNS, getTypesafeRouter());
248
+ }
272
249
  /**
273
250
  * Get the semantic router with environment detection.
274
251
  * Tries native VectorDb first (HNSW, 16k routes/s), falls back to pure JS (47k routes/s cosine).
252
+ *
253
+ * ADR-390: `requested` (else CLAUDE_FLOW_ROUTER_EMBEDDER, else the default)
254
+ * picks the embedder. The index is rebuilt when the requested embedder changes.
255
+ * Concurrent callers share one in-flight build.
275
256
  */
276
- async function getSemanticRouter() {
277
- if (semanticRouterInitialized) {
278
- return { router: semanticRouter, backend: routerBackend, native: nativeVectorDb };
257
+ async function getSemanticRouter(requested) {
258
+ const selection = resolveRouterEmbedder(requested);
259
+ if (routerInitPromise && routerRequestedEmbedder === selection.kind) {
260
+ return routerInitPromise;
261
+ }
262
+ semanticRouter = null;
263
+ nativeVectorDb = null;
264
+ routerBackend = 'none';
265
+ TASK_PATTERN_EMBEDDINGS.clear();
266
+ routerRequestedEmbedder = selection.kind;
267
+ routerInitPromise = buildSemanticRouter(selection.kind, selection.reason);
268
+ return routerInitPromise;
269
+ }
270
+ /** Test hook: drop the cached semantic index so the next route rebuilds it. */
271
+ export function resetSemanticRouterForTests() {
272
+ semanticRouter = null;
273
+ nativeVectorDb = null;
274
+ routerBackend = 'none';
275
+ routerInitPromise = null;
276
+ routerRequestedEmbedder = null;
277
+ TASK_PATTERN_EMBEDDINGS.clear();
278
+ }
279
+ /** Embed every pattern keyword with ONE embedder (ADR-390: never mix spaces). */
280
+ async function embedPatternKeywords(patterns, kind) {
281
+ const entries = Object.entries(patterns);
282
+ const flat = entries.flatMap(([, p]) => p.keywords);
283
+ const res = await embedForRouter(flat, kind);
284
+ const vectors = new Map();
285
+ let i = 0;
286
+ for (const [name, p] of entries) {
287
+ vectors.set(name, res.vectors.slice(i, i + p.keywords.length));
288
+ i += p.keywords.length;
279
289
  }
280
- semanticRouterInitialized = true;
290
+ return { vectors, embedder: res.embedder, reason: res.reason };
291
+ }
292
+ async function buildSemanticRouter(kind, selectionReason) {
293
+ const patterns = getMergedTaskPatterns();
294
+ const embedded = await embedPatternKeywords(patterns, kind);
295
+ routerEmbedder = embedded.embedder;
296
+ routerEmbedderReason = embedded.reason ?? selectionReason;
297
+ const handle = () => ({
298
+ router: semanticRouter,
299
+ backend: routerBackend,
300
+ native: nativeVectorDb,
301
+ embedder: routerEmbedder,
302
+ ...(routerEmbedderReason ? { embedderReason: routerEmbedderReason } : {}),
303
+ });
281
304
  // STEP 1: Try native VectorDb from @ruvector/router (HNSW-backed)
282
305
  // Note: Native VectorDb uses a persistent database file which can have lock issues
283
306
  // in concurrent environments. We try it first but fall back gracefully to pure JS.
@@ -299,17 +322,17 @@ async function getSemanticRouter() {
299
322
  hnswEfSearch: 100,
300
323
  });
301
324
  // Initialize with static + runtime-learned task patterns
302
- for (const [patternName, { keywords }] of Object.entries(getMergedTaskPatterns())) {
303
- for (const keyword of keywords) {
304
- const embedding = generateSimpleEmbedding(keyword);
305
- db.insert(`${patternName}:${keyword}`, embedding);
306
- TASK_PATTERN_EMBEDDINGS.set(`${patternName}:${keyword}`, embedding);
307
- }
325
+ for (const [patternName, { keywords }] of Object.entries(patterns)) {
326
+ const embeddings = embedded.vectors.get(patternName) ?? [];
327
+ keywords.forEach((keyword, i) => {
328
+ db.insert(`${patternName}:${keyword}`, embeddings[i]);
329
+ TASK_PATTERN_EMBEDDINGS.set(`${patternName}:${keyword}`, embeddings[i]);
330
+ });
308
331
  }
309
332
  nativeVectorDb = db;
310
333
  routerBackend = 'native';
311
- console.log('[hooks] Semantic router initialized: native VectorDb (HNSW, 16k+ routes/s)');
312
- return { router: null, backend: routerBackend, native: nativeVectorDb };
334
+ console.log(`[hooks] Semantic router initialized: native VectorDb (HNSW, 16k+ routes/s), embedder=${routerEmbedder}`);
335
+ return handle();
313
336
  }
314
337
  }
315
338
  catch (err) {
@@ -321,8 +344,8 @@ async function getSemanticRouter() {
321
344
  try {
322
345
  const { SemanticRouter } = await import('../ruvector/semantic-router.js');
323
346
  semanticRouter = new SemanticRouter({ dimension: 384 });
324
- for (const [patternName, { keywords, agents, source, support, reliability }] of Object.entries(getMergedTaskPatterns())) {
325
- const embeddings = keywords.map(kw => generateSimpleEmbedding(kw));
347
+ for (const [patternName, { keywords, agents, source, support, reliability }] of Object.entries(patterns)) {
348
+ const embeddings = embedded.vectors.get(patternName) ?? [];
326
349
  semanticRouter.addIntentWithEmbeddings(patternName, embeddings, {
327
350
  agents,
328
351
  keywords,
@@ -336,14 +359,14 @@ async function getSemanticRouter() {
336
359
  });
337
360
  }
338
361
  routerBackend = 'pure-js';
339
- console.log('[hooks] Semantic router initialized: pure JS (cosine, 47k routes/s)');
362
+ console.log(`[hooks] Semantic router initialized: pure JS (cosine, 47k routes/s), embedder=${routerEmbedder}`);
340
363
  }
341
364
  catch {
342
365
  semanticRouter = null;
343
366
  routerBackend = 'none';
344
367
  console.log('[hooks] Semantic router initialized: none (no backend available)');
345
368
  }
346
- return { router: semanticRouter, backend: routerBackend, native: nativeVectorDb };
369
+ return handle();
347
370
  }
348
371
  /**
349
372
  * Get router backend info for status display.
@@ -621,11 +644,21 @@ function suggestAgentsForFile(filePath) {
621
644
  }
622
645
  return AGENT_PATTERNS[ext] || ['coder', 'architect'];
623
646
  }
624
- function suggestAgentsForTask(task) {
625
- const taskLower = task.toLowerCase();
647
+ // Whole-word matchers for KEYWORD_PATTERNS. A bare `includes()` matched
648
+ // substrings: 'test' hit "latest" (tester @ 0.95), 'auth' hit "author",
649
+ // 'fix' hit "prefix", 'api' hit "capitalize". Single words get \b anchors plus
650
+ // simple inflections (tests, testing, fixes, deployed); phrases containing
651
+ // whitespace or '/' (e.g. 'ci/cd') match literally between word boundaries.
652
+ const KEYWORD_MATCHERS = Object.entries(KEYWORD_PATTERNS).map(([keyword, result]) => {
653
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
654
+ const body = /[\s/]/.test(keyword) ? escaped : `${escaped}(?:s|es|ing|ed)?`;
655
+ return { regex: new RegExp(`\\b${body}\\b`, 'i'), result };
656
+ });
657
+ /** Exported for tests. */
658
+ export function suggestAgentsForTask(task) {
626
659
  // Check static keyword patterns first
627
- for (const [pattern, result] of Object.entries(KEYWORD_PATTERNS)) {
628
- if (taskLower.includes(pattern)) {
660
+ for (const { regex, result } of KEYWORD_MATCHERS) {
661
+ if (regex.test(task)) {
629
662
  return result;
630
663
  }
631
664
  }
@@ -950,7 +983,8 @@ export const hooksRoute = {
950
983
  },
951
984
  required: ['task'],
952
985
  },
953
- handler: async (params) => {
986
+ // Opt-in @ruvector/typesafe augmentation (CLAUDE_FLOW_ROUTER_TYPESAFE=1); returns the legacy result unchanged when unset.
987
+ handler: withTypesafeRouting(async (params) => {
954
988
  const task = params.task;
955
989
  const context = params.context;
956
990
  const useSemanticRouter = params.useSemanticRouter !== false;
@@ -1005,134 +1039,185 @@ export const hooksRoute = {
1005
1039
  // AgentDB router not available — fall through to local routing
1006
1040
  }
1007
1041
  }
1008
- // Get router (tries native VectorDb first, falls back to pure JS)
1009
- const { router, backend, native } = useSemanticRouter
1010
- ? await getSemanticRouter()
1011
- : { router: null, backend: 'none', native: null };
1012
- let semanticResult = [];
1013
- let routingMethod = 'keyword';
1014
- let routingLatencyMs = 0;
1015
- let backendInfo = '';
1016
- const queryText = context ? `${task} ${context}` : task;
1017
- const queryEmbedding = generateSimpleEmbedding(queryText);
1018
- // Try native VectorDb (HNSW-backed)
1019
- if (native && backend === 'native') {
1020
- const routeStart = performance.now();
1021
- try {
1022
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1023
- const results = native.search(queryEmbedding, 5);
1024
- routingLatencyMs = performance.now() - routeStart;
1025
- routingMethod = 'semantic-native';
1026
- backendInfo = 'native VectorDb (HNSW)';
1027
- // Convert results to semantic format
1028
- const mergedPatterns = getMergedTaskPatterns();
1029
- semanticResult = results.map((r) => {
1030
- const [patternName] = r.id.split(':');
1031
- const pattern = mergedPatterns[patternName];
1032
- return {
1033
- intent: patternName,
1034
- score: 1 - r.score, // Native uses distance (lower is better), convert to similarity
1035
- metadata: {
1036
- agents: pattern?.agents || (patternName.startsWith('learned-') ? [patternName.slice(8)] : ['coder']),
1037
- source: pattern?.source ?? (patternName.startsWith('learned-') ? 'learned' : 'static'),
1038
- support: pattern?.support,
1039
- reliability: pattern?.reliability,
1040
- },
1041
- };
1042
- });
1043
- }
1044
- catch {
1045
- // Native failed, try pure JS fallback
1046
- }
1047
- }
1048
- // Try pure JS SemanticRouter fallback
1049
- if (router && backend === 'pure-js' && semanticResult.length === 0) {
1050
- const routeStart = performance.now();
1051
- semanticResult = router.routeWithEmbedding(queryEmbedding, 3);
1042
+ return routeTaskLocal(task, context, useSemanticRouter);
1043
+ }),
1044
+ };
1045
+ /**
1046
+ * hooks_route's local routing (semantic index + keyword fallback), run after the
1047
+ * AgentDB pre-route. Shared with {@link routeTaskForBench} so the benchmark
1048
+ * measures exactly the path hooks_route takes. (Body kept at its original
1049
+ * indentation to keep this refactor's diff small.)
1050
+ */
1051
+ async function routeTaskLocal(task, context, useSemanticRouter, embedderOverride) {
1052
+ // Get router (tries native VectorDb first, falls back to pure JS)
1053
+ let handle = useSemanticRouter
1054
+ ? await getSemanticRouter(embedderOverride)
1055
+ : {
1056
+ router: null, backend: 'none', native: null,
1057
+ embedder: resolveRouterEmbedder(embedderOverride).kind,
1058
+ embedderReason: 'semantic router disabled (useSemanticRouter=false)',
1059
+ };
1060
+ let semanticResult = [];
1061
+ let routingMethod = 'keyword';
1062
+ let routingLatencyMs = 0;
1063
+ let backendInfo = '';
1064
+ const queryText = context ? `${task} ${context}` : task;
1065
+ // ADR-390: the query is embedded with the embedder that built the index.
1066
+ let queryEmbedding = null;
1067
+ if (handle.router || handle.native) {
1068
+ const q = await embedForRouter([queryText], handle.embedder);
1069
+ if (q.embedder !== handle.embedder) {
1070
+ // MiniLM failed on the query after building a MiniLM index: rebuild the
1071
+ // index with the hash so patterns and query share one space again.
1072
+ routerInitPromise = buildSemanticRouter('hash', q.reason);
1073
+ handle = await routerInitPromise;
1074
+ }
1075
+ queryEmbedding = q.vectors[0];
1076
+ }
1077
+ const { router, backend, native } = handle;
1078
+ // Try native VectorDb (HNSW-backed)
1079
+ if (native && backend === 'native' && queryEmbedding) {
1080
+ const routeStart = performance.now();
1081
+ try {
1082
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1083
+ const results = native.search(queryEmbedding, 5);
1052
1084
  routingLatencyMs = performance.now() - routeStart;
1053
- routingMethod = 'semantic-pure-js';
1054
- backendInfo = 'pure JS (cosine similarity)';
1055
- }
1056
- // Get agents from semantic routing or fall back to keyword
1057
- let agents;
1058
- let confidence;
1059
- let matchedPattern = '';
1060
- // Both static and learned patterns are gated on the same similarity
1061
- // score. Learned patterns additionally require support/reliability as a
1062
- // quality guard, but do NOT need a higher score bar — a learned pattern
1063
- // that outscores every static candidate must not lose to one anyway
1064
- // (#2864: a 25pp higher threshold made a top-scoring learned-researcher
1065
- // match at 0.57 lose to a static match at 0.52, discarding the learned
1066
- // store's output on the majority of routes).
1067
- const eligibleSemantic = semanticResult.find((match) => {
1068
- if (match.score <= 0.4)
1069
- return false;
1070
- const learned = match.intent.startsWith('learned-') || match.metadata.source === 'learned';
1071
- if (!learned)
1072
- return true;
1073
- return Number(match.metadata.support ?? 0) >= 2
1074
- && Number(match.metadata.reliability ?? 0) >= 0.75;
1075
- });
1076
- if (eligibleSemantic) {
1077
- const topMatch = eligibleSemantic;
1078
- agents = topMatch.metadata.agents || ['coder', 'researcher'];
1079
- confidence = topMatch.score;
1080
- matchedPattern = topMatch.intent;
1085
+ routingMethod = 'semantic-native';
1086
+ backendInfo = 'native VectorDb (HNSW)';
1087
+ // Convert results to semantic format
1088
+ const mergedPatterns = getMergedTaskPatterns();
1089
+ semanticResult = results.map((r) => {
1090
+ const [patternName] = r.id.split(':');
1091
+ const pattern = mergedPatterns[patternName];
1092
+ return {
1093
+ intent: patternName,
1094
+ score: 1 - r.score, // Native uses distance (lower is better), convert to similarity
1095
+ metadata: {
1096
+ agents: pattern?.agents || (patternName.startsWith('learned-') ? [patternName.slice(8)] : ['coder']),
1097
+ source: pattern?.source ?? (patternName.startsWith('learned-') ? 'learned' : 'static'),
1098
+ support: pattern?.support,
1099
+ reliability: pattern?.reliability,
1100
+ },
1101
+ };
1102
+ });
1081
1103
  }
1082
- else {
1083
- // Fall back to keyword matching
1084
- const suggestion = suggestAgentsForTask(task);
1085
- agents = suggestion.agents;
1086
- confidence = suggestion.confidence;
1087
- matchedPattern = 'keyword-fallback';
1088
- routingMethod = 'keyword';
1089
- backendInfo = 'keyword matching';
1104
+ catch {
1105
+ // Native failed, try pure JS fallback
1090
1106
  }
1091
- // Determine complexity
1092
- const taskLower = task.toLowerCase();
1093
- const complexity = taskLower.includes('complex') || taskLower.includes('architecture') || task.length > 200
1094
- ? 'high'
1095
- : taskLower.includes('simple') || taskLower.includes('fix') || task.length < 50
1096
- ? 'low'
1097
- : 'medium';
1098
- return {
1099
- task,
1100
- routing: {
1101
- method: routingMethod,
1102
- backend: backendInfo,
1103
- latencyMs: routingLatencyMs,
1104
- throughput: routingLatencyMs > 0 ? `${Math.round(1000 / routingLatencyMs)} routes/s` : 'N/A',
1105
- },
1106
- matchedPattern,
1107
- semanticMatches: semanticResult.slice(0, 3).map(r => ({
1108
- pattern: r.intent,
1109
- score: Math.round(r.score * 100) / 100,
1110
- })),
1111
- primaryAgent: {
1112
- type: agents[0],
1113
- confidence: Math.round(confidence * 100) / 100,
1114
- reason: routingMethod.startsWith('semantic')
1115
- ? `Semantic similarity to "${matchedPattern}" pattern (${Math.round(confidence * 100)}%)`
1116
- : `Task contains keywords matching ${agents[0]} specialization`,
1117
- },
1118
- alternativeAgents: agents.slice(1).map((agent, i) => ({
1119
- type: agent,
1120
- confidence: Math.round((confidence - (0.1 * (i + 1))) * 100) / 100,
1121
- reason: `Alternative agent for ${agent} capabilities`,
1122
- })),
1123
- estimatedMetrics: {
1124
- successProbability: Math.round(confidence * 100) / 100,
1125
- estimatedDuration: complexity === 'high' ? '2-4 hours' : complexity === 'medium' ? '30-60 min' : '10-30 min',
1126
- complexity,
1127
- },
1128
- swarmRecommendation: agents.length > 2 ? {
1129
- topology: 'hierarchical',
1130
- agents,
1131
- coordination: 'queen-led',
1132
- } : null,
1133
- };
1134
- },
1135
- };
1107
+ }
1108
+ // Try pure JS SemanticRouter fallback
1109
+ if (router && backend === 'pure-js' && queryEmbedding && semanticResult.length === 0) {
1110
+ const routeStart = performance.now();
1111
+ semanticResult = router.routeWithEmbedding(queryEmbedding, 3);
1112
+ routingLatencyMs = performance.now() - routeStart;
1113
+ routingMethod = 'semantic-pure-js';
1114
+ backendInfo = 'pure JS (cosine similarity)';
1115
+ }
1116
+ // Get agents from semantic routing or fall back to keyword
1117
+ let agents;
1118
+ let confidence;
1119
+ let matchedPattern = '';
1120
+ // Both static and learned patterns are gated on the same similarity
1121
+ // score. Learned patterns additionally require support/reliability as a
1122
+ // quality guard, but do NOT need a higher score bar — a learned pattern
1123
+ // that outscores every static candidate must not lose to one anyway
1124
+ // (#2864: a 25pp higher threshold made a top-scoring learned-researcher
1125
+ // match at 0.57 lose to a static match at 0.52, discarding the learned
1126
+ // store's output on the majority of routes).
1127
+ const eligibleSemantic = semanticResult.find((match) => {
1128
+ if (match.score <= 0.4)
1129
+ return false;
1130
+ const learned = match.intent.startsWith('learned-') || match.metadata.source === 'learned';
1131
+ if (!learned)
1132
+ return true;
1133
+ return Number(match.metadata.support ?? 0) >= 2
1134
+ && Number(match.metadata.reliability ?? 0) >= 0.75;
1135
+ });
1136
+ if (eligibleSemantic) {
1137
+ const topMatch = eligibleSemantic;
1138
+ agents = topMatch.metadata.agents || ['coder', 'researcher'];
1139
+ confidence = topMatch.score;
1140
+ matchedPattern = topMatch.intent;
1141
+ }
1142
+ else {
1143
+ // Fall back to keyword matching
1144
+ const suggestion = suggestAgentsForTask(task);
1145
+ agents = suggestion.agents;
1146
+ confidence = suggestion.confidence;
1147
+ matchedPattern = 'keyword-fallback';
1148
+ routingMethod = 'keyword';
1149
+ backendInfo = 'keyword matching';
1150
+ }
1151
+ // Determine complexity
1152
+ const taskLower = task.toLowerCase();
1153
+ const complexity = taskLower.includes('complex') || taskLower.includes('architecture') || task.length > 200
1154
+ ? 'high'
1155
+ : taskLower.includes('simple') || taskLower.includes('fix') || task.length < 50
1156
+ ? 'low'
1157
+ : 'medium';
1158
+ return {
1159
+ task,
1160
+ routing: {
1161
+ method: routingMethod,
1162
+ backend: backendInfo,
1163
+ latencyMs: routingLatencyMs,
1164
+ throughput: routingLatencyMs > 0 ? `${Math.round(1000 / routingLatencyMs)} routes/s` : 'N/A',
1165
+ },
1166
+ matchedPattern,
1167
+ semanticMatches: semanticResult.slice(0, 3).map(r => ({
1168
+ pattern: r.intent,
1169
+ score: Math.round(r.score * 100) / 100,
1170
+ })),
1171
+ primaryAgent: {
1172
+ type: agents[0],
1173
+ confidence: Math.round(confidence * 100) / 100,
1174
+ reason: routingMethod.startsWith('semantic')
1175
+ ? `Semantic similarity to "${matchedPattern}" pattern (${Math.round(confidence * 100)}%)`
1176
+ : `Task contains keywords matching ${agents[0]} specialization`,
1177
+ },
1178
+ alternativeAgents: agents.slice(1).map((agent, i) => ({
1179
+ type: agent,
1180
+ confidence: Math.round((confidence - (0.1 * (i + 1))) * 100) / 100,
1181
+ reason: `Alternative agent for ${agent} capabilities`,
1182
+ })),
1183
+ estimatedMetrics: {
1184
+ successProbability: Math.round(confidence * 100) / 100,
1185
+ estimatedDuration: complexity === 'high' ? '2-4 hours' : complexity === 'medium' ? '30-60 min' : '10-30 min',
1186
+ complexity,
1187
+ },
1188
+ swarmRecommendation: agents.length > 2 ? {
1189
+ topology: 'hierarchical',
1190
+ agents,
1191
+ coordination: 'queen-led',
1192
+ } : null,
1193
+ // ADR-390: which embedder the semantic index + query used ('hash' when degraded).
1194
+ embedder: handle.embedder,
1195
+ ...(handle.embedderReason ? { embedderReason: handle.embedderReason } : {}),
1196
+ };
1197
+ }
1198
+ /**
1199
+ * INTERNAL / BENCH-ONLY (ADR-391). Not a public API; may change without notice.
1200
+ *
1201
+ * Routes `task` through the same local path `hooks_route` uses (semantic index +
1202
+ * keyword fallback) with an explicit embedder, without the MCP layer. It skips
1203
+ * the two steps that are neither ADR-391 candidate A nor B: the AgentDB
1204
+ * pre-route (`bridgeRouteTask`, which answers first when its confidence > 0.5)
1205
+ * and the opt-in typesafe wrapper (CLAUDE_FLOW_ROUTER_TYPESAFE). With those
1206
+ * inactive, `primaryAgent` equals `hooks_route`'s `primaryAgent.type`.
1207
+ *
1208
+ * Switching `embedder` between calls rebuilds the index; benchmark in blocks.
1209
+ */
1210
+ export async function routeTaskForBench(task, opts) {
1211
+ const r = await routeTaskLocal(task, opts.context, true, opts.embedder);
1212
+ return {
1213
+ primaryAgent: r.primaryAgent.type,
1214
+ confidence: r.primaryAgent.confidence,
1215
+ pattern: r.matchedPattern,
1216
+ embedder: r.embedder,
1217
+ ...(r.embedderReason ? { embedderReason: r.embedderReason } : {}),
1218
+ method: String(r.routing?.method ?? ''),
1219
+ };
1220
+ }
1136
1221
  export const hooksMetrics = {
1137
1222
  name: 'hooks_metrics',
1138
1223
  description: 'View learning metrics dashboard Use when native Bash hooks (via Claude Code\'s settings.json) are wrong because you need Ruflo-side state — pattern persistence, neural training signals, model-routing learning, cost tracking, audit chain. For one-off shell commands, plain Bash hooks are fine.',
@@ -1439,10 +1524,12 @@ export const hooksPostTask = {
1439
1524
  catch {
1440
1525
  // Non-fatal
1441
1526
  }
1442
- // Record trajectory via intelligence module (SONA + ReasoningBank)
1527
+ // Record trajectory via intelligence module (SONA + ReasoningBank).
1528
+ // #3353: keep the observed result instead of discarding it.
1529
+ let trajectoryRecorded = false;
1443
1530
  try {
1444
1531
  const intelligence = await import('../memory/intelligence.js');
1445
- await intelligence.recordTrajectory([{ type: 'result', content: params.task || taskId, metadata: { success, agent, quality }, timestamp: Date.now() }], success ? 'success' : 'failure');
1532
+ trajectoryRecorded = (await intelligence.recordTrajectory([{ type: 'result', content: params.task || taskId, metadata: { success, agent, quality }, timestamp: Date.now() }], success ? 'success' : 'failure')) === true;
1446
1533
  }
1447
1534
  catch {
1448
1535
  // Intelligence module not available — non-fatal
@@ -1582,17 +1669,32 @@ export const hooksPostTask = {
1582
1669
  writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf-8');
1583
1670
  }
1584
1671
  catch { /* non-critical */ }
1672
+ // #3353: report only observed learning results. The previous
1673
+ // `feedbackResult?.updated || (success ? 2 : 1)` / `newPatterns: success ? 1 : 0`
1674
+ // invented counts whenever the feedback controller was unavailable (and the
1675
+ // `||` turned an observed 0 into 2). No path reports pattern *creation*, so
1676
+ // newPatterns is null (unknown) rather than a guess; the trajectory has no
1677
+ // real id to surface, so trajectoryId is null.
1678
+ const feedbackRecorded = feedbackResult?.success === true;
1679
+ const learningAvailable = feedbackRecorded;
1585
1680
  return {
1586
1681
  taskId,
1587
1682
  success,
1588
1683
  duration,
1589
1684
  learningUpdates: {
1590
- patternsUpdated: feedbackResult?.updated || (success ? 2 : 1),
1591
- newPatterns: success ? 1 : 0,
1592
- trajectoryId: `traj-${Date.now()}`,
1685
+ patternsUpdated: feedbackRecorded ? (feedbackResult?.updated ?? 0) : 0,
1686
+ newPatterns: null,
1687
+ trajectoryId: null,
1593
1688
  controller: feedbackResult?.controller || 'none',
1594
1689
  outcomePersisted,
1690
+ available: learningAvailable,
1691
+ ...(learningAvailable ? {} : {
1692
+ reason: feedbackResult
1693
+ ? `feedback controller '${feedbackResult.controller}' did not record the outcome`
1694
+ : 'feedback controller unavailable',
1695
+ }),
1595
1696
  },
1697
+ trajectory: { recorded: trajectoryRecorded },
1596
1698
  quality,
1597
1699
  pheromone,
1598
1700
  feedback: feedbackResult ? {