Skip to content

Commit afc55d7

Browse files
authored
Merge pull request #13 from bmad-code-org/release/0.7.1
release: 0.7.1 — escalation auto-recovery + Log-tab underline fix
2 parents dc8eaec + 05135d9 commit afc55d7

17 files changed

Lines changed: 505 additions & 84 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "bauto",
1313
"source": "./src/automator/data/skills",
1414
"description": "Automation-mode skills driven by the bmad-auto orchestrator: interactive escalation resolution (bmad-auto-resolve) and deferred-work sweep triage (bmad-auto-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill",
15-
"version": "0.7.0",
15+
"version": "0.7.1",
1616
"author": {
1717
"name": "pinkyd"
1818
},

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ All notable changes to `bmad-auto` are documented here. The format is based on
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the project is pre-1.0,
66
breaking changes may land in a minor release.
77

8+
## [0.7.1] — 2026-06-25
9+
10+
### Fixed
11+
12+
- **The Log tab no longer renders whole CLI sessions underlined.** Modern CLIs emit an XTMODKEYS
13+
sequence (`CSI > 4 ; 2 m`, "modifyOtherKeys") at startup that the pane emulator (pyte) misread as
14+
SGR 4 / underline-on — with no matching off present in a live capture — so every line came out
15+
underlined and hard to read. The log view now strips private-marker CSI sequences before emulation;
16+
genuine color, bold, and properly-closed underline styling is preserved.
17+
18+
- **Resolving a CRITICAL escalation no longer loops on a manual-rollback prompt when the resolve
19+
edited the spec.** 0.7.0 fixed the loop only for an already-clean tree, but the resolve workflow's
20+
whole job is to correct the frozen spec under the BMAD artifact folder (`_bmad-output/...`, which is
21+
tracked). So on resume the orchestrator saw a dirty tree and — with the default
22+
`scm.rollback_on_failure = false` — paused for a manual reset; because the dirty check diffs against
23+
the frozen `baseline_commit`, even committing the spec re-paused on the next resume, an endless loop.
24+
A resolved re-drive is human-initiated, so it now always auto-recovers regardless of the flag: the
25+
BMAD artifact folders are treated as orchestrator-owned — excluded from the dirty check and preserved
26+
through every reset of the re-drive (not just the resume-time cleanup) — so the spec correction
27+
survives while the failed attempt's source changes revert to baseline. This closes a latent sibling
28+
bug: with `rollback_on_failure = true` a _later_ mid-re-drive retry/defer reset previously ran with no
29+
preserve set and reverted the just-corrected spec silently, looping the re-drive.
30+
`scm.rollback_on_failure` still defaults OFF and now governs only unattended/stopped attempts; the
31+
manual-recovery notice (reached by stopped attempts only now) drops its resolved-cause wording.
32+
33+
- **A failed artifact restore during rollback now surfaces instead of silently dropping the
34+
correction.** When `safe_rollback` restores the preserved BMAD folders from its pre-reset snapshot, a
35+
genuine `git checkout` failure (corrupt snapshot, lock, IO) was swallowed alongside the benign
36+
empty-dir "pathspec did not match" case — so a corrected spec could vanish with no error and loop the
37+
re-drive. Real failures now raise; the empty-dir case stays tolerated.
38+
839
## [0.7.0] — 2026-06-24
940

1041
### Changed

module.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
code: bauto
22
name: BMAD Auto Skills
33
description: "Automation-mode skills driven by the bmad-auto orchestrator: interactive escalation resolution (bmad-auto-resolve) and deferred-work sweep triage (bmad-auto-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill"
4-
module_version: 0.7.0
4+
module_version: 0.7.1
55
default_selected: false
66
module_greeting: >
77
BMAD Auto installed — both the automation skills and the

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "bmad-auto"
7-
version = "0.7.0"
7+
version = "0.7.1"
88
description = "Deterministic ralph-loop orchestrator for the BMAD implementation phase"
99
readme = "README.md"
1010
license = "MIT"

