@momentiq/dark-factory-cli 2.2.4 → 2.3.0

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 (37) hide show
  1. package/README.md +5 -1
  2. package/dist/adapters/_shared.d.ts +84 -0
  3. package/dist/adapters/_shared.d.ts.map +1 -1
  4. package/dist/adapters/_shared.js +163 -0
  5. package/dist/adapters/_shared.js.map +1 -1
  6. package/dist/adapters/gemini-sdk.d.ts.map +1 -1
  7. package/dist/adapters/gemini-sdk.js +70 -1
  8. package/dist/adapters/gemini-sdk.js.map +1 -1
  9. package/dist/adapters/grok-direct-sdk.d.ts.map +1 -1
  10. package/dist/adapters/grok-direct-sdk.js +71 -1
  11. package/dist/adapters/grok-direct-sdk.js.map +1 -1
  12. package/dist/adapters/index.d.ts +1 -0
  13. package/dist/adapters/index.d.ts.map +1 -1
  14. package/dist/adapters/index.js +6 -0
  15. package/dist/adapters/index.js.map +1 -1
  16. package/dist/adapters/minimax-direct-sdk.d.ts +139 -0
  17. package/dist/adapters/minimax-direct-sdk.d.ts.map +1 -0
  18. package/dist/adapters/minimax-direct-sdk.js +659 -0
  19. package/dist/adapters/minimax-direct-sdk.js.map +1 -0
  20. package/dist/adapters/static-schema-lint.d.ts.map +1 -1
  21. package/dist/adapters/static-schema-lint.js +1 -0
  22. package/dist/adapters/static-schema-lint.js.map +1 -1
  23. package/dist/cli.d.ts +32 -0
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +67 -6
  26. package/dist/cli.js.map +1 -1
  27. package/dist/exit.d.ts +27 -0
  28. package/dist/exit.d.ts.map +1 -0
  29. package/dist/exit.js +68 -0
  30. package/dist/exit.js.map +1 -0
  31. package/dist/mcp/tools/doctor.d.ts.map +1 -1
  32. package/dist/mcp/tools/doctor.js +2 -0
  33. package/dist/mcp/tools/doctor.js.map +1 -1
  34. package/dist/mcp/tools/review-bypass.d.ts.map +1 -1
  35. package/dist/mcp/tools/review-bypass.js +2 -0
  36. package/dist/mcp/tools/review-bypass.js.map +1 -1
  37. package/package.json +1 -1
