clembot-doorman 0.1.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +951 -0
  4. package/WALKTHROUGH.md +224 -0
  5. package/doorman/.claude/hooks/mcp-gate.sh +205 -0
  6. package/doorman/.claude/settings.json +16 -0
  7. package/doorman/.claude-plugin/plugin.json +22 -0
  8. package/doorman/.mcp.json +24 -0
  9. package/doorman/README.md +259 -0
  10. package/doorman/agents/doorman.md +104 -0
  11. package/doorman/cli/agents.mjs +128 -0
  12. package/doorman/cli/allow.mjs +128 -0
  13. package/doorman/cli/cost.mjs +119 -0
  14. package/doorman/cli/discover.mjs +265 -0
  15. package/doorman/cli/doctor.mjs +282 -0
  16. package/doorman/cli/doorman.mjs +345 -0
  17. package/doorman/cli/eval.mjs +320 -0
  18. package/doorman/cli/harness.mjs +179 -0
  19. package/doorman/cli/install.mjs +175 -0
  20. package/doorman/cli/needs.mjs +116 -0
  21. package/doorman/cli/report.mjs +89 -0
  22. package/doorman/cli/sandbox.mjs +177 -0
  23. package/doorman/cli/task.mjs +239 -0
  24. package/doorman/cli/verdict.mjs +199 -0
  25. package/doorman/cli/watch.mjs +218 -0
  26. package/doorman/commands/doorman.md +116 -0
  27. package/doorman/commands/vet.md +69 -0
  28. package/doorman/hooks/hooks.json +30 -0
  29. package/doorman/install.sh +186 -0
  30. package/doorman/package.json +38 -0
  31. package/doorman/recipes/README.md +36 -0
  32. package/doorman/recipes/deepwiki.md +10 -0
  33. package/doorman/recipes/planted-bad.md +27 -0
  34. package/doorman/recipes/scorecard.md +10 -0
  35. package/doorman/registry/allowlist.json +37 -0
  36. package/doorman/registry/denylist.json +23 -0
  37. package/doorman/registry/ledger.jsonl +1 -0
  38. package/doorman/scripts/poller.mjs +292 -0
  39. package/doorman/scripts/resolve-cli.sh +58 -0
  40. package/doorman/scripts/vet.mjs +190 -0
  41. package/doorman/skills/doorman-guide/SKILL.md +69 -0
  42. package/doorman/src/budget.mjs +236 -0
  43. package/doorman/src/candidate.mjs +132 -0
  44. package/doorman/src/fit-review.mjs +255 -0
  45. package/doorman/src/injection.mjs +189 -0
  46. package/doorman/src/instructions.mjs +134 -0
  47. package/doorman/src/inventory.mjs +411 -0
  48. package/doorman/src/llm.mjs +87 -0
  49. package/doorman/src/needs.mjs +491 -0
  50. package/doorman/src/note.mjs +213 -0
  51. package/doorman/src/reviews.mjs +120 -0
  52. package/doorman/src/scorecard.mjs +123 -0
  53. package/doorman/src/vet.mjs +174 -0
  54. package/package.json +54 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * What an eval can cost, before it costs it.
