Playbook — Authoring a Hook in src/core/hooks/
P: Step-by-step guide for adding, renaming, or removing a hook in the meta-repo's hook regime. R: Adding a PreToolUse / PostToolUse / SessionStart / Stop / UserPromptSubmit hook, or extending an existing one with a new event/matcher. S: Configuring a hook in a single consumer project — that is a settings.json change, not a hook authoring task. N: registry.yaml, hooks-reference.md, adapter-parity.md, bash-heredoc-deadlock.md
Nav: Section Index | Docs Index
When to use this playbook
Any time you create, rename, or restructure a hook script under src/core/hooks/, register a new entry in src/core/hooks/registry.yaml, or modify the helper modules under src/core/hooks/_helpers/.
The model
A hook is a single script that runs synchronously between the agent and the kernel. The hook regime in this repo has four invariants:
registry.yamlis the SSOT. Every hook entry lists its script, category, phase, timeout, and the(event, matcher)pairs it fires on. Adapter templates are GENERATED from this file viamake regen-adapter-templates— never hand-editsrc/adapters/*/settings.template.json.- Hooks source
cos-env.sh. That helper resolves$COS_AGENT_DIR,$COS_STATE_DIR, and$COS_DB_PATHconsistently across Claude / Codex and exposescos_log_hookfor structured logging. - Block vs warn is explicit. A
BLOCKhook prints to stderr and exits non-zero — the agent's tool call is rejected. Awarnhook prints to stderr and exits zero — the agent sees the message but proceeds. Mixing the two breaks the contract. - Adapter capabilities clip the registry. Codex doesn't fire
Write|Editmatchers. The renderer filters every(event, matcher)againstsrc/adapters/<id>/adapter.yaml::hook_capabilities. A registry entry with no capable adapter is fine — it's documented intent — but it shouldn't claim coverage it can't deliver.
Steps
- Decide block vs warn. Block when the action would corrupt state or violate a hard rule. Warn when the action is suspect but recoverable. If unsure, start with warn and promote later if the misuse rate justifies it.
- Write the script. Bash for fast / shell-glue work, Python via
src/core/hooks/_helpers/for anything with logic, JSON parsing, or DB reads. - Use the safe pattern. Source
cos-env.sh. Read stdin viaread -r INPUT. Parse withjqor Python — never withawkon the JSON envelope. Avoid heredocs in the script body — see bash-heredoc-deadlock.md for the upstream bash 5.3.9 deadlock that bit us. - Log via
cos_log_hook. Format:cos_log_hook <hook-id> <verb> "key=value key=value". Canonical verb vocabulary (use these instead of the raw event name likePreToolUse):- Lifecycle:
entry(hook started — emit only when the hook is actually going to do work, not before the first guard),dispatched(async work kicked off),spawned(subprocess started). - Outcome:
fire(main path executed),ok/pass/allowed(passed clean),block(refused write/exec),warn(non-blocking warning),advisory(gentle nudge),reminder(info nudge). - Skip reasons:
skip(debounced / sanity / missing-dep),disabled(env-var off),debounced, plus domain-specific tokens likenon-rename,no-strings,unchanged. The UI palette in HookStream.tsx colorsfire/block/warn/skip/pass/stale-gatespecifically; everything else renders neutral gray. Deferred-entry pattern (signal-to-noise): if the hook bails on >90% of invocations (typical forBashmatchers that only care about specific commands), defer theentrylog until after the first guard passes. Examples in the wild: auto-graph-reconcile-shell.sh, search-verify-remaining.sh, enforce-graph-context.sh.
- Lifecycle:
- Register in
registry.yaml. One row per hook, with description, category, phase, timeout, and theevents:list. Each event entry pairs anevent(PreToolUse / PostToolUse / SessionStart / UserPromptSubmit / Stop) with amatcher(Bash, Write|Edit, Skill, startup, compact|resume, etc.) and an optionalstatus_message. - Regenerate adapter templates.
make regen-adapter-templates. Verify the diff insrc/adapters/claude/settings.template.json(and codex) matches your intent. - Add a test if behavior is non-trivial.
tests/test_hook_<name>.pyor extendtests/test_hook_registry_integration.py. - Verify shell syntax.
make verify-hooksrunsbash -non every script undersrc/core/hooks/.
Acceptance
- The script exits 0 on success / warn and non-zero on block.
make verify-hookspasses.make regen-adapter-templatesproduces a clean diff.- The hook fires on the intended event for the intended adapter, and is silently dropped on adapters that lack the capability.
- The hook never spawns long-running children (no
waiton a backgrounded process). Hooks are synchronous; offload async work to a fire-and-forget Python helper.
Rollback
Hook changes propagate to consumer projects via live symlinks. To roll back: revert the commit and run cos sync-all in any consumer that pulled the change. The rendered src/adapters/<id>/settings.template.json is regenerated; consumer-side overrides in <project>/.claude/settings.json are not touched.
Anti-patterns
- Editing
src/adapters/*/settings.template.jsonby hand. The nextmake regen-adapter-templateswill overwrite it. - A block hook with a verbose multi-line error message. Agents truncate; one terse line plus a doc link is the right shape.
- Calling
cos_log_hookwith unstructured prose ("hook ran"). Always usekey=valueso the log viewer can filter. - A hook that reads a DB row inside a loop. Pre-fetch outside, or call a single
cos_*tool that does the work server-side. - A hook with no timeout. The default 1500 ms is the right ceiling for a synchronous step; if more is needed, change the design.