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: unattended dev (bmad-auto-dev), adversarial review (bmad-auto-review), and deferred-work sweep triage (bmad-auto-sweep)",
"version": "0.5.1",
"version": "0.6.0",
"author": {
"name": "pinkyd"
},
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ 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.6.0] — 2026-06-20

### Fixed

- **Rollback no longer wipes non-automator files.** A failed in-place attempt previously ran
`git reset --hard` + a blanket `git clean -fd` over the whole checkout, which could delete a
project's `_bmad-output/` and any other untracked files (only `.automator/` and two artifact
subdirs were spared). The orchestrator now never runs a blanket `git clean`: it reverts the
attempt's tracked changes and removes only the untracked files **that run created**, preserving
pre-existing untracked files and the entire `_bmad-output/` tree.

### Changed

- **Auto-rollback is now opt-in (`[scm] rollback_on_failure`, default off).** With it off the
orchestrator never touches your working tree on a failed attempt — it pauses the run with bold
manual-recovery instructions (back up untracked files → `git reset --hard <baseline>` → restore).
Turn it on for the safe automatic rollback above (it discards the attempt's uncommitted work, so
it warns when it fires). Worktree isolation (`scm.isolation = "worktree"`) sidesteps this entirely.

## [0.5.1] — 2026-06-20

### Added
Expand Down Expand Up @@ -396,6 +415,7 @@ enforced in CI.
implementation phase, driven by a Python control loop with hook-based session transport and
resumable on-disk run state.

[0.6.0]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.6.0
[0.5.1]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.5.1
[0.5.0]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.5.0
[0.4.4]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.4.4
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: unattended dev (bmad-auto-dev), adversarial review (bmad-auto-review), and deferred-work sweep triage (bmad-auto-sweep)"
module_version: 0.5.1
module_version: 0.6.0
default_selected: false
module_greeting: >
BMAD Auto installed — both the four 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.5.1"
version = "0.6.0"
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.5.1"
__version__ = "0.6.0"
11 changes: 11 additions & 0 deletions src/automator/bmadconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@ class ProjectPaths:
project: Path
implementation_artifacts: Path
planning_artifacts: Path
# the BMAD output root (parent of the artifact dirs, holds project-context,
# test-artifacts, story-automator, …). Protected wholesale on rollback so a
# failed attempt never deletes generated BMAD output. Defaults to
# {project-root}/_bmad-output when the config omits `output_folder`.
output_folder: Path = field(default=None) # type: ignore[assignment]
# the git root code/git work happens against; defaults to `project`. Phase 1
# foundation for worktree isolation — see ProjectPaths.rebased and Workspace.
repo_root: Path = field(default=None) # type: ignore[assignment]

def __post_init__(self) -> None:
if self.output_folder is None:
object.__setattr__(self, "output_folder", (self.project / "_bmad-output").resolve())
if self.repo_root is None:
object.__setattr__(self, "repo_root", self.project)

Expand Down Expand Up @@ -51,6 +58,7 @@ def rebase(p: Path) -> Path:
project=new_root,
implementation_artifacts=rebase(self.implementation_artifacts),
planning_artifacts=rebase(self.planning_artifacts),
output_folder=rebase(self.output_folder),
repo_root=new_root,
)

Expand All @@ -77,9 +85,12 @@ def load_paths(project: Path) -> ProjectPaths:
)
repo_root_raw = doc.get("repo_root")
repo_root = _resolve(str(repo_root_raw), project) if repo_root_raw else project
out_raw = doc.get("output_folder")
output_folder = _resolve(str(out_raw), project) if out_raw else (project / "_bmad-output")
return ProjectPaths(
project=project,
implementation_artifacts=_resolve(str(impl), project),
planning_artifacts=_resolve(str(plan), project),
output_folder=output_folder.resolve(),
repo_root=repo_root,
)
6 changes: 6 additions & 0 deletions src/automator/data/settings/core.toml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ kind = "switch"
default_ref = "ScmPolicy.keep_failed"
description = "worktree mode: keep a failed unit's worktree + branch mounted for inspection"
[[section.field]]
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."
[[section.field]]
key = "seed_adapter_defaults"
kind = "switch"
default_ref = "ScmPolicy.seed_adapter_defaults"
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: unattended dev (bmad-auto-dev), adversarial review (bmad-auto-review), and deferred-work sweep triage (bmad-auto-sweep)"
module_version: 0.5.1
module_version: 0.6.0
default_selected: false
module_greeting: >
BMAD Auto installed — both the four automation skills and the
Expand Down
84 changes: 73 additions & 11 deletions src/automator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,20 +636,79 @@ def _pick_next(self):
continue
return story

