v0.9.1
Compatibility hotfix for the BMad Method's bmad-dev-auto → bmad-build-auto rename
(BMAD-METHOD#2651, first shipped in bmad-method 6.10.1-next.33) and the two upstream changes
that rode the same window. Both skill eras are supported; the rename itself needs no
policy.toml edit.
Fixed
-
The dev primitive is now resolved on disk, so the upstream rename no longer breaks a
project (#405).bmad-build-autois preferred and a marker-completebmad-dev-autois
accepted, sovalidate/run/sweep/resumepass on either era. The forwarding shim
upstream left behind is refused by a newskills.base-shimcheck — its migration prompt is
interactive and would HALT an unattended session with nothing written to disk. The ok line
andvalidate --jsonname the primitive that actually resolved, and worktree isolation
copies the new skill directory.[dev] skillstaysbmad-dev-auto: it is the adapter
discriminator, not the invoked name. -
Every session prompt spells the resolved primitive (#405). Dev, review, repair, restore,
stories dispatch and all three sweep bundle legs hardcoded/bmad-dev-auto, which
post-rename dispatches the shim. The name is resolved per skill tree, so a run mixing
.claude/skillsand.agents/skillsgets the right one per role, and the no-spec fallback
result marker is read under both thebmad-build-auto-result-*and legacy prefixes. -
--dry-runsays when its preview is not runnable (#405).run,sweepand stories dry
runs returned before their own skill preflight, so a broken install still got a
plausible-looking schedule — and post-rename the previewed/bmad-dev-autois a valid
command that would HALT on the shim. The preflight failures now print to stderr under a "NOT
runnable as-is" banner. Exit code stays 0 and stdout is untouched: a dry run is a diagnostic,
and rc 0 has always meant "the preview rendered", not "the project is ready". -
The skills preflight no longer gates a triage-only CLI's skill tree (#405). Every
skills.*check asks a dev-primitive question — which primitive resolves, whether the three
review hunters it invokes inline are installed, whether a renderer stub's script unit is
whole — yet the tree list was built from all three adapter roles. So a[adapter.triage]of
geminibeside a claude dev/review pair demanded the whole BMad Method module in
.agents/skills, a tree whose only prompt (/bmad-loop-sweep) ships in this wheel and is
laid down bybmad-loop init. The over-breadth is pre-existing and was already a hard block —
0.9.0 emittedskills.base-missingas a problem over the same over-broad tree list and its run
preflight refused on it — but the new renderer checks route through the same list, so 0.9.1
added new ways for one to fire. The probe — andvalidate's own copy of it, which had the same defect twice
— is now scoped to the dev and review roles, the same setEngine._worktree_profiles
provisions into a worktree, and theskills.baseandskills.stories-dispatchok lines
report that narrowertreeslist. Single-CLI projects and dev/review splits across two CLIs
are unaffected. -
A worktree that could not be given its upstream skills now pauses instead of stalling
(#405).provision_worktreecopies the BMad Method skills from the main repo behind a
containment guard, and skipped anything resolving outside the repo — which is exactly what a
skill tree symlinked to a shared machine-wide BMad install does. The run-start preflight stats
through that symlink and passes, so an isolated run dispatched into a worktree holding none of
its skills and every session stalled onUnknown commandhaving written nothing. Provisioning
now reports the skills it could not deliver through the existingworktree-seed-skipped
journal channel, and the engine escalates and pauses before dispatch, naming them — the same
environment-fault treatment the renderer surface already got, since the seed reads the same
repo for every story. Unlike the renderer legs this carries no renderer-era condition: a
missing skill stalls a session whether itsSKILL.mdrenders or is inline. It is scoped to the
primitive era the run actually dispatches, though — the skills list it reads names both eras
because copying one that isn't there is free, but a leftoverbmad-dev-autoshim no prompt
spells is not a stall to pause over. The copy itself now merges per file, so a worktree that
already holds part of a skill directory is completed rather than skipped whole, and the
completeness check walks the repo's skill directory exactly the way the copy walks it,
reporting every file the seed could not deliver, naming the file rather than the directory
whenever the directory itself arrived (a wholly-absent skill still reports the coarse rel). A
fixed list of required files would have covered three of the thirteen a realbmad-build-auto
install carries — and would go stale the next time upstream renames a step file. Containment
is checked per file too, so one skill file, or one whole sub-directory, symlinked to a shared
install outside the repo is refused rather than read through, and reported rather than
silently missing. A skill tree that is committed (symlink and all)
is checked out into the worktree normally and is unaffected. Pre-existing since 0.7.0, which
is where the containment-guarded seed loop first shipped; found while reviewing this release. -
A renderer stub that cannot compose a prompt fails the preflight (#405). Three new checks
blockvalidate/run/sweep/resume:skills.dev-renderer, when the resolvedSKILL.md
is the new renderer stub (BMAD-METHOD#2601) but its script unit is not whole —
_bmad/scripts/render_skill.pyor the_bmad/scripts/config_utils.pyit imports at module
scope;skills.dev-renderer-config, when a stub resolved but_bmad/config.toml— the
renderer's one required config layer — is absent; andskills.dev-renderer-sources, when the
skill's own render sources are short: noworkflow.mdfor the renderer to compose from, or a
[[bmad-snapshot:…]]token naming something the renderer will not load as a source. Every
route ends in a
session that Stops having written no spec, and every one of them is a fact about the install
rather than the story, so every story after it does the same. A missing config exitsHALT:;
a missing entry document or snapshot target does too. A missing script or sibling loses even
that line —uvfails before the renderer runs, orModuleNotFoundErrorfires above its own
guard — leaving only the stub's own instruction to report the output and halt. The sibling is
required only when the installedrender_skill.pyactually imports it, and the source check
asks what the install itself declares — save theworkflow.mdentry name, which upstream
hardcodes too — so a later renderer that inlines the helper or
reorganizes its step files is not refused. They block rather than warn because only a green is
untrustworthy (uv on PATH is never probed) — a red is conclusive. The config check is emitted
once per project and only when a stub actually resolved, so a pre-renderer install stays
silent, and--dry-runnames all three under its "NOT runnable as-is" banner. A fourth check,
skills.customize-legacy, is a warning: it fires when a tree resolved tobmad-build-auto
while an override still sits at_bmad/custom/bmad-dev-auto[.user].toml, where it no longer
applies — the session still runs, just unstyled. -
Worktree isolation carries the
_bmad/config surface (#405). The renderer-era primitive
is handed the worktree as its project root and hard-fails when that root has no_bmad/—
there is no walk-up — so on a project that gitignores it (most do, this one included) every
isolated session HALTed with nothing written.provision_worktreenow merge-copies the repo's
_bmad/per file, copy-when-absent, so a checkout that commits it keeps every tracked file and
only the gitignored layers are filled in._bmad/scripts/is seeded whole rather than curated
becauserender_skill.pybare-imports its siblingconfig_utils, and a seed that comes up
short — the realistic trigger is a symlinked_bmad/, how a shared BMad install is wired —
pauses the run when the resolved dev primitive is a renderer stub. Every story would drive
the same incomplete seed into the same result-less Stop, so it escalates once with the
worktree left mounted for inspection, rather than dispatching the whole backlog and reporting
0 done._bmad/config.tomlis checked the same way and named separately in the escalation:
a repo that centralises only that file behind a symlink seeds a complete_bmad/scripts/and
no config at all, which was silent from every angle — the repo-side preflight follows the
symlink and passes. On a pre-BMAD-METHOD#2601 inlineSKILL.mdnothing reads either path,
so the short seed costs the run nothing and stays an ordinary journaled report. Neither
sentinel can be forged: aworktree_seedentry spelling one is dropped rather than reported,
so a checkout that commits its whole_bmad/is never paused on a healthy worktree.
_bmad/render/is never seeded and is git-excluded inside the worktree, so the renderer's
in-session rewrite of it cannot be swept into a story commit;initnow gitignores it as
well, which is the only protection under the defaultisolation = "none". -
Worktree provisioning survives a filesystem it cannot fully read (#405). The
_bmad/seed
and the detector that checks it now share the skill seed's walk:rglobdoes not descend a
symlinked sub-directory and the detector mirrored the samerglob, so a symlinked
_bmad/scripts/libone level in was seeded as nothing and reported as complete — the
under-seed and the check that should have caught it were wrong together. Pointing inside the
repo it is now seeded; pointing outside it is still dropped by the containment guard, but the
gate can see it.
That walk descends symlinks, so it carries a branch-local cycle guard — without one,
_bmad/scripts/loop -> ..is copied at every depth until the kernel'sELOOPstops it, a risk
the oldrglobnever ran because it refused the descent to begin with. Per file, a source the
walk cannot see inside, an occupied destination and a failed read or write are each a skip the
skills gate then names — a source that could never be delivered at all, a dangling link or a
FIFO, is still dropped by both halves in silence, and_bmad/'s own report stays the two
renderer sentinels — rather than an exception out of the seed:provision_worktreeruns inside
Engine._run_isolatedwith notryaround it, so one unreadable skill file ended the whole run
with a crash dump where a named, resumable escalation belonged. A dangling destination symlink
now counts as occupied: writing through one landed the bytes at the link's target and left the
gate reading green through the now-live link. Every probe the skills and_bmad/seeds make is
now total — and, the part atryalone does not buy, reads the same on every supported
interpreter.is_file()/exists()/is_dir()raise on an unsearchable parent through 3.13 and
answerFalseon 3.14, whereFalseis the silent drop and not the cautious reading, so a
Falsefromis_dir()is re-asked ofstat(), which still raises there. Two faults reach that
split: a directory readable but not searchable (mode0o444, where listing succeeds and
stat-ing each child does not) and an unreadable skill tree one level above the walk's own root.
What the report can say degrades with what the filesystem gives up: a directory that cannot be
listed is named once, and one that can be listed but not stat'd through is named file by file.
The eager copies are unchanged and can still raise — user-authoredworktree_seed, the
adapter-default and plugin seeds beside it, and the per-CLI hook-config write. -
A seed the worktree never got is now reported, and the
_bmad/shield writes one exclude line,
not two (#405). The two explicit seed loops drop an entry with a barecontinuewhen the
resolve-and-contain guard refuses it, so a config the repo carries as a symlink out of itself —
a dotfile-managed.claude/settings.json, a shared MCP config — delivered nothing and said
nothing:worktree-seed-skippedreports only the opposite case, an entry whose destination
already exists. Provisioning's result is now re-probed against the repo and the drops journaled
asworktree-seed-dropped, asking the two trees on disk rather than the loops' bookkeeping, so a
drop by any future path is caught the same way. Journaled and never escalated, unlike the
_bmad/and skills gates beside it: those name files the orchestrator dispatches or the renderer
HALTs on, while a seed entry is arbitrary user config whose canonical trigger is an ordinary
working setup, so pausing would refuse every run of such a project over a guard doing its job. A
broken link stays silent in both halves, as it already does for the skills gate; a FIFO does
not — these loops gate onexists(), so the copy reaches it and raises. The eager copies are
still unchanged: a copy that fails raises rather than landing here.
/_bmad/render/goes into.git/info/excludeonly when the blanket/_bmadthis release also
adds is not — that file lives under the git common dir a linked worktree shares with the main
checkout and nothing here ever prunes it, and/_bmadprunes the directory before git descends,
so a sibling line would be permanent state in the operator's own repo that git never consults.worktree-openedis
journaled as soon as the worktree is mounted rather than after every provisioning gate has
passed, so the escalations that leave a half-provisioned worktree mounted for inspection now say
where it is. -
Seeding never writes through a symlink, and a hook config is no longer its own alibi
(#405). Both explicit seed loops resolved the destination and then used the resolved path for
the occupancy probe, themkdirand the copy.Path.resolve()is non-strict, so a dangling
link answers for its target:exists()reported the slot free and the copy landed at the link's
target instead — an unrequested path inside the worktree that the exclude does not name and the
unit'sgit add -Awould stage into the story branch.git worktree addproduces exactly that
state for a tracked symlink whose target is untracked, so it arrives on a project's first
story. Both loops now compare the raw destination against the resolved one and refuse when they
differ, each with its own guard — they share no code — and strictly after the
copy-when-absent skip arm, since a live symlinked destination differs too and hoisting the
refusal would turn that ordinary no-op into a silent drop. What is refused is named afterwards
byworktree-seed-dropped, as every other refusal in those loops already is. The drop report in
turn stops trusting the destination for the one rel that cannot answer for itself: the per-CLI
hook step writesprofile.hooks.config_pathafter both seed loops, so a config the loops
dropped is answered for by the hook's own bytes — and for gemini, copilot and antigravity that
path is the profile's only default seed, so the false green was the gate's entire answer.
Those rels are now asked whether the source escapes the repo, the destination is a symlink, or
the destination escapes the worktree, replacing the existence probe rather than adding to it so
a destination that trips two of them is still named once. A live symlinked destination for a
hook config is consequently named here as well as inworktree-seed-skipped; both facts are
true at once and this channel journals rather than pauses. Two destination-containment guards —
the_bmad/merge's and the skill merge's — also gain their first tests: their existing
siblings arm the source guard and the occupancy check instead, so a live symlinked directory
left them unwitnessed. -
isolation = "worktree"is refused whenrepo_rootis overridden (#405). A project whose
_bmad/bmm/config.yamlsetsrepo_rootdecouples the git root from the project dir — the
documented monorepo knob — but worktree provisioning readsrepo_rootfor every surface it
seeds off disk (the upstream skill trees,_bmad/and its_bmad/custom/overrides, every
seed_files/seed_globsentry) and bakes the absolute hook-relay path from it into the
worktree's hook config, whileinit,validateand the run
preflight write and probe them underproject.load_pathsrequires
project/_bmad/bmm/config.yaml, so that surface is underprojectby definition and
repo_root/_bmad/generally does not exist: the preflight approved a surface the isolated run
never received, every seed-completeness gate above went inert rather than firing, and each
story ended as a result-less Stop with nothing naming the cause.validatenow reports the
pair as apolicy.isolation-repo-rootproblem, andrun,sweep,resume,resolve's
re-arm and the auto-triggered child sweep refuse to start, naming both remediations — drop the
repo_rootoverride, or setisolation = "none". The child sweep raises rather than declining
quietly, so a mid-run flip is journaled assweep-auto-failedinstead of being recorded as a
sweep that ran. The dry-run banner lists the refusal ahead of the skill
ones it aborts before, and the TUI's pre-launch guard mirrors it — ahead of its own clean-tree
gate, as the CLI does — so the operator gets a toast rather than a pane that dies.
Pre-existing since worktree isolation shipped. Plumbing
projectthrough provisioning instead — which would make the combination work rather than
refuse it — stays open as #414. -
validatewarns when the renderer's output is already committed (#405). The two shields
0.9.1 adds — the_bmad/render/lineinitwrites into.gitignore, and the/_bmad/render/
line provisioning writes into the repo's local git exclude — only help going forward, because a
tracked path ignores both entirely. A project that committed_bmad/render/earlier keeps
churning it into every story commit, gaining a snapshot directory per machine, per checkout path
and per upstream renderer bump, since the path is keyed on a hash of the absolute project root
plus a generation hash over the renderer, its sources and the resolved config. A new
git.render-trackedwarning names the one-time
git rm -r --cached _bmad/render. A warning and not a problem — nothing about a tracked
_bmad/render/stops a session — and outside a git repo it stays quiet rather than fabricating
an ok (#409). All three sites now spell the path from oneinstall.RENDER_DIR_REL, so the
probe cannot drift away from the shields it reports on. -
verify's emptiness probes no longer read their answer out of git's stderr (#405).
worktree_clean— andpath_tracked, added earlier in this release — tested stdout and stderr
merged, butls-filesand
statusexit 0 while still writing to stderr — acore.fsmonitorhook that cannot exec, an
unknowncore.fsyncMethod— and that chatter is indistinguishable from an index entry or a
porcelain line. Both answers were silently inverted: the newgit.render-trackedcheck would
have told operators togit rm -r --cacheda path that was never committed, and a checkout with
nothing in it was reported dirty, blockingrun,sweepandvalidateoutright. Both now read
stdout alone; the error paths keep the merge, where stderr is the informative half. -
An undecodable
policy.tomlorconfig.yamlis reported, not a traceback (#405).
read_textraisesUnicodeDecodeErroron a file saved as UTF-16 or latin-1, and that is a
ValueError, not anOSError— so it escaped everyexcept (PolicyError, OSError)handler in
the codebase, which are precisely the ones whose job is to degrade to defaults. The TUI could
not open its dashboard, and_configure_muxruns before argument dispatch on every command, so
the CLI died at startup on the raw codec message its catch-all prints, naming no file, instead
of any of its named findings.policy.loadandbmadconfig.load_pathsnow convert it to their
own typed errors, which fixes every caller at once;validatereports the file by name. -
Deferred review findings are harvested out of the spec's frontmatter (#405).
BMAD-METHOD#2640 moveddefer-triaged findings fromdeferred-work.mdinto the spec's own
deferred:list, silently starving the sweep pipeline. A successful dev or review session
now files each finding into the ledger with itslocationandseverity, journals
spec-deferrals-harvested, and dedups on a fingerprint of the summary and location so a
fixable retry, a resume replay or a second review pass never re-files one — including after a
sweep has already marked it done. (A NON-fixable retry re-files deliberately: the bullet below
reverts its entry along with the attempt, and the next attempt harvests it fresh.) Malformed items do not block their well-formed siblings: the loss is
journaled and filed as one aggregated ledger entry. The spec's frontmatter is never rewritten,
and the harvest keys on content rather than the installed skill name, so it works on both eras.
The review prompt no longer also asks the session to file the finding itself: that produced two
entries per finding which could never dedup, since an agent-written one carries neither the
fingerprintedorigin:nor asource_spec:line. It stays neutral rather than banning ledger
edits outright — on a pre-BMAD-METHOD#2640 skill there is no frontmatter to harvest and the
session's own append is the finding's only record — and still forbids rewriting existing entries. -
A harvested deferral is reverted when its attempt rolls back (#405). The harvest keys on
the spec's status and runs before the artifact gate, so a session that finalized its spec and
then failed a non-fixable check (abaseline_revisionmismatch is the canonical trigger) left
its ledger entry behind, describing code the rollback had just discarded. The reset alone does
not remove it: the ledger sits under a protected artifact folder, which_safe_reset'skeep
shields from the untracked-file cleanup. The dev phase now snapshots the ledger before the
harvest and restores it around the rollback — unlinking the file when the harvest created it,
and on the stop-and-wait path too, which raises. Lossless, because the spec'sdeferred:
frontmatter is never mutated and the next attempt re-harvests from it. The snapshot is taken
ahead of every engine-side ledger write in that window rather than immediately ahead of the
harvest, so one growing inside the status reconcile or the state sync is covered by
construction. And it is persisted with the attempt rather than held in a local, so a
host death between the harvest and the rollback no longer loses it: the replayed attempt
writes back the same bytes the live path would have. That replaces a presence bit which could
only say whether a ledger existed at the attempt baseline — enough to delete one the harvest
had created, never enough to restore one that was already there, so a pre-existing untracked
or gitignored ledger kept the dead attempt's finding for good. Nothing else reverts one:
reset --hardskips ignored paths, there is nogit clean, and the artifacts dir is
keep-shielded besides. The restore never unlinks a ledger git tracks — that one is the reset's
to restore, and deleting it would hand the next attempt'sgit add -Aa deletion to commit. The
snapshot is armed at the attempt's pre-harvest save and cleared once that attempt's decision is
acted on, so a finished story carries no copy of the ledger instate.json. The clear on the
PROCEED path persists immediately, because a hard kill there resumes straight into the review
leg and would otherwise strand that copy on a task that is already terminal; the retry site
deliberately does not, since the same kill replays that attempt and it still wants the
snapshot — except when that retry pauses, which the next bullet covers. A defer still
keeps its harvested entries: on the in-place path_stash_deferred_artifactshas already moved
the spec out of the artifacts dir, so the ledger entry is the finding's only surviving record. -
The pre-harvest snapshot is spent by a stop-and-wait pause instead of riding it (#405).
rollback_on_failure = offis the default and does not roll back: it hands the tree to a human
and raises. The window that opens is not a crash window but a human-scale one — the operator
reads the notice, inspects the tree, and may well append to the deferred-work ledger before
typingresume. The armed snapshot used to survive that wait instate.json, and the resume,
which replays the same attempt, wrote those stale bytes back over whatever the operator had
done: an unlink when no ledger existed at the attempt baseline, and otherwise an overwrite,
which has no tracked-file guard, so a committed ledger was rewritten too. Nothing spent the arm
either, so it happened again on every subsequent resume. The pause leg now disarms in the same
finallythat restores, strictly after it, and persists that immediately — the point is the
value the resume reads off disk. The replayed attempt then re-arms from the tree as the
operator left it, so its own harvest has something to revert to if it fails in turn. Replays
that never reached the arm re-arm the same way rather than proceeding blind, which retires the
ledger-snapshot-missingjournal line for that case. -
A harvest is reverted even when the ledger lives outside the repo (#405).
implementation_artifactsmay be configured out of tree —ProjectPaths.rebasedkeeps such a
dir put on purpose, as shared rather than per-checkout. The restore classified any ledger
outside the workspace as git's and left it alone, which is backwards:reset --hardruns in
the workspace and cannot reach a path outside it, so it restored nothing and the harvest's file
is a creation of the orchestrator's own. Every harvest revert on such a project was therefore a
silent no-op, and the entry outlived the code it described — open in the shared ledger, and
swept later as if that work still existed. It now reads as "not git's", on both retry legs and
under either isolation mode. The containment test is re-asked with both sides resolved before
that answer authorizes a delete, since a workspace root reached through a symlink is not
lexically a prefix of the ledger's real path; anOSErrorthere degrades to leaving the file
alone, like every other probe in thatfinally. -
The harvest's own ledger write is no longer the session's proof of work (#405). The harvest
runs four statements above the dev artifact gate, and that gate deliberately does not exclude
the ledger — a story whose whole authorized scope is ledger reconciliation has to register as
real work. So the gate could not tell the orchestrator's write from the session's: a session
that finalized its spec, changed no code and recorded onedeferred:finding proceeded to done
on the strength of the line the engine had just written for it. The gate now excludes the ledger
on exactly the attempts whose harvest filed into it, keyed on a flag persisted with the attempt
rather than re-derived — a crash replay re-runs the harvest, which dedupes against the dead
attempt's entries and so reports filing nothing while those entries are still in the tree.
Narrow by construction: a session's own ledger edit still counts as work on every attempt that
did not harvest, and the flag is cleared per attempt, so a later attempt is never charged for an
earlier one's harvest. All three dev legs pass it through — sprint story, sweep bundle, and
folder+id stories. The bundle leg is the costlier one, since an attempt let through there marks
real idsdoneandopen_idsre-bundles onlyopenentries. -
…and a session's own ledger edit on that same attempt still is (#405). That exclusion is
path-granular — it hides the whole ledger relpath, not the harvest's lines — so on an attempt
where both authors wrote, the session's edit went out with the orchestrator's. A
ledger-reconciliation story that also recorded onedeferred:finding therefore read as "no
changes since baseline", and the non-fixable retry that follows pauses the whole run under the
defaultscm.rollback_on_failure = false. The gate now stands down whenever the ledger had
already moved off the dev phase's baseline by the time that attempt's pre-harvest snapshot was
armed: its diff is then provably not the harvest's alone, and the gate judges the tree in full —
which is the conservative direction, since it sees more of the diff rather than less. The
baseline is a digest persisted besidebaseline_commit, and persisting it is the point: a
resumed dev phase deliberately does not re-capture its baselines, so a phase-local reference
would be absent for every attempt after a crash-resume and the mask would survive there.
Introduced earlier in this same release. -
…and both answers now span a fixable retry (#405). The two bullets above scope every piece
of harvest provenance to one attempt, and the dev phase's ledger baseline to the whole phase.
Those coincide on every run where at most one attempt's work sits abovebaseline_commit— and
a fixable retry is the one construct that breaks that: it keeps the tree, and the harvest's
ledger line with it, on purpose. So the repair session was judged against a baseline that
predated the surviving line: the flag was cleared, the exclusion stood down, and an attempt that
reverted the offending change and wrote nothing else passed the proof-of-work gate on the
orchestrator's own write — with the[verify] commandsthat had failed now passing precisely
because the change was reverted. (Only for a tracked or untracked-but-not-ignored ledger; an
ignored one is invisible to the gate either way.) Both records now survive the continuation, and
the ledger baseline is re-based to match — forward onto the kept tree when the fixable retry is
taken, back onto the restored snapshot if that chain later rolls back. Latching the answer
instead is the obvious pairing and re-opens the bullet above: the mask is path-granular, so a
stale one hides a repair session whose honest work genuinely is a ledger entry, and that pauses
the run. Same continuation, same reason, for the record of what the harvest intended to file: a
fixable retry followed by a stalled attempt used to defer with an empty record while the entry
it named sat in a worktree about to be dropped. -
The pre-harvest snapshot is scoped to the retry chain, not to one attempt (#405). The
rollback it feeds always resets to the dev phase'sbaseline_commit, so after a fixable
retry — which keeps its tree, and its harvest, on purpose — a later non-fixable failure
discarded several attempts' code while restoring only the last one's ledger. The kept
attempt's finding was written straight back over the reset that removed the code it named:
open in the ledger, and driven by the next sweep as if that work existed. A fixable retry now
neither re-arms the snapshot nor spends it, so the chain holds the one taken before its first
attempt and the revert reaches as far as the reset does. Both halves are required; either
alone leaves the entry standing. The rolling ledger reference is re-based back to the restored
bytes at the same point, or the next attempt reads the restore itself as somebody else's write
and stands the proof-of-work exclusion down. On therollback_on_failure = offdefault the
tree is kept and the restore now reverts the whole chain's ledger writes rather than the last
attempt's — matching thegit reset --hard <baseline_commit>the pause notice tells the
operator to run. The disarm that spent the snapshot on both retry legs moves onto the
non-fixable one, where it belongs; the stale comment claiming a fixable retry needed it, and
claiming the pause leg could not reach it, is replaced (the pause has disarmed in its own
finallysince0a8088f, and a fresh attempt re-arms unconditionally, so neither clause held).
Not closed here:_finish_inflight's restart arm resets to the same baseline with no ledger
restore at all, and cannot simply gain one — it also handles the resolved-escalation re-drive,
which preserves the artifacts folder through the reset on purpose. -
A sweep bundle's ledger closes are withheld until its attempt is accepted (#405). The
orchestrator marks a bundle's deferred-work idsdoneitself, and did so above the artifact
gate — so an attempt that finalized its spec and then failed a non-fixable check was discarded
with the ledger already claiming its work resolved. A surviving close is worse than a surviving
finding:open_idsonly ever re-bundles open entries, and the one reopen —mark_open, which
undoes only a close its own caller wrote — is not a general one, so no
later sweep looks at that id again and the work is silently lost rather than mis-recorded. On
the retry leg the dev phase's ledger snapshot reverted it; on the DEFER terminus nothing in the
dev phase could, because_defertakes its own snapshot after the close and writes it back
over its own reset — deliberately, so review-found deferrals survive a defer. The close now
runs below the gate, for a PROCEED decision only, which also excludes a CRITICAL escalation
(it preempts even a passing outcome) and a failing[verify] commandsrun. What remains after
acceptance is covered by the bullet below rather than by a revert. One deliberate consequence: that write no longer counts toward the gate's
proof-of-work diff, so a bundle session which changed no code now fails the gate instead of
passing on the orchestrator's own bookkeeping. -
A review-leg defer takes the bundle's ledger closes back (#405). The bullet above stops a
DISCARDED dev attempt from closing anything; a bundle accepted at dev that then defers in
review is a different shape. There the closes were correct when written and are required —
verify_review_bundlegates on them — and only the later failure makes them wrong._defer
then writes its own post-close ledger snapshot back over thereset --hardthat had reverted
them, deliberately, since a harvested finding's ledger entry is its only surviving record once
the spec is stashed. But the restore replays the whole file, so the closes rode it too and
named code that no longer existed — on every route into a post-acceptance defer: review budget
exhausted, the repair phase exhausted after a clean review, the budget rescue's own verify
failing,review.enabled = falsefailing at its gate, and a blocking workflow deferring from
post_dev_phase/post_review_result/pre_commit_gate. The defer now re-opens the ids it
closed itself.deferredwork.mark_openis the undo of one specificmark_done, not a general
reopen: it refuses any entry that is not closed carrying exactly the resolution note the caller
wrote, so a close from an earlier sweep, from the legacy path where the session edits the
ledger, or from a human is never revoked — and the round trip restores what the close changed,
character for character. Unchanged on the
stop-and-wait path (rollback_on_failure = offkeeps the tree, so there is nothing to undo)
and under worktree isolation (those closes live in the unit's own worktree, dropped unmerged). -
A deferred worktree unit carries its harvested findings out (#405). Under
isolation = "worktree"the harvest writes the unit worktree's ledger, and a defer drops that
worktree unmerged — so no ledger a sweep reads ever saw the findings. (Nothing was destroyed:
keep_faileddefaults on and the forensic patch takes untracked files, so they survive in the
kept worktree.)_defernow re-files what the final attempt's harvest intended to file into the
main checkout's ledger and commits that path alone — leaving it dirty would escalate the next
story's merge.append_entrydedups onorigin:+source_spec:, so an entry already open
there is not duplicated. Only the final attempt's record carries: it is cleared at each attempt's
start, so an attempt whose session never completed cannot re-file the findings its predecessor's
rollback removed. The gap pre-dates this release; the harvest added a systematic producer of
such entries. -
…and so does a unit that LANDS (#405). A landing unit looked untouched — its ledger edit
merges with the branch. Not when the ledger is gitignored:finalize_commitstages with
git add -A, which skips such a path in silence, so the entry never rode the branch and the
merge brings nothing over. (This repo's own default puts the ledger under a gitignored artifacts dir.)
The worktree then goes unconditionally, and the DONE leg takes no forensic patch, so the finding
simply disappears. That leg now runs the same carry — after the merge, since carrying first
files a fresh id where the merge would have delivered the entry for dedup. A tracked ledger
rides the branch as before and every row dedups, so nothing is committed. Not on the escalation
legs: those keep the branch for a human to merge, and that merge brings the entry with it. -
A sweep bundle's ledger closes reach the main checkout too (#405). The same mechanism one
layer up. The close writes the unit worktree's ledger, and with a gitignored ledger seeded into
the worktree (scm.worktree_seed) the flip dies unmerged — the entries stay open,open_ids
re-bundles them, and every later sweep re-triages and re-drives work that is already done. The
DONE leg now re-applies the closes the bundle intended;mark_doneis idempotent, so a tracked
ledger whose flip merged normally is a no-op. What is recorded is what the close intended,
not what it flipped: the close runs twice on a landing bundle — after dev, and again from the
review gate's idempotent reclose — and the second run finds every id already done, so a record
of the flips is empty on exactly the run that needs it. A DEFER still carries findings only and
never closes: it discarded the code they claim to resolve. Not closed here: an unseeded
gitignored ledger is absent from the worktree entirely, soverify_review_bundlenever sees the
ids done and the bundle defers rather than landing — loud, where this one was silent. -
…and a carry lost to a crash mid-landing replays on resume (#405).
Phase.DONEis
persisted before the merge runs, the merge ends by removing the worktree, and only then does
the carry fire — so a host death in that window destroyed the worktree's copy and skipped the
carry, and resume never looked again (it skips terminal tasks). The sibling DEFER leg carries
before its terminal save, which is why only DONE had the hole. Nothing was unrecoverable:
both payloads are already instate.json, so only the replay was missing. Resume now re-runs
an unlatched carry, once, guarded by a persisted latch — the writes are only partly idempotent,
sinceappend_entrydedups against open entries and a later close would turn a blind
replay into a duplicate. Skipped when the worktree is still mounted (the branch may never have
landed, and the human's merge would bring the entry itself), on the in-place path, and on any
phase but DONE. The sweep half is the sharper one: a lost close leaves ids open, the
suppression filter does not cover DONE, and every later sweep re-drives already-merged work
into a non-fixable retry that pauses the run. -
An artifacts dir whose name holds
[or]no longer misdirects the two paths above
(#405, #423). Git reads a positional operand as a pathspec, so such a path was a glob that
also matched its neighbours. The harvest revert's tracked-probe answered "git owns it" off a
neighbour and skipped its unlink, leaving the entry open for a later sweep; and the carry's
commit swept a neighbour's uncommitted edit in under the story's name — or, for the gitignored
ledger it is built to hit, staged nothing and reported success, sincegit addrefuses an
explicitly-named ignored path but skips a globbed one. Both now name the path literally. -
Three small contracts brought back into line (#405). A ledger entry filed with no file:line
now carrieslocation: n/arather than nolocation:line:deferred-work-format.mdhas
always specified that value, and of the fields an entry is created with,severity:is the
only one it calls optional — so the orchestrator's own writer was the one producer disagreeing
with the format the sweep skill reads. The harvest's 200-character clamp can land on a word
break; the value is now stripped where it is produced rather than where it is written, so the
origin:dedup key and thelocation:line stay derived from the same string. Entries already
on disk without the line stay valid — read an absentlocation:asn/a. A plugin workflow's
completion-marker filename is now resolved on the skill tree of the adapter its ownroleruns
on, not always the dev one, so a run mixing.claude/skillsand.agents/skillsat different
upstream eras no longer spells a review session's marker after a primitive only the dev tree
carries. That one is the two halves agreeing rather than a repair: read-back was never at risk,
because both prefixes are matched unconditionally. Anddiagnosenow pseudonymizes thespec
journal field, which is the customer's feature name and was shipping verbatim — the egress
backstop could only ever rescue the ones that happened to embed the story key. The value is
reduced to its basename first: the harvest kinds journal a bare filename while the reconcile
kinds journal an absolute path, so aliasing it raw would have given one spec two aliases in a
dump and parked a home path in the local--legendfile.