Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "bauto",
"source": "./src/automator/data/skills",
"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",
"version": "0.7.0",
"version": "0.7.1",
"author": {
"name": "pinkyd"
},
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ All notable changes to `bmad-auto` are documented here. The format is based on
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the project is pre-1.0,
breaking changes may land in a minor release.

## [0.7.1] — 2026-06-25

### Fixed

- **The Log tab no longer renders whole CLI sessions underlined.** Modern CLIs emit an XTMODKEYS
sequence (`CSI > 4 ; 2 m`, "modifyOtherKeys") at startup that the pane emulator (pyte) misread as
SGR 4 / underline-on — with no matching off present in a live capture — so every line came out
underlined and hard to read. The log view now strips private-marker CSI sequences before emulation;
genuine color, bold, and properly-closed underline styling is preserved.

- **Resolving a CRITICAL escalation no longer loops on a manual-rollback prompt when the resolve
edited the spec.** 0.7.0 fixed the loop only for an already-clean tree, but the resolve workflow's
whole job is to correct the frozen spec under the BMAD artifact folder (`_bmad-output/...`, which is
tracked). So on resume the orchestrator saw a dirty tree and — with the default
`scm.rollback_on_failure = false` — paused for a manual reset; because the dirty check diffs against
the frozen `baseline_commit`, even committing the spec re-paused on the next resume, an endless loop.
A resolved re-drive is human-initiated, so it now always auto-recovers regardless of the flag: the
BMAD artifact folders are treated as orchestrator-owned — excluded from the dirty check and preserved
through every reset of the re-drive (not just the resume-time cleanup) — so the spec correction
survives while the failed attempt's source changes revert to baseline. This closes a latent sibling
bug: with `rollback_on_failure = true` a _later_ mid-re-drive retry/defer reset previously ran with no
preserve set and reverted the just-corrected spec silently, looping the re-drive.
`scm.rollback_on_failure` still defaults OFF and now governs only unattended/stopped attempts; the
manual-recovery notice (reached by stopped attempts only now) drops its resolved-cause wording.

- **A failed artifact restore during rollback now surfaces instead of silently dropping the
correction.** When `safe_rollback` restores the preserved BMAD folders from its pre-reset snapshot, a
genuine `git checkout` failure (corrupt snapshot, lock, IO) was swallowed alongside the benign
empty-dir "pathspec did not match" case — so a corrected spec could vanish with no error and loop the
re-drive. Real failures now raise; the empty-dir case stays tolerated.

## [0.7.0] — 2026-06-24

### Changed
Expand Down
2 changes: 1 addition & 1 deletion module.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
code: bauto
name: BMAD Auto Skills
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"
module_version: 0.7.0
module_version: 0.7.1
default_selected: false
module_greeting: >
BMAD Auto installed — both the automation skills and the
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "bmad-auto"
version = "0.7.0"
version = "0.7.1"
description = "Deterministic ralph-loop orchestrator for the BMAD implementation phase"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/automator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
spec files, and the per-run directory under .automator/runs/.
"""

__version__ = "0.7.0"
__version__ = "0.7.1"
2 changes: 1 addition & 1 deletion src/automator/data/settings/core.toml
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ key = "rollback_on_failure"
kind = "switch"
default_ref = "ScmPolicy.rollback_on_failure"
label = "auto-rollback failed attempts"
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."
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."
[[section.field]]
key = "seed_adapter_defaults"
kind = "switch"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
code: bauto
name: BMAD Auto Skills
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"
module_version: 0.7.0
module_version: 0.7.1
default_selected: false
module_greeting: >
BMAD Auto installed — both the automation skills and the
Expand Down
137 changes: 80 additions & 57 deletions src/automator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,82 +637,99 @@ def _pick_next(self):
continue
return story

def _protected_relpaths(self) -> tuple[str, ...]:
"""Repo-relative posix paths of the BMAD artifact folders. These are
orchestrator-owned: never counted as a dev attempt's dirtiness (the
resolve workflow corrects the frozen spec here) and preserved through
rollback. Folders configured outside the repo are skipped — nothing to
protect there."""
out: list[str] = []
for protected in (
self.workspace.paths.output_folder,
self.workspace.paths.implementation_artifacts,
self.workspace.paths.planning_artifacts,
):
try:
out.append(protected.relative_to(self.workspace.root).as_posix())
except ValueError:
pass # configured outside the repo; nothing to protect here
return tuple(out)

def _rollback_or_pause(self, task: StoryTask, *, cause: str = "stopped") -> None:
"""Recover from an in-place attempt that won't proceed.

No-op when the tree is already at the attempt's baseline (nothing this
attempt touched): neither a reset nor a pause is needed. This is also
what lets the manual-recovery instructions terminate — after the operator
resets and resumes, the now-clean tree skips straight through instead of
re-pausing on the still-set ``baseline_commit``.

