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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs INSIDE the sandbox container. One task, one agent loop, one JSON line out.
|
|
3
|
+
*
|
|
4
|
+
* This file is copied into both images verbatim, so the only thing that differs
|
|
5
|
+
* between arms is whether the candidate's install layer is present. It reads the
|
|
6
|
+
* task on stdin and prints exactly one metrics object on stdout as its last line.
|
|
7
|
+
*
|
|
8
|
+
* Success is decided by the task's own machine-checkable conditions, never by
|
|
9
|
+
* asking a model whether it did well. A model-judged pass is not comparable
|
|
10
|
+
* across arms: the judge sees a different transcript each time, and its leniency
|
|
11
|
+
* is one more thing varying between the two numbers you are trying to compare.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MODEL = process.env.DOORMAN_MODEL || 'claude-sonnet-5';
|
|
15
|
+
const KEY = process.env.ANTHROPIC_API_KEY;
|
|
16
|
+
// Derived from the money, not a constant. cli/cost.mjs works out how many
|
|
17
|
+
// turns fit the ceiling and passes it in; 24 is only the fallback when this
|
|
18
|
+
// file is run outside that orchestration.
|
|
19
|
+
const MAX_TURNS = Number(process.env.DOORMAN_MAX_TURNS) || 24;
|
|
20
|
+
|
|
21
|
+
/** Priced per million tokens. Unknown model means cost is reported as null. */
|
|
22
|
+
const PRICES = {
|
|
23
|
+
'claude-sonnet-5': { in: 3, out: 15 },
|
|
24
|
+
'claude-opus-5': { in: 15, out: 75 },
|
|
25
|
+
'claude-haiku-4-5-20251001': { in: 1, out: 5 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const files = new Map();
|
|
29
|
+
|
|
30
|
+
const TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'write_file',
|
|
33
|
+
description: 'Write text to a file in the working directory.',
|
|
34
|
+
input_schema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
37
|
+
required: ['path', 'content'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'read_file',
|
|
42
|
+
description: 'Read a file previously written in this session.',
|
|
43
|
+
input_schema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: { path: { type: 'string' } },
|
|
46
|
+
required: ['path'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'fetch_url',
|
|
51
|
+
description: 'HTTP GET a public URL and return the response body as text.',
|
|
52
|
+
input_schema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: { url: { type: 'string' } },
|
|
55
|
+
required: ['url'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
async function callTool(name, input) {
|
|
61
|
+
if (name === 'write_file') {
|
|
62
|
+
files.set(String(input.path), String(input.content ?? ''));
|
|
63
|
+
return `wrote ${input.path} (${String(input.content ?? '').length} chars)`;
|
|
64
|
+
}
|
|
65
|
+
if (name === 'read_file') {
|
|
66
|
+
return files.has(String(input.path)) ? files.get(String(input.path)) : 'ENOENT';
|
|
67
|
+
}
|
|
68
|
+
if (name === 'fetch_url') {
|
|
69
|
+
try {
|
|
70
|
+
const r = await fetch(String(input.url), { redirect: 'follow' });
|
|
71
|
+
const t = await r.text();
|
|
72
|
+
return `HTTP ${r.status}\n${t.slice(0, 20000)}`;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// With --network none this is the expected path, and it is reported to the
|
|
75
|
+
// model rather than crashing the run: how an agent copes with a dead
|
|
76
|
+
// network is itself part of what the two arms are being compared on.
|
|
77
|
+
return `fetch failed: ${e.message}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return `unknown tool ${name}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every condition must be machine-checkable. See the note at the top. */
|
|
84
|
+
function checkSuccess(success) {
|
|
85
|
+
const failures = [];
|
|
86
|
+
const all = [...files.values()].join('\n');
|
|
87
|
+
|
|
88
|
+
if (success.file_exists) {
|
|
89
|
+
const want = Array.isArray(success.file_exists) ? success.file_exists : [success.file_exists];
|
|
90
|
+
for (const f of want) if (!files.has(f)) failures.push(`missing file ${f}`);
|
|
91
|
+
}
|
|
92
|
+
if (success.contains) {
|
|
93
|
+
const want = Array.isArray(success.contains) ? success.contains : [success.contains];
|
|
94
|
+
for (const s of want) {
|
|
95
|
+
if (!all.toLowerCase().includes(String(s).toLowerCase())) failures.push(`missing text "${s}"`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (success.sections) {
|
|
99
|
+
const n = Number(success.sections);
|
|
100
|
+
const found = (all.match(/^#{1,6} /gm) || []).length;
|
|
101
|
+
if (found < n) failures.push(`wanted ${n} sections, found ${found}`);
|
|
102
|
+
}
|
|
103
|
+
return failures;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
const raw = await new Promise((res) => {
|
|
108
|
+
let b = ''; process.stdin.setEncoding('utf8');
|
|
109
|
+
process.stdin.on('data', (d) => { b += d; });
|
|
110
|
+
process.stdin.on('end', () => res(b));
|
|
111
|
+
});
|
|
112
|
+
const task = JSON.parse(raw);
|
|
113
|
+
|
|
114
|
+
if (!KEY) {
|
|
115
|
+
// Invariant 9: a missing key stops the run and says so. It never produces a
|
|
116
|
+
// number, because a fabricated benchmark is worse than no benchmark.
|
|
117
|
+
console.log(JSON.stringify({
|
|
118
|
+
success: false, blocked: true,
|
|
119
|
+
error: 'ANTHROPIC_API_KEY is not set inside the sandbox, so no run happened.',
|
|
120
|
+
}));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const messages = [{ role: 'user', content: task.prompt }];
|
|
125
|
+
let turns = 0, toolCalls = 0, inTok = 0, outTok = 0, stop = 'max_turns';
|
|
126
|
+
|
|
127
|
+
while (turns < MAX_TURNS) {
|
|
128
|
+
turns++;
|
|
129
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'content-type': 'application/json',
|
|
133
|
+
'x-api-key': KEY,
|
|
134
|
+
'anthropic-version': '2023-06-01',
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 4096, temperature: 0, tools: TOOLS, messages }),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
console.log(JSON.stringify({
|
|
140
|
+
success: false, turns, tool_calls: toolCalls,
|
|
141
|
+
error: `model call failed: HTTP ${res.status} ${(await res.text()).slice(0, 300)}`,
|
|
142
|
+
}));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const body = await res.json();
|
|
146
|
+
inTok += body.usage?.input_tokens ?? 0;
|
|
147
|
+
outTok += body.usage?.output_tokens ?? 0;
|
|
148
|
+
messages.push({ role: 'assistant', content: body.content });
|
|
149
|
+
|
|
150
|
+
const uses = (body.content || []).filter((c) => c.type === 'tool_use');
|
|
151
|
+
if (!uses.length) { stop = 'end_turn'; break; }
|
|
152
|
+
|
|
153
|
+
const results = [];
|
|
154
|
+
for (const u of uses) {
|
|
155
|
+
toolCalls++;
|
|
156
|
+
results.push({ type: 'tool_result', tool_use_id: u.id, content: await callTool(u.name, u.input || {}) });
|
|
157
|
+
}
|
|
158
|
+
messages.push({ role: 'user', content: results });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const failures = checkSuccess(task.success || {});
|
|
162
|
+
const p = PRICES[MODEL];
|
|
163
|
+
console.log(JSON.stringify({
|
|
164
|
+
success: failures.length === 0,
|
|
165
|
+
failures,
|
|
166
|
+
turns,
|
|
167
|
+
tool_calls: toolCalls,
|
|
168
|
+
tokens: inTok + outTok,
|
|
169
|
+
input_tokens: inTok,
|
|
170
|
+
output_tokens: outTok,
|
|
171
|
+
cost_usd: p ? (inTok / 1e6) * p.in + (outTok / 1e6) * p.out : null,
|
|
172
|
+
stop_reason: stop,
|
|
173
|
+
files_written: [...files.keys()],
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((e) => {
|
|
178
|
+
console.log(JSON.stringify({ success: false, error: String(e && e.message ? e.message : e) }));
|
|
179
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doorman install [target]` — install the gate, subagent, slash command,
|
|
3
|
+
* and skill into a target project.
|
|
4
|
+
*
|
|
5
|
+
* Cross-platform Node implementation of install.sh. Zero runtime dependencies.
|
|
6
|
+
*
|
|
7
|
+
* It copies:
|
|
8
|
+
* - .claude/hooks/mcp-gate.sh
|
|
9
|
+
* - agents/doorman.md
|
|
10
|
+
* - commands/vet.md
|
|
11
|
+
* - skills/doorman-guide/SKILL.md
|
|
12
|
+
* - registry/allowlist.json & denylist.json (NEVER overwrites an existing registry)
|
|
13
|
+
*
|
|
14
|
+
* And safely inspects/wires .claude/settings.json without destructive clobbering.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { resolve, join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const ROOT = resolve(HERE, '..');
|
|
23
|
+
|
|
24
|
+
export const GATE_HOOK_CONFIG = {
|
|
25
|
+
matcher: 'mcp__.*',
|
|
26
|
+
hooks: [{
|
|
27
|
+
type: 'command',
|
|
28
|
+
command: '$CLAUDE_PROJECT_DIR/.claude/hooks/mcp-gate.sh',
|
|
29
|
+
timeout: 5,
|
|
30
|
+
}],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function install(targetDir = process.cwd(), { dryRun = false } = {}) {
|
|
34
|
+
const absTarget = resolve(targetDir);
|
|
35
|
+
if (!existsSync(absTarget)) {
|
|
36
|
+
return { ok: false, why: `target directory does not exist: ${absTarget}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const copied = [];
|
|
40
|
+
const kept = [];
|
|
41
|
+
const warnings = [];
|
|
42
|
+
|
|
43
|
+
const copy = (srcRel, destRel) => {
|
|
44
|
+
const src = join(ROOT, srcRel);
|
|
45
|
+
const dest = join(absTarget, destRel);
|
|
46
|
+
if (!existsSync(src)) {
|
|
47
|
+
warnings.push(`source file not found: ${srcRel}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
copied.push(`${srcRel} -> ${destRel}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
55
|
+
copyFileSync(src, dest);
|
|
56
|
+
copied.push(destRel);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// 1. Hooks, Subagent, Command
|
|
60
|
+
copy('.claude/hooks/mcp-gate.sh', '.claude/hooks/mcp-gate.sh');
|
|
61
|
+
copy('agents/doorman.md', '.claude/agents/doorman.md');
|
|
62
|
+
copy('commands/vet.md', '.claude/commands/vet.md');
|
|
63
|
+
|
|
64
|
+
// 2. Doorman Skill (both in .claude/skills and .agents/skills if applicable)
|
|
65
|
+
copy('skills/doorman-guide/SKILL.md', '.claude/skills/doorman-guide/SKILL.md');
|
|
66
|
+
if (existsSync(join(absTarget, '.agents'))) {
|
|
67
|
+
copy('skills/doorman-guide/SKILL.md', '.agents/skills/doorman-guide/SKILL.md');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 3. Registry (never overwrite existing)
|
|
71
|
+
let registryStatus = 'copied';
|
|
72
|
+
const targetAllowlist = join(absTarget, 'registry', 'allowlist.json');
|
|
73
|
+
if (existsSync(targetAllowlist)) {
|
|
74
|
+
registryStatus = 'kept';
|
|
75
|
+
kept.push('registry/allowlist.json (existing trust list preserved)');
|
|
76
|
+
} else if (!dryRun) {
|
|
77
|
+
mkdirSync(join(absTarget, 'registry'), { recursive: true });
|
|
78
|
+
copyFileSync(join(ROOT, 'registry', 'allowlist.json'), targetAllowlist);
|
|
79
|
+
copyFileSync(join(ROOT, 'registry', 'denylist.json'), join(absTarget, 'registry', 'denylist.json'));
|
|
80
|
+
writeFileSync(join(absTarget, 'registry', 'ledger.jsonl'), '', 'utf8');
|
|
81
|
+
copied.push('registry/ (allowlist.json, denylist.json, ledger.jsonl)');
|
|
82
|
+
} else {
|
|
83
|
+
copied.push('registry/ (allowlist.json, denylist.json, ledger.jsonl)');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Inspect & wire settings.json
|
|
87
|
+
const settingsPath = join(absTarget, '.claude', 'settings.json');
|
|
88
|
+
let settingsWired = false;
|
|
89
|
+
let settingsStatus = 'missing';
|
|
90
|
+
|
|
91
|
+
if (existsSync(settingsPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const raw = readFileSync(settingsPath, 'utf8');
|
|
94
|
+
if (raw.includes('mcp-gate.sh')) {
|
|
95
|
+
settingsWired = true;
|
|
96
|
+
settingsStatus = 'already-wired';
|
|
97
|
+
} else {
|
|
98
|
+
const settings = JSON.parse(raw);
|
|
99
|
+
if (!settings.hooks) settings.hooks = {};
|
|
100
|
+
if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
|
|
101
|
+
settings.hooks.PreToolUse.push(GATE_HOOK_CONFIG);
|
|
102
|
+
if (!dryRun) {
|
|
103
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
104
|
+
}
|
|
105
|
+
settingsWired = true;
|
|
106
|
+
settingsStatus = 'wired';
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
settingsStatus = 'unparseable';
|
|
110
|
+
warnings.push('.claude/settings.json exists but is not valid JSON. Wire hook manually.');
|
|
111
|
+
}
|
|
112
|
+
} else if (!dryRun) {
|
|
113
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
114
|
+
const initialSettings = {
|
|
115
|
+
hooks: {
|
|
116
|
+
PreToolUse: [GATE_HOOK_CONFIG],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
writeFileSync(settingsPath, JSON.stringify(initialSettings, null, 2) + '\n', 'utf8');
|
|
120
|
+
settingsWired = true;
|
|
121
|
+
settingsStatus = 'created-and-wired';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
target: absTarget,
|
|
127
|
+
dryRun,
|
|
128
|
+
copied,
|
|
129
|
+
kept,
|
|
130
|
+
warnings,
|
|
131
|
+
registryStatus,
|
|
132
|
+
settingsWired,
|
|
133
|
+
settingsStatus,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderInstall(res) {
|
|
138
|
+
const L = [];
|
|
139
|
+
L.push('');
|
|
140
|
+
L.push(`doorman install -> ${res.target}`);
|
|
141
|
+
if (res.dryRun) L.push(' (dry run: nothing was written)');
|
|
142
|
+
L.push('');
|
|
143
|
+
|
|
144
|
+
L.push('## Files installed');
|
|
145
|
+
for (const c of res.copied) L.push(` + ${c}`);
|
|
146
|
+
for (const k of res.kept) L.push(` = ${k}`);
|
|
147
|
+
L.push('');
|
|
148
|
+
|
|
149
|
+
L.push('## Gate & Hook Wiring');
|
|
150
|
+
if (res.settingsWired) {
|
|
151
|
+
L.push(` [PASS] Hook is wired in .claude/settings.json (${res.settingsStatus})`);
|
|
152
|
+
} else {
|
|
153
|
+
L.push(` [WARN] Hook is NOT wired in .claude/settings.json (${res.settingsStatus})`);
|
|
154
|
+
L.push(' Add the PreToolUse hook manually to activate protection.');
|
|
155
|
+
}
|
|
156
|
+
L.push('');
|
|
157
|
+
|
|
158
|
+
L.push('## Registry Trust List');
|
|
159
|
+
if (res.registryStatus === 'kept') {
|
|
160
|
+
L.push(' Your existing trust list was preserved. What is trusted has not changed.');
|
|
161
|
+
} else {
|
|
162
|
+
L.push(' Initial registry deployed with baseline trust list.');
|
|
163
|
+
L.push(' Everything else your agent reaches for is blocked until vetted: /vet <url>');
|
|
164
|
+
}
|
|
165
|
+
L.push('');
|
|
166
|
+
|
|
167
|
+
if (res.warnings.length) {
|
|
168
|
+
L.push('## Warnings');
|
|
169
|
+
for (const w of res.warnings) L.push(` ! ${w}`);
|
|
170
|
+
L.push('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
L.push('Done. Run `doorman doctor` to verify your installation.');
|
|
174
|
+
return L.join('\n');
|
|
175
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doorman needs` - the CLI half. Reads the history, fetches the free feed,
|
|
3
|
+
* and hands both to the pure functions in ../src/needs.mjs.
|
|
4
|
+
*
|
|
5
|
+
* The only request made here is the same anonymous `GET /feed` that `watch`
|
|
6
|
+
* makes. The prompts are read, matched and discarded locally. Nothing about
|
|
7
|
+
* this build is sent anywhere, which is the whole reason the expensive half of
|
|
8
|
+
* the product is the shared grade and the cheap half is the private fit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { inventoryFor } from '../src/inventory.mjs';
|
|
13
|
+
import { installedKeys, serverKey } from './watch.mjs';
|
|
14
|
+
import {
|
|
15
|
+
readPrompts, historyDirFor, suggest, renderNeeds, NEEDS,
|
|
16
|
+
} from '../src/needs.mjs';
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_API = 'https://scorecard.wanessalabs.com';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Candidates from a local `doorman discover` sweep, if one has been run.
|
|
22
|
+
*
|
|
23
|
+
* These are UNGRADED by construction: discover scans published text and never
|
|
24
|
+
* drives anything, and it writes `graded: false` on every row. They are
|
|
25
|
+
* included because a need with only ungraded options is still better
|
|
26
|
+
* information than a need with none, and every row says which it is.
|
|
27
|
+
*/
|
|
28
|
+
export function readCandidateFile(file) {
|
|
29
|
+
if (!file || !existsSync(file)) return [];
|
|
30
|
+
try {
|
|
31
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
32
|
+
const rows = Array.isArray(raw) ? raw : (raw.candidates ?? []);
|
|
33
|
+
return rows.map((c) => ({ ...c, source: c.source ?? 'candidates' }));
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function needs({
|
|
40
|
+
root, api, historyDir, candidateFile, limit = 200, fetchImpl = fetch,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const inv = inventoryFor(root);
|
|
43
|
+
const installed = installedKeys(inv);
|
|
44
|
+
|
|
45
|
+
const dir = historyDir ?? historyDirFor(root);
|
|
46
|
+
let prompts = [];
|
|
47
|
+
let historyNote;
|
|
48
|
+
if (!dir) {
|
|
49
|
+
historyNote = 'No readable prompt history for this path. Claude Code keeps it under ' +
|
|
50
|
+
'~/.claude/projects/<path-with-dashes>; other harnesses keep none that doorman can read. ' +
|
|
51
|
+
'Pass --history DIR if yours lives elsewhere.';
|
|
52
|
+
} else {
|
|
53
|
+
prompts = readPrompts(dir);
|
|
54
|
+
historyNote = `history: ${dir}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The feed is free and anonymous. A failure here is "could not measure",
|
|
58
|
+
// never a reason to invent a suggestion, so an empty candidate list flows
|
|
59
|
+
// straight through to a GAP line that says so.
|
|
60
|
+
let feed = [];
|
|
61
|
+
let feedNote = null;
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL('/feed', api);
|
|
64
|
+
url.searchParams.set('limit', String(limit));
|
|
65
|
+
const res = await fetchImpl(url.toString(), { headers: { accept: 'application/json' } });
|
|
66
|
+
if (res.ok) {
|
|
67
|
+
const body = await res.json();
|
|
68
|
+
feed = (body.candidates ?? []).map((c) => ({ ...c, source: 'feed' }));
|
|
69
|
+
} else {
|
|
70
|
+
feedNote = `feed returned HTTP ${res.status}; candidates below are local only`;
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
feedNote = `feed unreachable (${e.message}); candidates below are local only`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const candidates = [...feed, ...readCandidateFile(candidateFile)];
|
|
77
|
+
const installedUrls = new Set([...installed]);
|
|
78
|
+
|
|
79
|
+
const report = suggest({
|
|
80
|
+
prompts,
|
|
81
|
+
inventory: inv,
|
|
82
|
+
candidates,
|
|
83
|
+
// rankCandidates compares against whatever url a candidate carries, so the
|
|
84
|
+
// installed set has to be keyed the same way the inventory was.
|
|
85
|
+
installed: new Set([...candidates.map((c) => c.server_url).filter(Boolean)]
|
|
86
|
+
.filter((u) => installedUrls.has(serverKey(u)))),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
...report,
|
|
91
|
+
api,
|
|
92
|
+
history_dir: dir,
|
|
93
|
+
history_note: historyNote,
|
|
94
|
+
feed_note: feedNote,
|
|
95
|
+
feed_rows: feed.length,
|
|
96
|
+
local_candidates: candidates.length - feed.length,
|
|
97
|
+
taxonomy: NEEDS.map((n) => n.id),
|
|
98
|
+
inventory: {
|
|
99
|
+
root: inv.root ?? root,
|
|
100
|
+
coverage: inv.coverage ?? { agents: 'unknown', skills: 'unknown', mcpServers: 'unknown' },
|
|
101
|
+
mcp_servers: (inv.mcpServers ?? []).length,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function render(r) {
|
|
107
|
+
const head = [r.history_note, r.feed_note,
|
|
108
|
+
`feed: ${r.feed_rows} graded rows` + (r.local_candidates ? `, plus ${r.local_candidates} local` : ''),
|
|
109
|
+
].filter(Boolean).join('\n ');
|
|
110
|
+
let out = renderNeeds(r, { historyNote: head });
|
|
111
|
+
if (r.inventory.coverage.mcpServers !== 'read') {
|
|
112
|
+
out += '\n\n!! No MCP config was readable here, so "already covered" below is a' +
|
|
113
|
+
'\n weaker claim than it looks: a server you have may be invisible to this run.';
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doorman report <link>` — L1, the static implementation report.
|
|
3
|
+
*
|
|
4
|
+
* This layer already existed. `mcp-scorecard/runner/run.mjs --once --static-only`
|
|
5
|
+
* wraps the `mcpscore` CLI, runs the scan-only injection probe, and writes
|
|
6
|
+
* grade.json / report.md / recipe.md / transcripts.jsonl to an output directory.
|
|
7
|
+
* What was missing was a name and a stable interface, not the measurement.
|
|
8
|
+
*
|
|
9
|
+
* So this shells to it rather than reimplementing it. Invariant 2 in CLAUDE.md
|
|
10
|
+
* is about exactly this: two implementations drift, and the day they disagree,
|
|
11
|
+
* the one you trusted is whichever you happened to run.
|
|
12
|
+
*
|
|
13
|
+
* L1 needs NO model key. That matters more than it sounds: it means every
|
|
14
|
+
* candidate can get a real static report today, and the pipeline degrades to
|
|
15
|
+
* "measured statically, behaviourally unmeasured" rather than to nothing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { readFile, mkdir } from 'node:fs/promises';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
/**
|
|
26
|
+
* Where the static runner lives.
|
|
27
|
+
*
|
|
28
|
+
* The first version hardcoded `../../mcp-scorecard/runner/run.mjs`, which is
|
|
29
|
+
* true inside this repo and false the moment the package is installed on its
|
|
30
|
+
* own. Since the whole point is that someone else installs this, the lookup
|
|
31
|
+
* now tries the places it could legitimately be and reports every one it
|
|
32
|
+
* checked when it finds none. A path that is right in the author's checkout
|
|
33
|
+
* and wrong everywhere else is the classic giveaway bug.
|
|
34
|
+
*/
|
|
35
|
+
const RUNNER_CANDIDATES = [
|
|
36
|
+
process.env.DOORMAN_SCORECARD_RUNNER, // explicit wins
|
|
37
|
+
path.resolve(HERE, '..', '..', 'mcp-scorecard', 'runner', 'run.mjs'), // in-repo
|
|
38
|
+
path.resolve(HERE, '..', 'vendor', 'scorecard-runner', 'run.mjs'), // vendored
|
|
39
|
+
path.resolve(process.cwd(), 'mcp-scorecard', 'runner', 'run.mjs'), // cwd is the repo
|
|
40
|
+
].filter(Boolean);
|
|
41
|
+
|
|
42
|
+
const RUNNER = RUNNER_CANDIDATES.find((p) => existsSync(p)) ?? RUNNER_CANDIDATES[1];
|
|
43
|
+
|
|
44
|
+
export async function report({ link, out, neededFor, log }) {
|
|
45
|
+
if (!existsSync(RUNNER)) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
why:
|
|
49
|
+
'the scorecard static runner was not found. doorman report delegates the\n' +
|
|
50
|
+
'static layer rather than reimplementing it, so it needs that file.\n\n' +
|
|
51
|
+
'Looked in:\n' + RUNNER_CANDIDATES.map((p) => ` ${p}`).join('\n') + '\n\n' +
|
|
52
|
+
'Set DOORMAN_SCORECARD_RUNNER to its path, or run doorman from a clone of\n' +
|
|
53
|
+
'the repo, where the in-repo path resolves.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await mkdir(out, { recursive: true });
|
|
58
|
+
log(`static layer via ${path.relative(process.cwd(), RUNNER)}`);
|
|
59
|
+
log(`out: ${out}`);
|
|
60
|
+
|
|
61
|
+
const args = [
|
|
62
|
+
RUNNER, '--once',
|
|
63
|
+
'--server', link,
|
|
64
|
+
'--static-only',
|
|
65
|
+
'--out', out,
|
|
66
|
+
];
|
|
67
|
+
if (neededFor) args.push('--needed-for', neededFor);
|
|
68
|
+
|
|
69
|
+
const r = await new Promise((res) =>
|
|
70
|
+
execFile(process.execPath, args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) =>
|
|
71
|
+
res({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' })));
|
|
72
|
+
|
|
73
|
+
for (const line of r.stdout.split('\n').filter(Boolean)) log(' ' + line);
|
|
74
|
+
|
|
75
|
+
const gradePath = path.join(out, 'grade.json');
|
|
76
|
+
if (!existsSync(gradePath)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
why: 'the runner produced no grade.json',
|
|
80
|
+
detail: (r.stderr || r.stdout).slice(-1500),
|
|
81
|
+
hint:
|
|
82
|
+
'The most common cause is that `mcpscore` is not on PATH. It is a Python ' +
|
|
83
|
+
'console script: pip install mcpscore, then export the interpreter Scripts/ dir.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const grade = JSON.parse(await readFile(gradePath, 'utf8'));
|
|
88
|
+
return { ok: true, grade, out, files: ['grade.json', 'report.md', 'recipe.md', 'transcripts.jsonl'] };
|
|
89
|
+
}
|