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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ breaking changes may land in a minor release.
patch that fails to apply escalates instead of running on a half-restored tree (a resolve session
that committed over the patched lines triggers exactly this — re-resolve without a restore).
Restore is rejected up front for worktree-isolation runs and for stories-mode pre-planning
sentinels, and the latched patch file itself never counts as proof-of-work.
sentinels, and the latched patch file itself never counts as proof-of-work. Deferred-work
`sweep` bundles get the same recovery — an escalated bundle re-arms to `in-review` and the
re-driven bundle session resumes review on the re-applied patch (#75).
- **Preflight covers the inline review layers.** `bmad-loop validate` (and run-start) now require the
three upstream review-hunter skills `bmad-dev-auto`'s step-04 invokes — `bmad-review-adversarial-general`,
`bmad-review-edge-case-hunter`, and `bmad-review-verification-gap` (new in BMAD-METHOD#2550) — plus a
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues.
- Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues.
- CRITICAL resolution: `bmad-loop resolve <run-id>` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself.
- Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, `bmad-dev-auto` saves the attempted change as a patch file (in the implementation-artifacts folder, referenced from the halt output) before reverting the tree. If the attempted reading turns out to be correct, the resolve agent adds `"restore_patch": "<path>"` to its `resolution.json`; the orchestrator then re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch onto the baseline after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing from scratch. A hand-driven `bmad-loop resolve --no-interactive --restore-patch <path>` does the same. A patch that fails to apply escalates rather than dispatching a session onto a half-restored tree.
- Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, `bmad-dev-auto` saves the attempted change as a patch file (in the implementation-artifacts folder, referenced from the halt output) before reverting the tree. If the attempted reading turns out to be correct, the resolve agent adds `"restore_patch": "<path>"` to its `resolution.json`; the orchestrator then re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch onto the baseline after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing from scratch. A hand-driven `bmad-loop resolve --no-interactive --restore-patch <path>` does the same. A patch that fails to apply escalates rather than dispatching a session onto a half-restored tree. Deferred-work sweep bundles (below) get the same recovery — an escalated bundle re-arms to `in-review` and the re-driven bundle session resumes review on the re-applied patch.

### Git worktree isolation (opt-in)

Expand Down Expand Up @@ -106,7 +106,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Answer skipped/missed decisions out of band with `bmad-loop decisions` (or `d` in the TUI): reconstructed from past triage output, saved to `.bmad-loop/decisions.json`, and consumed by the next sweep with no re-prompt (build → bundle, close → closed, keep-open → recorded).
- Auto-sweep at epic boundaries or run-end (`[sweep] auto`); a failed/paused child sweep never interrupts the parent run.
- Repeat mode (`--repeat` / `[sweep] repeat`): re-triages after each cycle to absorb newly generated deferred work, stopping when a cycle does nothing addressable or hits `max_cycles`.
- Sweeps are their own resumable runs (`bmad-loop resume <id>`).
- Sweeps are their own resumable runs (`bmad-loop resume <id>`). An escalated bundle resolves like a story escalation, including intent-gap patch-restore: `bmad-loop resolve <id> --restore-patch <path>` re-arms the bundle spec to `in-review` and the re-driven bundle session resumes review on the re-applied patch instead of re-implementing.

### Stories mode (folder+id dispatch)

Expand Down
31 changes: 28 additions & 3 deletions src/bmad_loop/sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,10 @@ def _run_bundle(self, bundle: Bundle, cycle: int) -> None:
self.state.tasks[key] = task
self.journal.append("bundle-start", story_key=key, dw_ids=list(bundle.dw_ids))
else:
# interrupted mid-bundle: same recovery as Engine._finish_inflight
# interrupted mid-bundle (or a re-armed escalation resolved via
# `bmad-loop resolve`): same recovery as Engine._finish_inflight,
# including the re-drive latch so a human-resolved escalation is
# protected through every reset (mirrors engine.py:1151-1157).
self.journal.append("resume-restart", story_key=key, phase=str(task.phase))
isolated = self._isolated and task.worktree_path
if task.phase == Phase.DEV_VERIFY and task.spec_file:
Expand All @@ -538,7 +541,13 @@ def _run_bundle(self, bundle: Bundle, cycle: int) -> None:
task.worktree_path = ""
task.branch = ""
elif task.baseline_commit:
self._rollback_or_pause(task)
# latch resolved_redrive so the corrected spec + restored diff stay
# protected through every reset of this re-drive, not just this
# first one; cause="resolved" keeps a human-initiated re-arm
# pause-free regardless of scm.rollback_on_failure
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
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 @@ -979,9 +988,25 @@ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
intent.md (intent + verbatim ledger entries) is handed over as freeform
intent. The orchestrator owns the deferred-work ledger — the skill is told
not to edit it — and records resolution itself in `_post_dev_state_sync`.
On a repair the bundle spec is re-opened first (B6) so step-01 resumes."""
On a repair the bundle spec is re-opened first (B6) so step-01 resumes.

A patch-restore re-drive (#2564, #75) must point at the bundle spec
explicitly: only step-01's spec-pointer intent check EARLY EXITs on the
`in-review` status the re-arm set — before step-01's version-control
sanity check, which would otherwise HALT `blocked` on the diff
`_restore_patch` just laid onto the tree. The freeform intent.md pointer
takes the path where that dirty-tree check runs first."""
bundle_ref = task.bundle_file or task.story_key
if feedback is None:
if task.restore_patch and task.spec_file:
return (
f"/bmad-dev-auto Resume review of the in-review spec at "
f"`{task.spec_file}` for the deferred-work bundle `{bundle_ref}`. "
f"The attempted change was restored onto the working tree after "
f"an intent-gap resolution; review it against the amended spec. "
f"Do NOT edit the deferred-work ledger; the orchestrator records "
f"resolution."
)
return (
f"/bmad-dev-auto Implement the deferred-work bundle described in "
f"`{bundle_ref}` — it carries the intent and the verbatim ledger "
Expand Down
28 changes: 28 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,31 @@ def effect(spec: SessionSpec) -> SessionResult:
)

return effect


def bundle_dev_escalates(paths: ProjectPaths, name: str, dw_ids, detail: str = "intent gap"):
"""Simulate a bmad-dev-auto bundle session that hits an intent gap during its
inline review: it reverts its attempt, saves a patch, writes the bundle spec
``blocked``, and surfaces a CRITICAL escalation naming the spec — so the run
pauses for `bmad-loop resolve --restore-patch`. ``spec_file`` in the result lets
``_record_dev_spec`` latch ``task.spec_file`` (the restore re-arm's in-review
target), and the ``blocked`` status keeps the dw ids open (not synced done)."""

def effect(spec: SessionSpec) -> SessionResult:
sp = bundle_spec_path(paths, name)
baseline = rev_parse_head(paths.project)
write_spec(sp, "blocked", baseline)
return SessionResult(
status="completed",
result_json={
"workflow": "auto-dev",
"story_key": f"dw-{name}",
"spec_file": str(sp),
"dw_ids": list(dw_ids),
"escalations": [
{"type": "bundle-item-blocked", "severity": "CRITICAL", "detail": detail}
],
},
)

return effect
170 changes: 165 additions & 5 deletions tests/test_stories_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@
two-leg plan-halt + resume, (4) blocked → resolve → re-dispatch, (6)
sprint-mode regression (the new folder+id-capable dev skill installed, yet a
plain sprint run drives dev → verify → commit → sprint-status advance untouched
by the stories wiring), and (7) sprint-mode intent-gap patch-restore (halt saves
by the stories wiring), (7) sprint-mode intent-gap patch-restore (halt saves
the attempt as a patch → `resolve --restore-patch` re-arms to in-review +
re-stamps the spec baseline → resume re-applies the patch and dispatches an
explicit spec pointer, resuming review instead of re-implementing). Scenarios
(3) `done_checkpoint` and (5) worktree isolation are covered deterministically
at the engine level in test_stories_engine.py; here we prove the end-to-end CLI
stack.
explicit spec pointer, resuming review instead of re-implementing), and (8) the
same intent-gap patch-restore for a `sweep` deferred-work bundle (#75) —
triage → bundle halt → `resolve --restore-patch` → resume, driving the
sweep-specific CLI resolve→resume path (SweepEngine rebuilt from sweep.json).
Scenarios (3) `done_checkpoint` and (5) worktree isolation are covered
deterministically at the engine level in test_stories_engine.py; here we prove
the end-to-end CLI stack.
"""

from __future__ import annotations
Expand Down Expand Up @@ -57,6 +60,23 @@
"$ts" "$tid" > "$rd/events/$ts-$tid-SessionStart.json"
baseline=$(git rev-parse HEAD)

# SWEEP triage (`/bmad-loop-sweep`): the triage adapter is a plain GenericAdapter
# that reads a real result.json (not the spec-synthesizing dev adapter), so write
# the partition ourselves — one bundle "fix" owning the single open ledger id — and
# Stop. Bundle dev sessions carry no BMAD_LOOP_SPEC_FOLDER and fall through to the
# SPRINT branch below, which already drives spec-<key>.md and the intent-gap /
# patch-restore contracts (here <key> = dw-fix, the bundle task key).
if printf '%s' "$prompt" | grep -q "bmad-loop-sweep"; then
tdir="$rd/tasks/$tid"; mkdir -p "$tdir"
printf '%s' '{"workflow": "deferred-sweep-triage", "open_ids": ["DW-1"], "already_resolved": [], "bundles": [{"name": "fix", "dw_ids": ["DW-1"], "intent": "resolve DW-1"}], "blocked": [], "skip": [], "decisions": [], "escalations": []}' \
> "$tdir/result.json"
ts2=$(( ts + 1 ))
printf '{"ts": %s, "event": "Stop", "task_id": "%s", "session_id": "fake-1"}' \
"$ts2" "$tid" > "$rd/events/$ts2-$tid-Stop.json"
sleep 30
exit 0
fi

# SPRINT mode (no BMAD_LOOP_SPEC_FOLDER in env): the folder+id-capable skill is
# installed, but a plain sprint run must still work. Write the result artifact
# the orchestrator scans by mtime under implementation-artifacts, make a real
Expand Down Expand Up @@ -313,6 +333,93 @@ def _scaffold_sprint(root: Path, story_key: str) -> None:
_git(root, "commit", "-q", "-m", "sandbox")


def _scaffold_sweep(root: Path) -> None:
"""A committed, clean SWEEP-mode sandbox: same folder+id-capable dev-skill
stubs as `_scaffold_sprint`, but no sprint-status — instead a canonical
deferred-work.md ledger with one open entry (DW-1). The policy has no
[stories] section; the run is a plain `bmad-loop sweep`."""
root.mkdir(parents=True, exist_ok=True)
(root / "src.txt").write_text("original\n", encoding="utf-8")
(root / ".gitignore").write_text(".bmad-loop/runs/\n", encoding="utf-8")

cfg = root / "_bmad" / "bmm"
cfg.mkdir(parents=True)
(cfg / "config.yaml").write_text(
"implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts'\n"
"planning_artifacts: '{project-root}/_bmad-output/planning-artifacts'\n",
encoding="utf-8",
)
impl = root / "_bmad-output" / "implementation-artifacts"
for sub in ("implementation-artifacts", "planning-artifacts"):
(root / "_bmad-output" / sub).mkdir(parents=True, exist_ok=True)
(root / "_bmad-output" / sub / ".keep").write_text("", encoding="utf-8")

# the SAME folder+id-capable skill stubs the other scaffolds install
skills = root / ".claude" / "skills"
dev = skills / "bmad-dev-auto"
dev.mkdir(parents=True)
(dev / "SKILL.md").write_text("# bmad-dev-auto\n", encoding="utf-8")
(dev / "step-04-review.md").write_text("x\n", encoding="utf-8")
(dev / "customize.toml").write_text("# review layers\n", encoding="utf-8")
(dev / "step-01-clarify-and-route.md").write_text(
"This is a **folder+id dispatch** router.\n", encoding="utf-8"
)
for hunter in (
"bmad-review-adversarial-general",
"bmad-review-edge-case-hunter",
"bmad-review-verification-gap",
):
(skills / hunter).mkdir(parents=True)
(skills / hunter / "SKILL.md").write_text(f"# {hunter}\n", encoding="utf-8")

# canonical DW-format ledger (no legacy content → migration is skipped)
(impl / "deferred-work.md").write_text(
"# Deferred Work\n\n"
"### DW-1: item DW-1\n\n"
"origin: test, 2026-06-01\nlocation: src.txt:1\nreason: test entry.\nstatus: open\n",
encoding="utf-8",
)

fake = root / ".bmad-loop" / "fake-cli.sh"
fake.parent.mkdir(parents=True, exist_ok=True)
fake.write_text(FAKE_CLI, encoding="utf-8")
os.chmod(fake, 0o755)
profiles = root / ".bmad-loop" / "profiles"
profiles.mkdir(parents=True)
(profiles / "fakestories.toml").write_text(
PROFILE_TOML.format(binary=str(fake)), encoding="utf-8"
)
(root / ".bmad-loop" / "policy.toml").write_text(
'[adapter]\nname = "fakestories"\n\n'
"[review]\nenabled = false\n\n"
'[gates]\nmode = "none"\n',
encoding="utf-8",
)

_git(root, "init", "-q", "-b", "main")
_git(root, "config", "user.email", "e2e@test")
_git(root, "config", "user.name", "e2e")
_git(root, "config", "core.fsync", "none")
_git(root, "add", "-A")
_git(root, "commit", "-q", "-m", "sandbox")


def _dw_status(root: Path, dw_id: str) -> str:
"""The status field of one deferred-work ledger entry ('' if absent)."""
text = (root / "_bmad-output" / "implementation-artifacts" / "deferred-work.md").read_text(
encoding="utf-8"
)
in_entry = False
for line in text.splitlines():
if line.startswith(f"### {dw_id}:"):
in_entry = True
elif line.startswith("### "):
in_entry = False
elif in_entry and line.startswith("status:"):
return line.split(":", 1)[1].strip()
return ""


def _sprint_status(root: Path, story_key: str) -> str:
doc = yaml.safe_load(
(root / "_bmad-output" / "implementation-artifacts" / "sprint-status.yaml").read_text(
Expand Down Expand Up @@ -478,6 +585,59 @@ def test_e2e_sprint_intent_gap_patch_restore(tmp_path):
assert any(str(spec) in p for p in prompts)


def test_e2e_sweep_intent_gap_patch_restore(tmp_path):
# Scenario 8 (#75): a SWEEP deferred-work bundle hits an intent gap during its
# dev session — the patch is saved, the tree reverted, and the run escalates.
# `resolve --restore-patch` re-arms the bundle spec to in-review + re-stamps its
# baseline; resume re-applies the patch and dispatches an EXPLICIT spec pointer
# (Change A) so the bundle resumes review on the restored diff instead of
# re-implementing. This is the only scenario that drives the sweep-specific CLI
# resolve→resume path (SweepEngine rebuilt from sweep.json in _resume_paused_run).
root = tmp_path / "sbx"
_scaffold_sweep(root)
story = "dw-fix" # triage names the bundle "fix" → task key dw-fix
(root / f".intent-gap-{story}").write_text("", encoding="utf-8")
_git(root, "add", "-A")
_git(root, "commit", "-q", "-m", "poison: intent gap")
impl = root / "_bmad-output" / "implementation-artifacts"
spec = impl / f"spec-{story}.md"
patch = impl / f"attempt-{story}.patch"

# triage → bundle dev halts on the intent gap: patch saved, tree reverted
proc = _run(root, "sweep", "--no-prompt")
assert proc.returncode == 0, proc.stderr or proc.stdout
assert "status: blocked" in spec.read_text(encoding="utf-8")
assert patch.is_file() # the attempted change survives the revert
assert "attempted reading" not in (root / "src.txt").read_text(encoding="utf-8")
assert _dw_status(root, "DW-1").startswith("open") # a blocked pass does not close it
run_id = _run_id(root)

# resolve latches the restore: bundle spec → in-review + baseline re-stamped
resolve = _run(
root, "resolve", run_id, "--no-interactive", "--no-resume", "--restore-patch", str(patch)
)
assert resolve.returncode == 0, resolve.stderr or resolve.stdout
text = spec.read_text(encoding="utf-8")
assert "status: in-review" in text # restore routing: step-01 → step-04
head = subprocess.run(
["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True
).stdout.strip()
assert f"baseline_revision: {head}" in text # F2: spec baseline re-stamped

# resume: patch re-applied, review resumed, bundle lands done + ledger closed
resume = _run(root, "resume", run_id)
assert resume.returncode == 0, resume.stderr or resume.stdout
final = spec.read_text(encoding="utf-8")
assert "status: done" in final, final # fake blocks loudly on a broken contract
assert _dw_status(root, "DW-1").startswith("done") # the bundle closed the ledger id
src = (root / "src.txt").read_text(encoding="utf-8")
assert src.count("attempted reading") == 1 # restored from the patch, not re-implemented
# Change A: the re-drive dispatch pointed at the bundle spec, never the intent.md
run_dir = root / ".bmad-loop" / "runs" / run_id
prompts = [p.read_text(encoding="utf-8") for p in (run_dir / "tasks").glob("*/prompt.txt")]
assert any(str(spec) in p for p in prompts)


def test_e2e_sprint_mode_regression(tmp_path):
# Scenario 6 (audit MAJOR-2): the new folder+id-capable bmad-dev-auto skill is
# installed, but this is a plain SPRINT-mode run. It must drive dev → verify →
Expand Down
Loading
Loading