src/automator/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
spec files, and the per-run directory under .automator/runs/.
77
"""
88

9-
__version__ = "0.7.0"
9+
__version__ = "0.7.1"

src/automator/data/settings/core.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ key = "rollback_on_failure"
232232
kind = "switch"
233233
default_ref = "ScmPolicy.rollback_on_failure"
234234
label = "auto-rollback failed attempts"
235-
description = "⚠ in-place mode (isolation=none): when ON, a failed attempt's tracked changes are auto-reverted and the untracked files this run created are deleted (its uncommitted work is lost). When OFF (default), the orchestrator never touches your tree — it pauses with manual recovery steps. Prefer isolation=worktree to keep failures off your main checkout."
235+
description = "⚠ in-place mode (isolation=none): when ON, a failed attempt's tracked changes are auto-reverted and the untracked files this run created are deleted (its uncommitted work is lost). When OFF (default), the orchestrator never touches your tree — it pauses with manual recovery steps. Governs unattended/stopped attempts only: a resolved escalation's re-drive always auto-recovers regardless (reverts the failed source, keeps the corrected spec). Prefer isolation=worktree to keep failures off your main checkout."
236236
[[section.field]]
237237
key = "seed_adapter_defaults"
238238
kind = "switch"

src/automator/data/skills/bmad-auto-setup/assets/module.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
code: bauto
22
name: BMAD Auto Skills
33
description: "Automation-mode skills driven by the bmad-auto orchestrator: interactive escalation resolution (bmad-auto-resolve) and deferred-work sweep triage (bmad-auto-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill"
4-
module_version: 0.7.0
4+
module_version: 0.7.1
55
default_selected: false
66
module_greeting: >
77
BMAD Auto installed — both the automation skills and the

src/automator/engine.py

Lines changed: 80 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -637,82 +637,99 @@ def _pick_next(self):
637637
continue
638638
return story
639639

640+
def _protected_relpaths(self) -> tuple[str, ...]:
641+
"""Repo-relative posix paths of the BMAD artifact folders. These are
642+
orchestrator-owned: never counted as a dev attempt's dirtiness (the
643+
resolve workflow corrects the frozen spec here) and preserved through
644+
rollback. Folders configured outside the repo are skipped — nothing to
645+
protect there."""
646+
out: list[str] = []
647+
for protected in (
648+
self.workspace.paths.output_folder,
649+
self.workspace.paths.implementation_artifacts,
650+
self.workspace.paths.planning_artifacts,
651+
):
652+
try:
653+
out.append(protected.relative_to(self.workspace.root).as_posix())
654+
except ValueError:
655+
pass # configured outside the repo; nothing to protect here
656+
return tuple(out)
657+
640658
def _rollback_or_pause(self, task: StoryTask, *, cause: str = "stopped") -> None:
641659
"""Recover from an in-place attempt that won't proceed.
642660
643661
No-op when the tree is already at the attempt's baseline (nothing this
644-
attempt touched): neither a reset nor a pause is needed. This is also
645-
what lets the manual-recovery instructions terminate — after the operator
646-
resets and resumes, the now-clean tree skips straight through instead of
647-
re-pausing on the still-set ``baseline_commit``.
648-
649-
Otherwise, with ``scm.rollback_on_failure`` OFF (default) the orchestrator
650-
never touches the working tree: it emits a bold manual-recovery notice and
651-
pauses the run (stop-and-wait), so nothing proceeds on a half-finished
652-
tree. With it ON, it does the safest possible automatic rollback —
653-
revert the attempt's tracked changes to baseline and delete only the
654-
untracked files this run created (the whole BMAD output folder and every
655-
pre-existing untracked file are preserved; there is no blanket
656-
``git clean``). ``cause`` tunes the manual notice's wording."""
662+
attempt touched, ignoring orchestrator-owned artifact folders): neither a
663+
reset nor a pause is needed. This is also what lets the manual-recovery
664+
instructions terminate — after the operator resets and resumes, the
665+
now-clean tree skips straight through instead of re-pausing on the
666+
still-set ``baseline_commit``.
667+
668+
A ``cause="resolved"`` re-drive is human-initiated (the operator ran the
669+
resolve workflow and re-armed the story), so it always auto-recovers and
670+
never pauses, regardless of ``scm.rollback_on_failure``. For the entire
671+
re-drive (``task.resolved_redrive``, latched at resume and cleared once the
672+
correction is committed) the BMAD artifact folders are treated as
673+
orchestrator-owned: excluded from the dirty check (the corrected spec must
674+
not read as a failed attempt) and preserved through every reset — so a
675+
later mid-re-drive retry/defer reset can't silently revert the correction.
676+
677+
Otherwise (a stopped/abandoned attempt) the flag governs: OFF (default)
678+
leaves the working tree untouched and emits a bold manual-recovery notice
679+
that pauses the run (stop-and-wait); ON does a clean reset to baseline.
680+
Either way pre-existing untracked files are preserved; there is no blanket
681+
``git clean``."""
682+
resolved = cause == "resolved"
683+
# preserve the corrected spec for the whole re-drive, not just the first
684+
# reset; the auto-recover (pause-vs-reset) decision below is unaffected.
685+
redrive = resolved or task.resolved_redrive
686+
protected = self._protected_relpaths() if redrive else ()
657687
if task.baseline_commit and not verify.attempt_dirty(
658-
self.workspace.root, task.baseline_commit, task.baseline_untracked
688+
self.workspace.root, task.baseline_commit, task.baseline_untracked, exclude=protected
659689
):
660690
self.journal.append("rollback-skipped-clean", story_key=task.story_key)
661691
return
662-
if not self.policy.scm.rollback_on_failure:
663-
self._pause_for_manual_recovery(task, task.baseline_commit or "", cause=cause)
664-
return # unreachable: _pause_for_manual_recovery always raises
665-
self.journal.append(
666-
"rollback-auto",
667-
story_key=task.story_key,
668-
baseline=task.baseline_commit or "",
669-
note="reverting tracked changes + run-created untracked files",
670-
)
671-
self._safe_reset(task)
692+
if resolved or self.policy.scm.rollback_on_failure:
693+
self.journal.append(
694+
"rollback-auto",
695+
story_key=task.story_key,
696+
baseline=task.baseline_commit or "",
697+
note="reverting tracked changes + run-created untracked files",
698+
)
699+
self._safe_reset(task, preserve=protected)
700+
return
701+
self._pause_for_manual_recovery(task, task.baseline_commit or "")
702+
return # unreachable: _pause_for_manual_recovery always raises
672703

673-
def _safe_reset(self, task: StoryTask) -> None:
704+
def _safe_reset(self, task: StoryTask, *, preserve: tuple[str, ...] = ()) -> None:
674705
"""Revert tracked changes to the task baseline and remove only the
675706
untracked files this run created — never a blanket `git clean`. Used by
676-
the gated rollback (when enabled) and by internal ledger recovery (sweep
707+
the gated/resolved rollback and by internal ledger recovery (sweep
677708
migration), which restores the orchestrator's own state and must not
678-
pause."""
679-
keep = [".automator"]
680-
for protected in (
681-
self.workspace.paths.output_folder,
682-
self.workspace.paths.implementation_artifacts,
683-
self.workspace.paths.planning_artifacts,
684-
):
685-
try:
686-
keep.append(str(protected.relative_to(self.workspace.root)))
687-
except ValueError:
688-
pass # configured outside the repo; nothing to protect here
709+
pause. The BMAD artifact folders are always kept from untracked deletion;
710+
``preserve`` (set only on a resolved re-drive) additionally keeps their
711+
*tracked* content alive through the reset, so a just-corrected spec is not
712+
reverted. Sweep passes no ``preserve`` — it wants the broken ledger gone."""
689713
verify.safe_rollback(
690714
self.workspace.root,
691715
task.baseline_commit or "",
692716
baseline_untracked=task.baseline_untracked,
693-
keep=tuple(keep),
717+
keep=(".automator", *self._protected_relpaths()),
718+
preserve=preserve,
694719
)
695720

696-
def _pause_for_manual_recovery(
697-
self, task: StoryTask, baseline: str, *, cause: str = "stopped"
698-
) -> None:
699-
"""OFF path: leave the tree untouched, surface bold manual-recovery
700-
instructions, and pause the run. Always raises RunPaused. ``cause``
701-
selects the wording: ``"resolved"`` for an escalation re-armed into a
702-
clean rebuild, anything else for a stopped/abandoned attempt."""
721+
def _pause_for_manual_recovery(self, task: StoryTask, baseline: str) -> None:
722+
"""OFF path for a stopped/abandoned in-place attempt: leave the tree
723+
untouched, surface bold manual-recovery instructions, and pause the run.
724+
Always raises RunPaused. A *resolved* escalation never reaches here —
725+
`_rollback_or_pause` auto-recovers that human-initiated re-drive
726+
regardless of `scm.rollback_on_failure`."""
703727
short = baseline[:12] or "the run's baseline commit"
704-
if cause == "resolved":
705-
why = (
706-
f"Story **{task.story_key}**'s escalation was resolved; re-driving "
707-
"it needs a clean baseline, but auto-rollback is OFF, so the "
708-
"working tree was left exactly as-is for you to inspect.\n"
709-
)
710-
else:
711-
why = (
712-
f"Story **{task.story_key}**'s attempt was stopped and auto-rollback "
713-
"is OFF, so the working tree was left exactly as-is for you to "
714-
"inspect.\n"
715-
)
728+
why = (
729+
f"Story **{task.story_key}**'s attempt was stopped and auto-rollback "
730+
"is OFF, so the working tree was left exactly as-is for you to "
731+
"inspect.\n"
732+
)
716733
notice = (
717734
"**ACTION REQUIRED — manual rollback needed**\n"
718735
f"{why}"
@@ -767,6 +784,9 @@ def _finish_inflight(self) -> None:
767784
task.worktree_path = ""
768785
task.branch = ""
769786
elif task.baseline_commit:
787+
# latch resolved_redrive so the corrected spec stays protected
788+
# through every reset of this re-drive, not just this first one
789+
task.resolved_redrive = task.resolved_redrive or task.rearmed
770790
self._rollback_or_pause(task, cause="resolved" if task.rearmed else "stopped")
771791
task.rearmed = False # past rollback (only reached when not paused)
772792
task.phase = Phase.PENDING # deliberate reset, not a normal transition
@@ -1236,6 +1256,9 @@ def _commit(self, task: StoryTask) -> None:
12361256
# was nothing to finalize (NO_VCS, or the tree already at baseline).
12371257
sha = verify.finalize_commit(self.workspace.root, task.baseline_commit, message)
12381258
task.commit_sha = sha or task.baseline_commit
1259+
# the corrected spec is now durable in HEAD; later attempts need no
1260+
# special preservation, so drop the re-drive latch.
1261+
task.resolved_redrive = False
12391262
except verify.GitError as e:
12401263
self._escalate(task, f"commit failed: {e}")
12411264
advance(task, Phase.DONE)

src/automator/model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ class StoryTask:
141141
# resume-time manual-recovery notice describe the real cause; cleared once the
142142
# rebuild proceeds. Survives the resume serialization round-trip.
143143
rearmed: bool = False
144+
# latched True for the lifetime of a resolved-escalation re-drive (set when
145+
# _finish_inflight re-drives a `rearmed` task, cleared once the corrected spec
146+
# is committed). While set, every rollback preserves the BMAD artifact folders'
147+
# tracked content, so a mid-re-drive retry/defer reset can't silently revert
148+
# the human correction. Survives the resume serialization round-trip.
149+
resolved_redrive: bool = False
144150
# sweep bundles only: the deferred-work ids this task closes and the
145151
# rendered intent file handed to dev sessions
146152
dw_ids: list[str] = field(default_factory=list)
@@ -176,6 +182,7 @@ def to_dict(self) -> dict[str, Any]:
176182
"commit_sha": self.commit_sha,
177183
"defer_reason": self.defer_reason,
178184
"rearmed": self.rearmed,
185+
"resolved_redrive": self.resolved_redrive,
179186
"dw_ids": self.dw_ids,
180187
"bundle_file": self.bundle_file,
181188
"worktree_path": self.worktree_path,
@@ -215,6 +222,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
215222
commit_sha=d.get("commit_sha"),
216223
defer_reason=d.get("defer_reason"),
217224
rearmed=bool(d.get("rearmed", False)),
225+
resolved_redrive=bool(d.get("resolved_redrive", False)),
218226
dw_ids=[str(i) for i in d.get("dw_ids", [])],
219227
bundle_file=d.get("bundle_file"),
220228
worktree_path=str(d.get("worktree_path", "")),

src/automator/policy.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,11 @@ class ScmPolicy:
221221
# untracked files and the whole _bmad-output/ are preserved) — convenient but
222222
# it discards the attempt's uncommitted work, so a warning is journalled when
223223
# it fires. Worktree isolation sidesteps this entirely (failed work stays in
224-
# its worktree), so this knob only matters for isolation = "none".
224+
# its worktree), so this knob only matters for isolation = "none". This flag
225+
# governs unattended/stopped attempts only: a human-initiated escalation
226+
# resolve re-drive always auto-recovers regardless — it reverts the failed
227+
# attempt's source but preserves the corrected spec under the BMAD artifact
228+
# folders, which it treats as orchestrator-owned.
225229
rollback_on_failure: bool = False
226230
# failed_diff_max_mb caps the per-file size (MB) of untracked files captured
227231
# into a kept-failed unit's forensic changes.patch, so a stray build dir or
@@ -715,7 +719,7 @@ def _fold_deprecated_engine(
715719
merge_strategy = "merge" # ff | merge | squash (worktree mode merges the unit branch into target locally)
716720
delete_branch = true # delete the unit branch after a successful merge
717721
keep_failed = true # keep a failed unit's worktree+branch for inspection
718-
rollback_on_failure = false # in-place (isolation="none") recovery after a failed attempt. false = never touch the tree; pause with manual recovery steps. true = auto-revert the attempt's tracked changes + remove only the untracked files this run created (WARNING: discards the attempt's uncommitted work; never a blanket git clean). Prefer isolation="worktree" to avoid touching your main checkout.
722+
rollback_on_failure = false # in-place (isolation="none") recovery after a failed attempt. false = never touch the tree; pause with manual recovery steps. true = auto-revert the attempt's tracked changes + remove only the untracked files this run created (WARNING: discards the attempt's uncommitted work; never a blanket git clean). Governs unattended/stopped attempts only: a resolved escalation's re-drive always auto-recovers regardless (reverts the failed source, keeps the corrected spec). Prefer isolation="worktree" to avoid touching your main checkout.
719723
failed_diff_max_mb = 5 # per-file size cap (MB) for untracked files in a kept-failed unit's changes.patch; oversized files are skipped with a marker
720724
failed_diff_unlimited = false # true = capture the failed-unit diff with no size cap (may produce very large patches; warns when active)
721725
# commit_message_template: when set, the commit message dev sessions use for a

0 commit comments

Comments
 (0)