Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,32 @@ story <id>`, the same annotation a sweep bundle writes. Both sprint and stories

### Fixed

- **The orchestrator's ledger writers no longer inject lines from a multiline value (#305).** Found
by [@Haven2026](https://github.com/Haven2026) in #274. The deferred-work ledger is line-oriented,
but `deferredwork`'s mutators interpolated their arguments verbatim: a resolution note of
`fixed\n### DW-99: fake\nstatus: open` minted a phantom entry, a note containing `\n- source_spec:`
truncated the entry's span and re-surfaced its tail as a phantom legacy item, and a mid-note break
left one entry carrying two `status:` lines. The writers now collapse line breaks in free text to a
space. They sanitize rather than raise — the sweep's close paths call them bare, so a `ValueError`
would end the run as crashed — and nothing upstream rejects on a break either: a formatting defect
must not cost a triage attempt. The break set is `str.splitlines()`'s, not `\n` alone, since
`parse_legacy` scans with it. A value that never carried a break is written byte-for-byte as
before, and a title that sanitizes away is named rather than left blank.

**New exception paths.** The orchestrator-owned fields are now validated where they never were:
`mark_done`, `mark_done_many`, `append_decision` and `append_entry` raise `ValueError` on a `date`
that is not ISO `YYYY-MM-DD`, and `append_entry` on a `status` outside `open`/`done <date>` or a
`severity` outside `critical|high|medium|low`. All four previously accepted anything and wrote it.
A bad value there is a programmer bug, not model output.

This covers the orchestrator's writers only. Two producers still write ledger markdown directly and
bypass them: the inner dev session's flat appends, and the sweep's migration session, which rewrites
the whole file (its validation checks legacy leftovers, duplicate ids, statuses and numbering — not
breaks inside a field value). Sanitizing also makes a break inert, not the text around it: a
`DW-<n>` token surviving as prose still counts toward `next_seq`. The sweep skill now states the
single-line expectation as guidance; an existing install picks it up on
`bmad-loop init --force-skills` (a plain `init` keeps skills that already exist).

- **A deferred finding appended after the last ledger entry is no longer lost (#304).** Found and
first fixed by [@Haven2026](https://github.com/Haven2026) in #274. The inner dev session appends
each review defer as a flat `- source_spec:` / `summary:` / `evidence:` block; landing after the
Expand Down
22 changes: 21 additions & 1 deletion src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1496,7 +1496,27 @@ def cmd_decisions(args: argparse.Namespace) -> int:
today = time.strftime("%Y-%m-%d")
for decision in pending:
option = prompter.ask(decision)
decisions.apply_pre_answer(project, decision, option, date=today)
try:
decisions.apply_pre_answer(project, decision, option, date=today)
except (OSError, bmadconfig.BmadConfigError, ValueError) as e:
# What this buys is the `{decision.id}` in the message, and only
# that: `main`'s tail catches BmadConfigError by name and everything
# else through a bare `except Exception`, so none of these ever
# reached argparse as a traceback, and the exit code is 1 either way.
# The loop is what makes attribution worth a handler — it has already
# printed an outcome line per answered decision, and a bare
# `error: <msg>` after them does not say which one did not land.
#
# ValueError is the ledger writers' `date` precondition, unreachable
# from the strftime above and here for the same reason as the TUI's
# copy of this call. BmadConfigError is the reachable one:
# `apply_pre_answer` re-reads the BMAD config on every call and
# `prompter.ask` blocks on the human in between, so a config removed
# or broken mid-prompt raises here even though the read at the top of
# this command succeeded. Leaving it out gave the likelier failure the
# worse message.
print(f"error: could not record {decision.id}: {e}", file=sys.stderr)
return 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if option.effect == "close":
outcome = "closed now"
elif option.effect == "build":
Expand Down
15 changes: 15 additions & 0 deletions src/bmad_loop/data/skills/bmad-loop-sweep/automation-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ field-by-field, and will kill this session after your final turn.
`build|close|keep-open`, `intent` required when effect is `build`,
`recommendation` must be one of the option keys.

- Write `already_resolved[].evidence` and an option's `label` and `resolution`
as a **single line** — the orchestrator copies each onto one line of the
line-oriented deferred-work ledger. This is guidance, not a validation rule:
a break is collapsed to a space rather than rejected, so it costs you nothing
but reads worse.

Both `intent` fields stay 2-6 sentences as asked for above, newlines and all,
but for two different reasons. A **bundle's** `intent` is rendered to its own
intent file and never reaches the ledger. A **build option's** `intent` does
reach it — the ledger `decision:` line takes `resolution or intent`, and a
build option normally has no `resolution`, so its intent is usually exactly
what lands there. It is exempt anyway because the orchestrator flattens it on
write, and holding it to one line would flatten the brief that drives the
whole dev bundle to buy nothing.

- **Migration sessions** (`--migrate`, see `./migration-mode.md`) use this
result schema instead:

Expand Down
16 changes: 16 additions & 0 deletions src/bmad_loop/data/skills/bmad-loop-sweep/deferred-work-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ reason: <why this was deferred rather than done now, one or two sentences>
status: open
```

**Every field line is exactly one line, and so is the title.** The format is
line-oriented: readers find each field by scanning for `<name>:` at the start of
a line, and an entry ends at whichever comes first — the next `### DW-<n>`
entry, any other `#` .. `######` heading, or a `- source_spec:` flat-append
bullet. A value carrying a line break therefore does not wrap; it becomes new
ledger content, and three things can follow:

- a break followed by `### ` mints an entry nobody filed;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- a break before a `status:` line leaves one entry carrying two, so the ledger
no longer says one thing about it;
- a break followed by `- source_spec:` cuts the entry short at that bullet, and
everything after it re-surfaces as a phantom _legacy_ item.

Keep breaks out of field values, along with `### ` and a leading
`- source_spec:`. If a reason needs two sentences, write them on one line.

`severity:` is optional — entries written before this field existed have none
and that is fine; readers must treat a missing or unrecognized value as
"unspecified". Use `critical` for correctness/security issues, `high` for
Expand Down
8 changes: 7 additions & 1 deletion src/bmad_loop/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,13 @@ def apply_pre_answer(
the open set now), while `build`/`keep-open` are saved to the pre-answer
store for the next sweep to consume. When `commit`, the ledger and store are
committed on their own (only those paths) — best effort, so a non-git or
dirty tree never blocks the on-disk record."""
dirty tree never blocks the on-disk record.

Precondition: `date` is ISO `YYYY-MM-DD`. The ledger writers raise
`ValueError` on anything else (it would otherwise land a `status:` line that
reads as neither open nor done), so a caller building the date itself must
either guarantee the format or catch it. The option's own free text carries
no such precondition — it is sanitized, never refused."""
paths = bmadconfig.load_paths(project)
ledger = paths.deferred_work
detail = option.resolution or option.intent
Expand Down
Loading