@clue-ai/cli 0.0.15 → 0.0.17

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.
@@ -80,12 +80,22 @@ const ACTUAL_OUTCOME_ACTIONS = new Set([
80
80
  ]);
81
81
 
82
82
  const snakeSegmentPattern = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
83
- const SEMANTIC_SNAPSHOT_SCHEMA_VERSION = 2;
84
- const SEMANTIC_ANALYZER_VERSION = "fastapi-route-analyzer-v1";
85
- const SEMANTIC_ROUTE_PROMPT_CONTRACT_VERSION = "route-semantics-v1";
86
- const SEMANTIC_REUSE_PROMPT_CONTRACT_VERSION = "route-reuse-v1";
83
+ const SEMANTIC_SNAPSHOT_SCHEMA_VERSION = 3;
84
+ const SEMANTIC_ANALYZER_VERSION = "fastapi-route-analyzer-v2";
85
+ const SEMANTIC_ROUTE_PROMPT_CONTRACT_VERSION = "route-semantics-v2";
86
+ const SEMANTIC_REUSE_PROMPT_CONTRACT_VERSION = "purpose-stability-v1";
87
87
  const SEMANTIC_SELECTOR_PROMPT_CONTRACT_VERSION = "semantic-selector-v1";
88
88
  const SEMANTIC_PRIVACY_SANITIZER_VERSION = "privacy-sanitizer-v1";
89
+ const PURPOSE_STABILITY_CONFIDENCE_THRESHOLD = 0.75;
90
+
91
+ const PURPOSE_CHANGE_STATES = new Set([
92
+ "same_purpose",
93
+ "purpose_added",
94
+ "purpose_changed",
95
+ "purpose_removed",
96
+ "insufficient_evidence",
97
+ "new_route",
98
+ ]);
89
99
 
90
100
  const semanticSnapshotHashScope = (request) =>
