@mmerterden/multi-agent-pipeline 16.28.0 → 16.30.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 (52) hide show
  1. package/CHANGELOG.md +119 -2
  2. package/README.md +4 -4
  3. package/README.tr.md +3 -3
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/features.md +14 -0
  7. package/install/claude.mjs +17 -0
  8. package/package.json +1 -1
  9. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
  10. package/pipeline/commands/multi-agent/design-check/SKILL.md +6 -5
  11. package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
  12. package/pipeline/commands/multi-agent/help/SKILL.md +15 -12
  13. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
  15. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
  16. package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
  17. package/pipeline/lib/_jira-auth.sh +99 -0
  18. package/pipeline/lib/analysis-jira-write.sh +203 -0
  19. package/pipeline/lib/issue-fetcher.sh +4 -4
  20. package/pipeline/multi-agent-refs/analysis/render.md +1 -1
  21. package/pipeline/multi-agent-refs/channels/pr.md +37 -1
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
  24. package/pipeline/multi-agent-refs/features/doctor.md +197 -0
  25. package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
  26. package/pipeline/multi-agent-refs/features/visual-evidence.md +103 -20
  27. package/pipeline/multi-agent-refs/phases/phase-0-init.md +38 -7
  28. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +13 -1
  29. package/pipeline/multi-agent-refs/phases/phase-5-test.md +11 -1
  30. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +23 -0
  31. package/pipeline/multi-agent-refs/picker-contract.md +35 -0
  32. package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
  33. package/pipeline/preferences-template.json +1 -1
  34. package/pipeline/schemas/agent-state.schema.json +84 -1
  35. package/pipeline/schemas/analysis-spec.schema.json +336 -95
  36. package/pipeline/schemas/prefs.schema.json +80 -3
  37. package/pipeline/schemas/token-budget.json +10 -10
  38. package/pipeline/scripts/analysis-story-tree.mjs +441 -0
  39. package/pipeline/scripts/capture-evidence.sh +170 -5
  40. package/pipeline/scripts/doctor.mjs +758 -0
  41. package/pipeline/scripts/evidence-gate.mjs +31 -2
  42. package/pipeline/scripts/phase-tracker.sh +97 -17
  43. package/pipeline/scripts/probe-evidence-capability.sh +250 -0
  44. package/pipeline/scripts/run-ui-tests.sh +380 -0
  45. package/pipeline/scripts/scan-agent-config.sh +48 -10
  46. package/pipeline/scripts/skill-siblings.mjs +1 -1
  47. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
  48. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
  49. package/pipeline/skills/shared/core/multi-agent-manual-test/SKILL.md +10 -1
  50. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  51. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
  52. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +18 -0
