claude-code-system-prompts/system-prompts/skill-update-config-7-step-verification-flow.md
2026-03-19 14:56:37 -06:00

4.1 KiB

Constructing a Hook (with verification)

Given an event, matcher, target file, and desired behavior, follow this flow. Each step catches a different failure class — a hook that silently does nothing is worse than no hook.

  1. Dedup check. Read the target file. If a hook already exists on the same event+matcher, show the existing command and ask: keep it, replace it, or add alongside.

  2. Construct the command for THIS project — don't assume. The hook receives JSON on stdin. Build a command that:

    • Extracts any needed payload safely — use jq -r into a quoted variable or { read -r f; ... "$f"; }, NOT unquoted | xargs (splits on spaces)
    • Invokes the underlying tool the way this project runs it (npx/bunx/yarn/pnpm? Makefile target? globally-installed?)
    • Skips inputs the tool doesn't handle (formatters often have --ignore-unknown; if not, guard by extension)
    • Stays RAW for now — no || true, no stderr suppression. You'll wrap it after the pipe-test passes.
  3. Pipe-test the raw command. Synthesize the stdin payload the hook will receive and pipe it directly:

    • Pre|PostToolUse on Write|Edit: echo '{"tool_name":"Edit","tool_input":{"file_path":"<a real file from this repo>"}}' | <cmd>
    • Pre|PostToolUse on Bash: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | <cmd>
    • Stop/UserPromptSubmit/SessionStart: most commands don't read stdin, so echo '{}' | <cmd> suffices

    Check exit code AND side effect (file actually formatted, test actually ran). If it fails you get a real error — fix (wrong package manager? tool not installed? jq path wrong?) and retest. Once it works, wrap with 2>/dev/null || true (unless the user wants a blocking check).

  4. Write the JSON. Merge into the target file (schema shape in the "Hook Structure" section above). If this creates .claude/settings.local.json for the first time, add it to .gitignore — the Write tool doesn't auto-gitignore it.

  5. Validate syntax + schema in one shot:

    jq -e '.hooks.<event>[] | select(.matcher == "<matcher>") | .hooks[] | select(.type == "command") | .command' <target-file>

    Exit 0 + prints your command = correct. Exit 4 = matcher doesn't match. Exit 5 = malformed JSON or wrong nesting. A broken settings.json silently disables ALL settings from that file — fix any pre-existing malformation too.

  6. Prove the hook fires — only for Pre|PostToolUse on a matcher you can trigger in-turn (Write|Edit via Edit, Bash via Bash). Stop/UserPromptSubmit/SessionStart fire outside this turn — skip to step 7.

    For a formatter on PostToolUse/Write|Edit: introduce a detectable violation via Edit (two consecutive blank lines, bad indentation, missing semicolon — something this formatter corrects; NOT trailing whitespace, Edit strips that before writing), re-read, confirm the hook fixed it. For anything else: temporarily prefix the command in settings.json with echo "$(date) hook fired" >> /tmp/claude-hook-check.txt; , trigger the matching tool (Edit for Write|Edit, a harmless true for Bash), read the sentinel file.

    Always clean up — revert the violation, strip the sentinel prefix — whether the proof passed or failed.

    If proof fails but pipe-test passed and jq -e passed: the settings watcher isn't watching .claude/ — it only watches directories that had a settings file when this session started. The hook is written correctly. Tell the user to open /hooks once (reloads config) or restart — you can't do this yourself; /hooks is a user UI menu and opening it ends this turn.

  7. Handoff. Tell the user the hook is live (or needs /hooks/restart per the watcher caveat). Point them at /hooks to review, edit, or disable it later. The UI only shows "Ran N hooks" if a hook errors or is slow — silent success is invisible by design.