A feature moves through the system, not through the context window.

Six stages, each ending in a gate that produces a durable artifact the next stage reads cold. Specification through governed build, automated UAT, hybrid code review, and out to a PR contract that says what was actually proven.

14 specs 46 skills (.cursor + .agents) 12 hook events wired 2,727 LOC harness 2,295 LOC harness tests policy: review-bible.md 987 lines
01
Specify
specs/NNN-slug/
spec.md
checklists/
uat-checklist.md
GateHuman approves the spec before planning. Reject aborts.
02
Plan
plan.md
research.md
data-model.md
contracts/
GateHuman approves the plan before tasks are generated.
03
Build
tasks.md
working code
build-receipt.json
GateVerifier decides done, not the builder. One terminal state, recorded.
04
Review phase
results.json
review-packet.md
review-summary.json
GateEvery required criterion proven, or the loop parks for a human.
05
Code review
tier.json
deterministic.json
findings.md
GateEvery finding adjudicated: fix, route, or dismiss with a reason.
06
PR contract
pr-contract.md
→ PR description
→ pipeline
GateApproval asserts three things. "I read every line" is not one of them.
built and exercised end to end built, not yet proven on a live PR each stage reads the previous stage's file, never its transcript
01 Specify 14 specs

The spec is the oracle, and it is machine-readable

/speckit-specify writes specs/NNN-slug/spec.md from a natural-language description. What makes the rest of the workflow possible is that the acceptance scenarios aren't prose decoration — review/criteria.py parses them into ordered criterion IDs (CR-01, CR-02…) that later stages can prove against.

What a spec directory carries

specs/003-duplicate-account-check/ spec.md ← acceptance scenarios, parsed into CR-ids plan.md research.md data-model.md quickstart.md tasks.md uat-checklist.md contracts/ checklists/requirements.md

Two parsing subtleties that carry weight

  • The [UI] tag. A scenario marked [UI] cannot be proven by a view or test-client proxy. It demands a passing browser-layer test — the rendered page, or nothing.
  • Attribution is explicit, never guessed. A test module names its own spec path in its docstring; first match wins over a 2,000-character head window. Rows that can't be attributed are counted and surfaced rather than cross-credited to a neighbouring spec, because bare CR-ids exist in every spec.

Wave 3 of the build loop hand-derived spec 003's numbering from the raw markdown and the parser matched exactly. Counts were then hand-verified on specs 005 (16 criteria, decoy list correctly excluded) and 008 (10). All nine specs parsed at the time: 106 criteria.

02 Plan gated

Planning is a separate approval, on purpose

/speckit-plan produces the design artifacts. The speckit workflow puts an explicit human gate on both sides of it — review-spec before planning and review-plan before tasks — and both are on_reject: abort. You cannot fall forward into implementation.

.specify/workflows/speckit/workflow.yml

specify ──► [gate] review-spec ──► plan ──► [gate] review-plan ──► tasks ──► implement on_reject: abort on_reject: abort

