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

### Added

- **`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,
reachable only from `COMMITTING`), an `awaiting-operator` sprint-status token ordered immediately
before `done` and deliberately not actionable, `operator_actions` on each task, and the matching
run-summary count, `status` output and TUI glyphs. Nothing writes any of it yet — the park path
is part 2 — so no run can currently reach the phase.

Two consequences worth knowing. A `state.json` carrying the new phase is forward-only: an older
binary rejects it, as with every phase addition. And because the sprint token is now _ordered_
rather than unknown, a review session writing `awaiting-operator` onto a board the orchestrator
had already advanced to `done` is classified as a deliberate sign-off regression and escalates
(#334), where it previously fell through to a retry.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **Defer notifications name where the work survives (#333).** A deferred story's rollback parked
the attempt on an `attempt-preserve/*` branch (or a `refs/attempt-preserve-dirty/*` snapshot),
but the ref only ever reached `journal.jsonl` — the notification carried a bare reason, leaving
Expand Down
5 changes: 4 additions & 1 deletion src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1581,8 +1581,11 @@ def cmd_status(args: argparse.Namespace) -> int:
extra = task.defer_reason or task.commit_sha or ""
if task.preserve_ref:
extra = f"{extra} [{task.preserve_ref}]".lstrip()
# 17 = len("awaiting-operator"), the longest Phase token; a narrower
# field does not truncate, it shifts every following column on that one
# row, which reads as corrupt output rather than as a long status.
print(
f" {key:40s} {task.phase:16s} dev×{task.attempt} review×{task.review_cycle} "
f" {key:40s} {task.phase:17s} dev×{task.attempt} review×{task.review_cycle} "
f"{tokens} {extra}"
)
if state.source == "stories":
Expand Down
12 changes: 11 additions & 1 deletion src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ class RunSummary:
# direction this field exists to fix. There is one construction site; a
# TypeError beats a plausible zero.
weighted_tokens: int
# stories that committed but owe human-only external actions (Phase
# AWAITING_OPERATOR). Defaulted, unlike weighted_tokens: a miscounted 0 here
# is a count that is genuinely 0 on every run that never parks a story, so
# the default is honest rather than silently wrong.
awaiting_operator: int = 0
crashed: bool = False
crash_error: str | None = None

Expand All @@ -124,9 +129,13 @@ def render(self) -> str:
# shutdown-only flush). Splitting this into "0 weighted (0 raw)"
# would assert free work twice over; one plain zero is honest.
tokens = "0 tokens"
# Appended only when non-zero: a run that parks nothing is the norm, and
# a standing ", 0 awaiting operator" would train readers to skip the very
# clause that matters on the run where it is not zero.
parked = f", {self.awaiting_operator} awaiting operator" if self.awaiting_operator else ""
lines = [
f"run {self.run_id}: {self.done} done, {self.deferred} deferred, "
f"{self.escalated} escalated, {tokens}"
f"{self.escalated} escalated{parked}, {tokens}"
]
if self.crashed:
lines.append(f"CRASHED: {self.crash_error}")
Expand Down Expand Up @@ -626,6 +635,7 @@ def summary(self) -> RunSummary:
done=sum(1 for t in tasks if t.phase == Phase.DONE),
deferred=sum(1 for t in tasks if t.phase == Phase.DEFERRED),
escalated=sum(1 for t in tasks if t.phase == Phase.ESCALATED),
awaiting_operator=sum(1 for t in tasks if t.phase == Phase.AWAITING_OPERATOR),
paused=self.state.paused,
paused_reason=self.state.paused_reason or "",
total_tokens=sum(t.tokens.total for t in tasks),
Expand Down
29 changes: 28 additions & 1 deletion src/bmad_loop/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,20 @@ class Phase(StrEnum):
DONE = "done"
DEFERRED = "deferred"
ESCALATED = "escalated"
# the story's agent-doable work is finished and COMMITTED, but its acceptance
# criteria include external actions only a human can perform (buy a domain,
# publish a DNS record). Terminal like DONE — the run moves on rather than
# halting — but not DONE: the outstanding actions are recorded on the task.
# Deliberately NOT a pause: an operator-owed story must never block the
# stories behind it (contrast the spec's `Block If:` -> blocked -> CRITICAL
# -> pause channel, which is run-halting by design).
AWAITING_OPERATOR = "awaiting-operator"


TERMINAL_PHASES = frozenset({Phase.DONE, Phase.DEFERRED, Phase.ESCALATED})
# Terminal = the orchestrator will not drive this story further in this run. It
# says nothing about success: DONE and AWAITING_OPERATOR carry a commit, DEFERRED
# and ESCALATED do not.
TERMINAL_PHASES = frozenset({Phase.DONE, Phase.DEFERRED, Phase.ESCALATED, Phase.AWAITING_OPERATOR})

# Pause stages recorded in RunState.paused_stage
PAUSE_SPEC_APPROVAL = "spec-approval"
Expand Down Expand Up @@ -173,6 +184,20 @@ class StoryTask:
baseline_untracked: list[str] | None = None
spec_file: str | None = None
commit_sha: str | None = None
# the external, human-only actions this story still owes when it parks at
# Phase.AWAITING_OPERATOR — one free-text instruction per entry, as the dev
# session enumerated them in the spec's `operator_actions:` frontmatter.
# Plain strings in v1: a per-action deterministic `check:` command is a
# deliberate v2 question, and strings-now/objects-later is the cheaper
# migration than the reverse. Empty on every other phase: owing at least one
# action is what selects AWAITING_OPERATOR over DONE when the park path picks
# a committing story's final phase. That choice is the only enforcement —
# nothing validates this field on load or on write, so a task carrying the
# phase with no actions is unreachable rather than rejected. Survives the
# resume serialization round-trip (it is the durable record of what the human
# owes, and nothing re-derives it once the session that wrote the spec is
# gone).
operator_actions: list[str] = field(default_factory=list)
defer_reason: str | None = None
# the recovery ref this attempt's work was parked on by the last auto-rollback
# — an `attempt-preserve/*` branch (commits above baseline) or, when the tree
Expand Down Expand Up @@ -299,6 +324,7 @@ def to_dict(self) -> dict[str, Any]:
"baseline_untracked": self.baseline_untracked,
"spec_file": self._serialized_spec_file(),
"commit_sha": self.commit_sha,
"operator_actions": self.operator_actions,
"defer_reason": self.defer_reason,
"preserve_ref": self.preserve_ref,
"preserve_partial": self.preserve_partial,
Expand Down Expand Up @@ -349,6 +375,7 @@ def from_dict(cls, d: dict[str, Any]) -> "StoryTask":
),
spec_file=d.get("spec_file"),
commit_sha=d.get("commit_sha"),
operator_actions=[str(a) for a in d.get("operator_actions", [])],
defer_reason=d.get("defer_reason"),
preserve_ref=d.get("preserve_ref"),
preserve_partial=bool(d.get("preserve_partial", False)),
Expand Down
20 changes: 17 additions & 3 deletions src/bmad_loop/sprintstatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,25 @@
SHORT_REF_RE = re.compile(r"^(\d+)[-.](\d+)([a-z]?)$") # short story ref: 3-1, 3.1, 3-1a
BARE_NUM_RE = re.compile(r"^(\d+)([a-z]?)$") # a lone story number, needs --epic

STORY_STATUSES = {"backlog", "ready-for-dev", "in-progress", "review", "done"}
# Lifecycle order, earliest -> latest. `advance` never moves a story backward
# through this sequence (matches sync-sprint-status's "never regress").
STATUS_ORDER = ("backlog", "ready-for-dev", "in-progress", "review", "done")
# through this sequence (matches sync-sprint-status's "never regress"), and it is
# the only ordering any caller may use — a token absent from it cannot be ordered
# at all, so every consumer treats "unknown" conservatively rather than guessing.
# `awaiting-operator` sits immediately before `done`: parking is the last stop on
# the way to finished, so confirming a parked story is a legal forward advance
# through the sole writer, while nothing can ever regress `done` back into it.
STATUS_ORDER = (
"backlog",
"ready-for-dev",
"in-progress",
"review",
"awaiting-operator",
"done",
)
LEGACY_STORY_STATUSES = {"drafted": "ready-for-dev"}
# Statuses a story may be PICKED UP from. `awaiting-operator` is deliberately
# absent: the story's agent-doable work is already committed, so re-driving it
# would redo finished work while the human's external actions stay outstanding.
ACTIONABLE_STATUSES = {"backlog", "ready-for-dev"}


Expand Down
9 changes: 8 additions & 1 deletion src/bmad_loop/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,20 @@ class IllegalTransition(Exception):
Phase.ESCALATED,
}
),
Phase.COMMITTING: frozenset({Phase.DONE, Phase.ESCALATED}),
# AWAITING_OPERATOR: a story owing human-only external actions parks on the
# NORMAL commit path — the work commits, then the final phase is chosen by
# whether the task carries operator_actions. Reachable only from COMMITTING
# precisely so a park can never skip the gates/commit a DONE story clears.
Phase.COMMITTING: frozenset({Phase.DONE, Phase.ESCALATED, Phase.AWAITING_OPERATOR}),
Phase.TRIAGE_RUNNING: frozenset({Phase.TRIAGE_VERIFY}),
# TRIAGE_RUNNING: invalid triage output retries with feedback, like DEV_VERIFY
Phase.TRIAGE_VERIFY: frozenset({Phase.TRIAGE_RUNNING, Phase.DONE, Phase.ESCALATED}),
Phase.DONE: frozenset(),
Phase.DEFERRED: frozenset(),
Phase.ESCALATED: frozenset(),
# terminal: `bmad-loop confirm` completes a parked story out of band, not by
# transitioning the (by then finished) run's task.
Phase.AWAITING_OPERATOR: frozenset(),
}


Expand Down
13 changes: 12 additions & 1 deletion src/bmad_loop/tui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def show_run(
if state.current_epic is not None:
text.append(f" epic {state.current_epic}", style="dim")

counts = {Phase.DONE: 0, Phase.DEFERRED: 0, Phase.ESCALATED: 0}
counts = {Phase.DONE: 0, Phase.DEFERRED: 0, Phase.ESCALATED: 0, Phase.AWAITING_OPERATOR: 0}
weight = state.cache_read_weight()
weighted = raw = 0
for task in state.tasks.values():
Expand All @@ -174,6 +174,11 @@ def show_run(
text.append(f" deferred {counts[Phase.DEFERRED]}", style="yellow")
style = "red" if counts[Phase.ESCALATED] else "dim"
text.append(f" escalated {counts[Phase.ESCALATED]}", style=style)
# Only when non-zero, unlike the three above: the header line is already
# at the width the narrowest supported pane can hold, and a standing
# "awaiting 0" would cost that room on every run that never parks a story.
if counts[Phase.AWAITING_OPERATOR]:
text.append(f" awaiting {counts[Phase.AWAITING_OPERATOR]}", style="yellow")
text.append(f" {weighted:,} tokens ({raw:,} raw)", style="dim")

# The agent line: who is driving (or, when idle, who is configured to).
Expand Down Expand Up @@ -600,6 +605,7 @@ def validate_header(doc: dict) -> Text:
"in-progress": "▶",
"review": "◆",
"ready-for-dev": "○",
"awaiting-operator": "⏸",
"backlog": "·",
"optional": "·",
}
Expand All @@ -609,6 +615,9 @@ def validate_header(doc: dict) -> Text:
"in-progress": "cyan",
"review": "magenta",
"ready-for-dev": "cyan",
# yellow, matching the run header's deferred count: work is finished but the
# story is not, and something outside the loop has to happen next.
"awaiting-operator": "yellow",
"backlog": "dim",
"optional": "dim",
}
Expand Down Expand Up @@ -719,6 +728,7 @@ def update_sprint(self, ss: SprintStatus | None) -> None:
"in-progress": "▶",
"in-review": "◆",
"done": "✓",
"awaiting-operator": "⏸",
"blocked": "✖",
"ambiguous": "⚠",
"sentinel": "⚠",
Expand All @@ -731,6 +741,7 @@ def update_sprint(self, ss: SprintStatus | None) -> None:
"in-progress": "cyan",
"in-review": "magenta",
"done": "green",
"awaiting-operator": "yellow",
"blocked": "bold red",
"ambiguous": "bold red",
"sentinel": "bold red",
Expand Down
39 changes: 39 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,45 @@ def test_status_text_names_the_preserve_ref_beside_the_reason(project, capsys):
assert "review did not converge within budget [attempt-preserve/run-1-abcd1234]" in out


def test_status_reports_a_parked_story_on_both_surfaces(project, capsys):
"""The `--json` projection is already phase-agnostic (`str(task.phase)`), so a
new phase needs no schema change and no version bump — but "needs no change"
is a claim about the contract, and this is what holds it: the parked task
reports its own token, carries its commit, and is not confused with a defer.

On the text side the phase cell is measured, not just matched:
`awaiting-operator` is the longest phase token there is, and a field too
narrow for it does not truncate — it pushes every following cell one column
right on that one row, which reads as corrupt output rather than as a long
status. `_story_row` splits on whitespace and so cannot see that; comparing
the two rows' column offsets can.
"""
from bmad_loop.model import Phase, StoryTask

parked = StoryTask(story_key="1-1-login", epic=1, phase=Phase.AWAITING_OPERATOR)
parked.commit_sha = "abc1234"
parked.operator_actions = ["publish the DKIM TXT record"]
done = StoryTask(story_key="1-2-logout", epic=1, phase=Phase.DONE)
_make_run_with_tokens(project, {"1-1-login": parked, "1-2-logout": done}, weight=0.1)

doc = _status_json(project, capsys)
entry_parked, _ = doc["tasks"]
assert entry_parked["phase"] == "awaiting-operator"
assert entry_parked["commit_sha"] == "abc1234" # parked work is committed work
assert entry_parked["defer_reason"] is None # a park is not a failure
assert doc["schema_version"] == 1 # additive: no consumer breaks
# the v1 document carries the phase, not the actions themselves — the
# registry and `confirm --list` are their surface (part 3)
assert "operator_actions" not in entry_parked

assert cli.main(["status", "--project", str(project.project)]) == 0
out = capsys.readouterr().out
assert _story_row(out)[1] == "awaiting-operator"
(parked_line,) = [ln for ln in out.splitlines() if ln.startswith(" 1-1-login ")]
(done_line,) = [ln for ln in out.splitlines() if ln.startswith(" 1-2-logout ")]
assert parked_line.index("dev×") == done_line.index("dev×")


def test_status_json_stories_mode_is_pure_json(project, capsys):
"""--json must skip every text trailer (stories board, backlog, decisions
nudge) — the stories-mode board would otherwise corrupt the document."""
Expand Down
38 changes: 38 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,44 @@ def test_summary_weights_per_task_not_over_the_aggregate(project):
assert summary.weighted_tokens == 30 # NOT 32


def test_summary_counts_parked_stories_apart_from_done(project):
"""A story that committed but owes human external actions is neither `done`
(its acceptance criteria are not met) nor `deferred`/`escalated` (nothing
failed). Without its own count it would land in none of the three and vanish
from a summary that still counts it in the task total.

Constructed rather than driven: PR-1 ships the vocabulary with no writer, so
COMMITTING -> AWAITING_OPERATOR is unreachable through the engine loop.
"""
engine = _cache_heavy_engine(project, snapshot_weight=0.5, live_weight=0.5, usage=TokenUsage())
for key, phase in (
("1-1-a", Phase.DONE),
("1-2-b", Phase.AWAITING_OPERATOR),
("1-3-c", Phase.AWAITING_OPERATOR),
):
engine.state.tasks[key] = StoryTask(story_key=key, epic=1, phase=phase)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

summary = engine.summary()

assert summary.awaiting_operator == 2
# and it did NOT inflate any of the three existing counts
assert summary.done == 1 and summary.deferred == 0 and summary.escalated == 0


def test_run_summary_render_names_parked_stories_only_when_there_are_any(project):
"""The clause earns its space by being absent on the runs it would be 0 on —
a standing ", 0 awaiting operator" on every run trains readers to skip the
one clause that matters on the run where it is not zero."""
engine = _cache_heavy_engine(project, snapshot_weight=0.5, live_weight=0.5, usage=TokenUsage())
assert "awaiting operator" not in engine.summary().render()

engine.state.tasks["1-2-b"] = StoryTask(
story_key="1-2-b", epic=1, phase=Phase.AWAITING_OPERATOR
)

assert "1 awaiting operator" in engine.summary().render()


def test_run_summary_render_labels_both_units(project):
"""render() feeds stdout, the ATTENTION file and the desktop notification
from one place, so this covers all three."""
Expand Down
32 changes: 31 additions & 1 deletion tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from bmad_loop.model import RunState, SessionRecord, StoryTask, TokenUsage
from bmad_loop.model import Phase, RunState, SessionRecord, StoryTask, TokenUsage


def _state(**kw) -> RunState:
Expand Down Expand Up @@ -193,6 +193,36 @@ def test_preserve_ref_defaults_none_for_legacy_state():
assert restored.preserve_ref is None and restored.preserve_partial is False


def test_operator_actions_round_trip():
task = StoryTask(
story_key="1-1-a",
epic=1,
phase=Phase.AWAITING_OPERATOR,
operator_actions=["buy the domain", "publish the DKIM TXT record"],
)
restored = StoryTask.from_dict(task.to_dict())
# the phase travels as its plain token, so a parked run resumes parked
assert restored.phase is Phase.AWAITING_OPERATOR
assert task.to_dict()["phase"] == "awaiting-operator"
# order is meaningful — the human works the list top-down
assert restored.operator_actions == ["buy the domain", "publish the DKIM TXT record"]


def test_operator_actions_defaults_empty_for_legacy_state():
doc = StoryTask(story_key="1-1-a", epic=1).to_dict()
del doc["operator_actions"] # state.json from before the field existed
assert StoryTask.from_dict(doc).operator_actions == []


def test_operator_actions_are_not_shared_between_tasks():
"""`field(default_factory=list)` rather than a shared `[]` default: two parked
stories in one run must not accumulate each other's actions."""
one = StoryTask(story_key="1-1-a", epic=1)
other = StoryTask(story_key="1-2-b", epic=1)
one.operator_actions.append("buy the domain")
assert other.operator_actions == []


def test_token_budget_warned_round_trips():
task = StoryTask(story_key="1-1-a", epic=1, token_budget_warned=True)
assert StoryTask.from_dict(task.to_dict()).token_budget_warned is True
Expand Down
Loading