def _reset_to(self, baseline: str) -> None:
"""Roll back code changes, preserving run state and BMAD artifacts
(sprint-status etc. may be untracked in young projects — `git clean`
must never eat them)."""
def _rollback_or_pause(self, task: StoryTask) -> None:
"""Recover from a failed in-place attempt.

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``)."""
if not self.policy.scm.rollback_on_failure:
self._pause_for_manual_recovery(task, task.baseline_commit or "")
Comment on lines +650 to +651

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the manual-rollback pause resumable before continuing.

With rollback disabled, _pause_for_manual_recovery() saves the current task state. On retry/resume, _finish_inflight() calls _rollback_or_pause() again and pauses unconditionally, even if the operator already reset manually. In _defer(), the task is already terminal before the pause, so a resume can skip recovery entirely and continue on the dirty tree. Add a recovered/acknowledged state check before re-pausing, and avoid marking the task terminal until recovery has been verified or completed.

Also applies to: 742-743, 1339-1356

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/automator/engine.py` around lines 650 - 651, The manual recovery pause
logic lacks a check to determine if recovery has already been acknowledged,
causing unconditional re-pausing on resume even when manual recovery is
complete. Add a recovered or acknowledged state check before calling
`_pause_for_manual_recovery()` in the `_finish_inflight()` and
`_rollback_or_pause()` methods to skip the pause if recovery has been verified.
Additionally, in the `_defer()` method, defer marking the task as terminal until
after the recovery pause is completed, rather than marking it terminal before
the pause, so that resuming the workflow can properly verify recovery status
before continuing execution.

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)

def _safe_reset(self, task: StoryTask) -> 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
migration), which restores the orchestrator's own state and must not
pause."""
keep = [".automator"]
for artifact_dir in (
for protected in (
self.workspace.paths.output_folder,
self.workspace.paths.implementation_artifacts,
self.workspace.paths.planning_artifacts,
):
try:
keep.append(str(artifact_dir.relative_to(self.workspace.root)))
keep.append(str(protected.relative_to(self.workspace.root)))
except ValueError:
pass # artifacts configured outside the repo; nothing to protect
verify.reset_hard(self.workspace.root, baseline, keep=tuple(keep))
pass # configured outside the repo; nothing to protect here
verify.safe_rollback(
self.workspace.root,
task.baseline_commit or "",
baseline_untracked=task.baseline_untracked,
keep=tuple(keep),
)

def _pause_for_manual_recovery(self, task: StoryTask, baseline: str) -> None:
"""OFF path: leave the tree untouched, surface bold manual-recovery
instructions, and pause the run. Always raises RunPaused."""
short = baseline[:12] or "the run's baseline commit"
notice = (
"**ACTION REQUIRED — manual rollback needed**\n"
f"Story **{task.story_key}** failed and auto-rollback is OFF, so the "
"working tree was left exactly as-is for you to inspect.\n"
"To discard this attempt yourself:\n"
" 1. **BACK UP any untracked files you want to keep** — the reset "
"below deletes uncommitted work.\n"
f" 2. `git reset --hard {short}` then review/remove leftover "
"untracked files.\n"
" 3. **Restore the files you backed up in step 1.**\n"
f"Then run `bmad-auto resume {self.state.run_id}`. To let the "
"orchestrator do a safe automatic rollback next time, enable "
"`[scm] rollback_on_failure` (it discards the attempt's uncommitted "
"work but never deletes pre-existing untracked files)."
)
self.journal.append("rollback-manual-required", story_key=task.story_key, baseline=baseline)
gates.notify(
self.policy,
self.run_dir,
f"ACTION REQUIRED: manual rollback for {task.story_key}",
notice,
)
self._save()
raise RunPaused(notice, PAUSE_ESCALATION, task.story_key)

def _finish_inflight(self) -> None:
"""Complete or roll back tasks interrupted by a pause or crash."""
Expand Down Expand Up @@ -681,7 +740,7 @@ def _finish_inflight(self) -> None:
task.worktree_path = ""
task.branch = ""
elif task.baseline_commit:
self._reset_to(task.baseline_commit)
self._rollback_or_pause(task)
task.phase = Phase.PENDING # deliberate reset, not a normal transition
self._save()
self._run_story(task)
Expand Down Expand Up @@ -914,6 +973,9 @@ def _dev_phase(self, task: StoryTask) -> bool:
if self._vetoed(self._emit("pre_dev_phase", task), task):
return False
task.baseline_commit = verify.rev_parse_head(self.workspace.root)
# snapshot untracked files now so a later rollback removes only what THIS
# attempt creates, never files the user already had on disk.
task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root))
feedback: Path | None = None
while True:
task.attempt += 1
Expand Down Expand Up @@ -962,7 +1024,7 @@ def _dev_phase(self, task: StoryTask) -> bool:
feedback = self._write_feedback(task, decision.reason)
else:
feedback = None
self._reset_to(task.baseline_commit)
self._rollback_or_pause(task)
continue
if decision.action == Action.DEFER:
self._defer(task, decision.reason)
Expand Down Expand Up @@ -1291,7 +1353,7 @@ def _defer(self, task: StoryTask, reason: str) -> None:
snapshot = (
deferred_work.read_text(encoding="utf-8") if deferred_work.is_file() else None
)
self._reset_to(task.baseline_commit)
self._rollback_or_pause(task)
# reset reverts tracked deferred-work.md edits; restore review-found
# defer entries — they are real knowledge worth keeping
if snapshot is not None:
Expand Down
11 changes: 11 additions & 0 deletions src/automator/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ class StoryTask:
attempt: int = 0
review_cycle: int = 0
baseline_commit: str | None = None
# untracked, non-ignored paths present at baseline capture (repo-relative
# posix). On rollback only paths NOT in this set are removed, so files the
# user already had on disk are never deleted. None = pre-upgrade run (no
# snapshot); rollback then removes no untracked files at all.
baseline_untracked: list[str] | None = None
spec_file: str | None = None
commit_sha: str | None = None
defer_reason: str | None = None
Expand Down Expand Up @@ -155,6 +160,7 @@ def to_dict(self) -> dict[str, Any]:
"attempt": self.attempt,
"review_cycle": self.review_cycle,
"baseline_commit": self.baseline_commit,
"baseline_untracked": self.baseline_untracked,
"spec_file": self._serialized_spec_file(),
"commit_sha": self.commit_sha,
"defer_reason": self.defer_reason,
Expand Down Expand Up @@ -187,6 +193,11 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
attempt=int(d.get("attempt", 0)),
review_cycle=int(d.get("review_cycle", 0)),
baseline_commit=d.get("baseline_commit"),
baseline_untracked=(
[str(p) for p in d["baseline_untracked"]]
if d.get("baseline_untracked") is not None
else None
),
spec_file=d.get("spec_file"),
commit_sha=d.get("commit_sha"),
defer_reason=d.get("defer_reason"),
Expand Down
13 changes: 13 additions & 0 deletions src/automator/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ class ScmPolicy:
merge_strategy: str = "merge" # ff | merge | squash
delete_branch: bool = True # delete the unit branch after a successful merge
keep_failed: bool = True # keep a failed unit's worktree+branch for inspection
# rollback_on_failure governs in-place (isolation = "none") recovery after a
# failed attempt / rejected review. Default OFF: the orchestrator never
# touches the working tree — it pauses the run with manual recovery
# instructions, so a half-finished attempt is left for you to inspect. ON:
# the orchestrator auto-reverts the attempt's tracked changes and removes the
# untracked files THIS run created (never a blanket `git clean`; pre-existing
# 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".
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
# huge log can't blow it up; oversized files are skipped with a labelled
Expand Down Expand Up @@ -414,6 +425,7 @@ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy:
merge_strategy=str(scm_d.get("merge_strategy", ScmPolicy.merge_strategy)),
delete_branch=bool(scm_d.get("delete_branch", ScmPolicy.delete_branch)),
keep_failed=bool(scm_d.get("keep_failed", ScmPolicy.keep_failed)),
rollback_on_failure=bool(scm_d.get("rollback_on_failure", ScmPolicy.rollback_on_failure)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-boolean rollback_on_failure values instead of truthifying them.

bool(scm_d.get(...)) makes rollback_on_failure = "false" evaluate to True, silently opting into auto rollback. Since this flag controls whether the checkout is modified on failure, require a real TOML boolean and raise PolicyError otherwise.

Proposed fix
+    raw_rollback_on_failure = scm_d.get(
+        "rollback_on_failure", ScmPolicy.rollback_on_failure
+    )
+    if not isinstance(raw_rollback_on_failure, bool):
+        raise PolicyError(
+            "scm.rollback_on_failure must be a boolean: "
+            f"got {raw_rollback_on_failure!r}"
+        )
     scm = ScmPolicy(
         isolation=str(scm_d.get("isolation", ScmPolicy.isolation)),
         branch_per=str(scm_d.get("branch_per", ScmPolicy.branch_per)),
         target_branch=str(scm_d.get("target_branch", ScmPolicy.target_branch)),
         merge_strategy=str(scm_d.get("merge_strategy", ScmPolicy.merge_strategy)),
         delete_branch=bool(scm_d.get("delete_branch", ScmPolicy.delete_branch)),
         keep_failed=bool(scm_d.get("keep_failed", ScmPolicy.keep_failed)),
-        rollback_on_failure=bool(scm_d.get("rollback_on_failure", ScmPolicy.rollback_on_failure)),
+        rollback_on_failure=raw_rollback_on_failure,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rollback_on_failure=bool(scm_d.get("rollback_on_failure", ScmPolicy.rollback_on_failure)),
raw_rollback_on_failure = scm_d.get(
"rollback_on_failure", ScmPolicy.rollback_on_failure
)
if not isinstance(raw_rollback_on_failure, bool):
raise PolicyError(
"scm.rollback_on_failure must be a boolean: "
f"got {raw_rollback_on_failure!r}"
)
scm = ScmPolicy(
isolation=str(scm_d.get("isolation", ScmPolicy.isolation)),
branch_per=str(scm_d.get("branch_per", ScmPolicy.branch_per)),
target_branch=str(scm_d.get("target_branch", ScmPolicy.target_branch)),
merge_strategy=str(scm_d.get("merge_strategy", ScmPolicy.merge_strategy)),
delete_branch=bool(scm_d.get("delete_branch", ScmPolicy.delete_branch)),
keep_failed=bool(scm_d.get("keep_failed", ScmPolicy.keep_failed)),
rollback_on_failure=raw_rollback_on_failure,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/automator/policy.py` at line 428, The rollback_on_failure parameter
assignment uses bool() conversion which silently converts string values like
"false" to True, enabling unintended auto-rollback behavior. Instead of using
bool(scm_d.get(...)), extract the value and validate that it is actually a
boolean type, raising a PolicyError if a non-boolean value is provided. Only
assign the rollback_on_failure parameter when the value is confirmed to be a
genuine boolean from the TOML configuration or fall back to the
ScmPolicy.rollback_on_failure default.

