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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ breaking changes may land in a minor release.
severity (`P1`) files the entry without the line and journals
`spec-deferrals-severity-unrecognized` instead of dropping it silently; a collection
or NUL severity marks the item malformed (#913).
- Keep a parked story's follow-up review recommendation: the park record, the
`story-awaiting-operator` journal row, the park notification, `confirm --list`/`--json`,
`confirm`'s success output and `status --json` now carry `followup_review_recommended`,
and the escalations and tickets guides give the post-confirm review procedure. A park
still never runs the review (#888).

### Security

Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches
- `bmad-loop confirm --list` shows each parked story and what it owes; `--json` emits it as a machine-readable document.
- A confirmation adds an `## Operator Confirmation` section, moves spec and board to `done`, and commits.
- `confirm --reverify` runs the environment probes and `[verify]` commands first; a failure blocks the confirmation.
- A park keeps the build's follow-up review recommendation on the park record and shows it in `confirm`, `status --json` and the park notification. The park never runs that review: after confirming, the operator invokes the build skill on the spec to run it.
- `[operator] on_review_demotion = "park"` lets a review demote a `done` story to `awaiting-operator` (sprint and tickets mode); the default `escalate` rejects the demotion.
- **Config:** `[operator] enabled`, `[operator] on_review_demotion`, `bmad-loop confirm --list`, `bmad-loop confirm --yes`, `bmad-loop confirm --reverify`, `bmad-loop confirm --json`
- **Tested by:** `tests/test_engine.py`, `tests/test_cli.py`, `tests/test_operatoractions.py`, `tests/test_tickets_engine.py`
Expand Down
12 changes: 12 additions & 0 deletions docs/escalations-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,18 @@ If `confirm` is interrupted, run the same command again; it finishes without pro

In tickets mode the park lives on the ticket's plan, not a board. The unit's commit marks the plan `blocked` with a reason starting `awaiting operator: ` that lists the actions, and the park record names the ticket ref. Tickets that wait on it end the run with a parked summary. `confirm <ref>` appends the same section, then marks the plan through `tickets.py mark`: `done` under `[tickets] mark_done = "auto"`, `built` under `"never"`. A review that demotes a built ticket parks the same way under `[operator] on_review_demotion = "park"`.

### When a parked story's build recommended a follow-up review

A park skips the review loop: a review would either re-park the story or finalize the park away before the human acts. When the build session recommended a follow-up review, that pass is still owed, and the park keeps the recommendation. It shows in the park notification, in `confirm --list` (a `follow-up review recommended at park time` line under the actions), as `followup_review_recommended` in `confirm --json` and `status --json`, and on the `story-awaiting-operator` journal row. A park recorded before the field existed reads `null` and shows no line.

To run the owed pass:

1. Confirm the story first (`bmad-loop confirm <story-key>`). Its success output repeats the recommendation and names the spec.
2. Invoke the build skill that `confirm` names (bmad-build-auto, or bmad-dev-auto on a pre-rename install) on the now-`done` spec. The skill routes a `done` spec to a fresh follow-up review pass.
3. Commit what the pass patches.

Reviewing before you confirm does not work, because the build skill does not route a parked spec. If the owed action is a manual test, review the parked work first with a review that leaves the spec's status alone (for example bmad-code-review over the park commit), so the test covers reviewed code. Otherwise re-test after the post-confirm pass if it patches code.

## Environment faults

An environment fault is a failure caused by the environment rather than the code. It never charges a dev attempt.
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ Each row: `name`, `origin`, `path` (POSIX path from the code root), `surface` (`

### `confirm --json`

`parked`: stories at `awaiting-operator`, in story-key order. Each has `story_key`, `actions` (list of str), `spec_file`, `spec_status`, `board_status`, `commit` (`""` until a commit carries the park record), `run_id`, `parked_at`, `confirmable`, `confirmation_recorded`, `resumable` (an interrupted confirmation `confirm` will finish), and `drift` (human-readable).
`parked`: stories at `awaiting-operator`, in story-key order. Each has `story_key`, `actions` (list of str), `spec_file`, `spec_status`, `board_status`, `commit` (`""` until a commit carries the park record), `run_id`, `parked_at`, `confirmable`, `confirmation_recorded`, `resumable` (an interrupted confirmation `confirm` will finish), `drift` (human-readable), and `followup_review_recommended` (bool: the build recommended a follow-up review the park skipped; null for a park record that predates the field).

### `list --json`

Expand Down Expand Up @@ -547,7 +547,7 @@ Built from the run's `state.json`.
| `tickets` | object \| null | tickets runs only, below; null for every other source |
| `tasks` | array | one entry per story |

Each task: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `tokens` (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `raw`, `weighted`), `commit_sha`, `defer_reason`, `preserve_ref`, `adapters_used` (`{role: {name, model}}`), `ticket` (object or null, below).
Each task: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `tokens` (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `raw`, `weighted`), `commit_sha`, `defer_reason`, `preserve_ref`, `followup_review_recommended` (bool: the build session recommended a follow-up review), `adapters_used` (`{role: {name, model}}`), `ticket` (object or null, below).

`epic` is always an integer. A unit of a source with no integer epics (stories, sweep, tickets) reports `0`; a ticket's epic is in its `ticket` object.

Expand Down
2 changes: 2 additions & 0 deletions docs/tickets-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ The run records the document's `verdict` (or `unknown` when there is none) and p

When a session parks a ticket for a person (an `awaiting-operator` result, or a review demotion under `[operator] on_review_demotion = "park"`), the unit commits and its plan is marked `blocked` with the reason `awaiting operator: <actions>`, never `done`. Do the actions, then run `bmad-loop confirm <ref>`: it marks the plan `done` (`built` under `mark_done = "never"`) and clears `blocked_*`. The plan is committed with the park record's deletion when the code checkout tracks the store, in the store's own repository when it has one, and not at all in an unversioned store.

When the build session recommended a follow-up review, the park keeps that recommendation (`confirm --list`, `followup_review_recommended` in `confirm --json` and `status --json`) and `confirm <ref>` repeats it with the plan's path. Run the pass after confirming: the plan is then `done` (`built` under `mark_done = "never"`), and bmad-build-auto routes either status to a follow-up review. A parked plan reads `blocked`, which the skill refuses. See [When a parked story's build recommended a follow-up review](escalations-guide.md#when-a-parked-storys-build-recommended-a-follow-up-review).

An escalated ticket is recovered with `bmad-loop resolve <run-id>`. The ticket's plan is the frozen spec, and its `<intent-contract>` block is the intent. The re-arm moves the plan through `tickets.py mark` (back to `ready-for-dev`, or `in-review` for a restore) and clears `blocked_*`; a refused move aborts the re-arm and puts the plan back byte for byte. See [Resolve in tickets mode](escalations-guide.md#resolve-in-tickets-mode).

A code rollback re-arms the plan too: a plan past `ready-for-dev` returns to `ready-for-dev`, and a plan that holds only frontmatter is deleted.
Expand Down
16 changes: 16 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7158,6 +7158,10 @@ def _print_parked(parked: list[operatoractions.ParkedStory]) -> int:
print(f" {p.story_key} (parked {p.parked_at}{provenance}){suffix}")
for i, action in enumerate(p.actions, 1):
print(f" {i}. {action}")
# `is True`, not truthiness: None is a record that predates the field,
# which owes nothing we know of and so shows nothing.
if p.followup_review_recommended is True:
print(" follow-up review recommended at park time: run it after confirming")
print("")
print("confirm one when its actions are done: bmad-loop confirm <story-key>")
return 0
Expand Down Expand Up @@ -7693,6 +7697,18 @@ def _commit_confirmation(
except verify.GitError:
pass # files are written; git history is best effort (as `decisions`)
print(f"✓ {story.story_key} confirmed — {landed}")
if story.followup_review_recommended is True:
# The park skipped the pass; the now-done (or built) spec is one the build
# skill routes to a fresh review, which a parked spec is not.
# Named as the review session would spell it (a pre-rename project still
# drives `bmad-dev-auto`); an unreadable policy leaves it generic.
target = story.spec_path if story.spec_path is not None else "the story's spec"
pol = _confirm_policy(project)
skill = "the build skill" if pol is None else _dev_skill_for_role(pol, project, "review")
print(
f" follow-up review still owed: invoke {skill} on {target} "
"(it routes a done or built spec to a fresh review pass)"
)
return 0


Expand Down
8 changes: 5 additions & 3 deletions src/bmad_loop/devcontract.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,9 +638,11 @@ def synthesize_result(
# bmad-build-auto (BMAD-METHOD PR #2505) self-reviews inline and, on a `done`
# exit, sets `followup_review_recommended: true` when its review-driven
# changes warrant an independent second-opinion pass. The skill never sets it
# on a blocked exit, so only carry it through on a successful exit: `done`, or
# the caller's `success_statuses` (`built` for a v7 ticket).
if status in success_statuses:
# on a blocked exit, so only carry it through on a successful exit (`done`, or
# the caller's `success_statuses`: `built` for a v7 ticket) or a park. A park
# never routes into the review loop, but its record keeps the recommendation
# so the operator can run that skipped pass after confirming.
if status in success_statuses or status == AWAITING_OPERATOR:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
result["followup_review_recommended"] = bool(fm.get("followup_review_recommended", False))
# A park's obligations travel with its result so the engine never has to
# re-read the spec to learn them. Folded on the awaiting-operator status
Expand Down
11 changes: 10 additions & 1 deletion src/bmad_loop/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ def confirm_document(parked: list[ParkedStory]) -> dict[str, object]:
DERIVED from the park record's git history rather than stored at park time
(the committed record cannot contain its own sha), so it can be `""` for a
record not yet in any commit — a consumer that pinned "always 40 hex chars"
must treat empty as "no provenance", which the field always could express."""
must treat empty as "no provenance", which the field always could express.

`followup_review_recommended` is the build session's follow-up review
recommendation at park time, a pass the park skipped and the operator still
owes after confirming. It is a real bool for a park recorded with it and null
for a record that predates the field: unknown, deliberately not false."""
return {
"schema_version": CONFIRM_SCHEMA_VERSION,
"parked": [
Expand All @@ -184,6 +189,7 @@ def confirm_document(parked: list[ParkedStory]) -> dict[str, object]:
"confirmation_recorded": p.confirmation_recorded,
"resumable": p.resumable,
"drift": p.drift(),
"followup_review_recommended": p.followup_review_recommended,
}
for p in parked
],
Expand Down Expand Up @@ -378,6 +384,9 @@ def status_document(state: RunState, *, graceful_stop_pending: bool = False) ->
# null. Reported verbatim from state.json — never re-validated
# against git here (retention may since have pruned it).
"preserve_ref": task.preserve_ref,
# the build session's follow-up review recommendation; a park
# skips that pass, so a parked story still owes it after confirm.
"followup_review_recommended": task.followup_review_recommended,
"adapters_used": adapters_used,
"ticket": _ticket_projection(task),
}
Expand Down
33 changes: 26 additions & 7 deletions src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4899,12 +4899,16 @@ def validate_committed_publication(revision: str, staged_snapshot: object) -> No
advance(task, Phase.AWAITING_OPERATOR)
# The durable record of what a human owes: the actions themselves,
# not just a count, because the journal and the spec frontmatter are
# what survive the run.
# what survive the run. The follow-up review recommendation is the
# dev pass's (a dev park) or the last review pass's (a review-demotion
# park); a park never runs the recommended pass, so this row and the
# park record are where it survives.
self.journal.append(
"story-awaiting-operator",
story_key=task.story_key,
commit=task.commit_sha,
actions=list(task.operator_actions),
followup_review_recommended=task.followup_review_recommended,
)
self._notify_park(task)
else:
Expand Down Expand Up @@ -5055,6 +5059,7 @@ def _write_park_record(self, task: StoryTask) -> tuple[Path, str | None] | None:
run_id=self.state.run_id,
parked_at=self._today(),
root_identity=self._mount_root_identity(task, self.workspace.paths.project),
followup_review_recommended=task.followup_review_recommended,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
**self._park_record_fields(task),
)
except (OSError, RuntimeError) as e:
Expand Down Expand Up @@ -5174,16 +5179,27 @@ def _notify_park(self, task: StoryTask) -> None:

Each action is agent-authored text, so it is folded through
``gates.notice_line`` (DW-417): an embedded line break stays on its own
numbered line as a `` ⏎ `` segment instead of landing loose in ATTENTION."""
numbered line as a `` ⏎ `` segment instead of landing loose in ATTENTION.

When the build session recommended a follow-up review, a line before the
confirm hint says so: a park skips that pass, so the human runs it after
confirming."""
actions = "\n".join(
f" {i}. {gates.notice_line(a)}" for i, a in enumerate(task.operator_actions, 1)
)
followup = (
"the build session also recommended a follow-up review, which a park skips: "
"after confirming, re-run the build skill on the spec for that pass.\n"
if task.followup_review_recommended
else ""
)
gates.notify(
self.policy,
self.run_dir,
f"story awaiting operator: {task.story_key}",
f"committed, but {len(task.operator_actions)} action(s) are owed outside the repo:\n"
f"{actions}\n"
f"{followup}"
f"run `bmad-loop confirm {task.story_key}` once they are done.",
multiline=True,
)
Expand Down Expand Up @@ -5308,8 +5324,10 @@ def _followup_from_spec(self, task: StoryTask, rj: dict) -> bool:
flag — so without this fallback a recommended follow-up review would be
silently skipped. Gates on the frontmatter's own status (mirroring the
fold): a faulted replay leaves ``rj["status"]`` at the stale snapshot
value, so the result status must not decide. Degrades to False on a read
fault (journaled) — the pre-existing absent-key default.
value, so the result status must not decide. A parked spec counts too,
as it does in the synthesis: its park record carries the recommendation.
Degrades to False on a read fault (journaled) — the pre-existing
absent-key default.
"""
if not self._generic_dev():
return False
Expand All @@ -5322,9 +5340,10 @@ def _followup_from_spec(self, task: StoryTask, rj: dict) -> bool:
fm = self._observed_frontmatter(spec_path, task.story_key, "followup-routing")
if fm is None:
return False
return verify.status_of(fm) == self._dev_success_status() and bool(
fm.get("followup_review_recommended", False)
)
return verify.status_of(fm) in (
self._dev_success_status(),
verify.AWAITING_OPERATOR,
) and bool(fm.get("followup_review_recommended", False))

def _reconcile_generic_terminal_status(self, task: StoryTask, result_json: dict | None) -> None:
"""Repair a generic-skill spec the session finalized in prose but not in
Expand Down
18 changes: 17 additions & 1 deletion src/bmad_loop/operatoractions.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def record_park(
root_identity: os.stat_result | None = None,
source: str | None = None,
ref: str | None = None,
followup_review_recommended: bool | None = None,
) -> Path:
"""Write a story's park record, returning its path. Re-parking the same key
overwrites rather than accumulates: a story owes whatever its latest park
Expand All @@ -165,6 +166,11 @@ def record_park(
Each is recorded only when given, so a sprint or stories record keeps its
five keys.

``followup_review_recommended`` records the build session's follow-up review
recommendation at park time, a pass the park skips, so the record is where the
operator learns it is still owed. Like ``source``/``ref`` it is written only
when given.

The write goes through :func:`platform_util.atomic_write_text` (#379), which
carries the unlink-on-raise this used to hand-roll — hence no try/except here;
two nested guards would only obscure which one runs — and adds the two things
Expand Down Expand Up @@ -229,6 +235,8 @@ def record_park(
record["source"] = source
if ref is not None:
record["ref"] = ref
if followup_review_recommended is not None:
record["followup_review_recommended"] = followup_review_recommended
atomic_write_text_confined(
path,
json.dumps(record, indent=2, sort_keys=True),
Expand Down Expand Up @@ -340,7 +348,12 @@ class ParkedStory:
A ticket park (``source`` :data:`SOURCE_TICKETS`) has no board: its committed
truth is the plan alone, parked as ``blocked`` with an operator-park
``blocked_reason`` (read from the plan, "" when unset), and ``ref`` is the
ticket whose plan ``confirm`` marks."""
ticket whose plan ``confirm`` marks.

``followup_review_recommended`` is the build session's follow-up review
recommendation at park time, read from the record. None means the record
predates the field or holds a non-bool: unknown, deliberately distinct from
False (the engine always records a real bool)."""

story_key: str
actions: tuple[str, ...]
Expand All @@ -354,6 +367,7 @@ class ParkedStory:
source: str = ""
ref: str = ""
blocked_reason: str = ""
followup_review_recommended: bool | None = None

@property
def ticket(self) -> bool:
Expand Down Expand Up @@ -500,6 +514,7 @@ def resolve(project: Path, paths: ProjectPaths) -> list[ParkedStory]:
spec_path = _spec_path(entry, paths)
spec_fm = _spec_frontmatter(spec_path)
spec_actions = operator_actions_of(spec_fm) if spec_fm is not None else ()
raw = entry.get("followup_review_recommended")
out.append(
ParkedStory(
story_key=key,
Expand All @@ -516,6 +531,7 @@ def resolve(project: Path, paths: ProjectPaths) -> list[ParkedStory]:
source=str(entry.get("source") or ""),
ref=str(entry.get("ref") or ""),
blocked_reason=_blocked_reason(spec_fm),
followup_review_recommended=raw if isinstance(raw, bool) else None,
)
)
return out
Expand Down
Loading
Loading