Otherwise, with ``scm.rollback_on_failure`` OFF (default) the orchestrator
never touches the working tree: it emits a bold manual-recovery notice and
pauses the run (stop-and-wait), so nothing proceeds on a half-finished
tree. With it ON, it does the safest possible automatic rollback —
revert the attempt's tracked changes to baseline and delete only the
untracked files this run created (the whole BMAD output folder and every
pre-existing untracked file are preserved; there is no blanket
``git clean``). ``cause`` tunes the manual notice's wording."""
attempt touched, ignoring orchestrator-owned artifact folders): neither a
reset nor a pause is needed. This is also what lets the manual-recovery
instructions terminate — after the operator resets and resumes, the
now-clean tree skips straight through instead of re-pausing on the
still-set ``baseline_commit``.

A ``cause="resolved"`` re-drive is human-initiated (the operator ran the
resolve workflow and re-armed the story), so it always auto-recovers and
never pauses, regardless of ``scm.rollback_on_failure``. For the entire
re-drive (``task.resolved_redrive``, latched at resume and cleared once the
correction is committed) the BMAD artifact folders are treated as
orchestrator-owned: excluded from the dirty check (the corrected spec must
not read as a failed attempt) and preserved through every reset — so a
later mid-re-drive retry/defer reset can't silently revert the correction.

