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.
- package/.claude-plugin/marketplace.json +17 -0
- package/LICENSE +21 -0
- package/README.md +951 -0
- package/WALKTHROUGH.md +224 -0
- package/doorman/.claude/hooks/mcp-gate.sh +205 -0
- package/doorman/.claude/settings.json +16 -0
- package/doorman/.claude-plugin/plugin.json +22 -0
- package/doorman/.mcp.json +24 -0
- package/doorman/README.md +259 -0
- package/doorman/agents/doorman.md +104 -0
- package/doorman/cli/agents.mjs +128 -0
- package/doorman/cli/allow.mjs +128 -0
- package/doorman/cli/cost.mjs +119 -0
- package/doorman/cli/discover.mjs +265 -0
- package/doorman/cli/doctor.mjs +282 -0
- package/doorman/cli/doorman.mjs +345 -0
- package/doorman/cli/eval.mjs +320 -0
- package/doorman/cli/harness.mjs +179 -0
- package/doorman/cli/install.mjs +175 -0
- package/doorman/cli/needs.mjs +116 -0
- package/doorman/cli/report.mjs +89 -0
- package/doorman/cli/sandbox.mjs +177 -0
- package/doorman/cli/task.mjs +239 -0
- package/doorman/cli/verdict.mjs +199 -0
- package/doorman/cli/watch.mjs +218 -0
- package/doorman/commands/doorman.md +116 -0
- package/doorman/commands/vet.md +69 -0
- package/doorman/hooks/hooks.json +30 -0
- package/doorman/install.sh +186 -0
- package/doorman/package.json +38 -0
- package/doorman/recipes/README.md +36 -0
- package/doorman/recipes/deepwiki.md +10 -0
- package/doorman/recipes/planted-bad.md +27 -0
- package/doorman/recipes/scorecard.md +10 -0
- package/doorman/registry/allowlist.json +37 -0
- package/doorman/registry/denylist.json +23 -0
- package/doorman/registry/ledger.jsonl +1 -0
- package/doorman/scripts/poller.mjs +292 -0
- package/doorman/scripts/resolve-cli.sh +58 -0
- package/doorman/scripts/vet.mjs +190 -0
- package/doorman/skills/doorman-guide/SKILL.md +69 -0
- package/doorman/src/budget.mjs +236 -0
- package/doorman/src/candidate.mjs +132 -0
- package/doorman/src/fit-review.mjs +255 -0
- package/doorman/src/injection.mjs +189 -0
- package/doorman/src/instructions.mjs +134 -0
- package/doorman/src/inventory.mjs +411 -0
- package/doorman/src/llm.mjs +87 -0
- package/doorman/src/needs.mjs +491 -0
- package/doorman/src/note.mjs +213 -0
- package/doorman/src/reviews.mjs +120 -0
- package/doorman/src/scorecard.mjs +123 -0
- package/doorman/src/vet.mjs +174 -0
- package/package.json +54 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the fit review is allowed to know about your system.
|
|
3
|
+
*
|
|
4
|
+
* FRONTMATTER ONLY, and that is a hard constraint rather than an optimisation.
|
|
5
|
+
* Measured on this vault: reading agent, skill and command BODIES is 642 KB.
|
|
6
|
+
* Reading their frontmatter is about 30 KB. Only one of those fits in a single
|
|
7
|
+
* model call, so the shape of this module is decided by arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* The consequence is worth stating plainly: a fit review reasons about what
|
|
10
|
+
* your agents and skills SAY THEY DO, not about what they actually do. That is
|
|
11
|
+
* the same class of claim this whole project exists to distrust in MCP servers.
|
|
12
|
+
* It is acceptable here only because the fit verdict is free, reversible, and
|
|
13
|
+
* gates nothing on its own: a human reads it and flips a status by hand.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here makes a network request or writes a file.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** CLAUDE.md is 29 KB in this vault. Cap it and SAY that it was capped. */
|
|
22
|
+
export const CLAUDE_MD_CAP = 8000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Find the project whose inventory we should read.
|
|
26
|
+
*
|
|
27
|
+
* This repo is the giveaway. The agents and skills a fit review reasons about
|
|
28
|
+
* live in whatever project someone dropped it into, which is why this walks up
|
|
29
|
+
* rather than resolving anything relative to itself.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Every file a project can declare an MCP server in.
|
|
33
|
+
*
|
|
34
|
+
* Kept beside the reader rather than inline so the list is one thing, and
|
|
35
|
+
* exported so `doctor` and this module can be asserted equal. They were not:
|
|
36
|
+
* doctor read five, this read one, and the gap showed up as a Cursor user
|
|
37
|
+
* being told to adopt servers they already ran.
|
|
38
|
+
*/
|
|
39
|
+
export const MCP_CONFIG_SOURCES = [
|
|
40
|
+
'.mcp.json',
|
|
41
|
+
'.claude/settings.json',
|
|
42
|
+
'.claude/settings.local.json',
|
|
43
|
+
'.cursor/mcp.json',
|
|
44
|
+
'.vscode/mcp.json',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Anything that marks a directory as an agent project.
|
|
49
|
+
*
|
|
50
|
+
* `.claude/agents` used to be the only one, which meant a Cursor project had NO
|
|
51
|
+
* root at all: the whole reader was skipped and the caller received an empty
|
|
52
|
+
* inventory that looked exactly like a project with nothing installed. `watch`
|
|
53
|
+
* then told that user to consider adopting two servers listed in their own
|
|
54
|
+
* `.cursor/mcp.json`.
|
|
55
|
+
*/
|
|
56
|
+
export const SKILL_SOURCES = [
|
|
57
|
+
'.claude/skills',
|
|
58
|
+
'.agents/skills',
|
|
59
|
+
'skills',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export const ROOT_MARKERS = [
|
|
63
|
+
'.claude/agents',
|
|
64
|
+
'.claude/skills',
|
|
65
|
+
'.agents/skills',
|
|
66
|
+
'.claude/settings.json',
|
|
67
|
+
'.mcp.json',
|
|
68
|
+
'.cursor/mcp.json',
|
|
69
|
+
'.vscode/mcp.json',
|
|
70
|
+
'CLAUDE.md',
|
|
71
|
+
'AGENTS.md',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export function findInventoryRoot(startDir, { env = process.env, fs = { existsSync } } = {}) {
|
|
75
|
+
const configured = env.DOORMAN_INVENTORY_ROOT;
|
|
76
|
+
if (configured) return resolve(configured);
|
|
77
|
+
|
|
78
|
+
let dir = resolve(startDir);
|
|
79
|
+
for (;;) {
|
|
80
|
+
for (const marker of ROOT_MARKERS) {
|
|
81
|
+
// join() with a '/'-containing marker is fine on both platforms.
|
|
82
|
+
if (fs.existsSync(join(dir, marker))) return dir;
|
|
83
|
+
}
|
|
84
|
+
const parent = dirname(dir);
|
|
85
|
+
if (parent === dir) return null; // hit the filesystem root
|
|
86
|
+
dir = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A deliberately small frontmatter reader.
|
|
92
|
+
*
|
|
93
|
+
* Handles `key: value` on one line, plus `>` and `|` block scalars, which is
|
|
94
|
+
* every shape the real rosters actually use. It does NOT handle nested maps or
|
|
95
|
+
* block lists, and it returns what it understood rather than throwing: a skill
|
|
96
|
+
* with an exotic header should cost us that one skill, not the whole inventory.
|
|
97
|
+
* Callers that need a field must check for it.
|
|
98
|
+
*/
|
|
99
|
+
export function parseFrontmatter(text) {
|
|
100
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
101
|
+
if (!m) return null;
|
|
102
|
+
const out = {};
|
|
103
|
+
const lines = m[1].split(/\r?\n/);
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
|
|
107
|
+
if (!kv) continue; // indented, list item, or continuation
|
|
108
|
+
let value = kv[2].trim();
|
|
109
|
+
|
|
110
|
+
/* A block scalar: `key: >` or `key: |`, optionally chomped (`>-`, `|+`),
|
|
111
|
+
with the value on the following INDENTED lines.
|
|
112
|
+
Reading the marker as the value is not a harmless parse gap. People fold
|
|
113
|
+
precisely because a description is long, so this silently discarded the
|
|
114
|
+
most informative descriptions in the inventory and handed the fit review
|
|
115
|
+
the string ">" as though that were what the agent does. Four items in the
|
|
116
|
+
real Clembot roster, including three skills. */
|
|
117
|
+
const block = /^([>|])([-+]?)$/.exec(value);
|
|
118
|
+
if (block) {
|
|
119
|
+
const folded = block[1] === '>';
|
|
120
|
+
const body = [];
|
|
121
|
+
while (i + 1 < lines.length) {
|
|
122
|
+
const next = lines[i + 1];
|
|
123
|
+
if (next.trim() !== '' && !/^\s/.test(next)) break; // back to column 0
|
|
124
|
+
body.push(next.trim());
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
while (body.length && body[body.length - 1] === '') body.pop();
|
|
128
|
+
// Folded joins with spaces and treats a blank line as a paragraph break;
|
|
129
|
+
// literal keeps the line breaks.
|
|
130
|
+
value = folded
|
|
131
|
+
? body.reduce((acc, ln) => (ln === '' ? acc + '\n' : (acc && !acc.endsWith('\n') ? acc + ' ' : acc) + ln), '')
|
|
132
|
+
: body.join('\n');
|
|
133
|
+
out[kv[1]] = value.trim();
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
139
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
140
|
+
) {
|
|
141
|
+
value = value.slice(1, -1);
|
|
142
|
+
}
|
|
143
|
+
out[kv[1]] = value;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Every MCP server an agent can actually reach, named the way the gate names it.
|
|
150
|
+
*
|
|
151
|
+
* A tool reads `mcp__<server>__<tool>`, so the server identity is sitting in the
|
|
152
|
+
* agent's own frontmatter. Collecting only `.mcp.json` missed it entirely: the
|
|
153
|
+
* real Clembot project has ZERO `.mcp.json` files and one agent holding fifteen
|
|
154
|
+
* `mcp__claude_ai_Canva__*` tools.
|
|
155
|
+
*
|
|
156
|
+
* That was not a cosmetic gap. `validateVerdict` builds its known-names set from
|
|
157
|
+
* this list, so a fit review correctly answering "you already have Canva" was
|
|
158
|
+
* rejected as an invented overlap and then failed loudly. A guard built against
|
|
159
|
+
* a fixture refusing a true statement about reality.
|
|
160
|
+
*/
|
|
161
|
+
export function mcpServersFromTools(agents) {
|
|
162
|
+
const seen = new Map();
|
|
163
|
+
for (const a of agents) {
|
|
164
|
+
for (const t of a.tools ?? []) {
|
|
165
|
+
const m = /^mcp__([A-Za-z0-9_.-]+?)__/.exec(t);
|
|
166
|
+
if (!m) continue;
|
|
167
|
+
const name = m[1];
|
|
168
|
+
if (!seen.has(name)) seen.set(name, { name, url: null, heldBy: [], tools: 0, sources: ['agent tools'] });
|
|
169
|
+
const entry = seen.get(name);
|
|
170
|
+
entry.tools++;
|
|
171
|
+
if (!entry.heldBy.includes(a.name)) entry.heldBy.push(a.name);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [...seen.values()];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `tools: Read, Grep, mcp__x__y` -> ['Read', 'Grep', 'mcp__x__y'] */
|
|
178
|
+
function splitList(value) {
|
|
179
|
+
if (!value) return [];
|
|
180
|
+
const trimmed = value.trim();
|
|
181
|
+
if (trimmed === '[]' || trimmed === '') return [];
|
|
182
|
+
return trimmed
|
|
183
|
+
.replace(/^\[|\]$/g, '')
|
|
184
|
+
.split(',')
|
|
185
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readDirSafe(dir) {
|
|
190
|
+
try {
|
|
191
|
+
return readdirSync(dir);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readFileSafe(path) {
|
|
198
|
+
try {
|
|
199
|
+
return readFileSync(path, 'utf8');
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Collect the inventory.
|
|
207
|
+
*
|
|
208
|
+
* `registryDir` is separate from `root` because the registry belongs to the
|
|
209
|
+
* doorman and the agents belong to the host project. In the giveaway those are
|
|
210
|
+
* different directories, and conflating them is how you end up reading an
|
|
211
|
+
* allowlist that is not the one the gate reads.
|
|
212
|
+
*/
|
|
213
|
+
export function gatherInventory({ root, registryDir, claudeMdCap = CLAUDE_MD_CAP } = {}) {
|
|
214
|
+
const agents = [];
|
|
215
|
+
const skills = [];
|
|
216
|
+
const mcpServers = [];
|
|
217
|
+
const allowlisted = [];
|
|
218
|
+
const notes = [];
|
|
219
|
+
|
|
220
|
+
// What this reader could actually SEE here. `0 agents` in a Claude Code
|
|
221
|
+
// project is a fact; the same 0 in a Cursor project is an artefact of asking
|
|
222
|
+
// the wrong question, and a caller must be able to tell those apart.
|
|
223
|
+
const coverage = { agents: 'unknown', skills: 'unknown', mcpServers: 'unknown' };
|
|
224
|
+
|
|
225
|
+
if (!root) {
|
|
226
|
+
notes.push('no inventory root was found: agents and skills are UNKNOWN, not empty');
|
|
227
|
+
} else {
|
|
228
|
+
const hasAgentLayout = existsSync(join(root, '.claude', 'agents')) ||
|
|
229
|
+
existsSync(join(root, '.agents'));
|
|
230
|
+
const hasSkillLayout = SKILL_SOURCES.some((rel) => existsSync(join(root, rel)));
|
|
231
|
+
if (hasAgentLayout) coverage.agents = 'read';
|
|
232
|
+
if (hasSkillLayout) coverage.skills = 'read';
|
|
233
|
+
if (!hasAgentLayout && !hasSkillLayout) {
|
|
234
|
+
notes.push(
|
|
235
|
+
'this project has no .claude/agents or known skills directory, so its subagents and ' +
|
|
236
|
+
'skills are UNKNOWN rather than absent. Only the MCP config was read.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const agentDir = join(root, '.claude', 'agents');
|
|
240
|
+
for (const file of readDirSafe(agentDir).filter((f) => f.endsWith('.md'))) {
|
|
241
|
+
const text = readFileSafe(join(agentDir, file));
|
|
242
|
+
const fm = text && parseFrontmatter(text);
|
|
243
|
+
if (!fm) {
|
|
244
|
+
notes.push(`agent file ${file} has no readable frontmatter and was skipped`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
agents.push({
|
|
248
|
+
name: fm.name ?? file.replace(/\.md$/, ''),
|
|
249
|
+
description: fm.description ?? '',
|
|
250
|
+
tools: splitList(fm.tools),
|
|
251
|
+
skills: splitList(fm.skills),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const seenSkills = new Set();
|
|
256
|
+
for (const rel of SKILL_SOURCES) {
|
|
257
|
+
const skillsDir = join(root, rel);
|
|
258
|
+
if (!existsSync(skillsDir)) continue;
|
|
259
|
+
for (const entry of readDirSafe(skillsDir)) {
|
|
260
|
+
const skillFile = join(skillsDir, entry, 'SKILL.md');
|
|
261
|
+
const text = readFileSafe(skillFile);
|
|
262
|
+
const fm = text && parseFrontmatter(text);
|
|
263
|
+
if (!fm) continue; // not every directory is a skill
|
|
264
|
+
const name = fm.name ?? entry;
|
|
265
|
+
if (!seenSkills.has(name)) {
|
|
266
|
+
seenSkills.add(name);
|
|
267
|
+
skills.push({ name, description: fm.description ?? '', source: rel });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* Every place a server gets configured, not just the Claude Code one.
|
|
273
|
+
`doctor` has read all of these since it shipped; this did not, so `watch`
|
|
274
|
+
told a Cursor user that two servers listed in their own
|
|
275
|
+
`.cursor/mcp.json` were "new to this build". Both surfaces read the same
|
|
276
|
+
list now. `MCP_CONFIG_SOURCES` is exported so the drift is visible if the
|
|
277
|
+
two ever diverge again. */
|
|
278
|
+
for (const rel of MCP_CONFIG_SOURCES) {
|
|
279
|
+
const mcpText = readFileSafe(join(root, rel));
|
|
280
|
+
if (!mcpText) continue;
|
|
281
|
+
try {
|
|
282
|
+
const doc = JSON.parse(mcpText);
|
|
283
|
+
// `mcpServers` is the Claude/Cursor spelling, `servers` the VS Code one.
|
|
284
|
+
const declared = { ...(doc.mcpServers ?? {}), ...(doc.servers ?? {}) };
|
|
285
|
+
for (const [name, cfg] of Object.entries(declared)) {
|
|
286
|
+
const url = cfg?.url ?? cfg?.serverUrl ?? null;
|
|
287
|
+
const existing = mcpServers.find((m) => m.name === name);
|
|
288
|
+
if (existing) {
|
|
289
|
+
// Two files can declare the same server. Keep the first url found
|
|
290
|
+
// rather than letting a later file with no url erase it.
|
|
291
|
+
if (!existing.url && url) existing.url = url;
|
|
292
|
+
if (!existing.sources.includes(rel)) existing.sources.push(rel);
|
|
293
|
+
} else {
|
|
294
|
+
mcpServers.push({ name, url, heldBy: [], tools: 0, sources: [rel] });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
coverage.mcpServers = 'read';
|
|
298
|
+
} catch {
|
|
299
|
+
notes.push(`${rel} exists but did not parse; configured servers are UNKNOWN`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* Union with what the agents actually hold. `.mcp.json` is one way a server
|
|
304
|
+
gets configured and it is not the only one. */
|
|
305
|
+
for (const derived of mcpServersFromTools(agents)) {
|
|
306
|
+
const existing = mcpServers.find((m) => m.name === derived.name);
|
|
307
|
+
if (existing) {
|
|
308
|
+
existing.heldBy = derived.heldBy;
|
|
309
|
+
existing.tools = derived.tools;
|
|
310
|
+
} else {
|
|
311
|
+
mcpServers.push(derived);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (registryDir) {
|
|
317
|
+
const text = readFileSafe(join(registryDir, 'allowlist.json'));
|
|
318
|
+
if (text) {
|
|
319
|
+
try {
|
|
320
|
+
const doc = JSON.parse(text);
|
|
321
|
+
for (const [key, entry] of Object.entries(doc.servers ?? {})) {
|
|
322
|
+
allowlisted.push({ key, url: entry?.url ?? null, grade: entry?.grade ?? null });
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
notes.push('allowlist.json did not parse; already-graded servers are UNKNOWN');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let claudeMd = '';
|
|
331
|
+
let claudeMdTruncated = false;
|
|
332
|
+
if (root) {
|
|
333
|
+
const text = readFileSafe(join(root, 'CLAUDE.md'));
|
|
334
|
+
if (text) {
|
|
335
|
+
claudeMd = text.slice(0, claudeMdCap);
|
|
336
|
+
claudeMdTruncated = text.length > claudeMdCap;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
root: root ?? null,
|
|
342
|
+
agents,
|
|
343
|
+
skills,
|
|
344
|
+
mcpServers,
|
|
345
|
+
allowlisted,
|
|
346
|
+
claudeMd,
|
|
347
|
+
claudeMdTruncated,
|
|
348
|
+
coverage,
|
|
349
|
+
notes,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Render the inventory as the context block the model sees.
|
|
355
|
+
*
|
|
356
|
+
* Deterministic ordering, because a fit verdict at temperature 0 should not
|
|
357
|
+
* change because a directory listing came back in a different order.
|
|
358
|
+
*
|
|
359
|
+
* Truncation is ANNOUNCED. A model told it is seeing a partial CLAUDE.md can
|
|
360
|
+
* hedge; a model shown a silently clipped one cannot.
|
|
361
|
+
*/
|
|
362
|
+
export function renderInventory(inv) {
|
|
363
|
+
const lines = [];
|
|
364
|
+
const by = (a, b) => (a.name ?? a.key).localeCompare(b.name ?? b.key);
|
|
365
|
+
|
|
366
|
+
lines.push('# SUBAGENTS');
|
|
367
|
+
if (inv.agents.length === 0) lines.push('(none found)');
|
|
368
|
+
for (const a of [...inv.agents].sort(by)) {
|
|
369
|
+
lines.push(`- ${a.name}: ${a.description}`);
|
|
370
|
+
if (a.tools.length) lines.push(` tools: ${a.tools.join(', ')}`);
|
|
371
|
+
if (a.skills.length) lines.push(` skills: ${a.skills.join(', ')}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
lines.push('', '# SKILLS');
|
|
375
|
+
if (inv.skills.length === 0) lines.push('(none found)');
|
|
376
|
+
for (const s of [...inv.skills].sort(by)) lines.push(`- ${s.name}: ${s.description}`);
|
|
377
|
+
|
|
378
|
+
lines.push('', '# MCP SERVERS ALREADY CONFIGURED');
|
|
379
|
+
if (inv.mcpServers.length === 0) lines.push('(none)');
|
|
380
|
+
for (const m of [...inv.mcpServers].sort(by)) {
|
|
381
|
+
const held = m.heldBy && m.heldBy.length ? ` (held by ${m.heldBy.join(', ')}, ${m.tools} tools)` : '';
|
|
382
|
+
lines.push(`- ${m.name}: ${m.url ?? 'no url'}${held}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
lines.push('', '# MCP SERVERS ALREADY GRADED AND ALLOWLISTED');
|
|
386
|
+
if (inv.allowlisted.length === 0) lines.push('(none)');
|
|
387
|
+
for (const a of [...inv.allowlisted].sort((x, y) => x.key.localeCompare(y.key))) {
|
|
388
|
+
lines.push(`- ${a.key} (${a.grade ?? 'ungraded'}): ${a.url ?? 'no url'}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
lines.push('', '# PROJECT CLAUDE.md');
|
|
392
|
+
lines.push(inv.claudeMd || '(not found)');
|
|
393
|
+
if (inv.claudeMdTruncated) {
|
|
394
|
+
lines.push('', `[TRUNCATED at ${CLAUDE_MD_CAP} bytes. You are seeing the beginning only.]`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (inv.notes.length) {
|
|
398
|
+
lines.push('', '# GAPS IN THIS INVENTORY');
|
|
399
|
+
for (const n of inv.notes) lines.push(`- ${n}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return lines.join('\n');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Convenience for callers that just want the inventory for a directory. */
|
|
406
|
+
export function inventoryFor(startDir, { registryDir, env = process.env } = {}) {
|
|
407
|
+
const root = findInventoryRoot(startDir, { env });
|
|
408
|
+
return gatherInventory({ root, registryDir });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export const __testing = { splitList, readDirSafe, statSync };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only thing in the fit-review path allowed to touch the network.
|
|
3
|
+
*
|
|
4
|
+
* Everything else receives this object and can do nothing it does not expose,
|
|
5
|
+
* which is what lets every test run offline with a stub. Same seam the
|
|
6
|
+
* scorecard's probe runner uses, for the same reason.
|
|
7
|
+
*
|
|
8
|
+
* Pinned model, temperature 0, both from config rather than from a caller.
|
|
9
|
+
* A fit verdict is relative to the model that produced it exactly as a grade
|
|
10
|
+
* is, so letting a caller pass a different model per call would silently make
|
|
11
|
+
* two reviews incomparable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_MODEL = 'claude-sonnet-5';
|
|
15
|
+
export const ANTHROPIC_VERSION = '2023-06-01';
|
|
16
|
+
|
|
17
|
+
export class MissingApiKeyError extends Error {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {string} [opts.apiKey] from ANTHROPIC_API_KEY
|
|
22
|
+
* @param {string} [opts.model] from DOORMAN_MODEL
|
|
23
|
+
* @param {Function} [opts.fetch] injectable for tests
|
|
24
|
+
*/
|
|
25
|
+
export function anthropicClient({ apiKey, model = DEFAULT_MODEL, fetch: f = fetch, timeoutMs = 60_000 } = {}) {
|
|
26
|
+
if (!apiKey) {
|
|
27
|
+
// Loud, and at construction rather than at call time. A doorman that
|
|
28
|
+
// quietly skipped the free fit check and went straight to the paid grade
|
|
29
|
+
// would invert the entire feature.
|
|
30
|
+
throw new MissingApiKeyError(
|
|
31
|
+
'ANTHROPIC_API_KEY is not set, so the fit review cannot run.\n' +
|
|
32
|
+
'The fit review is the FREE check that decides whether the paid grade is ' +
|
|
33
|
+
'worth running at all. Skipping it to reach the paid path is backwards, ' +
|
|
34
|
+
'so this stops here instead.\n' +
|
|
35
|
+
'Set ANTHROPIC_API_KEY, or use --dry-run to inspect the candidate and the ' +
|
|
36
|
+
'inventory without running either phase.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
model,
|
|
42
|
+
temperature: 0,
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One turn. No tools, no streaming, no conversation: the fit review is a
|
|
46
|
+
* single question with a single JSON answer.
|
|
47
|
+
* @returns {Promise<{text: string, stop_reason: string, raw: unknown}>}
|
|
48
|
+
*/
|
|
49
|
+
async complete({ system, user, max_tokens = 1024 }) {
|
|
50
|
+
const ac = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await f('https://api.anthropic.com/v1/messages', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
'content-type': 'application/json',
|
|
58
|
+
'x-api-key': apiKey,
|
|
59
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
max_tokens,
|
|
64
|
+
temperature: 0,
|
|
65
|
+
system,
|
|
66
|
+
messages: [{ role: 'user', content: user }],
|
|
67
|
+
}),
|
|
68
|
+
signal: ac.signal,
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const body = await res.text().catch(() => '');
|
|
76
|
+
throw new Error(`Anthropic HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const json = await res.json();
|
|
80
|
+
const text = (json.content ?? [])
|
|
81
|
+
.filter((b) => b.type === 'text')
|
|
82
|
+
.map((b) => b.text)
|
|
83
|
+
.join('');
|
|
84
|
+
return { text, stop_reason: json.stop_reason, raw: json };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|