The hooks in the harness

Hooks

Four hooks are registered in harness/.claude/settings.json, and the git guard is registered again in harness/.codex/hooks.json. The principle behind all four is that the hook is the second layer and the contract is the first. A rule that matters has to live in the contract, because the contract is read on every runtime, while a hook can be disabled, can be untrusted, and can be bolted to an execution path nobody walks through.

That last case is not hypothetical. One of the four below is dormant, I measured it, and I am leaving it here rather than quietly removing it.

PreToolUse · Bash

scripts/hook-git-guard.py

Fires when
Before every Bash command, while permission is still being decided.
Blocks or logs
Tokenises argv and denies the git commands that destroy someone else’s work. It only denies; it never acts.
The Gate Before Every Bash Call A workflow diagram generated by Archify. 01 / Every Bash Call git guard decision Bash command · any tool call · Every Bash Call Bash command any tool call PreToolUse · fires before the call · Every Bash Call PreToolUse fires before the call hook-git-guard.py · allow or block? · Every Bash Call › git guard decision hook-git-guard.py allow or block? Block · stderr + logged, no run · Every Bash Call › git guard decision Block stderr + logged, no run Command runs · unchanged · Every Bash Call › git guard decision Command runs unchanged runs the guard script dangerous pattern clean Legend User UI Agent logic Policy
Explore ↗opens in a new tab

It fires before an agent runs the Bash tool, on both Claude Code and Codex, and it runs scripts/hook-git-guard.py.

The script reads the command about to run and does exactly one of two things: deny, naming the exact command to run instead, or say nothing. It never acts itself. The first version ran git worktree prune and killed brokers from inside the hook, and that was wrong in principle. A PreToolUse hook runs while permission is still being decided, so anything it changes is a side effect of a command that may yet be refused.

The script tokenises and inspects argv rather than matching regexes against the raw command string. A cross-vendor pass proved the regex version was bypassable and over-broad in the same breath: /usr/bin/git add -A and git -c k=v add -A slipped through, while printf '%s' "rm -rf .claude/worktrees/x" was denied for containing the words. A guard that misses the real case and blocks the harmless one teaches its own operator to route around it, which is worse than no guard.

I want to be exact about the limit. This is an accidental-misuse lint, not a boundary. Three adversarial gates each found a fresh way through, and the third concluded the matcher was not converging. The answer was to shrink its claim rather than grow its rules. It now recognises one shape, simple commands separated by unquoted operators, and stays silent on anything containing a substitution, heredoc, comment, reserved word, wrapper or interpreter. Silence there is the design, not a gap: a coverage claim that is not true is worse than no claim.

There is also a reason it is a .py file rather than a shell line crammed into settings.json. The old version was exactly that line: unreadable, unrunnable by hand, untestable, and it sat dormant across releases while looking installed (AST-102). This one runs standalone, which means it can be tested.

harness/scripts/hook-git-guard.py ↗AST-102WorktreeRemove hook does not fire, but documentation declares manual cleanup redundant

WorktreeRemove

scripts/release-worktree-resources.sh

Fires when
When a worktree is removed.
Blocks or logs
Reaps the processes rooted in the worktree, calls the project’s own release plug if it has one, and logs a line.
Worktree Cleanup on Teardown A workflow diagram generated by Archify. 01 / Worktree Cleanup WorktreeRemove · fires on worktree teardown · Worktree Cleanup WorktreeRemove fires on worktree teardown release-worktree-resources.sh · frees pane + processes · Worktree Cleanup release-worktree-resources.sh frees pane + processes runs the release script Legend Agent logic
Explore ↗opens in a new tab

It fires when a worktree is removed through the EnterWorktree / ExitWorktree tool path, and it runs scripts/release-worktree-resources.sh with $WORKTREE_PATH.

The script releases what a worktree allocated, in order. First the harness’s own part: processes whose real cwd is inside the worktree, reaped by reap-worktree-processes.sh. Then the part the project declares, through the plug at .astraler/project/cleanup-worktree.sh. That order is load-bearing. Resources bound to a directory, by cwd or by a label derived from the path or by a name the project computed from it, cannot be matched once the directory is gone, so this has to run before git worktree remove and never after (AST-100, AST-101).

