Watchdog test fixture, not a mod to install. release-delivery probe: two band cards on digit 2, a queued-prompt context, /rdprobe fork and note

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/band.mjs 158 lines1// Release-probe mod for the TUI scenario (spec §16.5 UI and queue order items):
2// - two AbovePrompt cards whose Buttons share the bare digit hotkey 2 (research/you-should-know.md:135);
3// - the prompt typed during a reply: its prompt.submit fields, and one context line (smoke-aside-nudge.md:398);
4// - `/rdprobe fork`: the /cost ledger around one $.model.fork, the route You should know uses, and around one
5// $.model.complete (research/you-should-know.md:61,136; research/model-calls.md:20);
6// - `/rdprobe note`: one $.ui.log row in the watchdog's row format (§13.2), for the ctrl+o view
7// (issues/11-ui-prototype.md:37).
8const FILE = '__LOG__/rd-band.jsonl';
9const QUEUED_MARK = 'QUEUED=PAPA';
10const QUEUED_CONTEXT = 'RDCTX-QUEUED: a probe marker line; ignore it.';
11const NOTE_ROW = '[concern] rdprobe: RDNOTE-CTRLO a row in the watchdog row format (steered)';
12const FORK_PROMPT = 'Reply with exactly FORKED.';
13const COMPLETE_PROMPT = 'Reply with exactly COMPLETED.';
14const COMMAND = { name: 'rdprobe', description: 'watchdog live probe', argumentHint: 'fork|note', immediate: true };
15const CALL_TIMEOUT_MS = 60_000;
16const SETTLE_MS = 300;
17// §13.2: a row from a command.run hook waits 300 ms, so it lands below the command echo.
18const ROW_DELAY_MS = 300;
19const lines = [];
20let writing = null;
21let isDirty = false;
22
23// Rewrites the whole log file; a write asked while one runs is folded into one more pass (as the observer does).
24const flush = ($) => {
25 if (writing) {
26 isDirty = true;
27 return writing;
28 }
29 writing = (async () => {
30 do {
31 isDirty = false;
32 await $.fs.write(FILE, `${lines.join('\n')}\n`);
33 } while (isDirty);
34 })()
35 .catch(() => undefined)
36 .finally(() => {
37 writing = null;
38 });
39 return writing;
40};
41
42const record = ($, data) => {
43 lines.push(JSON.stringify({ t: Date.now(), ...data }));
44 return flush($);
45};
46
47const errorText = (error) => String(error?.message ?? error).slice(0, 180);
48
49// §16.5 UI: which card's Button the digit pressed; the press runs the closure of the render that drew it.
50const press = ($, which) => {
51 void record($, { kind: 'press', which });
52 void Promise.resolve($.ui.log(`RDCARD press ${which}`)).catch(() => undefined);
53};
54
55// What /cost totals: `$.session.usage()` `cost.usd`, "what the session has cost so far, as /cost totals it" (d.ts).
56const ledger = async ($) => {
57 try {
58 return (await $.session.usage())?.cost?.usd ?? null;
59 } catch {
60 return null;
61 }
62};
63
64// One model call, bounded by the clock (a fork takes no timeout of its own), as plain data.
65const settle = async (call) => {
66 let timer;
67 try {
68 const timeout = new Promise((resolve) => {
69 timer = setTimeout(() => resolve({ isAnswered: false, reason: 'probe timeout' }), CALL_TIMEOUT_MS);
70 });
71 const result = await Promise.race([call(), timeout]);
72 return {
73 isAnswered: result?.isAnswered ?? false,
74 reason: result?.reason ?? null,
75 text: String(result?.text ?? '').slice(0, 80),
76 usage: result?.usage ?? null,
77 };
78 } catch (error) {
79 return { isAnswered: false, reason: `throw ${errorText(error)}`, text: '', usage: null };
80 } finally {
81 clearTimeout(timer);
82 }
83};
84
85// §16.5 UI: the ledger before and after one fork, then after one completion, with no model turn between them.
86const measure = async ($) => {
87 const before = await ledger($);
88 const fork = await settle(() => $.model.fork({ prompt: FORK_PROMPT }));
89 await $.clock.sleep(SETTLE_MS);
90 const afterFork = await ledger($);
91 const complete = await settle(() =>
92 $.model.complete({ model: 'haiku', prompt: COMPLETE_PROMPT, maxTokens: 16, timeoutMs: CALL_TIMEOUT_MS })
93 );
94 await $.clock.sleep(SETTLE_MS);
95 const afterComplete = await ledger($);
96 return { before, afterFork, afterComplete, fork, complete };
97};
98
99export const register = (on) => {
100 on('session.start', async ($, e, next) => {
101 try {
102 await $.command.register(COMMAND);
103 await record($, { kind: 'command', outcome: 'ok' });
104 } catch (error) {
105 await record($, { kind: 'command', outcome: `throw ${errorText(error)}` });
106 }
107 return next(e);
108 });
109 on('command.run', { command: 'rdprobe' }, async ($, e) => {
110 const args = String(e.args ?? '').trim();
111 if (args === 'fork') {
112 const result = await measure($);
113 await record($, { kind: 'fork', ...result });
114 return { text: `RDFORK ledger ${result.before} -> ${result.afterFork} -> ${result.afterComplete}` };
115 }
116 if (args === 'note') {
117 $.clock.after(ROW_DELAY_MS, () => {
118 $.ui.log(NOTE_ROW);
119 });
120 await record($, { kind: 'note' });
121 return { text: 'RDNOTE row sent' };
122 }
123 return { text: 'usage: /rdprobe fork|note' };
124 });
125 // research/you-should-know.md:135: two cards, each with a plain Button on hotkey 2; the later one in the tree is
126 // the second card.
127 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
128 if (e.props?.hasSurvey) {
129 return next(e);
130 }
131 const base = await next(e);
132 const E = $.ui.resolve(e);
133 const card = (key, title, which) =>
134 E.Box({
135 key,
136 flexDirection: 'column',
137 children: [
138 E.Text({ children: title }),
139 E.Button({ key: `${key}-btn`, label: which, hotkey: '2', plain: true, onPress: () => press($, which) }),
140 ],
141 });
142 return E.Box({
143 key: 'rd-band',
144 flexDirection: 'column',
145 children: [base, card('rd-card-1', 'RDCARD-A', 'first'), card('rd-card-2', 'RDCARD-B', 'second')],
146 });
147 });
148 // smoke-aside-nudge.md:398: the prompt typed during the last reply; the plugin's aside rides on such a prompt as
149 // a context line (§10.2), so this one carries one too.
150 on('prompt.submit', async ($, e, next) => {
151 if (!String(e.text ?? '').includes(QUEUED_MARK)) {
152 return next(e);
153 }
154 await record($, { kind: 'submit', turnId: e.turnId ?? null, wait: e.wait ?? null, origin: e.origin?.kind ?? null });
155 return next({ ...e, context: [...(e.context ?? []), QUEUED_CONTEXT] });
156 });
157};
158