ADR 0040: The worktree hooks parse their payload in the binary (no jq)
Amendments.
- Vocabulary: current pointers use
standards(formerlyratchets),accept(formerlygraduate), and known/customjob(formerly gatecapability/ customcheck); the decision and reasoning are unchanged.- ADR 0052 — placement: the
<cwd>/.claude/worktrees/<name>placement this record encodes was later replaced by a configurable sibling default ([worktree].root); the layering split below — adapter in the feature layer, engine location-agnostic — is exactly what kept that change contained to one resolver.- Hook naming: the verbs ship as
discern worktree hook create/discern worktree hook remove— the shipped settings template is the live authority; the parse-in-binary decision stands.- Vendor boundary (2026-09-04): Claude Code still supplies
WorktreeCreateandWorktreeRemoveJSON with no version field. The adapters require the current string fields, ignore unknown fields, add no private payload version or aliases, refuse malformed create loudly, and keep removal best effort. The provider registry renders the separate 600-second hook budget.
Status: accepted
Context
§discern's isolated-worktree workflow is driven by three Claude Code hooks wired into .claude/settings.json. Two of them — WorktreeCreate and WorktreeRemove — receive a JSON payload on stdin ({name, cwd} and {worktree_path} respectively). The original hooks were raw shell one-liners embedded in the settings JSON: each piped the payload through jq to extract its fields, and WorktreeCreate additionally ran git worktree add inline before handing off to discern worktree setup.
input=$(cat); name=$(printf %s "$input" | jq -r .name); cwd=$(... jq -r .cwd)
dir="$cwd/.claude/worktrees/$name"
if [ ! -e "$dir/.git" ]; then git -C "$cwd" worktree add "$dir" -b "agent/$name" 1>&2; fi
(cd "$dir" && discern worktree setup 1>&2); printf %s "$dir"
That made jq a hard runtime dependency of discern for every end user who drives the worktree workflow through a coding agent — a second tool to install and a second thing to go wrong, surfacing only at hook-fire time. The dependency was not fundamental: it existed only because a settings-JSON string cannot parse JSON on its own. But the program the hook shells into — discern — is a JSON-native binary that is already invoked by the same hook. The worktree-creation and field-parsing logic also lived as an untested shell string, split awkwardly between the hook (git worktree add) and the binary (via discern worktree setup).
Decision
§The two payload-bearing hooks are thin dispatches to the binary, which reads its own stdin: WorktreeCreate → discern worktree hook create, WorktreeRemove → discern worktree hook remove. Each verb reads the hook's JSON payload from stdin, parses it natively (no jq), and does the work the shell used to:
worktree hook createrequires non-empty stringnameandcwdfields, derives<resolveWorktreeRoot(cwd, config)>/<name>and branch<branch_prefix><name>, creates the linked worktree idempotently, runs worktree setup inside it, and writes only the worktree path to stdout. Malformed JSON or a missing required field fails clearly. All setup narration goes to stderr.worktree hook removereads the stringworktree_pathand runs worktree teardown. A missing field, malformed payload, or teardown failure is reported but never fails the vendor removal event; a stranded resource is reclaimed later byworktree prune.
Both adapters accept harmless unknown object fields because the vendor payload has no version field and may grow. discern invents no payload-version flag or speculative alias. Hook command, event, shape, and timeout come from the provider registry; every emitted Claude hook carries the explicit 600-second vendor-unit budget.
Layering. The adapter lives in the FEATURE layer (src/lib/worktree_hooks.ts), not the stack-neutral engine. The agent-agnosticism guard (tests/agent_agnostic_test.ts) forbids any .claude path in src/engine/**; the .claude/worktrees convention and the coupling to Claude Code's hook JSON shape are Claude-Code-specific, so they sit beside src/lib/skills.ts (which already owns .claude/skills materialization). The engine keeps a convention-free addWorktree(mainRepo, dir, branch) git helper that the adapter calls with an explicit directory.
git stays a hard requirement and is now asserted. git is irreducible to the worktree workflow, standards, acceptance, and status. discern doctor gained an explicit git check (it previously verified only sh and configured job commands), reporting the resolved git --version as triage context, plus an environment summary line (discern version · os/arch · git) for bug reports.
Consequences
§jqis no longer a discern dependency — for end users or for developing discern. Its entire footprint was these two hooks; the contributorBrewfileand the human-setup docs drop it, leavinggit(+ a coding agent) as the only external runtime requirement. The engine-hook tests no longer skip whenjqis absent, and assert the rendered.claude/settings.jsoncontains nojq.- The hook contract is small and explicit. The settings entries carry fixed verb names; the vendor's current fields are parsed in tested TypeScript (
tests/engine_hooks_test.tsdrives the rendered hooks end-to-end), not in a JSON-escaped shell string. The{{branch_prefix}}token is gone from the settings template. - Single source of truth for worktree creation. The
git worktree addstep moved out of the shell and into the binary, beside the setup it precedes. - A new coupling is made explicit, not added. The binary now knows Claude Code's hook JSON shape — but the shell hook already hard-coded
.name/.cwd/.worktree_path; the coupling moved into one tested, clearly-named module rather than being newly introduced.
Alternatives considered
§- Keep
jq, and havedoctorrequire it. Surfaces the dependency instead of removing it — strictly more for every user to install and maintain, for a need that the already-invoked binary erases. Rejected. - Write a
jq-free shell fallback in the hook (e.g.sed/grepJSON scraping). Trades one fragile, untested shell string for a worse one; brittle on arbitrary JSON. Rejected. - Put the adapter in the engine. Fails the agent-agnosticism guard (the engine builds no
.claudepath) and would entangle the stack-neutral core with one agent's hook contract. Rejected in favour of the feature layer.