@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.
- package/CHANGELOG.md +119 -2
- package/README.md +4 -4
- package/README.tr.md +3 -3
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/features.md +14 -0
- package/install/claude.mjs +17 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
- package/pipeline/commands/multi-agent/design-check/SKILL.md +6 -5
- package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +15 -12
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
- package/pipeline/lib/_jira-auth.sh +99 -0
- package/pipeline/lib/analysis-jira-write.sh +203 -0
- package/pipeline/lib/issue-fetcher.sh +4 -4
- package/pipeline/multi-agent-refs/analysis/render.md +1 -1
- package/pipeline/multi-agent-refs/channels/pr.md +37 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
- package/pipeline/multi-agent-refs/features/doctor.md +197 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
- package/pipeline/multi-agent-refs/features/visual-evidence.md +103 -20
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +38 -7
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +13 -1
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +11 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +23 -0
- package/pipeline/multi-agent-refs/picker-contract.md +35 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
- package/pipeline/preferences-template.json +1 -1
- package/pipeline/schemas/agent-state.schema.json +84 -1
- package/pipeline/schemas/analysis-spec.schema.json +336 -95
- package/pipeline/schemas/prefs.schema.json +80 -3
- package/pipeline/schemas/token-budget.json +10 -10
- package/pipeline/scripts/analysis-story-tree.mjs +441 -0
- package/pipeline/scripts/capture-evidence.sh +170 -5
- package/pipeline/scripts/doctor.mjs +758 -0
- package/pipeline/scripts/evidence-gate.mjs +31 -2
- package/pipeline/scripts/phase-tracker.sh +97 -17
- package/pipeline/scripts/probe-evidence-capability.sh +250 -0
- package/pipeline/scripts/run-ui-tests.sh +380 -0
- package/pipeline/scripts/scan-agent-config.sh +48 -10
- package/pipeline/scripts/skill-siblings.mjs +1 -1
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
- package/pipeline/skills/shared/core/multi-agent-manual-test/SKILL.md +10 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
- 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
|
+
}
|