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

### Added

- **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
API key — now finishes and **commits** everything an agent can do, records what is owed in the
spec's `operator_actions:` frontmatter, and parks. The run moves on to the next story instead of
stopping, and the board advances to `awaiting-operator` (a forward move; nothing regresses).
Previously such a story had only two dishonest outcomes: `done`, which hides the outstanding work
behind a green board, or `blocked`, which halts the whole run.

A park clears the same deterministic gates a `done` story clears — the spec/board pair, the
project's verify commands, and a non-empty action list — and skips only the review loop, which has
nothing in the diff to converge on. A park with no readable actions is refused and repaired, not
committed. Under worktree isolation the unit merges like a `done` one. `[operator] enabled =
false` restores the old two-outcome behavior.

`bmad-loop confirm` and the project-level registry it reads are part 3; for now a parked story's
obligations live in its spec and in the `story-awaiting-operator` journal entry.

- **`awaiting-operator` vocabulary, no writer yet (#335, part 1 of 4).** Names, at every layer, the
state a story reaches when its agent-doable work is finished and committed but its acceptance
criteria include external actions only a human can perform: `Phase.AWAITING_OPERATOR` (terminal,
Expand Down
10 changes: 10 additions & 0 deletions src/bmad_loop/data/settings/core.toml
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,16 @@ default_ref = "TuiPolicy.low_frame_rate"
label = "low frame rate"
description = "cap to 15fps + disable animations — fixes repaint tearing/garbage over slow or SSH links · takes effect next time the TUI launches"

[[section]]
name = "operator"
description = "stories that owe external, human-only actions"
[[section.field]]
key = "enabled"
kind = "switch"
default_ref = "OperatorPolicy.enabled"
label = "allow awaiting-operator parks"
description = "let a session finish + commit a story whose acceptance criteria need a human action (buy a domain, publish a DNS record) and park it as awaiting-operator instead of forcing done or blocked · what is owed is recorded in the story spec's operator_actions: frontmatter"

[[section]]
name = "mux"
description = "terminal-multiplexer backend (machine-specific — policy.toml is gitignored)"
Expand Down
33 changes: 27 additions & 6 deletions src/bmad_loop/devcontract.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from typing import Any

from .platform_util import atomic_replace
from .verify import DEV_WORKFLOW, read_frontmatter
from .verify import DEV_WORKFLOW, operator_actions_of, read_frontmatter

# The section the skill appends on EVERY terminal path (success and blocked),
# per its step-02/03/04 finalize instructions. Its presence is our completion
Expand All @@ -43,6 +43,14 @@
# Terminal frontmatter statuses the skill can leave behind.
DONE = "done"
BLOCKED = "blocked"
# The story's agent-doable work is finished, but its acceptance criteria include
# external actions only a human can perform (#335). Terminal beside DONE and
# BLOCKED, and deliberately NOT rendered as an escalation: BLOCKED means the
# session could not proceed and the run must halt, while a park means it finished
# everything an agent could and the run should move on. Synthesizing a CRITICAL
# here would collapse that distinction into the pause channel — exactly the
# failure the state exists to avoid.
AWAITING_OPERATOR = "awaiting-operator"

# The status a plan-halt dispatch leaves behind: under folder+id dispatch a
# `Halt after planning.` directive makes the skill HALT right after the
Expand Down Expand Up @@ -239,7 +247,11 @@ def synthesize_result(
fm_status = str(fm.get("status", "")).strip().lower()
arr = parse_auto_run_result(_read_text_or_empty(spec_path))

terminal = (DONE, BLOCKED, PLAN_HALT_STATUS) if plan_halt else (DONE, BLOCKED)
terminal = (
(DONE, BLOCKED, AWAITING_OPERATOR, PLAN_HALT_STATUS)
if plan_halt
else (DONE, BLOCKED, AWAITING_OPERATOR)
)
# Not terminal yet: no result section AND frontmatter not at a terminal state.
if not arr.present and fm_status not in terminal:
return SynthResult(result_json=None, status_consistent=True)
Expand Down Expand Up @@ -273,6 +285,14 @@ def synthesize_result(
# on a blocked exit, so only carry it through on `done`.
if status == DONE:
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
# ONLY: an `operator_actions:` list left on a `done` or `blocked` spec is
# not a park, and carrying it would let a story register obligations the
# verify gates never held it to. Malformed shapes read as [] here and are
# refused by verify's non-empty gate, which owns the retry + feedback.
if status == AWAITING_OPERATOR:
result["operator_actions"] = list(operator_actions_of(fm))
# Mark the clean plan-halt success so verify/engine expect a planned spec
# (status ready-for-dev, no implementation work). Never marked when a block
# escalation is present — that routes to PAUSE, not a plan-review pause.
Expand Down Expand Up @@ -350,8 +370,8 @@ def find_frontmatter_candidates(impl_artifacts: Path, *, since_ns: int) -> list[
A candidate must be modified at/after `since_ns` (same session-launch floor
as the marker scan), carry ZERO real (non-fenced) marker headings, not be the
no-spec fallback file (that one is already matched by name on the normal
path), and have frontmatter ``status:`` of ``done`` or ``blocked``. Returns
ALL matches, most-recent first — the caller refuses to guess between several
path), and have a terminal frontmatter ``status:`` (``done``, ``blocked``, or
``awaiting-operator``). Returns ALL matches, most-recent first — the caller refuses to guess between several
and must apply its own stability fingerprint before synthesizing, because a
terminal frontmatter under a live window is weaker evidence than the marker.
"""
Expand Down Expand Up @@ -380,7 +400,8 @@ def is_frontmatter_candidate(path: Path, *, since_ns: int) -> bool:
Qualifies when the file is modified at/after ``since_ns``, is NOT the no-spec
fallback (already matched by name on the marker path), carries ZERO real
(non-fenced) marker headings — one would put it in `find_result_artifact`'s
territory — and has a frontmatter ``status:`` of ``done`` or ``blocked``. Any
territory — and has a terminal frontmatter ``status:`` (``done``,
``blocked``, or ``awaiting-operator``). Any
unreadable/undecodable read degrades to False, never an exception."""
if path.name.startswith(FALLBACK_RESULT_PREFIX):
return False
Expand All @@ -399,7 +420,7 @@ def is_frontmatter_candidate(path: Path, *, since_ns: int) -> bool:
fm = read_frontmatter(path)
except OSError:
return False
return str(fm.get("status", "")).strip().lower() in (DONE, BLOCKED)
return str(fm.get("status", "")).strip().lower() in (DONE, BLOCKED, AWAITING_OPERATOR)


def _atomic_write_spec(spec_path: Path, text: str) -> None:
Expand Down
Loading