@@ -0,0 +1,659 @@
1
+ // Cycle 20 — MiniMax M3 critic adapter via the `openai` npm package
2
+ // against OpenRouter's OpenAI-compatible inference endpoint.
3
+ //
4
+ // Provider decision (cycle20 D1, pivoted 2026-06-08): MiniMax M3 is
5
+ // served through OpenRouter, whose `minimax/minimax-m3` endpoint routes
6
+ // to MiniMax's own provider — headquartered in SG, with inference
7
+ // datacenters in the US (per OpenRouter's per-provider residency
8
+ // metadata). The pivot from the original Together AI plan (#302: Together
9
+ // never shipped M3) keeps the inference compute US-based, which is the
10
+ // data-residency property the hosted critic requires. The SG corporate
11
+ // jurisdiction is a compliance-posture note surfaced to compliance, not a
12
+ // runtime concern of this adapter.
13
+ //
14
+ // Why a fifth adapter (cycle20 § Scope): the four-vendor critic fleet
15
+ // (cursor + codex + gemini + grok) leaves four vendor lineages
16
+ // (Anthropic-adjacent / OpenAI / Google / xAI). MiniMax M3 is an OSS-
17
+ // weights model whose training distribution + RLHF process are
18
+ // uncorrelated with those four, so adding it as a 5th critic carries
19
+ // the same § "uncorrelated lineage" information value that motivated
20
+ // the original Grok add (cycle 322.3) — a single-vendor outage can
21
+ // never paralyze the gate, AND inter-critic disagreement on a hard PR
22
+ // carries more signal than four-of-four agreement.
23
+ //
24
+ // The adapter:
25
+ // - implements `CriticAdapter` from `critic.ts` with
26
+ // `requiredEnvVars = [OPEN_ROUTER_API_KEY_ENV]`
27
+ // - calls OpenRouter's `/v1/chat/completions` endpoint (OpenAI-
28
+ // compatible Chat Completions API; cycle20 D3 explicitly chose
29
+ // this shape over the Responses API because OpenRouter exposes
30
+ // Chat Completions for MiniMax M3)
31
+ // - sends `provider.data_collection: "deny"` so OpenRouter only routes
32
+ // to a provider that does not retain/train on the prompt — the
33
+ // prompt is third-party customer diff content. This is a fail-LOUD
34
+ // compliance default: if the request 404s with "no allowed
35
+ // providers", that means MiniMax-on-OpenRouter can NOT guarantee
36
+ // no-retention, which is a compliance finding to escalate — NOT a
37
+ // reason to silently flip to "allow". Overridable via the
38
+ // `dataCollection` constructor option, and sent for any OpenRouter
39
+ // host (matched by hostname, not exact string — so trailing-slash /
40
+ // path variants still get it); a custom `baseUrl` pointing at a
41
+ // genuinely different OpenAI-compatible endpoint omits this
42
+ // OpenRouter-specific `provider` field.
43
+ // - token-accounts off the OpenAI-format `usage` field on the
44
+ // terminal `chunk.usage` of the streamed response (matching the
45
+ // OpenAI SDK contract — `stream_options: { include_usage: true }`
46
+ // surfaces `usage` on the final chunk), including the cached-prefix
47
+ // token count (`usage.prompt_tokens_details.cached_tokens`) that
48
+ // OpenRouter bills at the cache-read rate ($0.06/Mtok vs $0.30 input)
49
+ // - mirrors the 322.1 retry shape (`runRetryLoop` + `attemptReview`)
50
+ // from a single source of truth in `cursor-sdk.ts`, so the policy +
51
+ // budget are byte-identical across adapters
52
+ // - routes diagnostic-redaction + JSON parsing + reviewer-metadata
53
+ // merge + error-result construction through `_shared.ts` so the
54
+ // security boundary (redactSecrets at the only writeFileSync site)
55
+ // cannot drift
56
+ // - is read-only by structure: no `tools` configured on the request;
57
+ // the only output channel is the JSON response itself
58
+ //
59
+ // The implementation uses the dependency-injection ESCAPE hatch on the
60
+ // constructor (`createClient` factory) so unit tests can supply a mock
61
+ // OpenAI client without forcing the SDK to be present at test time —
62
+ // matching the testing posture of the cycle 322.3 grok adapter tests.
63
+ import OpenAI, { APIError } from "openai";
64
+ import { compileCriticPrompt } from "../prompt.js";
65
+ import { parseCriticResult, } from "@momentiq/dark-factory-schemas";
66
+ import { buildErrorResult, mergeAdapterMetadata, normalizeCriticEcho, parseAssistantJson, writeRedactedDiagnostic, } from "./_shared.js";
67
+ import { runRetryLoop, } from "./_retry.js";
68
+ export const MINIMAX_DIRECT_SDK_ADAPTER_ID = "minimax-direct-sdk";
69
+ export const OPEN_ROUTER_API_KEY_ENV = "OPEN_ROUTER_API_KEY";
70
+ // OpenRouter's OpenAI-compatible inference endpoint. The MiniMax M3
71
+ // model id (`minimax/minimax-m3`) is configured via `critic.model.id`
72
+ // and routed by OpenRouter's model dispatch on `/v1/chat/completions`.
73
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
74
+ /**
75
+ * True iff `baseUrl`'s host is OpenRouter (any path / trailing-slash variant,
76
+ * and regional `*.openrouter.ai` subdomains). Gates the OpenRouter-specific
77
+ * `provider` routing field by HOST rather than exact string equality, so the
78
+ * no-retention compliance constraint is applied to ALL OpenRouter targets and
79
+ * omitted only for a genuinely different OpenAI-compatible host. A malformed
80
+ * URL returns `false` (treated as non-OpenRouter). Exported for unit testing.
81
+ */
82
+ export function isOpenRouterEndpoint(baseUrl) {
83
+ try {
84
+ const host = new URL(baseUrl).hostname.toLowerCase();
85
+ return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export const OPENROUTER_DATA_COLLECTION_DEFAULT = "deny";
92
+ // Chat Completions permanent-failure HTTP statuses — same buckets as
93
+ // the Grok adapter uses against the Responses API since both OpenRouter
94
+ // (OpenAI-compatible) and OpenAI itself share status semantics. Burning
95
+ // retry budget on these wastes wall-clock AND can mask the real fault
96
+ // (e.g., a wrong API key would silently exhaust retries before
97
+ // surfacing).
98
+ // 400 invalid_request — bad request shape, model id typo
99
+ // 401 / 403 — auth failure
100
+ // 404 model_not_found — model id not in the provider's catalog, OR
101
+ // no allowed provider (data_collection=deny +
102
+ // no eligible provider — a compliance signal)
103
+ // 429 rate_limit — quota / rate-limit (retrying within 20s
104
+ // burns budget; surface immediately so the
105
+ // operator can investigate)
106
+ // Anything else (5xx, 504, transient network) is retryable. The retry
107
+ // budget is bounded by `runRetryLoop` to 2 retries / 20s wall-clock.
108
+ export const MINIMAX_PERMANENT_STATUS = new Set([
109
+ 400,
110
+ 401,
111
+ 403,
112
+ 404,
113
+ 429,
114
+ ]);
115
+ /**
116
+ * Probe a thrown error for an OpenAI-format API HTTP status code.
117
+ * Returns `null` when the error didn't carry one (network error,
118
+ * non-API exception). Exported for direct unit testing.
119
+ *
120
+ * The `openai` SDK throws `APIError extends Error` with a `status:
121
+ * number` field on HTTP-level failures; a non-API failure (DNS, timeout
122
+ * in fetch, etc.) won't have this field set. Treating "no status" as
123
+ * retryable lets the loop catch real transient blips while not silently
124
+ * retrying logic errors.
125
+ */
126
+ export function extractOpenRouterApiErrorStatus(err) {
127
+ if (!err || typeof err !== "object")
128
+ return null;
129
+ const e = err;
130
+ if (typeof e["status"] === "number")
131
+ return e["status"];
132
+ // Some SDK error shapes nest the status under `response.status`:
133
+ const response = e["response"];
134
+ if (response && typeof response === "object") {
135
+ const status = response["status"];
136
+ if (typeof status === "number")
137
+ return status;
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Policy gate: decide whether an OpenRouter / Chat Completions failure is
143
+ * retryable. Returns `false` for HTTP statuses in
144
+ * {@link MINIMAX_PERMANENT_STATUS}, `true` otherwise (including
145
+ * no-status network errors).
146
+ *
147
+ * Exported for direct unit testing.
148
+ */
149
+ export function isMinimaxPermanentFailure(status) {
150
+ if (status === null)
151
+ return false; // no status → treat as transient
152
+ return MINIMAX_PERMANENT_STATUS.has(status);
153
+ }
154
+ export class MinimaxDirectSdkAdapter {
155
+ options;
156
+ id = MINIMAX_DIRECT_SDK_ADAPTER_ID;
157
+ requiredEnvVars = [OPEN_ROUTER_API_KEY_ENV];
158
+ createClient;
159
+ baseUrl;
160
+ dataCollection;
161
+ constructor(options = {}) {
162
+ this.options = options;
163
+ this.baseUrl = options.baseUrl ?? OPENROUTER_BASE_URL;
164
+ this.dataCollection = options.dataCollection ?? OPENROUTER_DATA_COLLECTION_DEFAULT;
165
+ this.createClient =
166
+ options.createClient ??
167
+ ((apiKey, baseUrl) => new OpenAI({
168
+ apiKey,
169
+ baseURL: baseUrl,
170
+ // OpenRouter app-attribution headers (optional; recommended so
171
+ // usage is identifiable in the OpenRouter dashboard).
172
+ defaultHeaders: {
173
+ "HTTP-Referer": "https://github.com/momentiq-ai/dark-factory",
174
+ "X-Title": "Dark Factory critic",
175
+ },
176
+ }));
177
+ }
178
+ async review(packet, critic, options) {
179
+ return runRetryLoop({
180
+ attempt: (idx) => this.attemptReview(packet, critic, options, idx),
181
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
182
+ ...(this.options.sleep !== undefined ? { sleep: this.options.sleep } : {}),
183
+ buildExhausted: ({ last, totalAttempts, aborted }) => {
184
+ const retriesUsed = Math.max(0, totalAttempts - 1);
185
+ const summary = aborted
186
+ ? last
187
+ ? `minimax SDK run aborted after ${retriesUsed} retries: ${last.message}`
188
+ : "minimax SDK run aborted before any attempt completed"
189
+ : last
190
+ ? `minimax SDK run failed after ${retriesUsed} retries: ${last.message}`
191
+ : "minimax SDK run failed with no captured failure metadata";
192
+ return buildErrorResult({
193
+ critic,
194
+ message: summary,
195
+ retryable: true,
196
+ ...(last?.errorCode != null ? { code: last.errorCode } : {}),
197
+ retryCount: retriesUsed,
198
+ });
199
+ },
200
+ });
201
+ }
202
+ // One attempt. Mirrors `GrokDirectSdkAdapter.attemptReview` shape so
203
+ // the outer retry loop dispatches identically; differences are
204
+ // Chat-Completions-specific (delta event shape on `choices[0].delta`,
205
+ // `finish_reason='length'`/`'content_filter'` as truncation,
206
+ // `usage` on the terminal chunk) and surface here.
207
+ async attemptReview(packet, critic, options, attemptIdx) {
208
+ const apiKey = this.options.apiKey ?? process.env[OPEN_ROUTER_API_KEY_ENV];
209
+ if (!apiKey) {
210
+ // Missing key is permanent — no retry can fix a missing secret.
211
+ return {
212
+ kind: "permanent_failure",
213
+ errorCode: null,
214
+ statusMessage: null,
215
+ result: buildErrorResult({
216
+ critic,
217
+ message: `${OPEN_ROUTER_API_KEY_ENV} is not set; cannot run MiniMax critic`,
218
+ retryable: false,
219
+ retryCount: attemptIdx,
220
+ }),
221
+ };
222
+ }
223
+ const prompt = compileCriticPrompt({
224
+ packet,
225
+ critic,
226
+ blockingSeverities: options.blockingSeverities,
227
+ treatDiffAsUntrusted: true,
228
+ });
229
+ const startMs = Date.now();
230
+ if (attemptIdx === 0) {
231
+ options.emit?.({
232
+ ts: new Date().toISOString(),
233
+ event: "critic_run_started",
234
+ commit: packet.commit.sha,
235
+ criticId: critic.id,
236
+ adapter: this.id,
237
+ model: critic.model.id,
238
+ });
239
+ }
240
+ const client = this.createClient(apiKey, this.baseUrl);
241
+ let assistantText = "";
242
+ let lastUsage;
243
+ // OpenAI Chat Completions emits `finish_reason: 'length'` when the
244
+ // response was truncated at `max_tokens`, or `'content_filter'` when
245
+ // the model's safety filter triggered. Treat as permanent failure
246
+ // with preserved accumulated text — retrying the same prompt
247
+ // re-trips the same truncation, AND the partial text is the most
248
+ // informative thing the operator can read.
249
+ let truncationReason;
250
+ try {
251
+ // `chat.completions.create` returns either a single ChatCompletion
252
+ // (for non-streaming) or an AsyncIterable<ChatCompletionChunk>
253
+ // (for streaming). We always stream so the result is the
254
+ // async-iterable. Request options second-arg carries the
255
+ // AbortSignal — the SDK threads it to the underlying fetch.
256
+ const stream = await client.chat.completions.create({
257
+ model: critic.model.id,
258
+ messages: [
259
+ {
260
+ role: "user",
261
+ content: prompt.text,
262
+ },
263
+ ],
264
+ // Force JSON-only response. `parseAssistantJson` still runs as
265
+ // a safety net for occasional format drift — adapters never
266
+ // trust the structured-output guarantee because a malformed
267
+ // terminal text would otherwise produce an unparseable
268
+ // artifact that the gate can't evaluate.
269
+ response_format: { type: "json_object" },
270
+ stream: true,
271
+ // OpenAI streaming contract: `usage` surfaces on the terminal
272
+ // chunk only when `stream_options.include_usage: true` is
273
+ // sent. Without it `lastUsage` stays undefined and the
274
+ // telemetry payload omits token counts → null cost rows.
275
+ stream_options: { include_usage: true },
276
+ // Compliance: constrain OpenRouter routing to a no-retention
277
+ // provider for the third-party customer diff. Fail-loud — see
278
+ // file header. `provider` routing is OpenRouter-specific, so it is
279
+ // sent for ALL OpenRouter hosts (default + path/trailing-slash
280
+ // variants + regional shards, matched by HOST not raw string) and
281
+ // omitted only for a genuinely different OpenAI-compatible endpoint,
282
+ // whose caller owns its own data policy (the field is unknown there).
283
+ ...(isOpenRouterEndpoint(this.baseUrl)
284
+ ? { provider: { data_collection: this.dataCollection } }
285
+ : {}),
286
+ }, options.signal !== undefined ? { signal: options.signal } : {});
287
+ for await (const chunk of stream) {
288
+ if (options.signal?.aborted)
289
+ break;
290
+ // Primary path: accumulate text deltas from `choices[0].delta.content`.
291
+ // OpenAI Chat Completions streams emit one chunk per token (or
292
+ // per server-flush boundary); the role is set on the first chunk
293
+ // only.
294
+ const choice = chunk.choices?.[0];
295
+ if (choice) {
296
+ const delta = choice.delta?.content;
297
+ if (typeof delta === "string") {
298
+ assistantText += delta;
299
+ }
300
+ // `finish_reason` is set on the terminal chunk for that
301
+ // choice. `stop` = normal completion; `length` = max_tokens
302
+ // hit; `content_filter` = safety block.
303
+ const finishReason = choice.finish_reason;
304
+ if (finishReason === "length" || finishReason === "content_filter") {
305
+ truncationReason = finishReason;
306
+ }
307
+ }
308
+ // Per the OpenAI streaming contract with
309
+ // `stream_options.include_usage: true`, the terminal chunk
310
+ // carries `usage` populated and `choices: []`. Capture it here
311
+ // (latest-non-null wins so a future provider that re-emits
312
+ // usage mid-stream does not silently drop the count).
313
+ if (chunk.usage) {
314
+ lastUsage = chunk.usage;
315
+ }
316
+ }
317
+ }
318
+ catch (err) {
319
+ const e = err;
320
+ const status = err instanceof APIError ? err.status : extractOpenRouterApiErrorStatus(err);
321
+ const permanent = isMinimaxPermanentFailure(status);
322
+ const codeStr = status !== null ? `http_${status}` : "transport_error";
323
+ options.emit?.({
324
+ ts: new Date().toISOString(),
325
+ event: "critic_run_error",
326
+ commit: packet.commit.sha,
327
+ criticId: critic.id,
328
+ adapter: this.id,
329
+ model: critic.model.id,
330
+ durationMs: Date.now() - startMs,
331
+ error: e.message,
332
+ status: permanent ? "run_failure_permanent" : "run_failure",
333
+ retryCount: attemptIdx,
334
+ errorCode: codeStr,
335
+ });
336
+ if (permanent) {
337
+ return {
338
+ kind: "permanent_failure",
339
+ errorCode: codeStr,
340
+ statusMessage: null,
341
+ result: buildErrorResult({
342
+ critic,
343
+ message: `minimax SDK run failed (permanent, status=${status ?? "?"}): ${e.message}`,
344
+ retryable: false,
345
+ code: codeStr,
346
+ retryCount: attemptIdx,
347
+ }),
348
+ };
349
+ }
350
+ return {
351
+ kind: "retryable_failure",
352
+ errorCode: codeStr,
353
+ statusMessage: null,
354
+ message: `minimax SDK run failed: ${e.message}`,
355
+ runId: null,
356
+ agentId: null,
357
+ };
358
+ }
359
+ if (truncationReason) {
360
+ // Truncation / content-filter — permanent. Preserve the
361
+ // accumulated partial text in the diagnostic artifact for
362
+ // operator inspection. The distinct `incomplete` errorCode lets
363
+ // operators discriminate truncation patterns from transport
364
+ // failures in `_runs.ndjson` — important because the remediation
365
+ // differs (raise max_tokens / revise prompt vs. fix vendor
366
+ // incident).
367
+ const diagPath = writeRedactedDiagnostic({
368
+ diagnosticsDir: options.diagnosticsDir,
369
+ criticId: critic.id,
370
+ commit: packet.commit.sha,
371
+ rawText: assistantText,
372
+ });
373
+ const msg = `minimax critic response truncated: ${truncationReason} (partial text preserved)`;
374
+ options.emit?.({
375
+ ts: new Date().toISOString(),
376
+ event: "critic_run_error",
377
+ commit: packet.commit.sha,
378
+ criticId: critic.id,
379
+ adapter: this.id,
380
+ model: critic.model.id,
381
+ durationMs: Date.now() - startMs,
382
+ error: msg,
383
+ status: "incomplete",
384
+ retryCount: attemptIdx,
385
+ errorCode: "incomplete",
386
+ });
387
+ return {
388
+ kind: "permanent_failure",
389
+ errorCode: "incomplete",
390
+ statusMessage: null,
391
+ result: buildErrorResult({
392
+ critic,
393
+ message: msg,
394
+ retryable: false,
395
+ code: "incomplete",
396
+ ...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
397
+ retryCount: attemptIdx,
398
+ }),
399
+ };
400
+ }
401
+ const parseOutcome = parseAssistantJson(assistantText);
402
+ if (!parseOutcome.ok) {
403
+ const diagPath = writeRedactedDiagnostic({
404
+ diagnosticsDir: options.diagnosticsDir,
405
+ criticId: critic.id,
406
+ commit: packet.commit.sha,
407
+ rawText: assistantText,
408
+ });
409
+ options.emit?.({
410
+ ts: new Date().toISOString(),
411
+ event: "critic_run_error",
412
+ commit: packet.commit.sha,
413
+ criticId: critic.id,
414
+ adapter: this.id,
415
+ model: critic.model.id,
416
+ durationMs: Date.now() - startMs,
417
+ error: `invalid critic JSON: ${parseOutcome.message}`,
418
+ status: "invalid_json",
419
+ retryCount: attemptIdx,
420
+ });
421
+ return {
422
+ kind: "permanent_failure",
423
+ errorCode: null,
424
+ statusMessage: null,
425
+ result: buildErrorResult({
426
+ critic,
427
+ message: `minimax critic returned invalid JSON: ${parseOutcome.message}`,
428
+ retryable: false,
429
+ ...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
430
+ retryCount: attemptIdx,
431
+ }),
432
+ };
433
+ }
434
+ let result;
435
+ try {
436
+ // Drop schema-invalid `validation.qualityGateResults[]` entries
437
+ // (e.g. model emits `gate` instead of `command`) BEFORE strict
438
+ // parsing — the validation block is informational and gets
439
+ // overwritten below with deterministic packet evidence. Issue
440
+ // #1484 (Grok same behavior).
441
+ const normalized = normalizeCriticEcho(parseOutcome.value);
442
+ const enriched = mergeAdapterMetadata(normalized, { critic });
443
+ result = parseCriticResult(enriched, options.blockingSeverities);
444
+ }
445
+ catch (err) {
446
+ const e = err;
447
+ const diagPath = writeRedactedDiagnostic({
448
+ diagnosticsDir: options.diagnosticsDir,
449
+ criticId: critic.id,
450
+ commit: packet.commit.sha,
451
+ rawText: assistantText,
452
+ });
453
+ options.emit?.({
454
+ ts: new Date().toISOString(),
455
+ event: "critic_run_error",
456
+ commit: packet.commit.sha,
457
+ criticId: critic.id,
458
+ adapter: this.id,
459
+ model: critic.model.id,
460
+ durationMs: Date.now() - startMs,
461
+ error: `schema validation failed: ${e.message}`,
462
+ status: "schema_violation",
463
+ retryCount: attemptIdx,
464
+ });
465
+ return {
466
+ kind: "permanent_failure",
467
+ errorCode: null,
468
+ statusMessage: null,
469
+ result: buildErrorResult({
470
+ critic,
471
+ message: `minimax critic JSON failed schema validation: ${e.message}`,
472
+ retryable: false,
473
+ ...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
474
+ retryCount: attemptIdx,
475
+ }),
476
+ };
477
+ }
478
+ const durationMs = Date.now() - startMs;
479
+ const cachedTokens = lastUsage?.prompt_tokens_details?.cached_tokens;
480
+ const enriched = {
481
+ ...result,
482
+ durationMs,
483
+ // Cycle 6.3 — surface per-critic telemetry on the artifact-shaped
484
+ // result. The OpenAI Chat Completions usage block exposes
485
+ // `prompt_tokens` / `completion_tokens`; OpenRouter's MiniMax M3
486
+ // path additionally breaks out the cached-prefix portion under
487
+ // `prompt_tokens_details.cached_tokens` (billed at the cache-read
488
+ // rate), captured as `tokensCached` for accurate cost attribution.
489
+ retries: attemptIdx,
490
+ ...(typeof lastUsage?.prompt_tokens === "number"
491
+ ? { tokensInput: lastUsage.prompt_tokens }
492
+ : {}),
493
+ ...(typeof lastUsage?.completion_tokens === "number"
494
+ ? { tokensOutput: lastUsage.completion_tokens }
495
+ : {}),
496
+ ...(typeof cachedTokens === "number" ? { tokensCached: cachedTokens } : {}),
497
+ validation: {
498
+ qualityGateResults: packet.validation.evidence,
499
+ qualityGatesMissing: packet.validation.missing,
500
+ },
501
+ };
502
+ const blockerCount = enriched.findings.filter((f) => f.severity === "blocker").length;
503
+ const highCount = enriched.findings.filter((f) => f.severity === "high").length;
504
+ options.emit?.({
505
+ ts: new Date().toISOString(),
506
+ event: "critic_run_finished",
507
+ commit: packet.commit.sha,
508
+ criticId: critic.id,
509
+ adapter: this.id,
510
+ model: critic.model.id,
511
+ durationMs,
512
+ ...(enriched.verdict !== undefined ? { verdict: enriched.verdict } : {}),
513
+ findingCount: enriched.findings.length,
514
+ blockerCount,
515
+ highCount,
516
+ ...(typeof lastUsage?.prompt_tokens === "number"
517
+ ? { tokensIn: lastUsage.prompt_tokens }
518
+ : {}),
519
+ ...(typeof lastUsage?.completion_tokens === "number"
520
+ ? { tokensOut: lastUsage.completion_tokens }
521
+ : {}),
522
+ status: "complete",
523
+ retryCount: attemptIdx,
524
+ });
525
+ return { kind: "success", result: enriched };
526
+ }
527
+ async doctor(critic) {
528
+ const checks = [];
529
+ const apiKey = this.options.apiKey ?? process.env[OPEN_ROUTER_API_KEY_ENV];
530
+ const missingOptionalKey = !apiKey && !critic.required;
531
+ checks.push({
532
+ name: "open_router_api_key",
533
+ passed: Boolean(apiKey) || missingOptionalKey,
534
+ detail: apiKey
535
+ ? `${OPEN_ROUTER_API_KEY_ENV} present`
536
+ : missingOptionalKey
537
+ ? `${OPEN_ROUTER_API_KEY_ENV} missing; optional shadow critic will be skipped at review time`
538
+ : `${OPEN_ROUTER_API_KEY_ENV} missing`,
539
+ ...(apiKey || missingOptionalKey
540
+ ? {}
541
+ : {
542
+ remediation: `export ${OPEN_ROUTER_API_KEY_ENV}=... or add it to the Doppler scope (dark-factory/prd). MiniMax M3 is served via OpenRouter's OpenAI-compatible inference endpoint.`,
543
+ }),
544
+ });
545
+ let sdkLoaded = false;
546
+ try {
547
+ // The dynamic import path catches both "package missing on disk"
548
+ // and "package present but no exports we recognize" (older shape)
549
+ // cases.
550
+ const mod = (await import("openai"));
551
+ sdkLoaded = typeof mod["default"] === "function" || typeof mod["OpenAI"] === "function";
552
+ }
553
+ catch {
554
+ sdkLoaded = false;
555
+ }
556
+ checks.push({
557
+ name: "minimax_sdk_loaded",
558
+ passed: sdkLoaded,
559
+ detail: sdkLoaded
560
+ ? "openai SDK imported (used as OpenRouter client via baseURL)"
561
+ : "openai SDK missing or shape unexpected",
562
+ ...(sdkLoaded
563
+ ? {}
564
+ : { remediation: "npm ci --include=dev" }),
565
+ });
566
+ // Diagnostic family-prefix check: a stale config that pins to a
567
+ // non-MiniMax model id can be flagged BEFORE the live models.list()
568
+ // call below — useful when the operator's API key isn't yet
569
+ // provisioned but the doctor is still expected to catch obvious
570
+ // config errors. We match `minimax`-prefixed ids case-insensitively
571
+ // (OpenRouter's slug is `minimax/minimax-m3`).
572
+ const familyOk = critic.model.id.toLowerCase().includes("minimax");
573
+ checks.push({
574
+ name: "minimax_model_id_family",
575
+ passed: familyOk,
576
+ detail: familyOk
577
+ ? `${critic.model.id} matches minimax-* family pattern`
578
+ : `${critic.model.id} does NOT match minimax-* family pattern`,
579
+ ...(familyOk
580
+ ? {}
581
+ : {
582
+ remediation: "the configured MiniMax critic's model.id should contain 'minimax' (e.g., 'minimax/minimax-m3'). Update .agent-review/config.json:critics[].model.id.",
583
+ }),
584
+ });
585
+ if (!sdkLoaded || !apiKey)
586
+ return checks;
587
+ // Verify the configured model id resolves via models.list(). Mirrors
588
+ // the Grok doctor's live-catalog check so a future OpenRouter model
589
+ // retirement / id rename is caught before review time.
590
+ try {
591
+ const client = this.createClient(apiKey, this.baseUrl);
592
+ const list = client.models.list;
593
+ if (typeof list !== "function") {
594
+ checks.push({
595
+ name: "minimax_model_listing",
596
+ passed: false,
597
+ detail: "openai SDK models.list not exposed; cannot verify model id",
598
+ remediation: "verify the openai SDK version exposes Models.list (>= 4.x); upgrade if needed.",
599
+ });
600
+ return checks;
601
+ }
602
+ // Three shapes the doctor must handle:
603
+ // 1. openai SDK production: `PagePromise` — thenable AND
604
+ // `AsyncIterable<Item>` (verified against openai@^6
605
+ // `node_modules/openai/core/pagination.d.ts`). Per the SDK's
606
+ // own doc comment on `PagePromise`: "Allow auto-paginating
607
+ // iteration on an unawaited list call." Direct-iterate.
608
+ // 2. Plain AsyncIterable test mock — `list: () => ({
609
+ // async *[Symbol.asyncIterator]() {...} })`. Direct-iterate.
610
+ // 3. Promise<AsyncIterable> test mock — `list: async () =>
611
+ // ({...})` — the result is a Promise wrapping an
612
+ // AsyncIterable, NOT itself async-iterable. Must await first.
613
+ //
614
+ // Discriminator: if the returned value implements
615
+ // `Symbol.asyncIterator`, it's case (1) or (2) — direct-iterate.
616
+ // Else if it's thenable, it's case (3) — await to unwrap.
617
+ // Awaiting a `PagePromise` works too but it discards
618
+ // auto-pagination (drops to a single Page's worth of items —
619
+ // codex's original finding on commit c24256f), so the order of
620
+ // the checks matters: AsyncIterable FIRST, thenable SECOND.
621
+ const listed = list.call(client.models);
622
+ const hasAsyncIterator = (v) => v !== null && typeof v === "object" && Symbol.asyncIterator in v;
623
+ const isThenable = (v) => v !== null && typeof v === "object" && typeof v.then === "function";
624
+ const iterable = hasAsyncIterator(listed)
625
+ ? listed
626
+ : isThenable(listed)
627
+ ? (await listed)
628
+ : listed;
629
+ const ids = [];
630
+ for await (const m of iterable) {
631
+ if (typeof m.id === "string")
632
+ ids.push(m.id);
633
+ }
634
+ const matched = ids.some((n) => n === critic.model.id);
635
+ checks.push({
636
+ name: "minimax_model_id",
637
+ passed: matched,
638
+ detail: matched
639
+ ? `model ${critic.model.id} available`
640
+ : `model ${critic.model.id} not in available list (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? "..." : ""})`,
641
+ ...(matched
642
+ ? {}
643
+ : {
644
+ remediation: "update .agent-review/config.json:critics[].model.id to a model id surfaced by OpenRouter's /v1/models endpoint (e.g. 'minimax/minimax-m3')",
645
+ }),
646
+ });
647
+ }
648
+ catch (err) {
649
+ checks.push({
650
+ name: "minimax_model_id",
651
+ passed: false,
652
+ detail: `models.list() failed: ${err.message}`,
653
+ remediation: `verify ${OPEN_ROUTER_API_KEY_ENV} and network connectivity (OpenRouter endpoint: ${this.baseUrl})`,
654
+ });
655
+ }
656
+ return checks;
657
+ }
658
+ }
659
+ //# sourceMappingURL=minimax-direct-sdk.js.map