@clue-ai/cli 0.0.6 → 0.0.7

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.
@@ -1,12 +1,23 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
- import { callJsonAiProvider, resolveAiProviderConfig } from "./ai-provider.mjs";
3
2
  import {
4
3
  validateSemanticCiRequest,
5
4
  validateSemanticSnapshotRequest,
6
5
  validateSemanticSnapshotResponse,
7
6
  } from "./contracts.mjs";
7
+ import {
8
+ callJsonAiProvider,
9
+ resolveAiProviderConfig,
10
+ } from "./ai-provider.mjs";
8
11
  import { analyzeFastApiRoutes } from "./fastapi-analyzer.mjs";
9
12
  import { listAllowedPythonFiles } from "./path-policy.mjs";
13
+ import {
14
+ SEMANTIC_AGENT_ROLE_IDS,
15
+ SEMANTIC_AGENT_RUNNER_VERSION,
16
+ semanticAgentPromptEnvelope,
17
+ semanticAgentSkillBundle,
18
+ validateSemanticAgentSkillBundle,
19
+ } from "./semantic-agent-runner.mjs";
20
+ import { clueClientCiSemanticAiRuntime } from "./semantic-ai-config.mjs";
10
21
 
11
22
  const sha256 = (value) =>
12
23
  `sha256:${createHash("sha256").update(value).digest("hex")}`;
@@ -69,6 +80,12 @@ const ACTUAL_OUTCOME_ACTIONS = new Set([
69
80
  ]);
70
81
 
71
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";
87
+ const SEMANTIC_SELECTOR_PROMPT_CONTRACT_VERSION = "semantic-selector-v1";
88
+ const SEMANTIC_PRIVACY_SANITIZER_VERSION = "privacy-sanitizer-v1";
72
89
 
73
90
  const semanticSnapshotHashScope = (request) =>
74
91
  [
@@ -78,6 +95,15 @@ const semanticSnapshotHashScope = (request) =>
78
95
  request.service.service_key,
79
96
  ].join(":");
80
97
 
98
+ const semanticGenerationContract = () => ({
99
+ schema_version: SEMANTIC_SNAPSHOT_SCHEMA_VERSION,
100
+ analyzer_version: SEMANTIC_ANALYZER_VERSION,
101
+ route_prompt_contract_version: SEMANTIC_ROUTE_PROMPT_CONTRACT_VERSION,
102
+ reuse_prompt_contract_version: SEMANTIC_REUSE_PROMPT_CONTRACT_VERSION,
103
+ selector_prompt_contract_version: SEMANTIC_SELECTOR_PROMPT_CONTRACT_VERSION,
104
+ privacy_sanitizer_version: SEMANTIC_PRIVACY_SANITIZER_VERSION,
105
+ });
106
+
81
107
  const canonicalJson = (value) => {
82
108
  if (Array.isArray(value)) {
83
109
  return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
@@ -150,9 +176,14 @@ const fallbackSemantics = (route, reason, hashScope) => ({
150
176
  confidence_reason: reason,
151
177
  });
152
178
 
153
- const buildAiPrompt = (routes) =>
179
+ const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
154
180
  JSON.stringify({
181
+ ...semanticAgentPromptEnvelope({
182
+ bundle: agentSkills,
183
+ roleId: SEMANTIC_AGENT_ROLE_IDS.routeSemanticGenerator,
184
+ }),
155
185
  task: "Generate privacy-safe semantic meaning candidates for FastAPI operation sources. Return JSON only.",
186
+ generation_contract: generationContract,
156
187
  rules: [
157
188
  "Do not create operation_source_key values; only copy the provided keys.",
158
189
  "Do not create ids, refs, hashes, source fingerprints, or field paths.",
@@ -200,9 +231,20 @@ const buildAiPrompt = (routes) =>
200
231
  })),
201
232
  });
202
233
 
