SLOPSHOPPER

Watchdog

Watchdog agents review each update of the agent you work with and push short notes to it. It spawns review subagents and auto-allows only its own review spawn…

newbandrowsguardcommandprompt
★ 1v0.2.0Apache-2.0updated 2026-10-08matteoantoci/claude-code-watchdog/plugins/watchdog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · watchdog
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /watchdog ⎿ watchdog: watchdog unsupported: needs Claude Code 2.1.290 or later; update the Claude app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

watchdog

CI

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

A watchdog flags a planted bug and nudges Claude, which fixes it; the band card marks the note as maybe outdated and opens to the whole note, /watchdog status shows the review cost, and a later review retracts the note and raises a held concern: Claude reported a result it never ran

Requirements

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.

Quick start

/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.

What runs on your machine

  • The mod runs inside Claude Code with your permissions. Its code is in plugins/watchdog/hooks/.
  • It reads the 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.
  • It sends each update to the review model, as an agent that Claude Code runs on your account, and puts the notes into your session. /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.
  • Its only file write is the dump, under <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.
  • By default a reviewer gets 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.
  • The mod allows its own review spawn (the 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.
  • A project 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.

Cost and off switch

  • Each review is one more agent, on 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.
  • Settings, in /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.
  • Each nudge is one more turn of Claude. After each of your prompts the plugin sends at most 1 nudge for concerns and 2 for blockers (a concern that comes with a blocker rides along); a later note waits for your next prompt. /watchdog status shows both counts, for example nudge 1/1 · blocker 0/2.

Commands

  • /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).

Configure

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" }] }

Limitations

  • Notes are advice: the agent may reject one. A review runs in the background, so a note can come after the step; the outdated mark above counts every edit since the review, whatever file it touched.
  • Reviews run only on Anthropic models. claude -p needs CLAUDE_WATCHDOG=on and has no nudge and no cards: see docs/headless.md.
  • The cost comes from the plugin's own price table (plugins/watchdog/hooks/prices.ts); a model not in it shows $?.

Development

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.

License

Apache-2.0

