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
126 changes: 119 additions & 7 deletions engine/hooks/hook-freshness/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,88 @@ def advisory(repo, branch, behind):
return MESSAGE.format(detail=" and ".join(parts), repo=repo, trunk=TRUNK)


SETTINGS_PATH = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")

UNCHECKED_MESSAGE = (
"hook-freshness: could not check whether the registered hook scripts resolve, because "
"{reason}. Treat the hook set as unchecked rather than healthy."
)

UNRESOLVABLE_MESSAGE = (
"hook-freshness: {count} registered hook script(s) cannot run because their path does "
"not resolve: {paths}. Those hooks are unchecked, not clean — a gate that never "
"executes reports nothing. Re-run your catstack `install.sh` to relink them."
)


def _load_json(path):
with open(path, encoding="utf-8") as handle:
return json.load(handle)


def _hook_commands(settings_path=SETTINGS_PATH, load=None):
"""(commands, unreadable_reason). A reason means the sweep could not run at all."""
loader = load or _load_json
try:
data = loader(settings_path)
except FileNotFoundError:
return [], f"{settings_path} does not exist"
except (OSError, ValueError) as exc:
return [], f"{settings_path} could not be read ({type(exc).__name__}: {exc})"
hooks = data.get("hooks")
if not isinstance(hooks, dict):
return [], f"{settings_path} has no readable 'hooks' object"
commands = []
malformed = 0
for matchers in hooks.values():
if not isinstance(matchers, list):
malformed += 1
continue
for matcher in matchers:
if not isinstance(matcher, dict):
malformed += 1
continue
for entry in matcher.get("hooks") or []:
if isinstance(entry, dict) and entry.get("command"):
commands.append(str(entry["command"]))
else:
malformed += 1
if malformed and not commands:
return [], f"{settings_path} has {malformed} hook entr(ies) in an unrecognised shape and no readable command"
return commands, None


def _script_paths(command):
expanded = os.path.expandvars(command).replace("~/", os.path.expanduser("~") + "/")
return [tok for tok in expanded.split() if "/" in tok and not tok.startswith("-")]


def unresolvable_hooks(settings_path=SETTINGS_PATH, load=None, exists=os.path.exists):
"""(missing script paths, unreadable_reason). Never reports clean when it could not look."""
commands, unreadable = _hook_commands(settings_path, load=load)
if unreadable:
return [], unreadable
missing = []
for command in commands:
for path in _script_paths(command):
if "$" in path:
continue
if not exists(path) and path not in missing:
missing.append(path)
return missing, None


def unresolvable_advisory(missing, unreadable=None):
if unreadable:
return UNCHECKED_MESSAGE.format(reason=unreadable)
if not missing:
return None
shown = ", ".join(missing[:3])
if len(missing) > 3:
shown += f", and {len(missing) - 3} more"
return UNRESOLVABLE_MESSAGE.format(count=len(missing), paths=shown)


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")
Expand All @@ -115,26 +197,56 @@ def mark_advised(key):
pass


