# 14. Six phases, because two of the eight were doing the same work twice

**Status:** Accepted · 2026-09-18 · amends [ADR-0005](./0005-lazy-phase-docs.md) · amended by [ADR-0015](./0015-one-pipeline-no-depth-answer.md)

Three further ADRs carry the old numbers in their bodies and now carry a pointer
back here instead of being rewritten: [ADR-0002](./0002-instruction-driven-flag.md)
(Phase 6 commit truth table), [ADR-0008](./0008-installer-modularization-and-secret-leak-defense.md)
(producer/consumer phase docs) and [ADR-0010](./0010-own-code-graph.md)
(`phase-1-analysis.md`, Phase 7 refresh). [ADR-0007](./0007-multi-tool-adapter-framework.md)
is Superseded and needs none.

## Context

The pipeline shipped an eight-phase contract (0 Init, 1 Analysis, 2 Planning,
3 Dev, 4 Review, 5 Test, 6 Commit, 7 Report) from v1 until v18. Two seams in
it were doing redundant work, and both were visible in the repo rather than
inferred.

**The build ran twice.** Phase 2 Dev built the project and tee'd the log
(`phase-2-dev.md:177-182`, up to three attempts). Phase 3 Review opened by
building it again (`phase-3-review.md:19-20`). Both logs fed the same evidence
gate. Nothing consumed the difference between them - the second build existed
because Review was written to be runnable standalone, not because the first
build was untrusted.

**Analysis and Planning were already one unit.** The depth picker never chose
between them: `agent-state.schema.json` described `onlyDevelop` as "Short
pipeline - phases 1 and 2 are skipped", and `features/skill-conformance.md`
opened an entire section with "Dev-mode substitutes (Phases 1 and 2 never
ran)". Two phase numbers, one decision, in every mode that had a choice.

A third fact shaped the mapping rather than motivating it: the phase count
appeared in 91 places across 40 files and **no gate held any of them**. The
command count (56) was guarded in seven files, the jq count was guarded, the
persona count was guarded. The phase count was not. A renumbering could have
left the whole tree stale and the suite would have stayed green.

## Decision

Six phases. The mapping:

| Was                           | Is    | Name   | What changed                                       |
| ----------------------------- | ----- | ------ | -------------------------------------------------- |
| 0 Init                        | **0** | Init   | Nothing                                            |
| 1 Analysis + 2 Planning       | **1** | Plan   | One doc, one exit gate                             |
| 3 Dev + 4 Review Stage 1      | **2** | Dev    | Verify became Dev's exit gate; the build runs once |
| 4 Review (Stage 2-3) + 5 Test | **3** | Review | The user test moved inside Review                  |
| 6 Commit                      | **4** | Commit | Nothing                                            |
| 7 Report                      | **5** | Report | Nothing                                            |

Analysis folds into Plan rather than into Init because of the token budget,
not preference. Init is 733 lines with a 13,400-token ceiling, already the
largest doc; adding Analysis would have put it near 18,000 and
`smoke-token-budget.sh` would have rejected it. Folded into Planning instead,
the merged doc lands under Init's existing ceiling.

Three supporting decisions came with it:

**`pipeline/schemas/phases.json` is the contract.** The phase list previously
existed as eight independent copies, none derived from another. It is now one
file that `gen-mode-dispatch.mjs`, `runs-index.mjs`, `log-metric.sh` and the
token budget read, and that `smoke-phase-contract.sh` holds every remaining
copy to. Comparison thresholds that used to be literals (`phase >= 6` for
"waiting on you", the Short-run boundary) are named fields in it.

**`smoke-no-mcp-in-dev-phases.sh` keeps its `phase >= 2` threshold, and the
gate now asserts that it is unchanged.** Figma MCP was reachable only in
Analysis (1); Analysis is now inside Plan (1). The permitted set `{0, 1}` is
identical either way. The threshold surviving a renumbering is a result of the
mapping, not an oversight, and an edit that "corrects" it would widen MCP
access - so the assertion is written as a gate rather than a comment.

**`metrics.jsonl` gets a generation marker, not a rewrite.** The file is
append-only and `phase: 3` means Dev in a pre-v19 row and Review in a post-v19
one. Every line written from v19.0.0 on carries `phaseSchema: 2`; a line
without the field is generation 1. `pipeline/lib/phase-schema.mjs` resolves
both, and the two aggregators that compared phase numbers to literals
(`token-budget-report.mjs`, `run-aggregator.mjs`) go through it. The file held
four rows at the time of the change, so migrating history was not worth doing;
adding the field was, because the next renumbering will not find four rows.

## Consequences

Positive:

- One build per run instead of two. Phase 3 Review inherits `.build.log` and
  `.test.log` from Phase 2 and runs the evidence gate against them.
- The phase count is guarded. `smoke-phase-contract.sh` derives it from
  `phases.json` and checks the generator's output, the token budget, the state
  schema bounds, every shipped surface that states a count, the progress
  fractions in sample output, and the named thresholds. It fails on a
  deliberately wrong contract - verified both ways.
- `state-2.1.0-to-2.2.0.mjs` is the first migration that rewrites phase
  numbers, and it repairs four defects the live corpus already carried. The
  62-file corpus went from 52 valid to 62.

Negative:

- Two source phases can collide onto one target key in `state.phases{}`. The
  merge rule (furthest-along status, max `retryCount`, union of `files[]`,
  earliest start, latest finish) is a judgement call, and `retryCount` takes
  the max specifically because the schema caps it at 3 and a sum would emit an
  invalid state.
- Historical `agent-log.md` files keep their old phase names. They are dated
  human documents and are correct as written; only new runs use new names.
- Phase 3 Review is now the largest doc in the set. Stage 1 left it and the
  user test entered it, and the net is growth. Its ceiling was re-measured
  after the merge rather than summed from the old two, because
  `smoke-token-budget.sh` rejects a ceiling more than 25% above the
  measurement - summing would have shipped a stale ceiling by construction.

## Alternatives Considered

**Fold Analysis into Init.** Rejected on the token budget, as above. The
semantic argument pointed the same way: `onlyDevelop` already treated Analysis
and Planning as one unit, and never grouped Analysis with Init.

**Keep six phases, hide the empty tiles in the tracker.** Rejected: this is
cosmetic. The double build is a contract problem, not a display problem, and
hiding tiles would leave it running while making it harder to see.

**Fold Report into Commit.** Rejected. `ROADMAP.md` records the Phase 5
approval requirement as a permanent design line: Jira, Confluence, wiki and PR
bodies are externally visible, and content sent in the wrong tone leaks to the
team. Merging Report into Commit would put that approval gate inside a phase
that autopilot runs without interaction.

**Drop the user test entirely.** Rejected. `phase-3-review.md` produces
structured evidence that Phase 4 consumes; it is a real output, not a pause.
It moved inside Review and kept its behaviour, including its waiting state.

**Renumber without a contract file.** Rejected - this is what created the
problem being fixed. Eight hand edits to eight independent copies is the
mechanism by which the ninth copy gets missed.
