@clue-ai/cli 0.0.5 → 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.
@@ -4,8 +4,20 @@ import {
4
4
  validateSemanticSnapshotRequest,
5
5
  validateSemanticSnapshotResponse,
6
6
  } from "./contracts.mjs";
7
+ import {
8
+ callJsonAiProvider,
9
+ resolveAiProviderConfig,
10
+ } from "./ai-provider.mjs";
7
11
  import { analyzeFastApiRoutes } from "./fastapi-analyzer.mjs";
8
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";
9
21
 
10
22
  const sha256 = (value) =>
11
23
  `sha256:${createHash("sha256").update(value).digest("hex")}`;
@@ -68,6 +80,12 @@ const ACTUAL_OUTCOME_ACTIONS = new Set([
68
80
  ]);
69
81
 
70
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";
71
89
 
72
90
  const semanticSnapshotHashScope = (request) =>
73
91
  [
@@ -77,6 +95,15 @@ const semanticSnapshotHashScope = (request) =>
77
95
  request.service.service_key,
78
96
  ].join(":");
79
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
+
80
107
  const canonicalJson = (value) => {
81
108
  if (Array.isArray(value)) {
82
109
  return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
@@ -149,9 +176,14 @@ const fallbackSemantics = (route, reason, hashScope) => ({
149
176
  confidence_reason: reason,
150
177
  });
151
178
 
152
- const buildAiPrompt = (routes) =>
179
+ const buildAiPrompt = ({ routes, generationContract, agentSkills }) =>
153
180
  JSON.stringify({
181
+ ...semanticAgentPromptEnvelope({
182
+ bundle: agentSkills,
183
+ roleId: SEMANTIC_AGENT_ROLE_IDS.routeSemanticGenerator,
184
+ }),
154
185
  task: "Generate privacy-safe semantic meaning candidates for FastAPI operation sources. Return JSON only.",
186
+ generation_contract: generationContract,
155
187
  rules: [
156
188
  "Do not create operation_source_key values; only copy the provided keys.",
157
189
  "Do not create ids, refs, hashes, source fingerprints, or field paths.",
@@ -199,9 +231,20 @@ const buildAiPrompt = (routes) =>
199
231
  })),
200
232
  });
201
233
 
202
- const buildAiReuseDecisionPrompt = ({ route, previousRoute, currentHash }) =>
234
+ const buildAiReuseDecisionPrompt = ({
235
+ route,
236
+ previousRoute,
237
+ currentHash,
238
+ generationContract,
239
+ agentSkills,
240
+ }) =>
203
241
  JSON.stringify({
242
+ ...semanticAgentPromptEnvelope({
243
+ bundle: agentSkills,
244
+ roleId: SEMANTIC_AGENT_ROLE_IDS.reuseJudge,
245
+ }),
204
246
  task: "Decide whether previous privacy-safe route semantics still apply after a route source change. Return JSON only.",
247
+ generation_contract: generationContract,
205
248
  rules: [
206
249
  "Only decide for the provided operation_source_key.",
207
250
  "Return decision as reuse_previous, regenerate, or needs_review.",
@@ -234,6 +277,28 @@ const buildAiReuseDecisionPrompt = ({ route, previousRoute, currentHash }) =>
234
277
  });
235
278
 
236
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
+ }
237
302
  const decision = safeArray(parsed?.decisions).find(
238
303
  (entry) => entry?.operation_source_key === operationSourceKey,
239
304
  );
@@ -244,9 +309,11 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
244
309
  reason: "AI reuse decision omitted this changed route.",
245
310
  };
246
311
  }
247
- const normalizedDecision = ["reuse_previous", "regenerate", "needs_review"].includes(
248
- decision.decision,
249
- )
312
+ const normalizedDecision = [
313
+ "reuse_previous",
314
+ "regenerate",
315
+ "needs_review",
316
+ ].includes(decision.decision)
250
317
  ? decision.decision
251
318
  : "needs_review";
