In every session --- at session start, and again periodically during long sessions --- refresh the local state that goes stale as PRs merge elsewhere:

1. **The ai-config checkout.** Check that the local ai-config clone is on `main` --- not a leftover work branch from an earlier session --- and run `git pull --ff-only`.
   Only switch back to `main` when the working tree is clean; leave a dirty tree or another session's in-flight work alone and flag it instead.
   **If `pull --ff-only` fails with "diverged" rather than a dirty-tree error**, don't assume unpushed work is at risk --- a fresh container can seed local `main` from a stale/orphaned snapshot (e.g. a pre-history-rewrite state) whose commits never landed on `origin/main` at all.
   Confirm the working tree is clean (`git status --short`), then settle it by **content**, not by commit identity.
   `comm -23 <(git ls-tree -r --name-only main | sort) <(git ls-tree -r --name-only origin/main | sort)` returning nothing, plus a spot-check that a few of those files' contents match, means realigning loses nothing and is safe: `git checkout -B main origin/main`.
   **Don't decide this by matching the divergent commits' subjects against `git log origin/main`.**
   A squash merge writes one commit whose subject is the PR title, so a merged branch's own subjects are absent from `origin/main` by construction, and the check reports "orphaned" for ordinary merged work --- the alarming direction.
   See [`fail-fast`](../principles/fail-fast.md), "A proxy that answers a narrower question passes the same way".
   Still flag it rather than force if the tree is dirty, or if a path on local `main` is genuinely missing from `origin/main`.
   **If `main` isn't the currently checked-out branch** (the session is already working on a feature branch), skip the checkout dance entirely --- `git branch -f main origin/main` realigns the ref in place without touching the working tree or switching away from the branch you're actively on.

   **In a local session working in a worktree under `<primary>/.claude/worktrees/`, the running hooks can be the primary checkout's copies, and nothing fast-forwards that checkout.**
   `.claude/hooks/session-start.sh` exits unless `CLAUDE_CODE_REMOTE=true`, so a local session never pulls it.
   Measured 2026-09-30 ([ai-config#1815, comment](https://github.com/Morrison-Lab/ai-config/issues/1815#issuecomment-5925627614)): the primary sat on `main` 312 commits behind `origin/main`.
   `hooks/no-push-without-self-review.py` then refused a push with "could not load its push detector from no-unreviewed-pr.py ([Errno 2] No such file or directory: '<primary>/.claude/hooks/no-unreviewed-pr.py')",
   the symptom of the already-fixed [#2981](https://github.com/Morrison-Lab/ai-config/issues/2981).
   The path in the message is the evidence that the hook ran from the primary.
   The loading mechanism was not traced.
   `git -C <primary> pull --ff-only` cleared it.
   Step 2's guard-refusal paragraph covers the same class of symptom, a guard refusing because its copy is stale, when that copy is an installed plugin.

   - **Do:** at session start in a worktree session, fast-forward the primary checkout too, under the same on-`main`, clean-tree conditions as above.
   - **Do:** read a hook error naming a nonexistent path under `.claude/hooks/` as a stale-hook symptom first.
   - **Don't:** debug the hook's logic, or override the guard, before checking the primary checkout's freshness.

2. **The `~/.claude` consumer install.**
   Claude Code and Cursor no longer read this repo's `skills/` and `commands/` as a symlinked copy under `~/.claude` at all.
   They install this repo as a native plugin, which auto-updates at session start (see README's *Verify the install*),
   so the freshness question moves from a symlinked copy to the pinned snapshot the plugin serves.
   That is a claim about what is **served**, and not about what is **left over**.

   **The auto-update claim is narrower than it reads: it is a claim about the update *mechanism*, and says nothing about whether this session's already-cached snapshot is current.**
   `installed_plugins.json`'s `lastUpdated` field records when the pin was last written, not how far behind the pin currently sits, so confirming the plugin is enabled and not doubled tells you nothing about whether the cached snapshot it points at is stale.

   Measured on this Windows machine, 2026-09-09.
   The pinned commit's `lastUpdated` read 2026-08-27T18:33:12Z, 13 days before the session that read it,
   and `git rev-list --count <pinned-commit>..origin/main` in a freshly fetched ai-config checkout counted 459 commits ahead of that pin.
   The gap included a targeted hook fix (`hooks/no-placeholder-reply.py`, [#2964](https://github.com/Morrison-Lab/ai-config/pull/2964)) whose absence let a placeholder reply through unblocked --- see [ai-config#3437](https://github.com/Morrison-Lab/ai-config/issues/3437).

   Check it from the pin the active scope actually serves, not from the newest directory under the cache: the cache can hold a newer snapshot while this scope's entry still points at an older one.
   Read the `gitCommitSha` of the entry whose `scope` and `projectPath` match the session, then count how far `main` has moved past it:

   ```bash
   python3 - <<'EOF'
   import json, os
   pins = json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")))
   entries = pins.get("plugins", {}).get("ai-config@Morrison-Lab") or []
   if not entries:
       print("no ai-config@Morrison-Lab entry in installed_plugins.json")
   for entry in entries:
       print(entry.get("scope"), entry.get("projectPath", "-"), entry.get("gitCommitSha", "?"))
   EOF
   git -C <ai-config checkout> fetch -q origin && git -C <ai-config checkout> rev-list --count <gitCommitSha>..origin/main
   ```

   Any non-zero count means the served snapshot is behind `origin/main`, whatever `installed_plugins.json`'s own `lastUpdated` claims and however new the other cache directories are;
   the larger the count, the more fixes the session is running without.

   `claude plugin update <plugin>` (verified present in `claude plugin --help` output on this machine) is the remedy once staleness is confirmed --- run it per scope (`claude plugin update ai-config@Morrison-Lab`, and `claude plugin update --scope project ai-config@Morrison-Lab` from each affected project/worktree), then restart the session to pick up the refreshed cache path.
   [ai-config#2439](https://github.com/Morrison-Lab/ai-config/issues/2439) tracks making this check itself part of the session-start sweep rather than something a session discovers by symptom.

   **When a guard's refusal matches the shape of an already-fixed issue, check the installed build before treating it as a live bug or a classifier problem to work around.**
   Measured 2026-09-28 (Claude Code desktop, `Morrison-Lab/mln`): `hooks/no-push-without-self-review.py` refused a push after a foreground `adversarial-reviewer` had returned its CLEAN verdict via `SubagentHandback`, and `ALLOW_UNREVIEWED_PUSH=1` was then denied by the auto-mode classifier's `[Safety Bypass Flag]` --- the exact deadlock shape [`mistake-patterns.md`](../../memories/mistake-patterns.md) Pattern 43 already names.
   The session re-dispatched the reviewer twice more (roughly 160k tokens each) before checking versions.
   The installed plugin build was `6a4f97ebfc79` against `origin/main`'s `09fdd8c0`: grepping the cached hook for `SubagentHandback` returned 0 hits, where `origin/main`'s copy returns 21 --- the fix for [ai-config#3945](https://github.com/Morrison-Lab/ai-config/issues/3945) (closed 2026-09-26).
   `claude plugin marketplace update Morrison-Lab` then `claude plugin update ai-config@Morrison-Lab` advanced the pin, and a plain `git push` succeeded immediately afterward in the same session.
   See [`mistake-patterns.cases.md`](../../memories/mistake-patterns.cases.md), Pattern 43's 6th occurrence, for the full record --- including a data point against this file's own restart requirement two paragraphs above, which a single measurement does not settle either way.

   - **Do:** count commits from the active scope's pinned `gitCommitSha` to `origin/main`, rather than trusting the auto-update mechanism to have already run.
   - **Do:** run `claude plugin update` (per scope) once staleness is confirmed, then restart to apply it.
   - **Do:** confirm a CLI remedy exists (`claude plugin --help`) on the machine in question before writing that none does.
   - **Do:** when a guard's refusal resembles a specific fixed issue, grep the cached hook for a string that issue's fix introduced, before re-dispatching an expensive reviewer or reaching for an override.
   - **Don't:** read "auto-updates at session start" as meaning the currently-running session's cache is already current --- that is exactly the claim this check tests.
   - **Don't:** read `installed_plugins.json`'s `lastUpdated` field as a freshness measure.
     It says when the pin was last written, and nothing about how many commits `origin/main` has gained since the pinned SHA.
   - **Don't:** re-run an expensive dispatch against a guard, or reach for its override, without first checking whether the installed plugin is current for the specific behaviour the guard is enforcing.

   `shared/`, `hooks/`, and `memories/` have no plugin-equivalent replacement yet ([#2352](https://github.com/Morrison-Lab/ai-config/issues/2352)), so anyone relying on `~/.claude/shared`, `~/.claude/hooks`, or `~/.claude/memories` today is on a symlink or copy placed by an install predating that change, or by a manual step --- `bootstrap.sh` no longer places any of them.
   **`skills/` belongs in that sweep too, and the plugin serving them is not a reason to skip it.**
   A leftover `~/.claude/skills` from a pre-plugin install loads alongside the plugin, listing every skill twice --- bare `ums` beside `ai-config:ums` --- which crowds the skill listing and can cost entries their descriptions, the text routing selects on.
   Measured 2026-08-27 ([#2405](https://github.com/Morrison-Lab/ai-config/issues/2405)), found by Claude Code's built-in `/doctor` rather than by this check.

   **Detecting it is harder than the other three, so the instrument reports it at a lower confidence.**
   Those three are paths no client creates, so finding one is the finding.
   `~/.claude/skills` is a standard client location, holding a user's own personal skills and an account-level `synced/` bucket the client populates --- on the machine measured 2026-08-28 that bucket carried 45 directories whose names match this repo's skills, none of them a leftover.
   So neither presence nor a name match settles it, and a wrong answer is expensive in one direction: deleting that directory takes the user's own skills with it, and this corpus references `~/.claude/skills/...` paths directly in 31 places (`grep -rn '~/\.claude/skills/' --include=*.md skills/ shared/ memories/`, 2026-08-28), which the plugin install does not provide ([#2530](https://github.com/Morrison-Lab/ai-config/issues/2530)).
   `python3 scripts/doctor.py` sweeps all four paths and reports rather than deletes ([#2528](https://github.com/Morrison-Lab/ai-config/issues/2528)).
   Its `consumer_leftovers` check settles provenance only for a symlink resolving into an ai-config checkout;
   a bare name match it reports as the doubled-listing symptom to investigate by hand.
   Its walk of `~/.claude/skills` is one level deep, which is what keeps that bucket out of the report:
   the bucket's skill-named directories sit at `synced/<bucket-id>/<name>` and are never reached, so only the `synced` entry itself is examined and it matches neither test.
   It skips the sweep entirely when no ai-config plugin is enabled, since a `~/.claude` copy is then likely the machine's only install.
   **That gate resolves `enabledPlugins` by scope precedence --- local, then project, then user --- rather than reading one file**, matching the scope walk in `skills/ai-config-hooks/run-hook.sh`.
   Only the walk matches, and the two within-file rules are not variants of one reading: `doctor.py` parses the file and counts any truthy `ai-config@*` entry in `enabledPlugins`, since a second marketplace's copy loads the same plugin, while `run-hook.sh` parses nothing and takes the first raw-text match of `"ai-config@...": true|false` wherever it lands.
   So the two disagree on a file naming `ai-config@Morrison-Lab` false beside `ai-config@other` true --- the check reads the plugin as enabled, the runner as disabled --- and also on text the check never reads at all, such as a commented-out `// "ai-config@Morrison-Lab": true` beside an empty `enabledPlugins`, which the runner reads as enabled ([`claude-code-settings`](../../memories/claude-code-settings.md)).
   `enabledPlugins` resolves by precedence rather than by unioning truthy names across files, so the first file naming an `ai-config@*` entry decides and an explicit `false` there is final --- see [`claude-code-settings`](../../memories/claude-code-settings.md).
   **Two scopes above those three stay unread, so the gate can be wrong in both directions.**
   A managed-settings `false` over a walked `true` runs the sweep on a machine whose plugin is disabled, and reports its only install as leftovers.
   A managed-settings or command-line `true` with no walked entry makes the sweep skip.

   **A `~/.claude/shared` symlink resolving into a checkout is the documented configuration rather than a leftover, so the check reports it separately and stays green.**
   README.md tells a reader with a global `~/.claude/CLAUDE.md` to symlink `shared/` there by hand until [#2352](https://github.com/Morrison-Lab/ai-config/issues/2352) lands, so calling it a leftover would leave `python3 scripts/doctor.py --strict` red by construction on a machine that follows the documentation.
   A check that is red on the recommended configuration teaches its reader to ignore it, which is the opposite of what an instrument buys.
   **An exemption is only sound while the documentation recommends exactly the form it exempts, so the two are pinned to each other.**
   README.md used to offer a copy as an equal alternative, which left a conformant reader red anyway and moved the failure rather than removing it;
   it now names the symlink alone, because a copy does not track the checkout and so goes stale with nothing to say so.
   A copy of `shared/` is therefore a leftover, and the check says in its own output why it is reported and that replacing it with a symlink is the fix rather than deleting it.
   `hooks/` and `memories/` have no documented manual step, so both stay leftovers whatever placed them.

   - **Do:** include `skills/` when sweeping `~/.claude` for leftovers.
   - **Do:** read a doubled listing (a bare name beside an `ai-config:`-prefixed one) as the symptom, since the cost is otherwise invisible.
   - **Do:** run `python3 scripts/doctor.py` for the sweep rather than pasting an `ls` by hand.
   - **Do:** check managed settings by hand when the gate's answer surprises you, in either direction.
   - **Don't:** read "the plugin serves it" as "nothing is installed to check" --- a replacement does not remove what it replaced.
   - **Don't:** read its name-match finding as proof of a leftover --- provenance is what a symlink into a checkout settles and a shared name does not.
   - **Don't:** read that skip as proof the machine has no plugin --- managed settings and command-line arguments are not read.
   - **Don't:** read its leftover list as proof the plugin is enabled --- a managed-settings `false` over a lower-scope `true` produces the same list.
   - **Do:** place `~/.claude/shared` as a symlink into the checkout rather than as a copy, so the documented step reads as green and stays fresh on a pull.
   - **Don't:** delete `~/.claude/skills` on presence or on a name match;
     neither distinguishes a leftover from the client's own skills.
   - **Don't:** read a green `consumer_leftovers` as proof `~/.claude/shared` is absent --- the documented symlink is reported outside the leftover list.
   - **Do:** change the documentation and the exemption together, so exactly one form of a manual step is both recommended and exempted.
   - **Don't:** exempt one form of a step the documentation offers two ways --- a reader who took the other one is red by construction, which is the failure the exemption was meant to remove.
   - **Do:** say the *scope walk* is the shared part when describing the plugin gate, and describe each reader's own within-file rule --- the runner's raw-text grep as well as the check's parsed `enabledPlugins` union.
   - **Don't:** present the first-versus-union rule as the whole divergence --- the runner also matches a commented-out line and text outside `enabledPlugins`, which the check never reads.

   The dedicated verification instrument this section used to name (`check-install.py`, which compared installed copies against the checkout and repaired drift with `--fix`) was removed along with that symlink install and has no replacement yet either.
   Until one lands, use the manual branch-plus-diff check in this file's "The blast radius is the whole consumer surface" paragraph below, which does not depend on that instrument and still works whether the local copy is a symlink or a real copy.
   On Windows, Git Bash `ln -s` silently falls back to **real copies**, so a pull does NOT propagate to a real-copy install --- copy-sync every file whose repo version changed by hand.
   Before overwriting, check for edits made directly in `~/.claude` (a diff that adds prose the repo lacks) and upstream the genuine ones into the repo first;
   never clobber an un-upstreamed local edit.
   Don't rely on mtime to spot local edits --- git operations reset mtimes on checkout, so it false-positives right after a `pull`, the case this check most needs to handle correctly.
   **Don't rely on it in the other direction either --- to spot a copy that has gone stale.**
   A file copied once and never needing to change carries an old mtime and current contents, which is the reading a genuinely stale file also gives, so the proxy discriminates nothing.
   Run a direct content comparison instead.
   See [`verify-the-right-artifact`](verify-the-right-artifact.md), "A drift claim is relational, so one read cannot settle it".
   **Whether a hook's script is present and whether it is *registered* are two different questions, and `install-hooks.py` answers only the second.**
   `install-hooks.py` compares **bindings**: it asks whether `~/.claude/settings.json` actually invokes a script on an event, and its `stale` status names a registered script that is not on disk.
   A hook can be perfectly present and never run, so a report that finds it present is not evidence about registration.
   The failure is silent in the way this corpus is worst at noticing: an unregistered guard and a guard with nothing to block look identical, since neither ever produces output.
   It also degrades **one hook at a time** rather than all at once, which is why nothing announces it --- scripts reach `~/.claude/hooks` independently of registration (the retired symlink install placed them, and today only a manual copy does), so each hook added since the last registration run sits inert.
   That makes it a per-session freshness item rather than a one-time setup step.
   ```bash
   python3 <ai-config-checkout>/scripts/install-hooks.py          # report
   python3 <ai-config-checkout>/scripts/install-hooks.py --fix     # register the missing ones
   python3 <ai-config-checkout>/scripts/install-hooks.py --check   # do the registered paths resolve?
   ```
   **Run `--check` too, because the report above and `--check` answer different questions and only the second one can see the outage.**
   The report is keyed on `hooks/hooks.json`, so it speaks only about hooks this repo ships, at the path this script would itself write.
   `--check` reads the settings files instead and asks, of every binding they carry, whether the path inside the command resolves.
   That matters because an unresolvable path is not an inert guard: `python3` exits 2 on a file it cannot open, and exit 2 is the `PreToolUse` deny signal, so one stale absolute path denies every tool call its matcher names --- and from inside the session that is indistinguishable from the guard legitimately firing.
   A path this process cannot expand (`${CLAUDE_PLUGIN_ROOT}` is set by the plugin loader, not by the shell) is reported as `skipped` rather than `missing`, since not checkable here is a different finding from not present.
   `--check` exits 1 when any registered path is missing, and also when there is no settings file at all --- the zero case arms nothing, so reporting it as clean would be the pass-path-equals-failure-path shape.
   See [ai-config#2392](https://github.com/Morrison-Lab/ai-config/issues/2392) for the two measured occurrences, the second of which lost `Bash` and `Write`/`Edit` together and so had no self-repair path left.
   Four caveats before running `--fix`.
   Check `enabledPlugins` in `settings.json` first: if the ai-config **plugin** is enabled it already loads every hook in `hooks/hooks.json`, and `--fix` then registers each one a second time under a different command string, so every hook fires twice --- the two paths are mutually exclusive, per README.
   And hooks connect at **session start**, so a mid-session `--fix` arms nothing until a restart.
   Say so rather than reporting the guards as live.
   **On the plugin path nothing else is needed: the plugin loader serves and loads every hook in `hooks/hooks.json` straight from the plugin root.**
   **That plugin root is the version-cache SNAPSHOT, not the marketplace clone, so pulling the clone does not refresh a running session's hooks.**
   `${CLAUDE_PLUGIN_ROOT}` resolves to `~/.claude/plugins/cache/<marketplace>/<plugin>/<rev>/`;
   a `git pull` in `~/.claude/plugins/marketplaces/<marketplace>` updates the clone and changes nothing the harness executes,
   and overwriting the cache copy directly is denied by the auto-mode permission classifier (reasonably --- an agent rewriting its own active guard).
   A merged hook fix reaches a plugin-consumer session only when the plugin pin in `~/.claude/plugins/installed_plugins.json` advances to a snapshot that contains it ---
   a session restart alone does not advance it (measured 2026-09-01, recorded on [#2899](https://github.com/Morrison-Lab/ai-config/issues/2899): a session restarted under ten minutes after the marketplace pull still ran `a3e0fdb`, and a later app update added a `79def2e` snapshot to the cache without moving any scope's pin off `a3e0fdb`),
   so reporting "pulled the fix" or "restarted" reports a hook as updated that is still running stale.
   Advance the pin through the plugin CLI (`claude plugin marketplace update <marketplace>`, then `claude plugin update <plugin>`) --- the documented path, not yet measured against this incident --- then verify the pinned copy in `installed_plugins.json` rather than assuming it moved.
   `update` is the subcommand for an already-installed plugin (its help text reads "Update a plugin to the latest version (restart required to apply)", Claude Code 2.1.258, read 2026-09-01);
   the `install` step in [`use-plugins.md`](use-plugins.md) is the first-time path and does not advance an existing pin.
   (Measured 2026-09-01: the cache hook at rev `a3e0fdb` predated [ai-config#2820](https://github.com/Morrison-Lab/ai-config/pull/2820)'s fallback while the marketplace clone had pulled past it;
   tracked as [ai-config#2899](https://github.com/Morrison-Lab/ai-config/issues/2899);
   see [`mistake-patterns.md`](../../memories/mistake-patterns.md) Pattern 43 for the full deadlock.)
   `install-hooks.py --fix` covers the non-plugin path only, and its own docstring is explicit about what it does not do: it never places a file.
   `bootstrap.sh` no longer places `hooks/` under `~/.claude` (see its header comment), so this path currently only helps on a machine whose `~/.claude/hooks` already holds the scripts some other way.
   Registering a hook whose file is absent is worse than leaving it unregistered: an unregistered guard is inert, while a registered-but-absent `PreToolUse` `Bash` hook makes `python3` exit 2 on **every** Bash call and takes the shell down.
   `--fix` therefore refuses to register a hook whose script is not on disk, prints `REFUSED` naming it, and keeps the exit code non-zero (ai-config#2392) --- so the machine that lacks the scripts ends a `--fix` run with nothing registered rather than with a session-wide deny.
   **Point 1 governs this instrument too, and its stale run is dangerous.**
   A stale `install-hooks.py` run reads an old `hooks/hooks.json`, finds every hook it knows about already bound, and prints `All hooks registered.` --- a positive all-clear over hooks it cannot see.
   Pull first, then measure, and treat the examined count as the thing to read: it is the manifest's size, so a number below the current hook count means the checkout is behind rather than the machine being clean.
   **A container with no `settings.json` at all is the degenerate case, and it arms nothing --- so every guard in this corpus is inert there, not just a drifted one.**
   The paragraphs above describe registration *drift*: a `settings.json` that exists and lacks some binding, which `install-hooks.py` reports and `--fix` repairs.
   A remote/web container can ship `~/.claude` with **no `settings.json` and no `settings.local.json` at all**, which is the same failure with the count at zero.
   Nothing about it announces itself, for the reason the file-versus-binding distinction above already gives: an unregistered guard and a guard with nothing to block look identical.
   What differs is the blast radius --- drift disarms the hooks added since someone last ran the binder, while an absent file disarms all of them, including the one built for the mistake you are about to make.
   One read settles it, and it is cheaper than either instrument:
   ```bash
   for p in ~/.claude/settings.json ~/.claude/settings.local.json; do
     [ -f "$p" ] && echo "$p exists" || echo "$p ABSENT"
   done
   ```
   Read this as a fact about the **session**, not about the corpus.
   The guards are merged and correct.
   They simply are not running, so anything they would have caught is back to being your own responsibility.
   That is [`deterministic-tools`](../principles/deterministic-tools.md)'s constraint failing open rather than its goal failing --- the instrument exists, and the environment is not consuming it.
   - **Do:** check whether either settings file exists before relying on any hook, and say plainly in a status report that the guards are inert when they are.
   - **Don't:** treat a merged guard as an active one.
     Merging places a file, and only a binding in `settings.json` makes it fire.

   **The plugin path can be off at the same time, and then the caveat above has no working alternative left.**
   The paragraphs above treat the plugin as the path that already loads every hook, so `--fix` would double-register.
   A remote/web container can have neither: no `~/.claude/settings.json`, and no plugin installed.
   `${CLAUDE_PLUGIN_ROOT}` is what every command string in `hooks/hooks.json` interpolates, so one read settles the plugin half as cheaply as the file test above settles the other:
   ```bash
   echo "CLAUDE_PLUGIN_ROOT=[${CLAUDE_PLUGIN_ROOT:-UNSET}]"
   ```
   Measured 2026-08-22: `UNSET`, `SKIP_PLUGIN_MARKETPLACE=true`, and `install-hooks.py` reporting `registered=0 missing=41 stale=0`.
   A repo-local `.claude/settings.json` registering two hooks of its own does not change that, and reads like partial coverage when it is none.

   **The second direction is worse than trusting an absent guard, and nothing above covers it: diagnosing why an absent guard let something through.**
   A guard that did not warn invites a search for the flaw in its logic, and that search can be careful, reproducible, and about a hook that never ran.
   Reading the hook's source and its passing test suite is [`verify-the-right-artifact`](verify-the-right-artifact.md)'s substitution --- both are real artifacts, and neither is the registration.
   So run `install-hooks.py` before diagnosing a miss, not only before relying on a guard.
   Measured the same day: a real weakness in a guard's discharge logic was reproduced and filed as the cause of a missed warning, while that guard was one of the forty-one.
   The weakness was genuine; the attribution was not.

   **Measured recurrence, 2026-08-20: `registered=15 missing=16 stale=0` against a 31-hook manifest, on a machine where every rule above was already written.**
   That is worth recording as evidence about the *rule* rather than about the machine.
   Each paragraph above is correct and none of them fired, because all of them describe a check somebody has to decide to run, and the drift is silent by construction.
   Among the sixteen inert guards was `flag-add-a-outside-pathspec.py`, and in the same session the exact mistake that hook was written to prevent reached a pushed commit.
   The gap that incident exposes is not a rule but a **moment**: README's activation gate forbids registering before the PR merges and names nothing that happens after, so the owed registration has no owner.
   [`post-merge`](../../skills/post-merge/SKILL.md)'s step 3.75 is now that owner, and carries the incident, the mechanics, and the argument for why a hook cannot be the instrument here.

   - **Do:** run `install-hooks.py` each session, and report its counts.
   - **Do:** compare `install-hooks.py`'s `examined N` against the current `hooks/hooks.json` before believing `All hooks registered.`
   - **Don't:** run `install-hooks.py --fix` as the whole of "arm these hooks" --- it binds, it never places.
   **An entry that genuinely IS a symlink resolves through the checkout's CURRENT BRANCH, so a freshness check can pass over a file from the wrong branch.**
   Everything above splits the world into symlinks, which a pull refreshes, and real copies, which it does not.
   That split is real and it is not exhaustive.
   A symlink points at a **path in the working tree**, never at a commit, so the file the harness loads is whatever branch that checkout happens to have out --- which on a machine driving several PRs is routinely a feature branch rather than `main`.
   A `git pull` on `main` then updates a ref the loaded file does not resolve through, and `git branch -f main origin/main` does not help either, for the same reason.
   Note this is the opening sentence of point 2 failing, not a further wrinkle in the Windows real-copy case: "the pull alone refreshes them" holds only while the checkout is on the branch you pulled.

   Nothing that merely inspects `settings.json` or a symlink's destination can see it, and the reason is structural rather than an oversight.
   A symlink resolving inside this repo says nothing about *which commit* the working tree currently has checked out.
   `install-hooks.py` reads `settings.json` and never opens the linked file at all.
   A clean report from it is therefore consistent with every loaded file being a branch behind, which makes this a third way the installed state can be wrong, alongside registration drift and the registered-but-never-placed script in [`claude-code-hooks.md`](../../memories/claude-code-hooks.md).

   The blast radius is the whole consumer surface rather than hooks alone, because `skills/`, `shared/`, `memories/`, and `CLAUDE.md` are linked the same way --- so the `@shared/...` fragments this file imports are exactly as exposed as a guard is.
   One read settles it, and it is the content comparison no instrument here performs:
   ```bash
   git -C <ai-config-checkout> rev-parse --abbrev-ref HEAD                  # is it even on main?
   git -C <ai-config-checkout> diff origin/main --stat -- shared hooks skills memories CLAUDE.md
   ```
   The repair is constrained in a way worth stating, since the obvious one is forbidden.
   Point 1 already says to leave another session's in-flight work alone, and a checkout parked on someone else's branch is precisely what produces this drift, so switching it to refresh your own hook trades a stale guard for a clobbered colleague.
   Report the drift instead and read from `origin/main` directly (`git show origin/main:<path>`).
   - **Do:** read the checkout's current branch before believing any freshness report, and diff the consumer surface against `origin/main` when a loaded rule or guard matters.
   - **Do:** say which branch a `~/.claude` file resolved through when reporting an install clean.
   - **Don't:** read a symlink or a registered path resolving inside this repo as meaning its content matches `main` --- it only means the path lands inside this repo, whatever branch that repo has checked out.
   - **Don't:** switch a checkout parked on another session's branch to refresh your own hook, or expect `git branch -f main origin/main` to move what a symlink resolves through.

3. **The working repo's main checkout.**
   Fast-forward the `main` checkout of whatever repo the session is working on (`git fetch origin`, then `git pull --ff-only` when `main` is checked out) --- it goes stale as the session's own PRs and other sessions' PRs merge.
   **The same "diverged" failure from point 1 above can hit any repo's `main`, not just ai-config's own** --- a fresh container's checkout isn't guaranteed fresh for every repo it holds.
   Apply the same recovery: confirm the working tree is clean, then check whether the local tip's commit is actually reachable from `origin/main` (`git merge-base --is-ancestor <local-tip> origin/main`) before force-realigning with `git checkout -B main origin/main`.
   Don't rely on a commit-message grep alone to decide safety --- the same message can appear under a *different hash* after a squash-merge or rebase (so the grep matches but the underlying commits differ, the milder case in point 1), and `git log origin/main` only reflects whatever your local remote-tracking ref last fetched (so a check run before fetching in this session can miss commits that already landed).
   Re-run `git fetch origin main` immediately beforehand and use the hash-based ancestry check as the authoritative signal.
   A clean working tree plus a non-ancestor local `main` tip is still safe to realign in the common case (the checkout is stale, not carrying real work), since realigning only moves a local branch ref --- the discarded commits stay recoverable via `git reflog` regardless.
4. **The `.ai-config` submodule pin, in any repo that vendors ai-config as a git submodule** (check `.gitmodules` for a `.ai-config` entry --- not every repo has one; most consume ai-config only via the Plugin Marketplace, which doesn't need this).
   **If the repository uses ai-config (or another tool) as both a native plugin and a submodule, remove the submodule rather than bumping it.**
   Native plugin integration supersedes the submodule;
   keeping both causes drift, double-loading, and maintenance friction.
   See [`remove-redundant-plugin-submodules.md`](remove-redundant-plugin-submodules.md).
   Where a repo legitimately relies on the submodule (e.g. an environment lacking plugin support):
   Compare the pinned commit against ai-config's current `origin/main`: `git rev-parse HEAD:.ai-config` for the pin's SHA, then `git -C <path-to-a-local-ai-config-clone> rev-list --count <pin>..origin/main` for how far behind it is.
   A pin more than a few weeks or dozens of commits stale is worth refreshing: file a tracking issue, bump it (`git submodule update --init --remote .ai-config` from the parent repo handles both init and fetch in one step; or, if already checked out, `git fetch origin` inside the submodule before `git checkout origin/main`), then `git add .ai-config` in the parent repo to record the new gitlink, verify the parent repo's own checks still pass, and open a PR.
   Before assuming this is risk-free, check whether the parent repo's CI actually reads the submodule's checked-out content (vs. treating it as inert until a dev runs `git submodule update --init` locally) --- a pin bump is a pure pointer change with no functional surface only when nothing reads it.
   **When the current checkout isn't `main` itself** (a feature branch or a worktree), `HEAD:.ai-config` only reflects that branch's own pin --- it can look badly stale purely because the branch was cut before a bump PR merged into `main`, not because the project's actual pin needs refreshing.
   Also check `origin/main:.ai-config` (the pin as recorded on the base branch) against ai-config's `origin/main`;
   if that one is already fresh, no bump PR is needed --- the branch's own pin resolves itself on its next merge/rebase.
   On Windows Git Bash, that comparison command hits an MSYS gotcha --- see `memories/git.md`.
   **When *adding a new citation* to an ai-config shared fragment inside a submodule-consuming repo's own `CLAUDE.md`, verify --- don't assume --- that the citation already resolves.**
   It only does once BOTH (a) the source PR has merged into ai-config's `main`, and (b) that repo's own `.ai-config` pin has been bumped to a commit containing the path --- the pin doesn't auto-follow `main`.
   Check with `git show <pin>:<path>` (or `ls` inside the checked-out submodule) before writing the citation in present tense;
   if either gate hasn't cleared, hedge to future/conditional tense instead of asserting settled fact --- mirroring the "proposed in ai-config#N --- once merged, the fragment lives at ..." convention `gha`'s own `CLAUDE.md` already uses for citing its still-open companion PRs.
   Once the citation does resolve, keep the local **restatement** of the rule's key points alongside the citation rather than trimming to a bare pointer --- unlike a skill distributed via the Plugin Marketplace (point 4's own preamble), `.ai-config`'s `shared/`/`memories/` fragments aren't auto-loaded into agent context --- they only enter it when a `CLAUDE.md` explicitly restates or `@`-references them --- so a bare citation is invisible to an agent that doesn't take the extra step of reading the fragment on demand.

## A scoped fetch refreshes only what it fetched, and prunes only that too

Point 3 above prescribes `git fetch origin main` before the ancestry check, and
that is right for the question it answers.
It is the wrong instrument for every *other* remote-tracking ref in the
checkout, and the way it fails is silent.

`--prune` deletes only refs the fetch's own refspec covers.
Naming a branch on the command line replaces the configured
`+refs/heads/*:refs/remotes/origin/*` for that invocation, so a branch deleted
upstream keeps a **stale, resolving** `refs/remotes/origin/<name>` afterwards.
Measured on git 2.43.0, deleting `feat` upstream and then fetching from a
second clone:

```
git fetch origin main --prune   ->  origin/feat still resolves (95f2077)
git fetch --prune               ->  origin/feat GONE
```

**The `fetch.prune=true` config does not rescue this**, which is the part worth
knowing, because setting it is what most people believe closes the question.
It is consulted per invocation and bounded by that invocation's refspec, so the
scoped fetch leaves the ref standing with the config on:

```
fetch.prune=true, git fetch origin main   ->  origin/feat still resolves
fetch.prune=true, git fetch               ->  origin/feat GONE
```

So a stale ref survives a session that both set the config and ran a prune,
which is a third state alongside the two
[`memories/git-branches.md`](../../memories/git-branches.md)'s "Cleaning up a
branch deleted on `origin`" section describes: not "no prune ran", but "a
prune ran and did not cover this".
Its `[gone]` sweep reports a false clean either way.

The consequence lands on the next push.
`git push --force-with-lease` fails with `stale info` against a branch that no
longer exists at all --- reproduced in the same scratch repo, followed by an
empty `git ls-remote` and a plain push reporting `* [new branch]`.
That failure's meaning and its remedy are already recorded, so read
[`memories/git-branches.md`](../../memories/git-branches.md)'s "`stale info`
after `checkout -B`" bullet rather than re-deriving them; what this section
adds is only that a
scoped `--prune` is one of the ways you arrive there.

- **Do:** run an unscoped `git fetch --prune` before relying on any
  remote-tracking ref other than the one you just named.
- **Do:** treat `git ls-remote --heads origin <branch>` as the authoritative
  answer to whether a branch exists, since it consults the remote rather than a
  local cache.
- **Don't:** read `fetch.prune=true` as making pruning automatic --- it is
  bounded by each invocation's refspec, so a scoped fetch prunes nothing.
- **Don't:** count a `--prune` you ran as having pruned the ref you care about;
  check which refspec it covered.

## Disabling a hook by unregistering it assumes ONE installation shape, and does nothing on the other

Point 2 above already records that the two installation paths are mutually
exclusive: with the ai-config **plugin** enabled, every hook in
`hooks/hooks.json` loads from the plugin root, and `install-hooks.py`'s
`settings.json` bindings are the *other* path rather than an additional one.
That is stated there as a hazard for **enabling** a hook --- run both and each
one fires twice.

The same fact has a quieter consequence in the opposite direction, and nothing
above reaches it.
The standard way to silence a hook is to delete its entry from
`~/.claude/settings.json`.
On a plugin install there is no such entry, so that edit removes nothing, the
hook keeps firing, and **the edit itself succeeds** --- which is the whole
problem.
A remedy that errors gets fixed; a remedy that quietly no-ops gets recorded as
applied, and the next reader inherits an instruction that has never once
worked.

The asymmetry with the enabling case is worth naming, because it is why the
enabling hazard was noticed and this one was not.
Double-firing is loud and immediate.
A failed disable looks exactly like a hook you decided to keep.

So when a hook has to stop firing for a reason that is not per-PR --- a
standing directive, a moratorium, a repo where its demand is meaningless ---
put the switch **in the script**, where both installation shapes read it, and
prefer a self-expiring form over a flag somebody has to remember to clear.
`hooks/no-unreviewed-pr.py`'s `MORATORIUM_END` is the worked example, and
[`memories/gh-cli.md`](../../memories/gh-cli.md)'s Copilot-moratorium section
carries that one incident's own record --- read this section for the general
rule about installation shapes, and that one for what the moratorium requires
of a session today.

- **Do:** put a non-per-PR suppression in the hook script itself, so it holds
  on a plugin install and a settings install alike.
- **Do:** prefer a dated constant to an env flag when the reason for
  suppression has a known expiry, so the guard re-arms without anyone
  remembering.
- **Don't:** prescribe "remove it from `~/.claude/settings.json`" without
  naming the installation shape that assumes; on a plugin install it is a
  no-op that reads as a fix.
- **Don't:** treat an env-readable switch as equivalent --- a clock or a kill
  flag the guard reads from the environment is a one-variable bypass of the
  guard in production, which is the direction
  [`fail-fast`](../principles/fail-fast.md) refuses for any discharge path.

(`Morrison-Lab/ai-config#1709` / `#1710`, 2026-08-19: `memories/github.md`
prescribed unregistering `no-unreviewed-pr.py` for the Copilot moratorium's
duration.
`grep -n no-unreviewed-pr ~/.claude/settings.json` returned nothing on the
machine where the hook was firing every turn, because `hooks/hooks.json:349`
supplied it under `${CLAUDE_PLUGIN_ROOT}`.)

**The dated constant has a failure direction the pair above does not name, and it is the opposite of every other stale-hook symptom: it fails OPEN, on its own schedule, with no edit and no event.**
This narrows the recommendation above rather than leaving it untouched, so read the two together.
Automatic re-arming is a real benefit and it is exactly as trustworthy as the copy carrying the date, which on a plugin install is a snapshot nobody in the session controls.
A stale flag, threshold, or allowlist can fail in either direction too, so what distinguishes this one is not the direction but the *trigger*: those change behaviour only when the value they carry is wrong for a case someone brings to them, while a dated constant changes behaviour with no case, no edit, and no event at all.
Let the clock cross a number frozen in a snapshot and the guard computes that its own suppression has expired, then starts demanding an action a standing directive forbids --- with nobody having edited the hook, no PR having changed, and no session having done anything.
The clock-crossing route is an argument from how the construct is built, and the case record below does not exemplify it: that incident's snapshot was already past its date when it was written.
What the record does measure is the fail-open *direction* --- a guard demanding a forbidden action while its suppression should still hold --- by the stale-on-arrival route instead.
The trigger property is what separates the two routes, so it belongs to the argued one only: this incident's snapshot was already expired when it was written, and the guard fired when a case reached it rather than on its own.

That composes with this file's plugin-cache material into a worse failure than either part describes alone.
Those paragraphs --- in "On the plugin path nothing else is needed", well above this section --- explain why a merged fix does not reach a running session;
a dated constant is the one payload for which *not reaching the session* is not merely a delay but an inversion, because the stale copy does not hold the old behaviour --- it computes a new, wrong one.

- **Do:** gate the re-arm on a freshness signal from outside the snapshot --- the `lastUpdated` timestamp in `~/.claude/plugins/installed_plugins.json`, or a network read --- and keep suppressing when that signal is unavailable.
  A corpus file the guard reads at runtime is not such a signal, since it is frozen in the same snapshot the constant is.
  Neither is the pin's own `version`/`gitCommitSha`, which is an identity rather than a recency measure: it says which snapshot is served and cannot say whether that snapshot is old, so a gate reading it can never fire, which [`fail-fast`](../principles/fail-fast.md) names as the worst kind: "a precondition that can never fire is indistinguishable from one that fires correctly and finds nothing".
  `lastUpdated` sits beside the rev in the same entry and does carry recency.
- **Do:** read the constant from the copy that actually ran, when a guard demands something a standing directive forbids --- that demand is a freshness symptom before it is a policy question.
  Resolve which copy that is in this order, since the answer is frequently not the plugin at all: grep `~/.claude/settings.json` for a direct non-plugin registration of the script and read that file if one exists;
  check `enabledPlugins` for whether the plugin path is live on this machine at all;
  then read the per-scope pin in `~/.claude/plugins/installed_plugins.json`, which names the snapshot the `~/.claude` plugin loader serves;
  and --- in a desktop-app agent session, where none of the above is what runs --- the snapshot under `~/Library/Application Support/Claude/local-agent-mode-sessions/<session>/.../rpm/plugin_<id>/hooks/`.
  Three passes over this incident missed that last one because no step of this corpus mentioned it, so read the list as incomplete by construction and prefer the capture below to any list.
- **Do:** capture the path instead of deducing it, when a guard fires repeatedly.
  `ps -eo args` sampled every 0.05s while you deliberately trigger the guard prints the interpreter's own argv, which is the resolved path and not an inference from one.
  The block is the measurement, so a guard you cannot satisfy is the easiest case rather than the hardest.
- **Don't:** identify the loaded copy by the newest directory under `~/.claude/plugins/cache/`.
  Pattern 43's own Fix section already rules that proxy out --- "not the marketplace clone, and not merely the newest directory under the cache" --- and it isolates nothing when many cache directories carry the same value.
- **Don't:** read "the guard re-armed" as evidence the suppression period actually ended.
- **Don't:** treat a dated constant as carrying the same risk as a stale flag or threshold;
  a wrong flag waits for a case to reach it, and a wrong date fires on its own.
- **Don't:** answer this by moving the switch into the environment;
  the pair above refuses that for a separate reason --- an env-readable clock or kill flag is a one-variable bypass of the guard in production --- and that refusal is untouched here.

**The fail-open direction is measured, by a route other than the clock-crossing one above, and the incident is worth reading for how long the wrong artifact held out.**
On 2026-09-03 `hooks/no-unreviewed-pr.py` demanded a Copilot review on two PRs while the all-repos moratorium ran to `MORATORIUM_END = 2026-12-01` ([#3078](https://github.com/Morrison-Lab/ai-config/pull/3078)).
The failure is the one this section warns about --- a dated constant suppressing nothing --- though it arrived by a different route than the aging above.
The copy that fired --- for the firings sampled on 2026-09-04;
whether the same copy served the 2026-09-03 firings is not established --- resolves to `~/Library/Application Support/Claude/local-agent-mode-sessions/<session>/.../rpm/plugin_<id>/hooks/no-unreviewed-pr.py`, mtime 2026-09-01 22:42, carrying `MORATORIUM_END = 2026-09-01`;
importing it and calling `moratorium_active()` returns `False`.
It was captured by sampling `ps -eo args` at 0.05s while deliberately triggering the guard, after three firings.
**This instance is not an example of a payload aging past its date, and the first account of it here said it was.**
The guard compares `today < MORATORIUM_END`, so suppression ended at 2026-09-01 00:00 while the snapshot was written 2026-09-01 22:42 --- about 22.7 hours *after* expiry.
It was fail-open the moment it existed.
The hazard is still real, and a copy that is stale on arrival is a second route into it rather than the one the section describes.
Why that tree holds a copy at all, whether it is per-session, and whether a new session would replace it are all **unestablished**: exactly one such file exists across the whole tree, one of its four outer roots is named `skills-plugin` rather than a session uuid, and the file was written a month after the outer session directory was created, while its own `plugin_<id>/` and `hooks/` directories were created in the same second as the file --- which is consistent with a snapshot written when the plugin was installed, inside a session root far older than it.
The capture answers *which path*;
it does not answer why that path exists or what would change it, and the first write-up extended it to both.

**Everything below is what the diagnosis looked like before that capture, and every line of it is true and was useless.**
The copy registered directly in `~/.claude/settings.json` --- `python3 "$HOME/.claude/hooks/no-unreviewed-pr.py"`, present on disk --- carries `2026-12-01`, and its `main()` returns 0 on an active moratorium before reading the transcript, so that copy cannot be what fired.
`enabledPlugins["ai-config@Morrison-Lab"]` is `false`.
That a plugin copy ran at all rests on one thing only --- the refusal message named `${CLAUDE_PLUGIN_ROOT}/hooks/no-unreviewed-pr.py` --- which is an inference from a message string, not an observation of a process.
A 240-second `ps` sample taken 2026-09-03 captured twenty distinct hook command lines, **all twenty** from `$HOME/.claude/hooks/` and none from a plugin root --- and it misled, in the direction of doubting a mechanism that was real.
Its defect was population, not method: it ran while `no-unreviewed-pr.py` *could not fire*, since that guard only fires with an unreviewed PR open, so it sampled the `settings.json`-registered hooks and none of the ones in question.
Both registration paths are live at once.
A sample drawn when the event of interest is impossible measures the complement of what it was aimed at, and reports a clean number for doing so.
`installed_plugins.json` pins `a9ded3e1a9df` at user scope, whose hook carries **no `MORATORIUM_END` at all**, plus a project-scope pin whose directory is absent.
The cache holds copies in three different states at once: directories carrying `2026-09-01`, a `b0f279f8e8bd` entry carrying `2026-12-01`, and the pinned copy carrying no constant.
No *current* count is given, deliberately.
The cache is garbage-collected, so the number decays: nine directories carried `2026-09-01` on 2026-09-03 and five carried it on 2026-09-04, with no edit in between.
Derive it rather than citing one --- `find ~/.claude/plugins/cache -name no-unreviewed-pr.py | xargs grep -l 'MORATORIUM_END = datetime.date(2026, 9, 1)' | wc -l` --- since the argument needs only that several carry the same value, which is what makes "newest" isolate nothing.
A second marketplace, `ai-config@d-morrison`, carries its own user-scope pin to a separate cache tree, so "which copy ran" has more candidate answers than the one marketplace suggests.

**The reasoning above enumerated two explanations, then three, and the true one was in neither list.**
The pair was an expired constant and an absent one, both in `~/.claude/plugins/cache/`.
The third branch added later --- "some registration path not yet identified" --- was correct in form and was reached by doubting the mechanism rather than by doubting the *search space*, which is why it named no place to look.
Enumerating explanations over a candidate set nobody had established is what cost three passes: each list was internally sound, and every member of every list was drawn from the trees already known.
Widening the set is not a matter of adding a branch to the enumeration;
it takes an observation that can name a path the enumeration never contained, which is what the `ps` capture does and what no amount of further reasoning would have done.
[#3141](https://github.com/Morrison-Lab/ai-config/issues/3141) carries the incident, the three firings, and the capture.

**The diagnosis itself is the transferable failure, and it is a plain instance of the rule two bullets above.**
"The loaded copy" was identified as the cache's newest per-commit directory --- the one proxy Pattern 43's Fix section explicitly rules out --- while several directories carried the same value, so "newest" isolated nothing.
That is [`verify-the-right-artifact`](verify-the-right-artifact.md)'s substitution with a corpus rule already naming the substituted artifact by name, which is why the resolution order above is written as steps rather than as advice.
