DevFlow is an opinionated CLI that drives a fixed 5-stage pipeline (Define → Plan → Code → Validate → Ship) end-to-end: it launches a coding agent per stage, evaluates whether the agent succeeded, fires human gates at Validate/Ship, and runs side-effecting hooks (branch/docs/changelog/version) at the mapped transitions. This document describes the implementation as it actually exists; treat the named source files as the source of truth if this drifts.
DevFlow is a two-crate Cargo workspace (Cargo.toml):
| Crate | Kind | Responsibility |
|---|---|---|
devflow-core |
Library (crates/devflow-core) |
Stage machine, per-phase state, hooks, locking, events log, gate protocol, versioning, agent adapters, monitor daemon, worktrees, recovery. Returns structured data; no terminal formatting. |
devflow-cli |
Binary (crates/devflow-cli) |
Clap command parser and the user-facing devflow binary. Owns all printing and orchestration (advance, transition, run_gate); delegates primitives to devflow-core. |
The split keeps workflow logic testable and independent of both the CLI surface and any individual coding agent.
The pipeline is a single linear chain of five stages
(crates/devflow-core/src/stage.rs, enum Stage):
Define → Plan → Code → Validate → Ship
Stage::next()defines the linear transitions;Shipis terminal (next()returnsNone).Stage::is_agent_stage()is true forDefine,Plan,Code— these launch a headless coding agent driven by a GSD slash command (Stage::gsd_command()):/gsd-discuss-phase {N},/gsd-plan-phase {N},/gsd-execute-phase {N}.Stage::is_gate()is true forValidateandShip— these may pause the workflow at a human gate (see Gate protocol) depending on the activeMode. Validate also launches an agent (/gsd-validate-phase {N}) before its gate decision; Ship's agent step (/gsd-ship {N}) is described in Git and ship model.State(crates/devflow-core/src/state.rs) holdsstage,phase,agent,mode,gate_pending,consecutive_failures,project_root, and an optionalworktree_path. It is persisted per-phase — see Per-phase state and locking.crates/devflow-cli/src/main.rs'sadvance()function drives the machine: after an agent exits (or a gate resolves), it evaluates the result (see Completion evaluation) and callstransition()to move to the next stage,handle_validate_outcome()/handle_ship_outcome()for the two gate stages, orhandle_stage_failure()for a never-silent failure gate (WR-11 — any non-Validate stage failure fires a gate rather than halting silently).
Creating/cleaning up branches, updating docs, appending the changelog, and
bumping the version are not stages — they are side-effecting hooks
that fire at specific stage transitions
(crates/devflow-core/src/hooks.rs, enum Hook):
| Hook | Effect |
|---|---|
BranchCreate |
Create feature/phase-NN from develop (GitFlow::feature_start) |
BranchCleanup |
Delete the merged feature branch after Ship (non-force — never discards unmerged work) |
DocsUpdate |
Run cargo doc --no-deps and commit any doc changes |
ChangelogAppend |
Prepend a CHANGELOG.md entry for the computed version |
VersionBump |
Compute the next version, write it to the version file, and tag v{version} |
hooks_for_transition(from, to) maps a stage move to the hooks that should
run; today the only non-empty mapping is Validate → Ship, which runs
DocsUpdate + ChangelogAppend (docs/changelog are finalized before the
Ship agent runs). hooks_after_ship() — VersionBump + BranchCleanup —
fires once a human approves the Ship gate (finish_workflow()), not as a
hooks_for_transition entry. Both hook batches run under the project-wide
checkout lock (see locking) and are
individually fail-soft: a hook failure warns and the batch continues rather
than aborting the workflow.
BranchCreate is defined as a Hook variant but hooks_for_transition
never returns it — branch/worktree creation for a phase actually happens
directly in start() (crates/devflow-cli/src/main.rs) via
GitFlow::feature_start/feature_start_force (branch-in-place mode) or
worktree::add (the default, isolated-worktree mode), not through the hook
dispatch table.
Agent backends are isolated behind a trait
(crates/devflow-core/src/agents/mod.rs):
AgentDrivertrait:name(),render_prompt(intent),build_command(phase, prompt, extra_writable_roots) -> (program, args),parse_completion(output),health(state),environment(),capabilities(),interactivity_mode(stage),test_contract(). Each driver owns its prompt rendering, command building, completion parsing, and health.- Supported
AgentKinds (state.rs):Claude,Codex,OpenCode,Pi. Accepted names:claude,codex,opencode/open-code,pi. driver_for(kind)returns the boxed driver for a kind (ClaudeDriver/CodexDriver/OpenCodeDriver/PiDriver).- Pi subagent dispatch is user-installed and capability-detected. A user
installs
@bacnh85/pi-subagentat user scope (pi install npm:@bacnh85/pi-subagent); DevFlow never ships or manages it.PiDriver:: capabilities()probespi list --no-approvefor the vetted@bacnh85/pi-subagentpackage (not a bare*subagent*name-match) and setsDriverCapabilities::subagent_dispatch. The capability is reported only (surfaced bydevflow doctor) — no stage-routing or launch logic consumes it yet. The signal is name-based (Pi has nopi toolscommand), not a tool-registry proof. - Prompts are built per-stage by
crate::prompt::stage_prompt(stage, phase)(orstage_prompt_for_projectwhen the CLI applies project config), not a single shared instruction template. Every prompt hands the agent its GSD slash command (Stage::gsd_command()) plus aDEVFLOW_RESULTcompletion contract (DEVFLOW_RESULT: {"status": "success"}/{"status": "failed", "reason": "..."}). Prompts usingCOMPLETION_PROTOCOLrequire that result as the last line of the agent's final message, with any decision reasoning above it. Three stages get dedicated prompts:- Define / Plan — idempotent: if the stage's deliverable
(
CONTEXT.md/PLAN.md) already exists, the agent reports success without re-running the GSD command (headless Codex cannot answer GSD's interactive overwrite/append/cancel prompt). - Validate — REQUIRES a
verdict: "pass"orverdict: "gaps"field distinct fromstatus, soadvance()can tell "the validation task ran" apart from "validation passed." - Ship — runs
/gsd-code-review {N}first; ifREVIEW.mdcontains any Critical-severity finding, the agent must NOT run/gsd-ship {N}and instead reports areview:-prefixed failure (looped back to Code with the audit-fix prompt, not gated as a crash). Only a clean review proceeds to/gsd-ship {N}.
- Define / Plan — idempotent: if the stage's deliverable
(
When an agent exits, evaluate_agent_result()
(crates/devflow-core/src/agent_result.rs) decides success/failure with
four layers, tried in order:
- Layer 0 — external post-condition (authoritative failure). Commands
declared as
external_verifyin PLAN frontmatter run only after exact command-array approval throughDEVFLOW_TRUST_EXTERNAL_VERIFY; they run before agent-controlled signals. A failure returnsFailed; success or no declaration defers to the ordinary cascade. - Layer 1 —
DEVFLOW_RESULTmarker (authoritative for ordinary plans). Parses the agent's native per-adapter envelope (Claude's JSON result object, or Codex's--jsonJSONL event stream) or scans captured stdout for the lastDEVFLOW_RESULT: {...}line. An envelopeis_error: trueoverrides a stale marker; a marker-less Codexturn.completeddefers to Layer 2 rather than assuming success. - Layer 2 — exit code + commit gate (reliable fallback). Reads the exit
code from
.devflow/phase-NN-exitand counts commits onfeature/phase-NN(develop..feature/phase-NN).exit != 0is alwaysFailed, for every stage. Onexit == 0, the "zero commits → failed" gate applies only toStage::Plan/Stage::Code(checked explicitly, not viais_agent_stage(), since that also includesDefine, which legitimately produces zero commits) — Define/Validate/Ship withexit == 0succeed regardless of commit count; Validate's real pass signal is itsverdict, not a commit count. - Layer 3 — process gone + commits exist (last resort). If neither the
marker nor the exit-code file is available, commit presence is used as a
heuristic (
AgentStatus::Unknown) with a warning.
State is per-phase (crates/devflow-core/src/workflow.rs): each active
phase persists to .devflow/state-{phase:02}.json, so devflow parallel
sibling phases never clobber one another. A legacy single-slot
.devflow/state.json from a pre-14a binary is migrated to its per-phase
name on first read.
Two independent lock levels (crates/devflow-core/src/lock.rs), both
file-based (O_EXCL create, PID written into the file, stale-holder
reclaim if the recorded PID is dead):
- Per-phase advance lock (
.devflow/lock-{phase:02}) — held byadvance()across a gate's potentially multi-day blocking wait. Scoped per-phase (not per-project) so one phase blocked on a gate never starvesdevflow parallel's sibling phases. - Project-wide checkout lock (
.devflow/lock-project,lock::acquire_project_blocking) — a coarse, seconds-scale lock that serializes mutations of the shared primary checkout (hook batches) across concurrently finishing phases. It must never be held across a gate wait; on timeout (DEVFLOW_CHECKOUT_LOCK_TIMEOUT_SECS, default 120s) the hook batch is skipped (loudly, viaevents.jsonl) rather than ever run unserialized.
.devflow/events.jsonl (crates/devflow-core/src/events.rs) is an
append-only, one-JSON-object-per-line log, schema v1:
{"v":1,"ts":1752600000,"phase":14,"event":"transition","from":"code","to":"validate"}Every line carries v, ts (unix seconds), phase, and event; the rest
of the fields are event-specific. Emission is fail-soft (an unwritable log
warns and returns — never aborts the workflow it records), and appends are a
single write_all on an O_APPEND handle so concurrent phase monitors'
lines interleave without tearing. The CLI emits events at every meaningful
step — workflow_started, advance_evaluated, transition, gate_fired,
notify_fired, loop_back, hook_run, checkout_lock_timeout,
workflow_finished, advance_failed — so any frontend (Hermes plugin, a
future TUI/web UI) can observe a running loop by tailing one file instead of
integrating with DevFlow internals. devflow logs/status read it via
last_events_by_phase() (one read+parse pass over the whole log).
A gate is a pause point where DevFlow writes a request to
.devflow/gates/ and blocks until a human (or the Hermes cron poller) drops
a response file (crates/devflow-core/src/gates.rs, struct Gates). Three
files per gated stage, all written atomically (temp file + rename):
.devflow/gates/{phase:02}-{stage}.json— the request (GateFile), written byGates::write_gate()..devflow/gates/{phase:02}-{stage}.response.json— the human's answer (GateResponse { approved, note, responded_by }), written byGates::respond()(thedevflow gate approve|rejectCLI path) or by hand/Hermes..devflow/gates/{phase:02}-{stage}.ack.json— DevFlow's receipt (Gates::ack()) once the response is consumed, so a poller knows to clean up.
run_gate() (crates/devflow-cli/src/pipeline_gate.rs) writes the gate,
fires the notify hook (fire_gate_notify() — runs $DEVFLOW_GATE_NOTIFY_CMD
via sh -c with gate metadata as environment variables, never interpolated
into the command string; a silent no-op when the variable is unset), then
waits in wait_for_gate_response() (crates/devflow-cli/src/pipeline_launch.rs),
which polls the response file one of two ways:
- Inside a supervisor's advance (its stop token is engaged, 50-13): the
run's owner reads
gate_waitin apingreply for the length of the wait, andGates::poll_response_cancellable()reads the response file about once a second (every sleep is capped at 1s) and checks the stop token before each sleep. A stop — ashutdownrequest on the supervisor's socket, or SIGTERM/SIGINT to the supervisor — ends the wait within about a second: the gate's files are removed,stoppedis recorded in the state, which is kept fordevflow resume(not an abort), and the advance unwinds without acting on the gate. A response already on disk wins over a stop. - Anywhere else (no stop token: the hidden
devflow advancerun from a shell, or a gate a foreground command fires):Gates::poll_response()with exponential backoff (1s → 2s → … capped at 60s), which nothing cancels.
Either wait ends when a response appears or DEVFLOW_GATE_TIMEOUT_SECS
(default 259200 = 3 days, held equal to
DEVFLOW_GATE_MAX_UNATTENDED_AGE_SECS so devflow gate sweep cannot reap a
gate this poll is still watching) elapses. GateAction::from_response() turns an
approval into Advance, a rejection into LoopBack(Code), or — when the
rejection note contains "abort" — Abort(reason). devflow gate list
(Gates::list_open()) enumerates requests with no response file yet,
skipping unparsable entries rather than failing.
crates/devflow-core/src/monitor.rs is the core automation primitive — no
cron, no scheduler, no agent cooperation. spawn_monitor() re-execs the CLI
as its hidden __monitor subcommand, a detached supervisor process in its
own process group that owns the agent, for both launch shapes:
- capture (
MonitorLaunch::Legacy): the agent's stdin is/dev/nulland its stdout and stderr go straight to the phase capture files (stderr is kept out of the stdout capture so it can never corrupt theDEVFLOW_RESULTparse). Non-Claude adapters other than Antigravity, the legacy Claude launch opt-out and a checkpoint resume use it. - stream (
MonitorLaunch::PipeOwning): the supervisor owns the agent's stdin and stdout pipes, delivers the prompt as the first turn and applies the idle timeout. Claude and Antigravitystream-jsonlaunches use it.
The supervisor (run_supervised_stage()):
- Spawns the agent (
program+args) in the agent's own process group and records its PID. - Publishes a per-launch Unix socket under
registry::cache_dir()/sock/, with a JSON sidecar beside it naming the launch (project root, phase, agent process group, start time, boot id). The socket path is recorded in the phase'sstate.jsonasstate.supervisor; liveness (ALIVE / STALE / GONE) is answered by probing that path, never from a pid. - Answers
pingandshutdownon the socket, and treats SIGTERM and SIGINT sent to it as ashutdown: the agent's group is ended and no advance runs. - Waits for the agent to exit and records its exit code to
.devflow/phase-NN-exit. A stream supervisor stops reading when the agent exits, not when its stdout closes: when a process the agent started still holds that stdout, it waits at most 3 s for its pipe threads, closes the capture file, records astream_wait_boundedevent, and signals nothing. - On the agent's natural exit, runs the advance in-process. The phase is
threaded in at spawn time, so the advance's identity never depends on a
shared state singleton — under
devflow parallel, each phase's supervisor advances exactly its own stage. When the advance launches the next stage, that stage's supervisor publishes its socket before this one unlinks its own, so the hand-off leaves no gap with neither socket published.
spawn_monitor() returns once the new supervisor answers a ping with this
launch's id. Because the supervisor outlives the CLI process that launched
it, agent stdout keeps flowing into the capture file and the exit code is
still reaped after that process returns. spawn_monitor() is the single way
an agent process is spawned (23d).
crates/devflow-core/src/worktree.rs wraps plain git worktree commands.
Each agent gets an isolated working directory under
<project_root>/.worktrees/, sharing the main repo's object database; state
and capture files always live under the main project_root — only the
agent's working directory moves. Worktree isolation is the default:
devflow start creates a worktree unless --no-worktree is passed (the
--worktree flag itself is a deprecated no-op kept for one release). This
backs:
start(default) — run a single phase in its own worktree;--no-worktreeruns directly in the primary checkout instead.parallel— run multiple phases concurrently, each in its own worktree.reference— create or refresh a static snapshot worktree at.worktrees/reference/.
Branch integration uses GitFlow::ensure_branch() (create at a start point
without checking out).
crates/devflow-core/src/git.rs (GitFlow) implements branch primitives:
feature_start(phase)/feature_start_force(phase) (create
feature/phase-NN from develop), feature_finish(phase) (merge into
develop with --no-ff and delete), push, has_remote,
cleanup_merged. release_start/release_finish also exist on GitFlow
but are not called from any production CLI path today (only exercised in
git.rs's own tests) — cutting a release/{version} branch and opening a
PR is not part of the current Ship flow.
The real Ship model is gate-driven, not command-driven: there is no
standalone ship subcommand and no separate commands to finalize or undo a
recorded ship — those do not exist in enum Command. Ship works like any
other stage plus a mandatory terminal gate:
Validate → Shiptransition fires theDocsUpdate+ChangelogAppendhooks, then launches the Ship agent with its dedicated prompt (code review gate, then/gsd-ship {N}— see Agent model). PR creation, if any, happens inside that GSD command, not in DevFlow's own git primitives.- When the Ship agent exits successfully,
handle_ship_outcome()always fires a Ship gate (run_gate(..., Stage::Ship, "Ship complete — approve merge?")) — Ship is never auto-advanced past, regardless ofMode. - A human answers via
devflow gate approve <phase> --stage ship [--note ...]ordevflow gate reject <phase> --note "..." [--stage ship](GateCmd::Approve/GateCmd::Reject,crates/devflow-cli/src/main.rs). Approval runsfinish_workflow(): theVersionBump+BranchCleanuphooks, gate cleanup, andworkflow::clear_state(). A rejection loops back to Code (or aborts, if the note contains "abort"). - A Ship-stage agent crash (vs. a review rejection) is routed through
the same never-silent gate path as any other stage failure
(
handle_stage_failure) — WR-11: no non-Validate stage failure is ever silent.
DevFlow has no initialization step. Workflow options remain CLI flags to
devflow start (crates/devflow-cli/src/main.rs, Command::Start). An
optional minimal devflow.toml contains only reliability knobs:
capture_retention, review_angles, and external_verify_enabled.
| Flag | Purpose |
|---|---|
--phase N |
Phase number to work on |
--agent claude|codex|opencode |
Agent to launch |
--mode auto|supervise |
auto gates only at Ship (plus never-silent failure gates); supervise also gates every Validate |
--force |
Overwrite the feature branch if it already exists |
--no-worktree |
Run in the primary checkout instead of an isolated worktree |
--dry-run |
Print the pipeline that would run without launching anything |
Runtime state lives under .devflow/ (git-ignored), keyed per-phase —
state-{phase:02}.json (see Per-phase state and
locking). Tunable runtime behavior (not
workflow options) is read from environment variables —
DEVFLOW_GATE_NOTIFY_CMD, DEVFLOW_GATE_TIMEOUT_SECS,
DEVFLOW_CHECKOUT_LOCK_TIMEOUT_SECS, DEVFLOW_CAPTURE_RETENTION,
DEVFLOW_REVIEW_ANGLES, DEVFLOW_EXTERNAL_VERIFY_ENABLED, RUST_LOG,
DEVFLOW_LOG_FORMAT — see
OPERATIONS.md for the full operator-facing reference.
DevFlow uses the tracing ecosystem for structured
diagnostic logging. All log output goes to stderr; stdout is reserved
for agent/system output and structured results.
| Environment variable | Purpose | Default |
|---|---|---|
RUST_LOG |
Controls log verbosity via tracing-subscriber::EnvFilter. Accepts bare levels (error, warn, info, debug, trace) or targeted directives (devflow_core=debug,devflow=info). |
info |
DEVFLOW_LOG_FORMAT |
When set to json, enables JSON-structured log output (one JSON object per line on stderr). Any other value (or unset) produces human-readable plain-text logs. |
plain text |
Key modules log via tracing's info!/warn!/debug! macros:
main.rs—advance/transition/run_gateprint human-readable progress to stdout and emit structured events toevents.jsonlat every step (see Events log).workflow.rs— state I/O (save_state,load_state, legacy migration) logs atDEBUG; unreadable/corrupt state files warn atWARNrather than aborting a listing.git.rs— branch operations (feature_start,feature_finish,tag,push) log atINFO; checkout/rebase/commit operations log atDEBUG; force operations log atWARN.hooks.rs— each hook run logs its outcome atINFO(success) orWARN(fail-soft failure, e.g. an unmerged branch left in place byBranchCleanup).gates.rs— gate writes/polls log atINFO/DEBUG; a failed or unspawnable notify command warns atWARN(fail-soft — never abortsrun_gate).lock.rs— reclaiming a stale (dead-holder) lock warns atWARN.monitor.rs— agent spawn logs atINFO; PID/exit polling logs atDEBUG.
| Level | Usage |
|---|---|
ERROR |
Unrecoverable conditions that abort an operation. |
WARN |
Recoverable anomalies (force operations, stale locks, fail-soft hook/notify failures). |
INFO |
Stage transitions, agent lifecycle, branch/gate milestones. |
DEBUG |
I/O details, merge/checkout operations, poll iterations. |
TRACE |
Fine-grained execution tracing (rarely needed outside debugging). |
When DEVFLOW_LOG_FORMAT=json, each log event is a single JSON line
containing timestamp, level, target, fields, and optional span
information — for machine consumption (e.g. Hermes watching agent output).
This is a separate stream from events.jsonl (append-only workflow events);
JSON tracing output is DevFlow's own diagnostic logging.
# JSON log smoke test
DEVFLOW_LOG_FORMAT=json RUST_LOG=info cargo run -- status 2>devflow.json
head -1 devflow.json | python3 -m json.tool # Should parse as valid JSONdevflow doctor includes a RUST_LOG environment check that validates the
variable is set to a parseable value and warns when it is missing or invalid.
DevFlow supports four agents today (Claude Code, Codex, OpenCode, Pi) through
the modular AgentDriver contract; agent-specific code lives only in
agents/*.rs and the targeted result parsing. Adding a backend today is a
checklist — keep these in sync or tests/builds fail:
- Add a driver file under
crates/devflow-core/src/agents/implementing theAgentDrivertrait. - Add a variant to the
AgentKindenum instate.rs. - Update the
FromStrparser,Display, andAgentParseErrortext instate.rsto accept/emit the new name. - Add a match arm in
agents::driver_for(). - Add the
pub modandpub useexports inagents/mod.rs. - Add/extend tests (driver name, parser aliases, and a conformance-suite run).
- Update docs (
README.md,DEPENDENCIES.md,CONTRIBUTING.md, this file).