def decide(payload, env=None, run=_run_git, state=True):
def decide(
payload,
env=None,
run=_run_git,
state=True,
settings_path=SETTINGS_PATH,
load=None,
exists=os.path.exists,
):
"""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
missing, unreadable = unresolvable_hooks(settings_path=settings_path, load=load, exists=exists)
lines = [ln for ln in [unresolvable_advisory(missing, unreadable)] if ln]
repo = resolve_repo(env=env)
if not repo:
if repo:
branch, behind = repo_state(repo, env=env, run=run)
staleness = advisory(repo, branch, behind)
if staleness:
lines.append(staleness)
if not lines:
return None
branch, behind = repo_state(repo, env=env, run=run)
line = advisory(repo, branch, behind)
if line and state:
line = "\n".join(lines)
if 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)
def decide_json(
payload,
env=None,
run=_run_git,
state=True,
settings_path=SETTINGS_PATH,
load=None,
exists=os.path.exists,
):
line = decide(
payload,
env=env,
run=run,
state=state,
settings_path=settings_path,
load=load,
exists=exists,
)
if not line:
return None
return json.dumps({
Expand Down
113 changes: 110 additions & 3 deletions engine/hooks/hook-freshness/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
import detect # noqa: E402


def empty_settings(_path):
return {"hooks": {}}


def fake_git(branch="main", behind="0", fail=(), record=None):
def run(args, cwd, timeout=None):
if record is not None:
Expand Down Expand Up @@ -66,6 +70,8 @@ def test_hit_decide_returns_context_json(self):
{"transcript_path": os.path.join(tmp, "t.jsonl")},
env=env,
run=fake_git(branch="feat/x", behind="3"),
settings_path=os.path.join(tmp, "settings.json"),
load=empty_settings,
)
payload = json.loads(out)
self.assertEqual(payload["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit")
Expand All @@ -78,8 +84,20 @@ def test_hit_prints_once_per_session(self):
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"))
first = detect.decide(
payload,
env=env,
run=fake_git(branch="feat/x", behind="3"),
settings_path=os.path.join(tmp, "settings.json"),
load=empty_settings,
)
second = detect.decide(
payload,
env=env,
run=fake_git(branch="feat/x", behind="3"),
settings_path=os.path.join(tmp, "settings.json"),
load=empty_settings,
)
self.assertIsNotNone(first)
self.assertIsNone(second)

Expand All @@ -92,7 +110,25 @@ 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"}))
self.assertIsNone(
detect.decide(
{},
env={"CATSTACK_HOOKS_REPO": "/nope/not/a/repo"},
settings_path="/tmp/settings.json",
load=empty_settings,
)
)

def test_missing_settings_file_reports_unchecked_through_decide(self):
with tempfile.TemporaryDirectory() as tmp:
line = detect.decide(
{},
env={"CATSTACK_HOOKS_REPO": "/nope/not/a/repo"},
state=False,
settings_path=os.path.join(tmp, ".claude", "settings.json"),
)
self.assertIn("could not check", line)
self.assertIn("does not exist", line)

def test_no_hit_when_disabled_by_env(self):
self.assertIsNone(detect.decide({}, env={"CATSTACK_HOOK_FRESHNESS": "0"}))
Expand Down Expand Up @@ -142,3 +178,74 @@ def test_hit_resolves_repo_from_symlink_target(self):

if __name__ == "__main__":
unittest.main()


class TestUnresolvableHookSweep(unittest.TestCase):
"""A registered hook whose script path is gone is unchecked, never clean.