Source 91 files
hooks/register.ts 45 lines
1import { installAgents } from './agents/install';
2import { installBand } from './band/install';
3import { installCommand } from './command/install';
4import { installDelivery } from './delivery/install';
5import { installDump } from './dump/install';
6import { installFailure } from './failure/install';
7import { installFeed } from './feed/install';
8import { installGuidance } from './guidance/install';
9import { installLifecycle } from './lifecycle/install';
10import { installLog } from './log/install';
11import { installNote } from './note/install';
12import { installReview } from './review/install';
13import { installRewind } from './rewind/install';
14import { installRoster } from './roster/install';
15import { installSession } from './session/install';
16import { installStatus } from './status/install';
17import { installStop } from './stop/install';
18import { installStore } from './store/install';
19import { installSubagents } from './subagents/install';
20import { installTools } from './tools/install';
21import type { Register } from 'claude-code';
22
23export const register: Register = (on, options) => {
24  installStore(on);
25  installBand(on);
26  installSession(on);
27  installRewind(on);
28  installRoster(on);
29  installFailure(on);
30  installStop(on);
31  installStatus(on);
32  installCommand(on, options);
33  installDump(on);
34  installLifecycle(on);
35  installGuidance(on);
36  installAgents(on);
37  installFeed(on);
38  installReview(on);
39  installLog(on);
40  installNote(on);
41  installSubagents(on);
42  installDelivery(on, options);
43  installTools(on);
44};
45
hooks/agents/install.ts 91 lines
1import { rememberSpawnModel } from '../failure/state';
2import { isIdMissing, learnReviewAgent } from '../review/slots';
3import { crossCheck, needsCrossCheck, ownContext, watchdogIds } from './ids';
4import { isOwnSpawn, isOwnToolCall } from './self-review';
5import { TYPE_PREFIX } from './spec';
6import type { OnEvents } from '../on';
7import type { EngineInterface, Hook, MatchedHook } from 'claude-code';
8
9// §7.3: `$.state` keeps a copy of the id set, an array because a Set becomes `{}` in JSON. The live copy is
10// module memory, so a refused write loses only what a reload would carry over.
11const saveIds = async ($: EngineInterface): Promise<void> => {
12  await $.state.set({ plugin: 'watchdog', key: 'ids' }, watchdogIds()).catch(() => undefined);
13};
14
15// The agent id in an `Agent` call's result record.
16const agentIdOf = (record: unknown): string | undefined =>
17  typeof record === 'object' && record !== null && 'agentId' in record && typeof record.agentId === 'string'
18    ? record.agentId
19    : undefined;
20
21const learn = async ($: EngineInterface, subagentType: unknown, agentId: string | undefined): Promise<void> => {
22  if (agentId !== undefined && isOwnSpawn(subagentType)) {
23    learnReviewAgent(subagentType.slice(TYPE_PREFIX.length), agentId);
24    await saveIds($);
25  }
26};
27
28// §7.3: the id comes from the `next(e)` result of the mod's `agent.spawn` hook, before the spawn resolves.
29// §12.2: so does the model that the agent runs on.
30const onSpawn: Hook<'agent.spawn'> = async ($, e, next) => {
31  const result = await next(e);
32  if (result.agentId !== undefined) {
33    rememberSpawnModel(result.agentId, result.model);
34  }
35  await learn($, e.subagentType, result.agentId);
36  return result;
37};
38
39// §7.3: or from the `next(e)` result of the synthetic main-loop `Agent` call of the spawn.
40const onAgentCall: MatchedHook<'tool.call', { tool: 'Agent' }> = async ($, e, next) => {
41  if (e.agentId !== undefined || !isOwnToolCall(e, ownContext())) {
42    return next(e);
43  }
44  const result = await next(e);
45  const isAnswered = result.deny === undefined && result.isError !== true;
46  await learn($, e.subagent_type, isAnswered ? agentIdOf(result.result) : undefined);
47  return result;
48};
49
50// §7.3: `$.agent.list()` is a later cross-check only. While a review waits for its agent's id, an agent id that no
51// spawn source gave is the mod's once the list shows that the mod spawned it. A refused list answers nothing (in
52// `-p` it rejects inside the agent's own `turn.complete`), so the agent's next event asks again.
53const crossCheckId = async ($: EngineInterface, agentId: string | undefined): Promise<void> => {
54  if (agentId === undefined || !isIdMissing() || !needsCrossCheck(agentId)) {
55    return;
56  }
57  const agents = await $.agent.list().catch(() => undefined);
58  await learn($, agents === undefined ? undefined : crossCheck(agentId, agents), agentId);
59};
60
61// §7.3: each event of an agent loop that the hooks beneath read by the id set: its steps, its tool calls (the
62// `note` call too) and its end.
63const onLoopStep: Hook<'turn.step'> = async function* ($, e, next) {
64  await crossCheckId($, e.agentId);
65  return yield* next(e);
66};
67
68const onLoopCall: MatchedHook<'tool.call', { agentId: RegExp }> = async ($, e, next) => {
69  await crossCheckId($, e.agentId);
70  return next(e);
71};
72
73const onLoopEnd: MatchedHook<'turn.complete', { agentId: RegExp }> = async ($, e, next) => {
74  await crossCheckId($, e.agentId);
75  return next(e);
76};
77
78// §6.1: hide each watchdog type from the model; the hide does not block the mod's own spawn.
79export const installAgents = (
80  on: OnEvents<'agent.offer' | 'agent.spawn' | 'tool.call' | 'turn.step' | 'turn.complete'>
81): void => {
82  on('agent.offer', { agent: /^watchdog:/u }, () => ({ isOffered: false })).catch((_$, e, next) =>
83    next.called ? next(e) : { isOffered: false }
84  );
85  on('agent.spawn', { subagentType: /^watchdog:/u }, onSpawn);
86  on('tool.call', { tool: 'Agent' }, onAgentCall);
87  on('tool.call', { agentId: /^/u }, onLoopCall);
88  on('turn.step', { turnId: /^/u }, onLoopStep);
89  on('turn.complete', { agentId: /^/u }, onLoopEnd);
90};
91
hooks/band/install.ts 165 lines
1import { currentRoster } from '../agents/roster';
2import { currentTurn } from '../delivery/turns';
3import { currentMode } from '../lifecycle/mode';
4import { isInteractiveSession } from '../lifecycle/on-order';
5import { watchHeldNotes } from '../note/notes';
6import { isPersonPrompt } from '../person';
7import { EMPTY_BAND, bandState, changeCard, clearCards, restoreBand, toggleCard } from './cards';
8import { troubleLine } from './problems';
9import { bandTree } from './tree';
10import type { OnEvents } from '../on';
11import type { Trouble } from './problems';
12import type { EngineInterface, Hook, MatchedHook, PluginState, RenderElement } from 'claude-code';
13
14type BandHook = MatchedHook<'ui.render', { component: 'AbovePrompt' }>;
15
16type Health = PluginState['watchdog']['health'];
17
18// The band value this module instance last wrote to `$.state`, as JSON; whether it read the band back (§14.6).
19const memory: { written: string | undefined; isLoaded: boolean } = { written: undefined, isLoaded: false };
20
21// §13.1: a `$.state` write draws the band again; only a band that changed is written.
22const writeBand = async ($: EngineInterface): Promise<void> => {
23  const band = bandState(currentTurn());
24  const json = JSON.stringify(band);
25  if (json === memory.written) {
26    return;
27  }
28  memory.written = json;
29  await $.state.set({ plugin: 'watchdog', key: 'band' }, band).catch(() => {
30    memory.written = undefined;
31  });
32};
33
34// The note hook and the delivery hooks beneath change the cards; each of these hooks writes the band after them.
35const afterTool: Hook<'tool.call'> = async ($, e, next) => {
36  const result = await next(e);
37  await writeBand($);
38  return result;
39};
40
41const afterTurnStart: Hook<'turn.start'> = async ($, e, next) => {
42  const result = await next(e);
43  await writeBand($);
44  return result;
45};
46
47const afterTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
48  const result = await next(e);
49  await writeBand($);
50  return result;
51};
52
53// §13.1: a person prompt clears the cards; the count line then shows no open note.
54const onPrompt: Hook<'prompt.submit'> = async ($, e, next) => {
55  if (isPersonPrompt(e.origin)) {
56    clearCards();
57  }
58  const result = await next(e);
59  await writeBand($);
60  return result;
61};
62
63// §5.2 step 2: `/watchdog off` clears the cards.
64const afterCommand: Hook<'command.run'> = async ($, e, next) => {
65  const result = await next(e);
66  if (currentMode() === 'off') {
67    clearCards();
68  }
69  await writeBand($);
70  return result;
71};
72
73// §13.1: a press on a card's Button expands or collapses it beneath, in its `onPress`; the band is written after.
74const afterPress: Hook<'ui.press'> = async ($, e, next) => {
75  const result = await next(e);
76  await writeBand($);
77  return result;
78};
79
80// §14.6: at module load the cards come back from `$.state`, so a reload keeps the band.
81const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
82  const result = await next(e);
83  if (!memory.isLoaded) {
84    memory.isLoaded = true;
85    const stored = await $.state.get({ plugin: 'watchdog', key: 'band' }).catch(() => undefined);
86    restoreBand(stored?.value ?? EMPTY_BAND);
87    memory.written = JSON.stringify(stored?.value ?? EMPTY_BAND);
88  }
89  return result;
90};
91
92// §12.5: the watchdogs of the roster in a problem state, as `$.state` key `health` keeps them.
93const troubles = (health: Health | undefined): Trouble[] =>
94  currentRoster().flatMap(({ name, slug }) => {
95    const problem = health?.watchdogs[slug]?.problem ?? null;
96    return problem === null ? [] : [{ name, problem }];
97  });
98
99// §12.5: the failure line while on; the clock is read only for a halt's next try.
100const troubleText = async ($: EngineInterface, health: Health | undefined, columns: number) => {
101  const found = troubles(health);
102  const isHalted = found.some(({ problem }) => problem.state === 'halted');
103  const now = isHalted ? await $.clock.now() : 0;
104  return troubleLine(found, { now, columns, isAlone: currentRoster().length === 1 });
105};
106
107// §13.1: the `$.state` keys the band draws. Reading them subscribes the band, so each write draws it again.
108const readKeys = async ($: EngineInterface) => {
109  const on = await $.state.get({ plugin: 'watchdog', key: 'on' }).catch(() => undefined);
110  const band = await $.state.get({ plugin: 'watchdog', key: 'band' }).catch(() => undefined);
111  const health = await $.state.get({ plugin: 'watchdog', key: 'health' }).catch(() => undefined);
112  const isOn = on?.value?.isOn;
113  return { isOn, band: band?.value ?? EMPTY_BAND, health: isOn === true ? health?.value : undefined };
114};
115
116// §13.1: the band reads its keys first, also in a session it does not draw, so that a write that comes before
117// the session start of a reload draws it. It yields to a survey, and draws nothing in a headless session (§10.6).
118const onBand: BandHook = async ($, e, next) => {
119  if (e.props.hasSurvey) {
120    return next(e);
121  }
122  const base: RenderElement = await next(e);
123  const { isOn, band, health } = await readKeys($);
124  if (!isInteractiveSession() || currentMode() === 'unsupported') {
125    return base;
126  }
127  const columns = e.props.bodyColumns;
128  const trouble = await troubleText($, health, columns);
129  const el = $.ui.resolve(e);
130  // §13.1: a collapsed card names its watchdog only when 2 or more watchdogs are enabled.
131  const watchdogs = currentRoster().filter((watchdog) => watchdog.isEnabled).length;
132  const tree = bandTree(el, { isOn, band, trouble, columns, watchdogs, onToggle: toggleCard });
133  if (tree === undefined) {
134    return base;
135  }
136  // §13.1: no row above the divider. The engine draws its `[-]` on the band's first row, so the divider holds it in
137  // each state.
138  return el.Box({ key: 'watchdog-band', flexDirection: 'column', children: [base, tree] });
139};
140
141// Install first, so that these hooks sit above the note, delivery and command hooks that change the cards.
142// The matchers only tell these `on()` from the other areas'.
143export const installBand = (
144  on: OnEvents<
145    | 'tool.call'
146    | 'turn.start'
147    | 'turn.complete'
148    | 'prompt.submit'
149    | 'command.run'
150    | 'session.start'
151    | 'ui.render'
152    | 'ui.press'
153  >
154): void => {
155  watchHeldNotes(changeCard);
156  on('tool.call', { tool: /./u }, afterTool);
157  on('turn.start', { turnId: /^/u }, afterTurnStart);
158  on('turn.complete', { answer: /^/u }, afterTurnComplete);
159  on('prompt.submit', { origin: { kind: /./u } }, onPrompt);
160  on('command.run', { command: /^watchdog$/u }, afterCommand);
161  on('session.start', { cwd: /./u }, onSessionStart);
162  on('ui.render', { component: 'AbovePrompt' }, onBand);
163  on('ui.press', { plugin: 'watchdog', element: /^watchdog-expand-/u }, afterPress);
164};
165
hooks/command/install.ts 269 lines
1import { preflightProblem, preflightReject, preflightRequest } from '../agents/preflight';
2import { setRegisteredSpec } from '../agents/registered';
3import { setRoster } from '../agents/roster';
4import { agentSpec } from '../agents/spec';
5import { systemPrompt } from '../agents/system-prompt';
6import { COMMAND_LOG_DELAY_MS } from '../constants';
7import { currentNudgeClock, nudgeValue, setNudgeClock } from '../delivery/nudge';
8import { addDumpLines } from '../dump/sections';
9import { errorText } from '../errors';
10import { EMPTY_FEED, currentFeed, setFeed, startFeed } from '../feed/feed';
11import { freezeGuidance } from '../guidance/memory';
12import { currentMode, setMode } from '../lifecycle/mode';
13import {
14  DESKTOP_DROP_WARNING,
15  addOnWarning,
16  currentOnSource,
17  hasPrompted,
18  isEnvOn,
19  isEnvOnFlag,
20  isInteractiveSession,
21  isOnByDefault,
22  onStoreKey,
23  onWarnings,
24  pickOnFlag,
25  setInteractiveSession,
26  setOnByDefault,
27  setOnSource,
28} from '../lifecycle/on-order';
29import { clearHeldNotes } from '../note/notes';
30import { WATCHDOG_TOOLS } from '../note/tool';
31import { resetCadences } from '../review/cadence';
32import { slotsAfterOn } from '../review/slots';
33import { buildRoster } from '../roster/merge';
34import { sessionEffort } from '../roster/model';
35import { searchPaths } from '../roster/paths';
36import { parseSubcommand } from './args';
37import { UNSUPPORTED_REPLY, USAGE_REPLY } from './spec';
38import { addStatusLines, statusHeadline, statusText } from './status';
39import type { Roster, WatchedFile, Watchdog } from '../agents/roster';
40import type { OnFlag, OnSource } from '../lifecycle/on-order';
41import type { OnEvents } from '../on';
42import type { LoadedFile } from '../roster/merge';
43import type { SearchPath, Where } from '../roster/paths';
44import type { EngineInterface, Hook, PluginOptions } from 'claude-code';
45
46// §5.2 step 4, §14.1: `$.state` keeps the on flag and the feed; a refused write loses only the carry-over.
47const saveOnState = async ($: EngineInterface): Promise<void> => {
48  const source = currentOnSource();
49  const flag: OnFlag = currentMode() === 'on' && source !== undefined ? { isOn: true, source } : { isOn: false };
50  await $.state.set({ plugin: 'watchdog', key: 'on' }, flag).catch(() => undefined);
51  await $.state.set({ plugin: 'watchdog', key: 'feed' }, currentFeed()).catch(() => undefined);
52};
53
54// §5.2 step 1, §6.1, §8.3, §10.8: the mod's tools, then each runnable watchdog's agent type; each slug → its blocker.
55const registerAll = async (
56  $: EngineInterface,
57  watchdogs: readonly Watchdog[]
58): Promise<Map<string, string | undefined>> => {
59  const registered = Promise.all(WATCHDOG_TOOLS.map(async (tool) => $.tool.register(tool)));
60  const toolProblem = await registered.then(() => undefined, errorText);
61  if (toolProblem !== undefined) {
62    return new Map(watchdogs.map((watchdog) => [watchdog.slug, toolProblem]));
63  }
64  const base = await $.fs.read(`${$.plugin.root}/prompts/system.md`);
65  const problems = await Promise.all(
66    watchdogs.map(async (watchdog) => {
67      const spec = agentSpec(watchdog, systemPrompt(base, watchdog), sessionEffort());
68      return $.agent.register(spec).then(() => {
69        setRegisteredSpec(spec);
70        return undefined;
71      }, errorText);
72    })
73  );
74  return new Map(watchdogs.map((watchdog, index) => [watchdog.slug, problems[index]]));
75};
76
77// §5.2 step 2: one 1-token call for each distinct model. Maps each model to its `no_model` reason, or undefined.
78const preflightAll = async (
79  $: EngineInterface,
80  models: readonly string[]
81): Promise<Map<string, string | undefined>> => {
82  const distinct = [...new Set(models)];
83  const problems = await Promise.all(
84    distinct.map(async (model) =>
85      $.model.complete(preflightRequest(model)).then(preflightProblem, preflightReject(model))
86    )
87  );
88  return new Map(distinct.map((model, index) => [model, problems[index]]));
89};
90
91// §4.5: a searched path and its time at the read; a path that does not stat is not there.
92const readSearchPath = async ($: EngineInterface, search: SearchPath): Promise<LoadedFile & WatchedFile> => {
93  const mtimeMs = await $.fs.stat(search.path).then(
94    (stat) => stat.mtimeMs,
95    () => null
96  );
97  if (mtimeMs === null) {
98    return { ...search, mtimeMs };
99  }
100  const content = await $.fs.read(search.path).then(
101    (text) => ({ text }),
102    (error: unknown) => ({ error: errorText(error) })
103  );
104  return { ...search, mtimeMs, content };
105};
106
107// §4.3, §4.5: the user file and the project files of `WATCHDOG.json` and `WATCHDOG.md`, read at `/watchdog on`;
108// the roster and the guidance (§4.4) stay frozen until the next `/watchdog on`.
109const loadRoster = async ($: EngineInterface): Promise<{ roster: Roster; files: WatchedFile[] }> => {
110  const where: Where = {
111    configDir: await $.env.get('CLAUDE_CONFIG_DIR'),
112    home: await $.env.get('HOME'),
113    gitRoot: (await $.session.repo())?.root ?? null,
114    cwd: await $.session.cwd(),
115    root: await $.session.root(),
116  };
117  const read = async (name: string): Promise<(LoadedFile & WatchedFile)[]> =>
118    Promise.all(searchPaths(where, name).map(async (search) => readSearchPath($, search)));
119  const [files, guides] = await Promise.all([read('WATCHDOG.json'), read('WATCHDOG.md')]);
120  freezeGuidance(guides);
121  const watched = [...files, ...guides].map(({ path, mtimeMs }) => ({ path, mtimeMs }));
122  return { roster: buildRoster(files, where), files: watched };
123};
124
125// §4.6, §13.2: one row with the warning count; it waits, so that from a `command.run` hook it lands below the
126// command echo.
127const logWarnings = ($: EngineInterface, count: number): void => {
128  if (count > 0) {
129    const text = `${count} WATCHDOG.json ${count === 1 ? 'warning' : 'warnings'}; see /watchdog status`;
130    $.clock.after(COMMAND_LOG_DELAY_MS, () => {
131      $.ui.log(text);
132    });
133  }
134};
135
136// §5.2: read the roster, register, preflight, move the feed cursors to the end, then set the on flag and the
137// on source. The roster is set before the register, whose system prompt reads it (§8.1).
138const turnOn = async ($: EngineInterface, source: OnSource): Promise<void> => {
139  const { roster, files } = await loadRoster($);
140  setRoster(roster, files);
141  const runnable = roster.watchdogs.filter((watchdog) => watchdog.isEnabled && watchdog.noModel === null);
142  const blocked = await registerAll($, runnable);
143  const noModel = await preflightAll(
144    $,
145    runnable.filter((watchdog) => blocked.get(watchdog.slug) === undefined).map((watchdog) => watchdog.model)
146  );
147  const reviewers = slotsAfterOn(roster.watchdogs, blocked, noModel);
148  resetCadences();
149  setFeed(startFeed(reviewers));
150  setMode('on');
151  setOnSource(source);
152  await saveOnState($);
153  logWarnings($, roster.warnings.length);
154};
155
156// §5.2: stop feed recording; clear the backlog, the held notes and a waiting nudge, in `$.state` too.
157const turnOff = async ($: EngineInterface): Promise<void> => {
158  setMode('off');
159  setOnSource(undefined);
160  setFeed(EMPTY_FEED);
161  clearHeldNotes();
162  setNudgeClock({ ...currentNudgeClock(), dueAt: null });
163  await saveOnState($);
164  await $.state.set({ plugin: 'watchdog', key: 'nudge' }, nudgeValue([])).catch(() => undefined);
165};
166
167// §5.4: the person's toggle follows the session id into a new process (`claude -r`), with `lastUsed` (§14.2).
168const storeOnFlag = async ($: EngineInterface, flag: OnFlag): Promise<void> => {
169  const sessionId = await $.session.id();
170  await $.store.set(onStoreKey(sessionId), { ...flag, lastUsed: await $.clock.now() });
171};
172
173const toggle = async ($: EngineInterface, isOn: boolean): Promise<void> => {
174  await (isOn ? turnOn($, '/watchdog on') : turnOff($));
175  await storeOnFlag($, isOn ? { isOn: true, source: '/watchdog on' } : { isOn: false }).catch(() => undefined);
176};
177
178// §5.1: switch to the flag that the order picked. An off session that stays off writes nothing, so a later
179// `onByDefault` still applies after a reload.
180const applyOnFlag = async ($: EngineInterface, flag: OnFlag): Promise<void> => {
181  if (flag.isOn && currentMode() === 'on') {
182    setOnSource(flag.source);
183    await saveOnState($);
184    return;
185  }
186  if (flag.isOn || currentMode() === 'on') {
187    await (flag.isOn ? turnOn($, flag.source) : turnOff($));
188  }
189};
190
191const readOnState = async ($: EngineInterface): Promise<unknown> =>
192  (await $.state.get({ plugin: 'watchdog', key: 'on' }).catch(() => undefined))?.value;
193
194const readStoredFlag = async ($: EngineInterface): Promise<unknown> => {
195  const sessionId = await $.session.id();
196  return $.store.get(onStoreKey(sessionId));
197};
198
199// §5.1: the `$.state` flag (undefined when dropped), then for an interactive session the stored flag and
200// `onByDefault`. A failed turn-on writes one log row.
201const applyOrder = async ($: EngineInterface, state: unknown, isInteractive: boolean): Promise<void> => {
202  const stored = isInteractive ? await readStoredFlag($).catch(() => undefined) : undefined;
203  const flag = pickOnFlag({ state, stored, onByDefault: isOnByDefault(), isInteractive, isEnvOn: isEnvOn() });
204  const problem = flag === undefined ? undefined : await applyOnFlag($, flag).then(() => undefined, errorText);
205  if (problem !== undefined) {
206    $.ui.log(`watchdog on failed: ${problem}`);
207  }
208};
209
210// §5.1: after the version gate beneath, set the on state by the order; §14.6: a desktop reload is interactive.
211const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
212  const result = await next(e);
213  setInteractiveSession(e.isInteractive);
214  if (currentMode() !== 'unsupported') {
215    await applyOrder($, await readOnState($), isInteractiveSession());
216  }
217  return result;
218};
219
220// §5.3: a Desktop attach before the first prompt makes the session interactive. It drops an on state from
221// `CLAUDE_WATCHDOG` with one dump warning, then applies the order.
222const onDesktopAttach: Hook<'session.attach'> = async ($, e, next) => {
223  const result = await next(e);
224  if (hasPrompted() || currentMode() === 'unsupported') {
225    return result;
226  }
227  setInteractiveSession(true);
228  const state = await readOnState($);
229  const isDropped = isEnvOnFlag(state);
230  if (isDropped) {
231    addOnWarning(DESKTOP_DROP_WARNING);
232  }
233  await applyOrder($, isDropped ? undefined : state, true);
234  return result;
235};
236// §5.2, §13.3: on, off and status; `dump` goes to the dump hook beneath.
237const onWatchdogCommand: Hook<'command.run'> = async ($, e, next) => {
238  const subcommand = parseSubcommand(e.args);
239  if (subcommand === 'status') {
240    return { text: statusText(statusHeadline()) };
241  }
242  if (subcommand === 'unknown') {
243    return { text: USAGE_REPLY };
244  }
245  if ((subcommand === 'on' || subcommand === 'off') && currentMode() === 'unsupported') {
246    return { text: UNSUPPORTED_REPLY };
247  }
248  if (subcommand === 'on' || subcommand === 'off') {
249    const problem = await toggle($, subcommand === 'on').then(() => undefined, errorText);
250    return { text: problem === undefined ? statusText(statusHeadline()) : `watchdog ${subcommand} failed: ${problem}` };
251  }
252  return next(e);
253};
254
255// §5.4, §13.3, §13.4: the on source in the status while on, and always in the dump with the warnings.
256const onSourceLine = (): string => `on source: ${currentOnSource() ?? 'none (off)'}`;
257
258export const installCommand = (
259  on: OnEvents<'command.run' | 'session.start' | 'session.attach'>,
260  options: PluginOptions
261): void => {
262  setOnByDefault(options);
263  on('command.run', { command: 'watchdog' }, onWatchdogCommand);
264  on('session.start', { cwd: /^/u }, onSessionStart);
265  on('session.attach', { surface: 'desktop' }, onDesktopAttach);
266  addStatusLines(() => (currentMode() === 'on' ? [onSourceLine()] : []));
267  addDumpLines(() => [onSourceLine(), ...onWarnings().map((warning) => `warning: ${warning}`)]);
268};
269
hooks/delivery/install.ts 260 lines
1import { ownContext } from '../agents/ids';
2import { watchdogBySlug } from '../agents/roster';
3import { isOwnToolCall } from '../agents/self-review';
4import { addStatusHead, addStatusLines } from '../command/status';
5import { NUDGE_WAIT_MS } from '../constants';
6import { errorText } from '../errors';
7import { currentMode } from '../lifecycle/mode';
8import { addLogRecord, currentLog, errorRecord } from '../log/log';
9import { addDeliveryRoute, heldNotes, holdNote, rerouteNotes, takeNotes } from '../note/notes';
10import { isPersonPrompt } from '../person';
11import { wrapHeldNotes } from './held';
12import {
13  budgetOf,
14  currentNudgeClock,
15  currentRouting,
16  endMainTurn,
17  giveBackNudge,
18  immuneTurnsWarning,
19  isNudgePending,
20  lateRouteNow,
21  nudgeNotes,
22  nudgeStatus,
23  nudgeValue,
24  restoreNudge,
25  routeNow,
26  setImmuneTurns,
27  setNudgeClock,
28  spendNudge,
29  startMainTurn,
30} from './nudge';
31import { endReviewWaits, takeNudgeNotes } from './review-wait';
32import { countTurn, restoreTurn } from './turns';
33import type { DeliveryState, HeldNote } from '../note/notes';
34import type { OnEvents } from '../on';
35import type { EngineInterface, Hook, MatchedHook, PluginOptions, Timer } from 'claude-code';
36
37type SteerHook = MatchedHook<'tool.call', { tool: RegExp }>;
38
39// The shipped `prompts/boundary-guidance.md` (§10.7), read once; the 2 s wait of the nudge that waits
40// (§10.3); whether this module instance read its `$.state` back (§14.6).
41const memory: { guidance: string | undefined; wait: Timer | undefined; isLoaded: boolean } = {
42  guidance: undefined,
43  wait: undefined,
44  isLoaded: false,
45};
46
47const guidance = async ($: EngineInterface): Promise<string> => {
48  memory.guidance ??= await $.fs.read(`${$.plugin.root}/prompts/boundary-guidance.md`);
49  return memory.guidance;
50};
51
52// §14.1: `$.state` keeps the budgets, the cooldown and the nudge that waits with its notes; a refused write
53// loses only what a reload would carry over.
54const saveNudge = async ($: EngineInterface): Promise<void> => {
55  await $.state.set({ plugin: 'watchdog', key: 'nudge' }, nudgeValue(nudgeNotes(heldNotes()))).catch(() => undefined);
56};
57
58type Undelivered = {
59  readonly notes: readonly HeldNote[];
60  readonly route: (note: HeldNote) => DeliveryState;
61  readonly error: string;
62};
63
64// §10.1, §10.3: notes that a delivery could not send wait again, each in the state `route` gives it; the
65// review log gets one error.
66const keepUndelivered = async ($: EngineInterface, undelivered: Undelivered): Promise<void> => {
67  const { notes, route, error } = undelivered;
68  for (const note of notes) {
69    holdNote({ ...note, delivery: route(note) });
70  }
71  const names = notes.map((note) => watchdogBySlug(note.watchdog)?.name ?? note.watchdog);
72  const watchdog = [...new Set(names)].join(', ');
73  addLogRecord(errorRecord({ watchdog, time: await $.clock.now(), error }));
74  await $.state.set({ plugin: 'watchdog', key: 'log' }, currentLog()).catch(() => undefined);
75};
76
77// §10.1: one user row with the wrapped notes. Returns the reason of a reject or a deny, or undefined.
78const appendNotes = async ($: EngineInterface, notes: readonly HeldNote[]): Promise<string | undefined> => {
79  try {
80    const text = wrapHeldNotes(await guidance($), notes);
81    const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
82    return 'deny' in appended ? appended.deny : undefined;
83  } catch (error) {
84    return errorText(error);
85  }
86};
87
88// §10.1: a failed append keeps the notes undelivered: they take the late-note route (§10.3).
89const steer = async ($: EngineInterface): Promise<void> => {
90  const notes = takeNotes(['steered'], 'steered');
91  if (notes.length === 0) {
92    return;
93  }
94  const problem = await appendNotes($, notes);
95  if (problem !== undefined) {
96    await keepUndelivered($, { notes, route: lateRouteNow, error: `steer append failed: ${problem}` });
97  }
98};
99
100// §10.1: the ready steers go after the next main-loop tool result, once `next(e)` resolved. The mod's own
101// synthetic calls (§7.3) and every subagent call steer nothing.
102const onToolCall: SteerHook = async ($, e, next) => {
103  const result = await next(e);
104  if (e.agentId === undefined && !isOwnToolCall(e, ownContext(), next.origin.plugin)) {
105    await steer($);
106  }
107  return result;
108};
109
110// §10.3: one awaited `$.prompt.submit` with the wrapped notes. Returns the reason of a reject or a drop.
111const submitNudge = async ($: EngineInterface, notes: readonly HeldNote[]): Promise<string | undefined> => {
112  try {
113    const submitted = await $.prompt.submit({ text: wrapHeldNotes(await guidance($), notes) });
114    return submitted.drop;
115  } catch (error) {
116    return errorText(error);
117  }
118};
119
120// §10.3, §10.4: the callback of the 2 s wait sends one nudge with every late note that waits, while no turn
121// runs (a nudge queued behind a turn would survive Esc); the notes go out as `nudged`, and an outdated blocker may
122// wait for a review first (§10.8). A refused nudge gives its budget back, and its notes wait as an aside.
123const sendNudge = async ($: EngineInterface): Promise<void> => {
124  memory.wait = undefined;
125  setNudgeClock({ ...currentNudgeClock(), dueAt: null });
126  const notes = currentRouting().isTurnRunning ? [] : takeNudgeNotes();
127  const budget = budgetOf(notes);
128  if (notes.length > 0) {
129    spendNudge(budget);
130  }
131  await saveNudge($);
132  const problem = notes.length === 0 ? undefined : await submitNudge($, notes);
133  if (problem !== undefined) {
134    giveBackNudge(budget);
135    await keepUndelivered($, { notes, route: () => 'held', error: `nudge failed: ${problem}` });
136    await saveNudge($);
137  }
138};
139
140const startWait = ($: EngineInterface, ms: number): void => {
141  memory.wait = $.clock.after(ms, () => {
142    sendNudge($).catch(() => undefined);
143  });
144};
145
146// §10.3: late notes that wait while the session is idle start one 2 s wait; the notes ready within it share
147// the nudge. Only a `turn.complete` (or the load) starts it: a submit from a timer that a `tool.call` hook
148// set is refused, so the note hook never does.
149const canArm = (): boolean =>
150  !currentRouting().isTurnRunning && memory.wait === undefined && heldNotes().some(isNudgePending);
151
152// The clock is read before the wait starts, so a wait that runs out at once finds `dueAt` set and its nudge's
153// budget is not written over.
154const armNudge = async ($: EngineInterface): Promise<void> => {
155  if (!canArm()) {
156    return;
157  }
158  const dueAt = (await $.clock.now()) + NUDGE_WAIT_MS;
159  if (!canArm()) {
160    return;
161  }
162  setNudgeClock({ ...currentNudgeClock(), dueAt });
163  startWait($, NUDGE_WAIT_MS);
164  await saveNudge($);
165};
166
167// §10.7: the main-loop counter adds 1 at each `turn.start`; `$.state` key `turns` keeps a copy (§14.1).
168// §10.3 step 2: a turn that starts ends the 2 s wait, and its notes wait for a tool result of that turn.
169const onTurnStart: Hook<'turn.start'> = async ($, e, next) => {
170  const turn = countTurn();
171  startMainTurn(e.text);
172  memory.wait?.cancel();
173  memory.wait = undefined;
174  rerouteNotes((note) => (isNudgePending(note) ? 'steered' : note.delivery));
175  await $.state.set({ plugin: 'watchdog', key: 'turns' }, turn).catch(() => undefined);
176  await saveNudge($);
177  return next(e);
178};
179
180// §10.3: at the main-loop end, a steer with no tool result is a late note; after Esc (`isAborted`) it waits
181// as an aside. §10.8: a review's end ends the wait of the outdated blockers it saw. Any `turn.complete` while the
182// session is idle (a review's own too) starts the 2 s wait.
183const onTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
184  const result = await next(e);
185  if (e.agentId === undefined) {
186    endMainTurn(e.isAborted);
187    rerouteNotes((note) => (note.delivery === 'steered' || isNudgePending(note) ? lateRouteNow(note) : note.delivery));
188  }
189  endReviewWaits(e.agentId);
190  await armNudge($);
191  return result;
192};
193
194// §10.2, §13.1: the nits and the held notes in one wrapper; undefined when none waits. If the guidance
195// cannot be read, they wait for the next person prompt.
196const asideText = async ($: EngineInterface): Promise<string | undefined> => {
197  const isWaiting = heldNotes().some((note) => note.delivery === 'aside on next prompt' || note.delivery === 'held');
198  const head = isWaiting ? await guidance($).catch(() => undefined) : undefined;
199  return head === undefined
200    ? undefined
201    : wrapHeldNotes(head, takeNotes(['aside on next prompt', 'held'], 'aside on next prompt'));
202};
203
204// §10.2, §10.4: a person prompt resets both nudge budgets and carries the aside in its `context`, added on
205// the way down: a `context` added to the result after `next` is dropped (d.ts 8803-8805). Any other origin,
206// a task notification too (§10.5), passes as it came.
207const onPromptSubmit: Hook<'prompt.submit'> = async ($, e, next) => {
208  if (!isPersonPrompt(e.origin)) {
209    return next(e);
210  }
211  setNudgeClock({ ...currentNudgeClock(), nudges: 0, blockerNudges: 0 });
212  await saveNudge($);
213  const aside = await asideText($);
214  return next(aside === undefined ? e : { ...e, context: [...(e.context ?? []), aside] });
215};
216
217// §10.3, §14.6: a reload cancels the old instance's timers. At load the counter, the budgets, the cooldown
218// and the nudge that waits come back from `$.state`, and the wait starts again with the time left.
219const restoreDelivery = async ($: EngineInterface): Promise<void> => {
220  memory.isLoaded = true;
221  const turns = await $.state.get({ plugin: 'watchdog', key: 'turns' }).catch(() => undefined);
222  const nudge = await $.state.get({ plugin: 'watchdog', key: 'nudge' }).catch(() => undefined);
223  if (turns?.value !== undefined) {
224    restoreTurn(turns.value);
225  }
226  for (const note of restoreNudge(nudge?.value)) {
227    holdNote({ ...note, delivery: 'nudge pending' });
228  }
229  const { dueAt } = currentNudgeClock();
230  if (dueAt !== null) {
231    startWait($, Math.max(0, dueAt - (await $.clock.now())));
232  }
233};
234
235const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
236  const result = await next(e);
237  if (!memory.isLoaded) {
238    await restoreDelivery($);
239  }
240  return result;
241};
242
243export const installDelivery = (
244  on: OnEvents<'turn.start' | 'turn.complete' | 'tool.call' | 'prompt.submit' | 'session.start'>,
245  options: PluginOptions
246): void => {
247  setImmuneTurns(options.immuneTurns);
248  on('turn.start', onTurnStart);
249  on('turn.complete', { reason: /^/u }, onTurnComplete);
250  on('tool.call', { tool: /^/u }, onToolCall);
251  on('prompt.submit', onPromptSubmit);
252  on('session.start', { isInteractive: [true, false] }, onSessionStart);
253  addDeliveryRoute(routeNow);
254  addStatusHead(() => (currentMode() === 'on' ? nudgeStatus(currentRouting()) : []));
255  addStatusLines(() => {
256    const warning = immuneTurnsWarning();
257    return warning === undefined ? [] : [`warning: ${warning}`];
258  });
259};
260
hooks/dump/install.ts 115 lines
1import { watchdogBySlug } from '../agents/roster';
2import { parseSubcommand } from '../command/args';
3import { COMMAND_LOG_DELAY_MS } from '../constants';
4import { errorText } from '../errors';
5import { currentMode } from '../lifecycle/mode';
6import { isInteractiveSession, onWarnings } from '../lifecycle/on-order';
7import { addLogRecord, currentLog, recentPrompts, unreviewedRecord } from '../log/log';
8import { heldNotes, logRow } from '../note/notes';
9import { waitingUpdates } from '../review/backlog';
10import { watchedFeeds } from '../review/backlogs';
11import { slotOf } from '../review/slots';
12import { configDir, dumpPath, dumpText } from './dump';
13import { addDumpLines, dumpLines } from './sections';
14import type { OnEvents } from '../on';
15import type { EngineInterface, Hook } from 'claude-code';
16
17// §13.2, §13.4: on the terminal the dump text goes to the clipboard too; the row of the outcome waits, so
18// that it lands below the command echo. The desktop has no clipboard path, so it gets the file only.
19const copyDump = async ($: EngineInterface, text: string): Promise<void> => {
20  const surfaces = await $.session.surfaces();
21  if (!surfaces.includes('terminal')) {
22    return;
23  }
24  const row = await $.ui.copy({ text, surface: 'terminal' }).then(
25    (copied) => (copied.isCopied ? 'dump copied to the clipboard' : `dump not copied: ${copied.reason}`),
26    (error: unknown) => `dump not copied: ${errorText(error)}`
27  );
28  $.clock.after(COMMAND_LOG_DELAY_MS, () => {
29    $.ui.log(row);
30  });
31};
32
33// §13.4: the review log, the lines of the other areas and, for `dump raw`, the last prompts, in one file
34// under `<config>/watchdog/dumps/`. Returns its path and text; undefined when no `<config>` is known.
35const writeDumpFile = async (
36  $: EngineInterface,
37  sessionId: string,
38  isRaw: boolean
39): Promise<{ path: string; text: string } | undefined> => {
40  const config = configDir(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'));
41  if (config === undefined) {
42    return undefined;
43  }
44  const time = await $.clock.now();
45  const text = dumpText({
46    sessionId,
47    time,
48    lines: dumpLines(),
49    records: currentLog(),
50    ...(isRaw ? { prompts: recentPrompts() } : {}),
51  });
52  const path = dumpPath(config, sessionId, time);
53  await $.fs.write(path, text);
54  return { path, text };
55};
56
57// §13.4: `/watchdog dump`: the file, and on the terminal the clipboard. Returns the reply.
58const writeDump = async ($: EngineInterface, isRaw: boolean): Promise<string> => {
59  const dump = await writeDumpFile($, await $.session.id(), isRaw);
60  if (dump === undefined) {
61    return 'watchdog dump failed: neither CLAUDE_CONFIG_DIR nor HOME is set';
62  }
63  await copyDump($, dump.text);
64  return `watchdog dump: ${dump.path}`;
65};
66
67// §13.4: `/watchdog dump` and `/watchdog dump raw`; the command hook passes them here.
68const onDumpCommand: Hook<'command.run'> = async ($, e, next) => {
69  const subcommand = parseSubcommand(e.args);
70  if (subcommand !== 'dump' && subcommand !== 'dump raw') {
71    return next(e);
72  }
73  const reply = await writeDump($, subcommand === 'dump raw').catch(
74    (error: unknown) => `watchdog dump failed: ${errorText(error)}`
75  );
76  return { text: reply };
77};
78
79// §7.5, §11.2: one `unreviewed: N updates` record for each backlog that no review took, the primary agent's and
80// each watched subagent's: the updates after the cursor, or after the batch of the review that runs on it.
81const recordUnreviewed = async ($: EngineInterface): Promise<void> => {
82  const time = await $.clock.now();
83  const records = watchedFeeds().flatMap(({ subagent, feed }) =>
84    Object.entries(feed.cursors).flatMap(([slug, cursor]) => {
85      const slot = slotOf(slug);
86      const isTaken = slot.state === 'reviewing' && slot.subagent === subagent?.agentId;
87      const updates = waitingUpdates(feed, isTaken ? slot.batchEnd : cursor);
88      const watchdog = watchdogBySlug(slug)?.name ?? slug;
89      return updates === 0 ? [] : [unreviewedRecord({ watchdog, time, updates })];
90    })
91  );
92  for (const record of records) {
93    addLogRecord(record);
94  }
95};
96
97// §10.6: at the end of a headless session, the dump file holds the notes that still wait, the `unreviewed`
98// records and the warnings. A session that never turned on and has no warning leaves no file.
99const onSessionEnd: Hook<'session.end'> = async ($, e, next) => {
100  if (!isInteractiveSession() && (currentMode() === 'on' || onWarnings().length > 0)) {
101    await recordUnreviewed($).catch(() => undefined);
102    await writeDumpFile($, e.sessionId, false).catch(() => undefined);
103  }
104  return next(e);
105};
106
107export const installDump = (on: OnEvents<'command.run' | 'session.end'>): void => {
108  on('command.run', { command: 'watchdog' }, onDumpCommand);
109  on('session.end', onSessionEnd);
110  // §10.6, §13.4: the notes that still wait, as their log row shows them.
111  addDumpLines(() =>
112    heldNotes().map((note) => `waiting: ${logRow(note, watchdogBySlug(note.watchdog)?.name ?? note.watchdog)}`)
113  );
114};
115
hooks/failure/install.ts 99 lines
1import { watchdogOf } from '../agents/ids';
2import { currentRoster } from '../agents/roster';
3import { parseSubcommand } from '../command/args';
4import { COMMAND_LOG_DELAY_MS } from '../constants';
5import { changedHealth, rememberErrorText, resetFailures, restoreHealth, stateRows } from './state';
6import type { OnEvents } from '../on';
7import type { EngineInterface, Hook } from 'claude-code';
8
9// §12.5: one `$.ui.log` row for each change of a problem state; from a `command.run` hook it waits (§13.2).
10// §12.3, §14.1: `$.state` keeps the failure state, written when it changed; a refused write loses only what a
11// reload would carry over.
12const report = async ($: EngineInterface, isCommand = false): Promise<void> => {
13  stateRows(currentRoster()).forEach((row) => {
14    if (isCommand) {
15      $.clock.after(COMMAND_LOG_DELAY_MS, () => {
16        $.ui.log(row);
17      });
18      return;
19    }
20    $.ui.log(row);
21  });
22  const health = changedHealth(currentRoster());
23  if (health !== undefined) {
24    await $.state.set({ plugin: 'watchdog', key: 'health' }, health).catch(() => undefined);
25  }
26};
27
28// §12.1: the text of a review's error is in its agent's synthetic row; the hook relays the row unchanged.
29const onSyntheticRow: Hook<'session.append'> = async (_$, e, next) => {
30  if (watchdogOf(e.agentId) !== undefined && e.agentId !== undefined) {
31    const text = e.message.content.map((block) =>
32      'text' in block && typeof block.text === 'string' ? block.text : ''
33    );
34    rememberErrorText(e.agentId, text.join('\n'));
35  }
36  return next(e);
37};
38
39// The review area beneath ends reviews and spawns them at boundaries and at person prompts; each row and
40// each state write follows.
41const onStep: Hook<'turn.step'> = async function* ($, e, next) {
42  const response = yield* next(e);
43  if (e.agentId === undefined) {
44    await report($);
45  }
46  return response;
47};
48
49const onComplete: Hook<'turn.complete'> = async ($, e, next) => {
50  const result = await next(e);
51  await report($);
52  return result;
53};
54
55const onPrompt: Hook<'prompt.submit'> = async ($, e, next) => {
56  const result = await next(e);
57  await report($);
58  return result;
59};
60
61// §5.2: `/watchdog on` beneath sets the states again and tries each watchdog at once, so the failure counts
62// of its roster go (§12.3 item 2).
63const onCommand: Hook<'command.run'> = async ($, e, next) => {
64  const result = await next(e);
65  if (parseSubcommand(e.args) === 'on') {
66    resetFailures(currentRoster());
67    await report($, true);
68  }
69  return result;
70};
71
72// §12.3: at module load, after the on order beneath turned the session on again, the stored failure state
73// comes back: the counts, the problems and the halt with its next-try time, so its wait goes on.
74const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
75  const result = await next(e);
76  const stored = await $.state.get({ plugin: 'watchdog', key: 'health' }).then(
77    (read) => read.value,
78    () => undefined
79  );
80  if (stored !== undefined) {
81    restoreHealth(stored, currentRoster());
82  }
83  await report($);
84  return result;
85};
86
87// Install before the command and review areas, so that these hooks sit above theirs. The matchers only tell
88// these `on()` from the other areas'.
89export const installFailure = (
90  on: OnEvents<'session.append' | 'turn.step' | 'turn.complete' | 'prompt.submit' | 'command.run' | 'session.start'>
91): void => {
92  on('session.append', { origin: { kind: 'model', model: '<synthetic>' } }, onSyntheticRow);
93  on('turn.step', { model: /^/u }, onStep);
94  on('turn.complete', { isAborted: [true, false] }, onComplete);
95  on('prompt.submit', { wait: [true, false] }, onPrompt);
96  on('command.run', { command: 'watchdog' }, onCommand);
97  on('session.start', { isInteractive: [true, false] }, onSessionStart);
98};
99
hooks/feed/install.ts 52 lines
1import { ownContext } from '../agents/ids';
2import { isOwnRow } from '../agents/self-review';
3import { currentMode } from '../lifecycle/mode';
4import { isPersonPrompt } from '../person';
5import { currentFeed, recordRow, setFeed } from './feed';
6import type { OnEvents } from '../on';
7import type { EngineInterface, Hook, PromptOrigin } from 'claude-code';
8
9// §10: a person prompt is known by its `prompt.submit` origin. The engine appends the prompt's row while the
10// submit runs, and in `-p` it stamps that row `unclassified` though the submit says `sdk` (live probe
11// l3-headless-prompt). So a `prompt` row that enters during a person submit takes the submit's origin.
12const memory: { submitting: PromptOrigin | undefined } = { submitting: undefined };
13
14// §7.1: `$.state` keeps the feed. The live copy is module memory, so a refused write (a value over
15// 4,194,304 characters of JSON) loses only what a reload would carry over.
16const saveFeed = async ($: EngineInterface): Promise<void> => {
17  await $.state.set({ plugin: 'watchdog', key: 'feed' }, currentFeed()).catch(() => undefined);
18};
19
20// §7.1: while on, each main-loop row enters the feed rendered (§7.6), except the watchdog's own rows (§7.3).
21// A row with an `agentId` never enters the primary agent's feed. The row is kept before `next(e)`, which
22// the hook relays.
23const onAppend: Hook<'session.append'> = async ($, e, next) => {
24  const isRecorded = currentMode() === 'on' && e.agentId === undefined && !isOwnRow(e, ownContext());
25  if (isRecorded) {
26    const { submitting } = memory;
27    setFeed(
28      recordRow(currentFeed(), e.door === 'prompt' && submitting !== undefined ? { ...e, origin: submitting } : e)
29    );
30  }
31  const result = await next(e);
32  if (isRecorded) {
33    await saveFeed($);
34  }
35  return result;
36};
37
38const onPersonPrompt: Hook<'prompt.submit'> = async (_$, e, next) => {
39  if (!isPersonPrompt(e.origin)) {
40    return next(e);
41  }
42  memory.submitting = e.origin;
43  return next(e).finally(() => {
44    memory.submitting = undefined;
45  });
46};
47
48export const installFeed = (on: OnEvents<'session.append' | 'prompt.submit'>): void => {
49  on('session.append', onAppend);
50  on('prompt.submit', { origin: { kind: /./u } }, onPersonPrompt);
51};
52
hooks/guidance/install.ts 97 lines
1import { currentRosterConfig, watchdogBySlug } from '../agents/roster';
2import { agentType } from '../agents/spec';
3import { frozenGuidance, isRegistered, setFrozenReads, setRegistered } from './memory';
4import { fullPrompt, isContextType, soleRepoChild } from './prompt';
5import type { OnEvents } from '../on';
6import type { Frozen } from './memory';
7import type { ContextFile, Fragments } from './prompt';
8import type { EngineInterface, Hook } from 'claude-code';
9
10// §8.2: the shipped fragments, read at the register of `/watchdog on` (§8.1).
11const readFragments = async ($: EngineInterface): Promise<Fragments> => {
12  const read = async (name: string): Promise<string> => $.fs.read(`${$.plugin.root}/prompts/${name}`);
13  const [context, memory, activeRepo] = await Promise.all([
14    read('context-files.md'),
15    read('memory-context.md'),
16    read('active-repo-watchdog.md'),
17  ]);
18  return { context, memory, activeRepo };
19};
20
21// Build-session choice "Prompt fragments": outside git, the one direct child of the cwd that has a `.git`.
22const readRepoChild = async ($: EngineInterface): Promise<string | null> => {
23  if ((await $.session.repo()) !== null) {
24    return null;
25  }
26  const cwd = await $.session.cwd();
27  const entries = await $.fs.list(cwd).catch(() => []);
28  const children = await Promise.all(
29    entries
30      .filter((entry) => entry.kind === 'dir')
31      .map(async ({ name }) => ({ name, hasGit: await $.fs.exists(`${cwd}/${name}/.git`).catch(() => false) }))
32  );
33  return soleRepoChild(children);
34};
35
36// §4.5: what `/watchdog on` froze; its first register reads the fragments and the repo child.
37const readFrozen = async ($: EngineInterface): Promise<Required<Frozen>> => {
38  const frozen = frozenGuidance();
39  if (frozen.reads !== undefined) {
40    return { guidance: frozen.guidance, reads: frozen.reads };
41  }
42  const [fragments, repoChild] = await Promise.all([readFragments($), readRepoChild($)]);
43  setFrozenReads({ fragments, repoChild });
44  return { guidance: frozen.guidance, reads: { fragments, repoChild } };
45};
46
47// §8.2: the session's memory files of now, each read with `$.fs.read`; a file that does not read is left out.
48const readContext = async ($: EngineInterface): Promise<ContextFile[]> => {
49  const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined);
50  const files = (usage?.context.breakdown?.memoryFiles ?? []).filter((file) => isContextType(file.type));
51  const read = await Promise.all(
52    files.map(async ({ path, type }) =>
53      $.fs.read(path).then(
54        (content) => [{ path, type, content }],
55        () => []
56      )
57    )
58  );
59  return read.flat();
60};
61
62// §4.5, §8.1: each register of a watchdog type by the mod (at `/watchdog on` and before each spawn) gets the
63// full system prompt: the parts after the base the caller filled, the context of now among them. Only a spec
64// that differs from the last one registered reaches the engine; an unchanged spec is a no-op.
65const onRegister: Hook<'agent.register'> = async ($, e, next) => {
66  const watchdog = watchdogBySlug(e.name);
67  if (next.origin.plugin !== 'watchdog' || watchdog === undefined) {
68    return next(e);
69  }
70  const [frozen, contextFiles] = await Promise.all([readFrozen($), readContext($)]);
71  const spec = {
72    ...e,
73    prompt: fullPrompt({
74      base: e.prompt,
75      fragments: frozen.reads.fragments,
76      contextFiles,
77      tools: watchdog.tools,
78      guidance: frozen.guidance,
79      repoChild: frozen.reads.repoChild,
80      rosterInstructions: currentRosterConfig().instructions,
81      instructions: watchdog.instructions,
82    }),
83  };
84  if (isRegistered(spec)) {
85    return { value: { agent: agentType(e.name) } };
86  }
87  const result = await next(spec);
88  if (!('deny' in result)) {
89    setRegistered(spec);
90  }
91  return result;
92};
93
94export const installGuidance = (on: OnEvents<'agent.register'>): void => {
95  on('agent.register', onRegister);
96};
97
hooks/lifecycle/install.ts 76 lines
1import { COMMAND } from '../command/spec';
2import { errorText } from '../errors';
3import { WATCHDOG_TOOLS } from '../note/tool';
4import { envSwitch, settingsUnreadWarning } from './headless';
5import { setMode } from './mode';
6import { addOnWarning, notePrompt, setEnvOn } from './on-order';
7import { isSupportedVersion } from './version';
8import type { OnEvents } from '../on';
9import type { EnvSwitch } from './headless';
10import type { EngineInterface, Hook } from 'claude-code';
11
12// §8.3, §10.8: the `note` and `resolve` tools exist from the next prompt on, so they register before any spawn.
13// `/watchdog on` registers them again and blocks the watchdogs on a refusal, so a refusal here waits for that.
14const registerTools = async ($: EngineInterface): Promise<void> => {
15  await Promise.all(WATCHDOG_TOOLS.map(async (tool) => $.tool.register(tool).catch(() => undefined)));
16};
17
18// §5.1: the call throws for a name that another plugin has; one row tells the person.
19const registerCommand = async ($: EngineInterface): Promise<void> => {
20  try {
21    await $.command.register(COMMAND);
22  } catch (error) {
23    $.ui.log(`/watchdog is not registered: ${errorText(error)}`);
24  }
25};
26
27// §5.3: the project and local settings must not turn a headless run on (ADR 0001); a read that fails keeps
28// the session off too.
29const projectSwitch = async ($: EngineInterface, value: string): Promise<EnvSwitch> =>
30  Promise.all([$.settings.read({ source: 'project' }), $.settings.read({ source: 'local' })]).then(
31    ([project, local]) => envSwitch(value, { project, local }),
32    (error: unknown) => ({ isOn: false, warning: settingsUnreadWarning(errorText(error)) })
33  );
34
35// §5.3: every session reads `CLAUDE_WATCHDOG` and unsets it, so no Bash child and no nested `claude -p` gets
36// it. Only a headless session uses the value; the on order (`command/install.ts`) applies it.
37const takeEnvSwitch = async ($: EngineInterface, isUsed: boolean): Promise<void> => {
38  const value = await $.env.get('CLAUDE_WATCHDOG').catch(() => undefined);
39  if (value === undefined) {
40    return;
41  }
42  await $.env.set('CLAUDE_WATCHDOG', undefined).catch(() => undefined);
43  if (!isUsed || value === '') {
44    return;
45  }
46  const { isOn, warning } = await projectSwitch($, value);
47  setEnvOn(isOn);
48  if (warning !== undefined) {
49    addOnWarning(warning);
50  }
51};
52
53// §5.1: the version gate first, the command last. In `unsupported` no tool registers.
54const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
55  const { base } = await $.session.version();
56  const isSupported = isSupportedVersion(base);
57  setMode(isSupported ? 'off' : 'unsupported');
58  await takeEnvSwitch($, isSupported && !e.isInteractive);
59  if (isSupported) {
60    await registerTools($);
61  }
62  await registerCommand($);
63  return next(e);
64};
65
66// §5.3: a Desktop attach counts only before the first prompt.
67const onPromptSubmit: Hook<'prompt.submit'> = async (_$, e, next) => {
68  notePrompt();
69  return next(e);
70};
71
72export const installLifecycle = (on: OnEvents<'session.start' | 'prompt.submit'>): void => {
73  on('session.start', onSessionStart);
74  on('prompt.submit', { text: /^/u }, onPromptSubmit);
75};
76
hooks/log/install.ts 47 lines
1import { watchdogOf } from '../agents/ids';
2import { watchdogBySlug } from '../agents/roster';
3import { currentLedger, tallyReview } from '../status/ledger';
4import { currentReviews, stopReasonOf } from '../stop/reviews';
5import { subagentOfReview } from '../subagents/watch';
6import { addLogRecord, countStep, currentLog, reviewRecord, takeTrace } from './log';
7import type { OnEvents } from '../on';
8import type { EngineInterface, TurnCompleteInput } from 'claude-code';
9
10// §13.4, §14.1: one record for each review, at the review agent's own `turn.complete`; `$.state` keeps the log
11// and the cost ledger across a reload, so a refused write loses only that carry-over. §13.3, §15: the ledger
12// counts the review. §7.8: the end of a review the mod stopped writes no record (a timeout wrote its own, and
13// `off`, `session` and `rewind` drop the result); it only counts in the ledger with its usage.
14const logReview = async ($: EngineInterface, end: TurnCompleteInput): Promise<void> => {
15  const slug = watchdogOf(end.agentId);
16  const watchdog = slug === undefined ? undefined : watchdogBySlug(slug);
17  if (watchdog === undefined || end.agentId === undefined) {
18    return;
19  }
20  const time = await $.clock.now();
21  const watch = subagentOfReview(end.agentId);
22  const subagent = watch === undefined ? undefined : { agentId: watch.agentId, type: watch.type };
23  const record = reviewRecord({ watchdog, agentId: end.agentId, time, end, trace: takeTrace(end.agentId), subagent });
24  tallyReview(watchdog.slug, record);
25  await $.state.set({ plugin: 'watchdog', key: 'ledger' }, currentLedger()).catch(() => undefined);
26  if (stopReasonOf(currentReviews(), end.agentId) === undefined) {
27    addLogRecord(record);
28    await $.state.set({ plugin: 'watchdog', key: 'log' }, currentLog());
29  }
30};
31
32// §12.1, §7.2: the review agent's steps are counted in a registration that did not spawn it (the spawning
33// `on('turn.step')` never sees them). The matchers only tell these `on()` from the review area's.
34export const installLog = (on: OnEvents<'turn.step' | 'turn.complete'>): void => {
35  on('turn.step', { turnId: /^/u }, async function* (_$, e, next) {
36    if (e.agentId !== undefined && watchdogOf(e.agentId) !== undefined) {
37      countStep(e.agentId);
38    }
39    return yield* next(e);
40  });
41  on('turn.complete', { turnId: /^/u }, async ($, e, next) => {
42    const result = await next(e);
43    await logReview($, e).catch(() => undefined);
44    return result;
45  });
46};
47
hooks/note/install.ts 238 lines
1import { watchdogBySlug } from '../agents/roster';
2import { addCard } from '../band/cards';
3import { DEFAULT_MAX_NOTES_PER_REVIEW } from '../constants';
4import { errorText } from '../errors';
5import { currentLog, traceNote } from '../log/log';
6import { isLateNote } from '../subagents/watch';
7import { batchTextOf, noteOf } from './call';
8import { UNSAFE_ROW, isUnsafeNote } from './destructive';
9import { dropHeldNote } from './drop';
10import { DROP_ACKS, judgeNote, normalizeNote, reviewSlots, setReviewSlots } from './guard';
11import {
12  EMPTY_HISTORY,
13  changeLiveHistory,
14  isRepeat,
15  liveHistory,
16  notesKey,
17  readHistory,
18  recordGuardKey,
19  recordNote,
20  setLiveHistory,
21  updateNote,
22  watchdogNotes,
23} from './history';
24import { deliveryFor, guardNote, heldNoteOf, holdNote, logRow, replaceHeldNote, watchHeldNotes } from './notes';
25import { SUPERSEDED, retract, retractionOf } from './retract';
26import type { OnEvents } from '../on';
27import type { NoteCall } from './call';
28import type { Verdict } from './guard';
29import type { NoteHistory } from './history';
30import type { HeldNote, Note } from './notes';
31import type { ResolveCall } from './retract';
32import type { Severity } from './tool';
33import type { EngineInterface, MatchedHook, ToolCallResult } from 'claude-code';
34
35type NoteHook = MatchedHook<'tool.call', { tool: 'mcp__watchdog__note' }>;
36
37type ResolveHook = MatchedHook<'tool.call', { tool: 'mcp__watchdog__resolve' }>;
38
39// §9.5: the ack of an admitted note.
40const ADMITTED = 'Queued. Do not re-raise.';
41
42// §9.6, §11.4: the live copy of `notes:<sessionId>` (or of a watched subagent's `notes:<sessionId>:<agentId>`)
43// loads at the first note hook of a session id, so a new process, a hot reload and a session change each load it
44// once. A load that another note finished first wins.
45const loadHistory = async ($: EngineInterface, agentId?: string): Promise<string> => {
46  const sessionId = await $.session.id();
47  const stored =
48    liveHistory(sessionId, agentId) === undefined ? await $.store.get(notesKey(sessionId, agentId)) : undefined;
49  if (liveHistory(sessionId, agentId) === undefined) {
50    setLiveHistory(sessionId, readHistory(stored), agentId);
51  }
52  return sessionId;
53};
54
55// §9.6, §14.2: write the live copy back with `lastUsed`. A refused write keeps the live copy; the store area
56// shows it.
57const saveHistory = async ($: EngineInterface, sessionId: string, agentId?: string): Promise<void> => {
58  const lastUsed = await $.clock.now();
59  const history: NoteHistory = { ...(liveHistory(sessionId, agentId) ?? EMPTY_HISTORY), lastUsed };
60  setLiveHistory(sessionId, history, agentId);
61  await $.store.set(notesKey(sessionId, agentId), history).catch(() => undefined);
62};
63
64// §9.1: the queued entry takes the higher severity in place, and the delivery state of that severity.
65const raiseHeld = (history: NoteHistory, queued: HeldNote, severity: Severity) => {
66  const raised = { ...queued, severity };
67  const shown: HeldNote = { ...raised, delivery: deliveryFor(raised) };
68  replaceHeldNote(queued, shown);
69  const change = { key: normalizeNote(queued.text), severity, delivery: shown.delivery };
70  return { shown, history: updateNote(history, queued.watchdog, change) };
71};
72
73// §9.4: the displaced note leaves the held list and the band, and the history and the review log mark it
74// `displaced`.
75const displaceHeld = (history: NoteHistory, note: Note, key: string | undefined): NoteHistory => {
76  const gone = key === undefined ? undefined : heldNoteOf(note, key);
77  if (gone === undefined || key === undefined) {
78    return history;
79  }
80  dropHeldNote(gone);
81  traceNote({ ...gone, delivery: 'displaced' });
82  return updateNote(history, note.watchdog, { key, delivery: 'displaced' });
83};
84
85// §9.6: a new note waits in the held list and joins the history.
86const holdNew = (history: NoteHistory, note: Note, displaced: string | undefined) => {
87  const kept = displaceHeld(history, note, displaced);
88  const shown: HeldNote = { ...note, delivery: deliveryFor(note) };
89  holdNote(shown);
90  addCard(shown);
91  return {
92    shown,
93    history: recordNote(kept, note.watchdog, { text: note.text, severity: note.severity, delivery: shown.delivery }),
94  };
95};
96
97// §9.2 to §9.4, §11.4: what the guard of the note's watchdog knows about the note's watched agent, and its
98// verdict.
99const judge = (history: NoteHistory, note: Note, key: string): Verdict =>
100  judgeNote(
101    { key, severity: note.severity },
102    {
103      seen: watchdogNotes(history, note.watchdog).keys.find((known) => known.key === key)?.severity,
104      slots: reviewSlots(note.watchdog, note.agentId),
105      budget: watchdogBySlug(note.watchdog)?.maxNotesPerReview ?? DEFAULT_MAX_NOTES_PER_REVIEW,
106      pendingSeverity: (pending) => heldNoteOf(note, pending)?.severity,
107    }
108  );
109
110// §9: an admitted note waits for its delivery, joins the live history of its watched agent and the review log,
111// and writes one row; the ack.
112const emitNote = ($: EngineInterface, sessionId: string, note: Note): string => {
113  const key = normalizeNote(note.text);
114  const history = liveHistory(sessionId, note.subagent?.agentId) ?? EMPTY_HISTORY;
115  const queued = heldNoteOf(note, key);
116  const verdict = judge(history, note, key);
117  if (verdict.kind === 'dropped') {
118    return DROP_ACKS[verdict.reason];
119  }
120  setReviewSlots(note.watchdog, note.agentId, verdict.slots);
121  const next =
122    verdict.kind === 'raised' && queued !== undefined
123      ? raiseHeld(history, queued, note.severity)
124      : holdNew(history, note, 'displaced' in verdict ? verdict.displaced : undefined);
125  setLiveHistory(sessionId, next.history, note.subagent?.agentId);
126  traceNote({ ...next.shown, agentId: note.agentId });
127  $.ui.log(logRow(next.shown, watchdogBySlug(note.watchdog)?.name ?? note.watchdog));
128  return ADMITTED;
129};
130
131// §11.4: a late note on a subagent goes to the primary agent, so it is first checked against the primary agent's
132// key set. A repeat there is dropped; an admitted note records its key there too.
133const isLateRepeat = async ($: EngineInterface, note: Note): Promise<boolean> => {
134  const sessionId = isLateNote(note) ? await loadHistory($) : undefined;
135  const entry = { key: normalizeNote(note.text), severity: note.severity };
136  return sessionId !== undefined && isRepeat(liveHistory(sessionId) ?? EMPTY_HISTORY, note.watchdog, entry);
137};
138
139const keepLateKey = async ($: EngineInterface, sessionId: string, note: Note): Promise<void> => {
140  const entry = { key: normalizeNote(note.text), severity: note.severity };
141  setLiveHistory(sessionId, recordGuardKey(liveHistory(sessionId) ?? EMPTY_HISTORY, note.watchdog, entry));
142  await saveHistory($, sessionId);
143};
144
145// §12.6: a note with a destructive command that its review's batch does not hold is dropped, with one row; then a
146// guard of another area may drop it. The drop's ack, or undefined for a note that goes on to the emission guard.
147const refusal = ($: EngineInterface, note: Note): ToolCallResult | undefined => {
148  if (isUnsafeNote(note.text, batchTextOf(note.agentId))) {
149    traceNote({ ...note, delivery: 'dropped:unsafe' });
150    $.ui.log(UNSAFE_ROW);
151    return { result: DROP_ACKS.unsafe };
152  }
153  const dropped = guardNote(note);
154  return dropped === undefined ? undefined : { result: dropped };
155};
156
157// §8.3, §12.6, §9: a note of a known watchdog passes the destructive check first, then the guards of other
158// areas, then the emission guard of its watched agent (§11.4); the history is written back after each
159// admitted note.
160const admitNote = async ($: EngineInterface, e: NoteCall): Promise<ToolCallResult> => {
161  const note = noteOf(e);
162  if ('deny' in note) {
163    return note;
164  }
165  const refused = refusal($, note);
166  if (refused !== undefined) {
167    return refused;
168  }
169  const isLate = isLateNote(note);
170  if (await isLateRepeat($, note)) {
171    return { result: DROP_ACKS.duplicate };
172  }
173  const sessionId = await loadHistory($, note.subagent?.agentId);
174  const ack = emitNote($, sessionId, note);
175  if (ack === ADMITTED) {
176    await saveHistory($, sessionId, note.subagent?.agentId);
177  }
178  if (ack === ADMITTED && isLate) {
179    await keepLateKey($, sessionId, note);
180  }
181  return { result: ack };
182};
183
184// §8.3: the hook answers without `next(e)`, so no permission check runs; it catches every error, because
185// a throw opens a permission dialog in front of the person.
186const onNote: NoteHook = async ($, e) => {
187  try {
188    return await admitNote($, e);
189  } catch (error) {
190    return { deny: `The note was not recorded: ${errorText(error)}` };
191  }
192};
193
194// §10.8: a retraction drops the open note and writes one row; the history of the note's watched agent and the review
195// log are written back. A refused write keeps the live copies; the store area shows a refused history write.
196const retractNote = async ($: EngineInterface, e: ResolveCall): Promise<ToolCallResult> => {
197  const asked = retractionOf(e);
198  if (!('note' in asked)) {
199    return asked;
200  }
201  const { note, reason } = asked;
202  const sessionId = await loadHistory($, note.subagent?.agentId);
203  const ack = retract(note, reason);
204  $.ui.log(logRow({ ...note, delivery: SUPERSEDED }, watchdogBySlug(note.watchdog)?.name ?? note.watchdog));
205  await saveHistory($, sessionId, note.subagent?.agentId);
206  await $.state.set({ plugin: 'watchdog', key: 'log' }, currentLog()).catch(() => undefined);
207  return { result: ack };
208};
209
210// §8.3, §10.8: the `resolve` hook answers like the `note` hook: without `next(e)`, and it catches every error.
211const onResolve: ResolveHook = async ($, e) => {
212  try {
213    return await retractNote($, e);
214  } catch (error) {
215    return { deny: `The note was not retracted: ${errorText(error)}` };
216  }
217};
218
219// §8.3: the `.catch` form of 2.1.290; it denies only a call from a watchdog agent or a fork. The tool
220// names are literals so that `claude plugin validate` lists the matchers. §7.7, §9.6: the recap shows the state each
221// held note has now (a new route, a delivery); the next write of the history keeps it.
222export const installNote = (on: OnEvents<'tool.call'>): void => {
223  on('tool.call', { tool: 'mcp__watchdog__note' }, onNote).catch((_$, e, next) =>
224    next.called || next.origin.plugin !== 'watchdog' || e.agentId === undefined
225      ? next(e)
226      : { deny: 'The note was not recorded.' }
227  );
228  on('tool.call', { tool: 'mcp__watchdog__resolve' }, onResolve).catch((_$, e, next) =>
229    next.called || next.origin.plugin !== 'watchdog' || e.agentId === undefined
230      ? next(e)
231      : { deny: 'The note was not retracted.' }
232  );
233  watchHeldNotes((_before, after) => {
234    const change = { key: normalizeNote(after.text), delivery: after.delivery };
235    changeLiveHistory(after.subagent?.agentId, (history) => updateNote(history, after.watchdog, change));
236  });
237};
238