failed_diff_max_mb=int(scm_d.get("failed_diff_max_mb", ScmPolicy.failed_diff_max_mb)),
failed_diff_unlimited=bool(
scm_d.get("failed_diff_unlimited", ScmPolicy.failed_diff_unlimited)
Expand Down Expand Up @@ -600,6 +612,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.
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
7 changes: 4 additions & 3 deletions src/automator/sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ def _run_bundle(self, bundle: Bundle, cycle: int) -> None:
task.worktree_path = ""
task.branch = ""
elif task.baseline_commit:
self._reset_to(task.baseline_commit)
self._rollback_or_pause(task)
task.phase = Phase.PENDING # deliberate reset, not a normal transition
dirname = bundle.name if cycle == 1 else f"c{cycle}-{bundle.name}"
task.bundle_file = str(self._write_intent(bundle, dirname))
Expand Down Expand Up @@ -567,11 +567,12 @@ def _ensure_migration(self, text: str) -> None:
if task.phase == Phase.ESCALATED:
task.attempt = 0 # the human resumed deliberately; fresh budget
if task.baseline_commit and not verify.worktree_clean(self.workspace.root):
self._reset_to(task.baseline_commit) # a session died mid-rewrite
self._safe_reset(task) # a session died mid-rewrite; restore our ledger
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
task.phase = Phase.PENDING # deliberate reset, not a normal transition
if not task.baseline_commit:
task.baseline_commit = verify.rev_parse_head(self.workspace.root)
task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root))

legacy = deferredwork.parse_legacy(text)
pre_canonical = {e.id: e.status for e in deferredwork.parse_ledger(text)}
Expand Down Expand Up @@ -640,7 +641,7 @@ def _ensure_migration(self, text: str) -> None:
# never re-prompt over a half-broken rewrite; the baseline reset
# covers tracked files, the explicit write covers an untracked
# ledger that `git reset` cannot restore
self._reset_to(task.baseline_commit)
self._safe_reset(task)
ledger.parent.mkdir(parents=True, exist_ok=True)
ledger.write_text(text, encoding="utf-8")
if task.attempt >= self.policy.sweep.max_migration_attempts:
Expand Down
Loading
Loading