clembot-doorman 0.1.0 → 0.2.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/README.md CHANGED
@@ -12,7 +12,18 @@
12
12
 
13
13
  ---
14
14
 
15
- ## TL;DR · What is Clembot Doorman?
15
+ ### 🏆 ETHOnline 2026 Prize Tracks & Judge Quick-Links
16
+
17
+ | Track | Prize | Artifact / Manifest | What It Demonstrates |
18
+ |---|---|---|---|
19
+ | **Bazantic · Agentify an API** | $500 / 300 / 200 | [`01-vet-mcp-candidate.json`](./bazantic/recipes/01-vet-mcp-candidate.json) | Agentifies our live OpenAPI 3.1 `mcp-scorecard` service into an autonomous pre-flight tool that evaluates untrusted MCP servers before adoption. |
20
+ | **Bazantic · Continuity** | 2×$500 | [`02-deepwiki-pinned-researcher.json`](./bazantic/recipes/02-deepwiki-pinned-researcher.json) | **87.6% Context Token Reduction**: Crushes 3,400 tokens of raw DeepWiki schema bloat down to 420 tokens using Doorman's cached pinned recipe. |
21
+ | **Bazantic · Best Multi-API Recipe** | $500 / 300 / 200 | [`03-governance-pipeline.json`](./bazantic/recipes/03-governance-pipeline.json) | **3-Gateway Autonomous Pipeline**: Audits tool safety → Settles $0.01 audit micropayment on Base mainnet via x402 → Pins SHA-256 hash to allowlist. |
22
+ | **Base · x402 Micropayments** | Sponsor Track | [`mcp-scorecard/src/routes/spend.ts`](./mcp-scorecard/src/routes/spend.ts) | Real $0.01 per-audit micropayment settlement using EIP-712 spend permits on Base mainnet. |
23
+
24
+ > **Judge Portals**: [Live Interactive Simulator](https://clembot-doorman.wanessalabs.com/#flow) · [Bazantic Showcase Page](https://clembot-doorman.wanessalabs.com/bazantic.html#recipes) · [ETHOnline Judging Criteria](https://clembot-doorman.wanessalabs.com/judges.html) · [2-Minute Verification Runbook](RUNBOOK.md)
25
+
26
+ ---
16
27
 
17
28
  **Clembot is built in many versions, with the latest utilizing Claude Code and a custom combination of agent harnesses.** While AI agents need tools to do real work, loading raw MCP servers blindly introduces **in-context steering ads**, prompt injection risks, and token-draining schema hallucination.
18
29
 
@@ -23,7 +34,7 @@
23
34
  3. **Arm with Bazantic Recipes (`recipe.md`)**
24
35
  Turns raw, unpredictable APIs into bounded, high-performing tools via structured `recipe.md` guidance. Built and verified against the Bazantic gateway (`clembot-doorman.bazgateway.com`).
25
36
 
26
- ![The doorman declining a server it just graded F](media/doorman-decline.gif)
37
+ ![Clembot Doorman live interactive flow and tool governance demo](media/doorman-demo.gif)
27
38
 
28
39
  *A real run against a real server. The grade, the hard fail, and the arithmetic are live values, not a mockup: `webzum.com/api/mcp` scores **89.9% on configuration** and still fails, because one tool description injects **6,290 characters of unprompted upsell and competitor steering** directly into your agent's context window. One cent through the Bazantic gateway answered a question that would have cost $54.47 to measure in an unguided LLM loop. [Run it live in the simulator](https://clembot-doorman.wanessalabs.com/#flow).*
29
40
 
@@ -106,20 +117,25 @@ components that never register:
106
117
  claude plugin details clembot-doorman
107
118
  # Skills (3) doorman, doorman-guide, vet
108
119
  # Agents (1) doorman
109
- # Hooks (1) PreToolUse
120
+ # Hooks (2) PreToolUse, SessionStart
110
121
  # MCP servers (1) scorecard
111
122
  ```
112
123
 
113
- ### 2. The CLI: doctor, needs, report, watch, eval
124
+ ### 2. The CLI: doctor, needs, report, watch, notify, eval
114
125
 
115
126
  ```bash
116
- git clone https://github.com/clemenswan/clembot-doorman
117
- npm i -g ./clembot-doorman
127
+ npm i -g clembot-doorman
118
128
  doorman --version
119
129
  ```
120
130
 
121
131
  Node 20+. Zero runtime dependencies, deliberately: every dependency is one more thing that can fail to install on your machine.
122
132
 
133
+ **`notify` and the SessionStart hook need 0.2.0 or later.** The 0.1.0 on npm was
134
+ published before they landed, so `doorman --version` reporting `0.1.0` means the
135
+ push half is absent and no session will ever announce anything. Nothing errors:
136
+ that build simply stays quiet forever, which is indistinguishable from having no
137
+ news.
138
+
123
139
  **New here?** [`WALKTHROUGH.md`](WALKTHROUGH.md) runs the first ten minutes
124
140
  against three real builds: one with a long history, one brand new, and one in
125
141
  between. The output in it is captured from real runs, not written by hand.
@@ -320,6 +336,54 @@ Doorman is specifically designed to stop "skill sprawl" and prevent duplicate to
320
336
  2. **Adverse Drift Detection (`doorman watch`)**:
321
337
  When you run `doorman watch`, any candidate server already in your inventory is tagged `already-installed`. If an installed server is downgraded or caught with prompt injection on the feed, `watch` raises an immediate security alert.
322
338
 
339
+ 2a. **Push, without a daemon and without telemetry**:
340
+ Nobody can push to a laptop behind NAT that is asleep half the day, so the
341
+ push here is not a new transport. It is the poll going invisible. A
342
+ SessionStart hook prints a digest that is already on disk, then fires a
343
+ detached refresh so the next session is current. The hook makes no network
344
+ call: one that waited on a fetch would make every session start as slow as
345
+ the worst network it has seen, and offline would make them all fail.
346
+
347
+ ```text
348
+ ## doorman
349
+
350
+ 1 newly graded server this build does not have:
351
+ - **A** 85.71/100, model not recorded https://mcp.deepwiki.com/mcp
352
+ not measured: behavioral, guidance
353
+ ```
354
+
355
+ Three rules, and each one is a notification product failing if broken. It
356
+ is **silent when nothing is new**, because a hook that reports "nothing new"
357
+ every morning teaches you to skip past the one morning it matters. It
358
+ **announces nothing on the first run**, because with no cursor the feed
359
+ returns everything graded so far and 26 rows is a catalogue, not news. And a
360
+ digest is **shown exactly once**, because the same three servers every
361
+ morning is how a notification becomes furniture.
362
+
363
+ 2b. **Popularity and trend, as a second axis (`GET /feed`, `?sort=trending`)**:
364
+ Every feed row carries a `popularity` block: Smithery use counts, npm weekly
365
+ downloads and GitHub stars, swept daily, with a median percentile and a
366
+ trend.
367
+
368
+ It is **never part of the score**. The grade is what happened when an agent
369
+ drove the server; popularity is how many people installed it without asking
370
+ that. A popular F is the most useful row this feed can publish, and a
371
+ blended number is the one thing guaranteed to bury it.
372
+
373
+ Counts are **ranked within each source and never summed across them**:
374
+ 87,579 Smithery uses, 4,200 npm downloads and 1,100 stars are three units
375
+ counting three populations, and adding them makes a meaningless number that
376
+ still sorts confidently. `sources_measured` says how many sources backed the
377
+ percentile, because a server ranked on one and a server ranked on three are
378
+ not equally known. A trend needs two readings at least 12 hours apart, so a
379
+ newly tracked server reports `null` rather than zero growth, and a source
380
+ that could not be read is **absent rather than zero**.
381
+
382
+ **Doorman's own install counts are refused as a fourth source.** They would
383
+ be the best popularity signal available to anyone, and collecting them needs
384
+ telemetry. That would sell the guarantee that makes `watch` and `needs`
385
+ worth running at all: your inventory never leaves your machine.
386
+
323
387
  3. **Frontmatter Arithmetic (30 KB vs 640 KB)**:
324
388
  Doorman reads only YAML frontmatter (`name`, `description`) from `.claude/skills/*/SKILL.md` and `.claude/agents/*.md`. In our production vault, reading full markdown bodies was **642 KB**; reading frontmatter was **30 KB**. This allows the complete roster to be reviewed by a model in a single prompt without bloating context.
325
389
 
@@ -645,13 +709,20 @@ node runner/run.mjs --once --server https://mcp.deepwiki.com/mcp \
645
709
  --needed-for "look up how a public repository works" \
646
710
  --static-only --out ../evidence/deepwiki
647
711
 
648
- # Full behavioural run (needs a key)
649
- export ANTHROPIC_API_KEY=...
712
+ # Full behavioural run (needs a key).
713
+ # `=...` is NOT a value: read the secret in rather than pasting a placeholder,
714
+ # which also keeps it out of shell history. A pasted "..." reaches the server
715
+ # as a wrong token and comes back 401, which reads as a broken credential
716
+ # rather than as a placeholder nobody substituted.
717
+ read -rsp 'ANTHROPIC_API_KEY: ' ANTHROPIC_API_KEY && export ANTHROPIC_API_KEY
718
+ # --once PRINTS ONLY. Add --out DIR to keep the evidence bundle, or queue the
719
+ # audit and use --poll below to publish it to the feed.
650
720
  node runner/run.mjs --once --server https://mcp.deepwiki.com/mcp \
651
- --needed-for "look up how a public repository works"
721
+ --needed-for "look up how a public repository works" --out out/deepwiki
652
722
 
653
723
  # Poll the queue
654
- export RUNNER_TOKEN=... SCORECARD_API=http://127.0.0.1:8799
724
+ read -rsp 'RUNNER_TOKEN: ' RUNNER_TOKEN && export RUNNER_TOKEN
725
+ export SCORECARD_API=http://127.0.0.1:8799
655
726
  node runner/run.mjs --poll
656
727
  ```
657
728
 
package/WALKTHROUGH.md CHANGED
@@ -5,6 +5,8 @@ captured from an actual run on 2026-09-11 against the live feed, not written by
5
5
  hand. Where a run produced nothing useful, that is shown too, because "nothing
6
6
  matched" is a result and hiding it would make this document a brochure.
7
7
 
8
+ ![Clembot Doorman live interactive demo walkthrough](media/doorman-demo.gif)
9
+
8
10
  ---
9
11
 
10
12
  ## Install both halves
@@ -13,8 +15,7 @@ matched" is a result and hiding it would make this document a brochure.
13
15
  claude plugin marketplace add clemenswan/clembot-doorman
14
16
  claude plugin install clembot-doorman # the gate, /doorman, /vet, the subagent
15
17
 
16
- git clone https://github.com/clemenswan/clembot-doorman
17
- npm i -g ./clembot-doorman # doctor, needs, report, watch, eval
18
+ npm i -g clembot-doorman # doctor, needs, report, watch, eval
18
19
  ```
19
20
 
20
21
  Check what actually loaded. A manifest can validate and still ship components
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # SessionStart hook. The visible half of the push model.
4
+ #
5
+ # Prints a digest that is ALREADY ON DISK, then fires a detached refresh so the
6
+ # next session is current. It makes no network call itself, which is the whole
7
+ # design: a SessionStart hook that waits on a fetch makes every session start as
8
+ # slow as the worst network it has ever seen, and offline it makes them fail.
9
+ # The cost of this hook on a normal morning is one `test -f` and one `cat`.
10
+ #
11
+ # THIS IS NOT THE GATE. `mcp-gate.sh` is a security control and is offline,
12
+ # dependency-free, and fails CLOSED with exit 2. This is a notification and
13
+ # fails OPEN and silent: it always exits 0, and every failure path prints
14
+ # nothing. A notifier that can break a session start is worse than no notifier.
15
+ #
16
+ # ponytail: the refresh rides on session start, so the first session after
17
+ # install prints nothing (no digest yet) and news is at most one session stale.
18
+ # A background poller (`doorman watch --poll`, roadmap Phase 2) would close that
19
+ # gap and is not worth a daemon yet.
20
+
21
+ set -uo pipefail
22
+
23
+ # Never let this hook be the reason a session fails to start.
24
+ trap 'exit 0' ERR
25
+
26
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
27
+ DIGEST="$PROJECT_DIR/.doorman/notify.md"
28
+
29
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ RESOLVE="$HERE/../../scripts/resolve-cli.sh"
31
+
32
+ # 1. Say what is already known. Reading is destructive: consume-once is what
33
+ # stops the same three servers being announced every morning until they are
34
+ # furniture. `notify consume` does the delete.
35
+ if [ -f "$DIGEST" ]; then
36
+ CLI=""
37
+ [ -x "$RESOLVE" ] && CLI="$(bash "$RESOLVE" 2>/dev/null || true)"
38
+ if [ -n "$CLI" ] && [ "$CLI" != "NOT_FOUND" ]; then
39
+ $CLI notify consume --digest "$DIGEST" 2>/dev/null || true
40
+ else
41
+ # No CLI resolved. Print it anyway and remove it by hand rather than
42
+ # holding news hostage to a resolution problem.
43
+ cat "$DIGEST" 2>/dev/null || true
44
+ rm -f "$DIGEST" 2>/dev/null || true
45
+ fi
46
+ fi
47
+
48
+ # 2. Refresh for next time, detached.
49
+ #
50
+ # stdout and stderr MUST be redirected away from this process. A background
51
+ # child that inherits the hook's stdout keeps the pipe open, and the harness
52
+ # waits on the pipe rather than on the process, so an unredirected `&` turns a
53
+ # fire-and-forget into the exact session-start stall this design avoids.
54
+ if [ -z "${DOORMAN_NO_REFRESH:-}" ]; then
55
+ CLI="${CLI:-}"
56
+ if [ -z "$CLI" ] && [ -x "$RESOLVE" ]; then
57
+ CLI="$(bash "$RESOLVE" 2>/dev/null || true)"
58
+ fi
59
+ if [ -n "$CLI" ] && [ "$CLI" != "NOT_FOUND" ]; then
60
+ LOG="$PROJECT_DIR/.doorman/notify.log"
61
+ mkdir -p "$PROJECT_DIR/.doorman" 2>/dev/null || true
62
+ nohup $CLI notify refresh --root "$PROJECT_DIR" >"$LOG" 2>&1 &
63
+ disown 2>/dev/null || true
64
+ fi
65
+ fi
66
+
67
+ exit 0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "clembot-doorman",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "A gate on what your agent is allowed to use. Blocks MCP servers that are not on your trust list, reads your prompt history to find what the build actually needs, and grades candidates before you install them.",
6
6
  "author": {
7
7
  "name": "Clemens Wan",
@@ -16,6 +16,7 @@
16
16
  doorman needs [path] read this build's own prompt history, propose servers.
17
17
  * doorman watch [path] poll the feed for candidates new to THIS build.
18
18
  * doorman discover sweep a public directory for candidates. Curate, never enqueue.
19
+ * doorman notify refresh|consume the SessionStart push. Called by the hook, not by hand.
19
20
  *
20
21
  * Zero runtime dependencies, per the requirement in package.json. That is why
21
22
  * the YAML loader and the arg parser are in-tree rather than installed.
@@ -29,8 +30,13 @@ import { discover, renderDiscover, writeCandidates } from './discover.mjs';
29
30
  import { needs as readNeeds, render as renderNeedsCli } from './needs.mjs';
30
31
  import { install, renderInstall } from './install.mjs';
31
32
  import { allow, renderAllow, SCOPES } from './allow.mjs';
33
+ import { refreshNotify, consumeDigest, DEFAULT_DIGEST } from './notify.mjs';
34
+ import { join } from 'node:path';
32
35
 
33
- const VERSION = '0.1.0';
36
+ // Pinned to every other declaration by version.test.mjs. There are FOUR of
37
+ // them (root package.json, doorman/package.json, plugin.json, this) and this
38
+ // one silently reported 0.1.0 out of a 0.2.0 tarball.
39
+ const VERSION = '0.2.0';
34
40
 
35
41
  const HELP = `
36
42
  doorman ${VERSION} — measure a candidate, do not just read it
@@ -58,6 +64,12 @@ doorman ${VERSION} — measure a candidate, do not just read it
58
64
  a match is only ever worth-measuring. Nothing here drove anything, so
59
65
  nothing here claims a server will work. Only eval answers that.
60
66
 
67
+ doorman notify refresh [--root DIR] | doorman notify consume [--digest FILE]
68
+ The push half, and the SessionStart hook is what calls it. refresh polls
69
+ the feed and leaves a short digest on disk; consume prints that digest
70
+ and deletes it. Nothing is announced on the first run, nothing is written
71
+ when nothing is new, and a digest is shown exactly once.
72
+
61
73
  doorman report <link> [--out DIR] [--needed-for TEXT]
62
74
  L1. The static implementation report: protocol, schemas, annotations, and
63
75
  a scan-only pass over every description an agent would read before
@@ -248,6 +260,42 @@ async function main() {
248
260
  return;
249
261
  }
250
262
 
263
+ // The push half. Two verbs, and neither is meant to be typed by a human:
264
+ // the SessionStart hook calls `consume`, then `refresh` detached.
265
+ if (cmd === 'notify') {
266
+ const sub = args._[1];
267
+ const root = args.root || process.cwd();
268
+
269
+ if (sub === 'consume') {
270
+ const file = args.digest || join(root, DEFAULT_DIGEST);
271
+ const text = consumeDigest(file);
272
+ if (text) console.log(text);
273
+ // Nothing to say is exit 0 and silence, not an error. See rule 1 in
274
+ // notify.mjs: a notifier that speaks every session gets ignored.
275
+ return;
276
+ }
277
+
278
+ if (sub === 'refresh') {
279
+ const api = (args.api || DEFAULT_API).replace(/\/+$/, '');
280
+ try {
281
+ const r = await refreshNotify({ root, api, limit: Number(args.limit) > 0 ? Number(args.limit) : 50 });
282
+ if (args.json) console.log(JSON.stringify(r, null, 2));
283
+ else if (r.firstRun) console.log('First run: cursor established, nothing announced.');
284
+ else console.log(r.wrote ? `digest written to ${r.digest}` : 'nothing new, no digest written');
285
+ } catch (e) {
286
+ // Could not measure is 3, same as watch. This runs detached from a
287
+ // hook, so the exit code lands in .doorman/notify.log and nowhere else.
288
+ console.error(`notify refresh: ${e.message}`);
289
+ process.exitCode = e.code === 3 ? 3 : 1;
290
+ }
291
+ return;
292
+ }
293
+
294
+ console.error('notify needs a subcommand: consume or refresh');
295
+ process.exitCode = 2;
296
+ return;
297
+ }
298
+
251
299
  if (!['report', 'eval'].includes(cmd)) {
252
300
  console.error(`unknown command "${cmd}". Try: doorman --help`);
253
301
  process.exitCode = 2;
@@ -14,7 +14,7 @@
14
14
  * And safely inspects/wires .claude/settings.json without destructive clobbering.
15
15
  */
16
16
 
17
- import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
18
18
  import { resolve, join, dirname } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
 
@@ -58,6 +58,19 @@ export async function install(targetDir = process.cwd(), { dryRun = false } = {}
58
58
 
59
59
  // 1. Hooks, Subagent, Command
60
60
  copy('.claude/hooks/mcp-gate.sh', '.claude/hooks/mcp-gate.sh');
61
+ // copyFileSync preserves the SOURCE mode, and the source inside an installed
62
+ // npm package is 644: npm pack on Windows drops the executable bit from every
63
+ // file. Verified against the published 0.1.0 tarball, 2026-09-12. Without this
64
+ // the gate lands non-executable, fails to SPAWN rather than exiting 2, and the
65
+ // security control fails open. install.sh has carried the same chmod since it
66
+ // was written; this path never did.
67
+ if (!dryRun) {
68
+ try {
69
+ chmodSync(join(absTarget, '.claude/hooks/mcp-gate.sh'), 0o755);
70
+ } catch {
71
+ warnings.push('could not mark .claude/hooks/mcp-gate.sh executable; run: chmod +x .claude/hooks/mcp-gate.sh');
72
+ }
73
+ }
61
74
  copy('agents/doorman.md', '.claude/agents/doorman.md');
62
75
  copy('commands/vet.md', '.claude/commands/vet.md');
63
76
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The push half of the subscription.
3
+ *
4
+ * WHAT "PUSH" HONESTLY MEANS HERE. Nobody can push to a laptop behind NAT that
5
+ * is asleep half the day, and a doorman that opened a socket to wait for us
6
+ * would be a worse product than one that does not. So the push is not a new
7
+ * transport: it is the POLL BECOMING INVISIBLE. A SessionStart hook reads a
8
+ * digest that is already on disk and costs nothing, then fires a detached
9
+ * refresh so the NEXT session is current. From where the operator sits they
10
+ * never ran a command and the doorman told them anyway, which is the whole
11
+ * claim, and it is true.
12
+ *
13
+ * THREE RULES, AND EACH ONE IS A NOTIFICATION PRODUCT FAILING IF BROKEN:
14
+ *
15
+ * 1. SILENT WHEN THERE IS NOTHING. No digest file is written when nothing is
16
+ * new. A hook that prints "nothing new" at every session start teaches the
17
+ * operator to skip past the one session where it matters.
18
+ *
19
+ * 2. THE FIRST RUN SAYS NOTHING. With no cursor the feed returns everything
20
+ * graded so far. Announcing 26 rows as "new" the first time a plugin loads
21
+ * is not news, it is a catalogue nobody asked for. The first refresh
22
+ * establishes the cursor and writes no digest.
23
+ *
24
+ * 3. CONSUMED ONCE. The hook prints the digest and deletes it. Re-showing the
25
+ * same three servers every morning is how a notification becomes furniture.
26
+ *
27
+ * The network never runs inside the hook. A SessionStart hook that waits on a
28
+ * fetch makes every session start as slow as the slowest network it has ever
29
+ * been on, and offline it makes them all fail.
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
33
+ import { join, dirname } from 'node:path';
34
+ import { watch, readState, writeState, DEFAULT_API, DEFAULT_STATE } from './watch.mjs';
35
+
36
+ export const DEFAULT_DIGEST = join('.doorman', 'notify.md');
37
+
38
+ /** How many rows the digest will name before it stops listing and counts. */
39
+ export const MAX_LISTED = 3;
40
+
41
+ /**
42
+ * The digest, or null for "say nothing".
43
+ *
44
+ * Only two verdicts are ever worth interrupting a session for. `unreviewed` is
45
+ * something you could adopt and have not looked at. `blocked` is something the
46
+ * registry already refuses, and seeing it in a fresh grade is a reason to check
47
+ * whether anyone tried. `already-installed` and `skipped` are not news: the
48
+ * first is a server you have, and the second you decided about.
49
+ */
50
+ export function renderNotify(result, { firstRun = false } = {}) {
51
+ if (firstRun) return null;
52
+
53
+ const rows = result.candidates ?? [];
54
+ const fresh = rows.filter((c) => c.verdict === 'unreviewed' && !c.is_fixture && !c.self_graded);
55
+ const blocked = rows.filter((c) => c.verdict === 'blocked');
56
+ if (!fresh.length && !blocked.length) return null;
57
+
58
+ const out = [];
59
+ out.push('## doorman');
60
+ out.push('');
61
+
62
+ if (fresh.length) {
63
+ const shown = fresh.slice(0, MAX_LISTED);
64
+ const noun = fresh.length === 1 ? 'server' : 'servers';
65
+ out.push(`${fresh.length} newly graded ${noun} this build does not have:`);
66
+ for (const c of shown) {
67
+ // The grade travels with what produced it. A letter with no model behind
68
+ // it invites a comparison across models that the grade cannot support.
69
+ const model = c.model ? ` on ${c.model}` : ', model not recorded';
70
+ const score = c.score === null || c.score === undefined ? 'not scored' : `${c.score}/100`;
71
+ out.push(`- **${c.grade ?? '?'}** ${score}${model} ${c.server_url}`);
72
+ const layers = c.layers ?? {};
73
+ const missing = ['behavioral_pct', 'guidance_pct']
74
+ .filter((k) => layers[k] === null || layers[k] === undefined)
75
+ .map((k) => k.replace('_pct', ''));
76
+ if (missing.length) out.push(` not measured: ${missing.join(', ')}`);
77
+ }
78
+ if (fresh.length > shown.length) {
79
+ out.push(`- and ${fresh.length - shown.length} more`);
80
+ }
81
+ out.push('');
82
+ }
83
+
84
+ if (blocked.length) {
85
+ out.push(`${blocked.length} on your denylist were re-graded. Run \`doorman watch\` for the detail.`);
86
+ out.push('');
87
+ }
88
+
89
+ out.push('Run `doorman needs` to see which of these answer something this build ' +
90
+ 'keeps asking for, or `doorman watch` for the full list. Nothing has been ' +
91
+ 'installed and nothing was sent anywhere: the match runs on this machine.');
92
+ return out.join('\n');
93
+ }
94
+
95
+ /**
96
+ * Fetch, classify, and leave a digest for the next session to find.
97
+ *
98
+ * Advances the cursor whether or not a digest is written, because the cursor
99
+ * tracks what has been SEEN by the subscription rather than what was worth
100
+ * mentioning. Not advancing it on a quiet day would re-announce the same rows
101
+ * the moment something interesting finally landed.
102
+ */
103
+ export async function refreshNotify({
104
+ root = process.cwd(), api = DEFAULT_API, limit = 50,
105
+ stateFile, digestFile, fetchImpl = fetch,
106
+ } = {}) {
107
+ const state = stateFile ?? join(root, DEFAULT_STATE);
108
+ const digest = digestFile ?? join(root, DEFAULT_DIGEST);
109
+
110
+ const prior = readState(state);
111
+ const firstRun = !prior.since;
112
+
113
+ const result = await watch({ root, api, since: prior.since, limit, fetchImpl });
114
+ const text = renderNotify(result, { firstRun });
115
+
116
+ if (text) {
117
+ mkdirSync(dirname(digest), { recursive: true });
118
+ writeFileSync(digest, text + '\n', 'utf8');
119
+ }
120
+
121
+ if (result.next_since) {
122
+ writeState(state, { since: result.next_since, seen: (prior.seen ?? 0) + result.candidates.length });
123
+ }
124
+
125
+ return { wrote: Boolean(text), firstRun, digest, candidates: result.candidates.length };
126
+ }
127
+
128
+ /**
129
+ * What the hook prints. Reading is destructive on purpose: see rule 3.
130
+ *
131
+ * Any failure here returns nothing rather than throwing. This runs at session
132
+ * start, and a notification that can break a session start is worse than no
133
+ * notification.
134
+ */
135
+ export function consumeDigest(file) {
136
+ try {
137
+ if (!file || typeof file !== 'string') return null;
138
+ if (!existsSync(file)) return null;
139
+ const text = readFileSync(file, 'utf8').trim();
140
+ unlinkSync(file);
141
+ return text || null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
@@ -29,8 +29,7 @@ If it prints `NOT_FOUND`, stop and tell the user this, then end the turn:
29
29
  > Reinstall the plugin, or install the CLI directly:
30
30
  >
31
31
  > ```bash
32
- > git clone https://github.com/clemenswan/clembot-doorman
33
- > npm i -g ./clembot-doorman
32
+ > npm i -g clembot-doorman
34
33
  > ```
35
34
  >
36
35
  > The gate is still running either way. It is pure bash and depends on none of
@@ -11,7 +11,16 @@
11
11
  "",
12
12
  "Timeout matches .claude/settings.json. The gate does no network and no",
13
13
  "subprocess, so 5s is generous; it exists so a wedged hook fails rather",
14
- "than hanging every tool call."
14
+ "than hanging every tool call.",
15
+ "",
16
+ "BOTH COMMANDS RUN THROUGH `bash` ON PURPOSE. npm pack on Windows drops",
17
+ "the executable bit from every file, so the .sh files inside an installed",
18
+ "package are 644 and cannot be invoked by path. Verified against the",
19
+ "published 0.1.0 tarball on 2026-09-12: mcp-gate.sh ships -rw-r--r--.",
20
+ "A gate that cannot start does not exit 2, it fails to spawn, so the",
21
+ "security control FAILS OPEN, which is the one thing its design forbids.",
22
+ "Invoking through the interpreter makes the mode irrelevant on every",
23
+ "platform. Do not `simplify` this back to a bare path."
15
24
  ],
16
25
  "hooks": {
17
26
  "PreToolUse": [
@@ -20,11 +29,33 @@
20
29
  "hooks": [
21
30
  {
22
31
  "type": "command",
23
- "command": "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/mcp-gate.sh",
32
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/.claude/hooks/mcp-gate.sh",
24
33
  "timeout": 5
25
34
  }
26
35
  ]
27
36
  }
37
+ ],
38
+ "SessionStart": [
39
+ {
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/.claude/hooks/session-notify.sh",
44
+ "timeout": 10
45
+ }
46
+ ]
47
+ }
28
48
  ]
29
- }
49
+ },
50
+ "$comment2": [
51
+ "SessionStart is the PUSH surface, and it is a different kind of hook from",
52
+ "the gate above. The gate is a security control: offline, dependency-free,",
53
+ "and it fails CLOSED with exit 2. The notifier fails OPEN and silent, always",
54
+ "exits 0, and prints nothing on every error path, because a notification",
55
+ "that can break a session start is worse than no notification.",
56
+ "",
57
+ "It makes no network call. It prints a digest already on disk and fires a",
58
+ "detached refresh for next time, so the cost on a normal session is one",
59
+ "file test. The 10s timeout is for a slow disk, not for a fetch."
60
+ ]
30
61
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clembot-doorman",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Vets MCP servers, skills and repos before an agent gets them. Fit review first, paid grade second.",
@@ -32,5 +32,9 @@ Worth separating, because the project doc uses one word for both.
32
32
  its own past is not a record. The account is still the blocker, not the spec.
33
33
  Full detail in the root `README.md` under **Bazantic**.
34
34
 
35
- Filing these does not advance the prize recipes. It fills a directory the
36
- project doc asked for and that shipped empty.
35
+ The three canonical Bazantic platform prize recipes are published in
36
+ [`bazantic/recipes/`](../../bazantic/recipes/):
37
+ 1. `01-vet-mcp-candidate.json` — Agentify an API ($500/300/200)
38
+ 2. `02-deepwiki-pinned-researcher.json` — Help an Agent Use Your Project / Continuity (2×$500)
39
+ 3. `03-governance-pipeline.json` — Best Multi-API Recipe ($500/300/200)
40
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clembot-doorman",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The Package Manager & Security Doorman for Claude Code: inspect your build, recommend vetted MCPs from prompt history, and block rogue tools before context.",
5
5
  "homepage": "https://clembot-doorman.wanessalabs.com",
6
6
  "type": "module",