The next part I have to say straight: this hook is dormant. Measured 2026-08-20 with the very logging I added to answer the question. Three worktrees were removed after the log’s last write, one of them by a plain git worktree remove, and the number of WorktreeRemove events recorded was zero. Meanwhile the SubagentStop hook in the same file and the same session logged 27 events in that same window. I confirmed it a second, independent way: the shared test container was still Up (healthy) after the removal, which a live hook would have stopped.

The cause is not that the hook is broken, it is that the hook is never reached. WorktreeRemove hangs off the EnterWorktree / ExitWorktree tool path, and Thomas removes worktrees with git worktree remove in a Bash call. That is ordinary git, with nothing standing between the command and the repo, so no event exists to fire. A hook bolted to an execution path nobody walks through fires exactly as often as a broken one, and from the outside the two are indistinguishable.

So manual cleanup remains required on every runtime, and the hook’s command has to be kept safe against the day it wakes (AST-115). The lesson outlives this particular hook: when a mechanism does not fire, ask whether the trigger was reached before concluding the mechanism is broken.

harness/scripts/release-worktree-resources.sh ↗AST-100Codex companion broker leaks one process per arm pass, accumulating silently

SessionStart · compact

scripts/hook-contract-reload.py

Fires when
Right after the context is compacted.
Blocks or logs
Re-arms the role contract, because a contract read with the Read tool is what compaction drops first.
Session Resume After Compact A workflow diagram generated by Archify. 01 / Session Resume SessionStart · matcher: compact · Session Resume SessionStart matcher: compact hook-contract-reload.py · re-arms the role contract · Session Resume hook-contract-reload.py re-arms the role contract reloads the contract Legend Agent logic
Explore ↗opens in a new tab

It fires immediately after a Claude Code session compacts, with source: compact, and it runs scripts/hook-contract-reload.py.

The script re-arms exactly one thing: the path to the running role’s contract, delivered through hookSpecificOutput.additionalContext, reaching the agent before it acts.

The failure behind it is AST-069, and the one-line form is that an instruction with no moment attached measures zero. The system prompt already says “Read .agents/roles/<role>.md now”, and that line comes through compaction untouched because it is the system prompt. What does not come through is the word “now”. A compacted agent reads its own summary, sees work underway, concludes it is mid-session, and never re-reads. The instruction is present and inert.

Two details are deliberate. It fires on compact and not on clear, because after /clear an agent faces an empty context and reads its contract unprompted, while after compaction it faces a summary insisting work is underway and does not. That asymmetry is the entire defect. And it is Claude Code only, because Codex and OpenCode have no compaction, so there is no moment to attach to.

Every exit path in the script is either valid JSON on stdout or nothing at all, and none of them raise. A traceback on stderr would be read by the runtime as a broken hook and disabled, and the operator would carry on shipping a harness that looks armed.

harness/scripts/hook-contract-reload.py ↗AST-069An instruction with no moment attached measures zero

SubagentStop

settings.json

Fires when
When a subagent stops.
Blocks or logs
Logs the agent type, id and session to /tmp/harness-hook-events.log. It blocks nothing.
Logging Every Subagent Exit A workflow diagram generated by Archify. 01 / Subagent Exit SubagentStop · fires on every agent exit · Subagent Exit SubagentStop fires on every agent exit harness-hook-events.log · records the exit · Subagent Exit harness-hook-events.log records the exit appends one log line Legend Agent logic Context / trace
Explore ↗opens in a new tab

It fires every time a subagent ends. There is no script of its own: the hook reads JSON from stdin and appends a line to /tmp/harness-hook-events.log.

It records agent_type, agent_id, session_id and the payload size. That is all, and it blocks nothing. The first version read the environment variables $AGENT_NAME and $SESSION_ID and got empty strings back. The current one reads stdin and takes the right fields.

It is the cheapest of the four, and it returned the most value in a place nobody designed for. It was the control group. The 27 events it logged in that window are what turned “nothing was observed” into evidence that WorktreeRemove was dormant. Without them the same observation would have been indistinguishable from “hooks are off entirely”, and the first conclusion, that the hook was broken, was reached without any control group at all.

Beyond that, it records the moment a Builder dies, which without a log shows up only as a silent pane.

AST-102WorktreeRemove hook does not fire, but documentation declares manual cleanup redundant