basic BMAD setup

This commit is contained in:
Ebbe Baß
2026-10-15 08:57:23 +02:00
parent 44029127dc
commit 5e747654b9
438 changed files with 41818 additions and 0 deletions
@@ -0,0 +1,17 @@
---
name: bmad-retrospective
description: 'Review a finished epic folder in the ticket tree against the evidence it left behind — the epic file, each ticket plan, diffs, commits — and produce a retrospective with sourced findings, action items, and an acceptance decision. Use when the user says "run a retrospective" or "lets retro the epic [epic]". Supports -H/--headless'
---
Run the following command exactly once without changing the current working directory.
Replace `{project-root}` with the nearest folder containing `_bmad/`, from the working directory upward.
Replace `{skill-root}` with the absolute path to this skill's directory.
```bash
uv run --no-cache "{project-root}/_bmad/scripts/render_skill.py" --project-root "{project-root}" --skill "{skill-root}"
```
- The command should print one line to stdout. `read and follow <rendered workflow.md>`: read that file and follow it. `HALT: <reason>`: report the reason and stop.
- If the script does not exist, BMad is not set up in this project yet. Offer to set it up using `bmad` skill, then run the command again. If you do not have the `bmad` skill, offer to install it first.
- On any other output or failure, including `uv` being unavailable, report the command output and stop. Do not run any workflow source directly.
@@ -0,0 +1,3 @@
[skill]
bmod = "bmod-method"
source = "github:bmad-code-org/BMAD-METHOD/skills"
@@ -0,0 +1,39 @@
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-retrospective. Mirrors the
# agent customization shape under the [workflow] namespace.
[workflow]
# --- Configurable below. Overrides merge per BMad structural rules: ---
# scalars: override wins • arrays (persistent_facts, activation_steps_*): append
# arrays-of-tables with `code`/`id`: replace matching items, append new ones.
# Steps to run before the standard activation.
# Overrides append. Use for pre-flight loads, compliance checks, etc.
activation_steps_prepend = []
# Steps to run after persistent facts load but before the workflow begins.
# Overrides append. Use for context-heavy setup that should happen
# once activation context is in place.
activation_steps_append = []
# Persistent facts the workflow keeps in mind for the whole run
# (standards, compliance constraints, stylistic guardrails).
# Distinct from the runtime memory sidecar — these are static context
# loaded on activation. Overrides append.
#
# Each entry is either:
# - a literal sentence, e.g. "All retrospectives must produce SMART action items with named owners."
# - a file reference prefixed with `file:`, e.g. "file:{project-root}/docs/standards.md"
# (glob patterns are supported; the file's contents are loaded and treated as facts).
persistent_facts = []
# Scalar: executed at the end of Phase 5 (Finalize), after the retrospective
# document is saved. Override wins.
# Leave empty for no custom post-completion behavior.
on_complete = ""
@@ -0,0 +1,52 @@
# Decide: Routing and the Acceptance Verdict
Phase 4. Turn the consolidated findings into two outputs: routed action items the human can act on, and an honest verdict on whether the epic met its acceptance criteria. This skill proposes; it does not auto-apply fixes or edit the project spec. The human decides what executes.
## Route each finding
Give every finding two independent dispositions:
- **What to do about this instance** — *fix now*, *defer*, or *accept as-is*. Fix-now findings become action items. Deferred findings carry enough context to be acted on later without re-investigation. Accepted deviations are recorded so later retros stop re-flagging them.
- **What would prevent the next one** — the upstream lesson: spec wording, story sizing, a missing convention or gate, or nothing. This is where a recurring finding becomes a process change rather than a one-off fix.
Findings from sub-agents or the team discussion are unverified reports, not established facts. Before an action item relies on one, re-check it against the primary source — reopen the file, the commit, the spec. A finding whose source does not hold up is dropped, not routed.
## Action items
Compile fix-now findings and process lessons into specific, owned action items. Each names what to change and who owns it. Two kinds are *proposed, not applied* in this version:
- **Remediation** — code fixes are written up as action items (or story-shaped work) for the normal dev loop to execute later. The retrospective does not run the dev loop itself.
- **Spec reconciliation** — where the as-built diverges from the spec, propose the reconciliation as an action item with the evidence attached. The human applies it to the project contract; an uncertain interpretation is never written into the spec automatically.
## Previous-retro follow-through
When the previous epic's retrospective file exists, check whether the action items it committed to were completed. Read its Action items section, and its Previous-retro follow-through section for the items recorded there as not landed, and for every item, record one line in the retrospective document's Previous-retro follow-through section:
- **The item** — its action text as the previous file spells it, and its owner.
- **Whether it landed** — with the source that shows it: the commit, the file and line, the test. An item you cannot point at is "no evidence found", not "not done" — the reader must be able to tell a checked item from an unchecked one.
No status is written anywhere; the record is the follow-through. A run with no previous retrospective file, or one whose file has no Action items section, records that there was nothing to follow through on — and which of those it was, so a missing file is never mistaken for "no outstanding items."
## The verdict
Judge the final state against the epic file's Done when. If the epic file has none, profile the criteria from the diff and plans and mark the verdict as **profiled** rather than declared. Weigh verification results (the Phase 2 behavior check) and unresolved findings. Render one of:
- **Accepted** — criteria demonstrably met in the evidence, no blocking findings open, and **no unfinished tickets** for this epic.
- **Accepted-with-open-items** — criteria met, but named findings remain deferred and tracked — still only when every ticket of this epic is finished.
- **Rejected** — criteria not met, a blocking finding stands unresolved, **or any of this epic's tickets is still unfinished**.
### Unfinished tickets
`pending_tickets` is authoritative for this epic's incomplete work: the `ref`s of the `tickets.py status <folder>` rows that are unfinished — `status` not `built` and `state` not `done` or `dropped` — in build order. When that list is non-empty:
- The **machine** verdict is **rejected**. Name every unfinished ticket in the Acceptance verdict section as the evidence. Do not soften this to accepted-with-open-items: unfinished delivery is not an open finding about a finished epic — the epic itself is incomplete.
If the completeness check did not run (`tickets.py status` failed), do **not** render a rejected or accepted verdict from the absence of data — say the check was unavailable and weigh only the criteria and findings you have.
Three hard rules:
1. A human decision always overrides the machine verdict.
2. An epic that fails its criteria with **no** human decision is recorded as **not accepted** — never as silently accepted.
3. A non-empty `pending_tickets` list makes the machine verdict **rejected**, including in headless mode.
The verdict and its evidence carry into the Phase 5 document.
@@ -0,0 +1,17 @@
# Aggregate Views
Phase 2. An epic is many coding sessions, each validated in isolation; the defects that matter are the ones no single session — and no single diff hunk — could see. Nine sessions each added three hundred lines and none ever saw the 3,000-line class they collectively built. These views are properties of the *whole* change, derived across the full diff range from Phase 1.
Prefer deterministic derivation: a script that measures the codebase is evidence; a model's impression is not. Where you compute a view inline instead of by script, record the narrowed scope. Every observation that becomes a finding carries a source reference — the file, the symbol, the commits. `{{ rendered("references/evidence-gathering.md") }}` is authoritative for what every `git_evidence.py` key means, including the commit-level `is_merge` and `stories` — read it there before deriving anything from the numbers.
## The catalog
- **Architecture delta** — how the dependency structure changed across the epic. Where a language-native dependency tool exists (dependency-cruiser, madge, pydeps, and the like), run it before and after the range and diff the graphs; otherwise derive the module/import graph from the changed files. Look for new cross-cutting dependencies, layering violations, and cycles introduced — structure the code's own conventions would forbid but no single ticket tripped.
- **Duplication map** — the same problem solved more than one way across tickets. Two sessions independently writing near-identical logic, or a helper reimplemented because the second session did not know the first existed.
- **God-class / size growth** — files that grew past a healthy size *over the epic*, invisible per-commit because each session added only a little. The `git_evidence.py` pre-pass (Phase 1) reports `added` / `deleted` / `net` per path in `files` — *change volume*, not a file's absolute size or a per-commit growth rate. Those sums cover the range's **non-merge** commits only, and they are always integers: an unmeasurable revision is left out of them rather than nulling them. Rank on `files`, then open the top of the ranking and read each file's real current size and structure before calling anything a god-class — high net churn makes a file a candidate to inspect, not a verdict on its own. Three qualifiers say how far the ranking can be trusted: `binary_revisions` counts that path's revisions whose churn could not be measured, so its true volume is *at least* what the sums report; `merges_measured` short of `merge_count` means some merges were never measured at all, which caps how complete the ranking can be; and `merge_files` mostly restates churn `files` already counted, so summing the two double counts — but it is not redundant, because a merge's first-parent diff also carries whatever the conflict resolution itself added, code that lives in no non-merge commit and therefore appears in `files` nowhere. So read `merge_files` separately, for the paths whose churn shows up only there, rather than discarding it as double counting. Whether a flagged file is genuinely a god-class or legitimately large stays your judgment.
- **Pattern divergence** — where the epic's code diverges from the conventions the surrounding codebase already established: naming, error handling, test structure, module boundaries. Agents learn conventions by pattern-matching the code, so divergence compounds.
- **Spec-to-implementation reconciliation** — where the as-built diverges from the epic file's Done when and the initiative Requirements each ticket's `covers` names, with PRD/architecture as context. Requirements silently dropped, added behavior nobody specified, intent reinterpreted between tickets. Each divergence is either a defect (fix), an accepted deviation (record so later runs stop re-flagging it), or a spec that should be reconciled to reality (propose in Phase 4).
## Delegation
When sub-agents are available, delegate the derivation: each returns evidence with source refs and checked scope, never a verdict — the parent consolidates and decides. Give each a narrow view and an explicit return format. When sub-agents are unavailable, compute the highest-value views inline (architecture delta and spec reconciliation first) and record which views were narrowed or skipped.
@@ -0,0 +1,30 @@
# Evidence Gathering
Phase 1 of the retrospective. Enumerate what the completed epic produced, so every later analysis works from real artifacts instead of memory. Output is an inventory: what exists, what is missing, and the diff ranges the rest of the retro will read.
## Inventory checklist
Collect what the epic produced and note the source path or range of each:
- **Epic file** — `epic-<slug>.md` in the epic folder, the folder's name plus `.md`: Description, Outcome, Done when, Boundaries, and Notes. Done when governs Phase 4; when the file has none, note that the verdict will be profiled from the diff.
- **Initiative requirements** — the Requirements section of the initiative file in the epic folder's parent. Each ticket's `covers` names ids there.
- **Entries** — the `tickets` rows of `tickets.py status <folder>`, and for each, `tickets.py find <folder> <ref>` with the row's `ref` (same command form as the workflow): its `description`, `verify`, and `covers` are what the build was given. `description` and `verify` are empty once the entry was pulled: the story file holds them.
- **Story files** — `find`'s `story_file` when it is not null: the intent of a ticket a person reviewed or refined.
- **Plans** — `find`'s `plan` for each ticket: its frontmatter (`status`, `baseline_revision`) and the sections Review Triage Log, Verification, Plan Change Log, and the dated `### <date>` blocks under `## Code Review`, absent when no review ran. These mark the boundaries between build sessions.
- **Diff ranges and commits** — the full set of changes the epic introduced, one range per plan (see Ranges from the plans below). For each range, run `uv run --no-cache {skill-root}/scripts/git_evidence.py --repo {project-root} --range <range> --stories <story-ids>` to get, as JSON, the commits in the range and the per-file change volume — added / deleted / net across the range — that Phase 2 reads. Record each range explicitly; Phase 2's aggregate views and the `bmad-review` pass both read them. When a range cannot be established, say so and narrow the scope rather than guessing. Read the output keys precisely: each commit carries `is_merge` and `stories` — *every* id its subject names, so a commit spanning two stories counts for both. `files` sums non-merge commits only. `merge_files` is each measured merge's diff against its first parent, so it *restates* the churn that merge brought in plus whatever the conflict resolution added — never add it into `files`, and never read it as merge-introduced work on its own. `merges_measured` counts the merges on the range head's first-parent spine; `merge_count` counts every merge in the range, so a gap between the two means merges went unmeasured. `binary_revisions` is unmeasured churn, not zero churn.
- **Previous retrospective** — `<folder name>-retrospective.md` in the previous epic's folder, if one exists, located as the workflow's Inputs say, so Phase 4 can check whether last epic's action items landed.
- **Session logs** — conversation or session records for the epic's tickets, when available. They are the only record of *why* a session took an unexpected turn — what was tried and abandoned. They are also the evidence most likely to be deleted or expire, so capture references now.
## Ranges from the plans
Each plan records its own `baseline_revision`, the commit its build started from, so there is no single epic-wide range. Order the plans by their baselines' place in history, oldest first (`git merge-base --is-ancestor A B` says A is older) — the order the builds started, which need not be the row order. A plan's range runs from its baseline to the next baseline in that order; the last plan's range runs to `HEAD`, marked inferred rather than recorded. When later work has landed since, end it at the last commit that belongs to this epic's tickets, judged from the plans and commit subjects, and record the cut. A plan whose `baseline_revision` is missing or `NO_VCS` gets no commit or diff evidence — record that too. Two plans sharing a baseline give the earlier one an empty range: that ticket simply has no commits of its own. Group the tickets sharing an identical range and run `git_evidence.py` once per distinct range, passing that group's `ref`s (`3.4`, as `status` spells them) as one comma-separated `--stories` value. A commit's `stories` is usually empty, since subjects rarely name a ref; the range is the attribution, and an empty list is not missing evidence. Ranges may overlap or diverge; count a shared commit or file change once in the aggregate views while keeping each ticket's range as its provenance.
## Missing evidence
Evidence availability varies; never hide a gap. Each later analysis declares what it needs and, when that input is absent, records a narrowed scope rather than guessing. A reader of the final retro must always be able to tell **"checked and clean"** from **"never checked."**
- Missing session logs → process-lesson analysis is skipped, and the retro says so.
- No Done when in the epic file → the verdict is profiled from the diff and plans, flagged as profiled rather than declared.
- Sub-agents unavailable → analyses that would delegate run inline over a narrowed scope, and the narrowing is recorded.
Carry the inventory forward into Phase 2 as the authoritative list of what is available to read.
@@ -0,0 +1,44 @@
# Finalize: Retrospective Document
Phase 5. Finalize the retrospective document.
## The retrospective document
This document is the run's working artifact: it is created as a skeleton once the epic is fixed and filled as each phase completes, so Phase 5 finalizes rather than writes it from scratch. It lives at `<epic folder>/epic-<slug>-retrospective.md`, as readable markdown — a fixed name, so a resumed run finds it.
Open the document with YAML frontmatter a machine can read without parsing the prose — an epic gate or orchestrator keys off `verdict` to decide whether to hold the next epic:
```
---
epic: epic-<slug>
date: {date}
verdict: accepted | accepted-with-open-items | rejected
criteria: declared | profiled
headless: true | false
---
```
`epic` is the folder's name, as `tickets.py` spells the epic. Keep `verdict` in sync with the Acceptance verdict section below. Never write a `type` or `ticket` field into this frontmatter: `tickets.py` reads a markdown file in the epic folder as a ticket when `type` is a ticket type and as a plan when `ticket` is present. This frontmatter is the only machine-readable verdict; nothing in the tree records it, so a gate or orchestrator that acts on the verdict **must** read this file.
Sections:
- **Epic summary** — which epic, its tickets with their statuses, the tickets still at `built`, any tickets still unfinished (`pending_tickets`) that the user accepted retro-ing over, each plan's range, the evidence inventory (what was available, what was missing). Unfinished tickets force the machine acceptance verdict to **rejected** (see `{{ rendered("references/acceptance-verdict.md") }}`).
- **Findings** — grouped by aggregate view and by lens, each with its source reference and disposition (fix now / defer / accept). This is the record; do not summarize away the provenance.
- **Behavior verification** — what was exercised end to end and what was observed, or an explicit note that runtime behavior was not exercised.
- **Previous-retro follow-through** — if a prior retro exists, whether its action items landed, with evidence (`{{ rendered("references/acceptance-verdict.md") }}` specifies what to record).
- **Action items** — the routed fix-now items and process lessons, each with an owner. Note which are proposed remediation or spec reconciliations awaiting human application.
- **Acceptance verdict** — accepted / accepted-with-open-items / rejected, whether the criteria were declared or profiled, and the evidence behind the call.
- **Open questions** — what a human answer would materially change, and anything the analyses could not resolve.
- **Assumptions** — in headless runs, every choice made without the user: how the epic reference resolved to its folder, any non-empty `pending_tickets`, a machine **rejected** verdict forced by unfinished tickets or rendered with no human decision, each proposed item. Omit in interactive runs — an interactive run records the same facts where the user confirmed them, in Epic summary.
Do not state time estimates anywhere in the document.
## Finish
Report the document's path, the verdict, and the action-item count. Nothing else was written: no status changed, and no tree file was edited.
## On Complete
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{{ workflow.on_complete }}
@@ -0,0 +1,22 @@
# Team Discussion (opt-in)
An optional discussion layer over Phase 2's findings, off by default. It exists for users who want the retrospective discussed from multiple perspectives, the way a team would. One rule: **the team discusses evidence, never invention.** Agents speak only to findings that carry source references. No agent may describe an event that did not happen or report a pattern the diff does not show.
## When to run it
Only when asked — "discuss it as a team," "run party mode," or similar. A default run never enters this phase.
## How to run it
Invoke **`bmad-party-mode`**, seeded with the consolidated Phase 2 findings and the epic context, so the installed agents react as real subagents with independent thinking rather than a scripted dialogue. Seed it with:
- The findings, each with its source reference, grouped by the aggregate view or lens that produced it.
- The improvements the evidence confirms — real gains, patterns that worked — so positive observations are grounded in fact.
- The epic's acceptance criteria (or the profiled stand-in), so the discussion can weigh the verdict.
- The previous epic's action items and whether they landed, when a prior retro exists, so accountability is grounded in fact.
If `bmad-party-mode` is unavailable, a discussion the user asked for must not silently fail to happen. Run it inline over the same seed — take each perspective yourself, hold every perspective to sourced findings — and record in the retrospective document that the discussion ran inline rather than through the installed agents. Record it as the narrowing it is: one model playing every role loses the independent disagreement that surfaces missed findings. State that in the document rather than omitting it.
Keep the user an active participant and steer toward systemic understanding over blame — the point is which process or convention would have prevented a finding, not who wrote the line. Capture anything the discussion surfaces that the analyses missed; a genuinely new observation becomes a finding only once you can tie it to a source, otherwise it is a question for Phase 4, not a conclusion.
The discussion does not replace Phase 4. Its output feeds the action items and the verdict; it does not render them.
@@ -0,0 +1,295 @@
# /// script
# requires-python = ">=3.11"
# ///
"""Measure git commit and file-change evidence over a revision range.
Prints ONLY JSON to stdout. Errors are emitted as JSON to stdout with a
non-zero exit code: 2 for invalid arguments (rejected before git runs),
1 for git or I/O failures. This script only MEASURES — it never judges
acceleration or violations. The model interprets the numbers.
Two git passes. The first lists every commit in the range (merges included)
and sums the per-file churn of the non-merge commits, which is what `files`
reports. The second runs only when the range contains merges and measures
those merges alone, reported separately as `merge_files` — never folded into
`files`, because a merge's diff against its first parent restates the churn
of the commits it merged in, which the first pass already counted.
"""
import argparse
import json
import os
import re
import subprocess
import sys
UNIT_SEP = "\x1f"
# sha, space-separated parents (empty for a root commit), subject.
LOG_FORMAT = f"--format=%H{UNIT_SEP}%P{UNIT_SEP}%s"
def _emit(obj, code=0):
sys.stdout.write(json.dumps(obj))
sys.exit(code)
class JsonArgumentParser(argparse.ArgumentParser):
"""Emit argparse failures on the JSON-only stdout contract, not usage text.
The parser is constructed with ``add_help=False``. The override below covers
``error()``, but ``-h`` never reaches it: the built-in help action calls
``print_help()`` and ``exit(0)`` directly, which would put plain usage text
on stdout with a zero exit and break the JSON-only contract. Removing the
action instead of intercepting it routes ``-h`` through the already-tested
``error()`` path as an ordinary unrecognized argument. The cost is that the
``help=`` strings are unreachable from the CLI; the skill's references carry
the usage a human needs.
"""
def error(self, message):
_emit({"ok": False, "error": f"argument error: {message}"}, 2)
def _parse_numstat_line(line):
# numstat lines: "<added>\t<deleted>\t<path>"; binary files use "-".
parts = line.split("\t")
if len(parts) < 3:
return None
added_raw, deleted_raw, path = parts[0], parts[1], "\t".join(parts[2:])
added = None if added_raw == "-" else int(added_raw)
deleted = None if deleted_raw == "-" else int(deleted_raw)
return added, deleted, path
def _git_log(repo, extra_args, rng):
"""Run one `git log --numstat` pass over `rng` and return its stdout.
`core.quotePath=false` keeps non-ASCII paths as real UTF-8 strings instead
of octal escapes, and `--no-renames` makes a rename an honest delete + add
instead of an unopenable "src/{a => b}" pseudo-path that splits one file's
churn across several keys. Both matter for every pass, so both live here.
`log.diffMerges=separate` is pinned on the command line because it is what
`-m` means: a user or repo config setting it to `off` makes pass 2 emit no
file rows at all, so `merge_files` would come back empty beside a non-zero
`merges_measured` and read as "the merges changed nothing".
"""
cmd = [
"git",
"-c",
"core.quotePath=false",
"-c",
"log.diffMerges=separate",
"-C",
repo,
"log",
"--numstat",
"--no-renames",
*extra_args,
LOG_FORMAT,
rng,
"--", # terminate rev parsing so the range can never match a pathspec
]
try:
# Decode explicitly: git emits UTF-8 path bytes regardless of the
# caller's locale, and a C locale would otherwise decode them as ASCII.
# surrogateescape, not replace: replace maps every invalid byte to the
# same U+FFFD, so two distinct non-UTF-8 paths would collapse into one
# `files` key with their churn silently summed. Lone surrogates survive
# json.dumps (escaped as \udcXX under ensure_ascii) and json.loads.
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="surrogateescape",
env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")},
)
except Exception as exc: # noqa: BLE001
_emit({"ok": False, "error": str(exc)}, 1)
if proc.returncode != 0:
# stderr can be empty (a signal kill, a quiet failure); the exit code is
# then the only thing left to report, so never emit an empty error.
_emit(
{
"ok": False,
"error": proc.stderr.strip() or f"git exited {proc.returncode}",
},
1,
)
return proc.stdout
def _parse_log(output, stories):
"""Turn one pass's log output into (commits, files_map). Shared by both."""
commits = []
files = {} # path -> {path, _added, _deleted, binary_revisions, commit_count}
seen = set()
counting = True
for raw in output.splitlines():
if UNIT_SEP in raw:
sha, parents, subject = raw.split(UNIT_SEP, 2)
# Under -m, git repeats a merge's header once per parent unless it
# also honours --first-parent (git 2.31+). Count only the first
# block for a sha — git emits parents in order, so that block is
# the first-parent diff either way, and no churn is double counted.
counting = sha not in seen
if not counting:
continue
seen.add(sha)
commits.append(
{
"sha": sha,
"subject": subject,
# Every id the subject names, in --stories order: a commit
# spanning two stories belongs to both. Word-boundary match
# so a story id like "1-2" does not also match "11-2".
"stories": [sid for sid in stories if re.search(rf"\b{re.escape(sid)}\b", subject)],
"is_merge": len(parents.split()) > 1,
}
)
continue
if not counting or not raw.strip():
continue
parsed = _parse_numstat_line(raw)
if parsed is None:
continue
added, deleted, path = parsed
entry = files.get(path)
if entry is None:
# _added/_deleted are running sums over the path's text revisions.
entry = {
"path": path,
"_added": 0,
"_deleted": 0,
"binary_revisions": 0,
"commit_count": 0,
}
files[path] = entry
entry["commit_count"] += 1
if added is None or deleted is None:
# A binary revision is unmeasurable, not zero — count it alongside
# the sums instead of nulling the path's real measured churn.
entry["binary_revisions"] += 1
else:
entry["_added"] += added
entry["_deleted"] += deleted
return commits, files
def _file_list(files):
return [
{
"path": entry["path"],
"added": entry["_added"],
"deleted": entry["_deleted"],
"net": entry["_added"] - entry["_deleted"],
"commit_count": entry["commit_count"],
"binary_revisions": entry["binary_revisions"],
}
for entry in files.values()
]
def main(argv=None):
parser = JsonArgumentParser(
description=(
"Measure commit and per-file change evidence over a git revision range. Measures only; does not judge."
),
add_help=False,
)
parser.add_argument("--repo", default=".", help="Path to the git repo (default: .)")
parser.add_argument("--range", dest="range", help="Revision range REV..REV")
parser.add_argument(
"--stories",
help="Comma-separated story ids to match against commit subjects.",
)
args = parser.parse_args(argv)
stories = []
if args.stories:
# dict.fromkeys dedupes while keeping the caller's order: a repeated id
# would otherwise land twice in a commit's `stories`, double counting
# that commit in any per-story total built from the output.
stories = list(dict.fromkeys(s.strip() for s in args.stories.split(",") if s.strip()))
if not args.range:
_emit(
{
"range": None,
"note": "no range supplied",
"commits": [],
"files": [],
}
)
# Accept only an explicit REV..REV range. Anything else silently measures
# the wrong thing: a leading "-" is consumed by git as an option, a single
# rev logs all history up to it, a bare pathspec logs by path, an empty
# endpoint ("..", "a..", "..b") makes git default that side to HEAD, and a
# three-dot "A...B" is a symmetric difference — a different commit set
# entirely. partition splits at the FIRST "..", so any of those extra-dot
# shapes leaves `right` empty or dot-prefixed.
left, _, right = args.range.partition("..")
if args.range != args.range.strip() or args.range.startswith("-") or not left or not right or right.startswith("."):
_emit(
{
"ok": False,
"error": f"invalid --range {args.range!r}: expected a revision range like REV..REV",
},
2,
)
# Pass 1 — the listing. No extra args, so full topology: every commit in
# the range including merges, which is what per-story attribution reads.
# Merges contribute no numstat rows here, so `files` is non-merge churn.
commits, files = _parse_log(_git_log(args.repo, [], args.range), stories)
merge_count = sum(1 for commit in commits if commit["is_merge"])
# Pass 2 — merge churn, only when there is any. `-m --first-parent
# --min-parents=2` walks the range head's first-parent spine and emits
# exactly one diff-against-first-parent block per merge sitting on it.
# Merges off that spine are counted in merge_count and never measured,
# which is precisely why merges_measured is a separate key: the gap
# between the two is a visible statement that some merges went
# unmeasured. This never folds into `files` — a merge's first-parent diff
# restates the churn of the commits it merged in, which pass 1 already
# counted, so adding it in would double count.
merge_commits, merge_files = [], {}
if merge_count:
merge_commits, merge_files = _parse_log(
_git_log(
args.repo,
["-m", "--first-parent", "--min-parents=2"],
args.range,
),
stories,
)
_emit(
{
"range": args.range,
"commit_count": len(commits),
"merge_count": merge_count,
"merges_measured": len(merge_commits),
"commits": commits,
"files": _file_list(files),
"merge_files": _file_list(merge_files),
"stories_supplied": stories,
}
)
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
main()
@@ -0,0 +1,605 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for git_evidence.py — measurement over a real temp git repo.
Run: uv run scripts/tests/test_git_evidence.py
or: uv run --with pytest -m pytest scripts/tests/test_git_evidence.py
"""
import json
import os
import subprocess
import sys
import unicodedata
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "git_evidence.py"
# The fixture commit identity, shared by both git helpers below.
_IDENT = {
"GIT_AUTHOR_NAME": "T",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "T",
"GIT_COMMITTER_EMAIL": "t@t",
}
def _git_env(repo):
"""The environment every fixture git runs under, layered outward.
Inherit the real environment (PATH above all: git lives in /opt/homebrew,
/usr/local, or a nix store as readily as /usr/bin, and an env holding only
GIT_* vars sends execvp to os.defpath), then strip every ambient GIT_* var
-- GIT_DIR, GIT_WORK_TREE and GIT_CONFIG_COUNT would each silently redirect
or reconfigure the fixture -- and pin identity plus every source git reads
for settings, so nothing on the developer's machine can reach the fixture:
- gitconfig (commit.gpgsign, core.autocrlf, core.hooksPath,
init.defaultBranch). GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL cover
git >= 2.32; HOME and XDG_CONFIG_HOME cover older git, and are set to the
repo's parent directory -- always a per-test directory under pytest's
tmp_path -- so nothing is ever planted inside the working tree.
- gitattributes, a separate source GIT_CONFIG_NOSYSTEM does not cover: a
system `* -diff` rule would make numstat call every path binary and take
the churn assertions down with it. GIT_ATTR_NOSYSTEM shuts it out.
- the locale. LC_ALL/LANG are pinned to C, matching _run/_proc's existing
pin, so fixture git's text output cannot vary with the developer's
locale. Inheriting the environment is what makes this pin necessary:
the old four-variable env had no locale in it to inherit.
"""
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env.update(_IDENT)
env["GIT_CONFIG_NOSYSTEM"] = "1"
env["GIT_ATTR_NOSYSTEM"] = "1"
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["HOME"] = env["XDG_CONFIG_HOME"] = str(Path(repo).parent)
env["LC_ALL"] = env["LANG"] = "C"
return env
def _json(proc):
"""Parse the JSON-only stdout contract, surfacing a crash instead of hiding
it behind a JSONDecodeError."""
assert proc.stdout, f"empty stdout; stderr was: {proc.stderr}"
assert "Traceback" not in proc.stderr, proc.stderr
return json.loads(proc.stdout)
def _run(*args):
# LC_ALL=C keeps git's error strings in English so assertions on them
# are stable across locales.
proc = subprocess.run(
["uv", "run", str(SCRIPT), *args],
capture_output=True,
text=True,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
return proc.returncode, _json(proc)
def _proc(*args, env=None):
"""Run the script and return the raw process, so a test can assert on the
exit code and stderr together — and so `env` can carry an overlay (a
locale, an ambient GIT_DIR) that `_run` has no way to pass."""
overlay = {"LC_ALL": "C", "LANG": "C"}
if env:
overlay.update(env)
return subprocess.run(
["uv", "run", str(SCRIPT), *args],
capture_output=True,
text=True,
env={**os.environ, **overlay},
)
def _git(repo, *args):
subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
env=_git_env(repo),
)
def _git_unchecked(repo, *args):
"""`git` that tolerates a non-zero exit — the conflicting merge in
`_merge_repo` is supposed to fail, and the hand resolution comes after it.
Same environment as `_git`, which runs with check=True."""
return subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
env=_git_env(repo),
)
def _make_repo(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
(repo / "a.py").write_text("one\ntwo\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-1-1 initial a")
(repo / "a.py").write_text("one\ntwo\nthree\nfour\n")
(repo / "b.py").write_text("x\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-1-2 grow a, add b")
return repo
def test_no_range_returns_empty(tmp_path):
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo))
assert code == 0
assert out["range"] is None
assert out["commits"] == [] and out["files"] == []
def test_measures_commits_and_files_with_attribution(tmp_path):
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2,1-1")
assert code == 0
assert out["range"] == "HEAD~1..HEAD"
assert out["commit_count"] == 1
# The single commit in range is the second one; attributed to story "1-2".
assert out["commits"][0]["stories"] == ["1-2"]
files = {f["path"]: f for f in out["files"]}
# a.py grew by two lines, b.py added one — measured, not judged.
assert files["a.py"]["added"] == 2 and files["a.py"]["net"] == 2
assert files["b.py"]["added"] == 1
def test_story_attribution_respects_word_boundary(tmp_path):
# Story id "1-2" must NOT match a commit subject mentioning "11-2".
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
(repo / "f.py").write_text("a\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
(repo / "f.py").write_text("a\nb\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-11-2 unrelated story")
code, out = _run("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2")
assert code == 0
assert out["commits"][0]["stories"] == []
def test_bad_range_errors_as_json(tmp_path):
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "nope..alsonope")
assert code == 1
assert out["ok"] is False and out["error"]
def test_single_rev_range_rejected(tmp_path):
# A single rev is not a range: git would log ALL history up to it and the
# script would report the whole repo as the epic's evidence.
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "HEAD")
assert code == 2
assert out["ok"] is False and "invalid --range" in out["error"]
def test_pathspec_range_rejected(tmp_path):
# A path that exists must not be silently consumed as a pathspec.
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range", "a.py")
assert code == 2
assert out["ok"] is False and "invalid --range" in out["error"]
def test_range_shaped_pathspec_forced_to_rev_parse(tmp_path):
# A committed file literally named "a..b" passes the REV..REV shape check;
# without the trailing "--" in the git argv, git silently logs that FILE's
# history with exit 0. The "--" forces rev interpretation, so this must
# error instead of measuring the decoy.
repo = _make_repo(tmp_path)
(repo / "a..b").write_text("decoy\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "add decoy file named like a range")
code, out = _run("--repo", str(repo), "--range", "a..b")
assert code == 1
assert out["ok"] is False and "bad revision" in out["error"]
def test_option_like_range_rejected(tmp_path):
# A range starting with "-" must never reach git, where it would be
# consumed as an option (e.g. --output=... writes an arbitrary file).
repo = _make_repo(tmp_path)
code, out = _run("--repo", str(repo), "--range=--output=evil.txt")
assert code == 2
assert out["ok"] is False and "invalid --range" in out["error"]
assert not (repo / "evil.txt").exists()
def test_degenerate_range_shapes_rejected(tmp_path):
# Shapes that contain ".." but are not REV..REV: git would silently
# default an empty endpoint to HEAD ("..", "a..", "..HEAD"), a leading
# dash must never reach git even when dots are present ("-3..HEAD"),
# unstripped values must not slip past the dash guard, and a three-dot
# range is a symmetric difference — git would measure commits reachable
# from either endpoint but not both, a different evidence set entirely.
repo = _make_repo(tmp_path)
for bad in (
"..",
"a..",
"..HEAD",
"-3..HEAD",
" HEAD~1..HEAD",
"HEAD~1...HEAD",
"a...b",
):
code, out = _run("--repo", str(repo), f"--range={bad}")
assert code == 2, f"accepted {bad!r}"
assert out["ok"] is False and "invalid --range" in out["error"], bad
def test_malformed_args_emit_json_not_usage(tmp_path):
# An unknown flag must still land on the JSON contract, not argparse's
# plain usage text on stderr.
code, out = _run("--bogus-flag")
assert code != 0
assert out["ok"] is False and out["error"]
def test_help_flags_emit_json_not_usage():
# argparse's built-in help action bypasses the error() override entirely --
# it prints usage text on stdout and exits 0, which breaks the JSON-only
# contract for a machine consumer. add_help=False demotes -h to an ordinary
# unrecognized argument, which error() already handles.
for flag in ("-h", "--help"):
proc = _proc(flag)
# Exit 2 specifically: the module docstring reserves 2 for argument
# errors and 1 for git/I-O failures, so collapsing them must fail here.
assert proc.returncode == 2, flag
assert "usage:" not in proc.stdout, flag
out = _json(proc)
assert out["ok"] is False and out["error"], flag
# --- helpers for the fixtures below -----------------------------------------
def _rev(repo, ref):
return _git_unchecked(repo, "rev-parse", ref).stdout.strip()
def _merge_repo(tmp_path):
"""Two story branches merged into the mainline; the second merge conflicts
and is resolved by hand, adding a line neither branch had. Returns
(repo, base_sha) — `base_sha..HEAD` is the epic range."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "s.py").write_text("l1\nl2\nl3\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
_git(repo, "checkout", "-q", "-b", "s1")
(repo / "s.py").write_text("l1\nA\nl3\n")
_git(repo, "commit", "-qam", "epic-1-2 story one")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "s1", "-m", "merge story 1-2")
_git(repo, "checkout", "-q", "-b", "s2", base)
(repo / "s.py").write_text("l1\nB\nl3\n")
_git(repo, "commit", "-qam", "epic-1-3 story two")
_git(repo, "checkout", "-q", "main")
conflicted = _git_unchecked(repo, "merge", "--no-ff", "s2", "-m", "merge story 1-3")
assert conflicted.returncode != 0, "fixture expected a merge conflict"
(repo / "s.py").write_text("l1\nAB\nl3\nl4\n") # hand resolution
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "merge story 1-3")
return repo, base
def test_rename_yields_two_openable_paths(tmp_path):
# git's default rename detection emits "src/{mod.py => renamed.py}" — an
# unopenable pseudo-path that also splits one file's churn across keys.
# --no-renames makes the rename an honest delete + add.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "src").mkdir()
(repo / "src" / "mod.py").write_text("l1\nl2\nl3\nl4\nl5\nl6\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
_git(repo, "mv", "src/mod.py", "src/renamed.py")
_git(repo, "commit", "-qm", "rename mod")
(repo / "src" / "renamed.py").write_text("l1\nl2\nl3\nl4\nl5\nl6\nl7\n")
_git(repo, "commit", "-qam", "add a line after the rename")
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
files = {f["path"]: f for f in out["files"]}
assert not any("=>" in path for path in files), sorted(files)
assert files["src/mod.py"]["added"] == 0
assert files["src/mod.py"]["deleted"] == 6
assert files["src/renamed.py"]["added"] == 7
assert files["src/renamed.py"]["deleted"] == 0
def _accented_repo(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "src").mkdir()
(repo / "src" / "café.py").write_text("ca\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
(repo / "src" / "café.py").write_text("ca\ncb\n")
_git(repo, "commit", "-qam", "touch the accented file")
return repo, base
def _assert_accented_path(repo, out):
paths = [f["path"] for f in out["files"]]
assert len(paths) == 1
assert "\\" not in paths[0] and '"' not in paths[0], paths
assert unicodedata.normalize("NFC", paths[0]) == "src/café.py"
# The reported path is a real path: it opens under --repo.
assert (repo / paths[0]).read_text() == "ca\ncb\n"
def test_non_ascii_path_is_a_real_string(tmp_path):
# Without core.quotePath=false git emits "src/caf\303\251.py" — quoted and
# octal-escaped, so the documented "open the ranked files" step cannot.
repo, base = _accented_repo(tmp_path)
_assert_accented_path(repo, _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD")))
def test_non_ascii_path_survives_a_non_utf8_locale(tmp_path):
# Pins the explicit encoding="utf-8" on the subprocess. Modern CPython's
# UTF-8 mode hides its absence even under LC_ALL=C, so the pin only bites
# with UTF-8 mode and C-locale coercion both off — where the interpreter
# default is US-ASCII and git's UTF-8 path bytes fail to decode, taking the
# whole measurement down with them.
repo, base = _accented_repo(tmp_path)
out = _json(
_proc(
"--repo",
str(repo),
"--range",
f"{base}..HEAD",
env={"PYTHONUTF8": "0", "PYTHONCOERCECLOCALE": "0"},
)
)
_assert_accented_path(repo, out)
def test_merge_churn_is_measured_and_counted(tmp_path):
repo, base = _merge_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
assert out["commit_count"] == 4
assert out["merge_count"] == 2
assert out["merges_measured"] == 2
# Both merges' first-parent churn: 1/1 for the clean merge, 2/1 for the
# hand-resolved one (the resolution added a line neither branch had).
merge_files = {f["path"]: f for f in out["merge_files"]}
assert merge_files["s.py"]["added"] == 3
assert merge_files["s.py"]["deleted"] == 2
assert merge_files["s.py"]["net"] == 1
assert merge_files["s.py"]["commit_count"] == 2
def test_merge_commits_listed_but_excluded_from_files(tmp_path):
repo, base = _merge_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
by_subject = {c["subject"]: c for c in out["commits"]}
assert by_subject["merge story 1-2"]["is_merge"] is True
assert by_subject["merge story 1-3"]["is_merge"] is True
assert by_subject["epic-1-2 story one"]["is_merge"] is False
# `files` is the two story commits only — 1/1 each. Folding the merges in
# would double count: their diff restates the churn they merged.
files = {f["path"]: f for f in out["files"]}
assert files["s.py"]["added"] == 2
assert files["s.py"]["deleted"] == 2
assert files["s.py"]["commit_count"] == 2
def test_story_attribution_survives_merges(tmp_path):
repo, base = _merge_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD", "--stories", "1-2,1-3"))
by_subject = {c["subject"]: c for c in out["commits"]}
assert by_subject["epic-1-2 story one"]["stories"] == ["1-2"]
assert by_subject["epic-1-3 story two"]["stories"] == ["1-3"]
def test_off_spine_merge_is_counted_but_not_measured(tmp_path):
# A back-merge of the mainline into a story branch is a merge in the range,
# but its first-parent diff would restate unrelated mainline content as
# epic churn. It stays in merge_count and out of merges_measured, so the
# gap between the two says plainly that a merge went unmeasured.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "f.txt").write_text("base\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
(repo / "f.txt").write_text("base\nmain1\n")
_git(repo, "commit", "-qam", "mainline work")
_git(repo, "checkout", "-q", "-b", "s1", base)
(repo / "s1.txt").write_text("s1\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "epic-1-2 story one")
_git(repo, "merge", "-q", "--no-ff", "main", "-m", "back-merge main into s1")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "s1", "-m", "merge story 1-2")
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
assert out["merge_count"] == 2
assert out["merges_measured"] == 1
merge_files = {f["path"]: f for f in out["merge_files"]}
assert set(merge_files) == {"s1.txt"}
def test_merge_pass_survives_a_hostile_log_diffmerges_config(tmp_path):
# `-m` means "whatever log.diffMerges says", so a user or repo config of
# `off` makes the merge pass emit no file rows: merge_files comes back
# empty beside a non-zero merges_measured and reads as "the merges changed
# nothing". The command-line -c pin beats the config.
repo, base = _merge_repo(tmp_path)
_git(repo, "config", "log.diffMerges", "off")
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
assert out["merges_measured"] == 2
merge_files = {f["path"]: f for f in out["merge_files"]}
assert merge_files["s.py"]["added"] == 3
assert merge_files["s.py"]["deleted"] == 2
def test_valid_range_with_no_commits_keeps_the_full_shape(tmp_path):
# A mis-specified epic range is a valid, empty range. It must still answer
# with every documented key rather than a differently-shaped stub.
repo = _make_repo(tmp_path)
proc = _proc("--repo", str(repo), "--range", "HEAD..HEAD")
out = _json(proc)
assert proc.returncode == 0
assert set(out) == {
"range",
"commit_count",
"merge_count",
"merges_measured",
"commits",
"files",
"merge_files",
"stories_supplied",
}
assert out["commit_count"] == 0
assert out["merge_count"] == 0
assert out["merges_measured"] == 0
assert out["commits"] == [] and out["files"] == [] and out["merge_files"] == []
def test_linear_history_reports_no_merges(tmp_path):
repo = _make_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD"))
assert out["merge_count"] == 0
assert out["merges_measured"] == 0
assert out["merge_files"] == []
def test_multi_story_subject_attributes_to_every_match(tmp_path):
# First-match-wins silently dropped the second story from the attribution.
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "f.py").write_text("a\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
(repo / "f.py").write_text("a\nb\n")
_git(repo, "commit", "-qam", "fix seam between 1-2 and 1-3")
out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-3,1-2"))
# Every match, in --stories order — not whichever id was passed first.
assert out["commits"][0]["stories"] == ["1-3", "1-2"]
# A repeated id must not list the commit twice: any per-story total built
# from `stories` would count it twice.
repeated = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-3,1-2,1-3"))
assert repeated["commits"][0]["stories"] == ["1-3", "1-2"]
assert repeated["stories_supplied"] == ["1-3", "1-2"]
def test_subject_naming_no_story_gets_an_empty_list(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "f.py").write_text("a\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
(repo / "f.py").write_text("a\nb\n")
_git(repo, "commit", "-qam", "chore: tidy imports")
out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2,1-3"))
assert out["commits"][0]["stories"] == []
def _binary_repo(tmp_path):
"""A binary-only path plus a path that is binary twice and text once."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "keep.txt").write_text("keep\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
base = _rev(repo, "HEAD")
(repo / "src").mkdir()
(repo / "src" / "x.py").write_bytes(b"\x00\x01bin\n")
(repo / "blob.bin").write_bytes(b"\x00\x01blob\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "add binary content")
(repo / "src" / "x.py").write_text("one\ntwo\n") # binary -> text: still binary
_git(repo, "commit", "-qam", "x.py becomes text")
(repo / "src" / "x.py").write_text("one\ntwo\nthree\n") # text -> text: measured
_git(repo, "commit", "-qam", "grow x.py")
(repo / "blob.bin").write_bytes(b"\x00\x02blob\n")
_git(repo, "commit", "-qam", "churn the blob")
return repo, base
def test_binary_revisions_no_longer_erase_measured_text_churn(tmp_path):
repo, base = _binary_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
x = {f["path"]: f for f in out["files"]}["src/x.py"]
assert x["added"] == 1 and x["deleted"] == 0 and x["net"] == 1
assert x["binary_revisions"] == 2
assert x["commit_count"] == 3
def test_binary_only_path_reports_zero_sums_and_its_revision_count(tmp_path):
repo, base = _binary_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
blob = {f["path"]: f for f in out["files"]}["blob.bin"]
assert blob["added"] == 0 and blob["deleted"] == 0 and blob["net"] == 0
assert blob["binary_revisions"] == 2
assert blob["commit_count"] == 2
def test_success_shape_carries_every_documented_key(tmp_path):
repo = _make_repo(tmp_path)
out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD"))
assert set(out) >= {
"range",
"commit_count",
"merge_count",
"merges_measured",
"commits",
"files",
"merge_files",
"stories_supplied",
}
assert set(out["commits"][0]) == {"sha", "subject", "stories", "is_merge"}
assert set(out["files"][0]) == {
"path",
"added",
"deleted",
"net",
"commit_count",
"binary_revisions",
}
def test_explicit_repo_ignores_ambient_git_dir(tmp_path):
repo = _make_repo(tmp_path)
proc = _proc(
"--repo",
str(repo),
"--range",
"HEAD~1..HEAD",
env={"GIT_DIR": str(tmp_path / "wrong-git-dir")},
)
assert _json(proc)["commit_count"] == 1
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,99 @@
# Retrospective Workflow
**Goal:** Review a completed epic by reading the evidence it left in the ticket tree — the epic file, the initiative's requirements, each ticket's entry, story file, and plan, the diff and commits between plan baselines, and session logs when they exist. An unattended epic run leaves a record; this workflow reads that record, surfaces the defects no single ticket could show, and judges the epic against the criteria it set for itself.
Every finding you report carries a source reference (file, line, commit, or log). A claim you cannot point at — an invented root cause, a pattern the diff does not actually show — is not a finding. Drop it.
**CRITICAL:** If a phase directs you to another snapshot file, read it fully and follow it. No exceptions.
## Conventions
- Never state time estimates — AI has changed development speed, so hour/day/week predictions are noise.
## Modes
Interactive by default. With `-H`/`--headless`: skip every confirmation, take the epic from the invocation, never open the team discussion, render the verdict on the evidence alone, and record each assumption made without the user (which epic was selected, the machine verdict, each proposed item) into the retrospective document's Assumptions section so the audit trail survives. The Phase 4 acceptance fail-safe still applies in headless runs.
For automation, `-H <epic folder | id | slug>` — an explicit epic in headless mode — is the stable orchestrator-facing interface. Headless with nothing named stops and reports. The offer of finished epics (see Inputs) is a human convenience, not an automation contract.
## On Activation
### Step 1: Execute Prepend Steps
Execute each of these steps in order before proceeding (`_None._` means skip):
{{ workflow.activation_steps_prepend }}
### Step 2: Load Persistent Facts
Treat every entry below as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim (`_None._` means none):
{{ workflow.persistent_facts }}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{{ workflow.activation_steps_append }}
Activation is complete after all activation steps have run.
## Inputs
| Input | Where | Use |
|-------|-------|-----|
| epic | invocation argument — an epic folder, or an epic id or slug in the active initiative — or chosen from the offer below | which epic to retro |
| epic folder | `tickets.toml`, the epic file `epic-<slug>.md` (the folder's name plus `.md`), one `<type>-<slug>-plan.md` per ticket, and a story file where a ticket was pulled | the record the epic left |
| initiative file | the epic folder's parent's same-named file, section Requirements | what each ticket's `covers` points at |
| architecture / prd | `architecture-*/architecture-*.md` and `prd-*/prd-*.md` in the initiative folder (the epic folder's parent) | context for judging as-built vs intended |
| previous retro (optional) | `<folder name>-retrospective.md` in the previous epic's folder: run `status` with no folder for the `epics` order; the previous epic's folder is the sibling folder of that name | check whether last epic's actions landed |
| session logs (optional) | conversation/session records for the epic's tickets | process lessons; record the gap when absent |
The epic is a folder in the ticket tree, named `epic-<slug>`; the retrospective is `epic-<slug>-retrospective.md` in it — the folder's name plus `-retrospective.md`. Run `tickets.py` as `uv run {project-root}/_bmad/method/scripts/tickets.py --project-root {project-root} <verb>`; a non-zero exit prints an error — show it and stop. A ticket is finished when its `status` is `built` or its `state` is `done` or `dropped`; unfinished is everything else. An epic with no `tickets` rows has nothing to retro: leave it out of the offer, and when it is named, say so and stop. Resolve the reference:
- **Folder given**: run `status <folder>`.
- **Id or slug given** (including `-H <id|slug>`): run `status` with no folder and match the argument against `epics` by `id` or `slug`. No match: list the epics and ask which; headless, stop and report. The folder is the parent of `epic_file` from `find <ref>`, `ref` taken from the first `tickets` row whose `epic` is the slug. Then run `status <folder>`.
- **Nothing given**: interactive, run `status` with no folder and offer the epics whose `tickets` rows are all finished; when none is, say so and ask which. Headless, stop and report.
`status <folder>`'s `tickets` rows, in the order returned, are the ticket list — that order is the build order. Each row carries `id`, `ref`, `title`, `status`, and `state`. `pending_tickets` is the `ref`s of the unfinished rows, scoped to this epic alone.
Then check the epic is actually finished before Phase 1. When `pending_tickets` is non-empty, interactively list those tickets and ask whether to retro an unfinished epic: if the user declines, stop and report — do not enter Phase 1; if they accept, record the tickets they accepted proceeding over in the document's Epic summary. Headless, proceed and record the same list in the Assumptions section — do not invent a confirmation. Either way the list sits in the document, and Phase 4's machine verdict is **rejected** when any ticket remained unfinished (see `{{ rendered("references/acceptance-verdict.md") }}`); a human may override interactively. Tickets still at `built` — finished by the build, not yet called done — are listed in Epic summary too. Then go to Phase 1.
## Working state and resumption
The retrospective document is the working artifact, not only the final output. Once the epic is fixed, create it as a skeleton (`{{ rendered("references/retro-document.md") }}` names the sections) and write each phase's result into it as you finish — inventory, then findings with sources, then dispositions and verdict. Continuity is re-reading the file.
The document is the retrospective file named above, a fixed name so a resumed run finds it. If it already exists, load it, reconcile its recorded state against the current evidence — the current evidence wins, since commits may have landed and questions may have been answered since — and resume at the first incomplete phase instead of redoing finished ones.
## Flow
Run the phases in order. A default run stops at a written evidence report and verdict; the team discussion in Phase 3 is opt-in.
Before Phase 1, interactively invite the user's going-in concerns ("anything you want weighted — a ticket that felt rushed, a risky interaction between two tickets?"). Use any answer to focus the Phase 1–2 analysis; it directs attention but never becomes a finding without a source.
### Phase 1 — Gather
Enumerate what the epic actually produced and record what is missing. Read fully and follow `{{ rendered("references/evidence-gathering.md") }}` for the inventory checklist, the `git_evidence.py` pre-pass that derives each plan's diff range and commits, and the missing-evidence rule: each later analysis declares what it needs and records a narrowed scope when the evidence is absent, so a reader can always tell "checked and clean" from "never checked."
### Phase 2 — Analyze
Produce findings, each with a source reference, from three angles:
- **Aggregate views** — the defects no single diff hunk shows: architecture delta, duplication map, god-class growth, pattern divergence, spec-to-implementation reconciliation. Read fully and follow `{{ rendered("references/aggregate-views.md") }}` for the catalog and how to derive each (deterministic scripts first).
- **Diff-scope review** — do not reimplement review. Invoke **`bmad-review`** on the epic's diff for the code lenses (adversarial, edge-case, verification-gap), weighting the boundaries between tickets, where no single session ever saw both sides. Fold its findings in. If `bmad-review` is unavailable, run those lenses inline over the diff on a narrowed scope and record the narrowing.
- **Behavior check (when the epic changed runtime behavior)** — exercise the changed flows end to end and record what you observed. Passing tests do not substitute for running the system.
Consolidate: merge, dedupe, and provenance-link findings. Drop any finding you cannot tie to a source.
### Phase 3 — Team Discussion (opt-in)
Skip by default; never runs headless. When the user asks to "discuss it as a team," "run party mode," or similar, invoke the skill `bmad-party-mode` seeded with the Phase 2 findings so the installed agents react to real evidence — the god class the diff really grew, the verification gap that is actually there, the wins the evidence confirms. Read fully and follow `{{ rendered("references/team-discussion.md") }}` for how to seed it and keep it grounded. If `bmad-party-mode` is unavailable, run the discussion inline over the Phase 2 findings and record the narrowing. The rule: agents speak only to findings with sources.
### Phase 4 — Decide
- **Action items** — compile fix-now findings and process lessons into specific, owned action items. Fixes and spec reconciliations are *proposed here*, not auto-applied; the human decides what to execute.
- **Acceptance verdict** — judge the final state against the epic file's Done when (profile the criteria from the diff and plans if it has none): **accepted**, **accepted-with-open-items**, or **rejected** — one spelling, everywhere a machine reads it. Unfinished tickets in `pending_tickets` force the machine verdict to **rejected**. A human decision always overrides. An epic that fails its criteria with no human decision is recorded as *not accepted* — never as silently accepted. Read fully and follow `{{ rendered("references/acceptance-verdict.md") }}` for the rubric, the finding-routing dispositions, and the previous-retro follow-through record.
### Phase 5 — Finalize
Finalize the retrospective document and stop. Read fully and follow `{{ rendered("references/retro-document.md") }}` for the document's location, frontmatter, and sections, and the terminal instruction that ends the run. The document is the run's only write: no status change, no `tickets.py mark`, and no edit to the epic file, any plan, or any story file. Closing the epic is `bmad-ticket`'s, confirmed by the user.