Hooks
How AOForge turns advisory rules into enforced ones — and every documented way out.
Hooks run in a separate process, receive the tool call as JSON on stdin, and can inject context, warn, or block execution outright. They are the difference between a convention and a rule.
All hooks are declared in plugins/aoforge/hooks/hooks.json and registered
automatically when the plugin is enabled. Every command uses
${CLAUDE_PLUGIN_ROOT} for path resolution.
Enforcement — these can block
| Hook | Event | What it does | Escape hatch |
|---|---|---|---|
auto-continue.js | Stop | Blocks the main loop once when it ends a turn right after announcing its own next step (“Writing the predicate.”, “Ready for wave 4 on your word.”): a skill marker must be live, no background task may be running, and the last sentence must be an announcement. Questions, ask phrases, /aoforge: hand-offs, waits and result reports never fire it. The reason tells the model to take the announced step now, or ask one explicit question. Never blocks on the re-stop (stop_hook_active) or outside an AOForge project; fails open. | AOFORGE_SKIP_AUTOCONTINUE=1 |
changelog-on-tag.js | PreToolUse (Bash via pre-bash.js) | Blocks git tag -a vX.Y.Z unless CHANGELOG.md has a ## [X.Y.Z] heading and the three release manifests carry matching versions. | AOFORGE_SKIP_CHANGELOG_GATE=1 |
gate-bash-writes.js | PreToolUse (Bash via pre-bash.js) | Denies a Bash write to a tracked source file in ambient mode (redirect, tee, sed -i, perl -i, cp/mv, inline python/node), the same as Edit/Write. Commands that only mention a write (heredoc bodies, quoted arguments) and writes to .aoforge/, *.md, untracked files, paths outside the project and unresolvable targets are never gated. Same escapes as the Edit gate (live skill marker, aoforge:<name> agent, override phrase, which only a write that would be gated consumes). Severity is the least of gates.editGate and gates.bashEditGate (strict deny, warn ask, off). With no bashEditGate key the default is warn, measured at 633/17,957 = 0.035251 of ambient Bash calls (an upper bound, above the 0.02 threshold), so strict is opt-in; gates.editGate warn softens it to ask and off disables it. The env escape works only in the environment Claude Code was launched from, never as an inline command prefix. Needs an installed plugin carrying objective 60. | AOFORGE_SKIP_EDIT_GATE=1 |
gate-commits.js | PreToolUse (Bash via pre-bash.js) | Blocks raw git commit and redirects to aof-tools commit, which preserves objective scope and task IDs and updates STATE.md. Detection is invocation-aware: heredoc bodies and quoted arguments are stripped first, so prose that merely mentions the command is not gated. Merge, rebase and cherry-pick completions are allowed automatically (MERGE_HEAD, REBASE_HEAD, rebase-merge/, rebase-apply/ or CHERRY_PICK_HEAD in the target repo’s per-worktree git dir). From inside a session, the only in-command escape is an inline AOFORGE_ALLOW_RAW_COMMIT=1 git commit … prefix on every commit invocation: the hook decides before the command runs, so an export never reaches it. The env escape works only when set before Claude Code is launched. | AOFORGE_ALLOW_RAW_COMMIT=1 |
gate-edits.js | PreToolUse (Edit|Write|MultiEdit) | Strict DENY by default in ambient mode. Allows edits when a live .aoforge/.skill-active marker exists (resolved from both the local and the main checkout, so worktree-isolated agents are not denied by a marker they cannot see), when the PreToolUse payload’s agent_type is an AOForge agent (aoforge:<name>), when the prompt carries an override phrase, or when the env escape is set. Targets outside the project root are never gated. Severity is per-project via gates.editGate. Bash writes to the same files are gated by gate-bash-writes.js. | AOFORGE_SKIP_EDIT_GATE=1 |
gate-executor-stop.js | SubagentStop | Blocks an aoforge:executor subagent once when it stops naturally and its TRD has no <id>-SUMMARY.md in any checkout (the local tree, REPO_ROOT, main checkouts and one git worktree list). The TRD is identified from the first user prompt of the agent transcript (PLAN_ID: / exec-context check --id, then -TRD.md paths, then embedded frontmatter); an ambiguous or unidentifiable prompt is never blocked. The reason tells the executor to finish, or at least write the ## Progress checkpoint. Never blocks on the re-stop (stop_hook_active), outside an AOForge project, or after a deliberate checkpoint, escalation or preflight stop; fails open on any error. | AOFORGE_SKIP_EXECUTOR_STOP_GATE=1 |
gate-interactive.js | PreToolUse (Bash via pre-bash.js) | Intercepts TTY-requiring and shell-environment commands (auth logins, passphrase prompts, nvm use, direnv allow, …) and queues them to aoforge-watch with the session and agent that asked, rather than letting them hang on a prompt no one can answer. With the daemon running, Claude tells you in one line that the command waits in your aoforge-watch terminal, where you answer its prompt, and the result comes back by itself; without it, Claude asks you to run ! cmd. Detection is invocation-aware: each simple command is matched on its own words, so a quoted argument or heredoc body that mentions a gated command is data, while a heredoc fed to a shell is read as commands. Flags count per call, and a compound command’s reason names the call that matched. ssh-keygen read-only forms (-l, -F, -R) run directly; a key read with -y and no -P is handed off. The watch daemon’s built-in allowlist is the same matcher, so the daemon runs exactly what the hook hands off. Not matched: a quoted or path-qualified command name, a command behind env, time, sudo or a shell keyword, a $( ) inside double quotes, backticks and bash -c string bodies. | AOFORGE_SKIP_INTERACTIVE_GATE=1 |
gate-skill-requires.js | UserPromptExpansion PreToolUse (Skill) | Refuses to start a /aoforge:<skill> whose SKILL.md requires: names a tool that is not on PATH (today only /aoforge:gh-sync, which needs gh). A typed command is blocked on UserPromptExpansion and a Skill tool call is denied on PreToolUse, each with the install hint and a pointer to /aoforge:doctor (check skill-requires). Not project-scoped, and the PATH lookup only stats files, so nothing is spawned. Anything that is not an AOForge skill with a declared tool is allowed, and any error fails open. The env escape works only in the environment Claude Code was launched from. Needs an installed plugin carrying objective 61. | AOFORGE_SKIP_SKILL_REQUIRES=1 |
guard-no-progress.js | PreToolUse (^(?!Bash$).*) | Detects the same tool being called with identical arguments repeatedly: warns at 3, escalates to ask at 5, and resets whenever the agent varies its approach. Step limits cannot catch a stuck loop — they only fire once the whole budget is spent. | AOFORGE_SKIP_PROGRESS_GUARD=1 |
pre-bash.js | PreToolUse (Bash) | The PreToolUse(Bash) gates in one process: runs gate-commits, changelog-on-tag, gate-interactive, gate-bash-writes and guard-no-progress with the same stdin and merges their decisions (deny over ask, every reason kept), so a Bash call spawns one node instead of five. A hook that throws is skipped, never the event. | — |
route-intent.js | UserPromptSubmit | Matches the prompt against build/plan/verify/debug intent and injects a directive to route through the matching skill instead of editing code directly. Regexes require imperative form, so plain questions do not trip it. | — |
gate-edits is the one you will meet
In ambient mode — AOForge project detected, no skill running — gate-edits
denies Edit, Write and MultiEdit by default. That is intentional: it is
what forces work through skills instead of ad-hoc edits that leave no plan, no
summary and no atomic commits.
Four ways through it:
- Run a skill. Skills write
.aoforge/.skill-activeviaaof-tools skill-active --start, and the gate allows edits while that marker is live. Markers carry anexpires_at, 8 hours by default. - Use an override phrase in your prompt:
skip aoforge,just edit,bypass aoforge,force edit. Theroute-intenthook writes.aoforge/.edit-override, which this gate consumes — single-turn and TTL-bounded. - Set the env escape:
AOFORGE_SKIP_EDIT_GATE=1. - Lower the severity per-project in
.aoforge/config.json:
{ "gates": { "editGate": "strict" } } // strict (default) | warn | off
Two refinements worth knowing, because both were bugs once:
- The marker is resolved from both the local
.aoforge/and the main checkout’s, so worktree-isolated agents are not denied by a gitignored marker they could never see. - Targets outside the project root — the session scratchpad,
/private/tmp— are never gated.
gate-commits is invocation-aware
It blocks raw git commit and redirects to aof-tools commit, which preserves
objective scope and task IDs and updates STATE.md.
Detection is not a substring test. Heredoc bodies and quoted arguments are stripped
before matching, so a command that merely mentions git commit — writing docs
about it, for instance — is not gated.
guard-no-progress catches stuck loops
It fingerprints each tool call by name plus arguments. Three identical calls warn
on stderr; five escalate to an ask permission decision. Any variation in approach
resets the counter.
Step limits cannot do this. They fire only once the entire budget is spent, which catches an infinite loop but never a stuck one.
Deliberately not wired to tool errors
Tool errors run 3.6–4.3% at every model tier and are dominated by environment friction, not by model confusion. Escalating on them would fire constantly and mean nothing. The guard escalates on repetition, which is a real signal.Session context — these inject
| Hook | Event | What it does | Escape hatch |
|---|---|---|---|
awareness-cache-populate.js | SessionStart | Warms the cross-repo awareness cache in a detached child process. Never blocks session start, even when a scan takes 30s or more. | — |
classify-session.js | SessionStart | Classifies the project as ambient, init-offer, or skip and injects the routing decision table you see at session start. | AOFORGE_SKIP_CLASSIFY=1 |
coexistence-guard.js | SessionStart | Queues one notice per session for everything the rename left in your home that AOForge 3.1 no longer reads: the pre-rename plugin still enabled beside AOForge (its version and the exact claude plugin disable command, so the same gate never runs twice; a pointer release gets a softer line), pre-rename environment variables (each with its AOFORGE_* name), a pre-rename runtime home that 3.0.x never migrated, and files AOForge 3.0.0 left in ~/.aoforge/. Reports only: never edits settings, never moves a file, never blocks, fails open. | AOFORGE_SKIP_COEXISTENCE=1 |
inject-handoff-results.js | Stop PostToolUse (*) | Delivers a finished handoff-watcher result to the session and agent that queued it, exactly once, without waiting for your next prompt: after Claude’s next tool call (PostToolUse) or when Claude ends its turn (Stop, which blocks the stop with the result). At Stop it waits up to 120 s (AOFORGE_HANDOFF_STOP_WAIT_MS, 0 disables) for a command you are still answering in the aoforge-watch terminal. route-results.js delivers anything left on the next prompt. | AOFORGE_SKIP_HANDOFF_INJECT=1, AOFORGE_SKIP_HANDOFF_RESULTS=1 |
inject-org-context.js | not registered | Injects an objective’s full org context — parent issue, repo roadmap, sibling repo activity — at planning time. | — |
route-results.js | UserPromptSubmit | The catch-all for handoff results: on your next prompt, delivers every finished handoff-watcher result that no other hook delivered yet, whichever session or agent queued it, exactly once (an O_EXCL claim shared with inject-handoff-results.js). Also emits queued notices, such as those from upgrade-project.js and coexistence-guard.js, once each. | AOFORGE_SKIP_HANDOFF_RESULTS=1, AOFORGE_SKIP_NOTICES=1 |
upgrade-project.js | SessionStart | Upgrades a behind AOForge project in place at session start: applies the safe migrations, then commits exactly the changed files in a detached background process (skipped during rebase/merge/cherry-pick/bisect, on a detached HEAD, over uncommitted edits other than the gitignored runtime-state files, or if signing fails). Notices are emitted once on the next prompt. It also prunes ~/.claude/aoforge/backups at most once per 24 h (older than 14 days, keeping the newest 5 per repo). It also starts a background aof-tools transcript-export at most once per 24 h, in every session, which keeps the per-session index under ~/.claude/aoforge/ current before Claude Code deletes old transcripts. | AOFORGE_SKIP_UPGRADE=1, AOFORGE_SKIP_PRUNE=1, AOFORGE_SKIP_TRANSCRIPT_EXPORT=1 |
Runtime sync
| Hook | Event | What it does | Escape hatch |
|---|---|---|---|
sync-runtime.js | SessionStart | Mirrors the plugin-bundled runtime to ~/.claude/aoforge/ whenever the bundled version differs from the cached .plugin-version. Skills reference @~/.claude/aoforge/... paths, which do not interpolate ${CLAUDE_PLUGIN_ROOT} — this hook is what makes those references resolve. | — |
Observability — warn only, never block
| Hook | Event | What it does | Escape hatch |
|---|---|---|---|
gh-flush.js | Stop PostToolUse (Bash) | Store mode only: flushes the GitHub outbox after an aof-tools commit and at Stop, and reports pending or halted writes; at Stop it also reports cache drift (W055). Never blocks, fails open. | AOFORGE_SKIP_GH_FLUSH_HOOK=1 |
statusline.js | Registered via plugin.json statusLine | Renders model, current task, directory, and context usage in the Claude Code status line, plus estimated time remaining while an objective builds. | — |
todo-sync.js | Stop | At every Stop, merges the session’s /aoforge:todo items (TaskCreate/TaskUpdate or TodoWrite calls in the transcript) into the todo archive with the library behind aof-tools todo sync: .aoforge/todos/ in local mode, a queued aoforge:todo issue in store mode. Idempotent: a second Stop over the same transcript changes nothing and prints nothing. Says what it did in one message (archived, completed, uncommitted todo files, or a failure line), never commits, never blocks, keeps no state and fails open. | AOFORGE_SKIP_TODO_SYNC=1 |
verify-commits.js | SubagentStop | Warns when a subagent finishes without producing commits — a silent-failure detector for the executor. | — |
verify-completion.js | Stop | Checks that the most recent SUMMARY.md carries task evidence and no failure markers. Warns only — never blocks. | — |
Escape hatches, complete list
| Variable | Disables |
|---|---|
AOFORGE_ALLOW_RAW_COMMIT=1 | the commit gate |
AOFORGE_SKIP_EDIT_GATE=1 | the edit gate |
AOFORGE_SKIP_CHANGELOG_GATE=1 | the tag/changelog gate |
AOFORGE_SKIP_PROGRESS_GUARD=1 | the no-progress guard |
AOFORGE_SKIP_INTERACTIVE_GATE=1 | the interactive-command gate |
AOFORGE_SKIP_CLASSIFY=1 | session classification |
AOFORGE_SKIP_AWARENESS_POPULATE=1 | the awareness cache warmer |
AOFORGE_SKIP_ORG_CONTEXT=1 | org-context injection |
AOFORGE_SKIP_HANDOFF_INJECT=1 | handoff result injection |
AOFORGE_SKIP_HANDOFF_RESULTS=1 | handoff result routing |
Use them one-off rather than exporting them:
AOFORGE_ALLOW_RAW_COMMIT=1 git commit -m "..."
A gate you have permanently exported is a gate you have removed.
Logging an override instead
When you need to bypass a gate for a real reason, record it rather than silently escaping:
aof-tools override --gate edits --reason "hand-fixing generated file the executor cannot parse"
aof-tools override --list --limit 20
Overrides are written to a structured log. That log is what makes gate friction measurable — and a gate that is overridden constantly is a gate that is wrong.
Not an AOForge hook
The worktree-isolation guard — “This agent is isolated in the worktree…” — is a
Claude Code harness guard, not AOForge’s. It refuses compound Bash commands it
cannot statically verify, including ones with no git in them, and no AOFORGE_*
variable affects it.
Agents work around it by emitting one plain command per Bash call.