Skip to content

Working with hooks

Concepts live in Hooks; the full schema in the reference. This page is patterns and operations.

The essential pattern: env files per workspace

Section titled “The essential pattern: env files per workspace”
hooks:
post-create:
install:
command: "npm ci"
condition: "file_exists:package.json"
migrate:
command: "npm run migrate"
environment:
DATABASE_URL: "{{ service['app-db'].url }}"
post-switch:
env:
action:
type: write-env
path: .env.local
vars:
DATABASE_URL: "{{ service['app-db'].url }}"
REDIS_URL: "redis://{{ service.cache.host }}:{{ service.cache.port }}/{{ service.cache.database }}"

post-switch fires on every switch (including the implicit one after creation), so .env.local always matches the active workspace — in the right worktree.

The write-env action is preferred over echo … > .env.local: no shell, no quoting bugs, no approval prompt, and it merges instead of clobbering.

hooks:
post-start:
dev-server:
command: "npm run dev -- --port {{ workspace | hash_port }}"
background: true # deterministic port per workspace
pre-commit:
test: { command: "npm test", continue_on_error: false }
lint: { command: "npm run lint", continue_on_error: false }
post-remove:
cleanup:
command: "docker stop {{ repo }}-{{ workspace | sanitize }}-app 2>/dev/null || true"
continue_on_error: true
post-create:
announce:
command: "printf '%s\\n' 'Workspace {{ workspace }} ready'"

Conditions keep hooks polyglot-safe (condition: "file_exists:requirements.txt" for the Python path, package.json for Node) and context-aware (is_worktree, workspace_matches:^agent/.*, trigger_is:vcs). All conditions →

Recipes are intent-based hook generators. Installing one probes your project — files on disk and tools actually installed (PATH + mise shims) — proposes concrete parameter values, and writes one lean set of plain hooks into .devflow.yml (never overwriting your entries). Generated hooks are ordinary hooks: edit them freely afterwards.

Terminal window
devflow hook recipes # list + what detection found for THIS project
devflow hook setup # wizard: pick from detected recipes, install in one go
devflow hook install env-file # interactive: confirm detected params
devflow hook install db-migrate --param command="sqlx migrate run" --yes
devflow hook install install-deps --yes # accept detected values (CI/agents)
RecipeIntentDetection
env-filewrite per-workspace service URLs into an env file (approval-free write-env, merge mode)configured services → DATABASE_URL/REDIS_URL/…, existing .env* file
patch-configfind-and-replace a value in any config file (repeatable, one hook per file)none — fully parameterized
db-migraterun migrations after create/switch (one editable command)prisma, Rails, Django, alembic, sqlx, diesel, dbmate
install-depsinstall dependencies with the package manager the project useslockfile and binary installed (bun > pnpm > yarn > npm, uv, cargo)
workspace-setupcopy .env.example.env.local, mise trust, direnv allowonly the parts that apply (file present + tool installed)
multiplexer-sessionauto-open a tmux/zellij session in the worktree after creationtmux/zellij installed or execute configured

Interactive runs prompt once per hook template. For CI and agents (--non-interactive), unapproved hooks are skipped with a warning — the command still succeeds and reports per-phase counts. Make hooks actually run by either:

Terminal window
# approve specific templates once per project (covers all workspaces/worktrees)
devflow hook approvals add "npm run migrate"
devflow hook approvals list
# or blanket-approve config hooks for this run
DEVFLOW_APPROVE_HOOKS=1 devflow --json --non-interactive switch -c agent/t42

In JSON output, hooks[].skipped > 0 is your signal that an approval is missing. --no-verify is different — it skips all hooks entirely.

Terminal window
devflow hook show # everything configured
devflow hook show post-create
devflow hook run post-create # run a phase manually
devflow hook run post-create migrate # one named hook
devflow hook run post-create --workspace feature/auth
devflow hook explain post-switch # phase docs + when it fires
devflow hook vars # live template context
devflow hook render "{{ service['app-db'].url }}"
devflow hook triggers # VCS event → phase mapping
devflow hook actions # built-in action list

Any unknown phase name is a custom phase — define it and run it on demand:

hooks:
load-fixtures:
seed: "psql {{ service['app-db'].url }} -f fixtures.sql"
Terminal window
devflow hook run load-fixtures