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

HookEventWhat it doesEscape hatch
changelog-on-tag.jsPreToolUse (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.jsPreToolUse (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.jsPreToolUse (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.jsPreToolUse (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.jsPreToolUse (*)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.jsUserPromptSubmitMatches 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:

  1. Run a skill. Skills write .planning/.skill-active via df-tools skill-active --start, and the gate allows edits while that marker is live. Markers carry an expires_at, 8 hours by default.
  2. Use an override phrase in your prompt: skip devflow, just edit, bypass devflow, force edit. The route-intent hook writes .planning/.edit-override, which this gate consumes — single-turn and TTL-bounded.
  3. Set the env escape: DEVFLOW_SKIP_EDIT_GATE=1.
  4. 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:

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

HookEventWhat it doesEscape hatch
awareness-cache-populate.jsSessionStartWarms the cross-repo awareness cache in a detached child process. Never blocks session start, even when a scan takes 30s or more.
classify-session.jsSessionStartClassifies 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.jsnot registeredSurfaces completed handoff-watcher results back into the session.
inject-org-context.jsnot registeredInjects an objective’s full org context — parent issue, repo roadmap, sibling repo activity — at planning time.
route-results.jsUserPromptSubmitInjects completed handoff-watcher results into the next turn, so a queued interactive command resumes without you pasting anything.

Runtime sync

HookEventWhat it doesEscape hatch
sync-runtime.jsSessionStartMirrors 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

HookEventWhat it doesEscape hatch
statusline.jsRegistered via plugin.json statusLineRenders model, current task, directory, and context usage in the Claude Code status line.
verify-commits.jsSubagentStopWarns when a subagent finishes without producing commits — a silent-failure detector for the executor.
verify-completion.jsStopChecks that the most recent SUMMARY.md carries task evidence and no failure markers. Warns only — never blocks.

Escape hatches, complete list

VariableDisables
DEVFLOW_ALLOW_RAW_COMMIT=1the commit gate
DEVFLOW_SKIP_EDIT_GATE=1the edit gate
DEVFLOW_SKIP_CHANGELOG_GATE=1the tag/changelog gate
DEVFLOW_SKIP_PROGRESS_GUARD=1the no-progress guard
DEVFLOW_SKIP_INTERACTIVE_GATE=1the interactive-command gate
DEVFLOW_SKIP_CLASSIFY=1session classification
DEVFLOW_SKIP_AWARENESS_POPULATE=1the awareness cache warmer
DEVFLOW_SKIP_ORG_CONTEXT=1org-context injection
DEVFLOW_SKIP_HANDOFF_INJECT=1handoff result injection
DEVFLOW_SKIP_HANDOFF_RESULTS=1handoff 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.