@clue-ai/cli 0.0.23 → 0.0.24

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
@@ -928,9 +928,24 @@ const renderSetupResult = ({ preparation }) => {
928
928
 
929
929
  const renderSetupAgentResult = (report) => {
930
930
  if (report?.status === "user_verification_pending") {
931
+ const setupDoctorPassed = report.setup_doctor?.status === "passed";
932
+ const setupDoctorMissingInputs = Array.isArray(
933
+ report.setup_doctor?.missing_inputs,
934
+ )
935
+ ? report.setup_doctor.missing_inputs
936
+ : [];
937
+ const setupDoctorLine =
938
+ setupDoctorPassed
939
+ ? "setup-doctor のAPI疎通確認は通過しました。"
940
+ : setupDoctorMissingInputs.length > 0
941
+ ? `setup-doctor のAPI疎通確認は未実行です。不足: ${setupDoctorMissingInputs.join(", ")}`
942
+ : "setup-doctor のAPI疎通確認は未実行です。local services 起動後に確認してください。";
931
943
  return [
932
- "Clue setup-agent の実行が完了しました。",
944
+ setupDoctorPassed
945
+ ? "Clue setup-agent の実行が完了しました。"
946
+ : "Clue setup-agent の静的実装が完了しました。",
933
947
  "",
948
+ setupDoctorLine,
934
949
  "コード差分を確認してください。",
935
950
  `次の動作確認: ${clueCliCommand("setup-watch --local")}`,
936
951
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clue-ai/cli",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clue-ai": "bin/clue-cli.mjs"
@@ -79,6 +79,58 @@ const parseOpenAiStrictToolJson = async ({ response, toolName, emptyMessage }) =
79
79
  throw new Error(emptyMessage);
80
80
  };
81
81
 
82
+ const providerErrorDetailFromBody = (body) => {
83
+ if (!body) return "";
84
+ try {
85
+ const parsed = JSON.parse(body);
86
+ const message =
87
+ parsed?.error?.message ??
88
+ parsed?.message ??
89
+ parsed?.error ??
90
+ parsed?.detail ??
91
+ null;
92
+ if (typeof message === "string" && message.trim()) {
93
+ return message.trim();
94
+ }
95
+ } catch {
96
+ // The provider may return plain text, HTML, or an empty body.
97
+ }
98
+ return body.trim();
99
+ };
100
+
101
+ const redactedProviderErrorDetail = ({ detail, apiKey }) => {
102
+ const redacted = String(detail)
103
+ .replaceAll(apiKey, "[redacted]")
104
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[redacted]")
105
+ .replace(/\bsk-ant-[A-Za-z0-9_-]{8,}\b/g, "[redacted]");
106
+ return redacted.length > 600 ? `${redacted.slice(0, 600)}...` : redacted;
107
+ };
108
+
109
+ const providerHttpError = async ({ response, failureMessage, apiKey }) => {
110
+ let detail = "";
111
+ try {
112
+ const body =
113
+ typeof response.text === "function"
114
+ ? await response.text()
115
+ : typeof response.json === "function"
116
+ ? JSON.stringify(await response.json())
117
+ : "";
118
+ detail = redactedProviderErrorDetail({
119
+ detail: providerErrorDetailFromBody(body),
120
+ apiKey,
121
+ });
122
+ } catch {
123
+ detail = "";
124
+ }
125
+ const statusText =
126
+ typeof response.statusText === "string" && response.statusText.trim()
127
+ ? ` ${response.statusText.trim()}`
128
+ : "";
129
+ return new Error(
130
+ `${failureMessage}: ${response.status}${statusText}${detail ? ` - ${detail}` : ""}`,
131
+ );
132
+ };
133
+
82
134
  const parseAnthropicJson = async ({ response, toolName, emptyMessage }) => {
83
135
  const body = await response.json();
84
136
  const toolUse = Array.isArray(body?.content)
@@ -162,12 +214,15 @@ export const callStrictToolAiProvider = async ({
162
214
  ],
163
215
  tool_choice: { type: "function", name: toolName },
164
216
  parallel_tool_calls: false,
165
- temperature: 0,
166
217
  }),
167
218
  });
168
219
 
169
220
  if (!response.ok) {
170
- throw new Error(`${failureMessage}: ${response.status}`);
221
+ throw await providerHttpError({
222
+ response,
223
+ failureMessage,
224
+ apiKey: config.apiKey,
225
+ });
171
226
  }
172
227
  return config.provider === "anthropic"
173
228
  ? parseAnthropicJson({ response, toolName, emptyMessage })
@@ -219,7 +274,6 @@ export const callJsonAiProvider = async ({
219
274
  },
220
275
  body: JSON.stringify({
221
276
  model: config.model,
222
- temperature: 0,
223
277
  messages: [
224
278
  { role: "system", content: system },
225
279
  { role: "user", content: user },
@@ -229,7 +283,11 @@ export const callJsonAiProvider = async ({
229
283
  });
230
284
 
231
285
  if (!response.ok) {
232
- throw new Error(`${failureMessage}: ${response.status}`);
286
+ throw await providerHttpError({
287
+ response,
288
+ failureMessage,
289
+ apiKey: config.apiKey,
290
+ });
233
291
  }
234
292
  return config.provider === "anthropic"
235
293
  ? parseAnthropicJson({ response, toolName, emptyMessage })
@@ -40,7 +40,11 @@ const MAX_TOTAL_CHARS = 360_000;
40
40
  const FRONTEND_SDK_PACKAGE = "@clue-ai/browser-sdk";
41
41
  const WRONG_FRONTEND_SDK_PACKAGES = ["clue-js-sdk", "@clue/browser-sdk"];
42
42
  const CLUE_SETUP_ADDITION_PATTERN =
43
- /\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
+ /\b(?:Clue[A-Za-z0-9_]*|clue_init_fastapi|clue_init_django|clue-fastapi-sdk|clue-django-sdk|CLUE_[A-Z0-9_]+|browserTokenProvider|clue[-_])|@clue-ai\/browser-sdk/i;
44
+ const CLUE_SETUP_SUPPORT_IMPORT_PATTERN =
45
+ /^\s*(?:import\s+os|from\s+os\s+import\s+(?:getenv|environ)|import\s+json|from\s+urllib(?:\.error)?\s+import\s+[A-Za-z_,\s]+|from\s+fastapi\s+import\s+.*\b(?:HTTPException|Request)\b.*|from\s+pydantic\s+import\s+BaseModel)\s*$/m;
46
+ const CLUE_SETUP_SUPPORT_ADDITION_PATTERN =
47
+ /\b(?:os|getenv|environ|json|urllib_request|HTTPError|URLError|HTTPException|Request|BaseModel)\b/;
44
48
  const LIFECYCLE_PLAN_TOOL_SCHEMA = {
45
49
  type: "object",
46
50
  properties: {
@@ -70,6 +74,20 @@ const LIFECYCLE_PLAN_TOOL_SCHEMA = {
70
74
  additionalProperties: false,
71
75
  },
72
76
  },
77
+ insertions: {
78
+ type: "array",
79
+ items: {
80
+ type: "object",
81
+ properties: {
82
+ file_path: { type: "string" },
83
+ anchor: { type: "string" },
84
+ position: { type: "string", enum: ["before", "after"] },
85
+ content: { type: "string" },
86
+ },
87
+ required: ["file_path", "anchor", "position", "content"],
88
+ additionalProperties: false,
89
+ },
90
+ },
73
91
  lifecycle_insertions: {
74
92
  type: "array",
75
93
  items: {
@@ -98,7 +116,7 @@ const LIFECYCLE_PLAN_TOOL_SCHEMA = {
98
116
  type: "object",
99
117
  properties: {
100
118
  reason: { type: "string" },
101
- evidence: { type: ["string", "null"] },
119
+ evidence: { type: "string" },
102
120
  },
103
121
  required: ["reason", "evidence"],
104
122
  additionalProperties: false,
@@ -113,6 +131,7 @@ const LIFECYCLE_PLAN_TOOL_SCHEMA = {
113
131
  "status",
114
132
  "edits",
115
133
  "file_creations",
134
+ "insertions",
116
135
  "lifecycle_insertions",
117
136
  "blockers",
118
137
  "warnings",
@@ -127,6 +146,13 @@ const nonEmpty = (value, field) => {
127
146
  return value.trim();
128
147
  };
129
148
 
149
+ const nonEmptyRaw = (value, field) => {
150
+ if (typeof value !== "string" || value.trim() === "") {
151
+ throw new Error(`${field} is required`);
152
+ }
153
+ return value;
154
+ };
155
+
130
156
  const safeRelativePath = (repoRoot, filePath) => {
131
157
  const root = resolve(repoRoot);
132
158
  const absolutePath = resolve(root, filePath);
@@ -197,6 +223,18 @@ const assertLifecycleCallsAreNonBlocking = (replacement) => {
197
223
  }
198
224
  };
199
225
 
226
+ const assertNoRepeatedFrontendClueInit = (content) => {
227
+ if (
228
+ /useEffect\s*\([\s\S]{0,1200}ClueInit/.test(
229
+ stripSourceNoise(content, { stripStrings: true }),
230
+ )
231
+ ) {
232
+ throw new Error(
233
+ "ClueInit must not run inside React component lifecycle hooks; use a module-level client singleton or SDK adapter and import it from the app bootstrap",
234
+ );
235
+ }
236
+ };
237
+
200
238
  const assertClueSetupOnlyEdit = (edit) => {
201
239
  if (!edit.replace.includes(edit.find)) {
202
240
  throw new Error(
@@ -204,7 +242,10 @@ const assertClueSetupOnlyEdit = (edit) => {
204
242
  );
205
243
  }
206
244
  const addedText = edit.replace.split(edit.find).join("");
207
- if (!CLUE_SETUP_ADDITION_PATTERN.test(addedText)) {
245
+ if (
246
+ !CLUE_SETUP_ADDITION_PATTERN.test(addedText) &&
247
+ !CLUE_SETUP_SUPPORT_ADDITION_PATTERN.test(addedText)
248
+ ) {
208
249
  throw new Error(
209
250
  `Clue lifecycle edits must add only Clue setup related code in ${edit.file_path}`,
210
251
  );
@@ -219,6 +260,21 @@ const assertClueSetupOnlyFileCreation = (fileCreation) => {
219
260
  }
220
261
  assertNoForbiddenInstrumentation(fileCreation.content);
221
262
  assertLifecycleCallsAreNonBlocking(fileCreation.content);
263
+ assertNoRepeatedFrontendClueInit(fileCreation.content);
264
+ };
265
+
266
+ const assertClueSetupOnlyInsertion = (insertion) => {
267
+ if (
268
+ !CLUE_SETUP_ADDITION_PATTERN.test(insertion.content) &&
269
+ !CLUE_SETUP_SUPPORT_IMPORT_PATTERN.test(insertion.content)
270
+ ) {
271
+ throw new Error(
272
+ `Clue lifecycle insertions must contain only Clue setup related code in ${insertion.file_path}`,
273
+ );
274
+ }
275
+ assertNoForbiddenInstrumentation(insertion.content);
276
+ assertLifecycleCallsAreNonBlocking(insertion.content);
277
+ assertNoRepeatedFrontendClueInit(insertion.content);
222
278
  };
223
279
 
224
280
  const normalizeLifecycleInsertion = (input) => ({
@@ -261,13 +317,24 @@ const normalizePlan = (input) => {
261
317
  }
262
318
  const edits = input.edits.map((edit) => ({
263
319
  file_path: nonEmpty(edit.file_path, "edit.file_path"),
264
- find: nonEmpty(edit.find, "edit.find"),
265
- replace: nonEmpty(edit.replace, "edit.replace"),
320
+ find: nonEmptyRaw(edit.find, "edit.find"),
321
+ replace: nonEmptyRaw(edit.replace, "edit.replace"),
266
322
  }));
267
323
  const fileCreations = Array.isArray(input.file_creations)
268
324
  ? input.file_creations.map((fileCreation) => ({
269
325
  file_path: nonEmpty(fileCreation.file_path, "file_creation.file_path"),
270
- content: nonEmpty(fileCreation.content, "file_creation.content"),
326
+ content: nonEmptyRaw(fileCreation.content, "file_creation.content"),
327
+ }))
328
+ : [];
329
+ const insertions = Array.isArray(input.insertions)
330
+ ? input.insertions.map((insertion) => ({
331
+ file_path: nonEmpty(insertion.file_path, "insertion.file_path"),
332
+ anchor: nonEmptyRaw(insertion.anchor, "insertion.anchor"),
333
+ position:
334
+ insertion.position === "before" || insertion.position === "after"
335
+ ? insertion.position
336
+ : "after",
337
+ content: nonEmptyRaw(insertion.content, "insertion.content"),
271
338
  }))
272
339
  : [];
273
340
  const lifecycleInsertions = Array.isArray(input.lifecycle_insertions)
@@ -280,6 +347,7 @@ const normalizePlan = (input) => {
280
347
  if (
281
348
  edits.length > 0 ||
282
349
  fileCreations.length > 0 ||
350
+ insertions.length > 0 ||
283
351
  lifecycleInsertions.length > 0
284
352
  ) {
285
353
  throw new Error("blocked lifecycle plan must not include edits");
@@ -295,6 +363,7 @@ const normalizePlan = (input) => {
295
363
  status,
296
364
  edits,
297
365
  fileCreations,
366
+ insertions,
298
367
  lifecycleInsertions,
299
368
  blockers,
300
369
  warnings: Array.isArray(input.warnings)
@@ -570,6 +639,7 @@ const buildLifecycleEvidenceSourceMap = async ({
570
639
  ...new Set([
571
640
  ...plan.edits.map((edit) => edit.file_path),
572
641
  ...plan.fileCreations.map((fileCreation) => fileCreation.file_path),
642
+ ...plan.insertions.map((insertion) => insertion.file_path),
573
643
  ...plan.lifecycleInsertions.map((insertion) => insertion.file_path),
574
644
  ]),
575
645
  ]) {
@@ -638,6 +708,33 @@ export const applyLifecyclePlan = async ({
638
708
  text: fileCreation.content,
639
709
  });
640
710
  }
711
+ for (const insertion of plan.insertions) {
712
+ const { absolutePath, relativePath } = safeRelativePath(
713
+ repoRoot,
714
+ insertion.file_path,
715
+ );
716
+ assertAllowedWritePath({ allowedWritePaths, filePath: relativePath });
717
+ assertClueSetupOnlyInsertion(insertion);
718
+ const current =
719
+ sourceByPath.get(relativePath)?.text ??
720
+ (await readFile(absolutePath, "utf8"));
721
+ const occurrences = current.split(insertion.anchor).length - 1;
722
+ if (occurrences !== 1) {
723
+ throw new Error(
724
+ `insertion.anchor must match exactly once in ${insertion.file_path}; matched ${occurrences}`,
725
+ );
726
+ }
727
+ const replacement =
728
+ insertion.position === "before"
729
+ ? `${insertion.content}${insertion.anchor}`
730
+ : `${insertion.anchor}${insertion.content}`;
731
+ const next = current.replace(insertion.anchor, replacement);
732
+ sourceByPath.set(relativePath, {
733
+ file_path: relativePath,
734
+ text: next,
735
+ });
736
+ pendingWrites.set(relativePath, { absolutePath, text: next });
737
+ }
641
738
  for (const edit of plan.edits) {
642
739
  const { absolutePath, relativePath } = safeRelativePath(
643
740
  repoRoot,
@@ -656,6 +753,7 @@ export const applyLifecyclePlan = async ({
656
753
  }
657
754
  assertClueSetupOnlyEdit(edit);
658
755
  assertLifecycleCallsAreNonBlocking(edit.replace);
756
+ assertNoRepeatedFrontendClueInit(edit.replace);
659
757
  const next = current.replace(edit.find, edit.replace);
660
758
  sourceByPath.set(relativePath, {
661
759
  file_path: relativePath,
@@ -689,6 +787,10 @@ export const applyLifecyclePlan = async ({
689
787
  fileCreations: plan.fileCreations.map((fileCreation) => ({
690
788
  file_path: fileCreation.file_path,
691
789
  })),
790
+ insertions: plan.insertions.map((insertion) => ({
791
+ file_path: insertion.file_path,
792
+ position: insertion.position,
793
+ })),
692
794
  lifecycleInsertions: plan.lifecycleInsertions,
693
795
  warnings: plan.warnings,
694
796
  };
@@ -788,8 +890,11 @@ const buildLifecyclePrompt = ({ request, files }) =>
788
890
  "Understand the setup doctrine before choosing edits. This is an external Clue integration, not a host app refactor or redesign task.",
789
891
  "Use AI judgment only for lifecycle boundary placement. Let the CLI, generated skills, documentation contract, lifecycle plan schema, and setup-check static guards control deterministic mechanics.",
790
892
  "Treat official_sdk_contract as verified Clue setup input from the CLI. Do not block merely because the customer repo does not already contain Clue SDK imports, dependencies, browserTokenProvider wiring, or a Clue adapter.",
791
- "If SDK signatures, environment names, browser token behavior, package installability, or verification ownership remain unclear after applying official_sdk_contract, return status blocked instead of guessing.",
893
+ "For supported FastAPI and Next.js setup paths, do not block merely because the customer repo starts without Clue code or because one non-critical lifecycle point is unclear. Complete safe minimal Clue wiring, skip unclear lifecycle points with warnings, and let setup-check drive retries.",
894
+ "Return status blocked only when the official SDK contract does not cover the detected framework, required AI/provider setup is missing, no safe bootstrap or route insertion can be found, or applying the setup would require guessing outside Clue setup scope.",
792
895
  "Use only exact replacements. Each find string must be copied exactly from source.",
896
+ "Prefer insertions for adding imports, SDK initialization, lifecycle calls, route registration, and frontend provider mounts. For insertions, choose an anchor that appears exactly once and let the CLI preserve the existing source.",
897
+ "Use edits only when a true exact replacement is necessary. Every edit.replace must contain edit.find as an exact substring; otherwise the CLI rejects it as a host-app rewrite.",
793
898
  "Use file_creations only for minimal Clue-owned setup wiring files when no existing Clue adapter or browser-token proxy module exists.",
794
899
  "Do not use file_creations for host application refactors, business logic, unrelated helpers, formatting, or non-Clue abstractions.",
795
900
  "Add ClueInit, ClueIdentify, ClueSetAccount, and ClueLogout where repository code has clear lifecycle points.",
@@ -804,6 +909,7 @@ const buildLifecyclePrompt = ({ request, files }) =>
804
909
  "Inspect backend lifecycle points as carefully as frontend lifecycle points. Backend login/session/account code is especially important.",
805
910
  "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.",
806
911
  "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.",
912
+ "FastAPI clue_init_fastapi must pass service_key using the setup manifest service key or CLUE_SERVICE_KEY. Do not rely on service_name alone because setup-watch and event attribution use the service key.",
807
913
  "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.",
808
914
  "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.",
809
915
  "Do not add broad ClueTrack instrumentation.",
@@ -814,11 +920,15 @@ const buildLifecyclePrompt = ({ request, files }) =>
814
920
  "Prefer stable ids and non-PII booleans/counts for ClueIdentify and ClueSetAccount traits.",
815
921
  "Use environment variable names for Clue configuration values.",
816
922
  "For Python/FastAPI code, read CLUE_PROJECT_KEY, CLUE_ENVIRONMENT, CLUE_API_KEY, and CLUE_INGEST_ENDPOINT from the backend env block.",
923
+ "Do not use os.environ[\"CLUE_*\"] required indexing for Clue env values. Use non-crashing reads such as os.environ.get/os.getenv and skip initialization if required Clue env is missing.",
817
924
  "CLUE_API_BASE_URL is not part of backend SDK initialization. Use it only when the application backend owns a browser-token proxy for a frontend service.",
818
925
  "For Next.js browser/client code, read NEXT_PUBLIC_CLUE_PROJECT_KEY, NEXT_PUBLIC_CLUE_ENVIRONMENT, NEXT_PUBLIC_CLUE_SERVICE_KEY, NEXT_PUBLIC_CLUE_INGEST_ENDPOINT, and NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT from the frontend .env.local block.",
819
926
  "Do not read process.env.CLUE_PROJECT_KEY, process.env.CLUE_ENVIRONMENT, process.env.CLUE_SERVICE_KEY, or process.env.CLUE_INGEST_ENDPOINT in Next.js browser/client code, and do not add non-public CLUE_* fallbacks there.",
820
927
  "Frontend SDK adapter code is contract-owned Clue setup wiring. The AI may choose the existing import/mount point, but must not invent token URL, env, or initialization semantics.",
821
928
  "If no safe frontend/browser SDK adapter boundary exists, create a minimal Clue-owned adapter file under the existing frontend source root and add the smallest exact replacement needed to import or mount it from an existing stable bootstrap point.",
929
+ "For Next.js, prefer a module-level client singleton such as src/lib/clue.ts that calls ClueInit once after checking required NEXT_PUBLIC_CLUE_* values, then import that module from app/layout.tsx or the existing app bootstrap.",
930
+ "Next.js browser SDK adapter files must begin with the exact directive \"use client\" so browser SDK code is not imported as a server module.",
931
+ "Do not create a React component whose useEffect calls ClueInit. React component lifecycle hooks, page components, sidebars, and auth callbacks are repeated UI paths and are rejected by setup-check.",
822
932
  "For Next.js frontend adapters, read the full customer-backend browser-token proxy URL from NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT. Do not derive it from NEXT_PUBLIC_API_URL, generic app API env names, detected backend ports, or relative frontend-origin paths.",
823
933
  "Do not mix stale browser-token paths such as /api/clue/browser-token, /clue/browser-tokens, or /browser-tokens with the canonical /api/v1/clue/browser-tokens path.",
824
934
  "Do not call ClueInit with empty-string fallbacks for required NEXT_PUBLIC_CLUE_* values. If required Clue env is absent, skip initialization and report the missing env names.",
@@ -834,13 +944,15 @@ const buildLifecyclePrompt = ({ request, files }) =>
834
944
  "The browser token request must include the frontend service key used by ClueInit. Project key and environment may be included only as public consistency hints; the backend must use server configuration or validate them against server configuration before calling Clue.",
835
945
  "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.",
836
946
  "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.",
947
+ "When the backend browser token proxy calls Clue, send the server CLUE_API_KEY in the x-clue-api-key header. Do not use Authorization bearer, query parameters, or JSON body fields for the Clue API key.",
837
948
  "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.",
949
+ "Do not introduce non-Clue HTTP client dependencies for the browser-token proxy unless they already exist in dependency files. For Python, prefer an existing HTTP client or the standard library instead of importing undeclared httpx or requests.",
838
950
  "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.",
839
951
  "Prefer minimal edits that engineers can review in one PR.",
840
952
  "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.",
841
953
  "If a lifecycle point is unclear, skip that edit and include a warning.",
842
- "Return status ready only when edits are safe to apply. Return status blocked when required SDKs or lifecycle points cannot be verified.",
843
- "If status is blocked, return no edits and no lifecycle_insertions, and list blockers. Do not encode blockers as warnings.",
954
+ "Return status ready when the safe Clue setup edits can be applied, even if individual unclear lifecycle points are skipped and listed as warnings.",
955
+ "If status is blocked, return no edits and no lifecycle_insertions, and list blockers. Use an empty string for blocker evidence when there is no specific evidence. Do not encode blockers as warnings.",
844
956
  ],
845
957
  repository_context: {
846
958
  target_tool: request.target_tool,
@@ -871,6 +983,14 @@ const buildLifecyclePrompt = ({ request, files }) =>
871
983
  content: "new Clue-only setup file content",
872
984
  },
873
985
  ],
986
+ insertions: [
987
+ {
988
+ file_path: "app/main.py",
989
+ anchor: "app = FastAPI()\n",
990
+ position: "after",
991
+ content: "clue_init_fastapi(app=app)\n",
992
+ },
993
+ ],
874
994
  edits: [
875
995
  {
876
996
  file_path: "app/main.py",
@@ -1,4 +1,4 @@
1
- import { access, readFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  applyLifecyclePlan,
@@ -28,6 +28,8 @@ const DEPENDENCY_WRITE_FILE_CANDIDATES = [
28
28
  "Pipfile.lock",
29
29
  "poetry.lock",
30
30
  ];
31
+ const FRONTEND_SDK_PACKAGE = "@clue-ai/browser-sdk";
32
+ const BACKEND_FASTAPI_SDK_PACKAGE = "clue-fastapi-sdk";
31
33
 
32
34
  const optionalString = (value) =>
33
35
  typeof value === "string" && value.trim() ? value.trim() : null;
@@ -100,6 +102,13 @@ const blockedMissingAiProviderConfig = ({ manifestPath, missingInputs }) => ({
100
102
  missing_inputs: missingInputs,
101
103
  report: null,
102
104
  },
105
+ quality_gates: buildQualityGates({
106
+ aiConfigPresent: false,
107
+ setupDoctorResult: {
108
+ status: "skipped",
109
+ reason: "AI provider configuration is missing",
110
+ },
111
+ }),
103
112
  blockers: [
104
113
  {
105
114
  reason:
@@ -187,6 +196,143 @@ const discoveredDependencyWritePaths = async ({ repoRoot, sourcePaths }) => {
187
196
  return discovered;
188
197
  };
189
198
 
199
+ const writeJson = async ({ path, value }) => {
200
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
201
+ };
202
+
203
+ const ensureFrontendSdkDependency = async ({ repoRoot, rootPath }) => {
204
+ const packageJsonPath = resolve(repoRoot, rootPath, "package.json");
205
+ if (!(await exists(packageJsonPath))) return null;
206
+ const packageJson = await readJson(packageJsonPath);
207
+ const dependencies =
208
+ packageJson.dependencies && typeof packageJson.dependencies === "object"
209
+ ? packageJson.dependencies
210
+ : {};
211
+ if (dependencies[FRONTEND_SDK_PACKAGE] === "latest") return null;
212
+ packageJson.dependencies = {
213
+ ...dependencies,
214
+ [FRONTEND_SDK_PACKAGE]: "latest",
215
+ };
216
+ await writeJson({ path: packageJsonPath, value: packageJson });
217
+ return {
218
+ file_path: join(rootPath, "package.json"),
219
+ dependency: FRONTEND_SDK_PACKAGE,
220
+ version: "latest",
221
+ };
222
+ };
223
+
224
+ const ensureRequirementsDependency = async ({ repoRoot, path }) => {
225
+ const absolutePath = resolve(repoRoot, path);
226
+ if (!(await exists(absolutePath))) return null;
227
+ const text = await readFile(absolutePath, "utf8");
228
+ const rawLines = text.split(/\r?\n/);
229
+ while (rawLines.at(-1) === "") rawLines.pop();
230
+ const lines = rawLines.filter(
231
+ (line) => !new RegExp(`^\\s*${BACKEND_FASTAPI_SDK_PACKAGE}\\b`).test(line),
232
+ );
233
+ if (!lines.some((line) => line.trim() === BACKEND_FASTAPI_SDK_PACKAGE)) {
234
+ lines.push(BACKEND_FASTAPI_SDK_PACKAGE);
235
+ }
236
+ const next = `${lines.join("\n")}\n`;
237
+ if (next === text) return null;
238
+ await writeFile(absolutePath, next, "utf8");
239
+ return {
240
+ file_path: path,
241
+ dependency: BACKEND_FASTAPI_SDK_PACKAGE,
242
+ version: "latest",
243
+ };
244
+ };
245
+
246
+ const ensureSdkDependencies = async ({ repoRoot, manifest, request }) => {
247
+ const writes = [];
248
+ const watchTargets = Array.isArray(
249
+ manifest.lifecycle_verification?.watch_targets,
250
+ )
251
+ ? manifest.lifecycle_verification.watch_targets
252
+ : [];
253
+ for (const target of watchTargets) {
254
+ if (target?.kind !== "frontend" || !optionalString(target.root_path)) {
255
+ continue;
256
+ }
257
+ const write = await ensureFrontendSdkDependency({
258
+ repoRoot,
259
+ rootPath: target.root_path,
260
+ });
261
+ if (write) writes.push(write);
262
+ }
263
+ if (request.framework === "fastapi") {
264
+ const dependencyPaths = await discoveredDependencyWritePaths({
265
+ repoRoot,
266
+ sourcePaths: [request.backend_root_path],
267
+ });
268
+ const requirementsPath = dependencyPaths.find((path) =>
269
+ path.endsWith("requirements.txt"),
270
+ );
271
+ if (requirementsPath) {
272
+ const write = await ensureRequirementsDependency({
273
+ repoRoot,
274
+ path: requirementsPath,
275
+ });
276
+ if (write) writes.push(write);
277
+ }
278
+ }
279
+ return writes;
280
+ };
281
+
282
+ const planTouchedPaths = (plan) => [
283
+ ...new Set(
284
+ [
285
+ ...(Array.isArray(plan?.edits)
286
+ ? plan.edits.map((edit) => edit?.file_path)
287
+ : []),
288
+ ...(Array.isArray(plan?.file_creations)
289
+ ? plan.file_creations.map((fileCreation) => fileCreation?.file_path)
290
+ : []),
291
+ ...(Array.isArray(plan?.insertions)
292
+ ? plan.insertions.map((insertion) => insertion?.file_path)
293
+ : []),
294
+ ...(Array.isArray(plan?.lifecycle_insertions)
295
+ ? plan.lifecycle_insertions.map((insertion) => insertion?.file_path)
296
+ : []),
297
+ ]
298
+ .map(optionalString)
299
+ .filter(Boolean),
300
+ ),
301
+ ];
302
+
303
+ const snapshotPlanTouchedFiles = async ({ repoRoot, plan }) => {
304
+ const snapshot = new Map();
305
+ for (const filePath of planTouchedPaths(plan)) {
306
+ const absolutePath = resolve(repoRoot, filePath);
307
+ try {
308
+ snapshot.set(filePath, {
309
+ absolutePath,
310
+ existed: true,
311
+ text: await readFile(absolutePath, "utf8"),
312
+ });
313
+ } catch (error) {
314
+ if (error?.code !== "ENOENT") throw error;
315
+ snapshot.set(filePath, {
316
+ absolutePath,
317
+ existed: false,
318
+ text: null,
319
+ });
320
+ }
321
+ }
322
+ return snapshot;
323
+ };
324
+
325
+ const restorePlanTouchedFiles = async (snapshot) => {
326
+ for (const entry of snapshot.values()) {
327
+ if (entry.existed) {
328
+ await mkdir(dirname(entry.absolutePath), { recursive: true });
329
+ await writeFile(entry.absolutePath, entry.text, "utf8");
330
+ } else {
331
+ await rm(entry.absolutePath, { force: true });
332
+ }
333
+ }
334
+ };
335
+
190
336
  const allowedWritePathsFromRequest = async ({ repoRoot, request }) => [
191
337
  ...new Set([
192
338
  ...request.allowed_source_paths,
@@ -274,6 +420,55 @@ const missingInputsFromDoctor = (report) => [
274
420
  ),
275
421
  ];
276
422
 
423
+ const buildQualityGates = ({
424
+ aiConfigPresent,
425
+ dependencyWrites = [],
426
+ lifecyclePlanApplied = false,
427
+ setupCheck = null,
428
+ setupDoctorResult = null,
429
+ }) => [
430
+ {
431
+ id: "ai_provider_config_present",
432
+ passed: Boolean(aiConfigPresent),
433
+ evidence: REQUIRED_SETUP_AGENT_AI_ENV_NAMES,
434
+ },
435
+ {
436
+ id: "sdk_dependencies_cli_owned",
437
+ passed: true,
438
+ evidence: {
439
+ dependency_writes: dependencyWrites,
440
+ note:
441
+ "setup-agent owns Clue SDK dependency declarations before AI planning",
442
+ },
443
+ },
444
+ {
445
+ id: "lifecycle_plan_cli_applied",
446
+ passed: Boolean(lifecyclePlanApplied),
447
+ evidence:
448
+ "AI returns a structured plan only; setup-agent validates and applies edits",
449
+ },
450
+ {
451
+ id: "static_setup_check_passed",
452
+ passed: Boolean(setupCheck?.passed),
453
+ evidence: "setup-check --require-sdk-lifecycle",
454
+ },
455
+ {
456
+ id: "setup_doctor_preflight",
457
+ passed: setupDoctorResult?.status === "passed",
458
+ skipped: setupDoctorResult?.status === "skipped",
459
+ evidence:
460
+ setupDoctorResult?.status === "passed"
461
+ ? "setup-doctor --local passed"
462
+ : setupDoctorResult?.reason ?? "setup-doctor --local did not pass",
463
+ },
464
+ {
465
+ id: "setup_watch_user_owned",
466
+ passed: true,
467
+ evidence:
468
+ "setup-watch remains user-operated lifecycle and event-delivery verification",
469
+ },
470
+ ];
471
+
277
472
  const runOptionalSetupDoctor = async ({ env, flags, repoRoot, setupDoctor }) => {
278
473
  if (flags.has("skip-setup-doctor")) {
279
474
  return {
@@ -296,7 +491,7 @@ const runOptionalSetupDoctor = async ({ env, flags, repoRoot, setupDoctor }) =>
296
491
  );
297
492
  if (allFailuresAreMissingInputs) {
298
493
  return {
299
- status: "blocked_missing_inputs",
494
+ status: "skipped",
300
495
  reason: "setup-doctor required inputs are missing",
301
496
  missing_inputs: missingInputs,
302
497
  report,
@@ -430,10 +625,16 @@ export const runSetupAgent = async ({
430
625
  });
431
626
  }
432
627
  const attempts = [];
628
+ const dependencyWrites = await ensureSdkDependencies({
629
+ repoRoot: resolvedRepoRoot,
630
+ manifest,
631
+ request,
632
+ });
433
633
  let failureContext = null;
434
634
  let lastSetupCheck = null;
435
635
 
436
636
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
637
+ let planSnapshot = null;
437
638
  try {
438
639
  const plan = await lifecyclePlanner({
439
640
  repoRoot: resolvedRepoRoot,
@@ -441,6 +642,10 @@ export const runSetupAgent = async ({
441
642
  env: aiEnv,
442
643
  failureContext,
443
644
  });
645
+ planSnapshot = await snapshotPlanTouchedFiles({
646
+ repoRoot: resolvedRepoRoot,
647
+ plan,
648
+ });
444
649
  const lifecycleResult = await lifecycleApplier({
445
650
  repoRoot: resolvedRepoRoot,
446
651
  plan,
@@ -456,7 +661,9 @@ export const runSetupAgent = async ({
456
661
  lastSetupCheck = setupCheck;
457
662
  attempts.push({
458
663
  attempt,
664
+ dependency_writes: dependencyWrites,
459
665
  file_creations: lifecycleResult.fileCreations ?? [],
666
+ insertions: lifecycleResult.insertions ?? [],
460
667
  lifecycle_insertions: lifecycleResult.lifecycleInsertions,
461
668
  warnings: lifecycleResult.warnings,
462
669
  setup_check_passed: setupCheck.passed,
@@ -471,14 +678,20 @@ export const runSetupAgent = async ({
471
678
  });
472
679
  return {
473
680
  status:
474
- setupDoctorResult.status === "passed" ||
475
- setupDoctorResult.status === "skipped"
681
+ setupDoctorResult.status !== "failed"
476
682
  ? "user_verification_pending"
477
683
  : "blocked",
478
684
  manifest_path: manifestPath,
479
685
  attempts,
480
686
  setup_check: setupCheck,
481
687
  setup_doctor: setupDoctorResult,
688
+ quality_gates: buildQualityGates({
689
+ aiConfigPresent: true,
690
+ dependencyWrites,
691
+ lifecyclePlanApplied: true,
692
+ setupCheck,
693
+ setupDoctorResult,
694
+ }),
482
695
  user_verification: {
483
696
  setup_watch_owner: "user",
484
697
  setup_watch_auto_run: false,
@@ -487,12 +700,16 @@ export const runSetupAgent = async ({
487
700
  },
488
701
  };
489
702
  }
703
+ await restorePlanTouchedFiles(planSnapshot);
490
704
  failureContext = attemptFailureContext({
491
705
  attempt,
492
706
  setupCheck,
493
707
  stage: "setup_check",
494
708
  });
495
709
  } catch (error) {
710
+ if (planSnapshot) {
711
+ await restorePlanTouchedFiles(planSnapshot);
712
+ }
496
713
  failureContext = attemptFailureContext({
497
714
  attempt,
498
715
  error,
@@ -500,6 +717,7 @@ export const runSetupAgent = async ({
500
717
  });
501
718
  attempts.push({
502
719
  attempt,
720
+ dependency_writes: dependencyWrites,
503
721
  setup_check_passed: false,
504
722
  error: failureContext.error,
505
723
  failed_checks: [],
@@ -518,6 +736,18 @@ export const runSetupAgent = async ({
518
736
  missing_inputs: [],
519
737
  report: null,
520
738
  },
739
+ quality_gates: buildQualityGates({
740
+ aiConfigPresent: true,
741
+ dependencyWrites,
742
+ lifecyclePlanApplied: attempts.some(
743
+ (attempt) => Array.isArray(attempt.lifecycle_insertions),
744
+ ),
745
+ setupCheck: lastSetupCheck,
746
+ setupDoctorResult: {
747
+ status: "skipped",
748
+ reason: "setup-check did not pass",
749
+ },
750
+ }),
521
751
  blockers: [
522
752
  {
523
753
  reason: summarizeAttemptFailure(failureContext),
@@ -1,5 +1,5 @@
1
1
  export const AI_SETUP_CONTRACT_VERSION =
2
- "2026-05-10.latest-sdk-contract.v10";
2
+ "2026-05-10.latest-sdk-contract.v17";
3
3
 
4
4
  export const SETUP_DOCTRINE = {
5
5
  purpose:
@@ -13,7 +13,7 @@ export const SETUP_DOCTRINE = {
13
13
  documentation_reason:
14
14
  "SDK signatures, environment variable names, browser token behavior, and verification ownership are contracts. The AI must read the setup documents instead of relying on memory.",
15
15
  failure_posture:
16
- "When a lifecycle point, SDK package, signature, env name, or verification step is unclear, the correct output is a blocker or user_verification_pending, not a guessed implementation or a completion claim.",
16
+ "For supported setup paths, prefer completing safe minimal Clue wiring and reporting unclear lifecycle points as warnings. Reserve blockers for truly unsupported frameworks, unavailable SDK contracts, missing AI configuration, or edits that cannot be applied without guessing outside Clue setup scope.",
17
17
  };
18
18
 
19
19
  export const DETERMINISTIC_CONTROL_MODEL = {
@@ -22,7 +22,7 @@ export const DETERMINISTIC_CONTROL_MODEL = {
22
22
  "which existing login/session success point owns ClueIdentify",
23
23
  "which existing account/workspace/organization resolution point owns ClueSetAccount",
24
24
  "which existing logout/session reset point owns ClueLogout",
25
- "whether a lifecycle point is unclear and should be reported as blocked",
25
+ "whether a lifecycle point is unclear and should be skipped with a warning instead of guessed",
26
26
  ],
27
27
  cli_should_control: [
28
28
  "official SDK package names",
@@ -37,6 +37,39 @@ export const DETERMINISTIC_CONTROL_MODEL = {
37
37
  ],
38
38
  };
39
39
 
40
+ export const SETUP_AGENT_QUALITY_GATES = [
41
+ {
42
+ id: "ai_provider_config_present",
43
+ purpose:
44
+ "Prevent silent setup-agent startup failure when AI provider env is missing.",
45
+ },
46
+ {
47
+ id: "sdk_dependencies_cli_owned",
48
+ purpose:
49
+ "Ensure official SDK package names and latest-channel declarations are controlled by CLI logic, not model guesses.",
50
+ },
51
+ {
52
+ id: "lifecycle_plan_cli_applied",
53
+ purpose:
54
+ "Ensure the AI only returns a structured plan and cannot directly write files or run shell commands.",
55
+ },
56
+ {
57
+ id: "static_setup_check_passed",
58
+ purpose:
59
+ "Reject known bad local-prompt failure modes such as wrong SDKs, stale env names, unsafe browser token wiring, awaited lifecycle calls, and partial lifecycle setup.",
60
+ },
61
+ {
62
+ id: "setup_doctor_preflight",
63
+ purpose:
64
+ "Separate API connectivity verification from static source verification and make skipped preflight visible.",
65
+ },
66
+ {
67
+ id: "setup_watch_user_owned",
68
+ purpose:
69
+ "Keep real login/logout/account event delivery verification under the user's control.",
70
+ },
71
+ ];
72
+
40
73
  export const API_CONNECTIVITY_CONTRACT = {
41
74
  purpose:
42
75
  "Clue setup has four distinct HTTP hops. The AI must not collapse customer-owned proxy routes and Clue-owned ingest routes into one vague browser-token endpoint.",
@@ -55,7 +88,7 @@ export const API_CONNECTIVITY_CONTRACT = {
55
88
  path: "/api/v1/ingest/browser-tokens",
56
89
  caller: "customer_backend_browser_token_proxy",
57
90
  purpose:
58
- "Clue backend validates CLUE_API_KEY, project, environment, service key, and origin, then returns a browser token.",
91
+ "Clue backend validates x-clue-api-key, project, environment, service key, and origin, then returns a browser token.",
59
92
  },
60
93
  browser_ingest: {
61
94
  owner: "clue_backend",
@@ -93,12 +126,17 @@ export const FRONTEND_ADAPTER_CONTRACT = {
93
126
  ],
94
127
  browser_token_proxy_path: "/api/v1/clue/browser-tokens",
95
128
  rules: [
129
+ "For Next.js, prefer a module-level client singleton such as src/lib/clue.ts that calls ClueInit once after required NEXT_PUBLIC_CLUE_* values are present, then import that module from app/layout.tsx or the existing app bootstrap.",
130
+ "Next.js browser SDK adapter files must start with \"use client\".",
131
+ "Do not create a React component whose useEffect calls ClueInit. Component lifecycle hooks, page components, sidebars, login/register success callbacks, and other repeated UI paths are rejected setup locations.",
96
132
  "Do not derive the Clue browser-token proxy from generic app API env names such as NEXT_PUBLIC_API_URL.",
97
133
  "Do not mix stale browser token paths with the canonical /api/v1/clue/browser-tokens path in the same adapter.",
98
134
  "Next.js browserTokenProvider must call NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT, whose value is the customer backend browser-token proxy URL.",
99
135
  "Do not call ClueInit with empty-string fallbacks for required NEXT_PUBLIC_CLUE_* values.",
100
136
  "If a singleton guard is used, do not mark initialized=true before ClueInit has been called with required config present.",
101
137
  "The browser token provider must send the same frontend serviceKey used by ClueInit.",
138
+ "Backend browser-token proxy calls to Clue must send CLUE_API_KEY as the x-clue-api-key header, never as Authorization bearer, query, or JSON body.",
139
+ "Do not introduce non-Clue HTTP client dependencies for browser-token proxy code unless they already exist in dependency files.",
102
140
  ],
103
141
  };
104
142
 
@@ -136,7 +174,7 @@ export const OFFICIAL_SDK_CONTRACT = {
136
174
  ClueLogout: "ClueLogout(reason: str | None = None) -> bool",
137
175
  },
138
176
  safety_contract:
139
- "Public lifecycle APIs catch SDK errors internally and return bool where applicable. Do not wrap each call solely for Clue failure isolation.",
177
+ "Public lifecycle APIs catch SDK errors internally and return bool where applicable. Do not wrap each call solely for Clue failure isolation. FastAPI initialization must pass service_key so event attribution matches setup-watch service targets. Clue env reads must be non-crashing; do not use os.environ[\"CLUE_*\"] required indexing.",
140
178
  },
141
179
  minimal_file_creation_contract: {
142
180
  allowed_when:
@@ -39,6 +39,8 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
39
39
  "For Next.js browser/client code, use only `NEXT_PUBLIC_CLUE_PROJECT_KEY`",
40
40
  "Do not read `process.env.CLUE_PROJECT_KEY`",
41
41
  "CLUE_API_BASE_URL` is not part of backend SDK initialization",
42
+ "FastAPI `clue_init_fastapi` must pass `service_key`",
43
+ "Do not use `os.environ[\"CLUE_*\"]`",
42
44
  "Install Clue SDK dependencies through the latest channel",
43
45
  "`@clue-ai/browser-sdk` must use `latest`",
44
46
  "Python backend SDK dependencies must not be pinned",
@@ -49,6 +51,7 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
49
51
  "Do not forward `origin`, `projectKey`, or `environment` from JSON/body payload fields under server `CLUE_API_KEY`",
50
52
  "Frontend SDK adapter code is contract-owned Clue setup wiring",
51
53
  "Do not derive it from `NEXT_PUBLIC_API_URL`",
54
+ "Next.js browser SDK adapter files must start with `\"use client\"`",
52
55
  "Do not call `ClueInit` with empty-string fallbacks",
53
56
  "do not set `initialized = true` before `ClueInit`",
54
57
  "For Django code, use `clue-django-sdk` only after package-manager or registry verification confirms it is installable",
@@ -58,9 +61,13 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
58
61
  "Reject Django SDK setup when `clue-django-sdk` installability has not been verified",
59
62
  "Reject ClueTrack instrumentation unless the user explicitly requested product event tracking",
60
63
  "Reject Next.js browser/client code that reads non-public `process.env.CLUE_*` variables",
64
+ "Reject backend Clue setup code that uses `os.environ[\"CLUE_*\"]` required indexing instead of non-crashing env reads",
61
65
  "Reject browser token proxy code that forwards origin, projectKey, or environment from request JSON/body under server `CLUE_API_KEY`",
66
+ "Reject browser token proxy code that sends `CLUE_API_KEY` as `Authorization: Bearer`",
67
+ "Reject browser token proxy code that imports undeclared HTTP client dependencies",
62
68
  "Reject frontend browser token providers that derive the Clue proxy URL from `NEXT_PUBLIC_API_URL`",
63
69
  "Reject frontend adapters that mix stale browser-token paths",
70
+ "Reject Next.js browser SDK adapter files that omit `\"use client\"`",
64
71
  "Reject frontend adapters that set `initialized = true` before calling `ClueInit`",
65
72
  "Reject Clue SDK dependency entries that pin stale fixed versions",
66
73
  "Audit the setup diff against the Clue setup contract even when the code was written by another agent",
@@ -71,6 +78,7 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
71
78
  "clue-local-verification": [
72
79
  "`setup-check --require-sdk-lifecycle` is a static source check only",
73
80
  "`setup-doctor --local` API connectivity preflight",
81
+ "setup-agent quality gates must be reported honestly",
74
82
  "Verify frontend SDK installability/import",
75
83
  "Verify backend SDK installability/import",
76
84
  "Do not run `npx -y @clue-ai/cli setup-watch --local` automatically",
@@ -88,6 +96,7 @@ const REQUIRED_SETUP_SKILL_PHRASES = {
88
96
  "clue-setup-report": [
89
97
  "Never claim `setup completed` from `setup-check --require-sdk-lifecycle` alone",
90
98
  "Completion requires all applicable evidence",
99
+ "setup-agent quality gates must be reported honestly",
91
100
  "For every completion claim, include the evidence source",
92
101
  ],
93
102
  };
@@ -504,6 +513,9 @@ const sourceIsUnderAnyRoot = (source, roots) =>
504
513
  );
505
514
  });
506
515
 
516
+ const hasUseClientDirective = (text) =>
517
+ /^\s*(?:"use client"|'use client')\s*;?/.test(text);
518
+
507
519
  const dependencySourceHasPackage = (source, packageName) => {
508
520
  if (source.file_path.endsWith("package.json")) {
509
521
  return packageJsonDependencyNames(source.text).includes(packageName);
@@ -920,6 +932,25 @@ const findNextBrowserTokenProviderMissingPublicEndpointFiles = ({
920
932
  .map((source) => source.file_path);
921
933
  };
922
934
 
935
+ const findNextLifecycleMissingUseClientFiles = ({
936
+ dependencySources,
937
+ frontendSources,
938
+ }) => {
939
+ const roots = nextPackageRoots(dependencySources);
940
+ if (roots.length === 0) return [];
941
+ return frontendSources
942
+ .filter((source) => sourceIsUnderAnyRoot(source, roots))
943
+ .filter(
944
+ (source) =>
945
+ sourceImportsFrontendSdk(source) ||
946
+ /\bClueInit\s*\(/.test(
947
+ stripSourceNoise(source.text, { stripStrings: true }),
948
+ ),
949
+ )
950
+ .filter((source) => !hasUseClientDirective(source.text))
951
+ .map((source) => source.file_path);
952
+ };
953
+
923
954
  const browserTokenPathLiterals = (text) =>
924
955
  [
925
956
  ...stripSourceNoise(text).matchAll(
@@ -990,6 +1021,35 @@ const sourceLooksLikeBrowserTokenProxy = (source) => {
990
1021
  );
991
1022
  };
992
1023
 
1024
+ const findBackendSdkInitMissingServiceKeyFiles = ({
1025
+ backendSources,
1026
+ framework,
1027
+ }) => {
1028
+ if (framework !== "fastapi") return [];
1029
+ return backendSources
1030
+ .filter((source) =>
1031
+ /\bclue_init_fastapi\s*\(/.test(
1032
+ stripSourceNoise(source.text, { stripStrings: true }),
1033
+ ),
1034
+ )
1035
+ .filter(
1036
+ (source) =>
1037
+ !/\bclue_init_fastapi\s*\([\s\S]{0,1600}\bservice_key\s*=/.test(
1038
+ stripSourceNoise(source.text, { stripStrings: true }),
1039
+ ),
1040
+ )
1041
+ .map((source) => source.file_path);
1042
+ };
1043
+
1044
+ const findBackendClueEnvRequiredIndexFiles = (backendSources) =>
1045
+ backendSources
1046
+ .filter((source) =>
1047
+ /os\.environ\s*\[\s*["']CLUE_[A-Z0-9_]+["']\s*\]/.test(
1048
+ stripSourceNoise(source.text),
1049
+ ),
1050
+ )
1051
+ .map((source) => source.file_path);
1052
+
993
1053
  const findBrowserTokenProxyUsingBackendServiceKeyFiles = (backendSources) =>
994
1054
  backendSources
995
1055
  .filter(sourceLooksLikeBrowserTokenProxy)
@@ -1004,18 +1064,18 @@ const findBrowserTokenProxyUsingBackendServiceKeyFiles = (backendSources) =>
1004
1064
  .map((source) => source.file_path);
1005
1065
 
1006
1066
  const bodyOriginPatterns = [
1007
- /\b(?:payload|body|requestBody|request_body|data|input)\.origin\b/i,
1008
- /\b(?:payload|body|requestBody|request_body|data|input)\s*\[\s*["']origin["']\s*\]/i,
1009
- /\b(?:payload|body|requestBody|request_body|data|input)\.get\s*\(\s*["']origin["']/i,
1010
- /["']origin["']\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
1011
- /\borigin\s*=\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
1067
+ /\b(?:body|requestBody|request_body|data|input)\.origin\b/i,
1068
+ /\b(?:body|requestBody|request_body|data|input)\s*\[\s*["']origin["']\s*\]/i,
1069
+ /\b(?:body|requestBody|request_body|data|input)\.get\s*\(\s*["']origin["']/i,
1070
+ /["']origin["']\s*:\s*(?:body|requestBody|request_body|data|input)\b/i,
1071
+ /\borigin\s*=\s*(?:body|requestBody|request_body|data|input)\b/i,
1012
1072
  ];
1013
1073
 
1014
1074
  const bodyProjectEnvironmentPatterns = [
1015
- /\b(?:projectKey|project_key)\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
1016
- /["'](?:projectKey|project_key)["']\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
1017
- /\benvironment\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
1018
- /["']environment["']\s*:\s*(?:payload|body|requestBody|request_body|data|input)\b/i,
1075
+ /\b(?:projectKey|project_key)\s*:\s*(?:body|requestBody|request_body|data|input)\b/i,
1076
+ /["'](?:projectKey|project_key)["']\s*:\s*(?:body|requestBody|request_body|data|input)\b/i,
1077
+ /\benvironment\s*:\s*(?:body|requestBody|request_body|data|input)\b/i,
1078
+ /["']environment["']\s*:\s*(?:body|requestBody|request_body|data|input)\b/i,
1019
1079
  ];
1020
1080
 
1021
1081
  const findBrowserTokenProxyTrustingBodyOriginFiles = (backendSources) =>
@@ -1040,6 +1100,37 @@ const findBrowserTokenProxyTrustingBodyProjectEnvironmentFiles = (
1040
1100
  })
1041
1101
  .map((source) => source.file_path);
1042
1102
 
1103
+ const findBrowserTokenProxyMissingApiKeyHeaderFiles = (backendSources) =>
1104
+ backendSources
1105
+ .filter(sourceLooksLikeBrowserTokenProxy)
1106
+ .filter(
1107
+ (source) =>
1108
+ !/["']x-clue-api-key["']\s*:/.test(stripSourceNoise(source.text)),
1109
+ )
1110
+ .map((source) => source.file_path);
1111
+
1112
+ const findBrowserTokenProxyUndeclaredHttpClientFiles = ({
1113
+ backendSources,
1114
+ dependencySources,
1115
+ }) => {
1116
+ const declaredHttpClients = ["httpx", "requests"].filter((packageName) =>
1117
+ dependencyHasAnyPackage(dependencySources, [packageName]),
1118
+ );
1119
+ return backendSources
1120
+ .filter(sourceLooksLikeBrowserTokenProxy)
1121
+ .filter((source) => {
1122
+ const text = stripSourceNoise(source.text, { stripStrings: true });
1123
+ const importsHttpx = /(?:^|\n)\s*import\s+httpx\b/.test(text);
1124
+ const importsRequests =
1125
+ /(?:^|\n)\s*(?:import\s+requests\b|from\s+requests\b)/.test(text);
1126
+ return (
1127
+ (importsHttpx && !declaredHttpClients.includes("httpx")) ||
1128
+ (importsRequests && !declaredHttpClients.includes("requests"))
1129
+ );
1130
+ })
1131
+ .map((source) => source.file_path);
1132
+ };
1133
+
1043
1134
  const backendSdkSpec = (framework) =>
1044
1135
  BACKEND_SDK_BY_FRAMEWORK[String(framework ?? "").toLowerCase()] ?? {
1045
1136
  packages: Object.values(BACKEND_SDK_BY_FRAMEWORK).flatMap(
@@ -1143,6 +1234,13 @@ const checkSdkLifecycle = ({
1143
1234
  !backendPresent || backendSpec.installabilityStatus !== "unverified";
1144
1235
  const backendInitPresent =
1145
1236
  !backendPresent || backendSpec.initPattern.test(backendCombined);
1237
+ const backendSdkInitMissingServiceKeyFiles =
1238
+ findBackendSdkInitMissingServiceKeyFiles({
1239
+ backendSources,
1240
+ framework,
1241
+ });
1242
+ const backendClueEnvRequiredIndexFiles =
1243
+ findBackendClueEnvRequiredIndexFiles(backendSources);
1146
1244
  const frontendLifecyclePresent = frontendFoundApiNames.length > 0;
1147
1245
  const frontendSdkDependencyPresent =
1148
1246
  !frontendLifecyclePresent ||
@@ -1182,12 +1280,24 @@ const checkSdkLifecycle = ({
1182
1280
  dependencySources,
1183
1281
  frontendSources,
1184
1282
  });
1283
+ const nextLifecycleMissingUseClientFiles =
1284
+ findNextLifecycleMissingUseClientFiles({
1285
+ dependencySources,
1286
+ frontendSources,
1287
+ });
1185
1288
  const browserTokenProxyUsingBackendServiceKeyFiles =
1186
1289
  findBrowserTokenProxyUsingBackendServiceKeyFiles(backendSources);
1187
1290
  const browserTokenProxyTrustingBodyOriginFiles =
1188
1291
  findBrowserTokenProxyTrustingBodyOriginFiles(backendSources);
1189
1292
  const browserTokenProxyTrustingBodyProjectEnvironmentFiles =
1190
1293
  findBrowserTokenProxyTrustingBodyProjectEnvironmentFiles(backendSources);
1294
+ const browserTokenProxyMissingApiKeyHeaderFiles =
1295
+ findBrowserTokenProxyMissingApiKeyHeaderFiles(backendSources);
1296
+ const browserTokenProxyUndeclaredHttpClientFiles =
1297
+ findBrowserTokenProxyUndeclaredHttpClientFiles({
1298
+ backendSources,
1299
+ dependencySources,
1300
+ });
1191
1301
  const backendIdentityRequired =
1192
1302
  backendPresent &&
1193
1303
  /\b(login|signin|sign_in|auth|token|session)\b/i.test(backendCombined);
@@ -1228,6 +1338,8 @@ const checkSdkLifecycle = ({
1228
1338
  sdk_import_present: backendSdkImportPresent,
1229
1339
  sdk_dependency_or_import_present: backendSdkPresent,
1230
1340
  sdk_init_present: backendInitPresent,
1341
+ sdk_init_missing_service_key_files: backendSdkInitMissingServiceKeyFiles,
1342
+ clue_env_required_index_files: backendClueEnvRequiredIndexFiles,
1231
1343
  required_apis: [
1232
1344
  ...(backendIdentityRequired ? ["ClueIdentify"] : []),
1233
1345
  ...(backendAccountRequired ? ["ClueSetAccount"] : []),
@@ -1259,12 +1371,18 @@ const checkSdkLifecycle = ({
1259
1371
  nextBrowserTokenProviderMissingPublicServiceKeyFiles,
1260
1372
  next_browser_token_provider_missing_public_endpoint_files:
1261
1373
  nextBrowserTokenProviderMissingPublicEndpointFiles,
1374
+ next_lifecycle_missing_use_client_files:
1375
+ nextLifecycleMissingUseClientFiles,
1262
1376
  browser_token_proxy_uses_backend_service_key_files:
1263
1377
  browserTokenProxyUsingBackendServiceKeyFiles,
1264
1378
  browser_token_proxy_trusts_body_origin_files:
1265
1379
  browserTokenProxyTrustingBodyOriginFiles,
1266
1380
  browser_token_proxy_trusts_body_project_environment_files:
1267
1381
  browserTokenProxyTrustingBodyProjectEnvironmentFiles,
1382
+ browser_token_proxy_missing_api_key_header_files:
1383
+ browserTokenProxyMissingApiKeyHeaderFiles,
1384
+ browser_token_proxy_undeclared_http_client_files:
1385
+ browserTokenProxyUndeclaredHttpClientFiles,
1268
1386
  },
1269
1387
  has_noop_wrapper: noOpPattern.test(combined),
1270
1388
  component_lifecycle_init_files: componentLifecycleInitFiles,
@@ -1278,6 +1396,8 @@ const checkSdkLifecycle = ({
1278
1396
  backendSdkInstallabilityVerified &&
1279
1397
  pinnedBackendSdkDependencyFiles.length === 0 &&
1280
1398
  backendInitPresent &&
1399
+ backendSdkInitMissingServiceKeyFiles.length === 0 &&
1400
+ backendClueEnvRequiredIndexFiles.length === 0 &&
1281
1401
  backendMissingApis.length === 0 &&
1282
1402
  frontendSdkPresent &&
1283
1403
  frontendLifecycleFilesWithoutVerifiedSdk.length === 0 &&
@@ -1291,9 +1411,12 @@ const checkSdkLifecycle = ({
1291
1411
  frontendInitBeforeClueInitFiles.length === 0 &&
1292
1412
  nextBrowserTokenProviderMissingPublicServiceKeyFiles.length === 0 &&
1293
1413
  nextBrowserTokenProviderMissingPublicEndpointFiles.length === 0 &&
1414
+ nextLifecycleMissingUseClientFiles.length === 0 &&
1294
1415
  browserTokenProxyUsingBackendServiceKeyFiles.length === 0 &&
1295
1416
  browserTokenProxyTrustingBodyOriginFiles.length === 0 &&
1296
1417
  browserTokenProxyTrustingBodyProjectEnvironmentFiles.length === 0 &&
1418
+ browserTokenProxyMissingApiKeyHeaderFiles.length === 0 &&
1419
+ browserTokenProxyUndeclaredHttpClientFiles.length === 0 &&
1297
1420
  !noOpPattern.test(combined) &&
1298
1421
  componentLifecycleInitFiles.length === 0 &&
1299
1422
  blockingLifecycleFiles.length === 0,
@@ -34,6 +34,23 @@ const commonDirectory = (paths) => {
34
34
  return common.length ? common.join("/") : ".";
35
35
  };
36
36
 
37
+ const isPathInside = (childPath, parentPath) =>
38
+ childPath === parentPath || childPath.startsWith(`${parentPath}/`);
39
+
40
+ const findFastApiAppRoot = async ({ repoRoot, files, routeFiles }) => {
41
+ const appRootCandidates = [];
42
+ for (const absolutePath of files) {
43
+ const relativePath = relative(repoRoot, absolutePath);
44
+ const source = await readFile(absolutePath, "utf8");
45
+ if (!/\bFastAPI\s*\(/.test(source)) continue;
46
+ const candidate = dirname(relativePath);
47
+ if (routeFiles.every((routeFile) => isPathInside(routeFile, candidate))) {
48
+ appRootCandidates.push(candidate);
49
+ }
50
+ }
51
+ return appRootCandidates.sort((left, right) => right.length - left.length)[0] ?? null;
52
+ };
53
+
37
54
  const ignoredPackageDirs = new Set([
38
55
  ".git",
39
56
  ".next",
@@ -162,7 +179,12 @@ export const runSetupDetect = async ({ repoRoot, excludedSourcePaths = [] }) =>
162
179
  fastApiRoutes.map((route) => route.ai_context.relative_path).filter(Boolean),
163
180
  ),
164
181
  ].sort();
165
- const backendRootPath = commonDirectory(routeFiles);
182
+ const backendRootPath =
183
+ (await findFastApiAppRoot({
184
+ repoRoot: resolvedRepoRoot,
185
+ files,
186
+ routeFiles,
187
+ })) ?? commonDirectory(routeFiles);
166
188
  const backendServiceKey = deriveServiceKeyFromPath(backendRootPath);
167
189
  return {
168
190
  detected: true,
@@ -8,6 +8,7 @@ import {
8
8
  DETERMINISTIC_CONTROL_MODEL,
9
9
  FRONTEND_ADAPTER_CONTRACT,
10
10
  SETUP_DOCTRINE,
11
+ SETUP_AGENT_QUALITY_GATES,
11
12
  } from "./setup-ai-contract.mjs";
12
13
  import { buildSetupDocumentationContract } from "./setup-documents.mjs";
13
14
 
@@ -116,6 +117,7 @@ export const buildAiSetupHelp = () => ({
116
117
  "OpenAI uses Responses API forced function tools with strict schema and parallel_tool_calls false. Anthropic uses Messages API tool_choice with the same input schema.",
117
118
  setup_watch_auto_run: false,
118
119
  },
120
+ setup_agent_quality_gates: SETUP_AGENT_QUALITY_GATES,
119
121
  completion_boundary: {
120
122
  ai_may_claim: [
121
123
  "Clue setup code changes were applied",
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  AI_SETUP_CONTRACT_VERSION,
12
12
  API_CONNECTIVITY_CONTRACT,
13
+ SETUP_AGENT_QUALITY_GATES,
13
14
  setupDoctrineSkillLines,
14
15
  } from "./setup-ai-contract.mjs";
15
16
  import { buildSetupDocumentationContract } from "./setup-documents.mjs";
@@ -54,6 +55,9 @@ const skillBody = (name, { documentsUrl } = {}) => {
54
55
  )
55
56
  .map(([framework, docId]) => `${framework} -> ${docId}`)
56
57
  .join(", ");
58
+ const setupAgentQualityGateIds = SETUP_AGENT_QUALITY_GATES.map(
59
+ (gate) => gate.id,
60
+ ).join(", ");
57
61
  const descriptions = {
58
62
  "clue-setup-orchestrator":
59
63
  "Use first when running Clue setup so lifecycle placement remains the only implementation workstream and read-only checks stay separate.",
@@ -327,6 +331,7 @@ const skillBody = (name, { documentsUrl } = {}) => {
327
331
  "For Next.js browser/client code, use only `NEXT_PUBLIC_CLUE_PROJECT_KEY`, `NEXT_PUBLIC_CLUE_ENVIRONMENT`, `NEXT_PUBLIC_CLUE_SERVICE_KEY`, `NEXT_PUBLIC_CLUE_INGEST_ENDPOINT`, and `NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT` from the frontend `.env.local` block.",
328
332
  "Do not read `process.env.CLUE_PROJECT_KEY`, `process.env.CLUE_ENVIRONMENT`, `process.env.CLUE_SERVICE_KEY`, or `process.env.CLUE_INGEST_ENDPOINT` in Next.js browser/client code, and do not add non-public `CLUE_*` fallbacks there.",
329
333
  "Frontend SDK adapter code is contract-owned Clue setup wiring. The AI may choose the existing import/mount point, but must not invent token URL, env, or initialization semantics.",
334
+ "Next.js browser SDK adapter files must start with `\"use client\"` so Clue browser SDK code is not imported through a server component module.",
330
335
  "For Next.js frontend adapters, read the full customer-backend browser-token proxy URL from `NEXT_PUBLIC_CLUE_BROWSER_TOKEN_ENDPOINT`. Do not derive it from `NEXT_PUBLIC_API_URL`, generic app API env names, detected backend ports, or relative frontend-origin paths.",
331
336
  "Do not mix stale browser-token paths such as `/api/clue/browser-token`, `/clue/browser-tokens`, or `/browser-tokens` with the canonical `/api/v1/clue/browser-tokens` path.",
332
337
  "Do not call `ClueInit` with empty-string fallbacks for required `NEXT_PUBLIC_CLUE_*` values. If required Clue env is absent, skip initialization and report the missing env names.",
@@ -340,8 +345,12 @@ const skillBody = (name, { documentsUrl } = {}) => {
340
345
  "The browser token request must include the frontend service key used by `ClueInit`. Project key and environment may be included only as public consistency hints; the backend must use server configuration or validate them against server configuration before calling Clue.",
341
346
  "The backend browser token proxy must derive origin from trusted request headers or server request metadata. Do not forward `origin`, `projectKey`, or `environment` from JSON/body payload fields under server `CLUE_API_KEY`.",
342
347
  "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`.",
348
+ "When the backend browser token proxy calls Clue, send the server `CLUE_API_KEY` as the `x-clue-api-key` header. Do not use `Authorization: Bearer`, query parameters, or JSON body fields for the Clue API key.",
349
+ "Do not introduce non-Clue HTTP client dependencies for browser-token proxy code unless they already exist in dependency files. For Python, prefer an existing HTTP client or standard library instead of importing undeclared `httpx` or `requests`.",
343
350
  "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.",
344
351
  "For FastAPI code, add unpinned `clue-fastapi-sdk` to the backend dependency file when missing so pip resolves the latest release, import `clue_init_fastapi` plus `ClueIdentify`, `ClueSetAccount`, and `ClueLogout` where needed, and use `CLUE_PROJECT_KEY`, `CLUE_ENVIRONMENT`, `CLUE_API_KEY`, and `CLUE_INGEST_ENDPOINT` from the backend env block.",
352
+ "Do not use `os.environ[\"CLUE_*\"]` required indexing for Clue env values; Clue setup must not crash the host service when env is missing.",
353
+ "FastAPI `clue_init_fastapi` must pass `service_key` from the setup manifest/`CLUE_SERVICE_KEY`; do not rely on `service_name` alone because setup-watch targets are keyed by service key.",
345
354
  "`CLUE_API_BASE_URL` is not part of backend SDK initialization. Use it only when the application backend owns a browser-token proxy for a frontend service.",
346
355
  "Python backend SDK dependencies must not be pinned.",
347
356
  "`@clue-ai/browser-sdk` must use `latest`.",
@@ -374,11 +383,15 @@ const skillBody = (name, { documentsUrl } = {}) => {
374
383
  "Reject wrong SDK package names. Frontend must use `@clue-ai/browser-sdk`; FastAPI must use `clue-fastapi-sdk`; Django must use `clue-django-sdk`.",
375
384
  "Reject Django SDK setup when `clue-django-sdk` installability has not been verified.",
376
385
  "Reject backend setup when backend routes exist but no backend Clue SDK dependency/import/init was added.",
386
+ "Reject backend Clue setup code that uses `os.environ[\"CLUE_*\"]` required indexing instead of non-crashing env reads.",
377
387
  "Reject awaited lifecycle calls that can block host service behavior.",
378
388
  "Reject browser token proxy code that forwards origin, projectKey, or environment from request JSON/body under server `CLUE_API_KEY`.",
389
+ "Reject browser token proxy code that sends `CLUE_API_KEY` as `Authorization: Bearer`, query parameters, or JSON body fields instead of the `x-clue-api-key` header.",
390
+ "Reject browser token proxy code that imports undeclared HTTP client dependencies such as `httpx` or `requests`.",
379
391
  "Reject frontend browser token providers that derive the Clue proxy URL from `NEXT_PUBLIC_API_URL`, generic app API env names, detected backend ports, or non-Clue routing assumptions.",
380
392
  "Reject frontend adapters that mix stale browser-token paths such as `/api/clue/browser-token`, `/clue/browser-tokens`, or `/browser-tokens` with the canonical `/api/v1/clue/browser-tokens` path.",
381
393
  "Reject frontend adapters that set `initialized = true` before calling `ClueInit`, or pass empty-string fallbacks for required `NEXT_PUBLIC_CLUE_*` values into `ClueInit`.",
394
+ "Reject Next.js browser SDK adapter files that omit `\"use client\"`.",
382
395
  "Audit the setup diff against the Clue setup contract even when the code was written by another agent or an earlier pass. Ownership of authorship is irrelevant to approval.",
383
396
  "Reject setup that covers only one login path when multiple login success paths are clearly present.",
384
397
  "Reject ClueInit inside React component lifecycle hooks, page components, sidebars, login/register success callbacks, or any repeated user interaction path.",
@@ -485,6 +498,7 @@ const skillBody = (name, { documentsUrl } = {}) => {
485
498
  "- The full setup must start with `clue-setup-orchestrator`.",
486
499
  "- The implementation agent owns only lifecycle placement; monitoring agents review one surface at a time and report P0/P1 issues.",
487
500
  "- Do not continue past a P0/P1 monitoring finding until it is fixed or explicitly reported as blocked.",
501
+ `- setup-agent quality gates must be reported honestly when available: ${setupAgentQualityGateIds}. A skipped setup-doctor gate is not API connectivity verification.`,
488
502
  "- If subagents are unavailable, run the same structure as separate named review passes and say so in the final report.",
489
503
  "- Do not expose project keys, API keys, secrets, tokens, or environment variable values.",
490
504
  "- Do not ask the user to paste secret values.",