@@ -0,0 +1,758 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file doctor.mjs - what is wrong with this install, and the one step that fixes it.
4
+ *
5
+ * WHY THIS EXISTS
6
+ *
7
+ * Every failure this reports has already reached a user, and each one arrived
8
+ * the same way: late, mid-run, after the pickers had been answered. A missing
9
+ * script fails at the call. Malformed preferences fail after Phase 0 has already
10
+ * asked five questions. A token in a remote URL does not fail at all - it leaks.
11
+ * None of it is visible beforehand, so the pipeline's own answer to "is this
12
+ * machine set up correctly" was to start a run and find out.
13
+ *
14
+ * So this is a gate, not a report. The exit code is the product:
15
+ *
16
+ * 0 healthy nothing above INFO
17
+ * 1 degraded at least one WARN
18
+ * 2 blocked at least one BLOCK
19
+ * 3 usage error
20
+ * 4 indeterminate the layout did not resolve, so nothing was checked
21
+ *
22
+ * 4 is the one people forget. Without it "I could not look" borrows the exit
23
+ * code of "I looked and it is fine", and every consumer downstream believes a
24
+ * broken resolver is a clean bill.
25
+ *
26
+ * BLOCK IS A CLOSED DEFINITION: a state where a run will fail or leak, never one
27
+ * where it will merely be worse. Five checks can produce it and the rest top out
28
+ * at WARN however bad they look. Without that rule "blocked" grows until nobody
29
+ * respects exit 2.
30
+ *
31
+ * IT RECOMMENDS, IT NEVER FIXES. A remote whose URL carries a token may be the
32
+ * only credential that repo has; the remote may be a mirror a script depends on
33
+ * verbatim; and this can run inside a checkout the user does not own. Silent
34
+ * repair breaks all three.
35
+ *
36
+ * Contract, every check by id, and the severity rules: multi-agent-refs/features/doctor.md
37
+ *
38
+ * Usage:
39
+ * doctor.mjs [--probe] [--json] [--explain] [--task-tools=yes|no]
40
+ * doctor.mjs --list-checks
41
+ */
42
+
43
+ import {
44
+ existsSync,
45
+ readFileSync,
46
+ readdirSync,
47
+ mkdirSync,
48
+ writeFileSync,
49
+ rmSync,
50
+ realpathSync,
51
+ } from "node:fs";
52
+ import { join, dirname } from "node:path";
53
+ import { homedir } from "node:os";
54
+ import { execFileSync } from "node:child_process";
55
+ import { fileURLToPath, pathToFileURL } from "node:url";
56
+
57
+ const HERE = dirname(fileURLToPath(import.meta.url));
58
+ const HOME = homedir();
59
+ const CLAUDE = join(HOME, ".claude");
60
+
61
+ const argv = process.argv.slice(2);
62
+ const JSON_OUT = argv.includes("--json");
63
+ const PROBE = argv.includes("--probe");
64
+ const EXPLAIN = argv.includes("--explain");
65
+ const LIST = argv.includes("--list-checks");
66
+ const taskFlag = argv.find((a) => a.startsWith("--task-tools="));
67
+ const TASK_TOOLS = taskFlag ? taskFlag.split("=")[1] : null;
68
+
69
+ // The id set. Kept here and in features/doctor.md, and smoke-doctor.sh asserts
70
+ // the two are equal in BOTH directions: a check cannot ship without its entry,
71
+ // and an entry cannot outlive its check.
72
+ const CHECK_IDS = [
73
+ "install-present",
74
+ "install-version",
75
+ "script-surface",
76
+ "skill-siblings",
77
+ "state-writable",
78
+ "prefs-valid",
79
+ "identity",
80
+ "hook-coverage",
81
+ "credential-mapping",
82
+ "credential-liveness",
83
+ "embedded-credentials",
84
+ "task-tools",
85
+ "mcp-registration",
86
+ "disk-space",
87
+ ];
88
+
89
+ // Only these five may return BLOCK. Enforced below, not merely documented: a
90
+ // check that returns BLOCK without being listed here is downgraded and the
91
+ // downgrade is reported, because an unenforced rule is a comment.
92
+ const MAY_BLOCK = new Set([
93
+ "install-present",
94
+ "script-surface",
95
+ "state-writable",
96
+ "prefs-valid",
97
+ "embedded-credentials",
98
+ ]);
99
+
100
+ // The step's first word. A closed list is what stops "consider reviewing your
101
+ // configuration" from being an acceptable answer.
102
+ const STEP_VERBS = [
103
+ "run",
104
+ "set",
105
+ "map",
106
+ "revoke",
107
+ "install",
108
+ "remove",
109
+ "free",
110
+ "export",
111
+ "merge",
112
+ "record",
113
+ "re-run",
114
+ ];
115
+
116
+ const results = [];
117
+ const STEP_RE = new RegExp(`^(${STEP_VERBS.join("|")})\\b`);
118
+
119
+ function report(id, severity, problem, step, detail) {
120
+ let sev = severity;
121
+ if (sev === "BLOCK" && !MAY_BLOCK.has(id)) {
122
+ sev = "WARN";
123
+ problem = `${problem} (downgraded: ${id} is not a blocking check)`;
124
+ }
125
+ // The writing contract is enforced where a step is written, not only in the
126
+ // gate. A rule that lives solely in a test is a rule the next author will not
127
+ // see, and "consider reviewing your configuration" is the shape it decays to.
128
+ if (step && !STEP_RE.test(step)) {
129
+ throw new Error(
130
+ `doctor: the step for "${id}" must start with one of ${STEP_VERBS.join(", ")} - got "${step}"`,
131
+ );
132
+ }
133
+ results.push({ id, severity: sev, problem, step: step || null, detail: detail || null });
134
+ }
135
+ const ok = (id) => report(id, "OK", null, null);
136
+ const skip = (id, why) => report(id, "SKIP", why, null);
137
+
138
+ function readJson(path) {
139
+ try {
140
+ return JSON.parse(readFileSync(path, "utf8"));
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /* ---------------------------------------------------------------- checks -- */
147
+
148
+ // The scope is pinned in the reinstall step on purpose. A `@mmerterden:registry=`
149
+ // line in the user's own .npmrc outranks `--registry`, so an unpinned npx
150
+ // resolves against whatever that file names - which on a machine configured for
151
+ // GitHub Packages is a 404 and a process that never starts. A health check whose
152
+ // one step silently fails is worse than no step. Rule: smoke-npm-scope-pinning.sh.
153
+ const SUBTREES = ["commands", "multi-agent-refs", "scripts", "lib", "schemas"];
154
+
155
+ function checkInstallPresent() {
156
+ if (!existsSync(CLAUDE)) {
157
+ report(
158
+ "install-present",
159
+ "BLOCK",
160
+ `no install at ${CLAUDE}`,
161
+ "run npx --@mmerterden:registry=https://registry.npmjs.org @mmerterden/multi-agent-pipeline install",
162
+ );
163
+ return false;
164
+ }
165
+ const missing = SUBTREES.filter((d) => !existsSync(join(CLAUDE, d)));
166
+ if (missing.length) {
167
+ report(
168
+ "install-present",
169
+ "BLOCK",
170
+ `the install is missing ${missing.join(", ")}`,
171
+ "run npx --@mmerterden:registry=https://registry.npmjs.org @mmerterden/multi-agent-pipeline install",
172
+ );
173
+ return false;
174
+ }
175
+ ok("install-present");
176
+ return true;
177
+ }
178
+
179
+ function checkInstallVersion() {
180
+ const stamp = join(CLAUDE, ".pipeline-version");
181
+ if (!existsSync(stamp)) {
182
+ skip("install-version", "no .pipeline-version stamp in the installed tree");
183
+ return;
184
+ }
185
+ const installed = readFileSync(stamp, "utf8").trim();
186
+ const pkgPath = join(HERE, "..", "..", "package.json");
187
+ const pkg = existsSync(pkgPath) ? readJson(pkgPath) : null;
188
+ if (!pkg?.version) {
189
+ skip("install-version", "no checkout resolved from here to compare against");
190
+ return;
191
+ }
192
+ if (installed !== pkg.version) {
193
+ report(
194
+ "install-version",
195
+ "WARN",
196
+ `installed ${installed}, this checkout is ${pkg.version}`,
197
+ "run /multi-agent:update",
198
+ );
199
+ return;
200
+ }
201
+ ok("install-version");
202
+ }
203
+
204
+ function checkScriptSurface() {
205
+ const cmdDir = join(CLAUDE, "commands", "multi-agent");
206
+ if (!existsSync(cmdDir)) {
207
+ skip("script-surface", "no installed command tree to read");
208
+ return;
209
+ }
210
+ const wanted = new Set();
211
+ for (const d of readdirSync(cmdDir)) {
212
+ const f = join(cmdDir, d, "SKILL.md");
213
+ if (!existsSync(f)) continue;
214
+ const text = readFileSync(f, "utf8");
215
+ // The path segment has to allow `/`. Without it a reference to
216
+ // `scripts/nested/bar.mjs` was truncated to the directory, so as long as the
217
+ // directory existed a missing file one level inside it passed - the check
218
+ // reported OK for exactly the case it exists to catch.
219
+ for (const m of text.matchAll(
220
+ /\$HOME\/\.claude\/(scripts|lib)\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g,
221
+ )) {
222
+ wanted.add(join(m[1], m[2]));
223
+ }
224
+ }
225
+ const missing = [...wanted].filter((rel) => !existsSync(join(CLAUDE, rel))).sort();
226
+ if (missing.length) {
227
+ report(
228
+ "script-surface",
229
+ "BLOCK",
230
+ `${missing.length} script(s) a command calls are not installed: ${missing.slice(0, 3).join(", ")}`,
231
+ "run /multi-agent:update",
232
+ missing,
233
+ );
234
+ return;
235
+ }
236
+ ok("script-surface");
237
+ }
238
+
239
+ function checkSkillSiblings() {
240
+ const sib = join(CLAUDE, "scripts", "skill-siblings.mjs");
241
+ if (!existsSync(sib)) {
242
+ skip("skill-siblings", "skill-siblings.mjs is not installed");
243
+ return;
244
+ }
245
+ let out;
246
+ try {
247
+ out = execFileSync("node", [sib, "--audit", "--json"], {
248
+ encoding: "utf8",
249
+ stdio: ["ignore", "pipe", "pipe"],
250
+ });
251
+ } catch (e) {
252
+ report(
253
+ "skill-siblings",
254
+ "WARN",
255
+ `the sibling audit did not complete: ${String(e.message).split("\n")[0]}`,
256
+ "run /multi-agent:sync",
257
+ );
258
+ return;
259
+ }
260
+ const j = (() => {
261
+ try {
262
+ return JSON.parse(out);
263
+ } catch {
264
+ return null;
265
+ }
266
+ })();
267
+ if (!j) {
268
+ report("skill-siblings", "WARN", "the sibling audit returned no JSON", "run /multi-agent:sync");
269
+ return;
270
+ }
271
+ // A local-only wrapper is authored for this machine and deliberately excluded
272
+ // from the sync, so it is absent from Copilot and Codex by design. Counting it
273
+ // as unsynced told the user to run a sync that would change nothing - a health
274
+ // check whose one step is a no-op teaches people to ignore it.
275
+ const localOnly = new Set();
276
+ const cmdRoot = join(CLAUDE, "commands", "multi-agent");
277
+ if (existsSync(cmdRoot)) {
278
+ for (const d of readdirSync(cmdRoot)) {
279
+ const f = join(cmdRoot, d, "SKILL.md");
280
+ if (existsSync(f) && /^local-only:\s*true/m.test(readFileSync(f, "utf8"))) localOnly.add(d);
281
+ }
282
+ }
283
+ const unsynced = (j.rows || []).filter(
284
+ (r) => !localOnly.has(r.name) && (r.missingInstalled || []).length > 0,
285
+ ).length;
286
+ if (unsynced > 0) {
287
+ report(
288
+ "skill-siblings",
289
+ "WARN",
290
+ `${unsynced} of ${j.commands - localOnly.size} synced command(s) are missing from at least one installed host`,
291
+ "run /multi-agent:sync",
292
+ );
293
+ return;
294
+ }
295
+ ok("skill-siblings");
296
+ }
297
+
298
+ function checkStateWritable() {
299
+ const dir = join(CLAUDE, "logs", "multi-agent");
300
+ try {
301
+ mkdirSync(dir, { recursive: true });
302
+ const probe = join(dir, `.doctor-${process.pid}`);
303
+ writeFileSync(probe, "x");
304
+ rmSync(probe);
305
+ } catch (e) {
306
+ report(
307
+ "state-writable",
308
+ "BLOCK",
309
+ `cannot write run state under ${dir}: ${e.code || e.message}`,
310
+ `run chmod u+w ${dir}`,
311
+ );
312
+ return;
313
+ }
314
+ ok("state-writable");
315
+ }
316
+
317
+ function checkPrefsValid() {
318
+ const prefs = join(CLAUDE, "multi-agent-preferences.json");
319
+ if (!existsSync(prefs)) {
320
+ report("prefs-valid", "WARN", "no preferences file yet", "run /multi-agent:setup");
321
+ return;
322
+ }
323
+ const j = readJson(prefs);
324
+ if (!j) {
325
+ report(
326
+ "prefs-valid",
327
+ "BLOCK",
328
+ "multi-agent-preferences.json does not parse",
329
+ "run /multi-agent:setup",
330
+ );
331
+ return;
332
+ }
333
+ if (typeof j.global !== "object" || j.global === null) {
334
+ report("prefs-valid", "BLOCK", "preferences carry no global block", "run /multi-agent:setup");
335
+ return;
336
+ }
337
+ ok("prefs-valid");
338
+ }
339
+
340
+ function checkIdentity() {
341
+ // The key is `identities[]` - a list, because a machine with a work and a
342
+ // personal account needs both, and `platformIdentityRouting` picks per remote.
343
+ // Looking for a singular `identity` reported "none recorded" on a machine with
344
+ // two, which is the shape of wrongness a health check must not have.
345
+ const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
346
+ const list = prefs?.global?.identities;
347
+ const usable = Array.isArray(list) ? list.filter((i) => i?.name && i?.email) : [];
348
+ if (usable.length) {
349
+ ok("identity");
350
+ return;
351
+ }
352
+ report(
353
+ "identity",
354
+ "WARN",
355
+ "no git identity recorded, so a run stops at the commit phase",
356
+ "run /multi-agent:setup",
357
+ );
358
+ }
359
+
360
+ function checkHookCoverage() {
361
+ const tpl = join(CLAUDE, "templates", "claude-hooks.json");
362
+ if (!existsSync(tpl)) {
363
+ skip("hook-coverage", "no hooks template installed to compare against");
364
+ return;
365
+ }
366
+ const want = readJson(tpl);
367
+ const have = readJson(join(CLAUDE, "settings.json"));
368
+ if (!want) {
369
+ skip("hook-coverage", "the hooks template does not parse");
370
+ return;
371
+ }
372
+ const wantedCmds = new Set();
373
+ for (const group of Object.values(want.hooks || {})) {
374
+ for (const entry of group || []) {
375
+ for (const h of entry.hooks || []) if (h.command) wantedCmds.add(h.command);
376
+ }
377
+ }
378
+ const haveStr = JSON.stringify(have?.hooks || {});
379
+ const absent = [...wantedCmds].filter((c) => !haveStr.includes(c));
380
+ if (absent.length) {
381
+ report(
382
+ "hook-coverage",
383
+ "WARN",
384
+ `${absent.length} of ${wantedCmds.size} template hook(s) are not in settings.json`,
385
+ `merge ${tpl} into ${join(CLAUDE, "settings.json")}`,
386
+ );
387
+ return;
388
+ }
389
+ ok("hook-coverage");
390
+ }
391
+
392
+ function credentialInventory() {
393
+ const inv = join(CLAUDE, "lib", "credential-inventory.sh");
394
+ if (!existsSync(inv)) return null;
395
+ try {
396
+ const args = PROBE ? [inv, "--json", "--probe"] : [inv, "--json"];
397
+ return JSON.parse(
398
+ execFileSync("bash", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
399
+ );
400
+ } catch {
401
+ return null;
402
+ }
403
+ }
404
+
405
+ function checkCredentials(inv) {
406
+ if (!inv) {
407
+ skip("credential-mapping", "credential-inventory.sh did not produce an inventory");
408
+ skip("credential-liveness", "credential-inventory.sh did not produce an inventory");
409
+ return;
410
+ }
411
+ // The INFO / WARN boundary is decided in credential-inventory.sh; this only
412
+ // renders it. Unmapped is a capability the user chose not to enable, which is
413
+ // information. A mapped credential the service refused is a warning, because
414
+ // the configuration made a claim.
415
+ const malformed = (inv.credentials || []).filter((c) => c.state === "malformed");
416
+ const unmapped = (inv.credentials || []).filter((c) => c.state === "unmapped");
417
+ if (malformed.length) {
418
+ report(
419
+ "credential-mapping",
420
+ "WARN",
421
+ `${malformed.length} mapped credential(s) are malformed: ${malformed.map((c) => c.logical).join(", ")}`,
422
+ "run /multi-agent:setup",
423
+ );
424
+ } else if (unmapped.length) {
425
+ report(
426
+ "credential-mapping",
427
+ "INFO",
428
+ `${unmapped.length} optional capabilit${unmapped.length === 1 ? "y is" : "ies are"} not enabled: ${unmapped.map((c) => c.logical).join(", ")}`,
429
+ `map ${unmapped.map((c) => c.logical).join(", ")} with /multi-agent:setup`,
430
+ );
431
+ } else {
432
+ ok("credential-mapping");
433
+ }
434
+
435
+ if (!PROBE) {
436
+ skip("credential-liveness", "no --probe: a network check is the caller's decision to spend");
437
+ return;
438
+ }
439
+ // A rejected credential and an unreachable host need different actions, and
440
+ // credential-inventory.sh already separates them: "unreachable - no response
441
+ // at all - on a corporate host, almost always the VPN". Telling someone to
442
+ // re-onboard a token that was never the problem is the failure mode the
443
+ // registry's own keychain rule warns about, one layer up.
444
+ const rejected = [
445
+ ...(inv.authRejected || []).map((k) => [k, "rejected"]),
446
+ ...(inv.needsGrant || []).map((k) => [k, "no grant"]),
447
+ ];
448
+ const unreachable = (inv.unreachable || []).slice();
449
+ if (rejected.length) {
450
+ report(
451
+ "credential-liveness",
452
+ "WARN",
453
+ `${rejected.length} credential(s) were refused: ${rejected.map(([k, w]) => `${k} (${w})`).join(", ")}`,
454
+ "run /multi-agent:setup",
455
+ );
456
+ return;
457
+ }
458
+ if (unreachable.length) {
459
+ report(
460
+ "credential-liveness",
461
+ "WARN",
462
+ `${unreachable.length} host(s) did not answer at all: ${unreachable.join(", ")}`,
463
+ "run the probe again on the network those hosts are on",
464
+ );
465
+ return;
466
+ }
467
+ ok("credential-liveness");
468
+ }
469
+
470
+ function checkEmbeddedCredentials() {
471
+ // The value never enters a shell variable or this process's output. What is
472
+ // reported is the repo, the config key, the host, a shape label and a length
473
+ // bucket - because `ghp_` plus a length is already a fingerprint, and this
474
+ // line goes to a terminal that is often shared, which is why the check exists.
475
+ const roots = [process.cwd()];
476
+ const found = [];
477
+ for (const root of roots) {
478
+ const cfg = join(root, ".git", "config");
479
+ if (!existsSync(cfg)) continue;
480
+ let text;
481
+ try {
482
+ text = readFileSync(cfg, "utf8");
483
+ } catch {
484
+ continue;
485
+ }
486
+ for (const line of text.split("\n")) {
487
+ const m = line.match(/^\s*(url)\s*=\s*(\S+)\s*$/);
488
+ if (!m) continue;
489
+ const cred = m[2].match(/^https?:\/\/([^@/]+)@([^/]+)/);
490
+ if (!cred) continue;
491
+ const secret = cred[1].includes(":") ? cred[1].split(":")[1] : cred[1];
492
+ if (!secret || secret.length < 8) continue;
493
+ const bucket = secret.length < 40 ? "<40" : secret.length < 100 ? "40-99" : ">=100";
494
+ found.push({
495
+ repo: root,
496
+ key: m[1],
497
+ host: cred[2],
498
+ shape: "userinfo-in-url",
499
+ length: bucket,
500
+ });
501
+ }
502
+ }
503
+ if (found.length) {
504
+ const f = found[0];
505
+ report(
506
+ "embedded-credentials",
507
+ "BLOCK",
508
+ `a credential is embedded in a git remote (${f.repo}, ${f.key}, host ${f.host}, ${f.shape}, length ${f.length})`,
509
+ `revoke that credential at ${f.host}`,
510
+ found,
511
+ );
512
+ return;
513
+ }
514
+ ok("embedded-credentials");
515
+ }
516
+
517
+ function checkTaskTools() {
518
+ if (TASK_TOOLS !== "yes" && TASK_TOOLS !== "no") {
519
+ // A script cannot see the model's tool list. Answering "absent" from here
520
+ // would be the same defect this check is about.
521
+ skip("task-tools", "the caller did not report its tool list (--task-tools=yes|no)");
522
+ return;
523
+ }
524
+ if (TASK_TOOLS === "yes") {
525
+ ok("task-tools");
526
+ return;
527
+ }
528
+ report(
529
+ "task-tools",
530
+ "INFO",
531
+ "this session's model does not carry TaskCreate/TaskUpdate, so the phase widget cannot render",
532
+ "export CLAUDE_CODE_ENABLE_TODO_TOOLS=1 before starting Claude Code",
533
+ );
534
+ }
535
+
536
+ function checkMcpRegistration() {
537
+ // Registered is not the same as working, and the difference is what the user
538
+ // actually hits: a half-extracted package in the npx cache left the server
539
+ // dying on `Cannot find module` at startup, which the client reports only as
540
+ // CONNECTION_CLOSED. A check that reads the registration and stops would have
541
+ // called that healthy. So without --probe this reports what is CONFIGURED and
542
+ // says so; with --probe it starts the server and counts the tools it serves.
543
+ let entry = null;
544
+ for (const p of [join(HOME, ".claude.json"), join(CLAUDE, "settings.json")]) {
545
+ const j = readJson(p);
546
+ const key = j?.mcpServers && Object.keys(j.mcpServers).find((k) => /toolkit/i.test(k));
547
+ if (key) {
548
+ entry = j.mcpServers[key];
549
+ break;
550
+ }
551
+ }
552
+ if (!entry) {
553
+ report(
554
+ "mcp-registration",
555
+ "INFO",
556
+ "multi-agent-toolkit is not registered as an MCP server, so its tools are unavailable",
557
+ "install it with claude mcp add multi-agent-toolkit",
558
+ );
559
+ return;
560
+ }
561
+ if (!PROBE) {
562
+ ok("mcp-registration");
563
+ return;
564
+ }
565
+ const handshake = [
566
+ JSON.stringify({
567
+ jsonrpc: "2.0",
568
+ id: 1,
569
+ method: "initialize",
570
+ params: {
571
+ protocolVersion: "2024-11-05",
572
+ capabilities: {},
573
+ clientInfo: { name: "doctor", version: "1" },
574
+ },
575
+ }),
576
+ JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
577
+ JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
578
+ ].join("\n");
579
+ let served = 0;
580
+ let why = "";
581
+ try {
582
+ const out = execFileSync(entry.command, entry.args || [], {
583
+ input: `${handshake}\n`,
584
+ encoding: "utf8",
585
+ timeout: 240000,
586
+ stdio: ["pipe", "pipe", "pipe"],
587
+ env: { ...process.env, ...(entry.env || {}) },
588
+ });
589
+ for (const line of out.split("\n")) {
590
+ if (!line.trim()) continue;
591
+ try {
592
+ const m = JSON.parse(line);
593
+ if (m.id === 2 && m.result?.tools) served = m.result.tools.length;
594
+ } catch {
595
+ /* the server may print non-JSON lines */
596
+ }
597
+ }
598
+ } catch (e) {
599
+ why =
600
+ String(e.stderr || e.message || "")
601
+ .split("\n")
602
+ .find((l) => /Error|error/.test(l)) || "the server did not start";
603
+ }
604
+ if (served > 0) {
605
+ ok("mcp-registration");
606
+ return;
607
+ }
608
+ report(
609
+ "mcp-registration",
610
+ "WARN",
611
+ `multi-agent-toolkit is registered but served no tools${why ? ` (${why.trim().slice(0, 90)})` : ""}`,
612
+ "remove the stale npx cache entry under ~/.npm/_npx and let it re-download",
613
+ why,
614
+ );
615
+ }
616
+
617
+ function checkDiskSpace() {
618
+ let freeGb = null;
619
+ try {
620
+ const out = execFileSync("df", ["-k", HOME], { encoding: "utf8" });
621
+ const line = out.trim().split("\n").pop();
622
+ const cols = line.split(/\s+/);
623
+ const availKb = Number(cols[3]);
624
+ if (Number.isFinite(availKb)) freeGb = availKb / 1024 / 1024;
625
+ } catch {
626
+ /* df is not everywhere */
627
+ }
628
+ if (freeGb === null) {
629
+ skip("disk-space", "df did not report a usable figure on this host");
630
+ return;
631
+ }
632
+ if (freeGb < 2) {
633
+ report(
634
+ "disk-space",
635
+ "WARN",
636
+ `${freeGb.toFixed(1)} GB free on the volume holding $HOME; a worktree plus a build needs more`,
637
+ "free space before starting a run",
638
+ );
639
+ return;
640
+ }
641
+ ok("disk-space");
642
+ }
643
+
644
+ /* ------------------------------------------------------------------ main -- */
645
+
646
+ function main() {
647
+ if (LIST) {
648
+ for (const id of CHECK_IDS) process.stdout.write(`${id}\n`);
649
+ return;
650
+ }
651
+ const unknown = argv.filter(
652
+ (a) => a.startsWith("--") && !/^--(probe|json|explain|list-checks|task-tools=)/.test(a),
653
+ );
654
+ if (unknown.length) {
655
+ process.stderr.write(
656
+ `usage: doctor.mjs [--probe] [--json] [--explain] [--task-tools=yes|no] | --list-checks\n`,
657
+ );
658
+ process.exitCode = 3;
659
+ return;
660
+ }
661
+ if (taskFlag && TASK_TOOLS !== "yes" && TASK_TOOLS !== "no") {
662
+ process.stderr.write("usage: --task-tools takes yes or no\n");
663
+ process.exitCode = 3;
664
+ return;
665
+ }
666
+
667
+ const resolved = checkInstallPresent();
668
+ if (!resolved) {
669
+ // Indeterminate, not healthy and not merely degraded: nothing after this
670
+ // could be checked, and saying "blocked" would claim a verdict on checks
671
+ // that never ran.
672
+ const line = results[0];
673
+ if (JSON_OUT) {
674
+ process.stdout.write(`${JSON.stringify({ exit: 4, checks: results }, null, 2)}\n`);
675
+ } else {
676
+ process.stdout.write(`${line.severity} ${line.id} - ${line.problem} - ${line.step}\n`);
677
+ process.stdout.write(
678
+ "\n-> indeterminate: the layout did not resolve, so 13 checks did not run\n",
679
+ );
680
+ }
681
+ process.exitCode = 4;
682
+ return;
683
+ }
684
+
685
+ checkInstallVersion();
686
+ checkScriptSurface();
687
+ checkSkillSiblings();
688
+ checkStateWritable();
689
+ checkPrefsValid();
690
+ checkIdentity();
691
+ checkHookCoverage();
692
+ checkCredentials(credentialInventory());
693
+ checkEmbeddedCredentials();
694
+ checkTaskTools();
695
+ checkMcpRegistration();
696
+ checkDiskSpace();
697
+
698
+ const blocked = results.filter((r) => r.severity === "BLOCK");
699
+ const warned = results.filter((r) => r.severity === "WARN");
700
+ const skipped = results.filter((r) => r.severity === "SKIP");
701
+ const code = blocked.length ? 2 : warned.length ? 1 : 0;
702
+
703
+ if (JSON_OUT) {
704
+ process.stdout.write(`${JSON.stringify({ exit: code, checks: results }, null, 2)}\n`);
705
+ process.exitCode = code;
706
+ return;
707
+ }
708
+
709
+ // Every check prints, in registry order, including the ones that found
710
+ // nothing and the ones that were not run. A list that shows only problems
711
+ // cannot be told apart from a list that was never produced.
712
+ for (const id of CHECK_IDS) {
713
+ const r = results.find((x) => x.id === id);
714
+ if (!r) continue;
715
+ if (r.severity === "OK") {
716
+ process.stdout.write(`OK ${id}\n`);
717
+ } else if (r.severity === "SKIP") {
718
+ process.stdout.write(`SKIP ${id} - ${r.problem}\n`);
719
+ } else {
720
+ process.stdout.write(`${r.severity.padEnd(5)} ${id} - ${r.problem} - ${r.step}\n`);
721
+ }
722
+ }
723
+ const verdict = code === 0 ? "healthy" : code === 1 ? "degraded" : "blocked";
724
+ process.stdout.write(
725
+ `\n-> ${verdict}: ${blocked.length} blocking, ${warned.length} warning(s), ${skipped.length} not checked, ${results.length} checks\n`,
726
+ );
727
+ if (EXPLAIN) {
728
+ for (const r of results.filter((x) => x.detail)) {
729
+ process.stdout.write(`\n${r.id}:\n`);
730
+ for (const d of Array.isArray(r.detail) ? r.detail : [r.detail]) {
731
+ process.stdout.write(` ${typeof d === "string" ? d : JSON.stringify(d)}\n`);
732
+ }
733
+ }
734
+ }
735
+ process.exitCode = code;
736
+ }
737
+
738
+ // Exported so the gate can DRIVE the engine instead of reading it. The BLOCK
739
+ // downgrade used to be asserted with a regex over this file's own source, which
740
+ // a comment carrying the same two substrings satisfied just as well as the code
741
+ // did - the enforcement could be deleted outright and the gate stayed green.
742
+ export { report, results as __results, MAY_BLOCK as __mayBlock };
743
+
744
+ // realpath on both sides: `import.meta.url` is already resolved, while argv[1]
745
+ // is whatever the caller typed. On macOS a run out of /tmp (a symlink to
746
+ // /private/tmp) would otherwise never match, and the script would do nothing.
747
+ function invokedDirectly() {
748
+ if (!process.argv[1]) return false;
749
+ try {
750
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
751
+ } catch {
752
+ return false;
753
+ }
754
+ }
755
+
756
+ if (invokedDirectly()) {
757
+ main();
758
+ }