SLOPSHOPPER

decision-watch

Stop-time decision watch: judges each turn with the session's own model login, logs the verdict, and shows a FLAG or UNKNOWN. Adds /dw. Never blocks.

newcommandtoastmodelprocess
★ 1v0.0.2no licenseupdated 2026-10-16shivinate7/claude-settings/mods/decision-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-watch
› 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 › /dw ⎿ decision-watch: stop input: session preview-session ⎿ decision-watch: phase 1 (python, cheap path): exit 0, 1 ms ⎿ decision-watch: RESULT: nothing to say (no protected change, no model call) ⎿ decision-watch: Stops seen: 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

decision-watch

A mod. It runs the Stop-time decision watch with this session's own model login. It replaces the Stop hook row. It never blocks a turn. A FLAG or UNKNOWN verdict shows in the session as a toast. An ALLOW verdict shows nothing. Each config notice shows as its own toast, marked Decision watch notice:.

What it does

  • On every Stop, the mod writes one "STOP seen" line to the log. It waits at most 0.5 s for that write. Then it returns at once.
  • It then judges the turn in the background. It skips the judge when stop_hook_active is set.
  • One judge runs at a time. A Stop that arrives during a judge logs "SKIP busy" and does not judge.
  • The judging uses hooks/decision_watch.py in your Claude config folder, in two steps.
  • --emit-prompt runs the cheap path. It prints a prompt only when a model call is due.
  • The mod calls the model with that prompt. The mod enforces a 50 s limit itself (a timer races the call). A call that passes it reads as a model error. Then --model-text or --model-error gives the verdict.
  • Each python run (30 s) and each python finder (5 s) also has a mod timer: the host limit plus 1 s. A run that passes it reads UNKNOWN. A toast has a 5 s timer. A hung python, model or toast call frees the one-judge slot. The $.env.get reads have no timer.
  • Every failure reads as UNKNOWN. A failure never reads as ALLOW.
  • Phase 1 prints a key for the evidence it judged. The mod gives it back to phase 2 as --key after the reply. Evidence that moved between the two steps reads UNKNOWN. A phase 1 with no key reads UNKNOWN.
  • Under the split flags, the script prints verdict and notices as separate JSON fields. The mod does not parse joined text.
  • The verdict toast holds the script's own FLAG or UNKNOWN message, or the mod's own UNKNOWN text. Each notice (config files, merges into main, unread subjects) gets its own toast. Each notice is logged on its own NOTICE line or lines before the END line, which holds the verdict only. A verdict that is neither FLAG nor UNKNOWN is logged as NOT SHOWN. A failed toast is logged as TOAST FAILED, with the text it failed to show.
  • A model verdict that the script cannot use reads as "verdict lost". This happens when another judge already recorded the same turn. The script prints this marker whenever it gets a model reply and ends before the model step. A valid ALLOW that the script uses stays ALLOW.
  • A new human message between the two steps also causes it. The baseline moves, so a real FLAG can print "verdict lost". That is loud and correct.
  • The mod sends the Stop JSON to python as ASCII only. It escapes each other character as \uXXXX.

Log

  • The log is shadow.log (the file name is kept) in the state folder decision-watch-shadow, inside the config folder.
  • Each judged Stop has a START line and an END line. The END line holds the timings and the verdict line. The log never holds the prompt.
  • The log keeps the newest 500 lines. A line is cut at 2000 characters.
  • Writes go one at a time. The host writes the folder if it is missing.
  • If the host refuses to read a log that is too big, the log starts over with the new line.
  • A log write waits at most 2 s. A late write can be lost.

Where things live

  • The script path is CLAUDE_CONFIG_DIR/hooks/decision_watch.py. With no CLAUDE_CONFIG_DIR, the mod uses USERPROFILE or HOME, then .claude.
  • The background judge and /dw each have their own state folder: decision-watch-shadow and decision-watch-dw. Each holds its own "already judged" cache. A background verdict does not mute /dw.
  • The mod picks the first python3 or python on PATH that is not the Windows Store stub.

/dw

The command runs the same judgment on the latest turn. It shows the timings, the result and each notice. It never shows the prompt. Before the first Stop, it finds the transcript from the session id.

The last line is "Stops seen: N". N counts the STOP seen lines now in the log. The log keeps 500 lines, so N is at most 500.

