Files
Brian e9f636c1f7 feat(bmm): rework bmad-retrospective as an evidence-based epic review (#2612)
* feat(bmm): rework bmad-retrospective as an evidence-based epic review

Replace the persona-meeting simulation with an evidence engine that mines
what a completed epic actually left behind — spec, full diff, per-story
commits, sprint status, and session logs — instead of simulating a team
that "remembers." Every finding carries a source reference.

- Five-phase flow: Gather -> Analyze -> Decide -> Close. Analyze derives
  aggregate views (architecture delta, duplication, god-class growth,
  pattern divergence, spec reconciliation) and reuses bmad-review for the
  diff-scope code lenses rather than reimplementing them.
- Acceptance verdict (accepted / accepted-with-open-items / rejected); a
  failing epic can never close as silently accepted.
- Team discussion is now opt-in and off by default, delegating to
  bmad-party-mode seeded with the real findings.
- Add deterministic scripts: sprint_status.py (epic detection plus a
  comment-preserving, force-quoting, validate-and-restore sprint-status.yaml
  update) and git_evidence.py (range / commit / size-trajectory
  measurement), with a pytest suite.
- The retrospective document is the working artifact: built early, filled
  per phase, and resumable. Adds a headless mode (-H) with assumption
  capture.
- SKILL.md drops from ~1527 lines to a lean spine plus five references.

* fix(bmm): harden retrospective scripts for orchestrator use

Address review feedback on the evidence-based retrospective (PR #2612) from
CodeRabbit and the bmad-loop maintainer. All changes are additive — no existing
JSON field or CLI argument that a consumer reads is renamed or removed.

- detect-epic: STORY_RE now matches split-story keys (2-6a-...), aligning with
  bmad-loop's sprint-status detection; previously such an epic was invisible and
  a headless run could retro the wrong epic.
- sprint_status/git_evidence: argparse failures now emit JSON on the documented
  stdout contract instead of plain usage text.
- sprint_status: guard non-list action_items (clean JSON error, no traceback);
  _restore reports success/failure so a failed rollback is surfaced, not hidden.
- action_items entries carry a stable id and a ref to the retro document, so an
  orchestrator can dedupe across re-runs and dispatch each sourced finding.
- update echoes the acceptance verdict in its JSON; the retro key value stays
  "done" for existing lifecycle consumers. Retro doc gains machine-readable
  frontmatter, and -H <epic> is documented as the stable automation interface.
- evidence-gathering: derived range now includes the first story commit
  (<first>^..<last>); aggregate-views drops the size/acceleration overclaim.
- Tests: split-story detection, non-list guard, restore reporting, argparse
  JSON, and id/ref/verdict coverage (13 pass).

* docs(bmm): refine retrospective party-mode phrasing

* docs(bmm): document the reworked retrospective

- Add docs/explanation/retrospective.md — a short feature page: what the
  skill does, why to run it after an epic, and what to do with its output
  (retro document, action items, acceptance verdict).
- Update the workflow-map retrospective row to reflect the evidence-based
  review and its actual outputs instead of "Lessons learned."

* fix(bmm): validate git_evidence --range as a strict revision range

An unvalidated --range reached the git argv unchanged: a value starting
with "-" was consumed by git as an option, a single rev logged all
history up to it, an existing path was consumed as a pathspec, and a
file named like a range (a..b) was silently logged as one — each
returning a confident, wrong evidence set with exit 0. Reject anything
that is not an explicit REV..REV form — including empty endpoints
("..", "a..", "..b"), which git silently defaults to HEAD, and
unstripped values — and terminate the git argv with "--" so a
range-shaped name is always parsed as revisions, never as a pathspec.

Drop the root-commit special case from evidence-gathering.md: its
wording steered into a bare-rev invocation now rejected, the scenario
(an epic starting at the repository's first commit) is vanishingly
rare, and an agent that ever meets it can still construct a shape-valid
range itself. Document the exit-code split (2 invalid args, 1 git
failure). Every rejected mode is covered by regression tests; git
error-string assertions are pinned to LC_ALL=C.

* fix(bmm): harden sprint_status.py input validation and atomic writes

Nine findings from the PR #2612 review of the reworked retrospective, all
reproduced against the previous behavior:

- Pin the emitter indentation so a round-trip stops de-indenting
  pre-existing, untouched action_items; the sprint-status template now
  survives an update byte-identical apart from last_updated.
- Keep malformed input on the documented JSON-to-stdout contract instead
  of a traceback: shape-check the root document and development_status on
  both subcommands, widen the file open from FileNotFoundError to
  OSError, and report invalid UTF-8 as a JSON error. A non-mapping
  development_status previously reported ok:true while doing nothing.
- Validate comment preservation against the whole file's comment lines
  rather than only the leading block matched by substring, so a lost
  mid-file comment fails the write and restores.
- Write atomically through a temp file, fsync and os.replace, resolving
  symlinks and carrying the target's mode across. The rollback goes
  through the same path: a truncating rewrite that died halfway used to
  destroy the bytes it was restoring.
- Stop inserting an empty development_status mapping, and report
  retro_key_found: null when --set-retro-done was not passed, reserving
  true/false for "flag passed, key present/absent".
- Include restored:true on every update failure that precedes the write.
- Make _slugify Unicode-aware with a content-hash fallback, so non-Latin
  action text no longer collapses to the literal "item".
- Reject an --add-action item whose action is empty or not a string.
- Validate --date against MM-DD-YYYY HH:MM and normalize it, since
  strptime otherwise admits unpadded spellings.

Each fix is pinned by a subprocess-level regression test; the suite goes
from 18 to 49 tests.

* feat(bmm): add action-item status updates to sprint_status.py

The update subcommand could only append, and retro-document.md forbids
hand-editing sprint-status.yaml, so nothing anywhere could move an action
item off open. bmad-sprint-status renders every open and in-progress item
on every call, so retrospectives accumulated permanently-open noise.

Add --set-action-status, taking a JSON array of transitions for items
already in the file. Each entry selects one item by id, or by epic plus
exact action text for legacy entries written before ids existed, and gives
it one of open, in-progress, done. Selectors resolve against action_items
as loaded and strictly before the --add-action append, so an item added in
the same run is not addressable in that run. Validation is all-or-nothing
and entirely pre-write: a selector matching nothing, matching more than
one item, colliding with another entry, or carrying a status outside the
vocabulary aborts the whole invocation with restored: true and the file
byte-identical. After the write the file is re-parsed and every targeted
item is confirmed to carry its new status. Success reports the new
action_items_updated count.

Rewrite the contradicting paragraph in retro-document.md so the flag is
the one sanctioned path to change a status: offer the transitions when
the Phase 4 follow-through has evidence an item landed, apply only what
the user confirmed, and never touch prior statuses in a headless run.

* fix(bmm): measure renames, merges, and binary churn in git_evidence

Five ways the measurement was quietly wrong:

- Renames emitted an unopenable "src/{a => b}" pseudo-path and split one
  file's churn across up to three keys; non-ASCII paths arrived octal
  escaped. Both passes now run with core.quotePath=false and
  --no-renames, so a rename is an honest delete + add and a path is a
  real string that opens.
- Merge commits emit no numstat rows, so cross-story conflict-resolution
  churn vanished with nothing saying it had been skipped. The listing
  pass keeps full topology (per-story attribution is unchanged); a
  second pass measures merges alone under -m --first-parent
  --min-parents=2 and reports merge_files, merge_count and
  merges_measured. Merge churn is never folded into files: a merge's
  first-parent diff restates the churn of the commits it merged in.
- A commit naming two stories attributed to whichever id was passed
  first. Every matching id is now collected, so a seam commit counts for
  both stories. commits[].story becomes commits[].stories.
- A git failure with empty stderr reported "error": "". It now falls
  back to the exit code.
- One binary revision of a path nulled its added/deleted/net, dropping
  real measured text churn from the ranking. The text sums survive and
  binary_revisions reports the exposure beside them.

log.diffMerges is pinned to separate on the command line, because a user
config of off makes the merge pass emit no rows at all. Git output is
decoded with surrogateescape rather than replace, so two distinct
non-UTF-8 paths cannot collapse into one key.

evidence-gathering.md documents the changed output. 19 subprocess-level
tests over real git fixture repos cover every case above; the suite goes
81 -> 100.

* feat(bmm): add a pending-stories gate and align the retrospective docs

`detect-epic` now also reports `pending_stories` — the selected epic's
story keys that are not `done`, scoped to that epic alone — and SKILL.md
branches on it before Phase 1: interactively the user is shown the list
and may decline, headless the run proceeds and records the list as an
assumption. An epic supplied on the invocation skips detection, so the
instructions say plainly that the check did not run rather than implying
it did.

Both scripts are built with `add_help=False`. `-h`/`--help` previously
printed argparse's usage text on stdout and exited 0, breaking the
JSON-only stdout contract the scripts sell to their machine consumer;
help is now an ordinary unrecognized argument routed through the
existing JSON error path. SKILL.md also gained the branch for a
`detect-epic` that exits non-zero, which is the normal path for a
stories-mode project with no sprint-status.yaml.

The references are brought back into agreement with what the scripts do
after the last three batches: the close-out invocation quotes its values
and pins the `--date` format that now hard-fails a non-conforming value;
the result JSON documents `retro_key_found: null` and which failures
carry `restored`; the retro key is stated to read `done` even for a
rejected epic, so a verdict-aware gate must read the document
frontmatter; Phase 3 gains an inline fallback when bmad-party-mode is
absent; Phase 4 specifies the previous-retro follow-through record the
close-out consumes; and the god-class view is rewritten for the current
git_evidence keys.

Suite goes from 100 to 111 tests.

* chore(bmm): align retrospective script conventions and test harness

Adopt the invocation convention the quick-dev and dev-auto skills settled
on, and make the git_evidence test fixtures independent of the machine
they run on.

All five documented invocations become `uv run --no-cache`: the shared
cache may be unwritable in a sandbox and may serve a stale build. Both
scripts drop their shebangs entirely — they are only ever launched via
`uv run`, so a shebang and executable bit are a false affordance; the
PEP 723 blocks stay, since that is what uv reads. The encoding audit
that change implies found one implicit site left: `_load_yaml` now pins
the ruamel emitter's encoding, which is what encodes the bytes
`_dump_bytes` writes to the user's file.

`_git` and `_git_unchecked` passed an env of only the four GIT_* identity
vars, so PATH and HOME were wiped and git resolved through os.defpath —
the fixtures ran a different binary than the script under test, and
failed outright wherever git lives only in /opt/homebrew, /usr/local or
a nix store. Both now share a `_git_env` that inherits the environment,
strips ambient GIT_* vars that would redirect or reconfigure the fixture,
and pins identity, both gitconfig sources, gitattributes and the locale.

`_run` parsed stdout bare, so a crash surfaced as JSONDecodeError with
the real traceback hidden in the discarded stderr; it now routes through
the `_json` guard the rest of the file already used. Both test files gain
the repo's established runner footer and the PEP 723 header that makes it
self-bootstrap under `uv run <file>`.

No test's assertions change; the suite holds at 111 passing, and passes
under a gitconfig setting commit.gpgsign, core.autocrlf, core.hooksPath
and init.defaultBranch against it.

* feat(bmm): aim detect-epic at a supplied epic and reject unfinished ones

Unattended retros need the unfinished-story gate on the orchestrator path
and a hard machine verdict when delivery is incomplete.

detect-epic gains optional --epic N so -H <epic> can request the same
pending_stories list auto-detect already returns for its picked epic.
Non-positive values fail as JSON (exit 1). The skill now always runs
detect-epic for a supplied epic with that flag, and the acceptance
rubric forces rejected when pending_stories is non-empty — headless
included; interactive humans may still override.

Suite 111 → 115.

* fix(bmm): harden retrospective script contracts after re-review

- reject three-dot --range values in git_evidence.py: A...B passed the
  guard and silently measured a symmetric difference
- move the directory fsync in _atomic_write past the failure path so a
  post-rename fsync error can no longer report "restored": true about a
  write that landed
- validate --verdict against the frontmatter vocabulary and spell
  accepted-with-open-items one way across every machine-read surface
- emit story_count from detect-epic so a typo'd epic number is
  distinguishable from a finished epic, and teach the SKILL gate to
  treat 0 as suspect
- align the dev agent's ER menu description with the reworked skill

* fix(bmm): reject typed retrospective statuses

* docs: fix retrospective sidebar order

* ci(bmm): run retrospective Python tests

* fix(bmm): harden retrospective script execution

Reject non-positive epic numbers before update processing.
Keep explicit repositories authoritative when invoked from Git hooks.

* docs(bmm): rewrite retrospective skill prose in plain language

Remove invented metaphor and jargon from the skill's prompt surface
(SKILL.md and references): witnesses/interrogates, testimony, seams,
close-out, mining, and similar phrasing are replaced with plain
equivalents. Rename Phase 5 "Close" to "Finalize" and the "Honest
degradation" section to "Missing evidence", syncing cross-references.

Industry-standard terms (god-class, churn) and BMAD vocabulary
(declared/profiled, party mode) are kept. No operational rule,
script contract, or verdict vocabulary changes.

* docs(bmm): refine retrospective prose

---------

Co-authored-by: Alex Verkhovsky <alexey.verkhovsky@gmail.com>
2026-07-31 19:35:25 -07:00
..