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.11",
"version": "0.7.12",
"author": {
"name": "pinkyd"
},
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,51 @@ 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.12] — 2026-07-01

### Added

- **The TUI dashboard now shows the cost-proportional weighted token total, with the raw total in a new
column.** The `tokens` column and the run-header summary discount cache-read tokens by the run's
`cache_read_weight` — the same weighting the per-story budget enforces — so the headline number
tracks spend rather than context re-reads; the previous unweighted total moves to a new `raw` column.

### Fixed

- **The dashboard no longer crashes when a background poll lands as the screen is torn down or
switched away.** A poll worker delivers its refresh on the UI thread; if that arrived just as the
app quit or another screen opened, the query for the run table raised `NoMatches`. The apply now
drops stale refreshes for a screen that is no longer running (while still updating one merely
backgrounded under a modal).
- **A failed attempt's work is preserved before an auto-rollback hard reset instead of being silently
discarded.** With `scm.rollback_on_failure` on (or on a resolved re-drive), a deferred or stopped
attempt's commits above baseline are now parked under an `attempt-preserve/<run_id>-<head8>` branch,
and its uncommitted working-tree diff — tracked edits and run-created untracked files alike — under
`refs/attempt-preserve-dirty/`; both are recoverable by name and survive gc. A plain rollback that
cannot create the ref refuses to reset and pauses for manual recovery rather than destroying work.
The uncommitted snapshot is scoped to this run's own changes (never a pre-existing untracked file),
commits under a synthetic identity so it works with no git user configured, and is keyed per retry
so repeated rollbacks against the same baseline no longer overwrite each other's recovery ref.
- **Process liveness is now identity-aware, so a reused PID no longer reads as a live run.** A recycled
pid (common on Windows) used to register as a false "alive" — blocking resume of a dead run,
stranding worktree reclaim, leaking sessions, and showing dead runs as RUNNING. The pid file now
carries a process-identity token that resume, stop, and the TUI verify against; on win32 the engine
also ignores console SIGINT/SIGBREAK during a run so a ConPTY Ctrl+C broadcast can't kill it.
- **A story from a resolved escalation that still can't finish now re-escalates instead of being
silently deferred.** When a human-resolved CRITICAL `blocked` escalation was re-driven and the
re-drive couldn't reach `status: done` (e.g. the environment was still broken), the story used to
exhaust its dev/review budget and plateau-defer — filing an unresolved blocker as deferred work and
rolling back the implemented code. While `resolved_redrive` is latched, budget exhaustion now
re-escalates (pauses for the human) instead of deferring, and the attempt's tree is preserved.
- **A resumed `--epic N` run stays scoped to its epic and no longer declares the epic "done" while
stories remain.** `resume` rebuilt the engine without the run's `--epic`/`--story`/`--max-stories`,
so a scoped run silently widened to every epic; with strict file-order story selection, deferring or
finishing a story in an epic placed out of numeric order in the sprint board (e.g. one appended last)
bounced selection to an earlier-in-file epic and fired a spurious "epic N complete" boundary,
stranding the epic's remaining stories. The selector and cap are now persisted and restored on
resume, and story selection exhausts the current epic before advancing — so an epic boundary fires
only when that epic has no actionable stories left. Document-order epic execution is unchanged.

## [0.7.11] — 2026-06-30

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

