v2.2.0 — Checkpoint Answer Return Path, Hermetic Git Invocation
Phase 27 (hermetic git invocation) and phase 28 (the checkpoint answer return
path). Phase 26 remains unmerged and is not in this release.
The headline is phase 28: a gate="blocking-human" checkpoint used to be a dead
end for any DevFlow-driven run. The agent stopped, asked a question, and no path
existed by which an answer could reach it — every retry spawned a fresh process
that asked the identical question again. A plan that correctly gated an
irreversible decision became a plan that could never finish unattended.
⚠ Behavior change: human-blocking checkpoints are now resolved by the agent
This is unconditional and there is no flag or config toggle to disable it.
When a stage halts at a task declared gate="blocking-human", DevFlow now
relaunches the exact exited Claude session with an instruction to decide the
checkpoint itself, and records what it decided.
This deliberately overrides checkpoints.md rule 6 (blocking-human is never
auto-approved, in any mode). It was adopted because DevFlow has no usable
notification or response channel — the only push mechanism is an
operator-supplied DEVFLOW_GATE_NOTIFY_CMD that is a silent no-op when unset,
and the only pull mechanism is running devflow status by hand. A "wait for a
human" default would not degrade gracefully; it would hang. Given that, a flag
implying a working "off" state would have been misleading.
What bounds it: the path is reachable only for a checkpoint that the
operator's own approved plan declared — the authorizing check is a static scan
of the phase's PLAN.md files, which an agent cannot influence at runtime, and
it is evaluated strictly before any agent-controlled signal. Resumes are capped
(MAX_CHECKPOINT_RESUMES), and exhaustion falls through to the existing
never-silent gate. Every auto-decision writes a checkpoint_auto_decided event
to .devflow/events.jsonl naming the session, the instruction, and the policy —
with no human in the loop beforehand, that record is the only way anyone learns
after the fact what the agent decided.
Fixed
- A
blocking-humancheckpoint no longer strands a headless run (999.57). DevFlow statically scans the stage's plans for a declared human-blocking task before launching, confirms from captured stdout that one actually fired, and resolves it by resuming the exact exited session rather than spawning a fresh one — no CONTEXT/RESEARCH re-read, no re-running completed tasks - The checkpoint confirmation reader now matches what a real run actually emits. The reader was built against a rendering predicted by reading the emitting source, and shipped with that caveat recorded rather than hidden. A live end-to-end run then showed the executor renders the value as a markdown code span —
**Gate:** `blocking-human`— and the matcher, which trimmed only*and whitespace, terminated on the backtick and produced an empty token. Genuine checkpoints fell through to the generic gate. The unit suite could not catch it: every fixture was built from the same prediction. Regression tests are now transcribed from the live capture. The reader's documented safe-direction property held throughout — a false negative degrades to the never-silent gate, so nothing was ever silently authorized devflow resumeno longer discards an unfired--untilcap (999.60). The stop-marker clear was unconditional, so a cap the operator set that had not yet fired was silently dropped and the pipeline ran past the stage they named — observed live during phase 27's own dogfood run, which advanced into Ship unguarded. The clear is now gated on the pipeline having actually stopped- The Define stage no longer invokes an interactive interview headlessly (999.59). When
CONTEXT.mdwas absent, Define issued/gsd-discuss-phase, which hangs onAskUserQuestionunderclaude -pwith no operator present. The branch is deleted rather than flag-gated: whether to run an interview is decided beforedevflow startis ever invoked, so there is no runtime accommodation to make - All 41 production
gitinvocations are hermetic (phase 27). EveryCommand::new("git")in production code now routes throughgit_command/hermetic_command, including two indirectsh → cargo → gitedges andmonitor.rs's spawn of the coding agent itself — the highest-consequence site, and one that was not in the original grep scope. Under a hostileGIT_DIRthe suite went from 37 deliberately-failing tests to 0
Added
yes_shipis settable indevflow.toml(and viaDEVFLOW_YES_SHIP), in addition to the existing--yes-shipflag. The flag ORs with the config value rather than replacing it, so passing it always wins. A run whose authorization came from config prints a line namingdevflow.tomlas the source — a standing default, but never a silent onecheckpoint_auto_decidedevents in.devflow/events.jsonl, emitted before the relaunch spawns so a crash mid-relaunch still leaves the decision recordeddevflow-corepublic API:verify::phase_plan_files,verify::phase_has_blocking_human_checkpoint,agent_result::{claude_session_id, session_id_from_capture, blocking_human_checkpoint_reported, checkpoint_reported_in_capture},config::yes_ship, andState::{session_id, checkpoint_resumes}
Changed
Stateis now#[non_exhaustive]. Downstream crates must construct it throughState::newand assign fields afterward, rather than by struct literal. Deserialization is unaffected — every#[serde(default)]field still loads state written by older binaries.Stategains a field roughly every phase that introduces a run-scoped concept; without this, each addition is a semver-breaking change for any consumer using a literal. Paying that cost once here makes every future field additive--yes-shipmay now come from configuration, deliberately reversing the phase 23 decision that made it per-run only "so a standing unattended auto-merge can never become the silent default." That decision's own stated cost applies: relaxing this is easy, re-tightening it after operators depend on the persisted setting is not. The never-silent notice above is the compensating control. What did not change: the Ship gate still fires and still records an explicit, attributed approval rather than being bypassedsession_idis read only from the result envelope's top-level key, never from the agent-authoredDEVFLOW_RESULTmarker, and is deliberately not a deserialized field onAgentResult— otherwise an agent could nominate which session DevFlow resumes into