Skip to content

hook-dispatch

Provenance

  • Source: .spec/spexcode/spec-cli/sessions/injected-context/hook-dispatch/spec.md
  • Source SHA-256: 2c9712432ce6f3be6480320de6ecabfc9f7fe4b0278181d15d57f3e037c07973

hook-dispatch

raw source

A launched agent's lifecycle hooks are not wired harness-by-harness in code; they are discovered from the spec tree and delivered through one stable mechanism that works the same on Claude Code and Codex. Three parts: the handlers are surface: hook nodes (each a co-located script declaring the events it binds, an order, and whether it may block) — the spec-governed content, discovered recursively under the config roots. A compiler flattens them into a flat manifest (event · order · block · script), written PERSISTENTLY into the materialized tree's OWN slot — <runtime>/trees/<enc-worktree>/hooks-manifest under the per-project GLOBAL store ([[runtime]]; shell mirror hp_tree_dir), NOT the worktree, so materializing leaves zero SpexCode runtime in the tree. The slot is PER WORKTREE because the manifest is a pure function of the .plugins content of the tree that materialized it: the old single global file made the LAST materialize win, so when two trees' .plugins diverged (any node branch editing .plugins vs main), dispatch ran tree A's compiled hook set inside tree B's sessions — cross-session hook bleed. The slot key is the sessions-store encodeProject transform applied to the worktree's rev-parse --show-toplevel; the dispatcher derives it from its own cwd, so a dispatch can only ever read the manifest of the tree it fires in. The dispatcher (dispatch.sh, the one shim entry per event) does exactly ONE job: it dispatches the event's handlers from the persistent manifest. It is deliberately NOT a materialize trigger — the old content-hash gate (an auto-spex materialize on every event when the config fingerprint moved, serialized by a mkdir mutex) is RETIRED ([[commit-surgery]]): a harness event never materializes. The manifest and every other artifact refresh at the git-native anchors (the spex verbs, session-worktree creation, the pre-commit / post-checkout / post-merge hooks), which keeps the hook hot path pure bash with zero node boots and makes .plugins edits git-transactional — they take effect at the commit/checkout/merge that carries them, like any other source change.

Migration window (pre-slot trees). A worktree last materialized by a pre-slot toolchain has no slot until its next git-native anchor plants one. A slot-less dispatch FALLS BACK to the legacy global <runtime>/hooks-manifest — the very file (and one-slot semantics) it read before the migration — so no hook, the Stop gate included, silently no-ops in the window. The legacy file is never written again: the tree's next anchor plants its slot, the fallback goes dead, and the stale file is residue until [[spex-uninstall]]'s whole-store sweep. An explicit SPEX_HOOK_MANIFEST override skips both lookups.

[[dispatcher-runtime]] owns the shell entry that executes this compiled manifest. The dispatcher reproduces the native multi-hook contract — which on BOTH harnesses runs matching hooks in parallel with no ordering guarantee — but deterministically: it feeds each handler the original hook stdin, runs them all in manifest order so every side effect is preserved, concatenates their stdout (block decisions / additionalContext) through, and exits 2 when a handler declared block: true and either exited 2 OR emitted a {"decision":"block", ...} JSON decision. That exit code is the signal both harnesses propagate back to the model; the stdout JSON is the reason/additionalContext payload Claude reads. Codex, however, reads a Stop block's continuation prompt from STDERR — so on the JSON-decision path under codex, when the handler wrote its decision:block to stdout and left stderr empty, the dispatcher extracts the reason and forwards it to stderr; else codex would see exit 2 with no continuation. A handler that did not declare blocking can never block its event; a missing manifest dispatches nothing.

This is the substrate the spec-aware injections ([[inject-spec-first]], [[inject-spec-of-file]]) and the lifecycle gates ride on. Which nodes plug in is a [[surface]] field decision, not a code change here; adding or retiring a hook is a spec edit. The contract text (the surface: system bodies) is materialized by the same pass into the AGENTS.md/CLAUDE.md block ([[harness-delivery]]); only the event HOOKS converge through this dispatcher.