[0.7.12]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.7.12
[0.7.11]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.7.11
[0.7.9]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.7.9
[0.7.7]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.7.7
Expand Down
Binary file modified docs/images/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
230 changes: 116 additions & 114 deletions docs/images/dashboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/decision-answer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
242 changes: 122 additions & 120 deletions docs/images/decision-answer.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/deferred-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
238 changes: 120 additions & 118 deletions docs/images/deferred-modal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
192 changes: 96 additions & 96 deletions docs/images/settings.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/start-run-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
260 changes: 131 additions & 129 deletions docs/images/start-run-modal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/sweep-decision.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
232 changes: 117 additions & 115 deletions docs/images/sweep-decision.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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.11
module_version: 0.7.12
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.11"
version = "0.7.12"
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.11"
__version__ = "0.7.12"
8 changes: 8 additions & 0 deletions src/automator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ def cmd_run(args: argparse.Namespace) -> int:
project=str(project),
started_at=time.strftime("%Y-%m-%dT%H:%M:%S"),
policy_snapshot=pol.to_dict(),
epic_filter=args.epic,
story_filter=args.story,
max_stories=args.max_stories,
)
save_state(run_dir, state)
runs.write_pid(run_dir)
Expand Down Expand Up @@ -549,6 +552,11 @@ def _resume_paused_run(project: Path, run_dir: Path) -> int:
run_dir=run_dir,
journal=journal,
state=state,
# restore the launching scope + cap so a resumed `--epic N` run keeps
# picking within N instead of silently widening to every epic.
epic_filter=state.epic_filter,
story_filter=state.story_filter,
max_stories=state.max_stories,
sweep_factory=_sweep_factory(project, paths),
)
summary = engine.run()
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.11
module_version: 0.7.12
default_selected: false
module_greeting: >
BMAD Auto installed — both the automation skills and the
Expand Down
74 changes: 65 additions & 9 deletions src/automator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,15 +698,30 @@ def _pick_next(self):
ss = load_sprint_status(self.paths.sprint_status)
if ss.unknown_keys:
self.journal.append("sprint-status-unknown-keys", keys=list(ss.unknown_keys))
skip = set(self.state.tasks) # anything this run already touched
while True:
story = next_actionable(ss, skip)
if story is None:
return None
if not self._selector.matches(story):
skip.add(story.key)
continue
return story
base_skip = set(self.state.tasks) # anything this run already touched

def _first(epic: int | None):
# local skip copy so selector-rejections in this pass don't leak into
# the next one (a story rejected here may still match the fallback).
skip = set(base_skip)
while True:
story = next_actionable(ss, skip, epic=epic)
if story is None:
return None
if not self._selector.matches(story):
skip.add(story.key)
continue
return story

# Exhaust the current epic before advancing. Selection is otherwise
# strict file order, and epics need not be file-ordered by number (an
# epic can be appended out of place); without this, a still-open earlier-
# in-file epic would "steal" the pick and fire a spurious epic boundary.
if self.state.current_epic is not None:
story = _first(self.state.current_epic)
if story is not None:
return story
return _first(None)