3
+ *
4
+ * The first version of this harness had `MAX_TURNS = 24` and nothing else. That
5
+ * bounds TURNS, not tokens, and the two are not the same by a wide margin,
6
+ * because an agent loop resends the whole conversation every turn. Cost grows
7
+ * with the SQUARE of the turn count, not linearly, and a 24-turn cap quietly
8
+ * authorises far more spend than it looks like it does.
9
+ *
10
+ * Worked, for one run at the defaults:
11
+ *
12
+ * input at turn k = prompt + (k-1) x (max_tokens + tool_result_cap)
13
+ * total input = T x prompt + (max_tokens + cap) x T(T-1)/2
14
+ * T=24, cap=5000 = 24 x 1000 + 9096 x 276 = ~2.53M input tokens
15
+ * total output = 24 x 4096 = ~98k
16
+ *
17
+ * On Sonnet that is about **$9 for ONE run**, so a 3-run A/B is six runs and
18
+ * roughly **$54**. On Haiku, about $18. Those are the numbers that justify a
19
+ * ceiling rather than a promise to be careful.
20
+ *
21
+ * Typical is far lower, because most runs end in three to six turns. But
22
+ * "typical" is not what you need a limit for.
23
+ *
24
+ * The ceiling reuses `src/budget.mjs`, the same permit machinery the doorman
25
+ * already uses to stop itself overspending outward. Same rule pointed at our
26
+ * own bill: reserve the WORST case before the run, settle the actual after.
27
+ * Reserving the expected cost would be a limit that only holds when nothing
28
+ * goes wrong, which is the opposite of a limit.
29
+ */
30
+
31
+ /** USD per million tokens. A model absent here has no price and cannot be capped. */
32
+ export const PRICES = {
33
+ 'claude-opus-5': { in: 15, out: 75 },
34
+ 'claude-sonnet-5': { in: 3, out: 15 },
35
+ 'claude-haiku-4-5-20251001': { in: 1, out: 5 },
36
+ // Local models are free at the margin. Not zero-effort, but zero-billing.
37
+ 'ollama': { in: 0, out: 0 },
38
+ };
39
+
40
+ export const DEFAULTS = {
41
+ maxTurns: 24,
42
+ maxTokens: 4096,
43
+ toolResultCap: 5000, // 20000 chars, roughly
44
+ promptTokens: 1000,
45
+ };
46
+
47
+ export function priceFor(model) {
48
+ if (PRICES[model]) return PRICES[model];
49
+ if (String(model).startsWith('ollama/')) return PRICES.ollama;
50
+ return null;
51
+ }
52
+
53
+ /** The most one run can possibly cost. Used for the reservation, not for display alone. */
54
+ export function worstCaseRunUsd(model, o = {}) {
55
+ const p = priceFor(model);
56
+ if (!p) return null;
57
+ const { maxTurns, maxTokens, toolResultCap, promptTokens } = { ...DEFAULTS, ...o };
58
+ const perTurnGrowth = maxTokens + toolResultCap;
59
+ const inputTokens = maxTurns * promptTokens + perTurnGrowth * (maxTurns * (maxTurns - 1)) / 2;
60
+ const outputTokens = maxTurns * maxTokens;
61
+ return round4((inputTokens / 1e6) * p.in + (outputTokens / 1e6) * p.out);
62
+ }
63
+
64
+ /**
65
+ * A rough typical run, for the estimate line only.
66
+ *
67
+ * Labelled clearly wherever it is shown. It is an expectation, and an
68
+ * expectation is not a bound: nothing is ever reserved against this number.
69
+ */
70
+ export function typicalRunUsd(model, o = {}) {
71
+ return worstCaseRunUsd(model, { ...o, maxTurns: 5 });
72
+ }
73
+
74
+ export function estimateEval({ model, runs, ...o }) {
75
+ const perRunWorst = worstCaseRunUsd(model, o);
76
+ const perRunTypical = typicalRunUsd(model, o);
77
+ const totalRuns = runs * 2; // two arms
78
+ return {
79
+ model,
80
+ runs_per_arm: runs,
81
+ total_runs: totalRuns,
82
+ priced: perRunWorst !== null,
83
+ per_run_worst_usd: perRunWorst,
84
+ per_run_typical_usd: perRunTypical,
85
+ worst_case_usd: perRunWorst === null ? null : round4(perRunWorst * totalRuns),
86
+ typical_usd: perRunTypical === null ? null : round4(perRunTypical * totalRuns),
87
+ };
88
+ }
89
+
90
+ function round4(n) { return Math.round(n * 1e4) / 1e4; }
91
+
92
+ /**
93
+ * Render the estimate for a human about to spend money.
94
+ *
95
+ * Leads with the worst case, because that is the number the decision needs. A
96
+ * cost warning that leads with the typical figure is an advert.
97
+ */
98
+ export function renderEstimate(e) {
99
+ if (!e.priced) {
100
+ return [
101
+ `Model "${e.model}" has no price in the table, so no ceiling can be enforced.`,
102
+ 'Refusing to guess: an unknown price is not a free one.',
103
+ 'Add it to PRICES in cli/cost.mjs. A local-model backend is designed but not built.',
104
+ ].join('\n');
105
+ }
106
+ if (e.worst_case_usd === 0) {
107
+ return `Local model (${e.model}): $0.00. Nothing is billed. ${e.total_runs} run(s) total.`;
108
+ }
109
+ return [
110
+ `Model ${e.model} · ${e.runs_per_arm} run(s) per arm · ${e.total_runs} runs total`,
111
+ '',
112
+ ` WORST CASE $${e.worst_case_usd.toFixed(2)} ($${e.per_run_worst_usd.toFixed(2)} per run)`,
113
+ ` typical $${e.typical_usd.toFixed(2)} ($${e.per_run_typical_usd.toFixed(2)} per run)`,
114
+ '',
115
+ 'The worst case is what gets reserved before each run. It assumes every run',
116
+ 'hits the turn cap with a full tool result every turn. Most do not, and the',
117
+ 'unused reservation is released, so you are billed the actual, not the bound.',
118
+ ].join('\n');
119
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * `doorman discover` - find candidates in a public MCP directory.
3
+ *
4
+ * MACHINES FETCH, HUMANS CURATE. This writes a candidate file and stops. It
5
+ * never enqueues an audit, never spends, and never adds anything to a feed.
6
+ * 13,648 entries you have not looked at is not a feed, it is a backlog, and
7
+ * auto-promoting a directory into a grading queue would put the operator's name
8
+ * on verdicts nobody chose to seek.
9
+ *
10
+ * WHAT IT CAN AND CANNOT DO, and the distinction is the whole file:
11
+ *
12
+ * The Smithery registry publishes each server's TOOL DESCRIPTIONS in its detail
13
+ * record. So the static scan, the one that found instruction-shaped content in
14
+ * a shipping product, can run over the directory's own published text without
15
+ * calling a single server, without auth, and without spending anything.
16
+ *
17
+ * That is a SCAN, not a GRADE. A grade requires driving the server: does the
18
+ * agent succeed, does it recover, does it get steered. The registry cannot tell
19
+ * you that and neither can this command, so it reports `graded: false` on every
20
+ * row and never emits a letter. This is `mayBeGraded()` applied to a third
21
+ * source: when there is nothing to drive, say what was not measured rather than
22
+ * leaving a null for a reader to fill in wrongly.
23
+ *
24
+ * WHY IT DOES NOT RESOLVE AN ORIGIN. The registry only ever returns its own
25
+ * proxy (`<name>.run.tools`), which answers 401 without a Smithery token. The
26
+ * origin endpoint is not in the record. So a candidate here carries the
27
+ * registry identity and needs a human to supply the real endpoint before it can
28
+ * be graded. Pretending the proxy url is the server would grade the proxy.
29
+ */
30
+
31
+ import { writeFileSync, mkdirSync } from 'node:fs';
32
+ import { dirname } from 'node:path';
33
+ import { sniffInstructions } from '../src/injection.mjs';
34
+
35
+ export const SMITHERY_API = 'https://registry.smithery.ai';
36
+ export const PAGE_SIZE = 100;
37
+ export const UA = 'clembot-doorman/0.1 (+https://clembot-doorman.wanessalabs.com)';
38
+
39
+ /** The proxy host the registry hands out instead of an origin. */
40
+ export const PROXY_SUFFIX = '.run.tools';
41
+
42
+ /**
43
+ * Is this url something doorman could actually grade?
44
+ *
45
+ * The registry's deploymentUrl is a proxy that requires a Smithery token, so it
46
+ * is NOT a gradeable endpoint even though it is a valid https url. Saying so
47
+ * per row is the difference between a candidate list and a list of things that
48
+ * will 401 when somebody tries.
49
+ */
50
+ export function isGradeableEndpoint(url) {
51
+ if (!url) return false;
52
+ try {
53
+ const u = new URL(url);
54
+ if (u.protocol !== 'https:') return false;
55
+ return !u.hostname.endsWith(PROXY_SUFFIX);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Everything a scan can see in one registry record.
63
+ *
64
+ * Tool descriptions are concatenated with their names, because a name can carry
65
+ * steering too and scanning descriptions alone would miss it.
66
+ */
67
+ export function scannableText(detail) {
68
+ const parts = [];
69
+ if (detail.description) parts.push(String(detail.description));
70
+ for (const t of detail.tools ?? []) {
71
+ if (t?.name) parts.push(String(t.name));
72
+ if (t?.description) parts.push(String(t.description));
73
+ }
74
+ return parts.join('\n\n');
75
+ }
76
+
77
+ export function assess(listRow, detail) {
78
+ const text = scannableText(detail);
79
+ const scan = sniffInstructions(text, 'registry:tool-descriptions');
80
+ const endpoint = detail.deploymentUrl ?? (detail.connections ?? [])[0]?.deploymentUrl ?? null;
81
+
82
+ return {
83
+ source: 'smithery',
84
+ id: listRow.qualifiedName ?? detail.qualifiedName,
85
+ name: detail.displayName ?? listRow.displayName ?? null,
86
+ registry_url: `${SMITHERY_API}/servers/${encodeURIComponent(listRow.qualifiedName)}`,
87
+ // What the registry hands out, and whether it is any use for grading.
88
+ registry_endpoint: endpoint,
89
+ endpoint_is_proxy: endpoint ? !isGradeableEndpoint(endpoint) : null,
90
+ gradeable_endpoint: isGradeableEndpoint(endpoint) ? endpoint : null,
91
+ homepage: listRow.homepage ?? null,
92
+ verified: Boolean(listRow.verified),
93
+ use_count: listRow.useCount ?? null,
94
+ tools: (detail.tools ?? []).length,
95
+ // The registry's own words about what this does, kept so `doorman needs`
96
+ // can match it against a build's needs and CITE the text that matched.
97
+ // Capped because a sweep writes thousands of these and the file is read by
98
+ // a human. Tool NAMES only: the descriptions are already scanned above and
99
+ // storing them twice would double a candidate file for no new signal.
100
+ description: detail.description ? String(detail.description).slice(0, 400) : null,
101
+ tool_names: (detail.tools ?? []).map((t) => t?.name).filter(Boolean).slice(0, 40),
102
+ // The registry has a security field. On everything sampled it is null.
103
+ registry_security: detail.security ?? null,
104
+ scan: {
105
+ scanned_chars: scan.scanned_chars,
106
+ hard: scan.hard,
107
+ steering: scan.steering,
108
+ failure_modes: scan.failure_modes,
109
+ },
110
+ // Never a letter. Nothing here drove the server.
111
+ graded: false,
112
+ grade: null,
113
+ behavioral: 'n/a - nothing was driven, this is a scan of published text',
114
+ };
115
+ }
116
+
117
+ /** One page of the registry listing. */
118
+ export async function fetchPage(page, { fetchImpl = fetch, api = SMITHERY_API } = {}) {
119
+ const r = await fetchImpl(`${api}/servers?pageSize=${PAGE_SIZE}&page=${page}`, {
120
+ headers: { 'user-agent': UA, accept: 'application/json' },
121
+ });
122
+ if (!r.ok) {
123
+ const e = new Error(`registry listing returned HTTP ${r.status} on page ${page}`);
124
+ e.code = 3;
125
+ throw e;
126
+ }
127
+ return r.json();
128
+ }
129
+
130
+ export async function fetchDetail(qualifiedName, { fetchImpl = fetch, api = SMITHERY_API } = {}) {
131
+ const r = await fetchImpl(`${api}/servers/${encodeURIComponent(qualifiedName)}`, {
132
+ headers: { 'user-agent': UA, accept: 'application/json' },
133
+ });
134
+ if (!r.ok) return null; // one bad record must not stop a sweep
135
+ return r.json();
136
+ }
137
+
138
+ /** Servers already graded, so a sweep does not re-propose them. Free to read. */
139
+ export async function alreadyGraded(feedApi, { fetchImpl = fetch } = {}) {
140
+ const known = new Set();
141
+ if (!feedApi) return known;
142
+ try {
143
+ const r = await fetchImpl(`${feedApi.replace(/\/+$/, '')}/feed?limit=200`, {
144
+ headers: { accept: 'application/json' },
145
+ });
146
+ if (!r.ok) return known;
147
+ const j = await r.json();
148
+ for (const c of j.candidates ?? []) {
149
+ known.add(String(c.server_url).toLowerCase());
150
+ if (c.server_name) known.add(String(c.server_name).toLowerCase());
151
+ }
152
+ } catch { /* a discovery run must not fail because the feed is down */ }
153
+ return known;
154
+ }
155
+
156
+ export async function discover({
157
+ pages = 1,
158
+ feedApi = null,
159
+ fetchImpl = fetch,
160
+ api = SMITHERY_API,
161
+ onProgress = () => {},
162
+ } = {}) {
163
+ const known = await alreadyGraded(feedApi, { fetchImpl });
164
+ const rows = [];
165
+ let listed = 0;
166
+ let detailFailed = 0;
167
+ let totalCount = null;
168
+
169
+ for (let page = 1; page <= pages; page++) {
170
+ const j = await fetchPage(page, { fetchImpl, api });
171
+ totalCount = j.pagination?.totalCount ?? totalCount;
172
+ const servers = j.servers ?? [];
173
+ if (!servers.length) break;
174
+
175
+ for (const s of servers) {
176
+ listed++;
177
+ const detail = await fetchDetail(s.qualifiedName, { fetchImpl, api });
178
+ if (!detail) { detailFailed++; continue; }
179
+ const row = assess(s, detail);
180
+ row.already_graded = known.has(String(row.id).toLowerCase()) ||
181
+ (row.name ? known.has(String(row.name).toLowerCase()) : false);
182
+ rows.push(row);
183
+ onProgress(listed, rows.length);
184
+ }
185
+ if (j.pagination && page >= (j.pagination.totalPages ?? page)) break;
186
+ }
187
+
188
+ const flagged = rows.filter((r) => r.scan.hard > 0 || r.scan.steering > 0);
189
+ return {
190
+ source: 'smithery',
191
+ swept_at: new Date().toISOString(),
192
+ registry_total: totalCount,
193
+ listed,
194
+ assessed: rows.length,
195
+ detail_fetch_failed: detailFailed,
196
+ // The numbers that decide whether this is worth a human's time.
197
+ with_gradeable_endpoint: rows.filter((r) => r.gradeable_endpoint).length,
198
+ already_graded: rows.filter((r) => r.already_graded).length,
199
+ flagged_by_scan: flagged.length,
200
+ hard_hits: rows.reduce((n, r) => n + r.scan.hard, 0),
201
+ steering_hits: rows.reduce((n, r) => n + r.scan.steering, 0),
202
+ registry_security_present: rows.filter((r) => r.registry_security != null).length,
203
+ candidates: rows,
204
+ };
205
+ }
206
+
207
+ export function writeCandidates(file, result) {
208
+ mkdirSync(dirname(file), { recursive: true });
209
+ writeFileSync(file, JSON.stringify(result, null, 2) + '\n', 'utf8');
210
+ }
211
+
212
+ export function renderDiscover(r, file) {
213
+ const out = [];
214
+ out.push(`doorman discover ${r.source}`);
215
+ out.push(` registry holds ${r.registry_total ?? 'unknown'} servers`);
216
+ out.push(` swept ${r.listed} listed, ${r.assessed} assessed` +
217
+ (r.detail_fetch_failed ? `, ${r.detail_fetch_failed} detail fetches failed` : ''));
218
+ out.push('');
219
+ out.push(` already graded by this scorecard ${r.already_graded}`);
220
+ out.push(` with a gradeable endpoint ${r.with_gradeable_endpoint} of ${r.assessed}`);
221
+ out.push(` registry security field populated ${r.registry_security_present} of ${r.assessed}`);
222
+ out.push('');
223
+ out.push(` NEEDS A HUMAN LOOK ${r.flagged_by_scan}` +
224
+ ` (${r.hard_hits} injection-shaped, ${r.steering_hits} commercial steering)`);
225
+ out.push('');
226
+ out.push(' These are candidates for review, NOT findings, and the difference');
227
+ out.push(' is not pedantry. The first sweep of 100 servers flagged fifteen');
228
+ out.push(' and TWO survived a hand check: a Slack parameter that posts a');
229
+ out.push(' reply to a conversation, an LLM testing tool whose job is to');
230
+ out.push(' accept a system prompt, "system:" as a docstring parameter name,');
231
+ out.push(' and five vendors saying "use this instead of" about another tool');
232
+ out.push(' in their OWN server. Five patterns were tightened on 2026-09-10');
233
+ out.push(' and the same sweep now flags those two and nothing else.');
234
+ out.push('');
235
+ out.push(' That is thirteen strings, not a directory. Zero false positives on');
236
+ out.push(' a corpus that small means the KNOWN failure modes are fixed, not');
237
+ out.push(' that the next hundred servers hold none. Read the excerpt before');
238
+ out.push(' repeating any of it. See test/discover-precision.test.mjs.');
239
+
240
+ const flagged = r.candidates.filter((c) => c.scan.hard || c.scan.steering)
241
+ .sort((a, b) => (b.use_count ?? 0) - (a.use_count ?? 0));
242
+ if (flagged.length) {
243
+ out.push('');
244
+ for (const c of flagged.slice(0, 12)) {
245
+ const marks = [c.scan.hard ? `${c.scan.hard} hard` : null,
246
+ c.scan.steering ? `${c.scan.steering} steering` : null].filter(Boolean).join(', ');
247
+ out.push(` ${c.verified ? '*' : ' '} ${String(c.use_count ?? 0).padStart(7)} uses ${c.id}`);
248
+ out.push(` ${marks}${c.tools ? `, ${c.tools} tools` : ''}`);
249
+ if (c.scan.failure_modes[0]) out.push(` ${c.scan.failure_modes[0].slice(0, 118)}`);
250
+ }
251
+ if (flagged.length > 12) out.push(` ... and ${flagged.length - 12} more in the file`);
252
+ }
253
+
254
+ out.push('');
255
+ out.push(` written to ${file}`);
256
+ out.push('');
257
+ out.push('THIS IS A SCAN, NOT A GRADE. Nothing was driven and no letter was');
258
+ out.push('assigned. It reads the tool descriptions the registry itself');
259
+ out.push('publishes, which is the same surface an agent reads before deciding');
260
+ out.push('what to call. A grade needs the origin endpoint, and the registry');
261
+ out.push('returns only its own proxy, which answers 401 without its token.');
262
+ out.push('');
263
+ out.push('Nothing has been queued and nothing has been spent. Curate the file.');
264
+ return out.join('\n');
265
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `doorman doctor` — L0. What is actually in YOUR build.
3
+ *
4
+ * This is the layer that costs nobody anything. No model, no container, no
5
+ * network: it reads the project in front of it and reports what an agent in
6
+ * that project can currently reach.
7
+ *
8
+ * It exists because the question "is this tool worth adopting" has no universal
9
+ * answer. It depends on which harness you run, which model, which servers are
10
+ * already installed, and what your agents actually do. A benchmark run on
11
+ * somebody else's stack answers their question, not yours. So doorman looks at
12
+ * yours first, and every later layer is scoped to what it finds here.
13
+ *
14
+ * Everything below is read-only. It opens config files, never writes one, and
15
+ * never sends what it read anywhere.
16
+ */
17
+
18
+ import { readFile, readdir, stat } from 'node:fs/promises';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ const read = async (p) => {
23
+ try { return await readFile(p, 'utf8'); } catch { return null; }
24
+ };
25
+ const readJson = async (p) => {
26
+ const t = await read(p);
27
+ if (t === null) return null;
28
+ try { return JSON.parse(t); } catch { return { __unparseable: true }; }
29
+ };
30
+
31
+ /**
32
+ * Which agent harness is this project set up for.
33
+ *
34
+ * Reported as evidence, not as a guess: each hit names the file that proved it,
35
+ * so a wrong answer can be argued with.
36
+ */
37
+ async function detectHarness(root) {
38
+ const found = [];
39
+ const probe = async (rel, name, note) => {
40
+ if (existsSync(path.join(root, rel))) found.push({ harness: name, evidence: rel, note });
41
+ };
42
+ await probe('.claude', 'Claude Code', 'project-scoped agents, commands, hooks');
43
+ await probe('CLAUDE.md', 'Claude Code', 'project instructions');
44
+ await probe('AGENTS.md', 'Codex / AGENTS.md convention', 'project instructions');
45
+ await probe('.cursor', 'Cursor', 'editor-integrated agent');
46
+ await probe('.windsurf', 'Windsurf', 'editor-integrated agent');
47
+ await probe('.github/copilot-instructions.md', 'GitHub Copilot', 'repo instructions');
48
+ await probe('.gemini', 'Gemini CLI', 'project config');
49
+
50
+ // Deduplicate by harness, keeping every piece of evidence.
51
+ const byName = new Map();
52
+ for (const f of found) {
53
+ if (!byName.has(f.harness)) byName.set(f.harness, { harness: f.harness, evidence: [], note: f.note });
54
+ byName.get(f.harness).evidence.push(f.evidence);
55
+ }
56
+ return [...byName.values()];
57
+ }
58
+
59
+ /** Every MCP server this project can reach, and where that was declared. */
60
+ async function detectMcpServers(root) {
61
+ const servers = [];
62
+ const sources = [
63
+ '.mcp.json',
64
+ '.claude/settings.json',
65
+ '.claude/settings.local.json',
66
+ '.cursor/mcp.json',
67
+ '.vscode/mcp.json',
68
+ ];
69
+ for (const rel of sources) {
70
+ const j = await readJson(path.join(root, rel));
71
+ if (!j) continue;
72
+ if (j.__unparseable) {
73
+ servers.push({ name: '(unreadable)', source: rel, transport: null, target: null,
74
+ note: 'file exists but is not valid JSON' });
75
+ continue;
76
+ }
77
+ const block = j.mcpServers || j.servers || {};
78
+ for (const [name, cfg] of Object.entries(block)) {
79
+ servers.push({
80
+ name,
81
+ source: rel,
82
+ transport: cfg?.type || (cfg?.command ? 'stdio' : cfg?.url ? 'http' : 'unknown'),
83
+ target: cfg?.url || cfg?.command || null,
84
+ args: Array.isArray(cfg?.args) ? cfg.args.length : 0,
85
+ });
86
+ }
87
+ }
88
+ return servers;
89
+ }
90
+
91
+ /** Subagents, and which of them hold MCP tools. That ratio is the exposure. */
92
+ async function detectAgents(root) {
93
+ const dir = path.join(root, '.claude', 'agents');
94
+ if (!existsSync(dir)) return { count: 0, withMcp: [], dir: null };
95
+ let files = [];
96
+ try { files = (await readdir(dir)).filter((f) => f.endsWith('.md')); } catch { return { count: 0, withMcp: [], dir }; }
97
+ const withMcp = [];
98
+ for (const f of files) {
99
+ const text = (await read(path.join(dir, f))) || '';
100
+ // Frontmatter `tools:` naming an mcp__ tool is the thing worth counting.
101
+ const hits = text.match(/mcp__[a-zA-Z0-9_-]+/g);
102
+ if (hits) withMcp.push({ agent: f.replace(/\.md$/, ''), tools: [...new Set(hits)].length });
103
+ }
104
+ return { count: files.length, withMcp, dir: path.join('.claude', 'agents') };
105
+ }
106
+
107
+ /**
108
+ * Is the doorman gate installed and wired, or installed and inert?
109
+ *
110
+ * THERE ARE TWO WAYS TO INSTALL IT NOW, and this used to see only one. The
111
+ * plugin route puts the gate in the plugin directory and wires it through the
112
+ * plugin's own hooks.json, so a plugin user got told "not installed" by the one
113
+ * command whose entire job is answering that question, while the gate was
114
+ * actively blocking their calls. Reporting a control as absent when it is
115
+ * running is the same failure class as reporting it present when it is not.
116
+ *
117
+ * Project install is still reported first, because it is the one the operator
118
+ * controls per project. The plugin is reported as a second, separate source.
119
+ */
120
+ async function detectGate(root, { env = process.env } = {}) {
121
+ const hook = path.join(root, '.claude', 'hooks', 'mcp-gate.sh');
122
+ const installed = existsSync(hook);
123
+ const settings = await read(path.join(root, '.claude', 'settings.json'));
124
+ const wired = Boolean(settings && settings.includes('mcp-gate.sh'));
125
+ const registry = existsSync(path.join(root, 'registry', 'allowlist.json'));
126
+
127
+ // A user-scope plugin gates every project, so its absence from THIS project
128
+ // says nothing. Detected by looking for an installed plugin that ships the
129
+ // hook, not by asking the harness, so this stays offline and dependency-free.
130
+ const plugin = detectPluginGate(env);
131
+
132
+ const anyGate = installed || plugin.present;
133
+ return {
134
+ installed,
135
+ wired,
136
+ registry,
137
+ plugin,
138
+ // The distinction that matters: a gate that is present and not wired is a
139
+ // gate that is not running, and it looks exactly like one that is.
140
+ verdict: !anyGate ? 'not installed'
141
+ : !installed && plugin.present ? `installed as a PLUGIN (${plugin.name}), wired by the plugin`
142
+ : !wired ? 'INSTALLED BUT NOT RUNNING (no hook entry in settings.json)'
143
+ : !registry && !plugin.present ? 'wired, but no registry/allowlist.json: it will block everything'
144
+ : 'installed and wired',
145
+ };
146
+ }
147
+
148
+ /** An installed Claude Code plugin that ships mcp-gate.sh. Read-only. */
149
+ function detectPluginGate(env) {
150
+ const home = env.USERPROFILE || env.HOME;
151
+ if (!home) return { present: false, why: 'no home directory in the environment' };
152
+ const record = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
153
+ if (!existsSync(record)) return { present: false, why: 'no installed_plugins.json' };
154
+ let raw;
155
+ try { raw = JSON.parse(readFileSync(record, 'utf8')); } catch {
156
+ return { present: false, why: 'installed_plugins.json did not parse' };
157
+ }
158
+ // The shape of that file is the harness's business and it has changed before,
159
+ // so match on content rather than on a path into it.
160
+ const text = JSON.stringify(raw);
161
+ const named = /"(clembot-doorman[^"]*)"/.exec(text);
162
+ if (!named) return { present: false, why: 'no clembot-doorman plugin installed' };
163
+ return { present: true, name: named[1], record };
164
+ }
165
+
166
+ /** What an eval could drive. Presence only: nothing is executed. */
167
+ async function detectAgentRunners(root) {
168
+ const out = [];
169
+ const pkg = await readJson(path.join(root, 'package.json'));
170
+ if (pkg && !pkg.__unparseable) {
171
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
172
+ for (const d of Object.keys(deps)) {
173
+ if (/claude|anthropic|openai|langchain|langgraph|crewai|agent/i.test(d)) {
174
+ out.push({ kind: 'dependency', name: d, version: deps[d] });
175
+ }
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+ export async function doctor(root, opts = {}) {
182
+ const abs = path.resolve(root);
183
+ let ok = true;
184
+ try { ok = (await stat(abs)).isDirectory(); } catch { ok = false; }
185
+ if (!ok) return { ok: false, why: `not a directory: ${abs}` };
186
+
187
+ const [harnesses, servers, agents, gate, runners] = await Promise.all([
188
+ detectHarness(abs), detectMcpServers(abs), detectAgents(abs), detectGate(abs, opts), detectAgentRunners(abs),
189
+ ]);
190
+ return { ok: true, root: abs, harnesses, servers, agents, gate, runners };
191
+ }
192
+
193
+ export function renderDoctor(d) {
194
+ const L = [];
195
+ L.push(`# Your build`);
196
+ L.push('');
197
+ L.push(`\`${d.root}\``);
198
+ L.push('');
199
+ L.push('Read-only. Nothing here was executed, sent anywhere, or billed.');
200
+ L.push('');
201
+
202
+ L.push('## Harness');
203
+ L.push('');
204
+ if (!d.harnesses.length) {
205
+ L.push('None detected. doorman can still grade a server, but an eval needs an agent');
206
+ L.push('to drive, so `doorman eval --agent` would have to be told what to run.');
207
+ } else {
208
+ for (const h of d.harnesses) L.push(`- **${h.harness}** (${h.evidence.join(', ')})`);
209
+ }
210
+ L.push('');
211
+
212
+ L.push('## MCP servers this project can reach');
213
+ L.push('');
214
+ if (!d.servers.length) {
215
+ L.push('None declared. Nothing to grade yet, and nothing to gate.');
216
+ } else {
217
+ L.push('| Server | Transport | Declared in | Target |');
218
+ L.push('|---|---|---|---|');
219
+ for (const s of d.servers) {
220
+ L.push(`| \`${s.name}\` | ${s.transport ?? '?'} | \`${s.source}\` | ${s.target ? '`' + String(s.target).slice(0, 60) + '`' : '-'} |`);
221
+ }
222
+ L.push('');
223
+ L.push(`**${d.servers.length} server(s).** Each one describes its own tools to your`);
224
+ L.push('agent, and your agent reads those descriptions as instructions.');
225
+ }
226
+ L.push('');
227
+
228
+ L.push('## Agents');
229
+ L.push('');
230
+ if (!d.agents.count) {
231
+ L.push('No `.claude/agents/` directory.');
232
+ } else {
233
+ L.push(`**${d.agents.count} subagent(s)** in \`${d.agents.dir}\`.`);
234
+ if (d.agents.withMcp.length) {
235
+ L.push('');
236
+ for (const a of d.agents.withMcp) L.push(`- \`${a.agent}\` references ${a.tools} MCP tool name(s)`);
237
+ L.push('');
238
+ L.push('A tool handed to every agent costs every agent: each one carries');
239
+ L.push('descriptions it will mostly never call, plus that many more chances to');
240
+ L.push('pick the wrong one.');
241
+ } else {
242
+ L.push('None of them reference an MCP tool by name.');
243
+ }
244
+ }
245
+ L.push('');
246
+
247
+ L.push('## The gate');
248
+ L.push('');
249
+ L.push(`**${d.gate.verdict}**`);
250
+ L.push('');
251
+ L.push(`- hook present: ${d.gate.installed ? 'yes' : 'no'}`);
252
+ L.push(`- wired in settings.json: ${d.gate.wired ? 'yes' : 'no'}`);
253
+ L.push(`- registry present: ${d.gate.registry ? 'yes' : 'no'}`);
254
+ L.push(`- installed as a plugin: ${d.gate.plugin?.present ? `yes (${d.gate.plugin.name})` : 'no'}`);
255
+ if (d.gate.plugin?.present && !d.gate.installed) {
256
+ L.push('');
257
+ L.push('> The gate is running from a user-scope PLUGIN, so it applies to every');
258
+ L.push('> project on this machine, not just this one. The trust list it reads is');
259
+ L.push('> `$CLAUDE_PROJECT_DIR/registry/allowlist.json` if this project has one,');
260
+ L.push('> and the plugin default otherwise. `doorman install .` gives this project');
261
+ L.push('> its own list, which a plugin update can never overwrite.');
262
+ }
263
+ if (d.gate.installed && !d.gate.wired) {
264
+ L.push('');
265
+ L.push('> A gate that is installed and not wired is not running, and looks exactly');
266
+ L.push('> like one that is. Both are quiet.');
267
+ }
268
+ L.push('');
269
+
270
+ if (d.runners.length) {
271
+ L.push('## Agent-ish dependencies');
272
+ L.push('');
273
+ for (const r of d.runners) L.push(`- \`${r.name}\` ${r.version}`);
274
+ L.push('');
275
+ }
276
+
277
+ L.push('---');
278
+ L.push('');
279
+ L.push('_`doorman doctor`. Static, local, free. It reports what is here; it does not');
280
+ L.push('say whether any of it works. That is `doorman report` and `doorman eval`._');
281
+ return L.join('\n') + '\n';
282
+ }