91
101
  [
@@ -176,6 +186,92 @@ const fallbackSemantics = (route, reason, hashScope) => ({
176
186
  confidence_reason: reason,
177
187
  });
178
188
 
189
+ const redactAiInputSource = (value) =>
190
+ String(value ?? "")
191
+ .slice(0, 6000)
192
+ .replace(
193
+ /\b[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)\b(?:\s*=\s*["']?[^"'\s,;}]+)?/g,
194
+ "[secret]",
195
+ )
196
+ .replace(/\bBearer\s+(?!\[secret\])[A-Za-z0-9._~+/-]+=*/gi, "Bearer [secret]")
197
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[secret]");
198
+
199
+ const lastCallSegment = (target) =>
200
+ String(target ?? "")
201
+ .split(".")
202
+ .map((part) => part.trim())
203
+ .filter(Boolean)
204
+ .at(-1) ?? "";
205
+
206
+ const callActionHints = (route) =>
207
+ [
208
+ ...new Set(
209
+ safeArray(route.ai_context?.code_structure?.call_targets)
210
+ .map(lastCallSegment)
211
+ .map((segment) => normalizeSnakeSegment(segment))
212
+ .filter((segment) => segment && EFFECT_ACTION_TOKENS.has(segment)),
213
+ ),
214
+ ].slice(0, 12);
215
+
216
+ const buildEvidencePacketSummary = (route) => {
217
+ const callTargets = safeArray(route.ai_context?.code_structure?.call_targets);
218
+ const dependencyHints = safeArray(route.ai_context?.code_structure?.dependency_hints);
219
+ const actionHints = callActionHints(route);
220
+ return {
221
+ contract_summary: `${route.method ?? "UNKNOWN"} ${route.path_template ?? "/"} operation source contract.`,
222
+ behavior_summary:
223
+ callTargets.length > 0
224
+ ? `Handler has ${callTargets.length} bounded call target(s) that may describe domain behavior.`
225
+ : "No bounded domain call targets were detected in the route handler.",
226
+ data_effect_hints: actionHints,
227
+ side_effect_hints: callTargets
228
+ .map(lastCallSegment)
229
+ .map((segment) => normalizeSnakeSegment(segment))
230
+ .filter((segment) =>
231
+ ["send", "publish", "emit", "schedule", "enqueue", "notify"].includes(
232
+ segment,
233
+ ),
234
+ )
235
+ .slice(0, 12),
236
+ validation_hints: safeArray(
237
+ route.ai_context?.code_structure?.parameter_annotations,
238
+ )
239
+ .map((hint) => sanitizeText(String(hint), route))
240
+ .slice(0, 12),
241
+ auth_hints: dependencyHints
242
+ .map((hint) => sanitizeText(String(hint), route))
243
+ .slice(0, 12),
244
+ missing_context:
245
+ callTargets.length === 0
246
+ ? ["No local dependency call evidence was detected."]
247
+ : [],
248
+ };
249
+ };
250
+
251
+ const buildRouteEvidencePacket = (route, hashScope) => ({
252
+ contract: {
253
+ operation_source_key: route.operation_source_key,
254
+ method: route.method,
255
+ path_template: route.path_template,
256
+ router_prefix: route.ai_context?.router_prefix ?? null,
257
+ include_prefix: route.ai_context?.include_prefix ?? null,
258
+ },
259
+ code_context: {
260
+ handler_name: route.ai_context?.handler_name ?? null,
261
+ handler_source_excerpt: redactAiInputSource(route.ai_context?.source_snippet),
262
+ code_structure: route.ai_context?.code_structure ?? {},
263
+ },
264
+ stored_summary_preview: buildEvidencePacketSummary(route),
265
+ evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
266
+ (entry, index) => ({
267
+ evidence_index: index,
268
+ kind: entry.kind,
269
+ summary: entry.summary,
270
+ evidence_strength: entry.evidence_strength,
271
+ }),
272
+ ),
273
+ });
274
+
179
275
  const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
180
276
  JSON.stringify({
181
277
  ...semanticAgentPromptEnvelope({
@@ -187,8 +283,8 @@ const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
187
283
  rules: [
188
284
  "Do not create operation_source_key values; only copy the provided keys.",
189
285
  "Do not create ids, refs, hashes, source fingerprints, or field paths.",
190
- "Do not include raw source code, raw SQL, file paths, function names, class names, prompts, or completions in the output.",
191
- "Use the provided privacy-safe evidence summaries only.",
286
+ "You may use handler_source_excerpt and bounded dependency evidence to understand route purpose, but never copy raw source code, raw SQL, file paths, function names, class names, prompts, or completions into the output.",
287
+ "Use the provided route_evidence_packet to infer purpose-level business semantics and operation effects.",
192
288
  "Return operation_effect_candidates only when source evidence supports a schema-valid target object and effect action.",
193
289
  "Keep multiple effects separate when one route produces multiple meaningful effects.",
194
290
  "Do not encode actual outcomes such as completed, failed, blocked, validation_error, or abandoned into operation_effect_key or expected_domain_effect.",
@@ -228,6 +324,7 @@ const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
228
324
  method: route.method,
229
325
  path_template: route.path_template,
230
326
  evidence_summaries: route.evidence_summaries,
327
+ route_evidence_packet: route.route_evidence_packet,
231
328
  })),
232
329
  });
233
330
 
@@ -243,24 +340,30 @@ const buildAiReuseDecisionPrompt = ({
243
340
  bundle: agentSkills,
244
341
  roleId: SEMANTIC_AGENT_ROLE_IDS.reuseJudge,
245
342
  }),
246
- task: "Decide whether previous privacy-safe route semantics still apply after a route source change. Return JSON only.",
343
+ task: "Decide whether previous privacy-safe route semantics still apply at the customer-visible purpose level after a route source change. Return JSON only.",
247
344
  generation_contract: generationContract,
248
345
  rules: [
249
346
  "Only decide for the provided operation_source_key.",
250
- "Return decision as reuse_previous, regenerate, or needs_review.",
251
- "Choose reuse_previous only when the customer-visible route meaning, operation effects, and value interpretation remain the same.",
252
- "Choose regenerate when the route appears to create, update, delete, read, send, schedule, call, or validate a different business object or effect.",
253
- "Choose needs_review when evidence is insufficient or conflicting.",
347
+ "First decide purpose_change_state as same_purpose, purpose_added, purpose_changed, purpose_removed, or insufficient_evidence.",
348
+ "Use same_purpose when implementation details or wording changed but the customer-visible purpose and operation effects are unchanged.",
349
+ "Use purpose_added when the previous purpose still applies and the current route adds a new customer-visible purpose or operation effect.",
350
+ "Use purpose_changed when a previous purpose or primary operation effect is replaced with a different business object or effect.",
351
+ "Use purpose_removed when a previous purpose or operation effect is no longer present.",
352
+ "Use insufficient_evidence when evidence is sparse, conflicting, or cannot prove a purpose-level change.",
353
+ "Return legacy decision as reuse_previous for same_purpose, regenerate for purpose_added/purpose_changed/purpose_removed, and needs_review for insufficient_evidence.",
354
+ "Never recommend changing active semantics for wording drift alone.",
254
355
  "Do not include raw source code, raw SQL, file paths, function names, class names, prompts, completions, secrets, ids, or hashes except the provided hashes.",
255
356
  ],
256
357
  output_shape: {
257
358
  decisions: [
258
359
  {
259
360
  operation_source_key: "route.POST./reports",
361
+ purpose_change_state: "same_purpose",
260
362
  decision: "reuse_previous",
261
363
  confidence: 0.82,
262
364
  reason:
263
365
  "The source changed but the privacy-safe evidence still supports the previous reports.create meaning.",
366
+ missing_context: [],
264
367
  },
265
368
  ],
266
369
  },
@@ -273,6 +376,7 @@ const buildAiReuseDecisionPrompt = ({
273
376
  previous_semantics: previousRoute.semantics,
274
377
  previous_operation_effects: previousRoute.operation_effects ?? [],
275
378
  current_evidence_summaries: route.evidence_summaries,
379
+ current_route_evidence_packet: route.route_evidence_packet,
276
380
  },
277
381
  });
278
382
 
@@ -280,8 +384,10 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
280
384
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions)) {
281
385
  return {
282
386
  decision: "needs_review",
387
+ purpose_change_state: "insufficient_evidence",
283
388
  confidence: 0,
284
389
  reason: "AI reuse decision did not return the required decisions array.",
390
+ missing_context: ["AI reuse decision did not return a usable response."],
285
391
  };
286
392
  }