Otherwise (a stopped/abandoned attempt) the flag governs: OFF (default)
leaves the working tree untouched and emits a bold manual-recovery notice
that pauses the run (stop-and-wait); ON does a clean reset to baseline.
Either way pre-existing untracked files are preserved; there is no blanket
``git clean``."""
resolved = cause == "resolved"
# preserve the corrected spec for the whole re-drive, not just the first
# reset; the auto-recover (pause-vs-reset) decision below is unaffected.
redrive = resolved or task.resolved_redrive
protected = self._protected_relpaths() if redrive else ()
if task.baseline_commit and not verify.attempt_dirty(
self.workspace.root, task.baseline_commit, task.baseline_untracked
self.workspace.root, task.baseline_commit, task.baseline_untracked, exclude=protected
):
self.journal.append("rollback-skipped-clean", story_key=task.story_key)
return
if not self.policy.scm.rollback_on_failure:
self._pause_for_manual_recovery(task, task.baseline_commit or "", cause=cause)
return # unreachable: _pause_for_manual_recovery always raises
self.journal.append(
"rollback-auto",
story_key=task.story_key,
baseline=task.baseline_commit or "",
note="reverting tracked changes + run-created untracked files",
)
self._safe_reset(task)
if resolved or self.policy.scm.rollback_on_failure:
self.journal.append(
"rollback-auto",
story_key=task.story_key,
baseline=task.baseline_commit or "",
note="reverting tracked changes + run-created untracked files",
)
self._safe_reset(task, preserve=protected)
return
self._pause_for_manual_recovery(task, task.baseline_commit or "")
return # unreachable: _pause_for_manual_recovery always raises

def _safe_reset(self, task: StoryTask) -> None:
def _safe_reset(self, task: StoryTask, *, preserve: tuple[str, ...] = ()) -> None:
"""Revert tracked changes to the task baseline and remove only the
untracked files this run created — never a blanket `git clean`. Used by
the gated rollback (when enabled) and by internal ledger recovery (sweep
the gated/resolved rollback and by internal ledger recovery (sweep
migration), which restores the orchestrator's own state and must not
pause."""
keep = [".automator"]
for protected in (
self.workspace.paths.output_folder,
self.workspace.paths.implementation_artifacts,
self.workspace.paths.planning_artifacts,
):
try:
keep.append(str(protected.relative_to(self.workspace.root)))
except ValueError:
pass # configured outside the repo; nothing to protect here
pause. The BMAD artifact folders are always kept from untracked deletion;
``preserve`` (set only on a resolved re-drive) additionally keeps their
*tracked* content alive through the reset, so a just-corrected spec is not
reverted. Sweep passes no ``preserve`` — it wants the broken ledger gone."""
verify.safe_rollback(
self.workspace.root,
task.baseline_commit or "",
baseline_untracked=task.baseline_untracked,
keep=tuple(keep),
keep=(".automator", *self._protected_relpaths()),
preserve=preserve,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _pause_for_manual_recovery(
self, task: StoryTask, baseline: str, *, cause: str = "stopped"
) -> None:
"""OFF path: leave the tree untouched, surface bold manual-recovery
instructions, and pause the run. Always raises RunPaused. ``cause``
selects the wording: ``"resolved"`` for an escalation re-armed into a
clean rebuild, anything else for a stopped/abandoned attempt."""
def _pause_for_manual_recovery(self, task: StoryTask, baseline: str) -> None:
"""OFF path for a stopped/abandoned in-place attempt: leave the tree
untouched, surface bold manual-recovery instructions, and pause the run.
Always raises RunPaused. A *resolved* escalation never reaches here —
`_rollback_or_pause` auto-recovers that human-initiated re-drive
regardless of `scm.rollback_on_failure`."""
short = baseline[:12] or "the run's baseline commit"
if cause == "resolved":
why = (
f"Story **{task.story_key}**'s escalation was resolved; re-driving "
"it needs a clean baseline, but auto-rollback is OFF, so the "
"working tree was left exactly as-is for you to inspect.\n"
)
else:
why = (
f"Story **{task.story_key}**'s attempt was stopped and auto-rollback "
"is OFF, so the working tree was left exactly as-is for you to "
"inspect.\n"
)
why = (
f"Story **{task.story_key}**'s attempt was stopped and auto-rollback "
"is OFF, so the working tree was left exactly as-is for you to "
"inspect.\n"
)
notice = (
"**ACTION REQUIRED — manual rollback needed**\n"
f"{why}"
Expand Down Expand Up @@ -767,6 +784,9 @@ def _finish_inflight(self) -> None:
task.worktree_path = ""
task.branch = ""
elif task.baseline_commit:
# latch resolved_redrive so the corrected spec stays protected
# through every reset of this re-drive, not just this first one
task.resolved_redrive = task.resolved_redrive or task.rearmed
self._rollback_or_pause(task, cause="resolved" if task.rearmed else "stopped")
task.rearmed = False # past rollback (only reached when not paused)
task.phase = Phase.PENDING # deliberate reset, not a normal transition
Expand Down Expand Up @@ -1236,6 +1256,9 @@ def _commit(self, task: StoryTask) -> None:
# was nothing to finalize (NO_VCS, or the tree already at baseline).
sha = verify.finalize_commit(self.workspace.root, task.baseline_commit, message)
task.commit_sha = sha or task.baseline_commit
# the corrected spec is now durable in HEAD; later attempts need no
# special preservation, so drop the re-drive latch.
task.resolved_redrive = False
except verify.GitError as e:
self._escalate(task, f"commit failed: {e}")
advance(task, Phase.DONE)
Expand Down
8 changes: 8 additions & 0 deletions src/automator/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ class StoryTask:
# resume-time manual-recovery notice describe the real cause; cleared once the
# rebuild proceeds. Survives the resume serialization round-trip.
rearmed: bool = False
# latched True for the lifetime of a resolved-escalation re-drive (set when
# _finish_inflight re-drives a `rearmed` task, cleared once the corrected spec
# is committed). While set, every rollback preserves the BMAD artifact folders'
# tracked content, so a mid-re-drive retry/defer reset can't silently revert
# the human correction. Survives the resume serialization round-trip.
resolved_redrive: bool = False
# sweep bundles only: the deferred-work ids this task closes and the
# rendered intent file handed to dev sessions
dw_ids: list[str] = field(default_factory=list)
Expand Down Expand Up @@ -176,6 +182,7 @@ def to_dict(self) -> dict[str, Any]:
"commit_sha": self.commit_sha,
"defer_reason": self.defer_reason,
"rearmed": self.rearmed,
"resolved_redrive": self.resolved_redrive,
"dw_ids": self.dw_ids,
"bundle_file": self.bundle_file,
"worktree_path": self.worktree_path,
Expand Down Expand Up @@ -215,6 +222,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
commit_sha=d.get("commit_sha"),
defer_reason=d.get("defer_reason"),
rearmed=bool(d.get("rearmed", False)),
resolved_redrive=bool(d.get("resolved_redrive", False)),
dw_ids=[str(i) for i in d.get("dw_ids", [])],
bundle_file=d.get("bundle_file"),
worktree_path=str(d.get("worktree_path", "")),
Expand Down
8 changes: 6 additions & 2 deletions src/automator/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ class ScmPolicy:
# untracked files and the whole _bmad-output/ are preserved) — convenient but
# it discards the attempt's uncommitted work, so a warning is journalled when
# it fires. Worktree isolation sidesteps this entirely (failed work stays in
# its worktree), so this knob only matters for isolation = "none".
# its worktree), so this knob only matters for isolation = "none". This flag
# governs unattended/stopped attempts only: a human-initiated escalation
# resolve re-drive always auto-recovers regardless — it reverts the failed
# attempt's source but preserves the corrected spec under the BMAD artifact
# folders, which it treats as orchestrator-owned.
rollback_on_failure: bool = False
# failed_diff_max_mb caps the per-file size (MB) of untracked files captured
# into a kept-failed unit's forensic changes.patch, so a stray build dir or
Expand Down Expand Up @@ -715,7 +719,7 @@ def _fold_deprecated_engine(
merge_strategy = "merge" # ff | merge | squash (worktree mode merges the unit branch into target locally)
delete_branch = true # delete the unit branch after a successful merge
keep_failed = true # keep a failed unit's worktree+branch for inspection
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.
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.
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
failed_diff_unlimited = false # true = capture the failed-unit diff with no size cap (may produce very large patches; warns when active)
# commit_message_template: when set, the commit message dev sessions use for a
Expand Down
Loading
Loading