Skip to content

feat(deferred): enforceable hard gates — gate: field + two validate checks - #502

Open
pirony wants to merge 34 commits into
bmad-code-org:mainfrom
pirony:validate-hard-gate
Open

feat(deferred): enforceable hard gates — gate: field + two validate checks#502
pirony wants to merge 34 commits into
bmad-code-org:mainfrom
pirony:validate-hard-gate

Conversation

@pirony

@pirony pirony commented Aug 8, 2026

Copy link
Copy Markdown

What

A deferred-work entry can now declare the stories it blocks with a machine-readable gate: <story-key-token>[, …] field line; validate gains deferred.hard-gate (problem — an actionable story is named by an open entry's gate) and deferred.hard-gate-unstructured (warning — a prose HARD GATE: declaration or an inert/malformed gate: line the check cannot enforce).

Why

Ledgers already carry prose like HARD GATE: must run before story 3-2 (e.g. a spike's credential leg that must land before its first consuming story), but nothing mechanical stops bmad-loop run from driving that story while the entry is open — the gate is only as strong as someone re-reading the ledger. This keeps the control loop deterministic: the gate is plain parsing in validate, no LLM involved.

How

  • deferredwork.py: gates(entry) parses gate: lines inside parse_ledger's canonical span into a frozen EntryGates (valid tokens / malformed / inert); gates_story(token, key) matches at key boundaries — 3-2 gates 3-2-invite-link and the split halves 3-2a-…/3-2b-…, never 3-20-….
  • cli.py: a new _validate_deferred_ledger owns the single ledger read (and deferred.ledger-unreadable), dispatching to _validate_hard_gates (new; both queue modes — sprint actionable statuses, stories-mode manifest minus done specs) and the existing closes-deferred checks.
  • Docs: deferred-work-format.md documents the field (placement after status:, one-line rule, preservation on rewrite), plus a README subsection, FEATURES bullet and CHANGELOG entry.

Testing

uv run pytest -q -n auto: 5 failed, 4519 passed, 51 skipped — the 5 are pre-existing darwin/opencode-live failures, byte-identical on pristine main. New behavior pinned by name in tests/test_cli.py (12 cases) and tests/test_deferredwork.py (boundary/split/prose/inert parametrizations); ruff, black, isort and pyright all clean (pyright's 2 pre-existing darwin errors unchanged). Also exercised end-to-end against a real project ledger carrying one structured gate, one prose-only gate and one quoted citation — each classifies correctly.


Happy to split this (parser helper first, validate wiring second) or adjust semantics per maintainer preference — and apologies for not passing through Discord first; the change is additive and stays out of the control loop, but say the word and I'll rework or downscope it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added deferred-work gates that block validation and pause execution for matching actionable stories.
    • Supports exact, boundary-aware, prefix, and split-story key matching across sprint and stories modes.
    • Gates clear when entries close or tokens are removed; sweep tasks remain exempt.
  • Bug Fixes

    • Invalid, empty, malformed, or prose-only declarations now produce warnings without blocking.
    • Unreadable deferred-work ledgers fail validation and safely pause execution.
  • Documentation

    • Documented gate syntax, matching, pauses, exemptions, and warning conditions.

pirony added 5 commits August 8, 2026 14:59
An entry that must land before a story runs could only say so in prose;
prose stopped nothing, so `run` drove the story and the gate surfaced in
a diff built on the missing leg.

`gate: 3-2, 3-3` names the blocked story keys. While the entry is open,
validate fails (deferred.hard-gate) for every actionable story a token
matches — equal to the key or its `-`-delimited prefix, so one token
reaches both queue modes without sweeping in numeric neighbours. It is
the only deferred check that gates rather than advises: the closes-*
siblings describe traceability that may be wrong, this one describes
work that must not start.

A prose `HARD GATE:` with no `gate:` line, and a token that cannot name
a story key, warn instead (deferred.hard-gate-unstructured) — a gate
nothing can enforce. A ledger with no gate line stays silent.
Both queue modes, both severities, and the cases that decide whether the
gate is trustworthy: the `3-2` / `3-20` boundary, a mid-line HARD GATE
mention that must not warn, a gate line below a flat-append bullet that
belongs to the block and not the entry, and a gate-free ledger that adds
no findings at all.
The format doc is what a sweep session reads before appending, so the
field is specified there: comma-separated tokens, lines union, prefix
matching, and what each of the two checks reports.
Three ways the first cut let a real gate go silent-inert.

A split story kept no gate: breakdown can turn a gated 3-2 into 3-2a /
3-2b after the gate was written, and a `-`-only boundary dropped it at
exactly that moment. One lowercase letter followed by `-` is now also a
boundary. The `startswith` guard before the suffix slice is load-bearing
— the tail alone would let 3-2 gate 9-9a-x.

The prose detector was anchored to line start, but ledgers hard-wrap
their `reason:`, so a real declaration lands mid-line. It now matches
anywhere on a line and excludes a citation by the quote before it; the
colon still excludes prose about a gate rather than a gate.

An empty `gate:` line parsed identically to no gate line at all, so a
claim made inertly said nothing. EntryGates counts its lines, and an
`inert` declaration warns like a malformed token.

Checked against a live ledger: two mid-line declarations previously
missed are now reported, and the entry citing the phrase stays silent.
The three inert shapes are now one list, since one warning covers them
and the remedy is the same line.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • No new commits to review - use @coderabbitai full review for a full pass

Walkthrough

Added gate: fields to deferred-work entries. Open entries now block matching actionable stories during validation and dispatch in sprint and stories modes. The change adds fence-aware parsing, unreadable-ledger handling, warning diagnostics, resume-aware pauses, tests, and documentation.

Changes

Deferred-work story gates

Layer / File(s) Summary
Gate parsing and story matching
src/bmad_loop/deferredwork.py, src/bmad_loop/fences.py, src/bmad_loop/devcontract.py, tests/test_deferredwork.py
Added structured gate parsing, status handling, fence-aware scanning, malformed-token tracking, prose detection, and boundary-aware story matching.
Ledger validation enforcement
src/bmad_loop/checks.py, src/bmad_loop/cli.py, tests/test_cli.py, tests/conftest.py
Validation reads the ledger once, resolves actionable stories, blocks matching stories, and reports warnings or unreadable-ledger failures.
Story dispatch pauses
src/bmad_loop/engine.py, src/bmad_loop/model.py, tests/test_engine.py, tests/test_stories_engine.py, tests/test_sweep.py
Dispatch pauses before task creation when the ledger is unreadable or a gate targets the selected story. Resume rereads the ledger. Sweep tasks bypass their own gates.
Gate behavior documentation
CHANGELOG.md, README.md, docs/FEATURES.md, src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md, docs/tui-guide.md
Documented gate syntax, matching rules, clearing behavior, sweep exemptions, diagnostics, pause display, and unreadable-ledger handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RunEngine
  participant DeferredLedger
  participant StoryDispatcher
  RunEngine->>DeferredLedger: reread ledger before dispatch
  DeferredLedger-->>RunEngine: return readable snapshot or read error
  RunEngine->>RunEngine: match unfinished gate tokens
  RunEngine-->>StoryDispatcher: pause before task creation or permit dispatch
  StoryDispatcher-->>RunEngine: persist pause state or start story work
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: pbean, dracic

Poem

A rabbit reads each ledger line,
And finds the gates that still align.
Open gates pause stories in place,
Done entries clear the case.
Fences hide examples from view,
Resume hops when checks are through.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding a deferred-work gate field and enforceable validation checks.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds structured deferred-work gate: parsing and two validation checks for enforceable and malformed hard gates, with documentation and tests for both queue modes. However, the enforcement is connected only to the standalone validation command, not the story-dispatch paths.

  • Parses and classifies structured gate: declarations within deferred-work entries.
  • Matches gate tokens against exact, prefixed, and split story keys.
  • Reports blocked actionable stories as validation failures and malformed declarations as warnings.
  • Consolidates deferred-ledger reading for hard-gate and closure validation.
  • Documents the field and adds parser and CLI coverage.

Confidence Score: 4/5

This PR should not merge until hard-gate enforcement is placed on the run and resume paths that actually dispatch stories.

The parser and validation checks work on the standalone validation path, but normal and resumed runs can dispatch a story named by an open gate without executing those checks.

Files Needing Attention: src/bmad_loop/cli.py

Important Files Changed

Filename Overview
src/bmad_loop/cli.py Adds deferred-ledger validation and queue-specific gate checks, but wires them only into cmd_validate, leaving actual run and resume dispatch unenforced.
src/bmad_loop/deferredwork.py Adds immutable gate classification, token parsing, and boundary-aware story matching with focused tests.
src/bmad_loop/checks.py Registers the two new deferred hard-gate validation check identifiers.
tests/test_cli.py Covers validation behavior comprehensively but does not test that run or resume refuses to dispatch a gated story.
tests/test_deferredwork.py Covers multiline declarations, malformed and inert forms, canonical entry boundaries, matching boundaries, and prose detection.
src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md Documents gate syntax and semantics, including the claim that open gates prevent stories from running, which the runtime path does not currently enforce.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Open deferred entry with gate token] --> B{Command invoked}
    B -->|bmad-loop validate| C[_validate_deferred_ledger]
    C --> D[_validate_hard_gates]
    D --> E[Validation failure]
    B -->|bmad-loop run or resume| F[Compose engine]
    F --> G[Pick next actionable story]
    G --> H[Run gated story]
    D -. missing from run path .-> F
Loading
Prompt To Fix All With AI
### Issue 1
src/bmad_loop/cli.py:341-347
**Hard gates bypass story dispatch**

When a user starts or resumes a run without separately invoking `bmad-loop validate`, the dispatch path never calls `_validate_deferred_ledger` or `_validate_hard_gates`, causing the engine to run stories explicitly blocked by open `gate:` entries.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "docs(deferred): describe the split, mid-..." | Re-trigger Greptile

Comment thread src/bmad_loop/cli.py
Comment on lines 341 to 347
if paths:
if stories_on:
_validate_stories_queue(project, paths, spec_folder, dev_trees, report)
_validate_closes_deferred(paths, report, spec_folder=spec_folder)
_validate_deferred_ledger(paths, report, spec_folder=spec_folder)
else:
_validate_closes_deferred(paths, report)
_validate_deferred_ledger(paths, report)
_validate_operator_registry(project, paths, report)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hard gates bypass story dispatch

When a user starts or resumes a run without separately invoking bmad-loop validate, the dispatch path never calls _validate_deferred_ledger or _validate_hard_gates, causing the engine to run stories explicitly blocked by open gate: entries.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/bmad_loop/cli.py
Line: 341-347

Comment:
**Hard gates bypass story dispatch**

When a user starts or resumes a run without separately invoking `bmad-loop validate`, the dispatch path never calls `_validate_deferred_ledger` or `_validate_hard_gates`, causing the engine to run stories explicitly blocked by open `gate:` entries.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_deferredwork.py`:
- Line 1631: Update the test string in the deferred-work test case to replace
each raw no-break space with the \u00a0 escape sequence, preserving the exact
input value while resolving RUF001.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f08ec8e-f306-44a1-812c-bffaca09ab5c

📥 Commits

Reviewing files that changed from the base of the PR and between 43a2853 and 5a9c48c.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • README.md
  • docs/FEATURES.md
  • src/bmad_loop/checks.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/deferredwork.py
  • tests/test_cli.py
  • tests/test_deferredwork.py

Comment thread tests/test_deferredwork.py Outdated
@pbean

pbean commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Triage note 2026-08-08: this arrived mid-way through a full-tracker triage, so slotting it into the queue rather than reviewing on the spot. Two sequencing facts you should know: (1) a deferredwork.py writers bundle is queued (#328/#327/#329/#469/#363 — atomic appends, next_seq from headings, surrogate scrub, file locking); your change is parse-side so overlap looks moderate, but whichever lands second rebases. (2) Repo invariant: every new check id must land in checks.VALIDATE_CHECKS (a sync test enforces it) — flagging in case it isn't already covered. Review comes right after the current merge-track PRs (#353/#324/#475/#485). No need to pre-split; we'll say so after review if it would help. Thanks — the deterministic-parse framing is exactly right for this codebase.

@pbean

pbean commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Retracting one item from my triage note above: I flagged that "every new check id must land in checks.VALIDATE_CHECKS" in case it wasn't covered. It is — you'd already done it, and I should have checked the diff before raising it:

--- a/src/bmad_loop/checks.py
+++ b/src/bmad_loop/checks.py
@@ -90,6 +90,8 @@
         "deferred.closes-entry-unreadable",
+        "deferred.hard-gate",
+        "deferred.hard-gate-unstructured",
         "deferred.ledger-unreadable",

Both ids land inside the VALIDATE_CHECKS frozenset that opens at src/bmad_loop/checks.py:45, so the sync assertion at checks.py:134 is satisfied. My concern was unfounded.

Also: CI on this PR was sitting unapproved (first-contribution runs need a maintainer to release them), not failing. I've granted approval, so the suite is running now.

Full review is still queued behind the current merge-track PRs, and the sequencing note about the deferredwork.py writers bundle stands. No action needed from you right now.

@pbean

pbean commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a9c48ce67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/cli.py
{"ledger": str(ledger), "error": str(e)},
)
return
_validate_hard_gates(paths, text, report, spec_folder=spec_folder)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce hard gates at each dispatch

When an earlier story files an open entry that gates a later story during the same unattended run, this check is never revisited: _validate_hard_gates is called only by cmd_validate, while cmd_run, the engine's next-story dispatch, and resume do not call it. Thus even a run preceded by a successful validation can drive work that a newly written gate: says must not start. Re-read and enforce the ledger at the deterministic per-story dispatch boundary, not only in the standalone validation report.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

Comment on lines +168 to +169
for m in GATE_RE.finditer(entry.body):
lines += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track empty declarations per gate line

When an entry combines a valid declaration with an empty one, such as gate: 3-2 followed by gate:, the aggregate has tokens, so EntryGates.inert is false and deferred.hard-gate-unstructured is never emitted for the empty line. This silently loses exactly the malformed declaration the new warning promises to surface; track whether any individual matched line yielded no usable item rather than deriving inertness from the entry-wide token set, and cover the mixed-line case at the parser layer.

AGENTS.md reference: AGENTS.md:L62-L62

Useful? React with 👍 / 👎.

pbean added 2 commits August 8, 2026 23:49
Review of bmad-code-org#502. Six fixes, each with a repro that reddens without it:

- `gates_story` applied the split-story arm to any token, so `gate: auth`
  hard-FAILED `authz-login` — a refusal against a story nobody gated, which
  `stories.ID_RE` makes reachable. The arm now needs the token to end at a
  story number, where `STORY_RE` puts the split letter. Also drops the
  accidental `gates_story("", key)` match.
- An empty `gate:` line beside a valid one was never reported: `inert` is an
  entry-wide verdict and answers False once the entry has a token. Count the
  inert lines instead, and report every cause rather than the first.
- `_actionable_story_keys` left the per-story `resolve_story_spec` outside its
  guard, turning a degraded check into a traceback out of `validate`; and it
  returned `[]` for an unreadable queue, which the caller read as "nothing is
  gated" and answered `ok` — naming the entry it claimed was clear. It returns
  None for that now, and the queue is only read once some entry gates.
- The prose lookbehind missed the backtick and curly quotes, so an entry
  citing `HARD GATE:` in markdown warned about itself.
- The unstructured warning promised `gate:` stops `bmad-loop run`. It does not:
  enforcement is preflight-only, and the docstring now says so.
- `_validate_deferred_ledger` justified its ordering with an early return that
  leaves a *sibling* function and never could have swallowed the gate.

Pins the closed-entry warning skip, which an ablation showed unpinned.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Review — and thank you, this is a strong first contribution

No apology needed, and please don't downscope: the PR is coherent as one change and I'd rather not split it. The parser half and the validate half only make sense together — a gate: field with no check is the prose convention again, and a check with no field has nothing to read. Splitting would have made both halves harder to review, not easier.

The design instinct here is the right one, and the part I want to call out specifically is that you reached for "a token nothing can match is the same silent no-op the field exists to end" and then built malformed/inert around it. That's the correct thesis for this feature, and most of my findings below are places where the implementation didn't yet live up to it.

I've taken the branch over rather than sending you a list. It's rebased onto post-#485 main and I've pushed the fixes directly — details at the bottom. Nothing is needed from you on this PR.


What I verified as correct


Findings I fixed

1. deferredwork.py:205 — false refusal: gate: auth hard-FAILED authz-login. The split-story arm applied to any token, so it read the z of authz as a split letter. stories.py:42 ID_RE = ^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$ admits word ids, so this is reachable, and a problem-severity refusal against a story nobody gated is the one way this check can be worse than the prose it replaced. The arm now requires the token to end at a story number, which is where sprintstatus.py:23 STORY_RE puts the split letter. Also drops an accidental gates_story("", key) is True.

2. deferredwork.py:148 + cli.py:1164 — an empty gate: line beside a valid one was silently lost. gate: 3-2 followed by a bare gate: gives tokens=('3-2',), so inert is False and nothing was reported. Your own docstring at cli.py:1156 argues the opposite for malformed tokens — "the operator's belief about the other half is exactly the thing that goes wrong quietly" — and that reasoning applies verbatim here. Counts inert lines individually now, and reports every cause rather than the first. (Credit to the codex review for spotting this one.)

3. cli.py:1200 — the gate check could crash validate. resolve_story_spec globs the filesystem per story but sat outside _actionable_story_keys' try. On main that call lived inside _stories_declarations' guard, so this was a regression: a stories-mode project on a mount that raises went from "skips an advisory" to a traceback with no JSON on stdout. Moved inside the guard.

4. cli.py:1132 — false all-clear when the queue is unreadable. _actionable_story_keys degraded to [], the caller read that as "nothing is gated", and emitted ok: no actionable story is gated by an open entry (DW-1) — naming the entry it claimed was clear, with actionable: [] beside it. It returns None for an unreadable queue now and the check stays quiet; queue.* owns that outage. The queue is also only read once some entry actually gates, so a project with no gate: line pays neither the walk nor its failure modes.

5. deferredwork.py:57 — the prose lookbehind missed the backtick and curly quotes. A ledger is markdown, so `HARD GATE:` is the citation form an author reaches for first — an entry documenting the convention warned about itself. The lookbehind class now covers the backtick and the curly quotes alongside the straight pair and the guillemet.

6. Two docstrings that described mechanisms that don't exist. cli.py:1055 justified the gates-first ordering with "_validate_closes_deferred returns early on an unreadable manifest, which must not swallow it" — that return leaves that function and could never skip a sibling call in _validate_deferred_ledger; swapping the two lines changes no severity and no exit code. And cli.py:1182's "Degrades to nothing rather than raising" was an absolute the code didn't deliver. Both corrected to what is actually true. Ordering itself is fine — it's presentation, and that's now what it says.

7. Warning text promised enforcement that doesn't exist — see the scope note below.

Docs: trimmed the CHANGELOG entry (13 lines / 197 words against a 6-line / 82-word median for the section), lowercased the one all-caps FAILS in docs/FEATURES.md, documented the auth/authz-login rule and a gate:-preservation sentence mirroring source_spec:'s.


Findings I deliberately left for the follow-on

Not defects to fix under you — decisions I'd rather make with the dispatch work in front of us:

  • deferredwork.py:44GATE_RE is strictly ^gate:. Gate: 3-2 or an indented gate: 3-2 produces zero findings — not even the unstructured warning. The status: precedent doesn't transfer: a missing status: fails closed (the entry drops out of open_ids), while a missing gate: fails open. Either loosen the anchor or pin the limit deliberately.
  • deferredwork.py:49 — a shape-valid token that matches nothing reports green ok. gate: 3.2 (a spelling sprintstatus.py:24 SHORT_REF_RE itself blesses), gate: 3-2., gate: 32 — all pass GATE_TOKEN_RE and match no key. Can't be a hard failure, since your own test_validate_unions_multiple_gate_lines gates 9-9 on no board deliberately — but green is the wrong answer too.
  • deferredwork.py:205 — a bare 3-2a stories-mode id isn't gated by gate: 3-2. load_stories accepts ['3-2a','3-2b'], so the gate's behaviour flips on whether the author appended a slug. Your test pins False deliberately, so I've left your semantics alone rather than flipping them behind you.

NBSP at tests/test_deferredwork.py:1631 — disposition

CodeRabbit's rationale doesn't hold: RUF001 is not in this repo's rule set. pyproject.toml pins select = ["E4", "E7", "E9", "F"] with an explicit "Do NOT ratchet stricter than CI" note, and trunk check is clean. But the edit is worth taking on its own merits — the character is invisible and load-bearing, and it's the exact codepoint your adjacent comment names, so I applied the \u00a0 escape for legibility. Your test row stays semantically identical. Worth noting the raw form still passes: a plain space also returns True, so the escape is what keeps the row pinning the documented French-typography limit rather than a generic space.

Scope: enforcement is preflight-only

I verified this by hand rather than taking the bots' word (Greptile's trial has ended, so its check here is a stale artifact). Confirmed, and it is the one thing I'd want closed before merge:

  • Engine._pick_next (engine.py:797) reads sprint-status.yaml and nothing else; sprintstatus.py:46 ACTIONABLE_STATUSES is the whole predicate.
  • stories_engine.py has zero references to deferredwork.
  • cmd_run's preflight (cli.py:1343-1430) never reads the ledger, and cmd_validate is never called from the run path — the TUI's v binding is a manual operator action.

The sharpest evidence was in your own warning string: it told the operator that a well-formed gate: line stops bmad-loop run. It doesn't. I've corrected that message and the _validate_hard_gates docstring to state the real scope, so the branch is at least honest about it today.

We're extending this PR to enforce at dispatch as well — nothing needed from you. The encouraging part is that it's one insertion point: SweepEngine overrides _loop but StoriesEngine doesn't, so a check in Engine._loop before _run_story (engine.py:783) covers both queues and exempts the sweep for free — which it must be, since the sweep is the only thing that closes the gating entry. model.py:55 PAUSE_STORY_GATE is already declared and already rendered by the TUI, with no producer anywhere — which looks like exactly the seam this was reserved for.


Pushed to the branch

Rebased onto post-#485 main and pushed as c3a124a83ade4c00626a6ac7573f55e142b76622.

Every fix above is ablation-checked: I removed each one singly and confirmed the intended test reddened, restoring between each. Gates on the merged tree: uv run pytest -q -n auto4799 passed, 36 skipped, 5 xfailed; uv run pyright0 errors; trunk check → clean.

Not merging yet — the dispatch work lands on this branch first, and you'll stay as the author of it.

@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_cli.py`:
- Around line 4146-4160: Add the confusable story key named in the test
docstring, such as authz-login, to the queued stories passed to
_validate_gated_sprint, while retaining the existing gated story fixture. Ensure
the assertions then cover the split-letter path in gates_story and fail if its
digit guard is removed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 82eb35cf-0444-41f3-aabf-5c4466987950

📥 Commits

Reviewing files that changed from the base of the PR and between 5a9c48c and c3a124a.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • README.md
  • docs/FEATURES.md
  • src/bmad_loop/checks.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/deferredwork.py
  • tests/test_cli.py
  • tests/test_deferredwork.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • CHANGELOG.md
  • README.md
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/checks.py
  • docs/FEATURES.md
  • src/bmad_loop/cli.py

Comment thread tests/test_cli.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3a124a83a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# A story key as either queue spells one: a sprint key (`3-2-invite-link`), the
# stories-mode id it starts with (`3-2`), or a bare slug. Whitespace and
# separators are deliberately out — a token nothing can match is the same silent
# no-op the field exists to end, so it is surfaced rather than dropped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject tokens that can never match a queue key

Tokens such as 3.2 and 3_2 pass this regex and are therefore treated as enforceable, but they cannot match any queue key: stories-mode IDs reject ./_, while sprint keys parsed by STORY_RE require the 3-2-... form. An open entry using gate: 3.2 consequently produces neither deferred.hard-gate-unstructured nor a gate failure and can even receive the false all-clear, silently defeating the boundary check; validate tokens against the actual queue-key shapes or normalize supported selector syntax before accepting them.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real gap, but the premise is too broad to act on as stated, so I am tracking it rather than tightening the regex here.

. and _ are not universally unmatchable. sprintstatus.py:23 STORY_RE is ^(\d+)-(\d+)([a-z]?)-(.+)$ — the slug is unconstrained, so both of these are legal sprint keys and gate correctly today:

STORY_RE("3-2-foo.bar") -> True     gates_story("3-2-foo.bar", "3-2-foo.bar") -> True
STORY_RE("3-2-a_b")     -> True     gates_story("3-2", "3-2-foo.bar")         -> True

Rejecting ./_ in GATE_TOKEN_RE would therefore break legitimate gates on dotted or underscored slugs. The genuinely unmatchable case is narrower than the character class: a ./_ in the number prefix (3.2, 3_2), which neither key grammar can produce.

The correct fix is the one you name second — validate the token against the actual key shapes — and that is a semantics decision rather than a regex tweak, because a token legitimately may name a story not yet on the board (test_validate_unions_multiple_gate_lines gates 9-9 on no board deliberately, so "matched nothing" cannot become a failure). It is on the list for the follow-on session that wires enforcement into dispatch, together with two adjacent gaps: GATE_RE is strictly ^gate:, so Gate: or an indented gate: produces zero findings — failing open, where a missing status: fails closed — and a bare 3-2a stories-mode id is not gated by gate: 3-2.

Not merging until that lands, so nothing here ships silently.

The CLI half of the auth/authz-login regression was vacuous: the fixture
queued only 3-2-invite-link, so gates_story returned False at the
startswith check long before the split-letter arm, and the test passed
with the digit guard ablated. The pure-core parametrize was carrying it.

Moved to stories mode, the only queue that can express the case --
authz-login does not match sprintstatus.STORY_RE, so a sprint board drops
it before ss.stories and the same fixture stays vacuous there.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75508028be

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/cli.py Outdated
# the ledger, so returning quietly reported success for preflights that
# checked nothing, against the very file the run's closure will fail on
# (#284 round-5 review, finding 6).
report.warn(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail validation when the gate ledger is unreadable

When deferred-work.md exists but raises OSError or UnicodeDecodeError, this warning leaves ValidationReport.passed true even though _validate_hard_gates never runs. A preflight can therefore return success while an unreadable entry contains an open gate for an actionable story—the safety check this change introduces must fail closed here, rather than retaining the advisory severity that was sufficient when this read served only closes_deferred. The message should also identify that hard gates were not checked.

AGENTS.md reference: AGENTS.md:L74-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the diagnosis, and I have taken half of it in 75f1f43.

Taken: the message. It named only closes_deferred, which reads as though the refusal had run and found nothing. It now says both were unchecked and that an open entry could be gating an actionable story unseen. Pinned by an assertion in test_validate_warns_when_the_ledger_itself_is_unreadable and ablation-checked — reverting the wording reddens it.

Deferred, deliberately: the severity. You are right that this is a fail-open — the same bytes now back the one deferred check that is a refusal, so validate can exit 0 having evaluated no gate. But deferred.ledger-unreadable is a pre-existing check id that ships as a warning on main, and flipping it to a problem takes every project with an unreadable ledger from exit 0 to exit 1 — a user-visible behaviour change orthogonal to adding a parser and two checks.

It belongs with the dispatch-side enforcement work, which is where fail-closed becomes coherent end-to-end: today bmad-loop run never reads the ledger at all (engine.py:797 Engine._pick_next reads only the board), so making validate fail closed while run stays unguarded hardens the door and leaves the window open. Both land together, before merge. The fail-open is recorded in a comment at the call site so it is not rediscovered as a surprise.

The same bytes now back a refusal, not just the advisory closes_deferred
checks, so a message naming only closes_deferred reads as though the gate
had run and found nothing. Says what actually went unchecked.

Severity left as a warning deliberately: escalating a pre-existing check id
to a problem is a user-visible behaviour change, and it belongs with the
dispatch-side enforcement rather than this parse-and-check addition. The
fail-open is recorded at the call site.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75f1f433da

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/cli.py Outdated
Comment on lines +1143 to +1144
if not entry.open:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject gate-bearing entries with an invalid status

When a gate-bearing entry has a missing or misspelled status such as status: opne, entry.open is false, so this loop skips the entry and later emits deferred.hard-gate as ok if no other open gate matches. _validate_closes_deferred only diagnoses that malformed status when a story separately declares the same ID in closes_deferred, so the ordinary case silently allows the gated actionable story; treat a gate-bearing status that is neither open nor done as unsafe rather than as closed.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

…opens

The `gate:` field refused only at `bmad-loop validate`. `Engine._pick_next` reads
the board alone, so a `run` that skipped the preflight drove a gated story anyway
— the refusal was only as strong as an operator's habit.

- `Engine._refuse_gated_story` pauses the run (the reserved `story-gate` stage,
  already rendered by the TUI) rather than dispatch a story an unlanded entry
  gates. Called before the story is recorded in `state.tasks`, so a resume
  re-picks it and re-reads the ledger: closing the entry and resuming runs it.
  Registering the task first would fire the gate once and then retire the story
  for the rest of the run and every resume of it.
- The sweep stays exempt (it overrides `_loop`), now with a test: a sweep is the
  only automated closer of the gating entry, so gating it deadlocks the gate
  against its own remedy.
- `status: opne` disabled the gate AND produced a green `ok` naming the entry as
  clear: `entry.open` is False for a status the format cannot read, so the entry
  was skipped entirely. Status is a tri-state now — only an explicit `done`
  retires a gate.
- `deferred.ledger-unreadable` becomes a problem. It exited 0 with the gate never
  evaluated, and the question "does this project use gates?" is answerable only
  from the file that will not open. Dispatch refuses the same fault.
- `gate: 3.2` was a shape-valid token nothing can match, reported as a green `ok`.
  Matchability is now tested against the two key shapes rather than by banning
  `.`/`_`, which are legal inside a sprint slug (`gate: 3-2-a_b` still gates).
- `Gate:` and an indented `  gate:` yielded zero findings. They warn now, rather
  than being accepted: reading an indented line as a declaration would turn a
  fenced example inside an entry into a refusal.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 416bbaf88e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/cli.py
Comment on lines +1293 to +1297
for entry in stories_mod.load_stories(folder).entries:
state = stories_mod.resolve_story_spec(folder, entry.id)
if stories_mod._classify(state) != "actionable":
continue
keys.append(entry.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop scanning stories after a wedged entry

When stories.yaml contains a blocked, sentinel, ambiguous, or unknown-status entry before a later pending/resumable story, the new _classify check skips the wedged entry and adds the later story as actionable. However, stories.schedule() returns SCHEDULE_WEDGED immediately at that earlier entry and cannot dispatch anything after it, so a matching gate: incorrectly makes validate fail for unreachable work. Stop collecting keys at the first wedged state, mirroring the scheduler's strict left-to-right scan.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your premise is correct and I had it wrong in the docstring; I'm not taking the remedy. bae8f1f fixes the claim and pins the behaviour.

Verified, both halves. Driving stories.schedule() directly over a manifest of [1=blocked, 5=pending]:

sequential scan  -> wedged at 1
--story 5 scan   -> next at 5

So you are right that the sequential scan cannot reach story 5 — schedule() returns SCHEDULE_WEDGED at stories.py:438 rather than continuing. My docstring even said the wedged entry "STOPS the scan" while the code beside it wrote continue, and closed with "sharing the scheduler's predicate is what keeps preflight and dispatch answering alike" — a parity claim the code does not keep. That sentence is what makes break look like the obvious repair, and it was mine.

Why the behaviour stays continue. The second line above is the part the finding doesn't account for. run --story <id> scans that entry alone (cli.py:1527stories_engine.py:173selector), so a story behind a wedge is dispatchable right now, while the wedge stands. cmd_validate takes no story selector at all, so preflight cannot know which of the two runs is coming — and of the two ways to be wrong, only one is quiet.

The other cost is scale. Stopping at the first wedged entry doesn't drop one story, it drops every story below it. One blocked entry near the top of a manifest would silence deferred.hard-gate for the whole rest of the queue — a gate lost in silence, which is the exact failure this field was added to end, arriving by a quieter route than the one 24a089e fixed. Over-reporting a real gate on a story that first needs an unrelated resolution is the cheaper wrong answer, and dispatch refuses independently either way (_refuse_gated_story pauses on PAUSE_STORY_GATE), so nothing rides on preflight alone.

Worth being precise about what 24a089e actually shared, since that is where the confusion started: stories._classify, the per-entry predicate — deliberately not schedule()'s stop rule. A wedge is a property of some other story; the gated story's own state is what decides whether a gate against it is meaningful.

What changed in bae8f1f — no behaviour change:

  • the docstring now says which half is shared and why the other half is not, so the next reader isn't invited to the same repair;
  • summary line would dispatchcould dispatch;
  • tests/test_cli.py:4388 pins it: manifest [1=blocked, 2=pending], gate: 2, still a problem.

That test is there specifically because this decision was unpinned. Ablating exactly your proposal — stop at the first wedged, keep skipping done — turns it red and nothing else in the suite moves (1 failed, 1340 passed), which is what convinced me the direction was worth writing down rather than left to whoever reads continue next.

Gates on bae8f1f: pytest 4861 passed / 36 skipped / 5 xfailed, pyright 0 errors, trunk check clean.

Good finding — the docstring was making a promise the code didn't keep, and that was worth catching even though the code was the half I want to keep.

`_actionable_story_keys` shares `stories._classify`, the per-entry
predicate — not `schedule()`'s stop rule, which gives up at the first
wedged entry. The docstring claimed the broader parity ("keeps preflight
and dispatch answering alike"), which reads as a promise the code does
not keep and invites narrowing this to a `break`.

Narrowing it would be wrong twice: `run --story <id>` scans that entry
alone, so a story behind a wedge is reachable right now while `validate`
takes no selector and cannot know which run is coming; and stopping
would let one blocked entry near the top of a manifest silence the gate
check for everything below it. Pin the decision with a test so it is not
optimized away later.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: bae8f1f3c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/deferredwork.py`:
- Around line 320-346: The fence check in _quoted currently uses only the entry
body, so it cannot account for fences opened before the entry span. Pass the
whole-file ledger fence index into the gate scans, including gates() and
declares_prose_gate(), and query fence state with entry.span[0] + m.start() for
each match; keep a single shared fence rule and preserve the existing
unclosed-fence behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f4ccbc9-5c20-4e43-81d0-51a8b2c56b8d

📥 Commits

Reviewing files that changed from the base of the PR and between 2ab1f13 and bae8f1f.

📒 Files selected for processing (8)
  • docs/tui-guide.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/deferredwork.py
  • src/bmad_loop/devcontract.py
  • src/bmad_loop/fences.py
  • tests/test_cli.py
  • tests/test_deferredwork.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/bmad_loop/devcontract.py
  • docs/tui-guide.md
  • src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
  • src/bmad_loop/fences.py
  • src/bmad_loop/cli.py
  • tests/test_cli.py
  • tests/test_deferredwork.py

Comment thread src/bmad_loop/deferredwork.py Outdated
@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bae8f1f3c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +101 to +103
Like `source_spec:`, a `gate:` line is never edited or dropped when an entry is
otherwise touched: removing it un-gates the story silently, which is the exact
failure this field exists to prevent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve gate fields through ledger migration

When a sweep migrates a mixed legacy/canonical ledger, _ensure_migration snapshots each pre-existing canonical entry only as {id: status}, and validate_migration checks only that the ID remains and its status is unchanged. The migration session can therefore remove a pre-existing gate: line, pass deterministic validation, and commit the rewrite; the next dispatch then runs the story that entry was meant to protect despite this new byte-preservation guarantee. Include the original gate declarations or full canonical entry bodies in migration validation so dropping one rejects the repair.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — filed as #519 rather than fixed here, because the fix belongs in sweep.py, which is not in this PR's diff.

Verified rather than accepted on reading. Driving validate_migration with a rewrite whose only change is a deleted gate: line:

pre_canonical snapshot : {'DW-1': 'open'}
DW-1 gate tokens BEFORE: ('3-2',)
DW-1 gate tokens AFTER : ()
validate_migration errors: []

Accepted, exactly as you said. _ensure_migration snapshots {e.id: e.status} (sweep.py:717) and the pre-existing-entry check compares the status first-word only (sweep.py:279-284); nothing there reads gate: — the other gate hits in that file are the unrelated gates notification module.

You are also right about which guarantee it defeats. deferred-work-format.md says a gate: line "is never edited or dropped when an entry is otherwise touched" — but that is instruction to the session, not a check, and migration is precisely the path where an LLM rewrites entries it did not author. The one place the instruction most needs backing is the one place nothing backs it.

Not folding it into this PR for two reasons. sweep.py is untouched here, so the fix needs its own tests against the migration path rather than riding on this diff's. And the snapshot shape is a decision of its own — id → (status, gate tokens) is the minimal form, while preserving whole entry bodies would also refuse the reformatting a migration legitimately performs. #519 records both, plus the open question of whether origin:/location:/source_spec: deserve the same treatment, since source_spec: carries a comparable never-drop instruction and has no check either.

Good catch — this is the field's guarantee being defeated by a route the field's own guard doesn't cover.

`parse_ledger` reads headings and `status:` against the whole file
because a body slice cannot see a fence opened above the heading. The
gate scans asked the same question of `entry.body` alone, and the two
views disagree: a stray unclosed ``` above the heading is ordinary text
at file scope — so the entry is carved — while the body reads a later
matched `~~~` pair as a real fence and masks a live `gate:` into an
example. A gate lost in silence is what the field exists to end.

Carry the whole-file index `parse_ledger` already builds on `DWEntry`
(no default, like `status_span`) and query it at absolute offsets, so
one fence rule answers for headings, `status:`, `gate:` and the prose
scan alike. Found by differential fuzz against the whole-file
predicate — 5 divergences over 26k ledgers, now 0 over 77k.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@pbean

pbean commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b79edeee5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/engine.py
# Before ANY state mutation for this story, and deliberately so — see
# _refuse_gated_story. The story is not in state.tasks yet, so a resume
# re-picks it and re-asks the ledger.
self._refuse_gated_story(story.key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck gates before restarting untouched tasks

Fresh evidence after the normal dispatch check was added: if the process dies after persisting the new StoryTask but before _run_story() starts, resume enters _finish_inflight() before this loop, and its Phase.PENDING restart arm calls _run_story() directly. If an open gate is added—or the ledger becomes unreadable—while the run is down, no gate check executes and the first session starts anyway. Re-run _refuse_gated_story() before restarting an untouched pending task; genuine mid-story recovery can remain exempt.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1728267. Good catch — this one was real, and the window is wider than "dies between two statements".

Repro before the fix. A StoryTask in exactly the shape _loop persists between engine.py:846 (_save()) and engine.py:847 (_run_story(task)), then a gate landing while the run is down: the resume ran the story to completion, done=1, sessions dispatched, ledger never consulted. _pick_next can't catch it either — the key is already in base_skip.

And it isn't only that two-statement gap. Nothing advances the task until _dev_phase bumps attempt in the same save as the DEV_RUNNING advance (engine.py:1595, engine.py:1620-1621), so the whole isolated worktree mount and the pre_story / pre_ready_gate hooks sit inside the window — including paths that can pause rather than crash. Not microseconds.

Predicate. attempt == 0 (engine.py:1239), for the reason above: it is bumped before the session is spawned, so 0 means no session ever started.

Two things I rejected on the way:

  • Not task.sessions. Tempting and wrong — record_session is called after a session returns (engine.py:4232), so a host death inside a session leaves no record. That task is genuinely in flight, with a live worktree, and gating it is exactly the stranding the exemption exists to prevent.
  • rearm_escalation resets attempt to 0 (runs.py:802) on a task that HAS run sessions, so the bare counter would read a human-armed re-drive as a first dispatch. Hence and not task.rearmed. Refusing there would be worse than a missed gate: the operator resolved the escalation to get that story moving, and the entry gating it may be the one the re-drive is about to close.

Placement is after the arm's unwinding and save (engine.py:1258), not before it. A refused task is then left in the same shape _loop hands to _run_story — no half-built worktree stranded behind a pause that may last days — and because _finish_inflight re-drives non-terminal tasks, the refusal re-asks on every later resume instead of retiring the story. Your "genuine mid-story recovery can remain exempt" is preserved: the line is drawn by session, not by the presence of a task record.

Three tests, each ablated whole-file against test_deferredwork / test_devcontract / test_cli / test_engine / test_sweep (1347 tests), all predicted before running:

ablation red
delete the gate call test_resume_re_gates_a_story_registered_but_never_started
predicate → not task.sessions test_resume_still_finishes_a_story_that_was_already_in_flight
drop and not task.rearmed test_resume_does_not_gate_a_human_armed_re_drive

1 red / 1346 green each — so each test discriminates a distinct design rather than merely the absence of the gate.

One note on scope: the README this PR ships already said the gate is about "work that must not start" and that the exemption is for a story already in flight. The code was the more permissive half, so this is the code catching up to the documented contract; I sharpened the sentence to say the line is drawn by session. SweepEngine is untouched — it overrides _loop and uses its own _finish_inflight_bundles, so the sweep exemption holds by construction, not by a flag I had to remember to set.

…arted

`_loop` saves the new StoryTask and *then* calls `_run_story`, so a host
death anywhere from that save through the isolated worktree mount and the
pre_story hooks persists a PENDING task no session ever touched. On resume
`_finish_inflight` drives it — `_pick_next` cannot, having skipped the key
as touched — and its restart arm called `_run_story` directly. A `gate:`
landing while the run was down was therefore never asked, and the one
deferred check that refuses could be disabled by the run's own crash.

The exemption is meant to be drawn by session, not by the presence of a
task record: `attempt` is bumped in the same save as the DEV_RUNNING
advance, so 0 means no session ever started. Not `sessions` — a record is
written after a session returns, so a death *inside* a session leaves
none, and that story is genuinely in flight. `rearm_escalation` is the one
other producer of a 0, on a task that HAS run sessions, so a human-armed
re-drive stays exempt.

Asked after the arm's unwinding and save, so a refused task is left in the
same shape `_loop` hands to `_run_story` — no worktree stranded behind a
pause, and the refusal re-asks on every later resume until the entry lands.

README already described this behavior ("work that must not start"); the
code was the more permissive half. Sharpened it to say the line is drawn
by session.
@pbean

pbean commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Pushed 1728267, taking the codex P2 on engine.py:839. Disposition is inline on the finding; the short version:

Real, and the window is wider than described. _loop saves the task at engine.py:846 and calls _run_story at engine.py:847, but nothing advances the task until _dev_phase bumps attempt in the same save as the DEV_RUNNING advance (engine.py:1620-1621) — so the isolated worktree mount and the pre_story / pre_ready_gate hooks are all inside it, including paths that pause rather than crash. A gate landing while the run was down was never asked, because _finish_inflight's restart arm calls _run_story directly and _pick_next has already skipped the key as touched. Verified by repro first: done=1, sessions dispatched, ledger never read.

The fix re-asks _refuse_gated_story in that arm for a task that never dispatched a session (attempt == 0 and not task.rearmed), after the unwinding and save so nothing is stranded behind the pause. rearm_escalation resets attempt to 0 on a task that has run sessions (runs.py:802), so a human-armed re-drive stays exempt — and task.sessions is the wrong signal in the other direction, since the record is written only after a session returns.

The README this PR ships already drew this line ("work that must not start"); the code was the more permissive half, so this is the code catching up to the documented contract rather than a new rule. SweepEngine never reaches this method, so the sweep exemption is unchanged.

Gates on 1728267: 4867 passed / 36 skipped / 5 xfailed, pyright 0, trunk check clean. Three whole-file ablations, all predicted before running, 1 red / 1346 green each.

@pirony — this is the last of the review rounds, and it lands on the enforcement side rather than the parse/validate half you wrote. That half is still what made all of this expressible: a gate: field the format can actually read is what let the dispatch check be a lookup instead of a guess. Thanks for the patch, and for sitting through a long review queue on a first contribution.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17282673c1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/engine.py Outdated
# that has run sessions — a human-armed re-drive, which stays
# exempt like any other resume of started work. Read here,
# before the resets below clear `rearmed`.
never_dispatched = task.attempt == 0 and not task.rearmed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist actual launch state before exempting resumed tasks

Fresh evidence beyond the prior resume finding is that _dev_phase persists attempt == 1 at lines 1620-1621 before _restore_patch, the pre-dev/pre-session plugin hooks at line 4058, and the actual adapter.run() launch at line 4188. If the host dies anywhere in that interval and a gate is added while it is down, no session has started, but this predicate sets never_dispatched false; _finish_inflight consequently restarts the task without _refuse_gated_story() and dispatches work the new gate says must not start. Track a durable launch boundary rather than using the earlier attempt increment as proof that a session ran.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and chasing it showed the predicate itself was the wrong shape. Fixed in c6b0a53 + 83f45ae — by deleting the predicate, not by adding the launch boundary you suggested.

Your finding verified. _dev_phase persists attempt == 1 with the DEV_RUNNING advance (engine.py:1620-1621), but the session does not launch until adapter.run(), past _restore_patch, the prompt build and the pre_session plugin gate (engine.py:4058) — which can veto or be slow. A death in there records an attempt no session backed, and my attempt == 0 read it as started.

And a second hole in the same predicate, in the opposite direction. I went looking for a durable launch marker as you suggested, and while checking whether rearmed really implied "a session ran" — the justification I had given for that conjunct — found it does not. StoriesEngine._pause_wedged (stories_engine.py:260-261) records an ESCALATED task with attempt == 0, no session records, and the comment "stopped the scan before any session ran this pick". After resolve, that task is also rearmed. So my exemption was waving a wedged story's very first dispatch straight past the gate. That one was mine, not inherited.

So the remedy is not a launch boundary. Three signals, three different ways of being wrong: attempt is set before launch, sessions only after a session returns (engine.py:4232), rearmed covers a story that never ran one. A new durable field would add a state-model change plus a save on the session hot path to answer a question the code already answers structurally:

the restart arm is the one _finish_inflight arm that finishes nothing.

It discards the worktree or resets the tree to baseline, then re-runs the story from scratch. By the time the gate is asked, the task holds nothing a session produced and is in the same shape _loop hands to _run_story. So it asks unconditionally, and the exemption keeps its real home — the four finishing arms (defer replay, spec-approval continuation, recorded-session replay, commit completion), where the story is ending rather than starting. That is also what the README already claimed, and it makes the gate agree with validate, which refuses a gated story regardless of what its task record remembers — the docstring's own stated reason for "pause, not skip".

One consequence worth flagging for the maintainer rather than burying: a resolved escalation's re-drive is now gated too. That is deliberate. Resolving an escalation is not evidence the gating entry landed, the re-drive is a from-scratch restart, and the pause is re-askable and names the remedy.

Tests — five now, ablated whole-file (1417 tests), each RED set predicted before running:

ablation red
delete the restart-arm gate never-started · re-drive · resolved wedge
restore attempt == 0 and not rearmed (the superseded predicate) pre-launch attempt window · re-drive · resolved wedge

The second row is the one that matters: those three fail against my previous commit's design, so they discriminate it rather than merely detecting the gate's absence. test_resume_still_finishes_a_story_whose_session_already_completed was rewritten to pin the exemption where it actually lives — a completed review session replaying through resume-verify — because its old form (DEV_RUNNING, attempt == 1, no record) was asserting exactly the behavior you flagged as wrong.

Good find, and thanks for pushing on the second round rather than accepting the first fix.

t added 2 commits August 9, 2026 21:31
Codex found the residual in the previous commit's predicate, and chasing it
showed the predicate itself was the wrong shape.

`attempt` is bumped in the DEV_RUNNING save (engine.py:1620-1621) but the
session does not launch until `adapter.run()`, past `_restore_patch`, the
prompt build and the pre_session plugin gate — so a death in between reads
as "started" when nothing started. `sessions` fails the other way: the
record is written after a session returns, so a death inside one reads as
never started. And `rearmed` — the signal the last commit trusted — is set
by `StoriesEngine._pause_wedged` on a story that reached ESCALATED with
`attempt == 0` and no session at all, so exempting re-drives waved a wedged
story's very first dispatch straight past the gate.

No such test can be right, because the question is already answered
structurally: the restart arm is the one `_finish_inflight` arm that
finishes nothing. It discards the worktree or resets the tree to baseline,
then re-runs the story from scratch — so by the time the gate is asked the
task holds nothing a session produced and is in the same shape `_loop`
hands to `_run_story`. Ask unconditionally. The exemption keeps its real
home: the finishing arms (defer replay, spec-approval continuation,
recorded-session replay, commit completion), where the story is ending
rather than starting.

This also makes the gate agree with `validate`, which refuses a gated story
regardless of what its task record remembers — the docstring's stated
reason for "pause, not skip".

Tests: the never-started case is unchanged; the in-flight exemption now
pins a completed review session replaying through resume-verify (the real
finishing arm); the re-drive case is inverted; and a stories-mode resolved
wedge pins the state that makes the inference unbuildable.
…covers

The reported case: attempt persisted with the DEV_RUNNING advance, no
session record, host dead before adapter.run(). Covered by the arm asking
unconditionally, but not directly pinned — the re-drive and wedge tests
catch a reintroduced attempt check only incidentally.
@pbean

pbean commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Pushed c6b0a53 + 83f45ae, taking the codex P2 on engine.py:1239 — which was a finding against my own previous commit, and a good one. Full disposition is inline; the short version:

1728267 gated the restart arm on attempt == 0 and not rearmed. Both halves are wrong:

  • attempt is set before the session launches (engine.py:1620-1621 vs adapter.run(), past _restore_patch, the prompt build and the pre_session gate at engine.py:4058), so a death in between reads as "started" when nothing started. That is codex's finding.
  • rearmed does not imply a session ran. StoriesEngine._pause_wedged (stories_engine.py:260-261) records an ESCALATED task with attempt == 0 and no sessions — "stopped the scan before any session ran this pick" — and resolve then marks it rearmed. My exemption was waving a wedged story's first dispatch past the gate. Found while checking my own justification for that conjunct; it was false.

Rather than track a durable launch boundary (a new state field plus a save on the session hot path), the predicate is deleted. The restart arm is the one _finish_inflight arm that finishes nothing — it discards the worktree or resets to baseline and re-runs from scratch — so by the time the gate is asked the task holds nothing a session produced and is in the same shape _loop hands to _run_story. It asks unconditionally; the exemption keeps its real home in the four finishing arms (defer replay, spec-approval continuation, recorded-session replay, commit completion). Net effect: the enforcement is simpler than what I pushed this morning, not more elaborate.

One behavior call for you, since it is a widening and not just a bug fix: a resolved escalation's re-drive is now gated as well. My reasoning is that resolving an escalation is not evidence the gating entry landed, the re-drive is a from-scratch restart, and validate refuses that story on the same ledger regardless of what the task record remembers — the docstring's stated reason for "pause, not skip". The pause is re-askable and names its remedy. Happy to carve the re-drive back out if you would rather ship the narrower rule, but there is then no principled signal to carve it with (see the wedge above).

Five tests, two whole-file ablations over 1417 tests, every RED set predicted before running. The load-bearing one restores the superseded attempt == 0 and not rearmed predicate: three tests go red against it, so they discriminate the earlier design rather than merely detecting the gate's absence.

Gates on 83f45ae: 4869 passed / 36 skipped / 5 xfailed, pyright 0, trunk check clean. CI approved and running.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83f45aedc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/engine.py Outdated
# wedge that escalated with no session at all. Placed after the
# unwinding and the save, so a refusal strands no worktree and the
# task re-asks on every later resume until the entry lands.
self._refuse_gated_story(task.story_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve newly added gates across restart rollback

When an in-place task has the normal persisted baseline_commit and a tracked gate: is added or committed while the process is down, this check runs only after _rollback_or_pause: with the default rollback policy, the ledger change makes recovery pause before the gate is evaluated, while with rollback_on_failure = true, safe_reset restores the old baseline and this call sees no gate, allowing the forbidden session to start. Preserve the post-crash ledger or latch the gate before recovery can reset it.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by repro, and fixed in 986a3e5. This one was a genuine P1 — my placement, not the predicate.

Reproduced before fixing. In-place task, persisted baseline_commit, rollback_on_failure = true, and a gate: committed while the run was down: the gated story ran to completion, done=1, paused=False. Your mechanism is exactly right — the gate commit is after the baseline, so git reset --hard <baseline> (verify.py:876) rewound the ledger and the check then asked its question of a file the human never wrote.

Worth spelling out why keep doesn't save it, since that was my assumption when I placed the call late: keep=(".bmad-loop",) guards untracked deletion only, not tracked reverts. verify.safe_rollback's own docstring says so, and it is the reason policy.toml needs a hand-rolled read-before / write-after rescue naming "a change committed after baseline". A tracked ledger has no such rescue.

Your default-policy half is right too: with rollback_on_failure = false, _rollback_or_pause pauses for manual recovery and the gate is never evaluated. The run does stop there, so nothing forbidden starts — but the operator gets recovery instructions instead of "story gated", which is the less actionable of the two pauses. The same move fixes both halves.

Fix. Moved ahead of the journal append and both unwinding branches. That makes the rule uniform rather than two separate judgements: both call sites ask before their caller mutates anything — in _loop to keep the refusal re-askable, here to keep the ledger readable.

I took the ordering rather than your "preserve the post-crash ledger or latch the gate" alternatives on purpose: latching would make the gate answer from a snapshot, and the docstring already commits to the opposite ("the ledger is re-read here rather than carried from preflight: a sweep, or a human, may have closed the entry since"). Asking earlier keeps the live read and fixes the ordering.

Accepted cost, stated in the comment: a refused isolated task keeps its half-built worktree mounted until a resume gets past the gate. That is what an escalation pause already does, and it is the cheaper of the two mistakes — the alternative discards a worktree for a story that is not allowed to run yet.

Ablation, predicted first: move the call back after the unwinding → exactly 1 red (test_resume_reads_the_gate_before_the_restart_rollback_rewinds_the_ledger), 1417 green. So the new test discriminates the ordering, not the gate's presence.

Filed #520 for the part that is not this PR's. Your finding generalizes past gates: the restart arm's reset discards any commit a human made while the run was down, ledger or not, recoverable only from the reflog. verify.py is not in this PR's diff and the behavior predates the gate: field, so it is an issue rather than more scope here. #502 fixes the gate half by reading before the rewind; it deliberately leaves the rewind alone.

… ledger

Codex P1, confirmed by repro: an in-place task with a persisted baseline,
`rollback_on_failure = true`, and a `gate:` committed while the run was
down ran the gated story to completion.

The arm's rollback is `git reset --hard <baseline>` and the gate commit is
*after* that baseline, so asking the gate after the unwinding put the
question to a ledger the rollback had just rewound. `keep=(".bmad-loop",)`
does not save it — keep guards untracked deletion only, which is precisely
why `verify.safe_rollback` restores `policy.toml` by hand. Under the
default `rollback_on_failure = false` the same ordering pauses for manual
recovery and never reaches the gate at all.

Moved ahead of the journal append and both unwinding branches, which makes
the rule uniform: both call sites ask before their caller mutates anything
— in `_loop` to keep the refusal re-askable, here to keep the ledger
readable. Accepted cost: a refused isolated task keeps its half-built
worktree mounted until a resume gets past the gate, the same thing an
escalation pause does.
@pbean

pbean commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Pushed 986a3e5, taking the codex P1. Reproduced before fixing: an in-place task with a persisted baseline, rollback_on_failure = true, and a gate: committed while the run was down ran the gated story to completion (done=1, paused=False).

The gate commit is after the task's baseline, so the restart arm's git reset --hard <baseline> (verify.py:876) rewound the ledger and the check — which I had placed after the unwinding — asked its question of a file the human never wrote. keep=(".bmad-loop",) does not prevent this: keep guards untracked deletion, not tracked reverts, which is exactly why verify.safe_rollback restores policy.toml by hand.

Fixed by moving the call ahead of the journal append and both unwinding branches, which makes one rule out of what had been two separate judgements: both call sites ask before their caller mutates anything — in _loop to keep the refusal re-askable, in the restart arm to keep the ledger readable. Accepted cost, stated in the comment: a refused isolated task keeps its half-built worktree mounted until a resume gets past the gate, the same as an escalation pause.

Ablation (predicted first): move the call back after the unwinding → exactly 1 red, 1417 green. The test discriminates the ordering, not the gate's presence.

Filed #520 for the half that is not this PR's: the restart arm's reset discards any commit a human made while the run was down — ledger or not — recoverable only from the reflog. verify.py is not in this diff and the behavior predates gate:, so it is an issue rather than more scope here.

Gates on 986a3e5: 4870 passed / 36 skipped / 5 xfailed, pyright 0, trunk check clean. CI approved and running.

Round tally for whoever reads this later: three rounds of findings against my own enforcement commits, each one real — the registered-but-never-started window, the predicate that was the wrong shape entirely, and now the ordering against the rollback. The parse/validate half @pirony wrote has been stable throughout; every defect in this sequence was in the enforcement I added on top of it.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 986a3e586e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pbean

pbean commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bmad_loop/engine.py (1)

895-911: 🗄️ Data Integrity & Integration | 🟠 Major

Check the restart-arm sweep exemption from the ledger gate.

SweepEngine skips Engine._finish_inflight, but _recover_inflight_bundle still has a restart arm for nonterminal non-COMMITTING bundles that resumes without calling _refuse_gated_story. The documented pause now points at bmad-loop resume <run_id> after the bundle's worktree has already been discarded, because there is no fresh PENDING bundle task keyed for retry. Either keep SweepEngine._recover_inflight_bundle from reaching the discard/restart path, or restore the rejected bundle task before refusing.

[low_effort и high_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/engine.py` around lines 895 - 911, Update the restart arm of
_recover_inflight_bundle so a nonterminal, non-COMMITTING bundle is not
discarded without restoring a fresh PENDING bundle task. Restore the task before
invoking _refuse_gated_story, ensuring the gate rejection leaves the story
retryable through the documented resume flow while preserving the existing
finishing-arm behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/bmad_loop/engine.py`:
- Around line 895-911: Update the restart arm of _recover_inflight_bundle so a
nonterminal, non-COMMITTING bundle is not discarded without restoring a fresh
PENDING bundle task. Restore the task before invoking _refuse_gated_story,
ensuring the gate rejection leaves the story retryable through the documented
resume flow while preserving the existing finishing-arm behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 061d4764-58b1-446a-be9a-7d0c18883dd5

📥 Commits

Reviewing files that changed from the base of the PR and between bae8f1f and 986a3e5.

📒 Files selected for processing (6)
  • README.md
  • src/bmad_loop/deferredwork.py
  • src/bmad_loop/engine.py
  • tests/test_deferredwork.py
  • tests/test_engine.py
  • tests/test_stories_engine.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • README.md
  • tests/test_deferredwork.py
  • src/bmad_loop/deferredwork.py

…ands

The refusal has to be a standing condition, not a one-shot: a refused task
stays non-terminal, so every resume re-reads the ledger and closing the
entry is what releases it. Asserted for the restart arm, where nothing
covered it — the _loop side already has its own.

Raised by a CodeRabbit question about bundle retryability; the sweep half
of that finding is refuted (sweep.py has no _refuse_gated_story call site
and SweepEngine overrides _loop), but the retry property it asked about
was a real gap in the tests.
@pbean

pbean commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Pushed 77dddc1. @coderabbitai — your outside-diff Major on engine.py:895-911 is refuted on its premise, but the question underneath it found a real gap in the tests, so half of it landed.

The finding, checked against the code. It reads: "_recover_inflight_bundle still has a restart arm ... that resumes without calling _refuse_gated_story. The documented pause now points at bmad-loop resume <run_id> after the bundle's worktree has already been discarded."

That describes a consequence of a call that does not exist. _refuse_gated_story has exactly two call sites, both in engine.py:

  • engine.py:839_loop, before the story is recorded
  • engine.py:1259_finish_inflight's restart arm (this PR's change)

grep -rn "_refuse_gated_story" src/ returns no hit in sweep.py. SweepEngine overrides _loop (sweep.py:419) and recovers through its own _finish_inflight_bundles (sweep.py:422), so neither call site is reachable from a sweep. There is no gate pause on the sweep path, so there is no pause pointing anywhere after a discard.

And the remedy would be a regression, not a fix. Both options you offer route toward the sweep refusing on a gate. That is the one thing this design forbids: the sweep is the automated closer of the gating entry (sweep.py _close_resolved / bundle close), so gating it deadlocks the gate against its own remedy — the operator is told "run a sweep to close DW-1" by a pause that would then refuse to run the sweep. It is pinned behaviorally, not by mock assertions, in tests/test_sweep.py::test_sweep_is_exempt_from_the_dispatch_hard_gate, whose ledger gates sweep-triage, dw-fix-things — the sweep's own unit keys — and asserts the run completes and closes DW-1. That test's own docstring calls the exemption "exactly the kind of thing a later refactor 'unifies' away", which is what this suggestion would do.

src/bmad_loop/sweep.py is also not in this PR's diff (git diff --name-only origin/main...HEAD).

What did land. Underneath the sweep framing you were asking whether a gate refusal leaves the work retryable — "no fresh PENDING task keyed for retry". For the engine's restart arm that property is real and load-bearing, and it was asserted nowhere. A refused task stays non-terminal, so _finish_inflight re-drives it on the next resume and re-reads the ledger; if the refusal retired the story instead, the gate would drop the work it was protecting. Now pinned by test_restart_arm_gate_re_asks_until_the_entry_lands: refuse, resume-changing-nothing refuses again, close the entry, and the third resume runs it to done. The _loop side already had its equivalent; the restart arm did not.

Gates on 77dddc1: 4871 passed / 36 skipped / 5 xfailed, pyright 0, trunk check clean.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@pbean, thank you for the detailed verification. You are correct.

My finding incorrectly assumed that _recover_inflight_bundle could reach _refuse_gated_story. The sweep path does not call that method. The proposed remedy would also violate the required sweep exemption and could deadlock the deferred-work closure path.

The new restart-arm test covers the valid underlying concern: a refused task remains retryable, rechecks the ledger on each resume, and runs only after the entry closes.

🐇


✏️ Learnings added
Learnt from: pbean
URL: https://github.com/bmad-code-org/bmad-loop/pull/502

Timestamp: 2026-08-10T05:36:06.684Z
Learning: In this repository, `SweepEngine` must remain exempt from deferred-work story-gate enforcement. The sweep closes deferred-work entries, including entries that can gate its own unit keys; applying `_refuse_gated_story` to sweep dispatch or sweep bundle recovery can deadlock the closure mechanism. Engine restart-arm gate refusals must leave the non-terminal task retryable so later `resume` calls re-evaluate the ledger.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants