@clue-ai/cli 0.0.18 → 0.0.19

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.
package/bin/clue-cli.mjs CHANGED
@@ -20,6 +20,7 @@ import { runSemanticCi, runSemanticInventory } from "../src/semantic-ci.mjs";
20
20
  import { runSetupCheck } from "../src/setup-check.mjs";
21
21
  import { runSetupDetect } from "../src/setup-detect.mjs";
22
22
  import { runSetupDoctor } from "../src/setup-doctor.mjs";
23
+ import { runSetupAgent } from "../src/setup-agent.mjs";
23
24
  import { buildAiSetupHelp } from "../src/setup-help.mjs";
24
25
  import { runSetupPrepare } from "../src/setup-prepare.mjs";
25
26
  import { installSetupSkills } from "../src/setup-tool.mjs";
@@ -850,6 +851,7 @@ const usage = () =>
850
851
  "Usage:",
851
852
  " /clue-init",
852
853
  ` ${clueCliCommand("setup --clue-api-key <key> --clue-api-base-url <url> --project-key <key> --environment dev --documents-url <url>")}`,
854
+ ` ${clueCliCommand("setup-agent --repo . [--ai-provider openai|anthropic --ai-provider-api-key <key> --ai-model <model>]")}`,
853
855
  ` ${clueCliCommand("setup-detect --repo .")}`,
854
856
  ` ${clueCliCommand("semantic-inventory --framework fastapi --backend-root-path backend --repo .")}`,
855
857
  ` ${clueCliCommand("semantic-agent-skills --output .clue/semantic-agent-skills.json")}`,
@@ -930,6 +932,19 @@ const main = async () => {
930
932
  return;
931
933
  }
932
934
 
935
+ if (command === "setup-agent") {
936
+ const report = await runSetupAgent({
937
+ env: process.env,
938
+ flags,
939
+ repoRoot,
940
+ });
941
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
942
+ if (report.status === "blocked") {
943
+ process.exitCode = 1;
944
+ }
945
+ return;
946
+ }
947
+
933
948
  if (command === "setup") {
934
949
  const report = await installSetupSkills({
935
950
  repoRoot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clue-ai/cli",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clue-ai": "bin/clue-cli.mjs"
@@ -25,8 +25,7 @@ const providerBaseUrl = ({ provider, env = {}, request = {} }) => {
25
25
  };
26
26
 
27
27
  const providerModel = ({ env = {}, request = {} }) => {
28
- const configuredModel =
29
- env.CLUE_INIT_AI_MODEL || env.CLUE_AI_MODEL || request.ai_model;
28
+ const configuredModel = env.CLUE_AI_MODEL || request.ai_model;
30
29
  const model = String(configuredModel || "").trim();
31
30
  if (!model) {
32
31
  throw new Error("CLUE_AI_MODEL is required");
@@ -59,6 +58,27 @@ const parseOpenAiJson = async ({ response, emptyMessage }) => {
59
58
  return JSON.parse(content);
60
59
  };
61
60
 
61
+ const parseOpenAiStrictToolJson = async ({ response, toolName, emptyMessage }) => {
62
+ const body = await response.json();
63
+ const output = Array.isArray(body?.output) ? body.output : [];
64
+ const functionCalls = output.flatMap((entry) => {
65
+ if (entry?.type === "function_call") return [entry];
66
+ if (Array.isArray(entry?.content)) {
67
+ return entry.content.filter((item) => item?.type === "function_call");
68
+ }
69
+ return [];
70
+ });
71
+ const toolCall = functionCalls.find((entry) => entry?.name === toolName);
72
+ const rawArguments = toolCall?.arguments ?? toolCall?.input;
73
+ if (typeof rawArguments === "string" && rawArguments.trim()) {
74
+ return JSON.parse(rawArguments);
75
+ }
76
+ if (rawArguments && typeof rawArguments === "object") {
77
+ return rawArguments;
78
+ }
79
+ throw new Error(emptyMessage);
80
+ };
81
+
62
82
  const parseAnthropicJson = async ({ response, toolName, emptyMessage }) => {
63
83
  const body = await response.json();
64
84
  const toolUse = Array.isArray(body?.content)
@@ -84,6 +104,76 @@ const parseAnthropicJson = async ({ response, toolName, emptyMessage }) => {
84
104
  return JSON.parse(text);
85
105
  };
86
106
 
107
+ export const callStrictToolAiProvider = async ({
108
+ config,
109
+ system,
110
+ user,
111
+ toolName,
112
+ toolDescription,
113
+ parameters,
114
+ failureMessage = "AI provider failed",
115
+ emptyMessage = "AI provider returned empty tool call",
116
+ }) => {
117
+ const response =
118
+ config.provider === "anthropic"
119
+ ? await fetch(`${config.baseUrl}/messages`, {
120
+ method: "POST",
121
+ headers: {
122
+ "content-type": "application/json",
123
+ "x-api-key": config.apiKey,
124
+ "anthropic-version": "2023-06-01",
125
+ },
126
+ body: JSON.stringify({
127
+ model: config.model,
128
+ max_tokens: 4096,
129
+ temperature: 0,
130
+ system,
131
+ messages: [{ role: "user", content: user }],
132
+ tools: [
133
+ {
134
+ name: toolName,
135
+ description: toolDescription,
136
+ input_schema: parameters,
137
+ },
138
+ ],
139
+ tool_choice: { type: "tool", name: toolName },
140
+ }),
141
+ })
142
+ : await fetch(`${config.baseUrl}/responses`, {
143
+ method: "POST",
144
+ headers: {
145
+ "content-type": "application/json",
146
+ authorization: `Bearer ${config.apiKey}`,
147
+ },
148
+ body: JSON.stringify({
149
+ model: config.model,
150
+ input: [
151
+ { role: "system", content: system },
152
+ { role: "user", content: user },
153
+ ],
154
+ tools: [
155
+ {
156
+ type: "function",
157
+ name: toolName,
158
+ description: toolDescription,
159
+ strict: true,
160
+ parameters,
161
+ },
162
+ ],
163
+ tool_choice: { type: "function", name: toolName },
164
+ parallel_tool_calls: false,
165
+ temperature: 0,
166
+ }),
167
+ });
168
+
169
+ if (!response.ok) {
170
+ throw new Error(`${failureMessage}: ${response.status}`);
171
+ }
172
+ return config.provider === "anthropic"
173
+ ? parseAnthropicJson({ response, toolName, emptyMessage })
174
+ : parseOpenAiStrictToolJson({ response, toolName, emptyMessage });
175
+ };
176
+
87
177
  export const callJsonAiProvider = async ({
88
178
  config,
89
179
  system,
@@ -11,6 +11,7 @@ const ROUTE_DECORATOR = new RegExp(
11
11
  const API_ROUTE_DECORATOR = /@(?<router>[A-Za-z_][A-Za-z0-9_]*)\.api_route\(\s*["'](?<path>[^"']+)["'][\s\S]*?methods\s*=\s*\[(?<methods>[^\]]+)\]/gi;
12
12
  const FUNCTION_PATTERN = /(?:async\s+def|def)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
13
13
  const ROUTER_ASSIGNMENT = /\b(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*APIRouter\s*\(/g;
14
+ const ROUTER_ALIAS_ASSIGNMENT = /^\s*(?<alias>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?<owner>[A-Za-z_][A-Za-z0-9_]*)\.(?<routerName>[A-Za-z_][A-Za-z0-9_]*)\s*$/gm;
14
15
  const INCLUDE_ROUTER_CALL = /\b(?<owner>[A-Za-z_][A-Za-z0-9_]*)\.include_router\s*\(/g;
15
16
  const FROM_IMPORT = /^\s*from\s+(?<module>[A-Za-z0-9_.]+|\.+[A-Za-z0-9_.]*)\s+import\s+(?<names>[A-Za-z0-9_,\s]+)$/gm;
16
17
  const IMPORT_MODULE = /^\s*import\s+(?<modules>[A-Za-z0-9_.,\s]+)$/gm;
@@ -214,9 +215,35 @@ const parseImports = ({ source, currentRelativePath, filesByRelativePath }) => {
214
215
 
215
216
  const routerKey = (file, routerName) => `${file}::${routerName}`;
216
217
 
217
- const resolveRouterTarget = ({ target, relativePath, imports }) => {
218
+ const parseRouterAliases = ({ source, relativePath, imports }) => {
219
+ const aliases = new Map();
220
+ for (const match of source.matchAll(ROUTER_ALIAS_ASSIGNMENT)) {
221
+ const importedOwner = imports.get(match.groups.owner);
222
+ if (importedOwner?.file && !importedOwner.name) {
223
+ aliases.set(match.groups.alias, {
224
+ file: importedOwner.file,
225
+ name: match.groups.routerName,
226
+ });
227
+ continue;
228
+ }
229
+ aliases.set(match.groups.alias, {
230
+ file: relativePath,
231
+ name: match.groups.routerName,
232
+ });
233
+ }
234
+ return aliases;
235
+ };
236
+
237
+ const resolveRouterTarget = ({ target, relativePath, imports, routerAliases }) => {
218
238
  const parts = target.split(".");
219
239
  if (parts.length === 1) {
240
+ const aliased = routerAliases.get(target);
241
+ if (aliased) {
242
+ return {
243
+ file: aliased.file,
244
+ routerName: aliased.name,
245
+ };
246
+ }
220
247
  const imported = imports.get(target);
221
248
  return {
222
249
  file: imported?.file ?? relativePath,
@@ -236,7 +263,7 @@ const resolveRouterTarget = ({ target, relativePath, imports }) => {
236
263
  };
237
264
  };
238
265
 
239
- const parseIncludeRouters = ({ source, relativePath, imports }) => {
266
+ const parseIncludeRouters = ({ source, relativePath, imports, routerAliases }) => {
240
267
  const includes = [];
241
268
  for (const match of source.matchAll(INCLUDE_ROUTER_CALL)) {
242
269
  const openParenIndex = match.index + match[0].length - 1;
@@ -249,6 +276,7 @@ const parseIncludeRouters = ({ source, relativePath, imports }) => {
249
276
  target: routerMatch.groups.router,
250
277
  relativePath,
251
278
  imports,
279
+ routerAliases,
252
280
  });
253
281
  includes.push({
254
282
  ownerName: match.groups.owner,
@@ -359,6 +387,11 @@ export const analyzeFastApiRoutes = async ({ repoRoot, files }) => {
359
387
  currentRelativePath: record.relativePath,
360
388
  filesByRelativePath,
361
389
  });
390
+ record.routerAliases = parseRouterAliases({
391
+ source: record.source,
392
+ relativePath: record.relativePath,
393
+ imports: record.imports,
394
+ });
362
395
  }
363
396
 
364
397
  const routerPrefixesByKey = new Map();
@@ -375,6 +408,7 @@ export const analyzeFastApiRoutes = async ({ repoRoot, files }) => {
375
408
  source: record.source,
376
409
  relativePath: record.relativePath,
377
410
  imports: record.imports,
411
+ routerAliases: record.routerAliases,
378
412
  })) {
379
413
  const parentKey = record.routerPrefixes.has(include.ownerName)
380
414
  ? routerKey(record.relativePath, include.ownerName)
@@ -1,6 +1,10 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { callJsonAiProvider, resolveAiProviderConfig } from "./ai-provider.mjs";
3
+ import {
4
+ callJsonAiProvider,
5
+ callStrictToolAiProvider,
6
+ resolveAiProviderConfig,
7
+ } from "./ai-provider.mjs";
4
8
  import {
5
9
  extractExecutableModuleStatements,
6
10
  findLifecycleCallApiNames,
@@ -22,6 +26,13 @@ const API_NAMES = new Set([
22
26
  ]);
23
27
 
24
28
  const SOURCE_EXTENSIONS = [".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
29
+ const DEPENDENCY_CONTEXT_FILE_CANDIDATES = [
30
+ "package.json",
31
+ "requirements.txt",
32
+ "requirements-dev.txt",
33
+ "pyproject.toml",
34
+ "Pipfile",
35
+ ];
25
36
  const MAX_CONTEXT_FILES = 180;
26
37
  const MAX_FILE_CHARS = 12_000;
27
38
  const MAX_TOTAL_CHARS = 360_000;
@@ -29,6 +40,71 @@ const FRONTEND_SDK_PACKAGE = "@clue-ai/browser-sdk";
29
40
  const WRONG_FRONTEND_SDK_PACKAGES = ["clue-js-sdk", "@clue/browser-sdk"];
30
41
  const CLUE_SETUP_ADDITION_PATTERN =
31
42
  /\b(?:ClueInit|ClueIdentify|ClueSetAccount|ClueLogout|clue_init_fastapi|clue_init_django|clue-fastapi-sdk|clue-django-sdk|CLUE_[A-Z0-9_]+|browserTokenProvider|clue[-_])|@clue-ai\/browser-sdk/i;
43
+ const LIFECYCLE_PLAN_TOOL_SCHEMA = {
44
+ type: "object",
45
+ properties: {
46
+ status: { type: "string", enum: ["ready", "blocked"] },
47
+ edits: {
48
+ type: "array",
49
+ items: {
50
+ type: "object",
51
+ properties: {
52
+ file_path: { type: "string" },
53
+ find: { type: "string" },
54
+ replace: { type: "string" },
55
+ },
56
+ required: ["file_path", "find", "replace"],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ lifecycle_insertions: {
61
+ type: "array",
62
+ items: {
63
+ type: "object",
64
+ properties: {
65
+ api_name: {
66
+ type: "string",
67
+ enum: [
68
+ "ClueInit",
69
+ "ClueIdentify",
70
+ "ClueSetAccount",
71
+ "ClueLogout",
72
+ ],
73
+ },
74
+ file_path: { type: "string" },
75
+ confidence: { type: "number" },
76
+ reason: { type: "string" },
77
+ },
78
+ required: ["api_name", "file_path", "confidence", "reason"],
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ blockers: {
83
+ type: "array",
84
+ items: {
85
+ type: "object",
86
+ properties: {
87
+ reason: { type: "string" },
88
+ evidence: { type: ["string", "null"] },
89
+ },
90
+ required: ["reason", "evidence"],
91
+ additionalProperties: false,
92
+ },
93
+ },
94
+ warnings: {
95
+ type: "array",
96
+ items: { type: "string" },
97
+ },
98
+ },
99
+ required: [
100
+ "status",
101
+ "edits",
102
+ "lifecycle_insertions",
103
+ "blockers",
104
+ "warnings",
105
+ ],
106
+ additionalProperties: false,
107
+ };
32
108
 
33
109
  const nonEmpty = (value, field) => {
34
110
  if (typeof value !== "string" || value.trim() === "") {
@@ -47,6 +123,33 @@ const safeRelativePath = (repoRoot, filePath) => {
47
123
  return { absolutePath, relativePath };
48
124
  };
49
125
 
126
+ const startsWithAllowedPath = (filePath, allowedPath) => {
127
+ const normalizedFilePath = filePath.replace(/\\/g, "/").replace(/^\/+/, "");
128
+ const normalizedAllowedPath = String(allowedPath ?? "")
129
+ .replace(/\\/g, "/")
130
+ .replace(/^\/+/, "")
131
+ .replace(/\/+$/, "");
132
+ return (
133
+ normalizedAllowedPath !== "" &&
134
+ (normalizedFilePath === normalizedAllowedPath ||
135
+ normalizedFilePath.startsWith(`${normalizedAllowedPath}/`))
136
+ );
137
+ };
138
+
139
+ const assertAllowedWritePath = ({ allowedWritePaths, filePath }) => {
140
+ if (!Array.isArray(allowedWritePaths) || allowedWritePaths.length === 0) {
141
+ return;
142
+ }
143
+ if (
144
+ allowedWritePaths.some((allowedPath) =>
145
+ startsWithAllowedPath(filePath, allowedPath),
146
+ )
147
+ ) {
148
+ return;
149
+ }
150
+ throw new Error(`setup-agent cannot edit outside allowed setup paths: ${filePath}`);
151
+ };
152
+
50
153
  const assertApiName = (apiName) => {
51
154
  if (!API_NAMES.has(apiName)) {
52
155
  throw new Error(`unsupported lifecycle API: ${apiName}`);
@@ -458,7 +561,11 @@ const buildLifecycleEvidenceSourceMap = async ({
458
561
  return sourceByPath;
459
562
  };
460
563
 
461
- export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
564
+ export const applyLifecyclePlan = async ({
565
+ repoRoot,
566
+ plan: rawPlan,
567
+ allowedWritePaths,
568
+ }) => {
462
569
  const plan = normalizePlan(rawPlan);
463
570
  if (plan.status === "blocked") {
464
571
  throw new Error(
@@ -478,6 +585,7 @@ export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
478
585
  repoRoot,
479
586
  edit.file_path,
480
587
  );
588
+ assertAllowedWritePath({ allowedWritePaths, filePath: relativePath });
481
589
  assertNoForbiddenInstrumentation(edit.replace);
482
590
  const current =
483
591
  sourceByPath.get(relativePath)?.text ??
@@ -499,6 +607,10 @@ export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
499
607
  }
500
608
  await buildLifecycleEvidenceSourceMap({ repoRoot, plan, sourceByPath });
501
609
  for (const insertion of plan.lifecycleInsertions) {
610
+ assertAllowedWritePath({
611
+ allowedWritePaths,
612
+ filePath: insertion.file_path,
613
+ });
502
614
  const source = sourceByPath.get(insertion.file_path);
503
615
  if (!source) {
504
616
  throw new Error(
@@ -520,6 +632,42 @@ export const applyLifecyclePlan = async ({ repoRoot, plan: rawPlan }) => {
520
632
  };
521
633
  };
522
634
 
635
+ const dependencyContextCandidatePaths = (allowedSourcePaths) => [
636
+ ...DEPENDENCY_CONTEXT_FILE_CANDIDATES,
637
+ ...allowedSourcePaths.flatMap((root) => {
638
+ const normalized = root.replace(/\/+$/, "");
639
+ const roots =
640
+ normalized.endsWith("/src") || normalized.endsWith("/app")
641
+ ? [normalized, dirname(normalized)]
642
+ : [normalized];
643
+ return roots.flatMap((candidateRoot) =>
644
+ DEPENDENCY_CONTEXT_FILE_CANDIDATES.map((file) =>
645
+ join(candidateRoot, file),
646
+ ),
647
+ );
648
+ }),
649
+ ];
650
+
651
+ const addContextFile = async ({
652
+ absolutePath,
653
+ context,
654
+ repoRoot,
655
+ totalChars,
656
+ }) => {
657
+ const text = await readFile(absolutePath, "utf8");
658
+ const snippet = text.slice(0, MAX_FILE_CHARS);
659
+ const nextTotalChars = totalChars + snippet.length;
660
+ if (nextTotalChars > MAX_TOTAL_CHARS) {
661
+ return { totalChars, added: false };
662
+ }
663
+ context.push({
664
+ file_path: relative(resolve(repoRoot), absolutePath),
665
+ source: snippet,
666
+ truncated: text.length > snippet.length,
667
+ });
668
+ return { totalChars: nextTotalChars, added: true };
669
+ };
670
+
523
671
  const readContextFiles = async ({ repoRoot, request }) => {
524
672
  const files = await listAllowedSourceFiles({
525
673
  repoRoot,
@@ -530,17 +678,38 @@ const readContextFiles = async ({ repoRoot, request }) => {
530
678
  const context = [];
531
679
  let totalChars = 0;
532
680
  for (const absolutePath of files.slice(0, MAX_CONTEXT_FILES)) {
533
- const text = await readFile(absolutePath, "utf8");
534
- const snippet = text.slice(0, MAX_FILE_CHARS);
535
- totalChars += snippet.length;
536
- if (totalChars > MAX_TOTAL_CHARS) {
681
+ const result = await addContextFile({
682
+ absolutePath,
683
+ context,
684
+ repoRoot,
685
+ totalChars,
686
+ });
687
+ totalChars = result.totalChars;
688
+ if (!result.added) {
537
689
  break;
538
690
  }
539
- context.push({
540
- file_path: relative(resolve(repoRoot), absolutePath),
541
- source: snippet,
542
- truncated: text.length > snippet.length,
543
- });
691
+ }
692
+ const seen = new Set(context.map((entry) => entry.file_path));
693
+ for (const candidatePath of dependencyContextCandidatePaths(
694
+ request.allowed_source_paths,
695
+ )) {
696
+ const { absolutePath, relativePath } = safeRelativePath(
697
+ repoRoot,
698
+ candidatePath,
699
+ );
700
+ if (seen.has(relativePath)) continue;
701
+ try {
702
+ const result = await addContextFile({
703
+ absolutePath,
704
+ context,
705
+ repoRoot,
706
+ totalChars,
707
+ });
708
+ totalChars = result.totalChars;
709
+ if (result.added) seen.add(relativePath);
710
+ } catch (error) {
711
+ if (error?.code !== "ENOENT") throw error;
712
+ }
544
713
  }
545
714
  return context;
546
715
  };
@@ -567,8 +736,8 @@ const buildLifecyclePrompt = ({ request, files }) =>
567
736
  "Find all clear account, workspace, organization, or tenant resolution paths and add ClueSetAccount to every one of them.",
568
737
  "Find all clear logout or session reset paths and add ClueLogout to every one of them.",
569
738
  "Inspect backend lifecycle points as carefully as frontend lifecycle points. Backend login/session/account code is especially important.",
570
- "For frontend code, add or use the real @clue-ai/browser-sdk dependency. Do not invent clue-js-sdk, @clue/browser-sdk, local SDK modules, global window.Clue APIs, or dynamic imports that hide a missing SDK.",
571
- "For FastAPI backends, add the clue-fastapi-sdk dependency when missing, import clue_init_fastapi plus ClueIdentify/ClueSetAccount/ClueLogout where needed, and initialize the SDK at FastAPI app creation.",
739
+ "For frontend code, add or use the real @clue-ai/browser-sdk dependency through the latest channel (@clue-ai/browser-sdk@latest). Do not invent clue-js-sdk, @clue/browser-sdk, local SDK modules, global window.Clue APIs, or dynamic imports that hide a missing SDK.",
740
+ "For FastAPI backends, add the unpinned clue-fastapi-sdk dependency when missing so pip resolves the latest release, import clue_init_fastapi plus ClueIdentify/ClueSetAccount/ClueLogout where needed, and initialize the SDK at FastAPI app creation.",
572
741
  "For Django backends, use clue-django-sdk only after dependency or registry verification confirms it is installable. If it cannot be verified, report a blocker instead of adding guessed imports or dependencies.",
573
742
  "For other backend frameworks, treat SDK existence as unverified unless present in dependency files or verified through package-manager/official documentation. If no backend SDK exists or verification is impossible, report a blocker instead of silently frontend-only setup.",
574
743
  "Do not add broad ClueTrack instrumentation.",
@@ -598,7 +767,7 @@ const buildLifecyclePrompt = ({ request, files }) =>
598
767
  "The backend browser token proxy must derive request origin from trusted request headers or server request metadata. Do not trust origin, projectKey, or environment from JSON/body payload fields when calling Clue with server CLUE_API_KEY.",
599
768
  "For browser token proxy code, the service key sent to Clue must be the frontend ClueInit serviceKey from the browser request, not the backend service's CLUE_SERVICE_KEY.",
600
769
  "If a backend-owned browser token endpoint is implemented, read CLUE_API_BASE_URL from the backend env block and normalize it so values with or without a trailing /api/v1 do not produce duplicate paths.",
601
- "Do not add @clue-ai/browser-sdk or backend SDK dependencies with * or latest; use a concrete published version or package-manager-resolved semver range and update the repository lockfile when one exists.",
770
+ "Install Clue SDK dependencies through the latest channel. Frontend package managers must use @clue-ai/browser-sdk@latest; Python backend dependency declarations must not pin clue-fastapi-sdk or clue-django-sdk to a fixed version.",
602
771
  "Prefer minimal edits that engineers can review in one PR.",
603
772
  "Do not run broad formatters, import sorters, cleanup tools, or style-only edits. Whitespace-only changes are allowed only on lines directly changed for Clue SDK wiring.",
604
773
  "If a lifecycle point is unclear, skip that edit and include a warning.",
@@ -667,3 +836,45 @@ export const planLifecycleInsertions = async ({ repoRoot, request, env }) => {
667
836
  emptyMessage: "AI provider returned empty lifecycle plan",
668
837
  });
669
838
  };
839
+
840
+ export const planLifecycleInsertionsStrict = async ({
841
+ repoRoot,
842
+ request,
843
+ env,
844
+ failureContext = null,
845
+ }) => {
846
+ const apiKey = env.CLUE_AI_PROVIDER_API_KEY;
847
+ if (!apiKey) {
848
+ throw new Error(
849
+ "CLUE_AI_PROVIDER_API_KEY is required for setup-agent lifecycle planning",
850
+ );
851
+ }
852
+ const setupAgentEnv = {
853
+ ...env,
854
+ CLUE_AI_PROVIDER_BASE_URL: undefined,
855
+ };
856
+ const files = await readContextFiles({ repoRoot, request });
857
+ const prompt = JSON.parse(buildLifecyclePrompt({ request, files }));
858
+ const user = JSON.stringify({
859
+ ...prompt,
860
+ setup_agent_mode: {
861
+ execution_owner: "clue_cli",
862
+ model_role: "lifecycle_plan_only",
863
+ direct_file_writes_allowed: false,
864
+ setup_watch_allowed: false,
865
+ retry_failure_context: failureContext,
866
+ },
867
+ });
868
+ return callStrictToolAiProvider({
869
+ config: resolveAiProviderConfig({ env: setupAgentEnv, apiKey }),
870
+ system:
871
+ "You are a safe code-edit planner for Clue SDK setup. Call the provided function with a schema-valid lifecycle plan only.",
872
+ user,
873
+ toolName: "propose_clue_lifecycle_plan",
874
+ toolDescription:
875
+ "Return the exact Clue SDK lifecycle insertion plan for the local CLI to validate and apply.",
876
+ parameters: LIFECYCLE_PLAN_TOOL_SCHEMA,
877
+ failureMessage: "AI provider failed during setup-agent lifecycle planning",
878
+ emptyMessage: "AI provider returned empty setup-agent lifecycle plan",
879
+ });
880
+ };
@@ -185,6 +185,7 @@ const semanticSnapshotPurposeChangeStateValues = [
185
185
  const semanticSnapshotActiveSemanticSourceValues = [
186
186
  "previous_reused",
187
187
  "new_confirmed",
188
+ "new_unconfirmed",
188
189
  "previous_kept_pending_review",
189
190
  ];
190
191
  const targetObjectKeySchema = nonEmptyStringSchema.regex(snakeSegmentPattern, {