203
- const buildAiReuseDecisionPrompt = ({ route, previousRoute, currentHash }) =>
234
+ const buildAiReuseDecisionPrompt = ({
235
+ route,
236
+ previousRoute,
237
+ currentHash,
238
+ generationContract,
239
+ agentSkills,
240
+ }) =>
204
241
  JSON.stringify({
242
+ ...semanticAgentPromptEnvelope({
243
+ bundle: agentSkills,
244
+ roleId: SEMANTIC_AGENT_ROLE_IDS.reuseJudge,
245
+ }),
205
246
  task: "Decide whether previous privacy-safe route semantics still apply after a route source change. Return JSON only.",
247
+ generation_contract: generationContract,
206
248
  rules: [
207
249
  "Only decide for the provided operation_source_key.",
208
250
  "Return decision as reuse_previous, regenerate, or needs_review.",
@@ -235,6 +277,28 @@ const buildAiReuseDecisionPrompt = ({ route, previousRoute, currentHash }) =>
235
277
  });
236
278
 
237
279
  const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
280
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions)) {
281
+ return {
282
+ decision: "needs_review",
283
+ confidence: 0,
284
+ reason: "AI reuse decision did not return the required decisions array.",
285
+ };
286
+ }
287
+ const unknownDecision = parsed.decisions.find(
288
+ (entry) =>
289
+ !entry ||
290
+ typeof entry !== "object" ||
291
+ typeof entry.operation_source_key !== "string" ||
292
+ (entry.operation_source_key !== operationSourceKey &&
293
+ entry.operation_source_key.trim() !== ""),
294
+ );
295
+ if (unknownDecision) {
296
+ return {
297
+ decision: "needs_review",
298
+ confidence: 0,
299
+ reason: "AI reuse decision returned an unexpected route key.",
300
+ };
301
+ }
238
302
  const decision = safeArray(parsed?.decisions).find(
239
303
  (entry) => entry?.operation_source_key === operationSourceKey,
240
304
  );