/dw has its own cache. Run on the same turn twice, the second run says "nothing to say".

Unknown

  • Whether the desktop app and the terminal both give the model call the same login.
  • Whether $.model.complete stops at timeoutMs. The request field is not in the host's type, so the mod does not rely on it. Its own timer enforces the 50 s limit. The host call may keep running after the timer.
  • Whether $.process.run kills the child at timeoutMs. The mod's timer frees the slot, but it does not kill the child. A hung python may keep running.

Limits

  • The script must be the version that knows --emit-prompt, --model-text, and --model-error.
  • A bad flag combination on the script falls back to the normal claude -p call. This is a hole: a wrong flag from a caller runs the old path.
  • A Stop that arrives during a judge is skipped. The script scopes a turn by the file time after the last human message. So a Stop for a new turn that lands during a judge is never judged. Hole in decision-watch-mod-limits.
  • The ruling and the holes: decision-watch-mod-limits (limits of the mod).

Try in the desktop app

Refresh the installed ~/.claude/hooks/decision_watch.py first. Replace it with this repo's hooks/decision_watch.py. An old copy ignores --emit-prompt, so each Stop runs claude -p.

  1. Install: /plugin install decision-watch --marketplace shivinate7/claude-settings, then restart the session.
  2. Finish a turn. Run /dw. Check that the last line counts the Stop.

Check

  • node mods/decision-watch/decision-watch.test.mjs runs the tests (stdlib only).
  • claude plugin validate mods/decision-watch checks the mod.
  • Try it: claude --plugin-dir mods/decision-watch, then /dw.