def _protected_relpaths(self) -> tuple[str, ...]:
"""Repo-relative posix paths of the BMAD artifact folders. These are
Expand Down Expand Up @@ -775,6 +790,11 @@ def _rollback_or_pause(self, task: StoryTask, *, cause: str = "stopped") -> None
# preserves best-effort but never blocks; a plain rollback pauses rather
# than reset past work it could not park.
self._preserve_attempt_commits(task, allow_pause=not redrive)
# Park the attempt's uncommitted diff too, so the reset below (and its
# untracked cleanup) can't silently destroy in-progress work. Runs only
# if _preserve_attempt_commits did not pause (plain-rollback preserve
# failure); best-effort, never blocks.
self._preserve_attempt_worktree(task)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self._safe_reset(task, preserve=protected)
return
self._pause_for_manual_recovery(task, task.baseline_commit or "")
Expand Down Expand Up @@ -842,6 +862,42 @@ def _preserve_attempt_commits(self, task: StoryTask, *, allow_pause: bool) -> No
"attempt-commits-preserved", story_key=task.story_key, ref=ref, count=len(commits)
)

def _preserve_attempt_worktree(self, task: StoryTask) -> None:
"""Before an auto-rollback's hard reset, park the attempt's *uncommitted*
working-tree changes (tracked edits + run-created untracked files) under a
named recovery ref, so `reset --hard baseline` and its untracked cleanup
can't silently destroy in-progress work. Complements
`_preserve_attempt_commits` (which parks *committed* work above baseline);
together they cover the whole attempt. No-op when the tree is clean vs HEAD
— the intended non-destructive uncommitted-revert case. Best-effort: a
capture failure is journaled but never blocks the (human-directed re-drive
or policy-gated) reset — the recovery ref is a safety net, not a gate."""
baseline = task.baseline_commit
if not baseline:
return
# Same git-safe, length-bounded slug as _preserve_attempt_commits so an
# exotic/overlong --run-id can't blow the ref-name limit and drop the ref.
slug = "".join(c if (c.isalnum() or c in "_-") else "-" for c in self.state.run_id)[:64]
# ``baseline_commit`` is fixed across the whole dev retry loop, so keying the
# ref on the baseline alone would make a 2nd dirty rollback reuse the name and
# orphan the 1st attempt's snapshot. ``task.attempt`` only ever increments
# (never resets), so it uniquely discriminates each retry's recovery ref.
ref = f"refs/attempt-preserve-dirty/{slug}-{baseline[:8]}-{task.attempt}"
try:
parked = verify.snapshot_worktree(
self.workspace.root, ref, baseline_untracked=task.baseline_untracked
)
except verify.GitError as exc:
# Keep the git failure detail (commit-tree/update-ref stderr): if the
# following reset destroys work, this is the only breadcrumb explaining
# why the safety-net snapshot couldn't be captured.
self.journal.append(
"attempt-worktree-preserve-failed", story_key=task.story_key, error=str(exc)
)
return

@augmentcode augmentcode Bot Jul 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/automator/engine.py:892: Catching verify.GitError here journals only a generic attempt-worktree-preserve-failed entry, dropping the underlying git error output; if the subsequent reset destroys work, this makes diagnosis of the preservation failure much harder.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7b06edf. _preserve_attempt_worktree now binds the exception and journals its message:

except verify.GitError as exc:
    self.journal.append(
        "attempt-worktree-preserve-failed", story_key=task.story_key, error=str(exc)
    )
    return

The GitError messages already carry the underlying git stderr (e.g. git commit-tree (snapshot) failed in <repo>: <output>), so this keeps the diagnosis breadcrumb when a preservation failure is followed by a destructive reset. Covered by test_rollback_worktree_preserve_failure_journals_git_error, which forces a GitError and asserts the journal entry's error field carries the detail while the reset still proceeds.

if parked:
self.journal.append("attempt-worktree-preserved", story_key=task.story_key, ref=parked)

def _pause_for_manual_recovery(
self, task: StoryTask, baseline: str, *, preserve_failed: bool = False
) -> None:
Expand Down
32 changes: 29 additions & 3 deletions src/automator/escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,22 @@ def decide_dev(
return Decision(Action.PAUSE, f"CRITICAL escalation from dev session: {details}")

budget_left = task.attempt < policy.limits.max_dev_attempts
exhausted = _exhausted_action(task)

if result.status != "completed":
reason = f"dev session {result.status}"
return Decision(Action.RETRY if budget_left else Action.DEFER, reason)
if budget_left:
return Decision(Action.RETRY, reason)
return Decision(exhausted, _exhaust_reason(task, reason))

assert outcome is not None
if outcome.ok:
return Decision(Action.PROCEED)
if outcome.severity == SEVERITY_CRITICAL:
return Decision(Action.PAUSE, outcome.reason)
return Decision(Action.RETRY if budget_left else Action.DEFER, outcome.reason)
if budget_left:
return Decision(Action.RETRY, outcome.reason)
return Decision(exhausted, _exhaust_reason(task, outcome.reason))


def decide_review_session(task: StoryTask, result: SessionResult, policy: Policy) -> Decision:
Expand All @@ -89,5 +94,26 @@ def decide_review_session(task: StoryTask, result: SessionResult, policy: Policy
budget_left = task.review_cycle < policy.limits.max_review_cycles
if result.status != "completed":
reason = f"review session {result.status}"
return Decision(Action.RETRY if budget_left else Action.DEFER, reason)
if budget_left:
return Decision(Action.RETRY, reason)
return Decision(_exhausted_action(task), _exhaust_reason(task, reason))
return Decision(Action.PROCEED)


def _exhausted_action(task: StoryTask) -> Action:
"""What a budget-exhausted, non-CRITICAL failure resolves to. Normally a
plateau-defer (skip the story, keep the run alive). But a story mid re-drive
of a human-resolved CRITICAL escalation (``resolved_redrive`` latched, not
yet re-committed) must NOT silently downgrade to a defer — that would file an
unresolved escalation as deferred work and roll back the human's correction.
Re-escalate so the human sees it again; ``_escalate`` preserves the tree."""
return Action.PAUSE if task.resolved_redrive else Action.DEFER


def _exhaust_reason(task: StoryTask, reason: str) -> str:
if task.resolved_redrive:
return (
"resolved-escalation re-drive did not converge — re-escalating "
f"instead of deferring: {reason}"
)
return reason
26 changes: 26 additions & 0 deletions src/automator/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ class RunState:
started_at: str
policy_snapshot: dict[str, Any] = field(default_factory=dict)
current_epic: int | None = None
# the run's story scope + cap, as passed on the launching CLI (`--epic`,
# `--story`, `--max-stories`). Persisted so `resume` rebuilds the Engine with
# the SAME selector — otherwise a resumed `--epic N` run silently widens to
# every epic and can jump out of its scope at the next pick.
epic_filter: int | None = None
story_filter: str | None = None
max_stories: int | None = None
paused_reason: str | None = None
paused_stage: str | None = None
paused_story_key: str | None = None
Expand Down Expand Up @@ -287,13 +294,29 @@ def clear_pause(self) -> None:
self.crashed = False
self.crash_error = None

def cache_read_weight(self) -> float:
"""The run's cache-read weight from its persisted policy snapshot; the
product default (policy.LimitsPolicy.cache_read_weight = 0.1) when the
snapshot predates the field or is malformed. Lets the TUI show the same
weighted total the engine's budget uses without importing Policy."""
limits = self.policy_snapshot.get("limits")
if isinstance(limits, dict):
try:
return float(limits["cache_read_weight"])
except (KeyError, TypeError, ValueError):
pass
return 0.1

