From 0fc2a139aff1a2cd81b0d73a3ab8d4bce2897914 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Fri, 11 Sep 2026 23:04:54 -0700 Subject: [PATCH] fix(hooks): wire unverified-tag-ledger into settings and declare its subagent opt-out CI caught the hook shipping unwired. Two repo gates failed on it: FAIL: test_every_claude_hook_entrypoint_is_in_settings AssertionError: Lists differ: ['unverified-tag-ledger/claude_prompt_reminder.py'] != [] FAIL: test_every_stop_hook_is_mirrored_or_opted_out_with_reason (hook='unverified-tag-ledger', command='.../claude_stop_check.py') The hook directory had both fragments but no install_claude_hook.py, install.sh never called one, and claude.hook.json declared no subagent_stop stance -- so the Stop and UserPromptSubmit entries never reached ~/.claude/settings.json. Adds the installer on diu-stop's pattern (same idempotent per-hook-type merge, since this hook also owns one Stop and one UserPromptSubmit fragment), calls it from install.sh next to diu-stop's, and opts the Stop hook out of subagent mirroring with a reason: the ledger is keyed by session id, so a subagent would log its tags against the parent's ledger, and a subagent has no next user prompt to be reminded at. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018u8S5ct3kFhosinSbybc7W Change-Id: Id4d2ab4e768ee3ef0679a5fb382e8901534de171 --- .../unverified-tag-ledger/claude.hook.json | 4 + .../install_claude_hook.py | 74 +++++++++++++++++++ install.sh | 1 + 3 files changed, 79 insertions(+) create mode 100644 engine/hooks/unverified-tag-ledger/install_claude_hook.py diff --git a/engine/hooks/unverified-tag-ledger/claude.hook.json b/engine/hooks/unverified-tag-ledger/claude.hook.json index fc42fd5..800014f 100644 --- a/engine/hooks/unverified-tag-ledger/claude.hook.json +++ b/engine/hooks/unverified-tag-ledger/claude.hook.json @@ -12,5 +12,9 @@ ] } ] + }, + "subagent_stop": { + "inherit": false, + "reason": "the ledger and its next-prompt reminder belong to the session owner; a subagent shares the parent's session id and would log its own tags against the parent's ledger, and it has no next user prompt to be reminded at" } } diff --git a/engine/hooks/unverified-tag-ledger/install_claude_hook.py b/engine/hooks/unverified-tag-ledger/install_claude_hook.py new file mode 100644 index 0000000..0986c73 --- /dev/null +++ b/engine/hooks/unverified-tag-ledger/install_claude_hook.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Idempotently merge unverified-tag-ledger's Claude Code hooks into +~/.claude/settings.json: the Stop hook (records tags and refuses a turn that +tagged without checking) and the UserPromptSubmit hook (re-surfaces claims +earlier turns deferred and never settled). + +Safe to rerun on every `install.sh`: each hook type is identified by whether +any of its entries' "command" mentions that hook's own marker script, +replaces just that hook type's unverified-tag-ledger entry with the current fragment +file's content, and leaves every other key in settings.json (model, theme, +other hooks, other hook types, ...) untouched. That means editing either +fragment file and rerunning install.sh converges cleanly instead of +appending a duplicate entry each time. +""" +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json") + +# (hook type in settings.json, marker script identifying "our" entry, fragment file) +HOOK_SPECS = [ + ("Stop", "claude_stop_check.py", os.path.join(HERE, "claude.hook.json")), + ("UserPromptSubmit", "claude_prompt_reminder.py", os.path.join(HERE, "claude.prompt.hook.json")), +] + + +def _is_ours(entry, marker): + return any(marker in h.get("command", "") for h in entry.get("hooks", [])) + + +def merge_hook(settings, hook_type, marker, fragment): + """Pure: returns (new_settings, changed). Replaces any existing + unverified-tag-ledger entry of this hook type with fragment's, appends if none + existed yet.""" + settings = json.loads(json.dumps(settings)) # deep copy, no external dep + entry_list = settings.setdefault("hooks", {}).setdefault(hook_type, []) + new_entries = fragment["hooks"][hook_type] + + before = json.dumps(entry_list, sort_keys=True) + entry_list[:] = [e for e in entry_list if not _is_ours(e, marker)] + new_entries + changed = json.dumps(entry_list, sort_keys=True) != before + return settings, changed + + +def main(): + settings = {} + if os.path.exists(SETTINGS_PATH): + with open(SETTINGS_PATH) as f: + settings = json.load(f) + + any_changed = False + for hook_type, marker, fragment_path in HOOK_SPECS: + with open(fragment_path) as f: + fragment = json.load(f) + settings, changed = merge_hook(settings, hook_type, marker, fragment) + if changed: + any_changed = True + print(f"link claude {hook_type} hook merged into settings.json") + else: + print(f"ok claude {hook_type} hook already up to date") + + if not any_changed: + return + + os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True) + with open(SETTINGS_PATH, "w") as f: + json.dump(settings, f, indent=2) + f.write("\n") + print(" (restart Claude Code to pick up the change)") + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh index c1360a0..a8acfac 100755 --- a/install.sh +++ b/install.sh @@ -335,6 +335,7 @@ fi # either file. See each script's docstring for exactly what it does. echo "--- claude Stop + UserPromptSubmit hooks (\$HOME/.claude/settings.json) ---" python3 "$REPO_DIR/engine/hooks/diu-stop/install_claude_hook.py" +python3 "$REPO_DIR/engine/hooks/unverified-tag-ledger/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/bug-complaint-leak/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/reflect-on-thrash/install_claude_hook.py" python3 "$REPO_DIR/engine/hooks/scope-lock/install_claude_hook.py"