From 6f1586721c0e9f34c1ffd7c937191e9bfe774310 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 12 Sep 2026 07:03:19 +0000 Subject: [PATCH 1/6] Check hook commands in installed settings --- scripts/check_install_effective.py | 56 ++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/scripts/check_install_effective.py b/scripts/check_install_effective.py index 11894d0e..5642d55c 100755 --- a/scripts/check_install_effective.py +++ b/scripts/check_install_effective.py @@ -33,6 +33,7 @@ """ from __future__ import annotations +import json import os import pwd import re @@ -155,6 +156,8 @@ def check_links() -> list[str]: name = skill.parent.name installed = HOME / ".claude/skills" / name if installed.exists() and not installed.is_symlink(): + if installed.is_dir() and (installed / ".catstack-generated").is_file(): + continue problems.append(f"skill shadowed by a real directory: {installed}") return problems @@ -226,17 +229,58 @@ def check_worktree_links() -> tuple[list[str], list[str]]: return problems, unchecked +def hook_commands_by_event(data: object) -> dict[str, set[str]]: + if not isinstance(data, dict): + return {} + hooks = data.get("hooks", {}) + if not isinstance(hooks, dict): + return {} + commands_by_event: dict[str, set[str]] = {} + for event, entries in hooks.items(): + if not isinstance(event, str) or not isinstance(entries, list): + continue + commands = commands_by_event.setdefault(event, set()) + for entry in entries: + if not isinstance(entry, dict): + continue + entry_hooks = entry.get("hooks", []) + if not isinstance(entry_hooks, list): + continue + for hook in entry_hooks: + if not isinstance(hook, dict): + continue + command = hook.get("command") + if isinstance(command, str): + commands.add(command) + return commands_by_event + + def check_hooks_registered() -> list[str]: settings = HOME / ".claude/settings.json" if not settings.exists(): return ["no ~/.claude/settings.json; no hook is registered"] - text = settings.read_text() + try: + settings_data = json.loads(settings.read_text(encoding="utf-8")) + except OSError as exc: + return [f"UNCHECKED: ~/.claude/settings.json could not be read ({exc.__class__.__name__}); hooks are unchecked"] + except json.JSONDecodeError as exc: + return [ + f"UNCHECKED: ~/.claude/settings.json could not be parsed as JSON " + f"({exc.msg} at line {exc.lineno}, column {exc.colno}); hooks are unchecked" + ] + registered = hook_commands_by_event(settings_data) problems = [] - for hook_dir in sorted((REPO / "engine/hooks").glob("*/")): - if not (hook_dir / "claude.hook.json").exists(): - continue - if hook_dir.name not in text: - problems.append(f"hook built but never registered in settings.json: {hook_dir.name}") + for hook_file in sorted((REPO / "engine/hooks").glob("*/claude*.hook.json")): + hook_dir = hook_file.parent + with hook_file.open(encoding="utf-8") as handle: + hook_data = json.load(handle) + for event, commands in hook_commands_by_event(hook_data).items(): + for command in sorted(commands): + if command not in registered.get(event, set()): + problems.append( + f"hook declared but not registered for {event} in settings.json: " + f"{hook_dir.name}/{hook_file.name}: {command}" + ) return problems From d1fccaa07ebeec723c0b8d2f418bd270ae376898 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:04:15 +0000 Subject: [PATCH 2/6] =?UTF-8?q?invoker:=20wf-1789196402073-11/implement-re?= =?UTF-8?q?gistration-check=20=E2=80=94=20Review=20claim:=20scripts/check?= =?UTF-8?q?=5Finstall=5Feffective.py=20fails=20when=20a=20declared=20hook?= =?UTF-8?q?=20entrypoint=20is=20missing=20from=20settings.json=20for=20its?= =?UTF-8?q?=20own=20event,=20and=20treats=20a=20generated=20skill=20direct?= =?UTF-8?q?ory=20carrying=20.catstack-generated=20as=20installed=20rather?= =?UTF-8?q?=20than=20shadowed.=20Review=20lane:=20policy=20Safety=20invari?= =?UTF-8?q?ant:=20Read-only=20gate.=20It=20writes=20nothing,=20installs=20?= =?UTF-8?q?nothing,=20and=20reports=20an=20unreadable=20or=20unparsable=20?= =?UTF-8?q?settings=20file=20as=20UNCHECKED=20rather=20than=20as=20a=20pas?= =?UTF-8?q?s.=20Effectiveness=20measurement:=20The=20next=20task's=20fixtu?= =?UTF-8?q?res,=20run=20against=20this=20change,=20must=20pass,=20and=20th?= =?UTF-8?q?e=20same=20fixtures=20must=20fail=20against=20the=20gate=20as?= =?UTF-8?q?=20it=20stands=20today.=20Slice=20rationale:=20The=20gate=20its?= =?UTF-8?q?elf,=20alone.=20Its=20fixtures=20are=20the=20next=20task=20beca?= =?UTF-8?q?use=20the=20atomicity=20lint=20keeps=20repo-root=20scripts=20an?= =?UTF-8?q?d=20repo-root=20tests=20in=20separate=20tasks.=20Architectural?= =?UTF-8?q?=20effect:=20The=20gate's=20input=20changes=20from=20one=20file?= =?UTF-8?q?=20name=20plus=20a=20substring=20search=20to=20the=20parsed=20c?= =?UTF-8?q?ontents=20of=20every=20claude*.hook.json=20compared=20against?= =?UTF-8?q?=20the=20parsed=20settings=20file.=20Goal:=20Replace=20the=20fo?= =?UTF-8?q?lder-name=20substring=20test=20with=20a=20parsed=20event-and-co?= =?UTF-8?q?mmand=20comparison,=20and=20exempt=20a=20directory=20that=20car?= =?UTF-8?q?ries=20the=20installer's=20own=20marker.=20Motivation:=20Eleven?= =?UTF-8?q?=20declared=20entrypoints=20were=20dead=20on=20a=20real=20machi?= =?UTF-8?q?ne=20while=20this=20gate=20passed,=20so=20a=20merged=20hook=20d?= =?UTF-8?q?id=20nothing=20and=20the=20operator=20had=20no=20signal.=20Alte?= =?UTF-8?q?rnative=20considerations:=20Asserting=20only=20that=20each=20ev?= =?UTF-8?q?ent=20name=20appears=20was=20rejected:=20the=20event=20can=20be?= =?UTF-8?q?=20present=20for=20another=20hook=20while=20this=20hook's=20com?= =?UTF-8?q?mand=20is=20absent.=20Dropping=20the=20shadow=20check=20entirel?= =?UTF-8?q?y=20was=20rejected:=20a=20hand-made=20directory=20that=20is=20n?= =?UTF-8?q?ot=20the=20installer's=20own=20output=20still=20hides=20the=20r?= =?UTF-8?q?epo's=20copy.=20Implementation=20details:=20In=20scripts/check?= =?UTF-8?q?=5Finstall=5Feffective.py,=20rewrite=20check=5Fhooks=5Fregister?= =?UTF-8?q?ed=20(currently=20at=20lines=20229-240)=20to=20glob=20every=20c?= =?UTF-8?q?laude*.hook.json=20in=20each=20engine/hooks/*=20directory,=20re?= =?UTF-8?q?ad=20its=20"hooks"=20object,=20and=20for=20each=20event=20and?= =?UTF-8?q?=20each=20command=20entry=20assert=20that=20settings.json's=20p?= =?UTF-8?q?arsed=20hooks=20hold=20an=20entry=20for=20that=20event=20whose?= =?UTF-8?q?=20command=20string=20matches;=20report=20each=20missing=20pair?= =?UTF-8?q?=20naming=20the=20hook,=20the=20file=20and=20the=20event.=20Kee?= =?UTF-8?q?p=20the=20existing=20behavior=20for=20a=20missing=20settings=20?= =?UTF-8?q?file=20and=20add=20the=20same=20treatment=20for=20a=20settings?= =?UTF-8?q?=20file=20that=20does=20not=20parse.=20In=20the=20shadow=20chec?= =?UTF-8?q?k=20(currently=20lines=20154-158),=20skip=20an=20installed=20pa?= =?UTF-8?q?th=20that=20is=20a=20directory=20containing=20a=20.catstack-gen?= =?UTF-8?q?erated=20file,=20because=20install.sh=20writes=20that=20marker?= =?UTF-8?q?=20itself.=20Non-goals:=20No=20change=20to=20install.sh,=20to?= =?UTF-8?q?=20any=20hook,=20or=20to=20any=20other=20check=20in=20this=20fi?= =?UTF-8?q?le.=20No=20test=20edits=20here,=20no=20new=20gate,=20and=20no?= =?UTF-8?q?=20CI=20wiring.=20Layer:=20domain=20Feature=20state:=20active?= =?UTF-8?q?=20Files:=20-=20scripts/check=5Finstall=5Feffective.py=20Change?= =?UTF-8?q?=20types:=20-=20scripts/check=5Finstall=5Feffective.py:=20modif?= =?UTF-8?q?y=20Acceptance=20criteria:=20-=20`python3=20scripts/check=5Fno?= =?UTF-8?q?=5Fnew=5Fcomments.py=20--base=20origin/main`=20exits=200.=20-?= =?UTF-8?q?=20`python3=20-c=20"import=20ast,sys;=20ast.parse(open('scripts?= =?UTF-8?q?/check=5Finstall=5Feffective.py').read())"`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 7900229a-d0ad-4c69-b9fc-2ac738cba100 From a0655739c2733b389b6d0336b9a3466afa7611f1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 12 Sep 2026 07:13:53 +0000 Subject: [PATCH 3/6] Pin install effective registration fixtures --- tests/test_install_effective.py | 75 ++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/tests/test_install_effective.py b/tests/test_install_effective.py index 54629305..ac60e898 100644 --- a/tests/test_install_effective.py +++ b/tests/test_install_effective.py @@ -13,6 +13,7 @@ import contextlib import importlib.util import io +import json import os import pwd import shutil @@ -106,6 +107,24 @@ def run_installed_checker(repo, home): return code, out.getvalue() +def hook_entry(command): + return [{"matcher": "", "hooks": [{"type": "command", "command": command}]}] + + +def write_settings(home, hooks): + (Path(home) / ".claude/settings.json").write_text(json.dumps({"hooks": hooks}), encoding="utf-8") + + +def write_declared_hook(repo, event="UserPromptSubmit", command="$HOME/.claude/hooks/demo-freeze/run.py"): + hook = Path(repo) / "engine/hooks/demo-freeze" + hook.mkdir(parents=True) + (hook / "claude.hook.json").write_text( + json.dumps({"hooks": {event: hook_entry(command)}}), + encoding="utf-8", + ) + return "demo-freeze", event, command + + class TestSandboxHomeIsSkippedNotFailed(unittest.TestCase): def test_a_throwaway_home_is_named_as_a_sandbox(self): with tempfile.TemporaryDirectory() as home: @@ -218,15 +237,61 @@ def test_links_resolving_into_the_primary_checkout_pass(self): self.assertEqual(code, 0, output) self.assertNotIn(WORKTREE_NAME, output) - def test_an_unregistered_hook_still_fails_when_no_link_is_in_a_worktree(self): + def test_a_declared_hook_command_under_the_wrong_event_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + repo, home = build_installation(tmp, link_into_worktree=False) + hook_name, event, command = write_declared_hook(repo) + write_settings(home, {"Stop": hook_entry(command)}) + code, output = run_installed_checker(repo, home) + self.assertEqual(code, 1, output) + self.assertIn(hook_name, output) + self.assertIn("claude.hook.json", output) + self.assertIn(event, output) + self.assertIn(command, output) + + def test_a_declared_hook_event_with_the_wrong_command_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + repo, home = build_installation(tmp, link_into_worktree=False) + hook_name, event, command = write_declared_hook(repo) + write_settings(home, {event: hook_entry("$HOME/.claude/hooks/demo-freeze/other.py")}) + code, output = run_installed_checker(repo, home) + self.assertEqual(code, 1, output) + self.assertIn(hook_name, output) + self.assertIn("claude.hook.json", output) + self.assertIn(event, output) + self.assertIn(command, output) + + def test_a_declared_hook_event_and_command_pair_is_silent(self): + with tempfile.TemporaryDirectory() as tmp: + repo, home = build_installation(tmp, link_into_worktree=False) + hook_name, event, command = write_declared_hook(repo) + write_settings(home, {event: hook_entry(command)}) + code, output = run_installed_checker(repo, home) + self.assertEqual(code, 0, output) + self.assertNotIn(hook_name, output) + self.assertNotIn(command, output) + + def test_an_installer_generated_skill_directory_is_silent(self): + with tempfile.TemporaryDirectory() as tmp: + repo, home = build_installation(tmp, link_into_worktree=False) + installed = Path(home) / ".claude/skills/cat-mode" + installed.unlink() + installed.mkdir() + (installed / ".catstack-generated").write_text("", encoding="utf-8") + code, output = run_installed_checker(repo, home) + self.assertEqual(code, 0, output) + self.assertNotIn("skill shadowed", output) + + def test_a_hand_made_skill_directory_is_reported(self): with tempfile.TemporaryDirectory() as tmp: repo, home = build_installation(tmp, link_into_worktree=False) - hook = repo / "engine/hooks/demo-freeze" - hook.mkdir(parents=True) - (hook / "claude.hook.json").write_text("{}", encoding="utf-8") + installed = Path(home) / ".claude/skills/cat-mode" + installed.unlink() + installed.mkdir() code, output = run_installed_checker(repo, home) self.assertEqual(code, 1, output) - self.assertIn("hook built but never registered", output) + self.assertIn("skill shadowed by a real directory", output) + self.assertIn(str(installed), output) def relink_claude_md(home, source): From bb99c180bb82a854057521d6b2bf27c152e6a50b Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:15:06 +0000 Subject: [PATCH 4/6] =?UTF-8?q?invoker:=20wf-1789196402073-11/add-registra?= =?UTF-8?q?tion-fixtures=20=E2=80=94=20Review=20claim:=20tests=20pin=20tha?= =?UTF-8?q?t=20the=20installer=20verifier=20names=20an=20unregistered=20ev?= =?UTF-8?q?ent-and-command=20pair=20and=20stays=20silent=20on=20the=20inst?= =?UTF-8?q?aller's=20own=20generated=20directory.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Test=20files=20only.=20No=20product=20?= =?UTF-8?q?or=20gate=20code=20changes=20in=20this=20task.=20Effectiveness?= =?UTF-8?q?=20measurement:=20The=20new=20cases=20pass=20on=20this=20branch?= =?UTF-8?q?=20and=20fail=20when=20the=20gate=20is=20reverted=20to=20its=20?= =?UTF-8?q?previous=20shape.=20Slice=20rationale:=20Fixtures=20for=20the?= =?UTF-8?q?=20gate=20changed=20in=20the=20previous=20task,=20kept=20separa?= =?UTF-8?q?te=20because=20the=20atomicity=20lint=20classes=20repo-root=20t?= =?UTF-8?q?ests=20as=20proof=20files.=20Architectural=20effect:=20None;=20?= =?UTF-8?q?coverage=20only.=20Goal:=20Pin=20both=20behaviors=20with=20fixt?= =?UTF-8?q?ures.=20Motivation:=20The=20gate's=20untested=20branches=20are?= =?UTF-8?q?=20how=20eleven=20dead=20entrypoints=20passed=20unnoticed.=20Al?= =?UTF-8?q?ternative=20considerations:=20Asserting=20only=20the=20happy=20?= =?UTF-8?q?path=20was=20rejected:=20the=20defect=20was=20a=20false=20pass,?= =?UTF-8?q?=20so=20the=20missing-pair=20case=20is=20the=20one=20that=20mat?= =?UTF-8?q?ters.=20Implementation=20details:=20Extend=20tests/test=5Finsta?= =?UTF-8?q?ll=5Feffective.py=20with=20a=20temporary=20HOME=20and=20a=20set?= =?UTF-8?q?tings=20fixture:=20one=20case=20omits=20a=20declared=20event=20?= =?UTF-8?q?and=20asserts=20the=20hook=20and=20event=20are=20named;=20one?= =?UTF-8?q?=20case=20has=20the=20event=20with=20a=20different=20command=20?= =?UTF-8?q?and=20asserts=20it=20is=20still=20reported;=20one=20case=20is?= =?UTF-8?q?=20fully=20registered=20and=20asserts=20silence;=20one=20case?= =?UTF-8?q?=20has=20a=20generated=20directory=20carrying=20.catstack-gener?= =?UTF-8?q?ated=20and=20asserts=20silence;=20one=20case=20has=20a=20real?= =?UTF-8?q?=20directory=20without=20the=20marker=20and=20asserts=20it=20is?= =?UTF-8?q?=20still=20reported.=20Follow=20the=20existing=20style=20in=20t?= =?UTF-8?q?hat=20file=20for=20pointing=20the=20module=20at=20a=20temporary?= =?UTF-8?q?=20HOME.=20Non-goals:=20No=20change=20to=20the=20gate,=20to=20i?= =?UTF-8?q?nstall.sh,=20or=20to=20any=20other=20test=20file.=20Layer:=20ap?= =?UTF-8?q?p=5Fregression=20Feature=20state:=20active=20Files:=20-=20tests?= =?UTF-8?q?/test=5Finstall=5Feffective.py=20Change=20types:=20-=20tests/te?= =?UTF-8?q?st=5Finstall=5Feffective.py:=20modify=20Acceptance=20criteria:?= =?UTF-8?q?=20-=20`python3=20-m=20unittest=20discover=20-s=20tests=20-p=20?= =?UTF-8?q?'test=5Finstall=5Feffective*'=20-v`=20exits=200=20and=20runs=20?= =?UTF-8?q?the=20new=20cases.=20-=20`python3=20scripts/check=5Fno=5Fnew=5F?= =?UTF-8?q?comments.py=20--base=20origin/main`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: d9859789-e795-42e7-a22b-1d123a452446 From a750e8ca675f2268668ccba270afd374b820e467 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:15:59 +0000 Subject: [PATCH 5/6] =?UTF-8?q?invoker:=20wf-1789196402073-11/verify-regis?= =?UTF-8?q?tration-check=20=E2=80=94=20Review=20claim:=20the=20installer-v?= =?UTF-8?q?erifier=20tests,=20including=20the=20new=20registration=20and?= =?UTF-8?q?=20generated-directory=20cases,=20pass.=20Review=20lane:=20proo?= =?UTF-8?q?f=20Safety=20invariant:=20Verification=20is=20read-only=20and?= =?UTF-8?q?=20changes=20no=20file.=20Effectiveness=20measurement:=20The=20?= =?UTF-8?q?unittest=20run=20is=20the=20measurement.=20Slice=20rationale:?= =?UTF-8?q?=20One=20proof=20for=20one=20claim.=20Architectural=20effect:?= =?UTF-8?q?=20None;=20verification=20only.=20Goal:=20Prove=20the=20gate's?= =?UTF-8?q?=20tests=20pass.=20Motivation:=20A=20gate=20with=20untested=20b?= =?UTF-8?q?ranches=20is=20how=20this=20defect=20survived.=20Alternative=20?= =?UTF-8?q?considerations:=20The=20whole=20suite=20was=20rejected=20as=20s?= =?UTF-8?q?lower=20without=20adding=20evidence=20for=20this=20claim.=20Imp?= =?UTF-8?q?lementation=20details:=20Run=20the=20installer-verifier=20tests?= =?UTF-8?q?.=20Non-goals:=20No=20edits.=20Layer:=20app=5Fregression=20Feat?= =?UTF-8?q?ure=20state:=20active=20Acceptance=20criteria:=20-=20Exits=200?= =?UTF-8?q?=20only=20when=20every=20test=20in=20that=20file=20passes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: f7f3f5df-dab1-4b09-bb8b-a1b062807446 From f97bbcd00f0c858e55ef49ee075ba1eea99b7b00 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 07:16:46 +0000 Subject: [PATCH 6/6] =?UTF-8?q?invoker:=20wf-1789196402073-11/scrub-handof?= =?UTF-8?q?f-artifacts=20=E2=80=94=20Review=20claim:=20no=20ephemeral=20ha?= =?UTF-8?q?ndoff=20files=20remain=20in=20the=20worktree.=20Review=20lane:?= =?UTF-8?q?=20cleanup=20Safety=20invariant:=20The=20scrub=20script=20only?= =?UTF-8?q?=20checks=20for=20known=20handoff=20artifact=20names=20and=20ne?= =?UTF-8?q?ver=20touches=20source=20or=20tests.=20Effectiveness=20measurem?= =?UTF-8?q?ent:=20The=20script=20exits=20non-zero=20if=20any=20handoff=20a?= =?UTF-8?q?rtifact=20remains.=20Slice=20rationale:=20Required=20terminal?= =?UTF-8?q?=20scrub=20for=20every=20implementation=20workflow.=20Architect?= =?UTF-8?q?ural=20effect:=20None;=20hygiene=20only.=20Goal:=20Leave=20the?= =?UTF-8?q?=20branch=20free=20of=20handoff=20artifacts.=20Motivation:=20Ha?= =?UTF-8?q?ndoff=20files=20must=20not=20reach=20the=20PR.=20Alternative=20?= =?UTF-8?q?considerations:=20Manual=20cleanup=20was=20rejected=20as=20non-?= =?UTF-8?q?deterministic.=20Implementation=20details:=20Run=20scripts/scru?= =?UTF-8?q?b-handoff-artifacts.sh.=20Non-goals:=20No=20product=20edits.=20?= =?UTF-8?q?Layer:=20app=5Fregression=20Feature=20state:=20active=20Accepta?= =?UTF-8?q?nce=20criteria:=20-=20`bash=20scripts/scrub-handoff-artifacts.s?= =?UTF-8?q?h`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: ec522a8e-4769-4249-b0d0-d439b832abb6