def to_dict(self) -> dict[str, Any]:
return {
"run_id": self.run_id,
"project": self.project,
"started_at": self.started_at,
"policy_snapshot": self.policy_snapshot,
"current_epic": self.current_epic,
"epic_filter": self.epic_filter,
"story_filter": self.story_filter,
"max_stories": self.max_stories,
"paused_reason": self.paused_reason,
"paused_stage": self.paused_stage,
"paused_story_key": self.paused_story_key,
Expand All @@ -317,6 +340,9 @@ def from_dict(cls, d: dict[str, Any]) -> "RunState":
started_at=d["started_at"],
policy_snapshot=d.get("policy_snapshot", {}),
current_epic=d.get("current_epic"),
epic_filter=d.get("epic_filter"),
story_filter=d.get("story_filter"),
max_stories=d.get("max_stories"),
paused_reason=d.get("paused_reason"),
paused_stage=d.get("paused_stage"),
paused_story_key=d.get("paused_story_key"),
Expand Down
10 changes: 8 additions & 2 deletions src/automator/sprintstatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,18 @@ def load(path: Path) -> SprintStatus:
)


def next_actionable(ss: SprintStatus, skip: set[str] | None = None) -> Story | None:
"""First story in file order whose status allows starting work."""
def next_actionable(
ss: SprintStatus, skip: set[str] | None = None, *, epic: int | None = None
) -> Story | None:
"""First story in file order whose status allows starting work. When
``epic`` is given, only stories of that epic are considered — the caller
uses this to exhaust the current epic before advancing to another."""
skip = skip or set()
for story in ss.stories:
if story.key in skip:
continue
if epic is not None and story.epic != epic:
continue
if story.status in ACTIONABLE_STATUSES:
return story
return None
Expand Down
Loading
Loading