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
27 changes: 18 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ breaking changes may land in a minor release.

### Added

- **A park travels with its story's commit, so `bmad-loop confirm` works from any clone (#356).**
Each parked story now writes one committed JSON record to `.bmad-loop/operator/<key>.json`, inside
the story's own commit window, so the record rides the park's commit — through the worktree
merge-back, under every merge strategy — and reaches every clone the commit does. A teammate,
fresh clone, or CI can `confirm --list` and `confirm <key>` a story parked on another machine.
The machine-local index (`.bmad-loop/operator-actions.json`) is retired: still read and pruned so
an in-flight park from an older version stays confirmable where it was written, never written
again. The record carries no commit sha (it cannot name the commit it rides); provenance is
derived from the record's own git history, and an uncommitted record renders without one. A
failed record write never blocks the story — it parks recordless, journaled and reported by
`validate` — and a failed commit restores the record as found. Confirming commits the record's
deletion together with the spec+board flip, and a legacy-only park still gets that commit.

- **`bmad-loop confirm` completes a parked story (#335, part 3 of 4).** Once you have carried out the
external actions a story owed, `bmad-loop confirm <story-key>` walks you through them one at a time
(`--yes` skips the prompts), writes the spec's `## Operator Confirmation` audit section, advances
Expand All @@ -31,15 +44,11 @@ breaking changes may land in a minor release.
disagreement that co-occurs with an unreadable action list — the two have different remedies, and
only the first was ever reported.

Parks are indexed in `.bmad-loop/operator-actions.json`, written at park time along with a
notification naming the actions and the command that ends them. The index is **machine-local and
never committed** (it is registered in the repository's local git exclude): it is written from
inside a story's commit window, where committing it would either shift `HEAD` past the commit the
park just stamped or, under worktree isolation, advance the target branch and break an
`scm.merge_strategy = "ff"` merge-back. So a park is confirmed on the machine that ran it. The
committed truth stays the spec and the board; `confirm` refuses an index entry that disagrees with
them, and `validate` reports the drift in both directions (`operator.registry-stale`,
`operator.actions-malformed`).
Parks are recorded at park time along with a notification naming the actions and the command
that ends them — as committed per-story records since #356 (above), which is what lets a clone
that never ran the story confirm it. The committed truth stays the spec and the board: `confirm`
refuses a record that disagrees with them, and `validate` reports the drift in both directions
(`operator.registry-stale`, `operator.actions-malformed`).

- **Stories can park at `awaiting-operator` (#335, part 2 of 4).** A dev session whose story needs
an action only a human can take outside the repo — buy a domain, publish a DNS record, grant an
Expand Down
27 changes: 19 additions & 8 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1567,7 +1567,11 @@ def _print_parked(parked: list[operatoractions.ParkedStory]) -> int:
else:
drift = p.drift()
suffix = f" — NOT CONFIRMABLE: {drift}" if drift else ""
print(f" {p.story_key} (parked {p.parked_at}, commit {p.commit[:8]}){suffix}")
# `commit` is derived provenance and legitimately empty for a record not
# yet in any commit (NO_VCS, or a crash before the park's commit landed)
# — render nothing rather than a dangling "commit " fragment.
provenance = f", commit {p.commit[:8]}" if p.commit else ""
print(f" {p.story_key} (parked {p.parked_at}{provenance}){suffix}")
for i, action in enumerate(p.actions, 1):
print(f" {i}. {action}")
print("")
Expand Down Expand Up @@ -1606,7 +1610,7 @@ def cmd_confirm(args: argparse.Namespace) -> int:
Out of band by construction. The run that parked the story is long finished
and its task is in a terminal phase with no legal transition, so this touches
no run state at all — it writes the two things that DEFINE the story's
completion (the spec and the board) and drops the index entry that pointed at
completion (the spec and the board) and drops the park record that pointed at
them. Nothing is re-driven: the agent-doable work was committed at park time,
and re-running a session would redo finished work while the human's actions
stayed outside the repo. `--reverify` is there for the case that matters —
Expand Down Expand Up @@ -1636,10 +1640,11 @@ def cmd_confirm(args: argparse.Namespace) -> int:
story = next((p for p in parked if p.story_key == key), None)
if story is None:
print(
f"error: {key} is not awaiting operator actions on this machine. The index "
f"({operatoractions.STORE_REL}) is written by the run that parked the story "
f"and is not committed, so confirm runs where the orchestrator ran. "
f"`bmad-loop confirm --list` shows what is parked here.",
f"error: {key} is not awaiting operator actions in this checkout. A park is "
f"recorded under {operatoractions.RECORDS_REL.as_posix()}/ and travels with "
f"the story's own commit, so if another machine's run parked it, pull the "
f"branch that carries the park commit. `bmad-loop confirm --list` shows "
f"what is parked here.",
file=sys.stderr,
)
return 1
Expand Down Expand Up @@ -1814,7 +1819,7 @@ def _land_confirmation(
today: str,
) -> int:
"""The half of a confirmation that lives OUTSIDE the spec: advance the board,
drop the index entry, commit the pair.
drop the park record, commit the set.

Split out because it is exactly what an interrupted confirmation still owes.
The spec half is already on disk in that case — audit section appended, status
Expand All @@ -1835,12 +1840,18 @@ def _land_confirmation(
file=sys.stderr,
)
return 1
# Resolve the record path BEFORE dropping it: the drop unlinks the file, and
# a committed record's deletion must ride the confirm commit (#356) — the
# record arrived in the park's commit, so leaving its removal uncommitted
# would dirty the tree the next run's preflight refuses. `commit_paths`
# skips the path when no record was ever committed (a legacy-index park).
record = operatoractions.record_path(project, story.story_key)
operatoractions.drop(project, story.story_key)
try:
verify.commit_paths(
paths.repo_root,
f"chore(operator): confirm {story.story_key}",
[spec, paths.sprint_status],
[spec, paths.sprint_status, record],
)
except verify.GitError:
pass # files are written; git history is best effort (as `decisions`)
Expand Down
9 changes: 6 additions & 3 deletions src/bmad_loop/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,12 @@ def confirm_document(parked: list[ParkedStory]) -> dict[str, object]:
has to be able to see WHICH reading made the boolean what it is.

CONFIRM_SCHEMA_VERSION deliberately stays 1. Evolution here is additive-only
(machine.py), new fields may appear, and every field that already existed
produces the same value for every input — nothing a consumer pinned has
moved."""
(machine.py), new fields may appear, and no field has changed presence or
type. One value semantic did move with #356, stated honestly: `commit` is now
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."""
return {
"schema_version": CONFIRM_SCHEMA_VERSION,
"parked": [
Expand Down
93 changes: 63 additions & 30 deletions src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1798,10 +1798,11 @@ def _finalize_commit_phase(self, task: StoryTask) -> None:
inside it. ``finalize_commit`` is content-idempotent across both crash
states — pre-squash (skill commit chain above baseline) squashes
normally; post-squash (squashed commit at HEAD, clean tree)
re-squashes to an identical-content commit, orphaning the pre-crash
squash (harmless). ``commit_sha`` is stamped only here and is
write-only (never routing), so the empty persisted value is
harmless."""
re-squashes to an equivalent-content commit, orphaning the pre-crash
squash (harmless; "equivalent" rather than "identical" because a park
record regenerated across midnight carries a new ``parked_at``).
``commit_sha`` is stamped only here and is write-only (never routing),
so the empty persisted value is harmless."""
message = self._commit_message(task)
# pre_commit: a plugin may rewrite the commit message or escalate (pause).
# A defer/skip veto would have to unwind a COMMITTING task (no legal move
Expand All @@ -1821,8 +1822,13 @@ def _finalize_commit_phase(self, task: StoryTask) -> None:
# the close, before its write, so both failure arms below hold the
# pre-close text no matter where in the window a raise lands.
snapshot: list[tuple[Path, str]] = []
park_record: tuple[Path, str | None] | None = None
try:
self._close_declared_deferred(task, snapshot)
# A parked story's record rides this same commit (#356): written into
# the workspace ahead of the `git add -A`, it reaches every clone the
# story's commit does — including through the worktree merge-back.
park_record = self._write_park_record(task)
# bmad-dev-auto commits its own work each iteration; the orchestrator
# squashes that chain plus its uncommitted bookkeeping back onto the
# pre-dev baseline as one commit carrying `message`. None means there
Expand All @@ -1837,16 +1843,18 @@ def _finalize_commit_phase(self, task: StoryTask) -> None:
task.restore_patch = None
except verify.GitError as e:
self._restore_deferred_closes(task, snapshot)
self._restore_park_record(park_record)
self._escalate(task, f"commit failed: {e}")
except BaseException:
# A failed commit is not the only way out of this window: the signal
# handler `_run` installed raises RunStopped from wherever the main
# thread is standing, and an OSError raised outside `_run_git` (an
# FS fault; the spawn class arrives as GitSpawnError since #343 and
# takes the arm above) passes through as itself. Either leaves the
# ledger flipped for a commit that does not exist. Restore, then
# re-raise untouched.
# ledger flipped — and the park record written — for a commit that
# does not exist. Restore, then re-raise untouched.
self._restore_deferred_closes(task, snapshot)
self._restore_park_record(park_record)
raise
# Final-phase rule: AWAITING_OPERATOR iff the task carries actions,
# otherwise DONE. Derived from PERSISTED task state, never from a local
Expand All @@ -1864,7 +1872,6 @@ def _finalize_commit_phase(self, task: StoryTask) -> None:
commit=task.commit_sha,
actions=list(task.operator_actions),
)
self._index_park(task)
self._notify_park(task)
else:
advance(task, Phase.DONE)
Expand All @@ -1888,8 +1895,8 @@ def _park_spec_relpath(self, task: StoryTask) -> str:
`RuntimeError` joins the guard because `Path.resolve` reports a symlink
loop that way below 3.13 — the same non-OSError `_ledger_in_repo` already
catches, and `requires-python` is 3.11. Catching it HERE rather than only
around the call is what keeps the fallback: the index entry is still
written, with `spec_file` recorded verbatim, and per #356 the entry is
around the call is what keeps the fallback: the park record is still
written, with `spec_file` recorded verbatim, and per #356 the record is
load-bearing (a story key does not yield a spec path)."""
spec_file = task.spec_file or ""
if not spec_file:
Expand All @@ -1899,39 +1906,65 @@ def _park_spec_relpath(self, task: StoryTask) -> str:
except (OSError, RuntimeError, ValueError):
return spec_file

def _index_park(self, task: StoryTask) -> None:
"""Add the parked story to the project's operator-actions index — the
store `bmad-loop confirm` reads.
def _write_park_record(self, task: StoryTask) -> tuple[Path, str | None] | None:
"""Write the parked story's committed record — the store `bmad-loop
confirm` reads (#356) — returning ``(path, prior_text)`` for the restore
arm, or None when nothing was written (not a park, or the write failed).

Runs inside the commit window, into the WORKSPACE (the unit worktree
under isolation, like the sprint-status mirror and the deferred-close
annotations), so `finalize_commit`'s `git add -A` folds it into the
story's own commit and the merge-back carries it to the target. Rooted
at `self.workspace.paths.project`, NOT `self.paths.project`: the main
root is the wrong tree here — a record written there would sit
untracked beside the commit instead of inside it.

Best-effort by design, and the only write on this path that is. The
commit has already landed: the work is safe, the board says
`awaiting-operator`, and the journal and spec both record what is owed.
Raising here would abort a story that genuinely succeeded over its
bookkeeping, so an unwritable index degrades to a journal line — and
`validate` reports the resulting drift (`operator.registry-stale`).

This runs at the tail of `_finalize_commit_phase`, OUTSIDE every try
there and after `advance(task, Phase.AWAITING_OPERATOR)`, so an escape
skips `_notify_park`, `_emit("post_commit")` and `self._save()` — the
park phase is never persisted, over an index write that was best-effort
by design. `RuntimeError` is in the guard for a second reason beyond the
resolve above: `record_park` -> `_write_store` -> `_exclude_from_git` ->
`install._worktree_local_exclude` has a bare `.resolve()` outside that
function's only try, reachable when `git rev-parse --git-common-dir`
answers with a relative path (the linked-worktree case — exactly this
one)."""
park itself is defined by the spec frontmatter and the board token;
raising here would abort a story that genuinely succeeded over its
bookkeeping, so an unwritable record degrades to a journal line — and
`validate` reports the board-parked-but-recordless drift. The kind
stays `operator-index-failed` so nothing greping journals re-learns a
name. `RuntimeError` stays in the guard for `_park_spec_relpath`'s
reason (symlink-loop `resolve` below 3.13) and as insurance: the cost
of degrading a non-OSError here is a missing record, the cost of NOT
catching it is an aborted commit for a finished story."""
if not task.operator_actions:
return None
path = operatoractions.record_path(self.workspace.paths.project, task.story_key)
try:
prior = path.read_text(encoding="utf-8") if path.is_file() else None
operatoractions.record_park(
self.paths.project,
self.workspace.paths.project,
task.story_key,
actions=list(task.operator_actions),
spec_file=self._park_spec_relpath(task),
commit=task.commit_sha or "",
run_id=self.state.run_id,
parked_at=self._today(),
)
except (OSError, RuntimeError) as e:
self.journal.append("operator-index-failed", story_key=task.story_key, error=str(e))
return None
return (path, prior)

def _restore_park_record(self, record: tuple[Path, str | None] | None) -> None:
"""Put the park record back the way `_write_park_record` found it, for
the failure arms of the commit window: a `GitError` escalation or a
pass-through raise must not leave a record — untracked, in a tree the
next story's `git add -A` would sweep — for a commit that does not
exist. Best-effort like `_restore_deferred_closes`: the restore runs
under an exception already in flight and must never replace it."""
if record is None:
return
path, prior = record
with contextlib.suppress(OSError):
if prior is None:
path.unlink(missing_ok=True)
parent = path.parent
if parent.is_dir() and not any(parent.iterdir()):
parent.rmdir()
else:
path.write_text(prior, encoding="utf-8")

def _notify_park(self, task: StoryTask) -> None:
"""Tell the human a story is waiting on them, and exactly what for.
Expand Down
Loading