@momentiq/dark-factory-cli 0.1.0-alpha.4

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.
Files changed (132) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +150 -0
  3. package/dist/adapters/_shared.d.ts +45 -0
  4. package/dist/adapters/_shared.d.ts.map +1 -0
  5. package/dist/adapters/_shared.js +200 -0
  6. package/dist/adapters/_shared.js.map +1 -0
  7. package/dist/adapters/codex-sdk.d.ts +279 -0
  8. package/dist/adapters/codex-sdk.d.ts.map +1 -0
  9. package/dist/adapters/codex-sdk.js +930 -0
  10. package/dist/adapters/codex-sdk.js.map +1 -0
  11. package/dist/adapters/critic-result-schema.d.ts +215 -0
  12. package/dist/adapters/critic-result-schema.d.ts.map +1 -0
  13. package/dist/adapters/critic-result-schema.js +153 -0
  14. package/dist/adapters/critic-result-schema.js.map +1 -0
  15. package/dist/adapters/critic.d.ts +62 -0
  16. package/dist/adapters/critic.d.ts.map +1 -0
  17. package/dist/adapters/critic.js +79 -0
  18. package/dist/adapters/critic.js.map +1 -0
  19. package/dist/adapters/cursor-sdk.d.ts +153 -0
  20. package/dist/adapters/cursor-sdk.d.ts.map +1 -0
  21. package/dist/adapters/cursor-sdk.js +818 -0
  22. package/dist/adapters/cursor-sdk.js.map +1 -0
  23. package/dist/adapters/gemini-sdk.d.ts +113 -0
  24. package/dist/adapters/gemini-sdk.d.ts.map +1 -0
  25. package/dist/adapters/gemini-sdk.js +532 -0
  26. package/dist/adapters/gemini-sdk.js.map +1 -0
  27. package/dist/adapters/grok-direct-sdk.d.ts +148 -0
  28. package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
  29. package/dist/adapters/grok-direct-sdk.js +694 -0
  30. package/dist/adapters/grok-direct-sdk.js.map +1 -0
  31. package/dist/branch-protection/audit_branch_protection.py +759 -0
  32. package/dist/branch-protection/index.d.ts +25 -0
  33. package/dist/branch-protection/index.d.ts.map +1 -0
  34. package/dist/branch-protection/index.js +70 -0
  35. package/dist/branch-protection/index.js.map +1 -0
  36. package/dist/branch-protection/spec-default.yaml +314 -0
  37. package/dist/cli.d.ts +3 -0
  38. package/dist/cli.d.ts.map +1 -0
  39. package/dist/cli.js +581 -0
  40. package/dist/cli.js.map +1 -0
  41. package/dist/cycle-doc-validator/index.d.ts +43 -0
  42. package/dist/cycle-doc-validator/index.d.ts.map +1 -0
  43. package/dist/cycle-doc-validator/index.js +69 -0
  44. package/dist/cycle-doc-validator/index.js.map +1 -0
  45. package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
  46. package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
  47. package/dist/cycle-tracker-sync/index.d.ts +20 -0
  48. package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
  49. package/dist/cycle-tracker-sync/index.js +71 -0
  50. package/dist/cycle-tracker-sync/index.js.map +1 -0
  51. package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
  52. package/dist/evidence/audit-trail.d.ts +59 -0
  53. package/dist/evidence/audit-trail.d.ts.map +1 -0
  54. package/dist/evidence/audit-trail.js +283 -0
  55. package/dist/evidence/audit-trail.js.map +1 -0
  56. package/dist/evidence/index.d.ts +4 -0
  57. package/dist/evidence/index.d.ts.map +1 -0
  58. package/dist/evidence/index.js +29 -0
  59. package/dist/evidence/index.js.map +1 -0
  60. package/dist/evidence/per-sha.d.ts +13 -0
  61. package/dist/evidence/per-sha.d.ts.map +1 -0
  62. package/dist/evidence/per-sha.js +97 -0
  63. package/dist/evidence/per-sha.js.map +1 -0
  64. package/dist/evidence/quality-gates.d.ts +19 -0
  65. package/dist/evidence/quality-gates.d.ts.map +1 -0
  66. package/dist/evidence/quality-gates.js +212 -0
  67. package/dist/evidence/quality-gates.js.map +1 -0
  68. package/dist/git.d.ts +40 -0
  69. package/dist/git.d.ts.map +1 -0
  70. package/dist/git.js +414 -0
  71. package/dist/git.js.map +1 -0
  72. package/dist/glob.d.ts +4 -0
  73. package/dist/glob.d.ts.map +1 -0
  74. package/dist/glob.js +99 -0
  75. package/dist/glob.js.map +1 -0
  76. package/dist/index.d.ts +17 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +33 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/paths.d.ts +12 -0
  81. package/dist/paths.d.ts.map +1 -0
  82. package/dist/paths.js +42 -0
  83. package/dist/paths.js.map +1 -0
  84. package/dist/policy/baseline.d.ts +15 -0
  85. package/dist/policy/baseline.d.ts.map +1 -0
  86. package/dist/policy/baseline.js +115 -0
  87. package/dist/policy/baseline.js.map +1 -0
  88. package/dist/policy/config.d.ts +64 -0
  89. package/dist/policy/config.d.ts.map +1 -0
  90. package/dist/policy/config.js +363 -0
  91. package/dist/policy/config.js.map +1 -0
  92. package/dist/policy/gate.d.ts +80 -0
  93. package/dist/policy/gate.d.ts.map +1 -0
  94. package/dist/policy/gate.js +1019 -0
  95. package/dist/policy/gate.js.map +1 -0
  96. package/dist/policy/index.d.ts +7 -0
  97. package/dist/policy/index.d.ts.map +1 -0
  98. package/dist/policy/index.js +14 -0
  99. package/dist/policy/index.js.map +1 -0
  100. package/dist/policy/merge-queue.d.ts +183 -0
  101. package/dist/policy/merge-queue.d.ts.map +1 -0
  102. package/dist/policy/merge-queue.js +310 -0
  103. package/dist/policy/merge-queue.js.map +1 -0
  104. package/dist/policy/profile.d.ts +98 -0
  105. package/dist/policy/profile.d.ts.map +1 -0
  106. package/dist/policy/profile.js +156 -0
  107. package/dist/policy/profile.js.map +1 -0
  108. package/dist/policy/tdd-classifier.d.ts +18 -0
  109. package/dist/policy/tdd-classifier.d.ts.map +1 -0
  110. package/dist/policy/tdd-classifier.js +79 -0
  111. package/dist/policy/tdd-classifier.js.map +1 -0
  112. package/dist/prompt.d.ts +13 -0
  113. package/dist/prompt.d.ts.map +1 -0
  114. package/dist/prompt.js +175 -0
  115. package/dist/prompt.js.map +1 -0
  116. package/dist/report.d.ts +88 -0
  117. package/dist/report.d.ts.map +1 -0
  118. package/dist/report.js +376 -0
  119. package/dist/report.js.map +1 -0
  120. package/dist/runner.d.ts +30 -0
  121. package/dist/runner.d.ts.map +1 -0
  122. package/dist/runner.js +456 -0
  123. package/dist/runner.js.map +1 -0
  124. package/dist/security.d.ts +2 -0
  125. package/dist/security.d.ts.map +1 -0
  126. package/dist/security.js +19 -0
  127. package/dist/security.js.map +1 -0
  128. package/dist/trusted-surface/rebind.d.ts +10 -0
  129. package/dist/trusted-surface/rebind.d.ts.map +1 -0
  130. package/dist/trusted-surface/rebind.js +154 -0
  131. package/dist/trusted-surface/rebind.js.map +1 -0
  132. package/package.json +78 -0