@@ -271,6 +335,8 @@ const callAiReuseDecisionProvider = async ({
271
335
  route,
272
336
  previousRoute,
273
337
  currentHash,
338
+ generationContract,
339
+ agentSkills,
274
340
  }) => {
275
341
  try {
276
342
  const parsed = await callJsonAiProvider({
@@ -281,6 +347,8 @@ const callAiReuseDecisionProvider = async ({
281
347
  route,
282
348
  previousRoute,
283
349
  currentHash,
350
+ generationContract,
351
+ agentSkills,
284
352
  }),
285
353
  toolName: "return_reuse_decision",
286
354
  toolDescription:
@@ -307,26 +375,59 @@ const callAiReuseDecisionProvider = async ({
307
375
  }
308
376
  };
309
377
 
310
- const requireAiProviderApiKey = (env) => {
311
- const apiKey = env.CLUE_AI_PROVIDER_API_KEY;
312
- if (!apiKey) {
313
- throw new Error(
314
- "CLUE_AI_PROVIDER_API_KEY is required for semantic generation",
315
- );
378
+ const assertAiRouteResponseContract = ({ parsed, expectedRouteKeys }) => {
379
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.routes)) {
380
+ throw new SyntaxError("AI route semantics response must include routes");
381
+ }
382
+ const expected = new Set(expectedRouteKeys);
383
+ const actual = new Set();
384
+ for (const route of parsed.routes) {
385
+ if (!route || typeof route !== "object") {
386
+ throw new SyntaxError("AI route semantics response includes invalid route");
387
+ }
388
+ if (
389
+ typeof route.operation_source_key !== "string" ||
390
+ !expected.has(route.operation_source_key)
391
+ ) {
392
+ throw new SyntaxError(
393
+ "AI route semantics response included an unexpected route key",
394
+ );
395
+ }
396
+ if (actual.has(route.operation_source_key)) {
397
+ throw new SyntaxError(
398
+ "AI route semantics response included a duplicate route key",
399
+ );
400
+ }
401
+ actual.add(route.operation_source_key);
402
+ if (!route.semantics || typeof route.semantics !== "object") {
403
+ throw new SyntaxError(
404
+ "AI route semantics response omitted required semantics",
405
+ );
406
+ }
316
407
  }
317
- return apiKey;
318
408
  };
319
409
 
320
- const callAiProvider = async ({ request, env, apiKey, routes }) => {
410
+ const callAiProvider = async ({
411
+ request,
412
+ env,
413
+ apiKey,
414
+ routes,
415
+ generationContract,
416
+ agentSkills,
417
+ }) => {
321
418
  const parsed = await callJsonAiProvider({
322
419
  config: resolveAiProviderConfig({ env, request, apiKey }),
323
420
  system: "You generate privacy-safe route semantics JSON.",
324
- user: buildAiPrompt(routes),
421
+ user: buildAiPrompt({ routes, generationContract, agentSkills }),
325
422
  toolName: "return_route_semantics",
326
423
  toolDescription: "Return privacy-safe route semantics JSON.",
327
424
  failureMessage: "AI provider failed",
328
425
  emptyMessage: "AI provider returned empty semantic generation content",
329
426
  });
427
+ assertAiRouteResponseContract({
428
+ parsed,
429
+ expectedRouteKeys: routes.map((route) => route.operation_source_key),
430
+ });
330
431
  return new Map(
331
432
  (parsed.routes ?? []).map((route) => [route.operation_source_key, route]),
332
433
  );
@@ -374,7 +475,14 @@ const assertSnapshotRouteCoverage = ({ routes, snapshotRoutes }) => {
374
475
  }
375
476
  };
376
477
 
377
- const generateAiRoutesPerRoute = async ({ request, env, apiKey, routes }) => {
478
+ const generateAiRoutesPerRoute = async ({
479
+ request,
480
+ env,
481
+ apiKey,
482
+ routes,
483
+ generationContract,
484
+ agentSkills,
485
+ }) => {
378
486
  const aiRoutes = new Map();
379
487
  for (const route of routes) {
380
488
  try {
@@ -383,6 +491,8 @@ const generateAiRoutesPerRoute = async ({ request, env, apiKey, routes }) => {
383
491
  env,
384
492
  apiKey,
385
493
  routes: [route],
494
+ generationContract,
495
+ agentSkills,
386
496
  });
387
497
  const aiRoute = routeAiRoutes.get(route.operation_source_key);
388
498
  if (aiRoute?.semantics) {
@@ -409,9 +519,18 @@ const generateAiRoutesPerRoute = async ({ request, env, apiKey, routes }) => {
409
519
  return aiRoutes;
410
520
  };
411
521
 
412
- const buildAiSelectionPrompt = (selectionCandidates) =>
522
+ const buildAiSelectionPrompt = ({
523
+ selectionCandidates,
524
+ generationContract,
525
+ agentSkills,
526
+ }) =>
413
527
  JSON.stringify({
528
+ ...semanticAgentPromptEnvelope({
529
+ bundle: agentSkills,
530
+ roleId: SEMANTIC_AGENT_ROLE_IDS.semanticSelector,
531
+ }),
414
532
  task: "Choose adopted semantic values from deterministic and AI candidates. Return JSON only.",
533
+ generation_contract: generationContract,
415
534
  rules: [
416
535
  "Only copy operation_source_key, candidate_index, and parameter_name values from the provided selection_candidates.",
417
536
  "For each semantic field, return selected_source as deterministic or ai.",
@@ -442,15 +561,21 @@ const callAiSelectionProvider = async ({
442
561
  apiKey,
443
562
  selectionCandidates,
444
563
  routeBySelectionKey,
564
+ generationContract,
565
+ agentSkills,
445
566
  }) => {
446
567
  if (selectionCandidates.length === 0) {
447
568
  return new Map();
448
569
  }
449
- assertNoRawCodeStructure(selectionCandidates, "semantic_selection_prompt");
570
+ assertNoRawCodeStructureForSelectionCandidates(selectionCandidates);
450
571
  const parsed = await callJsonAiProvider({
451
572
  config: resolveAiProviderConfig({ env, request, apiKey }),
452
573
  system: "You select adopted semantic values from privacy-safe candidates.",
453
- user: buildAiSelectionPrompt(selectionCandidates),
574
+ user: buildAiSelectionPrompt({
575
+ selectionCandidates,
576
+ generationContract,
577
+ agentSkills,
578
+ }),
454
579
  toolName: "return_semantic_selection",
455
580
  toolDescription:
456
581
  "Return selected semantic values from privacy-safe candidates.",
@@ -460,11 +585,34 @@ const callAiSelectionProvider = async ({
460
585
  return normalizeAiSelectionResponse({
461
586
  parsed,
462
587
  routeBySelectionKey,
588
+ validSelectionParametersByKey: validSelectionParametersByKey(
589
+ selectionCandidates,
590
+ ),
463
591
  });
464
592
  };
465
593
 
466
- const buildSemanticSnapshotVersion = ({ request, generatedAt }) =>
467
- `snap_${shortHash(`${request.project_key}:${request.repository.repository_id}:${request.repository.merge_commit}:${request.repository.workflow_run_id ?? generatedAt}`)}`;
594
+ const buildSemanticSnapshotVersion = ({
595
+ request,
596
+ generationContract,
597
+ snapshotRoutes,
598
+ }) =>
599
+ `snap_${shortHash(
600
+ canonicalJson({
601
+ project_key: request.project_key,
602
+ repository_id: request.repository.repository_id,
603
+ service_key: request.service.service_key,
604
+ generation_contract: generationContract,
605
+ routes: snapshotRoutes
606
+ .map((route) => ({
607
+ operation_source_key: route.operation_source_key,
608
+ route_input_hash: route.route_input_hash,
609
+ route_semantic_hash: route.route_semantic_hash,
610
+ }))
611
+ .sort((left, right) =>
612
+ left.operation_source_key.localeCompare(right.operation_source_key),
613
+ ),
614
+ }),
615
+ )}`;
468
616
 
469
617
  const normalizeSnakeSegment = (value) => {
470
618
  const normalized = String(value ?? "")
@@ -827,7 +975,30 @@ const candidateHasUnsafeSemanticText = (candidate) =>
827
975
  const selectionKeyForCandidate = (route, candidateIndex) =>
828
976
  `${route.operation_source_key}#${candidateIndex}`;
829
977
 
830
- const normalizeAiSelectionResponse = ({ parsed, routeBySelectionKey }) => {
978
+ const validSelectionParametersByKey = (selectionCandidates) =>
979
+ new Map(
980
+ selectionCandidates.map((candidate) => [
981
+ selectionKeyForCandidate(candidate, candidate.candidate_index),
982
+ new Set(
983
+ safeArray(candidate.semantic_fields)
984
+ .map((field) => field?.parameter_name)
985
+ .filter((parameterName) => typeof parameterName === "string"),
986
+ ),
987
+ ]),
988
+ );
989
+
990
+ const normalizeAiSelectionResponse = ({
991
+ parsed,
992
+ routeBySelectionKey,
993
+ validSelectionParametersByKey,
994
+ }) => {
995
+ if (
996
+ !parsed ||
997
+ typeof parsed !== "object" ||
998
+ !Array.isArray(parsed.field_selections)
999
+ ) {
1000
+ throw new SyntaxError("AI selector response must include field_selections");
1001
+ }
831
1002
  const decisionsByCandidate = new Map();
832
1003
  for (const decision of safeArray(parsed?.field_selections)) {
833
1004
  if (!decision || typeof decision !== "object") {
@@ -846,6 +1017,10 @@ const normalizeAiSelectionResponse = ({ parsed, routeBySelectionKey }) => {
846
1017
  if (!route) {
847
1018
  continue;
848
1019
  }
1020
+ const validParameters = validSelectionParametersByKey.get(selectionKey);
1021
+ if (!validParameters?.has(decision.parameter_name)) {
1022
+ continue;
1023
+ }
849
1024
  const candidateDecisions =
850
1025
  decisionsByCandidate.get(selectionKey) ?? new Map();
851
1026
  candidateDecisions.set(decision.parameter_name, {
@@ -1011,7 +1186,8 @@ const buildSelectorCandidates = ({ routes, aiRoutes, hashScope }) => {
1011
1186
  candidateHasUnsafeSemanticText(candidate) ||
1012
1187
  !values.rawTargetObjectText ||
1013
1188
  !values.targetKeyCandidate ||
1014
- !values.aiExpectedDomainEffect ||
1189
+ (!values.aiExpectedDomainEffect &&
1190
+ !values.deterministicExpectedDomainEffect) ||
1015
1191
  !hasBehaviorEvidence(values.sourceEvidenceRefs, routeEvidenceRefs) ||
1016
1192
  !candidateHasTargetSpecificEvidence({
1017
1193
  route,
@@ -1577,13 +1753,12 @@ const buildSnapshot = ({
1577
1753
  routePlans = new Map(),
1578
1754
  previousSnapshot,
1579
1755
  deletedRouteCount = 0,
1756
+ generationContract,
1757
+ aiRuntime,
1580
1758
  }) => {
1581
1759
  const generatedAt = new Date().toISOString();
1582
1760
  const hashScope = semanticSnapshotHashScope(request);
1583
- const semanticSnapshotVersion = buildSemanticSnapshotVersion({
1584
- request,
1585
- generatedAt,
1586
- });
1761
+ const provisionalSemanticSnapshotVersion = "snap_pending";
1587
1762
  const targetObjectProfiles = [];
1588
1763
  const targetObjectMappings = [];
1589
1764
  const sourceEvidenceRefs = [];
@@ -1617,7 +1792,7 @@ const buildSnapshot = ({
1617
1792
  const snapshotRoutes = routes.map((route) => {
1618
1793
  const routeWithVersion = {
1619
1794
  ...route,
1620
- semantic_snapshot_version: semanticSnapshotVersion,
1795
+ semantic_snapshot_version: provisionalSemanticSnapshotVersion,
1621
1796
  };
1622
1797
  const plan = routePlans.get(route.operation_source_key) ?? {
1623
1798
  origin: "new_route_ai_generated",
@@ -1636,7 +1811,7 @@ const buildSnapshot = ({
1636
1811
  return rebasePreviousRoute({
1637
1812
  previousRoute: plan.previous_route,
1638
1813
  currentRoute: route,
1639
- semanticSnapshotVersion,
1814
+ semanticSnapshotVersion: provisionalSemanticSnapshotVersion,
1640
1815
  plan,
1641
1816
  });
1642
1817
  }
@@ -1654,7 +1829,7 @@ const buildSnapshot = ({
1654
1829
  ? fallbackSemantics(routeWithVersion, unavailableReason, hashScope)
1655
1830
  : {
1656
1831
  operation_source_key: route.operation_source_key,
1657
- semantic_snapshot_version: semanticSnapshotVersion,
1832
+ semantic_snapshot_version: provisionalSemanticSnapshotVersion,
1658
1833
  method: route.method,
1659
1834
  path_template: route.path_template,
1660
1835
  semantics: sanitizeSemantics(aiRoute.semantics, route),
@@ -1735,6 +1910,15 @@ const buildSnapshot = ({
1735
1910
  sourceEvidenceRefs,
1736
1911
  (entry) => entry.id,
1737
1912
  );
1913
+ const semanticSnapshotVersion = buildSemanticSnapshotVersion({
1914
+ request,
1915
+ generationContract,
1916
+ snapshotRoutes,
1917
+ });
1918
+ const versionedSnapshotRoutes = snapshotRoutes.map((route) => ({
1919
+ ...route,
1920
+ semantic_snapshot_version: semanticSnapshotVersion,
1921
+ }));
1738
1922
 
1739
1923
  const snapshot = validateSemanticSnapshotRequest({
1740
1924
  project_key: request.project_key,
@@ -1743,42 +1927,48 @@ const buildSnapshot = ({
1743
1927
  ),
1744
1928
  schema_version: 2,
1745
1929
  semantic_snapshot_version: semanticSnapshotVersion,
1930
+ generation_contract: generationContract,
1931
+ ai_runtime: aiRuntime,
1746
1932
  generated_at: generatedAt,
1747
1933
  repository: request.repository,
1748
- service: request.service,
1934
+ service: {
1935
+ service_key: request.service.service_key,
1936
+ framework: request.service.framework,
1937
+ language: request.service.language,
1938
+ },
1749
1939
  analysis_summary: {
1750
1940
  total_routes_scanned: routes.length,
1751
- routes_generated: snapshotRoutes.filter(
1941
+ routes_generated: versionedSnapshotRoutes.filter(
1752
1942
  (route) => route.semantics.route_confidence > 0,
1753
1943
  ).length,
1754
- uncertain_routes: snapshotRoutes.filter(
1944
+ uncertain_routes: versionedSnapshotRoutes.filter(
1755
1945
  (route) => route.semantics.route_confidence < 0.5,
1756
1946
  ).length,
1757
1947
  failed_routes: failedRouteCount,
1758
- routes_reused: snapshotRoutes.filter((route) =>
1948
+ routes_reused: versionedSnapshotRoutes.filter((route) =>
1759
1949
  ["unchanged_route_reused", "changed_route_semantic_reused"].includes(
1760
1950
  route.semantic_origin,
1761
1951
  ),
1762
1952
  ).length,
1763
- routes_ai_generated: snapshotRoutes.filter((route) =>
1953
+ routes_ai_generated: versionedSnapshotRoutes.filter((route) =>
1764
1954
  [
1765
1955
  "new_route_ai_generated",
1766
1956
  "changed_route_semantic_regenerated",
1767
1957
  ].includes(route.semantic_origin),
1768
1958
  ).length,
1769
1959
  routes_deleted: deletedRouteCount,
1770
- routes_needs_review: snapshotRoutes.filter(
1960
+ routes_needs_review: versionedSnapshotRoutes.filter(
1771
1961
  (route) => route.semantic_origin === "changed_route_needs_review",
1772
1962
  ).length,
1773
- changed_routes_semantic_reused: snapshotRoutes.filter(
1963
+ changed_routes_semantic_reused: versionedSnapshotRoutes.filter(
1774
1964
  (route) => route.semantic_origin === "changed_route_semantic_reused",
1775
1965
  ).length,
1776
- changed_routes_semantic_regenerated: snapshotRoutes.filter(
1966
+ changed_routes_semantic_regenerated: versionedSnapshotRoutes.filter(
1777
1967
  (route) =>
1778
1968
  route.semantic_origin === "changed_route_semantic_regenerated",
1779
1969
  ).length,
1780
1970
  },
1781
- routes: snapshotRoutes,
1971
+ routes: versionedSnapshotRoutes,
1782
1972
  target_object_profiles: targetObjectProfiles,
1783
1973
  target_object_catalog: [...catalogByGroup.values()],
1784
1974
  target_object_mappings: targetObjectMappings,
@@ -1983,6 +2173,8 @@ const classifyRoutesForSnapshot = async ({
1983
2173
  previousSnapshot,
1984
2174
  aiProviderApiKey,
1985
2175
  hashScope,
2176
+ generationContract,
2177
+ agentSkills,
1986
2178
  }) => {
1987
2179
  const previousRoutes = previousRouteMap(previousSnapshot);
1988
2180
  const plans = new Map();
@@ -2021,8 +2213,10 @@ const classifyRoutesForSnapshot = async ({
2021
2213
  route: buildRouteEvidencePromptEntry({ route, hashScope }),
2022
2214
  previousRoute,
2023
2215
  currentHash,
2216
+ generationContract,
2217
+ agentSkills,
2024
2218
  });
2025
- if (decision.decision === "reuse_previous") {
2219
+ if (decision.decision === "reuse_previous" && decision.confidence >= 0.75) {
2026
2220
  plans.set(route.operation_source_key, {
2027
2221
  origin: "changed_route_semantic_reused",
2028
2222
  route_input_hash: currentHash,
@@ -2034,6 +2228,21 @@ const classifyRoutesForSnapshot = async ({
2034
2228
  });
2035
2229
  continue;
2036
2230
  }
2231
+ if (decision.decision === "reuse_previous") {
2232
+ plans.set(route.operation_source_key, {
2233
+ origin: "changed_route_needs_review",
2234
+ route_input_hash: currentHash,
2235
+ previous_route: previousRoute,
2236
+ previous_route_input_hash: previousRoute.route_input_hash,
2237
+ previous_route_semantic_hash:
2238
+ previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
2239
+ semantic_change_reason: sanitizeText(
2240
+ `AI reuse decision confidence was too low for automatic reuse: ${decision.reason}`,
2241
+ route,
2242
+ ),
2243
+ });
2244
+ continue;
2245
+ }
2037
2246
  if (decision.decision === "regenerate") {
2038
2247
  plans.set(route.operation_source_key, {
2039
2248
  origin: "changed_route_semantic_regenerated",
@@ -2333,6 +2542,32 @@ const assertNoRawCodeStructure = (value, path = "snapshot") => {
2333
2542
  }
2334
2543
  };
2335
2544
 
2545
+ const assertNoRawCodeStructureForSelectionCandidates = (
2546
+ selectionCandidates,
2547
+ ) => {
2548
+ for (const candidate of selectionCandidates) {
2549
+ safeArray(candidate.evidence_summaries).forEach((entry, index) => {
2550
+ assertNoRawCodeStructure(
2551
+ {
2552
+ kind: entry?.kind,
2553
+ summary: entry?.summary,
2554
+ evidence_strength: entry?.evidence_strength,
2555
+ },
2556
+ `semantic_selection_prompt[${index}].evidence_summaries`,
2557
+ );
2558
+ });
2559
+ safeArray(candidate.semantic_fields).forEach((field, index) => {
2560
+ assertNoRawCodeStructure(
2561
+ {
2562
+ deterministic_candidate: field?.deterministic_candidate,
2563
+ ai_candidate: field?.ai_candidate,
2564
+ },
2565
+ `semantic_selection_prompt[${index}].semantic_fields`,
2566
+ );
2567
+ });
2568
+ }
2569
+ };
2570
+
2336
2571
  const semanticSnapshotUrl = (baseUrl) => {
2337
2572
  const normalized = baseUrl.replace(/\/+$/, "");
2338
2573
  if (normalized.endsWith("/api/v1/semantic-snapshots")) {
@@ -2360,6 +2595,8 @@ const fetchLatestSnapshot = async ({ request, env }) => {
2360
2595
  if (!apiKey) {
2361
2596
  return null;
2362
2597
  }
2598
+ const allowFullRegeneration =
2599
+ env.CLUE_SEMANTIC_ALLOW_FULL_REGEN_WITHOUT_CACHE === "1";
2363
2600
  let response;
2364
2601
  try {
2365
2602
  response = await fetch(semanticSnapshotLatestUrl(request), {
@@ -2368,19 +2605,34 @@ const fetchLatestSnapshot = async ({ request, env }) => {
2368
2605
  authorization: `Bearer ${apiKey}`,
2369
2606
  },
2370
2607
  });
2371
- } catch {
2372
- return null;
2608
+ } catch (error) {
2609
+ if (allowFullRegeneration) {
2610
+ return null;
2611
+ }
2612
+ throw new Error(
2613
+ `previous semantic snapshot lookup failed: ${
2614
+ error instanceof Error ? error.message : String(error)
2615
+ }`,
2616
+ );
2373
2617
  }
2374
2618
  if (response.status === 404 || response.status === 204) {
2375
2619
  return null;
2376
2620
  }
2377
2621
  if (!response.ok) {
2378
- return null;
2622
+ if (allowFullRegeneration) {
2623
+ return null;
2624
+ }
2625
+ throw new Error(
2626
+ `previous semantic snapshot lookup failed: ${response.status}`,
2627
+ );
2379
2628
  }
2380
2629
  const body = await response.json();
2381
2630
  const candidate = body?.snapshot ?? body;
2382
2631
  if (!candidate || !Array.isArray(candidate.routes)) {
2383
- return null;
2632
+ if (allowFullRegeneration) {
2633
+ return null;
2634
+ }
2635
+ throw new Error("previous semantic snapshot lookup returned invalid body");
2384
2636
  }
2385
2637
  const parsed = validateSemanticSnapshotRequest(candidate);
2386
2638
  assertNoRawCodeStructure(parsed);
@@ -2426,14 +2678,89 @@ const sendSnapshot = async ({ request, env, snapshot }) => {
2426
2678
  }
2427
2679
  };
2428
2680
 
2681
+ const assertSemanticSnapshotAudit = ({
2682
+ routes,
2683
+ snapshot,
2684
+ generationContract,
2685
+ aiRuntime,
2686
+ }) => {
2687
+ const auditedSnapshot = validateSemanticSnapshotRequest(snapshot);
2688
+ assertSnapshotRouteCoverage({ routes, snapshotRoutes: auditedSnapshot.routes });
2689
+ assertNoRawCodeStructure(auditedSnapshot);
2690
+ if (canonicalJson(auditedSnapshot.generation_contract) !== canonicalJson(generationContract)) {
2691
+ throw new Error("semantic snapshot generation contract mismatch");
2692
+ }
2693
+ if (canonicalJson(auditedSnapshot.ai_runtime) !== canonicalJson(aiRuntime)) {
2694
+ throw new Error("semantic snapshot AI runtime metadata mismatch");
2695
+ }
2696
+ const routeByKey = new Map(
2697
+ routes.map((route) => [route.operation_source_key, route]),
2698
+ );
2699
+ const allowedOrigins = new Set([
2700
+ "new_route_ai_generated",
2701
+ "unchanged_route_reused",
2702
+ "changed_route_semantic_reused",
2703
+ "changed_route_semantic_regenerated",
2704
+ "changed_route_needs_review",
2705
+ ]);
2706
+ for (const route of auditedSnapshot.routes) {
2707
+ const currentRoute = routeByKey.get(route.operation_source_key);
2708
+ if (!currentRoute) {
2709
+ throw new Error(
2710
+ `semantic snapshot audit found unknown route: ${route.operation_source_key}`,
2711
+ );
2712
+ }
2713
+ const expectedInputHash = routeInputHash(currentRoute);
2714
+ if (route.route_input_hash !== expectedInputHash) {
2715
+ throw new Error(
2716
+ `semantic snapshot audit found stale route_input_hash: ${route.operation_source_key}`,
2717
+ );
2718
+ }
2719
+ if (!allowedOrigins.has(route.semantic_origin)) {
2720
+ throw new Error(
2721
+ `semantic snapshot audit found invalid semantic_origin: ${route.operation_source_key}`,
2722
+ );
2723
+ }
2724
+ if (route.route_semantic_hash !== routeSemanticHash(route)) {
2725
+ throw new Error(
2726
+ `semantic snapshot audit found stale route_semantic_hash: ${route.operation_source_key}`,
2727
+ );
2728
+ }
2729
+ if (
2730
+ (route.semantic_origin === "unchanged_route_reused" ||
2731
+ route.semantic_origin === "changed_route_semantic_reused") &&
2732
+ (!route.previous_route_input_hash ||
2733
+ !route.previous_route_semantic_hash ||
2734
+ !route.previous_semantic_snapshot_version)
2735
+ ) {
2736
+ throw new Error(
2737
+ `semantic snapshot audit found incomplete reuse metadata: ${route.operation_source_key}`,
2738
+ );
2739
+ }
2740
+ if (
2741
+ route.semantic_origin === "changed_route_needs_review" &&
2742
+ !route.semantic_change_reason
2743
+ ) {
2744
+ throw new Error(
2745
+ `semantic snapshot audit found missing review reason: ${route.operation_source_key}`,
2746
+ );
2747
+ }
2748
+ }
2749
+ };
2750
+
2429
2751
  export const runSemanticCi = async ({
2430
2752
  repoRoot,
2431
2753
  request: rawRequest,
2432
2754
  env,
2433
2755
  previousSnapshot: providedPreviousSnapshot,
2756
+ agentSkills: rawAgentSkills,
2434
2757
  }) => {
2435
2758
  const request = validateSemanticCiRequest(rawRequest);
2436
2759
  const hashScope = semanticSnapshotHashScope(request);
2760
+ const generationContract = semanticGenerationContract();
2761
+ const agentSkills = validateSemanticAgentSkillBundle(
2762
+ rawAgentSkills ?? semanticAgentSkillBundle(),
2763
+ );
2437
2764
  const files = await listAllowedPythonFiles({
2438
2765
  repoRoot,
2439
2766
  allowedSourcePaths: request.allowed_source_paths,
@@ -2445,6 +2772,9 @@ export const runSemanticCi = async ({
2445
2772
  "semantic CI discovered zero routes; check framework, backend root path, and allowed_source_paths",
2446
2773
  );
2447
2774
  }
2775
+ if (!env.CLUE_API_KEY) {
2776
+ throw new Error("CLUE_API_KEY is required");
2777
+ }
2448
2778
  const previousSnapshot =
2449
2779
  providedPreviousSnapshot === undefined
2450
2780
  ? await fetchLatestSnapshot({ request, env })
@@ -2452,11 +2782,25 @@ export const runSemanticCi = async ({
2452
2782
  const previousRoutes = previousRouteMap(previousSnapshot);
2453
2783
  const routeNeedsAi = routes.some((route) => {
2454
2784
  const previousRoute = previousRoutes.get(route.operation_source_key);
2455
- return (
2456
- !previousRoute || previousRoute.route_input_hash !== routeInputHash(route)
2457
- );
2785
+ return !previousRoute || previousRoute.route_input_hash !== routeInputHash(route);
2458
2786
  });
2459
- const aiProviderApiKey = routeNeedsAi ? requireAiProviderApiKey(env) : null;
2787
+ if (routeNeedsAi && !env.CLUE_AI_PROVIDER_API_KEY) {
2788
+ throw new Error("CLUE_AI_PROVIDER_API_KEY is required for semantic generation");
2789
+ }
2790
+ const aiProviderConfig = routeNeedsAi
2791
+ ? resolveAiProviderConfig({ env, request })
2792
+ : null;
2793
+ const aiRuntime =
2794
+ aiProviderConfig === null && previousSnapshot?.ai_runtime
2795
+ ? previousSnapshot.ai_runtime
2796
+ : clueClientCiSemanticAiRuntime({
2797
+ provider: aiProviderConfig?.provider ?? "not_used",
2798
+ model: aiProviderConfig?.model ?? "not_used",
2799
+ roleIds: SEMANTIC_AGENT_ROLE_IDS
2800
+ ? Object.values(SEMANTIC_AGENT_ROLE_IDS)
2801
+ : [],
2802
+ runnerVersion: SEMANTIC_AGENT_RUNNER_VERSION,
2803
+ });
2460
2804
  const {
2461
2805
  plans: routePlans,
2462
2806
  routesRequiringGeneration,
@@ -2466,8 +2810,10 @@ export const runSemanticCi = async ({
2466
2810
  env,
2467
2811
  routes,
2468
2812
  previousSnapshot,
2469
- aiProviderApiKey,
2813
+ aiProviderApiKey: aiProviderConfig?.apiKey ?? null,
2470
2814
  hashScope,
2815
+ generationContract,
2816
+ agentSkills,
2471
2817
  });
2472
2818
  const promptRoutes = routesRequiringGeneration.map((route) =>
2473
2819
  buildRouteEvidencePromptEntry({ route, hashScope }),
@@ -2475,24 +2821,32 @@ export const runSemanticCi = async ({
2475
2821
  const aiRoutes = await generateAiRoutesPerRoute({
2476
2822
  request,
2477
2823
  env,
2478
- apiKey: aiProviderApiKey,
2824
+ apiKey: aiProviderConfig?.apiKey ?? null,
2479
2825
  routes: promptRoutes,
2826
+ generationContract,
2827
+ agentSkills,
2480
2828
  });
2481
2829
  const { selectionCandidates, routeBySelectionKey } = buildSelectorCandidates({
2482
2830
  routes: routesRequiringGeneration,
2483
2831
  aiRoutes,
2484
2832
  hashScope,
2485
2833
  });
2486
- const aiSelectionDecisions =
2487
- selectionCandidates.length === 0
2488
- ? new Map()
2489
- : await callAiSelectionProvider({
2490
- request,
2491
- env,
2492
- apiKey: aiProviderApiKey,
2493
- selectionCandidates,
2494
- routeBySelectionKey,
2495
- });
2834
+ let aiSelectionDecisions = new Map();
2835
+ if (selectionCandidates.length > 0) {
2836
+ try {
2837
+ aiSelectionDecisions = await callAiSelectionProvider({
2838
+ request,
2839
+ env,
2840
+ apiKey: aiProviderConfig?.apiKey ?? null,
2841
+ selectionCandidates,
2842
+ routeBySelectionKey,
2843
+ generationContract,
2844
+ agentSkills,
2845
+ });
2846
+ } catch {
2847
+ aiSelectionDecisions = new Map();
2848
+ }
2849
+ }
2496
2850
  const snapshot = buildSnapshot({
2497
2851
  request,
2498
2852
  routes,
@@ -2501,6 +2855,14 @@ export const runSemanticCi = async ({
2501
2855
  routePlans,
2502
2856
  previousSnapshot,
2503
2857
  deletedRouteCount,
2858
+ generationContract,
2859
+ aiRuntime,
2860
+ });
2861
+ assertSemanticSnapshotAudit({
2862
+ routes,
2863
+ snapshot,
2864
+ generationContract,
2865
+ aiRuntime,
2504
2866
  });
2505
2867
  const upload = await sendSnapshot({ request, env, snapshot });
2506
2868
  return {