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,491 @@
1
+ /**
2
+ * `doorman needs` - what THIS build keeps reaching for, read from its own history.
3
+ *
4
+ * The gap this closes. `doctor` reads what a build HAS. `watch` reads what has
5
+ * been graded lately and says which rows are new to this build. Neither one can
6
+ * answer the question anybody actually asks first, which is "what should I
7
+ * install". `watch` refuses to answer it on purpose: it does a mechanical
8
+ * overlap check and the comment at the top of that file says in as many words
9
+ * that it must never emit the word `fits`.
10
+ *
11
+ * So this reads a third thing: the prompts already typed into this build. Not
12
+ * the code, not the config, the asks. A build whose operator has typed
13
+ * "cloudflare" forty times and has no deployment server is a measurable gap,
14
+ * and the evidence is a count of their own sentences rather than a guess about
15
+ * their intentions.
16
+ *
17
+ * WHAT THIS IS NOT, and the file is structured so it cannot drift into it:
18
+ *
19
+ * - It is not a recommendation that a server will work. Nothing here drives
20
+ * anything. A match means the candidate's OWN published text claims the
21
+ * capability this build keeps asking for. That is a reason to measure it,
22
+ * which is why the verdict word is `worth-measuring` and never `fits`.
23
+ * - It never scores or grades. Grades come from the feed, already measured,
24
+ * and a candidate with no grade is reported as ungraded rather than given
25
+ * a benefit of the doubt.
26
+ * - It never sends the history anywhere. Reading is local, matching is local,
27
+ * and the single network call in the CLI is the same anonymous GET /feed
28
+ * that `watch` makes. The prompts never leave the machine.
29
+ *
30
+ * A need with NO candidate is reported, not dropped. "Nothing graded covers
31
+ * this" is the most useful line in the output and the easiest one to lose.
32
+ */
33
+
34
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import { homedir } from 'node:os';
37
+
38
+ /**
39
+ * The capability taxonomy.
40
+ *
41
+ * Every term is a substring matched on word boundaries, and the matched term is
42
+ * carried into the output so a human can see exactly why a line appeared.
43
+ * Terms are deliberately specific. `search` alone would match "search the
44
+ * codebase", which is not a web-search need; `search the web` would not.
45
+ *
46
+ * This list is short on purpose. A taxonomy with eighty buckets looks thorough
47
+ * and cannot be checked by hand against a real corpus, and an unchecked bucket
48
+ * is a confident wrong answer waiting for a user.
49
+ */
50
+ export const NEEDS = [
51
+ {
52
+ id: 'docs-lookup',
53
+ label: 'Current documentation for a library it does not know',
54
+ why: 'A model answers from training data. Where the library moved, it answers wrongly and confidently.',
55
+ terms: ['read the docs', 'documentation for', 'api reference', 'latest docs',
56
+ 'official docs', 'deepwiki', 'context7', 'library docs', 'check the docs'],
57
+ catalog: ['docs', 'documentation', 'javadocs', 'devdocs'],
58
+ },
59
+ {
60
+ id: 'web-search',
61
+ label: 'Reading the live web',
62
+ why: 'Anything after the cutoff is unreachable without a fetch.',
63
+ terms: ['search the web', 'web search', 'search online', 'look it up online',
64
+ 'google it', 'latest news', 'what is the current', 'find current'],
65
+ catalog: ['search'],
66
+ },
67
+ {
68
+ id: 'browser-automation',
69
+ label: 'Driving a real browser',
70
+ why: 'Layout, contrast and rendering cannot be asserted from source. This project learned that twice.',
71
+ terms: ['playwright', 'puppeteer', 'headless chrome', 'browser automation',
72
+ 'screenshot the page', 'in a real browser', 'click the button'],
73
+ catalog: ['browser', 'screenshot'],
74
+ },
75
+ {
76
+ id: 'code-host',
77
+ label: 'Issues, pull requests and repository state',
78
+ why: 'Reading a repo from disk misses everything that lives in the forge.',
79
+ terms: ['github', 'pull request', 'open a pr', 'gitlab', 'github issue',
80
+ 'the pr ', 'merge the pr'],
81
+ catalog: ['gitlab', 'repo', 'git'],
82
+ },
83
+ {
84
+ id: 'database',
85
+ label: 'Querying the database directly',
86
+ why: 'Guessing at a schema produces migrations that pass review and fail on real rows.',
87
+ terms: ['supabase', 'postgres', 'sql query', 'the database', 'run a migration',
88
+ 'd1 database'],
89
+ catalog: ['sql', 'database', 'duckdb', 'sqlite'],
90
+ },
91
+ {
92
+ id: 'observability',
93
+ label: 'Production errors and logs',
94
+ why: 'A bug that only exists in production is invisible to every local test.',
95
+ terms: ['sentry', 'error tracking', 'production logs', 'tail the logs',
96
+ 'stack trace from prod', 'observability', 'production error'],
97
+ catalog: ['logs', 'metrics', 'tracing'],
98
+ },
99
+ {
100
+ id: 'cloud-deploy',
101
+ label: 'Deploying, and reading back what deployed',
102
+ why: 'Most of this vault\u2019s recorded deploy failures were invisible until something read the deployment back.',
103
+ terms: ['cloudflare', 'wrangler', 'pages deploy', 'deploy it', 'deploy so i can',
104
+ 'cloudflare worker', 'vercel', 'netlify'],
105
+ catalog: ['deploy', 'hosting'],
106
+ },
107
+ {
108
+ id: 'design-assets',
109
+ label: 'Design files and rendered output',
110
+ why: 'A design system in a file and a design on screen drift, and only one of them is what a visitor sees.',
111
+ terms: ['figma', 'design tokens', 'og image', 'the mockup', 'brand.md',
112
+ 'take a screenshot'],
113
+ catalog: ['design', 'screenshot'],
114
+ },
115
+ {
116
+ id: 'payments',
117
+ label: 'Payments and settlement',
118
+ why: 'A money path that is never exercised end to end is a claim, not a feature.',
119
+ terms: ['stripe', 'x402', 'checkout session', 'usdc', 'payment intent', 'take a payment'],
120
+ catalog: ['payment', 'wallet', 'invoice'],
121
+ },
122
+ {
123
+ id: 'knowledge-base',
124
+ label: 'A knowledge base outside the repo',
125
+ why: 'Decisions recorded somewhere the agent cannot read get re-litigated every session.',
126
+ terms: ['Notion', 'notion.so', 'obsidian vault', 'the wiki', 'wiki page', 'knowledge base'],
127
+ catalog: ['wiki', 'memory', 'docs vault'],
128
+ },
129
+ {
130
+ id: 'comms',
131
+ label: 'Messaging and calendar',
132
+ why: 'Output with no delivery surface is why thirty-five routines in this vault are still switched off.',
133
+ terms: ['Slack', 'slack channel', 'slack message', 'post to slack', 'send an email',
134
+ 'gmail', 'google calendar', 'calendar invite', 'telegram'],
135
+ catalog: ['email', 'calendar', 'messaging'],
136
+ },
137
+ {
138
+ id: 'data-files',
139
+ label: 'Spreadsheets, PDFs and tabular data',
140
+ why: 'Tabular and binary formats are where a text-only agent silently reads nothing.',
141
+ terms: ['spreadsheet', 'google sheet', 'the csv', 'a pdf', 'xlsx', 'the pdf'],
142
+ catalog: ['csv', 'excel', 'sheets', 'pdf'],
143
+ },
144
+ ];
145
+
146
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
147
+
148
+ /**
149
+ * A term ruled OUT rather than asked for.
150
+ *
151
+ * "I would like to avoid supabase" and "no Postgres, that is enterprise
152
+ * weight" both mention a capability in order to reject it. Counting those as
153
+ * demand inverts the signal completely, and both appear in the corpus this was
154
+ * measured against.
155
+ */
156
+ const NEGATED = /\b(no|not|never|avoid|avoiding|without|instead of|rather than)\s+(\w+\s+){0,2}$/i;
157
+
158
+ /**
159
+ * First term in `terms` that appears in `text`, or null.
160
+ *
161
+ * Three things are going on here and each one came from a hand-check of 94 real
162
+ * matches, not from imagination:
163
+ *
164
+ * BOUNDARIES are applied only where the term's own edge is a word character,
165
+ * so a term ending in a space (`the pr `) keeps the space that makes it
166
+ * specific instead of having it eaten by a `\b`.
167
+ *
168
+ * CASE MATTERS when the term contains a capital. `Slack` and `Notion` are
169
+ * product names that collide with ordinary English words, and the corpus had
170
+ * five such collisions: "64px slack at 390px", "the 180px of footer slack",
171
+ * "advanceMatch has no notion of a round winner". A capital letter is the
172
+ * cheapest discriminator that exists and it costs nothing to honour. Same
173
+ * trick the injection scanner uses on `steer-from-competitor`.
174
+ *
175
+ * A PATH IS NOT AN ASK. `.obsidian/` in an exclusion list and
176
+ * `logs/queue/.runner.lock` are filesystem paths. A term glued to a dot on
177
+ * its left or a slash on its right is part of one.
178
+ */
179
+ export function termHit(text, terms) {
180
+ const raw = String(text ?? '');
181
+ for (const t of terms) {
182
+ const cased = /[A-Z]/.test(t);
183
+ const hay = cased ? raw : raw.toLowerCase();
184
+ const lead = /^\w/.test(t) ? '\\b' : '';
185
+ const tail = /\w$/.test(t) ? '\\b' : '';
186
+ const re = new RegExp(lead + escape(t) + tail, 'g');
187
+ let m;
188
+ while ((m = re.exec(hay)) !== null) {
189
+ const before = hay.slice(Math.max(0, m.index - 40), m.index);
190
+ const after = hay.slice(m.index + t.length, m.index + t.length + 1);
191
+ // A LEADING DOT means a dotfile (` .obsidian/`). A dot with a word
192
+ // character in front of it is a hostname (`docs.mcp.cloudflare.com`), and
193
+ // rejecting those threw away the best-graded candidate in the first real
194
+ // run: an A-grade Cloudflare docs server, invisible because of a dot.
195
+ if (/(^|[^\w])\.$/.test(before) || after === '/') continue;
196
+ if (NEGATED.test(before)) continue;
197
+ return t;
198
+ }
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /** Where a harness keeps its transcripts, and whether this one can be read. */
204
+ export function historyDirFor(root, { home = homedir() } = {}) {
205
+ // Claude Code encodes the project path by replacing every non-alphanumeric
206
+ // character with a dash. `C:\Users\x\Vault` becomes `C--Users-x-Vault`.
207
+ const slug = String(root).replace(/[^A-Za-z0-9]/g, '-');
208
+ const dir = join(home, '.claude', 'projects', slug);
209
+ return existsSync(dir) ? dir : null;
210
+ }
211
+
212
+ const NOISE = [
213
+ '<system-reminder>', '<local-command-stdout>', '<command-name>',
214
+ 'Caveat: The messages below', 'tool_use_id',
215
+ ];
216
+
217
+ /**
218
+ * Is this transcript record something a human actually typed?
219
+ *
220
+ * THIS FUNCTION IS THE WHOLE ACCURACY OF THE COMMAND, and the first version of
221
+ * it was wrong in a way that looked right. Claude Code files a great deal on
222
+ * the `user` channel that no user wrote: every tool result, every hook
223
+ * attachment, every compaction summary, and the full expanded body of a slash
224
+ * command. In this vault that is 1548 of 1656 `user` records. Counting them
225
+ * produced a confident report whose evidence quotes were fragments of skill
226
+ * files, and a build that had never once asked for a thing would be told it
227
+ * asks constantly.
228
+ *
229
+ * The discriminators are structural, not textual, which is why they hold:
230
+ *
231
+ * toolUseResult present on a result, absent on a prompt
232
+ * isMeta a record the harness filed about itself
233
+ * isCompactSummary a summary Claude wrote, attributed to the user channel
234
+ * text beginning `# /` a slash command's expanded body, not the ask
235
+ *
236
+ * The slash-command case is the subtle one. `/session-end` IS a real user
237
+ * action, but what lands in the transcript is the skill's entire markdown body,
238
+ * so counting it measures the skill file rather than the person.
239
+ */
240
+ export function isRealPrompt(rec) {
241
+ if (rec.type !== 'user' || rec.isSidechain) return false;
242
+ if (rec.toolUseResult !== undefined) return false;
243
+ if (rec.isMeta || rec.isCompactSummary || rec.isVisibleInTranscriptOnly) return false;
244
+ return true;
245
+ }
246
+
247
+ /**
248
+ * User prompts from a Claude Code transcript directory.
249
+ *
250
+ * Deduplicated by text. A resumed session copies the whole prior transcript
251
+ * into a new file, so without this the same sentence is counted once per
252
+ * resume, and the busiest needs are simply the ones from the most-resumed
253
+ * session.
254
+ */
255
+ export function readPrompts(dir, { limit = 4000, minChars = 12, fs = { readdirSync, readFileSync, statSync } } = {}) {
256
+ const files = fs.readdirSync(dir)
257
+ .filter((f) => f.endsWith('.jsonl'))
258
+ .map((f) => join(dir, f));
259
+
260
+ const out = [];
261
+ const seen = new Set();
262
+ for (const file of files) {
263
+ let body;
264
+ try { body = fs.readFileSync(file, 'utf8'); } catch { continue; }
265
+ for (const line of body.split('\n')) {
266
+ if (!line.startsWith('{')) continue;
267
+ let rec;
268
+ try { rec = JSON.parse(line); } catch { continue; }
269
+ if (!isRealPrompt(rec)) continue;
270
+ const content = rec.message?.content;
271
+ let text = null;
272
+ if (typeof content === 'string') text = content;
273
+ else if (Array.isArray(content)) {
274
+ const parts = content.filter((p) => p?.type === 'text').map((p) => p.text);
275
+ if (parts.length) text = parts.join('\n');
276
+ }
277
+ if (!text || text.length < minChars) continue;
278
+ if (text.trimStart().startsWith('# /')) continue;
279
+ if (NOISE.some((n) => text.includes(n))) continue;
280
+ const key = text.trim().slice(0, 400);
281
+ if (seen.has(key)) continue;
282
+ seen.add(key);
283
+ out.push({ text, session: rec.sessionId ?? null });
284
+ if (out.length >= limit) return out;
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /** Count, per need, how often this build has asked for it. */
291
+ export function signalsFrom(prompts) {
292
+ const rows = NEEDS.map((n) => ({
293
+ id: n.id, label: n.label, why: n.why, hits: 0, sessions: new Set(), terms: new Set(), examples: [],
294
+ }));
295
+ const byId = new Map(rows.map((r) => [r.id, r]));
296
+
297
+ for (const p of prompts) {
298
+ for (const need of NEEDS) {
299
+ const term = termHit(p.text, need.terms);
300
+ if (!term) continue;
301
+ const row = byId.get(need.id);
302
+ row.hits += 1;
303
+ row.terms.add(term);
304
+ if (p.session) row.sessions.add(p.session);
305
+ // Two examples is enough to check a match by eye and few enough that the
306
+ // output stays readable. The excerpt is the operator's own words.
307
+ if (row.examples.length < 2) row.examples.push(excerpt(p.text, term));
308
+ }
309
+ }
310
+
311
+ return rows
312
+ .map((r) => ({ ...r, sessions: r.sessions.size, terms: [...r.terms] }))
313
+ .filter((r) => r.hits > 0)
314
+ .sort((a, b) => b.hits - a.hits || a.id.localeCompare(b.id));
315
+ }
316
+
317
+ /** A window around the matched term, so the reader can judge the match. */
318
+ export function excerpt(text, term, width = 90) {
319
+ const at = text.toLowerCase().indexOf(term.toLowerCase());
320
+ const from = Math.max(0, at - Math.floor((width - term.length) / 2));
321
+ const cut = text.slice(from, from + width).replace(/\s+/g, ' ').trim();
322
+ return (from > 0 ? '\u2026' : '') + cut + (from + width < text.length ? '\u2026' : '');
323
+ }
324
+
325
+ /**
326
+ * Which needs this build already covers, and what covers them.
327
+ *
328
+ * Matched against the servers' own names, urls and the MCP tool names the
329
+ * agents hold, because that is the only capability text a local inventory has.
330
+ */
331
+ export function coveredBy(inv) {
332
+ const text = [];
333
+ for (const s of inv.mcpServers ?? []) {
334
+ // `tools` is not always an array. Different inventory sources populate it
335
+ // differently, and spreading a non-array threw `is not iterable` from
336
+ // inside a read-only command, which is the last place a crash belongs.
337
+ const tools = Array.isArray(s.tools) ? s.tools
338
+ : s.tools && typeof s.tools === 'object' ? Object.keys(s.tools)
339
+ : [];
340
+ text.push([s.name, s.url, ...tools].filter(Boolean).join(' '));
341
+ }
342
+ for (const a of inv.allowlisted ?? []) text.push([a.name, a.url].filter(Boolean).join(' '));
343
+
344
+ const covered = new Map();
345
+ for (const need of NEEDS) {
346
+ for (const t of text) {
347
+ const term = termHit(t, need.terms);
348
+ if (term) { covered.set(need.id, { by: t.trim().slice(0, 80), term }); break; }
349
+ }
350
+ }
351
+ return covered;
352
+ }
353
+
354
+ /**
355
+ * Capability text a candidate published about itself. Never our words.
356
+ *
357
+ * URLS INSIDE THE DESCRIPTION ARE STRIPPED, and `homepage` is not read at all.
358
+ * Nearly every MCP server on a public registry links its source on github.com,
359
+ * so matching on that link makes a weather server and a paper search look like
360
+ * answers to "I need pull requests". Both showed up in the first real run.
361
+ *
362
+ * The server's OWN url survives, because `docs.mcp.cloudflare.com` is a genuine
363
+ * statement about what the thing does rather than an incidental link.
364
+ */
365
+ export function candidateText(c) {
366
+ const described = String(c.description ?? '').replace(/https?:\/\/\S+/g, ' ');
367
+ return [c.name, c.server_name, c.id, c.server_url, described,
368
+ ...(Array.isArray(c.tool_names) ? c.tool_names : [])]
369
+ .filter(Boolean).join(' ');
370
+ }
371
+
372
+ /**
373
+ * Candidates whose own published text claims a need.
374
+ *
375
+ * `blocked` outranks everything: a candidate that hard-failed or graded F is
376
+ * still listed, because suppressing it would make a fixable gap look like an
377
+ * empty one, but it can never be the thing suggested.
378
+ */
379
+ export function rankCandidates(need, candidates, installed = new Set()) {
380
+ const out = [];
381
+ // A PRODUCT NAME IS NOT PROSE, and the two need different thresholds.
382
+ //
383
+ // The prompt side has to be strict: `search` would match "search the
384
+ // codebase". A candidate called `exa-search-server` is not ambiguous in the
385
+ // same way, because nobody names a server after an incidental verb. Holding
386
+ // both sides to the prose threshold made the graded half of the catalogue
387
+ // match WORST: the A-graded Exa and Astro Docs servers were invisible for
388
+ // exactly the needs they serve, while ungraded registry rows with long
389
+ // marketing descriptions surfaced instead. That is backwards, and the graded
390
+ // rows are the ones worth anything.
391
+ const terms = [...need.terms, ...(need.catalog ?? [])];
392
+ for (const c of candidates) {
393
+ if (c.is_fixture || c.self_graded) continue;
394
+ const term = termHit(candidateText(c), terms);
395
+ if (!term) continue;
396
+ const url = c.server_url ?? c.gradeable_endpoint ?? c.registry_endpoint ?? c.homepage ?? null;
397
+ const blocked = Boolean(c.hard_fail) || c.grade === 'F';
398
+ out.push({
399
+ name: c.server_name ?? c.name ?? c.id ?? url,
400
+ url,
401
+ grade: c.grade ?? null,
402
+ score: typeof c.score === 'number' ? c.score : null,
403
+ hard_fail: c.hard_fail ?? null,
404
+ graded: c.grade != null,
405
+ source: c.source ?? (c.audit_id ? 'feed' : 'candidates'),
406
+ transcripts: c.transcripts ?? null,
407
+ matched: term,
408
+ verdict: installed.has(url) ? 'already-installed'
409
+ : blocked ? 'blocked'
410
+ : c.grade == null ? 'ungraded'
411
+ : 'worth-measuring',
412
+ });
413
+ }
414
+ // Graded and unblocked first, then by score, then by whether anything is known.
415
+ const rank = { 'worth-measuring': 0, ungraded: 1, blocked: 2, 'already-installed': 3 };
416
+ return out.sort((a, b) => rank[a.verdict] - rank[b.verdict] || (b.score ?? -1) - (a.score ?? -1));
417
+ }
418
+
419
+ /**
420
+ * The whole report. Pure: every input is passed in, so this is testable with no
421
+ * network, no filesystem and no model.
422
+ */
423
+ export function suggest({ prompts, inventory, candidates = [], installed = new Set() }) {
424
+ const signals = signalsFrom(prompts);
425
+ const covered = coveredBy(inventory ?? {});
426
+
427
+ const needs = signals.map((s) => {
428
+ const cover = covered.get(s.id) ?? null;
429
+ const matches = cover ? [] : rankCandidates(NEEDS.find((n) => n.id === s.id), candidates, installed);
430
+ return {
431
+ ...s,
432
+ covered: Boolean(cover),
433
+ covered_by: cover?.by ?? null,
434
+ candidates: matches,
435
+ // The line that must never be dropped. A need nothing graded covers is a
436
+ // gap in the feed, and saying so is more useful than saying nothing.
437
+ gap: !cover && matches.length === 0,
438
+ };
439
+ });
440
+
441
+ return {
442
+ generated_at: new Date().toISOString(),
443
+ prompts_read: prompts.length,
444
+ needs,
445
+ unmet: needs.filter((n) => !n.covered).length,
446
+ gaps: needs.filter((n) => n.gap).length,
447
+ };
448
+ }
449
+
450
+ export function renderNeeds(r, { historyNote = null } = {}) {
451
+ const L = [];
452
+ L.push('');
453
+ L.push(`doorman needs \u2014 ${r.prompts_read} prompts read from this build\u2019s own history`);
454
+ if (historyNote) L.push(` ${historyNote}`);
455
+ L.push('');
456
+
457
+ if (!r.needs.length) {
458
+ L.push(' Nothing in the taxonomy matched. That is a real answer: either this');
459
+ L.push(' build has not asked for any of the twelve capabilities doorman knows');
460
+ L.push(' how to look for, or the history it could read is too short to tell.');
461
+ return L.join('\n');
462
+ }
463
+
464
+ for (const n of r.needs) {
465
+ const head = n.covered ? 'COVERED' : n.gap ? 'GAP' : 'UNMET';
466
+ L.push(`${head.padEnd(8)} ${n.label}`);
467
+ L.push(` ${n.hits} prompts across ${n.sessions} sessions \u00b7 matched ${n.terms.map((t) => `"${t}"`).join(', ')}`);
468
+ for (const e of n.examples) L.push(` > ${e}`);
469
+ if (n.covered) {
470
+ L.push(` already covered by: ${n.covered_by}`);
471
+ } else if (n.gap) {
472
+ L.push(' nothing graded covers this. The feed has the gap, not your build.');
473
+ } else {
474
+ for (const c of n.candidates.slice(0, 3)) {
475
+ const g = c.graded ? `${c.grade}${c.score != null ? ` (${c.score})` : ''}` : 'ungraded';
476
+ L.push(` ${c.verdict.padEnd(16)} ${c.name} [${g}] matched "${c.matched}"`);
477
+ if (c.hard_fail) L.push(` hard fail: ${c.hard_fail}`);
478
+ if (c.url) L.push(` ${c.url}`);
479
+ }
480
+ }
481
+ L.push('');
482
+ }
483
+
484
+ L.push(`${r.unmet} unmet, ${r.gaps} of them with nothing graded to offer.`);
485
+ L.push('');
486
+ L.push('What this is: your own prompts, counted, against capability text those');
487
+ L.push('candidates published about themselves. Nothing here was driven, so');
488
+ L.push('nothing here is a claim that a server works. `worth-measuring` means');
489
+ L.push('exactly that \u2014 run `doorman eval` with your key and find out.');
490
+ return L.join('\n');
491
+ }