clembot-doorman 0.1.0 โ†’ 0.2.1

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
@@ -1,18 +1,31 @@
1
- <img src="logo.png" alt="Clembot Doorman" width="150" align="right">
1
+ <p align="center">
2
+ <img src="media/hero.png" alt="Clembot Doorman - The Package Manager and Security Gate for AI Agents" width="100%">
3
+ </p>
2
4
 
3
5
  # Clembot Doorman
4
6
 
5
- > **The Package Manager & Security Doorman for Clembot.**
7
+ > **The Package Manager & Security Doorman for AI Agents.**
6
8
  > Inspect your build. Recommend vetted MCPs from prompt history. Block rogue tools before they reach context.
7
9
 
8
10
  [![Live Explainer & Simulator](https://img.shields.io/badge/Live%20Explainer-clembot--doorman.wanessalabs.com-green.svg)](https://clembot-doorman.wanessalabs.com)
9
11
  [![Gateway](https://img.shields.io/badge/Bazantic%20Gateway-active-blue.svg)](https://clembot-doorman.bazgateway.com)
10
- [![Tests](https://img.shields.io/badge/Tests-403%20passing-brightgreen.svg)](RUNBOOK.md)
12
+ [![Tests](https://img.shields.io/badge/Tests-505%20passing-brightgreen.svg)](RUNBOOK.md)
11
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
14
 
13
15
  ---
14
16
 
15
- ## TL;DR ยท What is Clembot Doorman?
17
+ ### ๐Ÿ† ETHOnline 2026 Prize Tracks & Judge Quick-Links
18
+
19
+ | Track | Prize | Artifact / Manifest | What It Demonstrates |
20
+ |---|---|---|---|
21
+ | **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. |
22
+ | **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. |
23
+ | **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. |
24
+ | **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. |
25
+
26
+ > **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)
27
+
28
+ ---
16
29
 
17
30
  **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
31
 
@@ -23,12 +36,62 @@
23
36
  3. **Arm with Bazantic Recipes (`recipe.md`)**
24
37
  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
38
 
26
- ![The doorman declining a server it just graded F](media/doorman-decline.gif)
39
+ ![Clembot Doorman live interactive flow and tool governance demo](media/doorman-demo.gif)
27
40
 
28
41
  *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
42
 
30
43
  ---
31
44
 
45
+ ## ๐Ÿ“ธ Architecture & Platform Gallery
46
+
47
+ <table>
48
+ <tr>
49
+ <td width="50%" align="center" valign="top">
50
+ <a href="media/gallery-03-two-phase-solution.png"><img src="media/gallery-03-two-phase-solution.png" alt="2 phase solution with Clembot Doorman" width="100%"></a><br>
51
+ <b>2-Phase Defense Solution with Clembot Doorman</b><br>
52
+ <i>$0.00 local pre-screening before $0.01 Base mainnet verification</i>
53
+ </td>
54
+ <td width="50%" align="center" valign="top">
55
+ <a href="media/gallery-04-tiered-architecture.png"><img src="media/gallery-04-tiered-architecture.png" alt="Tiered Clembot-doorman Architecture" width="100%"></a><br>
56
+ <b>Tiered Clembot-Doorman Architecture</b><br>
57
+ <i>Four modular layers (L0 Doctor โ†’ L1 Needs โ†’ L2 Offline Gate โ†’ L3 Scorecard)</i>
58
+ </td>
59
+ </tr>
60
+ <tr>
61
+ <td width="50%" align="center" valign="top">
62
+ <a href="media/gallery-01-bazantic-gateway.png"><img src="media/gallery-01-bazantic-gateway.png" alt="Bazantic doorman gateway" width="100%"></a><br>
63
+ <b>Bazantic Doorman Gateway</b><br>
64
+ <i>Live edge gateway (<code>clembot-doorman.bazgateway.com</code>) with 10 managed tools</i>
65
+ </td>
66
+ <td width="50%" align="center" valign="top">
67
+ <a href="media/gallery-02-bazantic-recipes.png"><img src="media/gallery-02-bazantic-recipes.png" alt="Bazantic recipes" width="100%"></a><br>
68
+ <b>Bazantic Recipes</b><br>
69
+ <i>Three published production recipes delivering 87% context token reduction</i>
70
+ </td>
71
+ </tr>
72
+ <tr>
73
+ <td width="50%" align="center" valign="top">
74
+ <a href="media/gallery-05-powered-by-bazantic.png"><img src="media/gallery-05-powered-by-bazantic.png" alt="Powered by Bazantic" width="100%"></a><br>
75
+ <b>Powered by Bazantic</b><br>
76
+ <i>Deep technical integration: Continuity, Edge Infrastructure, and Cryptographic Allowlisting</i>
77
+ </td>
78
+ <td width="50%" align="center" valign="top">
79
+ <a href="media/gallery-06-doorman-demo.png"><img src="media/gallery-06-doorman-demo.png" alt="Doorman demo" width="100%"></a><br>
80
+ <b>Doorman Demo</b><br>
81
+ <i>Interactive flow testing real MCPs with toggleable Base x402 micropayments</i>
82
+ </td>
83
+ </tr>
84
+ <tr>
85
+ <td colspan="2" align="center" valign="top">
86
+ <a href="media/gallery-07-clembot-org-chart.png"><img src="media/gallery-07-clembot-org-chart.png" alt="Clembot org chart" width="100%"></a><br>
87
+ <b>Clembot Organization Chart</b><br>
88
+ <i>27 autonomous agents across 5 divisions: Design Director is the sole surface gated by Doorman</i>
89
+ </td>
90
+ </tr>
91
+ </table>
92
+
93
+ ---
94
+
32
95
  ## What this is for
33
96
 
34
97
  Doorman measures whether a candidate tool actually helps **your** agent, and gives you a report about **your** build.
@@ -66,6 +129,7 @@ The verdict is yours, produced on your machine, from numbers we never receive.
66
129
  | `site/` | The live product & explainer at [clembot-doorman.wanessalabs.com](https://clembot-doorman.wanessalabs.com). |
67
130
  | `fixtures/planted-bad-mcp/` | A deliberately hostile MCP server, deployed, so the demo denies something real instead of a line in a JSON file. |
68
131
 
132
+ <a id="install"></a>
69
133
  ## Quickstart & Installation
70
134
 
71
135
  **The answer depends on your stack, so run it on yours.**
@@ -106,20 +170,25 @@ components that never register:
106
170
  claude plugin details clembot-doorman
107
171
  # Skills (3) doorman, doorman-guide, vet
108
172
  # Agents (1) doorman
109
- # Hooks (1) PreToolUse
173
+ # Hooks (2) PreToolUse, SessionStart
110
174
  # MCP servers (1) scorecard
111
175
  ```
112
176
 
113
- ### 2. The CLI: doctor, needs, report, watch, eval
177
+ ### 2. The CLI: doctor, needs, report, watch, notify, eval
114
178
 
115
179
  ```bash
116
- git clone https://github.com/clemenswan/clembot-doorman
117
- npm i -g ./clembot-doorman
180
+ npm i -g clembot-doorman
118
181
  doorman --version
119
182
  ```
120
183
 
121
184
  Node 20+. Zero runtime dependencies, deliberately: every dependency is one more thing that can fail to install on your machine.
122
185
 
186
+ **`notify` and the SessionStart hook need 0.2.0 or later.** The 0.1.0 on npm was
187
+ published before they landed, so `doorman --version` reporting `0.1.0` means the
188
+ push half is absent and no session will ever announce anything. Nothing errors:
189
+ that build simply stays quiet forever, which is indistinguishable from having no
190
+ news.
191
+
123
192
  **New here?** [`WALKTHROUGH.md`](WALKTHROUGH.md) runs the first ten minutes
124
193
  against three real builds: one with a long history, one brand new, and one in
125
194
  between. The output in it is captured from real runs, not written by hand.
@@ -320,6 +389,54 @@ Doorman is specifically designed to stop "skill sprawl" and prevent duplicate to
320
389
  2. **Adverse Drift Detection (`doorman watch`)**:
321
390
  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
391
 
392
+ 2a. **Push, without a daemon and without telemetry**:
393
+ Nobody can push to a laptop behind NAT that is asleep half the day, so the
394
+ push here is not a new transport. It is the poll going invisible. A
395
+ SessionStart hook prints a digest that is already on disk, then fires a
396
+ detached refresh so the next session is current. The hook makes no network
397
+ call: one that waited on a fetch would make every session start as slow as
398
+ the worst network it has seen, and offline would make them all fail.
399
+
400
+ ```text
401
+ ## doorman
402
+
403
+ 1 newly graded server this build does not have:
404
+ - **A** 85.71/100, model not recorded https://mcp.deepwiki.com/mcp
405
+ not measured: behavioral, guidance
406
+ ```
407
+
408
+ Three rules, and each one is a notification product failing if broken. It
409
+ is **silent when nothing is new**, because a hook that reports "nothing new"
410
+ every morning teaches you to skip past the one morning it matters. It
411
+ **announces nothing on the first run**, because with no cursor the feed
412
+ returns everything graded so far and 26 rows is a catalogue, not news. And a
413
+ digest is **shown exactly once**, because the same three servers every
414
+ morning is how a notification becomes furniture.
415
+
416
+ 2b. **Popularity and trend, as a second axis (`GET /feed`, `?sort=trending`)**:
417
+ Every feed row carries a `popularity` block: Smithery use counts, npm weekly
418
+ downloads and GitHub stars, swept daily, with a median percentile and a
419
+ trend.
420
+
421
+ It is **never part of the score**. The grade is what happened when an agent
422
+ drove the server; popularity is how many people installed it without asking
423
+ that. A popular F is the most useful row this feed can publish, and a
424
+ blended number is the one thing guaranteed to bury it.
425
+
426
+ Counts are **ranked within each source and never summed across them**:
427
+ 87,579 Smithery uses, 4,200 npm downloads and 1,100 stars are three units
428
+ counting three populations, and adding them makes a meaningless number that
429
+ still sorts confidently. `sources_measured` says how many sources backed the
430
+ percentile, because a server ranked on one and a server ranked on three are
431
+ not equally known. A trend needs two readings at least 12 hours apart, so a
432
+ newly tracked server reports `null` rather than zero growth, and a source
433
+ that could not be read is **absent rather than zero**.
434
+
435
+ **Doorman's own install counts are refused as a fourth source.** They would
436
+ be the best popularity signal available to anyone, and collecting them needs
437
+ telemetry. That would sell the guarantee that makes `watch` and `needs`
438
+ worth running at all: your inventory never leaves your machine.
439
+
323
440
  3. **Frontmatter Arithmetic (30 KB vs 640 KB)**:
324
441
  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
442
 
@@ -645,13 +762,20 @@ node runner/run.mjs --once --server https://mcp.deepwiki.com/mcp \
645
762
  --needed-for "look up how a public repository works" \
646
763
  --static-only --out ../evidence/deepwiki
647
764
 
648
- # Full behavioural run (needs a key)
649
- export ANTHROPIC_API_KEY=...
765
+ # Full behavioural run (needs a key).
766
+ # `=...` is NOT a value: read the secret in rather than pasting a placeholder,
767
+ # which also keeps it out of shell history. A pasted "..." reaches the server
768
+ # as a wrong token and comes back 401, which reads as a broken credential
769
+ # rather than as a placeholder nobody substituted.
770
+ read -rsp 'ANTHROPIC_API_KEY: ' ANTHROPIC_API_KEY && export ANTHROPIC_API_KEY
771
+ # --once PRINTS ONLY. Add --out DIR to keep the evidence bundle, or queue the
772
+ # audit and use --poll below to publish it to the feed.
650
773
  node runner/run.mjs --once --server https://mcp.deepwiki.com/mcp \
651
- --needed-for "look up how a public repository works"
774
+ --needed-for "look up how a public repository works" --out out/deepwiki
652
775
 
653
776
  # Poll the queue
654
- export RUNNER_TOKEN=... SCORECARD_API=http://127.0.0.1:8799
777
+ read -rsp 'RUNNER_TOKEN: ' RUNNER_TOKEN && export RUNNER_TOKEN
778
+ export SCORECARD_API=http://127.0.0.1:8799
655
779
  node runner/run.mjs --poll
656
780
  ```
657
781
 
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.1",
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",
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `doorman audit` โ€” Unified security audit & tool recommendation report.
3
+ *
4
+ * Combines three layers in one fast, local sweep:
5
+ * 1. L0 doctor: Inspects your harness, gate wiring, and installed MCP servers.
6
+ * 2. L0.5 needs: Scans local prompt history to detect missing capability needs.
7
+ * 3. L2 watch: Cross-references unmet gaps against the public graded feed.
8
+ *
9
+ * Output: An actionable, executive-grade posture and tool recommendation report.
10
+ * Free, offline-first, zero runtime dependencies.
11
+ */
12
+
13
+ import { resolve } from 'node:path';
14
+ import { writeFileSync } from 'node:fs';
15
+ import { doctor } from './doctor.mjs';
16
+ import { needs as readNeeds } from './needs.mjs';
17
+ import { watch } from './watch.mjs';
18
+
19
+ export async function auditProject(targetDir = process.cwd(), opts = {}) {
20
+ const absRoot = resolve(targetDir);
21
+
22
+ // 1. Run Doctor (L0: Harness, Gate, Installed servers)
23
+ const doc = await doctor(absRoot, opts);
24
+ if (!doc.ok) {
25
+ return { ok: false, why: doc.why };
26
+ }
27
+
28
+ // 2. Run Needs (L0.5: Prompt history scan for capability gaps)
29
+ let needsResult = null;
30
+ try {
31
+ needsResult = await readNeeds({
32
+ root: absRoot,
33
+ historyDir: opts.historyDir,
34
+ candidateFile: opts.candidateFile,
35
+ api: opts.api,
36
+ });
37
+ } catch {
38
+ needsResult = null;
39
+ }
40
+
41
+ // 3. Run Watch (L2: Classified feed of newly graded tools & threats)
42
+ let watchResult = null;
43
+ try {
44
+ watchResult = await watch({
45
+ root: absRoot,
46
+ api: opts.api,
47
+ all: true,
48
+ });
49
+ } catch {
50
+ watchResult = null;
51
+ }
52
+
53
+ // Synthesize executive findings
54
+ const gateStatus = doc.gate?.verdict || 'unknown';
55
+ const isGateWired = gateStatus === 'installed and wired' || gateStatus.includes('wired');
56
+ const harnesses = (doc.harnesses || []).map((h) => h.harness);
57
+ const installedCount = (doc.servers || []).length;
58
+
59
+ const gaps = (needsResult?.matches || []).filter((m) => m.status === 'GAP' || m.status === 'UNMET');
60
+ const covered = (needsResult?.matches || []).filter((m) => m.status === 'COVERED');
61
+
62
+ const candidates = watchResult?.candidates || [];
63
+ const recommended = candidates
64
+ .filter((c) => (c.grade === 'A' || c.grade === 'B') && c.verdict === 'unreviewed' && !c.is_fixture && !c.self_graded)
65
+ .slice(0, 5);
66
+
67
+ const blockedThreats = candidates
68
+ .filter((c) => c.grade === 'F' || c.hard_fail || c.verdict === 'blocked')
69
+ .slice(0, 3);
70
+
71
+ const result = {
72
+ ok: true,
73
+ root: absRoot,
74
+ timestamp: new Date().toISOString(),
75
+ posture: {
76
+ gateStatus,
77
+ isGateWired,
78
+ harnesses,
79
+ installedCount,
80
+ agentsCount: doc.agents?.count ?? 0,
81
+ },
82
+ needs: {
83
+ totalPrompts: needsResult?.promptCount ?? 0,
84
+ gaps,
85
+ covered,
86
+ },
87
+ recommendations: recommended,
88
+ threats: blockedThreats,
89
+ };
90
+
91
+ if (opts.out) {
92
+ const md = renderAuditMarkdown(result);
93
+ writeFileSync(opts.out, md, 'utf8');
94
+ result.outFile = opts.out;
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ export function renderAuditMarkdown(res) {
101
+ const lines = [];
102
+ lines.push('# Clembot Doorman Security & Tool Recommendation Report');
103
+ lines.push(`\n**Target:** \`${res.root}\``);
104
+ lines.push(`**Generated:** ${res.timestamp}`);
105
+ lines.push('');
106
+
107
+ // Posture
108
+ lines.push('## 1. Security & Gate Posture');
109
+ const gateIcon = res.posture.isGateWired ? 'PASS' : 'WARN';
110
+ lines.push(`- **Gate Status:** [${gateIcon}] ${res.posture.gateStatus}`);
111
+ lines.push(`- **Harnesses Detected:** ${res.posture.harnesses.join(', ') || 'none'}`);
112
+ lines.push(`- **Reachable MCP Servers:** ${res.posture.installedCount}`);
113
+ lines.push(`- **Subagents Configured:** ${res.posture.agentsCount}`);
114
+ lines.push('');
115
+
116
+ // Recommendations based on needs
117
+ lines.push('## 2. Capability Needs & Tool Recommendations');
118
+ if (res.needs.gaps.length > 0) {
119
+ lines.push('Your prompt history reveals the following unmet tool capabilities:');
120
+ for (const g of res.needs.gaps) {
121
+ lines.push(`- **${g.title}:** ${g.promptsCount} prompt(s) reaching for this capability.`);
122
+ if (g.topCandidates && g.topCandidates.length > 0) {
123
+ for (const c of g.topCandidates.slice(0, 2)) {
124
+ lines.push(` โ†’ Recommended: **${c.name || c.url}** (${c.grade ? `Grade ${c.grade}` : 'verified'})`);
125
+ }
126
+ }
127
+ }
128
+ } else {
129
+ lines.push('- No active capability gaps detected in prompt history.');
130
+ }
131
+ lines.push('');
132
+
133
+ if (res.recommendations.length > 0) {
134
+ lines.push('### Top Verified Safe MCP Servers');
135
+ for (const r of res.recommendations) {
136
+ const score = typeof r.score === 'number' ? `${r.score.toFixed(1)}/100` : 'passing';
137
+ lines.push(`- **${r.server_name || r.server_url}** โ€” Grade **${r.grade}** (${score})`);
138
+ lines.push(` \`${r.server_url}\``);
139
+ }
140
+ lines.push('');
141
+ }
142
+
143
+ // Threats
144
+ lines.push('## 3. Threat Intelligence');
145
+ if (res.threats.length > 0) {
146
+ lines.push('The following servers failed security inspection and are blocked at the gate:');
147
+ for (const t of res.threats) {
148
+ const reason = t.hard_fail || 'Failed safety scan / commercial steering detected';
149
+ lines.push(`- โš ๏ธ **${t.server_name || t.server_url}** โ€” Grade **${t.grade}** [BLOCKED]: ${reason}`);
150
+ }
151
+ } else {
152
+ lines.push('- No active threat flags recorded on your trust list.');
153
+ }
154
+ lines.push('');
155
+
156
+ // Next Steps
157
+ lines.push('## 4. Recommended Actions');
158
+ if (!res.posture.isGateWired) {
159
+ lines.push('1. **Wire the security gate:** `claude plugin marketplace add clemenswan/clembot-doorman && claude plugin install clembot-doorman`');
160
+ }
161
+ lines.push('2. **Vet new candidate servers before adoption:** `/vet <url>`');
162
+ lines.push('3. **Allow trusted servers:** `doorman allow <server-name>`');
163
+ lines.push('4. **Schedule recurring audits:** `doorman schedule`');
164
+
165
+ return lines.join('\n');
166
+ }
167
+
168
+ export function renderAudit(res) {
169
+ const lines = [];
170
+ lines.push('');
171
+ lines.push('================================================================');
172
+ lines.push(' CLEMBOT DOORMAN ยท UNIFIED AUDIT & RECOMMENDATIONS ');
173
+ lines.push('================================================================');
174
+ lines.push(`Build: ${res.root}`);
175
+ lines.push('');
176
+
177
+ // 1. Security Posture
178
+ lines.push('โ”€โ”€ 1. SECURITY POSTURE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
179
+ const gateMark = res.posture.isGateWired ? 'โœ“' : '!';
180
+ lines.push(` [${gateMark}] Gate: ${res.posture.gateStatus}`);
181
+ lines.push(` [i] Harness: ${res.posture.harnesses.join(', ') || 'None declared'}`);
182
+ lines.push(` [i] Reachable MCP Tools: ${res.posture.installedCount} declared`);
183
+ lines.push('');
184
+
185
+ // 2. Capability Recommendations
186
+ lines.push('โ”€โ”€ 2. CAPABILITY GAPS & RECOMMENDED TOOLS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
187
+ if (res.needs.gaps.length > 0) {
188
+ lines.push(` Detected ${res.needs.gaps.length} capability gap(s) from prompt history:`);
189
+ for (const g of res.needs.gaps) {
190
+ lines.push(` โ€ข ${g.title} (${g.promptsCount} prompt asks)`);
191
+ }
192
+ } else {
193
+ lines.push(' No unmet capability gaps detected.');
194
+ }
195
+
196
+ if (res.recommendations.length > 0) {
197
+ lines.push('');
198
+ lines.push(' Top Vetted Candidates from Graded Feed:');
199
+ for (const r of res.recommendations) {
200
+ const score = typeof r.score === 'number' ? `${r.score.toFixed(1)}/100` : '';
201
+ lines.push(` โ†’ [Grade ${r.grade} ยท ${score}] ${r.server_name || 'Server'}`);
202
+ lines.push(` URL: ${r.server_url}`);
203
+ }
204
+ }
205
+ lines.push('');
206
+
207
+ // 3. Blocked Threats
208
+ lines.push('โ”€โ”€ 3. THREATS BLOCKED AT THE GATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
209
+ if (res.threats.length > 0) {
210
+ for (const t of res.threats) {
211
+ lines.push(` โœ— [Grade ${t.grade}] ${t.server_name || t.server_url}`);
212
+ if (t.hard_fail) lines.push(` Reason: ${t.hard_fail}`);
213
+ }
214
+ } else {
215
+ lines.push(' No active security alerts on installed servers.');
216
+ }
217
+ lines.push('');
218
+
219
+ // 4. Quick Actions
220
+ lines.push('โ”€โ”€ 4. QUICK ACTIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
221
+ if (!res.posture.isGateWired) {
222
+ lines.push(' โ€ข Wire Gate: claude plugin install clembot-doorman');
223
+ }
224
+ lines.push(' โ€ข Vet Tool: /vet <candidate_url>');
225
+ lines.push(' โ€ข Trust Tool: doorman allow <server_name>');
226
+ lines.push(' โ€ข Schedule: doorman schedule');
227
+ if (res.outFile) {
228
+ lines.push(` โ€ข Report: Saved to ${res.outFile}`);
229
+ }
230
+ lines.push('');
231
+ return lines.join('\n');
232
+ }
@@ -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,12 +30,49 @@ 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';
32
-
33
- const VERSION = '0.1.0';
33
+ import { refreshNotify, consumeDigest, DEFAULT_DIGEST } from './notify.mjs';
34
+ import { auditProject, renderAudit } from './audit.mjs';
35
+ import { schedule as scheduleReport, renderSchedule } from './schedule.mjs';
36
+ import { join } from 'node:path';
37
+
38
+ // Pinned to every other declaration by version.test.mjs. There are FOUR of
39
+ // them (root package.json, doorman/package.json, plugin.json, this) and this
40
+ // one silently reported 0.1.0 out of a 0.2.0 tarball.
41
+ const VERSION = '0.2.1';
42
+
43
+ const QUICKSTART = `
44
+ doorman ${VERSION} โ€” Security gate & tool package manager for AI agents
45
+
46
+ QUICKSTART (3 SIMPLE STEPS):
47
+ 1. doorman audit Run full build check & get vetted MCP recommendations
48
+ 2. /vet <url> Audit an untrusted candidate MCP server before adoption
49
+ 3. doorman schedule Configure automated weekly/daily audit reports
50
+
51
+ COMMON COMMANDS:
52
+ doorman audit [path] Unified scan: doctor + prompt needs + recommended tools
53
+ doorman schedule Configure recurring automated reports (cron, GitHub Actions)
54
+ doorman doctor [path] L0 build & gate inspection (free, offline, <5ms)
55
+ doorman needs [path] L0.5 scan prompt history for missing capabilities
56
+ doorman watch [path] Check public feed for newly graded tools
57
+ doorman allow <server> Trust an MCP server by name on your local allowlist
58
+ doorman install [path] Install the gate & hooks into this project
59
+
60
+ Run \`doorman --help\` for full manual and advanced benchmark flags.
61
+ `;
34
62
 
35
63
  const HELP = `
36
64
  doorman ${VERSION} โ€” measure a candidate, do not just read it
37
65
 
66
+ doorman audit [path] [--out FILE] [--json]
67
+ Unified security audit & capability recommendations. Runs doctor,
68
+ prompt-history needs, and watch in one fast command. Produces an
69
+ executive report with security posture, capability gaps, and top
70
+ verified tools.
71
+
72
+ doorman schedule [path] [--github]
73
+ Set up automated recurring audit reports via GitHub Actions, system
74
+ cron, or session-notify hooks.
75
+
38
76
  doorman doctor [path]
39
77
  L0. What is in YOUR build: which harness, which MCP servers your agents can
40
78
  reach, how many subagents hold MCP tools, and whether the gate is installed
@@ -58,6 +96,12 @@ doorman ${VERSION} โ€” measure a candidate, do not just read it
58
96
  a match is only ever worth-measuring. Nothing here drove anything, so
59
97
  nothing here claims a server will work. Only eval answers that.
60
98
 
99
+ doorman notify refresh [--root DIR] | doorman notify consume [--digest FILE]
100
+ The push half, and the SessionStart hook is what calls it. refresh polls
101
+ the feed and leaves a short digest on disk; consume prints that digest
102
+ and deletes it. Nothing is announced on the first run, nothing is written
103
+ when nothing is new, and a digest is shown exactly once.
104
+
61
105
  doorman report <link> [--out DIR] [--needed-for TEXT]
62
106
  L1. The static implementation report: protocol, schemas, annotations, and
63
107
  a scan-only pass over every description an agent would read before
@@ -113,7 +157,7 @@ Options
113
157
  */
114
158
  const BOOLEAN_FLAGS = new Set([
115
159
  'json', 'dry-run', 'all', 'estimate', 'help', 'version', 'allow-network',
116
- 'static-only', 'no-feed',
160
+ 'static-only', 'no-feed', 'github', 'cron', 'weekly',
117
161
  ]);
118
162
 
119
163
  function parseArgs(argv) {
@@ -137,12 +181,34 @@ const log = (m) => console.error(`${new Date().toISOString().slice(11, 19)} ${m}
137
181
  async function main() {
138
182
  const args = parseArgs(process.argv.slice(2));
139
183
  if (args.version) { console.log(VERSION); return; }
140
- if (args.help || args._.length === 0) { console.log(HELP); return; }
184
+ if (args.help) { console.log(HELP); return; }
185
+ if (args._.length === 0) { console.log(QUICKSTART); return; }
141
186
 
142
- // `doctor` is the one command that needs no link: it reads the project.
187
+ const [cmd, link] = args._;
143
188
 
189
+ if (cmd === 'audit' || cmd === 'scan' || cmd === 'recommend') {
190
+ const target = args._[1] || process.cwd();
191
+ const r = await auditProject(target, {
192
+ out: typeof args.out === 'string' ? args.out : null,
193
+ api: typeof args.api === 'string' ? args.api : undefined,
194
+ historyDir: typeof args.history === 'string' ? args.history : undefined,
195
+ });
196
+ if (!r.ok) { console.error(`audit: ${r.why}`); process.exitCode = 1; return; }
197
+ if (args.json) { console.log(JSON.stringify(r, null, 2)); return; }
198
+ console.log(renderAudit(r));
199
+ return;
200
+ }
144
201
 
145
- const [cmd, link] = args._;
202
+ if (cmd === 'schedule') {
203
+ const target = args._[1] || process.cwd();
204
+ const r = await scheduleReport(target, {
205
+ github: Boolean(args.github),
206
+ });
207
+ if (!r.ok) { console.error(`schedule: ${r.why}`); process.exitCode = 1; return; }
208
+ if (args.json) { console.log(JSON.stringify(r, null, 2)); return; }
209
+ console.log(renderSchedule(r));
210
+ return;
211
+ }
146
212
 
147
213
  if (cmd === 'doctor') {
148
214
  const d = await doctor(args._[1] || process.cwd());
@@ -248,6 +314,42 @@ async function main() {
248
314
  return;
249
315
  }
250
316
 
317
+ // The push half. Two verbs, and neither is meant to be typed by a human:
318
+ // the SessionStart hook calls `consume`, then `refresh` detached.
319
+ if (cmd === 'notify') {
320
+ const sub = args._[1];
321
+ const root = args.root || process.cwd();
322
+
323
+ if (sub === 'consume') {
324
+ const file = args.digest || join(root, DEFAULT_DIGEST);
325
+ const text = consumeDigest(file);
326
+ if (text) console.log(text);
327
+ // Nothing to say is exit 0 and silence, not an error. See rule 1 in
328
+ // notify.mjs: a notifier that speaks every session gets ignored.
329
+ return;
330
+ }
331
+
332
+ if (sub === 'refresh') {
333
+ const api = (args.api || DEFAULT_API).replace(/\/+$/, '');
334
+ try {
335
+ const r = await refreshNotify({ root, api, limit: Number(args.limit) > 0 ? Number(args.limit) : 50 });
336
+ if (args.json) console.log(JSON.stringify(r, null, 2));
337
+ else if (r.firstRun) console.log('First run: cursor established, nothing announced.');
338
+ else console.log(r.wrote ? `digest written to ${r.digest}` : 'nothing new, no digest written');
339
+ } catch (e) {
340
+ // Could not measure is 3, same as watch. This runs detached from a
341
+ // hook, so the exit code lands in .doorman/notify.log and nowhere else.
342
+ console.error(`notify refresh: ${e.message}`);
343
+ process.exitCode = e.code === 3 ? 3 : 1;
344
+ }
345
+ return;
346
+ }
347
+
348
+ console.error('notify needs a subcommand: consume or refresh');
349
+ process.exitCode = 2;
350
+ return;
351
+ }
352
+
251
353
  if (!['report', 'eval'].includes(cmd)) {
252
354
  console.error(`unknown command "${cmd}". Try: doorman --help`);
253
355
  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
 
@@ -170,6 +183,20 @@ export function renderInstall(res) {
170
183
  L.push('');
171
184
  }
172
185
 
173
- L.push('Done. Run `doorman doctor` to verify your installation.');
186
+ L.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
187
+ L.push(' INSTALLATION COMPLETE ยท GETTING STARTED GUIDANCE');
188
+ L.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
189
+ L.push(' 1. Run unified health & tool check:');
190
+ L.push(' doorman audit');
191
+ L.push('');
192
+ L.push(' 2. In Claude Code, manage your gate interactively:');
193
+ L.push(' /doorman');
194
+ L.push('');
195
+ L.push(' 3. Audit any candidate tool before adoption:');
196
+ L.push(' /vet <mcp_server_url>');
197
+ L.push('');
198
+ L.push(' 4. Set up recurring automated audit digests:');
199
+ L.push(' doorman schedule');
200
+ L.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
174
201
  return L.join('\n');
175
202
  }
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `doorman schedule` โ€” Configure automated, recurring tool audits & recommendation reports.
3
+ *
4
+ * Supports three automation modes:
5
+ * 1. Built-in SessionStart Hook (runs silently in background on session open)
6
+ * 2. GitHub Actions Workflow (--github: creates .github/workflows/doorman-audit.yml)
7
+ * 3. Agent / Cron Scheduling (prints crontab entry & /schedule agent prompt)
8
+ */
9
+
10
+ import { resolve, join } from 'node:path';
11
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
12
+
13
+ export const GITHUB_WORKFLOW_YAML = `name: Doorman Tool Audit & Security Report
14
+
15
+ on:
16
+ schedule:
17
+ # Run every Monday at 09:00 UTC
18
+ - cron: '0 9 * * 1'
19
+ workflow_dispatch:
20
+
21
+ jobs:
22
+ audit:
23
+ name: Run Clembot Doorman Audit
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - name: Checkout repository
27
+ uses: actions/checkout@v4
28
+
29
+ - name: Setup Node.js
30
+ uses: actions/setup-node@v4
31
+ with:
32
+ node-version: 20
33
+
34
+ - name: Install Clembot Doorman
35
+ run: npm i -g clembot-doorman
36
+
37
+ - name: Run Unified Audit
38
+ run: |
39
+ mkdir -p .doorman
40
+ doorman audit --out .doorman/audit-report.md
41
+
42
+ - name: Upload Audit Artifact
43
+ uses: actions/upload-artifact@v4
44
+ with:
45
+ name: doorman-audit-report
46
+ path: .doorman/audit-report.md
47
+ `;
48
+
49
+ export async function schedule(targetDir = process.cwd(), opts = {}) {
50
+ const absRoot = resolve(targetDir);
51
+
52
+ if (opts.github) {
53
+ const wfDir = join(absRoot, '.github', 'workflows');
54
+ mkdirSync(wfDir, { recursive: true });
55
+ const wfPath = join(wfDir, 'doorman-audit.yml');
56
+ writeFileSync(wfPath, GITHUB_WORKFLOW_YAML, 'utf8');
57
+ return {
58
+ ok: true,
59
+ mode: 'github',
60
+ path: wfPath,
61
+ };
62
+ }
63
+
64
+ // Check hook status
65
+ const sessionHook = join(absRoot, '.claude', 'hooks', 'session-notify.sh');
66
+ const hasHook = existsSync(sessionHook);
67
+
68
+ return {
69
+ ok: true,
70
+ mode: 'guide',
71
+ root: absRoot,
72
+ hasHook,
73
+ };
74
+ }
75
+
76
+ export function renderSchedule(res) {
77
+ const lines = [];
78
+ lines.push('');
79
+ lines.push('================================================================');
80
+ lines.push(' CLEMBOT DOORMAN ยท AUTOMATED AUDIT SCHEDULING ');
81
+ lines.push('================================================================');
82
+ lines.push('');
83
+
84
+ if (res.mode === 'github') {
85
+ lines.push(` โœ“ Created GitHub Actions workflow at:`);
86
+ lines.push(` ${res.path}`);
87
+ lines.push('');
88
+ lines.push(' This workflow runs `doorman audit` every Monday at 09:00 UTC');
89
+ lines.push(' and uploads the markdown report as an artifact.');
90
+ lines.push('');
91
+ return lines.join('\n');
92
+ }
93
+
94
+ lines.push('Choose how you want to schedule your tool audit & recommendations:');
95
+ lines.push('');
96
+
97
+ // 1. SessionStart Hook
98
+ lines.push('โ”€โ”€ 1. BUILT-IN SESSION HOOK (ZERO CONFIG) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
99
+ if (res.hasHook) {
100
+ lines.push(' [ACTIVE] .claude/hooks/session-notify.sh is installed.');
101
+ lines.push(' Claude Code automatically checks for new graded MCP tools at the');
102
+ lines.push(' start of every session and notifies you if fresh tools are available.');
103
+ } else {
104
+ lines.push(' Install the plugin to enable silent background session checks:');
105
+ lines.push(' claude plugin marketplace add clemenswan/clembot-doorman && claude plugin install clembot-doorman');
106
+ }
107
+ lines.push('');
108
+
109
+ // 2. AI Agent Loop / Slash Command
110
+ lines.push('โ”€โ”€ 2. AI AGENT RECURRING SCHEDULING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
111
+ lines.push(' In Antigravity or pair-programming chat, invoke the /schedule command:');
112
+ lines.push(' /schedule CronExpression="0 9 * * 1", Prompt="Run doorman audit and report recommended MCP tools"');
113
+ lines.push('');
114
+
115
+ // 3. GitHub Actions
116
+ lines.push('โ”€โ”€ 3. CI/CD GITHUB ACTIONS (RECOMMENDED FOR TEAMS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
117
+ lines.push(' Generate a weekly automated audit workflow with:');
118
+ lines.push(' doorman schedule --github');
119
+ lines.push('');
120
+
121
+ // 4. System Cron
122
+ lines.push('โ”€โ”€ 4. SYSTEM CRON / TASK SCHEDULER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
123
+ lines.push(' Add to your crontab (crontab -e) to generate weekly markdown reports:');
124
+ lines.push(` 0 9 * * 1 cd "${res.root}" && npx clembot-doorman audit --out .doorman/audit.md`);
125
+ lines.push('');
126
+
127
+ return lines.join('\n');
128
+ }
@@ -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.1",
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.1",
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",