Mirrors the real failure: ~/.claude/hooks/split-scope pointed into a deleted
worktree, so the gate could not run for a whole session and said nothing.
"""

def _settings(self, commands):
return {"hooks": {"UserPromptSubmit": [{"matcher": "*", "hooks": [
{"type": "command", "command": c} for c in commands
]}]}}

def test_names_a_registered_hook_whose_script_is_missing(self):
settings = self._settings([
"python3 /real/hooks/diu-stop/claude_stop_check.py",
"python3 /gone/hooks/split-scope/claude_prompt_submit.py",
])
missing, unreadable = detect.unresolvable_hooks(
settings_path="/tmp/settings.json",
load=lambda _p: settings,
exists=lambda p: p.startswith("/real/"),
)
self.assertIsNone(unreadable)
self.assertEqual(missing, ["/gone/hooks/split-scope/claude_prompt_submit.py"])
line = detect.unresolvable_advisory(missing, unreadable)
self.assertIn("split-scope", line)
self.assertIn("unchecked, not clean", line)

def test_silent_when_every_registered_hook_resolves(self):
settings = self._settings(["python3 /real/hooks/diu-stop/claude_stop_check.py"])
missing, unreadable = detect.unresolvable_hooks(
settings_path="/tmp/settings.json",
load=lambda _p: settings,
exists=lambda _p: True,
)
self.assertEqual((missing, unreadable), ([], None))
self.assertIsNone(detect.unresolvable_advisory(missing, unreadable))

def test_an_unreadable_settings_file_reports_unchecked_not_clean(self):
def boom(_path):
raise ValueError("Expecting ',' delimiter: line 4 column 3")

missing, unreadable = detect.unresolvable_hooks(
settings_path="/tmp/settings.json", load=boom, exists=lambda _p: True,
)
self.assertEqual(missing, [])
self.assertIsNotNone(unreadable)
line = detect.unresolvable_advisory(missing, unreadable)
self.assertIn("could not check", line)
self.assertIn("unchecked rather than healthy", line)

def test_a_missing_settings_file_reports_unchecked_not_clean(self):
def gone(_path):
raise FileNotFoundError(2, "No such file or directory")

missing, unreadable = detect.unresolvable_hooks(
settings_path="/tmp/settings.json", load=gone, exists=lambda _p: True,
)
self.assertEqual(missing, [])
self.assertIn("does not exist", unreadable)

def test_unexpanded_variables_are_not_reported_as_missing(self):
settings = self._settings(["python3 $UNSET_ROOT/hooks/x/run.py"])
missing, unreadable = detect.unresolvable_hooks(
settings_path="/tmp/settings.json",
load=lambda _p: settings,
exists=lambda _p: False,
)
self.assertEqual((missing, unreadable), ([], None))
17 changes: 14 additions & 3 deletions engine/hooks/narrow-the-scope/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@
PostToolUse (Edit|Write|MultiEdit|NotebookEdit|StrReplace|Bash): count edits
per file in session state; a verification-shaped Bash command (pytest,
unittest, npm test, jest, vitest, tsc, eslint, ruff, mypy, `check_*.py`,
`run_all_tests`, cargo/go test, ...) resets every count. When a file reaches
three edits with no reset, inject the `narrow-the-scope` reminder once for
that streak episode. Inject-only, never blocks, fail-open.
`run_all_tests`, cargo/go test, `bash <name>test.sh`, `docker build`, ...)
resets every count. When a file reaches three edits with no reset, inject the
`narrow-the-scope` reminder once for that streak episode. Inject-only, never
blocks, fail-open.

A Bash command that names the basename of a file in the current streak also
clears that one file's count: running the script you just edited is how shell
and container work gets verified, and `VERIFY_RE` cannot enumerate every
project's entry point. Precision matters more than recall here — a detector
that cries wolf trains the reader to skip it (Kim & Ernst, "Which warnings
should I fix first?", ESEC/FSE 2007,
https://dl.acm.org/doi/10.1145/1287624.1287633). `tests/fixtures/shell_verified_streak_2026-09-11.json`
is the verbatim sequence from a session where this hook fired four times and
was wrong all four.

Mechanical half of `product/skills/narrow-the-scope`, whose trigger text is
"three or more edits to the same file without a passing test/build/lint run
Expand Down
19 changes: 18 additions & 1 deletion engine/hooks/narrow-the-scope/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,18 @@
r"\b(?:pytest|unittest|npm (?:run )?test|pnpm (?:run )?test|yarn test|jest|vitest|"
r"cargo (?:test|check|build)|go (?:test|build|vet)|make (?:test|check|lint)|tsc\b|eslint|"
r"ruff|mypy|pyright|flake8|run_all_tests|check_\w+\.py|gradle(?:w)? (?:test|build)|"
r"swift (?:test|build)|xcodebuild|dotnet test|python3? -m (?:pytest|unittest)|node --test)",
r"swift (?:test|build)|xcodebuild|dotnet test|python3? -m (?:pytest|unittest)|node --test|"
r"(?:ba)?sh\s+\S*(?:test|check|verify|prove|run)[\w.-]*\.(?:sh|bash)|"
r"docker\s+(?:build|compose\s+(?:build|up))|"
r"node --check)",
re.IGNORECASE,
)


def _basename(path: str) -> str:
return path.rsplit("/", 1)[-1]


def _file_of(payload: dict) -> str:
inp = payload.get("tool_input") or {}
return str(inp.get("file_path") or inp.get("notebook_path") or inp.get("path") or "")
Expand All @@ -56,6 +63,16 @@ def observe(payload: dict) -> str | None:
state["counts"] = {}
state["fired"] = []
save_state(payload, state)
return None
executed = [p for p in counts if _basename(p) and _basename(p) in cmd]
if executed:
for path in executed:
counts.pop(path, None)
if path in fired:
fired.remove(path)
state["counts"] = counts
state["fired"] = fired
save_state(payload, state)
return None
if tool not in EDIT_TOOLS:
return None
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"source": "Claude Code session c2a4bea7-2e0b-428f-9dbb-0498a9daedeb, building scripts/e2e-cli-install for Invoker",
"why": "The detector fired four times in this session and every one was a false positive: the verification between the edits was a bash script run and a docker build, neither of which VERIFY_RE could see. This is the sandbox-guard slice of that sequence, verbatim.",
"verification_index": 2,
"sequence": [
{"tool": "Write", "file_path": "/w/scripts/e2e-cli-install/lib/sandbox-guard.sh"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/lib/sandbox-guard.sh"},
{"tool": "Bash", "command": "bash \"$WT/scripts/test-e2e-cli-install-guard.sh\""},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/lib/sandbox-guard.sh"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/lib/sandbox-guard.sh"}
],
"docker_sequence": [
{"tool": "Write", "file_path": "/w/scripts/e2e-cli-install/Dockerfile"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/Dockerfile"},
{"tool": "Bash", "command": "bash scripts/e2e-cli-install/run.sh --docker"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/Dockerfile"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/Dockerfile"}
],
"direct_execution_sequence": [
{"tool": "Write", "file_path": "/w/scripts/e2e-cli-install/run.sh"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/run.sh"},
{"tool": "Bash", "command": "bash /w/scripts/e2e-cli-install/run.sh --docker"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/run.sh"},
{"tool": "Edit", "file_path": "/w/scripts/e2e-cli-install/run.sh"}
]
}
Loading
Loading