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 @@ -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`. |
Expand Down
1 change: 1 addition & 0 deletions docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
33 changes: 33 additions & 0 deletions engine/hooks/hook-freshness/README.md
Original file line number Diff line number Diff line change
@@ -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/<name>` 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. |
15 changes: 15 additions & 0 deletions engine/hooks/hook-freshness/claude.prompt.hook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/hook-freshness/claude_prompt_submit.py",
"timeout": 10
}
]
}
]
}
}
28 changes: 28 additions & 0 deletions engine/hooks/hook-freshness/claude_prompt_submit.py
Original file line number Diff line number Diff line change
@@ -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()
145 changes: 145 additions & 0 deletions engine/hooks/hook-freshness/detect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""hook-freshness: the installed hooks are only as new as the checkout behind them.

`install.sh` symlinks `~/.claude/hooks/<name>` 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,
}
})
46 changes: 46 additions & 0 deletions engine/hooks/hook-freshness/install_claude_hook.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading