@golden-frijoles/kit 0.5.4 → 0.7.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.
@@ -0,0 +1,617 @@
1
+ #!/usr/bin/env node
2
+ // cross-agent-doctor.mjs — ONE doctor for the reviewer CLIs (ways-of-work-lean-pass S2.5).
3
+ //
4
+ // ONE file across every consumer since distribute-what-we-use S1 (2026-09-29): a consuming project's doctor (codex +
5
+ // agy, the `unavailable` probe class, the strict slug parser) merged with Golden Frijoles' `agy-doctor.mjs`
6
+ // (every configured agy model audited via AGY_MODELS_IN_USE, a drift note that names the model). That
7
+ // `agy-doctor.mjs` survives only as an alias for `cross-agent-doctor.mjs agy`.
8
+ //
9
+ // Merged 2026-09-16 from two former files (a codex doctor and an agy doctor): same diagnosis shape, two copies, and
10
+ // the review policy now runs ONE external pass — so when the family that was going to review a PR
11
+ // cannot run, you want a single command that says which one is broken and what fixes it.
12
+ //
13
+ // node scripts/cross-agent-doctor.mjs # both families
14
+ // node scripts/cross-agent-doctor.mjs codex # just codex
15
+ // node scripts/cross-agent-doctor.mjs agy --fix # bump AGY_PINNED after a green contract probe
16
+ //
17
+ // Exit code 1 if any family needs an operator action. The two pure decision cores below are unchanged
18
+ // from the files they came from, and so are their tests (cross-agent-doctor.{codex,agy}.test.mjs) —
19
+ // this is a merge, not a rewrite. vibe and claude have no pinned print contract to drift, so they are
20
+ // presence-checked by cross-review.mjs itself rather than here.
21
+ //
22
+ // Zero npm deps — Node 18+ to diagnose; `agy --fix` needs Node 21+ and refuses below it (see agyMain).
23
+ // isMain-guarded so importing the pure cores does not run the CLI.
24
+
25
+ import { spawnSync } from 'node:child_process';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { resolve, dirname, join } from 'node:path';
28
+ import {
29
+ isCodexAuthError,
30
+ isCodexOutdated,
31
+ CODEX_MODEL,
32
+ AGY_PINNED,
33
+ AGY_MODEL,
34
+ AGY_FALLBACK_MODEL,
35
+ AGY_MODELS_IN_USE,
36
+ codexExecArgs,
37
+ isCodexCapped,
38
+ } from './lib/cross-agent-cli.mjs';
39
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
40
+
41
+ // ═══ CODEX ════════════════════════════════════════════════════════════════════════════════════
42
+ // The codex half — diagnose why the Codex CLI can't run, and name the exact fix.
43
+ //
44
+ // WHY: `codex` is the DEFAULT cross-agent reviewer (scripts/cross-review.mjs), but it fails in three
45
+ // operator-fixable ways that otherwise surface as an opaque `codex exec failed …` and force a manual
46
+ // `--agent antigravity`. This makes the diagnosis executable — the agent that hits a codex failure runs
47
+ // this and learns which of the three it is:
48
+ //
49
+ // node scripts/cross-agent-doctor.mjs codex # diagnose: present? version? a live `codex exec` probe → class
50
+ //
51
+ // The three recoverable classes (all confirmed live 2026-07-20 against codex-cli 0.142.5):
52
+ // • auth-lapsed — token expired/revoked. Fix: `codex login`.
53
+ // • cli-outdated — the installed CLI is too old for the model it runs (its own default, or CODEX_MODEL):
54
+ // "The 'gpt-5.6-sol' model requires a newer version of Codex." Fix: upgrade codex
55
+ // (`npm install -g @openai/codex@latest`, or your install channel), OR set
56
+ // CODEX_MODEL to a model the INSTALLED codex supports as a stopgap (`codex exec -m …`).
57
+ // • missing — codex not on PATH. Fix: install it.
58
+ // Plus 'broken' (a non-auth/non-stale exec error — surfaced verbatim, not masked) and 'ok'.
59
+ //
60
+ // UNLIKE agy-doctor there is NO --fix: codex is not version-PINNED in the repo (nothing to bump), and
61
+ // upgrading a global binary is an environment-specific system mutation this script deliberately won't run
62
+ // on its own (same stance agy-doctor takes toward the agy binary itself). It DIAGNOSES and names the fix.
63
+ // The runtime already self-heals both recoverable classes by auto-falling-back to Antigravity
64
+ // (runWithCodexFallback) — this script is for when you want codex ITSELF back.
65
+ //
66
+ // NOT wired into a cloud routine (deliberate): a routine sandbox has no codex binary/auth, so codex drift
67
+ // can only be observed on a machine where codex runs — the same reason cross-review is local-only.
68
+ //
69
+ // Zero npm deps — Node 18+. Pure decision logic exported for node:test (isMain-guarded, per LEARNINGS).
70
+ // ── Pure decision core (the unit under test) ─────────────────────────────────────────────────────────
71
+ // Given the observed facts, decide ONE action. `probe` is the classified result of a live `codex exec`:
72
+ // 'ok' — real stdout, exit 0.
73
+ // 'auth' — non-zero + an auth-lapse signal (isCodexAuthError).
74
+ // 'outdated' — non-zero + a stale-CLI signal (isCodexOutdated).
75
+ // 'error' — non-zero for some other reason (surfaced verbatim, never masked).
76
+ // 'skipped' — not probed (binary missing).
77
+ export function decideCodexDoctorAction({ present, probe }) {
78
+ if (!present) return { action: 'missing', note: 'codex is not on PATH.' };
79
+ switch (probe) {
80
+ case 'ok':
81
+ return { action: 'ok', note: 'a live `codex exec` probe returned real output.' };
82
+ case 'auth':
83
+ return { action: 'auth-lapsed', note: 'codex is installed but its token has lapsed/was revoked.' };
84
+ case 'outdated':
85
+ return { action: 'cli-outdated', note: 'the installed codex is too old for the model it runs.' };
86
+ case 'capped':
87
+ return {
88
+ action: 'capped',
89
+ note: 'codex is installed and signed in, but the account hit its usage cap.',
90
+ };
91
+ default:
92
+ return { action: 'broken', note: 'a live `codex exec` probe failed for a non-auth, non-stale reason.' };
93
+ }
94
+ }
95
+
96
+ // Human-readable remediation per action — kept beside the decision so the message and the classification
97
+ // can't drift. `ctx` carries the observed version + the CODEX_MODEL escape-hatch state.
98
+ export function remediation(action, ctx = {}) {
99
+ const model = ctx.codexModel
100
+ ? `CODEX_MODEL="${ctx.codexModel}"`
101
+ : 'CODEX_MODEL (unset — codex uses its own default model)';
102
+ switch (action) {
103
+ case 'ok':
104
+ return null;
105
+ case 'missing':
106
+ return 'Install the Codex CLI (e.g. `npm install -g @openai/codex`), then `codex login`.';
107
+ case 'auth-lapsed':
108
+ return 'Restore the token: `codex login`. (The cross-review runtime already auto-falls-back to Antigravity meanwhile.)';
109
+ case 'capped':
110
+ return (
111
+ 'Wait for the cap to reset, or route past codex: `node scripts/review-route.mjs --builder <who> --exclude codex <PR#>`. ' +
112
+ '(cross-review already heals a capped codex onto Antigravity.)'
113
+ );
114
+ case 'cli-outdated':
115
+ return [
116
+ 'The installed codex is behind its model requirement. Either:',
117
+ ' 1. Upgrade the CLI: `npm install -g @openai/codex@latest` (or your install channel), OR',
118
+ ` 2. Stopgap — pin a model the installed CLI supports: set CODEX_MODEL to a listed model so`,
119
+ ' cross-review runs `codex exec -m <model>`. Currently ' + model + '.',
120
+ 'Until then, cross-review auto-falls-back to Antigravity on its own.',
121
+ ].join('\n');
122
+ default:
123
+ return 'Non-auth, non-stale codex error — read the probe stderr above; not an auto-diagnosable class.';
124
+ }
125
+ }
126
+
127
+ // ── I/O (thin — the decision core above is what tests exercise) ───────────────────────────────────────
128
+ function codex(args, input) {
129
+ return spawnSync('codex', args, { encoding: 'utf8', input: input ?? '', maxBuffer: 16 * 1024 * 1024 });
130
+ }
131
+
132
+ function observeCodex() {
133
+ const ver = codex(['--version']);
134
+ if (ver.error) return { present: false, version: null, probe: 'skipped', probeStderr: '' };
135
+ const version = (((ver.stdout || '') + (ver.stderr || '')).match(/\d+\.\d+\.\d+/) || [null])[0];
136
+ // One real, minimal `codex exec` — the same call cross-review makes (respecting CODEX_MODEL), so the
137
+ // probe sees exactly what the reviewer would. A tiny prompt keeps the token/CLI-version check cheap.
138
+ const r = codex(codexExecArgs('Reply with exactly: OK'), '');
139
+ const out = `${r.stdout || ''}\n${r.stderr || ''}`;
140
+ let probe;
141
+ if (r.status === 0 && (r.stdout || '').trim()) probe = 'ok';
142
+ else if (isCodexOutdated(out)) probe = 'outdated';
143
+ else if (isCodexCapped(out)) probe = 'capped';
144
+ else if (isCodexAuthError(out)) probe = 'auth';
145
+ else probe = 'error';
146
+ return { present: true, version, probe, probeStderr: (r.stderr || '').trim() };
147
+ }
148
+
149
+ async function codexMain() {
150
+ const obs = observeCodex();
151
+ const { action, note } = decideCodexDoctorAction(obs);
152
+ const line = (s) => process.stdout.write(`${s}\n`);
153
+
154
+ line(
155
+ `codex — ${obs.present ? `installed ${obs.version || '(unparsed version)'}` : 'NOT INSTALLED'} · CODEX_MODEL ${CODEX_MODEL ? `="${CODEX_MODEL}"` : '=default (codex built-in; user config ignored)'}`
156
+ );
157
+ if (obs.present) line(`live probe: ${obs.probe}`);
158
+ line(` → ${note}`);
159
+
160
+ const fix = remediation(action, { codexModel: CODEX_MODEL });
161
+ if (action === 'ok') {
162
+ line('✓ codex is healthy — no fallback needed.');
163
+ return;
164
+ }
165
+ if (obs.probe === 'error' && obs.probeStderr) {
166
+ line(' probe stderr (tail):');
167
+ line(' ' + obs.probeStderr.split('\n').slice(-3).join('\n '));
168
+ }
169
+ line('✗ ' + action + ':');
170
+ line(
171
+ fix
172
+ .split('\n')
173
+ .map((l) => ' ' + l)
174
+ .join('\n')
175
+ );
176
+ process.exitCode = 1;
177
+ }
178
+
179
+ // ═══ ANTIGRAVITY (agy) ════════════════════════════════════════════════════════════════════════
180
+ // agy-doctor.mjs — diagnose & self-heal drift between the Antigravity CLI and our pinned contract.
181
+ //
182
+ // WHY: `agy` is under constant development and its headless print contract has broken on minor bumps
183
+ // before (1.0.10 shipped empty reviews for weeks). The shared rail (scripts/lib/cross-agent-cli.mjs)
184
+ // therefore PINS the version and FAIL-LOUDS on mismatch — safe, but every agy self-update stalls
185
+ // cross-review until a human re-verifies the contract and bumps AGY_PINNED by hand. This script makes
186
+ // that re-verification executable, so the agent that hits the pin failure is AUTHORIZED to clear it:
187
+ //
188
+ // node scripts/cross-agent-doctor.mjs agy # diagnose: version vs pin, help contract, models, live probes
189
+ // node scripts/cross-agent-doctor.mjs agy --fix # on clean version drift: bump AGY_PINNED + run the test suite
190
+ //
191
+ // What --fix will and will NOT do:
192
+ // • WILL bump the pin — only when the installed version differs AND the live contract probe is green
193
+ // (`agy --help` still shows -p/--model, both pinned models still listed by `agy models`, and at
194
+ // least one live `-p`/`--model` probe returns real output). A failed probe still fails loud with
195
+ // what broke — that's the 1.0.10 protection, kept.
196
+ // • WILL NOT swap models. A vanished/renamed pinned model is reported with the current `agy models`
197
+ // list; choosing a reviewer model is a judgment call (edit AGY_MODEL/AGY_FALLBACK_MODEL or set the
198
+ // env overrides). It also never upgrades/downgrades the agy binary itself.
199
+ // • WILL NOT commit or push. After --fix, commit the one-line bump via the normal flow (LOW tier —
200
+ // docs/tooling). A script pushing branches from arbitrary checkouts would recreate the
201
+ // shared-worktree collision class LEARNINGS warns about; agents already own the commit/PR flow.
202
+ //
203
+ // NOT wired into ops-nightly (deliberate): a cloud routine sandbox has no agy binary and agy has no
204
+ // headless auth (the same fact that keeps cross-review local-only), so drift can only be observed —
205
+ // and fixed — on a machine where agy runs. The distribution point is checkAgyVersion's own failure
206
+ // message, which names this command.
207
+ //
208
+ // Zero npm deps — Node 21+ (the --fix flow's `node --test 'scripts/lib/*.test.mjs' 'scripts/*.test.mjs'` relies on the test
209
+ // runner's own glob expansion under spawnSync(..., {shell:false}), which Node added in 21; below that,
210
+ // the literal, unexpanded pattern silently matches nothing). Pure decision logic exported for node:test
211
+ // (isMain-guarded, per LEARNINGS).
212
+ const __dirname = dirname(fileURLToPath(import.meta.url));
213
+ const LIB_PATH = join(__dirname, 'lib', 'cross-agent-cli.mjs');
214
+
215
+ // ── Pure decision core (the unit under test) ─────────────────────────────────────────────────────────
216
+ // Given the observed facts, decide ONE action, most severe first:
217
+ // 'contract-broken' — the -p/--model interface itself changed, or agy errored on a live probe, or NO
218
+ // model produced output. A version bump must NOT be blessed; human re-verify.
219
+ // 'model-drift' — a pinned model is no longer listed by `agy models` (rename/retirement). Reported,
220
+ // never auto-swapped.
221
+ // 'bump' — version differs from the pin and the full contract probe is green → safe to bump.
222
+ // 'quota-warn' — contract fine (version matches or bump already decided against), but the PRIMARY
223
+ // model returned empty on the live probe (quota/transient) while the fallback
224
+ // carried it — exactly the degrade runAntigravity handles; informational.
225
+ // 'ok' — everything matches and probes green.
226
+ // `probes` values: 'ok' (real stdout) | 'empty' (exit 0, no output — the quota signature)
227
+ // | 'unavailable' (non-zero, but the provider said it is busy — see UPSTREAM_UNAVAILABLE)
228
+ // | 'error'
229
+ // (non-zero exit) | 'skipped'.
230
+ /**
231
+ * Provider-side "busy, try later" signatures.
232
+ *
233
+ * 🚨 WHY THIS EXISTS. `agy -p … --model gpt-oss-120b-medium` began exiting 1 with
234
+ * "Our servers are experiencing high traffic right now, please try again in a
235
+ * minute." The probe classified any non-zero exit as 'error', 'error' means
236
+ * "the interface changed", and that verdict refuses to bump the pin — so a
237
+ * TRANSIENT CAPACITY BLIP ON ONE MODEL took the whole agy review family offline
238
+ * and kept it there. Observed live 2026-08-19: the epic's cross-family pass ran
239
+ * with two families instead of three for exactly this reason.
240
+ *
241
+ * The interface was never broken: `--help` still showed the print contract, both
242
+ * slugs were still listed by `agy models`, and the primary model answered fine.
243
+ * Three states were not enough — "I could not reach it" is not "it changed", and
244
+ * collapsing them is the same mistake this codebase keeps recording.
245
+ *
246
+ * Kept deliberately NARROW. Anything that does not match stays 'error', so an
247
+ * unrecognized flag or an unknown model slug — which also exit non-zero — still
248
+ * break the contract loudly. A guard that swallowed every failure would be worse
249
+ * than the bug it fixes.
250
+ */
251
+ export const UPSTREAM_UNAVAILABLE = [
252
+ /experiencing high traffic/i,
253
+ /please try again/i,
254
+ /server is busy/i,
255
+ /temporarily unavailable/i,
256
+ /\b(?:429|503)\b/,
257
+ /rate.?limit/i,
258
+ /overloaded/i,
259
+ ];
260
+
261
+ /** Whether a non-zero probe was the provider being busy rather than the CLI changing. */
262
+ export function isUpstreamUnavailable(output) {
263
+ const text = String(output || '');
264
+ return UPSTREAM_UNAVAILABLE.some((re) => re.test(text));
265
+ }
266
+
267
+ /**
268
+ * agy's "you are signed out" answer. Observed live 2026-09-29 (agy 1.2.13, right after an auto-update):
269
+ * `agy models` prints "Error: Please sign in to view available models. Launch the CLI without arguments
270
+ * to sign in.", and every `-p` probe waits 60 s for an OAuth code, then "authentication timed out".
271
+ * Read as a contract, that is every model "NOT LISTED" plus two probe errors — a CONTRACT-BROKEN verdict
272
+ * for what is really an operator action. Could-not-look is its own state (LEARNINGS), so it is checked
273
+ * first, and the probes are skipped (each one would otherwise burn a minute waiting for a login).
274
+ */
275
+ export function isAgySignedOut(output) {
276
+ return /please sign in|sign in to view|waiting for authentication|authentication (?:timed out|failed)/i.test(
277
+ String(output || '')
278
+ );
279
+ }
280
+
281
+ export function decideDoctorAction({
282
+ installed,
283
+ pinned,
284
+ helpOk,
285
+ primaryListed,
286
+ fallbackListed,
287
+ probes,
288
+ unlistedModels = [],
289
+ signedOut = false,
290
+ }) {
291
+ const notes = [];
292
+ if (!installed)
293
+ return {
294
+ action: 'contract-broken',
295
+ notes: [
296
+ '`agy --version` output didn\'t contain a parseable X.Y.Z — a version this blind cannot be bumped (would write the literal string "null" as the pin).',
297
+ ],
298
+ };
299
+ if (!helpOk)
300
+ return {
301
+ action: 'contract-broken',
302
+ notes: ['`agy --help` no longer shows the -p/--model print contract.'],
303
+ };
304
+ // Signed out AFTER the checks that need no login (pr-reviewer S2): a help-contract break is visible
305
+ // signed in or not, and must never be reported as merely "could not look".
306
+ if (signedOut)
307
+ return {
308
+ action: 'signed-out',
309
+ notes: [
310
+ 'agy is signed out, so models and the print contract could not be checked (not a contract break).',
311
+ ],
312
+ };
313
+ if (probes.primary === 'error' || probes.fallback === 'error')
314
+ return {
315
+ action: 'contract-broken',
316
+ notes: [
317
+ 'a live `agy -p … --model …` probe exited non-zero (not the quota signature — a real interface error).',
318
+ ],
319
+ };
320
+ // 'unavailable' is the provider being busy, NOT the interface changing — so it
321
+ // must never break the contract on its own. But a version whose ONLY evidence
322
+ // is two unreachable models has not been demonstrated to work, so that pairing
323
+ // is still not blessable.
324
+ const blind = (p) => p === 'empty' || p === 'unavailable';
325
+ if (blind(probes.primary) && blind(probes.fallback))
326
+ return {
327
+ action: 'contract-broken',
328
+ notes: [
329
+ 'NEITHER model produced output on the live probe (quota exhaustion and/or provider capacity) — a version this blind cannot be blessed; re-run later or re-verify by hand.',
330
+ ],
331
+ };
332
+ // Report each drifted constant ONCE, preferring the entry that names the offending model. Building the
333
+ // descriptive entries (every agy model this repo configures — AGY_MODELS_IN_USE, not just the review
334
+ // pair) FIRST and only then filling gaps from the bare flags makes the precedence explicit rather than an
335
+ // accident of array order (Golden Frijoles' agy-doctor; cross-review caught the wrong duplicate kept).
336
+ const described = new Map(unlistedModels.map((m) => [m.constant, `${m.constant} ("${m.value}")`]));
337
+ for (const [flag, isListed] of [
338
+ ['AGY_MODEL', primaryListed],
339
+ ['AGY_FALLBACK_MODEL', fallbackListed],
340
+ ]) {
341
+ if (!isListed && !described.has(flag)) described.set(flag, flag);
342
+ }
343
+ const missing = [...described.values()];
344
+ if (missing.length) {
345
+ return {
346
+ action: 'model-drift',
347
+ notes: [
348
+ `${missing.join(' and ')} no longer listed by \`agy models\` — pick a replacement (env override or edit the constant); not auto-swapped. ` +
349
+ `NOTE: agy does NOT fail on an unknown --model; it silently substitutes its default, so an unlisted name here means that tool has been quietly running on the wrong model.`,
350
+ ],
351
+ };
352
+ }
353
+ if (probes.primary === 'empty')
354
+ notes.push(
355
+ 'the primary model returned empty on the live probe (quota/transient) — the fallback carried it.'
356
+ );
357
+ if (probes.primary === 'unavailable')
358
+ notes.push(
359
+ 'the primary model reported provider capacity trouble — the fallback carried it. Not an interface problem.'
360
+ );
361
+ if (probes.fallback === 'unavailable')
362
+ notes.push(
363
+ 'the FALLBACK model reported provider capacity trouble. The primary answered, so the contract is intact — but the second quota pool is unavailable right now, which is the whole point of having one. Re-check before relying on it.'
364
+ );
365
+ if (installed !== pinned) return { action: 'bump', notes };
366
+ if (notes.length) return { action: 'quota-warn', notes };
367
+ return { action: 'ok', notes };
368
+ }
369
+
370
+ // Pure string transform: rewrite the AGY_PINNED constant + the doctor-managed "last verified" marker
371
+ // line in the lib source. Throws (never half-writes) if either anchor is missing or ambiguous.
372
+ export function bumpPinnedSource(source, newVersion, date) {
373
+ const pinRe = /^export const AGY_PINNED = '[^']+';$/m;
374
+ const markerRe = /^\/\/ agy-doctor: last verified [0-9-]+ against \S+\.$/m;
375
+ if (!pinRe.test(source))
376
+ throw new Error('AGY_PINNED constant line not found — lib shape changed, bump by hand.');
377
+ if (!markerRe.test(source))
378
+ throw new Error('agy-doctor marker line not found — lib shape changed, bump by hand.');
379
+ return source
380
+ .replace(pinRe, `export const AGY_PINNED = '${newVersion}';`)
381
+ .replace(markerRe, `// agy-doctor: last verified ${date} against ${newVersion}.`);
382
+ }
383
+
384
+ // agy 1.1.11 changed `agy models` from one slug per line to tabular
385
+ // "slug<TAB>display name" rows. Compare the first field only; comparing the whole
386
+ // display row produces a false model-drift result even while both live probes pass.
387
+ export function parseAgyModelSlugs(output) {
388
+ return (
389
+ String(output ?? '')
390
+ .split('\n')
391
+ .map((line) => line.trim())
392
+ .filter((line) => line && !/^fetching available models/i.test(line))
393
+ // 1.1.11 rows are tab-separated. Legacy output is one complete slug per
394
+ // line. Never take the first whitespace word from prose: a status line
395
+ // such as "gemini-… is unavailable" must not prove that model is listed.
396
+ .flatMap((line) => {
397
+ const tab = line.indexOf('\t');
398
+ const candidate = tab === -1 ? line : line.slice(0, tab).trim();
399
+ // A tab is the 1.1.11 row boundary, so its first field may be a simple
400
+ // alphabetic slug. A legacy bare line has no structural marker: require
401
+ // model-ID punctuation/digits so arbitrary prose cannot prove a listing.
402
+ const valid =
403
+ tab === -1
404
+ ? /^(?=.*[0-9._:/-])[a-z0-9][a-z0-9._:/-]*$/.test(candidate)
405
+ : /^[a-z0-9][a-z0-9._:/-]*$/.test(candidate);
406
+ return valid ? [candidate] : [];
407
+ })
408
+ );
409
+ }
410
+
411
+ // ── I/O helpers (thin, injectable-free — the decision core above is what tests exercise) ─────────────
412
+ function agy(args, input) {
413
+ return spawnSync('agy', args, { encoding: 'utf8', input: input ?? '', maxBuffer: 16 * 1024 * 1024 });
414
+ }
415
+
416
+ function observeAgy() {
417
+ const ver = agy(['--version']);
418
+ // Missing is a reported state, never a process.exit: `cross-agent-doctor.mjs` with no family argument
419
+ // still owes the codex line.
420
+ if (ver.error) return null;
421
+ const installed = (((ver.stdout || '') + (ver.stderr || '')).match(/\d+\.\d+\.\d+/) || [null])[0];
422
+ // agy prints --help to STDERR (confirmed live 1.0.16) — read both streams for robustness.
423
+ const helpR = agy(['--help']);
424
+ const help = (helpR.stdout || '') + (helpR.stderr || '');
425
+ const helpOk = /^\s*-p\b/m.test(help) && /--model\b/.test(help);
426
+ const modelsR = agy(['models']);
427
+ const modelsOutput = (modelsR.stdout || '') + (modelsR.stderr || '');
428
+ const models = modelsOutput
429
+ .split('\n')
430
+ .map((l) => l.trim())
431
+ .filter(Boolean);
432
+ const modelSlugs = parseAgyModelSlugs(modelsOutput);
433
+ const probe = (model) => {
434
+ const r = agy(['-p', 'Reply with exactly: OK', '--model', model]);
435
+ // A non-zero exit is an interface break ONLY if the provider did not just say
436
+ // it was busy. See `UPSTREAM_UNAVAILABLE` for the incident this distinction
437
+ // comes from.
438
+ if (r.status !== 0) {
439
+ return isUpstreamUnavailable((r.stdout || '') + (r.stderr || '')) ? 'unavailable' : 'error';
440
+ }
441
+ return (r.stdout || '').trim() ? 'ok' : 'empty';
442
+ };
443
+ if (isAgySignedOut(modelsOutput)) {
444
+ return {
445
+ installed,
446
+ pinned: AGY_PINNED,
447
+ helpOk,
448
+ signedOut: true,
449
+ primaryListed: false,
450
+ fallbackListed: false,
451
+ unlistedModels: [],
452
+ models: [],
453
+ probes: { primary: 'skipped', fallback: 'skipped' },
454
+ };
455
+ }
456
+ const probes = { primary: probe(AGY_MODEL), fallback: 'skipped' };
457
+ // Probe the fallback only when needed for the decision (primary empty/error, or a version bump needs
458
+ // the fuller picture) — each probe is a real model call.
459
+ if (probes.primary !== 'ok' || installed !== AGY_PINNED) probes.fallback = probe(AGY_FALLBACK_MODEL);
460
+ return {
461
+ installed,
462
+ pinned: AGY_PINNED,
463
+ helpOk,
464
+ primaryListed: modelSlugs.includes(AGY_MODEL),
465
+ fallbackListed: modelSlugs.includes(AGY_FALLBACK_MODEL),
466
+ // Checked cheaply against the already-fetched list — no extra model calls, so auditing every
467
+ // configured agy model (not just the review pair) costs nothing.
468
+ unlistedModels: AGY_MODELS_IN_USE.filter((m) => !modelSlugs.includes(m.value)),
469
+ models,
470
+ probes,
471
+ };
472
+ }
473
+
474
+ async function agyMain() {
475
+ const fix = process.argv.includes('--fix');
476
+ const obs = observeAgy();
477
+ if (!obs) {
478
+ process.stdout.write(
479
+ 'agy — ✗ not installed (not on PATH). Install the Antigravity CLI, sign in, then re-run.\n'
480
+ );
481
+ process.exitCode = 1;
482
+ return;
483
+ }
484
+ // 'skipped' → 'ok' is safe BY CONSTRUCTION, not convention: observe() skips the fallback probe only
485
+ // when the primary probed 'ok' AND installed === pinned — and with installed === pinned the decision
486
+ // can never reach 'bump', so a bump is never blessed on an unprobed fallback. (If observe()'s skip
487
+ // condition ever changes, revisit this substitution.)
488
+ const decision = decideDoctorAction({
489
+ ...obs,
490
+ probes: {
491
+ primary: obs.probes.primary,
492
+ fallback: obs.probes.fallback === 'skipped' ? 'ok' : obs.probes.fallback,
493
+ },
494
+ });
495
+
496
+ const line = (s) => process.stdout.write(`${s}\n`);
497
+ line(
498
+ `agy-doctor — installed ${obs.installed} · pinned ${obs.pinned} · help contract ${obs.helpOk ? 'ok' : 'BROKEN'}`
499
+ );
500
+ // Every configured name, not just the review pair — a silently-substituted model is visible at a glance.
501
+ // Signed out, nothing was listed or probed, so printing the names as "listed" would be a false pass.
502
+ if (!obs.signedOut) {
503
+ for (const m of AGY_MODELS_IN_USE) {
504
+ const listed = !obs.unlistedModels.some((u) => u.constant === m.constant);
505
+ line(` ${listed ? '·' : '✗'} ${m.constant} = "${m.value}"${listed ? '' : ' ← NOT LISTED'}`);
506
+ }
507
+ line(`live probe: primary ${obs.probes.primary} · fallback ${obs.probes.fallback}`);
508
+ }
509
+ for (const n of decision.notes) line(` note: ${n}`);
510
+
511
+ switch (decision.action) {
512
+ case 'signed-out':
513
+ line(
514
+ '✗ could not look: agy is signed out. Run `agy` once with no arguments to sign in, then re-run this doctor.'
515
+ );
516
+ process.exitCode = 1;
517
+ return;
518
+ case 'ok':
519
+ line('✓ no drift — pin, models, and print contract all verified live.');
520
+ return;
521
+ case 'quota-warn':
522
+ line(
523
+ '✓ no drift (transient primary-model quota noted above — runAntigravity degrades to the fallback on its own).'
524
+ );
525
+ return;
526
+ case 'model-drift':
527
+ line(`✗ model drift. Current \`agy models\`:\n ${obs.models.join('\n ')}`);
528
+ line(
529
+ 'Pick a replacement: set AGY_MODEL / AGY_FALLBACK_MODEL env, or edit the constants in scripts/lib/cross-agent-cli.mjs. Not auto-fixed (judgment call).'
530
+ );
531
+ process.exitCode = 1;
532
+ return;
533
+ case 'contract-broken':
534
+ line(
535
+ '✗ contract broken — do NOT bump the pin. Re-verify `agy -p "<prompt>" --model "<model>"` by hand against `agy --help`.'
536
+ );
537
+ process.exitCode = 1;
538
+ return;
539
+ case 'bump': {
540
+ // --fix runs `node --test '<glob>'`, whose glob expansion Node added in 21; below that the pattern matches
541
+ // nothing and a pin would be bumped with no tests run. Refuse rather than bless blind (cross-review, #188).
542
+ if (fix && Number(process.versions.node.split('.')[0]) < 21) {
543
+ line(
544
+ `✗ --fix needs Node 21+ (this is ${process.versions.node}): it runs the test suite after bumping. Not bumped.`
545
+ );
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+ if (!fix) {
550
+ line(
551
+ `→ version drift with a GREEN contract probe: safe to bump. Run \`node scripts/cross-agent-doctor.mjs agy --fix\` to update AGY_PINNED ${obs.pinned} → ${obs.installed}.`
552
+ );
553
+ return;
554
+ }
555
+ const src = readFileSync(LIB_PATH, 'utf8');
556
+ const today = new Date().toISOString().slice(0, 10);
557
+ writeFileSync(LIB_PATH, bumpPinnedSource(src, obs.installed, today));
558
+ line(`✓ AGY_PINNED bumped ${obs.pinned} → ${obs.installed} (probe green; marker dated ${today}).`);
559
+ const t = spawnSync(process.execPath, ['--test', 'scripts/lib/*.test.mjs', 'scripts/*.test.mjs'], {
560
+ encoding: 'utf8',
561
+ cwd: resolve(__dirname, '..'),
562
+ shell: false,
563
+ });
564
+ if (t.status !== 0) {
565
+ line('✗ test suite FAILED after the bump — review before committing:');
566
+ process.stdout.write(
567
+ (t.stdout || '')
568
+ .split('\n')
569
+ .filter((l) => /not ok|fail/i.test(l))
570
+ .slice(0, 10)
571
+ .join('\n') + '\n'
572
+ );
573
+ process.exitCode = 1;
574
+ return;
575
+ }
576
+ line('✓ scripts test suite green. Next: commit the one-line bump (LOW tier) via the normal flow —');
577
+ line(
578
+ ' branch `chore/agy-pin-bump-' +
579
+ obs.installed +
580
+ '`, path-limited commit of scripts/lib/cross-agent-cli.mjs, PR.'
581
+ );
582
+ return;
583
+ }
584
+ }
585
+ }
586
+
587
+ // ── The dispatcher ───────────────────────────────────────────────────────────────────────────────
588
+ async function main() {
589
+ const argv = process.argv.slice(2);
590
+ const families = argv.filter((a) => !a.startsWith('-'));
591
+ const want = families.length ? families : ['codex', 'agy'];
592
+ for (const f of want) {
593
+ if (!['codex', 'agy'].includes(f)) {
594
+ process.stderr.write(`unknown family '${f}' — expected codex | agy\n`);
595
+ process.exitCode = 1;
596
+ return;
597
+ }
598
+ }
599
+ for (const f of want) {
600
+ if (f !== want[0]) process.stdout.write('\n');
601
+ if (f === 'codex') await codexMain();
602
+ else await agyMain();
603
+ }
604
+ }
605
+
606
+ // realpath on both sides: through a symlinked path a plain compare is false and the doctor would exit 0
607
+ // having checked nothing (#189 review).
608
+ const isMain = (() => {
609
+ try {
610
+ return (
611
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
612
+ );
613
+ } catch {
614
+ return false;
615
+ }
616
+ })();
617
+ if (isMain) await main();