From a8cf5eb3610c822df31acf5b639710a6461938ea Mon Sep 17 00:00:00 2001 From: pirony Date: Sat, 8 Aug 2026 14:59:13 +0200 Subject: [PATCH 01/34] feat(deferred): make a ledger hard gate enforceable with `gate:` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bmad_loop/checks.py | 2 + src/bmad_loop/cli.py | 200 ++++++++++++++++++++++++++++++---- src/bmad_loop/deferredwork.py | 70 ++++++++++++ 3 files changed, 249 insertions(+), 23 deletions(-) diff --git a/src/bmad_loop/checks.py b/src/bmad_loop/checks.py index ba81f565..93db0a2e 100644 --- a/src/bmad_loop/checks.py +++ b/src/bmad_loop/checks.py @@ -90,6 +90,8 @@ "deferred.closes-unknown", "deferred.closes-malformed", "deferred.closes-entry-unreadable", + "deferred.hard-gate", + "deferred.hard-gate-unstructured", "deferred.ledger-unreadable", } ) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 1c09c9bd..cef4f9e8 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -341,9 +341,9 @@ def cmd_validate(args: argparse.Namespace) -> int: 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) try: ss = sprintstatus.load(paths.sprint_status) @@ -1034,8 +1034,181 @@ def _validate_operator_registry( ) +def _validate_deferred_ledger( + paths: bmadconfig.ProjectPaths, + report: ValidationReport, + *, + spec_folder: str | None = None, +) -> None: + """Read the deferred-work ledger once, then run every check that needs it. + + The read sits here rather than inside each check because an unreadable ledger + is one fault, not one per reader: two checks opening the same file would + report the same outage twice under two ids. + + A ledger that cannot be read at all is reported. Staying quiet there is not + the same trade as staying quiet about an unparseable manifest: the manifest is + already reported by ``queue.stories-manifest``, while nothing else in + ``validate`` reads the ledger, so silence meant reporting success for + preflights that checked nothing. + + The gate runs first — an operator who has both a blocked story and a stale + traceability field needs the refusal at the top, and ``_validate_closes_deferred`` + returns early on an unreadable manifest, which must not swallow it. + """ + ledger = paths.deferred_work + try: + text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + except (OSError, UnicodeDecodeError) as e: + # Split from the manifest read in the checks below, which is silent for a + # good reason that does not apply here: nothing else in `validate` reads + # 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( + "deferred.ledger-unreadable", + f"{ledger} cannot be read ({e}) — closes_deferred declarations were not " + "checked against it, and the run's own closure will fail the same way", + {"ledger": str(ledger), "error": str(e)}, + ) + return + _validate_hard_gates(paths, text, report, spec_folder=spec_folder) + _validate_closes_deferred(paths, text, report, spec_folder=spec_folder) + + +def _validate_hard_gates( + paths: bmadconfig.ProjectPaths, + text: str, + report: ValidationReport, + *, + spec_folder: str | None = None, +) -> None: + """FAIL when the queue is about to dispatch a story an open ledger entry gates. + + A ledger entry could always *say* it blocked a story — ``HARD GATE: must land + before 3-2`` in its reason — and saying it stopped nothing. ``run`` took the + story off the board and drove it, and the gate was discovered afterwards, in + the diff of work built on a leg nobody had wired. A ``gate:`` line makes the + claim matchable and this check makes it a refusal. + + The only deferred check that is a gate rather than an advisory, and the + severity is the whole point: the ``closes-*`` siblings describe traceability + that is wrong, which must never block a run, while this one describes work + that must not start, which is exactly what a non-zero exit is for. + + Silent on a ledger nobody has gated, so the zero-config output is unchanged. + Once a gate exists the passing case reports itself — a gate that only ever + speaks when it fires is indistinguishable, on the day it matters, from one + nobody remembered to write. + """ + declared = [(entry, deferredwork.gates(entry)) for entry in deferredwork.parse_ledger(text)] + story_keys = _actionable_story_keys(paths, spec_folder) + gated = False + for entry, entry_gates in declared: + if not entry.open: + continue # a landed entry gates nothing; that is what closing it means + _report_unstructured_gate(entry, entry_gates, report) + for story_key in story_keys: + hits = [t for t in entry_gates.tokens if deferredwork.gates_story(t, story_key)] + if not hits: + continue + gated = True + report.fail( + "deferred.hard-gate", + f"{entry.id} ({entry.title}) is open and gates {story_key} " + f"(gate: {', '.join(hits)}) — that story must not run until the entry " + f"lands. Close it in {paths.deferred_work.name} (`status: done `), " + f"or drop the token from its `gate:` line if it no longer blocks this work", + { + "dw_id": entry.id, + "title": entry.title, + "story_key": story_key, + "tokens": hits, + }, + ) + # Keyed on enforceable tokens, not on `gate:` lines: a ledger whose only gate + # is malformed enforced nothing, and an `ok` there would be the same false + # all-clear the warning above exists to break. + if not gated and any(entry_gates.tokens for _, entry_gates in declared): + open_gated = [e.id for e, g in declared if e.open and g.tokens] + report.ok( + "deferred.hard-gate", + f"deferred-work gates OK: no actionable story is gated by an open entry " + f"({', '.join(open_gated) if open_gated else 'no open gated entries'})", + {"open_gated_ids": open_gated, "actionable": list(story_keys)}, + ) + + +def _report_unstructured_gate( + entry: deferredwork.DWEntry, + entry_gates: deferredwork.EntryGates, + report: ValidationReport, +) -> None: + """Warn about a hard gate the mechanical check cannot enforce. + + Two causes, one id, because the remedy is the same line in the same file. A + ``HARD GATE:`` written as prose is the pre-``gate:`` convention still holding + nothing back; a token that cannot name a story key is that same nothing with + the field's syntax around it, which is worse — it reads, to anyone scanning + the entry, as a gate that is already in force. + + An entry carrying a valid token *and* a malformed one is still reported: the + valid half gates what it names, and the operator's belief about the other half + is exactly the thing that goes wrong quietly. + """ + if entry_gates.malformed: + reason = ( + f"declares `gate:` tokens that cannot name a story: {', '.join(entry_gates.malformed)}" + ) + elif not entry_gates.tokens and deferredwork.HARD_GATE_PROSE_RE.search(entry.body): + reason = "declares a `HARD GATE:` in prose but carries no `gate:` line" + else: + return + report.warn( + "deferred.hard-gate-unstructured", + f"{entry.id} ({entry.title}) {reason} — nothing holds the gated story back, so " + f"`bmad-loop run` will drive it while the entry is open; name the blocked stories " + f"on a `gate:` line (comma-separated) to make the gate enforceable", + {"dw_id": entry.id, "malformed": list(entry_gates.malformed)}, + ) + + +def _actionable_story_keys(paths: bmadconfig.ProjectPaths, spec_folder: str | None) -> list[str]: + """The story keys this queue would dispatch, in queue order, in either mode. + + Degrades to nothing rather than raising: ``queue.sprint-status`` and + ``queue.stories-manifest`` own queue readability, and a queue nothing can read + dispatches nothing for a gate to refuse. + + Stories mode has no status column — the manifest is a flat schedule and the + story's own spec carries the status — so a story whose spec reads ``done`` is + dropped here. That is the same line ``ACTIONABLE_STATUSES`` draws on the + sprint board, and without it a finished epic would fail ``validate`` forever + over gates on work that already landed. + """ + if spec_folder is not None: + try: + folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) + entries = stories_mod.load_stories(folder).entries + except (OSError, UnicodeDecodeError, stories_mod.StoriesError): + return [] + keys = [] + for entry in entries: + state = stories_mod.resolve_story_spec(folder, entry.id) + if state.kind == stories_mod.KIND_PRESENT and state.status == stories_mod.DONE: + continue + keys.append(entry.id) + return keys + try: + ss = sprintstatus.load(paths.sprint_status) + except (sprintstatus.SprintStatusError, OSError, UnicodeDecodeError): + return [] + return [s.key for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] + + def _validate_closes_deferred( paths: bmadconfig.ProjectPaths, + text: str, report: ValidationReport, *, spec_folder: str | None = None, @@ -1070,31 +1243,12 @@ def _validate_closes_deferred( close nothing and say nothing. Covering only two of the three left the third to be discovered in the journal after the run it should have preceded. - A ledger that cannot be read at all is reported as well. Staying quiet there - is not the same trade as staying quiet about an unparseable manifest: the - manifest is already reported by ``queue.stories-manifest``, while nothing - else in ``validate`` reads the ledger, so silence meant reporting success for - a preflight that checked nothing. - Never a failure. The annotation is traceability, not a gate, so a stale reference must not be able to block a run that would otherwise start. + ``text`` is the ledger snapshot :func:`_validate_deferred_ledger` already + read; an unreadable ledger never reaches here. """ ledger = paths.deferred_work - try: - text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" - except (OSError, UnicodeDecodeError) as e: - # Split from the manifest read below, which is silent for a good reason - # that does not apply here: nothing else in `validate` reads the ledger, so - # returning quietly reported success for a preflight that checked nothing, - # against the very file the run's closure will fail on - # (#284 round-5 review, finding 6). - report.warn( - "deferred.ledger-unreadable", - f"{ledger} cannot be read ({e}) — closes_deferred declarations were not " - "checked against it, and the run's own closure will fail the same way", - {"ledger": str(ledger), "error": str(e)}, - ) - return try: sources = ( _stories_declarations(paths, spec_folder) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index e28b923e..c4a9d550 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -33,6 +33,25 @@ _FLAT_SOURCE_BODY = r"source_spec:[ \t]" FLAT_ENTRY_RE = re.compile(rf"^[-*][ \t]+{_FLAT_SOURCE_BODY}", re.IGNORECASE | re.MULTILINE) STATUS_RE = re.compile(r"^status:[ \t]*(.*)$", re.MULTILINE) +# The mechanical half of a hard gate. An entry could always *say* it blocked a +# story — `HARD GATE: must land before 3-2` in the reason line — and saying it +# stopped nothing: the queue picked the story up anyway, and the gate surfaced +# afterwards in the diff of work built on a leg nobody had wired. `gate:` names +# the blocked story keys in a form a check can match, so the claim can refuse. +# Parsed exactly like `status:`: a field line, read inside `parse_ledger`'s +# canonical span, so a line under a flat-append bullet belongs to that block and +# not to the entry above it. +GATE_RE = re.compile(r"^gate:[ \t]*(.*)$", re.MULTILINE) +# 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. +GATE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +# Line-start only. The prose convention this field replaces is a line that +# *opens* with it; a sentence mentioning a hard gate mid-line ("the DW-1 HARD +# GATE: wording predates the field") is discussion, and reading it as a +# declaration would warn about every entry that talks about gating. +HARD_GATE_PROSE_RE = re.compile(r"^HARD GATE:", re.MULTILINE) # Everything `str.splitlines()` splits on, not `\n` alone (#305). The writers # below interpolate their arguments into a line-oriented file, so a break in a # value injects ledger lines. The C1/Unicode members are load-bearing rather @@ -104,6 +123,57 @@ def open_ids(text: str) -> set[str]: return {e.id for e in parse_ledger(text) if e.open} +@dataclass(frozen=True) +class EntryGates: + """One entry's ``gate:`` declaration, split by what a check can act on. + + Both halves are reported by ``validate``, because a token that matches no + story key is not a weaker gate than a valid one — it is the prose gate again, + wearing the field's clothes, and silence about it is what let the story run. + """ + + tokens: tuple[str, ...] = () + malformed: tuple[str, ...] = () + + +def gates(entry: DWEntry) -> EntryGates: + """Every ``gate:`` token in one entry's canonical span, order-preserving. + + Multiple ``gate:`` lines union: an entry blocking three stories may list them + on one line or on three, and to a line-oriented file neither spelling is the + wrong one. Within a line the separator is a comma, and only a comma — a + space-separated ``gate: 3-2 3-3`` lands in ``malformed`` rather than being + read leniently, so the operator is told the spelling gated nothing instead of + finding out from a story that ran. + + Duplicates collapse (an id repeated across lines is one claim, not two); + empty items drop, so a trailing separator is not a token. + """ + tokens: list[str] = [] + malformed: list[str] = [] + for m in GATE_RE.finditer(entry.body): + for raw in m.group(1).split(","): + token = raw.strip() + if not token: + continue + bucket = tokens if GATE_TOKEN_RE.match(token) else malformed + if token not in bucket: + bucket.append(token) + return EntryGates(tokens=tuple(tokens), malformed=tuple(malformed)) + + +def gates_story(token: str, story_key: str) -> bool: + """Whether ``token`` gates ``story_key`` — equal, or its ``-``-delimited prefix. + + The prefix arm is what lets one token reach both queues: stories mode keys on + the bare id (``3-2``) while sprint mode keys on the full ``3-2-invite-link``, + and an author gating "story 3-2" means the story, not the spelling. The + delimiter is required rather than a bare ``startswith`` so ``3-2`` cannot + sweep in its numeric neighbours — ``3-20-...`` is a different story. + """ + return story_key == token or story_key.startswith(f"{token}-") + + def parse_declaration(raw: object) -> tuple[tuple[str, ...], str | None]: """The single reading of a ``closes_deferred:`` declaration (#234), shared by the ``stories.yaml`` parser, the engine's close hook, and ``validate``. From d5bc0d3d7210efb141542ff0614675ab8593359c Mon Sep 17 00:00:00 2001 From: pirony Date: Sat, 8 Aug 2026 14:59:22 +0200 Subject: [PATCH 02/34] test(deferred): pin the hard-gate check and its token boundaries 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. --- tests/test_cli.py | 229 +++++++++++++++++++++++++++++++++++++ tests/test_deferredwork.py | 63 ++++++++++ 2 files changed, 292 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4346ea1c..e8b15c2c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3884,6 +3884,235 @@ def test_validate_sprint_mode_silent_without_declarations(project, capsys): assert [f for f in doc["findings"] if f["check"].startswith("deferred.closes")] == [] +def write_gated_ledger(paths, entries) -> None: + """`write_ledger` plus the lines a hard gate is written on: `entries` maps a + DW id to `(status, extra_field_lines)`, appended verbatim after `status:` so a + test can spell a `gate:` line, a prose `HARD GATE:`, or a deliberately broken + one exactly as a human would.""" + parts = ["# Deferred Work\n"] + for dw_id, (status, extra) in entries.items(): + tail = "".join(f"{line}\n" for line in extra) + parts.append( + f"### {dw_id}: item {dw_id}\n\norigin: test, 2026-06-01\n" + f"location: src.txt:1\nreason: test entry.\nstatus: {status}\n{tail}" + ) + paths.deferred_work.write_text("\n".join(parts), encoding="utf-8") + + +def _hard_gate_findings(capsys, check="deferred.hard-gate"): + doc = json.loads(capsys.readouterr().out) + return [f for f in doc["findings"] if f["check"] == check] + + +def _validate_gated_sprint(project, capsys, board, ledger): + """Run validate over a sprint project with `board` on the queue and `ledger` + (write_gated_ledger's shape) on disk; returns the parsed findings.""" + install_bmad_config(project) + _write_policy(project.project) + write_sprint(project, board) + write_gated_ledger(project, ledger) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) # rc varies by host (binary/skills) — parse the document + return json.loads(capsys.readouterr().out)["findings"] + + +def test_validate_fails_when_an_open_entry_gates_an_actionable_story(project, capsys): + """The gate the ledger could only ever *say* before: an entry whose prose read + "HARD GATE: must run before 3-2" stopped nothing, and `run` drove the story on + a leg nobody had wired. With `gate:` the claim is matchable, and unlike every + other deferred check this one is a problem — it describes work that must not + start, which is what a non-zero exit is for.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link-student-surface": "ready-for-dev"}, + {"DW-1": ("open", ["gate: 3-2"])}, + ) + + gates = [f for f in findings if f["check"] == "deferred.hard-gate"] + assert len(gates) == 1 + assert gates[0]["severity"] == "problem" # the one deferred check that gates + assert gates[0]["detail"] == { + "dw_id": "DW-1", + "title": "item DW-1", + "story_key": "3-2-invite-link-student-surface", + "tokens": ["3-2"], + } + # names the entry, the story, the token, and both ways out + assert "DW-1 (item DW-1)" in gates[0]["message"] + assert "3-2-invite-link-student-surface" in gates[0]["message"] + assert "gate: 3-2" in gates[0]["message"] + assert "status: done " in gates[0]["message"] + + +def test_validate_passes_when_the_gated_story_is_already_done(project, capsys): + """A gate is about work that must not *start*. A story the board has already + finished is past the point the entry was protecting, so it reports the passing + case rather than a refusal nobody can act on.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link-student-surface": "done"}, + {"DW-1": ("open", ["gate: 3-2"])}, + ) + + gates = [f for f in findings if f["check"] == "deferred.hard-gate"] + assert len(gates) == 1 and gates[0]["severity"] == "ok" + assert gates[0]["detail"] == {"open_gated_ids": ["DW-1"], "actionable": []} + + +def test_validate_passes_when_the_gate_entry_is_closed(project, capsys): + """Closing the entry is the primary remedy the failure names, so it has to be + the one that clears it: the same board passes once DW-1 lands.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link-student-surface": "ready-for-dev"}, + {"DW-1": ("done 2026-08-01", ["gate: 3-2"])}, + ) + + gates = [f for f in findings if f["check"] == "deferred.hard-gate"] + assert len(gates) == 1 and gates[0]["severity"] == "ok" + assert gates[0]["detail"]["open_gated_ids"] == [] + + +def test_validate_hard_gate_token_stops_at_the_key_boundary(project, capsys): + """`3-2` gates the story it names and not its numeric neighbours. A bare + `startswith` would sweep `3-20-...` in and block an unrelated story for as long + as the entry stayed open — the failure mode that makes an operator delete the + gate rather than trust it.""" + findings = _validate_gated_sprint( + project, + capsys, + { + "3-2-invite-link-student-surface": "ready-for-dev", + "3-20-later-story": "ready-for-dev", + }, + {"DW-1": ("open", ["gate: 3-2"])}, + ) + + gates = [f for f in findings if f["check"] == "deferred.hard-gate"] + assert len(gates) == 1 + assert gates[0]["detail"]["story_key"] == "3-2-invite-link-student-surface" + + +def test_validate_unions_multiple_gate_lines(project, capsys): + """One entry may block several stories, spelled across lines or on one line. + A line-oriented file gives an author no reason to prefer either, so both read + the same — and each gated story is its own refusal.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev", "4-1-receipts": "backlog"}, + {"DW-1": ("open", ["gate: 3-2", "gate: 4-1, 9-9"])}, + ) + + gated = [f["detail"]["story_key"] for f in findings if f["check"] == "deferred.hard-gate"] + assert gated == ["3-2-invite-link", "4-1-receipts"] # 9-9 is on no board + + +def test_validate_warns_on_a_prose_only_hard_gate(project, capsys): + """The convention `gate:` replaces. A ledger that says HARD GATE in prose is + making a claim nothing enforces, so preflight names it — otherwise the entry + reads, to anyone scanning it, as a gate already in force.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["HARD GATE: must land before story 3-2"])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" + assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": []} + assert "`gate:` line" in unstructured[0]["message"] + # nothing enforceable exists, so there is no passing gate to report either + assert not [f for f in findings if f["check"] == "deferred.hard-gate"] + + +def test_validate_ignores_a_mid_line_hard_gate_mention(project, capsys): + """Line-start only: an entry *discussing* a hard gate is not declaring one. + Matching anywhere in the body would warn about every entry whose reason + mentions the convention, which is the fastest way to make the warning noise.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["note: the old HARD GATE: wording predates the field."])}, + ) + + assert not [f for f in findings if f["check"].startswith("deferred.hard-gate")] + + +def test_validate_warns_on_a_malformed_gate_token(project, capsys): + """`gate: 3-2 3-3` looks like two tokens and is one that matches nothing. + Reading it leniently would guess at a separator the format never promised, so + it is surfaced instead — a token that gates nothing is the prose gate again, + wearing the field's syntax.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["gate: 3-2 3-3"])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" + assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": ["3-2 3-3"]} + # and it is NOT reported as an enforced gate: nothing matched + assert not [f for f in findings if f["check"] == "deferred.hard-gate"] + + +def test_validate_silent_when_the_ledger_declares_no_gate(project, capsys): + """Zero-config output stays byte-identical: a project that has never written a + `gate:` line gets no new lines at all, in either direction.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", []), "DW-2": ("done 2026-08-01", [])}, + ) + + assert not [f for f in findings if f["check"].startswith("deferred.hard-gate")] + + +def test_validate_hard_gate_runs_in_stories_mode(project, capsys): + """Stories mode dispatches manifest ids rather than board keys, and the same + token has to reach both — an epic driven from stories.yaml is exactly where a + gated leg would otherwise be run first and discovered later.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _setup_stories_fixture(project, [_stories_entry("1")]) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + findings = _hard_gate_findings(capsys) + assert len(findings) == 1 and findings[0]["severity"] == "problem" + assert findings[0]["detail"]["story_key"] == "1" + + +def test_validate_stories_mode_skips_a_done_story(project, capsys): + """The manifest carries no status — the story's own spec does. Without reading + it, a finished epic would fail validate forever over gates on work that already + landed, which is the sprint arm's ACTIONABLE_STATUSES line drawn twice.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + folder = _setup_stories_fixture(project, [_stories_entry("1")]) + (folder / "stories" / "1-slug.md").write_text( + "---\ntitle: 'test'\nstatus: 'done'\n---\n\n## Intent\n\ntest\n", encoding="utf-8" + ) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + findings = _hard_gate_findings(capsys) + assert len(findings) == 1 and findings[0]["severity"] == "ok" + + OPENCODE_QUALIFIED_POLICY = '[adapter]\nname = "opencode"\nmodel = "anthropic/claude-haiku-4-5"\n' diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index e176032a..95fddc13 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1529,3 +1529,66 @@ def test_mark_done_many_skips_an_already_done_entry(tmp_path): assert again == [] body = next(e for e in parse_ledger(p.read_text(encoding="utf-8")) if e.id == "DW-1").body assert body.count("resolution: resolved by story 1") == 1 + + +def _gated(*lines: str): + text = ( + "# Deferred Work\n\n### DW-1: gated entry\n\n" + "origin: test\nlocation: n/a\nreason: test\nstatus: open\n" + + "".join(f"{x}\n" for x in lines) + ) + (entry,) = parse_ledger(text) + return deferredwork.gates(entry) + + +def test_gates_unions_every_line_and_splits_on_commas(): + """Several `gate:` lines are one claim, not competing ones: a line-oriented + file gives an author no reason to prefer one line over three. Duplicates + collapse and a trailing separator is not a token.""" + g = _gated("gate: 3-2, 3-3", "gate:\t4-1,3-2,") + + assert g.tokens == ("3-2", "3-3", "4-1") + assert g.malformed == () + + +def test_gates_reports_a_token_that_cannot_name_a_story(): + """The separator is a comma and only a comma. Reading `3-2 3-3` leniently + would guess at one the format never promised, so it lands in `malformed` — + surfaced by validate rather than silently gating nothing.""" + g = _gated("gate: 3-2 3-3, ../etc, 4-1") + + assert g.tokens == ("4-1",) + assert g.malformed == ("3-2 3-3", "../etc") + + +def test_gates_stop_at_the_canonical_span_boundary(): + """A `gate:` line below a flat-append bullet belongs to that block, not to the + entry above it — the same boundary `status:` is read within. Absorbing it would + let an unrelated appended finding block a story nobody gated.""" + text = ( + "# Deferred Work\n\n### DW-1: canonical\n\n" + "origin: test\nlocation: n/a\nreason: test\nstatus: open\n\n" + "- source_spec: `s.md`\n summary: finding\ngate: 3-2\n" + ) + + (entry,) = parse_ledger(text) + + assert deferredwork.gates(entry).tokens == () + + +@pytest.mark.parametrize( + ("token", "story_key", "gated"), + [ + ("3-2", "3-2", True), # stories-mode id: the token IS the key + ("3-2", "3-2-invite-link-student-surface", True), # sprint key: `-` prefix + ("3-2", "3-20-later-story", False), # the boundary the `-` buys + # a split story is its own key, so `gate: 3-2` does NOT cover 3-2a/3-2b — + # name the halves when a split is what is blocked + ("3-2", "3-2a-split-half", False), + ("3", "3-2-invite-link", True), # a whole epic is a legal token + ("3-2-invite", "3-2-invite-link", True), + ("3-2-invite", "3-2-invited", False), + ], +) +def test_gates_story_matches_on_key_boundaries(token, story_key, gated): + assert deferredwork.gates_story(token, story_key) is gated From e2411a1c5f389d166e0efd08e0569f9dc92cfa1c Mon Sep 17 00:00:00 2001 From: pirony Date: Sat, 8 Aug 2026 14:59:22 +0200 Subject: [PATCH 03/34] docs(deferred): document `gate:` in the format, README and FEATURES 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. --- CHANGELOG.md | 11 ++++++ README.md | 11 ++++++ docs/FEATURES.md | 1 + .../bmad-loop-sweep/deferred-work-format.md | 37 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e55e6250..d14adb23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ whose seams had diverged enough that several ports needed a different fix, and t ### Added +- **A deferred-work entry can block a story: `gate:`.** An entry that must land before specific + stories run could only say so in prose (`HARD GATE: must land before 3-2`), and prose stopped + nothing — `run` drove the story and the gate surfaced in a diff built on the missing leg. A + `gate: 3-2, 3-3` field line names the blocked story keys, and while the entry is `open` + `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches + (equal to the key, or its `-`-delimited prefix — `3-2` covers `3-2-invite-link` and never + `3-20-later`). Both queue modes. The only deferred check that gates rather than advises; cleared + by closing the entry or dropping the token. An open entry that opens a line with `HARD GATE:` + but declares no `gate:` line — or whose token cannot name a story key — is a warning + (`deferred.hard-gate-unstructured`). A ledger with no `gate:` line at all is silent as before. + - **Deferred review findings are harvested from spec frontmatter (#433).** BMAD-METHOD#2640 moved `defer`-triaged findings into the spec's unfiled `deferred:` list. A successful dev, review, repair or review-timeout-salvage pass now files each as `### DW-` (`spec-deferrals-harvested`), diff --git a/README.md b/README.md index 0d685535..2982d644 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,17 @@ The orchestrator writes the same annotation a bundle close writes — `status: d A declaration in a shape nothing can read — a bare `closes_deferred: DW-5` where a list belongs — depends on which channel it is in. In a **story spec** it is journaled and dropped, like an unknown id: the spec is generated mid-run by a dev skill, and a malformed field there must not be able to fail a story that succeeded. In **`stories.yaml`** it is a schema error, exactly like every other manifest field of the wrong type, and the manifest fails to load — the breakdown is hand-authored before the run, where `bmad-loop validate` reports it up front and a typo is still cheap to fix. `validate` warns about unknown ids in both queue modes and about a malformed spec declaration; a malformed manifest is the manifest's own `queue.stories-manifest` failure. +**Blocking a story from the ledger.** Some entries do not merely defer work, they block it: a leg nobody has wired yet is not a nice-to-have for the first story that consumes it. Entries said so in prose (`HARD GATE: must land before 3-2`) long before anything could act on it, and prose gates nothing — `run` picked the story off the board and drove it anyway, and the gate surfaced afterwards, in a diff built on the missing leg. A `gate:` field line makes the claim enforceable: + +```markdown +### DW-1: wire the blob-storage credentials + +status: open +gate: 3-2, 3-3 +``` + +While that entry is `open`, `bmad-loop validate` **fails** for every actionable story a token matches — a token gates a story key when it is that key or its `-`-delimited prefix, so `3-2` covers the sprint key `3-2-invite-link-student-surface` and the stories-mode id `3-2`, and never `3-20-later-story`. Closing the entry clears it, and so does dropping the token. This is the only deferred-work check that is a gate rather than an advisory: the `closes_deferred` checks above describe traceability that may be wrong and must never block a run, while this one describes work that must not start. An open entry that opens a line with `HARD GATE:` but carries no `gate:` line — and a token that cannot name a story key, including a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones — is reported as a warning: a gate exists that nothing can enforce. + > **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. The annotation is written all the same, at the same moment, and the run journals `deferred-close-external-ledger` so its absence from git history is not a surprise. A location that cannot be read or written when the write comes due (a shared mount that has gone away) closes nothing and is journaled — those entries stay `open` for a sweep to re-verify, and an outage is never read as "no such entries". **Answering missed decisions later.** An unattended sweep (`--no-prompt`) skips decisions, and an interactive one can be abandoned before you answer them all — those answers would otherwise be lost, since triage re-derives the decision set from the ledger every run. `bmad-loop decisions` (or press `d` in the TUI) surfaces every decision past sweeps left unanswered, reconstructed from their triage output, and lets you answer them out of band. A `close` is applied immediately; a `build`/`keep-open` is saved to `.bmad-loop/decisions.json` and consumed by the next sweep (build → bundle, keep-open → recorded) with no re-prompt. `--list` shows them without answering; `bmad-loop status` reports the outstanding count. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 863a77e1..49fe10ff 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -112,6 +112,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. - Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips to `status: done ` + `resolution: resolved by story ` — the annotation a sweep bundle writes — so the ledger stops being one-way. Written at the commit boundary, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status or a non-list declaration in a story spec is journaled, never fatal, and `bmad-loop validate` warns about all of them before the run starts. (A non-list `closes_deferred` in `stories.yaml` is different: the manifest is a schema the parser owns, so it is refused outright, before the run.) An artifact dir outside the repo cannot be committed — the annotation is written anyway and journaled (`deferred-close-external-ledger`). +- Hard gates (`gate: 3-2, 3-3` on an entry): while the entry is `open`, `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches — a token gates a key it equals or `-`-prefixes, so `3-2` covers `3-2-invite-link` and the stories-mode id `3-2` but never `3-20-later`. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. An open entry whose body opens a line with `HARD GATE:` but declares no `gate:` line, or whose token cannot name a story key, is a warning (`deferred.hard-gate-unstructured`) — a gate nothing can enforce. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index c085e3d1..cfb7092b 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -72,6 +72,43 @@ for polish and nice-to-haves. When a deferred item is later completed, set its `status:` to `done` with the date (e.g. `status: done 2026-06-20`) — do not delete the entry. +## Hard gates: `gate:` + +Some entries are not merely deferred — they **block** specific stories. An +infrastructure leg nobody has wired yet is not a nice-to-have for the first story +that consumes it; that story must not run at all until the entry lands. Say so +with a `gate:` line naming the blocked story keys: + +```markdown +### DW-1: wire the blob-storage credentials + +status: open +gate: 3-2, 3-3 +``` + +`gate:` is optional and most entries have none. Its value is a **comma-separated** +list of story-key tokens; several `gate:` lines in one entry union, so an entry +blocking three stories may list them on one line or on three. A token matches a +story key when it **is** that key or is its `-`-delimited prefix: `3-2` gates the +sprint key `3-2-invite-link-student-surface` and the stories-mode id `3-2`, and +never `3-20-later-story`. + +While the entry is `open`, `bmad-loop validate` **fails** (`deferred.hard-gate`) +for every actionable story a token matches — sprint-status stories at `backlog` / +`ready-for-dev`, or manifest entries whose spec is not yet `done`. Two things +clear it: closing the entry (`status: done `), or removing the token +because it no longer blocks that work. This is the one deferred-work check that +gates rather than advises: everything else here is traceability that may be +wrong, while this is work that must not start. + +A token must look like a story key — `[A-Za-z0-9][A-Za-z0-9._-]*`, no spaces. +Anything else matches nothing, so it is reported (`deferred.hard-gate-unstructured`) +rather than dropped; note that this makes a space-separated `gate: 3-2 3-3` one +bad token, not two good ones. The same warning covers an open entry whose body +opens a line with `HARD GATE:` but carries no `gate:` line — the prose convention +that predates this field, which reads like a gate already in force while holding +nothing back. Add the `gate:` line to make it enforceable. + ## Sweep annotations `bmad-loop sweep` runs (the orchestrator and its bundle dev sessions) add two From 4918f87a0ed355584593b52114849d746eefc7ed Mon Sep 17 00:00:00 2001 From: pirony Date: Sat, 8 Aug 2026 15:26:37 +0200 Subject: [PATCH 04/34] fix(deferred): align gate semantics with the adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bmad_loop/cli.py | 13 +++++--- src/bmad_loop/deferredwork.py | 61 ++++++++++++++++++++++++++--------- tests/test_cli.py | 60 +++++++++++++++++++++++++++------- tests/test_deferredwork.py | 51 +++++++++++++++++++++++++++-- 4 files changed, 150 insertions(+), 35 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index cef4f9e8..04faf355 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1146,11 +1146,12 @@ def _report_unstructured_gate( ) -> None: """Warn about a hard gate the mechanical check cannot enforce. - Two causes, one id, because the remedy is the same line in the same file. A - ``HARD GATE:`` written as prose is the pre-``gate:`` convention still holding - nothing back; a token that cannot name a story key is that same nothing with - the field's syntax around it, which is worse — it reads, to anyone scanning - the entry, as a gate that is already in force. + Three causes, one id, because the remedy is the same line in the same file. + A ``HARD GATE:`` written as prose is the pre-``gate:`` convention still + holding nothing back. A token that cannot name a story key, and a ``gate:`` + line with nothing after the colon, are that same nothing with the field's + syntax around it — worse, because to anyone scanning the entry they read as a + gate already in force. An entry carrying a valid token *and* a malformed one is still reported: the valid half gates what it names, and the operator's belief about the other half @@ -1160,6 +1161,8 @@ def _report_unstructured_gate( reason = ( f"declares `gate:` tokens that cannot name a story: {', '.join(entry_gates.malformed)}" ) + elif entry_gates.inert: + reason = "declares an empty `gate:` line, which names no story" elif not entry_gates.tokens and deferredwork.HARD_GATE_PROSE_RE.search(entry.body): reason = "declares a `HARD GATE:` in prose but carries no `gate:` line" else: diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index c4a9d550..7df13c2a 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -47,11 +47,14 @@ # 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. GATE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") -# Line-start only. The prose convention this field replaces is a line that -# *opens* with it; a sentence mentioning a hard gate mid-line ("the DW-1 HARD -# GATE: wording predates the field") is discussion, and reading it as a -# declaration would warn about every entry that talks about gating. -HARD_GATE_PROSE_RE = re.compile(r"^HARD GATE:", re.MULTILINE) +# The prose convention `gate:` replaces, matched anywhere on a line rather than +# at its start: real ledgers hard-wrap their `reason:` prose, so the declaration +# routinely lands mid-line and a line-anchored pattern misses exactly the entries +# that have one. The quote lookbehind is what keeps that from over-firing — an +# entry *citing* the phrase (`names a "HARD GATE: ..."`) is discussion, not a +# declaration — and the colon does the rest of the work, since a sentence about +# "this HARD GATE is textual only" never reaches the pattern at all. +HARD_GATE_PROSE_RE = re.compile(r"""(? set[str]: class EntryGates: """One entry's ``gate:`` declaration, split by what a check can act on. - Both halves are reported by ``validate``, because a token that matches no - story key is not a weaker gate than a valid one — it is the prose gate again, - wearing the field's clothes, and silence about it is what let the story run. + Every shape that is not an enforceable token is reported by ``validate``, + because none of them is a *weaker* gate than a valid one — each is the prose + gate again wearing the field's clothes, and silence about it is what let the + story run. ``lines`` is what distinguishes "declared nothing usable" from + "declared nothing at all": an entry with no ``gate:`` line has made no claim, + while ``gate:`` with an empty value has made one and inertly. """ tokens: tuple[str, ...] = () malformed: tuple[str, ...] = () + lines: int = 0 + + @property + def inert(self) -> bool: + """A ``gate:`` line that yielded no token at all — ``gate:`` or ``gate: ,``.""" + return self.lines > 0 and not self.tokens and not self.malformed def gates(entry: DWEntry) -> EntryGates: @@ -147,11 +159,14 @@ def gates(entry: DWEntry) -> EntryGates: finding out from a story that ran. Duplicates collapse (an id repeated across lines is one claim, not two); - empty items drop, so a trailing separator is not a token. + empty items drop, so a trailing separator is not a token — but the *line* is + still counted, which is how an all-empty declaration stays reportable. """ tokens: list[str] = [] malformed: list[str] = [] + lines = 0 for m in GATE_RE.finditer(entry.body): + lines += 1 for raw in m.group(1).split(","): token = raw.strip() if not token: @@ -159,19 +174,35 @@ def gates(entry: DWEntry) -> EntryGates: bucket = tokens if GATE_TOKEN_RE.match(token) else malformed if token not in bucket: bucket.append(token) - return EntryGates(tokens=tuple(tokens), malformed=tuple(malformed)) + return EntryGates(tokens=tuple(tokens), malformed=tuple(malformed), lines=lines) def gates_story(token: str, story_key: str) -> bool: - """Whether ``token`` gates ``story_key`` — equal, or its ``-``-delimited prefix. + """Whether ``token`` gates ``story_key``: equal, or its prefix at a key boundary. - The prefix arm is what lets one token reach both queues: stories mode keys on + The prefix arm is what lets one token reach both queues — stories mode keys on the bare id (``3-2``) while sprint mode keys on the full ``3-2-invite-link``, and an author gating "story 3-2" means the story, not the spelling. The - delimiter is required rather than a bare ``startswith`` so ``3-2`` cannot - sweep in its numeric neighbours — ``3-20-...`` is a different story. + boundary is required rather than a bare ``startswith`` so ``3-2`` cannot sweep + in its numeric neighbours: ``3-20-later`` is a different story. + + Two boundaries count, because BMAD spells a story key two ways. The plain one + is ``-``. The other is a **split**: ``sprintstatus.STORY_RE`` lets an oversized + story become ``3-2a-...`` / ``3-2b-...`` at breakdown time, and a token that + only knew ``-`` would lose its gate the moment the gated story was split — + silently, which is the worst thing a gate can do. One lowercase ASCII letter + followed by ``-`` is therefore also a boundary. Exactly one letter, and the + ``-`` after it is required, so ``3-2ab-x`` and a bare ``3-2a`` are not swept in. """ - return story_key == token or story_key.startswith(f"{token}-") + if story_key == token or story_key.startswith(f"{token}-"): + return True + # The `startswith` guard is load-bearing, not redundant with the slice below: + # `story_key[len(token):]` says nothing about what preceded it, so without it + # `3-2` would gate `9-9a-x` on the tail alone. + if not story_key.startswith(token): + return False + rest = story_key[len(token) :] + return len(rest) >= 2 and "a" <= rest[0] <= "z" and rest[1] == "-" def parse_declaration(raw: object) -> tuple[tuple[str, ...], str | None]: diff --git a/tests/test_cli.py b/tests/test_cli.py index e8b15c2c..a479d454 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3978,23 +3978,23 @@ def test_validate_passes_when_the_gate_entry_is_closed(project, capsys): def test_validate_hard_gate_token_stops_at_the_key_boundary(project, capsys): - """`3-2` gates the story it names and not its numeric neighbours. A bare - `startswith` would sweep `3-20-...` in and block an unrelated story for as long - as the entry stayed open — the failure mode that makes an operator delete the - gate rather than trust it.""" + """`3-2` gates the story it names and both halves of that story once breakdown + splits it, and not its numeric neighbours. A bare `startswith` would sweep + `3-20-...` in and block unrelated work; a `-`-only boundary would drop the gate + the moment 3-2 became 3-2a/3-2b, which is the same gate failing silently.""" findings = _validate_gated_sprint( project, capsys, { "3-2-invite-link-student-surface": "ready-for-dev", + "3-2a-split-half": "ready-for-dev", "3-20-later-story": "ready-for-dev", }, {"DW-1": ("open", ["gate: 3-2"])}, ) - gates = [f for f in findings if f["check"] == "deferred.hard-gate"] - assert len(gates) == 1 - assert gates[0]["detail"]["story_key"] == "3-2-invite-link-student-surface" + gated = [f["detail"]["story_key"] for f in findings if f["check"] == "deferred.hard-gate"] + assert gated == ["3-2-invite-link-student-surface", "3-2a-split-half"] def test_validate_unions_multiple_gate_lines(project, capsys): @@ -4031,20 +4031,56 @@ def test_validate_warns_on_a_prose_only_hard_gate(project, capsys): assert not [f for f in findings if f["check"] == "deferred.hard-gate"] -def test_validate_ignores_a_mid_line_hard_gate_mention(project, capsys): - """Line-start only: an entry *discussing* a hard gate is not declaring one. - Matching anywhere in the body would warn about every entry whose reason - mentions the convention, which is the fastest way to make the warning noise.""" +def test_validate_warns_on_a_mid_line_hard_gate(project, capsys): + """Real ledgers hard-wrap their `reason:` prose, so a declaration routinely + lands mid-line. A line-anchored detector missed exactly the entries that had + one — which is the whole population this warning exists for.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["reason: wired late. HARD GATE: must land before 3-2."])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" + + +def test_validate_ignores_an_entry_that_only_cites_a_hard_gate(project, capsys): + """An entry *about* the convention is not declaring one — the ledger's own + "no mechanical check enforces a HARD GATE" entry must not warn about itself. + The quote is what separates the two, and a colon-less mention never reaches + the pattern at all.""" findings = _validate_gated_sprint( project, capsys, {"3-2-invite-link": "ready-for-dev"}, - {"DW-1": ("open", ["note: the old HARD GATE: wording predates the field."])}, + { + "DW-1": ("open", ['reason: an entry naming a "HARD GATE: before X" binds nothing.']), + "DW-2": ("open", ["reason: this HARD GATE is textual only, nothing enforces it."]), + }, ) assert not [f for f in findings if f["check"].startswith("deferred.hard-gate")] +def test_validate_warns_on_an_empty_gate_line(project, capsys): + """`gate:` with nothing after it is a claim made inertly. It reads, to anyone + scanning the entry, as a gate already in force, and silence would make it + indistinguishable from an entry that never gated anything.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["gate:"])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" + assert "empty `gate:` line" in unstructured[0]["message"] + assert not [f for f in findings if f["check"] == "deferred.hard-gate"] + + def test_validate_warns_on_a_malformed_gate_token(project, capsys): """`gate: 3-2 3-3` looks like two tokens and is one that matches nothing. Reading it leniently would guess at a separator the format never promised, so diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 95fddc13..d0cdb24a 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1549,6 +1549,19 @@ def test_gates_unions_every_line_and_splits_on_commas(): assert g.tokens == ("3-2", "3-3", "4-1") assert g.malformed == () + assert not g.inert + + +@pytest.mark.parametrize("line", ["gate:", "gate: ", "gate: ,", "gate: , ,"]) +def test_gates_reports_a_line_that_names_nothing(line): + """`gate:` with nothing usable after it is a claim made inertly, and the + parse alone cannot tell it apart from an entry that never gated anything — + `lines` is what keeps it reportable. Left silent, it reads to anyone scanning + the entry as a gate already in force.""" + g = _gated(line) + + assert g.tokens == () and g.malformed == () + assert g.inert def test_gates_reports_a_token_that_cannot_name_a_story(): @@ -1582,9 +1595,16 @@ def test_gates_stop_at_the_canonical_span_boundary(): ("3-2", "3-2", True), # stories-mode id: the token IS the key ("3-2", "3-2-invite-link-student-surface", True), # sprint key: `-` prefix ("3-2", "3-20-later-story", False), # the boundary the `-` buys - # a split story is its own key, so `gate: 3-2` does NOT cover 3-2a/3-2b — - # name the halves when a split is what is blocked - ("3-2", "3-2a-split-half", False), + # A split story is still the gated story. STORY_RE lets breakdown turn an + # oversized 3-2 into 3-2a/3-2b, and a token that only knew `-` would lose + # its gate at exactly that moment — silently, which is the one thing a + # gate must never do. + ("3-2", "3-2a-split-half", True), + ("3-2", "3-2b-other-half", True), + ("3-2", "3-2A-upper", False), # the split suffix is lowercase ASCII + ("3-2", "3-2ab-two-letters", False), # exactly one letter, or it is a slug + ("3-2", "3-2a", False), # the `-` after the letter is required + ("3-2", "9-9a-elsewhere", False), # the split arm still needs the prefix ("3", "3-2-invite-link", True), # a whole epic is a legal token ("3-2-invite", "3-2-invite-link", True), ("3-2-invite", "3-2-invited", False), @@ -1592,3 +1612,28 @@ def test_gates_stop_at_the_canonical_span_boundary(): ) def test_gates_story_matches_on_key_boundaries(token, story_key, gated): assert deferredwork.gates_story(token, story_key) is gated + + +@pytest.mark.parametrize( + ("body", "declared"), + [ + ("HARD GATE: must land before 3-2", True), # the bare convention + ("reason: wired late. HARD GATE: must land before 3-2", True), # hard-wrapped prose + ('reason: an entry naming a "HARD GATE: before X" is enforced by nothing', False), + ("reason: an entry naming a 'HARD GATE: before X'", False), + ("reason: «HARD GATE: before X» is only prose", False), + ("reason: this HARD GATE is textual only, nothing enforces it", False), + # KNOWN LIMIT, pinned rather than left to surprise someone: the lookbehind + # is one character wide, so a citation that spaces its opening quote off + # the phrase — the French convention, `«` + U+00A0 — still reads as a + # declaration. The remedy for such an entry is a `gate:` line, which + # silences the warning either way. + ("reason: « HARD GATE: before X » is only prose", True), + ], +) +def test_hard_gate_prose_detects_a_declaration_not_a_citation(body, declared): + """Matched anywhere on a line, because real ledgers hard-wrap `reason:` and the + declaration lands mid-line. The quote lookbehind is what keeps that honest — an + entry *citing* the phrase is discussion — and the colon excludes prose that + merely talks about a hard gate.""" + assert bool(deferredwork.HARD_GATE_PROSE_RE.search(body)) is declared From 5a9c48ce67cbeec41863acf086f50a14f6b6f290 Mon Sep 17 00:00:00 2001 From: pirony Date: Sat, 8 Aug 2026 15:26:37 +0200 Subject: [PATCH 05/34] docs(deferred): describe the split, mid-line and empty-gate rules The three inert shapes are now one list, since one warning covers them and the remedy is the same line. --- CHANGELOG.md | 15 +++++---- README.md | 4 ++- docs/FEATURES.md | 2 +- .../bmad-loop-sweep/deferred-work-format.md | 31 +++++++++++++------ 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14adb23..f483aed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,12 +18,15 @@ whose seams had diverged enough that several ports needed a different fix, and t stories run could only say so in prose (`HARD GATE: must land before 3-2`), and prose stopped nothing — `run` drove the story and the gate surfaced in a diff built on the missing leg. A `gate: 3-2, 3-3` field line names the blocked story keys, and while the entry is `open` - `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches - (equal to the key, or its `-`-delimited prefix — `3-2` covers `3-2-invite-link` and never - `3-20-later`). Both queue modes. The only deferred check that gates rather than advises; cleared - by closing the entry or dropping the token. An open entry that opens a line with `HARD GATE:` - but declares no `gate:` line — or whose token cannot name a story key — is a warning - (`deferred.hard-gate-unstructured`). A ledger with no `gate:` line at all is silent as before. + `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches — + the key itself, or its prefix at a key boundary (`-`, or a split-story suffix), so `3-2` covers + `3-2-invite-link` and both halves of a `3-2a`/`3-2b` split but never `3-20-later`. Both queue + modes. The only deferred check that gates rather than advises; cleared by closing the entry or + dropping the token. Three inert shapes warn instead (`deferred.hard-gate-unstructured`): a token + that cannot name a story key, an empty `gate:` line, and prose declaring `HARD GATE:` on an entry + with no `gate:` line — matched anywhere on a line, since `reason:` prose wraps, but not straight + after a quote character, so an entry citing the phrase stays silent. A ledger with no `gate:` + line at all is silent as before. - **Deferred review findings are harvested from spec frontmatter (#433).** BMAD-METHOD#2640 moved `defer`-triaged findings into the spec's unfiled `deferred:` list. A successful dev, review, diff --git a/README.md b/README.md index 2982d644..3220c750 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,9 @@ status: open gate: 3-2, 3-3 ``` -While that entry is `open`, `bmad-loop validate` **fails** for every actionable story a token matches — a token gates a story key when it is that key or its `-`-delimited prefix, so `3-2` covers the sprint key `3-2-invite-link-student-surface` and the stories-mode id `3-2`, and never `3-20-later-story`. Closing the entry clears it, and so does dropping the token. This is the only deferred-work check that is a gate rather than an advisory: the `closes_deferred` checks above describe traceability that may be wrong and must never block a run, while this one describes work that must not start. An open entry that opens a line with `HARD GATE:` but carries no `gate:` line — and a token that cannot name a story key, including a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones — is reported as a warning: a gate exists that nothing can enforce. +While that entry is `open`, `bmad-loop validate` **fails** for every actionable story a token matches. A token gates a story key when it is that key, or is its prefix at a key boundary — a `-`, or the split-story suffix (one lowercase letter then `-`). So `3-2` covers the sprint key `3-2-invite-link-student-surface`, the stories-mode id `3-2`, and both halves of a split (`3-2a-…`/`3-2b-…`), but never `3-20-later-story`; the split arm is there because breakdown can split a gated story after the gate was written, and a gate that quietly stops matching is worse than none. Closing the entry clears it, and so does dropping the token. This is the only deferred-work check that is a gate rather than an advisory: the `closes_deferred` checks above describe traceability that may be wrong and must never block a run, while this one describes work that must not start. + +Three shapes declare a gate nothing can enforce, and each is a warning on an open entry: a token that cannot name a story key (including a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones); a `gate:` line with nothing usable after the colon; and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. > **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. The annotation is written all the same, at the same moment, and the run journals `deferred-close-external-ledger` so its absence from git history is not a surprise. A location that cannot be read or written when the write comes due (a shared mount that has gone away) closes nothing and is journaled — those entries stay `open` for a sweep to re-verify, and an outage is never read as "no such entries". diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 49fe10ff..81a5d835 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -112,7 +112,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. - Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips to `status: done ` + `resolution: resolved by story ` — the annotation a sweep bundle writes — so the ledger stops being one-way. Written at the commit boundary, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status or a non-list declaration in a story spec is journaled, never fatal, and `bmad-loop validate` warns about all of them before the run starts. (A non-list `closes_deferred` in `stories.yaml` is different: the manifest is a schema the parser owns, so it is refused outright, before the run.) An artifact dir outside the repo cannot be committed — the annotation is written anyway and journaled (`deferred-close-external-ledger`). -- Hard gates (`gate: 3-2, 3-3` on an entry): while the entry is `open`, `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches — a token gates a key it equals or `-`-prefixes, so `3-2` covers `3-2-invite-link` and the stories-mode id `3-2` but never `3-20-later`. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. An open entry whose body opens a line with `HARD GATE:` but declares no `gate:` line, or whose token cannot name a story key, is a warning (`deferred.hard-gate-unstructured`) — a gate nothing can enforce. +- Hard gates (`gate: 3-2, 3-3` on an entry): while the entry is `open`, `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches — a token gates a key it equals or prefixes at a key boundary (`-`, or a split-story suffix), so `3-2` covers `3-2-invite-link`, the stories-mode id `3-2` and both halves of a `3-2a`/`3-2b` split, but never `3-20-later`. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (`deferred.hard-gate-unstructured`) covers the three gates nothing can enforce: a token that cannot name a story key, an empty `gate:` line, and prose declaring `HARD GATE:` (matched mid-line, since `reason:` prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no `gate:` line. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index cfb7092b..c2c1ea48 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -89,9 +89,12 @@ gate: 3-2, 3-3 `gate:` is optional and most entries have none. Its value is a **comma-separated** list of story-key tokens; several `gate:` lines in one entry union, so an entry blocking three stories may list them on one line or on three. A token matches a -story key when it **is** that key or is its `-`-delimited prefix: `3-2` gates the -sprint key `3-2-invite-link-student-surface` and the stories-mode id `3-2`, and -never `3-20-later-story`. +story key when it **is** that key, or is its prefix at a key boundary — either a +`-`, or the split-story suffix (one lowercase letter then `-`). So `3-2` gates the +sprint key `3-2-invite-link-student-surface`, the stories-mode id `3-2`, and both +halves of a split (`3-2a-…` / `3-2b-…`), but never `3-20-later-story`. The split +arm matters because breakdown can split a story _after_ the gate was written, and +a gate that quietly stops matching is worse than one that was never there. While the entry is `open`, `bmad-loop validate` **fails** (`deferred.hard-gate`) for every actionable story a token matches — sprint-status stories at `backlog` / @@ -101,13 +104,21 @@ because it no longer blocks that work. This is the one deferred-work check that gates rather than advises: everything else here is traceability that may be wrong, while this is work that must not start. -A token must look like a story key — `[A-Za-z0-9][A-Za-z0-9._-]*`, no spaces. -Anything else matches nothing, so it is reported (`deferred.hard-gate-unstructured`) -rather than dropped; note that this makes a space-separated `gate: 3-2 3-3` one -bad token, not two good ones. The same warning covers an open entry whose body -opens a line with `HARD GATE:` but carries no `gate:` line — the prose convention -that predates this field, which reads like a gate already in force while holding -nothing back. Add the `gate:` line to make it enforceable. +Three shapes declare a gate that nothing can enforce, and all three are reported +as `deferred.hard-gate-unstructured` on an open entry: + +- a token that does not look like a story key (`[A-Za-z0-9][A-Za-z0-9._-]*`, no + spaces) — note that this makes a space-separated `gate: 3-2 3-3` one bad token + rather than two good ones; +- a `gate:` line with nothing usable after the colon (`gate:`, `gate: ,`); +- prose declaring `HARD GATE:` — the convention that predates this field — + anywhere on a line of an entry that carries no `gate:` line. It is matched + mid-line because `reason:` prose is hard-wrapped, but never directly after a + quote character: an entry that merely _cites_ the phrase stays silent, as does + one that writes it without the colon. + +Each reads like a gate already in force while holding nothing back. Add or repair +the `gate:` line to make it enforceable. ## Sweep annotations From c3a124a83ade4c00626a6ac7573f55e142b76622 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 00:00:57 -0700 Subject: [PATCH 06/34] fix(deferred): close the gate's false-refusal, lost-line and crash paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #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. --- CHANGELOG.md | 17 +-- docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 109 ++++++++++++------ .../bmad-loop-sweep/deferred-work-format.md | 16 ++- src/bmad_loop/deferredwork.py | 32 ++++- tests/test_cli.py | 98 +++++++++++++++- tests/test_deferredwork.py | 32 ++++- 7 files changed, 247 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6eb779..a98e6479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,17 +16,12 @@ whose seams had diverged enough that several ports needed a different fix, and t - **A deferred-work entry can block a story: `gate:`.** An entry that must land before specific stories run could only say so in prose (`HARD GATE: must land before 3-2`), and prose stopped - nothing — `run` drove the story and the gate surfaced in a diff built on the missing leg. A - `gate: 3-2, 3-3` field line names the blocked story keys, and while the entry is `open` - `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches — - the key itself, or its prefix at a key boundary (`-`, or a split-story suffix), so `3-2` covers - `3-2-invite-link` and both halves of a `3-2a`/`3-2b` split but never `3-20-later`. Both queue - modes. The only deferred check that gates rather than advises; cleared by closing the entry or - dropping the token. Three inert shapes warn instead (`deferred.hard-gate-unstructured`): a token - that cannot name a story key, an empty `gate:` line, and prose declaring `HARD GATE:` on an entry - with no `gate:` line — matched anywhere on a line, since `reason:` prose wraps, but not straight - after a quote character, so an entry citing the phrase stays silent. A ledger with no `gate:` - line at all is silent as before. + nothing — `run` drove the story anyway. A `gate: 3-2, 3-3` line names the blocked story keys, and + while the entry is `open` `bmad-loop validate` fails (`deferred.hard-gate`) for every actionable + story a token matches, in both queue modes. The only deferred check that gates rather than + advises; cleared by closing the entry or dropping the token. A gate that can enforce nothing — + an unusable token, an empty `gate:` line, or a prose-only `HARD GATE:` — warns instead + (`deferred.hard-gate-unstructured`). Silent on a ledger that gates nothing, as before. - **Deferred review findings are harvested from spec frontmatter (#433).** BMAD-METHOD#2640 moved `defer`-triaged findings into the spec's unfiled `deferred:` list. A successful dev, review, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8509772a..00670b8b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -112,7 +112,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. - Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips to `status: done ` + `resolution: resolved by story ` — the annotation a sweep bundle writes — so the ledger stops being one-way. Written at the commit boundary, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status or a non-list declaration in a story spec is journaled, never fatal, and `bmad-loop validate` warns about all of them before the run starts. (A non-list `closes_deferred` in `stories.yaml` is different: the manifest is a schema the parser owns, so it is refused outright, before the run.) An artifact dir outside the repo cannot be committed — the annotation is written anyway and journaled (`deferred-close-external-ledger`). -- Hard gates (`gate: 3-2, 3-3` on an entry): while the entry is `open`, `bmad-loop validate` FAILS (`deferred.hard-gate`) for every actionable story a token matches — a token gates a key it equals or prefixes at a key boundary (`-`, or a split-story suffix), so `3-2` covers `3-2-invite-link`, the stories-mode id `3-2` and both halves of a `3-2a`/`3-2b` split, but never `3-20-later`. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (`deferred.hard-gate-unstructured`) covers the three gates nothing can enforce: a token that cannot name a story key, an empty `gate:` line, and prose declaring `HARD GATE:` (matched mid-line, since `reason:` prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no `gate:` line. +- Hard gates (`gate: 3-2, 3-3` on an entry): while the entry is `open`, `bmad-loop validate` fails (`deferred.hard-gate`) for every actionable story a token matches — a token gates a key it equals or prefixes at a key boundary (`-`, or a split-story suffix), so `3-2` covers `3-2-invite-link`, the stories-mode id `3-2` and both halves of a `3-2a`/`3-2b` split, but never `3-20-later`. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (`deferred.hard-gate-unstructured`) covers the three gates nothing can enforce: a token that cannot name a story key, an empty `gate:` line, and prose declaring `HARD GATE:` (matched mid-line, since `reason:` prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no `gate:` line. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 2d68d250..936b2554 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1052,9 +1052,11 @@ def _validate_deferred_ledger( ``validate`` reads the ledger, so silence meant reporting success for preflights that checked nothing. - The gate runs first — an operator who has both a blocked story and a stale - traceability field needs the refusal at the top, and ``_validate_closes_deferred`` - returns early on an unreadable manifest, which must not swallow it. + The gate runs first for one reason only: an operator who has both a blocked + story and a stale traceability field should meet the refusal before the + advisory. Presentation, nothing more — ``_validate_closes_deferred``'s early + return leaves *that function*, so it could never have skipped a sibling call + here, and swapping the two lines changes no severity and no exit code. """ ledger = paths.deferred_work try: @@ -1083,7 +1085,13 @@ def _validate_hard_gates( *, spec_folder: str | None = None, ) -> None: - """FAIL when the queue is about to dispatch a story an open ledger entry gates. + """FAIL when the queue would dispatch a story an open ledger entry gates. + + Preflight only, and the scope is worth stating plainly: this refuses at + ``bmad-loop validate``, not at dispatch. ``Engine._loop`` selects a story from + the board alone and never reads the ledger, so a ``run`` that skipped the + preflight is still unguarded. Wiring the same check into dispatch is follow-on + work; until it lands, the refusal is only as strong as the operator's habit. A ledger entry could always *say* it blocked a story — ``HARD GATE: must land before 3-2`` in its reason — and saying it stopped nothing. ``run`` took the @@ -1102,12 +1110,29 @@ def _validate_hard_gates( nobody remembered to write. """ declared = [(entry, deferredwork.gates(entry)) for entry in deferredwork.parse_ledger(text)] - story_keys = _actionable_story_keys(paths, spec_folder) - gated = False for entry, entry_gates in declared: if not entry.open: continue # a landed entry gates nothing; that is what closing it means _report_unstructured_gate(entry, entry_gates, report) + # Keyed on enforceable tokens, not on `gate:` lines: a ledger whose only gate is + # malformed enforced nothing, and an `ok` there would be the same false all-clear + # the warning above exists to break. Closed entries still count, deliberately — + # the passing case has to keep speaking after the gate lands, or `ok` and "nobody + # ever wrote a gate" become the same silence. Reading the queue sits behind this + # test so a project that gates nothing pays neither the walk nor its failure modes. + if not any(entry_gates.tokens for _, entry_gates in declared): + return + story_keys = _actionable_story_keys(paths, spec_folder) + if story_keys is None: + # The queue could not be read, so nothing was compared. `queue.sprint-status` + # and `queue.stories-manifest` already fail for it; adding an `ok` here would + # say "no story is gated" about a queue this check never saw. + return + open_gated = [e.id for e, g in declared if e.open and g.tokens] + gated = False + for entry, entry_gates in declared: + if not entry.open: + continue for story_key in story_keys: hits = [t for t in entry_gates.tokens if deferredwork.gates_story(t, story_key)] if not hits: @@ -1126,11 +1151,7 @@ def _validate_hard_gates( "tokens": hits, }, ) - # Keyed on enforceable tokens, not on `gate:` lines: a ledger whose only gate - # is malformed enforced nothing, and an `ok` there would be the same false - # all-clear the warning above exists to break. - if not gated and any(entry_gates.tokens for _, entry_gates in declared): - open_gated = [e.id for e, g in declared if e.open and g.tokens] + if not gated: report.ok( "deferred.hard-gate", f"deferred-work gates OK: no actionable story is gated by an open entry " @@ -1153,35 +1174,52 @@ def _report_unstructured_gate( syntax around it — worse, because to anyone scanning the entry they read as a gate already in force. - An entry carrying a valid token *and* a malformed one is still reported: the + An entry carrying a valid token *and* an unenforceable one is still reported, + and each cause is reported on its own rather than the first one winning: the valid half gates what it names, and the operator's belief about the other half - is exactly the thing that goes wrong quietly. + is exactly the thing that goes wrong quietly. That applies to an empty line as + much as to a malformed token — ``gate: 3-2`` followed by a bare ``gate:`` used + to report neither, because the entry had tokens and so read as fully gated. """ + reasons: list[str] = [] if entry_gates.malformed: - reason = ( + reasons.append( f"declares `gate:` tokens that cannot name a story: {', '.join(entry_gates.malformed)}" ) - elif entry_gates.inert: - reason = "declares an empty `gate:` line, which names no story" - elif not entry_gates.tokens and deferredwork.HARD_GATE_PROSE_RE.search(entry.body): - reason = "declares a `HARD GATE:` in prose but carries no `gate:` line" - else: + if entry_gates.empty == 1: + reasons.append("declares an empty `gate:` line, which names no story") + elif entry_gates.empty: + reasons.append(f"declares {entry_gates.empty} empty `gate:` lines, which name no story") + prose_only = not entry_gates.tokens and not reasons + if prose_only and deferredwork.HARD_GATE_PROSE_RE.search(entry.body): + reasons.append("declares a `HARD GATE:` in prose but carries no `gate:` line") + if not reasons: return report.warn( "deferred.hard-gate-unstructured", - f"{entry.id} ({entry.title}) {reason} — nothing holds the gated story back, so " - f"`bmad-loop run` will drive it while the entry is open; name the blocked stories " - f"on a `gate:` line (comma-separated) to make the gate enforceable", - {"dw_id": entry.id, "malformed": list(entry_gates.malformed)}, + f"{entry.id} ({entry.title}) {' and '.join(reasons)} — so `validate` cannot refuse " + f"the gated story and nothing holds it back; name the blocked stories on a `gate:` " + f"line (comma-separated) to make the gate enforceable", + { + "dw_id": entry.id, + "malformed": list(entry_gates.malformed), + "empty": entry_gates.empty, + }, ) -def _actionable_story_keys(paths: bmadconfig.ProjectPaths, spec_folder: str | None) -> list[str]: +def _actionable_story_keys( + paths: bmadconfig.ProjectPaths, spec_folder: str | None +) -> list[str] | None: """The story keys this queue would dispatch, in queue order, in either mode. - Degrades to nothing rather than raising: ``queue.sprint-status`` and - ``queue.stories-manifest`` own queue readability, and a queue nothing can read - dispatches nothing for a gate to refuse. + ``None`` when the queue could not be read, which is not the same answer as an + empty list: ``queue.sprint-status`` and ``queue.stories-manifest`` own queue + readability, so this check stays quiet rather than raising — but a caller that + read ``[]`` as "nothing is gated" would report an all-clear about a queue it + never saw. The whole walk is inside the guard for the same reason: the + per-story ``resolve_story_spec`` globs the filesystem too, and leaving it + outside turned a degraded check into a traceback out of ``validate``. Stories mode has no status column — the manifest is a flat schedule and the story's own spec carries the status — so a story whose spec reads ``done`` is @@ -1190,22 +1228,21 @@ def _actionable_story_keys(paths: bmadconfig.ProjectPaths, spec_folder: str | No over gates on work that already landed. """ if spec_folder is not None: + keys: list[str] = [] try: folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) - entries = stories_mod.load_stories(folder).entries + for entry in stories_mod.load_stories(folder).entries: + state = stories_mod.resolve_story_spec(folder, entry.id) + if state.kind == stories_mod.KIND_PRESENT and state.status == stories_mod.DONE: + continue + keys.append(entry.id) except (OSError, UnicodeDecodeError, stories_mod.StoriesError): - return [] - keys = [] - for entry in entries: - state = stories_mod.resolve_story_spec(folder, entry.id) - if state.kind == stories_mod.KIND_PRESENT and state.status == stories_mod.DONE: - continue - keys.append(entry.id) + return None return keys try: ss = sprintstatus.load(paths.sprint_status) except (sprintstatus.SprintStatusError, OSError, UnicodeDecodeError): - return [] + return None return [s.key for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index c2c1ea48..98e4ccc5 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -94,7 +94,13 @@ story key when it **is** that key, or is its prefix at a key boundary — either sprint key `3-2-invite-link-student-surface`, the stories-mode id `3-2`, and both halves of a split (`3-2a-…` / `3-2b-…`), but never `3-20-later-story`. The split arm matters because breakdown can split a story _after_ the gate was written, and -a gate that quietly stops matching is worse than one that was never there. +a gate that quietly stops matching is worse than one that was never there. The +prefix must end at a story **number** for the split arm to apply, so a word id +like `auth` does not gate `authz-login`. + +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. While the entry is `open`, `bmad-loop validate` **fails** (`deferred.hard-gate`) for every actionable story a token matches — sprint-status stories at `backlog` / @@ -110,12 +116,14 @@ as `deferred.hard-gate-unstructured` on an open entry: - a token that does not look like a story key (`[A-Za-z0-9][A-Za-z0-9._-]*`, no spaces) — note that this makes a space-separated `gate: 3-2 3-3` one bad token rather than two good ones; -- a `gate:` line with nothing usable after the colon (`gate:`, `gate: ,`); +- a `gate:` line with nothing usable after the colon (`gate:`, `gate: ,`) — each + such line is reported, including one sitting beside a line that does name a + story, since the half that names nothing is the half you are wrong about; - prose declaring `HARD GATE:` — the convention that predates this field — anywhere on a line of an entry that carries no `gate:` line. It is matched mid-line because `reason:` prose is hard-wrapped, but never directly after a - quote character: an entry that merely _cites_ the phrase stays silent, as does - one that writes it without the colon. + quote character (`"`, `'`, `` ` ``, `«`, or a curly quote): an entry that merely + _cites_ the phrase stays silent, as does one that writes it without the colon. Each reads like a gate already in force while holding nothing back. Add or repair the `gate:` line to make it enforceable. diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 7df13c2a..0388ef0f 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -54,7 +54,11 @@ # entry *citing* the phrase (`names a "HARD GATE: ..."`) is discussion, not a # declaration — and the colon does the rest of the work, since a sentence about # "this HARD GATE is textual only" never reaches the pattern at all. -HARD_GATE_PROSE_RE = re.compile(r"""(? bool: - """A ``gate:`` line that yielded no token at all — ``gate:`` or ``gate: ,``.""" + """Every ``gate:`` line named nothing — ``gate:`` or ``gate: ,`` and no others.""" return self.lines > 0 and not self.tokens and not self.malformed @@ -165,16 +177,21 @@ def gates(entry: DWEntry) -> EntryGates: tokens: list[str] = [] malformed: list[str] = [] lines = 0 + empty = 0 for m in GATE_RE.finditer(entry.body): lines += 1 + named = False for raw in m.group(1).split(","): token = raw.strip() if not token: continue + named = True bucket = tokens if GATE_TOKEN_RE.match(token) else malformed if token not in bucket: bucket.append(token) - return EntryGates(tokens=tuple(tokens), malformed=tuple(malformed), lines=lines) + if not named: + empty += 1 + return EntryGates(tokens=tuple(tokens), malformed=tuple(malformed), lines=lines, empty=empty) def gates_story(token: str, story_key: str) -> bool: @@ -193,13 +210,20 @@ def gates_story(token: str, story_key: str) -> bool: silently, which is the worst thing a gate can do. One lowercase ASCII letter followed by ``-`` is therefore also a boundary. Exactly one letter, and the ``-`` after it is required, so ``3-2ab-x`` and a bare ``3-2a`` are not swept in. + + The split arm applies only to a token ending in a digit, because that is the + only place a split letter can attach: ``STORY_RE`` puts it straight after the + story *number*. Without that guard the arm reads any trailing letter as a + split and gates a story nobody named — ``stories.ID_RE`` admits word ids, so + ``gate: auth`` refused ``authz-login``, and a hard failure on an unrelated + story is the one way this check can be worse than the prose it replaced. """ if story_key == token or story_key.startswith(f"{token}-"): return True # The `startswith` guard is load-bearing, not redundant with the slice below: # `story_key[len(token):]` says nothing about what preceded it, so without it # `3-2` would gate `9-9a-x` on the tail alone. - if not story_key.startswith(token): + if not story_key.startswith(token) or not token[-1:].isascii() or not token[-1:].isdigit(): return False rest = story_key[len(token) :] return len(rest) >= 2 and "a" <= rest[0] <= "z" and rest[1] == "-" diff --git a/tests/test_cli.py b/tests/test_cli.py index 102dcc64..c9474a00 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3981,6 +3981,24 @@ def test_validate_passes_when_the_gate_entry_is_closed(project, capsys): assert gates[0]["detail"]["open_gated_ids"] == [] +def test_validate_ignores_an_unstructured_gate_on_a_closed_entry(project, capsys): + """The `entry.open` skip guards the warning as well as the refusal, and only the + refusal half was pinned — an ablation of the skip left every closed entry + warning about gates that already landed, with nothing red.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + { + "DW-1": ("done 2026-08-01", ["gate: 3-2 3-3"]), + "DW-2": ("done 2026-08-01", ["HARD GATE: must land before 3-2"]), + "DW-3": ("done 2026-08-01", ["gate:"]), + }, + ) + + assert not [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + + def test_validate_hard_gate_token_stops_at_the_key_boundary(project, capsys): """`3-2` gates the story it names and both halves of that story once breakdown splits it, and not its numeric neighbours. A bare `startswith` would sweep @@ -4029,7 +4047,7 @@ def test_validate_warns_on_a_prose_only_hard_gate(project, capsys): unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" - assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": []} + assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": [], "empty": 0} assert "`gate:` line" in unstructured[0]["message"] # nothing enforceable exists, so there is no passing gate to report either assert not [f for f in findings if f["check"] == "deferred.hard-gate"] @@ -4099,11 +4117,49 @@ def test_validate_warns_on_a_malformed_gate_token(project, capsys): unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" - assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": ["3-2 3-3"]} + assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": ["3-2 3-3"], "empty": 0} # and it is NOT reported as an enforced gate: nothing matched assert not [f for f in findings if f["check"] == "deferred.hard-gate"] +def test_validate_warns_on_an_empty_gate_line_beside_an_enforced_one(project, capsys): + """The entry gates 3-2 and names nothing on its second line. Reporting only the + token would leave the operator believing both lines hold — the belief the field + exists to end — so the empty line is reported even though the entry is gating.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev", "4-1-billing": "ready-for-dev"}, + {"DW-1": ("open", ["gate: 3-2", "gate:"])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" + assert "empty `gate:` line" in unstructured[0]["message"] + assert unstructured[0]["detail"]["empty"] == 1 + # the valid half still gates, so this is additive to the refusal, not instead of it + gated = [f for f in findings if f["check"] == "deferred.hard-gate"] + assert [f["severity"] for f in gated] == ["problem"] + assert gated[0]["detail"]["story_key"] == "3-2-invite-link" + + +def test_validate_does_not_gate_a_word_id_that_merely_shares_a_prefix(project, capsys): + """`stories.ID_RE` admits word ids, and the split-story arm used to read the `z` + of `authz-login` as a split letter — FAILING validate for a story nobody gated. + A false refusal wedges a run, which is worse than the prose gate it replaced.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["gate: auth"])}, + ) + + assert not [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert not [ + f for f in findings if f["check"] == "deferred.hard-gate" and f["severity"] == "problem" + ] + + def test_validate_silent_when_the_ledger_declares_no_gate(project, capsys): """Zero-config output stays byte-identical: a project that has never written a `gate:` line gets no new lines at all, in either direction.""" @@ -4134,6 +4190,44 @@ def test_validate_hard_gate_runs_in_stories_mode(project, capsys): assert findings[0]["detail"]["story_key"] == "1" +def test_validate_survives_an_unreadable_story_spec_in_stories_mode(project, capsys, monkeypatch): + """`resolve_story_spec` globs the filesystem per story, so it has to sit inside + the same guard as the manifest read. Outside it, a gated stories-mode project on + a mount that raises turned a degraded advisory into a traceback out of + `validate` — no findings, no JSON, which is worse than the check being skipped.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _setup_stories_fixture(project, [_stories_entry("1")]) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + monkeypatch.setattr( + cli.stories_mod, + "resolve_story_spec", + lambda *a, **k: (_ for _ in ()).throw(OSError("EIO")), + ) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) # must not raise + + # the queue was never read, so the check says nothing rather than an all-clear + assert _hard_gate_findings(capsys) == [] + + +def test_validate_reports_no_gate_all_clear_when_the_queue_is_unreadable(project, capsys): + """`_actionable_story_keys` degrades on an unreadable queue, and an empty list + read as "nothing is gated" produced an `ok` naming the very entry it claimed was + clear. `queue.*` owns the outage; this check must not answer for a queue it + never saw.""" + install_bmad_config(project) + _write_policy(project.project) + project.sprint_status.write_text("development_status: [oh no\n", encoding="utf-8") + write_gated_ledger(project, {"DW-1": ("open", ["gate: 3-2"])}) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + assert _hard_gate_findings(capsys) == [] + + def test_validate_stories_mode_skips_a_done_story(project, capsys): """The manifest carries no status — the story's own spec does. Without reading it, a finished epic would fail validate forever over gates on work that already diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index d0cdb24a..4ddf4512 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1562,6 +1562,20 @@ def test_gates_reports_a_line_that_names_nothing(line): assert g.tokens == () and g.malformed == () assert g.inert + assert g.empty == 1 + + +def test_gates_counts_an_empty_line_beside_a_valid_one(): + """An entry can gate one story and name nothing on the next line. `inert` is an + entry-wide verdict and answers False here — the entry does have a token — so the + empty line needs its own count or the operator who wrote it is never told the + second gate holds nothing back.""" + g = _gated("gate: 3-2", "gate:") + + assert g.tokens == ("3-2",) + assert g.lines == 2 + assert not g.inert # the entry-wide verdict cannot express this case... + assert g.empty == 1 # ...which is why the per-line count exists def test_gates_reports_a_token_that_cannot_name_a_story(): @@ -1608,6 +1622,16 @@ def test_gates_stop_at_the_canonical_span_boundary(): ("3", "3-2-invite-link", True), # a whole epic is a legal token ("3-2-invite", "3-2-invite-link", True), ("3-2-invite", "3-2-invited", False), + # The split arm needs the token to end at a story NUMBER, because that is + # the only place STORY_RE can attach a split letter. `stories.ID_RE` admits + # word ids, so without the digit guard the arm read the `z` of `authz` as a + # split and FAILED validate for a story nobody gated — the one way this + # check can be worse than the prose it replaced. + ("auth", "authz-login", False), + ("api", "apis-v2", False), + ("3-2a", "3-2ab-x", False), # a token already carrying a split letter + ("3-2a", "3-2a-x", True), # ...still gates its own `-` boundary + ("", "a-b", False), # an empty token names nothing, so it gates nothing ], ) def test_gates_story_matches_on_key_boundaries(token, story_key, gated): @@ -1623,12 +1647,18 @@ def test_gates_story_matches_on_key_boundaries(token, story_key, gated): ("reason: an entry naming a 'HARD GATE: before X'", False), ("reason: «HARD GATE: before X» is only prose", False), ("reason: this HARD GATE is textual only, nothing enforces it", False), + # A ledger is markdown, so the backtick is the citation form an author + # reaches for first, and an LLM-written entry curls its quotes. Both used + # to warn, so an entry documenting the convention accused itself. + ("reason: a `HARD GATE:` is prose only", False), + ("reason: cites “HARD GATE: before X” only", False), + ("reason: cites ‘HARD GATE: before X’ only", False), # KNOWN LIMIT, pinned rather than left to surprise someone: the lookbehind # is one character wide, so a citation that spaces its opening quote off # the phrase — the French convention, `«` + U+00A0 — still reads as a # declaration. The remedy for such an entry is a `gate:` line, which # silences the warning either way. - ("reason: « HARD GATE: before X » is only prose", True), + ("reason: «\u00a0HARD GATE: before X\u00a0» is only prose", True), ], ) def test_hard_gate_prose_detects_a_declaration_not_a_citation(body, declared): From 75508028be610dbb775ce790307a37e1f7dac0e1 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 00:17:14 -0700 Subject: [PATCH 07/34] test(deferred): make the word-id false-refusal guard actually bite 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. --- tests/test_cli.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index c9474a00..d82441a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4146,18 +4146,22 @@ def test_validate_warns_on_an_empty_gate_line_beside_an_enforced_one(project, ca def test_validate_does_not_gate_a_word_id_that_merely_shares_a_prefix(project, capsys): """`stories.ID_RE` admits word ids, and the split-story arm used to read the `z` of `authz-login` as a split letter — FAILING validate for a story nobody gated. - A false refusal wedges a run, which is worse than the prose gate it replaced.""" - findings = _validate_gated_sprint( - project, - capsys, - {"3-2-invite-link": "ready-for-dev"}, - {"DW-1": ("open", ["gate: auth"])}, - ) + A false refusal wedges a run, which is worse than the prose gate it replaced. - assert not [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] - assert not [ - f for f in findings if f["check"] == "deferred.hard-gate" and f["severity"] == "problem" - ] + Stories mode deliberately: a sprint board cannot express this. `authz-login` + does not match `sprintstatus.STORY_RE`, so it never reaches `ss.stories`, and a + sprint fixture stays green with the digit guard ablated — which is exactly how + the first version of this test was written, and it measured nothing.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + _setup_stories_fixture(project, [_stories_entry("authz-login")]) + write_gated_ledger(project, {"DW-1": ("open", ["gate: auth"])}) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + findings = _hard_gate_findings(capsys) + assert [f["severity"] for f in findings] == ["ok"] def test_validate_silent_when_the_ledger_declares_no_gate(project, capsys): From 75f1f433daf2b72344680e9f7c95ab0d6660fa41 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 00:42:36 -0700 Subject: [PATCH 08/34] fix(validate): name the hard gate in the unreadable-ledger warning 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. --- src/bmad_loop/cli.py | 13 +++++++++++-- tests/test_cli.py | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 936b2554..b3d4ddf1 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1067,10 +1067,19 @@ def _validate_deferred_ledger( # 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). + # Severity is deliberately unchanged from when this read served only + # `closes_deferred`, but it is now load-bearing in a way it was not: the + # hard gate rides on the same bytes, so a warning lets `validate` exit 0 + # having evaluated no gate at all. That is a fail-open on the one deferred + # check that is a refusal. Escalating a pre-existing id from warning to + # problem is a user-visible change and belongs with the dispatch-side + # enforcement work, not with this parse-and-check addition — until then the + # message at least names what went unchecked instead of implying the gate ran. report.warn( "deferred.ledger-unreadable", - f"{ledger} cannot be read ({e}) — closes_deferred declarations were not " - "checked against it, and the run's own closure will fail the same way", + f"{ledger} cannot be read ({e}) — neither closes_deferred declarations nor " + "`gate:` hard gates were checked against it, so an open entry could be " + "gating an actionable story unseen; the run's own closure will fail the same way", {"ledger": str(ledger), "error": str(e)}, ) return diff --git a/tests/test_cli.py b/tests/test_cli.py index d82441a5..a82a6f17 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3848,8 +3848,13 @@ def test_validate_warns_when_the_ledger_itself_is_unreadable(project, capsys, mo assert len(findings) == 1 assert findings[0]["severity"] == "warning" # advisory: still never a gate assert findings[0]["detail"]["ledger"] == str(project.deferred_work) + # the same bytes now back the hard gate, so the message has to say the gate went + # unchecked too — a warning that names only closes_deferred reads as though the + # refusal had run and found nothing + assert "gate:" in findings[0]["message"] and "hard gates" in findings[0]["message"] # and the declaration checks it could not run stay quiet rather than guessing assert not [f for f in doc["findings"] if f["check"] == "deferred.closes-unknown"] + assert not [f for f in doc["findings"] if f["check"].startswith("deferred.hard-gate")] def test_validate_warns_on_a_malformed_closes_deferred_declaration(project, capsys): From 93207d1f5d20416560fa2a253d2ccbfa6f3a5693 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 01:20:31 -0700 Subject: [PATCH 09/34] feat(deferred): enforce hard gates at dispatch, and close three fail-opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 18 ++- README.md | 8 +- docs/FEATURES.md | 2 +- src/bmad_loop/cli.py | 94 ++++++++++---- .../bmad-loop-sweep/deferred-work-format.md | 45 ++++--- src/bmad_loop/deferredwork.py | 85 ++++++++++++- src/bmad_loop/engine.py | 96 +++++++++++++++ src/bmad_loop/model.py | 3 + tests/test_cli.py | 94 ++++++++++++-- tests/test_deferredwork.py | 75 ++++++++++++ tests/test_engine.py | 115 ++++++++++++++++++ tests/test_sweep.py | 44 +++++++ 12 files changed, 624 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a98e6479..31b1e3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,14 @@ whose seams had diverged enough that several ports needed a different fix, and t - **A deferred-work entry can block a story: `gate:`.** An entry that must land before specific stories run could only say so in prose (`HARD GATE: must land before 3-2`), and prose stopped nothing — `run` drove the story anyway. A `gate: 3-2, 3-3` line names the blocked story keys, and - while the entry is `open` `bmad-loop validate` fails (`deferred.hard-gate`) for every actionable - story a token matches, in both queue modes. The only deferred check that gates rather than - advises; cleared by closing the entry or dropping the token. A gate that can enforce nothing — - an unusable token, an empty `gate:` line, or a prose-only `HARD GATE:` — warns instead + it is enforced on both sides: `bmad-loop validate` fails (`deferred.hard-gate`) for every + actionable story a token matches, in both queue modes, and `run` pauses (`story-gate`) rather + than dispatch a gated story — so the refusal no longer depends on remembering to run the + preflight. The pause happens before the story is recorded, so closing the entry and resuming + runs it. Sweeps are exempt: they are what closes the gating entry. The only deferred check that + gates rather than advises; cleared by closing the entry or dropping the token. A gate that can + enforce nothing — an unusable token, an empty `gate:` line, a `gate:` not written lowercase at + the start of a line, or a prose-only `HARD GATE:` — warns instead (`deferred.hard-gate-unstructured`). Silent on a ledger that gates nothing, as before. - **Deferred review findings are harvested from spec frontmatter (#433).** BMAD-METHOD#2640 moved @@ -112,6 +116,12 @@ whose seams had diverged enough that several ports needed a different fix, and t ### Changed +- **An unreadable deferred-work ledger fails `validate` instead of warning + (`deferred.ledger-unreadable`).** The hard gate rides on the same bytes, so a warning exited 0 + with the gate never evaluated — a fail-open on the one deferred check that refuses, and one that + cannot be narrowed by asking whether the project uses gates, because the file that would answer + is the unreadable one. `run` pauses on the same fault, so preflight and dispatch now agree. + - **Every spec-frontmatter status read goes through `status_of` (#358 follow-up).** Five inline status reads remained in the engine and the generic adapter, each reading a blank `status:` as the token `none` — the defect #358 fixed at the shared reader. Three were neutral; the pair that was diff --git a/README.md b/README.md index 61fc1ecd..2024c4ef 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,13 @@ status: open gate: 3-2, 3-3 ``` -While that entry is `open`, `bmad-loop validate` **fails** for every actionable story a token matches. A token gates a story key when it is that key, or is its prefix at a key boundary — a `-`, or the split-story suffix (one lowercase letter then `-`). So `3-2` covers the sprint key `3-2-invite-link-student-surface`, the stories-mode id `3-2`, and both halves of a split (`3-2a-…`/`3-2b-…`), but never `3-20-later-story`; the split arm is there because breakdown can split a gated story after the gate was written, and a gate that quietly stops matching is worse than none. Closing the entry clears it, and so does dropping the token. This is the only deferred-work check that is a gate rather than an advisory: the `closes_deferred` checks above describe traceability that may be wrong and must never block a run, while this one describes work that must not start. +Until that entry lands, `bmad-loop validate` **fails** for every actionable story a token matches, and `run` **pauses** rather than dispatch one — the gate is enforced at dispatch as well as at preflight, so it no longer depends on remembering to run `validate` first. A token gates a story key when it is that key, or is its prefix at a key boundary — a `-`, or the split-story suffix (one lowercase letter then `-`). So `3-2` covers the sprint key `3-2-invite-link-student-surface`, the stories-mode id `3-2`, and both halves of a split (`3-2a-…`/`3-2b-…`), but never `3-20-later-story`; the split arm is there because breakdown can split a gated story after the gate was written, and a gate that quietly stops matching is worse than none. Closing the entry clears it, and so does dropping the token. This is the only deferred-work check that is a gate rather than an advisory: the `closes_deferred` checks above describe traceability that may be wrong and must never block a run, while this one describes work that must not start. -Three shapes declare a gate nothing can enforce, and each is a warning on an open entry: a token that cannot name a story key (including a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones); a `gate:` line with nothing usable after the colon; and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. +Only an explicit `status: done ` retires a gate. An entry whose status the format cannot read — `status: opne`, or no `status:` line at all — still gates, because an unreadable status is not evidence the work landed; letting it read as closed would have meant one keystroke silently disabling the refusal. + +The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story already in flight when a run resumes finishes rather than stranding a half-done session; the gate is about work that must not *start*. + +Four shapes declare a gate nothing can enforce, and each is a warning while the entry is unlanded: a token that cannot name a story key (a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones, or an unmatchable `gate: 3.2` — note `.` and `_` are fine inside a sprint slug, so `gate: 3-2-a_b` is a real gate); a `gate:` line with nothing usable after the colon; a `gate:` not written lowercase at the very start of a line (`Gate:`, or indented — surfaced rather than accepted, so a fenced example inside an entry cannot become a refusal); and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. > **Ledger outside the repo.** If `implementation_artifacts` is configured outside the project tree, the ledger is shared between worktrees and cannot be part of any commit. The annotation is written all the same, at the same moment, and the run journals `deferred-close-external-ledger` so its absence from git history is not a surprise. A location that cannot be read or written when the write comes due (a shared mount that has gone away) closes nothing and is journaled — those entries stay `open` for a sweep to re-verify, and an outage is never read as "no such entries". diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 00670b8b..70779485 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -112,7 +112,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Skills accumulate an append-only ledger (`deferred-work.md`, `DW-` entries): split-off goals, pre-existing findings, "needs human decision" items. - Story-declared closure (`closes_deferred: [DW-5, DW-6]`, human-authored on a `stories.yaml` entry or in a story spec's frontmatter — the two are unioned): when the story commits, each declared entry flips to `status: done ` + `resolution: resolved by story ` — the annotation a sweep bundle writes — so the ledger stops being one-way. Written at the commit boundary, so an in-repo ledger carries the annotation in the story's own commit and a story that fails, is rejected by review, or escalates closes nothing. Declared, never inferred from a diff; re-read at the commit so a declaration edited after implementation still counts; idempotent across a resume; an unknown id, an unreadable entry status or a non-list declaration in a story spec is journaled, never fatal, and `bmad-loop validate` warns about all of them before the run starts. (A non-list `closes_deferred` in `stories.yaml` is different: the manifest is a schema the parser owns, so it is refused outright, before the run.) An artifact dir outside the repo cannot be committed — the annotation is written anyway and journaled (`deferred-close-external-ledger`). -- Hard gates (`gate: 3-2, 3-3` on an entry): while the entry is `open`, `bmad-loop validate` fails (`deferred.hard-gate`) for every actionable story a token matches — a token gates a key it equals or prefixes at a key boundary (`-`, or a split-story suffix), so `3-2` covers `3-2-invite-link`, the stories-mode id `3-2` and both halves of a `3-2a`/`3-2b` split, but never `3-20-later`. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (`deferred.hard-gate-unstructured`) covers the three gates nothing can enforce: a token that cannot name a story key, an empty `gate:` line, and prose declaring `HARD GATE:` (matched mid-line, since `reason:` prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no `gate:` line. +- Hard gates (`gate: 3-2, 3-3` on an entry): until the entry lands, `bmad-loop validate` fails (`deferred.hard-gate`) for every actionable story a token matches and `run` pauses (`story-gate`) rather than dispatch one — a token gates a key it equals or prefixes at a key boundary (`-`, or a split-story suffix), so `3-2` covers `3-2-invite-link`, the stories-mode id `3-2` and both halves of a `3-2a`/`3-2b` split, but never `3-20-later`. Only an explicit `status: done` retires a gate; an unreadable status (`opne`, or no status line) still gates. The dispatch pause precedes the story's own run record, so closing the entry and resuming runs it; sweeps are exempt, since a sweep is what closes the gating entry. The only deferred check that gates rather than advises; cleared by closing the entry or dropping the token. A warning (`deferred.hard-gate-unstructured`) covers the four gates nothing can enforce: a token that cannot name a story key (`3-2 3-3`, or an unmatchable `3.2` — `.`/`_` are legal inside a sprint slug), an empty `gate:` line, a `gate:` not lowercase at the start of a line, and prose declaring `HARD GATE:` (matched mid-line, since `reason:` prose wraps — but not straight after a quote, so a citation stays silent) on an entry with no `gate:` line. - `bmad-loop sweep` triages every open entry against the actual code (ledger statuses treated as unreliable) → partition: already-resolved (auto-closed with evidence) / bundles / blocked / skip / decisions. - Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`. - Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index b3d4ddf1..ffa8d4a9 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1067,15 +1067,18 @@ def _validate_deferred_ledger( # 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). - # Severity is deliberately unchanged from when this read served only - # `closes_deferred`, but it is now load-bearing in a way it was not: the - # hard gate rides on the same bytes, so a warning lets `validate` exit 0 - # having evaluated no gate at all. That is a fail-open on the one deferred - # check that is a refusal. Escalating a pre-existing id from warning to - # problem is a user-visible change and belongs with the dispatch-side - # enforcement work, not with this parse-and-check addition — until then the - # message at least names what went unchecked instead of implying the gate ran. - report.warn( + # + # A problem rather than a warning, escalated from the severity this id + # carried while the read served only `closes_deferred`. The hard gate now + # rides on the same bytes, and a warning exits 0 having evaluated no gate + # at all — a fail-open on the one deferred check that is a refusal, and one + # that cannot be narrowed by asking whether the project uses gates, because + # the file that would answer is the unreadable one. `Engine._loop` refuses + # the same way for the same reason, so preflight and dispatch agree about + # this file instead of `validate` reporting a run that then pauses at its + # first story. Nothing is lost by failing early: the message's last clause + # is literal — the run's own closure reads this file too. + report.fail( "deferred.ledger-unreadable", f"{ledger} cannot be read ({e}) — neither closes_deferred declarations nor " "`gate:` hard gates were checked against it, so an open entry could be " @@ -1094,13 +1097,18 @@ def _validate_hard_gates( *, spec_folder: str | None = None, ) -> None: - """FAIL when the queue would dispatch a story an open ledger entry gates. + """FAIL when the queue would dispatch a story an unlanded ledger entry gates. + + The preflight half of a two-sided refusal: ``Engine._refuse_gated_story`` + enforces the same gate at dispatch, so a ``run`` that skipped ``validate`` + pauses instead of proceeding. This side exists to move the answer earlier — + the operator learns before the run starts, and learns about *every* gated + story on the queue rather than just the first one picked. - Preflight only, and the scope is worth stating plainly: this refuses at - ``bmad-loop validate``, not at dispatch. ``Engine._loop`` selects a story from - the board alone and never reads the ledger, so a ``run`` that skipped the - preflight is still unguarded. Wiring the same check into dispatch is follow-on - work; until it lands, the refusal is only as strong as the operator's habit. + The two must keep agreeing about what "unlanded" means (only an explicit + ``done`` retires a gate) and about an unreadable ledger (both refuse); a + ``validate`` that passed a run which then paused at its first story would + teach operators to trust neither. A ledger entry could always *say* it blocked a story — ``HARD GATE: must land before 3-2`` in its reason — and saying it stopped nothing. ``run`` took the @@ -1120,7 +1128,7 @@ def _validate_hard_gates( """ declared = [(entry, deferredwork.gates(entry)) for entry in deferredwork.parse_ledger(text)] for entry, entry_gates in declared: - if not entry.open: + if entry.done: continue # a landed entry gates nothing; that is what closing it means _report_unstructured_gate(entry, entry_gates, report) # Keyed on enforceable tokens, not on `gate:` lines: a ledger whose only gate is @@ -1137,10 +1145,15 @@ def _validate_hard_gates( # and `queue.stories-manifest` already fail for it; adding an `ok` here would # say "no story is gated" about a queue this check never saw. return - open_gated = [e.id for e, g in declared if e.open and g.tokens] + gating = [e.id for e, g in declared if not e.done and g.tokens] gated = False for entry, entry_gates in declared: - if not entry.open: + # `done`, not `not open` — the tri-state is the whole point. An entry whose + # status the format cannot read (`status: opne`, or no status line) is not + # evidence the work landed, and skipping it let one typo disable the gate + # *and* emit an `ok` naming the entry as clear. Only an explicit `done` + # retires a gate; everything else holds until someone writes that word. + if entry.done: continue for story_key in story_keys: hits = [t for t in entry_gates.tokens if deferredwork.gates_story(t, story_key)] @@ -1149,10 +1162,11 @@ def _validate_hard_gates( gated = True report.fail( "deferred.hard-gate", - f"{entry.id} ({entry.title}) is open and gates {story_key} " - f"(gate: {', '.join(hits)}) — that story must not run until the entry " - f"lands. Close it in {paths.deferred_work.name} (`status: done `), " - f"or drop the token from its `gate:` line if it no longer blocks this work", + f"{entry.id} ({entry.title}) {_gate_status_clause(entry)} and gates " + f"{story_key} (gate: {', '.join(hits)}) — that story must not run until " + f"the entry lands. Close it in {paths.deferred_work.name} " + f"(`status: done `), or drop the token from its `gate:` line if it " + f"no longer blocks this work", { "dw_id": entry.id, "title": entry.title, @@ -1163,12 +1177,28 @@ def _validate_hard_gates( if not gated: report.ok( "deferred.hard-gate", - f"deferred-work gates OK: no actionable story is gated by an open entry " - f"({', '.join(open_gated) if open_gated else 'no open gated entries'})", - {"open_gated_ids": open_gated, "actionable": list(story_keys)}, + f"deferred-work gates OK: no actionable story is gated by an unlanded entry " + f"({', '.join(gating) if gating else 'no unlanded gated entries'})", + {"gating_ids": gating, "actionable": list(story_keys)}, ) +def _gate_status_clause(entry: deferredwork.DWEntry) -> str: + """How the failure names *why* this entry still gates. + + An unreadable status is reported as what it is rather than folded into "is + open": the remedy differs — the operator with a typo fixes the `status:` line, + and telling them the entry "is open" sends them to close work that may already + have landed. Naming the offending value is what makes a one-character typo + findable in a ledger of fifty entries. + """ + if entry.open: + return "is open" + if not entry.status: + return "has no `status:` line, so it cannot be read as landed" + return f"has an unreadable status (`{entry.status}`), so it cannot be read as landed" + + def _report_unstructured_gate( entry: deferredwork.DWEntry, entry_gates: deferredwork.EntryGates, @@ -1189,6 +1219,14 @@ def _report_unstructured_gate( is exactly the thing that goes wrong quietly. That applies to an empty line as much as to a malformed token — ``gate: 3-2`` followed by a bare ``gate:`` used to report neither, because the entry had tokens and so read as fully gated. + + A fourth cause, and the only one that is about a line the parser never saw: a + ``gate:`` the strict field anchor misses (``Gate:``, or indented). It is a + warning rather than an accepted gate on purpose — see :data:`_GATE_NEAR_RE`. + + Runs for every entry that is not ``done``, which is the same set the refusal + holds against. Keying it on ``open`` instead would have left an entry with an + unreadable status silent about a malformed token as well as about its gate. """ reasons: list[str] = [] if entry_gates.malformed: @@ -1199,6 +1237,11 @@ def _report_unstructured_gate( reasons.append("declares an empty `gate:` line, which names no story") elif entry_gates.empty: reasons.append(f"declares {entry_gates.empty} empty `gate:` lines, which name no story") + if entry_gates.near_miss: + reasons.append( + f"spells {entry_gates.near_miss} `gate:` line(s) in a form the field does not " + f"read (the field is a lowercase `gate:` at the very start of a line)" + ) prose_only = not entry_gates.tokens and not reasons if prose_only and deferredwork.HARD_GATE_PROSE_RE.search(entry.body): reasons.append("declares a `HARD GATE:` in prose but carries no `gate:` line") @@ -1213,6 +1256,7 @@ def _report_unstructured_gate( "dw_id": entry.id, "malformed": list(entry_gates.malformed), "empty": entry_gates.empty, + "near_miss": entry_gates.near_miss, }, ) diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 98e4ccc5..912859d9 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -102,23 +102,40 @@ 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. -While the entry is `open`, `bmad-loop validate` **fails** (`deferred.hard-gate`) -for every actionable story a token matches — sprint-status stories at `backlog` / -`ready-for-dev`, or manifest entries whose spec is not yet `done`. Two things -clear it: closing the entry (`status: done `), or removing the token -because it no longer blocks that work. This is the one deferred-work check that -gates rather than advises: everything else here is traceability that may be -wrong, while this is work that must not start. - -Three shapes declare a gate that nothing can enforce, and all three are reported -as `deferred.hard-gate-unstructured` on an open entry: - -- a token that does not look like a story key (`[A-Za-z0-9][A-Za-z0-9._-]*`, no - spaces) — note that this makes a space-separated `gate: 3-2 3-3` one bad token - rather than two good ones; +Until the entry lands, the gate is enforced twice. `bmad-loop validate` **fails** +(`deferred.hard-gate`) for every actionable story a token matches — sprint-status +stories at `backlog` / `ready-for-dev`, or manifest entries whose spec is not yet +`done` — and a `run` that never called `validate` **pauses** (`story-gate`) rather +than dispatch a gated story. Two things clear it: closing the entry +(`status: done `), or removing the token because it no longer blocks that +work. This is the one deferred-work check that gates rather than advises: +everything else here is traceability that may be wrong, while this is work that +must not start. + +**Only an explicit `done` retires a gate.** A status the format cannot read — +`status: opne`, or an entry with no `status:` line — is not evidence the work +landed, so the gate still holds. Write the status word exactly. + +A sweep is never gated by the ledger it is draining, whatever any entry's `gate:` +says: closing the gating entry is what a sweep is for, so gating it would +deadlock the gate against its own remedy. + +Four shapes declare a gate that nothing can enforce, and all four are reported as +`deferred.hard-gate-unstructured` while the entry is unlanded: + +- a token nothing can match. It must look like a story key + (`[A-Za-z0-9][A-Za-z0-9._-]*`, no spaces) **and** be a shape a key can actually + take — alphanumeric segments joined by `-`, or a full sprint key. So a + space-separated `gate: 3-2 3-3` is one bad token rather than two good ones, and + `gate: 3.2` / `gate: 3_2` are rejected: no key spells its numbers that way. + Inside a sprint slug those characters are fine — `gate: 3-2-a_b` is a real gate; - a `gate:` line with nothing usable after the colon (`gate:`, `gate: ,`) — each such line is reported, including one sitting beside a line that does name a story, since the half that names nothing is the half you are wrong about; +- a `gate:` that is not lowercase at the very start of its line — `Gate: 3-2`, or + an indented ` gate: 3-2`. These are reported rather than read as declarations, + because accepting an indented one would turn a fenced example quoted inside an + entry into a refusal of a story nobody meant to block; - prose declaring `HARD GATE:` — the convention that predates this field — anywhere on a line of an entry that carries no `gate:` line. It is matched mid-line because `reason:` prose is hard-wrapped, but never directly after a diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 0388ef0f..ff3a6f8e 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -18,6 +18,7 @@ from datetime import date as calendar_date from pathlib import Path +from . import sprintstatus from .platform_util import atomic_write_text HEADING_RE = re.compile(r"^### (DW-\d+): (.+?)\s*$", re.MULTILINE) @@ -47,6 +48,39 @@ # 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. GATE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +# The second half of "can this token gate anything", and a different miss from the +# one above: `GATE_TOKEN_RE` rejects the spellings a *line* cannot carry (a space, +# a bare separator), this rejects the ones no *key* can carry. `gate: 3.2` passes +# the first and can never match `gates_story` against any legal key, so it used to +# report a green `ok` while gating nothing — the field's own silent no-op, one +# keystroke away from the shape that works. +# +# Two arms, because BMAD spells a story key two ways and they are NOT +# interchangeable. A stories-mode id is alphanumeric segments joined by single +# dashes (`_STORIES_ID_RE`), so `3.2` and `3_2` are out. A sprint key's slug is +# unconstrained (`sprintstatus.STORY_RE`'s trailing group), so `3-2-foo.bar` and +# `3-2-a_b` are LEGAL keys that gate correctly — which is why this is a +# whole-token shape test and not a ban on `.`/`_`. Only those characters in the +# *number* prefix are unmatchable; banning them outright would refuse real gates. +# +# Sound in the direction that matters: a token matching either arm is itself a +# legal key, so a story it could gate can exist. `gates_story`'s prefix and split +# arms only ever extend a key rightward past a `-`, and every such prefix of a +# legal key matches one of these arms too. +# +# `sprintstatus` is imported for its regex; `stories.ID_RE` is copied rather than +# imported because `stories` imports *this* module (a cycle). The copy is pinned +# to the original by a drift test rather than to a comment. +_STORIES_ID_RE = re.compile(r"^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$") +# A `gate:` line the strict field pattern above will never see. `GATE_RE` is +# anchored to a lowercase `gate:` in column 0, exactly like `status:`, and that +# strictness fails in opposite directions for the two fields: a missed `status:` +# leaves an entry unresolved, which now gates conservatively, while a missed +# `gate:` leaves no gate at all. `Gate: 3-2` and an indented ` gate: 3-2` are +# therefore surfaced as unenforceable rather than silently absent — and surfaced +# rather than *accepted*, because accepting an indented line would read a fenced +# example inside an entry as a live gate and refuse a story nobody meant to block. +_GATE_NEAR_RE = re.compile(r"^[ \t]*gate[ \t]*:", re.IGNORECASE | re.MULTILINE) # The prose convention `gate:` replaces, matched anywhere on a line rather than # at its start: real ledgers hard-wrap their `reason:` prose, so the declaration # routinely lands mid-line and a line-anchored pattern misses exactly the entries @@ -87,6 +121,20 @@ class DWEntry: def open(self) -> bool: return self.status.split()[0] == "open" if self.status else False + @property + def done(self) -> bool: + """Whether the entry has landed. + + Deliberately NOT ``not open``. A status line the format does not + understand — ``status: opne``, or no status line at all — is neither open + nor done, and the readers that ask want *opposite* answers about it: + :func:`open_ids` drops it (it may already be finished), while a gate on it + has to hold (it may not be). Deriving one from the other is what let + ``gate:`` fail open on a one-character typo — the entry read as closed, so + the gate was skipped and ``validate`` reported an all-clear naming it. + """ + return self.status.split()[0] == "done" if self.status else False + def parse_ledger(text: str) -> list[DWEntry]: """Extract DW entries; non-conforming sections are skipped, an entry @@ -153,6 +201,7 @@ class EntryGates: malformed: tuple[str, ...] = () lines: int = 0 empty: int = 0 + near_miss: int = 0 @property def inert(self) -> bool: @@ -173,6 +222,12 @@ def gates(entry: DWEntry) -> EntryGates: Duplicates collapse (an id repeated across lines is one claim, not two); empty items drop, so a trailing separator is not a token — but the *line* is still counted, which is how an all-empty declaration stays reportable. + + ``near_miss`` counts the lines this function deliberately did NOT read as a + declaration: a `gate:` the strict field anchor misses (see + :data:`_GATE_NEAR_RE`). They are counted rather than parsed so the operator is + told the spelling gated nothing — the same trade the space-separated token + makes, one level up. """ tokens: list[str] = [] malformed: list[str] = [] @@ -186,12 +241,38 @@ def gates(entry: DWEntry) -> EntryGates: if not token: continue named = True - bucket = tokens if GATE_TOKEN_RE.match(token) else malformed + bucket = tokens if matchable_token(token) else malformed if token not in bucket: bucket.append(token) if not named: empty += 1 - return EntryGates(tokens=tuple(tokens), malformed=tuple(malformed), lines=lines, empty=empty) + near_miss = sum( + # `^` puts every match at a line start, so this asks whether the same line + # would have satisfied `GATE_RE` — i.e. whether it is the canonical spelling + # already counted above — without re-running the anchor against a slice. + not entry.body.startswith("gate:", m.start()) + for m in _GATE_NEAR_RE.finditer(entry.body) + ) + return EntryGates( + tokens=tuple(tokens), + malformed=tuple(malformed), + lines=lines, + empty=empty, + near_miss=near_miss, + ) + + +def matchable_token(token: str) -> bool: + """Whether ``token`` could gate any legal story key — the test that decides + :attr:`EntryGates.tokens` vs :attr:`EntryGates.malformed`. + + Both halves are required and neither implies the other: ``GATE_TOKEN_RE`` + alone admits ``3.2``, which nothing can match, and the key shapes alone admit + ``3-2 3-3`` via the sprint slug, which is one token pretending to be two. + """ + if not GATE_TOKEN_RE.match(token): + return False + return bool(_STORIES_ID_RE.match(token) or sprintstatus.STORY_RE.match(token)) def gates_story(token: str, story_key: str) -> bool: diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index a3726720..7cfb6901 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -42,6 +42,7 @@ PAUSE_EPIC_BOUNDARY, PAUSE_ESCALATION, PAUSE_SPEC_APPROVAL, + PAUSE_STORY_GATE, Phase, RunState, SessionRecord, @@ -832,6 +833,10 @@ def _loop(self) -> None: if story is None: self._maybe_auto_sweep("run-end", "run-end") return + # 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) if self.state.current_epic is not None and story.epic != self.state.current_epic: self._epic_boundary(self.state.current_epic, story.epic) self.state.current_epic = story.epic @@ -853,6 +858,97 @@ def _dispatched_count(self) -> int: resume would reset the counter and let the run dispatch past its cap.""" return len(self.state.tasks) + def _refuse_gated_story(self, story_key: str) -> None: + """Pause the run rather than dispatch a story an unlanded ledger entry gates. + + The enforcing half of ``gate:``. ``bmad-loop validate`` refuses the same + story at preflight, but a preflight is only as strong as the operator's + habit — ``run`` never called it, and ``_pick_next`` reads the board alone, + so before this the field's whole promise rested on someone remembering to + type a second command. + + **Placement is load-bearing.** Called from ``_loop`` before + ``state.tasks[key] = task``, so the gated story is *not* recorded as + touched by this run. That is what makes the refusal re-askable: a resume + re-picks the same story and re-reads the ledger, so closing the entry and + resuming runs it. Registering the task first — the obvious placement, next + to ``_run_story`` — would put the key in ``_pick_next``'s ``base_skip``, + and the gate would fire once and then silently retire the story for the + rest of the run and every resume of it. A gate that drops the work it was + protecting is worse than no gate. + + **Pause, not skip.** ``validate`` fails the whole preflight over one gated + story, and the two surfaces have to agree or the operator learns to + distrust both. It raises the reserved :data:`PAUSE_STORY_GATE` stage, which + the TUI already renders and routes to its gate viewer. + + The ledger is re-read here rather than carried from preflight: a sweep (or + a human) may have closed the entry since, and a gate answering from a stale + snapshot would refuse work that has landed. + + **Unreadable ledger pauses too.** Degrading to "not gated" would let the + one deferred check that is a refusal be disabled by a broken file, and the + question "does this project use gates?" is answerable only from the file + that will not open. ``deferred.ledger-unreadable`` is a ``validate`` + problem for the same reason. + + Two exemptions, both deliberate. ``SweepEngine`` overrides ``_loop`` and so + never reaches this call — it must not, because the sweep is the only + automated closer of the gating entry (``sweep.py`` `_close_resolved` / + bundle close), and gating the sweep would deadlock the gate against its own + remedy. ``_finish_inflight`` runs before the loop, so a story already + in-flight when the gate appeared finishes rather than stranding a half-done + session with a live worktree; the gate applies to work that must not + *start*, which is the same line ``validate`` draws when it passes a story + the board has already finished. + """ + ledger = self.paths.deferred_work + try: + text = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + except (OSError, UnicodeDecodeError) as e: + self.journal.append("story-gate-unreadable", story_key=story_key, error=str(e)) + reason = ( + f"{ledger} cannot be read ({e}), so the `gate:` hard gates protecting " + f"{story_key} could not be evaluated — fix the file, then " + f"`bmad-loop resume {self.state.run_id}`" + ) + gates.notify(self.policy, self.run_dir, f"story gated: {story_key}", reason) + raise RunPaused(reason, PAUSE_STORY_GATE, story_key) from e + blocking = [ + (entry.id, hits) + for entry, hits in ( + ( + entry, + [ + token + for token in deferredwork.gates(entry).tokens + if deferredwork.gates_story(token, story_key) + ], + ) + # `done`, not `not open`: an entry whose status the format cannot + # read is not evidence the work landed, and reading it as closed + # would let a one-character typo disable the gate. + for entry in deferredwork.parse_ledger(text) + if not entry.done + ) + if hits + ] + if not blocking: + return + named = ", ".join(f"{dw_id} (gate: {', '.join(hits)})" for dw_id, hits in blocking) + reason = ( + f"{story_key} is gated by unlanded deferred work: {named} — close the " + f"entry in {ledger.name} (`status: done `) or run `bmad-loop sweep`, " + f"then `bmad-loop resume {self.state.run_id}`" + ) + self.journal.append( + "story-gated", + story_key=story_key, + dw_ids=[dw_id for dw_id, _ in blocking], + ) + gates.notify(self.policy, self.run_dir, f"story gated: {story_key}", reason) + raise RunPaused(reason, PAUSE_STORY_GATE, story_key) + def _pick_next(self): ss = load_sprint_status(self.paths.sprint_status) if ss.unknown_keys: diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index 7f0ebbb3..a62d62b0 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -52,6 +52,9 @@ class Phase(StrEnum): PAUSE_SPEC_APPROVAL = "spec-approval" PAUSE_EPIC_BOUNDARY = "epic-boundary" PAUSE_ESCALATION = "escalation" +# Raised by Engine._refuse_gated_story: the picked story is named by the `gate:` +# line of a deferred-work entry that has not landed. Produced before the story is +# recorded in state.tasks, so a resume re-picks it and re-reads the ledger. PAUSE_STORY_GATE = "story-gate" # stories-mode HITL checkpoints (independent per story). PLAN fires after a # spec_checkpoint story's plan-halt leg (ready-for-dev, awaiting human plan diff --git a/tests/test_cli.py b/tests/test_cli.py index a82a6f17..b4d78271 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3826,13 +3826,20 @@ def test_validate_warns_on_unknown_closes_deferred_in_sprint_mode(project, capsy assert findings[0]["detail"] == {"source": "spec spec-1-1-a.md", "unknown_ids": ["DW-99"]} -def test_validate_warns_when_the_ledger_itself_is_unreadable(project, capsys, monkeypatch): +def test_validate_fails_when_the_ledger_itself_is_unreadable(project, capsys, monkeypatch): """The ledger read shared a `try` with the manifest read, and that arm returns silently — correctly for the manifest, which `queue.stories-manifest` already reports, but nothing else in `validate` reads the ledger. So an unreadable one produced no finding at all: preflight reported success for a check that examined nothing, against the very file the run's closure will fail on - (#284 round-5 review, finding 6).""" + (#284 round-5 review, finding 6). + + A problem, not a warning. A warning exits 0 with the hard gate never evaluated, + which is a fail-open on the one deferred check that refuses — and it cannot be + narrowed by asking whether the project gates anything, because the file that + would answer is the unreadable one. `Engine._refuse_gated_story` pauses on the + same fault, and the two surfaces have to give the same verdict about the same + file.""" install_bmad_config(project) _write_policy(project.project) write_sprint(project, {"1-1-a": "ready-for-dev"}) @@ -3846,7 +3853,7 @@ def test_validate_warns_when_the_ledger_itself_is_unreadable(project, capsys, mo doc = json.loads(capsys.readouterr().out) findings = [f for f in doc["findings"] if f["check"] == "deferred.ledger-unreadable"] assert len(findings) == 1 - assert findings[0]["severity"] == "warning" # advisory: still never a gate + assert findings[0]["severity"] == "problem" # the gate rode on these bytes assert findings[0]["detail"]["ledger"] == str(project.deferred_work) # the same bytes now back the hard gate, so the message has to say the gate went # unchecked too — a warning that names only closes_deferred reads as though the @@ -3968,7 +3975,7 @@ def test_validate_passes_when_the_gated_story_is_already_done(project, capsys): gates = [f for f in findings if f["check"] == "deferred.hard-gate"] assert len(gates) == 1 and gates[0]["severity"] == "ok" - assert gates[0]["detail"] == {"open_gated_ids": ["DW-1"], "actionable": []} + assert gates[0]["detail"] == {"gating_ids": ["DW-1"], "actionable": []} def test_validate_passes_when_the_gate_entry_is_closed(project, capsys): @@ -3983,7 +3990,7 @@ def test_validate_passes_when_the_gate_entry_is_closed(project, capsys): gates = [f for f in findings if f["check"] == "deferred.hard-gate"] assert len(gates) == 1 and gates[0]["severity"] == "ok" - assert gates[0]["detail"]["open_gated_ids"] == [] + assert gates[0]["detail"]["gating_ids"] == [] def test_validate_ignores_an_unstructured_gate_on_a_closed_entry(project, capsys): @@ -4004,6 +4011,69 @@ def test_validate_ignores_an_unstructured_gate_on_a_closed_entry(project, capsys assert not [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] +@pytest.mark.parametrize("status", ["opne", "opened", "in progress", ""]) +def test_validate_hard_gate_holds_on_a_status_the_format_cannot_read(project, capsys, status): + """`entry.open` is False for a typo'd status, so the entry was skipped — the + gate silently did not apply, AND the check went on to emit an `ok` naming the + board as clear. One character disabled the refusal and replaced it with an + all-clear, which is the exact silent miss `gate:` exists to end. + + Only an explicit `done` retires a gate; a status the format cannot read is not + evidence the work landed.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": (status, ["gate: 3-2"])}, + ) + + gates = [f for f in findings if f["check"] == "deferred.hard-gate"] + assert len(gates) == 1 and gates[0]["severity"] == "problem" + assert gates[0]["detail"]["story_key"] == "3-2-invite-link" + # and the message sends the operator to the `status:` line rather than telling + # them to close work that may already have landed + assert "cannot be read as landed" in gates[0]["message"] + if status: + assert f"`{status}`" in gates[0]["message"] + + +def test_validate_warns_on_a_gate_line_the_field_anchor_cannot_read(project, capsys): + """`Gate: 3-2` and an indented ` gate: 3-2` produced no finding of any kind — + the field failing open, where a missed `status:` fails closed. Surfaced rather + than accepted: reading an indented line as a declaration would turn a fenced + example inside an entry into a refusal of a story nobody meant to block.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["Gate: 3-2", " gate: 3-3"])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" + assert unstructured[0]["detail"]["near_miss"] == 2 + assert "the very start of a line" in unstructured[0]["message"] + # nothing enforceable was declared, so there is no passing gate to report either + assert not [f for f in findings if f["check"] == "deferred.hard-gate"] + + +def test_validate_does_not_report_an_all_clear_for_an_unmatchable_token(project, capsys): + """`gate: 3.2` is one keystroke from the shape that works and can never match + any key. It used to land in `tokens`, which made the ledger "gated", and the + check then reported a green `ok` — an all-clear earned by a gate that held + nothing. It is a malformed token now, so the operator is told.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["gate: 3.2"])}, + ) + + unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + assert len(unstructured) == 1 and unstructured[0]["detail"]["malformed"] == ["3.2"] + assert not [f for f in findings if f["check"] == "deferred.hard-gate"] + + def test_validate_hard_gate_token_stops_at_the_key_boundary(project, capsys): """`3-2` gates the story it names and both halves of that story once breakdown splits it, and not its numeric neighbours. A bare `startswith` would sweep @@ -4052,7 +4122,12 @@ def test_validate_warns_on_a_prose_only_hard_gate(project, capsys): unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" - assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": [], "empty": 0} + assert unstructured[0]["detail"] == { + "dw_id": "DW-1", + "malformed": [], + "empty": 0, + "near_miss": 0, + } assert "`gate:` line" in unstructured[0]["message"] # nothing enforceable exists, so there is no passing gate to report either assert not [f for f in findings if f["check"] == "deferred.hard-gate"] @@ -4122,7 +4197,12 @@ def test_validate_warns_on_a_malformed_gate_token(project, capsys): unstructured = [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" - assert unstructured[0]["detail"] == {"dw_id": "DW-1", "malformed": ["3-2 3-3"], "empty": 0} + assert unstructured[0]["detail"] == { + "dw_id": "DW-1", + "malformed": ["3-2 3-3"], + "empty": 0, + "near_miss": 0, + } # and it is NOT reported as an enforced gate: nothing matched assert not [f for f in findings if f["check"] == "deferred.hard-gate"] diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 4ddf4512..53a52d2c 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1588,6 +1588,81 @@ def test_gates_reports_a_token_that_cannot_name_a_story(): assert g.malformed == ("3-2 3-3", "../etc") +def test_gate_token_shape_copy_agrees_with_the_stories_id_it_mirrors(): + """`_STORIES_ID_RE` is a copy of `stories.ID_RE`, taken because `stories` + imports this module and the reverse would cycle. Pinned to the original rather + than to a comment: if the manifest ever admits a new id shape, a gate on one + would start reporting `malformed` and refuse nothing.""" + from bmad_loop import stories + + assert deferredwork._STORIES_ID_RE.pattern == stories.ID_RE.pattern + + +@pytest.mark.parametrize("token", ["3.2", "3_2", "3.2-invite", "3_2-invite"]) +def test_gates_reject_a_token_no_story_key_can_carry(token): + """Shape-valid and unmatchable. `GATE_TOKEN_RE` admits `.` and `_` because a + sprint slug may contain them, so `gate: 3.2` used to land in `tokens` — where + it matched nothing, gated nothing, and reported a green `ok` for doing so. + A `.`/`_` in the *number* prefix is what no legal key can carry.""" + g = _gated(f"gate: {token}") + + assert g.tokens == () + assert g.malformed == (token,) + + +@pytest.mark.parametrize("token", ["3-2-foo.bar", "3-2-a_b", "authz-login", "3"]) +def test_gates_keep_a_token_a_sprint_slug_can_actually_spell(token): + """The trap in the fix above: `sprintstatus.STORY_RE`'s slug is unconstrained, + so `3-2-foo.bar` and `3-2-a_b` are LEGAL keys that gate correctly. Banning `.` + and `_` outright — the obvious reading of "reject 3.2" — would refuse real + gates, turning a fail-open into a false refusal.""" + g = _gated(f"gate: {token}") + + assert g.tokens == (token,) + assert g.malformed == () + + +@pytest.mark.parametrize("line", ["Gate: 3-2", "GATE: 3-2", " gate: 3-2", "\tgate: 3-2"]) +def test_gates_count_a_line_the_field_anchor_will_never_read(line): + """`GATE_RE` is a lowercase `gate:` in column 0. Every other spelling produced + ZERO findings — no gate, no warning, nothing — which is the field failing open, + where a missed `status:` now fails closed. Counted, not parsed: accepting an + indented line would read a fenced example inside an entry as a live gate.""" + g = _gated(line) + + assert g.tokens == () # deliberately NOT enforced... + assert g.near_miss == 1 # ...but no longer silent + assert g.lines == 0 + + +def test_gates_do_not_count_the_canonical_spelling_as_a_near_miss(): + """The near-miss pattern is a superset of the field pattern, so the canonical + line matches both. Counting it would warn about every gate that works.""" + g = _gated("gate: 3-2") + + assert g.tokens == ("3-2",) and g.near_miss == 0 + + +@pytest.mark.parametrize( + ("status", "is_open", "is_done"), + [ + ("open", True, False), + ("done 2026-08-01", False, True), + ("opne", False, False), # a typo is neither, and must not read as landed + ("", False, False), # no status line at all + ], +) +def test_entry_status_is_a_tri_state_not_a_boolean(status, is_open, is_done): + """`done` is deliberately not `not open`. The readers want opposite answers + about an unreadable status — `open_ids` drops it, a gate on it has to hold — + and deriving one from the other let `status: opne` disable a gate silently.""" + line = f"status: {status}\n" if status else "" + (entry,) = parse_ledger(f"# DW\n\n### DW-1: t\n\norigin: t\nreason: t\n{line}") + + assert entry.open is is_open + assert entry.done is is_done + + def test_gates_stop_at_the_canonical_span_boundary(): """A `gate:` line below a flat-append bullet belongs to that block, not to the entry above it — the same boundary `status:` is read within. Absorbing it would diff --git a/tests/test_engine.py b/tests/test_engine.py index 790a0d43..0ef57dd7 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -40,6 +40,7 @@ PAUSE_EPIC_BOUNDARY, PAUSE_ESCALATION, PAUSE_SPEC_APPROVAL, + PAUSE_STORY_GATE, Phase, RunState, SessionRecord, @@ -6451,6 +6452,120 @@ def test_critical_escalation_pauses_and_resume_continues(project): assert resumed.state.finished +def write_gated_ledger(paths, entries, commit=True) -> None: + """`write_ledger` plus the `gate:` lines a hard gate is written on: `entries` + maps a DW id to `(status, extra_field_lines)`, appended verbatim after + `status:`. Committed by default — the engine's own paths assume a clean tree.""" + parts = ["# Deferred Work\n"] + for dw_id, (status, extra) in entries.items(): + tail = "".join(f"{line}\n" for line in extra) + parts.append( + f"### {dw_id}: item {dw_id}\n\norigin: test, 2026-06-01\n" + f"location: src.txt:1\nreason: test entry.\nstatus: {status}\n{tail}" + ) + paths.deferred_work.write_text("\n".join(parts), encoding="utf-8") + if commit: + git(paths.project, "add", "-A") + git(paths.project, "commit", "-q", "-m", "ledger") + + +def test_dispatch_refuses_a_story_an_unlanded_entry_gates(project): + """The enforcing half of `gate:`. Before this, `_pick_next` read the board + alone: the ledger could say a story was blocked and `run` drove it anyway, and + the gate was discovered afterwards in the diff of work built on a leg nobody + had wired. `validate` refuses the same story, but only if someone ran it.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + engine, adapter = make_engine(project, [dev_effect(project, "1-1-a")]) + + summary = engine.run() + + assert summary.paused + saved = load_state(engine.run_dir) + assert saved.paused_stage == PAUSE_STORY_GATE + assert saved.paused_story_key == "1-1-a" + # the refusal has to precede the work, not follow it + assert adapter.sessions == [] + # ...and precede the *record* of the work: the story is deliberately NOT in + # state.tasks, which is what `_pick_next`'s base_skip keys on. Registering it + # first would fire the gate once and then retire the story for this run and + # every resume of it — a gate that drops the work it was protecting. + assert saved.tasks == {} + events = [e for e in engine.journal.entries() if e["kind"] == "story-gated"] + assert len(events) == 1 and events[0]["dw_ids"] == ["DW-1"] + assert "DW-1" in saved.paused_reason and "bmad-loop sweep" in saved.paused_reason + + +def test_a_gated_story_runs_once_the_entry_lands(project): + """The other half of the placement above: because the pause left no task + behind, a resume re-picks the story and re-reads the ledger. Closing the entry + is the primary remedy the pause names, so it has to be the one that clears + it — a gate nobody can get past is a wedge, not a gate.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + engine, _ = make_engine(project, []) + assert engine.run().paused + + write_gated_ledger(project, {"DW-1": ("done 2026-08-01", ["gate: 1-1"])}) + resumed, _ = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = resumed.run() + + assert summary.done == 1 and not summary.paused + + +def test_dispatch_gate_holds_on_a_status_the_format_cannot_read(project): + """`status: opne` is not evidence the work landed. The check keys on an + explicit `done` rather than on `not open` precisely so a one-character typo + cannot disable the gate — the silent no-op the whole field exists to end.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_gated_ledger(project, {"DW-1": ("opne", ["gate: 1-1"])}) + engine, adapter = make_engine(project, [dev_effect(project, "1-1-a")]) + + summary = engine.run() + + assert summary.paused + assert load_state(engine.run_dir).paused_stage == PAUSE_STORY_GATE + assert adapter.sessions == [] + + +def test_dispatch_gate_does_not_fire_for_a_story_it_does_not_name(project): + """A false refusal wedges a run, which is worse than the prose gate this + replaced. An entry gating 2-1 must let 1-1-a through untouched.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 2-1"])}) + engine, _ = make_engine( + project, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + + summary = engine.run() + + assert summary.done == 1 and not summary.paused + + +def test_dispatch_pauses_when_the_ledger_cannot_be_read(project, monkeypatch): + """Degrading to "not gated" would let a broken file disable the one deferred + check that refuses, and "does this project use gates?" is answerable only from + the file that will not open. `validate` reports the same fault as a problem.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 9-9"])}) + engine, adapter = make_engine(project, [dev_effect(project, "1-1-a")]) + fault_read_text(monkeypatch, project.deferred_work) + + summary = engine.run() + + assert summary.paused + saved = load_state(engine.run_dir) + assert saved.paused_stage == PAUSE_STORY_GATE + assert adapter.sessions == [] + assert "cannot be read" in saved.paused_reason + assert [e["kind"] for e in engine.journal.entries()].count("story-gate-unreadable") == 1 + + def test_epic_boundary_gate_pause_and_resume(project): write_sprint( project, diff --git a/tests/test_sweep.py b/tests/test_sweep.py index f3020cff..7580fbce 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -965,6 +965,50 @@ def test_sweep_happy_path(project): assert "fix both" in intent and "DW-2" in intent and "### DW-3" in intent +def test_sweep_is_exempt_from_the_dispatch_hard_gate(project): + """The sweep must never be gated by the ledger it exists to drain. + + `Engine._refuse_gated_story` refuses a picked story named by an unlanded + `gate:` entry. `SweepEngine` overrides `_loop` and so never reaches that call — + exemption by omission, which is exactly the kind of thing a later refactor + "unifies" away. Gating the sweep would deadlock the gate against its own + remedy: closing DW-1 is what the pause tells the operator to run a sweep for, + and here DW-1 gates the sweep's own unit keys. + + Written behaviorally rather than as "the method was not called" so it also + fails if the refusal arrives by some other route. + """ + paths = project + paths.deferred_work.write_text( + "# Deferred Work\n\n" + "### DW-1: item DW-1\n\norigin: test, 2026-06-01\nlocation: src.txt:1\n" + "reason: test entry.\nstatus: open\ngate: sweep-triage, dw-fix-things\n", + encoding="utf-8", + ) + git(paths.project, "add", "-A") + git(paths.project, "commit", "-q", "-m", "ledger") + plan = triage_result( + ["DW-1"], + bundles=[{"name": "fix-things", "dw_ids": ["DW-1"], "intent": "fix it"}], + ) + engine, _ = make_sweep( + project, + [ + triage_effect(plan), + bundle_dev_effect(project, "fix-things", ["DW-1"]), + bundle_review_effect(project, "fix-things"), + ], + ) + + summary = engine.run() + + assert not summary.paused, "the sweep must not be gated by the ledger it drains" + assert engine.state.tasks["sweep-triage"].phase == Phase.DONE + assert engine.state.tasks["dw-fix-things"].phase == Phase.DONE + # and the gating entry is closed — the remedy the story-gate pause points at + assert ledger_entries(project)["DW-1"].status.startswith("done") + + def test_generic_skill_bundle_orchestrator_closes_ledger(project): """B4: on the generic bmad-dev-auto path the bundle session never edits the ledger; the orchestrator marks each owned dw id done only after the dev attempt From 891417ef4793af44e8b55c3479978af282fb8dcc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 01:23:38 -0700 Subject: [PATCH 10/34] test(deferred): pin that the dispatch gate survives a resume that fixed nothing --- tests/test_engine.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/test_engine.py b/tests/test_engine.py index 0ef57dd7..4d76d353 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6496,11 +6496,34 @@ def test_dispatch_refuses_a_story_an_unlanded_entry_gates(project): assert "DW-1" in saved.paused_reason and "bmad-loop sweep" in saved.paused_reason +def test_the_gate_still_holds_when_a_resume_has_not_closed_the_entry(project): + """A resume that fixed nothing must not get the story through. + + This is what the placement buys, stated as behavior. Recording the task before + the check — the obvious placement, next to `_run_story` — leaves a non-terminal + task behind, and `_finish_inflight` runs *before* the loop and drives exactly + those: the resume would dispatch the gated story without ever consulting the + ledger again. Refusing before the story is recorded is what makes the gate a + standing condition rather than a one-shot speed bump. + """ + write_sprint(project, {"1-1-a": "ready-for-dev"}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + engine, _ = make_engine(project, []) + assert engine.run().paused + + resumed, adapter = resume_engine(project, engine, [dev_effect(project, "1-1-a")]) + summary = resumed.run() + + assert summary.paused and summary.done == 0 + assert adapter.sessions == [] # the entry is still open; nothing may run + assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE + + def test_a_gated_story_runs_once_the_entry_lands(project): - """The other half of the placement above: because the pause left no task - behind, a resume re-picks the story and re-reads the ledger. Closing the entry - is the primary remedy the pause names, so it has to be the one that clears - it — a gate nobody can get past is a wedge, not a gate.""" + """Closing the entry is the primary remedy the pause names, so it has to be + the one that clears it — a gate nobody can get past is a wedge, not a gate. + (That the refusal survives a resume which changed nothing is the test above; + this one is the release.)""" write_sprint(project, {"1-1-a": "ready-for-dev"}) write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) engine, _ = make_engine(project, []) From 6c74c7a81f159e81c089e5e668eddeb6b6001f4a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 01:26:12 -0700 Subject: [PATCH 11/34] style: reflow the README hard-gate paragraph (prettier) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2024c4ef..868627fa 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ Until that entry lands, `bmad-loop validate` **fails** for every actionable stor Only an explicit `status: done ` retires a gate. An entry whose status the format cannot read — `status: opne`, or no `status:` line at all — still gates, because an unreadable status is not evidence the work landed; letting it read as closed would have meant one keystroke silently disabling the refusal. -The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story already in flight when a run resumes finishes rather than stranding a half-done session; the gate is about work that must not *start*. +The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story already in flight when a run resumes finishes rather than stranding a half-done session; the gate is about work that must not _start_. Four shapes declare a gate nothing can enforce, and each is a warning while the entry is unlanded: a token that cannot name a story key (a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones, or an unmatchable `gate: 3.2` — note `.` and `_` are fine inside a sprint slug, so `gate: 3-2-a_b` is a real gate); a `gate:` line with nothing usable after the colon; a `gate:` not written lowercase at the very start of a line (`Gate:`, or indented — surfaced rather than accepted, so a fenced example inside an entry cannot become a refusal); and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. From 6ad16d5bf38c4dabf056d4114a478cdb5e700467 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 01:30:43 -0700 Subject: [PATCH 12/34] refactor(deferred): make the token matchability helper private Used only by `gates()`; the module marks every internal helper with a leading underscore (`_find_entry`, `_one_line`, `_bracket_severity`). --- src/bmad_loop/deferredwork.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index ff3a6f8e..5cc07154 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -241,7 +241,7 @@ def gates(entry: DWEntry) -> EntryGates: if not token: continue named = True - bucket = tokens if matchable_token(token) else malformed + bucket = tokens if _matchable_token(token) else malformed if token not in bucket: bucket.append(token) if not named: @@ -262,7 +262,7 @@ def gates(entry: DWEntry) -> EntryGates: ) -def matchable_token(token: str) -> bool: +def _matchable_token(token: str) -> bool: """Whether ``token`` could gate any legal story key — the test that decides :attr:`EntryGates.tokens` vs :attr:`EntryGates.malformed`. From fd5d47bebba041283f7a58a5c7b1346e80c6d458 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 01:33:02 -0700 Subject: [PATCH 13/34] docs(deferred): drop the leading spaces from an inline code span (MD038) --- .../data/skills/bmad-loop-sweep/deferred-work-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 912859d9..70c16e67 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -133,7 +133,7 @@ Four shapes declare a gate that nothing can enforce, and all four are reported a such line is reported, including one sitting beside a line that does name a story, since the half that names nothing is the half you are wrong about; - a `gate:` that is not lowercase at the very start of its line — `Gate: 3-2`, or - an indented ` gate: 3-2`. These are reported rather than read as declarations, + a line that indents `gate: 3-2`. These are reported rather than read as declarations, because accepting an indented one would turn a fenced example quoted inside an entry into a refusal of a story nobody meant to block; - prose declaring `HARD GATE:` — the convention that predates this field — From 3c7768d3adbb19ec87b6766b4a8e40ff32e39086 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 01:37:05 -0700 Subject: [PATCH 14/34] test: move write_gated_ledger into conftest beside write_ledger Both test files had a near-copy; only the engine's needed the commit, which is now the one parameter that differs rather than a second definition. --- tests/conftest.py | 24 ++++++++++++++++++++++++ tests/test_cli.py | 28 +++++++--------------------- tests/test_engine.py | 18 +----------------- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0a86eae3..bf11b295 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -876,6 +876,30 @@ def write_ledger(paths: ProjectPaths, statuses: dict[str, str], commit: bool = T git(paths.project, "commit", "-q", "-m", "ledger") +def write_gated_ledger(paths: ProjectPaths, entries, commit: bool = True) -> None: + """`write_ledger` plus the lines a hard gate is written on: `entries` maps a + DW id to `(status, extra_field_lines)`, appended verbatim after `status:` so a + test can spell a `gate:` line, a prose `HARD GATE:`, or a deliberately broken + one exactly as a human would. + + Committed by default, like `write_ledger` and for the same reason: the engine + paths that dispatch a story need a clean tree. `validate` reads the file + directly and never looks at git, so its callers pass `commit=False` and skip + the git round-trip. + """ + parts = ["# Deferred Work\n"] + for dw_id, (status, extra) in entries.items(): + tail = "".join(f"{line}\n" for line in extra) + parts.append( + f"### {dw_id}: item {dw_id}\n\norigin: test, 2026-06-01\n" + f"location: src.txt:1\nreason: test entry.\nstatus: {status}\n{tail}" + ) + paths.deferred_work.write_text("\n".join(parts), encoding="utf-8") + if commit: + git(paths.project, "add", "-A") + git(paths.project, "commit", "-q", "-m", "ledger") + + def mark_ledger_done(paths: ProjectPaths, dw_ids, date: str = "2026-06-11") -> None: from bmad_loop import deferredwork diff --git a/tests/test_cli.py b/tests/test_cli.py index b4d78271..cd56ef64 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,6 +21,7 @@ machine_json, mark_ledger_done, spec_path, + write_gated_ledger, write_ledger, write_spec, write_sprint, @@ -3900,21 +3901,6 @@ def test_validate_sprint_mode_silent_without_declarations(project, capsys): assert [f for f in doc["findings"] if f["check"].startswith("deferred.closes")] == [] -def write_gated_ledger(paths, entries) -> None: - """`write_ledger` plus the lines a hard gate is written on: `entries` maps a - DW id to `(status, extra_field_lines)`, appended verbatim after `status:` so a - test can spell a `gate:` line, a prose `HARD GATE:`, or a deliberately broken - one exactly as a human would.""" - parts = ["# Deferred Work\n"] - for dw_id, (status, extra) in entries.items(): - tail = "".join(f"{line}\n" for line in extra) - parts.append( - f"### {dw_id}: item {dw_id}\n\norigin: test, 2026-06-01\n" - f"location: src.txt:1\nreason: test entry.\nstatus: {status}\n{tail}" - ) - paths.deferred_work.write_text("\n".join(parts), encoding="utf-8") - - def _hard_gate_findings(capsys, check="deferred.hard-gate"): doc = json.loads(capsys.readouterr().out) return [f for f in doc["findings"] if f["check"] == check] @@ -3926,7 +3912,7 @@ def _validate_gated_sprint(project, capsys, board, ledger): install_bmad_config(project) _write_policy(project.project) write_sprint(project, board) - write_gated_ledger(project, ledger) + write_gated_ledger(project, ledger, commit=False) args = argparse.Namespace(project=str(project.project), spec=None, json=True) cli.cmd_validate(args) # rc varies by host (binary/skills) — parse the document @@ -4240,7 +4226,7 @@ def test_validate_does_not_gate_a_word_id_that_merely_shares_a_prefix(project, c install_bmad_config(project) _write_policy(project.project, STORIES_POLICY) _setup_stories_fixture(project, [_stories_entry("authz-login")]) - write_gated_ledger(project, {"DW-1": ("open", ["gate: auth"])}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: auth"])}, commit=False) args = argparse.Namespace(project=str(project.project), spec=None, json=True) cli.cmd_validate(args) @@ -4269,7 +4255,7 @@ def test_validate_hard_gate_runs_in_stories_mode(project, capsys): install_bmad_config(project) _write_policy(project.project, STORIES_POLICY) _setup_stories_fixture(project, [_stories_entry("1")]) - write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}, commit=False) args = argparse.Namespace(project=str(project.project), spec=None, json=True) cli.cmd_validate(args) @@ -4287,7 +4273,7 @@ def test_validate_survives_an_unreadable_story_spec_in_stories_mode(project, cap install_bmad_config(project) _write_policy(project.project, STORIES_POLICY) _setup_stories_fixture(project, [_stories_entry("1")]) - write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}, commit=False) monkeypatch.setattr( cli.stories_mod, "resolve_story_spec", @@ -4309,7 +4295,7 @@ def test_validate_reports_no_gate_all_clear_when_the_queue_is_unreadable(project install_bmad_config(project) _write_policy(project.project) project.sprint_status.write_text("development_status: [oh no\n", encoding="utf-8") - write_gated_ledger(project, {"DW-1": ("open", ["gate: 3-2"])}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 3-2"])}, commit=False) args = argparse.Namespace(project=str(project.project), spec=None, json=True) cli.cmd_validate(args) @@ -4327,7 +4313,7 @@ def test_validate_stories_mode_skips_a_done_story(project, capsys): (folder / "stories" / "1-slug.md").write_text( "---\ntitle: 'test'\nstatus: 'done'\n---\n\n## Intent\n\ntest\n", encoding="utf-8" ) - write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}, commit=False) args = argparse.Namespace(project=str(project.project), spec=None, json=True) cli.cmd_validate(args) diff --git a/tests/test_engine.py b/tests/test_engine.py index 4d76d353..e31af59c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -26,6 +26,7 @@ review_effect, set_sprint, spec_path, + write_gated_ledger, write_ledger, write_spec, write_sprint, @@ -6452,23 +6453,6 @@ def test_critical_escalation_pauses_and_resume_continues(project): assert resumed.state.finished -def write_gated_ledger(paths, entries, commit=True) -> None: - """`write_ledger` plus the `gate:` lines a hard gate is written on: `entries` - maps a DW id to `(status, extra_field_lines)`, appended verbatim after - `status:`. Committed by default — the engine's own paths assume a clean tree.""" - parts = ["# Deferred Work\n"] - for dw_id, (status, extra) in entries.items(): - tail = "".join(f"{line}\n" for line in extra) - parts.append( - f"### {dw_id}: item {dw_id}\n\norigin: test, 2026-06-01\n" - f"location: src.txt:1\nreason: test entry.\nstatus: {status}\n{tail}" - ) - paths.deferred_work.write_text("\n".join(parts), encoding="utf-8") - if commit: - git(paths.project, "add", "-A") - git(paths.project, "commit", "-q", "-m", "ledger") - - def test_dispatch_refuses_a_story_an_unlanded_entry_gates(project): """The enforcing half of `gate:`. Before this, `_pick_next` read the board alone: the ledger could say a story was blocked and `run` drove it anyway, and From 11f4b072150e78a27c8a74f9ca18ceea3576b704 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 08:56:18 -0700 Subject: [PATCH 15/34] fix(deferred): a fenced `gate:` example is not a declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry whose subject is this field quotes it, and a quoted example sits in column 0 — right where the strict field anchor looks. `HARD_GATE_PROSE_RE` already needed quote guards for exactly that, and its comment records the warning firing on entries documenting the convention, this repo's own docs included. `gate:` is worse off: the answer is a refusal, so an entry explaining the field would fail validate and pause a run on the story its example names. Both `gate:` scans now run against a fence-masked body. An UNCLOSED fence masks nothing — swallowing to end-of-entry would let one stray backtick run silently disable every gate below it, which is the miss this field exists to end. --- src/bmad_loop/deferredwork.py | 69 +++++++++++++++++++++++++++++++++-- tests/test_cli.py | 16 ++++++++ tests/test_deferredwork.py | 41 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 5cc07154..c79bdb1f 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -81,6 +81,16 @@ # rather than *accepted*, because accepting an indented line would read a fenced # example inside an entry as a live gate and refuse a story nobody meant to block. _GATE_NEAR_RE = re.compile(r"^[ \t]*gate[ \t]*:", re.IGNORECASE | re.MULTILINE) +# A fenced code block's delimiter, with its info string. The ledger is markdown, +# and an entry whose whole subject is this field quotes it — the sibling +# `HARD_GATE_PROSE_RE` needed quote guards for exactly that reason, and its +# comment records the warning firing on entries documenting the convention, +# including this repo's own docs. `gate:` is worse off than that detector was: a +# fenced example sits in column 0, so the strict field anchor reads it as a live +# declaration, and the answer is now a *refusal* — `validate` fails and the run +# pauses on a story nobody meant to block. A false refusal wedges a run, which is +# the one way this check can be worse than the prose it replaced. +_FENCE_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})(.*)$") # The prose convention `gate:` replaces, matched anywhere on a line rather than # at its start: real ledgers hard-wrap their `reason:` prose, so the declaration # routinely lands mid-line and a line-anchored pattern misses exactly the entries @@ -233,7 +243,13 @@ def gates(entry: DWEntry) -> EntryGates: malformed: list[str] = [] lines = 0 empty = 0 - for m in GATE_RE.finditer(entry.body): + # Both scans below run against the fence-masked body, never `entry.body`: an + # entry documenting this field quotes it, and a quoted example is not a + # declaration. Only the two `gate:` scans are masked — `HARD_GATE_PROSE_RE` + # is left reading the raw body deliberately, because it produces a warning + # rather than a refusal and already carries its own quote guard. + body = _mask_fenced_blocks(entry.body) + for m in GATE_RE.finditer(body): lines += 1 named = False for raw in m.group(1).split(","): @@ -250,8 +266,8 @@ def gates(entry: DWEntry) -> EntryGates: # `^` puts every match at a line start, so this asks whether the same line # would have satisfied `GATE_RE` — i.e. whether it is the canonical spelling # already counted above — without re-running the anchor against a slice. - not entry.body.startswith("gate:", m.start()) - for m in _GATE_NEAR_RE.finditer(entry.body) + not body.startswith("gate:", m.start()) + for m in _GATE_NEAR_RE.finditer(body) ) return EntryGates( tokens=tuple(tokens), @@ -262,6 +278,53 @@ def gates(entry: DWEntry) -> EntryGates: ) +def _mask_fenced_blocks(body: str) -> str: + """Blank the contents of fenced code blocks, preserving every offset. + + Length-preserving (spaces in, newlines kept) so a caller can keep matching + against the masked text and still index into it — :func:`gates` relies on that + for its near-miss test. + + Splits on ``\\n`` alone, deliberately, because that is exactly where + ``re.MULTILINE``'s ``^`` matches. Using ``str.splitlines()`` would split on + U+2028 and friends as well, and the mask would then disagree with the very + patterns it is masking for — the same two-readers-disagree trap + :data:`LINE_BREAK_RE` exists to document. + + **An unclosed fence masks nothing.** Masking to end-of-entry would let one + stray ``` swallow a real ``gate:`` line below it — a gate lost in silence, + which is precisely the failure this field exists to end. Between the two + directions, a fenced example that is never closed staying readable is the + cheaper wrong answer: it costs a spurious refusal only in an entry that is + already malformed markdown, while the greedy reading costs a lost gate in an + entry that is merely long. + """ + lines = body.split("\n") + masked = list(lines) + opener: int | None = None + marker = "" + for i, line in enumerate(lines): + m = _FENCE_RE.match(line) + if opener is None: + if m: + opener, marker = i, m.group(1) + continue + # A closer is the same character, at least as long, and carries no info + # string (CommonMark). Requiring that is what keeps ```` ```python ```` + # inside a ```` ``` ```` block from ending it early and re-exposing the + # lines the fence was hiding. + if ( + m + and m.group(1)[0] == marker[0] + and len(m.group(1)) >= len(marker) + and not m.group(2).strip() + ): + for j in range(opener, i + 1): + masked[j] = " " * len(lines[j]) + opener = None + return "\n".join(masked) + + def _matchable_token(token: str) -> bool: """Whether ``token`` could gate any legal story key — the test that decides :attr:`EntryGates.tokens` vs :attr:`EntryGates.malformed`. diff --git a/tests/test_cli.py b/tests/test_cli.py index cd56ef64..efa4b702 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4060,6 +4060,22 @@ def test_validate_does_not_report_an_all_clear_for_an_unmatchable_token(project, assert not [f for f in findings if f["check"] == "deferred.hard-gate"] +def test_validate_does_not_refuse_a_story_over_a_quoted_gate_example(project, capsys): + """End to end: an entry documenting the field must not refuse the story its + example names. A false refusal wedges a run, which is the one way this check + can be worse than the prose gate it replaced — and the entry most likely to + carry a quoted `gate:` is the one written to explain `gate:`.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + {"DW-1": ("open", ["```markdown", "gate: 3-2", "```"])}, + ) + + assert not [f for f in findings if f["check"] == "deferred.hard-gate"] + assert not [f for f in findings if f["check"] == "deferred.hard-gate-unstructured"] + + def test_validate_hard_gate_token_stops_at_the_key_boundary(project, capsys): """`3-2` gates the story it names and both halves of that story once breakdown splits it, and not its numeric neighbours. A bare `startswith` would sweep diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 53a52d2c..ab31f839 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1588,6 +1588,47 @@ def test_gates_reports_a_token_that_cannot_name_a_story(): assert g.malformed == ("3-2 3-3", "../etc") +@pytest.mark.parametrize("fence", ["```markdown", "```", "~~~"]) +def test_a_fenced_example_is_not_a_gate_declaration(fence): + """An entry whose subject IS this field quotes it, and a quoted example sits in + column 0 — right where the strict field anchor looks. The sibling + `HARD_GATE_PROSE_RE` already needed quote guards for exactly this (its comment + records the warning firing on entries documenting the convention, this repo's + own docs included), and `gate:` is worse off: the answer here is a refusal, so + an entry explaining the field would fail validate and pause a run.""" + close = "```" if fence.startswith("`") else "~~~" + g = _gated(fence, "gate: 3-2", close) + + assert g.tokens == () and g.near_miss == 0 and g.lines == 0 + + +def test_a_fence_hides_only_itself(): + """The mask must not reach past the block. A real declaration on either side of + a quoted example still gates — otherwise the fix for a false refusal would have + bought a lost gate, which is the worse of the two.""" + g = _gated("gate: 4-1", "```", "gate: 3-2", "```", "gate: 5-1") + + assert g.tokens == ("4-1", "5-1") + + +def test_an_unclosed_fence_swallows_no_gate(): + """The deliberate asymmetry. Masking an unterminated fence to end-of-entry + would let one stray ``` silently disable every gate below it — the exact + silent miss this field exists to end. A malformed-markdown entry keeping a + readable gate is the cheaper wrong answer.""" + g = _gated("```", "an example nobody closed", "gate: 4-1") + + assert g.tokens == ("4-1",) + + +def test_a_longer_fence_is_not_closed_by_an_info_string_line(): + """A closer carries no info string (CommonMark). Without that rule an inner + ```python would end the outer block early and re-expose the lines it hid.""" + g = _gated("````", "```python", "gate: 3-2", "````") + + assert g.tokens == () + + def test_gate_token_shape_copy_agrees_with_the_stories_id_it_mirrors(): """`_STORIES_ID_RE` is a copy of `stories.ID_RE`, taken because `stories` imports this module and the reverse would cycle. Pinned to the original rather From 2ab1f132a84409e11e2147268d5335c8da882a6b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 08:57:13 -0700 Subject: [PATCH 16/34] test(deferred): isolate the info-string closer rule from the fence-length rule --- tests/test_deferredwork.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index ab31f839..061833b9 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1621,10 +1621,13 @@ def test_an_unclosed_fence_swallows_no_gate(): assert g.tokens == ("4-1",) -def test_a_longer_fence_is_not_closed_by_an_info_string_line(): - """A closer carries no info string (CommonMark). Without that rule an inner - ```python would end the outer block early and re-expose the lines it hid.""" - g = _gated("````", "```python", "gate: 3-2", "````") +def test_a_line_with_an_info_string_does_not_close_a_fence(): + """A closer carries no info string (CommonMark), and the rule has to be tested + at EQUAL fence length or the length rule answers first and the assertion + measures nothing. Here every line is a 3-backtick run: without the + info-string requirement, the ```python would close the block early, re-expose + `gate: 4-1`, and leave the trailing ``` opening an unclosed fence.""" + g = _gated("```", "gate: 3-2", "```python", "gate: 4-1", "```") assert g.tokens == () From 00b5185dda7726dcec954d87cdbb1f5775d13b79 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 09:04:42 -0700 Subject: [PATCH 17/34] refactor(fences): share one fence reader between devcontract and deferredwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devcontract._section_headings` already argued the case: "a second copy of `_fenced`'s open-marker walk is exactly the kind of near-duplicate that drifts." The fence fix in 11f4b07 wrote exactly that copy, and it had already diverged — its unclosed-fence handling is the opposite of devcontract's, for a real reason. Extracted to a leaf module (neither caller can host it: devcontract imports deferredwork). The divergence becomes the `unclosed_hides_rest` parameter, with both directions documented as "be wrong survivably": devcontract keeps an ambiguous tail inert because misreading a quoted heading is destructive; deferredwork keeps a gate readable because a gate lost in silence is the failure that field exists to end. devcontract's behavior is unchanged (default True). Filtering matches by offset also retires the length-preserving mask helper. --- .../bmad-loop-sweep/deferred-work-format.md | 12 ++- src/bmad_loop/deferredwork.py | 84 ++++--------------- src/bmad_loop/devcontract.py | 32 +------ src/bmad_loop/fences.py | 74 ++++++++++++++++ 4 files changed, 102 insertions(+), 100 deletions(-) create mode 100644 src/bmad_loop/fences.py diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index 70c16e67..f7009ecc 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -120,6 +120,12 @@ A sweep is never gated by the ledger it is draining, whatever any entry's `gate: says: closing the gating entry is what a sweep is for, so gating it would deadlock the gate against its own remedy. +**Quoting the field is safe.** A `gate:` line inside a fenced code block is an +example, not a declaration, so an entry that documents this convention gates +nothing. One exception worth knowing when you write an entry: a fence you open +and never close is not treated as a fence at all, because swallowing the rest of +the entry could silently disable a real `gate:` line below it. Close your fences. + Four shapes declare a gate that nothing can enforce, and all four are reported as `deferred.hard-gate-unstructured` while the entry is unlanded: @@ -133,9 +139,9 @@ Four shapes declare a gate that nothing can enforce, and all four are reported a such line is reported, including one sitting beside a line that does name a story, since the half that names nothing is the half you are wrong about; - a `gate:` that is not lowercase at the very start of its line — `Gate: 3-2`, or - a line that indents `gate: 3-2`. These are reported rather than read as declarations, - because accepting an indented one would turn a fenced example quoted inside an - entry into a refusal of a story nobody meant to block; + a line that indents `gate: 3-2`. These are reported rather than read as + declarations: the field is a fixed spelling, and guessing at near-misses is how + a line that was never meant to gate ends up refusing a story; - prose declaring `HARD GATE:` — the convention that predates this field — anywhere on a line of an entry that carries no `gate:` line. It is matched mid-line because `reason:` prose is hard-wrapped, but never directly after a diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index c79bdb1f..3a4d0298 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -19,6 +19,7 @@ from pathlib import Path from . import sprintstatus +from .fences import fenced from .platform_util import atomic_write_text HEADING_RE = re.compile(r"^### (DW-\d+): (.+?)\s*$", re.MULTILINE) @@ -81,16 +82,6 @@ # rather than *accepted*, because accepting an indented line would read a fenced # example inside an entry as a live gate and refuse a story nobody meant to block. _GATE_NEAR_RE = re.compile(r"^[ \t]*gate[ \t]*:", re.IGNORECASE | re.MULTILINE) -# A fenced code block's delimiter, with its info string. The ledger is markdown, -# and an entry whose whole subject is this field quotes it — the sibling -# `HARD_GATE_PROSE_RE` needed quote guards for exactly that reason, and its -# comment records the warning firing on entries documenting the convention, -# including this repo's own docs. `gate:` is worse off than that detector was: a -# fenced example sits in column 0, so the strict field anchor reads it as a live -# declaration, and the answer is now a *refusal* — `validate` fails and the run -# pauses on a story nobody meant to block. A false refusal wedges a run, which is -# the one way this check can be worse than the prose it replaced. -_FENCE_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})(.*)$") # The prose convention `gate:` replaces, matched anywhere on a line rather than # at its start: real ledgers hard-wrap their `reason:` prose, so the declaration # routinely lands mid-line and a line-anchored pattern misses exactly the entries @@ -243,13 +234,20 @@ def gates(entry: DWEntry) -> EntryGates: malformed: list[str] = [] lines = 0 empty = 0 - # Both scans below run against the fence-masked body, never `entry.body`: an - # entry documenting this field quotes it, and a quoted example is not a - # declaration. Only the two `gate:` scans are masked — `HARD_GATE_PROSE_RE` - # is left reading the raw body deliberately, because it produces a warning - # rather than a refusal and already carries its own quote guard. - body = _mask_fenced_blocks(entry.body) - for m in GATE_RE.finditer(body): + + # Both scans below skip fenced matches: an entry documenting this field quotes + # it, and a quoted example is not a declaration — a fenced `gate: 3-2` sits in + # column 0, right where the anchor looks, and the answer here is a *refusal*. + # `unclosed_hides_rest=False` because a stray unterminated fence must not be + # able to silence a real `gate:` line below it (see `fences.fenced`). Only the + # two `gate:` scans are filtered — `HARD_GATE_PROSE_RE` deliberately still + # reads the raw body: it warns rather than refuses, and carries a quote guard. + def _quoted(offset: int) -> bool: + return fenced(entry.body, offset, unclosed_hides_rest=False) + + for m in GATE_RE.finditer(entry.body): + if _quoted(m.start()): + continue lines += 1 named = False for raw in m.group(1).split(","): @@ -266,8 +264,9 @@ def gates(entry: DWEntry) -> EntryGates: # `^` puts every match at a line start, so this asks whether the same line # would have satisfied `GATE_RE` — i.e. whether it is the canonical spelling # already counted above — without re-running the anchor against a slice. - not body.startswith("gate:", m.start()) - for m in _GATE_NEAR_RE.finditer(body) + not entry.body.startswith("gate:", m.start()) + for m in _GATE_NEAR_RE.finditer(entry.body) + if not _quoted(m.start()) ) return EntryGates( tokens=tuple(tokens), @@ -278,53 +277,6 @@ def gates(entry: DWEntry) -> EntryGates: ) -def _mask_fenced_blocks(body: str) -> str: - """Blank the contents of fenced code blocks, preserving every offset. - - Length-preserving (spaces in, newlines kept) so a caller can keep matching - against the masked text and still index into it — :func:`gates` relies on that - for its near-miss test. - - Splits on ``\\n`` alone, deliberately, because that is exactly where - ``re.MULTILINE``'s ``^`` matches. Using ``str.splitlines()`` would split on - U+2028 and friends as well, and the mask would then disagree with the very - patterns it is masking for — the same two-readers-disagree trap - :data:`LINE_BREAK_RE` exists to document. - - **An unclosed fence masks nothing.** Masking to end-of-entry would let one - stray ``` swallow a real ``gate:`` line below it — a gate lost in silence, - which is precisely the failure this field exists to end. Between the two - directions, a fenced example that is never closed staying readable is the - cheaper wrong answer: it costs a spurious refusal only in an entry that is - already malformed markdown, while the greedy reading costs a lost gate in an - entry that is merely long. - """ - lines = body.split("\n") - masked = list(lines) - opener: int | None = None - marker = "" - for i, line in enumerate(lines): - m = _FENCE_RE.match(line) - if opener is None: - if m: - opener, marker = i, m.group(1) - continue - # A closer is the same character, at least as long, and carries no info - # string (CommonMark). Requiring that is what keeps ```` ```python ```` - # inside a ```` ``` ```` block from ending it early and re-exposing the - # lines the fence was hiding. - if ( - m - and m.group(1)[0] == marker[0] - and len(m.group(1)) >= len(marker) - and not m.group(2).strip() - ): - for j in range(opener, i + 1): - masked[j] = " " * len(lines[j]) - opener = None - return "\n".join(masked) - - def _matchable_token(token: str) -> bool: """Whether ``token`` could gate any legal story key — the test that decides :attr:`EntryGates.tokens` vs :attr:`EntryGates.malformed`. diff --git a/src/bmad_loop/devcontract.py b/src/bmad_loop/devcontract.py index 8914bbea..3fd16138 100644 --- a/src/bmad_loop/devcontract.py +++ b/src/bmad_loop/devcontract.py @@ -28,6 +28,7 @@ from typing import Any from . import deferredwork +from .fences import fenced as _fenced from .frontmatter import _edit_frontmatter_block, status_of from .platform_util import atomic_replace from .verify import DEV_WORKFLOW, operator_actions_of, read_frontmatter @@ -113,37 +114,6 @@ class AutoRunResult: detail: str # the prose body after the heading, trimmed (human-readable) -# A fence line: up to three spaces of indent, then a maximal run of >= 3 backticks -# or tildes (its char AND length both matter per CommonMark), then the rest of the -# line — an info string on an opener; on a close, only whitespace is allowed. -_FENCE_LINE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})([^\n]*)$", re.MULTILINE) - - -def _fenced(text: str, offset: int) -> bool: - """True when `offset` falls inside a ``` / ~~~ fenced code block. - - A fence opens on a line of three-or-more backticks or tildes (indentable up - to three spaces; a tab would make an indented code block instead). Per - CommonMark it closes only on a later line using the SAME character, at least - as long as the opener, with no trailing non-whitespace — so a shorter run, a - different fence char, or an info-bearing line inside the block is content, - not a close. Tracking the open fence's char+length (not a bare line-parity - count) is what stops a nested-or-mismatched inner fence from flipping state - early and exposing a quoted `## Auto Run Result` as a real heading — a - destructive misread on the strip path.""" - open_marker: str | None = None - for m in _FENCE_LINE_RE.finditer(text): - if m.start() >= offset: - break - marker, rest = m.group(1), m.group(2) - if open_marker is None: - open_marker = marker # opening fence — an info string is allowed - elif marker[0] == open_marker[0] and len(marker) >= len(open_marker) and not rest.strip(): - open_marker = None # valid closing fence - # else: a shorter / mismatched / info-bearing fence line — literal content - return open_marker is not None - - def _section_headings( text: str, pattern: re.Pattern[str] = AUTO_RUN_HEADING_RE ) -> list[re.Match[str]]: diff --git a/src/bmad_loop/fences.py b/src/bmad_loop/fences.py new file mode 100644 index 00000000..05ca709e --- /dev/null +++ b/src/bmad_loop/fences.py @@ -0,0 +1,74 @@ +"""Whether a markdown offset sits inside a fenced code block. + +A leaf module with no bmad-loop imports, deliberately: the two readers that need +this — `devcontract` (is a `## Auto Run Result` heading real, or quoted?) and +`deferredwork` (is a `gate:` line a declaration, or an example?) — sit on opposite +sides of an import edge (`devcontract` imports `deferredwork`), so neither can +host it for the other. `devcontract._section_headings` already argued the case in +prose: "a second copy of `_fenced`'s open-marker walk is exactly the kind of +near-duplicate that drifts." This module is that argument taken one step further +once a second subsystem needed the same walk. +""" + +from __future__ import annotations + +import re + +# A fence line: up to three spaces of indent, then a maximal run of >= 3 backticks +# or tildes (its char AND length both matter per CommonMark), then the rest of the +# line — an info string on an opener; on a close, only whitespace is allowed. +FENCE_LINE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})([^\n]*)$", re.MULTILINE) + + +def fenced(text: str, offset: int, *, unclosed_hides_rest: bool = True) -> bool: + """True when ``offset`` falls inside a ``` / ~~~ fenced code block. + + A fence opens on a line of three-or-more backticks or tildes (indentable up + to three spaces; a tab would make an indented code block instead). Per + CommonMark it closes only on a later line using the SAME character, at least + as long as the opener, with no trailing non-whitespace — so a shorter run, a + different fence char, or an info-bearing line inside the block is content, + not a close. Tracking the open fence's char+length (not a bare line-parity + count) is what stops a nested-or-mismatched inner fence from flipping state + early and exposing a quoted heading as a real one. + + ``unclosed_hides_rest`` decides the one case CommonMark leaves to the reader: + a fence that opens and never closes. The two callers need opposite answers, + and both are choosing the direction where being wrong is survivable, so this + is a parameter rather than a policy: + + - ``True`` (``devcontract``): everything after the opener is content. Reading + a quoted ``## Auto Run Result`` as a real section is a *destructive* misread + — it strips or terminates a spec — so an ambiguous tail must stay inert. + - ``False`` (``deferredwork``): the opener is ordinary text. A `gate:` line + below a stray fence must keep gating, because a gate lost in silence is the + exact failure that field exists to end; a spurious refusal in an entry whose + markdown is already malformed is the cheaper wrong answer. + """ + open_marker: str | None = None + for m in FENCE_LINE_RE.finditer(text): + if m.start() >= offset: + break + marker, rest = m.group(1), m.group(2) + if open_marker is None: + open_marker = marker # opening fence — an info string is allowed + elif marker[0] == open_marker[0] and len(marker) >= len(open_marker) and not rest.strip(): + open_marker = None # valid closing fence + # else: a shorter / mismatched / info-bearing fence line — literal content + if open_marker is None: + return False + return unclosed_hides_rest or _closes_later(text, offset, open_marker) + + +def _closes_later(text: str, offset: int, open_marker: str) -> bool: + """Whether the fence open at ``offset`` is ever validly closed after it. + + Only consulted under ``unclosed_hides_rest=False``, and only when the offset + is inside an open fence — so the walk above has already paid for the prefix + and this pays for the remainder exactly once per query. + """ + for m in FENCE_LINE_RE.finditer(text, offset): + marker, rest = m.group(1), m.group(2) + if marker[0] == open_marker[0] and len(marker) >= len(open_marker) and not rest.strip(): + return True + return False From 6c4a5c76fe7e8feddbd2a32cbe187a4369a5cda3 Mon Sep 17 00:00:00 2001 From: pbean Date: Sun, 9 Aug 2026 09:40:53 -0700 Subject: [PATCH 18/34] fix(deferredwork): read fence state at file scope so a quoted example is not an entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_ledger split on HEADING_RE before any fence was consulted, so a complete worked example — heading, status and gate inside one fence — became a real entry: the opening fence stayed with the PREVIOUS entry and the example body saw no open fence, leaving its `gate:` live. A ledger quoting deferred-work-format.md, which ships exactly that shape, would refuse a story nobody deferred. Every scan that decides entry identity or extent now skips fenced matches, not just the heading one. Filtering headings alone would have traded the phantom entry for a truncation: a quoted heading or flat bullet would still end the entry that quotes it, dropping a real `gate:` below the example out of the span — a lost gate, the worse half. Status is read the same way, because moving the example inside the quoting entry hands STATUS_RE a candidate it never used to see. _example asks with unclosed_hides_rest=False, matching gates() one level down: the opposite answer would let one stray opener erase every heading below it and drop real open work out of open_ids() in silence. Also pins FENCE_LINE_RE`s CommonMark ` {0,3}` indent limit, which nothing read — ablating it to `^[ \t]*` left all 1317 tests green. At four spaces a backtick run is indented code, so accepting it would let two such lines mask a live column-0 gate. --- src/bmad_loop/deferredwork.py | 57 +++++++++++++++++-- tests/test_deferredwork.py | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 3a4d0298..2fb18eb6 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -137,16 +137,57 @@ def done(self) -> bool: return self.status.split()[0] == "done" if self.status else False +def _example(text: str, offset: int) -> bool: + """Whether ``offset`` sits in a fenced worked example rather than the ledger. + + Asked at WHOLE-FILE scope, which is the whole point. A fence that opens above + a quoted ``### DW-n:`` heading is stranded in the *previous* entry once spans + are carved, so an entry-local query reads the example as live — a phantom + entry whose ``gate:`` refuses a story nobody deferred. `deferred-work-format.md` + ships exactly that shape (a complete entry inside a ```markdown fence), so + quoting it into a ledger is the expected trigger, not a corner case. + + ``unclosed_hides_rest=False`` repeats the answer `gates()` gives one level + down, and here for a stronger reason: under ``True`` a single stray opener + would erase every heading below it, dropping real open work out of + ``open_ids()`` in silence. A phantom entry from an unterminated fence is + today's behaviour and is visible; a vanished ledger is neither. + """ + return fenced(text, offset, unclosed_hides_rest=False) + + +def _unfenced(pattern: re.Pattern[str], text: str, start: int, end: int) -> re.Match[str] | None: + """First match of ``pattern`` within ``text[start:end]`` that is not quoted. + + Not `search()` plus a check: the first match may be the quoted one, and the + real boundary sits after it. Bounded by ``endpos`` so a match beyond the span + cannot claim it, while ``_example`` still reads fence state from offset 0. + """ + for m in pattern.finditer(text, start, end): + if not _example(text, m.start()): + return m + return None + + def parse_ledger(text: str) -> list[DWEntry]: """Extract DW entries; non-conforming sections are skipped, an entry - without a status line parses with status "" (not open).""" + without a status line parses with status "" (not open). + + Fenced matches are skipped by every scan below, not just the heading one: a + heading or flat bullet quoted inside an example must not start an entry, end + one, or bound a block out of one. Filtering only the headings would trade the + phantom entry for a truncation — a fenced ``## heading`` would still cut a + real entry short at its own boundary, and a `gate:` line below the example + would fall outside the span and stop gating, which is the failure this field + exists to end. + """ entries = [] - headings = list(HEADING_RE.finditer(text)) + headings = [m for m in HEADING_RE.finditer(text) if not _example(text, m.start())] for i, m in enumerate(headings): end = headings[i + 1].start() if i + 1 < len(headings) else len(text) # an entry also ends at any intervening heading (e.g. a "## Deferred # from:" section header between freeform and DW-format content) - other = ANY_HEADING_RE.search(text, m.end(), end) + other = _unfenced(ANY_HEADING_RE, text, m.end(), end) if other: end = other.start() # ...and at a flat appender block, which belongs to no canonical entry @@ -157,12 +198,16 @@ def parse_ledger(text: str) -> list[DWEntry]: # done (open_ids() drops it, classify() calls it malformed), which trades # one lost flat block for one lost tracked entry. An entry with no status # line has nothing to protect, so the whole span is fair game. - status_m = STATUS_RE.search(text, m.end(), end) - flat = FLAT_ENTRY_RE.search(text, status_m.end() if status_m else m.end(), end) + status_m = _unfenced(STATUS_RE, text, m.end(), end) + flat = _unfenced(FLAT_ENTRY_RE, text, status_m.end() if status_m else m.end(), end) if flat: end = flat.start() body = text[m.start() : end] - status_m = STATUS_RE.search(body) + # Re-read rather than reuse the probe above: `end` may have moved, and the + # status must be the one inside the final span. Searched over `text` at + # absolute offsets because `_example` reads fence state from the top of the + # file — a body slice cannot see an opener that sits above the heading. + status_m = _unfenced(STATUS_RE, text, m.start(), end) entries.append( DWEntry( id=m.group(1), diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 061833b9..e719bcd3 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1632,6 +1632,109 @@ def test_a_line_with_an_info_string_does_not_close_a_fence(): assert g.tokens == () +def test_a_four_space_backtick_run_is_indented_code_and_cannot_silence_a_gate(): + """CommonMark indents a fence up to three spaces; at four it is indented code, + not a delimiter. `FENCE_LINE_RE`'s ` {0,3}` is the only thing enforcing that, + and it enforces it in the fail-OPEN direction: were the run accepted, two such + lines would wrap a real column-0 `gate:` and mask it out of existence — a gate + lost in silence, the exact failure this field exists to end. The devcontract + half already reasoned this through (reviewer guard #53): the limit is safe + because fenced content in a list is co-indented and can never match a + column-0 anchor, so only the delimiter rule needs pinning.""" + g = _gated(" ```", "gate: 4-1", " ```") + + assert g.tokens == ("4-1",) + + +def test_a_fenced_worked_example_is_not_an_entry(): + """A complete example — heading, status and gate inside one fence — is the + shape `deferred-work-format.md` ships for authors to copy, so a ledger quoting + it is expected rather than exotic. Read entry-locally it used to become a real + entry: `HEADING_RE` split the file first, stranding the opening fence in the + PREVIOUS entry, so the example's body saw no open fence and its `gate:` went + live — a phantom entry refusing a story nobody deferred.""" + text = ( + "# Deferred Work\n\n### DW-01: a real entry\nstatus: open\n\n" + "The format, for reference:\n\n" + "```markdown\n### DW-99: worked example\nstatus: open\ngate: 3-2\n```\n" + ) + + (entry,) = parse_ledger(text) + + assert entry.id == "DW-01" + assert open_ids(text) == {"DW-01"} + assert deferredwork.gates(entry).tokens == () + + +def test_a_fenced_heading_does_not_bound_the_entry_that_quotes_it(): + """The other half of skipping fenced headings, and the one that fails OPEN. + `ANY_HEADING_RE` ends an entry at any intervening heading; left fence-blind it + would end this one at the quoted `### DW-99`, dropping the real `gate:` below + the example out of the span entirely. Trading a phantom entry for a lost gate + would have been the worse of the two bugs.""" + text = ( + "# Deferred Work\n\n### DW-01: a real entry\nstatus: open\n\n" + "```markdown\n### DW-99: worked example\nstatus: done 2026-01-01\n```\n\n" + "gate: 3-2\n" + ) + + (entry,) = parse_ledger(text) + + assert deferredwork.gates(entry).tokens == ("3-2",) + + +def test_a_fenced_flat_bullet_does_not_bound_the_entry_that_quotes_it(): + """Same failure through the #304 flat-appender boundary: a quoted bullet is an + example of the appender's shape, not an appended block, and bounding the entry + at it would again strand the `gate:` below. The real block must still be + bounded out — `test_flat_boundary_still_applies_after_a_quoted_block_inside_the_entry` + holds that end.""" + text = ( + "# Deferred Work\n\n### DW-01: a real entry\nstatus: open\n\n" + "```markdown\n- source_spec: `example.md`\n summary: quoted\n" + " evidence: e\n```\n\ngate: 3-2\n" + ) + + (entry,) = parse_ledger(text) + + assert deferredwork.gates(entry).tokens == ("3-2",) + + +def test_a_stray_unclosed_fence_does_not_erase_the_entries_below_it(): + """Why `_example` asks with `unclosed_hides_rest=False`. Under the opposite + answer one unterminated fence would swallow every heading after it, and those + entries would vanish from `open_ids()` — real open work reported as landed, in + silence. A phantom entry from a stray opener is today's behaviour and is + visible on the page; a disappeared ledger is neither.""" + text = ( + "# Deferred Work\n\n### DW-01: oops\nstatus: open\n```\n\n" + "### DW-02: still real\nstatus: open\ngate: 3-2\n" + ) + + first, second = parse_ledger(text) + + assert (first.id, second.id) == ("DW-01", "DW-02") + assert open_ids(text) == {"DW-01", "DW-02"} + assert deferredwork.gates(second).tokens == ("3-2",) + + +def test_a_fenced_status_line_is_not_the_status_of_the_entry_quoting_it(): + """Skipping fenced headings moves the example INSIDE the quoting entry instead + of splitting it off, which hands `STATUS_RE` a second candidate it never used + to see. An entry with no status of its own must not inherit the example's: + reading `done` there would drop live work out of `open_ids()` on the strength + of a quotation.""" + text = ( + "# Deferred Work\n\n### DW-01: no status of its own\n\norigin: test\n\n" + "```markdown\n### DW-99: worked example\nstatus: done 2026-01-01\n```\n" + ) + + (entry,) = parse_ledger(text) + + assert entry.status == "" + assert not entry.done and not entry.open + + def test_gate_token_shape_copy_agrees_with_the_stories_id_it_mirrors(): """`_STORIES_ID_RE` is a copy of `stories.ID_RE`, taken because `stories` imports this module and the reverse would cycle. Pinned to the original rather From 597572c81484cff42ae9d402bce93d7955929d19 Mon Sep 17 00:00:00 2001 From: pbean Date: Sun, 9 Aug 2026 09:45:45 -0700 Subject: [PATCH 19/34] docs(gate): say that quoting a whole entry is safe, and name the story-gate viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The format doc already promised "quoting the field is safe", which was true of a bare `gate:` line and false of the shape the doc itself shows two sections up: a complete entry inside a fence became a real entry with a live gate. State the promise the parser now keeps, and extend the unclosed-fence caveat to file scope. tui-guide listed the gate viewer as "Spec-approval / epic gate" while app.py has always routed story-gate there too — invisible until this branch made that stage reachable. Name it, and point at where the reason text actually is: the viewer reuses the spec pane and a story gate fires before the story has a spec. --- docs/tui-guide.md | 8 +++++--- .../skills/bmad-loop-sweep/deferred-work-format.md | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 953a4716..fa877583 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -440,9 +440,11 @@ artifacts the engine already wrote. interactive agent as `R`; **Re-arm & resume** (offered once the resolve agent has recorded a resolution) re-arms and resumes — deleting a sentinel with a preserved copy for a clean re-dispatch. Both refuse a still-live engine. -- **Spec-approval / epic gate** — reuses the spec viewer (view the finalized spec, - then **Approve & resume**), so the pre-existing sprint-mode gates inherit the same - richer surface. +- **Spec-approval / epic / story gate** — reuses the spec viewer (view the finalized + spec, then **Approve & resume**), so the pre-existing sprint-mode gates inherit the + same richer surface. A story gate fires before the story is recorded, so it has no + spec to show; read its reason — which names the blocking entries and the remedy — in + the run-header banner or the resume confirmation. `p` and `R` overlap for an escalation (both reach Resolve); `p` also exposes Re-arm & resume inline once a resolution exists. Pause badges in the run list and diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index f7009ecc..dde853a6 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -122,9 +122,14 @@ deadlock the gate against its own remedy. **Quoting the field is safe.** A `gate:` line inside a fenced code block is an example, not a declaration, so an entry that documents this convention gates -nothing. One exception worth knowing when you write an entry: a fence you open -and never close is not treated as a fence at all, because swallowing the rest of -the entry could silently disable a real `gate:` line below it. Close your fences. +nothing. This holds for a whole quoted entry too — heading, `status:` and `gate:` +inside one fence, the shape shown above: the fenced heading starts no entry, and +a quoted heading or bullet does not end the entry that quotes it, so a real +`gate:` below the example keeps gating. One exception worth knowing when you +write an entry: a fence you open and never close is not treated as a fence at +all, because swallowing the rest of the entry could silently disable a real +`gate:` line below it — and, at file scope, hide every entry after it. Close your +fences. Four shapes declare a gate that nothing can enforce, and all four are reported as `deferred.hard-gate-unstructured` while the entry is unlanded: From 6eb075dc9bd99da3ae8ccb755b6ea427f398d4d6 Mon Sep 17 00:00:00 2001 From: pbean Date: Sun, 9 Aug 2026 10:03:13 -0700 Subject: [PATCH 20/34] fix(fences): reject backticks in a backtick info string; mask examples out of parse_legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of finishing the fence reader. FENCE_LINE_RE accepted any info string, but CommonMark forbids a backtick in the info string of a BACKTICK fence — the rule exists so inline code is not read as opening a block. A ledger line starting with a backtick run and carrying more backticks (```gate:``` is the field name.) therefore opened a block that CommonMark never opens, and a real `gate:` above the next closing run was read as a quoted example and silently stopped gating. That is the lost gate this field exists to prevent, not the spurious refusal the fenced path may make. Tildes keep the looser rule; a tilde run cannot appear in inline code. parse_legacy is now fence-aware too (#514). While parse_ledger read a quoted example as a phantom canonical entry, that entry span masked the quotation out of parse_legacy by accident; removing the phantom removed the accident, and the same bullets and `### DW-n:` heading surfaced as legacy findings instead. The span walk moved into fences.fenced_spans and fenced() is now one membership test over it, so the module keeps the single walk it exists to keep. Verified as a pure refactor by differential fuzz against the previous implementation: 1,356,172 offset/flag/text combinations, zero divergence. The fuzz found one real boundary bug on the way — an unclosed fence covered every offset at or past len(text), so its span ends one past the last offset rather than at it. --- src/bmad_loop/deferredwork.py | 22 ++++++++--- src/bmad_loop/fences.py | 71 ++++++++++++++++++++++++----------- tests/test_deferredwork.py | 53 ++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 2fb18eb6..0143ae61 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -19,7 +19,7 @@ from pathlib import Path from . import sprintstatus -from .fences import fenced +from .fences import fenced, fenced_spans from .platform_util import atomic_write_text HEADING_RE = re.compile(r"^### (DW-\d+): (.+?)\s*$", re.MULTILINE) @@ -1065,11 +1065,23 @@ def _heading_entry(struck: bool, hid: str, rest: str, body: str, section: str) - def parse_legacy(text: str) -> list[LegacyEntry]: - """Extract legacy (non-DW) deferred items. Canonical DW entries are - masked out first, so mixed ledgers parse both ways without overlap.""" + """Extract legacy (non-DW) deferred items. Canonical DW entries and fenced + examples are masked out first, so mixed ledgers parse both ways without + overlap and a quoted example contributes nothing to either reading. + + The fenced half is not symmetry for its own sake. `parse_ledger` used to hand + a quoted example over as a phantom canonical entry, whose span masked the + example here by accident; once it stopped doing that, the same quotation + surfaced on this side instead — a bullet or `### DW-n:` heading inside a fence + read as a legacy finding (#514). + """ masked = text - for e in parse_ledger(text): - s, t = e.span + # `unclosed_hides_rest=False` for the reason the canonical side uses it: one + # stray opener must not blank every legacy finding below it out of view. The + # delimiter lines survive as a lone backtick or tilde plus spaces, which no + # pattern below can start an item on — masking them too made no test disagree. + spans = [e.span for e in parse_ledger(text)] + fenced_spans(text, unclosed_hides_rest=False) + for s, t in spans: masked = masked[:s] + re.sub(r"[^\n]", " ", masked[s:t]) + masked[t:] found: list[tuple[dict, tuple[int, int]]] = [] diff --git a/src/bmad_loop/fences.py b/src/bmad_loop/fences.py index 05ca709e..a531540c 100644 --- a/src/bmad_loop/fences.py +++ b/src/bmad_loop/fences.py @@ -20,6 +20,23 @@ FENCE_LINE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})([^\n]*)$", re.MULTILINE) +def _delimits(marker: str, rest: str) -> bool: + """Whether a matched line delimits a fence at all, or is ordinary text. + + CommonMark forbids a backtick anywhere in the info string of a BACKTICK fence, + and only there — the rule exists so that inline code is not read as opening a + block. Tilde fences carry no such restriction. Checked here rather than folded + into `FENCE_LINE_RE` so the pattern stays one readable alternation instead of + two near-identical arms with different info-string classes. + + The miss runs the wrong way for `deferredwork`: a line of prose quoting a + fence opens a block CommonMark never opens, and a real `gate:` above the next + closing run is then read as an example and silently stops gating — the lost + gate the field exists to prevent, not the spurious refusal it tolerates. + """ + return marker[0] == "~" or "`" not in rest + + def fenced(text: str, offset: int, *, unclosed_hides_rest: bool = True) -> bool: """True when ``offset`` falls inside a ``` / ~~~ fenced code block. @@ -45,30 +62,42 @@ def fenced(text: str, offset: int, *, unclosed_hides_rest: bool = True) -> bool: exact failure that field exists to end; a spurious refusal in an entry whose markdown is already malformed is the cheaper wrong answer. """ + return any( + s <= offset < e for s, e in fenced_spans(text, unclosed_hides_rest=unclosed_hides_rest) + ) + + +def fenced_spans(text: str, *, unclosed_hides_rest: bool = True) -> list[tuple[int, int]]: + """Half-open ``[start, end)`` ranges of ``text`` that sit inside a fenced block. + + The walk itself, which `fenced()` reduces to one offset and + `deferredwork.parse_legacy` blanks out wholesale before scanning line by line. + Keeping it here is the point of the module: a reader that needs the ranges and + a reader that needs one answer must not disagree about where a block ends. + + The bounds follow the delimiters' roles rather than their extents. A span opens + one character past the opener's line start, so the opener itself reads as + outside the block — it is markup that a scanner may still want to see. It ends + one character past the closer's line start, which puts the closer *inside*: the + scanners this serves anchor at column 0, and a closing delimiter is the one + line of a block that can never be mistaken for the content it terminates. + """ + spans: list[tuple[int, int]] = [] open_marker: str | None = None + start = 0 for m in FENCE_LINE_RE.finditer(text): - if m.start() >= offset: - break marker, rest = m.group(1), m.group(2) + if not _delimits(marker, rest): + continue # inline code, not a fence line if open_marker is None: - open_marker = marker # opening fence — an info string is allowed + open_marker, start = marker, m.start() + 1 # opener — an info string is allowed elif marker[0] == open_marker[0] and len(marker) >= len(open_marker) and not rest.strip(): - open_marker = None # valid closing fence + spans.append((start, m.start() + 1)) # valid closing fence + open_marker = None # else: a shorter / mismatched / info-bearing fence line — literal content - if open_marker is None: - return False - return unclosed_hides_rest or _closes_later(text, offset, open_marker) - - -def _closes_later(text: str, offset: int, open_marker: str) -> bool: - """Whether the fence open at ``offset`` is ever validly closed after it. - - Only consulted under ``unclosed_hides_rest=False``, and only when the offset - is inside an open fence — so the walk above has already paid for the prefix - and this pays for the remainder exactly once per query. - """ - for m in FENCE_LINE_RE.finditer(text, offset): - marker, rest = m.group(1), m.group(2) - if marker[0] == open_marker[0] and len(marker) >= len(open_marker) and not rest.strip(): - return True - return False + if open_marker is not None and unclosed_hides_rest: + # Past the last offset, not up to it: an unclosed fence has no end, and + # `fenced()` answered True for an offset at or beyond `len(text)` before + # these ranges existed. Slicing clamps, so a mask is unaffected either way. + spans.append((start, len(text) + 1)) + return spans diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index e719bcd3..b98bf429 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1735,6 +1735,59 @@ def test_a_fenced_status_line_is_not_the_status_of_the_entry_quoting_it(): assert not entry.done and not entry.open +def test_a_backtick_run_carrying_backticks_is_inline_code_not_a_fence(): + """CommonMark forbids a backtick anywhere in a BACKTICK fence's info string, + exactly so a line of inline code does not open a block. Without the rule this + line opens one, the trailing ``` closes it, and the `gate:` between them reads + as a quoted example — a gate lost in silence, which is the failure this field + exists to end, not the spurious refusal the fenced path is allowed to make.""" + g = _gated("```gate:``` is the field name.", "gate: 3-2", "```") + + assert g.tokens == ("3-2",) + + +def test_a_tilde_fence_may_carry_backticks_in_its_info_string(): + """The other arm of the same rule, and the reason it is not simply "no + backticks in an info string": the restriction is backtick-only, because a + tilde run cannot appear in inline code. Dropping the fence-char test would + leave this example live and refuse a story the entry only documented.""" + g = _gated("~~~ `inline`", "gate: 3-2", "~~~") + + assert g.tokens == () + + +def test_a_fenced_example_is_not_a_legacy_finding_either(): + """The far side of skipping fenced headings (#514). While `parse_ledger` read a + quoted example as a phantom canonical entry, that entry's span masked the + quotation out of this reader by accident; removing the phantom removed the + accident, and the same bullet surfaced here as a legacy finding instead. The + real block below must still parse — over-masking would lose a tracked item, + which is the failure `parse_legacy` exists to prevent.""" + text = ( + "# Deferred Work\n\nThe format, for reference:\n\n" + "```markdown\n### DW-1: wire the blob-storage credentials\nstatus: open\n" + "gate: 3-2\n- source_spec: `example.md`\n summary: quoted\n evidence: e\n```\n\n" + "- source_spec: `real.md`\n summary: real finding\n evidence: e\n" + ) + + (legacy,) = parse_legacy(text) + + assert legacy.title == "real finding" + assert parse_ledger(text) == [] + + +def test_a_stray_unclosed_fence_does_not_hide_legacy_findings_below_it(): + """`_quoted_line_spans` asks with `unclosed_hides_rest=False` for the reason + the canonical side does: under the opposite answer one unterminated fence + would blank every finding after it out of the ledger, and a lost legacy item + is as silent as a lost entry.""" + text = "# Deferred Work\n\n```\n\n- source_spec: `real.md`\n summary: real finding\n evidence: e\n" + + (legacy,) = parse_legacy(text) + + assert legacy.title == "real finding" + + def test_gate_token_shape_copy_agrees_with_the_stories_id_it_mirrors(): """`_STORIES_ID_RE` is a copy of `stories.ID_RE`, taken because `stories` imports this module and the reverse would cycle. Pinned to the original rather From 854c0e77689e8c806c630ccdec4a528cc88cd030 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 10:18:14 -0700 Subject: [PATCH 21/34] docs(test): name the real helper in the legacy-fence docstring The docstring credited `_quoted_line_spans`, which does not exist. The behaviour it explains belongs to `parse_legacy`, via `fences.fenced_spans(..., unclosed_hides_rest=False)`. --- tests/test_deferredwork.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index b98bf429..eb43356b 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1777,10 +1777,10 @@ def test_a_fenced_example_is_not_a_legacy_finding_either(): def test_a_stray_unclosed_fence_does_not_hide_legacy_findings_below_it(): - """`_quoted_line_spans` asks with `unclosed_hides_rest=False` for the reason - the canonical side does: under the opposite answer one unterminated fence - would blank every finding after it out of the ledger, and a lost legacy item - is as silent as a lost entry.""" + """`parse_legacy` asks `fences.fenced_spans` with `unclosed_hides_rest=False` + for the reason the canonical side does: under the opposite answer one + unterminated fence would blank every finding after it out of the ledger, and + a lost legacy item is as silent as a lost entry.""" text = "# Deferred Work\n\n```\n\n- source_spec: `real.md`\n summary: real finding\n evidence: e\n" (legacy,) = parse_legacy(text) From 831be5c175fc5019a2ec5866dd4684a91e0e6a85 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 11:03:15 -0700 Subject: [PATCH 22/34] fix(deferredwork): close the status line the reader chose, not the first raw match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_ledger` picks an entry's status with a fence-aware lookup, but `_apply_done`, `_insert_after_status` and `mark_open` re-derived it with `STATUS_RE.search(entry.body)` — the first raw match. The two differ exactly when an entry quotes an example above its live status: the writers rewrote the quoted line, the reader kept reporting the real `status: open`, and the close reported success while the entry — and any `gate:` it carries — stayed open. A sweep or story close then never closed anything and the gate refused its story on every following pass. Carry the reader's match on `DWEntry` so all three writers act on the line the reader chose. No default on the field: `parse_ledger` is the only constructor, and a fallback would silently restore the split. --- src/bmad_loop/deferredwork.py | 37 +++++++++++++------- tests/test_deferredwork.py | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 0143ae61..f17fd8db 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -117,6 +117,16 @@ class DWEntry: status: str # the status field value, "" when the line is missing body: str # full entry text including the heading span: tuple[int, int] # char offsets of the entry in the ledger text + # Body-relative offsets of the line `status` was read from; None when the + # entry has no status line. Carried rather than re-derived because the reader + # picks the status with a fence-aware lookup at file scope, and a writer that + # ran `STATUS_RE.search(body)` again would pick the *first* raw match instead. + # Those differ exactly when an entry quotes an example above its live status: + # the writer rewrote the quoted line, the reader kept reporting the real + # `status: open`, and the close reported success while the entry — and any + # `gate:` it carries — stayed open forever. No default: `parse_ledger` is the + # only constructor, and a fallback here would silently restore that split. + status_span: tuple[int, int] | None @property def open(self) -> bool: @@ -215,6 +225,9 @@ def parse_ledger(text: str) -> list[DWEntry]: status=status_m.group(1).strip() if status_m else "", body=body, span=(m.start(), end), + status_span=( + (status_m.start() - m.start(), status_m.end() - m.start()) if status_m else None + ), ) ) return entries @@ -476,9 +489,8 @@ def _find_entry(text: str, dw_id: str) -> DWEntry | None: def _insert_after_status(text: str, entry: DWEntry, line: str) -> str: """Insert a field line right after the entry's status line (or at the end of the entry when no status line exists).""" - status_m = STATUS_RE.search(entry.body) - if status_m: - pos = entry.span[0] + status_m.end() + if entry.status_span: + pos = entry.span[0] + entry.status_span[1] return text[:pos] + "\n" + line + text[pos:] insert_at = entry.span[0] + len(entry.body.rstrip()) return text[:insert_at] + "\n" + line + text[insert_at:] @@ -596,11 +608,10 @@ def _apply_done( entry = _find_entry(text, dw_id) if entry is None or not entry.open: return None - status_m = STATUS_RE.search(entry.body) - assert status_m is not None # open implies a status line - start = entry.span[0] + status_m.start() - end = entry.span[0] + status_m.end() - previous_status_line = status_m.group(0) + assert entry.status_span is not None # open implies a status line + start = entry.span[0] + entry.status_span[0] + end = entry.span[0] + entry.status_span[1] + previous_status_line = entry.body[entry.status_span[0] : entry.status_span[1]] if undo_owner is not None and LINE_BREAK_RE.search(previous_status_line): # An undo marker must never preserve a value that becomes more than one line # under the ledger readers' shared splitlines semantics. Standard closes @@ -719,24 +730,24 @@ def mark_open(path: Path, dw_id: str, note: str, operation_id: str) -> bool: entry = _find_entry(text, dw_id) if entry is None or entry.open: return False - status_m = STATUS_RE.search(entry.body) - if status_m is None: + if entry.status_span is None: # parse_ledger deliberately tolerates status-less entries. This primitive # is later called from _defer, where an AttributeError would crash the run # instead of completing the deferral. return False + status_line = entry.body[entry.status_span[0] : entry.status_span[1]] try: _require_canonical_status(entry.status) except ValueError: # Only a canonical status written by mark_done is eligible for undo. # Preserve malformed or human-authored statuses for validation/reporting. return False - res_m = _MARK_DONE_TAIL_RE.match(entry.body, status_m.end()) + res_m = _MARK_DONE_TAIL_RE.match(entry.body, entry.status_span[1]) if res_m is None: return False if res_m.group(1).strip() != _one_line(note).strip() or res_m.group(2) != undo_owner: return False - if status_m.group(0) != f"status: done {res_m.group(3)}": + if status_line != f"status: done {res_m.group(3)}": return False try: previous_status_line = bytes.fromhex(res_m.group(4)).decode("utf-8") @@ -748,7 +759,7 @@ def mark_open(path: Path, dw_id: str, note: str, operation_id: str) -> bool: previous_status = previous_status_m.group(1).strip() if previous_status_m else "" if not previous_status or previous_status.split()[0] != "open": return False - start = entry.span[0] + status_m.start() + start = entry.span[0] + entry.status_span[0] end = entry.span[0] + res_m.end() atomic_write_text(path, text[:start] + previous_status_line + text[end:]) return True diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index eb43356b..b5f2aa44 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1788,6 +1788,72 @@ def test_a_stray_unclosed_fence_does_not_hide_legacy_findings_below_it(): assert legacy.title == "real finding" +QUOTED_STATUS_LEDGER = """\ +# Deferred Work + +### DW-1: an entry that quotes the format in an example + +summary: shows an operator what an entry looks like +evidence: e + +``` +status: open +``` + +status: open +gate: 3-2 +""" + + +def test_a_close_rewrites_the_live_status_and_not_a_quoted_one(tmp_path): + """The reader picks the status with a fence-aware lookup, so a writer that ran + `STATUS_RE.search(body)` again would pick the *first* raw match — the quoted + one. That split is worse than either half alone: `mark_done_many` reports the + id as closed while `open_ids` still lists it, so a sweep or story close can + never actually close the entry, and a `gate:` it carries refuses its story on + every following pass. Asserted on `open_ids` rather than on the return value, + because the return value is exactly what the bug got right.""" + path = write_ledger(tmp_path, QUOTED_STATUS_LEDGER) + + assert mark_done_many(path, ["DW-1"], "2026-06-11", "fixed") == ["DW-1"] + + text = path.read_text(encoding="utf-8") + assert open_ids(text) == set() + (entry,) = parse_ledger(text) + assert entry.status == "done 2026-06-11" + # the quoted example is documentation, and a close must not edit it + assert "```\nstatus: open\n```" in text + + +def test_a_reopen_restores_the_live_status_of_an_entry_that_quotes_an_example(tmp_path): + """The undo path reads its marker at an offset taken from the status line, so + it has to start from the same line the close wrote. Round-tripped rather than + asserted field-by-field: the close and the reopen must agree about *which* + line they own, and only the round trip pins that they do.""" + path = write_ledger(tmp_path, QUOTED_STATUS_LEDGER) + close_reopenable(path, "DW-1", "fixed") + assert open_ids(path.read_text(encoding="utf-8")) == set() + + assert mark_open(path, "DW-1", "fixed", OPERATION_ID) is True + + assert path.read_text(encoding="utf-8") == QUOTED_STATUS_LEDGER + + +def test_a_decision_lands_after_the_live_status_of_an_entry_that_quotes_an_example( + tmp_path, +): + """`_insert_after_status` is the third writer that used to re-derive the status + line. Inserting after the quoted one would bury the decision inside the fenced + example, where every reader — the parser and the human — treats it as prose.""" + path = write_ledger(tmp_path, QUOTED_STATUS_LEDGER) + + assert append_decision(path, "DW-1", "2026-06-11", "keep", "still worth doing") is True + + text = path.read_text(encoding="utf-8") + assert "```\nstatus: open\n```" in text + assert "status: open\ndecision: 2026-06-11 keep — still worth doing" in text + + def test_gate_token_shape_copy_agrees_with_the_stories_id_it_mirrors(): """`_STORIES_ID_RE` is a copy of `stories.ID_RE`, taken because `stories` imports this module and the reverse would cycle. Pinned to the original rather From 24a089e20e8d340e9686c9f2a254c0286dc00f79 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 11:57:19 -0700 Subject: [PATCH 23/34] fix(deferredwork,cli): hoist the fence walk out of the parse; gate on the scheduler's predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects codex raised on 831be5c, both introduced by this branch. `parse_ledger` asked for fence state per offset, and each ask re-walked the whole file — once per heading and several times per entry. That made the parse quadratic in entries (668 ms at 800 entries against main's 2 ms), and `Engine._refuse_gated_story` re-parses before every story dispatch, so a mature ledger paid it on the dispatch path. Walk once per parse and index the spans; a query is now a binary search, so a ledger whose examples grow with its entries stays linear too (800 entries: 2.7 ms plain, 3.3 ms with an example per entry). The bisect was differentially fuzzed against the linear membership test and against `fences.fenced` over 940k offsets. `_actionable_story_keys` treated every non-`done` stories-mode entry as dispatchable, but `blocked`, sentinel, ambiguous and unknown-status entries stop the scan (`SCHEDULE_WEDGED`) instead of dispatching. `validate` therefore exited nonzero over a gate on a story the queue could not run, and the two queue modes disagreed — the sprint arm's `ACTIONABLE_STATUSES` is a two-element allowlist that already excluded them. Share `stories._classify`, so preflight and dispatch answer alike. The docstring claiming parity with `ACTIONABLE_STATUSES` was false and is corrected. --- src/bmad_loop/cli.py | 16 +++++--- src/bmad_loop/deferredwork.py | 70 +++++++++++++++++++++++++++++------ tests/test_cli.py | 29 ++++++++++++++- tests/test_deferredwork.py | 30 ++++++++++++++- 4 files changed, 126 insertions(+), 19 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ffa8d4a9..20e98857 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1275,10 +1275,16 @@ def _actionable_story_keys( outside turned a degraded check into a traceback out of ``validate``. Stories mode has no status column — the manifest is a flat schedule and the - story's own spec carries the status — so a story whose spec reads ``done`` is - dropped here. That is the same line ``ACTIONABLE_STATUSES`` draws on the - sprint board, and without it a finished epic would fail ``validate`` forever - over gates on work that already landed. + story's own spec carries the status — so actionability comes from + :func:`stories._classify`, the predicate the scheduler itself picks with. + Dropping only ``done`` is not the same line the sprint board draws: + ``ACTIONABLE_STATUSES`` is a two-element allowlist, so ``blocked`` and the + rest are already out on that side. In stories mode a ``blocked``, sentinel, + ambiguous or unknown-status entry STOPS the scan (``SCHEDULE_WEDGED``) instead + of dispatching, so treating every non-``done`` state as actionable made + ``validate`` exit nonzero over a gate on a story the queue could not run — and + made the two queue modes disagree about what a gate refuses. Sharing the + scheduler's predicate is what keeps preflight and dispatch answering alike. """ if spec_folder is not None: keys: list[str] = [] @@ -1286,7 +1292,7 @@ def _actionable_story_keys( folder = stories_mod.resolve_spec_folder(paths.project, spec_folder) for entry in stories_mod.load_stories(folder).entries: state = stories_mod.resolve_story_spec(folder, entry.id) - if state.kind == stories_mod.KIND_PRESENT and state.status == stories_mod.DONE: + if stories_mod._classify(state) != "actionable": continue keys.append(entry.id) except (OSError, UnicodeDecodeError, stories_mod.StoriesError): diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index f17fd8db..0b433f03 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -13,6 +13,7 @@ import hashlib import re +from bisect import bisect_right from collections.abc import Sequence from dataclasses import dataclass from datetime import date as calendar_date @@ -147,10 +148,30 @@ def done(self) -> bool: return self.status.split()[0] == "done" if self.status else False -def _example(text: str, offset: int) -> bool: - """Whether ``offset`` sits in a fenced worked example rather than the ledger. +@dataclass(frozen=True) +class _Examples: + """The ledger's fenced worked examples, indexed for repeated offset queries. + + ``fenced_spans`` returns its ranges in increasing order and non-overlapping (a + fence cannot open inside an open one), so a query is a binary search for the + last span starting at or before the offset. Kept as an index rather than a bare + list because both scales are in play at once: a parse asks once per heading and + several times per entry, so a linear membership test would leave the parse + quadratic whenever a ledger's examples grow with its entries. + """ + + spans: tuple[tuple[int, int], ...] + starts: tuple[int, ...] - Asked at WHOLE-FILE scope, which is the whole point. A fence that opens above + def covers(self, offset: int) -> bool: + i = bisect_right(self.starts, offset) + return i > 0 and offset < self.spans[i - 1][1] + + +def _example_spans(text: str) -> _Examples: + """The ledger's fenced worked examples, as offset ranges. + + Read at WHOLE-FILE scope, which is the whole point. A fence that opens above a quoted ``### DW-n:`` heading is stranded in the *previous* entry once spans are carved, so an entry-local query reads the example as live — a phantom entry whose ``gate:`` refuses a story nobody deferred. `deferred-work-format.md` @@ -162,19 +183,41 @@ def _example(text: str, offset: int) -> bool: would erase every heading below it, dropping real open work out of ``open_ids()`` in silence. A phantom entry from an unterminated fence is today's behaviour and is visible; a vanished ledger is neither. + + Walked once per :func:`parse_ledger` and passed down to the offset checks. The + walk covers the whole file, so recomputing it per offset made the parse + quadratic in the number of entries — and `Engine._refuse_gated_story` re-parses + before every story dispatch, so a mature ledger paid it on the dispatch path. """ - return fenced(text, offset, unclosed_hides_rest=False) + spans = tuple(fenced_spans(text, unclosed_hides_rest=False)) + return _Examples(spans=spans, starts=tuple(s for s, _ in spans)) -def _unfenced(pattern: re.Pattern[str], text: str, start: int, end: int) -> re.Match[str] | None: +def _example(examples: _Examples, offset: int) -> bool: + """Whether ``offset`` sits in a fenced worked example rather than the ledger. + + Takes the index rather than the text: the answer must come from the same + whole-file walk for every offset in one parse, and a signature that re-derived + it per call is what made that expensive enough to matter. + """ + return examples.covers(offset) + + +def _unfenced( + pattern: re.Pattern[str], + text: str, + start: int, + end: int, + examples: _Examples, +) -> re.Match[str] | None: """First match of ``pattern`` within ``text[start:end]`` that is not quoted. Not `search()` plus a check: the first match may be the quoted one, and the real boundary sits after it. Bounded by ``endpos`` so a match beyond the span - cannot claim it, while ``_example`` still reads fence state from offset 0. + cannot claim it, while ``examples`` still describes fence state from offset 0. """ for m in pattern.finditer(text, start, end): - if not _example(text, m.start()): + if not _example(examples, m.start()): return m return None @@ -192,12 +235,13 @@ def parse_ledger(text: str) -> list[DWEntry]: exists to end. """ entries = [] - headings = [m for m in HEADING_RE.finditer(text) if not _example(text, m.start())] + examples = _example_spans(text) + headings = [m for m in HEADING_RE.finditer(text) if not _example(examples, m.start())] for i, m in enumerate(headings): end = headings[i + 1].start() if i + 1 < len(headings) else len(text) # an entry also ends at any intervening heading (e.g. a "## Deferred # from:" section header between freeform and DW-format content) - other = _unfenced(ANY_HEADING_RE, text, m.end(), end) + other = _unfenced(ANY_HEADING_RE, text, m.end(), end, examples) if other: end = other.start() # ...and at a flat appender block, which belongs to no canonical entry @@ -208,8 +252,10 @@ def parse_ledger(text: str) -> list[DWEntry]: # done (open_ids() drops it, classify() calls it malformed), which trades # one lost flat block for one lost tracked entry. An entry with no status # line has nothing to protect, so the whole span is fair game. - status_m = _unfenced(STATUS_RE, text, m.end(), end) - flat = _unfenced(FLAT_ENTRY_RE, text, status_m.end() if status_m else m.end(), end) + status_m = _unfenced(STATUS_RE, text, m.end(), end, examples) + flat = _unfenced( + FLAT_ENTRY_RE, text, status_m.end() if status_m else m.end(), end, examples + ) if flat: end = flat.start() body = text[m.start() : end] @@ -217,7 +263,7 @@ def parse_ledger(text: str) -> list[DWEntry]: # status must be the one inside the final span. Searched over `text` at # absolute offsets because `_example` reads fence state from the top of the # file — a body slice cannot see an opener that sits above the heading. - status_m = _unfenced(STATUS_RE, text, m.start(), end) + status_m = _unfenced(STATUS_RE, text, m.start(), end, examples) entries.append( DWEntry( id=m.group(1), diff --git a/tests/test_cli.py b/tests/test_cli.py index efa4b702..bca259df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4322,7 +4322,8 @@ def test_validate_reports_no_gate_all_clear_when_the_queue_is_unreadable(project def test_validate_stories_mode_skips_a_done_story(project, capsys): """The manifest carries no status — the story's own spec does. Without reading it, a finished epic would fail validate forever over gates on work that already - landed, which is the sprint arm's ACTIONABLE_STATUSES line drawn twice.""" + landed. `done` is only the clearest case of the general rule the sibling test + pins: actionability is `stories._classify`, not "anything but done".""" install_bmad_config(project) _write_policy(project.project, STORIES_POLICY) folder = _setup_stories_fixture(project, [_stories_entry("1")]) @@ -4338,6 +4339,32 @@ def test_validate_stories_mode_skips_a_done_story(project, capsys): assert len(findings) == 1 and findings[0]["severity"] == "ok" +@pytest.mark.parametrize("status", ["blocked", "opne"]) +def test_validate_stories_mode_skips_a_story_the_scheduler_would_wedge(project, capsys, status): + """A gate only means something for a story the queue can dispatch. `blocked` + and an unrecognized status both STOP the stories scan (`SCHEDULE_WEDGED`), so + refusing over them made `validate` exit nonzero about a story that could not + move, and made the two queue modes disagree — the sprint arm's + `ACTIONABLE_STATUSES` is a two-element allowlist that already excludes both. + + Parametrized over the two arms `_classify` reaches "wedged" by: a status it + knows and refuses, and one it cannot read at all. A single case would let the + other regress, since only the second depends on the unknown-status branch.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + folder = _setup_stories_fixture(project, [_stories_entry("1")]) + (folder / "stories" / "1-slug.md").write_text( + f"---\ntitle: 'test'\nstatus: '{status}'\n---\n\n## Intent\n\ntest\n", encoding="utf-8" + ) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}, commit=False) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + findings = _hard_gate_findings(capsys) + assert len(findings) == 1 and findings[0]["severity"] == "ok" + + OPENCODE_QUALIFIED_POLICY = '[adapter]\nname = "opencode"\nmodel = "anthropic/claude-haiku-4-5"\n' diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index b5f2aa44..b7db1ed1 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -4,7 +4,7 @@ import pytest -from bmad_loop import deferredwork +from bmad_loop import deferredwork, fences from bmad_loop.deferredwork import ( _ISO_DATE_RE, LINE_BREAK_RE, @@ -1854,6 +1854,34 @@ def test_a_decision_lands_after_the_live_status_of_an_entry_that_quotes_an_examp assert "status: open\ndecision: 2026-06-11 keep — still worth doing" in text +def test_parse_ledger_walks_the_fences_once_however_many_entries(monkeypatch): + """Asserted as a call count, not a duration: the property is that the fence + walk is hoisted out of the per-offset checks, and a timing threshold would + both flake and stop meaning that. Reading fence state per offset made + `parse_ledger` quadratic in entries, and `Engine._refuse_gated_story` re-parses + before every story dispatch, so a mature ledger paid it on the dispatch path. + + Both bindings are patched because the module imported the name at import time + (`from .fences import fenced_spans`), so patching only `fences` would miss the + direct call and only `deferredwork` would miss any walk reached via + `fences.fenced`.""" + walks: list[int] = [] + real = fences.fenced_spans + + def counting(text: str, **kw: object): + walks.append(len(text)) + return real(text, **kw) # type: ignore[arg-type] + + monkeypatch.setattr(fences, "fenced_spans", counting) + monkeypatch.setattr(deferredwork, "fenced_spans", counting) + text = "# Deferred Work\n" + "".join( + f"\n### DW-{i}: entry {i}\n\norigin: o\nreason: r\nstatus: open\n" for i in range(1, 26) + ) + + assert len(parse_ledger(text)) == 25 + assert len(walks) == 1 + + def test_gate_token_shape_copy_agrees_with_the_stories_id_it_mirrors(): """`_STORIES_ID_RE` is a copy of `stories.ID_RE`, taken because `stories` imports this module and the reverse would cycle. Pinned to the original rather From cab6f662c828a54b13b3501df2c0eb9606fea72c Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 11:58:51 -0700 Subject: [PATCH 24/34] test(deferredwork): pin the example index at its span bounds The off-by-one a differential fuzz caught passes the whole suite: no heading or field line starts at a span boundary, so no behavioural test reaches it. --- tests/test_deferredwork.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index b7db1ed1..8af6da35 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1854,6 +1854,25 @@ def test_a_decision_lands_after_the_live_status_of_an_entry_that_quotes_an_examp assert "status: open\ndecision: 2026-06-11 keep — still worth doing" in text +def test_the_example_index_answers_exactly_at_its_span_bounds(): + """The binary search replaced a linear `any(s <= offset < e)` test, and the + whole suite passes with its upper bound moved by one character — no heading or + field line ever begins at that offset, so no behavioural test can reach it. + Pinned directly for the same reason a differential fuzz found it and the tests + did not: an index that is right about every real offset and wrong about the + boundary is one refactor away from being wrong about a real one.""" + text = "before\n```\nquoted\n```\nafter\n" + examples = deferredwork._example_spans(text) + + ((start, end),) = examples.spans + assert examples.covers(start - 1) is False + assert examples.covers(start) is True + assert examples.covers(end - 1) is True + assert examples.covers(end) is False + # and the index must not answer for a ledger that quotes nothing + assert deferredwork._example_spans("### DW-1: t\nstatus: open\n").covers(0) is False + + def test_parse_ledger_walks_the_fences_once_however_many_entries(monkeypatch): """Asserted as a call count, not a duration: the property is that the fence walk is hoisted out of the per-offset checks, and a timing threshold would From b279fe6106d6de351786e0dfe4263d5370c9af47 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 12:42:55 -0700 Subject: [PATCH 25/34] fix(deferredwork): mask fenced examples out of the prose-gate scan too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gates()` skips a fenced `gate:` line because an entry documenting the field quotes it, but the sibling `HARD GATE:` scan still read the raw body — on the reasoning that its quote lookbehind was guard enough. That lookbehind is one character wide and reaches an inline citation only: nothing precedes a line inside a fence, so an entry explaining the old convention in an example warned about a gate it was not declaring. Filter it the way every other gate scan here is filtered, through a shared `_quoted` and a `declares_prose_gate` helper beside them, so the fence rule has one implementation rather than a second reader reaching for the bare pattern. --- src/bmad_loop/cli.py | 2 +- src/bmad_loop/deferredwork.py | 43 +++++++++++++++++++++++++++-------- tests/test_cli.py | 20 ++++++++++++++++ tests/test_deferredwork.py | 37 ++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 20e98857..e4503af5 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1243,7 +1243,7 @@ def _report_unstructured_gate( f"read (the field is a lowercase `gate:` at the very start of a line)" ) prose_only = not entry_gates.tokens and not reasons - if prose_only and deferredwork.HARD_GATE_PROSE_RE.search(entry.body): + if prose_only and deferredwork.declares_prose_gate(entry): reasons.append("declares a `HARD GATE:` in prose but carries no `gate:` line") if not reasons: return diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 0b433f03..e2a02721 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -94,6 +94,9 @@ # a ledger is markdown, so `HARD GATE:` is the citation form an author reaches for # first, and an LLM-written entry curls its quotes. Missing them made the warning # fire on entries documenting the convention — including this repo's own docs. +# The lookbehind only reaches an *inline* citation, though; the block form of the +# same quoting is a fence, and no character precedes a line inside one. Callers +# read this through `declares_prose_gate`, which masks those out. HARD_GATE_PROSE_RE = re.compile(r"""(? bool: return self.lines > 0 and not self.tokens and not self.malformed +def _quoted(body: str, offset: int) -> bool: + """Whether ``offset`` sits in a fenced example inside one entry's body. + + The single rule every gate scan in this module reads through, so that a fence + means the same thing to all of them: an entry documenting the field quotes it, + and a quoted example is not a declaration. Sharing it is the point — the prose + scan was left on the raw body once, on the reasoning that a warning is cheap + and its quote lookbehind was guard enough. It is not: that lookbehind reaches + an inline citation only, so an entry explaining the old convention in a fenced + block was told to convert a gate it was not declaring. + + ``unclosed_hides_rest=False`` because a stray unterminated fence must not be + able to silence a real ``gate:`` line below it (see :func:`fences.fenced`). + """ + return fenced(body, offset, unclosed_hides_rest=False) + + +def declares_prose_gate(entry: DWEntry) -> bool: + """Whether the entry declares a gate in the pre-``gate:`` prose convention. + + :data:`HARD_GATE_PROSE_RE` filtered the way every other gate scan here is + filtered. Lives beside them rather than at the caller so the fence rule has + one implementation: ``validate`` is the only reader today, and a second one + reaching for the bare pattern would reintroduce exactly the half-applied rule + this replaced. + """ + return any(not _quoted(entry.body, m.start()) for m in HARD_GATE_PROSE_RE.finditer(entry.body)) + + def gates(entry: DWEntry) -> EntryGates: """Every ``gate:`` token in one entry's canonical span, order-preserving. @@ -342,15 +374,8 @@ def gates(entry: DWEntry) -> EntryGates: # Both scans below skip fenced matches: an entry documenting this field quotes # it, and a quoted example is not a declaration — a fenced `gate: 3-2` sits in # column 0, right where the anchor looks, and the answer here is a *refusal*. - # `unclosed_hides_rest=False` because a stray unterminated fence must not be - # able to silence a real `gate:` line below it (see `fences.fenced`). Only the - # two `gate:` scans are filtered — `HARD_GATE_PROSE_RE` deliberately still - # reads the raw body: it warns rather than refuses, and carries a quote guard. - def _quoted(offset: int) -> bool: - return fenced(entry.body, offset, unclosed_hides_rest=False) - for m in GATE_RE.finditer(entry.body): - if _quoted(m.start()): + if _quoted(entry.body, m.start()): continue lines += 1 named = False @@ -370,7 +395,7 @@ def _quoted(offset: int) -> bool: # already counted above — without re-running the anchor against a slice. not entry.body.startswith("gate:", m.start()) for m in _GATE_NEAR_RE.finditer(entry.body) - if not _quoted(m.start()) + if not _quoted(entry.body, m.start()) ) return EntryGates( tokens=tuple(tokens), diff --git a/tests/test_cli.py b/tests/test_cli.py index bca259df..cd133271 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4150,6 +4150,26 @@ def test_validate_warns_on_a_mid_line_hard_gate(project, capsys): assert len(unstructured) == 1 and unstructured[0]["severity"] == "warning" +def test_validate_ignores_a_hard_gate_quoted_in_a_fenced_example(project, capsys): + """The citation an author writes as a block rather than inline. `gates()` + already masks a fenced `gate:` out of the refusal; leaving the prose scan on + the raw body made the entry documenting the migration — the one place both + spellings appear together — warn about its own example.""" + findings = _validate_gated_sprint( + project, + capsys, + {"3-2-invite-link": "ready-for-dev"}, + { + "DW-1": ( + "open", + ["reason: documents the old convention:", "```", "HARD GATE: before 3-2", "```"], + ) + }, + ) + + assert not [f for f in findings if f["check"].startswith("deferred.hard-gate")] + + def test_validate_ignores_an_entry_that_only_cites_a_hard_gate(project, capsys): """An entry *about* the convention is not declaring one — the ledger's own "no mechanical check enforces a HARD GATE" entry must not warn about itself. diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index 8af6da35..d5cc3b3f 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -2055,3 +2055,40 @@ def test_hard_gate_prose_detects_a_declaration_not_a_citation(body, declared): entry *citing* the phrase is discussion — and the colon excludes prose that merely talks about a hard gate.""" assert bool(deferredwork.HARD_GATE_PROSE_RE.search(body)) is declared + + +def _prose_gated(*lines: str) -> bool: + text = ( + "# Deferred Work\n\n### DW-1: gated entry\n\n" + "origin: test\nlocation: n/a\nreason: test\nstatus: open\n" + + "".join(f"{x}\n" for x in lines) + ) + (entry,) = parse_ledger(text) + return deferredwork.declares_prose_gate(entry) + + +@pytest.mark.parametrize("fence", ["```markdown", "```", "~~~"]) +def test_a_fenced_prose_gate_is_not_a_declaration(fence): + """The block form of the citation the quote lookbehind already handles inline. + Nothing precedes a line inside a fence, so an entry documenting the old + convention in an example was told to convert a gate it was not declaring — + the same rule `gates()` applies to `gate:`, left half-applied.""" + close = "```" if fence.startswith("`") else "~~~" + body = f"{fence}\nHARD GATE: must land before 3-2\n{close}\n" + + # the pattern itself still matches: the mask is what answers, not a lucky miss + assert deferredwork.HARD_GATE_PROSE_RE.search(body) + assert _prose_gated(fence, "HARD GATE: must land before 3-2", close) is False + + +def test_a_fence_hides_only_the_prose_gate_it_quotes(): + """Masking must not reach past the block, or the fix for a spurious warning + would buy a missed one — an entry that both explains the convention and uses + it is exactly the entry this warning is for.""" + assert _prose_gated("```", "HARD GATE: an example", "```", "HARD GATE: for real") is True + + +def test_an_unclosed_fence_swallows_no_prose_gate(): + """Parity with `gates()`: `unclosed_hides_rest=False`, so one stray ``` cannot + silence every declaration below it.""" + assert _prose_gated("```", "an example nobody closed", "HARD GATE: for real") is True From 416bbaf88eadcf19c727871c076d8c3c0e4a718f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 12:57:58 -0700 Subject: [PATCH 26/34] docs(gate): describe stories-mode gating as the scheduler's predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The format guide still said `validate` refuses a gated manifest entry whose spec is "not yet done" — the behaviour before the preflight arm started sharing `stories._classify`. It now excludes every state the scan would wedge on, so an operator reading this could expect a refusal over a `blocked` story that is never emitted. Name the states that actually gate, and say why the wedged ones do not: the queue cannot reach that story, so refusing it would report work held back that was never going to run. --- .../skills/bmad-loop-sweep/deferred-work-format.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md index dde853a6..da98933c 100644 --- a/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md +++ b/src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md @@ -103,9 +103,14 @@ otherwise touched: removing it un-gates the story silently, which is the exact failure this field exists to prevent. Until the entry lands, the gate is enforced twice. `bmad-loop validate` **fails** -(`deferred.hard-gate`) for every actionable story a token matches — sprint-status -stories at `backlog` / `ready-for-dev`, or manifest entries whose spec is not yet -`done` — and a `run` that never called `validate` **pauses** (`story-gate`) rather +(`deferred.hard-gate`) for every story a token matches that the queue would +actually dispatch — sprint-status stories at `backlog` / `ready-for-dev`, or +manifest entries whose spec is not yet written or sits at `draft` / +`ready-for-dev` / `in-progress` / `in-review`. A `blocked` manifest entry is not +gated, nor is one the scheduler would stop on anyway (two specs matching one id, +or a skeletal sentinel from a failed planning halt): the queue cannot reach that +story, so a gate refusing it would report work held back that was never going to +run. A `run` that never called `validate` **pauses** (`story-gate`) rather than dispatch a gated story. Two things clear it: closing the entry (`status: done `), or removing the token because it no longer blocks that work. This is the one deferred-work check that gates rather than advises: From bae8f1f3c443feabd5ac3fa438995f29b33b3da3 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 13:14:09 -0700 Subject: [PATCH 27/34] docs(validate): say which half of the scheduler preflight shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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 ` 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. --- src/bmad_loop/cli.py | 25 +++++++++++++++++++------ tests/test_cli.py | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index e4503af5..a345a639 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1264,7 +1264,7 @@ def _report_unstructured_gate( def _actionable_story_keys( paths: bmadconfig.ProjectPaths, spec_folder: str | None ) -> list[str] | None: - """The story keys this queue would dispatch, in queue order, in either mode. + """The story keys this queue could dispatch, in queue order, in either mode. ``None`` when the queue could not be read, which is not the same answer as an empty list: ``queue.sprint-status`` and ``queue.stories-manifest`` own queue @@ -1280,11 +1280,24 @@ def _actionable_story_keys( Dropping only ``done`` is not the same line the sprint board draws: ``ACTIONABLE_STATUSES`` is a two-element allowlist, so ``blocked`` and the rest are already out on that side. In stories mode a ``blocked``, sentinel, - ambiguous or unknown-status entry STOPS the scan (``SCHEDULE_WEDGED``) instead - of dispatching, so treating every non-``done`` state as actionable made - ``validate`` exit nonzero over a gate on a story the queue could not run — and - made the two queue modes disagree about what a gate refuses. Sharing the - scheduler's predicate is what keeps preflight and dispatch answering alike. + ambiguous or unknown-status entry is one :func:`stories.schedule` refuses to + dispatch (``SCHEDULE_WEDGED``), so treating every non-``done`` state as + actionable made ``validate`` exit nonzero over a gate on a story the queue + could not run — and made the two queue modes disagree about what a gate + refuses. + + What is shared is that per-entry predicate and deliberately NOT the scan's + stop rule: ``schedule`` gives up at the FIRST wedged entry, and mirroring that + here would drop every later story from this list. Two reasons not to. A wedge + is a property of some *other* story, and ``run --story `` scans that entry + alone (``selector``), so a later story really is reachable while the wedge + stands — while ``validate`` takes no story selector and so 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, which is the failure + this check exists to prevent, arriving by a quieter route than the one it + fixed. Over-reporting a real gate on a story that needs an unrelated + resolution first is the cheaper wrong answer, and dispatch still refuses + independently. """ if spec_folder is not None: keys: list[str] = [] diff --git a/tests/test_cli.py b/tests/test_cli.py index cd133271..b0e91986 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4385,6 +4385,30 @@ def test_validate_stories_mode_skips_a_story_the_scheduler_would_wedge(project, assert len(findings) == 1 and findings[0]["severity"] == "ok" +def test_validate_stories_mode_still_gates_a_story_behind_a_wedged_one(project, capsys): + """The deliberate half of the parity, pinned so it is not "fixed" into a + `break`. `schedule()` gives up at the first wedged entry, but preflight shares + its per-entry predicate and NOT its stop rule: story 2 is reachable right now + via `run --story 2`, which scans that entry alone, and `validate` takes no + story selector so it cannot know which run is coming. Stopping here would also + let one blocked entry at the top of a manifest silence the gate check for + every story below it — the silent miss this field exists to end.""" + install_bmad_config(project) + _write_policy(project.project, STORIES_POLICY) + folder = _setup_stories_fixture(project, [_stories_entry("1"), _stories_entry("2")]) + (folder / "stories" / "1-slug.md").write_text( + "---\ntitle: 'test'\nstatus: 'blocked'\n---\n\n## Intent\n\ntest\n", encoding="utf-8" + ) + write_gated_ledger(project, {"DW-1": ("open", ["gate: 2"])}, commit=False) + args = argparse.Namespace(project=str(project.project), spec=None, json=True) + + cli.cmd_validate(args) + + findings = _hard_gate_findings(capsys) + assert len(findings) == 1 and findings[0]["severity"] == "problem" + assert findings[0]["detail"]["story_key"] == "2" + + OPENCODE_QUALIFIED_POLICY = '[adapter]\nname = "opencode"\nmodel = "anthropic/claude-haiku-4-5"\n' From 4b79edeee5c70f982e9ab232367fb35650447ca6 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 13:56:37 -0700 Subject: [PATCH 28/34] fix(deferredwork): ask the gate scans' fence question at file scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/bmad_loop/deferredwork.py | 34 +++++++++++++++++++++++++--------- tests/test_deferredwork.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index e2a02721..47fbf428 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -20,7 +20,7 @@ from pathlib import Path from . import sprintstatus -from .fences import fenced, fenced_spans +from .fences import fenced_spans from .platform_util import atomic_write_text HEADING_RE = re.compile(r"^### (DW-\d+): (.+?)\s*$", re.MULTILINE) @@ -131,6 +131,15 @@ class DWEntry: # `gate:` it carries — stayed open forever. No default: `parse_ledger` is the # only constructor, and a fallback here would silently restore that split. status_span: tuple[int, int] | None + # The whole-file fence index the entry was carved with, so the gate scans can + # ask the question the heading and status reads already ask at file scope. A + # body slice cannot see a fence opened above the heading, and the two views + # disagree: under a stray unclosed ``` above the heading, whole-file scope + # treats the opener as text (so this entry EXISTS) while the body sees a later + # matched `~~~` pair as a real fence and reads a live `gate:` as an example. + # That direction loses a gate in silence, which is what the field exists to + # end. No default, for `status_span`'s reason: the fallback IS the bug. + examples: _Examples @property def open(self) -> bool: @@ -277,6 +286,7 @@ def parse_ledger(text: str) -> list[DWEntry]: status_span=( (status_m.start() - m.start(), status_m.end() - m.start()) if status_m else None ), + examples=examples, ) ) return entries @@ -317,8 +327,8 @@ def inert(self) -> bool: return self.lines > 0 and not self.tokens and not self.malformed -def _quoted(body: str, offset: int) -> bool: - """Whether ``offset`` sits in a fenced example inside one entry's body. +def _quoted(entry: DWEntry, offset: int) -> bool: + """Whether a BODY-relative ``offset`` sits in a fenced example. The single rule every gate scan in this module reads through, so that a fence means the same thing to all of them: an entry documenting the field quotes it, @@ -328,10 +338,16 @@ def _quoted(body: str, offset: int) -> bool: an inline citation only, so an entry explaining the old convention in a fenced block was told to convert a gate it was not declaring. - ``unclosed_hides_rest=False`` because a stray unterminated fence must not be - able to silence a real ``gate:`` line below it (see :func:`fences.fenced`). + Asked at FILE scope, like the heading and status reads in :func:`parse_ledger` + and for the same reason: a body slice cannot see a fence opened above the + heading, so the two views can disagree about the same line. They disagree in + the direction that matters — a stray unclosed ``` above the heading leaves the + entry standing at file scope while the body reads a later matched ``~~~`` pair + as a real fence, masking a live ``gate:`` into an example. A gate lost in + silence is the failure this field exists to end; a spurious refusal in an entry + whose markdown is already malformed is the cheaper wrong answer. """ - return fenced(body, offset, unclosed_hides_rest=False) + return entry.examples.covers(entry.span[0] + offset) def declares_prose_gate(entry: DWEntry) -> bool: @@ -343,7 +359,7 @@ def declares_prose_gate(entry: DWEntry) -> bool: reaching for the bare pattern would reintroduce exactly the half-applied rule this replaced. """ - return any(not _quoted(entry.body, m.start()) for m in HARD_GATE_PROSE_RE.finditer(entry.body)) + return any(not _quoted(entry, m.start()) for m in HARD_GATE_PROSE_RE.finditer(entry.body)) def gates(entry: DWEntry) -> EntryGates: @@ -375,7 +391,7 @@ def gates(entry: DWEntry) -> EntryGates: # it, and a quoted example is not a declaration — a fenced `gate: 3-2` sits in # column 0, right where the anchor looks, and the answer here is a *refusal*. for m in GATE_RE.finditer(entry.body): - if _quoted(entry.body, m.start()): + if _quoted(entry, m.start()): continue lines += 1 named = False @@ -395,7 +411,7 @@ def gates(entry: DWEntry) -> EntryGates: # already counted above — without re-running the anchor against a slice. not entry.body.startswith("gate:", m.start()) for m in _GATE_NEAR_RE.finditer(entry.body) - if not _quoted(entry.body, m.start()) + if not _quoted(entry, m.start()) ) return EntryGates( tokens=tuple(tokens), diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index d5cc3b3f..fe795af0 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -1602,6 +1602,36 @@ def test_a_fenced_example_is_not_a_gate_declaration(fence): assert g.tokens == () and g.near_miss == 0 and g.lines == 0 +@pytest.mark.parametrize(("outer", "inner"), [("```", "~~~"), ("~~~", "```")]) +def test_a_stray_opener_above_the_heading_does_not_mask_a_live_gate(outer, inner): + """The two views of the same line, and the reason `_quoted` asks at FILE scope. + + The stray `outer` opener never closes (`inner` is the other fence char and + cannot close it), and at whole-file scope `unclosed_hides_rest=False` reads it + as ordinary text — which is why the heading below it still carves an entry. A + body slice starts at that heading, cannot see the opener, and so reads the + matched `inner` pair as a real fence, masking the `gate:` between them into an + example. That drops a live gate in silence, which is the failure the field + exists to end; `parse_ledger` already reads headings and `status:` at file + scope for exactly this reason. Found by differential fuzz against the + whole-file predicate, not by inspection.""" + text = f"# Deferred Work\n\n{outer}\n### DW-2: title\n{inner}\ngate: 3-2\n{inner}\n" + + (entry,) = parse_ledger(text) + + assert deferredwork.gates(entry).tokens == ("3-2",) + + +def test_a_stray_opener_above_the_heading_does_not_mask_a_prose_gate(): + """The prose scan shares `_quoted`, so it shares the file-scope question too — + pinned separately because the two scans reach it by different call paths.""" + text = "# Deferred Work\n\n```\n### DW-2: title\n~~~\nHARD GATE: before 3-2\n~~~\n" + + (entry,) = parse_ledger(text) + + assert deferredwork.declares_prose_gate(entry) is True + + def test_a_fence_hides_only_itself(): """The mask must not reach past the block. A real declaration on either side of a quoted example still gates — otherwise the fix for a false refusal would have From 17282673c12f2d7b55ac89f7a71ff18789254e41 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 21:10:49 -0700 Subject: [PATCH 29/34] fix(engine): re-ask the story gate for a task registered but never started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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. --- README.md | 2 +- src/bmad_loop/engine.py | 40 +++++++++++++++--- tests/test_engine.py | 91 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 868627fa..2b723c87 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ Until that entry lands, `bmad-loop validate` **fails** for every actionable stor Only an explicit `status: done ` retires a gate. An entry whose status the format cannot read — `status: opne`, or no `status:` line at all — still gates, because an unreadable status is not evidence the work landed; letting it read as closed would have meant one keystroke silently disabling the refusal. -The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story already in flight when a run resumes finishes rather than stranding a half-done session; the gate is about work that must not _start_. +The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story whose session already ran finishes when the run resumes, rather than stranding a half-done session; the gate is about work that must not _start_. That line is drawn by session and not by the run's own bookkeeping — a story a crashed run had recorded but never started is re-asked on resume, so a gate landing while the run was down still stops it. Four shapes declare a gate nothing can enforce, and each is a warning while the entry is unlanded: a token that cannot name a story key (a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones, or an unmatchable `gate: 3.2` — note `.` and `_` are fine inside a sprint slug, so `gate: 3-2-a_b` is a real gate); a `gate:` line with nothing usable after the colon; a `gate:` not written lowercase at the very start of a line (`Gate:`, or indented — surfaced rather than accepted, so a fenced example inside an entry cannot become a refusal); and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 7cfb6901..154d07e5 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -896,11 +896,20 @@ def _refuse_gated_story(self, story_key: str) -> None: never reaches this call — it must not, because the sweep is the only automated closer of the gating entry (``sweep.py`` `_close_resolved` / bundle close), and gating the sweep would deadlock the gate against its own - remedy. ``_finish_inflight`` runs before the loop, so a story already - in-flight when the gate appeared finishes rather than stranding a half-done - session with a live worktree; the gate applies to work that must not - *start*, which is the same line ``validate`` draws when it passes a story - the board has already finished. + remedy. And a story whose session has already run finishes on resume rather + than stranding a half-done session with a live worktree; the gate applies to + work that must not *start*, which is the same line ``validate`` draws when + it passes a story the board has already finished. + + That second exemption is drawn by session, not by the presence of a task + record. ``_finish_inflight`` runs before the loop and drives every + non-terminal task, including one this loop registered and never started — + the window from ``_loop``'s task save through the worktree mount. Such a task + is a first dispatch wearing a resume's clothes, so the restart arm re-asks + this gate for it (``attempt == 0``); ``_pick_next`` cannot, having skipped + the key as touched. Without that, a gate landing while the run was down + would never be asked, and the one deferred check that refuses would be + disabled by the run's own crash. """ ledger = self.paths.deferred_work try: @@ -1213,6 +1222,21 @@ def _finish_inflight(self) -> None: else: self._finalize_commit_phase(task) else: + # Is this a restart of work, or a first start wearing a task + # record? `_loop` saves the task and *then* calls `_run_story`, + # and nothing advances until `_dev_phase` bumps `attempt` in the + # same save as the DEV_RUNNING advance — so everything from that + # save through the isolated worktree mount and the pre_story + # hooks persists a task no session ever touched. `attempt == 0` + # is exactly that state. (Not `sessions`: a record is written + # after a session returns, so a host 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 — 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 self.journal.append( "resume-restart", story_key=task.story_key, phase=str(task.phase) ) @@ -1231,6 +1255,12 @@ def _finish_inflight(self) -> None: task.rearmed = False # past rollback (only reached when not paused) task.phase = Phase.PENDING # deliberate reset, not a normal transition self._save() + if never_dispatched: + # Asked after the unwinding above and the save, so the task is + # in the same shape `_loop` hands to `_run_story` — no worktree + # to strand behind a pause that may last days, and the refusal + # re-asks on every later resume until the entry lands. + self._refuse_gated_story(task.story_key) self._run_story(task) # a resumed story that just reached DONE gets the same post-story hook # the _loop path fires (e.g. the stories-mode done_checkpoint pause), diff --git a/tests/test_engine.py b/tests/test_engine.py index e31af59c..4ffd710d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6573,6 +6573,97 @@ def test_dispatch_pauses_when_the_ledger_cannot_be_read(project, monkeypatch): assert [e["kind"] for e in engine.journal.entries()].count("story-gate-unreadable") == 1 +def test_resume_re_gates_a_story_registered_but_never_started(project): + """`_loop` saves the task and *then* calls `_run_story`, so a host death in + that window — or anywhere before `_dev_phase`'s advance, which spans the + isolated worktree mount — persists a PENDING task no session ever touched. + `_pick_next` skips it (it is in `base_skip`), so only `_finish_inflight` + drives it, and its restart arm calls `_run_story` directly. A gate that + landed while the run was down would never be asked. + + The exemption below is for work already *in flight*; this task is not. Its + state is byte-identical to one the loop would have re-picked and re-gated a + microsecond earlier, and the run's own crash is not a reason to skip it. + """ + write_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, []) + # exactly what _loop persists between `state.tasks[key] = task` and _run_story + engine.state.tasks["1-1-a"] = StoryTask(story_key="1-1-a", epic=1) + engine._save() + # the gate lands while the run is down + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + resumed, adapter = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = resumed.run() + + assert summary.paused and summary.done == 0 + assert adapter.sessions == [] + saved = load_state(resumed.run_dir) + assert saved.paused_stage == PAUSE_STORY_GATE + assert saved.paused_story_key == "1-1-a" + + +def test_resume_still_finishes_a_story_that_was_already_in_flight(project): + """The other side of the line, and the exemption stated as behavior: a story + whose session already ran finishes even under a gate that landed mid-flight. + Gating here would strand half-done work — a live worktree, an unmerged + branch — behind an entry whose remedy is a *later* story. The gate stops work + from starting; it does not abandon work in progress.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, []) + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_RUNNING, attempt=1) + engine.state.tasks["1-1-a"] = task + engine._save() + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + resumed, adapter = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = resumed.run() + + assert summary.done == 1 and not summary.paused + assert adapter.sessions # the in-flight story ran to completion + + +def test_resume_does_not_gate_a_human_armed_re_drive(project): + """`rearm_escalation` resets `attempt` to 0 on a task that has already run + sessions, so `attempt == 0` alone would read a human-resolved re-drive as a + first dispatch and refuse it. It stays exempt for the same reason every other + resume of started work does — and refusing here would be worse than a missed + gate: the operator resolved the escalation precisely to get this story moving, + and the entry gating it is the one the re-drive may be about to close.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + escalating = SessionResult( + status="completed", + result_json={ + "workflow": "auto-dev", + "escalations": [{"type": "missing-config", "severity": "CRITICAL", "detail": "boom"}], + }, + ) + engine, _ = make_engine(project, [escalating]) + assert engine.run().escalated == 1 + rearm_escalation(engine.run_dir) # the resolve workflow's re-arm step + assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 # the confusable state + # a gate lands on the story while the operator is resolving it + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + resumed, adapter = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = resumed.run() + + assert summary.done == 1 and not summary.paused + assert adapter.sessions # the re-drive ran + + def test_epic_boundary_gate_pause_and_resume(project): write_sprint( project, From c6b0a53f84527ab8b666cda6690d3211edb69aa0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 21:31:32 -0700 Subject: [PATCH 30/34] fix(engine): ask the story gate for the whole resume-restart arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- src/bmad_loop/engine.py | 69 ++++++++++++++++++------------------ tests/test_engine.py | 67 +++++++++++++++++++++------------- tests/test_stories_engine.py | 40 ++++++++++++++++++++- 4 files changed, 117 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 2b723c87..fe158d89 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ Until that entry lands, `bmad-loop validate` **fails** for every actionable stor Only an explicit `status: done ` retires a gate. An entry whose status the format cannot read — `status: opne`, or no `status:` line at all — still gates, because an unreadable status is not evidence the work landed; letting it read as closed would have meant one keystroke silently disabling the refusal. -The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story whose session already ran finishes when the run resumes, rather than stranding a half-done session; the gate is about work that must not _start_. That line is drawn by session and not by the run's own bookkeeping — a story a crashed run had recorded but never started is re-asked on resume, so a gate landing while the run was down still stops it. +The dispatch pause (`story-gate`, reviewable in the TUI like any other gate) fires before the story is recorded as touched, so closing the entry — by hand or with `bmad-loop sweep` — and resuming runs it. **Sweeps themselves are never gated**: a sweep is what closes the gating entry, so gating it would deadlock the gate against its own remedy. A story whose session already completed **finishes** when the run resumes — its recorded result replays through to commit rather than stranding half-done work; the gate is about work that must not _start_. A resume that instead **restarts** a story, discarding its worktree or resetting to baseline and re-running from scratch, is a start and is asked again: so a gate landing while a run was down still stops the story that run had picked but never got underway, and stops a re-drive of one whose escalation or wedge you have just resolved. Four shapes declare a gate nothing can enforce, and each is a warning while the entry is unlanded: a token that cannot name a story key (a space-separated `gate: 3-2 3-3`, which is one bad token rather than two good ones, or an unmatchable `gate: 3.2` — note `.` and `_` are fine inside a sprint slug, so `gate: 3-2-a_b` is a real gate); a `gate:` line with nothing usable after the colon; a `gate:` not written lowercase at the very start of a line (`Gate:`, or indented — surfaced rather than accepted, so a fenced example inside an entry cannot become a refusal); and prose declaring `HARD GATE:` on an entry that carries no `gate:` line. The prose arm matches mid-line, because `reason:` prose is hard-wrapped and that is where a real declaration lands — but not directly after a quote character, so an entry that merely cites the phrase stays silent. diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 154d07e5..f95ee7d0 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -896,20 +896,25 @@ def _refuse_gated_story(self, story_key: str) -> None: never reaches this call — it must not, because the sweep is the only automated closer of the gating entry (``sweep.py`` `_close_resolved` / bundle close), and gating the sweep would deadlock the gate against its own - remedy. And a story whose session has already run finishes on resume rather - than stranding a half-done session with a live worktree; the gate applies to - work that must not *start*, which is the same line ``validate`` draws when - it passes a story the board has already finished. - - That second exemption is drawn by session, not by the presence of a task - record. ``_finish_inflight`` runs before the loop and drives every - non-terminal task, including one this loop registered and never started — - the window from ``_loop``'s task save through the worktree mount. Such a task - is a first dispatch wearing a resume's clothes, so the restart arm re-asks - this gate for it (``attempt == 0``); ``_pick_next`` cannot, having skipped - the key as touched. Without that, a gate landing while the run was down - would never be asked, and the one deferred check that refuses would be - disabled by the run's own crash. + remedy. And a resumed story *finishes* rather than stranding a half-done + session with a live worktree; the gate applies to work that must not + *start*, which is the same line ``validate`` draws when it passes a story + the board has already finished. + + That second exemption belongs to ``_finish_inflight``'s finishing arms — + the defer replay, the spec-approval continuation, the recorded-session + replay, the commit completion — and not to its restart arm, which finishes + nothing: it discards the worktree (or resets to baseline) and re-runs the + story from scratch. So the restart arm re-asks this gate, and + unconditionally. ``_pick_next`` cannot ask for it, having skipped the key as + touched, and the run's own crash must not be what disables the one deferred + check that refuses. + + Deliberately no "but did a session really run?" test there. Every available + signal is wrong somewhere: ``attempt`` is bumped before the session launches, + ``sessions`` is written only after one returns, and ``rearmed`` covers a + stories-mode wedge (``StoriesEngine._pause_wedged``) that reaches ESCALATED + with no session at all. The arm's own unwinding is the stronger guarantee. """ ledger = self.paths.deferred_work try: @@ -1222,21 +1227,6 @@ def _finish_inflight(self) -> None: else: self._finalize_commit_phase(task) else: - # Is this a restart of work, or a first start wearing a task - # record? `_loop` saves the task and *then* calls `_run_story`, - # and nothing advances until `_dev_phase` bumps `attempt` in the - # same save as the DEV_RUNNING advance — so everything from that - # save through the isolated worktree mount and the pre_story - # hooks persists a task no session ever touched. `attempt == 0` - # is exactly that state. (Not `sessions`: a record is written - # after a session returns, so a host 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 — 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 self.journal.append( "resume-restart", story_key=task.story_key, phase=str(task.phase) ) @@ -1255,12 +1245,21 @@ def _finish_inflight(self) -> None: task.rearmed = False # past rollback (only reached when not paused) task.phase = Phase.PENDING # deliberate reset, not a normal transition self._save() - if never_dispatched: - # Asked after the unwinding above and the save, so the task is - # in the same shape `_loop` hands to `_run_story` — no worktree - # to strand behind a pause that may last days, and the refusal - # re-asks on every later resume until the entry lands. - self._refuse_gated_story(task.story_key) + # This arm is the one that does not finish work: the lines above + # discarded the worktree or reset the tree to baseline, so the task + # now holds nothing a session produced and is about to be re-run + # from scratch — the same shape `_loop` hands to `_run_story`. That + # makes the gate question the same one `_loop` asks, and asking it + # unconditionally is what keeps it honest: any test for "did a + # session really run?" would be inferring what this arm has already + # guaranteed, and each such test is wrong somewhere. `attempt` is + # bumped before the session launches (so a death in between reads as + # started); `sessions` is written after one returns (so a death + # inside one reads as never started); `rearmed` covers a stories-mode + # 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) self._run_story(task) # a resumed story that just reached DONE gets the same post-story hook # the _loop path fires (e.g. the stories-mode done_checkpoint pause), diff --git a/tests/test_engine.py b/tests/test_engine.py index 4ffd710d..3b98e5ac 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6607,37 +6607,55 @@ def test_resume_re_gates_a_story_registered_but_never_started(project): assert saved.paused_story_key == "1-1-a" -def test_resume_still_finishes_a_story_that_was_already_in_flight(project): - """The other side of the line, and the exemption stated as behavior: a story - whose session already ran finishes even under a gate that landed mid-flight. - Gating here would strand half-done work — a live worktree, an unmerged - branch — behind an entry whose remedy is a *later* story. The gate stops work - from starting; it does not abandon work in progress.""" +def test_resume_still_finishes_a_story_whose_session_already_completed(project): + """The other side of the line, and the exemption stated as behavior. It belongs + to `_finish_inflight`'s *finishing* arms, not to every non-terminal task: here + the review session completed and its result is on disk, so the resume replays + that record straight into the decision path. Gating it would abandon a verified + session's work over an entry whose remedy is a later story — and the story is + not starting, it is ending. The restart arm is the opposite case: it discards + the work and re-runs, so it re-asks (the tests above).""" write_sprint(project, {"1-1-a": "ready-for-dev"}) - engine, _ = make_engine(project, []) - task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_RUNNING, attempt=1) - engine.state.tasks["1-1-a"] = task - engine._save() - write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) - - resumed, adapter = resume_engine( + engine, _ = make_engine( project, - engine, [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], ) + post_sessions = [] + original_emit = engine._emit + + def crashing_emit(stage, *args, **kwargs): + if stage == "post_session": + post_sessions.append(stage) + if len(post_sessions) == 2: # the review session's post_session window + raise RuntimeError("host died in the post-session window") + return original_emit(stage, *args, **kwargs) + + engine._emit = crashing_emit + assert engine.run().crashed + assert load_state(engine.run_dir).tasks["1-1-a"].phase == Phase.REVIEW_RUNNING + # the gate lands while the run is down, on a story whose work is already done + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + resumed, adapter = resume_engine(project, engine, []) summary = resumed.run() assert summary.done == 1 and not summary.paused - assert adapter.sessions # the in-flight story ran to completion + assert adapter.sessions == [] # replayed the recorded result; nothing re-run + kinds = [e["kind"] for e in resumed.journal.entries()] + assert "resume-verify" in kinds and "resume-restart" not in kinds + +def test_resume_re_gates_a_human_armed_re_drive(project): + """A resolved escalation re-drives through the restart arm — the escalated + attempt is rolled back and the story re-runs from scratch — so it is a start, + and the gate is asked. Resolving an escalation is not evidence that the gating + entry landed, and `validate` refuses this story on the same ledger no matter + what its task record remembers; the two surfaces have to agree. -def test_resume_does_not_gate_a_human_armed_re_drive(project): - """`rearm_escalation` resets `attempt` to 0 on a task that has already run - sessions, so `attempt == 0` alone would read a human-resolved re-drive as a - first dispatch and refuse it. It stays exempt for the same reason every other - resume of started work does — and refusing here would be worse than a missed - gate: the operator resolved the escalation precisely to get this story moving, - and the entry gating it is the one the re-drive may be about to close.""" + `rearmed` is also the signal a "has this story ever run a session?" test would + most want to trust, and it cannot be trusted: `StoriesEngine._pause_wedged` + reaches ESCALATED with `attempt == 0` and no session at all, so exempting + re-drives would wave through a wedged story's very first dispatch.""" write_sprint(project, {"1-1-a": "ready-for-dev"}) escalating = SessionResult( status="completed", @@ -6660,8 +6678,9 @@ def test_resume_does_not_gate_a_human_armed_re_drive(project): ) summary = resumed.run() - assert summary.done == 1 and not summary.paused - assert adapter.sessions # the re-drive ran + assert summary.paused and summary.done == 0 + assert adapter.sessions == [] # the re-drive is a start, and it was refused + assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE def test_epic_boundary_gate_pause_and_resume(project): diff --git a/tests/test_stories_engine.py b/tests/test_stories_engine.py index 7abefa06..103b9d44 100644 --- a/tests/test_stories_engine.py +++ b/tests/test_stories_engine.py @@ -8,7 +8,7 @@ import pytest import yaml -from conftest import attach_profile, git, install_build_auto_skill, write_spec +from conftest import attach_profile, git, install_build_auto_skill, write_gated_ledger, write_spec from bmad_loop.adapters.base import SessionResult from bmad_loop.adapters.mock import MockAdapter @@ -25,6 +25,7 @@ PAUSE_PLAN_CHECKPOINT, PAUSE_SPEC_APPROVAL, PAUSE_STORY_CHECKPOINT, + PAUSE_STORY_GATE, Phase, RunState, StoryTask, @@ -1102,6 +1103,43 @@ def test_blocked_resolve_rearm_then_redispatch_to_done(project): ] +def test_resolved_wedge_is_still_gated_on_redispatch(project): + """The state that makes a "has this story ever run?" test unbuildable, and so + the reason `_finish_inflight`'s restart arm asks the gate unconditionally. + + `_pause_wedged` records an ESCALATED task *before any session runs this pick*: + `attempt == 0`, no session records, and after `resolve` also `rearmed`. Every + signal that would exempt a re-drive is therefore set on a story whose first + dispatch has not happened — so exempting re-drives would wave a wedged story + straight past a gate that landed while the run was down. Story 1's re-dispatch + is a start like any other, and story 2 must not be leapfrogged either: the gate + pauses the run rather than skipping the story, exactly as `validate` fails the + whole preflight.""" + from bmad_loop import runs + + folder = setup_stories(project, [entry("1"), entry("2")]) + write_spec(folder / "stories" / "1-slug.md", "blocked", rev_parse_head(project.project)) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "story 1 blocked") + + engine, _ = make_engine(project, []) + assert engine.run().paused + wedged = load_state(engine.run_dir).tasks["1"] + assert wedged.phase == Phase.ESCALATED and wedged.attempt == 0 and not wedged.sessions + + runs.rearm_escalation(engine.run_dir, "1") # human fixed the frozen spec + assert load_state(engine.run_dir).tasks["1"].rearmed # ...and the re-drive is armed + # a gate on story 1 lands while the run is down + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1"])}) + + resumed, radapter = resume_engine(project, engine, [stories_dev_effect(), stories_dev_effect()]) + summary = resumed.run() + + assert summary.paused and summary.done == 0 + assert radapter.sessions == [] + assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE + + def test_sentinel_rearm_deletes_by_recorded_verdict_e2e(project): """C2 (E2E): a pick-time sentinel wedge records task.sentinel_kind on disk; a subsequent rearm clears the sentinel by that recorded verdict (not the basename) From 83f45aedc3d132baa3e43e1241c5608ab0f9f249 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 21:33:49 -0700 Subject: [PATCH 31/34] test(engine): pin the pre-launch attempt window the restart-arm gate covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_engine.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_engine.py b/tests/test_engine.py index 3b98e5ac..9083273d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6607,6 +6607,35 @@ def test_resume_re_gates_a_story_registered_but_never_started(project): assert saved.paused_story_key == "1-1-a" +def test_resume_re_gates_a_story_whose_attempt_never_reached_a_session(project): + """The second window, and why the arm asks unconditionally rather than testing + the attempt counter: `_dev_phase` persists `attempt == 1` with the DEV_RUNNING + advance, but the session does not launch until `adapter.run()` — past + `_restore_patch`, the prompt build and the pre_session plugin gate, any of which + can be slow or can pause. A host death in there records an attempt no session + ever backed, and the restart arm rolls the task back and re-runs it from + scratch, so this is a start too.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, []) + # attempt counted, no session record: the state _dev_phase saves before launch + engine.state.tasks["1-1-a"] = StoryTask( + story_key="1-1-a", epic=1, phase=Phase.DEV_RUNNING, attempt=1 + ) + engine._save() + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + resumed, adapter = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = resumed.run() + + assert summary.paused and summary.done == 0 + assert adapter.sessions == [] + assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE + + def test_resume_still_finishes_a_story_whose_session_already_completed(project): """The other side of the line, and the exemption stated as behavior. It belongs to `_finish_inflight`'s *finishing* arms, not to every non-terminal task: here From 986a3e586e48577bbc1df01693899af73a55a099 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 21:48:28 -0700 Subject: [PATCH 32/34] fix(engine): ask the restart-arm gate before the rollback rewinds the ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- src/bmad_loop/engine.py | 45 +++++++++++++++++++++++++++-------------- tests/test_engine.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index f95ee7d0..934ad7a1 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -910,6 +910,12 @@ def _refuse_gated_story(self, story_key: str) -> None: touched, and the run's own crash must not be what disables the one deferred check that refuses. + Both call sites ask **before** their caller mutates anything, and that is + one rule rather than two coincidences. In ``_loop`` it keeps the refusal + re-askable; in the restart arm it keeps the ledger readable, because that + arm's in-place rollback is ``git reset --hard `` and a gate + committed while the run was down is a commit *after* that baseline. + Deliberately no "but did a session really run?" test there. Every available signal is wrong somewhere: ``attempt`` is bumped before the session launches, ``sessions`` is written only after one returns, and ``rearmed`` covers a @@ -1227,6 +1233,30 @@ def _finish_inflight(self) -> None: else: self._finalize_commit_phase(task) else: + # This arm is the one that does not finish work: it discards the + # worktree or resets the tree to baseline and re-runs the story + # from scratch, so what follows is a *start* and gets the same + # question `_loop` asks. Unconditionally — any test for "did a + # session really run?" is wrong somewhere: `attempt` is bumped + # before the session launches, `sessions` is written only after one + # returns, and `rearmed` covers a stories-mode wedge that reached + # ESCALATED with no session at all. + # + # Asked BEFORE the unwinding below, for the same reason `_loop` + # asks before it registers the task: the in-place rollback is + # `git reset --hard `, and a `gate:` committed while the + # run was down lives in a commit *after* that baseline. Rolling + # back first would rewind a tracked ledger and put the question to + # a file the human never wrote — `keep=(".bmad-loop",)` guards only + # untracked deletion, which is exactly why `verify.safe_rollback` + # has to restore `policy.toml` by hand. It also keeps the pause + # honest under the default `rollback_on_failure = false`, where + # `_rollback_or_pause` would otherwise pause for manual recovery + # and never reach the gate. The cost is that a refused isolated + # task keeps its half-built worktree mounted until a resume gets + # past the gate — the same thing an escalation pause does, and the + # cheaper of the two mistakes. + self._refuse_gated_story(task.story_key) self.journal.append( "resume-restart", story_key=task.story_key, phase=str(task.phase) ) @@ -1245,21 +1275,6 @@ def _finish_inflight(self) -> None: task.rearmed = False # past rollback (only reached when not paused) task.phase = Phase.PENDING # deliberate reset, not a normal transition self._save() - # This arm is the one that does not finish work: the lines above - # discarded the worktree or reset the tree to baseline, so the task - # now holds nothing a session produced and is about to be re-run - # from scratch — the same shape `_loop` hands to `_run_story`. That - # makes the gate question the same one `_loop` asks, and asking it - # unconditionally is what keeps it honest: any test for "did a - # session really run?" would be inferring what this arm has already - # guaranteed, and each such test is wrong somewhere. `attempt` is - # bumped before the session launches (so a death in between reads as - # started); `sessions` is written after one returns (so a death - # inside one reads as never started); `rearmed` covers a stories-mode - # 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) self._run_story(task) # a resumed story that just reached DONE gets the same post-story hook # the _loop path fires (e.g. the stories-mode done_checkpoint pause), diff --git a/tests/test_engine.py b/tests/test_engine.py index 9083273d..d0c90aaf 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6636,6 +6636,41 @@ def test_resume_re_gates_a_story_whose_attempt_never_reached_a_session(project): assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE +def test_resume_reads_the_gate_before_the_restart_rollback_rewinds_the_ledger(project): + """Order matters, not just placement. The restart arm's in-place rollback is + `git reset --hard `, and `keep=(".bmad-loop",)` guards only untracked + deletion — tracked content under it is reverted anyway, which is why + `verify.safe_rollback` restores `policy.toml` by hand. A tracked ledger has no + such rescue: a `gate:` committed while the run was down lives in a commit + *after* the baseline, so a rollback that ran first would rewind the ledger and + the gate would read a file the human never wrote. Ask before the arm mutates + anything — the same rule `_loop` follows.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + git(project.project, "add", "-A") + git(project.project, "commit", "-q", "-m", "board") # board predates the baseline + engine, _ = make_engine(project, []) # default test policy: rollback_on_failure=True + baseline = rev_parse_head(project.project) + task = StoryTask(story_key="1-1-a", epic=1, phase=Phase.DEV_RUNNING, attempt=1) + task.baseline_commit = baseline + task.baseline_untracked = [] + engine.state.tasks["1-1-a"] = task + engine._save() + # the gate is committed while the run is down — i.e. after the task's baseline + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + assert rev_parse_head(project.project) != baseline # the gate is a later commit + + resumed, adapter = resume_engine( + project, + engine, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = resumed.run() + + assert summary.paused and summary.done == 0 + assert adapter.sessions == [] + assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE + + def test_resume_still_finishes_a_story_whose_session_already_completed(project): """The other side of the line, and the exemption stated as behavior. It belongs to `_finish_inflight`'s *finishing* arms, not to every non-terminal task: here From 77dddc113488f75ce132019845e546e856447674 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 22:35:13 -0700 Subject: [PATCH 33/34] test(engine): pin that the restart-arm gate re-asks until the entry lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_engine.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_engine.py b/tests/test_engine.py index d0c90aaf..cd3abf0c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -6636,6 +6636,40 @@ def test_resume_re_gates_a_story_whose_attempt_never_reached_a_session(project): assert load_state(resumed.run_dir).paused_stage == PAUSE_STORY_GATE +def test_restart_arm_gate_re_asks_until_the_entry_lands(project): + """The restart arm's refusal must be a standing condition, not a one-shot. + + `_finish_inflight` drives every non-terminal task, and a refused task stays + non-terminal — so each resume re-reads the ledger, and closing the entry is + what releases it. Were the refusal to retire the story instead, the gate would + drop the very work it was protecting; that is the same property the `_loop` + side buys by refusing before it records the task.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + engine, _ = make_engine(project, []) + engine.state.tasks["1-1-a"] = StoryTask(story_key="1-1-a", epic=1) + engine._save() + write_gated_ledger(project, {"DW-1": ("open", ["gate: 1-1"])}) + + first, adapter1 = resume_engine(project, engine, [dev_effect(project, "1-1-a")]) + assert first.run().paused and adapter1.sessions == [] + + # a resume that changed nothing must not get the story through + second, adapter2 = resume_engine(project, first, [dev_effect(project, "1-1-a")]) + assert second.run().paused and adapter2.sessions == [] + assert load_state(second.run_dir).paused_stage == PAUSE_STORY_GATE + + # ...and closing the entry releases it, so the gate is not a wedge + write_gated_ledger(project, {"DW-1": ("done 2026-08-01", ["gate: 1-1"])}) + third, _ = resume_engine( + project, + second, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + summary = third.run() + + assert summary.done == 1 and not summary.paused + + def test_resume_reads_the_gate_before_the_restart_rollback_rewinds_the_ledger(project): """Order matters, not just placement. The restart arm's in-place rollback is `git reset --hard `, and `keep=(".bmad-loop",)` guards only untracked From 578ca38e284f51bc31424bd1d9619be1c2b57f25 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 9 Aug 2026 23:28:03 -0700 Subject: [PATCH 34/34] fix(deferredwork): fire the split arm only for a bare - token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2, confirmed: the split arm's guard was `token ends in a digit`, but the digit can belong to a slug. `gate: 3-2-v2` therefore read the `a-` of `3-2-v2a-followup` as a split boundary and refused a different, legal sprint key, and `gate: 3` refused the distinct stories id `3a-task`. A false refusal is the one way this check can be worse than the prose it replaced — the same failure mode the digit guard was added for, just written too loosely. sprintstatus.STORY_RE attaches the split letter straight after -, both numeric, so that is exactly the token set the arm may fire for. The parametrized table's own comment already said 'the token must end at a story NUMBER'; the code said something weaker. Strictly narrowing: all 17 existing rows are unchanged, including `3-2`/`3-2a` staying False. --- src/bmad_loop/deferredwork.py | 24 +++++++++++++++++------- tests/test_deferredwork.py | 6 ++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/bmad_loop/deferredwork.py b/src/bmad_loop/deferredwork.py index 47fbf428..5ce7fe79 100644 --- a/src/bmad_loop/deferredwork.py +++ b/src/bmad_loop/deferredwork.py @@ -74,6 +74,12 @@ # imported because `stories` imports *this* module (a cycle). The copy is pinned # to the original by a drift test rather than to a comment. _STORIES_ID_RE = re.compile(r"^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$") +# The tokens `gates_story`'s split arm may fire for: a bare `-`, both +# numeric. `sprintstatus.STORY_RE` attaches the split letter straight after the +# story *number*, so that is the only token a split can extend. "Ends in a digit" +# is a weaker test that reads the same shape into a slug — `3-2-v2` would take the +# arm and refuse `3-2-v2a-followup`, a different and legal key. +_SPLITTABLE_TOKEN_RE = re.compile(r"^\d+-\d+$") # A `gate:` line the strict field pattern above will never see. `GATE_RE` is # anchored to a lowercase `gate:` in column 0, exactly like `status:`, and that # strictness fails in opposite directions for the two fields: a missed `status:` @@ -452,19 +458,23 @@ def gates_story(token: str, story_key: str) -> bool: followed by ``-`` is therefore also a boundary. Exactly one letter, and the ``-`` after it is required, so ``3-2ab-x`` and a bare ``3-2a`` are not swept in. - The split arm applies only to a token ending in a digit, because that is the - only place a split letter can attach: ``STORY_RE`` puts it straight after the - story *number*. Without that guard the arm reads any trailing letter as a - split and gates a story nobody named — ``stories.ID_RE`` admits word ids, so - ``gate: auth`` refused ``authz-login``, and a hard failure on an unrelated - story is the one way this check can be worse than the prose it replaced. + The split arm applies only to a token that *is* a bare ``-``, + because that is the only place a split letter can attach: ``STORY_RE`` puts it + straight after the story *number*. Without that guard the arm reads any + trailing letter as a split and gates a story nobody named — ``stories.ID_RE`` + admits word ids, so ``gate: auth`` refused ``authz-login``, and a hard failure + on an unrelated story is the one way this check can be worse than the prose it + replaced. "Ends in a digit" is the same guard written too loosely: the digit + can belong to a *slug*, so ``gate: 3-2-v2`` took the arm and refused + ``3-2-v2a-followup`` — a different, legal key — and ``gate: 3`` refused the + distinct stories id ``3a-task``. """ if story_key == token or story_key.startswith(f"{token}-"): return True # The `startswith` guard is load-bearing, not redundant with the slice below: # `story_key[len(token):]` says nothing about what preceded it, so without it # `3-2` would gate `9-9a-x` on the tail alone. - if not story_key.startswith(token) or not token[-1:].isascii() or not token[-1:].isdigit(): + if not story_key.startswith(token) or not _SPLITTABLE_TOKEN_RE.match(token): return False rest = story_key[len(token) :] return len(rest) >= 2 and "a" <= rest[0] <= "z" and rest[1] == "-" diff --git a/tests/test_deferredwork.py b/tests/test_deferredwork.py index fe795af0..06a162c5 100644 --- a/tests/test_deferredwork.py +++ b/tests/test_deferredwork.py @@ -2047,6 +2047,12 @@ def test_gates_stop_at_the_canonical_span_boundary(): # check can be worse than the prose it replaced. ("auth", "authz-login", False), ("api", "apis-v2", False), + # ...and "ends in a digit" was that same guard written too loosely: the + # digit can belong to a slug, so the arm read a slug boundary as a split + # and refused keys the entry never named. + ("3-2-v2", "3-2-v2a-followup", False), # `2` closes the slug `v2`, not a story + ("3", "3a-task", False), # a distinct stories id, not a split of `3` + ("3-2-v2", "3-2-v2-followup", True), # the plain `-` arm is untouched by that ("3-2a", "3-2ab-x", False), # a token already carrying a split letter ("3-2a", "3-2a-x", True), # ...still gates its own `-` boundary ("", "a-b", False), # an empty token names nothing, so it gates nothing