Source 1 files
hooks/register.mjs 322 lines
1// decision-watch: runs the Stop-time decision watch (hooks/decision_watch.py) with the session's
2// own model login, and writes the verdict to a log. A FLAG or UNKNOWN verdict shows in the session
3// with $.ui.toast. An ALLOW verdict shows nothing. Each config notice shows as its own toast, marked
4// as a notice (decision decision-watch-mod-limits). It never blocks and never changes the Stop result.
5//
6// classic.Stop  returns next(e) at once. The judging runs in the background and is skipped when
7//               stop_hook_active is set.
8// /dw           runs the same judgment on the latest turn and shows the result.
9//
10// The python script has two phases: --emit-prompt prints the prompt when a model call is due,
11// then --model-text or --model-error finishes the verdict. This mod makes the model call between.
12// Phase 1 prints a "key" for the evidence it judged. Phase 2 gets it back as --key after the reply,
13// so evidence that moved between the phases reads UNKNOWN, never a silent ALLOW. No key reads UNKNOWN.
14// The script prints `verdict` and `notices` apart, so the mod never parses joined text.
15// The log holds the verdict line, the notices and timings, never the prompt.
16//
17// The host reads on(...) and $.noun.method(...) from source, so they are spelled literally.
18
19let lastStop; // the last classic.Stop input this session, for /dw
20let busy = false; // true while a background judge runs
21
22// The prefix of every UNKNOWN this mod makes itself; python's own UNKNOWN text comes through as printed.
23const UNKNOWN = "Decision watch: UNKNOWN, ";
24// The mark on a config notice toast. It never matches a FLAG or UNKNOWN verdict.
25const NOTICE = "Decision watch notice: ";
26const NOTHING = "nothing to say (no protected change, no model call)";
27const msg = (err) => String((err && err.message) || err).slice(0, 300);
28// $.model.complete returns {isAnswered, text, usage}. Accept a plain string too.
29const textOf = (r) => (typeof r === "string" ? r : String((r && r.text) ?? ""));
30
31export function register(on) {
32  on("session.start", async ($, e, next) => {
33    await $.command.register({
34      name: "dw",
35      description: "Run the decision watch on the latest turn with this session's own model login",
36    });
37    return next(e);
38  });
39
40  on("command.run", { command: "dw" }, async ($, e) => {
41    const lines = [];
42    try {
43      const stop = lastStop ?? (await deriveStop($));
44      lines.push(`stop input: session ${stop.session_id}`);
45      const out = await judge($, stop, "dw");
46      lines.push(...out.trace, `RESULT: ${out.result}`, ...out.notices.map((n) => `NOTICE: ${n}`));
47    } catch (err) {
48      lines.push(`RESULT: UNKNOWN (mod error): ${msg(err)}`);
49    }
50    lines.push(`Stops seen: ${await stopsSeen($)}`);
51    return { text: lines.join("\n") };
52  });
53
54  // Background judge. Waits at most STOP_WAIT_MS for one "STOP seen" line, then returns next(e).
55  // The judging is fire-and-forget, one judge at a time. A Stop that finds one running logs "SKIP busy".
56  on("classic.Stop", async ($, e, next) => {
57    lastStop = stopOf(e);
58    const note = e.stop_hook_active ? " (stop_hook_active, no judge)" : "";
59    try {
60      await within(log($, `STOP seen ${e.session_id}${note}`), STOP_WAIT_MS);
61    } catch {
62      // the log never holds a Stop
63    }
64    if (!e.stop_hook_active) {
65      if (busy) {
66        void log($, `SKIP busy ${e.session_id}`);
67      } else {
68        busy = true;
69        void shadow($, lastStop).finally(() => {
70          busy = false;
71        });
72      }
73    }
74    return next(e);
75  });
76}
77
78function stopOf(e) {
79  return {
80    session_id: e.session_id,
81    transcript_path: e.transcript_path,
82    cwd: e.cwd,
83    hook_event_name: "Stop",
84    stop_hook_active: false,
85  };
86}
87
88async function configDir($) {
89  const given = await $.env.get("CLAUDE_CONFIG_DIR");
90  if (given) return given;
91  const home = (await $.env.get("USERPROFILE")) || (await $.env.get("HOME"));
92  return `${home}/.claude`;
93}
94
95// No Stop seen yet: rebuild transcript_path from the session id and cwd the way the app names it.
96async function deriveStop($) {
97  const id = await $.session.id();
98  const cwd = await $.session.cwd();
99  const path = `${await configDir($)}/projects/${cwd.replace(/[^A-Za-z0-9]/g, "-")}/${id}.jsonl`;
100  if (!(await $.fs.exists(path))) throw new Error("no Stop seen yet and no transcript found for this session");
101  return { session_id: id, transcript_path: path, cwd, hook_event_name: "Stop", stop_hook_active: false };
102}
103
104// First python on PATH that is not the WindowsApps Store stub (settings.json's launcher does the same).
105async function pickPython($) {
106  for (const argv of [["where.exe", "python3", "python"], ["which", "-a", "python3", "python"]]) {
107    try {
108      const r = await deadline($.process.run(argv, { timeoutMs: FIND_MS }), FIND_MS + 1000);
109      const hit = r.stdout.split(/\r?\n/).map((s) => s.trim()).find((s) => s && !/WindowsApps/i.test(s));
110      if (r.exitCode === 0 && hit) return hit;
111    } catch {
112      // try the next finder
113    }
114  }
115  throw new Error("no python on PATH other than the WindowsApps stub");
116}
117
118// ASCII-only JSON: python -I may read stdin in a legacy code page, so escape every non-ASCII character.
119const asciiJson = (v) => JSON.stringify(v).replace(/[\u0080-￿]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
120
121// The background judge and /dw each get their own state folder, so neither mutes the other's
122// "already judged" cache.
123const stateDir = (cfg, who) => `${cfg}/decision-watch-${who}`;
124
125async function dwRun($, stop, extra, who) {
126  const cfg = await configDir($);
127  const py = await pickPython($);
128  const t0 = Date.now();
129  // The mod's own timer frees the slot if the host does not stop the child. It does not kill the child.
130  const r = await deadline($.process.run([py, "-I", `${cfg}/hooks/decision_watch.py`, ...extra], {
131    stdin: asciiJson(stop),
132    env: { CLAUDE_CONFIG_DIR: stateDir(cfg, who), DW_REAL_CONFIG_DIR: cfg },
133    timeoutMs: PY_MS,
134  }), PY_MS + 1000);
135  return { ...r, ms: Date.now() - t0 };
136}
137
138// Two phases. Every failure is UNKNOWN, never a pass: the python side prints the same
139// "Decision watch: UNKNOWN ..." text for a model error it is handed with --model-error.
140async function judge($, stop, who) {
141  const trace = [];
142  const p1 = await dwRun($, stop, ["--emit-prompt"], who);
143  trace.push(`phase 1 (python, cheap path): exit ${p1.exitCode}, ${p1.ms} ms`);
144  if (p1.exitCode !== 0) return unknown(trace, `decision_watch exited ${p1.exitCode}: ${p1.stderr.slice(0, 200)}`);
145  const raw = p1.stdout.trim();
146  if (raw === "") return { trace, result: NOTHING, shown: "", notices: [] };
147  let out;
148  try {
149    out = JSON.parse(raw);
150  } catch {
151    return unknown(trace, `phase 1 output was not JSON: ${raw.slice(0, 200)}`);
152  }
153  if (!out.needs_model) {
154    const { verdict, notices } = partsOf(out);
155    const result = verdict ? `message from the cheap path: ${verdict}` : notices.length ? NOTHING : `message from the cheap path: ${raw.slice(0, 300)}`;
156    return { trace, result, shown: verdict, notices };
157  }
158  if (typeof out.key !== "string" || out.key === "") {
159    return unknown(trace, "phase 1 printed no key, so the reply cannot be tied to the evidence judged (an old decision_watch.py?)");
160  }
161
162  trace.push(`model due: ${out.model}, prompt ${out.prompt.length} chars`);
163  let args;
164  let modelFailed = false;
165  const t0 = Date.now();
166  try {
167    // The host's timeoutMs is unproven, so the mod enforces the limit itself: the race rejects, phase 2
168    // gets --model-error, and the one-judge slot frees. A late reply is dropped.
169    const reply = await deadline($.model.complete({ model: out.model, prompt: out.prompt, maxTokens: 512, timeoutMs: MODEL_MS }), MODEL_MS);
170    const text = textOf(reply);
171    if (text === "") throw new Error("the model reply carried no text");
172    trace.push(`model call: OK ${Date.now() - t0} ms, reply ${text.length} chars`);
173    args = ["--model-text", text.slice(0, 4000)];
174  } catch (err) {
175    modelFailed = true;
176    trace.push(`model call: FAIL ${Date.now() - t0} ms`);
177    args = ["--model-error", `model call: ${msg(err)}`];
178  }
179  const p2 = await dwRun($, stop, [...args, "--key", out.key], who);
180  trace.push(`phase 2 (python, verdict): exit ${p2.exitCode}, ${p2.ms} ms`);
181  if (p2.exitCode !== 0) return unknown(trace, `decision_watch exited ${p2.exitCode} in phase 2: ${p2.stderr.slice(0, 200)}`);
182  let parts = { verdict: "", notices: [] };
183  try {
184    if (p2.stdout.trim() !== "") parts = partsOf(JSON.parse(p2.stdout));
185  } catch {
186    return unknown(trace, `phase 2 output was not JSON: ${p2.stdout.slice(0, 200)}`);
187  }
188  if (parts.verdict === "" && modelFailed) return unknown(trace, "the model call failed and the script printed no verdict", parts.notices);
189  return { trace, result: parts.verdict === "" ? "ALLOW (silent)" : parts.verdict, shown: parts.verdict, notices: parts.notices };
190}
191
192// The script prints `verdict` and `notices` under a split flag. Output with no `notices` array
193// (a script that prints systemMessage only) is all verdict.
194function partsOf(o) {
195  if (Array.isArray(o.notices)) {
196    return { verdict: typeof o.verdict === "string" ? o.verdict : "", notices: o.notices.filter((n) => typeof n === "string" && n) };
197  }
198  return { verdict: typeof o.systemMessage === "string" ? o.systemMessage : "", notices: [] };
199}
200
201// A failure the mod itself sees is UNKNOWN, and it shows like the script's own UNKNOWN.
202function unknown(trace, why, notices = []) {
203  return { trace, result: `UNKNOWN: ${why}`, shown: UNKNOWN + why, notices };
204}
205
206async function shadow($, stop) {
207  await log($, `START ${stop.session_id}`);
208  let line;
209  let shown = "";
210  let notices = [];
211  try {
212    const out = await judge($, stop, "shadow");
213    // Each notice goes on its own log lines, before END, so the END cut at MAX_LINE never drops one.
214    // A longer notice is split over several lines with the same prefix. The pieces, joined, give the notice with
215    // its whitespace folded. A cut never splits a surrogate pair, and no piece after the first starts with a space,
216    // because log() would fold that space into the prefix. Whitespace at the very start of a notice is lost.
217    const prefix = `NOTICE ${stop.session_id} | NOTICE: `;
218    const room = Math.max(MAX_LINE - TIME_LEN - 1 - prefix.length, 100);
219    for (const n of out.notices) {
220      const text = n.replace(/\s+/g, " ");
221      let i = 0;
222      do {
223        let j = Math.min(i + room, text.length);
224        while (j < text.length && j > i + 1 && (text[j] === " " || /[\uDC00-\uDFFF]/.test(text[j]))) j--;
225        await log($, prefix + text.slice(i, j));
226        i = j;
227      } while (i < text.length);
228    }
229    line = `END ${stop.session_id} | ${out.trace.join(" | ")} | ${out.result}`;
230    shown = out.shown;
231    notices = out.notices;
232  } catch (err) {
233    line = `END ${stop.session_id} | MOD ERROR, reads as UNKNOWN: ${msg(err)}`;
234    shown = UNKNOWN + `the mod raised (${msg(err)})`;
235  }
236  await log($, line);
237  // A FLAG or UNKNOWN verdict toasts. An ALLOW verdict is silent. Each notice toasts on its own line.
238  if (/^Decision watch: (FLAG|UNKNOWN)/.test(shown)) {
239    await toast($, stop, shown);
240  } else if (shown) {
241    await log($, `NOT SHOWN ${stop.session_id} | not a FLAG or UNKNOWN | text: ${shown}`);
242  }
243  for (const n of notices) await toast($, stop, NOTICE + n);
244}
245
246async function toast($, stop, text) {
247  try {
248    await deadline(Promise.resolve($.ui.toast(text)), TOAST_MS);
249  } catch (err) {
250    await log($, `TOAST FAILED ${stop.session_id} | ${msg(err)} | text: ${text}`);
251  }
252}
253
254const MAX_LOG_LINES = 500;
255const MAX_LINE = 2000;
256const TIME_LEN = 24; // length of an ISO timestamp, the start of each log line
257const LOG_WAIT_MS = 2000;
258const STOP_WAIT_MS = 500;
259const MODEL_MS = 50000;
260const PY_MS = 30000; // each python run (unmeasured; split mode calls no model); the mod's timer adds 1 s
261const FIND_MS = 5000; // each python finder; the mod's timer adds 1 s
262const TOAST_MS = 5000;
263
264// Like within, but a late promise rejects, so the caller can fail the step and free its slot.
265async function deadline(work, ms) {
266  let timer;
267  try {
268    return await Promise.race([work, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`no reply within ${ms} ms`)), ms); })]);
269  } finally {
270    clearTimeout(timer);
271  }
272}
273
274// Race a promise against a timer, and free the timer either way.
275async function within(work, ms) {
276  let timer;
277  try {
278    return await Promise.race([work, new Promise((resolve) => { timer = setTimeout(resolve, ms); })]);
279  } finally {
280    clearTimeout(timer);
281  }
282}
283
284async function logFile($) {
285  return `${stateDir(await configDir($), "shadow")}/shadow.log`;
286}
287
288// Writes go one at a time, so two lines never read the same old file. $.fs.write makes a missing folder.
289// The file keeps its newest MAX_LOG_LINES lines. A file the host will not read (too big) starts over.
290let logChain = Promise.resolve();
291function log($, line) {
292  const work = async () => {
293    try {
294      const file = await logFile($);
295      let old = [];
296      try {
297        if (await $.fs.exists(file)) old = String(await $.fs.read(file)).split("\n").filter(Boolean);
298      } catch {
299        old = [];
300      }
301      // Whitespace is folded, as guard_core.log_cut does, so no text can forge a second log line.
302      const next = `${new Date().toISOString()} ${line.replace(/\s+/g, " ")}`.slice(0, MAX_LINE);
303      await $.fs.write(file, [...old.slice(-(MAX_LOG_LINES - 1)), next].join("\n") + "\n");
304    } catch {
305      // the log never fails the Stop
306    }
307  };
308  const done = logChain.then(() => within(work(), LOG_WAIT_MS));
309  logChain = done;
310  return done;
311}
312
313async function stopsSeen($) {
314  try {
315    const file = await logFile($);
316    if (!(await $.fs.exists(file))) return 0;
317    return String(await $.fs.read(file)).split("\n").filter((l) => /^\S+ STOP seen /.test(l)).length;
318  } catch {
319    return "unknown";
320  }
321}
322