287
393
  const unknownDecision = parsed.decisions.find(
@@ -295,8 +401,10 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
295
401
  if (unknownDecision) {
296
402
  return {
297
403
  decision: "needs_review",
404
+ purpose_change_state: "insufficient_evidence",
298
405
  confidence: 0,
299
406
  reason: "AI reuse decision returned an unexpected route key.",
407
+ missing_context: ["AI reuse decision returned an unexpected route key."],
300
408
  };
301
409
  }
302
410
  const decision = safeArray(parsed?.decisions).find(
@@ -305,8 +413,10 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
305
413
  if (!decision || typeof decision !== "object") {
306
414
  return {
307
415
  decision: "needs_review",
416
+ purpose_change_state: "insufficient_evidence",
308
417
  confidence: 0,
309
418
  reason: "AI reuse decision omitted this changed route.",
419
+ missing_context: ["AI reuse decision omitted this changed route."],
310
420
  };
311
421
  }
312
422
  const normalizedDecision = [
@@ -316,8 +426,27 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
316
426
  ].includes(decision.decision)
317
427
  ? decision.decision
318
428
  : "needs_review";
429
+ const explicitPurposeState = PURPOSE_CHANGE_STATES.has(
430
+ decision.purpose_change_state,
431
+ )
432
+ ? decision.purpose_change_state
433
+ : null;
434
+ const purposeChangeState =
435
+ explicitPurposeState ??
436
+ {
437
+ reuse_previous: "same_purpose",
438
+ regenerate: "purpose_changed",
439
+ needs_review: "insufficient_evidence",
440
+ }[normalizedDecision];
441
+ const decisionFromPurpose =
442
+ purposeChangeState === "same_purpose"
443
+ ? "reuse_previous"
444
+ : purposeChangeState === "insufficient_evidence"
445
+ ? "needs_review"
446
+ : "regenerate";
319
447
  return {
320
- decision: normalizedDecision,
448
+ decision: decisionFromPurpose,
449
+ purpose_change_state: purposeChangeState,
321
450
  confidence: Number.isFinite(Number(decision.confidence))
322
451
  ? Math.max(0, Math.min(1, Number(decision.confidence)))
323
452
  : 0,
@@ -325,6 +454,9 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
325
454
  typeof decision.reason === "string" && decision.reason.trim()
326
455
  ? decision.reason.trim()
327
456
  : "AI reuse decision did not include a reason.",
457
+ missing_context: stringArray(decision.missing_context, {
458
+ ai_context: {},
459
+ }),
328
460
  };
329
461
  };
330
462
 
@@ -367,10 +499,12 @@ const callAiReuseDecisionProvider = async ({
367
499
  : null;
368
500
  return {
369
501
  decision: "needs_review",
502
+ purpose_change_state: "insufficient_evidence",
370
503
  confidence: 0,
371
504
  reason: providerStatusMatch?.groups?.status
372
505
  ? `AI reuse decision failed: provider_${providerStatusMatch.groups.status}.`
373
506
  : "AI reuse decision returned malformed JSON.",
507
+ missing_context: ["AI reuse decision failed or returned malformed JSON."],
374
508
  };
375
509
  }
376
510
  };
@@ -1373,6 +1507,7 @@ const buildOperationAssignmentCollections = ({
1373
1507
  routeEvidenceRefs,
1374
1508
  catalogByGroup,
1375
1509
  aiSelectionDecisions,
1510
+ existingEffectKeys = [],
1376
1511
  }) => {
1377
1512
  const operationEffects = [];
1378
1513
  const unresolvedOperationEffects = [];
@@ -1380,7 +1515,8 @@ const buildOperationAssignmentCollections = ({
1380
1515
  const mappings = [];
1381
1516
  const aiInferenceEvidence = [];
1382
1517
  const semanticMeaningCandidates = [];
1383
- const emittedEffectKeys = new Set();
1518
+ const existingEffectKeySet = new Set(existingEffectKeys);
1519
+ const emittedEffectKeys = new Set(existingEffectKeys);
1384
1520
 
1385
1521
  const candidates = normalizeAiOperationEffectCandidates(aiRoute);
1386
1522
  for (
@@ -1569,6 +1705,9 @@ const buildOperationAssignmentCollections = ({
1569
1705
  continue;
1570
1706
  }
1571
1707
  if (emittedEffectKeys.has(operationEffectKey)) {
1708
+ if (existingEffectKeySet.has(operationEffectKey)) {
1709
+ continue;
1710
+ }
1572
1711
  unresolvedOperationEffects.push(
1573
1712
  unresolvedEffect({
1574
1713
  route,
@@ -1787,7 +1926,9 @@ const buildSnapshot = ({
1787
1926
  for (const [operationSourceKey, plan] of routePlans.entries()) {
1788
1927
  if (
1789
1928
  plan.origin === "unchanged_route_reused" ||
1790
- plan.origin === "changed_route_semantic_reused"
1929
+ plan.origin === "changed_route_semantic_reused" ||
1930
+ plan.active_semantic_source === "previous_kept_pending_review" ||
1931
+ plan.purpose_change_state === "purpose_added"
1791
1932
  ) {
1792
1933
  reusedRouteKeys.push(operationSourceKey);
1793
1934
  }
@@ -1813,6 +1954,10 @@ const buildSnapshot = ({
1813
1954
  };
1814
1955
  const plan = routePlans.get(route.operation_source_key) ?? {
1815
1956
  origin: "new_route_ai_generated",
1957
+ purpose_change_state: "new_route",
1958
+ active_semantic_source: "new_confirmed",
1959
+ stability_confidence: 1,
1960
+ stability_missing_context: [],
1816
1961
  route_input_hash: routeInputHash(route),
1817
1962
  };
1818
1963
  const routeEvidenceRefs = buildSourceEvidenceRefs(
@@ -1821,15 +1966,17 @@ const buildSnapshot = ({
1821
1966
  );
1822
1967
  sourceEvidenceRefs.push(...routeEvidenceRefs);
1823
1968
  if (
1824
- (plan.origin === "unchanged_route_reused" ||
1825
- plan.origin === "changed_route_semantic_reused") &&
1826
- plan.previous_route
1969
+ plan.previous_route &&
1970
+ (plan.active_semantic_source === "previous_reused" ||
1971
+ plan.active_semantic_source === "previous_kept_pending_review") &&
1972
+ plan.purpose_change_state !== "purpose_added"
1827
1973
  ) {
1828
1974
  return rebasePreviousRoute({
1829
1975
  previousRoute: plan.previous_route,
1830
1976
  currentRoute: route,
1831
1977
  semanticSnapshotVersion: provisionalSemanticSnapshotVersion,
1832
1978
  plan,
1979
+ routeEvidenceRefs,
1833
1980
  });
1834
1981
  }
1835
1982
  const aiRoute = aiRoutes.get(route.operation_source_key);
@@ -1869,6 +2016,12 @@ const buildSnapshot = ({
1869
2016
  routeEvidenceRefs,
1870
2017
  catalogByGroup,
1871
2018
  aiSelectionDecisions,
2019
+ existingEffectKeys:
2020
+ plan.purpose_change_state === "purpose_added"
2021
+ ? safeArray(plan.previous_route?.operation_effects).map(
2022
+ (effect) => effect.operation_effect_key,
2023
+ )
2024
+ : [],
1872
2025
  });
1873
2026
  targetObjectProfiles.push(...assignmentCollections.profiles);
1874
2027
  targetObjectMappings.push(...assignmentCollections.mappings);
@@ -1876,7 +2029,44 @@ const buildSnapshot = ({
1876
2029
  semanticMeaningCandidates.push(
1877
2030
  ...assignmentCollections.semanticMeaningCandidates,
1878
2031
  );
1879
- return {
2032
+ if (plan.purpose_change_state === "purpose_added" && plan.previous_route) {
2033
+ const rebasedPrevious = rebasePreviousRoute({
2034
+ previousRoute: plan.previous_route,
2035
+ currentRoute: route,
2036
+ semanticSnapshotVersion: provisionalSemanticSnapshotVersion,
2037
+ plan,
2038
+ routeEvidenceRefs,
2039
+ });
2040
+ const previousEffectKeys = new Set(
2041
+ safeArray(rebasedPrevious.operation_effects).map(
2042
+ (effect) => effect.operation_effect_key,
2043
+ ),
2044
+ );
2045
+ const appendedEffects = assignmentCollections.operationEffects.filter(
2046
+ (effect) => !previousEffectKeys.has(effect.operation_effect_key),
2047
+ );
2048
+ const mergedRoute = {
2049
+ ...rebasedPrevious,
2050
+ operation_effects: [
2051
+ ...safeArray(rebasedPrevious.operation_effects),
2052
+ ...appendedEffects,
2053
+ ],
2054
+ unresolved_operation_effects: [
2055
+ ...safeArray(rebasedPrevious.unresolved_operation_effects),
2056
+ ...assignmentCollections.unresolvedOperationEffects,
2057
+ ],
2058
+ semantic_change_reason: plan.semantic_change_reason,
2059
+ };
2060
+ return {
2061
+ ...mergedRoute,
2062
+ route_semantic_hash: routeSemanticHash(mergedRoute),
2063
+ };
2064
+ }
2065
+
2066
+ return withStabilityMetadata({
2067
+ currentRoute: route,
2068
+ plan,
2069
+ route: {
1880
2070
  ...baseRoute,
1881
2071
  operation_effects: assignmentCollections.operationEffects,
1882
2072
  unresolved_operation_effects:
@@ -1898,7 +2088,8 @@ const buildSnapshot = ({
1898
2088
  unresolved_operation_effects:
1899
2089
  assignmentCollections.unresolvedOperationEffects,
1900
2090
  }),
1901
- };
2091
+ },
2092
+ });
1902
2093
  });
1903
2094
  addUniqueBy(
1904
2095
  targetObjectProfiles,
@@ -1942,7 +2133,7 @@ const buildSnapshot = ({
1942
2133
  idempotency_key: sha256(
1943
2134
  `${request.project_key}:${request.repository.repository_id}:${request.repository.merge_commit}:${request.repository.workflow_run_id ?? generatedAt}`,
1944
2135
  ),
1945
- schema_version: 2,
2136
+ schema_version: SEMANTIC_SNAPSHOT_SCHEMA_VERSION,
1946
2137
  semantic_snapshot_version: semanticSnapshotVersion,
1947
2138
  generation_contract: generationContract,
1948
2139
  ai_runtime: aiRuntime,
@@ -1984,6 +2175,24 @@ const buildSnapshot = ({
1984
2175
  (route) =>
1985
2176
  route.semantic_origin === "changed_route_semantic_regenerated",
1986
2177
  ).length,
2178
+ same_purpose_routes: versionedSnapshotRoutes.filter(
2179
+ (route) => route.purpose_change_state === "same_purpose",
2180
+ ).length,
2181
+ purpose_added_routes: versionedSnapshotRoutes.filter(
2182
+ (route) => route.purpose_change_state === "purpose_added",
2183
+ ).length,
2184
+ purpose_changed_routes: versionedSnapshotRoutes.filter(
2185
+ (route) => route.purpose_change_state === "purpose_changed",
2186
+ ).length,
2187
+ purpose_removed_routes: versionedSnapshotRoutes.filter(
2188
+ (route) => route.purpose_change_state === "purpose_removed",
2189
+ ).length,
2190
+ insufficient_evidence_routes: versionedSnapshotRoutes.filter(
2191
+ (route) => route.purpose_change_state === "insufficient_evidence",
2192
+ ).length,
2193
+ new_route_purpose_routes: versionedSnapshotRoutes.filter(
2194
+ (route) => route.purpose_change_state === "new_route",
2195
+ ).length,
1987
2196
  },
1988
2197
  routes: versionedSnapshotRoutes,
1989
2198
  target_object_profiles: targetObjectProfiles,
@@ -2228,10 +2437,87 @@ const hasReusablePreviousRouteSemantics = (route) =>
2228
2437
  Number(route?.semantics?.route_confidence ?? 0) > 0 &&
2229
2438
  route?.semantics?.route_summary !== "unknown";
2230
2439
 
2440
+ const normalizeRoutePathForPolicy = (path) => {
2441
+ const trimmed = String(path ?? "").trim();
2442
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2443
+ return withSlash.replace(/\/{2,}/g, "/").replace(/\/$/g, "") || "/";
2444
+ };
2445
+
2446
+ const isReservedClueBrowserTokenPath = (path) =>
2447
+ /^\/(?:api\/(?:v[0-9]+\/)?)?clue\/browser-tokens?$/i.test(
2448
+ normalizeRoutePathForPolicy(path),
2449
+ );
2450
+
2451
+ const routeSourceForPolicy = (route) =>
2452
+ [
2453
+ route?.ai_context?.route_decorator,
2454
+ route?.ai_context?.source_snippet,
2455
+ safeArray(route?.ai_context?.code_structure?.call_targets).join("\n"),
2456
+ ]
2457
+ .filter((entry) => typeof entry === "string")
2458
+ .join("\n");
2459
+
2460
+ const classifyClueInfrastructureRoute = (route) => {
2461
+ const source = routeSourceForPolicy(route);
2462
+ const reservedPath = isReservedClueBrowserTokenPath(route.path_template);
2463
+ const proxiesToClueBrowserTokenApi =
2464
+ /\/(?:api\/v[0-9]+\/)?ingest\/browser-tokens\b/i.test(source);
2465
+ const usesServerSideClueApiKey = /\bCLUE_API_KEY\b|x-clue-api-key/i.test(
2466
+ source,
2467
+ );
2468
+ if (
2469
+ route.method === "POST" &&
2470
+ reservedPath &&
2471
+ proxiesToClueBrowserTokenApi &&
2472
+ usesServerSideClueApiKey
2473
+ ) {
2474
+ return {
2475
+ excluded: true,
2476
+ reason:
2477
+ "Clue SDK browser-token proxy route detected by reserved Clue path, Clue browser-token API proxy call, and server-side Clue API key usage.",
2478
+ evidence: {
2479
+ reserved_clue_path: true,
2480
+ proxies_to_clue_browser_token_api: true,
2481
+ uses_server_side_clue_api_key: true,
2482
+ },
2483
+ };
2484
+ }
2485
+ return {
2486
+ excluded: false,
2487
+ reason: null,
2488
+ evidence: {
2489
+ reserved_clue_path: reservedPath,
2490
+ proxies_to_clue_browser_token_api: proxiesToClueBrowserTokenApi,
2491
+ uses_server_side_clue_api_key: usesServerSideClueApiKey,
2492
+ },
2493
+ };
2494
+ };
2495
+
2496
+ const splitSemanticProductRoutes = (routes) => {
2497
+ const productRoutes = [];
2498
+ const excludedInfrastructureRoutes = [];
2499
+ for (const route of routes) {
2500
+ const classification = classifyClueInfrastructureRoute(route);
2501
+ if (classification.excluded) {
2502
+ excludedInfrastructureRoutes.push({
2503
+ operation_source_key: route.operation_source_key,
2504
+ method: route.method,
2505
+ path_template: route.path_template,
2506
+ reason: classification.reason,
2507
+ evidence: classification.evidence,
2508
+ });
2509
+ continue;
2510
+ }
2511
+ productRoutes.push(route);
2512
+ }
2513
+ return { productRoutes, excludedInfrastructureRoutes };
2514
+ };
2515
+
2231
2516
  const buildRouteEvidencePromptEntry = ({ route, hashScope }) => ({
2232
2517
  operation_source_key: route.operation_source_key,
2233
2518
  method: route.method,
2234
2519
  path_template: route.path_template,
2520
+ route_evidence_packet: buildRouteEvidencePacket(route, hashScope),
2235
2521
  evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
2236
2522
  (entry, index) => ({
2237
2523
  evidence_index: index,
@@ -2262,6 +2548,10 @@ const classifyRoutesForSnapshot = async ({
2262
2548
  if (!previousRoute) {
2263
2549
  plans.set(route.operation_source_key, {
2264
2550
  origin: "new_route_ai_generated",
2551
+ purpose_change_state: "new_route",
2552
+ active_semantic_source: "new_confirmed",
2553
+ stability_confidence: 1,
2554
+ stability_missing_context: [],
2265
2555
  route_input_hash: currentHash,
2266
2556
  });
2267
2557
  routesRequiringGeneration.push(route);
@@ -2274,6 +2564,10 @@ const classifyRoutesForSnapshot = async ({
2274
2564
  ) {
2275
2565
  plans.set(route.operation_source_key, {
2276
2566
  origin: "unchanged_route_reused",
2567
+ purpose_change_state: "same_purpose",
2568
+ active_semantic_source: "previous_reused",
2569
+ stability_confidence: 1,
2570
+ stability_missing_context: [],
2277
2571
  route_input_hash: currentHash,
2278
2572
  previous_route: previousRoute,
2279
2573
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2288,6 +2582,12 @@ const classifyRoutesForSnapshot = async ({
2288
2582
  if (previousRoute.route_input_hash === currentHash) {
2289
2583
  plans.set(route.operation_source_key, {
2290
2584
  origin: "changed_route_semantic_regenerated",
2585
+ purpose_change_state: "new_route",
2586
+ active_semantic_source: "new_confirmed",
2587
+ stability_confidence: 0,
2588
+ stability_missing_context: [
2589
+ "Previous route semantic confidence was too low for stable reuse.",
2590
+ ],
2291
2591
  route_input_hash: currentHash,
2292
2592
  previous_route: previousRoute,
2293
2593
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2310,9 +2610,16 @@ const classifyRoutesForSnapshot = async ({
2310
2610
  generationContract,
2311
2611
  agentSkills,
2312
2612
  });
2313
- if (decision.decision === "reuse_previous" && decision.confidence >= 0.75) {
2613
+ if (
2614
+ decision.purpose_change_state === "same_purpose" &&
2615
+ decision.confidence >= PURPOSE_STABILITY_CONFIDENCE_THRESHOLD
2616
+ ) {
2314
2617
  plans.set(route.operation_source_key, {
2315
2618
  origin: "changed_route_semantic_reused",
2619
+ purpose_change_state: "same_purpose",
2620
+ active_semantic_source: "previous_reused",
2621
+ stability_confidence: decision.confidence,
2622
+ stability_missing_context: decision.missing_context,
2316
2623
  route_input_hash: currentHash,
2317
2624
  previous_route: previousRoute,
2318
2625
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2322,9 +2629,16 @@ const classifyRoutesForSnapshot = async ({
2322
2629
  });
2323
2630
  continue;
2324
2631
  }
2325
- if (decision.decision === "reuse_previous") {
2632
+ if (
2633
+ decision.purpose_change_state === "same_purpose" ||
2634
+ decision.purpose_change_state === "insufficient_evidence"
2635
+ ) {
2326
2636
  plans.set(route.operation_source_key, {
2327
2637
  origin: "changed_route_needs_review",
2638
+ purpose_change_state: "insufficient_evidence",
2639
+ active_semantic_source: "previous_kept_pending_review",
2640
+ stability_confidence: decision.confidence,
2641
+ stability_missing_context: decision.missing_context,
2328
2642
  route_input_hash: currentHash,
2329
2643
  previous_route: previousRoute,
2330
2644
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2337,9 +2651,16 @@ const classifyRoutesForSnapshot = async ({
2337
2651
  });
2338
2652
  continue;
2339
2653
  }
2340
- if (decision.decision === "regenerate") {
2654
+ if (
2655
+ decision.decision === "regenerate" &&
2656
+ decision.confidence >= PURPOSE_STABILITY_CONFIDENCE_THRESHOLD
2657
+ ) {
2341
2658
  plans.set(route.operation_source_key, {
2342
2659
  origin: "changed_route_semantic_regenerated",
2660
+ purpose_change_state: decision.purpose_change_state,
2661
+ active_semantic_source: "new_confirmed",
2662
+ stability_confidence: decision.confidence,
2663
+ stability_missing_context: decision.missing_context,
2343
2664
  route_input_hash: currentHash,
2344
2665
  previous_route: previousRoute,
2345
2666
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2352,6 +2673,10 @@ const classifyRoutesForSnapshot = async ({
2352
2673
  }
2353
2674
  plans.set(route.operation_source_key, {
2354
2675
  origin: "changed_route_needs_review",
2676
+ purpose_change_state: "insufficient_evidence",
2677
+ active_semantic_source: "previous_kept_pending_review",
2678
+ stability_confidence: decision.confidence,
2679
+ stability_missing_context: decision.missing_context,
2355
2680
  route_input_hash: currentHash,
2356
2681
  previous_route: previousRoute,
2357
2682
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2380,7 +2705,14 @@ const rebasePreviousRoute = ({
2380
2705
  currentRoute,
2381
2706
  semanticSnapshotVersion,
2382
2707
  plan,
2708
+ routeEvidenceRefs = [],
2383
2709
  }) => {
2710
+ const sourceEvidenceRefs = [
2711
+ ...new Set([
2712
+ ...safeArray(previousRoute.source_evidence_refs),
2713
+ ...routeEvidenceRefs.map((entry) => entry.id),
2714
+ ]),
2715
+ ];
2384
2716
  const route = {
2385
2717
  ...previousRoute,
2386
2718
  operation_source_key: currentRoute.operation_source_key,
@@ -2395,6 +2727,11 @@ const rebasePreviousRoute = ({
2395
2727
  previous_route_semantic_hash: plan.previous_route_semantic_hash,
2396
2728
  semantic_origin: plan.origin,
2397
2729
  semantic_change_reason: plan.semantic_change_reason,
2730
+ purpose_change_state: plan.purpose_change_state,
2731
+ active_semantic_source: plan.active_semantic_source,
2732
+ semantic_stability: buildSemanticStability(plan),
2733
+ evidence_packet_summary: buildEvidencePacketSummary(currentRoute),
2734
+ source_evidence_refs: sourceEvidenceRefs,
2398
2735
  };
2399
2736
  return {
2400
2737
  ...route,
@@ -2402,6 +2739,42 @@ const rebasePreviousRoute = ({
2402
2739
  };
2403
2740
  };
2404
2741
 
2742
+ const buildSemanticStability = (plan) => ({
2743
+ purpose_change_state: plan.purpose_change_state ?? "new_route",
2744
+ confidence:
2745
+ typeof plan.stability_confidence === "number"
2746
+ ? Math.max(0, Math.min(1, plan.stability_confidence))
2747
+ : plan.purpose_change_state === "new_route"
2748
+ ? 1
2749
+ : 0,
2750
+ reason:
2751
+ plan.semantic_change_reason ??
2752
+ (plan.purpose_change_state === "new_route"
2753
+ ? "No previous route semantic existed, so a new active semantic was generated."
2754
+ : "Semantic purpose stability was evaluated for this route."),
2755
+ ...(plan.previous_route_input_hash
2756
+ ? { previous_route_input_hash: plan.previous_route_input_hash }
2757
+ : {}),
2758
+ ...(plan.previous_route_semantic_hash
2759
+ ? { previous_route_semantic_hash: plan.previous_route_semantic_hash }
2760
+ : {}),
2761
+ ...(plan.previous_route?.semantic_snapshot_version
2762
+ ? {
2763
+ previous_semantic_snapshot_version:
2764
+ plan.previous_route.semantic_snapshot_version,
2765
+ }
2766
+ : {}),
2767
+ missing_context: safeArray(plan.stability_missing_context),
2768
+ });
2769
+
2770
+ const withStabilityMetadata = ({ route, currentRoute, plan }) => ({
2771
+ ...route,
2772
+ purpose_change_state: plan.purpose_change_state,
2773
+ active_semantic_source: plan.active_semantic_source,
2774
+ semantic_stability: buildSemanticStability(plan),
2775
+ evidence_packet_summary: buildEvidencePacketSummary(currentRoute),
2776
+ });
2777
+
2405
2778
  const fieldPathMatchesKeys = ({
2406
2779
  fieldPath,
2407
2780
  routeKeys,
@@ -2452,7 +2825,7 @@ const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
2452
2825
  ),
2453
2826
  ),
2454
2827
  ]);
2455
- const catalogs = safeArray(previousSnapshot?.target_object_catalog).filter(
2828
+ const catalogCandidates = safeArray(previousSnapshot?.target_object_catalog).filter(
2456
2829
  (entry) => catalogKeys.has(entry.target_object_key),
2457
2830
  );
2458
2831
  const matches = (fieldPath) =>
@@ -2508,11 +2881,6 @@ const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
2508
2881
  for (const mapping of mappings) {
2509
2882
  sourceEvidenceIds.add(mapping.grouping_evidence_ref);
2510
2883
  }
2511
- for (const catalog of catalogs) {
2512
- for (const ref of safeArray(catalog.grouping_evidence_refs)) {
2513
- sourceEvidenceIds.add(ref);
2514
- }
2515
- }
2516
2884
  for (const evidence of aiInferenceEvidence) {
2517
2885
  for (const ref of safeArray(evidence.input_source_evidence_refs)) {
2518
2886
  sourceEvidenceIds.add(ref);
@@ -2526,6 +2894,21 @@ const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
2526
2894
  sourceEvidenceIds.add(ref);
2527
2895
  }
2528
2896
  }
2897
+ const catalogs = catalogCandidates
2898
+ .map((entry) => ({
2899
+ ...entry,
2900
+ raw_target_object_profile_refs: safeArray(
2901
+ entry.raw_target_object_profile_refs,
2902
+ ).filter((ref) => profileIds.has(ref)),
2903
+ grouping_evidence_refs: safeArray(entry.grouping_evidence_refs).filter(
2904
+ (ref) => sourceEvidenceIds.has(ref),
2905
+ ),
2906
+ }))
2907
+ .filter(
2908
+ (entry) =>
2909
+ entry.raw_target_object_profile_refs.length > 0 &&
2910
+ entry.grouping_evidence_refs.length > 0,
2911
+ );
2529
2912
  const sourceEvidenceRefs = safeArray(
2530
2913
  previousSnapshot?.source_evidence_refs,
2531
2914
  ).filter((entry) => sourceEvidenceIds.has(entry.id));
@@ -2873,10 +3256,12 @@ export const runSemanticCi = async ({
2873
3256
  allowedSourcePaths: request.allowed_source_paths,
2874
3257
  excludedSourcePaths: request.excluded_source_paths,
2875
3258
  });
2876
- const routes = await analyzeFastApiRoutes({ repoRoot, files });
3259
+ const discoveredRoutes = await analyzeFastApiRoutes({ repoRoot, files });
3260
+ const { productRoutes: routes, excludedInfrastructureRoutes } =
3261
+ splitSemanticProductRoutes(discoveredRoutes);
2877
3262
  if (routes.length === 0) {
2878
3263
  throw new Error(
2879
- "semantic CI discovered zero routes; check framework, backend root path, and allowed_source_paths",
3264
+ "semantic CI discovered zero routes after infrastructure exclusions; check framework, backend root path, allowed_source_paths, and infrastructure route exclusions",
2880
3265
  );
2881
3266
  }
2882
3267
  if (!env.CLUE_API_KEY) {
@@ -2979,6 +3364,7 @@ export const runSemanticCi = async ({
2979
3364
  return {
2980
3365
  accepted: upload.accepted === true,
2981
3366
  route_count: snapshot.routes.length,
3367
+ excluded_infrastructure_routes: excludedInfrastructureRoutes,
2982
3368
  upload,
2983
3369
  };
2984
3370
  };
@@ -2989,10 +3375,12 @@ export const runSemanticInventory = async ({ repoRoot, request }) => {
2989
3375
  allowedSourcePaths: request.allowed_source_paths,
2990
3376
  excludedSourcePaths: request.excluded_source_paths,
2991
3377
  });
2992
- const routes = await analyzeFastApiRoutes({ repoRoot, files });
3378
+ const discoveredRoutes = await analyzeFastApiRoutes({ repoRoot, files });
3379
+ const { productRoutes: routes, excludedInfrastructureRoutes } =
3380
+ splitSemanticProductRoutes(discoveredRoutes);
2993
3381
  if (routes.length === 0) {
2994
3382
  throw new Error(
2995
- "semantic inventory discovered zero routes; check framework, backend root path, and allowed_source_paths",
3383
+ "semantic inventory discovered zero routes after infrastructure exclusions; check framework, backend root path, allowed_source_paths, and infrastructure route exclusions",
2996
3384
  );
2997
3385
  }
2998
3386
  return {
@@ -3000,7 +3388,9 @@ export const runSemanticInventory = async ({ repoRoot, request }) => {
3000
3388
  service_key: request.service_key,
3001
3389
  allowed_source_paths: request.allowed_source_paths,
3002
3390
  excluded_source_paths: request.excluded_source_paths,
3391
+ discovered_route_count: discoveredRoutes.length,
3003
3392
  route_count: routes.length,
3393
+ excluded_infrastructure_routes: excludedInfrastructureRoutes,
3004
3394
  operation_source_keys: routes.map((route) => route.operation_source_key),
3005
3395
  routes: routes.map((route) => ({
3006
3396
  operation_source_key: route.operation_source_key,