Constraints live where the machinery can read them, not only in prose: .specify/memory/constitution.md for principles, review/risk_tier_paths.json for what counts as dangerous, and .cursor/rules/*.mdc for workflow conventions. One authoritative list each — decision D7 exists specifically to stop two divergent notions of "dangerous path" from living in the repo at once.

03 Build 4/4 waves T1

A build loop with a finite boundary and a recorded outcome

/speckit-build merges task breakdown and implementation into one session, because splitting them makes the implementer re-derive intent the planner already had. It runs unattended, serially, and bounded — and it never marks its own work done.

Boundedstop at whichever comes first

Per task: 3 failed attempts.

Per run: one forward pass through the stories, plus at most one rework pass.

Repeating an approach that already failed is not progress.

Terminal statesexactly one, written to the receipt

CONVERGED — every story passed the verifier, suite green. The only success state.

BLOCKED — environment or coordination problem. Migration divergence lands here by rule.

EXHAUSTED — hit the boundary without converging.

NO_PROGRESS — two consecutive attempts cleared nothing.

Complete ≠ correct

The builder never marks a story done on its own judgment; a separate verifier decides. A run that ends in prose alone has not finished — it must classify its outcome into build-receipt.json. That receipt is what the dashboard and the next stage read.

The autonomy loop that built the review harness — LOOP-STATE.md

Waves 1–4 of the harness were themselves built under this protocol: a fresh builder agent in its own worktree makes one bounded change; a fresh reviewer agent in a detached checkout of the builder's SHA reruns every gate from scratch, then proves the test by reverting the change (must go red) and mutating it (must go red).

Trust tiers, assigned at accept
TierMeansResult
T1 provenAll gates green + revert proof red + mutation proof red4 of 4 waves
T2 gatedGates green, proof partial — reason logged0
P parkedHuman required; evidence attached0

The reviewer chose its own mutations, not the builder's. Across the four waves, 16 of 17 independent mutations were killed. The one survivor was honest signal, not a gap: disabling the duplicate-check service left the behavior blocked anyway, because User.email unique=True still holds — CR-02 turned out to be enforced three times over. Recorded as a finding, not patched away.

R1/R2/R3 grade a change. T1/T2/P grade how well a completed wave was proven. Different axis, different vocabulary — the docs call this out explicitly because the two were being conflated.

04 Review phase live

Automated UAT: does the feature do what the spec promised?

This is a different question from the build's own gates. The build asks "do my tests pass?" The review phase asks "is every required acceptance criterion in spec.md actually proven?" — and it asks from a fresh context that never saw the build.

System map

spec.md ──parse──► criteria.py ─┐ │ (CR-ids, [UI] flags) review-marked ──pytest──► conftest collector ──► results.json tests (markers) │ ▼ packet.py: compute_coverage + verdict │ │ review-packet.md review-summary.json (human-facing) (machine-facing, SSOT) │ ┌───────────────────────────────────────────────┬──────┴────────┐ ▼ ▼ ▼ run.py (two-lane, gate exit code) loop.py build-dash make review / review-det (control plane) Review screen

Two lanes, and only one of them gates

Deterministicgates — exit 2 on red

Service, view, model and admin-layer checks. Browser-free by construction: pytest addopts pins -m "not integration" so the gate can never depend on a browser being installed.

Integrationinforms — never gates

Playwright browser proofs, run explicitly via -m "review and integration". A flake here downgrades the verdict to 🟡; it never fails the gate. Screenshots and video land in review/artifacts/browser/.

Verdict is spec-aware, not suite-aware

A green suite is not a green verdict. packet.py only reports pass when every required criterion is proven. An unproven criterion is treated as risk, not as test hygiene — it downgrades the verdict to 🟡 Incomplete and says so loudly in the packet, while exiting 0. Only a check that ran and failed exits non-zero.

33checks passed0 failed, 0 informational
30criteria verifiedof 35 required
5unverifiedall in spec 003
3specs covered003 · 013 · 014
review-summary.json — current coverage rollup
SpecRequiredVerifiedState
003-duplicate-account-check945 unproven
013-transcript-reminders1616complete
014-course-creation-core1010complete

The convergence control plane

review/loop.py decides the next action — FIX_RED before PROVE_UNVERIFIED — and decides when to stop: CONVERGED, PARKED, NO_PROGRESS, EXHAUSTED. A genuine pass is checked first, so a green run at the budget edge is never mislabelled as exhausted.

The decider is not the fixer. loop.py never edits application code. The agent that fixes is an injected, pluggable callback — the speckit-review skill. Demonstrated live moving spec 003 from 3/9 to 4/9 proven.

05 Code review machine half live

The hybrid: the agent does the exhaustive verified read, the human adjudicates

An agent reads every line of a large diff and never gets bored on line 400 — but it cannot decide what matters, and unconstrained it invents findings that evaporate under scrutiny. A human decides what matters — but at this volume cannot read everything, and pretending otherwise produces approvals that look like coverage. Each half covers the other's failure mode.

Step one — the tier is computed, not declared

review/tier.py derives R1/R2/R3 from the changed-file list against review/risk_tier_paths.json. Highest match wins: one R3 path in an otherwise docs-only PR makes the whole PR R3. A dev may escalate freely; lowering requires a named approver and a recorded reason.

What each tier requires — review-bible.md §3.2
RequirementR1R2R3
Deterministic checks (§4.1)
LLM judgment pass
Failure-path tests for new behavior
make review incl. Playwrightif spec'd
Mutation proof on new tests
Human review11 + independent 2nd agent pass
Preview environment exercised

Path globs couldn't express "anything Stripe" — apps/applications/views.py holds StripeWebhookView and the PaymentIntent creator but has no "stripe" in its filename, so money code was grading R2. The fix was content regexes matched against file bodies, targeting SDK calls and settings keys rather than the bare word so prose doesn't trip them. Erring toward R3 is deliberate: tiering money code down is the unsafe direction.

Step two — the deterministic set, run for real

Six commands, all six must pass, and none is redundant. review/deterministic.py shells out and records real subprocess output with the SHA it ran against.

deterministic.json — live run, be2ca08 · trustworthy: true · clean tree
CommandCatches what nothing else doesResult
ruff check .Lint violationspass
black --check .Formatting driftpass
pytest --covBehavioral regressionspass
manage.py checkDjango config errors — pytest passes with a misconfigured app registrypass
makemigrations --checkModel/migration drift — pytest structurally cannot catch thispass
lintmigrationsBackwards-incompatible migrations — "is this safe to deploy?", not "is one missing?"pass

Zero-to-clean, and two of the four failures weren't lint at all

f64a6c27 — "Green the deterministic gate" — took this table from four reds to six greens. ruff/black were the easy ones: 2 lint errors and 4 unformatted files, all auto-fixable, plus one real modernization (typing.Callablecollections.abc.Callable). The other two reads were hiding something worse than a lint violation:

  • lintmigrations was crashing, not reporting (TD-16). It ran sqlmigrate over every third-party migration and died on a Wagtail one — wagtailcore_grouppagepermission. Scoping it to our own apps stopped the crash and surfaced 3 real errors in our own migrations that the crash had been hiding: NOT NULL constraints on columns in migrations already applied to prod, which can't be rewritten after the fact. Baselined by name rather than disabling the rule — verified live by removing a baseline entry and watching it go red again.
  • The settings block meant to configure the linter had never been read. It was named DJANGO_MIGRATION_LINTER; the package reads MIGRATION_LINTER_OPTIONS — zero references to the old name anywhere in the installed package. ignore_name_patterns and warnings_as_errors had never applied to anything, silently, until this commit renamed it.
  • The pytest failure was order-dependent, not flaky. fingerprint_for() claimed to be a pure disk-state read but went through MigrationLoader, which pytest-django's --nomigrations patches to report zero migrations once any test sets up a database. Passed in isolation, failed in the full suite — every fingerprint was silently collapsing to the same empty-string hash. Fixed by reading the migrations package off disk directly, pinned by a django_db regression test that forces the exact failure condition.

Deliberately not maximal. all_warnings_as_errors stayed off — two already-applied migrations carry unfixable CREATE INDEX warnings, and failing the gate on day one over something nobody can act on is exactly how a gate earns being ignored. Live right now, re-run at be2ca08: 1,108 passed, 4 deselected; tests/review/ 281 passed; tree clean at commit.

This panel used to report a lie it couldn't detect

Green checkmarks above weren't always trustworthy in the way they look. Until be2ca08, code_review.py's prepare command returned 0 unconditionally, discarding the verdict it had just computed — and the hook wrapped the call in if, which disarms set -e, so a genuinely red gate printed nothing at all, not even to stderr. Two swallow points stacked on top of each other. Fixed and proven by injecting a ruff error and watching prepare move from exit 0 to exit 1, with the hook reporting it instead of going quiet. The table above is the first version of this panel where a red row was structurally guaranteed to be seen.

The makemigrations --check line is the one most easily dropped and most expensive to lose: addopts pins --nomigrations, so pytest builds the test schema straight from the models and never executes a migration. A green suite proves nothing about whether the migration files match the models.

Step three — two lanes, two forcing documents

Verification lanecatches missing proof

Fill a behavior→proof matrix by lookup: behavior changed, test that proves it, red without the change, failure path. You cannot name a test without confirming it exists. Empty cells are the findings.

Correctness lanecatches wrong code

A defect report where every finding carries Location, Input, Expected, Actual, Repro, Severity, Route. Anything that can't fill Input/Actual/Repro goes to a separate Questions for the human list that is explicitly not severity-ranked.

That separation is the single most important implementation detail. Without it, uncertainty leaks into the findings list as hedged bugs and the output loses credibility by week three.

Step four — routing, because severity alone doesn't tell you who fixes it

Every finding lands in exactly one bucket
BucketFixable byHandling
Code defectEngineeringFix in this PR if in scope, else a bd issue
Spec contradictionProduct ownerEngineering cannot fix it — two documented positions disagree
Phantom implementationWhoever owns statusCorrect the tracker first; planning is running on it
Test infrastructureEngineeringFix before trusting any pass rate, including this review's
Coverage gapEngineeringFailure paths before happy paths
Doc rotAnyoneCheap — batch them
DismissedAuthorTo the contract's disposition field, with the reason

Routing a spec contradiction to engineering wastes a sprint. It comes back as "blocked, needs decision" no matter who picks it up.

06 PR contract writes to Bitbucket

The record that makes soft enforcement real

review/contract.py emits the machine half from real results. The human writes only three fields: Intent, Dispositions, and Reviewer focus areas. Everything else is filled or it isn't there.

pr-contract.md — the emission on this branch, right now

Risk tier: R3 — High computed from changed paths. Not author-declared. Size: 6,417 hand-written lines · 0 generated · 49 files ⚠ over the ~250 guideline — consider splitting Intent ✍ human Deterministic 6 of 6 checks pass, vs be2ca08a · clean tree Spec/UAT evidence → review-packet.md — 0 proven, 6 unverified Test evidence 18 changed test files, all "unproven — no behavior→proof matrix on disk" Dispositions ✍ human — what you did NOT act on, and why Focus areas ✍ human — where you want eyes, and where you don't

Read what a green run still refuses to do. All six checks pass now — but the contract does not read that as "done." Seventeen changed test files still come back unproven, with the reason stated: no behavior→proof matrix exists on disk yet, because that matrix is B3's job, not the deterministic gate's. It will not let a size warning pass silently at 6,417 lines. Green deterministic checks and a proven spec are different claims, and the contract keeps them in separate rows on purpose.

Approving asserts three things and nothing more: findings adjudicated, intent right, you can explain it. It does not assert "I read every line." On R2 the agent asks a substantive "why" and the human answers in the PR; on R3 the human walks through the change and the agent challenges the explanation against the code. "The agent wrote it and the tests pass" is not an explanation.

Bitbucket Cloud has no PR label primitive, so the description is the only place the tier is both durable and visible without opening a tool. The packet copy is what survives if the description is edited. As of the last commit (85abac8), a human no longer pastes it: review/pr.py finds the open PR for the current branch and upserts the contract between HTML-comment markers — idempotent, so re-running replaces the block instead of appending another copy, and preserves anything a human wrote outside the markers. make code-review-publish previews the write; code-review-publish-apply sends it. Nothing reaches the PR without that explicit flag — an outward-facing write doesn't get a silent default.

Cross-cutting

What runs underneath all six stages

Hooks.cursor/hooks.json — 12 events

tdd-gate.sh on preToolUse. code-review-prepare.sh on beforeSubmitPrompt and stop — fast path skips pytest for latency, the stop path runs it. understand-auto-update.sh keeps the knowledge graph fresh. The collector hook feeds build-dash on every event.

Beadsbd — issue tracking as a dependency graph

Every wave claims a bead before work and closes it after the merge lands — an ordering learned the hard way when bd close auto-committed onto a branch mid-merge. No TodoWrite, no markdown TODO lists.

build-dashtools/build-dash — the surface

An event reducer over the hook stream with parsers per stage: spec, plan, tasks, research, build-receipt, review. It renders review-summary.json and re-implements none of its logic.

Isolationmake test-isolated DB_NAME_SUFFIX=…

Per-agent databases with --create-db. Parallel agents on a shared --reuse-db deadlock and then report infrastructure noise as product failures.

§ Tenets

Six rules the machinery exists to enforce

These aren't aspirations written after the fact. Each one is a lesson already paid for, and dropping any of them is how the workflow becomes noise.

01
Never ask an agent to find bugs.

A bug hunt asks the model to be right about something it invented. Give it a document to produce that requires citations, and one hard rule: every factual claim must be verified against the code first. Findings then arrive as a side effect of not being allowed to write the comfortable sentence.

docs/code-review-spec.md §2 — inherited from the tech-debt sweep
02
A defect claim must come with a reproduction, not a suspicion.

State the concrete input, the exact line, and the wrong result. If you cannot demonstrate the failure it is not a finding — downgrade it to a question for the human, or drop it. Never report it as a probable bug.

docs/code-review-spec.md §2 — the rule the sweep didn't have
03
Never lead the witness.

The reviewing agent gets the diff and the spec. It does not get the author's explanation of why the code is fine. In the review phase the same rule runs the other way: to close a red finding you fix the real behavior — never edit or weaken the check to turn it green.

code-review-spec.md §4 · speckit-review SKILL.md · LOOP-STATE.md hard rules
04
No borrowed verdicts.

If an exemplar carries a strong claim — "9/9 proven" — agents will copy that shape onto work that earned nothing of the sort. It has to be forbidden by name: the exemplar says X because it had Y; your unit has no Y; state your actual evidentiary basis. Without this, review launders confidence, which is worse than no review.

docs/code-review-spec.md §4
05
The decider is not the fixer.

The convergence control plane decides what to fix and when to stop, and never edits application code. The agent that fixes is an injected, pluggable callback. A review that fixes things is unreviewable and unbounded — write-scope confinement is a constraint in the prompt, not a convention.

review-phase-architecture.md §3 tenet 8 · code-review-spec.md §4
06
Single source of truth in Python; the dash only renders.

All coverage and verdict logic lives in packet.py, which emits a structured review-summary.json. The dashboard maps that JSON onto a screen and never re-implements the logic. The same discipline puts tier paths in one JSON file that both the review skill and the build loop's park rule read.

review-phase-architecture.md §3 tenet 7 · code-review-spec.md D7
State audited

Where it actually stands

🟡 Incompleterequired criteria unproven

The workflow's own verdict on itself, read from review-summary.json. Everything below is the independently audited column of docs/code-review-spec.md §8 — not a self-report.

8metP1 · P2 · B1 · B2 · B4 · B5 · B6 · B7
2partialB3 · B8 — both down to "never run live"
0not metwas 1 (B8) three commits back
26.7%failure paths252 of 943 test functions
Build sequence — re-verified against docs/code-review-spec.md §8 and live code, 29 Jul 2026
StepWhatStateThe gap
P1pytest_plugins collection errorsmet739/743 collected, 0 errors, no tests deleted to get there
P2Per-agent DB isolationmetSame suffix twice still collides; every run leaks a database
B1Failure-path requirement in the build skillmetClosed (be2ca08)review/failure_paths.py could measure but was structurally incapable of failing: no threshold, every code path returned 0. Added --changed/--base (diff-scoped, not the global 26.7%) and --fail-on-zero, wired into prepare via make review-failure-paths-gate. Deliberately scoped to the diff — gating on legacy zero-coverage files would fail forever and teach people to disable it. Caught a real offender on its first run: test_program_cta_service.py was happy-path only
B2Tier computationmetClosed since the earlier readR100\told\tnew now parses as the three tab-separated fields git actually emits, so a rename into an R3 directory grades correctly. Stripe coverage is by content pattern, not filename, so money code can't hide behind a name with no "stripe" in it. 41 tests
B3The code-review skill — both lanespartialStill the real gap: never run on a real PR — that clause is exercise, not code, and it was deliberately not softened to match what got built. What did move (be2ca08): the Step 2 conformance block was checked in as a literal <run> placeholder, not runnable, and its *.md glob flagged pr-contract.md itself as malformed unit output — unit files now land in latest/units/ with nullglob so an empty run is silent. Two of the doc's own complaints were struck as false rather than fixed: the routing table has carried a "Fixable by" owner column all along, and the conformance grep covers 6 of 6 mandated headings, not 4
B4Execution capabilitymetClosed since the earlier read — all 6 commands now produce real results. Pytest no longer dies at argparse with zero tests run; a check that could not execute is recorded as error, kept structurally distinct from fail so an infrastructure gap can never masquerade as a red suite. HEAD is captured before and after, tree dirtiness is recorded, and the contract carries the ⚠️ warning when results describe uncommitted code
B5ruff/black back in an automatic gatemetClosed (be2ca08) — two swallow points, not one. prepare returned 0 unconditionally, discarding the verdict it had already written to the manifest; it now returns a result and exits non-zero on any red check. The hook wrapped the call in if, which disarms set -e, so a failing prepare printed nothing — not even stderr; it now captures the exit status explicitly. Proven, not asserted: injecting a ruff error moves prepare from exit 0 to exit 1 and the hook reports it. bd lsuoce-registration-d0h (move lint/format into a review skill) is still open — the gate exists now, but the skill migration it originally asked for hasn't happened
B6Contract emissionmetClosed since the audit (85abac8) — review/pr.py resolves the open Bitbucket PR from origin and upserts the contract into its description between HTML-comment markers, so re-running replaces rather than appends and human prose outside the block survives. Nothing is written without --apply. 29 tests, all offline
B7Automatic triggeringmet*Closed (be2ca08), * a stated boundary below. Cursor hooks are shell-only — there's no agent/prompt field in hooks.json — so the fan-out can't be invoked directly. The hook now surfaces a red gate via additional_context instead of staying silent, following the repo's own precedent in understand-auto-update.sh ("tell the developer, do not silently run it yourself"). It nudges; a human still triggers /code-review and still runs code-review-publish-apply. The status cell says "with a boundary" rather than claiming more than that
B8Independent second pass for R3partialRanking and corroboration were already fixed — corroborated-first within severity band, matched on file:line/token overlap, pass 2 as a sibling directory. New this round (be2ca08): runs made before that fix had left a stale latest/second-pass/ child directory whose own INSTRUCTIONS.md still taught the layout the fix forbids — an artifact actively instructing the wrong thing, worse than no artifact. init_second_pass now detects and removes it. Still open: no live dual-pass run has ever exercised any of this

Three ways this ends up wasted effort

Named in the spec so they get checked early rather than discovered late:

  • B3 without the reproduction rule → a plausible-bug generator, ignored by week three.
  • B7 without B6 → the review runs but leaves no record, so nothing is attestable.
  • B8 as self-checking → a manufactured second opinion, worse than skipping it. Asking the same agent to check its own work is not independence.

The number to move

Failure-path coverage is 26.7% — a real number rather than an alarming one. The actionable target is the 13 files with zero, not the aggregate. make review-failure-paths ARGS=--zero names them.

The previously published figure was 2.7% and it was wrong. A shell grep counted assertion tokens and missed this codebase's dominant idiom — assert not result — scoring a password-reset file with 8 failure paths of 16 as zero. It also counted the wrong unit: the standard is a property of a test, not of an assertion. The measurement now lives in review/failure_paths.py as AST analysis per test function, so it can be re-checked rather than re-argued.

$ Surface

The whole workflow from a terminal

# Stage 04 — automated UAT
make review              # both lanes, incl. Playwright — before any PR with user-facing criteria
make review-det          # deterministic lane only — no browsers, safe anywhere
make review-loop         # one review + the next action + the terminal state

# Stage 05 — hybrid code review
make review-tier         # R1/R2/R3 from the diff — nobody types it
make code-review-prepare # tier + the six §4.1 checks + contract → artifacts/latest/
make code-review-deterministic
make code-review-contract

# Measurement
make review-failure-paths ARGS=--zero    # the 13 files with none
make test-isolated DB_NAME_SUFFIX=review_unit ARGS='apps/foo/'

None of these run in CI, deliberately. The standard is dev-side because a failure a developer can act on immediately is worth more than one that surfaces after the fact — but dev-side does not mean manual. The hooks run the machine half; the developer sees results. A standard that depends on human diligence gets skipped under deadline, and its being skipped is invisible. That is precisely how the security scan and the PR Validation step both went dark for months.