Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
7 changes: 7 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
Expand Down
155 changes: 155 additions & 0 deletions hooks/test-warn-stale-plugin-pin.py
Original file line number Diff line number Diff line change
@@ -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())
197 changes: 197 additions & 0 deletions hooks/warn-stale-plugin-pin.py
Original file line number Diff line number Diff line change
@@ -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/<marketplace>/ai-config/<sha>/`, 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@<marketplace>` 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/<marketplace>`.

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())
7 changes: 7 additions & 0 deletions plugins/ai-config-hooks/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
Expand Down
3 changes: 2 additions & 1 deletion shared/workflow/keep-checkouts-fresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
[`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.
Expand Down
Loading