252
319
  return {
@@ -263,109 +330,104 @@ const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
263
330
 
264
331
  const callAiReuseDecisionProvider = async ({
265
332
  request,
333
+ env,
266
334
  apiKey,
267
335
  route,
268
336
  previousRoute,
269
337
  currentHash,
338
+ generationContract,
339
+ agentSkills,
270
340
  }) => {
271
- const response = await fetch(
272
- `${request.ai_provider_base_url.replace(/\/+$/, "")}/chat/completions`,
273
- {
274
- method: "POST",
275
- headers: {
276
- "content-type": "application/json",
277
- authorization: `Bearer ${apiKey}`,
278
- },
279
- body: JSON.stringify({
280
- model: request.ai_model,
281
- messages: [
282
- {
283
- role: "system",
284
- content:
285
- "You judge whether previous route semantics can be reused from privacy-safe evidence.",
286
- },
287
- {
288
- role: "user",
289
- content: buildAiReuseDecisionPrompt({
290
- route,
291
- previousRoute,
292
- currentHash,
293
- }),
294
- },
295
- ],
296
- response_format: { type: "json_object" },
297
- }),
298
- },
299
- );
300
- if (!response.ok) {
301
- return {
302
- decision: "needs_review",
303
- confidence: 0,
304
- reason: `AI reuse decision failed: provider_${response.status}.`,
305
- };
306
- }
307
- const body = await response.json();
308
- const content = body?.choices?.[0]?.message?.content;
309
- if (typeof content !== "string" || content.trim() === "") {
310
- return {
311
- decision: "needs_review",
312
- confidence: 0,
313
- reason: "AI reuse decision returned empty content.",
314
- };
315
- }
316
341
  try {
342
+ const parsed = await callJsonAiProvider({
343
+ config: resolveAiProviderConfig({ env, request, apiKey }),
344
+ system:
345
+ "You judge whether previous route semantics can be reused from privacy-safe evidence.",
346
+ user: buildAiReuseDecisionPrompt({
347
+ route,
348
+ previousRoute,
349
+ currentHash,
350
+ generationContract,
351
+ agentSkills,
352
+ }),
353
+ toolName: "return_reuse_decision",
354
+ toolDescription:
355
+ "Return whether the previous route semantics can be reused.",
356
+ failureMessage: "AI reuse decision failed",
357
+ emptyMessage: "AI reuse decision returned empty content.",
358
+ });
317
359
  return normalizeReuseDecision({
318
- parsed: JSON.parse(content),
360
+ parsed,
319
361
  operationSourceKey: route.operation_source_key,
320
362
  });
321
- } catch {
363
+ } catch (error) {
364
+ const providerStatusMatch =
365
+ error instanceof Error
366
+ ? /AI reuse decision failed: (?<status>\d+)/.exec(error.message)
367
+ : null;
322
368
  return {
323
369
  decision: "needs_review",
324
370
  confidence: 0,
325
- reason: "AI reuse decision returned malformed JSON.",
371
+ reason: providerStatusMatch?.groups?.status
372
+ ? `AI reuse decision failed: provider_${providerStatusMatch.groups.status}.`
373
+ : "AI reuse decision returned malformed JSON.",
326
374
  };
327
375
  }
328
376
  };
329
377
 
330
- const requireAiProviderApiKey = (env) => {
331
- const apiKey = env.AI_PROVIDER_API_KEY;
332
- if (!apiKey) {
333
- throw new Error("AI_PROVIDER_API_KEY is required for semantic generation");
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
+ }
334
407
  }
335
- return apiKey;
336
408
  };
337
409
 
