diff --git a/README.md b/README.md index 99035f8..dbdef2c 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Full sourcing notes, including what was left out and why: [docs/provenance.md](d | `new-file-callout` | A new untracked file at the repo root or under `scripts/`: the reply must name it and say why. | | `agent-relay-attribution` | Advisory: facts relayed from a subagent's report must say so or be re-verified. | | `scratchpad-collision` | Two agents writing the same scratchpad file within ten minutes: use a uniquely named file. | +| `hook-freshness` | Advisory: the catstack checkout behind `~/.claude/hooks` is off `main` or behind `origin/main`, so merged hook fixes are not live on this machine. | | `auto-pr` | catstack itself changed: tell the agent to open a PR, no request needed. | | `cat-mode-default` | Every investigation or execution prompt, and every subagent prompt sent through the Agent tool: apply `cat-mode` without typing `/cat-mode`. Off unless `CATSTACK_CAT_MODE_DEFAULT=1` (env or `.env`; see `engine/hooks/cat-mode-default/README.md`). | | `plan-discipline` | **Not installed yet** (needs Agent mode): block product `.py` writes after a declined SwitchMode; require "How we test" on new-module plans; no eval numbers without a verifying run; warn on semantic plan-churn. Spec: `engine/hooks/plan-discipline/README.md`. | diff --git a/docs/ecosystem.md b/docs/ecosystem.md index c9a7b2f..f074a8c 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -82,6 +82,7 @@ again. | `new-file-callout` | hook | | `agent-relay-attribution` | hook (advisory) | | `scratchpad-collision` | hook | +| `hook-freshness` | hook (advisory) | | `engine/CLAUDE.core.md` | global hand-written Claude rules | | `scripts/`, `always-on/`, `cursor/rules/` (repo root), root `install.sh` | runtime (engine-owned entrypoints at root for CI) | diff --git a/engine/hooks/hook-freshness/README.md b/engine/hooks/hook-freshness/README.md new file mode 100644 index 0000000..8a2d4cb --- /dev/null +++ b/engine/hooks/hook-freshness/README.md @@ -0,0 +1,33 @@ +# hook-freshness + +UserPromptSubmit hook: the installed hooks are only as new as the checkout +behind them. `install.sh` symlinks `~/.claude/hooks/` at a catstack +checkout, so a hook or skill fix merged on `origin/main` does nothing on this +machine while that checkout sits on a feature branch or behind the remote. + +Resolves the checkout from the `~/.claude/hooks/diu-stop` symlink (override +with `CATSTACK_HOOKS_REPO`), reads `git branch --show-current` and +`git rev-list --count HEAD..origin/main`, and adds one advisory line to the +turn's context when the checkout is off `main` or behind it. Once per +session, keyed by transcript path. + +Advisory only — never blocks. No network by default; set +`CATSTACK_HOOK_FRESHNESS_FETCH=1` to allow a 3-second `git fetch` first, so +the count is not itself stale. `CATSTACK_HOOK_FRESHNESS=0` silences it. +Fails open on every error: no symlink, no git, a detached HEAD, a timeout. + +## Files + +- `detect.py` — checkout resolution, `repo_state()`, `advisory()`, `decide()`. +- `claude_prompt_submit.py` — Claude UserPromptSubmit entrypoint. +- `claude.prompt.hook.json` / `install_claude_hook.py` — settings.json merge (idempotent). +- `tests/test_hooks.py` — stale branch, behind count, clean checkout, fail-open. + +## Env + +| Var | Effect | +|-----|--------| +| `CATSTACK_HOOKS_REPO` | Use this checkout instead of resolving the symlink. | +| `CATSTACK_HOOK_FRESHNESS_FETCH=1` | Allow a short `git fetch origin main` first. | +| `CATSTACK_HOOK_FRESHNESS=0` | Silence the advisory. | +| `HOOK_FRESHNESS_STATE_DIR` | Once-per-session marker directory. | diff --git a/engine/hooks/hook-freshness/claude.prompt.hook.json b/engine/hooks/hook-freshness/claude.prompt.hook.json new file mode 100644 index 0000000..167939b --- /dev/null +++ b/engine/hooks/hook-freshness/claude.prompt.hook.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/hook-freshness/claude_prompt_submit.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/engine/hooks/hook-freshness/claude_prompt_submit.py b/engine/hooks/hook-freshness/claude_prompt_submit.py new file mode 100644 index 0000000..3cc5d02 --- /dev/null +++ b/engine/hooks/hook-freshness/claude_prompt_submit.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Claude Code UserPromptSubmit: warn once per session when the catstack +checkout behind ~/.claude/hooks is off main or behind origin/main, so merged +hook fixes that are not live here get noticed. Advisory, fail-open, no block. +""" +from __future__ import annotations + +import json +import sys + +from detect import decide_json + + +def main() -> None: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, OSError): + return + try: + out = decide_json(payload if isinstance(payload, dict) else {}) + except Exception: + return + if out: + print(out) + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/hook-freshness/detect.py b/engine/hooks/hook-freshness/detect.py new file mode 100644 index 0000000..6cb7dfa --- /dev/null +++ b/engine/hooks/hook-freshness/detect.py @@ -0,0 +1,145 @@ +"""hook-freshness: the installed hooks are only as new as the checkout behind them. + +`install.sh` symlinks `~/.claude/hooks/` at a catstack checkout, so a +hook fix that is merged on `origin/main` does nothing on this machine while +that checkout sits on a feature branch or behind the remote. This resolves +the checkout from the `diu-stop` symlink, reads its branch and its distance +from `origin/main`, and returns one advisory line for the turn. + +Advisory only: no block, no LLM, no network unless +CATSTACK_HOOK_FRESHNESS_FETCH=1. Fails open on every error. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess + +STATE_DIR = os.environ.get( + "HOOK_FRESHNESS_STATE_DIR", + os.path.join(os.path.expanduser("~"), ".cache", "catstack-hook-freshness"), +) + +ANCHOR_LINK = os.path.join(os.path.expanduser("~"), ".claude", "hooks", "diu-stop") +TRUNK = "origin/main" +FETCH_TIMEOUT_SECS = 3 +GIT_TIMEOUT_SECS = 5 + +MESSAGE = ( + "catstack hooks are stale: the checkout behind ~/.claude/hooks is {detail}. " + "Merged hook and skill fixes are not live here until it is updated. Run " + "`git -C {repo} pull --ff-only` (or merge {trunk} into the branch), then " + "`{repo}/install.sh`, and restart the harness." +) + + +def _run_git(args, cwd, timeout=GIT_TIMEOUT_SECS): + result = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def resolve_repo(env=None, realpath=os.path.realpath, isdir=os.path.isdir): + """The catstack checkout the live hooks point at, or None.""" + env = env if env is not None else os.environ + override = env.get("CATSTACK_HOOKS_REPO") + if override: + return override if isdir(os.path.join(override, ".git")) else None + try: + target = realpath(ANCHOR_LINK) + except OSError: + return None + repo = os.path.dirname(os.path.dirname(os.path.dirname(target))) + return repo if isdir(os.path.join(repo, ".git")) else None + + +def repo_state(repo, env=None, run=_run_git): + """(branch, commits behind trunk) for the checkout, or (None, None).""" + env = env if env is not None else os.environ + try: + if env.get("CATSTACK_HOOK_FRESHNESS_FETCH") == "1": + run(["fetch", "--quiet", "origin", "main"], repo, FETCH_TIMEOUT_SECS) + branch = run(["branch", "--show-current"], repo) + behind_raw = run(["rev-list", "--count", f"HEAD..{TRUNK}"], repo) + except (OSError, subprocess.SubprocessError): + return None, None + if behind_raw is None: + return branch, None + try: + return branch, int(behind_raw) + except ValueError: + return branch, None + + +def advisory(repo, branch, behind): + """One line when the checkout is off trunk or behind it, else None.""" + if not repo or behind is None: + return None + off_trunk = bool(branch) and branch != "main" + if not off_trunk and behind <= 0: + return None + parts = [] + if off_trunk: + parts.append(f"on branch `{branch}`") + if behind > 0: + commit_word = "commit" if behind == 1 else "commits" + parts.append(f"{behind} {commit_word} behind {TRUNK}") + return MESSAGE.format(detail=" and ".join(parts), repo=repo, trunk=TRUNK) + + +def _state_file(key): + digest = hashlib.sha256((key or "no-transcript").encode("utf-8")).hexdigest()[:16] + return os.path.join(STATE_DIR, f"{digest}.advised") + + +def already_advised(key): + return os.path.isfile(_state_file(key)) + + +def mark_advised(key): + path = _state_file(key) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write((key or "") + "\n") + except OSError: + pass + + +def decide(payload, env=None, run=_run_git, state=True): + """Advisory context for this prompt, or None. Once per session.""" + env = env if env is not None else os.environ + if env.get("CATSTACK_HOOK_FRESHNESS") == "0": + return None + key = payload.get("transcript_path") or payload.get("transcriptPath") or "" + if state and already_advised(key): + return None + repo = resolve_repo(env=env) + if not repo: + return None + branch, behind = repo_state(repo, env=env, run=run) + line = advisory(repo, branch, behind) + if line and state: + mark_advised(key) + return line + + +def decide_json(payload, env=None, run=_run_git, state=True): + line = decide(payload, env=env, run=run, state=state) + if not line: + return None + return json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": line, + } + }) diff --git a/engine/hooks/hook-freshness/install_claude_hook.py b/engine/hooks/hook-freshness/install_claude_hook.py new file mode 100644 index 0000000..63e610b --- /dev/null +++ b/engine/hooks/hook-freshness/install_claude_hook.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Merge hook-freshness into ~/.claude/settings.json UserPromptSubmit hooks. Idempotent.""" +from __future__ import annotations + +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json") +FRAGMENT_PATH = os.path.join(HERE, "claude.prompt.hook.json") +MARKER = "hook-freshness/claude_prompt_submit.py" +EVENT = "UserPromptSubmit" + + +def _is_ours(entry: dict) -> bool: + return any(MARKER in h.get("command", "") for h in entry.get("hooks", [])) + + +def merge_hook(settings: dict, fragment: dict) -> bool: + entry_list = settings.setdefault("hooks", {}).setdefault(EVENT, []) + new_entries = fragment.get("hooks", {}).get(EVENT, []) + before = json.dumps(entry_list, sort_keys=True) + kept = [e for e in entry_list if not _is_ours(e)] + entry_list[:] = kept + new_entries + return json.dumps(entry_list, sort_keys=True) != before + + +def main() -> None: + settings: dict = {} + if os.path.exists(SETTINGS_PATH): + with open(SETTINGS_PATH) as handle: + settings = json.load(handle) + with open(FRAGMENT_PATH) as handle: + fragment = json.load(handle) + if not merge_hook(settings, fragment): + print("ok claude UserPromptSubmit hook-freshness already up to date") + return + os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True) + with open(SETTINGS_PATH, "w") as handle: + json.dump(settings, handle, indent=2) + handle.write("\n") + print("added claude UserPromptSubmit hook-freshness") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/hook-freshness/tests/test_hooks.py b/engine/hooks/hook-freshness/tests/test_hooks.py new file mode 100644 index 0000000..38f91a9 --- /dev/null +++ b/engine/hooks/hook-freshness/tests/test_hooks.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests for the hook-freshness UserPromptSubmit hook. + +Run: python3 -m unittest discover -s engine/hooks/hook-freshness/tests -v + +Git is injected as a fake runner, so no test touches a real repo or the +network. The stale case mirrors the real failure: the checkout behind the +live symlinks on a feature branch, dozens of commits behind origin/main, +with merged hook fixes therefore not installed. +""" +from __future__ import annotations + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +HOOK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, HOOK_DIR) + +import claude_prompt_submit # noqa: E402 +import detect # noqa: E402 + + +def fake_git(branch="main", behind="0", fail=(), record=None): + def run(args, cwd, timeout=None): + if record is not None: + record.append(args) + key = args[0] + if key in fail: + return None + if key == "branch": + return branch + if key == "rev-list": + return behind + if key == "fetch": + return "" + return "" + return run + + +class TestAdvisoryFires(unittest.TestCase): + def test_hit_feature_branch_far_behind_trunk(self): + line = detect.advisory("/repo/catstack", "feat/skill-usage-log", 40) + self.assertIsNotNone(line) + self.assertIn("feat/skill-usage-log", line) + self.assertIn("40 commits behind origin/main", line) + self.assertIn("install.sh", line) + + def test_hit_on_main_but_one_commit_behind(self): + line = detect.advisory("/repo/catstack", "main", 1) + self.assertIsNotNone(line) + self.assertIn("1 commit behind", line) + + def test_hit_decide_returns_context_json(self): + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + os.makedirs(os.path.join(repo, ".git")) + env = {"CATSTACK_HOOKS_REPO": repo, "HOOK_FRESHNESS_STATE_DIR": tmp} + with patch.object(detect, "STATE_DIR", tmp): + out = detect.decide_json( + {"transcript_path": os.path.join(tmp, "t.jsonl")}, + env=env, + run=fake_git(branch="feat/x", behind="3"), + ) + payload = json.loads(out) + self.assertEqual(payload["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit") + self.assertIn("3 commits behind", payload["hookSpecificOutput"]["additionalContext"]) + + def test_hit_prints_once_per_session(self): + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + os.makedirs(os.path.join(repo, ".git")) + env = {"CATSTACK_HOOKS_REPO": repo} + payload = {"transcript_path": os.path.join(tmp, "t.jsonl")} + with patch.object(detect, "STATE_DIR", tmp): + first = detect.decide(payload, env=env, run=fake_git(branch="feat/x", behind="3")) + second = detect.decide(payload, env=env, run=fake_git(branch="feat/x", behind="3")) + self.assertIsNotNone(first) + self.assertIsNone(second) + + +class TestAdvisorySilent(unittest.TestCase): + def test_no_hit_on_main_up_to_date(self): + self.assertIsNone(detect.advisory("/repo/catstack", "main", 0)) + + def test_no_hit_when_behind_count_unavailable(self): + self.assertIsNone(detect.advisory("/repo/catstack", "main", None)) + + def test_no_hit_when_repo_unresolvable(self): + self.assertIsNone(detect.decide({}, env={"CATSTACK_HOOKS_REPO": "/nope/not/a/repo"})) + + def test_no_hit_when_disabled_by_env(self): + self.assertIsNone(detect.decide({}, env={"CATSTACK_HOOK_FRESHNESS": "0"})) + + def test_fails_open_when_git_errors(self): + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + os.makedirs(os.path.join(repo, ".git")) + branch, behind = detect.repo_state(repo, env={}, run=fake_git(fail=("rev-list",))) + self.assertIsNone(behind) + + def test_no_fetch_unless_opted_in(self): + calls = [] + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + os.makedirs(os.path.join(repo, ".git")) + detect.repo_state(repo, env={}, run=fake_git(record=calls)) + self.assertNotIn("fetch", [c[0] for c in calls]) + + def test_fetch_when_opted_in(self): + calls = [] + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + os.makedirs(os.path.join(repo, ".git")) + detect.repo_state(repo, env={"CATSTACK_HOOK_FRESHNESS_FETCH": "1"}, run=fake_git(record=calls)) + self.assertIn("fetch", [c[0] for c in calls]) + + def test_fails_open_on_garbage_stdin(self): + out = io.StringIO() + with patch.object(sys, "stdin", io.StringIO("not json")): + with redirect_stdout(out): + claude_prompt_submit.main() + self.assertEqual(out.getvalue(), "") + + +class TestRepoResolution(unittest.TestCase): + def test_hit_resolves_repo_from_symlink_target(self): + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "catstack") + target = os.path.join(repo, "engine", "hooks", "diu-stop") + os.makedirs(target) + os.makedirs(os.path.join(repo, ".git")) + with patch.object(detect, "ANCHOR_LINK", os.path.join(tmp, "link")): + os.symlink(target, os.path.join(tmp, "link")) + self.assertEqual(detect.resolve_repo(env={}), os.path.realpath(repo)) + + +if __name__ == "__main__": + unittest.main() diff --git a/install.sh b/install.sh index e146adf..8ace0c2 100755 --- a/install.sh +++ b/install.sh @@ -239,6 +239,7 @@ link_item "hedge-runs-prove-it" "$REPO_DIR/engine/hooks/hedge-runs-prove-it" "$H link_item "new-file-callout" "$REPO_DIR/engine/hooks/new-file-callout" "$HOME/.claude/hooks/new-file-callout" link_item "agent-relay-attribution" "$REPO_DIR/engine/hooks/agent-relay-attribution" "$HOME/.claude/hooks/agent-relay-attribution" link_item "scratchpad-collision" "$REPO_DIR/engine/hooks/scratchpad-collision" "$HOME/.claude/hooks/scratchpad-collision" +link_item "hook-freshness" "$REPO_DIR/engine/hooks/hook-freshness" "$HOME/.claude/hooks/hook-freshness" echo "--- cursor hooks dir (\$HOME/.cursor/hooks) ---" mkdir -p "$HOME/.cursor/hooks" @@ -334,6 +335,7 @@ python3 "$REPO_DIR/engine/hooks/hedge-runs-prove-it/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/new-file-callout/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/agent-relay-attribution/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/scratchpad-collision/install_claude_hook.py" +python3 "$REPO_DIR/engine/hooks/hook-freshness/install_claude_hook.py" python3 "$REPO_DIR/scripts/prune_dead_hook_entries.py" echo "--- cursor bug-complaint-leak merge (\$HOME/.cursor/hooks.json) ---" diff --git a/tests/test_install.py b/tests/test_install.py index 0893629..51dbcd2 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -189,6 +189,15 @@ def test_hedge_runs_prove_it_linked_and_stop_wired_for_claude(self): stop = [h["command"] for e in settings["hooks"]["Stop"] for h in e["hooks"]] self.assertTrue(any("hedge-runs-prove-it/claude_stop_check.py" in c for c in stop), stop) + def test_hook_freshness_linked_and_prompt_wired_for_claude(self): + target = os.path.join(self.fake_home, ".claude", "hooks", "hook-freshness") + self.assertTrue(os.path.islink(target), target) + self.assertEqual(os.readlink(target), hook_src("hook-freshness")) + commands = self._claude_hook_commands("UserPromptSubmit") + self.assertTrue( + any("hook-freshness/claude_prompt_submit.py" in c for c in commands), commands + ) + def test_new_file_callout_linked_and_stop_wired_for_claude(self): target = os.path.join(self.fake_home, ".claude", "hooks", "new-file-callout") self.assertTrue(os.path.islink(target), target)