doctor's hook-drift row counts again. 0.84.0 demoted it to advisory on a claim about what a doctor process can know; this release replaces that claim with a measurement and puts the row back on the exit code.
The half that reaches an ordinary install is the basis, not the flip. hook-drift asks whether the tree doctor runs from differs from the plugin Claude Code actually fires hooks from. Answering that needs the running root, and the only resolver the repo had — activePluginRoot() — reads installed_plugins.json and the versioned cache, so it can return nothing but a cache path. The plugins reference documents three install shapes that are not copied into that cache and run where they sit: --plugin-dir, a skills-directory plugin, a synced plugin. For all three the row was handed a leftover cache directory belonging to some other install and compared the real running plugin against it. That is worse than uninformative: the row printed drift, named a root as "the running plugin root" that was not running, and advised a refresh for a condition nobody had.
CLAUDE_PLUGIN_ROOT is not the way out, and 0.84.0 shipped believing it was. Claude Code expands that token textually into the hooks.json command string and exports no variable. Measured three ways in a live session with these hooks firing: env | grep -c CLAUDE_PLUGIN returns 0; transcripts record the command with the path already substituted; process.env.CLAUDE_PLUGIN_ROOT is undefined. A row gated on it was inert in production and would have fired only for someone who had exported it by hand — the maintainer run it was meant to keep.
What does exist is a measurement. Hooks are the one component Claude Code launches from the directory it loaded, and every hook already resolves its own location from ${BASH_SOURCE[0]} — a $ the shell expands inside a process Claude Code started, which is exactly the observer no later script can be. session-start-check.sh and session-end-check.sh now record that directory into ~/.claude/.claudemd-state/hook-root.json through hook_record_plugin_root, a non-blocking writer that skips a root carrying a quote, a backslash or a newline rather than leaving a half-written file behind. runningPluginRoot() (scripts/lib/paths.js) reads it back, checks the recorded path is still a directory, and falls back to activePluginRoot() when there is no record — so a machine where hooks never fired, or one that has not restarted since installing this version, behaves exactly as it did on 0.84.1. Both drift rows compare against it. An in-place install therefore compares its own directory against itself and receives compareHooks's self-compare skip: green by measurement rather than silent by policy.
What changes in the exit code, and for whom. hook-drift reaches a byte comparison only after a chain of skips declines it, in this order: plugin-root:stale-registration, then the new one below, then compareHooks's own self-compare. The self-compare skip is what covers the callers the row could not identify. The one added in the pre-merge review covers a state the record itself can be in: hook-root.json is one global file and the last hook to write it wins, so a user who upgrades while an older session is still open in another terminal gets that session's closing hook writing the old cache directory into the record. doctor in the live session then compares two real installs, reports drift, names a cause that is false and advises /claudemd-refresh, which cannot clear it — only the next SessionStart rewrites the record. plugin-root:stale-registration does not catch that one, because it needs the running version to be older than the registered one and here doctor's own root is the registered one. So hook-drift now also skips when doctor's root and the recorded root are two different directories inside the plugin cache: that is a version transition, and the pending-restart question already has its own row.
With that chain in place, a red hook-drift moves /claudemd-doctor to exit 3 again. Red takes two separate things, and the difference is worth keeping: the chain has to decline — the new skip needs doctor's own tree and the recorded root to be both inside the plugin cache and different from each other, so a pair with a checkout or another run-in-place tree on either side gets compared — and the hooks then have to differ byte for byte. A checkout that matches the plugin it is compared against is green.
No shape reachable by an ordinary install has been found. That is deliberately weaker than what this paragraph said in two earlier drafts: each enumerated the states an install-only machine can be in and each was wrong, the second because activePluginRoot()'s third step returns the marketplace clone rather than a cache path. The counter-example the pre-tag review built from that needed a cache directory whose name is not a semver, which claudemd's releases do not produce. Who feels the flip is who it always was — someone with a checkout and an install — but not only in the shape that reads as obvious.
The record is global, so the pair is not always "the tree you launched doctor from versus your own session". Run /claudemd-doctor from the ordinary cache install while a checkout session fired the last hook, and — if that checkout's hooks differ from the install's — the row goes red about a root you did not launch: measured — exit 3, hook-drift the only counted red, the detail naming the checkout. Nothing is wrong on that install; two sessions disagree about which root is running and the last writer won. That is why the row names the recording session alongside the timestamp — via hook-fired at <ts> (sid <id>) — so a reader can tell "my tree has drifted" from "another session wrote this basis" instead of guessing. What changes the answer is a SessionStart in the session whose root you care about: that is the event that rewrites the record, and hooks/session-start-check.sh and hooks/session-end-check.sh are the only two things in the plugin that write it. hook-drift:upstream stays advisory on the 0.84.0 reasoning, which this release does not disturb — the marketplace clone tracks main rather than the released tag, so its red is a legitimate steady state between releases.
No kill switch ships with the flip, and that is a decision rather than an oversight. No shape reachable by an ordinary install was found for the change to reach, and a switch that nobody can be shown to need is a switch that rots unexercised and is wrong by the time someone reaches for it. The way back is the documented one: CLAUDEMD_ALLOW_DOWNGRADE=1 node scripts/install.js from a checkout of v0.84.1, per docs/ROLLBACK.md.
Two things the new record had to be taught to the rest of the tree. A persistent file in a directory whose other residents are per-session sentinels is a new shape for the gates that sweep it, and both were changed deliberately rather than left to behave by accident. CLAUDEMD_STATE_FILE_RE in scripts/uninstall.js now admits hook-root.json and its . + pid temp, so CLAUDEMD_PURGE=1 removes the record it is responsible for; /claudemd-clean-residue leaves it alone, because it is not session-scoped and has no retention window to age out of. docs/ARCHITECTURE.md gains the file, its four fields, its writer and its reader.
Migration: /claudemd-refresh, then restart Claude Code — the restart is what fires the hook that writes the first record, and until it does, every row reads as it did before. No spec change, no new deny. If your /claudemd-doctor goes from exit 0 to exit 3 after this — on a checkout, or on an install while a checkout session fired the last hook — read the hook-drift line: it now names the root a hook measured, the timestamp it was measured at and the session that recorded it. What re-points the record is a SessionStart in the session whose root you care about — /claudemd-refresh is scripts/refresh-plugin.sh, a marketplace update plus an uninstall and install, and it writes nothing to hook-root.json. So a refresh moves the installed tree and the restart after it rewrites the record; the refresh on its own leaves the record exactly as it found it, and if the session id printed is somebody else's, the row is describing their session rather than yours.
Tests. doctor.test.js 82 → 93, uninstall.test.js 27 → 28, clean-residue.test.js 47 → 48, session-start.test.sh 53 cases (new Case 40: a session opening on a compaction event still records its root, because the write sits above the compact early-exit), session-end-check.test.sh (new Case 18: SessionEnd records the root with no prior record, covering a session whose SessionStart hook was switched off), hook-common.test.sh 17 → 22, and two of the five new ones feed hook_record_plugin_root a quote-carrying and a newline-carrying root to assert the prior record survives intact.
The flip's own two assertions were confirmed red before the regex changed and green after: isAdvisoryCheck('hook-drift') is false and isAdvisoryCheck('hook-drift:upstream') is true. They are written as a pair on purpose — the two row names match the same alternation token, so the edit that demotes one is a single | from demoting both.
The exit-code case is asserted end to end through the CLI rather than through the row, and its fixture is installed first for a reason worth copying: on a bare sandbox HOME that scenario already fails nine other counted rows (settings.json, four spec:*, four spec-hash:*), and against those the process exits 3 whether or not hook-drift counts — an assertion that would have passed unchanged on 0.84.1. With install.js run into the sandbox first, the drifted arm's only red row is hook-drift, so the 3 has one possible cause; the control arm is the same fixture with the hooks matching, and it exits 0.
Three of the eleven new doctor.test.js cases came out of the pre-merge review, and two were written as mutation tests because that is the only way to know a test holds anything. The two-cache-directories case builds all three states of the record — naming the live root, overwritten by a closing older session, and absent — and asserts the counted-red list and $? on each; with its guard disabled the middle leg reports ['hook-drift'] and exits 3, which is the defect it exists for. The hook-drift:upstream case covers an argument this release repointed from the registered root to the hook-fired one, and which no existing case could see: the five upstream cases in the suite all predate the record and seed none, so every one of them lands on the fallback where the two roots are the same directory. Reverting that argument left the suite at 90 pass / 0 fail before this case and fails it after. The third asserts that a record naming a path that is no longer a directory falls back to the cache resolution rather than being handed to compareHooks, which tests for hooks/ on its first argument only and would have painted the row solid red.
npm run check exit 0, read from $?.