338
- const callAiProvider = async ({ request, apiKey, routes }) => {
339
- const response = await fetch(
340
- `${request.ai_provider_base_url.replace(/\/+$/, "")}/chat/completions`,
341
- {
342
- method: "POST",
343
- headers: {
344
- "content-type": "application/json",
345
- authorization: `Bearer ${apiKey}`,
346
- },
347
- body: JSON.stringify({
348
- model: request.ai_model,
349
- messages: [
350
- {
351
- role: "system",
352
- content: "You generate privacy-safe route semantics JSON.",
353
- },
354
- { role: "user", content: buildAiPrompt(routes) },
355
- ],
356
- response_format: { type: "json_object" },
357
- }),
358
- },
359
- );
360
- if (!response.ok) {
361
- throw new Error(`AI provider failed: ${response.status}`);
362
- }
363
- const body = await response.json();
364
- const content = body?.choices?.[0]?.message?.content;
365
- if (typeof content !== "string" || content.trim() === "") {
366
- throw new Error("AI provider returned empty semantic generation content");
367
- }
368
- const parsed = JSON.parse(content);
410
+ const callAiProvider = async ({
411
+ request,
412
+ env,
413
+ apiKey,
414
+ routes,
415
+ generationContract,
416
+ agentSkills,
417
+ }) => {
418
+ const parsed = await callJsonAiProvider({
419
+ config: resolveAiProviderConfig({ env, request, apiKey }),
420
+ system: "You generate privacy-safe route semantics JSON.",
421
+ user: buildAiPrompt({ routes, generationContract, agentSkills }),
422
+ toolName: "return_route_semantics",
423
+ toolDescription: "Return privacy-safe route semantics JSON.",
424
+ failureMessage: "AI provider failed",
425
+ emptyMessage: "AI provider returned empty semantic generation content",
426
+ });
427
+ assertAiRouteResponseContract({
428
+ parsed,
429
+ expectedRouteKeys: routes.map((route) => route.operation_source_key),
430
+ });
369
431
  return new Map(
370
432
  (parsed.routes ?? []).map((route) => [route.operation_source_key, route]),
371
433
  );
@@ -413,14 +475,24 @@ const assertSnapshotRouteCoverage = ({ routes, snapshotRoutes }) => {
413
475
  }
414
476
  };
415
477
 
416
- const generateAiRoutesPerRoute = async ({ request, apiKey, routes }) => {
478
+ const generateAiRoutesPerRoute = async ({
479
+ request,
480
+ env,
481
+ apiKey,
482
+ routes,
483
+ generationContract,
484
+ agentSkills,
485
+ }) => {
417
486
  const aiRoutes = new Map();
418
487
  for (const route of routes) {
419
488
  try {
420
489
  const routeAiRoutes = await callAiProvider({
421
490
  request,
491
+ env,
422
492
  apiKey,
423
493
  routes: [route],
494
+ generationContract,
495
+ agentSkills,
424
496
  });
425
497
  const aiRoute = routeAiRoutes.get(route.operation_source_key);
426
498
  if (aiRoute?.semantics) {
@@ -447,9 +519,18 @@ const generateAiRoutesPerRoute = async ({ request, apiKey, routes }) => {
447
519
  return aiRoutes;
448
520
  };
449
521
 
450
- const buildAiSelectionPrompt = (selectionCandidates) =>
522
+ const buildAiSelectionPrompt = ({
523
+ selectionCandidates,
524
+ generationContract,
525
+ agentSkills,
526
+ }) =>
451
527
  JSON.stringify({
528
+ ...semanticAgentPromptEnvelope({
529
+ bundle: agentSkills,
530
+ roleId: SEMANTIC_AGENT_ROLE_IDS.semanticSelector,
531
+ }),
452
532
  task: "Choose adopted semantic values from deterministic and AI candidates. Return JSON only.",
533
+ generation_contract: generationContract,
453
534
  rules: [
454
535
  "Only copy operation_source_key, candidate_index, and parameter_name values from the provided selection_candidates.",
455
536
  "For each semantic field, return selected_source as deterministic or ai.",
@@ -476,56 +557,62 @@ const buildAiSelectionPrompt = (selectionCandidates) =>
476
557
 
477
558
  const callAiSelectionProvider = async ({
478
559
  request,
560
+ env,
479
561
  apiKey,
480
562
  selectionCandidates,
481
563
  routeBySelectionKey,
564
+ generationContract,
565
+ agentSkills,
482
566
  }) => {
483
567
  if (selectionCandidates.length === 0) {
484
568
  return new Map();
485
569
  }
486
- assertNoRawCodeStructure(selectionCandidates, "semantic_selection_prompt");
487
- const response = await fetch(
488
- `${request.ai_provider_base_url.replace(/\/+$/, "")}/chat/completions`,
489
- {
490
- method: "POST",
491
- headers: {
492
- "content-type": "application/json",
493
- authorization: `Bearer ${apiKey}`,
494
- },
495
- body: JSON.stringify({
496
- model: request.ai_model,
497
- messages: [
498
- {
499
- role: "system",
500
- content:
501
- "You select adopted semantic values from privacy-safe candidates.",
502
- },
503
- {
504
- role: "user",
505
- content: buildAiSelectionPrompt(selectionCandidates),
506
- },
507
- ],
508
- response_format: { type: "json_object" },
509
- }),
510
- },
511
- );
512
- if (!response.ok) {
513
- throw new Error(`AI selector failed: ${response.status}`);
514
- }
515
- const body = await response.json();
516
- const content = body?.choices?.[0]?.message?.content;
517
- if (typeof content !== "string" || content.trim() === "") {
518
- throw new Error("AI selector returned empty semantic selection content");
519
- }
520
- const parsed = JSON.parse(content);
570
+ assertNoRawCodeStructureForSelectionCandidates(selectionCandidates);
571
+ const parsed = await callJsonAiProvider({
572
+ config: resolveAiProviderConfig({ env, request, apiKey }),
573
+ system: "You select adopted semantic values from privacy-safe candidates.",
574
+ user: buildAiSelectionPrompt({
575
+ selectionCandidates,
576
+ generationContract,
577
+ agentSkills,
578
+ }),
579
+ toolName: "return_semantic_selection",
580
+ toolDescription:
581
+ "Return selected semantic values from privacy-safe candidates.",
582
+ failureMessage: "AI selector failed",
583
+ emptyMessage: "AI selector returned empty semantic selection content",
584
+ });
521
585
  return normalizeAiSelectionResponse({
522
586
  parsed,
523
587
  routeBySelectionKey,
588
+ validSelectionParametersByKey: validSelectionParametersByKey(
589
+ selectionCandidates,
590
+ ),
524
591
  });
525
592
  };
526
593
 
527
- const buildSemanticSnapshotVersion = ({ request, generatedAt }) =>
528
- `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
+ )}`;
529
616
 
530
617
  const normalizeSnakeSegment = (value) => {
531
618
  const normalized = String(value ?? "")
@@ -888,7 +975,30 @@ const candidateHasUnsafeSemanticText = (candidate) =>
888
975
  const selectionKeyForCandidate = (route, candidateIndex) =>
889
976
  `${route.operation_source_key}#${candidateIndex}`;
890
977
 
891
- 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
+ }
892
1002
  const decisionsByCandidate = new Map();
893
1003
  for (const decision of safeArray(parsed?.field_selections)) {
894
1004
  if (!decision || typeof decision !== "object") {
@@ -907,6 +1017,10 @@ const normalizeAiSelectionResponse = ({ parsed, routeBySelectionKey }) => {
907
1017
  if (!route) {
908
1018
  continue;
909
1019
  }
1020
+ const validParameters = validSelectionParametersByKey.get(selectionKey);
1021
+ if (!validParameters?.has(decision.parameter_name)) {
1022
+ continue;
1023
+ }
910
1024
  const candidateDecisions =
911
1025
  decisionsByCandidate.get(selectionKey) ?? new Map();
912
1026
  candidateDecisions.set(decision.parameter_name, {
@@ -1072,7 +1186,8 @@ const buildSelectorCandidates = ({ routes, aiRoutes, hashScope }) => {
1072
1186
  candidateHasUnsafeSemanticText(candidate) ||
1073
1187
  !values.rawTargetObjectText ||
1074
1188
  !values.targetKeyCandidate ||
1075
- !values.aiExpectedDomainEffect ||
1189
+ (!values.aiExpectedDomainEffect &&
1190
+ !values.deterministicExpectedDomainEffect) ||
1076
1191
  !hasBehaviorEvidence(values.sourceEvidenceRefs, routeEvidenceRefs) ||
1077
1192
  !candidateHasTargetSpecificEvidence({
1078
1193
  route,
@@ -1638,13 +1753,12 @@ const buildSnapshot = ({
1638
1753
  routePlans = new Map(),
1639
1754
  previousSnapshot,
1640
1755
  deletedRouteCount = 0,
1756
+ generationContract,
1757
+ aiRuntime,
1641
1758
  }) => {
1642
1759
  const generatedAt = new Date().toISOString();
1643
1760
  const hashScope = semanticSnapshotHashScope(request);
1644
- const semanticSnapshotVersion = buildSemanticSnapshotVersion({
1645
- request,
1646
- generatedAt,
1647
- });
1761
+ const provisionalSemanticSnapshotVersion = "snap_pending";
1648
1762
  const targetObjectProfiles = [];
1649
1763
  const targetObjectMappings = [];
1650
1764
  const sourceEvidenceRefs = [];
@@ -1678,7 +1792,7 @@ const buildSnapshot = ({
1678
1792
  const snapshotRoutes = routes.map((route) => {
1679
1793
  const routeWithVersion = {
1680
1794
  ...route,
1681
- semantic_snapshot_version: semanticSnapshotVersion,
1795
+ semantic_snapshot_version: provisionalSemanticSnapshotVersion,
1682
1796
  };
1683
1797
  const plan = routePlans.get(route.operation_source_key) ?? {
1684
1798
  origin: "new_route_ai_generated",
@@ -1697,7 +1811,7 @@ const buildSnapshot = ({
1697
1811
  return rebasePreviousRoute({
1698
1812
  previousRoute: plan.previous_route,
1699
1813
  currentRoute: route,
1700
- semanticSnapshotVersion,
1814
+ semanticSnapshotVersion: provisionalSemanticSnapshotVersion,
1701
1815
  plan,
1702
1816
  });
1703
1817
  }
@@ -1710,31 +1824,28 @@ const buildSnapshot = ({
1710
1824
  ? `changed_route_needs_review: ${plan.semantic_change_reason}`
1711
1825
  : (aiRoute?.confidence_reason ??
1712
1826
  "AI semantics were unavailable for this route.");
1713
- const baseRoute = !aiRoute?.semantics || plan.origin === "changed_route_needs_review"
1714
- ? fallbackSemantics(
1715
- routeWithVersion,
1716
- unavailableReason,
1717
- hashScope,
1718
- )
1719
- : {
1720
- operation_source_key: route.operation_source_key,
1721
- semantic_snapshot_version: semanticSnapshotVersion,
1722
- method: route.method,
1723
- path_template: route.path_template,
1724
- semantics: sanitizeSemantics(aiRoute.semantics, route),
1725
- layer_evidence: sanitizeLayerEvidence(
1726
- aiRoute.layer_evidence,
1727
- route,
1728
- hashScope,
1729
- ),
1730
- confidence_reason: sanitizeText(
1731
- String(
1732
- aiRoute.confidence_reason ||
1733
- "Generated by AI from privacy-safe route evidence.",
1827
+ const baseRoute =
1828
+ !aiRoute?.semantics || plan.origin === "changed_route_needs_review"
1829
+ ? fallbackSemantics(routeWithVersion, unavailableReason, hashScope)
1830
+ : {
1831
+ operation_source_key: route.operation_source_key,
1832
+ semantic_snapshot_version: provisionalSemanticSnapshotVersion,
1833
+ method: route.method,
1834
+ path_template: route.path_template,
1835
+ semantics: sanitizeSemantics(aiRoute.semantics, route),
1836
+ layer_evidence: sanitizeLayerEvidence(
1837
+ aiRoute.layer_evidence,
1838
+ route,
1839
+ hashScope,
1734
1840
  ),
1735
- route,
1736
- ),
1737
- };
1841
+ confidence_reason: sanitizeText(
1842
+ String(
1843
+ aiRoute.confidence_reason ||
1844
+ "Generated by AI from privacy-safe route evidence.",
1845
+ ),
1846
+ route,
1847
+ ),
1848
+ };
1738
1849
  const assignmentCollections = buildOperationAssignmentCollections({
1739
1850
  route,
1740
1851
  aiRoute,
@@ -1794,7 +1905,20 @@ const buildSnapshot = ({
1794
1905
  );
1795
1906
 
1796
1907
  const uniqueSourceEvidenceRefs = [];
1797
- addUniqueBy(uniqueSourceEvidenceRefs, sourceEvidenceRefs, (entry) => entry.id);
1908
+ addUniqueBy(
1909
+ uniqueSourceEvidenceRefs,
1910
+ sourceEvidenceRefs,
1911
+ (entry) => entry.id,
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
+ }));
1798
1922
 
1799
1923
  const snapshot = validateSemanticSnapshotRequest({
1800
1924
  project_key: request.project_key,
@@ -1803,43 +1927,48 @@ const buildSnapshot = ({
1803
1927
  ),
1804
1928
  schema_version: 2,
1805
1929
  semantic_snapshot_version: semanticSnapshotVersion,
1930
+ generation_contract: generationContract,
1931
+ ai_runtime: aiRuntime,
1806
1932
  generated_at: generatedAt,
1807
1933
  repository: request.repository,
1808
- service: request.service,
1934
+ service: {
1935
+ service_key: request.service.service_key,
1936
+ framework: request.service.framework,
1937
+ language: request.service.language,
1938
+ },
1809
1939
  analysis_summary: {
1810
1940
  total_routes_scanned: routes.length,
1811
- routes_generated: snapshotRoutes.filter(
1941
+ routes_generated: versionedSnapshotRoutes.filter(
1812
1942
  (route) => route.semantics.route_confidence > 0,
1813
1943
  ).length,
1814
- uncertain_routes: snapshotRoutes.filter(
1944
+ uncertain_routes: versionedSnapshotRoutes.filter(
1815
1945
  (route) => route.semantics.route_confidence < 0.5,
1816
1946
  ).length,
1817
1947
  failed_routes: failedRouteCount,
1818
- routes_reused: snapshotRoutes.filter((route) =>
1819
- [
1820
- "unchanged_route_reused",
1821
- "changed_route_semantic_reused",
1822
- ].includes(route.semantic_origin),
1948
+ routes_reused: versionedSnapshotRoutes.filter((route) =>
1949
+ ["unchanged_route_reused", "changed_route_semantic_reused"].includes(
1950
+ route.semantic_origin,
1951
+ ),
1823
1952
  ).length,
1824
- routes_ai_generated: snapshotRoutes.filter((route) =>
1953
+ routes_ai_generated: versionedSnapshotRoutes.filter((route) =>
1825
1954
  [
1826
1955
  "new_route_ai_generated",
1827
1956
  "changed_route_semantic_regenerated",
1828
1957
  ].includes(route.semantic_origin),
1829
1958
  ).length,
1830
1959
  routes_deleted: deletedRouteCount,
1831
- routes_needs_review: snapshotRoutes.filter(
1960
+ routes_needs_review: versionedSnapshotRoutes.filter(
1832
1961
  (route) => route.semantic_origin === "changed_route_needs_review",
1833
1962
  ).length,
1834
- changed_routes_semantic_reused: snapshotRoutes.filter(
1963
+ changed_routes_semantic_reused: versionedSnapshotRoutes.filter(
1835
1964
  (route) => route.semantic_origin === "changed_route_semantic_reused",
1836
1965
  ).length,
1837
- changed_routes_semantic_regenerated: snapshotRoutes.filter(
1966
+ changed_routes_semantic_regenerated: versionedSnapshotRoutes.filter(
1838
1967
  (route) =>
1839
1968
  route.semantic_origin === "changed_route_semantic_regenerated",
1840
1969
  ).length,
1841
1970
  },
1842
- routes: snapshotRoutes,
1971
+ routes: versionedSnapshotRoutes,
1843
1972
  target_object_profiles: targetObjectProfiles,
1844
1973
  target_object_catalog: [...catalogByGroup.values()],
1845
1974
  target_object_mappings: targetObjectMappings,
@@ -2039,10 +2168,13 @@ const buildRouteEvidencePromptEntry = ({ route, hashScope }) => ({
2039
2168
 
2040
2169
  const classifyRoutesForSnapshot = async ({
2041
2170
  request,
2171
+ env,
2042
2172
  routes,
2043
2173
  previousSnapshot,
2044
2174
  aiProviderApiKey,
2045
2175
  hashScope,
2176
+ generationContract,
2177
+ agentSkills,
2046
2178
  }) => {
2047
2179
  const previousRoutes = previousRouteMap(previousSnapshot);
2048
2180
  const plans = new Map();
@@ -2076,12 +2208,15 @@ const classifyRoutesForSnapshot = async ({
2076
2208
 
2077
2209
  const decision = await callAiReuseDecisionProvider({
2078
2210
  request,
2211
+ env,
2079
2212
  apiKey: aiProviderApiKey,
2080
2213
  route: buildRouteEvidencePromptEntry({ route, hashScope }),
2081
2214
  previousRoute,
2082
2215
  currentHash,
2216
+ generationContract,
2217
+ agentSkills,
2083
2218
  });
2084
- if (decision.decision === "reuse_previous") {
2219
+ if (decision.decision === "reuse_previous" && decision.confidence >= 0.75) {
2085
2220
  plans.set(route.operation_source_key, {
2086
2221
  origin: "changed_route_semantic_reused",
2087
2222
  route_input_hash: currentHash,
@@ -2093,6 +2228,21 @@ const classifyRoutesForSnapshot = async ({
2093
2228
  });
2094
2229
  continue;
2095
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
+ }
2096
2246
  if (decision.decision === "regenerate") {
2097
2247
  plans.set(route.operation_source_key, {
2098
2248
  origin: "changed_route_semantic_regenerated",
@@ -2158,7 +2308,13 @@ const rebasePreviousRoute = ({
2158
2308
  };
2159
2309
  };
2160
2310
 
2161
- const fieldPathMatchesKeys = ({ fieldPath, routeKeys, profileIds, mappingIds, catalogKeys }) => {
2311
+ const fieldPathMatchesKeys = ({
2312
+ fieldPath,
2313
+ routeKeys,
2314
+ profileIds,
2315
+ mappingIds,
2316
+ catalogKeys,
2317
+ }) => {
2162
2318
  for (const routeKey of routeKeys) {
2163
2319
  if (fieldPath.startsWith(`routes[${routeKey}]`)) {
2164
2320
  return true;
@@ -2197,7 +2353,9 @@ const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
2197
2353
  ...safeArray(previousSnapshot?.routes)
2198
2354
  .filter((route) => routeKeys.has(route.operation_source_key))
2199
2355
  .flatMap((route) =>
2200
- safeArray(route.operation_effects).map((effect) => effect.target_object_key),
2356
+ safeArray(route.operation_effects).map(
2357
+ (effect) => effect.target_object_key,
2358
+ ),
2201
2359
  ),
2202
2360
  ]);
2203
2361
  const catalogs = safeArray(previousSnapshot?.target_object_catalog).filter(
@@ -2274,9 +2432,9 @@ const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
2274
2432
  sourceEvidenceIds.add(ref);
2275
2433
  }
2276
2434
  }
2277
- const sourceEvidenceRefs = safeArray(previousSnapshot?.source_evidence_refs).filter(
2278
- (entry) => sourceEvidenceIds.has(entry.id),
2279
- );
2435
+ const sourceEvidenceRefs = safeArray(
2436
+ previousSnapshot?.source_evidence_refs,
2437
+ ).filter((entry) => sourceEvidenceIds.has(entry.id));
2280
2438
  return {
2281
2439
  profiles,
2282
2440
  mappings,
@@ -2384,6 +2542,32 @@ const assertNoRawCodeStructure = (value, path = "snapshot") => {
2384
2542
  }
2385
2543
  };
2386
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
+
2387
2571
  const semanticSnapshotUrl = (baseUrl) => {
2388
2572
  const normalized = baseUrl.replace(/\/+$/, "");
2389
2573
  if (normalized.endsWith("/api/v1/semantic-snapshots")) {
@@ -2396,7 +2580,9 @@ const semanticSnapshotUrl = (baseUrl) => {
2396
2580
  };
2397
2581
 
2398
2582
  const semanticSnapshotLatestUrl = (request) => {
2399
- const url = new URL(`${semanticSnapshotUrl(request.clue_api_base_url)}/latest`);
2583
+ const url = new URL(
2584
+ `${semanticSnapshotUrl(request.clue_api_base_url)}/latest`,
2585
+ );
2400
2586
  url.searchParams.set("project_key", request.project_key);
2401
2587
  url.searchParams.set("environment", request.environment);
2402
2588
  url.searchParams.set("repository_id", request.repository.repository_id);
@@ -2409,6 +2595,8 @@ const fetchLatestSnapshot = async ({ request, env }) => {
2409
2595
  if (!apiKey) {
2410
2596
  return null;
2411
2597
  }
2598
+ const allowFullRegeneration =
2599
+ env.CLUE_SEMANTIC_ALLOW_FULL_REGEN_WITHOUT_CACHE === "1";
2412
2600
  let response;
2413
2601
  try {
2414
2602
  response = await fetch(semanticSnapshotLatestUrl(request), {
@@ -2417,19 +2605,34 @@ const fetchLatestSnapshot = async ({ request, env }) => {
2417
2605
  authorization: `Bearer ${apiKey}`,
2418
2606
  },
2419
2607
  });
2420
- } catch {
2421
- 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
+ );
2422
2617
  }
2423
2618
  if (response.status === 404 || response.status === 204) {
2424
2619
  return null;
2425
2620
  }
2426
2621
  if (!response.ok) {
2427
- return null;
2622
+ if (allowFullRegeneration) {
2623
+ return null;
2624
+ }
2625
+ throw new Error(
2626
+ `previous semantic snapshot lookup failed: ${response.status}`,
2627
+ );
2428
2628
  }
2429
2629
  const body = await response.json();
2430
2630
  const candidate = body?.snapshot ?? body;
2431
2631
  if (!candidate || !Array.isArray(candidate.routes)) {
2432
- return null;
2632
+ if (allowFullRegeneration) {
2633
+ return null;
2634
+ }
2635
+ throw new Error("previous semantic snapshot lookup returned invalid body");
2433
2636
  }
2434
2637
  const parsed = validateSemanticSnapshotRequest(candidate);
2435
2638
  assertNoRawCodeStructure(parsed);
@@ -2475,14 +2678,89 @@ const sendSnapshot = async ({ request, env, snapshot }) => {
2475
2678
  }
2476
2679
  };
2477
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
+
2478
2751
  export const runSemanticCi = async ({
2479
2752
  repoRoot,
2480
2753
  request: rawRequest,
2481
2754
  env,
2482
2755
  previousSnapshot: providedPreviousSnapshot,
2756
+ agentSkills: rawAgentSkills,
2483
2757
  }) => {
2484
2758
  const request = validateSemanticCiRequest(rawRequest);
2485
2759
  const hashScope = semanticSnapshotHashScope(request);
2760
+ const generationContract = semanticGenerationContract();
2761
+ const agentSkills = validateSemanticAgentSkillBundle(
2762
+ rawAgentSkills ?? semanticAgentSkillBundle(),
2763
+ );
2486
2764
  const files = await listAllowedPythonFiles({
2487
2765
  repoRoot,
2488
2766
  allowedSourcePaths: request.allowed_source_paths,
@@ -2494,6 +2772,9 @@ export const runSemanticCi = async ({
2494
2772
  "semantic CI discovered zero routes; check framework, backend root path, and allowed_source_paths",
2495
2773
  );
2496
2774
  }
2775
+ if (!env.CLUE_API_KEY) {
2776
+ throw new Error("CLUE_API_KEY is required");
2777
+ }
2497
2778
  const previousSnapshot =
2498
2779
  providedPreviousSnapshot === undefined
2499
2780
  ? await fetchLatestSnapshot({ request, env })
@@ -2503,37 +2784,69 @@ export const runSemanticCi = async ({
2503
2784
  const previousRoute = previousRoutes.get(route.operation_source_key);
2504
2785
  return !previousRoute || previousRoute.route_input_hash !== routeInputHash(route);
2505
2786
  });
2506
- const aiProviderApiKey = routeNeedsAi ? requireAiProviderApiKey(env) : null;
2507
- const { plans: routePlans, routesRequiringGeneration, deletedRouteCount } =
2508
- await classifyRoutesForSnapshot({
2509
- request,
2510
- routes,
2511
- previousSnapshot,
2512
- aiProviderApiKey,
2513
- hashScope,
2514
- });
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
+ });
2804
+ const {
2805
+ plans: routePlans,
2806
+ routesRequiringGeneration,
2807
+ deletedRouteCount,
2808
+ } = await classifyRoutesForSnapshot({
2809
+ request,
2810
+ env,
2811
+ routes,
2812
+ previousSnapshot,
2813
+ aiProviderApiKey: aiProviderConfig?.apiKey ?? null,
2814
+ hashScope,
2815
+ generationContract,
2816
+ agentSkills,
2817
+ });
2515
2818
  const promptRoutes = routesRequiringGeneration.map((route) =>
2516
2819
  buildRouteEvidencePromptEntry({ route, hashScope }),
2517
2820
  );
2518
2821
  const aiRoutes = await generateAiRoutesPerRoute({
2519
2822
  request,
2520
- apiKey: aiProviderApiKey,
2823
+ env,
2824
+ apiKey: aiProviderConfig?.apiKey ?? null,
2521
2825
  routes: promptRoutes,
2826
+ generationContract,
2827
+ agentSkills,
2522
2828
  });
2523
2829
  const { selectionCandidates, routeBySelectionKey } = buildSelectorCandidates({
2524
2830
  routes: routesRequiringGeneration,
2525
2831
  aiRoutes,
2526
2832
  hashScope,
2527
2833
  });
2528
- const aiSelectionDecisions =
2529
- selectionCandidates.length === 0
2530
- ? new Map()
2531
- : await callAiSelectionProvider({
2532
- request,
2533
- apiKey: aiProviderApiKey,
2534
- selectionCandidates,
2535
- routeBySelectionKey,
2536
- });
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
+ }
2537
2850
  const snapshot = buildSnapshot({
2538
2851
  request,
2539
2852
  routes,
@@ -2542,6 +2855,14 @@ export const runSemanticCi = async ({
2542
2855
  routePlans,
2543
2856
  previousSnapshot,
2544
2857
  deletedRouteCount,
2858
+ generationContract,
2859
+ aiRuntime,
2860
+ });
2861
+ assertSemanticSnapshotAudit({
2862
+ routes,
2863
+ snapshot,
2864
+ generationContract,
2865
+ aiRuntime,
2545
2866
  });
2546
2867
  const upload = await sendSnapshot({ request, env, snapshot });
2547
2868
  return {