@@ -0,0 +1,818 @@
1
+ import { Agent } from "@cursor/sdk";
2
+ import { compileCriticPrompt } from "../prompt.js";
3
+ import { parseCriticResult, } from "@momentiq/dark-factory-schemas";
4
+ import { buildErrorResult, mergeAdapterMetadata, normalizeCriticEcho, parseAssistantJson, shouldEnableCursorSandbox, writeRedactedDiagnostic, } from "./_shared.js";
5
+ // Re-export for backwards compatibility — `normalizeCriticEcho` originally
6
+ // lived here. Existing imports from `cursor-sdk.js` (e.g. cursor-adapter
7
+ // tests) continue to work after the move to `_shared.ts` (issue #1484).
8
+ export { normalizeCriticEcho } from "./_shared.js";
9
+ export const CURSOR_SDK_ADAPTER_ID = "cursor-sdk";
10
+ export const CURSOR_API_KEY_ENV = "CURSOR_API_KEY";
11
+ // Cycle 322.1 — bounded retry policy for the Cursor SDK adapter.
12
+ //
13
+ // The Cursor SDK delivers terminal upstream failures as a normal
14
+ // `RunResult.status === "error"` (no thrown exception). The richer
15
+ // signal — `LocalRunStreamResultEvent.errorCode` and the streamed
16
+ // `SDKStatusMessage` — is what tells operators whether the failure is
17
+ // transient (capacity_exceeded / upstream_timeout) or permanent
18
+ // (auth_failed / quota_exceeded). Capturing both and retrying ONLY
19
+ // transient failures replaces the prior "any terminal error → gate
20
+ // blocks → emergency bypass" anti-pattern with a sanctioned, bounded,
21
+ // observable recovery path.
22
+ //
23
+ // Empirical signal: 26/27 transient terminal-error runs observed
24
+ // over a 4-day window succeeded on retry with the same prompt
25
+ // within 1–5 minutes. The fixed `[5s, 15s]` schedule covers that
26
+ // recovery window without trading too much wall-clock for tail
27
+ // success.
28
+ //
29
+ // Total budget: 20s across 2 retries (3 attempts total). If a vendor
30
+ // outage outlives 20s, the gate blocks deterministically with an
31
+ // actionable `errorCode` instead of returning APPROVED on stale data.
32
+ export const RETRY_BACKOFF_MS = Object.freeze([5_000, 15_000]);
33
+ // Error codes that are NOT retryable. A terminal failure matching one
34
+ // of these proceeds directly to error-result construction without a
35
+ // retry. These are permanent failures where retrying wastes budget
36
+ // AND can mask the real fault (e.g., a wrong API key would silently
37
+ // burn 20s of retries before surfacing the auth issue).
38
+ //
39
+ // The set is intentionally narrow — anything not on this list is
40
+ // treated as retryable when accompanied by a runId (indicating the
41
+ // SDK accepted the request and the failure happened upstream).
42
+ export const PERMANENT_ERROR_CODES = new Set([
43
+ "auth_failed",
44
+ "invalid_api_key",
45
+ "quota_exceeded",
46
+ "model_not_found",
47
+ "content_policy_violation",
48
+ "invalid_request",
49
+ "context_length_exceeded",
50
+ ]);
51
+ /**
52
+ * Cycle 322.1 — Pure retry-loop runner.
53
+ *
54
+ * Drives a sequence of `attempt(idx)` calls under the
55
+ * {@link RETRY_BACKOFF_MS} schedule, dispatching on the returned
56
+ * {@link AttemptOutcome}:
57
+ * - `success` / `permanent_failure` → return immediately, no more
58
+ * attempts.
59
+ * - `retryable_failure` → record the failure and (if budget
60
+ * remains) sleep + try again.
61
+ *
62
+ * Honors `signal` between attempts and during backoff sleeps; on
63
+ * abort, builds a terminal result via `buildExhausted` with the
64
+ * last failure context (so callers can surface "what was the last
65
+ * upstream error" even when cancellation cut the loop short).
66
+ *
67
+ * Extracted as a free function so:
68
+ * - 322.2 Gemini and 322.3 Grok adapters inherit the retry pattern
69
+ * without copy-paste drift.
70
+ * - The loop is unit-testable with scripted outcomes + a mock
71
+ * `sleep` (see `tests/cursor-retry-loop.test.ts`) — no SDK mock
72
+ * required.
73
+ */
74
+ export async function runRetryLoop(args) {
75
+ const maxAttempts = RETRY_BACKOFF_MS.length + 1;
76
+ const sleep = args.sleep ?? sleepForRetry;
77
+ let attempt = 0;
78
+ let lastFailure = null;
79
+ let aborted = false;
80
+ while (attempt < maxAttempts) {
81
+ if (args.signal?.aborted) {
82
+ aborted = true;
83
+ break;
84
+ }
85
+ const outcome = await args.attempt(attempt);
86
+ if (outcome.kind === "success")
87
+ return outcome.result;
88
+ if (outcome.kind === "permanent_failure")
89
+ return outcome.result;
90
+ lastFailure = outcome;
91
+ // Sleep only if we still have retries left. After the last
92
+ // attempt (idx === RETRY_BACKOFF_MS.length), no sleep — fall out
93
+ // to exhausted path immediately.
94
+ if (attempt < RETRY_BACKOFF_MS.length) {
95
+ try {
96
+ await sleep(attempt, args.signal);
97
+ }
98
+ catch (err) {
99
+ if (err.name === "AbortError") {
100
+ aborted = true;
101
+ break;
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+ attempt++;
107
+ }
108
+ return args.buildExhausted({ last: lastFailure, totalAttempts: attempt, aborted });
109
+ }
110
+ export class CursorSdkAdapter {
111
+ options;
112
+ id = CURSOR_SDK_ADAPTER_ID;
113
+ // Cycle 322.2 Component 3 — declared so the CLI's
114
+ // `maybeReexecUnderDoppler` can walk required critics' adapters and
115
+ // re-exec under `doppler run` when a required critic key is missing.
116
+ requiredEnvVars = [CURSOR_API_KEY_ENV];
117
+ constructor(options = {}) {
118
+ this.options = options;
119
+ }
120
+ async review(packet, critic, options) {
121
+ // Cycle 322.1 — bounded retry across the Cursor SDK's transient
122
+ // failure surface. Total budget = sum(RETRY_BACKOFF_MS) = 20s
123
+ // across 2 retries (3 attempts). Per-attempt telemetry is emitted
124
+ // inside `attemptReview` so the loop itself stays pure.
125
+ return runRetryLoop({
126
+ attempt: (idx) => this.attemptReview(packet, critic, options, idx),
127
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
128
+ buildExhausted: ({ last, totalAttempts, aborted }) => {
129
+ // `totalAttempts` is the post-increment attempt index when the
130
+ // loop ended naturally (= RETRY_BACKOFF_MS.length + 1 = 3 on
131
+ // full exhaustion). On abort it's the index reached before
132
+ // the abort. Either way the highest *retry index* used is
133
+ // `totalAttempts - 1` (clamped to ≥ 0).
134
+ const retriesUsed = Math.max(0, totalAttempts - 1);
135
+ const summary = aborted
136
+ ? last
137
+ ? `cursor SDK run aborted after ${retriesUsed} retries: ${last.message}`
138
+ : "cursor SDK run aborted before any attempt completed"
139
+ : last
140
+ ? `cursor SDK run failed after ${retriesUsed} retries: ${last.message}`
141
+ : // Shouldn't happen — loop exhaustion without a failure
142
+ // means attempt() returned non-retryable for all attempts,
143
+ // which would have returned early. Defensive fallback.
144
+ "cursor SDK run failed with no captured failure metadata";
145
+ return buildErrorResult({
146
+ critic,
147
+ message: summary,
148
+ retryable: true,
149
+ ...(last?.errorCode != null ? { code: last.errorCode } : {}),
150
+ retryCount: retriesUsed,
151
+ ...(last?.agentId !== null && last?.agentId !== undefined ? { agentId: last.agentId } : {}),
152
+ ...(last?.runId !== null && last?.runId !== undefined ? { runId: last.runId } : {}),
153
+ });
154
+ },
155
+ });
156
+ }
157
+ // Cycle 322.1 — one attempt. Pure-ish: emits its own telemetry
158
+ // event for terminal outcomes (success, permanent_failure,
159
+ // retryable_failure), returns a tagged AttemptOutcome the outer
160
+ // loop dispatches on. The `attemptIdx` is woven into telemetry
161
+ // (`retryCount`) and into the success result (`retryCount` on
162
+ // `critic_run_finished`).
163
+ async attemptReview(packet, critic, options, attemptIdx) {
164
+ const apiKey = this.options.apiKey ?? process.env["CURSOR_API_KEY"];
165
+ if (!apiKey) {
166
+ // Missing key is a permanent failure regardless of attempt — no
167
+ // retry can fix a missing secret. Skip the retry policy and
168
+ // return immediately with a permanent envelope.
169
+ return {
170
+ kind: "permanent_failure",
171
+ errorCode: null,
172
+ statusMessage: null,
173
+ result: buildErrorResult({
174
+ critic,
175
+ message: "CURSOR_API_KEY is not set; cannot run Cursor critic",
176
+ retryable: false,
177
+ retryCount: attemptIdx,
178
+ }),
179
+ };
180
+ }
181
+ const prompt = compileCriticPrompt({
182
+ packet,
183
+ critic,
184
+ blockingSeverities: options.blockingSeverities,
185
+ treatDiffAsUntrusted: true,
186
+ });
187
+ const startMs = Date.now();
188
+ // Emit `critic_run_started` only on the first attempt; retries
189
+ // are NOT independent reviews, they're continuations of the
190
+ // single review the runner kicked off, so double-counting starts
191
+ // would mis-attribute attempt counts in `agent-review-stats`.
192
+ if (attemptIdx === 0) {
193
+ options.emit?.({
194
+ ts: new Date().toISOString(),
195
+ event: "critic_run_started",
196
+ commit: packet.commit.sha,
197
+ criticId: critic.id,
198
+ adapter: this.id,
199
+ model: critic.model.id,
200
+ });
201
+ }
202
+ let agent;
203
+ let agentId;
204
+ let runId;
205
+ let assistantText = "";
206
+ // Capture SDKStatusMessage events as they stream; the last one
207
+ // before terminal status carries the SDK's own diagnostic when an
208
+ // upstream incident is in progress.
209
+ let lastStatusMessage = null;
210
+ try {
211
+ const localOptions = buildLocalOptions(packet.repoRoot);
212
+ agent = await Agent.create({
213
+ apiKey,
214
+ model: buildModelSelection(critic),
215
+ local: localOptions,
216
+ });
217
+ agentId = agent.id;
218
+ }
219
+ catch (err) {
220
+ const e = err;
221
+ options.emit?.({
222
+ ts: new Date().toISOString(),
223
+ event: "critic_run_error",
224
+ commit: packet.commit.sha,
225
+ criticId: critic.id,
226
+ adapter: this.id,
227
+ model: critic.model.id,
228
+ durationMs: Date.now() - startMs,
229
+ error: e.message,
230
+ status: "startup_failure",
231
+ retryCount: attemptIdx,
232
+ });
233
+ // Adapter init failure has no runId, so `shouldRetryRunFailure`
234
+ // would deny retry. Treat as retryable_failure here (the
235
+ // network blip case is genuine) — the outer loop respects
236
+ // attempt count and stops naturally; without a runId there's
237
+ // no upstream state to be ambiguous about.
238
+ return {
239
+ kind: "retryable_failure",
240
+ errorCode: null,
241
+ statusMessage: null,
242
+ message: `cursor SDK startup failed: ${e.message}`,
243
+ runId: null,
244
+ agentId: agentId ?? null,
245
+ };
246
+ }
247
+ let runStatus;
248
+ let terminalResult = null;
249
+ try {
250
+ const sendable = agent;
251
+ const run = (await sendable.send(prompt.text));
252
+ runId = run.id;
253
+ for await (const event of run.stream()) {
254
+ if (options.signal?.aborted)
255
+ break;
256
+ const ids = extractIds(event);
257
+ if (!agentId && ids.agentId)
258
+ agentId = ids.agentId;
259
+ if (!runId && ids.runId)
260
+ runId = ids.runId;
261
+ const text = extractAssistantText(event);
262
+ if (text)
263
+ assistantText += text;
264
+ // Cycle 322.1 — capture status-message stream events. Each
265
+ // overwrites the previous so we end with the LAST status the
266
+ // SDK emitted, which is the most relevant signal for the
267
+ // terminal outcome.
268
+ const status = extractStatusMessage(event);
269
+ if (status)
270
+ lastStatusMessage = status;
271
+ }
272
+ terminalResult = await run.wait();
273
+ runStatus = terminalResult.status;
274
+ const statusError = checkRunFinished(runStatus);
275
+ if (statusError)
276
+ throw new Error(statusError);
277
+ }
278
+ catch (err) {
279
+ const e = err;
280
+ const errorCode = extractRunErrorCode(terminalResult);
281
+ // Apply the retry policy — does this failure pattern allow
282
+ // another attempt? If not, return a permanent envelope; the
283
+ // outer loop will surface this immediately without sleeping.
284
+ const retryAllowed = shouldRetryRunFailure({
285
+ result: terminalResult,
286
+ errorCode,
287
+ runId: runId ?? null,
288
+ });
289
+ options.emit?.({
290
+ ts: new Date().toISOString(),
291
+ event: "critic_run_error",
292
+ commit: packet.commit.sha,
293
+ criticId: critic.id,
294
+ adapter: this.id,
295
+ model: critic.model.id,
296
+ ...(agentId !== undefined ? { agentId } : {}),
297
+ ...(runId !== undefined ? { runId } : {}),
298
+ durationMs: Date.now() - startMs,
299
+ error: e.message,
300
+ status: "run_failure",
301
+ retryCount: attemptIdx,
302
+ ...(errorCode !== null ? { errorCode } : {}),
303
+ ...(lastStatusMessage !== null ? { statusMessage: lastStatusMessage } : {}),
304
+ });
305
+ await disposeAgent(agent);
306
+ if (!retryAllowed) {
307
+ return {
308
+ kind: "permanent_failure",
309
+ errorCode,
310
+ statusMessage: lastStatusMessage,
311
+ result: buildErrorResult({
312
+ critic,
313
+ message: `cursor SDK run failed (permanent): ${e.message}`,
314
+ retryable: false,
315
+ ...(agentId !== undefined ? { agentId } : {}),
316
+ ...(runId !== undefined ? { runId } : {}),
317
+ ...(errorCode !== null ? { code: errorCode } : {}),
318
+ retryCount: attemptIdx,
319
+ }),
320
+ };
321
+ }
322
+ return {
323
+ kind: "retryable_failure",
324
+ errorCode,
325
+ statusMessage: lastStatusMessage,
326
+ message: `cursor SDK run failed: ${e.message}`,
327
+ runId: runId ?? null,
328
+ agentId: agentId ?? null,
329
+ };
330
+ }
331
+ await disposeAgent(agent);
332
+ const parseOutcome = parseAssistantJson(assistantText);
333
+ if (!parseOutcome.ok) {
334
+ const diagPath = writeRedactedDiagnostic({
335
+ diagnosticsDir: options.diagnosticsDir,
336
+ criticId: critic.id,
337
+ commit: packet.commit.sha,
338
+ rawText: assistantText,
339
+ });
340
+ options.emit?.({
341
+ ts: new Date().toISOString(),
342
+ event: "critic_run_error",
343
+ commit: packet.commit.sha,
344
+ criticId: critic.id,
345
+ adapter: this.id,
346
+ model: critic.model.id,
347
+ ...(agentId !== undefined ? { agentId } : {}),
348
+ ...(runId !== undefined ? { runId } : {}),
349
+ durationMs: Date.now() - startMs,
350
+ error: `invalid critic JSON: ${parseOutcome.message}`,
351
+ status: "invalid_json",
352
+ retryCount: attemptIdx,
353
+ });
354
+ // JSON parse failure is a permanent failure for this attempt —
355
+ // the SDK succeeded but the model returned malformed output.
356
+ // Retrying typically produces the same malformed output (the
357
+ // model isn't aware of its own parse failure), and a retry
358
+ // budget burnt on bad JSON is budget not available for a real
359
+ // transient upstream incident.
360
+ return {
361
+ kind: "permanent_failure",
362
+ errorCode: null,
363
+ statusMessage: lastStatusMessage,
364
+ result: buildErrorResult({
365
+ critic,
366
+ message: `cursor critic returned invalid JSON: ${parseOutcome.message}`,
367
+ retryable: false,
368
+ ...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
369
+ ...(agentId !== undefined ? { agentId } : {}),
370
+ ...(runId !== undefined ? { runId } : {}),
371
+ retryCount: attemptIdx,
372
+ }),
373
+ };
374
+ }
375
+ let result;
376
+ try {
377
+ const baseRaw = parseOutcome.value;
378
+ const normalized = normalizeCriticEcho(baseRaw);
379
+ const runtimeModel = extractRuntimeModel(agent);
380
+ const enriched = mergeAdapterMetadata(normalized, {
381
+ critic,
382
+ ...(runtimeModel !== undefined ? { runtimeModel } : {}),
383
+ ...(agentId !== undefined ? { agentId } : {}),
384
+ ...(runId !== undefined ? { runId } : {}),
385
+ });
386
+ result = parseCriticResult(enriched, options.blockingSeverities);
387
+ }
388
+ catch (err) {
389
+ const e = err;
390
+ const diagPath = writeRedactedDiagnostic({
391
+ diagnosticsDir: options.diagnosticsDir,
392
+ criticId: critic.id,
393
+ commit: packet.commit.sha,
394
+ rawText: assistantText,
395
+ });
396
+ options.emit?.({
397
+ ts: new Date().toISOString(),
398
+ event: "critic_run_error",
399
+ commit: packet.commit.sha,
400
+ criticId: critic.id,
401
+ adapter: this.id,
402
+ model: critic.model.id,
403
+ ...(agentId !== undefined ? { agentId } : {}),
404
+ ...(runId !== undefined ? { runId } : {}),
405
+ durationMs: Date.now() - startMs,
406
+ error: `schema validation failed: ${e.message}`,
407
+ status: "schema_violation",
408
+ retryCount: attemptIdx,
409
+ });
410
+ return {
411
+ kind: "permanent_failure",
412
+ errorCode: null,
413
+ statusMessage: lastStatusMessage,
414
+ result: buildErrorResult({
415
+ critic,
416
+ message: `cursor critic JSON failed schema validation: ${e.message}`,
417
+ retryable: false,
418
+ ...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
419
+ ...(agentId !== undefined ? { agentId } : {}),
420
+ ...(runId !== undefined ? { runId } : {}),
421
+ retryCount: attemptIdx,
422
+ }),
423
+ };
424
+ }
425
+ const durationMs = Date.now() - startMs;
426
+ // Replace the critic's echoed validation block with the truth: the
427
+ // deterministic evidence the packet carried. The critic's echo is
428
+ // accepted leniently above for legacy/varying responses but the
429
+ // authoritative copy comes from us.
430
+ const enriched = {
431
+ ...result,
432
+ durationMs,
433
+ validation: {
434
+ qualityGateResults: packet.validation.evidence,
435
+ qualityGatesMissing: packet.validation.missing,
436
+ },
437
+ };
438
+ const blockerCount = enriched.findings.filter((f) => f.severity === "blocker").length;
439
+ const highCount = enriched.findings.filter((f) => f.severity === "high").length;
440
+ options.emit?.({
441
+ ts: new Date().toISOString(),
442
+ event: "critic_run_finished",
443
+ commit: packet.commit.sha,
444
+ criticId: critic.id,
445
+ adapter: this.id,
446
+ model: critic.model.id,
447
+ ...(agentId !== undefined ? { agentId } : {}),
448
+ ...(runId !== undefined ? { runId } : {}),
449
+ durationMs,
450
+ ...(enriched.verdict !== undefined ? { verdict: enriched.verdict } : {}),
451
+ findingCount: enriched.findings.length,
452
+ blockerCount,
453
+ highCount,
454
+ status: runStatus ?? "complete",
455
+ retryCount: attemptIdx,
456
+ });
457
+ return { kind: "success", result: enriched };
458
+ }
459
+ async doctor(critic) {
460
+ const checks = [];
461
+ const apiKey = this.options.apiKey ?? process.env["CURSOR_API_KEY"];
462
+ checks.push({
463
+ name: "cursor_api_key",
464
+ passed: Boolean(apiKey),
465
+ detail: apiKey ? "CURSOR_API_KEY present" : "CURSOR_API_KEY missing",
466
+ ...(apiKey ? {} : { remediation: "export CURSOR_API_KEY=..." }),
467
+ });
468
+ let cursorModule = {};
469
+ try {
470
+ cursorModule = (await import("@cursor/sdk"));
471
+ }
472
+ catch (err) {
473
+ checks.push({
474
+ name: "cursor_sdk_loaded",
475
+ passed: false,
476
+ detail: `failed to import @cursor/sdk: ${err.message}`,
477
+ remediation: "make agent-review-deps && make agent-review-build",
478
+ });
479
+ return checks;
480
+ }
481
+ checks.push({
482
+ name: "cursor_sdk_loaded",
483
+ passed: true,
484
+ detail: "@cursor/sdk imported",
485
+ });
486
+ const cursor = cursorModule["Cursor"];
487
+ const list = cursor?.models?.list;
488
+ if (typeof list !== "function") {
489
+ checks.push({
490
+ name: "cursor_model_listing",
491
+ passed: false,
492
+ detail: "Cursor.models.list is not exposed by this SDK version; cannot verify model id",
493
+ remediation: "Run the Cursor SDK spike (make agent-review-spike) and update the doctor check to match the SDK shape.",
494
+ });
495
+ return checks;
496
+ }
497
+ if (!apiKey)
498
+ return checks;
499
+ try {
500
+ const models = await list();
501
+ const ids = models.map((m) => m.id ?? "");
502
+ const matched = ids.includes(critic.model.id);
503
+ checks.push({
504
+ name: "cursor_model_id",
505
+ passed: matched,
506
+ detail: matched
507
+ ? `model ${critic.model.id} available`
508
+ : `model ${critic.model.id} not in available list (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? "..." : ""})`,
509
+ ...(matched ? {} : { remediation: "update .agent-review/config.json critic.model.id" }),
510
+ });
511
+ }
512
+ catch (err) {
513
+ checks.push({
514
+ name: "cursor_model_id",
515
+ passed: false,
516
+ detail: `Cursor.models.list() failed: ${err.message}`,
517
+ remediation: "verify CURSOR_API_KEY and network connectivity",
518
+ });
519
+ }
520
+ return checks;
521
+ }
522
+ }
523
+ function buildLocalOptions(cwd) {
524
+ // Empty settingSources isolates the critic from project/user/team Cursor settings
525
+ // so reviews are reproducible across workstations.
526
+ // sandboxOptions.enabled is the SDK's defense-in-depth toggle — the critic
527
+ // must never write files locally even if a malicious diff convinces the
528
+ // model to try. On CI runners (#1577) the SDK's sandbox primitive is
529
+ // unavailable; `shouldEnableCursorSandbox` (in `_shared.ts`) detects that
530
+ // environment via `CI` / `GITHUB_ACTIONS` env vars and disables the toggle
531
+ // so the SDK doesn't fail the run with "sandboxing not supported".
532
+ return {
533
+ cwd,
534
+ settingSources: [],
535
+ sandboxOptions: { enabled: shouldEnableCursorSandbox() },
536
+ };
537
+ }
538
+ // Pass model id AND configured params to the Cursor SDK. Without this, the
539
+ // SDK falls back to the model's default variant (e.g., gpt-5.5 default is
540
+ // reasoning=medium even when config asks for extra-high), and the critic runs
541
+ // at a weaker tier than configured. The reviewer-metadata echo would still
542
+ // claim the configured params, so the artifact would silently lie.
543
+ //
544
+ // SDK shape (`ModelParameterValue`) requires `value: string`; our schema allows
545
+ // `string | number | boolean` so we coerce here. Booleans serialize to
546
+ // "true"/"false" matching Cursor's model-list values.
547
+ export function buildModelSelection(critic) {
548
+ const sel = { id: critic.model.id };
549
+ if (critic.model.params && critic.model.params.length > 0) {
550
+ sel.params = critic.model.params.map((p) => ({ id: p.id, value: String(p.value) }));
551
+ }
552
+ return sel;
553
+ }
554
+ // Extract only ASSISTANT message text from the stream. The SDK also emits
555
+ // `thinking`, `status`, `tool_call`, `task`, etc.; capturing those pollutes the
556
+ // output and breaks JSON parsing. Empirical SDK shapes are pinned in
557
+ // fixtures/spike-2026-05-03.json.
558
+ function extractAssistantText(event) {
559
+ if (typeof event !== "object" || event === null)
560
+ return "";
561
+ const e = event;
562
+ if (e["type"] !== "assistant")
563
+ return "";
564
+ const message = e["message"];
565
+ if (!message || typeof message !== "object")
566
+ return "";
567
+ const content = message["content"];
568
+ if (typeof content === "string")
569
+ return content;
570
+ if (!Array.isArray(content))
571
+ return "";
572
+ return content
573
+ .map((c) => {
574
+ if (!c || typeof c !== "object")
575
+ return "";
576
+ const block = c;
577
+ if (block["type"] !== "text")
578
+ return "";
579
+ const t = block["text"];
580
+ return typeof t === "string" ? t : "";
581
+ })
582
+ .join("");
583
+ }
584
+ function extractIds(event) {
585
+ if (typeof event !== "object" || event === null)
586
+ return {};
587
+ const e = event;
588
+ const out = {};
589
+ const aid = e["agent_id"] ?? e["agentId"];
590
+ if (typeof aid === "string")
591
+ out.agentId = aid;
592
+ const rid = e["run_id"] ?? e["runId"];
593
+ if (typeof rid === "string")
594
+ out.runId = rid;
595
+ return out;
596
+ }
597
+ // Returns null when the run terminated normally, an error string otherwise.
598
+ // Healthy terminal status is `"finished"` per spike fixture and SDK type
599
+ // definition (`"running" | "finished" | "error"`). Anything else means the
600
+ // SDK run did not complete — partial assistantText could still happen to
601
+ // parse as valid JSON and be treated as APPROVED, so the adapter must fail
602
+ // closed before parsing. Extracted for direct unit-testing.
603
+ export function checkRunFinished(runStatus) {
604
+ if (runStatus === "finished")
605
+ return null;
606
+ return `cursor SDK run terminated with status=${runStatus ?? "unknown"} (expected "finished")`;
607
+ }
608
+ // Read the SDK's resolved model selection off the agent. The SDK type
609
+ // definition (`SDKAgent.model: ModelSelection | undefined`) says this is
610
+ // "updated after each successful send({ model })", which means it reflects
611
+ // what the SDK actually accepted — not what we tried to send. Returns
612
+ // undefined when the SDK shape doesn't expose it (defensive fallback).
613
+ export function extractRuntimeModel(agent) {
614
+ if (!agent || typeof agent !== "object")
615
+ return undefined;
616
+ const model = agent.model;
617
+ if (!model || typeof model !== "object")
618
+ return undefined;
619
+ const id = model.id;
620
+ if (typeof id !== "string" || id.length === 0)
621
+ return undefined;
622
+ const paramsRaw = model.params;
623
+ if (!Array.isArray(paramsRaw))
624
+ return { id };
625
+ const params = [];
626
+ for (const p of paramsRaw) {
627
+ if (!p || typeof p !== "object")
628
+ continue;
629
+ const pid = p.id;
630
+ const pval = p.value;
631
+ if (typeof pid !== "string" || typeof pval !== "string")
632
+ continue;
633
+ params.push({ id: pid, value: pval });
634
+ }
635
+ return params.length > 0 ? { id, params } : { id };
636
+ }
637
+ async function disposeAgent(agent) {
638
+ if (!agent || typeof agent !== "object")
639
+ return;
640
+ const dispose = agent[Symbol.asyncDispose];
641
+ if (typeof dispose === "function") {
642
+ try {
643
+ await dispose.call(agent);
644
+ }
645
+ catch {
646
+ // best-effort
647
+ }
648
+ return;
649
+ }
650
+ const close = agent.close;
651
+ if (typeof close === "function") {
652
+ try {
653
+ await close.call(agent);
654
+ }
655
+ catch {
656
+ // best-effort
657
+ }
658
+ }
659
+ }
660
+ // Cycle 322.1 — pure-function helpers for the retry loop. Extracted
661
+ // so the policy is testable without an SDK mock and so future
662
+ // adapters (322.2 Gemini, 322.3 Grok) can mirror the shape from a
663
+ // single source of truth instead of copy-pasting the patterns.
664
+ /**
665
+ * Pull structured status fields from an SDKStatusMessage stream event.
666
+ *
667
+ * The Cursor SDK streams `SDKStatusMessage` events before the terminal
668
+ * RunResult; when an upstream incident is in progress these carry the
669
+ * SDK's own human-readable explanation (e.g.,
670
+ * `{ status: "error", message: "Upstream model gpt-5.5 returned
671
+ * capacity_exceeded after retry policy exhausted" }`). The runtime
672
+ * shape is intentionally not pinned to a TypeScript type from the SDK
673
+ * because the SDK may evolve the field names; this extractor probes
674
+ * the documented Cursor surface AND falls back to a top-level
675
+ * `{ status, message }` shape so a slightly older or newer SDK still
676
+ * surfaces the signal.
677
+ *
678
+ * Returns `null` when the event is not a status message or lacks the
679
+ * structured payload (the common case — most stream events are
680
+ * `assistant` / `thinking` / `tool_call`).
681
+ */
682
+ export function extractStatusMessage(event) {
683
+ if (typeof event !== "object" || event === null)
684
+ return null;
685
+ const e = event;
686
+ // Cursor SDK shape (per spike fixture): assistant/thinking events use
687
+ // `type: "assistant"` etc.; status messages use either `type: "status"`
688
+ // or an explicit `kind: "status_message"`. Both surfaces are observed
689
+ // in the wild — accept either as long as the structured payload is
690
+ // present.
691
+ const isStatusEvent = e["type"] === "status" ||
692
+ e["type"] === "status_message" ||
693
+ e["kind"] === "status_message" ||
694
+ e["kind"] === "status";
695
+ if (!isStatusEvent)
696
+ return null;
697
+ // Sometimes the payload is at the top level; sometimes it is nested
698
+ // under `data` or `message`. Probe in order; first hit wins.
699
+ const candidates = [
700
+ e,
701
+ e["data"],
702
+ e["message"],
703
+ e["payload"],
704
+ ];
705
+ for (const candidate of candidates) {
706
+ if (typeof candidate !== "object" || candidate === null)
707
+ continue;
708
+ const c = candidate;
709
+ const status = c["status"];
710
+ const message = c["message"];
711
+ if (typeof status === "string" && status.length > 0 && typeof message === "string" && message.length > 0) {
712
+ return { status, message };
713
+ }
714
+ }
715
+ return null;
716
+ }
717
+ /**
718
+ * Probe a terminal RunResult for the SDK's structured error code.
719
+ *
720
+ * The Cursor SDK has used at least four field names for this code
721
+ * across versions: `errorCode` (current), `error_code`, `code`, and
722
+ * `error.code` (nested under an `error` object). Probe in that order
723
+ * — first non-empty string wins. Returns `null` when none are
724
+ * present, which means the SDK did not surface a structured code at
725
+ * all (treat as "transient unless proven otherwise" so retry policy
726
+ * applies).
727
+ */
728
+ export function extractRunErrorCode(result) {
729
+ if (typeof result !== "object" || result === null)
730
+ return null;
731
+ const r = result;
732
+ // Top-level surfaces, in priority order. `errorCode` is the
733
+ // documented current name; the others are legacy / underscored /
734
+ // nested.
735
+ const direct = [r["errorCode"], r["error_code"], r["code"]];
736
+ for (const v of direct) {
737
+ if (typeof v === "string" && v.length > 0)
738
+ return v;
739
+ }
740
+ // Nested under `error: { code }`.
741
+ const errorObj = r["error"];
742
+ if (typeof errorObj === "object" && errorObj !== null) {
743
+ const nested = errorObj["code"];
744
+ if (typeof nested === "string" && nested.length > 0)
745
+ return nested;
746
+ }
747
+ return null;
748
+ }
749
+ /**
750
+ * Policy gate: decide whether a terminal Cursor SDK failure is
751
+ * retryable.
752
+ *
753
+ * Returns `false` (DO NOT retry) when:
754
+ * - `runId` is null/undefined — the SDK never accepted the request;
755
+ * this is an infrastructure failure (network, sandbox, adapter
756
+ * init), not an API-layer failure that retry can paper over.
757
+ * - `errorCode` is in {@link PERMANENT_ERROR_CODES} — retrying an
758
+ * auth failure or quota-exceeded just wastes budget AND can mask
759
+ * the real fault.
760
+ *
761
+ * Returns `true` (retry allowed) when the failure carries a `runId`
762
+ * AND `errorCode` is either missing OR not on the permanent-deny
763
+ * list. The 26/27 retryable-failure success rate documented at
764
+ * `RETRY_BACKOFF_MS` drives this policy without per-vendor heuristics.
765
+ */
766
+ export function shouldRetryRunFailure(input) {
767
+ // Without a runId, the SDK didn't accept the request — there is
768
+ // nothing on the upstream side to retry. Retrying here would just
769
+ // re-run the same infrastructure-level failure.
770
+ if (!input.runId)
771
+ return false;
772
+ // Permanent-error deny list short-circuits retries.
773
+ if (input.errorCode && PERMANENT_ERROR_CODES.has(input.errorCode))
774
+ return false;
775
+ return true;
776
+ }
777
+ /**
778
+ * AbortSignal-aware sleep used between retry attempts.
779
+ *
780
+ * Resolves after `RETRY_BACKOFF_MS[idx]` ms, OR rejects immediately
781
+ * with an Error whose `name === "AbortError"` if the signal is (or
782
+ * becomes) aborted. The abort handler also clears the pending timer
783
+ * so a long backoff doesn't leak a Node timer after the caller
784
+ * cancelled.
785
+ *
786
+ * Throws synchronously (via the returned rejected promise) on
787
+ * out-of-range `idx` so an indexing bug in the caller fails loud
788
+ * instead of silently sleeping zero ms.
789
+ */
790
+ export async function sleepForRetry(idx, signal) {
791
+ if (idx < 0 || idx >= RETRY_BACKOFF_MS.length) {
792
+ throw new Error(`sleepForRetry: idx ${idx} out of range (RETRY_BACKOFF_MS.length=${RETRY_BACKOFF_MS.length})`);
793
+ }
794
+ const ms = RETRY_BACKOFF_MS[idx];
795
+ if (signal?.aborted) {
796
+ const e = new Error("aborted");
797
+ e.name = "AbortError";
798
+ throw e;
799
+ }
800
+ await new Promise((resolveSleep, rejectSleep) => {
801
+ const timer = setTimeout(() => {
802
+ if (signal)
803
+ signal.removeEventListener("abort", onAbort);
804
+ resolveSleep();
805
+ }, ms);
806
+ function onAbort() {
807
+ clearTimeout(timer);
808
+ if (signal)
809
+ signal.removeEventListener("abort", onAbort);
810
+ const e = new Error("aborted");
811
+ e.name = "AbortError";
812
+ rejectSleep(e);
813
+ }
814
+ if (signal)
815
+ signal.addEventListener("abort", onAbort, { once: true });
816
+ });
817
+ }
818
+ //# sourceMappingURL=cursor-sdk.js.map