Watchdog test fixture, not a mod to install. live probe: tool.check ceiling an org sets

A second model reviews each step that Claude Code takes and sends it short notes while it works: nit, concern or blocker.

Claude Code 2.1.290 or later. Watchdog is a mod: a plugin whose code Claude Code runs inside your session. The npm stable channel (2.1.285 on 2026-10-06) has no mods. Below 2.1.290 the plugin shows unsupported. Desktop support starts when Claude.app bundles Claude Code 2.1.290 or later.
Check claude --version first. If it is below 2.1.290, move to the npm latest channel: npm install -g @anthropic-ai/claude-code@latest.
/plugin marketplace add matteoantoci/claude-plugins
/plugin install watchdog@matteoantoci-plugins
Then run /watchdog on (reviews are off until you do) and ask Claude for a small change. The note shows as a watchdog: [concern] … line in the transcript and as a card, one line with its first sentence above the prompt box. Click the card's ▸, or press ctrl+x tab and then its letter (a, b, c), to read the whole note with its watchdog, age and state; Esc gives the focus back to the prompt. Run /watchdog status to see each watchdog's reviews, notes, tokens and cost. A card names its watchdog when you run two or more.
At its right end a card shows only what needs a look: the subagent type for a note on a subagent; the state while the note has not reached Claude yet, nudge pending (the plugin starts a turn so that Claude reads it), held or aside (Claude reads it with your next prompt); nothing once it is steered (Claude reads it after its next tool result) or nudged. When Claude edited files after the review read its update, the card says outdated? N edits (the open card says may be outdated: N edits since), and Claude reads the same mark with the note. The next review of the same watchdog sees the edits and the note; when the note no longer holds, it retracts it: the card goes, and a note that waits never reaches Claude. A blocker that may be outdated and came after Claude's reply waits as held for that review before it nudges, so Claude does not go after a bug it already fixed.
plugins/watchdog/hooks/.WATCHDOG.json and WATCHDOG.md files, the session's memory files (such as CLAUDE.md), each update of the agent you work with and, when CLAUDE_WATCHDOG is set in a claude -p run, your project and local settings./watchdog on also sends one 1-token request for each model, to check that it exists. Apart from these model requests through Claude Code, the mod makes no network calls: its code never calls $.http.fetch or fetch.<config>/watchdog/dumps/ (<config> is $CLAUDE_CONFIG_DIR or ~/.claude). It keeps its notes and review state in Claude Code's session state and plugin store. In the terminal, /watchdog dump also copies the dump text to the clipboard.Read, Grep and Glob. A project WATCHDOG.json can grant no more; only <config>/WATCHDOG.json can grant other tools and mcp__* tools. Bash, Edit, Write, NotebookEdit, Agent, SendMessage, AskUserQuestion and ToolSearch are always refused. A reviewer never asks you for a permission.Agent call of a watchdog:* type) when Claude Code would ask, so no dialog or Auto-mode classifier sees it. A permission rule that denies Agent still wins.WATCHDOG.json or WATCHDOG.md sets the number of reviewers, their model, effort and instructions. In a repo you did not write, read these files before /watchdog on. Once on, /watchdog status lists each watchdog with its model, effort and file.opus with medium effort by default (in the demo: 3 reviews, 37.2k tokens, $0.07). The built-in "You should know" mod, when on, runs its own side agent too; turn it off in /plugin to pay for one only./watchdog off stops reviews for this session. /plugin uninstall watchdog@matteoantoci-plugins removes the plugin./plugin (Installed, Watchdog, Configure options) or /config: onByDefault (default false) turns reviews on in each new interactive session. immuneTurns (0 to 5, default 3) is the number of turns after a nudge before the next nudge for a concern; a nudge is a turn that the plugin starts so that Claude reads a note that came after its reply./watchdog status shows both counts, for example nudge 1/1 · blocker 0/2./watchdog or /watchdog status: each watchdog's state, reviews, notes, tokens and cost, and the session totals. For a state such as halted, see docs/failures.md./watchdog on and /watchdog off: turn reviews on or off for this session./watchdog dump and /watchdog dump raw: write the review log to a file (raw adds the review prompts).A WATCHDOG.json in your project or in ~/.claude sets the watchdogs. The load order, every key and the tool grants are in docs/configuration.md. This file adds a second reviewer to the default one:
{ "watchdogs": [{ "name": "default" }, { "name": "security", "model": "sonnet", "effort": "high" }] }
claude -p needs CLAUDE_WATCHDOG=on and has no nudge and no cards: see docs/headless.md.plugins/watchdog/hooks/prices.ts); a model not in it shows $?.npm install sets up the tools and the pre-commit hook. npm run check runs the 6 checks of pre-commit and CI: rules, fmt:check, lint, typecheck, validate and test. See docs/plugin-dev.md. Before a release and before a bump of the pinned Claude Code version, run the live probe by hand, on a real model and login: scripts/live-probe/README.md.
Apache-2.0
hooks/ceiling.mjs 84 lines1// §16.5 read scope (spec §6.5): a tool.check `ceiling` that an org sets. d.ts ToolCheckInput.ceiling: "the most
2// permissive verdict the organization lets a call of the tool reach, as its administrators set it on a connector's
3// tool", set by the engine, never by a query. Run only with `--with org:ceiling`. installMod fills __LOG__.
4// At the main turn.complete of a prompt that carries RRCEIL, a sweep asks $.tool.check for every tool of
5// $.tool.list() (empty input; nothing runs, no dialog opens) and keeps each verdict's `ceiling`. Then, for each
6// tool with a ceiling, it asks again while this plugin's tool.check hook answers `allow`: whether the ceiling caps
7// a hook's allow, as the watchdog's §6.5 hook gives one. Two sweeps, so connector tools that connect late count.
8// Result: __LOG__/rr-ceiling.json.
9const FILE = '__LOG__/rr-ceiling.json';
10const MARKER = 'RRCEIL';
11const MAX_TOOLS = 200;
12
13const state = { sweeps: [], forced: [], hookSaw: [] };
14let lastText = '';
15let forceAllow = null;
16
17const errOf = (err) => ({ name: err?.name ?? 'Error', message: String(err?.message ?? err).slice(0, 400) });
18
19const verdictOf = (verdict) => ({
20 decision: verdict?.decision ?? null,
21 ceiling: verdict?.ceiling ?? null,
22 rule: verdict?.rule ?? null,
23 reason: String(verdict?.reason ?? '').slice(0, 200),
24});
25
26const ask = async ($, tool) => {
27 try {
28 return verdictOf(await $.tool.check({ tool, input: {} }));
29 } catch (err) {
30 return { error: errOf(err).message };
31 }
32};
33
34const sweep = async ($) => {
35 let tools;
36 try {
37 tools = await $.tool.list();
38 } catch (err) {
39 state.sweeps.push({ listError: errOf(err) });
40 return;
41 }
42 const rows = [];
43 for (const tool of tools.slice(0, MAX_TOOLS)) {
44 rows.push({ tool: tool.name, mcp: tool.mcp === true, ...(await ask($, tool.name)) });
45 }
46 state.sweeps.push({ count: tools.length, rows });
47 for (const row of rows.filter((item) => item.ceiling)) {
48 if (!state.forced.some((item) => item.tool === row.tool)) {
49 forceAllow = row.tool;
50 state.forced.push({ tool: row.tool, ceiling: row.ceiling, before: row.decision, ...(await ask($, row.tool)) });
51 forceAllow = null;
52 }
53 }
54};
55
56export const register = (on) => {
57 // Answers `allow` for the one tool under test, as the watchdog's §6.5 hook turns an `ask` into an `allow`.
58 on('tool.check', async ($, e, next) => {
59 const result = await next(e);
60 if (forceAllow !== null && e.tool === forceAllow) {
61 state.hookSaw.push({ tool: e.tool, ceiling: e.ceiling ?? null, beneath: verdictOf(result) });
62 return { decision: 'allow', reason: 'rrceiling probe: a hook allow' };
63 }
64 return result;
65 });
66
67 on('turn.start', async ($, e, next) => {
68 if (!e.agentId) {
69 lastText = String(e.text ?? '');
70 }
71 return next(e);
72 });
73
74 on('turn.complete', async ($, e, next) => {
75 const result = await next(e);
76 if (!e.agentId && lastText.includes(MARKER)) {
77 lastText = '';
78 await sweep($);
79 await $.fs.write(FILE, JSON.stringify(state)).catch(() => undefined);
80 }
81 return result;
82 });
83};
84