Hooks
How DevFlow 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/devflow/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 |
|---|---|---|---|
changelog-on-tag.js | PreToolUse (Bash) | Blocks git tag -a vX.Y.Z unless CHANGELOG.md has a ## [X.Y.Z] heading and the three release manifests carry matching versions. | DEVFLOW_SKIP_CHANGELOG_GATE=1 |
gate-commits.js | PreToolUse (Bash) | Blocks raw git commit and redirects to df-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. | DEVFLOW_ALLOW_RAW_COMMIT=1 |
gate-edits.js | PreToolUse (Edit|Write|MultiEdit) | Strict DENY by default in ambient mode. Allows edits when a live .planning/.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 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. | DEVFLOW_SKIP_EDIT_GATE=1 |
gate-interactive.js | PreToolUse (Bash) | Intercepts TTY-requiring commands and routes them to the handoff watcher rather than letting them hang on a prompt no one can answer. | — |
guard-no-progress.js | PreToolUse (*) | 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. | DEVFLOW_SKIP_PROGRESS_GUARD=1 |
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 — DevFlow 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
.planning/.skill-activeviadf-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 devflow,just edit,bypass devflow,force edit. Theroute-intenthook writes.planning/.edit-override, which this gate consumes — single-turn and TTL-bounded. - Set the env escape:
DEVFLOW_SKIP_EDIT_GATE=1. - Lower the severity per-project in
.planning/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
.planning/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 df-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. | DEVFLOW_SKIP_CLASSIFY=1 |
inject-handoff-results.js | not registered | Surfaces completed handoff-watcher results back into the session. | — |
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 | Injects completed handoff-watcher results into the next turn, so a queued interactive command resumes without you pasting anything. | — |
Runtime sync
| Hook | Event | What it does | Escape hatch |
|---|---|---|---|
sync-runtime.js | SessionStart | Mirrors the plugin-bundled runtime to ~/.claude/devflow/ whenever the bundled version differs from the cached .plugin-version. Skills reference @~/.claude/devflow/... 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 |
|---|---|---|---|
statusline.js | Registered via plugin.json statusLine | Renders model, current task, directory, and context usage in the Claude Code status line. | — |
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 |
|---|---|
DEVFLOW_ALLOW_RAW_COMMIT=1 | the commit gate |
DEVFLOW_SKIP_EDIT_GATE=1 | the edit gate |
DEVFLOW_SKIP_CHANGELOG_GATE=1 | the tag/changelog gate |
DEVFLOW_SKIP_PROGRESS_GUARD=1 | the no-progress guard |
DEVFLOW_SKIP_INTERACTIVE_GATE=1 | the interactive-command gate |
DEVFLOW_SKIP_CLASSIFY=1 | session classification |
DEVFLOW_SKIP_AWARENESS_POPULATE=1 | the awareness cache warmer |
DEVFLOW_SKIP_ORG_CONTEXT=1 | org-context injection |
DEVFLOW_SKIP_HANDOFF_INJECT=1 | handoff result injection |
DEVFLOW_SKIP_HANDOFF_RESULTS=1 | handoff result routing |
Use them one-off rather than exporting them:
DEVFLOW_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:
df-tools override --gate gate-edits --reason "hand-fixing generated file the executor cannot parse"
df-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 a DevFlow hook
The worktree-isolation guard — “This agent is isolated in the worktree…” — is a
Claude Code harness guard, not DevFlow’s. It refuses compound Bash commands it
cannot statically verify, including ones with no git in them, and no DEVFLOW_*
variable affects it.
Agents work around it by emitting one plain command per Bash call.