@clue-ai/cli 0.0.16 → 0.0.18

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,25 @@ 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
+ ]);
99
+
100
+ const shouldPurposeRecheckAllRoutes = (env) =>
101
+ env.CLUE_SEMANTIC_PURPOSE_RECHECK_ALL === "1";
89
102
 
90
103
  const semanticSnapshotHashScope = (request) =>
91
104
  [
@@ -176,6 +189,92 @@ const fallbackSemantics = (route, reason, hashScope) => ({
176
189
  confidence_reason: reason,
177
190
  });
178
191
 
192
+ const redactAiInputSource = (value) =>
193
+ String(value ?? "")
194
+ .slice(0, 6000)
195
+ .replace(
196
+ /\b[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)\b(?:\s*=\s*["']?[^"'\s,;}]+)?/g,
197
+ "[secret]",
198
+ )
199
+ .replace(/\bBearer\s+(?!\[secret\])[A-Za-z0-9._~+/-]+=*/gi, "Bearer [secret]")
200
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[secret]");
201
+
202
+ const lastCallSegment = (target) =>
203
+ String(target ?? "")
204
+ .split(".")
205
+ .map((part) => part.trim())
206
+ .filter(Boolean)
207
+ .at(-1) ?? "";
208
+
209
+ const callActionHints = (route) =>
210
+ [
211
+ ...new Set(
212
+ safeArray(route.ai_context?.code_structure?.call_targets)
213
+ .map(lastCallSegment)
214
+ .map((segment) => normalizeSnakeSegment(segment))
215
+ .filter((segment) => segment && EFFECT_ACTION_TOKENS.has(segment)),
216
+ ),
217
+ ].slice(0, 12);
218
+
219
+ const buildEvidencePacketSummary = (route) => {
220
+ const callTargets = safeArray(route.ai_context?.code_structure?.call_targets);
221
+ const dependencyHints = safeArray(route.ai_context?.code_structure?.dependency_hints);
222
+ const actionHints = callActionHints(route);
223
+ return {
224
+ contract_summary: `${route.method ?? "UNKNOWN"} ${route.path_template ?? "/"} operation source contract.`,
225
+ behavior_summary:
226
+ callTargets.length > 0
227
+ ? `Handler has ${callTargets.length} bounded call target(s) that may describe domain behavior.`
228
+ : "No bounded domain call targets were detected in the route handler.",
229
+ data_effect_hints: actionHints,
230
+ side_effect_hints: callTargets
231
+ .map(lastCallSegment)
232
+ .map((segment) => normalizeSnakeSegment(segment))
233
+ .filter((segment) =>
234
+ ["send", "publish", "emit", "schedule", "enqueue", "notify"].includes(
235
+ segment,
236
+ ),
237
+ )
238
+ .slice(0, 12),
239
+ validation_hints: safeArray(
240
+ route.ai_context?.code_structure?.parameter_annotations,
241
+ )
242
+ .map((hint) => sanitizeText(String(hint), route))
243
+ .slice(0, 12),
244
+ auth_hints: dependencyHints
245
+ .map((hint) => sanitizeText(String(hint), route))
246
+ .slice(0, 12),
247
+ missing_context:
248
+ callTargets.length === 0
249
+ ? ["No local dependency call evidence was detected."]
250
+ : [],
251
+ };
252
+ };
253
+
254
+ const buildRouteEvidencePacket = (route, hashScope) => ({
255
+ contract: {
256
+ operation_source_key: route.operation_source_key,
257
+ method: route.method,
258
+ path_template: route.path_template,
259
+ router_prefix: route.ai_context?.router_prefix ?? null,
260
+ include_prefix: route.ai_context?.include_prefix ?? null,
261
+ },
262
+ code_context: {
263
+ handler_name: route.ai_context?.handler_name ?? null,
264
+ handler_source_excerpt: redactAiInputSource(route.ai_context?.source_snippet),
265
+ code_structure: route.ai_context?.code_structure ?? {},
266
+ },
267
+ stored_summary_preview: buildEvidencePacketSummary(route),
268
+ evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
269
+ (entry, index) => ({
270
+ evidence_index: index,
271
+ kind: entry.kind,
272
+ summary: entry.summary,
273
+ evidence_strength: entry.evidence_strength,
274
+ }),
275
+ ),
276
+ });
277
+
179
278
  const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
180
279
  JSON.stringify({
181
280
  ...semanticAgentPromptEnvelope({
@@ -187,8 +286,8 @@ const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
187
286
  rules: [
188
287
  "Do not create operation_source_key values; only copy the provided keys.",
189
288
  "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.",
289
+ "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.",
290
+ "Use the provided route_evidence_packet to infer purpose-level business semantics and operation effects.",
192
291
  "Return operation_effect_candidates only when source evidence supports a schema-valid target object and effect action.",
193
292
  "Keep multiple effects separate when one route produces multiple meaningful effects.",
194
293
  "Do not encode actual outcomes such as completed, failed, blocked, validation_error, or abandoned into operation_effect_key or expected_domain_effect.",
@@ -228,6 +327,7 @@ const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
228
327
  method: route.method,
229
328
  path_template: route.path_template,
230
329
  evidence_summaries: route.evidence_summaries,
330
+ route_evidence_packet: route.route_evidence_packet,
231
331
  })),
232
332
  });
233
333
 
@@ -243,24 +343,30 @@ const buildAiReuseDecisionPrompt = ({
243
343
  bundle: agentSkills,
244
344
  roleId: SEMANTIC_AGENT_ROLE_IDS.reuseJudge,
245
345
  }),
246
- task: "Decide whether previous privacy-safe route semantics still apply after a route source change. Return JSON only.",
346
+ 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
347
  generation_contract: generationContract,
248
348
  rules: [
249
349
  "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.",
350
+ "First decide purpose_change_state as same_purpose, purpose_added, purpose_changed, purpose_removed, or insufficient_evidence.",
351
+ "Use same_purpose when implementation details or wording changed but the customer-visible purpose and operation effects are unchanged.",
352
+ "Use purpose_added when the previous purpose still applies and the current route adds a new customer-visible purpose or operation effect.",
353
+ "Use purpose_changed when a previous purpose or primary operation effect is replaced with a different business object or effect.",
354
+ "Use purpose_removed when a previous purpose or operation effect is no longer present.",
355
+ "Use insufficient_evidence when evidence is sparse, conflicting, or cannot prove a purpose-level change.",
356
+ "Return legacy decision as reuse_previous for same_purpose, regenerate for purpose_added/purpose_changed/purpose_removed, and needs_review for insufficient_evidence.",
357
+ "Never recommend changing active semantics for wording drift alone.",
254
358
  "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
359
  ],
256
360
  output_shape: {
257
361
  decisions: [
258
362
  {
259
363
  operation_source_key: "route.POST./reports",
364
+ purpose_change_state: "same_purpose",
260
365
  decision: "reuse_previous",
261
366
  confidence: 0.82,
262
367
  reason:
263
368
  "The source changed but the privacy-safe evidence still supports the previous reports.create meaning.",
369
+ missing_context: [],
264
370
  },
265
371
  ],
266
372
  },
@@ -273,6 +379,7 @@ const buildAiReuseDecisionPrompt = ({
273
379
  previous_semantics: previousRoute.semantics,
274
380
  previous_operation_effects: previousRoute.operation_effects ?? [],
275
381
  current_evidence_summaries: route.evidence_summaries,
382
+ current_route_evidence_packet: route.route_evidence_packet,
276
383
  },
277
384
  });
278
385
 
@@ -280,8 +387,10 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
280
387
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions)) {
281
388
  return {
282
389
  decision: "needs_review",
390
+ purpose_change_state: "insufficient_evidence",
283
391
  confidence: 0,
284
392
  reason: "AI reuse decision did not return the required decisions array.",
393
+ missing_context: ["AI reuse decision did not return a usable response."],
285
394
  };
286
395
  }
