diff --git a/README.md b/README.md index d3fbad58e..f754a8913 100644 --- a/README.md +++ b/README.md @@ -449,6 +449,7 @@ The payload gaps that remain and the per-guard status are in | hook | event | enforces | |---|---|---| | `inject-core-rules.py` | `SessionStart` (startup\|clear\|compact) | on startup, `/clear` and compaction, names `AGENTS.md` and `CLAUDE.md` by path, orders `AGENTS.md` read in full and lists its section headings, wherever the plugin is enabled, since a plugin has no instruction-file slot of its own; inside an ai-config checkout it names that checkout's own files, since Claude Code's default loads only its `CLAUDE.md` there (ai-config#4206) | +| `warn-stale-plugin-pin.py` | `SessionStart` (startup\|clear\|compact) | warns, never blocks: compares each ai-config pin in `~/.claude/plugins/installed_plugins.json` that applies to the session (user scope, or a project scope covering its directory) against the local marketplace clone's HEAD, and when the pin lags, names how many commits and the per-scope `claude plugin update` commands, since the pin does not advance on its own and merged hook fixes otherwise go unrun with nothing saying so; it does not fetch, so it compares against the local clone, not origin; it runs only from pins that already contain it, so a pin older than the hook needs one manual update first (ai-config#2439) | | `inject-local-time.sh` | `UserPromptSubmit` | supplies the real local time, so a recap timestamp is never recalled | | `warn-python3-cannot-read-hooks.sh` | `UserPromptSubmit` | names the interpreter when the `python3` on `PATH` cannot read the directory the hooks live in -- a condition that denies `Bash`, `Edit`, `Write` and `Agent` at once (every tool a `PreToolUse` matcher names; `Read` and `Grep` are unaffected), while each denial names a hook rather than the interpreter. Shell, not Python: in the failure this hook reports, no Python hook can run. Silent when the interpreter is fine; see the hook's own header for the mechanism (ai-config#3624) | | `require-gh-repo-flag.py` | `PreToolUse` (Bash) | blocks a mutating repo-scoped `gh` command that omits `-R` | diff --git a/hooks/hooks.json b/hooks/hooks.json index 3ce2e378a..68548bf33 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -24,6 +24,13 @@ "timeout": 10, "script": "inject-core-rules.py", "why": "ai-config#4206 -- a plugin has no instruction-file slot, so AGENTS.md and CLAUDE.md shipped inside the plugin and were never read; a project thread saw the rules only after cloning ai-config by hand. At session start, names AGENTS.md and CLAUDE.md, orders AGENTS.md read in full and lists its headings, within the 10,000-character hook-output cap, wherever the plugin is enabled; uses an ai-config checkout's own files when the project is one." + }, + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-plugin-pin.py\"", + "timeout": 10, + "script": "warn-stale-plugin-pin.py", + "why": "ai-config#2439 -- the plugin cache pin that runs hooks does not advance when the marketplace clone updates, so merged hook fixes went unrun for weeks with nothing saying so. At session start, compares each applicable ai-config pin in installed_plugins.json against the local marketplace clone's HEAD and, when it lags, names the commit count and the per-scope update commands." } ] } diff --git a/hooks/test-warn-stale-plugin-pin.py b/hooks/test-warn-stale-plugin-pin.py new file mode 100644 index 000000000..32a99989c --- /dev/null +++ b/hooks/test-warn-stale-plugin-pin.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Tests for hooks/warn-stale-plugin-pin.py (ai-config#2439). + +Builds a fake `~/.claude/plugins` (a three-commit marketplace clone plus an +`installed_plugins.json`) under a temp dir, points the hook at it through +`AI_CONFIG_PLUGINS_DIR`, and runs the hook as a subprocess: + + 1. A user-scope pin two commits behind: warns, counts 2, and gives the + user-scope update command with no --scope flag. + 2. A user-scope pin at the clone's HEAD: silent. + 3. A project-scope pin behind, session in a subdirectory of its + projectPath: warns with --scope project. + 4. Negative control: a stale project pin for a DIFFERENT project: silent. + 5. Negative control: a stale pin for another plugin in the same + marketplace: silent. + 6. A pin the clone does not contain: warns, says the count is unknown. + 6b. A non-object `plugins` value: exit 0, stderr says so. + 6c. A pin AHEAD of the clone (the clone is the stale one): silent. + 7. No installed_plugins.json: silent, exit 0. + 8. Malformed installed_plugins.json: exit 0, nothing on stdout, stderr + names the file. + 9. No marketplace clone: exit 0, nothing on stdout, stderr says so. +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HOOK = Path(sys.argv[1] if len(sys.argv) > 1 else + os.path.join(os.path.dirname(__file__), "warn-stale-plugin-pin.py")) + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(f"PASS {name}") + else: + failures.append(name) + print(f"FAIL {name} {detail}") + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], check=True, + capture_output=True, text=True, + env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}, + ).stdout.strip() + + +def make_root(tmp): + root = tmp / "plugins" + mkt = root / "marketplaces" / "Morrison-Lab" + mkt.mkdir(parents=True) + git(mkt, "init", "-q", "-b", "main") + shas = [] + for i in range(3): + (mkt / "f").write_text(str(i), encoding="utf-8") + git(mkt, "add", "f") + git(mkt, "commit", "-q", "-m", f"c{i}") + shas.append(git(mkt, "rev-parse", "HEAD")) + return root, shas + + +def run(root, record, cwd): + if record is not None: + path = root / "installed_plugins.json" + path.write_text(record if isinstance(record, str) else json.dumps(record), encoding="utf-8") + env = {**os.environ, "AI_CONFIG_PLUGINS_DIR": str(root)} + env.pop("CLAUDE_PROJECT_DIR", None) + out = subprocess.run( + [sys.executable, str(HOOK)], input=json.dumps({"cwd": str(cwd)}), + capture_output=True, text=True, env=env, + ) + context = "" + if out.stdout.strip(): + context = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"] + return out.returncode, context, out.stderr + + +def record(*entries, key="ai-config@Morrison-Lab"): + return {"version": 2, "plugins": {key: list(entries)}} + + +def main(): + tmp = Path(tempfile.mkdtemp()) + try: + root, shas = make_root(tmp) + project = tmp / "proj" + (project / "sub").mkdir(parents=True) + other = tmp / "other" + other.mkdir() + + rc, ctx, _ = run(root, record({"scope": "user", "gitCommitSha": shas[0]}), project) + check("1 stale user pin warns", rc == 0 and "STALE PLUGIN PIN" in ctx, ctx) + check("1 counts two commits", "2 commit(s)" in ctx, ctx) + check("1 user command has no scope flag", + "`claude plugin update ai-config@Morrison-Lab`" in ctx, ctx) + + rc, ctx, _ = run(root, record({"scope": "user", "gitCommitSha": shas[2]}), project) + check("2 current user pin is silent", rc == 0 and ctx == "", ctx) + + rc, ctx, _ = run(root, record({"scope": "project", "projectPath": str(project), + "gitCommitSha": shas[1]}), project / "sub") + check("3 stale project pin warns with scope", + "--scope project" in ctx and "1 commit(s)" in ctx, ctx) + + rc, ctx, _ = run(root, record({"scope": "project", "projectPath": str(other), + "gitCommitSha": shas[0]}), project) + check("4 other project's pin is silent", rc == 0 and ctx == "", ctx) + + rc, ctx, _ = run(root, record({"scope": "user", "gitCommitSha": shas[0]}, + key="other-plugin@Morrison-Lab"), project) + check("5 other plugin is silent", rc == 0 and ctx == "", ctx) + + rc, ctx, _ = run(root, record({"scope": "user", "gitCommitSha": "f" * 40}), project) + check("6 unknown pin warns with unknown count", + "unknown number of commits" in ctx, ctx) + + rc, ctx, err = run(root, {"version": 2, "plugins": []}, project) + check("6b non-object plugins reports on stderr", + rc == 0 and ctx == "" and "not an object" in err, err) + + git(root / "marketplaces" / "Morrison-Lab", "reset", "-q", "--hard", shas[1]) + rc, ctx, _ = run(root, record({"scope": "user", "gitCommitSha": shas[2]}), project) + check("6c pin ahead of the clone is silent", rc == 0 and ctx == "", ctx) + git(root / "marketplaces" / "Morrison-Lab", "reset", "-q", "--hard", shas[2]) + + (root / "installed_plugins.json").unlink() + rc, ctx, err = run(root, None, project) + check("7 no record is silent", rc == 0 and ctx == "" and err == "", err) + + rc, ctx, err = run(root, "{not json", project) + check("8 malformed record reports on stderr", + rc == 0 and ctx == "" and "installed_plugins.json" in err, err) + + shutil.rmtree(root / "marketplaces") + rc, ctx, err = run(root, record({"scope": "user", "gitCommitSha": shas[0]}), project) + check("9 missing clone reports on stderr", + rc == 0 and ctx == "" and "cannot compare" in err, err) + finally: + shutil.rmtree(tmp, ignore_errors=True) + if failures: + print(f"{len(failures)} failure(s): {failures}") + return 1 + print("all passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hooks/warn-stale-plugin-pin.py b/hooks/warn-stale-plugin-pin.py new file mode 100644 index 000000000..4f7c77246 --- /dev/null +++ b/hooks/warn-stale-plugin-pin.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""SessionStart: warn when this session's ai-config plugin pin lags the marketplace clone. + +WHY THIS EXISTS (ai-config#2439) +-------------------------------- +Claude Code runs a plugin's hooks from a per-scope PINNED cache snapshot, +`~/.claude/plugins/cache//ai-config//`, recorded in +`~/.claude/plugins/installed_plugins.json`. The pin does not advance when the +marketplace clone updates, so a merged hook fix can sit unrun for weeks: +measured 2026-08-27 (a project pin 25 days stale), 2026-09-21 (a merged guard +absent from every pin while the session made the exact mistake it guards), +and 2026-10-08 (a user pin of `6a4f97ebfc79` refusing pushes that main had +stopped refusing). + +Nothing surfaced the lag. `shared/workflow/keep-checkouts-fresh.md` documents +the manual check and `scripts/check-hook-delivery.py` reports missing hooks, +but both run only when a session already suspects the cache. This hook runs +the comparison at every session start, where the remedy is cheapest. + +WHAT IT COMPARES +---------------- +Each `ai-config@` entry that applies to this session -- the +`user` scope, and any `project`/`local` scope whose `projectPath` is this +session's directory or an ancestor of it -- against the HEAD of the local +marketplace clone `~/.claude/plugins/marketplaces/`. + +It does not fetch. A session-start hook has a short timeout and may have no +network, so the clone itself can lag origin; the warning says to update the +marketplace first for that reason. A clean result therefore means "the pin +matches the local clone", not "the pin matches origin/main". + +A pin that is AHEAD of the clone (the clone, not the pin, is behind) is +not reported. A pin cannot warn about itself: this hook runs only from pins +that already contain it, so a pin that predates it stays silent until it is +updated once by hand. + +It never blocks and always exits 0. A missing or unreadable record means +there is nothing to compare (no plugin install, or a non-Claude harness), and +that case is reported on stderr rather than as a warning. + +`AI_CONFIG_PLUGINS_DIR` overrides `~/.claude/plugins` for tests. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +PLUGIN = "ai-config" + + +def plugins_dir() -> Path: + override = os.environ.get("AI_CONFIG_PLUGINS_DIR") + if override: + return Path(override) + return Path.home() / ".claude" / "plugins" + + +def session_dir(payload: dict) -> Path: + for candidate in (payload.get("cwd"), os.environ.get("CLAUDE_PROJECT_DIR")): + if candidate: + return Path(candidate) + return Path.cwd() + + +def applies(entry: dict, here: Path) -> bool: + scope = entry.get("scope") + if scope == "user": + return True + project = entry.get("projectPath") + if scope not in ("project", "local") or not project: + return False + try: + here.resolve().relative_to(Path(project).resolve()) + except ValueError: + return False + return True + + +def git(clone: Path, *args: str, quiet: bool = False) -> str | None: + try: + out = subprocess.run( + ["git", "-C", str(clone), *args], + capture_output=True, text=True, timeout=5, + ) + except (OSError, subprocess.TimeoutExpired) as err: + print(f"warn-stale-plugin-pin: git {' '.join(args)} in {clone}: {err}", + file=sys.stderr) + return None + if out.returncode != 0: + if not quiet: + print(f"warn-stale-plugin-pin: git {' '.join(args)} in {clone}: " + f"{out.stderr.strip()}", file=sys.stderr) + return None + return out.stdout.strip() + + +def pin_is_current(clone: Path, pin: str, head: str) -> bool: + """True when the pin is HEAD, or HEAD is its ancestor (the clone lags).""" + if head.startswith(pin): + return True + return git(clone, "merge-base", "--is-ancestor", head, pin, + quiet=True) is not None + + +def behind(clone: Path, pin: str, head: str) -> str: + count = git(clone, "rev-list", "--count", f"{pin}..{head}", quiet=True) + if count is None: + return "an unknown number of commits (the pin is not in the clone)" + return f"{count} commit(s)" + + +def stale_lines(record: dict, here: Path, root: Path) -> list[str]: + lines = [] + plugins = record.get("plugins", {}) + if not isinstance(plugins, dict): + print("warn-stale-plugin-pin: installed_plugins.json 'plugins' is " + "not an object", file=sys.stderr) + return lines + for key, entries in sorted(plugins.items()): + name, _, marketplace = key.partition("@") + if name != PLUGIN or not marketplace or not isinstance(entries, list): + continue + clone = root / "marketplaces" / marketplace + head = git(clone, "rev-parse", "HEAD") + if head is None: + print(f"warn-stale-plugin-pin: no git clone at {clone}; " + f"cannot compare {key}", file=sys.stderr) + continue + for entry in entries: + if not isinstance(entry, dict) or not applies(entry, here): + continue + pin = entry.get("gitCommitSha") or "" + if pin and pin_is_current(clone, pin, head): + continue + scope = entry.get("scope", "?") + flag = "" if scope == "user" else f" --scope {scope}" + where = entry.get("projectPath") or "user scope" + shown = pin[:12] if pin else "no recorded commit" + lines.append( + f"- {key}, {scope} scope ({where}): pinned to {shown}, " + f"{behind(clone, pin, head) if pin else 'an unknown number of commits'} " + f"behind the marketplace clone at {head[:12]}. " + f"Run `claude plugin marketplace update {marketplace}`, then " + f"`claude plugin update {key}{flag}`" + + ("" if scope == "user" else " from that directory") + + "." + ) + return lines + + +def main() -> int: + try: + payload = json.load(sys.stdin) + if not isinstance(payload, dict): + payload = {} + except (ValueError, OSError): + payload = {} + root = plugins_dir() + path = root / "installed_plugins.json" + try: + record = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return 0 + except (OSError, ValueError) as err: + print(f"warn-stale-plugin-pin: cannot read {path}: {err}", + file=sys.stderr) + return 0 + if not isinstance(record, dict): + print(f"warn-stale-plugin-pin: {path} is not a JSON object", + file=sys.stderr) + return 0 + lines = stale_lines(record, session_dir(payload), root) + if not lines: + return 0 + text = ( + "STALE PLUGIN PIN (ai-config#2439): this session runs ai-config hooks " + "from a cached snapshot older than the local marketplace clone, so " + "hook fixes merged since then are NOT running here. A guard that " + "refuses something already fixed on main is most likely this, not a " + "live bug.\n" + + "\n".join(lines) + + "\nRestart the session afterwards to load the new snapshot." + ) + json.dump({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": text, + } + }, sys.stdout) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ai-config-hooks/hooks/hooks.json b/plugins/ai-config-hooks/hooks/hooks.json index 95a2264e8..780c8a5f9 100644 --- a/plugins/ai-config-hooks/hooks/hooks.json +++ b/plugins/ai-config-hooks/hooks/hooks.json @@ -17,6 +17,13 @@ "timeout": 10, "script": "inject-core-rules.py", "why": "ai-config#4206 -- a plugin has no instruction-file slot, so AGENTS.md and CLAUDE.md shipped inside the plugin and were never read; a project thread saw the rules only after cloning ai-config by hand. At session start, names AGENTS.md and CLAUDE.md, orders AGENTS.md read in full and lists its headings, within the 10,000-character hook-output cap, wherever the plugin is enabled; uses an ai-config checkout's own files when the project is one." + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/run-hook.sh 'python3 \"${CLAUDE_PLUGIN_ROOT}/../../hooks/warn-stale-plugin-pin.py\"'", + "timeout": 10, + "script": "warn-stale-plugin-pin.py", + "why": "ai-config#2439 -- the plugin cache pin that runs hooks does not advance when the marketplace clone updates, so merged hook fixes went unrun for weeks with nothing saying so. At session start, compares each applicable ai-config pin in installed_plugins.json against the local marketplace clone's HEAD and, when it lags, names the commit count and the per-scope update commands." } ] } diff --git a/shared/workflow/keep-checkouts-fresh.md b/shared/workflow/keep-checkouts-fresh.md index 82f611186..25bbd2e85 100644 --- a/shared/workflow/keep-checkouts-fresh.md +++ b/shared/workflow/keep-checkouts-fresh.md @@ -59,7 +59,8 @@ In every session --- at session start, and again periodically during long sessio the larger the count, the more fixes the session is running without. `claude plugin update ` (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. + [`hooks/warn-stale-plugin-pin.py`](../../hooks/warn-stale-plugin-pin.py) runs this comparison at every session start, against the local marketplace clone rather than `origin/main` ([ai-config#2439](https://github.com/Morrison-Lab/ai-config/issues/2439)). + It does not fetch, so a clean start means the pin matches the local clone, and the manual count above is still the check against `origin/main`. **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.