acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/doctor.mjs ADDED
@@ -0,0 +1,2139 @@
1
+ /**
2
+ * ── ⭐⭐ THE DOCTOR — ONE COMMAND THAT SAYS WHAT IS ACTUALLY WORKING ─────────
3
+ *
4
+ * ⚠️ THIS FILE IS PAID FOR BY A MEASURED HOUR. Four media tools were silently
5
+ * dark because ONE undocumented environment variable (`MODAL_VIDEO_SECRET`) was
6
+ * missing. The error the agent saw was *"the speech service returned no audio"*
7
+ * — which reads TRANSIENT, so it retried four times and burned six rounds on a
8
+ * call that could never have succeeded. An hour went into diagnosing a
9
+ * thirty-second question.
10
+ *
11
+ * ── ⚠️⚠️ THE ONE RULE THIS FILE EXISTS TO ENFORCE: A 200 IS NOT HEALTH ──────
12
+ *
13
+ * MEASURED LIVE, 2026-08-11, against all four Modal endpoints:
14
+ *
15
+ * POST {} -> 200 {"ok":false,"error":"unauthorised"}
16
+ * POST {secret} -> 200 {"ok":false,"error":"empty text"}
17
+ *
18
+ * Both are HTTP 200. The FIRST is a dead capability; the SECOND is a healthy
19
+ * service complaining about a deliberately-empty payload — which is exactly
20
+ * the proof we want, because a payload complaint can only be reached AFTER the
21
+ * credential was accepted. So the health probe here sends the secret and NO
22
+ * payload: it costs no GPU-seconds, it returns in ~300ms, and it distinguishes
23
+ * "your key is wrong" from "your request was wrong". Believing the 200 is
24
+ * precisely the bug that cost the hour.
25
+ *
26
+ * ── ⭐ THE OUTPUT IS THE PRODUCT ────────────────────────────────────────────
27
+ * Three states, and nothing else:
28
+ *
29
+ * live it is configured AND something proved it works
30
+ * dark it is switched off / not configured — nothing is wrong, but the
31
+ * capability is not there and the model will never be offered it
32
+ * broken it is configured and it DOES NOT WORK
33
+ *
34
+ * ⚠️ AND EVERY dark OR broken LINE NAMES THE EXACT VARIABLE OR ACTION. A
35
+ * diagnostic that says "TTS: unavailable" has done nothing — the reader is
36
+ * exactly where they started. "TTS: dark — MODAL_TTS_URL unset" ends the
37
+ * investigation in one line. The word "unavailable" is banned here and a test
38
+ * pins that.
39
+ *
40
+ * ── ⚠️ THE FOURTH THING THAT IS NOT A STATE: `verified` ─────────────────────
41
+ * A machine with no network is NOT a broken machine, and a doctor that paints
42
+ * an offline laptop red is a check that fails correct work — the failure mode
43
+ * this repo has now been bitten by six times. So when every single probe fails
44
+ * at the TRANSPORT layer (no HTTP status ever arrived), the endpoints keep the
45
+ * state their configuration earns and carry `verified: false` plus the words
46
+ * "could not check". One host down while the others answer is a different
47
+ * story and IS reported broken, because then we have proof.
48
+ *
49
+ * ── ⚠️⚠️ NEVER PRINT A SECRET. NOT A PREFIX, NOT A SUFFIX, NOT A LENGTH ─────
50
+ * The temptation is real and there is a specific trap: OpenRouter's own
51
+ * `GET /api/v1/key` answers with `{"data":{"label":"sk-or-v1-abc...xyz"}}` —
52
+ * a prefix AND a suffix of a live credential, handed to us by the service we
53
+ * are checking. Reading that field would leak a key into terminal scrollback,
54
+ * CI logs and every pasted bug report. So: this file reports "present" or
55
+ * "absent", it reads `label` from nothing, and `scrubSecrets` runs over the
56
+ * WHOLE report on the way out as a second line of defence.
57
+ *
58
+ * ── ⚠️ IT MUST NEVER HANG ───────────────────────────────────────────────────
59
+ * Every probe is bounded twice — an `AbortSignal.timeout` for the real network
60
+ * and a `withTimeout` race for a `fetchImpl` that ignores signals — and they
61
+ * all run concurrently. A diagnostic you have to wait thirty seconds for is one
62
+ * people stop running.
63
+ *
64
+ * ── PURITY ─────────────────────────────────────────────────────────────────
65
+ * Everything that decides anything is a pure function of data: `assessProbe`,
66
+ * `isOffline`, `checkNodeVersion`, `gitignoreCoversAcuvo`, `scrubSecrets`,
67
+ * `summarise`, `formatDoctor`. `runDoctor` is the only edge, and it takes its
68
+ * clock, its fetch, its git and its filesystem as arguments.
69
+ */
70
+
71
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
72
+ import { join, dirname, resolve } from 'node:path';
73
+ import { fileURLToPath } from 'node:url';
74
+
75
+ import { readMcpConfig, MCP_CONFIG_FILES, MAX_SERVERS } from './mcp.mjs';
76
+ import { CATALOGUE, assessCatalogue, packageOf, STARTER_CONFIG_FILE } from './mcp-defaults.mjs';
77
+ import { readModelConfig, DEFAULT_MODEL } from './model.mjs';
78
+ import { buildChain } from './chain.mjs';
79
+ import { mediaConfig, MEDIA_SECRET_ENV_NAMES } from './media.mjs';
80
+ import { editConfig } from './image-edit.mjs';
81
+ import { imageConfig, engineConfig, generateEndpoint, IMAGE_URL_ENV, IMAGE_TOKEN_ENV, ENGINE_SECRET_ENV } from './imagegen.mjs';
82
+ import { resolveCommandAllowlist, COMMANDS_CONFIG_FILE, ALLOW_COMMANDS_ENV, PRESET_NAMES } from './command.mjs';
83
+ import { TOOL_NAMES, toolNamesForRounds, languagesPresent } from './tools.mjs';
84
+ import { LANGUAGE_SERVERS } from './lsp.mjs';
85
+ import { gitStatus as realGitStatus, ALLOW_PUSH_ENV, PROTECTED_BRANCHES } from './git.mjs';
86
+
87
+ /** ⚠️ THREE. Adding a fourth is how "unknown" becomes a place to hide. */
88
+ export const DOCTOR_STATES = ['live', 'dark', 'broken'];
89
+
90
+ /**
91
+ * The credentials this package reads. Used only to know what to SCRUB — the
92
+ * values are never rendered, compared against the output, or counted.
93
+ */
94
+ export const SECRET_ENV_VARS = [
95
+ 'OPENROUTER_API_KEY',
96
+ 'MODAL_VIDEO_SECRET',
97
+ IMAGE_TOKEN_ENV,
98
+ 'GITHUB_TOKEN',
99
+ 'GH_TOKEN',
100
+ ];
101
+
102
+ /** OpenRouter's free, tokenless endpoints. A model call would cost money and prove less. */
103
+ const OR_KEY_URL = 'https://openrouter.ai/api/v1/key';
104
+ const OR_CREDITS_URL = 'https://openrouter.ai/api/v1/credits';
105
+ const OR_MODELS_URL = 'https://openrouter.ai/api/v1/models';
106
+
107
+ const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
108
+
109
+ /** The directory the CLI writes sessions and audit logs into, inside the USER's repo. */
110
+ const ACUVO_DIR = '.acuvo';
111
+
112
+ // ───────────────────────────────────────────────────────────────────────────
113
+ // PURE: TIME AND VERSIONS
114
+ // ───────────────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Bound a promise without leaking a timer.
118
+ *
119
+ * ⚠️ `clearTimeout` IS THE POINT, not the race. A doctor that finished in 400ms
120
+ * and then held the process open for the remaining ten seconds of every probe
121
+ * timeout would look like a hang to the only observer who matters — the person
122
+ * waiting for their shell prompt back.
123
+ *
124
+ * ⚠️ AND IT IS NEEDED EVEN THOUGH EVERY REQUEST CARRIES AN AbortSignal. A
125
+ * `fetchImpl` handed in by a caller (or a test) is under no obligation to
126
+ * honour the signal, and "we trusted the injected dependency" is not a bound.
127
+ *
128
+ * ⚠️⚠️ THE TIMER IS DELIBERATELY **NOT** unref'd, and the first draft got this
129
+ * backwards. An unref'd alarm does not hold the event loop open, so when the
130
+ * thing being bounded is a promise that never settles — precisely the case this
131
+ * function exists for — the loop drains and the await never returns. Node's own
132
+ * test runner names it exactly: *"Promise resolution is still pending but the
133
+ * event loop has already resolved"*. The leak this was meant to avoid is
134
+ * handled by `clearTimeout` in the `finally`, which runs on every path.
135
+ */
136
+ export function withTimeout(promise, ms, fallback) {
137
+ let timer = null;
138
+ const alarm = new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); });
139
+ return Promise.race([promise, alarm]).finally(() => { if (timer) clearTimeout(timer); });
140
+ }
141
+
142
+ /**
143
+ * The minimum major version an `engines` range asks for.
144
+ *
145
+ * ⚠️ DELIBERATELY NOT A SEMVER IMPLEMENTATION. This package has zero
146
+ * dependencies forever, and the question is small: what is the lowest major
147
+ * that satisfies this string. `>=20`, `>=20.0.0`, `^22.0.0` and `22.x` are the
148
+ * shapes a package.json really carries.
149
+ *
150
+ * ⭐ RETURNS null RATHER THAN GUESSING. `*`, `latest`, an absent field and
151
+ * anything unparseable all mean "we do not know", and a doctor that invents a
152
+ * requirement will fail a machine that is completely fine.
153
+ */
154
+ export function parseEnginesRange(spec) {
155
+ if (typeof spec !== 'string') return null;
156
+ const m = /(\d+)/.exec(spec);
157
+ if (!m) return null;
158
+ return Number(m[1]);
159
+ }
160
+
161
+ /**
162
+ * ⚠️ AN UNPARSEABLE VERSION IS NOT A FAILING VERSION. Node is obviously running
163
+ * — it is running this code — so the worst honest answer is "we could not
164
+ * compare", never "your runtime is broken".
165
+ */
166
+ export function checkNodeVersion(version, enginesSpec) {
167
+ const min = parseEnginesRange(enginesSpec);
168
+ const running = /v?(\d+)/.exec(String(version ?? ''));
169
+ const major = running ? Number(running[1]) : null;
170
+
171
+ if (min === null) {
172
+ return {
173
+ id: 'runtime.node',
174
+ label: 'node',
175
+ state: 'live',
176
+ verified: true,
177
+ detail: `${version} — no engines range to compare against`,
178
+ fix: null,
179
+ };
180
+ }
181
+ if (major === null) {
182
+ return {
183
+ id: 'runtime.node',
184
+ label: 'node',
185
+ state: 'live',
186
+ verified: false,
187
+ detail: `could not read a major version out of "${version}"; engines asks for >=${min}`,
188
+ fix: null,
189
+ };
190
+ }
191
+ if (major < min) {
192
+ return {
193
+ id: 'runtime.node',
194
+ label: 'node',
195
+ state: 'broken',
196
+ verified: true,
197
+ detail: `${version} is below the engines floor of >=${min}`,
198
+ fix: `upgrade Node to ${min} or newer — https://nodejs.org (this package uses Node ${min}+ built-ins and has no dependencies to fall back on)`,
199
+ };
200
+ }
201
+ return {
202
+ id: 'runtime.node',
203
+ label: 'node',
204
+ state: 'live',
205
+ verified: true,
206
+ detail: `${version} satisfies engines >=${min}`,
207
+ fix: null,
208
+ };
209
+ }
210
+
211
+ // ───────────────────────────────────────────────────────────────────────────
212
+ // PURE: PROBE VERDICTS
213
+ // ───────────────────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * True only when EVERY network probe failed before an HTTP status arrived.
217
+ *
218
+ * ⚠️ A REFUSAL OR A 404 PROVES THE NETWORK WORKS, so one of those anywhere in
219
+ * the set means this machine is online and a dead host is genuinely dead. The
220
+ * distinction is the whole reason an offline laptop does not light up red.
221
+ */
222
+ export function isOffline(probes) {
223
+ const seen = (probes ?? []).filter(Boolean);
224
+ if (seen.length === 0) return false;
225
+ return seen.every((p) => p.kind === 'unreachable');
226
+ }
227
+
228
+ /**
229
+ * Turn one probe result into a check verdict.
230
+ *
231
+ * kinds:
232
+ * ok the service answered and the credential was accepted
233
+ * refused the service answered and rejected the credential (often HTTP 200!)
234
+ * http the service answered with a status or body we cannot call healthy
235
+ * unreachable no HTTP status ever arrived
236
+ * unchecked we deliberately did not probe (no fetch, or no health route)
237
+ */
238
+ export function assessProbe(probe, { envVar, secretVar = 'MODAL_VIDEO_SECRET', offline = false, host = null } = {}) {
239
+ const where = host ? ` (${host})` : '';
240
+ switch (probe?.kind) {
241
+ case 'ok':
242
+ return { state: 'live', verified: true, detail: `configured${where} · reachable and authorised${probe.detail ? ` — ${probe.detail}` : ''}`, fix: null };
243
+
244
+ /**
245
+ * ⭐⭐ THE LINE THAT PAYS FOR THE FILE. The service said 200 and it said no.
246
+ * The fix is a credential, and NOTHING about it is transient — so the
247
+ * message must not read like something worth retrying.
248
+ */
249
+ case 'refused':
250
+ return {
251
+ state: 'broken',
252
+ verified: true,
253
+ detail: `configured${where} · the service REFUSED the credential — ${probe.detail || 'unauthorised'} (it answered HTTP 200; a 200 is not proof of health)`,
254
+ fix: `set ${secretVar} to the value this endpoint expects — retrying will not help`,
255
+ };
256
+
257
+ case 'http':
258
+ return {
259
+ state: 'broken',
260
+ verified: true,
261
+ detail: `configured${where} · ${probe.detail || 'the service answered with an error'}`,
262
+ fix: `check ${envVar} points at the right service, and that the service is deployed`,
263
+ };
264
+
265
+ case 'unreachable':
266
+ if (offline) {
267
+ return {
268
+ state: 'live',
269
+ verified: false,
270
+ detail: `configured${where} · could not check — nothing on this machine reached the network`,
271
+ fix: `reconnect and re-run the doctor to verify ${envVar}`,
272
+ };
273
+ }
274
+ return {
275
+ state: 'broken',
276
+ verified: true,
277
+ detail: `configured${where} · unreachable while other services answered — ${probe.detail || 'no response'}`,
278
+ fix: `check ${envVar} — the host may be wrong, or the service scaled to zero and failed to start`,
279
+ };
280
+
281
+ case 'unchecked':
282
+ default:
283
+ return {
284
+ state: 'live',
285
+ verified: false,
286
+ detail: `configured${where} · ${probe?.detail || 'not checked'}`,
287
+ fix: null,
288
+ };
289
+ }
290
+ }
291
+
292
+ /** Count the states, and count separately how much of the green was never proved. */
293
+ export function summarise(checks) {
294
+ const out = { live: 0, dark: 0, broken: 0, unverified: 0 };
295
+ for (const c of checks ?? []) {
296
+ if (out[c.state] !== undefined) out[c.state] += 1;
297
+ if (c.verified === false) out.unverified += 1;
298
+ }
299
+ return out;
300
+ }
301
+
302
+ // ───────────────────────────────────────────────────────────────────────────
303
+ // PURE: THE WORKSPACE
304
+ // ───────────────────────────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * Does this .gitignore body keep `.acuvo/` out of the user's repository?
308
+ *
309
+ * ⚠️ THIS MATTERS BECAUSE WE WRITE INTO SOMEBODY ELSE'S REPO. Sessions, audit
310
+ * logs and generated screenshots land in `.acuvo/`, and an un-ignored `.acuvo/`
311
+ * turns every `git status` into noise and every `git add -A` into a commit of
312
+ * our transcripts — which can contain the user's own prompts.
313
+ *
314
+ * ⚠️ A LATER `!.acuvo/` WINS, exactly as git resolves it. Reporting "ignored"
315
+ * for a file git will happily commit is the false all-clear this repo keeps
316
+ * relearning, so the last matching rule decides.
317
+ */
318
+ export function gitignoreCoversAcuvo(text) {
319
+ if (typeof text !== 'string' || text.trim() === '') return false;
320
+ let covered = false;
321
+ // A BOM in front of the first pattern makes it match nothing — strip it.
322
+ for (const raw of text.replace(/^/, '').split(/\r?\n/)) {
323
+ const line = raw.trim();
324
+ if (line === '' || line.startsWith('#')) continue;
325
+ const negated = line.startsWith('!');
326
+ const pattern = (negated ? line.slice(1) : line).replace(/^\/+/, '').replace(/\/+$/, '');
327
+ // `.acuvo`, `.acuvo/`, `/.acuvo/` and `.acuvo/**` all cover the directory.
328
+ // `.acuvo/skills` covers only a child, and `.acuvo.md` is a different name.
329
+ if (pattern === ACUVO_DIR || pattern === `${ACUVO_DIR}/**` || pattern === `${ACUVO_DIR}/*`) {
330
+ covered = !negated;
331
+ }
332
+ }
333
+ return covered;
334
+ }
335
+
336
+ // ───────────────────────────────────────────────────────────────────────────
337
+ // PURE: SECRETS
338
+ // ───────────────────────────────────────────────────────────────────────────
339
+
340
+ const REDACTED = '<redacted>';
341
+
342
+ /**
343
+ * ⚠️ TWO MECHANISMS, BECAUSE ONE IS NOT ENOUGH.
344
+ *
345
+ * 1. VALUE MATCHING catches a credential we ourselves put in a string.
346
+ * 2. SHAPE MATCHING catches one we never held — specifically OpenRouter's
347
+ * `label` field, `sk-or-v1-abc...xyz`, which is NOT a substring of the key
348
+ * and which a value-only scrub sails straight past. It is still a prefix
349
+ * and a suffix of a live credential.
350
+ *
351
+ * ⚠️ AN EMPTY OR WHITESPACE ENV VALUE IS NEVER A PATTERN. `''` as a search
352
+ * string replaces between every character; a doctor that redacted every
353
+ * character of its own report would be a very thorough kind of useless.
354
+ */
355
+ export function scrubSecrets(value, env = {}, names = SECRET_ENV_VARS) {
356
+ const literals = [];
357
+ for (const name of names) {
358
+ const v = env?.[name];
359
+ if (typeof v === 'string' && v.trim().length >= 8) literals.push(v.trim());
360
+ }
361
+ // Longest first, so a key that contains another value is not half-replaced.
362
+ literals.sort((a, b) => b.length - a.length);
363
+
364
+ const scrubString = (s) => {
365
+ let out = s;
366
+ for (const lit of literals) out = out.split(lit).join(REDACTED);
367
+ return out
368
+ // Whole credential-bearing header lines, value and all.
369
+ .replace(/^[ \t]*(authorization|proxy-authorization|x-api-key|api-key)[ \t]*:.*$/gim, `<header ${REDACTED}>`)
370
+ // OpenRouter's own shape, dots included so the `abc...xyz` label dies too.
371
+ .replace(/sk-or-v1-[A-Za-z0-9._-]+/g, REDACTED)
372
+ // Every other provider's key, loose on purpose: a false positive costs a
373
+ // reader nothing, a false negative costs them a credential.
374
+ .replace(/sk-[A-Za-z0-9_-]{16,}/g, REDACTED);
375
+ };
376
+
377
+ const walk = (v) => {
378
+ if (typeof v === 'string') return scrubString(v);
379
+ if (Array.isArray(v)) return v.map(walk);
380
+ if (v && typeof v === 'object') {
381
+ const out = {};
382
+ for (const [k, val] of Object.entries(v)) out[k] = walk(val);
383
+ return out;
384
+ }
385
+ return v;
386
+ };
387
+ return walk(value);
388
+ }
389
+
390
+ /** Host only — a path or a query string can carry a token, and a host cannot. */
391
+ function hostOf(url) {
392
+ try { return new URL(String(url)).host || null; } catch { return null; }
393
+ }
394
+
395
+ // ───────────────────────────────────────────────────────────────────────────
396
+ // PURE: THE TOOL OFFER
397
+ // ───────────────────────────────────────────────────────────────────────────
398
+
399
+ /** Tools that spawn a process, run a command, or write history — all ride with --no-run. */
400
+ /**
401
+ * ⚠️ THE BACKGROUND THREE RIDE WITH `allowRun` TOO. This set has to name every
402
+ * tool `toolNamesForRounds` withholds under `--no-run`, or the doctor reports a
403
+ * tool as missing and cannot say why — which is the one thing it exists to do.
404
+ */
405
+ const RUN_GATED = new Set([
406
+ 'run_command', 'run_program', 'evaluate', 'git_commit', 'declare_acceptance', 'check_acceptance',
407
+ // ⚠️ The delivery half rides with `allowRun` too — `git_branch` writes a ref
408
+ // and `git_push` writes to a remote, and the drift guard in doctor.test.mjs
409
+ // ("every tool --no-run withholds can be EXPLAINED") is what would catch this
410
+ // list going stale again, as it already did for the background three.
411
+ 'git_branch', 'git_push',
412
+ 'start_process', 'check_process', 'stop_process',
413
+ // ⚠️ The REPL executes the user's JavaScript, so --no-run withholds it too —
414
+ // caught by the drift guard the hour it shipped, which is the point of it.
415
+ 'repl', 'repl_reset',
416
+ /**
417
+ * ⚠️ `call_endpoint` RIDES WITH `allowRun` BECAUSE ITS TARGET DOES. It can only
418
+ * reach a port registered by `start_process`, so under `--no-run` there is
419
+ * nothing for it to call and offering it would be a dead button.
420
+ *
421
+ * ⭐ Caught by this same drift guard on the day it shipped — the third time
422
+ * that has happened (the background three, then the REPL pair, now this).
423
+ * ⚠️ Without the entry the doctor reported it dark as "not offered in this
424
+ * configuration", which is true and useless: it names no cause and no fix, and
425
+ * that is precisely the failure this file exists to prevent.
426
+ */
427
+ 'call_endpoint',
428
+ /**
429
+ * ⚠️ THE SIX THAT ARRIVED WITH THE DARK-MODULE WIRING (2026-08-17). gh spawns
430
+ * the `gh` binary; the three log verbs can only read a process `start_process`
431
+ * launched, which `--no-run` refuses. Fourth time this drift guard has caught
432
+ * a missing entry the same day a tool shipped — which is the entire argument
433
+ * for deriving the list from the gating function rather than typing it out.
434
+ */
435
+ 'gh_issue', 'gh_pr', 'gh_run',
436
+ 'read_log', 'wait_for_output', 'summarize_log',
437
+ ]);
438
+ /** media tool -> the variable that turns it on. */
439
+ const MEDIA_ENV = {
440
+ see_page: 'RENDER_AUDIT_URL',
441
+ speak: 'MODAL_TTS_URL',
442
+ transcribe: 'MODAL_TRANSCRIBE_URL',
443
+ make_document: 'MODAL_PRESS_URL',
444
+ read_document: 'MODAL_DOC_READ_URL',
445
+ read_table: 'MODAL_TABLE_READ_URL',
446
+ // ⚠️ edit_image needs TWO services and this map holds one name. MODAL_SELECT_URL
447
+ // is the one without which the verb is impossible (no mask, no inpaint), so it
448
+ // is the one a person should be told to set. expand_image needs only flux.
449
+ edit_image: 'MODAL_SELECT_URL',
450
+ expand_image: 'MODAL_FLUX_URL',
451
+ };
452
+ /**
453
+ * ⭐ The media tools whose endpoint URL is BAKED IN by `media.mjs` and therefore
454
+ * blocked only by the credential. Kept beside MEDIA_ENV so nobody edits one
455
+ * without seeing the other.
456
+ */
457
+
458
+ /**
459
+ * ── ⭐⭐ "IT IS DARK" IS HALF AN ANSWER WHEN THE CREDENTIAL IS 40cm AWAY ─────
460
+ *
461
+ * Measured 2026-08-12: all six media capabilities were dark on this machine, and
462
+ * every one of them WORKED — `see_page` rendered a page and caught a real
463
+ * 1.15:1 contrast failure in 5 seconds — the moment the secret was loaded. The
464
+ * secret was sitting in a SIBLING project's `.env.local` the whole time.
465
+ *
466
+ * The env walk deliberately stops at the repository root, so it cannot reach a
467
+ * sibling, and it should not: silently crossing into another checkout to find
468
+ * credentials is exactly the behaviour a security reviewer would object to.
469
+ *
470
+ * ⭐ SO THE DOCTOR LOOKS, AND ONLY TELLS. It reports the PATH and the variable
471
+ * NAME — never the value — and leaves the copying to a person. That turns "this
472
+ * is dark" into "this is dark, and the thing that fixes it is in that file",
473
+ * which is the difference between a diagnosis and a dead end.
474
+ *
475
+ * ⚠️ BOUNDED AND READ-ONLY: siblings of the workspace and its parent, one level,
476
+ * `.env.local`/`.env` only, and it never reads a value out.
477
+ */
478
+ /**
479
+ * ⚠️⚠️ MEMOISED, AND THE FIRST VERSION WAS NOT — IT COST 36 SECONDS.
480
+ *
481
+ * `toolOffer` asks this question once per withheld media tool, and there are six
482
+ * of them. Each ask walked the workspace's parent (sixty-odd sibling projects on
483
+ * this machine), then stat-ed and read two candidate files in every one. A
484
+ * doctor that takes half a minute is a doctor nobody runs — and the end-to-end
485
+ * test that asserts "the MCP section must not spawn or WAIT on anything" caught
486
+ * it, which is exactly the kind of thing that assertion is for.
487
+ *
488
+ * The answer cannot change during one process, so it is computed once per
489
+ * (root, names) and reused.
490
+ */
491
+ const credentialCache = new Map();
492
+
493
+ export function findCredentialNearby(varNames, { root = process.cwd(), readdirImpl = readdirSync, readImpl = readFileSync, existsImpl = existsSync } = {}) {
494
+ const names = Array.isArray(varNames) ? varNames : [varNames];
495
+ const cacheKey = `${resolve(root)}::${names.join(',')}`;
496
+ if (credentialCache.has(cacheKey)) return credentialCache.get(cacheKey);
497
+ const answer = scanForCredential(names, { root, readdirImpl, readImpl, existsImpl });
498
+ credentialCache.set(cacheKey, answer);
499
+ return answer;
500
+ }
501
+
502
+ /** For tests: the cache must not leak an answer from one temp workspace to another. */
503
+ export function clearCredentialCache() {
504
+ credentialCache.clear();
505
+ }
506
+
507
+ /**
508
+ * ⚠️⚠️ BOUNDED, BECAUSE AN UNBOUNDED WALK IS NOT A CONVENIENCE, IT IS A HAZARD.
509
+ *
510
+ * Measured: a workspace whose parent is `%TEMP%` — which after a day of testing
511
+ * held several hundred directories — took **6.8 seconds** to answer, tripping
512
+ * the end-to-end assertion that the doctor must never WAIT on anything. In a
513
+ * user's home or a monorepo root the same shape exists.
514
+ *
515
+ * This is a helpfulness feature: "the credential you need is in that file". It
516
+ * is worth a few milliseconds and not worth a directory crawl, so it looks at a
517
+ * bounded number of neighbours and stops. Missing a credential buried in the
518
+ * 41st sibling costs one line of advice; scanning forever costs the tool.
519
+ */
520
+ const MAX_NEIGHBOUR_DIRS = 40;
521
+
522
+ function scanForCredential(varNames, { root, readdirImpl, readImpl, existsImpl }) {
523
+ const seen = new Set();
524
+ const dirs = [];
525
+ try {
526
+ const parent = dirname(resolve(root));
527
+ for (const base of [resolve(root), parent]) {
528
+ dirs.push(base);
529
+ try {
530
+ for (const entry of readdirImpl(base, { withFileTypes: true })) {
531
+ if (dirs.length >= MAX_NEIGHBOUR_DIRS) break;
532
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
533
+ dirs.push(join(base, entry.name));
534
+ }
535
+ }
536
+ } catch { /* unreadable level */ }
537
+ }
538
+ } catch { return null; }
539
+
540
+ for (const dir of dirs) {
541
+ for (const name of ['.env.local', '.env']) {
542
+ const file = join(dir, name);
543
+ if (seen.has(file)) continue;
544
+ seen.add(file);
545
+ try {
546
+ if (!existsImpl(file)) continue;
547
+ const text = readImpl(file, 'utf8');
548
+ // ⚠️ The NAME only. A doctor that prints a secret is a worse problem
549
+ // than the one it was diagnosing.
550
+ /**
551
+ * ⚠️ A LINE SCAN, NOT A REGEX. The pattern needed four levels of
552
+ * escaping to survive the tooling that wrote this file, and the version
553
+ * that shipped compiled to `^s*NAME s*=` — matching nothing, silently.
554
+ * A check that quietly matches nothing is the check-that-cannot-fail
555
+ * again, and this one is one line of string work.
556
+ */
557
+ /**
558
+ * ⚠️⚠️ EVERY ACCEPTED NAME, NOT ONE — AND THIS IS THE FOURTH TIME TODAY
559
+ * THAT NAMING ANOTHER MODULE'S STRINGS WAS A GUESS. The first version
560
+ * searched only `ACUVO_MEDIA_SECRET` and reported "not found anywhere"
561
+ * while the credential sat in the very file it had just read, under
562
+ * `MODAL_VIDEO_SECRET` — the OTHER name `mediaConfig` accepts. The list
563
+ * is imported from `media.mjs`, so the two cannot disagree again.
564
+ */
565
+ const names = Array.isArray(varNames) ? varNames : [varNames];
566
+ let hit = null;
567
+ for (const line of text.split('\n')) {
568
+ const t = line.trim();
569
+ const name = names.find((n) => t.startsWith(`${n}=`));
570
+ if (!name) continue;
571
+ // ⚠️ An empty value is a deliberate OFF, not a credential to copy.
572
+ if (t.slice(name.length + 1).trim() === '') continue;
573
+ hit = name;
574
+ break;
575
+ }
576
+ /**
577
+ * ⚠️ IT RETURNS THE NAME IT ACTUALLY FOUND, not the one asked for first.
578
+ * The message read "ACUVO_MEDIA_SECRET is already in <file>" about a file
579
+ * containing `MODAL_VIDEO_SECRET` — advice that sends somebody looking
580
+ * for a line that is not there.
581
+ */
582
+ if (hit) return { file, name: hit };
583
+ } catch { /* unreadable — not a finding */ }
584
+ }
585
+ }
586
+ return null;
587
+ }
588
+
589
+ const MEDIA_BUILT_IN = new Set(['speak', 'transcribe', 'make_document', 'read_document', 'read_table']);
590
+ const LSP_TOOLS = new Set(['find_definition', 'find_references', 'check_types', 'list_symbols']);
591
+
592
+ /**
593
+ * ── ⭐⭐ WHICH TOOLS WOULD ACTUALLY BE OFFERED HERE, AND WHY THE REST WOULD NOT
594
+ *
595
+ * ⚠️ IT ASKS THE REAL FUNCTION. `toolNamesForRounds` is the code the turn loop
596
+ * runs, so this cannot drift from what the model is really shown — a doctor
597
+ * with its own copy of the gating rules is a doctor that eventually lies. That
598
+ * is the same class of bug as a capability that exists and is never imported.
599
+ *
600
+ * ⭐ AND THE REASON IS THE PRODUCT. "read_skill: withheld" is worthless.
601
+ * "read_skill: withheld — no .acuvo/skills directory with a skill in it" is the
602
+ * end of the question.
603
+ */
604
+ export function toolOffer({ root = process.cwd(), env = process.env, allowRun = true, maxRounds = 8 } = {}) {
605
+ let offered = [];
606
+ let failed = null;
607
+ try {
608
+ offered = toolNamesForRounds(maxRounds, { allowRun, env, root });
609
+ } catch (err) {
610
+ failed = err instanceof Error ? err.message : String(err);
611
+ offered = [];
612
+ }
613
+ const have = new Set(offered);
614
+
615
+ let languages = new Set();
616
+ try { languages = languagesPresent(root); } catch { languages = new Set(); }
617
+
618
+ const withheld = [];
619
+ for (const name of TOOL_NAMES) {
620
+ if (have.has(name)) continue;
621
+ withheld.push({ name, ...whyWithheld(name, { env, allowRun, maxRounds, languages, failed, root }) });
622
+ }
623
+ return { offered, withheld, total: TOOL_NAMES.length };
624
+ }
625
+
626
+ function whyWithheld(name, { env, allowRun, maxRounds, languages, failed, root = process.cwd() }) {
627
+ if (failed) {
628
+ return { why: `the offer could not be computed here — ${failed}`, fix: 'run the doctor from a readable workspace directory' };
629
+ }
630
+ /**
631
+ * ⚠️ THE ROUND BUDGET IS CHECKED FIRST, because in a single-shot run nearly
632
+ * everything is withheld and blaming configuration for it would send the
633
+ * reader hunting for an env var that is already set correctly.
634
+ */
635
+ if (maxRounds <= 1 && !MEDIA_ENV[name] && name !== 'generate_image') {
636
+ return {
637
+ why: 'this is a single-shot run: a read or a command has no round to come back in, so offering it would be a dead button',
638
+ fix: 'raise the round budget (--max-rounds N) so results have somewhere to go',
639
+ };
640
+ }
641
+ if (!allowRun && RUN_GATED.has(name)) {
642
+ return { why: '--no-run was passed, and this tool executes something', fix: 'drop --no-run to allow it' };
643
+ }
644
+ if (MEDIA_ENV[name]) {
645
+ /**
646
+ * ⚠️ SAME CORRECTION AS THE MEDIA SECTION, AND IT HAD TO BE MADE TWICE
647
+ * BECAUSE THE ADVICE LIVES IN TWO PLACES. `media.mjs` bakes a default URL in
648
+ * for these, so the reader does not need an endpoint URL they cannot guess —
649
+ * they need ONE credential. Telling them otherwise is wrong advice in a
650
+ * diagnostic, which is worse than none because it gets followed.
651
+ *
652
+ * `see_page` and the two image services have NO baked default, so for them
653
+ * the variable really is the thing to set.
654
+ */
655
+ if (MEDIA_BUILT_IN.has(name)) {
656
+ const found = findCredentialNearby(MEDIA_SECRET_ENV_NAMES, { root });
657
+ return {
658
+ why: 'no media credential is set, so the service is not configured here (its URL is built in)',
659
+ fix: found
660
+ ? `${found.name} is already set in ${found.file} — copy that line into this workspace's .env.local. `
661
+ + 'The env walk stops at the repository root on purpose, so it will not reach across to another checkout for you.'
662
+ : 'set ACUVO_MEDIA_SECRET in .env.local — one credential turns on speech, transcription, documents and table reading together',
663
+ };
664
+ }
665
+ const alt = name === 'see_page' ? ' (or MODAL_RENDER_AUDIT_URL)' : '';
666
+ return {
667
+ why: `${MEDIA_ENV[name]} is unset${alt}, so the service does not exist here`,
668
+ fix: `set ${MEDIA_ENV[name]} to your endpoint URL, and ${'MODAL_VIDEO_SECRET'} to the value it expects`,
669
+ };
670
+ }
671
+ if (name === 'generate_image') {
672
+ return {
673
+ why: `${IMAGE_URL_ENV} is set to an empty value, which means the image service is deliberately OFF`,
674
+ fix: `unset ${IMAGE_URL_ENV} to use the default endpoint, or set it to your own image service`,
675
+ };
676
+ }
677
+ /**
678
+ * ── ⚠️ THIS REASON WAS TRUE AND STOPPED BEING TRUE ─────────────────────────
679
+ *
680
+ * It said "no `.acuvo/skills` directory with at least one readable skill in
681
+ * it", which was the whole story while the only skills were a project's own.
682
+ * Skills SHIP with the CLI now, so a bare workspace has a full shelf and
683
+ * `read_skill` is never withheld for a missing directory — measured: on a
684
+ * fresh temp dir it is OFFERED, and this branch could only fire if the
685
+ * BUNDLED shelf were empty.
686
+ *
687
+ * ⭐ A withheld-reason that names the wrong cause is worse than a generic one,
688
+ * because it sends somebody to create a directory that would change nothing.
689
+ * The message now names both sources, so whichever one is actually missing,
690
+ * the sentence is true.
691
+ */
692
+ if (name === 'read_skill') {
693
+ return {
694
+ why: `no skills are readable at all — the bundled shelf is empty AND there is no ${ACUVO_DIR}/skills directory with a readable skill in it`,
695
+ fix: `reinstall acuvo-code to restore the bundled skills, or create ${ACUVO_DIR}/skills/<name>.md with name/description frontmatter`,
696
+ };
697
+ }
698
+ /**
699
+ * ⭐ THE REASON IS THE PRODUCT, and "not offered in this configuration" (the
700
+ * fallback at the bottom of this function) is exactly the non-answer this
701
+ * doctor exists to avoid. Push is dark on every machine by default, so it
702
+ * would be the most frequently non-answered tool in the list.
703
+ */
704
+ if (name === 'git_push') {
705
+ return {
706
+ why: `${ALLOW_PUSH_ENV} is not set, so pushing is off — the one verb that leaves this machine is opt-in`,
707
+ fix: `set ${ALLOW_PUSH_ENV}=1 to allow it. It still refuses protected branches (${PROTECTED_BRANCHES.slice(0, 4).join(', ')}, …) and never force-pushes.`,
708
+ };
709
+ }
710
+ if (LSP_TOOLS.has(name)) {
711
+ const langs = [...languages];
712
+ if (langs.length === 0) {
713
+ return {
714
+ why: 'no recognised language manifest or source file was found near the top of this workspace, so no language server could answer here',
715
+ fix: 'run the doctor from the project root (a package.json / pyproject.toml / Cargo.toml / go.mod)',
716
+ };
717
+ }
718
+ const installs = langs.map((l) => LANGUAGE_SERVERS[l]?.install).filter(Boolean);
719
+ return {
720
+ why: `no language server is installed for ${langs.join(', ')} — semantic navigation cannot answer here`,
721
+ fix: installs.length ? `install one: ${installs.join(' · ')}` : 'install a language server for this project',
722
+ };
723
+ }
724
+ return { why: 'not offered in this configuration', fix: 'run the doctor again with the flags you actually use' };
725
+ }
726
+
727
+ // ───────────────────────────────────────────────────────────────────────────
728
+ // PURE: MCP — EVERYTHING KNOWABLE WITHOUT STARTING A PROGRAM
729
+ //
730
+ // ── ⚠️⚠️ THE DOCTOR MUST NEVER SPAWN AN MCP SERVER ──────────────────────────
731
+ //
732
+ // It is the one probe in this file that would be genuinely dangerous to run.
733
+ // Every other check costs an HTTP round trip we bound twice; starting an MCP
734
+ // server costs a PROCESS — and measured on this network today, an `npx`-based
735
+ // server hangs for minutes on a cold package cache. Eight of those, at
736
+ // `HANDSHAKE_TIMEOUT_MS = 20s` each, is a diagnostic nobody runs twice. It also
737
+ // has side effects: MCP servers open sockets, touch remote systems and are
738
+ // handed the user's real tokens (`mcp.mjs` passes credentials deliberately).
739
+ //
740
+ // ⭐ SO THIS SECTION ANSWERS THE CHEAP QUESTIONS AND SAYS "not checked" TO THE
741
+ // REST — the `verified: false` convention this file already uses for a probe it
742
+ // deliberately did not make. The cheap questions turn out to be the ones that
743
+ // actually fail:
744
+ //
745
+ // · is a server declared at all, and in which file
746
+ // · does its command RESOLVE — the #1 failure, and free to answer
747
+ // · does its env block break the credential rather than supply it
748
+ //
749
+ // ── ⚠️ THIS FILE MODELS HOW mcp.mjs STARTS A SERVER, AND THAT IS A COUPLING ──
750
+ // `resolveExecutable` and `nodeCliEntry` are not exported there, so the rules
751
+ // are mirrored here rather than asked for — the one thing this file otherwise
752
+ // refuses to do (see `toolOffer`, which calls the real gate). Two tests in
753
+ // doctor.test.mjs import `readMcpConfig` and `connectServer` and pin the
754
+ // behaviours this mirror depends on, so a change there fails a test rather than
755
+ // silently turning this section into fiction.
756
+ // ───────────────────────────────────────────────────────────────────────────
757
+
758
+ /**
759
+ * ⚠️ ONE HONEST LIMIT ON THE "NEVER HANGS" PROMISE. This section is the only
760
+ * one that touches the filesystem SYNCHRONOUSLY (`existsSync`/`statSync` while
761
+ * walking PATH, and `readMcpConfig`'s own `readFileSync`), and a sync call
762
+ * cannot be raced by `withTimeout`. A PATH entry pointing at a dead network
763
+ * share can therefore stall it — the same exposure `mcp.mjs` and `command.mjs`
764
+ * already carry at spawn time, and the walk stops at the first hit. Bounding it
765
+ * properly would mean a worker thread, which is a large amount of machinery to
766
+ * add to a diagnostic; recorded here rather than quietly assumed away.
767
+ */
768
+
769
+ /** `${VAR}` / `$VAR` — a reference the user THINKS will be expanded. */
770
+ const ENV_PLACEHOLDER = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/;
771
+
772
+ export function envPlaceholderName(value) {
773
+ const m = ENV_PLACEHOLDER.exec(String(value ?? '').trim());
774
+ return m ? m[1] : null;
775
+ }
776
+
777
+ /**
778
+ * Where `mcp.mjs` would find this command — or why it would not.
779
+ *
780
+ * kinds: `path` · `node-entry` (npm/npx, run through node's own CLI entry) ·
781
+ * `missing` · `missing-path` · `missing-npm` · `unknown` (no PATH to search).
782
+ *
783
+ * ⚠️ `unknown` EXISTS SO AN ODD ENVIRONMENT IS NOT CALLED BROKEN. A container
784
+ * with no PATH set is not proof that a binary is absent, and painting it red
785
+ * would be this repo's most-repeated mistake: a check that fails correct work.
786
+ */
787
+ export function resolveMcpCommand(command, {
788
+ env = process.env,
789
+ platform = process.platform,
790
+ existsImpl = existsSync,
791
+ statImpl = statSync,
792
+ execPath = process.execPath,
793
+ } = {}) {
794
+ const cmd = String(command ?? '').trim();
795
+ if (!cmd) return { kind: 'missing', path: null };
796
+
797
+ const exists = (p) => { try { return !!existsImpl(p); } catch { return false; } };
798
+ const isFile = (p) => { try { return exists(p) && statImpl(p).isFile(); } catch { return false; } };
799
+
800
+ /**
801
+ * ⭐ npm/npx NEVER RESOLVE ON PATH HERE, and that is not an optimisation.
802
+ * `mcp.mjs` spawns them as `node <npm's own npx-cli.js>` because Windows
803
+ * refuses to run a `.cmd` without a shell (CVE-2024-27980) and a shell is the
804
+ * injection surface the whole package exists to avoid. Reporting "npx found
805
+ * at C:\…\npx.cmd" would name a file that is never executed.
806
+ */
807
+ if (cmd === 'npm' || cmd === 'npx') {
808
+ const dir = dirname(execPath);
809
+ const file = cmd === 'npm' ? 'npm-cli.js' : 'npx-cli.js';
810
+ for (const c of [
811
+ join(dir, 'node_modules', 'npm', 'bin', file),
812
+ join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', file),
813
+ join(dir, '..', 'node_modules', 'npm', 'bin', file),
814
+ ]) {
815
+ if (exists(c)) return { kind: 'node-entry', path: c };
816
+ }
817
+ return { kind: 'missing-npm', path: null };
818
+ }
819
+
820
+ if (cmd.includes('/') || cmd.includes(String.fromCharCode(92))) {
821
+ return exists(cmd) ? { kind: 'path', path: cmd } : { kind: 'missing-path', path: cmd };
822
+ }
823
+
824
+ const sep = platform === 'win32' ? ';' : ':';
825
+ const dirs = String(env?.PATH || env?.Path || '').split(sep).filter(Boolean);
826
+ if (dirs.length === 0) return { kind: 'unknown', path: null };
827
+ /**
828
+ * ⚠️ EXTENSIONS BEFORE THE BARE NAME, the order `mcp.mjs` had to fix: the
829
+ * nodejs directory holds BOTH `npx` (a bash script Windows cannot spawn) and
830
+ * `npx.cmd`, and finding the bare name first reports a path that exists and
831
+ * still fails to start.
832
+ */
833
+ const exts = platform === 'win32'
834
+ ? [...String(env?.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
835
+ : [''];
836
+ for (const d of dirs) {
837
+ for (const ext of exts) {
838
+ const candidate = join(d, cmd + ext);
839
+ if (isFile(candidate)) return { kind: 'path', path: candidate };
840
+ }
841
+ }
842
+ return { kind: 'missing', path: null };
843
+ }
844
+
845
+ /**
846
+ * ── ⭐⭐ THE CREDENTIAL BUG THAT IS INVISIBLE FROM THE CONFIG FILE ───────────
847
+ *
848
+ * `connectServer` builds the child environment as `{...process.env, ...server.env}`
849
+ * and expands NOTHING. So the two shapes people write most often are both
850
+ * actively harmful rather than merely useless:
851
+ *
852
+ * "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } the server receives the
853
+ * fifteen literal characters — AND the real token it would have
854
+ * inherited is overwritten by them.
855
+ * "env": { "GITHUB_TOKEN": "" } the real token is
856
+ * overwritten with nothing.
857
+ *
858
+ * ⚠️ NOTE WHAT THIS MEANS FOR A NAIVE CHECK: asking "is GITHUB_TOKEN absent
859
+ * from the environment" gives the placeholder case a clean bill of health,
860
+ * because the variable IS set — it is just not what the server will get.
861
+ *
862
+ * Returns `{ key, kind, ref }`. Never the value: a config entry can hold a real
863
+ * credential, and this file does not print those.
864
+ */
865
+ export function mcpCredentialGaps(serverEnv, env = {}) {
866
+ const gaps = [];
867
+ for (const [key, raw] of Object.entries(serverEnv ?? {})) {
868
+ const value = typeof raw === 'string' ? raw : '';
869
+ if (value.trim() === '') { gaps.push({ key, kind: 'empty', ref: null }); continue; }
870
+ const ref = envPlaceholderName(value);
871
+ if (ref) gaps.push({ key, kind: 'placeholder', ref });
872
+ }
873
+ return gaps;
874
+ }
875
+
876
+ /** One declared server, judged. Nothing here starts anything. */
877
+ export function assessMcpServer(server, { file = null, env = {}, resolution = { kind: 'unknown', path: null } } = {}) {
878
+ const base = { id: `mcp.${server?.name}`, label: String(server?.name ?? '') };
879
+ const where = `declared in ${file ?? 'the MCP config'}`;
880
+ const cmd = String(server?.command ?? '');
881
+ const fixFile = file ?? MCP_CONFIG_FILES[0];
882
+
883
+ /**
884
+ * ── ⚠️⚠️ A HOSTED SERVER HAS NO COMMAND, AND THIS SECTION WOULD CALL IT
885
+ * BROKEN ────────────────────────────────────────────────────────────
886
+ *
887
+ * Added 2026-08-15 with remote transports in `mcp.mjs`. Everything below this
888
+ * point asks "where would `mcp.mjs` find this executable" — a question with no
889
+ * answer for `{"type":"http","url":"https://mcp.sentry.dev/mcp"}`, because
890
+ * nothing is executed. Without this branch `resolveMcpCommand` reports
891
+ * `missing-path` for the URL and the doctor prints
892
+ * ✖ the command "https://mcp.sentry.dev/mcp" does not exist
893
+ * for a server that works perfectly — a check that fails correct work, which
894
+ * this repo has paid for four times in one day and which is worse than no
895
+ * check because it gets acted on.
896
+ *
897
+ * ⚠️ IT STAYS `verified: false`. The doctor never connects, so "declared, and
898
+ * the URL is well-formed" is the entire claim — exactly the honesty convention
899
+ * the branch below it uses for a command that resolves.
900
+ */
901
+ if (server?.transport === 'http' || server?.transport === 'sse') {
902
+ let host = String(server?.url ?? '');
903
+ try { host = new URL(server.url).host; } catch { /* readMcpConfig already validated it */ }
904
+ const refs = [...new Set(
905
+ Object.values(server?.headers ?? {})
906
+ .flatMap((v) => [...String(v).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)])
907
+ .map((m) => m[1] ?? m[2]),
908
+ )];
909
+ const unset = refs.filter((n) => typeof env?.[n] !== 'string' || env[n].trim() === '');
910
+ if (unset.length > 0) {
911
+ return {
912
+ ...base,
913
+ state: 'broken',
914
+ verified: true,
915
+ detail: `${where} · reached over ${server.transport === 'sse' ? 'SSE' : 'HTTP'} at ${host}, and its headers reference ${unset.join(', ')}, which ${unset.length === 1 ? 'is' : 'are'} not set — this client refuses to connect rather than send the literal "\${${unset[0]}}" to a third party`,
916
+ fix: `export ${unset.join(', ')} in your shell (the value is read at connect time and never written into ${fixFile})`,
917
+ };
918
+ }
919
+ return {
920
+ ...base,
921
+ state: 'live',
922
+ verified: false,
923
+ detail: `${where} · reached over ${server.transport === 'sse' ? 'SSE' : 'HTTP'} at ${host} · nothing is spawned${refs.length ? `, and your ${refs.join(', ')} travels with every call` : ''} · not checked — the doctor never connects to an MCP server`,
924
+ fix: null,
925
+ };
926
+ }
927
+
928
+ if (resolution.kind === 'missing' || resolution.kind === 'missing-path') {
929
+ return {
930
+ ...base,
931
+ state: 'broken',
932
+ verified: true,
933
+ detail: `${where} · the command "${cmd}" ${resolution.kind === 'missing-path' ? 'does not exist' : 'was not found on PATH'}, so this server cannot start and none of its tools are offered`,
934
+ fix: `install it, or set "${server?.name}".command in ${fixFile} to a full path to the executable`,
935
+ };
936
+ }
937
+ if (resolution.kind === 'missing-npm') {
938
+ return {
939
+ ...base,
940
+ state: 'broken',
941
+ verified: true,
942
+ detail: `${where} · "${cmd}" is run through npm's own JavaScript entry point (a .cmd shim cannot be spawned without a shell) and no npm installation was found next to this node`,
943
+ fix: `install npm alongside this node, or set "${server?.name}".command in ${fixFile} to a full path to the server's executable`,
944
+ };
945
+ }
946
+
947
+ /**
948
+ * ⚠️⚠️ "COULD NOT LOOK" IS NOT "FOUND IT", AND THE FIRST DRAFT SAID IT WAS.
949
+ * With no PATH to search, `resolveMcpCommand` answers `unknown` — and this
950
+ * function fell through to the healthy branch and printed
951
+ * `"linear-mcp" resolves`, a sentence with no evidence behind it whatsoever.
952
+ * Caught by a test, which is the only reason it is not in the product.
953
+ */
954
+ if (resolution.kind === 'unknown') {
955
+ return {
956
+ ...base,
957
+ state: 'live',
958
+ verified: false,
959
+ detail: `${where} · "${cmd}" · not checked — there is no PATH in this environment to look it up in, and nothing here starts a server to find out`,
960
+ fix: null,
961
+ };
962
+ }
963
+
964
+ const gaps = mcpCredentialGaps(server?.env, env);
965
+ if (gaps.length) {
966
+ const g = gaps[0];
967
+ const detail = g.kind === 'placeholder'
968
+ // ⚠️ The placeholder TEXT is described, never echoed — an echoed `${…}` is
969
+ // harmless, but the same line renders values that may not be.
970
+ ? `${where} · its "${g.key}" entry is a $\{…} placeholder, and this client does not expand those — the server receives that text literally, and it OVERRIDES the real ${g.ref} in your environment`
971
+ : `${where} · its "${g.key}" entry is empty, which OVERRIDES any real ${g.key} in your environment with nothing`;
972
+ return {
973
+ ...base,
974
+ state: 'broken',
975
+ verified: true,
976
+ detail,
977
+ fix: `delete the "${g.key}" line from "${server?.name}".env in ${fixFile} so the server inherits ${g.ref ?? g.key} from your shell (this client passes the whole environment through)`,
978
+ };
979
+ }
980
+
981
+ const via = resolution.kind === 'node-entry' ? ` (run as node ${cmd}-cli.js, the only shell-free way on Windows)` : '';
982
+ return {
983
+ ...base,
984
+ state: 'live',
985
+ verified: false,
986
+ detail: `${where} · "${cmd}" resolves${via} · not checked — the doctor never starts an MCP server (an npx server can take minutes on a cold cache, and a diagnostic must not hang)`,
987
+ fix: null,
988
+ };
989
+ }
990
+
991
+ /**
992
+ * ── ⚠️⚠️ A JSON PARSE ERROR QUOTES THE FILE BACK AT YOU ─────────────────────
993
+ *
994
+ * MEASURED ON NODE 22.17, and it is the exact trap `model.mjs` documents for
995
+ * proxy error pages, arriving through a completely different door:
996
+ *
997
+ * JSON.parse('…"env":{"T":ghp_REALTOKEN0123…')
998
+ * -> Unexpected token 'g', ..."env":{"T":ghp_REALTO"... is not valid JSON
999
+ *
1000
+ * V8 echoes roughly twenty characters of the source around the fault. An
1001
+ * `mcp.json` is one of the few files people type an API token straight into, so
1002
+ * a stray comma in it would print part of that token to the terminal, to CI
1003
+ * logs, and into whatever the user pastes into a bug report — and `SECRET_ENV_VARS`
1004
+ * cannot help, because the value was never an environment variable.
1005
+ *
1006
+ * ⭐ THE ECHO IS ALSO THE LEAST USEFUL PART OF THE MESSAGE. "Unexpected token
1007
+ * 'g' … is not valid JSON", the position, and the file name are what a person
1008
+ * acts on; the twenty quoted characters are already on their screen in the
1009
+ * editor. So this drops the echo and keeps everything else.
1010
+ */
1011
+ export function redactConfigEcho(message) {
1012
+ return String(message ?? '')
1013
+ // `..."<echo>"...` — the form V8 uses mid-file.
1014
+ .replace(/\.\.\."[\s\S]*?"\.\.\./g, '…')
1015
+ // `"<echo>"...` — the form it uses at the very start of the input.
1016
+ .replace(/"[\s\S]*?"\.\.\./g, '…')
1017
+ .slice(0, 240);
1018
+ }
1019
+
1020
+ /** The whole MCP section, from an already-read config. */
1021
+ /**
1022
+ * @param {object} [options]
1023
+ * @param {number} [options.offeredCount] how many tools the model is ACTUALLY
1024
+ * offered here. ⚠️ Defaults to the registry size only so a direct caller that
1025
+ * omits it still produces a sentence; `runDoctor` always passes the real
1026
+ * number, because the registry size is the flattering one and the two rows
1027
+ * sitting next to each other must agree.
1028
+ */
1029
+ export function mcpChecks(cfg, env = {}, { resolve = resolveMcpCommand, offeredCount = TOOL_NAMES.length } = {}) {
1030
+ const offeredHere = offeredCount;
1031
+ const names = MCP_CONFIG_FILES.join(' or ');
1032
+
1033
+ if (!cfg?.ok) {
1034
+ return [{
1035
+ id: 'mcp.config',
1036
+ label: 'mcp config',
1037
+ state: 'broken',
1038
+ verified: true,
1039
+ detail: `${redactConfigEcho(cfg?.error) || 'the MCP config could not be read'} — no external server is connected while this stands`,
1040
+ fix: `fix or delete the file (${names}); a config that will not parse takes every server with it`,
1041
+ }];
1042
+ }
1043
+
1044
+ if (!cfg.file || cfg.servers.length === 0) {
1045
+ return [{
1046
+ id: 'mcp.config',
1047
+ label: 'mcp servers',
1048
+ state: 'dark',
1049
+ verified: true,
1050
+ /**
1051
+ * ⚠️ THE OFFERED COUNT, NOT `TOOL_NAMES.length` — AND THEY DIFFER BY 13.
1052
+ *
1053
+ * This line used to quote the size of the registry while the row directly
1054
+ * above it printed "34 of 47". Two adjacent rows of one report giving
1055
+ * different answers to "how many tools does the model have" is worse than
1056
+ * either being wrong alone: `--doctor` exists to tell you what is actually
1057
+ * working here, so a diagnostic that disagrees with itself cannot be used
1058
+ * at all without checking it.
1059
+ *
1060
+ * ⚠️ And 47 was the FLATTERING number, which is the direction that costs
1061
+ * trust. On a bare machine 8 tools need infrastructure we host, 4 need
1062
+ * TypeScript in the user's own project, and 1 needs a skills directory.
1063
+ */
1064
+ detail: cfg.file
1065
+ ? `${cfg.file} declares no servers, so the model sees only the ${offeredHere} tools available in this workspace`
1066
+ : `no ${names} in this workspace, so the model sees only the ${offeredHere} tools available in this workspace`,
1067
+ fix: `create ${MCP_CONFIG_FILES[0]} with an "mcpServers" object to give it tools we did not build (the user chooses the servers — there is deliberately no tool that lets the model add its own)`,
1068
+ }];
1069
+ }
1070
+
1071
+ const checks = cfg.servers.map((s) => assessMcpServer(s, { file: cfg.file, env, resolution: resolve(s.command, { env }) }));
1072
+
1073
+ /**
1074
+ * ⚠️ readMcpConfig STOPS AT MAX_SERVERS AND SAYS NOTHING. A ninth server in
1075
+ * the file is simply never connected, which from the outside looks exactly
1076
+ * like a server that failed to start.
1077
+ */
1078
+ if (cfg.servers.length >= MAX_SERVERS) {
1079
+ checks.push({
1080
+ id: 'mcp.cap',
1081
+ label: 'server cap',
1082
+ state: 'dark',
1083
+ verified: true,
1084
+ detail: `${cfg.servers.length} servers is the MAX_SERVERS ceiling — any declared after the first ${MAX_SERVERS} in ${cfg.file} are silently not connected`,
1085
+ fix: `remove servers from ${cfg.file} until there are fewer than ${MAX_SERVERS}, so nothing is dropped without being reported`,
1086
+ });
1087
+ }
1088
+ return checks;
1089
+ }
1090
+
1091
+ /**
1092
+ * ── ⭐⭐ THE CURATED CATALOGUE, WHICH HAD NO DOOR AT ALL ─────────────────────
1093
+ *
1094
+ * `lib/mcp-defaults.mjs` is 500 lines of hand-verified integration work with
1095
+ * **zero importers** — measured by an import-graph probe over `lib/`, `bin/` and
1096
+ * `scripts/`: one test imported it and nothing else did. So a user could not
1097
+ * reach any of it. They had to hand-write `.acuvo/mcp.json` from install lines
1098
+ * the repo itself recorded as wrong, which is the exact "built but unreached"
1099
+ * shape this project keeps re-learning: a screenshot beats any number of green
1100
+ * tests, and a module nobody can call is a module that does not exist.
1101
+ *
1102
+ * ⭐ THE DOCTOR IS THE RIGHT DOOR because it is already the place a stranger
1103
+ * goes to ask "what can this thing do here, and what would it take to do more".
1104
+ * The catalogue answers exactly that question and its `assessEntry` already
1105
+ * returns doctor's own `state`/`detail`/`fix` shape — it was written for this
1106
+ * seam and then never plugged into it.
1107
+ *
1108
+ * ⚠️ ONLY ENTRIES WE ACTUALLY RAN APPEAR HERE. The catalogue has a second,
1109
+ * INERT tier of servers nobody on this project has started; a health report is
1110
+ * the wrong place for "someone else says this works". Those stay in
1111
+ * `formatAvailability` and the starter config, where they are labelled
1112
+ * unverified. The doctor shows what was measured.
1113
+ *
1114
+ * ⚠️ AND IT NEVER SPAWNS ANYTHING. `assessCatalogue` is pure — that is its whole
1115
+ * reason for existing (each dark entry it rules out is a measured 20,052ms
1116
+ * handshake timeout not spent) — and the ONLY I/O added here is `existsSync` on
1117
+ * candidate `node_modules` directories.
1118
+ *
1119
+ * ⚠️ IDS ARE NAMESPACED `mcp.catalogue.<name>`, NOT `mcp.<name>`. `assessEntry`
1120
+ * returns the latter and so does `assessMcpServer` for a user's own server — a
1121
+ * user with a server called `browser` would have produced two rows sharing an
1122
+ * id, and `find(report, id)` returns the first. Two different facts under one
1123
+ * key is how a report starts lying without anyone editing a sentence.
1124
+ */
1125
+ export function mcpCatalogueChecks({ env = {}, root = '.', declaredNames = [], installedImpl = installedPackages } = {}) {
1126
+ /**
1127
+ * ⚠️ A SERVER THE USER ALREADY DECLARED IS REPORTED BY `mcpChecks` ABOVE,
1128
+ * from their actual config — including whether its command resolves. Repeating
1129
+ * it from the catalogue would put two rows about one server in one section,
1130
+ * and the catalogue's row is the weaker of the two (it knows the entry, not
1131
+ * the user's args).
1132
+ */
1133
+ const declared = new Set(declaredNames);
1134
+ const installed = installedImpl({ env, root, packages: CATALOGUE.map(packageOf).filter(Boolean) });
1135
+ const rows = assessCatalogue({ env, installed });
1136
+ const verified = rows.filter((r) => r.verified);
1137
+ const live = verified.filter((r) => r.state === 'live');
1138
+
1139
+ const checks = [{
1140
+ id: 'mcp.catalogue',
1141
+ label: 'curated servers',
1142
+ state: live.length > 0 ? 'live' : 'dark',
1143
+ verified: true,
1144
+ detail: `${live.length} of ${verified.length} servers we have run ourselves are usable here${live.length ? ` (${live.map((r) => r.label).join(', ')})` : ''} — ${CATALOGUE.length} in the catalogue in total`,
1145
+ fix: live.length > 0
1146
+ ? `add one to ${STARTER_CONFIG_FILE} under "mcpServers" to turn it on`
1147
+ : `install one of the servers below, then declare it in ${STARTER_CONFIG_FILE}`,
1148
+ }];
1149
+
1150
+ for (const r of verified) {
1151
+ if (declared.has(r.entry)) continue;
1152
+ checks.push({
1153
+ id: `mcp.catalogue.${r.entry}`,
1154
+ label: r.label,
1155
+ state: r.state,
1156
+ // ⚠️ `verified` here means "we ran this server", which is the same claim
1157
+ // the doctor's own column makes: this line is a measurement, not a table
1158
+ // lookup. Every row in this loop passed that filter.
1159
+ verified: true,
1160
+ detail: `${r.purpose} — ${r.detail}`,
1161
+ fix: r.fix,
1162
+ });
1163
+ }
1164
+ return checks;
1165
+ }
1166
+
1167
+ /**
1168
+ * Which of these npm packages are on this machine.
1169
+ *
1170
+ * ⚠️ NO SPAWN. `npm ls -g` would be the obvious implementation and it costs
1171
+ * hundreds of milliseconds on the doctor's hot path — the same mistake that
1172
+ * made the credential finder a 6.8s directory crawl. This is `existsSync` on a
1173
+ * short list of known roots.
1174
+ *
1175
+ * ⚠️ THE WINDOWS GLOBAL ROOT IS NOT BESIDE `node.exe`. npm's default prefix on
1176
+ * Windows is `%APPDATA%\npm`, so globals land in `%APPDATA%\npm\node_modules`
1177
+ * while `dirname(process.execPath)\node_modules` holds only npm itself.
1178
+ * Checking only the latter reported every globally installed server as missing.
1179
+ *
1180
+ * ⚠️ AND A MISS IS NOT A LIE. `assessEntry` words a negative as "not installed",
1181
+ * which is the honest reading of "not found in any root we know" — the cost of
1182
+ * being wrong is one unnecessary install line, never a false all-clear.
1183
+ */
1184
+ export function installedPackages({ env = {}, root = '.', packages = [], existsImpl = existsSync } = {}) {
1185
+ const nodeDir = dirname(process.execPath);
1186
+ const roots = [
1187
+ join(root, 'node_modules'),
1188
+ join(nodeDir, 'node_modules'),
1189
+ join(nodeDir, '..', 'lib', 'node_modules'),
1190
+ ...(env.npm_config_prefix ? [join(env.npm_config_prefix, 'node_modules'), join(env.npm_config_prefix, 'lib', 'node_modules')] : []),
1191
+ ...(env.APPDATA ? [join(env.APPDATA, 'npm', 'node_modules')] : []),
1192
+ ];
1193
+ const found = new Set();
1194
+ for (const pkg of packages) {
1195
+ for (const r of roots) {
1196
+ // package.json, not the directory: an empty leftover folder is not an
1197
+ // installed package, and npm leaves those behind on a failed install.
1198
+ if (existsImpl(join(r, pkg, 'package.json'))) { found.add(pkg); break; }
1199
+ }
1200
+ }
1201
+ return found;
1202
+ }
1203
+
1204
+ // ───────────────────────────────────────────────────────────────────────────
1205
+ // PURE: PROMPT CACHING — DOES THE CONFIGURED MODEL CACHE AT ALL
1206
+ //
1207
+ // ── ⭐ WHY THIS DESERVES A SECTION ──────────────────────────────────────────
1208
+ // The loop's whole economic design assumes an automatically-cached prefix:
1209
+ // `turn.mjs` appends rather than rebuilds, `learned.mjs` renders sorted and
1210
+ // timestamp-free, and the loop nudges once per session — all so the prefix
1211
+ // stays byte-identical. Measured on DeepSeek: 97.2% cached and 3-4x cheaper.
1212
+ //
1213
+ // ⚠️ ON A MODEL THAT DOES NOT CACHE, EVERY ONE OF THOSE CONTORTIONS BUYS
1214
+ // NOTHING, and the run costs several times what the user expects with nothing
1215
+ // reporting it. That is a money surprise, which is the class of failure this
1216
+ // package treats most seriously.
1217
+ //
1218
+ // ⚠️ UNKNOWN IS THE DEFAULT AND IT IS DELIBERATE. A caching claim invented for
1219
+ // a model nobody measured turns a cost estimate into fiction. Add to the table
1220
+ // only with a source, and prefer "not known" every other time.
1221
+ // ───────────────────────────────────────────────────────────────────────────
1222
+
1223
+ /** Caches the prefix with no flag in the request. */
1224
+ const AUTO_CACHE = [
1225
+ // Measured in this repo, 2026-08-11 (see turn.mjs) — automatic, on disk.
1226
+ /^deepseek\//i,
1227
+ // Documented automatic prompt caching above ~1k tokens.
1228
+ /^openai\/(gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4)/i,
1229
+ // Implicit caching, 2.5 and newer (1.5 was explicit-only).
1230
+ /^google\/gemini-(2\.5|3)/i,
1231
+ ];
1232
+ /**
1233
+ * Caches ONLY when the request carries explicit breakpoints — which this client
1234
+ * does not send: `callModel` builds one plain OpenAI-shaped body.
1235
+ */
1236
+ const EXPLICIT_CACHE = [/^anthropic\//i];
1237
+
1238
+ export function cachingSupport(modelId) {
1239
+ const id = String(modelId ?? '').trim();
1240
+ if (!id) return { kind: 'unknown' };
1241
+ if (AUTO_CACHE.some((re) => re.test(id))) return { kind: 'automatic' };
1242
+ if (EXPLICIT_CACHE.some((re) => re.test(id))) return { kind: 'explicit' };
1243
+ return { kind: 'unknown' };
1244
+ }
1245
+
1246
+ export function cacheChecks({ model, maxRounds = 8 } = {}) {
1247
+ const support = cachingSupport(model);
1248
+ const checks = [];
1249
+
1250
+ if (support.kind === 'automatic') {
1251
+ checks.push({
1252
+ id: 'cache.model',
1253
+ label: 'prompt cache',
1254
+ state: 'live',
1255
+ /**
1256
+ * ⚠️ verified:false, AND THE DETAIL SAYS WHY IN WORDS. This is a fact
1257
+ * about the MODEL, read from a table — nothing here measured your run, and
1258
+ * a green tick claiming otherwise is exactly the false all-clear this file
1259
+ * exists to prevent. That half is unchanged and correct.
1260
+ *
1261
+ * ⚠️⚠️ THE OTHER HALF WAS A STALE FACT REPORTED BY THE DIAGNOSTIC TOOL —
1262
+ * the exact defect class this file was built to catch. The detail used to
1263
+ * end "the real hit rate is not instrumented yet", and that has never been
1264
+ * true: it was written in the same commit that instrumented it.
1265
+ * `readCacheUsage` reads cached tokens per round, `aggregateCache` totals
1266
+ * them per session, `cacheClause` prints them on the summary line, and
1267
+ * `toJson` emits them into `--json` and therefore into `.acuvo/audit/`.
1268
+ * Telling the user their hit rate does not exist, while the run they just
1269
+ * did printed it, is worse than saying nothing.
1270
+ *
1271
+ * ⚠️ AND IT NOW NAMES THE REAL CAUSE OF A LOW NUMBER. Measured 2026-08-14
1272
+ * on one identical 4-round task: 46.7% unpinned against 95.8% pinned. The
1273
+ * variance is UPSTREAM ROUTING across the model's 28 endpoints, not prefix
1274
+ * discipline — so a user who reads "your cache is bad" and goes hunting
1275
+ * their prompt is hunting the wrong thing.
1276
+ */
1277
+ verified: false,
1278
+ detail: `${model} caches the prompt prefix automatically — a continuing session re-sends an identical prefix and is billed a fraction for it. This line is the model's documented behaviour, not a measurement of your runs; YOUR hit rate is measured on every run and printed on the summary line, and in --json as cache.hitRate. ⚠️ It varies mostly with UPSTREAM ROUTING rather than with your prefix — measured 2026-08-14 on one identical 4-round task, 46.7% unpinned against 95.8% pinned via ACUVO_PROVIDER_ORDER. --json also reports \`providers\`, which names who served each round.`,
1279
+ fix: null,
1280
+ });
1281
+ } else if (support.kind === 'explicit') {
1282
+ checks.push({
1283
+ id: 'cache.model',
1284
+ label: 'prompt cache',
1285
+ state: 'dark',
1286
+ verified: true,
1287
+ detail: `${model} caches only when the request carries cache_control breakpoints, and this client sends none — every round pays the full prompt price, however stable the prefix is`,
1288
+ fix: `set OPENROUTER_CODEGEN_MODEL to a model that caches automatically (e.g. ${DEFAULT_MODEL}) if the cost of a long session matters`,
1289
+ });
1290
+ } else {
1291
+ checks.push({
1292
+ id: 'cache.model',
1293
+ label: 'prompt cache',
1294
+ state: 'dark',
1295
+ verified: false,
1296
+ detail: `${model || 'this model'} is not known here to cache the prompt prefix automatically — cost this run as if every round pays full price for the whole prompt`,
1297
+ fix: `check the provider's docs, or set OPENROUTER_CODEGEN_MODEL to a model that is known to cache automatically (e.g. ${DEFAULT_MODEL})`,
1298
+ });
1299
+ }
1300
+
1301
+ /**
1302
+ * ⭐ A CACHE HIT NEEDS A SECOND ROUND. In a single-shot run the prefix is
1303
+ * sent exactly once, so no model on earth can save anything — worth saying,
1304
+ * because otherwise the line above reads like money being saved that is not.
1305
+ */
1306
+ if (maxRounds <= 1) {
1307
+ checks.push({
1308
+ id: 'cache.rounds',
1309
+ label: 'rounds',
1310
+ state: 'dark',
1311
+ verified: true,
1312
+ detail: 'this is a single-shot run: the prompt prefix is sent once, so no cache hit is possible whatever the model supports',
1313
+ fix: 'raise the round budget (--max-rounds N) — caching only pays from the second round onward',
1314
+ });
1315
+ }
1316
+ return checks;
1317
+ }
1318
+
1319
+ // ───────────────────────────────────────────────────────────────────────────
1320
+ // THE EDGE: PROBES
1321
+ // ───────────────────────────────────────────────────────────────────────────
1322
+
1323
+ const TIMED_OUT = Symbol('timed-out');
1324
+
1325
+ /** Never throws. A failure is data, with the code that caused it. */
1326
+ async function httpProbe(fetchImpl, url, init, timeoutMs) {
1327
+ let res;
1328
+ try {
1329
+ res = await withTimeout(
1330
+ // ⚠️ `.then()` so a fetchImpl that throws SYNCHRONOUSLY becomes a
1331
+ // rejection rather than an exception escaping this function.
1332
+ Promise.resolve().then(() => fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })),
1333
+ timeoutMs,
1334
+ TIMED_OUT,
1335
+ );
1336
+ } catch (err) {
1337
+ const code = err?.cause?.code || err?.name || 'unknown';
1338
+ return { kind: 'unreachable', detail: String(code) };
1339
+ }
1340
+ if (res === TIMED_OUT) return { kind: 'unreachable', detail: `no answer within ${timeoutMs}ms` };
1341
+
1342
+ let text = '';
1343
+ try { text = await res.text(); } catch { text = ''; }
1344
+ let json = null;
1345
+ try { json = JSON.parse(text); } catch { /* not JSON — the text is all we have */ }
1346
+ return { status: res.status, json, text };
1347
+ }
1348
+
1349
+ /** Is this string the service saying "your credential is wrong"? */
1350
+ function readsAsAuthFailure(s) {
1351
+ return /unauthoris|unauthoriz|forbidden|invalid secret|invalid token|not authenticated|401|403/i.test(String(s ?? ''));
1352
+ }
1353
+
1354
+ /**
1355
+ * ── ⭐ THE MEDIA HEALTH PROBE: THE SECRET, AND NO PAYLOAD ───────────────────
1356
+ *
1357
+ * A payload complaint is PROOF the credential was accepted, and it costs the
1358
+ * service nothing to produce — no GPU, no render, no synthesis. Measured
1359
+ * round trips on the live endpoints: 284ms–1195ms.
1360
+ */
1361
+ async function probeMediaEndpoint(fetchImpl, url, secret, timeoutMs) {
1362
+ const raw = await httpProbe(fetchImpl, url, {
1363
+ method: 'POST',
1364
+ headers: { 'content-type': 'application/json' },
1365
+ body: JSON.stringify(secret ? { secret } : {}),
1366
+ }, timeoutMs);
1367
+ if (raw.kind) return raw;
1368
+
1369
+ if (raw.status === 401 || raw.status === 403) return { kind: 'refused', detail: `HTTP ${raw.status}` };
1370
+ if (raw.status >= 400) return { kind: 'http', detail: `HTTP ${raw.status}` };
1371
+ if (!raw.json) return { kind: 'http', detail: `HTTP ${raw.status} but the body was not JSON — this may not be the service you think it is` };
1372
+ if (raw.json.ok === false && readsAsAuthFailure(raw.json.error)) {
1373
+ return { kind: 'refused', detail: String(raw.json.error).slice(0, 120) };
1374
+ }
1375
+ /**
1376
+ * ⭐ `ok:false` WITH A PAYLOAD COMPLAINT IS THE HEALTHY ANSWER. We sent no
1377
+ * payload on purpose; the service telling us so is the credential being
1378
+ * accepted. Treating it as a failure would report every working endpoint red.
1379
+ */
1380
+ return { kind: 'ok', detail: raw.json.ok === false ? `answered: ${String(raw.json.error).slice(0, 80)}` : 'answered ok' };
1381
+ }
1382
+
1383
+ // ───────────────────────────────────────────────────────────────────────────
1384
+ // THE EDGE: runDoctor
1385
+ // ───────────────────────────────────────────────────────────────────────────
1386
+
1387
+ /** The engines floor this package declares, read from its own manifest. */
1388
+ function readOwnEngines() {
1389
+ try {
1390
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
1391
+ return pkg?.engines?.node ?? null;
1392
+ } catch {
1393
+ return null;
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * @param {object} opts
1399
+ * @param {Record<string,string|undefined>} [opts.env]
1400
+ * @param {Function|null} [opts.fetchImpl] null = do not touch the network at all
1401
+ * @param {string} [opts.root]
1402
+ */
1403
+ export async function runDoctor({
1404
+ env = process.env,
1405
+ fetchImpl = fetch,
1406
+ root = process.cwd(),
1407
+ now = Date.now,
1408
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
1409
+ allowRun = true,
1410
+ maxRounds = 8,
1411
+ nodeVersion = process.version,
1412
+ platform = `${process.platform} ${process.arch}`,
1413
+ engines = undefined,
1414
+ gitStatusImpl = realGitStatus,
1415
+ /**
1416
+ * ⚠️ WE DO NOT INHERIT git.mjs's BOUND, WE IMPOSE OUR OWN. `GIT_TIMEOUT_MS`
1417
+ * there is 20s, which is a sensible bound for a tool call and a terrible one
1418
+ * for a diagnostic — and an INJECTED `gitStatusImpl` is under no obligation
1419
+ * to have any bound at all. Same argument as `withTimeout` for fetch.
1420
+ */
1421
+ gitTimeoutMs = 15_000,
1422
+ readFileImpl = readFileSync,
1423
+ /**
1424
+ * ⚠️ IT READS THE CONFIG, IT NEVER CONNECTS. `readMcpConfig` only parses a
1425
+ * file; `connectServer` — the function that spawns a program — is deliberately
1426
+ * not imported into this module at all, so no future edit here can start one
1427
+ * by accident.
1428
+ */
1429
+ mcpConfigImpl = readMcpConfig,
1430
+ /**
1431
+ * ── ⚠️⚠️ THE PROMISE WAS FALSE, AND IT WAS THE WORST ONE TO GET WRONG ──────
1432
+ *
1433
+ * `--help` and `README.md` both said `--doctor` "Needs no API key and no
1434
+ * network". It sends `Authorization: Bearer <the user's key>` to
1435
+ * openrouter.ai/api/v1/key and /credits, and probes every Modal endpoint.
1436
+ *
1437
+ * ⭐ WHY THIS ONE MATTERS MORE THAN AN ORDINARY DOC BUG: `--doctor` is what a
1438
+ * security-conscious evaluator runs FIRST, precisely BECAUSE they were told it
1439
+ * was offline. They then find their key in a corporate proxy log. The lie and
1440
+ * the audience are perfectly aligned to destroy trust on first contact.
1441
+ *
1442
+ * ⚠️ THE ANSWER IS NOT TO DELETE THE PROBES. Verifying that a key actually
1443
+ * authenticates is the most valuable thing this command does — "present, but
1444
+ * it does NOT authenticate" is a real diagnosis no offline check can make. So:
1445
+ * the docs now say what it does, and `--offline` delivers what they used to
1446
+ * promise, for an air-gapped machine or anyone who wants to read the code
1447
+ * before it touches the network.
1448
+ *
1449
+ * ⚠️ NAMED `skipNetwork`, NOT `offline`, DELIBERATELY. This function already
1450
+ * computes an `offline` VERDICT further down — "every probe was unreachable,
1451
+ * so this laptop has no connection" — and those are opposite things: one is a
1452
+ * request not to look, the other is a finding from having looked. Reusing the
1453
+ * word was a redeclaration error, and it would have been a reasoning error
1454
+ * even if the language had allowed it.
1455
+ */
1456
+ skipNetwork = false,
1457
+ } = {}) {
1458
+ const probed = typeof fetchImpl === 'function' && skipNetwork !== true;
1459
+ const model = readModelConfig(env);
1460
+ const media = mediaConfig(env);
1461
+ const edit = editConfig(env);
1462
+ const image = imageConfig(env);
1463
+
1464
+ // ── plan every network probe, then fire them all at once ─────────────────
1465
+ /**
1466
+ * ⚠️ `see_page` READS TWO VARIABLES, AND THE REPORT MUST NAME THE ONE IN
1467
+ * PLAY. Caught by looking at a live run: with both unset the line read
1468
+ * "MODAL_RENDER_AUDIT_URL is unset", which points a new user at the FALLBACK
1469
+ * name — a fix that names the wrong variable is worse than no fix, because
1470
+ * it gets followed. Primary when nothing is set; the fallback only when the
1471
+ * fallback is the one actually carrying the value.
1472
+ */
1473
+ const renderVar = env.RENDER_AUDIT_URL?.trim() ? 'RENDER_AUDIT_URL'
1474
+ : env.MODAL_RENDER_AUDIT_URL?.trim() ? 'MODAL_RENDER_AUDIT_URL'
1475
+ : 'RENDER_AUDIT_URL';
1476
+ /**
1477
+ * ── ⚠️⚠️ `builtIn` — BECAUSE THE ADVICE WAS TELLING PEOPLE TO DO SIX TIMES
1478
+ * THE WORK, AND THE WRONG WORK ─────────────────────────────────────
1479
+ *
1480
+ * Measured 2026-08-12 on a bare machine: the doctor printed six lines saying
1481
+ * "MODAL_TTS_URL is unset → set MODAL_TTS_URL to your endpoint URL", and so on
1482
+ * for all five. **Every one of those URLs is already baked into media.mjs as a
1483
+ * default.** Nobody needs to set them. `mediaConfig` returns them the moment a
1484
+ * SECRET exists (`url(k) || (secret ? fallback : null)`) — so the single real
1485
+ * blocker is one credential, and the doctor was sending its reader off to find
1486
+ * five endpoint URLs they do not have and cannot guess.
1487
+ *
1488
+ * ⭐ Wrong advice in a diagnostic is worse than no advice: it is followed. A
1489
+ * doctor exists to turn "it does not work" into one action, and this one
1490
+ * turned it into five impossible ones.
1491
+ *
1492
+ * ⚠️ `see_page` IS GENUINELY DIFFERENT and must keep the old message. The
1493
+ * renderer has NO baked-in default (see `mediaConfig`: `render` is
1494
+ * `url(...)` with no fallback), so there `RENDER_AUDIT_URL` really is the
1495
+ * thing to set. One flag distinguishes them so the two can never drift.
1496
+ */
1497
+ const mediaTargets = [
1498
+ { id: 'see_page', label: 'see_page', envVar: renderVar, url: media.render, builtIn: false },
1499
+ { id: 'speak', label: 'speak', envVar: 'MODAL_TTS_URL', url: media.speak, builtIn: true },
1500
+ { id: 'transcribe', label: 'transcribe', envVar: 'MODAL_TRANSCRIBE_URL', url: media.transcribe, builtIn: true },
1501
+ { id: 'make_document', label: 'make_document', envVar: 'MODAL_PRESS_URL', url: media.document, builtIn: true },
1502
+ { id: 'read_document', label: 'read_document', envVar: 'MODAL_DOC_READ_URL', url: media.docRead, builtIn: true },
1503
+ { id: 'read_table', label: 'read_table', envVar: 'MODAL_TABLE_READ_URL', url: media.tableRead, builtIn: true },
1504
+ // ⭐ PROBED SEPARATELY, NOT AS ONE "image editing" LINE. They are two Modal
1505
+ // apps, and the failure a person needs to see is "select is dark, so
1506
+ // edit_image cannot work while expand_image still can" — a merged line
1507
+ // cannot say that.
1508
+ { id: 'select', label: 'edit_image (select)', envVar: 'MODAL_SELECT_URL', url: edit.select },
1509
+ { id: 'flux', label: 'edit/expand (studio)', envVar: 'MODAL_FLUX_URL', url: edit.flux },
1510
+ ];
1511
+
1512
+ const jobs = [];
1513
+ const push = (key, run) => { jobs.push(run().then((v) => [key, v], () => [key, { kind: 'unreachable', detail: 'probe failed' }])); };
1514
+
1515
+ if (probed) {
1516
+ if (model.configured) {
1517
+ push('or.key', () => httpProbe(fetchImpl, OR_KEY_URL, { headers: { authorization: `Bearer ${model.apiKey}` } }, timeoutMs));
1518
+ push('or.credits', () => httpProbe(fetchImpl, OR_CREDITS_URL, { headers: { authorization: `Bearer ${model.apiKey}` } }, timeoutMs));
1519
+ }
1520
+ // ⭐ ONE request answers the whole chain. Calling each model would cost money
1521
+ // and prove less: a 404 on an id is a catalogue fact, not a generation fact.
1522
+ push('or.models', () => httpProbe(fetchImpl, OR_MODELS_URL, {}, timeoutMs));
1523
+ for (const t of mediaTargets) {
1524
+ if (t.url && hostOf(t.url)) push(`media.${t.id}`, () => probeMediaEndpoint(fetchImpl, t.url, media.secret, timeoutMs));
1525
+ }
1526
+ if (image.configured && hostOf(image.base)) {
1527
+ // Measured: `GET /health` -> 200 {"ok":true,"browser":"idle"} in ~1s.
1528
+ push('image', () => httpProbe(fetchImpl, `${generateEndpoint(image.base).replace(/\/generate$/, '')}/health`, {}, timeoutMs));
1529
+ }
1530
+ }
1531
+
1532
+ const results = Object.fromEntries(await Promise.all(jobs));
1533
+
1534
+ /**
1535
+ * ⚠️ THE OFFLINE VERDICT IS COMPUTED OVER RAW TRANSPORT OUTCOMES, before any
1536
+ * of them has been turned into a state. Deciding "offline" from already-red
1537
+ * checks would be circular.
1538
+ */
1539
+ const transportKinds = Object.entries(results).map(([, v]) => (v?.kind ? v : (v?.status !== undefined ? { kind: 'http' } : { kind: 'unreachable' })));
1540
+ const offline = probed ? isOffline(transportKinds) : false;
1541
+
1542
+ const sections = [];
1543
+
1544
+ // ── RUNTIME ──────────────────────────────────────────────────────────────
1545
+ sections.push({
1546
+ id: 'runtime',
1547
+ title: 'Runtime',
1548
+ checks: [
1549
+ checkNodeVersion(nodeVersion, engines === undefined ? readOwnEngines() : engines),
1550
+ { id: 'runtime.platform', label: 'platform', state: 'live', verified: true, detail: platform, fix: null },
1551
+ ],
1552
+ });
1553
+
1554
+ // ── MODEL ────────────────────────────────────────────────────────────────
1555
+ const modelChecks = [];
1556
+ if (!model.configured) {
1557
+ modelChecks.push({
1558
+ id: 'model.key',
1559
+ label: 'OPENROUTER_API_KEY',
1560
+ state: 'dark',
1561
+ verified: true,
1562
+ detail: 'absent — every model call will refuse before it is sent',
1563
+ fix: 'set OPENROUTER_API_KEY (free to create at https://openrouter.ai/keys)',
1564
+ });
1565
+ } else {
1566
+ const p = results['or.key'];
1567
+ modelChecks.push(assessKey(p, offline, probed));
1568
+ const credits = assessCredits(results['or.credits'], offline, probed, modelChecks[0].state);
1569
+ if (credits) modelChecks.push(credits);
1570
+ }
1571
+
1572
+ const chain = safeChain(model.model, env);
1573
+ const catalogue = catalogueIds(results['or.models']);
1574
+ for (const id of chain) {
1575
+ modelChecks.push(assessChainModel(id, {
1576
+ catalogue,
1577
+ primary: id === model.model,
1578
+ offline,
1579
+ probed,
1580
+ configuredVar: env.OPENROUTER_CODEGEN_MODEL ? 'OPENROUTER_CODEGEN_MODEL' : (id === model.model ? 'OPENROUTER_CODEGEN_MODEL' : 'ACUVO_FALLBACK_MODELS'),
1581
+ }));
1582
+ }
1583
+ sections.push({ id: 'model', title: `Model chain (${chain.length} deep · default ${DEFAULT_MODEL})`, checks: modelChecks });
1584
+
1585
+ /**
1586
+ * ⭐ A PROPERTY OF THE MODEL, SO IT SITS WITH THE MODEL. Whether the prefix
1587
+ * caches decides what a long session costs, and it is knowable for free.
1588
+ */
1589
+ sections.push({ id: 'cache', title: 'Prompt cache', checks: cacheChecks({ model: model.model, maxRounds }) });
1590
+
1591
+ // ── MEDIA ────────────────────────────────────────────────────────────────
1592
+ const mediaChecks = [];
1593
+ for (const t of mediaTargets) {
1594
+ if (!t.url) {
1595
+ /**
1596
+ * ⚠️ THE SECRET, NOT THE URL — see `builtIn` above. `explicitlyOff` is the
1597
+ * third case and it must not be told to set a variable it already set: an
1598
+ * empty `MODAL_TTS_URL=` is a deliberate opt-out on an air-gapped machine,
1599
+ * and `media.mjs` honours it on purpose.
1600
+ */
1601
+ const explicitlyOff = t.envVar in env && String(env[t.envVar] ?? '').trim() === '';
1602
+ mediaChecks.push({
1603
+ id: `media.${t.id}`,
1604
+ label: t.label,
1605
+ state: 'dark',
1606
+ verified: true,
1607
+ detail: explicitlyOff
1608
+ ? `${t.envVar} is set to an empty value, which means OFF, so ${t.label} is never offered to the model`
1609
+ : (t.builtIn
1610
+ ? `no media credential is set, so ${t.label} is never offered to the model (its URL is built in — that part needs nothing from you)`
1611
+ : `${t.envVar} is unset, so ${t.label} is never offered to the model`),
1612
+ fix: explicitlyOff
1613
+ ? `remove the empty ${t.envVar}, or give it a URL, to turn ${t.label} back on`
1614
+ : (t.builtIn
1615
+ ? 'set ACUVO_MEDIA_SECRET in .env.local — ONE credential turns on speech, transcription, documents and table reading together'
1616
+ : `set ${t.envVar} to your endpoint URL (and MODAL_VIDEO_SECRET to the value it expects)`),
1617
+ });
1618
+ continue;
1619
+ }
1620
+ const host = hostOf(t.url);
1621
+ if (!host) {
1622
+ mediaChecks.push({
1623
+ id: `media.${t.id}`,
1624
+ label: t.label,
1625
+ state: 'broken',
1626
+ verified: true,
1627
+ detail: `${t.envVar} is set but is not a valid URL`,
1628
+ fix: `set ${t.envVar} to a full https:// URL`,
1629
+ });
1630
+ continue;
1631
+ }
1632
+ /**
1633
+ * ⚠️ THE SECRET IS CHECKED BEFORE THE WIRE, because this is the exact
1634
+ * configuration that cost the hour: a URL that is set, a secret that is
1635
+ * not, four tools OFFERED to the model, and every call answering 200 with
1636
+ * a refusal the caller read as transient.
1637
+ */
1638
+ const probe = probed
1639
+ ? (results[`media.${t.id}`] ?? { kind: 'unreachable', detail: 'not probed' })
1640
+ : { kind: 'unchecked', detail: 'no network probe was requested' };
1641
+ const verdict = assessProbe(probe, { envVar: t.envVar, offline, host });
1642
+ if (!media.secret && verdict.state === 'live' && verdict.verified) {
1643
+ // Belt and braces: if a service ever stops requiring the secret we still
1644
+ // say the variable is missing, because three of the four do require it.
1645
+ verdict.detail += ' — note MODAL_VIDEO_SECRET is unset';
1646
+ }
1647
+ mediaChecks.push({ id: `media.${t.id}`, label: t.label, ...verdict });
1648
+ }
1649
+
1650
+ mediaChecks.push(assessImage(image, results.image, offline, probed, env));
1651
+ sections.push({ id: 'media', title: 'Media services', checks: mediaChecks });
1652
+
1653
+ // ── TOOLS ────────────────────────────────────────────────────────────────
1654
+ const offer = toolOffer({ root, env, allowRun, maxRounds });
1655
+ const toolChecks = [{
1656
+ id: 'tools.offer',
1657
+ label: 'tools offered here',
1658
+ state: 'live',
1659
+ verified: true,
1660
+ detail: `${offer.offered.length} of ${offer.total}${allowRun ? '' : ' (--no-run)'}${maxRounds <= 1 ? ' (single-shot)' : ''}`,
1661
+ fix: null,
1662
+ }];
1663
+ for (const w of offer.withheld) {
1664
+ toolChecks.push({ id: `tools.withheld.${w.name}`, label: w.name, state: 'dark', verified: true, detail: w.why, fix: w.fix });
1665
+ }
1666
+ sections.push({ id: 'tools', title: 'Tool surface', checks: toolChecks });
1667
+
1668
+ /**
1669
+ * ⭐ THE OTHER HALF OF THE TOOL SURFACE, AND THE HALF WE DID NOT WRITE. An
1670
+ * MCP server is how a user's Linear / Postgres / Sentry becomes reachable, so
1671
+ * "is it connected" belongs directly under "what tools exist here".
1672
+ *
1673
+ * ⚠️ READ ONCE, USED TWICE — the checks below, and the scrub at the very end.
1674
+ * A config file is allowed to contain a real credential, and this is the one
1675
+ * secret source that is NOT an environment variable, so SECRET_ENV_VARS would
1676
+ * never have caught it.
1677
+ */
1678
+ const mcp = mcpConfigImpl(root);
1679
+ /**
1680
+ * ⚠️ `offeredCount` IS PASSED SO THE TWO ROWS CANNOT DISAGREE. This line used
1681
+ * to let `mcpChecks` quote `TOOL_NAMES.length` while the row above printed
1682
+ * `offer.offered.length` — 47 against 34, two lines apart in one report.
1683
+ */
1684
+ /**
1685
+ * ⭐ THE CATALOGUE RIDES IN THE SAME SECTION, DELIBERATELY. A separate
1686
+ * "MCP catalogue" heading would be a second place to look for one subject,
1687
+ * and the two answers belong side by side: what you HAVE configured, then
1688
+ * what you could have. See `mcpCatalogueChecks` for why only servers we ran
1689
+ * ourselves appear.
1690
+ */
1691
+ sections.push({
1692
+ id: 'mcp',
1693
+ title: 'MCP servers',
1694
+ checks: [
1695
+ ...mcpChecks(mcp, env, { offeredCount: offer.offered.length }),
1696
+ ...mcpCatalogueChecks({ env, root, declaredNames: (mcp?.servers ?? []).map((s) => s.name) }),
1697
+ ],
1698
+ });
1699
+
1700
+ /**
1701
+ * ── ⭐ WHAT THIS AGENT MAY RUN, AND WHAT IT COULD RUN ──────────────────────
1702
+ *
1703
+ * The write→run→fix loop IS the product, so "can it run your test suite" is
1704
+ * the single most consequential fact about a workspace — and it was the one
1705
+ * thing doctor did not say. The polyglot presets are built, tested and wired;
1706
+ * they are simply OFF until someone opts in, and a capability nobody knows
1707
+ * about is worth what an absent one is worth.
1708
+ *
1709
+ * ⚠️ THE REFUSAL ALREADY TEACHES THIS — it names the preset, the exact JSON
1710
+ * and the env var — but only AFTER the model has burned a round discovering
1711
+ * it. This is the same sentence, before the round is spent.
1712
+ *
1713
+ * ⚠️ NEVER PRESENTED AS BROKEN. Four binaries is the deliberate default
1714
+ * posture, not a fault; `state: 'live'` with the presets listed as available
1715
+ * says "this is what you have and here is more", which is true. Marking it
1716
+ * dark would train people to fix something that is working as designed.
1717
+ */
1718
+ sections.push({ id: 'commands', title: 'What it may run', checks: commandChecks(root, env, readFileImpl) });
1719
+
1720
+ // ── WORKSPACE ────────────────────────────────────────────────────────────
1721
+ sections.push({ id: 'workspace', title: 'Workspace', checks: await workspaceChecks(root, gitStatusImpl, readFileImpl, gitTimeoutMs) });
1722
+
1723
+ const flat = sections.flatMap((s) => s.checks);
1724
+ const summary = summarise(flat);
1725
+
1726
+ const report = {
1727
+ ok: summary.broken === 0,
1728
+ generatedAt: now(),
1729
+ root,
1730
+ platform,
1731
+ node: nodeVersion,
1732
+ probed,
1733
+ offline,
1734
+ summary,
1735
+ sections,
1736
+ };
1737
+ /**
1738
+ * ⚠️⚠️ THE LAST THING THAT HAPPENS, AND IT IS NOT OPTIONAL. Everything above
1739
+ * is written not to hold a credential; this is the guarantee that a service
1740
+ * echoing one back at us cannot launder it into the report anyway.
1741
+ *
1742
+ * ⚠️ AND THE ENVIRONMENT IS NO LONGER THE ONLY PLACE A SECRET LIVES. An
1743
+ * `mcp.json` env block routinely holds a real API token typed in by hand —
1744
+ * a source `SECRET_ENV_VARS` cannot know about, because these names are the
1745
+ * user's, not ours. Placeholders are excluded on purpose: `${GITHUB_TOKEN}`
1746
+ * is not a credential, and redacting it would delete the very text the
1747
+ * placeholder line needs in order to explain itself.
1748
+ */
1749
+ const scrubEnv = { ...env };
1750
+ const scrubNames = [...SECRET_ENV_VARS];
1751
+ if (mcp?.ok) {
1752
+ for (const s of mcp.servers ?? []) {
1753
+ for (const [k, v] of Object.entries(s.env ?? {})) {
1754
+ if (typeof v !== 'string' || envPlaceholderName(v)) continue;
1755
+ const name = `mcp:${s.name}:${k}`;
1756
+ scrubEnv[name] = v;
1757
+ scrubNames.push(name);
1758
+ }
1759
+ }
1760
+ }
1761
+ return scrubSecrets(report, scrubEnv, scrubNames);
1762
+ }
1763
+
1764
+ // ── the small assessors, kept separate so each is readable ─────────────────
1765
+
1766
+ function safeChain(primary, env) {
1767
+ try {
1768
+ const c = buildChain(primary, env);
1769
+ return Array.isArray(c) && c.length ? c : [primary];
1770
+ } catch {
1771
+ return [primary];
1772
+ }
1773
+ }
1774
+
1775
+ function catalogueIds(probe) {
1776
+ if (!probe || probe.kind || !probe.json) return null;
1777
+ const data = probe.json?.data;
1778
+ if (!Array.isArray(data)) return null;
1779
+ return new Set(data.map((m) => m?.id).filter(Boolean));
1780
+ }
1781
+
1782
+ function assessKey(probe, offline, probed) {
1783
+ const base = { id: 'model.key', label: 'OPENROUTER_API_KEY' };
1784
+ if (!probed) {
1785
+ return { ...base, state: 'live', verified: false, detail: 'present — not checked (no network probe was requested)', fix: null };
1786
+ }
1787
+ if (probe?.kind === 'unreachable') {
1788
+ return offline
1789
+ ? { ...base, state: 'live', verified: false, detail: 'present — could not check (nothing on this machine reached the network)', fix: 'reconnect and re-run the doctor to verify OPENROUTER_API_KEY' }
1790
+ : { ...base, state: 'broken', verified: true, detail: 'present, but openrouter.ai could not be reached while other hosts answered', fix: 'check outbound access to openrouter.ai, then re-check OPENROUTER_API_KEY' };
1791
+ }
1792
+ if (probe?.status === 401 || probe?.status === 403) {
1793
+ /**
1794
+ * ⭐⭐ PRESENT AND REVOKED IS THE WORST CASE, and "present" is what every
1795
+ * other check in this package tests for. A key that exists and authenticates
1796
+ * nothing passes `configured`, passes the offer gate, and fails every call.
1797
+ */
1798
+ return { ...base, state: 'broken', verified: true, detail: `present, but it does NOT authenticate — the API answered HTTP ${probe.status}. The key is revoked, mistyped, or from another account.`, fix: 'replace OPENROUTER_API_KEY with a working key from https://openrouter.ai/keys' };
1799
+ }
1800
+ if (probe?.status >= 400) {
1801
+ return { ...base, state: 'broken', verified: true, detail: `present, but the key endpoint answered HTTP ${probe.status}`, fix: 'retry; if it persists, replace OPENROUTER_API_KEY from https://openrouter.ai/keys' };
1802
+ }
1803
+ return { ...base, state: 'live', verified: true, detail: 'present, and it authenticates', fix: null };
1804
+ }
1805
+
1806
+ /**
1807
+ * ⭐ ZERO CREDITS IS BROKEN EVEN THOUGH THE KEY IS VALID. Measured on this
1808
+ * project before: a key that authenticates against an exhausted balance returns
1809
+ * HTTP 402 on every paid model, and the chain spends four attempts discovering
1810
+ * it. "Your key is fine" is a true sentence that sends someone the wrong way.
1811
+ */
1812
+ function assessCredits(probe, offline, probed, keyState) {
1813
+ const base = { id: 'model.credits', label: 'account balance' };
1814
+ if (!probed) return { ...base, state: 'live', verified: false, detail: 'not checked (no network probe was requested)', fix: null };
1815
+ if (probe?.kind === 'unreachable') {
1816
+ return offline
1817
+ ? { ...base, state: 'live', verified: false, detail: 'could not check — nothing on this machine reached the network', fix: 'reconnect and re-run the doctor' }
1818
+ : null;
1819
+ }
1820
+ const d = probe?.json?.data;
1821
+ if (!d || typeof d.total_credits !== 'number') {
1822
+ // The key line already carries the failure; a second red line about the
1823
+ // same cause is noise, not diagnosis.
1824
+ return keyState === 'broken' ? null : { ...base, state: 'live', verified: false, detail: 'the balance could not be read', fix: null };
1825
+ }
1826
+ const left = d.total_credits - (typeof d.total_usage === 'number' ? d.total_usage : 0);
1827
+ if (left <= 0) {
1828
+ return { ...base, state: 'broken', verified: true, detail: `$${left.toFixed(2)} remaining — the key authenticates but every paid model will answer HTTP 402`, fix: 'top up at https://openrouter.ai/credits, or set OPENROUTER_CODEGEN_MODEL to a :free model' };
1829
+ }
1830
+ return { ...base, state: 'live', verified: true, detail: `$${left.toFixed(2)} remaining of $${d.total_credits.toFixed(2)}`, fix: null };
1831
+ }
1832
+
1833
+ function assessChainModel(id, { catalogue, primary, offline, probed, configuredVar }) {
1834
+ const base = { id: `model.chain.${id}`, label: id + (primary ? ' (primary)' : '') };
1835
+ if (!probed || catalogue === null) {
1836
+ const why = !probed ? 'not checked (no network probe was requested)'
1837
+ : offline ? 'could not check — nothing on this machine reached the network'
1838
+ : 'could not read OpenRouter\'s model catalogue';
1839
+ return { ...base, state: 'live', verified: false, detail: why, fix: null };
1840
+ }
1841
+ if (!catalogue.has(id)) {
1842
+ /**
1843
+ * ⚠️ A RETIRED ID IS A SILENT OUTAGE. It is the failure that sat in the
1844
+ * OpenCode integration for weeks: every request answered 404 and three
1845
+ * healthy fallbacks were never tried.
1846
+ */
1847
+ return {
1848
+ ...base,
1849
+ state: 'broken',
1850
+ verified: true,
1851
+ detail: 'not in OpenRouter\'s catalogue — every call to this id will 404',
1852
+ fix: primary
1853
+ ? `set OPENROUTER_CODEGEN_MODEL to a live model id (or unset it to fall back to ${DEFAULT_MODEL})`
1854
+ : `remove "${id}" from ${configuredVar}`,
1855
+ };
1856
+ }
1857
+ return { ...base, state: 'live', verified: true, detail: 'in OpenRouter\'s catalogue', fix: null };
1858
+ }
1859
+
1860
+ /**
1861
+ * ── ⚠️⚠️ THIS LINE NAMED A HOST THE RUN NEVER CONTACTS ──────────────────────
1862
+ *
1863
+ * It read `configured (…acuvo-perchance-images-serve.modal.run, the built-in
1864
+ * default)` and that host is exactly the one that does NOT get called in the
1865
+ * default configuration. Read `generateThroughProviders` (lib/imagegen.mjs:686+)
1866
+ * and the chain is:
1867
+ *
1868
+ * 1. our own GPU engine — only when `engineConfig().configured`, i.e. a URL
1869
+ * AND ACUVO_IMAGE_SECRET. It fails shut otherwise (imagegen.mjs:334-339).
1870
+ * 2. `useOwnServer = Boolean(env[PERCHANCE_IMAGE_URL])` — and when that
1871
+ * variable is UNSET the code takes `generateViaNativePerchance`, which
1872
+ * talks to perchance.org directly. `DEFAULT_IMAGE_URL` is the value used
1873
+ * when someone POINTS AT OUR SERVICE, which is the one case where
1874
+ * `usingDefault` is false.
1875
+ * 3. Pollinations, always, as the fallback.
1876
+ *
1877
+ * So the old sentence printed the built-in default precisely in the branch that
1878
+ * bypasses it — and it did so because the destination was DERIVED TWICE, once
1879
+ * by `imageConfig().base` here and once by `useOwnServer` there. This repo has
1880
+ * a memory entry for exactly this shape ("THE REGISTRY NAMES THE WRONG
1881
+ * TRANSPORT — musetalk gated on RunPod while MODAL serves it"). Deriving the
1882
+ * same fact in two places is what produces the drift; the fix is to read it off
1883
+ * the same predicate the run uses.
1884
+ *
1885
+ * ⚠️ THIS MATTERS MOST TO THE READER IT WAS WRITTEN FOR. ENTERPRISE.md §2.2
1886
+ * calls `generate_image`'s egress "easy to miss"; an egress reviewer who
1887
+ * allow-lists the host this line named would still see the prompt leave to
1888
+ * perchance.org and pollinations.ai. A doctor that misstates a destination is
1889
+ * worse than one that says nothing about it.
1890
+ *
1891
+ * ⚠️ THE PROBE STILL TARGETS `image.base`/health (see the `push('image', …)`
1892
+ * call), so on a default configuration it verifies a host the run will not use.
1893
+ * That is left alone deliberately — changing what the doctor probes is a
1894
+ * behaviour change on the network path — but the wording below no longer lets
1895
+ * a reader mistake the probed host for the destination.
1896
+ */
1897
+ function imageDestination(image, env) {
1898
+ const engine = engineConfig(env);
1899
+ if (engine.configured) {
1900
+ const h = hostOf(engine.base);
1901
+ return h ? `${h} — our own GPU engine, which the chain tries FIRST` : null;
1902
+ }
1903
+ const useOwnServer = Boolean((env?.[IMAGE_URL_ENV] || '').trim());
1904
+ if (useOwnServer) {
1905
+ const h = hostOf(image.base);
1906
+ return h ? `${h}, the image service you named in ${IMAGE_URL_ENV}` : null;
1907
+ }
1908
+ return `perchance.org direct, then image.pollinations.ai — ${IMAGE_URL_ENV} is unset, so neither our GPU engine (no ${ENGINE_SECRET_ENV}) nor ${hostOf(image.base) || 'the built-in service'} is contacted`;
1909
+ }
1910
+
1911
+ function assessImage(image, probe, offline, probed, env = {}) {
1912
+ const base = { id: 'media.generate_image', label: 'generate_image' };
1913
+ if (!image.configured) {
1914
+ return { ...base, state: 'dark', verified: true, detail: `${IMAGE_URL_ENV} is set to an empty value, which means OFF`, fix: `unset ${IMAGE_URL_ENV} to use the default endpoint, or set it to your own image service` };
1915
+ }
1916
+ const dest = imageDestination(image, env);
1917
+ const where = dest ? ` (${dest})` : '';
1918
+ if (!probed) return { ...base, state: 'live', verified: false, detail: `configured${where} · not checked (no network probe was requested)`, fix: null };
1919
+ if (probe?.kind === 'unreachable') {
1920
+ return offline
1921
+ ? { ...base, state: 'live', verified: false, detail: `configured${where} · could not check — nothing on this machine reached the network`, fix: `reconnect and re-run the doctor to verify ${IMAGE_URL_ENV}` }
1922
+ : { ...base, state: 'broken', verified: true, detail: `configured${where} · unreachable while other services answered`, fix: `check ${IMAGE_URL_ENV}` };
1923
+ }
1924
+ /**
1925
+ * ⚠️ A 404 ON /health IS NOT A DEAD SERVICE. Older deployments of this image
1926
+ * server had no health route at all, and condemning them would be a check
1927
+ * failing correct work — the mistake this repo has now made six times.
1928
+ */
1929
+ if (probe?.status === 404) return { ...base, state: 'live', verified: false, detail: `configured${where} · this deployment has no /health route, so it could not be verified`, fix: null };
1930
+ /**
1931
+ * ⚠️ ONLY AN ANSWER THAT READS AS AN AUTH FAILURE NAMES THE TOKEN. The first
1932
+ * draft treated ANY `ok:false` as a credential problem, which sent a reader
1933
+ * to set `PERCHANCE_IMAGE_TOKEN` for a service that was complaining about
1934
+ * something else entirely — a fix that names the wrong variable is worse than
1935
+ * no fix, because it is followed.
1936
+ */
1937
+ if (probe?.status === 401 || probe?.status === 403 || (probe?.json?.ok === false && readsAsAuthFailure(probe?.json?.error))) {
1938
+ return { ...base, state: 'broken', verified: true, detail: `configured${where} · the service refused the credential`, fix: `set ${IMAGE_TOKEN_ENV} to the value the image service expects (it is that service's SHARED_TOKEN — same value, two names)` };
1939
+ }
1940
+ if (probe?.status >= 400) return { ...base, state: 'broken', verified: true, detail: `configured${where} · HTTP ${probe.status}`, fix: `check ${IMAGE_URL_ENV} points at a running image service` };
1941
+ if (probe?.json?.ok === false) {
1942
+ return { ...base, state: 'broken', verified: true, detail: `configured${where} · the health route answered: ${String(probe.json.error ?? 'no reason given').slice(0, 120)}`, fix: `check ${IMAGE_URL_ENV} points at a healthy image service` };
1943
+ }
1944
+ const browser = probe?.json?.browser ? ` — browser ${probe.json.browser}` : '';
1945
+ return { ...base, state: 'live', verified: true, detail: `configured${where} · reachable${browser}`, fix: null };
1946
+ }
1947
+
1948
+ const GIT_TIMED_OUT = Symbol('git-timed-out');
1949
+
1950
+ /**
1951
+ * What `run_command` will actually accept in THIS workspace, and what it could.
1952
+ *
1953
+ * ⚠️ It asks the real resolver rather than restating the rules. `command.mjs`
1954
+ * owns what an allowlist is; a second opinion here would be the copy that goes
1955
+ * stale the day someone adds a preset — the same argument search.mjs makes for
1956
+ * importing the credential list instead of re-declaring it.
1957
+ */
1958
+ function commandChecks(root, env, readFileImpl) {
1959
+ let configText = null;
1960
+ try {
1961
+ configText = readFileImpl(join(root, COMMANDS_CONFIG_FILE), 'utf8');
1962
+ } catch {
1963
+ // Absent is the overwhelmingly common case and means "the default four".
1964
+ configText = null;
1965
+ }
1966
+
1967
+ const resolved = resolveCommandAllowlist({ configText, envValue: env[ALLOW_COMMANDS_ENV] });
1968
+
1969
+ if (!resolved.ok) {
1970
+ return [{
1971
+ id: 'commands.config',
1972
+ label: 'command allowlist',
1973
+ state: 'broken',
1974
+ verified: true,
1975
+ detail: resolved.error,
1976
+ // ⚠️ A malformed control stops commands rather than silently reverting to
1977
+ // the default — so this is genuinely broken, not merely unset.
1978
+ fix: `fix or delete ${COMMANDS_CONFIG_FILE} — a config that cannot be parsed stops every command rather than falling back`,
1979
+ }];
1980
+ }
1981
+
1982
+ const binaries = [...(resolved.allowlist?.binaries ?? [])];
1983
+ const on = resolved.presets ?? [];
1984
+ const off = PRESET_NAMES.filter((p) => !on.includes(p));
1985
+
1986
+ const checks = [{
1987
+ id: 'commands.allowed',
1988
+ label: 'programs it may run',
1989
+ state: 'live',
1990
+ verified: true,
1991
+ detail: binaries.join(', ') + (on.length ? ` · presets on: ${on.join(', ')}` : ' · no presets enabled'),
1992
+ fix: null,
1993
+ }];
1994
+
1995
+ if (off.length) {
1996
+ checks.push({
1997
+ id: 'commands.presets',
1998
+ label: 'presets available',
1999
+ state: 'dark',
2000
+ verified: true,
2001
+ /**
2002
+ * ⭐ THE SENTENCE THAT MATTERS. Someone whose repo is Python sees, before
2003
+ * spending a round on a refusal, that the loop CAN run their tests.
2004
+ */
2005
+ detail: `${off.join(', ')} — off by default, so pytest / go test / cargo test are refused here`,
2006
+ fix: `add {"presets": ["${off[0]}"]} to ${COMMANDS_CONFIG_FILE}, or set ${ALLOW_COMMANDS_ENV}=${off[0]}`,
2007
+ });
2008
+ }
2009
+
2010
+ return checks;
2011
+ }
2012
+
2013
+ async function workspaceChecks(root, gitStatusImpl, readFileImpl, gitTimeoutMs) {
2014
+ let status;
2015
+ try {
2016
+ status = await withTimeout(
2017
+ Promise.resolve().then(() => gitStatusImpl(root)),
2018
+ gitTimeoutMs,
2019
+ GIT_TIMED_OUT,
2020
+ );
2021
+ if (status === GIT_TIMED_OUT) {
2022
+ return [{
2023
+ id: 'workspace.git',
2024
+ label: 'git',
2025
+ state: 'dark',
2026
+ verified: true,
2027
+ detail: `git did not answer within ${gitTimeoutMs}ms — the doctor stopped waiting rather than hang`,
2028
+ fix: 'check for a stale index.lock, or a filesystem/credential helper that is blocking git',
2029
+ }];
2030
+ }
2031
+ } catch (err) {
2032
+ return [{
2033
+ id: 'workspace.git',
2034
+ label: 'git',
2035
+ state: 'dark',
2036
+ verified: true,
2037
+ detail: `git could not be run here — ${err instanceof Error ? err.message : String(err)}`,
2038
+ fix: 'install git (git_status / git_diff / git_log / git_commit refuse until then)',
2039
+ }];
2040
+ }
2041
+
2042
+ if (!status?.ok) {
2043
+ return [{
2044
+ id: 'workspace.git',
2045
+ label: 'git',
2046
+ state: 'dark',
2047
+ verified: true,
2048
+ detail: String(status?.error ?? 'not a git repository').slice(0, 240),
2049
+ fix: 'run `git init` here if you want git_status / git_diff / git_log / git_commit to work',
2050
+ }];
2051
+ }
2052
+
2053
+ const branch = status.branch || '(no commits yet)';
2054
+ const count = Array.isArray(status.files) ? status.files.length : 0;
2055
+ const checks = [{
2056
+ id: 'workspace.git',
2057
+ label: 'git',
2058
+ state: 'live',
2059
+ verified: true,
2060
+ /**
2061
+ * ⚠️ A DIRTY TREE IS INFORMATION, NOT A FAULT. Flagging uncommitted work as
2062
+ * broken would paint every real working session red, and a doctor people
2063
+ * learn to ignore is worse than no doctor.
2064
+ */
2065
+ detail: `repository on ${branch} · ${count === 0 ? 'clean' : `${count} file${count === 1 ? '' : 's'} changed`}`,
2066
+ fix: null,
2067
+ }];
2068
+
2069
+ let ignore = null;
2070
+ try { ignore = readFileImpl(join(root, '.gitignore'), 'utf8'); } catch { ignore = null; }
2071
+ const covered = gitignoreCoversAcuvo(typeof ignore === 'string' ? ignore : null);
2072
+ checks.push({
2073
+ id: 'workspace.gitignore',
2074
+ label: `${ACUVO_DIR}/ ignored`,
2075
+ state: covered ? 'live' : 'dark',
2076
+ verified: true,
2077
+ detail: covered
2078
+ ? `${ACUVO_DIR}/ is ignored, so sessions and audit logs stay out of your commits`
2079
+ : `${ACUVO_DIR}/ is NOT ignored — this CLI writes sessions and audit logs there, inside your repository`,
2080
+ fix: covered ? null : `add "${ACUVO_DIR}/" to .gitignore`,
2081
+ });
2082
+ return checks;
2083
+ }
2084
+
2085
+ // ───────────────────────────────────────────────────────────────────────────
2086
+ // PURE: THE RENDERING
2087
+ // ───────────────────────────────────────────────────────────────────────────
2088
+
2089
+ const NO_PAINT = { dim: (t) => t, bold: (t) => t, gold: (t) => t, green: (t) => t, red: (t) => t, cyan: (t) => t };
2090
+
2091
+ /**
2092
+ * ── ⭐ THE OUTPUT IS THE PRODUCT ────────────────────────────────────────────
2093
+ *
2094
+ * Three words in a fixed column so the eye can scan them, and — the part that
2095
+ * matters — a `→` line under every dark or broken check carrying the exact
2096
+ * variable or command that fixes it. A reader should never have to leave this
2097
+ * screen to know what to do next.
2098
+ *
2099
+ * ⚠️ COLOUR IS OPT-IN AND REINFORCES A SIGNAL THAT IS ALREADY LEGIBLE. The
2100
+ * state words are distinct without it, so a redirected file and a colour-blind
2101
+ * reader lose nothing — the rule colour.mjs sets out for the rest of the CLI.
2102
+ */
2103
+ export function formatDoctor(report, { paint = null } = {}) {
2104
+ const p = { ...NO_PAINT, ...(paint ?? {}) };
2105
+ const r = report ?? {};
2106
+ const sections = Array.isArray(r.sections) ? r.sections : [];
2107
+ const lines = [];
2108
+
2109
+ lines.push(p.bold('Acuvo Code — doctor'));
2110
+ const head = [r.root, r.node ? `node ${r.node}` : null, r.platform].filter(Boolean).join(' · ');
2111
+ if (head) lines.push(p.dim(head));
2112
+ if (r.probed === false) lines.push(p.dim('network probes were not run — every remote line below is configuration only'));
2113
+ else if (r.offline) lines.push(p.dim('nothing reached the network: remote lines are marked unverified rather than broken'));
2114
+ lines.push('');
2115
+
2116
+ const colourFor = (state) => (state === 'live' ? p.green : state === 'broken' ? p.red : p.gold);
2117
+
2118
+ for (const section of sections) {
2119
+ const checks = Array.isArray(section?.checks) ? section.checks : [];
2120
+ if (checks.length === 0) continue;
2121
+ lines.push(p.bold(String(section.title ?? section.id ?? '').toUpperCase()));
2122
+ for (const c of checks) {
2123
+ const mark = c.state === 'live' && c.verified === false ? 'live?' : String(c.state ?? '');
2124
+ const state = colourFor(c.state)(mark.padEnd(7));
2125
+ lines.push(` ${state} ${String(c.label ?? '').padEnd(26)} ${p.dim(String(c.detail ?? ''))}`);
2126
+ if (c.fix) lines.push(` ${' '.repeat(7)} ${' '.repeat(26)} ${p.gold('→')} ${c.fix}`);
2127
+ }
2128
+ lines.push('');
2129
+ }
2130
+
2131
+ const s = r.summary ?? { live: 0, dark: 0, broken: 0, unverified: 0 };
2132
+ lines.push(p.dim(`${s.live} live · ${s.dark} dark · ${s.broken} broken${s.unverified ? ` · ${s.unverified} unverified` : ''}`));
2133
+ lines.push(
2134
+ s.broken === 0
2135
+ ? p.green('nothing is broken.')
2136
+ : p.red(`${s.broken} thing${s.broken === 1 ? ' is' : 's are'} broken — each red line above names what fixes it.`),
2137
+ );
2138
+ return lines.join('\n');
2139
+ }