287
396
  const unknownDecision = parsed.decisions.find(
@@ -295,8 +404,10 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
295
404
  if (unknownDecision) {
296
405
  return {
297
406
  decision: "needs_review",
407
+ purpose_change_state: "insufficient_evidence",
298
408
  confidence: 0,
299
409
  reason: "AI reuse decision returned an unexpected route key.",
410
+ missing_context: ["AI reuse decision returned an unexpected route key."],
300
411
  };
301
412
  }
302
413
  const decision = safeArray(parsed?.decisions).find(
@@ -305,8 +416,10 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
305
416
  if (!decision || typeof decision !== "object") {
306
417
  return {
307
418
  decision: "needs_review",
419
+ purpose_change_state: "insufficient_evidence",
308
420
  confidence: 0,
309
421
  reason: "AI reuse decision omitted this changed route.",
422
+ missing_context: ["AI reuse decision omitted this changed route."],
310
423
  };
311
424
  }
312
425
  const normalizedDecision = [
@@ -316,8 +429,27 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
316
429
  ].includes(decision.decision)
317
430
  ? decision.decision
318
431
  : "needs_review";
432
+ const explicitPurposeState = PURPOSE_CHANGE_STATES.has(
433
+ decision.purpose_change_state,
434
+ )
435
+ ? decision.purpose_change_state
436
+ : null;
437
+ const purposeChangeState =
438
+ explicitPurposeState ??
439
+ {
440
+ reuse_previous: "same_purpose",
441
+ regenerate: "purpose_changed",
442
+ needs_review: "insufficient_evidence",
443
+ }[normalizedDecision];
444
+ const decisionFromPurpose =
445
+ purposeChangeState === "same_purpose"
446
+ ? "reuse_previous"
447
+ : purposeChangeState === "insufficient_evidence"
448
+ ? "needs_review"
449
+ : "regenerate";
319
450
  return {
320
- decision: normalizedDecision,
451
+ decision: decisionFromPurpose,
452
+ purpose_change_state: purposeChangeState,
321
453
  confidence: Number.isFinite(Number(decision.confidence))
322
454
  ? Math.max(0, Math.min(1, Number(decision.confidence)))
323
455
  : 0,
@@ -325,6 +457,9 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
325
457
  typeof decision.reason === "string" && decision.reason.trim()
326
458
  ? decision.reason.trim()
327
459
  : "AI reuse decision did not include a reason.",
460
+ missing_context: stringArray(decision.missing_context, {
461
+ ai_context: {},
462
+ }),
328
463
  };
329
464
  };
330
465
 
@@ -367,10 +502,12 @@ const callAiReuseDecisionProvider = async ({
367
502
  : null;
368
503
  return {
369
504
  decision: "needs_review",
505
+ purpose_change_state: "insufficient_evidence",
370
506
  confidence: 0,
371
507
  reason: providerStatusMatch?.groups?.status
372
508
  ? `AI reuse decision failed: provider_${providerStatusMatch.groups.status}.`
373
509
  : "AI reuse decision returned malformed JSON.",
510
+ missing_context: ["AI reuse decision failed or returned malformed JSON."],
374
511
  };
375
512
  }
376
513
  };
@@ -1373,6 +1510,7 @@ const buildOperationAssignmentCollections = ({
1373
1510
  routeEvidenceRefs,
1374
1511
  catalogByGroup,
1375
1512
  aiSelectionDecisions,
1513
+ existingEffectKeys = [],
1376
1514
  }) => {
1377
1515
  const operationEffects = [];
1378
1516
  const unresolvedOperationEffects = [];
@@ -1380,7 +1518,8 @@ const buildOperationAssignmentCollections = ({
1380
1518
  const mappings = [];
1381
1519
  const aiInferenceEvidence = [];
1382
1520
  const semanticMeaningCandidates = [];
1383
- const emittedEffectKeys = new Set();
1521
+ const existingEffectKeySet = new Set(existingEffectKeys);
1522
+ const emittedEffectKeys = new Set(existingEffectKeys);
1384
1523
 
1385
1524
  const candidates = normalizeAiOperationEffectCandidates(aiRoute);
1386
1525
  for (
@@ -1569,6 +1708,9 @@ const buildOperationAssignmentCollections = ({
1569
1708
  continue;
1570
1709
  }
1571
1710
  if (emittedEffectKeys.has(operationEffectKey)) {
1711
+ if (existingEffectKeySet.has(operationEffectKey)) {
1712
+ continue;
1713
+ }
1572
1714
  unresolvedOperationEffects.push(
1573
1715
  unresolvedEffect({
1574
1716
  route,
@@ -1787,7 +1929,9 @@ const buildSnapshot = ({
1787
1929
  for (const [operationSourceKey, plan] of routePlans.entries()) {
1788
1930
  if (
1789
1931
  plan.origin === "unchanged_route_reused" ||
1790
- plan.origin === "changed_route_semantic_reused"
1932
+ plan.origin === "changed_route_semantic_reused" ||
1933
+ plan.active_semantic_source === "previous_kept_pending_review" ||
1934
+ plan.purpose_change_state === "purpose_added"
1791
1935
  ) {
1792
1936
  reusedRouteKeys.push(operationSourceKey);
1793
1937
  }
@@ -1813,6 +1957,10 @@ const buildSnapshot = ({
1813
1957
  };
1814
1958
  const plan = routePlans.get(route.operation_source_key) ?? {
1815
1959
  origin: "new_route_ai_generated",
1960
+ purpose_change_state: "new_route",
1961
+ active_semantic_source: "new_confirmed",
1962
+ stability_confidence: 1,
1963
+ stability_missing_context: [],
1816
1964
  route_input_hash: routeInputHash(route),
1817
1965
  };
1818
1966
  const routeEvidenceRefs = buildSourceEvidenceRefs(
@@ -1821,15 +1969,17 @@ const buildSnapshot = ({
1821
1969
  );
1822
1970
  sourceEvidenceRefs.push(...routeEvidenceRefs);
1823
1971
  if (
1824
- (plan.origin === "unchanged_route_reused" ||
1825
- plan.origin === "changed_route_semantic_reused") &&
1826
- plan.previous_route
1972
+ plan.previous_route &&
1973
+ (plan.active_semantic_source === "previous_reused" ||
1974
+ plan.active_semantic_source === "previous_kept_pending_review") &&
1975
+ plan.purpose_change_state !== "purpose_added"
1827
1976
  ) {
1828
1977
  return rebasePreviousRoute({
1829
1978
  previousRoute: plan.previous_route,
1830
1979
  currentRoute: route,
1831
1980
  semanticSnapshotVersion: provisionalSemanticSnapshotVersion,
1832
1981
  plan,
1982
+ routeEvidenceRefs,
1833
1983
  });
1834
1984
  }
1835
1985
  const aiRoute = aiRoutes.get(route.operation_source_key);
@@ -1869,6 +2019,12 @@ const buildSnapshot = ({
1869
2019
  routeEvidenceRefs,
1870
2020
  catalogByGroup,
1871
2021
  aiSelectionDecisions,
2022
+ existingEffectKeys:
2023
+ plan.purpose_change_state === "purpose_added"
2024
+ ? safeArray(plan.previous_route?.operation_effects).map(
2025
+ (effect) => effect.operation_effect_key,
2026
+ )
2027
+ : [],
1872
2028
  });
1873
2029
  targetObjectProfiles.push(...assignmentCollections.profiles);
1874
2030
  targetObjectMappings.push(...assignmentCollections.mappings);
@@ -1876,29 +2032,67 @@ const buildSnapshot = ({
1876
2032
  semanticMeaningCandidates.push(
1877
2033
  ...assignmentCollections.semanticMeaningCandidates,
1878
2034
  );
1879
- return {
1880
- ...baseRoute,
1881
- operation_effects: assignmentCollections.operationEffects,
1882
- unresolved_operation_effects:
1883
- assignmentCollections.unresolvedOperationEffects,
1884
- source_evidence_refs: routeEvidenceRefs.map((entry) => entry.id),
1885
- route_input_hash: plan.route_input_hash,
1886
- previous_route_input_hash: plan.previous_route_input_hash,
1887
- previous_route_semantic_hash: plan.previous_route_semantic_hash,
1888
- semantic_origin:
1889
- plan.origin === "changed_route_needs_review" && aiRoute?.semantics
1890
- ? "changed_route_needs_review"
1891
- : plan.origin,
1892
- semantic_change_reason: plan.semantic_change_reason,
1893
- previous_semantic_snapshot_version:
1894
- plan.previous_route?.semantic_snapshot_version,
1895
- route_semantic_hash: routeSemanticHash({
2035
+ if (plan.purpose_change_state === "purpose_added" && plan.previous_route) {
2036
+ const rebasedPrevious = rebasePreviousRoute({
2037
+ previousRoute: plan.previous_route,
2038
+ currentRoute: route,
2039
+ semanticSnapshotVersion: provisionalSemanticSnapshotVersion,
2040
+ plan,
2041
+ routeEvidenceRefs,
2042
+ });
2043
+ const previousEffectKeys = new Set(
2044
+ safeArray(rebasedPrevious.operation_effects).map(
2045
+ (effect) => effect.operation_effect_key,
2046
+ ),
2047
+ );
2048
+ const appendedEffects = assignmentCollections.operationEffects.filter(
2049
+ (effect) => !previousEffectKeys.has(effect.operation_effect_key),
2050
+ );
2051
+ const mergedRoute = {
2052
+ ...rebasedPrevious,
2053
+ operation_effects: [
2054
+ ...safeArray(rebasedPrevious.operation_effects),
2055
+ ...appendedEffects,
2056
+ ],
2057
+ unresolved_operation_effects: [
2058
+ ...safeArray(rebasedPrevious.unresolved_operation_effects),
2059
+ ...assignmentCollections.unresolvedOperationEffects,
2060
+ ],
2061
+ semantic_change_reason: plan.semantic_change_reason,
2062
+ };
2063
+ return {
2064
+ ...mergedRoute,
2065
+ route_semantic_hash: routeSemanticHash(mergedRoute),
2066
+ };
2067
+ }
2068
+
2069
+ return withStabilityMetadata({
2070
+ currentRoute: route,
2071
+ plan,
2072
+ route: {
1896
2073
  ...baseRoute,
1897
2074
  operation_effects: assignmentCollections.operationEffects,
1898
2075
  unresolved_operation_effects:
1899
2076
  assignmentCollections.unresolvedOperationEffects,
1900
- }),
1901
- };
2077
+ source_evidence_refs: routeEvidenceRefs.map((entry) => entry.id),
2078
+ route_input_hash: plan.route_input_hash,
2079
+ previous_route_input_hash: plan.previous_route_input_hash,
2080
+ previous_route_semantic_hash: plan.previous_route_semantic_hash,
2081
+ semantic_origin:
2082
+ plan.origin === "changed_route_needs_review" && aiRoute?.semantics
2083
+ ? "changed_route_needs_review"
2084
+ : plan.origin,
2085
+ semantic_change_reason: plan.semantic_change_reason,
2086
+ previous_semantic_snapshot_version:
2087
+ plan.previous_route?.semantic_snapshot_version,
2088
+ route_semantic_hash: routeSemanticHash({
2089
+ ...baseRoute,
2090
+ operation_effects: assignmentCollections.operationEffects,
2091
+ unresolved_operation_effects:
2092
+ assignmentCollections.unresolvedOperationEffects,
2093
+ }),
2094
+ },
2095
+ });
1902
2096
  });
1903
2097
  addUniqueBy(
1904
2098
  targetObjectProfiles,
@@ -1942,7 +2136,7 @@ const buildSnapshot = ({
1942
2136
  idempotency_key: sha256(
1943
2137
  `${request.project_key}:${request.repository.repository_id}:${request.repository.merge_commit}:${request.repository.workflow_run_id ?? generatedAt}`,
1944
2138
  ),
1945
- schema_version: 2,
2139
+ schema_version: SEMANTIC_SNAPSHOT_SCHEMA_VERSION,
1946
2140
  semantic_snapshot_version: semanticSnapshotVersion,
1947
2141
  generation_contract: generationContract,
1948
2142
  ai_runtime: aiRuntime,
@@ -1984,6 +2178,24 @@ const buildSnapshot = ({
1984
2178
  (route) =>
1985
2179
  route.semantic_origin === "changed_route_semantic_regenerated",
1986
2180
  ).length,
2181
+ same_purpose_routes: versionedSnapshotRoutes.filter(
2182
+ (route) => route.purpose_change_state === "same_purpose",
2183
+ ).length,
2184
+ purpose_added_routes: versionedSnapshotRoutes.filter(
2185
+ (route) => route.purpose_change_state === "purpose_added",
2186
+ ).length,
2187
+ purpose_changed_routes: versionedSnapshotRoutes.filter(
2188
+ (route) => route.purpose_change_state === "purpose_changed",
2189
+ ).length,
2190
+ purpose_removed_routes: versionedSnapshotRoutes.filter(
2191
+ (route) => route.purpose_change_state === "purpose_removed",
2192
+ ).length,
2193
+ insufficient_evidence_routes: versionedSnapshotRoutes.filter(
2194
+ (route) => route.purpose_change_state === "insufficient_evidence",
2195
+ ).length,
2196
+ new_route_purpose_routes: versionedSnapshotRoutes.filter(
2197
+ (route) => route.purpose_change_state === "new_route",
2198
+ ).length,
1987
2199
  },
1988
2200
  routes: versionedSnapshotRoutes,
1989
2201
  target_object_profiles: targetObjectProfiles,
@@ -2228,10 +2440,87 @@ const hasReusablePreviousRouteSemantics = (route) =>
2228
2440
  Number(route?.semantics?.route_confidence ?? 0) > 0 &&
2229
2441
  route?.semantics?.route_summary !== "unknown";
2230
2442
 
2443
+ const normalizeRoutePathForPolicy = (path) => {
2444
+ const trimmed = String(path ?? "").trim();
2445
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2446
+ return withSlash.replace(/\/{2,}/g, "/").replace(/\/$/g, "") || "/";
2447
+ };
2448
+
2449
+ const isReservedClueBrowserTokenPath = (path) =>
2450
+ /^\/(?:api\/(?:v[0-9]+\/)?)?clue\/browser-tokens?$/i.test(
2451
+ normalizeRoutePathForPolicy(path),
2452
+ );
2453
+
2454
+ const routeSourceForPolicy = (route) =>
2455
+ [
2456
+ route?.ai_context?.route_decorator,
2457
+ route?.ai_context?.source_snippet,
2458
+ safeArray(route?.ai_context?.code_structure?.call_targets).join("\n"),
2459
+ ]
2460
+ .filter((entry) => typeof entry === "string")
2461
+ .join("\n");
2462
+
2463
+ const classifyClueInfrastructureRoute = (route) => {
2464
+ const source = routeSourceForPolicy(route);
2465
+ const reservedPath = isReservedClueBrowserTokenPath(route.path_template);
2466
+ const proxiesToClueBrowserTokenApi =
2467
+ /\/(?:api\/v[0-9]+\/)?ingest\/browser-tokens\b/i.test(source);
2468
+ const usesServerSideClueApiKey = /\bCLUE_API_KEY\b|x-clue-api-key/i.test(
2469
+ source,
2470
+ );
2471
+ if (
2472
+ route.method === "POST" &&
2473
+ reservedPath &&
2474
+ proxiesToClueBrowserTokenApi &&
2475
+ usesServerSideClueApiKey
2476
+ ) {
2477
+ return {
2478
+ excluded: true,
2479
+ reason:
2480
+ "Clue SDK browser-token proxy route detected by reserved Clue path, Clue browser-token API proxy call, and server-side Clue API key usage.",
2481
+ evidence: {
2482
+ reserved_clue_path: true,
2483
+ proxies_to_clue_browser_token_api: true,
2484
+ uses_server_side_clue_api_key: true,
2485
+ },
2486
+ };
2487
+ }
2488
+ return {
2489
+ excluded: false,
2490
+ reason: null,
2491
+ evidence: {
2492
+ reserved_clue_path: reservedPath,
2493
+ proxies_to_clue_browser_token_api: proxiesToClueBrowserTokenApi,
2494
+ uses_server_side_clue_api_key: usesServerSideClueApiKey,
2495
+ },
2496
+ };
2497
+ };
2498
+
2499
+ const splitSemanticProductRoutes = (routes) => {
2500
+ const productRoutes = [];
2501
+ const excludedInfrastructureRoutes = [];
2502
+ for (const route of routes) {
2503
+ const classification = classifyClueInfrastructureRoute(route);
2504
+ if (classification.excluded) {
2505
+ excludedInfrastructureRoutes.push({
2506
+ operation_source_key: route.operation_source_key,
2507
+ method: route.method,
2508
+ path_template: route.path_template,
2509
+ reason: classification.reason,
2510
+ evidence: classification.evidence,
2511
+ });
2512
+ continue;
2513
+ }
2514
+ productRoutes.push(route);
2515
+ }
2516
+ return { productRoutes, excludedInfrastructureRoutes };
2517
+ };
2518
+
2231
2519
  const buildRouteEvidencePromptEntry = ({ route, hashScope }) => ({
2232
2520
  operation_source_key: route.operation_source_key,
2233
2521
  method: route.method,
2234
2522
  path_template: route.path_template,
2523
+ route_evidence_packet: buildRouteEvidencePacket(route, hashScope),
2235
2524
  evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
2236
2525
  (entry, index) => ({
2237
2526
  evidence_index: index,
@@ -2251,6 +2540,7 @@ const classifyRoutesForSnapshot = async ({
2251
2540
  hashScope,
2252
2541
  generationContract,
2253
2542
  agentSkills,
2543
+ purposeRecheckAllRoutes = false,
2254
2544
  }) => {
2255
2545
  const previousRoutes = previousRouteMap(previousSnapshot);
2256
2546
  const plans = new Map();
@@ -2262,6 +2552,10 @@ const classifyRoutesForSnapshot = async ({
2262
2552
  if (!previousRoute) {
2263
2553
  plans.set(route.operation_source_key, {
2264
2554
  origin: "new_route_ai_generated",
2555
+ purpose_change_state: "new_route",
2556
+ active_semantic_source: "new_confirmed",
2557
+ stability_confidence: 1,
2558
+ stability_missing_context: [],
2265
2559
  route_input_hash: currentHash,
2266
2560
  });
2267
2561
  routesRequiringGeneration.push(route);
@@ -2272,8 +2566,86 @@ const classifyRoutesForSnapshot = async ({
2272
2566
  previousRoute.route_input_hash === currentHash &&
2273
2567
  hasReusablePreviousRouteSemantics(previousRoute)
2274
2568
  ) {
2569
+ if (purposeRecheckAllRoutes) {
2570
+ const decision = await callAiReuseDecisionProvider({
2571
+ request,
2572
+ env,
2573
+ apiKey: aiProviderApiKey,
2574
+ route: buildRouteEvidencePromptEntry({ route, hashScope }),
2575
+ previousRoute,
2576
+ currentHash,
2577
+ generationContract,
2578
+ agentSkills,
2579
+ });
2580
+ if (
2581
+ decision.purpose_change_state === "same_purpose" &&
2582
+ decision.confidence >= PURPOSE_STABILITY_CONFIDENCE_THRESHOLD
2583
+ ) {
2584
+ plans.set(route.operation_source_key, {
2585
+ origin: "unchanged_route_reused",
2586
+ purpose_change_state: "same_purpose",
2587
+ active_semantic_source: "previous_reused",
2588
+ stability_confidence: decision.confidence,
2589
+ stability_missing_context: decision.missing_context,
2590
+ route_input_hash: currentHash,
2591
+ previous_route: previousRoute,
2592
+ previous_route_input_hash: previousRoute.route_input_hash,
2593
+ previous_route_semantic_hash:
2594
+ previousRoute.route_semantic_hash ??
2595
+ routeSemanticHash(previousRoute),
2596
+ semantic_change_reason: sanitizeText(
2597
+ `Periodic full purpose check confirmed previous semantics still apply: ${decision.reason}`,
2598
+ route,
2599
+ ),
2600
+ });
2601
+ continue;
2602
+ }
2603
+ if (
2604
+ decision.decision === "regenerate" &&
2605
+ decision.confidence >= PURPOSE_STABILITY_CONFIDENCE_THRESHOLD
2606
+ ) {
2607
+ plans.set(route.operation_source_key, {
2608
+ origin: "changed_route_semantic_regenerated",
2609
+ purpose_change_state: decision.purpose_change_state,
2610
+ active_semantic_source: "new_confirmed",
2611
+ stability_confidence: decision.confidence,
2612
+ stability_missing_context: decision.missing_context,
2613
+ route_input_hash: currentHash,
2614
+ previous_route: previousRoute,
2615
+ previous_route_input_hash: previousRoute.route_input_hash,
2616
+ previous_route_semantic_hash:
2617
+ previousRoute.route_semantic_hash ??
2618
+ routeSemanticHash(previousRoute),
2619
+ semantic_change_reason: sanitizeText(decision.reason, route),
2620
+ });
2621
+ routesRequiringGeneration.push(route);
2622
+ continue;
2623
+ }
2624
+ plans.set(route.operation_source_key, {
2625
+ origin: "changed_route_needs_review",
2626
+ purpose_change_state: "insufficient_evidence",
2627
+ active_semantic_source: "previous_kept_pending_review",
2628
+ stability_confidence: decision.confidence,
2629
+ stability_missing_context: decision.missing_context,
2630
+ route_input_hash: currentHash,
2631
+ previous_route: previousRoute,
2632
+ previous_route_input_hash: previousRoute.route_input_hash,
2633
+ previous_route_semantic_hash:
2634
+ previousRoute.route_semantic_hash ??
2635
+ routeSemanticHash(previousRoute),
2636
+ semantic_change_reason: sanitizeText(
2637
+ `Periodic full purpose check could not prove a purpose-level change: ${decision.reason}`,
2638
+ route,
2639
+ ),
2640
+ });
2641
+ continue;
2642
+ }
2275
2643
  plans.set(route.operation_source_key, {
2276
2644
  origin: "unchanged_route_reused",
2645
+ purpose_change_state: "same_purpose",
2646
+ active_semantic_source: "previous_reused",
2647
+ stability_confidence: 1,
2648
+ stability_missing_context: [],
2277
2649
  route_input_hash: currentHash,
2278
2650
  previous_route: previousRoute,
2279
2651
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2288,6 +2660,12 @@ const classifyRoutesForSnapshot = async ({
2288
2660
  if (previousRoute.route_input_hash === currentHash) {
2289
2661
  plans.set(route.operation_source_key, {
2290
2662
  origin: "changed_route_semantic_regenerated",
2663
+ purpose_change_state: "new_route",
2664
+ active_semantic_source: "new_confirmed",
2665
+ stability_confidence: 0,
2666
+ stability_missing_context: [
2667
+ "Previous route semantic confidence was too low for stable reuse.",
2668
+ ],
2291
2669
  route_input_hash: currentHash,
2292
2670
  previous_route: previousRoute,
2293
2671
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2310,9 +2688,16 @@ const classifyRoutesForSnapshot = async ({
2310
2688
  generationContract,
2311
2689
  agentSkills,
2312
2690
  });
2313
- if (decision.decision === "reuse_previous" && decision.confidence >= 0.75) {
2691
+ if (
2692
+ decision.purpose_change_state === "same_purpose" &&
2693
+ decision.confidence >= PURPOSE_STABILITY_CONFIDENCE_THRESHOLD
2694
+ ) {
2314
2695
  plans.set(route.operation_source_key, {
2315
2696
  origin: "changed_route_semantic_reused",
2697
+ purpose_change_state: "same_purpose",
2698
+ active_semantic_source: "previous_reused",
2699
+ stability_confidence: decision.confidence,
2700
+ stability_missing_context: decision.missing_context,
2316
2701
  route_input_hash: currentHash,
2317
2702
  previous_route: previousRoute,
2318
2703
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2322,9 +2707,16 @@ const classifyRoutesForSnapshot = async ({
2322
2707
  });
2323
2708
  continue;
2324
2709
  }
2325
- if (decision.decision === "reuse_previous") {
2710
+ if (
2711
+ decision.purpose_change_state === "same_purpose" ||
2712
+ decision.purpose_change_state === "insufficient_evidence"
2713
+ ) {
2326
2714
  plans.set(route.operation_source_key, {
2327
2715
  origin: "changed_route_needs_review",
2716
+ purpose_change_state: "insufficient_evidence",
2717
+ active_semantic_source: "previous_kept_pending_review",
2718
+ stability_confidence: decision.confidence,
2719
+ stability_missing_context: decision.missing_context,
2328
2720
  route_input_hash: currentHash,
2329
2721
  previous_route: previousRoute,
2330
2722
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2337,9 +2729,16 @@ const classifyRoutesForSnapshot = async ({
2337
2729
  });
2338
2730
  continue;
2339
2731
  }
2340
- if (decision.decision === "regenerate") {
2732
+ if (
2733
+ decision.decision === "regenerate" &&
2734
+ decision.confidence >= PURPOSE_STABILITY_CONFIDENCE_THRESHOLD
2735
+ ) {
2341
2736
  plans.set(route.operation_source_key, {
2342
2737
  origin: "changed_route_semantic_regenerated",
2738
+ purpose_change_state: decision.purpose_change_state,
2739
+ active_semantic_source: "new_confirmed",
2740
+ stability_confidence: decision.confidence,
2741
+ stability_missing_context: decision.missing_context,
2343
2742
  route_input_hash: currentHash,
2344
2743
  previous_route: previousRoute,
2345
2744
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2352,6 +2751,10 @@ const classifyRoutesForSnapshot = async ({
2352
2751
  }
2353
2752
  plans.set(route.operation_source_key, {
2354
2753
  origin: "changed_route_needs_review",
2754
+ purpose_change_state: "insufficient_evidence",
2755
+ active_semantic_source: "previous_kept_pending_review",
2756
+ stability_confidence: decision.confidence,
2757
+ stability_missing_context: decision.missing_context,
2355
2758
  route_input_hash: currentHash,
2356
2759
  previous_route: previousRoute,
2357
2760
  previous_route_input_hash: previousRoute.route_input_hash,
@@ -2380,7 +2783,14 @@ const rebasePreviousRoute = ({
2380
2783
  currentRoute,
2381
2784
  semanticSnapshotVersion,
2382
2785
  plan,
2786
+ routeEvidenceRefs = [],
2383
2787
  }) => {
2788
+ const sourceEvidenceRefs = [
2789
+ ...new Set([
2790
+ ...safeArray(previousRoute.source_evidence_refs),
2791
+ ...routeEvidenceRefs.map((entry) => entry.id),
2792
+ ]),
2793
+ ];
2384
2794
  const route = {
2385
2795
  ...previousRoute,
2386
2796
  operation_source_key: currentRoute.operation_source_key,
@@ -2395,6 +2805,11 @@ const rebasePreviousRoute = ({
2395
2805
  previous_route_semantic_hash: plan.previous_route_semantic_hash,
2396
2806
  semantic_origin: plan.origin,
2397
2807
  semantic_change_reason: plan.semantic_change_reason,
2808
+ purpose_change_state: plan.purpose_change_state,
2809
+ active_semantic_source: plan.active_semantic_source,
2810
+ semantic_stability: buildSemanticStability(plan),
2811
+ evidence_packet_summary: buildEvidencePacketSummary(currentRoute),
2812
+ source_evidence_refs: sourceEvidenceRefs,
2398
2813
  };
2399
2814
  return {
2400
2815
  ...route,
@@ -2402,6 +2817,42 @@ const rebasePreviousRoute = ({
2402
2817
  };
2403
2818
  };
2404
2819
 
2820
+ const buildSemanticStability = (plan) => ({
2821
+ purpose_change_state: plan.purpose_change_state ?? "new_route",
2822
+ confidence:
2823
+ typeof plan.stability_confidence === "number"
2824
+ ? Math.max(0, Math.min(1, plan.stability_confidence))
2825
+ : plan.purpose_change_state === "new_route"
2826
+ ? 1
2827
+ : 0,
2828
+ reason:
2829
+ plan.semantic_change_reason ??
2830
+ (plan.purpose_change_state === "new_route"
2831
+ ? "No previous route semantic existed, so a new active semantic was generated."
2832
+ : "Semantic purpose stability was evaluated for this route."),
2833
+ ...(plan.previous_route_input_hash
2834
+ ? { previous_route_input_hash: plan.previous_route_input_hash }
2835
+ : {}),
2836
+ ...(plan.previous_route_semantic_hash
2837
+ ? { previous_route_semantic_hash: plan.previous_route_semantic_hash }
2838
+ : {}),
2839
+ ...(plan.previous_route?.semantic_snapshot_version
2840
+ ? {
2841
+ previous_semantic_snapshot_version:
2842
+ plan.previous_route.semantic_snapshot_version,
2843
+ }
2844
+ : {}),
2845
+ missing_context: safeArray(plan.stability_missing_context),
2846
+ });
2847
+
2848
+ const withStabilityMetadata = ({ route, currentRoute, plan }) => ({
2849
+ ...route,
2850
+ purpose_change_state: plan.purpose_change_state,
2851
+ active_semantic_source: plan.active_semantic_source,
2852
+ semantic_stability: buildSemanticStability(plan),
2853
+ evidence_packet_summary: buildEvidencePacketSummary(currentRoute),
2854
+ });
2855
+
2405
2856
  const fieldPathMatchesKeys = ({
2406
2857
  fieldPath,
2407
2858
  routeKeys,
@@ -2813,6 +3264,10 @@ const assertSemanticSnapshotAudit = ({
2813
3264
  const routeByKey = new Map(
2814
3265
  routes.map((route) => [route.operation_source_key, route]),
2815
3266
  );
3267
+ const previousActiveSources = new Set([
3268
+ "previous_reused",
3269
+ "previous_kept_pending_review",
3270
+ ]);
2816
3271
  const allowedOrigins = new Set([
2817
3272
  "new_route_ai_generated",
2818
3273
  "unchanged_route_reused",
@@ -2843,6 +3298,56 @@ const assertSemanticSnapshotAudit = ({
2843
3298
  `semantic snapshot audit found stale route_semantic_hash: ${route.operation_source_key}`,
2844
3299
  );
2845
3300
  }
3301
+ if (auditedSnapshot.schema_version >= 3) {
3302
+ if (route.semantic_stability.purpose_change_state !== route.purpose_change_state) {
3303
+ throw new Error(
3304
+ `semantic snapshot audit found mismatched purpose stability metadata: ${route.operation_source_key}`,
3305
+ );
3306
+ }
3307
+ if (
3308
+ previousActiveSources.has(route.active_semantic_source) &&
3309
+ (!route.previous_route_input_hash ||
3310
+ !route.previous_route_semantic_hash ||
3311
+ !route.previous_semantic_snapshot_version)
3312
+ ) {
3313
+ throw new Error(
3314
+ `semantic snapshot audit found incomplete previous active semantic metadata: ${route.operation_source_key}`,
3315
+ );
3316
+ }
3317
+ if (
3318
+ previousActiveSources.has(route.active_semantic_source) &&
3319
+ route.route_semantic_hash !== route.previous_route_semantic_hash
3320
+ ) {
3321
+ throw new Error(
3322
+ `semantic snapshot audit found changed active semantics for previous-backed route: ${route.operation_source_key}`,
3323
+ );
3324
+ }
3325
+ if (
3326
+ route.active_semantic_source === "previous_kept_pending_review" &&
3327
+ (route.purpose_change_state !== "insufficient_evidence" ||
3328
+ route.semantic_origin !== "changed_route_needs_review")
3329
+ ) {
3330
+ throw new Error(
3331
+ `semantic snapshot audit found inconsistent pending-review semantics: ${route.operation_source_key}`,
3332
+ );
3333
+ }
3334
+ if (
3335
+ route.active_semantic_source === "previous_reused" &&
3336
+ route.purpose_change_state !== "same_purpose"
3337
+ ) {
3338
+ throw new Error(
3339
+ `semantic snapshot audit found inconsistent reused semantics: ${route.operation_source_key}`,
3340
+ );
3341
+ }
3342
+ if (
3343
+ route.purpose_change_state === "purpose_added" &&
3344
+ (!route.previous_route_input_hash || !route.previous_route_semantic_hash)
3345
+ ) {
3346
+ throw new Error(
3347
+ `semantic snapshot audit found missing previous metadata for added purpose: ${route.operation_source_key}`,
3348
+ );
3349
+ }
3350
+ }
2846
3351
  if (
2847
3352
  (route.semantic_origin === "unchanged_route_reused" ||
2848
3353
  route.semantic_origin === "changed_route_semantic_reused") &&
@@ -2883,10 +3388,12 @@ export const runSemanticCi = async ({
2883
3388
  allowedSourcePaths: request.allowed_source_paths,
2884
3389
  excludedSourcePaths: request.excluded_source_paths,
2885
3390
  });
2886
- const routes = await analyzeFastApiRoutes({ repoRoot, files });
3391
+ const discoveredRoutes = await analyzeFastApiRoutes({ repoRoot, files });
3392
+ const { productRoutes: routes, excludedInfrastructureRoutes } =
3393
+ splitSemanticProductRoutes(discoveredRoutes);
2887
3394
  if (routes.length === 0) {
2888
3395
  throw new Error(
2889
- "semantic CI discovered zero routes; check framework, backend root path, and allowed_source_paths",
3396
+ "semantic CI discovered zero routes after infrastructure exclusions; check framework, backend root path, allowed_source_paths, and infrastructure route exclusions",
2890
3397
  );
2891
3398
  }
2892
3399
  if (!env.CLUE_API_KEY) {
@@ -2897,12 +3404,14 @@ export const runSemanticCi = async ({
2897
3404
  ? await fetchLatestSnapshot({ request, env })
2898
3405
  : validatePreviousSnapshotForReuse(providedPreviousSnapshot);
2899
3406
  const previousRoutes = previousRouteMap(previousSnapshot);
3407
+ const purposeRecheckAllRoutes = shouldPurposeRecheckAllRoutes(env);
2900
3408
  const routeNeedsAi = routes.some((route) => {
2901
3409
  const previousRoute = previousRoutes.get(route.operation_source_key);
2902
3410
  return (
2903
3411
  !previousRoute ||
2904
3412
  previousRoute.route_input_hash !== routeInputHash(route) ||
2905
- !hasReusablePreviousRouteSemantics(previousRoute)
3413
+ !hasReusablePreviousRouteSemantics(previousRoute) ||
3414
+ purposeRecheckAllRoutes
2906
3415
  );
2907
3416
  });
2908
3417
  if (routeNeedsAi && !env.CLUE_AI_PROVIDER_API_KEY) {
@@ -2935,6 +3444,7 @@ export const runSemanticCi = async ({
2935
3444
  hashScope,
2936
3445
  generationContract,
2937
3446
  agentSkills,
3447
+ purposeRecheckAllRoutes,
2938
3448
  });
2939
3449
  const promptRoutes = routesRequiringGeneration.map((route) =>
2940
3450
  buildRouteEvidencePromptEntry({ route, hashScope }),
@@ -2989,6 +3499,7 @@ export const runSemanticCi = async ({
2989
3499
  return {
2990
3500
  accepted: upload.accepted === true,
2991
3501
  route_count: snapshot.routes.length,
3502
+ excluded_infrastructure_routes: excludedInfrastructureRoutes,
2992
3503
  upload,
2993
3504
  };
2994
3505
  };
@@ -2999,10 +3510,12 @@ export const runSemanticInventory = async ({ repoRoot, request }) => {
2999
3510
  allowedSourcePaths: request.allowed_source_paths,
3000
3511
  excludedSourcePaths: request.excluded_source_paths,
3001
3512
  });
3002
- const routes = await analyzeFastApiRoutes({ repoRoot, files });
3513
+ const discoveredRoutes = await analyzeFastApiRoutes({ repoRoot, files });
3514
+ const { productRoutes: routes, excludedInfrastructureRoutes } =
3515
+ splitSemanticProductRoutes(discoveredRoutes);
3003
3516
  if (routes.length === 0) {
3004
3517
  throw new Error(
3005
- "semantic inventory discovered zero routes; check framework, backend root path, and allowed_source_paths",
3518
+ "semantic inventory discovered zero routes after infrastructure exclusions; check framework, backend root path, allowed_source_paths, and infrastructure route exclusions",
3006
3519
  );
3007
3520
  }
3008
3521
  return {
@@ -3010,7 +3523,9 @@ export const runSemanticInventory = async ({ repoRoot, request }) => {
3010
3523
  service_key: request.service_key,
3011
3524
  allowed_source_paths: request.allowed_source_paths,
3012
3525
  excluded_source_paths: request.excluded_source_paths,
3526
+ discovered_route_count: discoveredRoutes.length,
3013
3527
  route_count: routes.length,
3528
+ excluded_infrastructure_routes: excludedInfrastructureRoutes,
3014
3529
  operation_source_keys: routes.map((route) => route.operation_source_key),
3015
3530
  routes: routes.map((route) => ({
3016
3531
  operation_source_key: route.operation_source_key,