# CI & Trains page — Implementation Plan

audience: AI coding agents first.

> **For agentic workers:** Execute as ONE sequential Codex run. Follow checkbox order. Read
> `docs/specs/2026-07-19-ci-trains-page-spec.md` before each task; spec wins on conflict.

**Goal:** Extend existing static `/ci` U5 page with cross-project PR queues, merge trains, runner
occupancy, and land-lag from real `ghci` data. Preserve existing CI & Build behavior.

**Architecture:** Extend `collector/src/adapters/ghci.ts`; do not add an adapter or persistent
history store. Extend existing `CiApp`/`CiContent`; do not create a page/app, change navigation,
or change `apps/web/src/pages/ci.astro` prerender mode. Keep panel history/cache state in adapter
memory. Persist item reconciliation-scope identity only through existing item journal; add no second
restart-persistent store.

**Tech stack:** Bun, TypeScript, GitHub REST/GraphQL via argv-array `gh`, React 18, Astro,
TanStack Query, SSE, Tailwind, `@overdeck/deck-ui`, Vitest, Playwright.

**Branch:** `plan/ci-trains-page` from `main`. Commit per task. NEVER push.

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|---|---|---|---|
| 1 | T0 restore page-regression baseline | `pages.spec.ts`, shared collector fixture | single; prerequisite |
| 2 | T1 GitHub snapshot + budget | `ghci.ts`, tests, fixtures | single; follows T0 |
| 3 | T2 train/job/runner derivation | `ghci.ts`, tests, fixtures | single; follows T1 |
| 4 | T3 lag/amplification memory | `ghci.ts`, tests, fixtures | single; follows T2 |
| 5 | T4 action verbs | `actions.ts`, tests | single; follows T0 |
| 6 | T5 inbox items + action routing | `ghci.ts`, Inbox components, E2E fixtures/tests | single; follows T4 |
| 7 | T6 semantic tokens | `tokens.css` | single; follows T0 |
| 8 | T7 `RepoCiStrip` + `RepoCiTile` | components, shared fixture, `index.ts` | single |
| 9 | T8 `TrainRail` + `TrainCard` + `LaneBar` + `LaneSegment` | components, shared fixture, `index.ts` | single; `index.ts` append is sequential |
| 10 | T9 `PrQueueTable` | component, shared fixture, `index.ts` | single; `index.ts` append is sequential |
| 11 | T10 `RunnerFleetStrip` | component, shared fixture, `index.ts` | single; `index.ts` append is sequential |
| 12 | T11 `AmplificationSpark` | component, shared fixture, `index.ts` | single; `index.ts` append is sequential |
| 13 | T12 extend existing `/ci` | existing CI/Inbox components, panel types, web fixtures/tests | single; follows T5 + T7–T11 |
| 14 | T13 honest partial U5 state | existing CI mappers/content, fixture collector, regression tests | single; follows T12 |
| 15 | T14 verification | none | single; follows all code tasks |
| Post-land | O1 live repo config | `~/.config/overdeck/config.toml` | operational; NEVER run before land |

### T0: restore full page-regression baseline

**Files:**
- Modify `apps/web/tests/fixtures/collector-fixtures.ts`.
- Modify `apps/web/tests/pages.spec.ts`.

**Contract:** Restore the FULL `apps/web/tests/pages.spec.ts` suite before feature work. Update
`PLANS_PANEL` runs to the current `HarnessPlanRun` wire contract: add explicit `repoRoot`,
`abandoned:false`, and `abandonedAt:null` to both runs. Use fixture roots
`/home/user/Projects/overdeck` for `autonomy-p4-babysitter` and
`/home/user/Projects/dep-provisioning` for `dep-provisioning`; do not invent registry metadata.
Replace removed `/plans` snippet `wave 2/2 ▶` with an assertion scoped to the
`[data-testid="project-group-row"]` containing `overdeck`; require that row to contain exact honest
summary `1 need you`. Do NOT restore fabricated wave copy to `PlansContent`.

Keep `CiContent` section title `CI runners`: it describes runner data honestly. Change only the
`/ci` entry in `SIDEBAR_PAGES` from `CI & Runners` to exact heading `CI runners`; do NOT rename the
component to satisfy stale test text. Preserve fixture snippet `alexcodeplace/multideal #4821`.

**Acceptance:**
- [ ] `pnpm exec playwright test apps/web/tests/pages.spec.ts --project=chromium` passes every
  page in serial mode; no skip, warning, console error, or early `/plans` failure.
- [ ] Commit `Restore page regression baseline`.

### T1: GitHub snapshot queries, pagination, and budget

**Files:**
- Modify `collector/src/adapters/ghci.ts`.
- Modify `collector/src/adapters/ghci.test.ts`.
- Create focused JSON fixtures under `collector/test/fixtures/ghci/` for repo metadata, train
  runs, runners, pagination, and budget exhaustion.

**Contract:** Preserve existing `ci` panel fields. Add backward-compatible repo metadata using
exact calls:

1. One bounded history page:
   `GET /repos/{owner}/{repo}/actions/runs?per_page=100&exclude_pull_requests=false`.
2. Two active-run count queries:
   - `GET /repos/{owner}/{repo}/actions/runs?status=queued&per_page=1&exclude_pull_requests=false`.
   - `GET /repos/{owner}/{repo}/actions/runs?status=in_progress&per_page=1&exclude_pull_requests=false`.
   `queueDepth` = sum of both validated `total_count` values; returned node counts do not define it.
3. `GET /repos/{owner}/{repo}/actions/runners?per_page=100`.
4. One `gh api graphql` `CiOpenRepoSnapshot` request per repo:
   - `repository.defaultBranchRef.name`.
   - `refs(refPrefix:"refs/heads/", query:"integration/batch-train-", first:100,
     orderBy:{field:ALPHABETICAL,direction:ASC}) { nodes { name target { oid } } pageInfo {
     hasNextPage } }`.
   - `pullRequests(states:OPEN, first:100,
     orderBy:{field:CREATED_AT,direction:ASC})` selecting `number`, `title`, `body`, `url`,
     `headRefName`, `headRefOid`, `baseRefName`, `createdAt`, `mergeable`, `mergeStateStatus`, and
     `commits(last:1).nodes.commit.statusCheckRollup.contexts(first:100)` with `CheckRun`
     `name/status/conclusion`, `StatusContext` `context/state`, and nested `pageInfo.hasNextPage`.
   Branch refs share this GraphQL request; they add zero HTTP calls.
5. Every tenth successful repo poll only, one separate `gh api graphql` `CiMergedPrSnapshot`
   search per repo. Set `today` from `now()` in UTC and query exactly one date-bounded connection:
   `search(type:ISSUE, first:100, query:"repo:<owner>/<repo> is:pr is:merged
   merged:<UTC-today-minus-6d>..<UTC-today>")`, selecting `issueCount`, `pageInfo.hasNextPage`,
   and `nodes {... on PullRequest { number body url headRefName headRefOid createdAt mergedAt }}`. This
   inclusive UTC calendar range is today plus six preceding UTC dates; never query repository
   merge history without the date qualifier.
6. After resolving current train branch/OID identities from call 4, one exact query for each of the
   first two non-merged trains in T2 canonical order:
   `GET /repos/{owner}/{repo}/actions/runs?branch=<url-encoded-branch>&head_sha=<oid>&per_page=100&exclude_pull_requests=false`.
   Never use call 1 as train evidence. More than two current non-merged identities makes
   `trainRunsComplete:false`; returned exact-query data may remain cached but MUST NOT grant state or
   action eligibility while the repo train set is incomplete.

One page maximum per connection/request. For calls 1, 3, and 6, compare REST `total_count` with
returned length; for GraphQL connections, inspect `pageInfo.hasNextPage`. Calls 2 require successful
responses with non-negative safe-integer `total_count`; their intentionally one-node pages are not
pagination gaps. Any other truncation adds `"pagination"` degradation; affected counts/metrics become
`null` or labeled incomplete, never partial values presented as complete.

Emit independent `queueComplete`, `historyComplete`, `trainRunsComplete`, `runnersComplete`,
`refsComplete`, `prsComplete`, and derived `trainsComplete` on every repo; emit `checksComplete` on
every PR. Failed fresh fetch or skipped admission makes only dependent dimensions false:
- `queueComplete` requires both call-2 counts and controls `queueDepth` plus queue reconciliation.
- `historyComplete` requires a complete call-1 page and controls returned run-history claims plus
  existing run-failure reconciliation only. `total_count > 100` MUST NOT affect queue or train data.
- `trainRunsComplete` requires every current non-merged train identity to fit the two-train cap and
  have a successful, untruncated call 6. It controls latest-run state and mutations.
- `refsComplete` requires the complete call-4 refs connection. `prsComplete` independently requires
  the complete open-PR connection. `trainsComplete = refsComplete && prsComplete &&
  trainRunsComplete`; false suppresses all train state, actions, and train reconciliation scopes.
- `runnersComplete` controls fleet totals/saturation. `checksComplete` controls check rollups.
Preserve simultaneous gaps; never let one false dimension poison unrelated complete data.

Budget ledger counts every GitHub HTTP request before dispatch. Maintain persistent base-rotation
cursor into configured repo order. Before each poll, `B` = at most 5 consecutive repos starting at
cursor and wrapping once; `R = |B|`. Every repo in `B` is admitted for enhanced polling: `S = B`.
Before dispatch, compute `nextBaseCursor = (baseCursor + R) % configuredRepoCount`; assign it after
every poll, including polls with call failures. Advance by admitted count `R`; NEVER compute
`R % R`, which freezes a full batch at `0`. Reset to `0` only when repo list is empty.
`d_i ∈ {0,1}` = repo `i`'s latched merged-fetch due flag; `D = Σ_{i∈B} d_i`; `T` = exact train-run
calls, at most `2R`; `A` = active-train job calls; `L` = uncached failed-job log calls. Calls 1–4
cost exactly `5R`. Reserve `D` plus two potential exact train calls per repo before jobs/logs; release
unused train reservations after call 4 identifies current trains. Full required-snapshot worst case
is `5R + D + T <= 5R + R + 2R = 8R = 40`. Allocate only released/remaining ledger to jobs, then
logs: `5R + D + T + A + L <= 40`. Maximum two exact train queries and two active-train job calls
per repo.

Emit one row for every configured repo on every poll. Repos outside `B` receive no GitHub calls,
retain all cached base/enhanced rows when present, set `queueComplete`, `historyComplete`,
`trainRunsComplete`, `runnersComplete`, `refsComplete`, `prsComplete`, `trainsComplete`, and every
retained `checksComplete`/`jobsComplete` false, and add `"budget"`; without
cache, emit the same budget-degraded row with empty collections and null metrics. These stale/empty
rows MUST NOT advance cadence counters or clear due latches. Rotation, not successful dispatch,
controls next base admission so repeated failures cannot starve later repos.

Maintain cadence per repo. Counter = successful polls since last successful merged fetch, capped at
10. Advance it exactly once only after all five fixed calls (history, both active counts, runners,
and open snapshot) return successfully; response completeness is not required. Exclusion from `B`
or any failed required call does not advance it. At counter `9`,
latch `d_i = 1` before admission so phase 2 reserves the possible tenth-poll merged fetch. Clear
the latch and reset counter to `0` only after a successful merged fetch. Dispatch that fetch only
after the same poll's five fixed calls succeed; otherwise skip it. Exclusion from `B`,
skipped merged dispatch, or failed merged fetch MUST leave `d_i = 1`; while latched, successful polls
do not advance counter past `10`.

Five-repo admission arithmetic:
- Ordinary worst case: history `5` + active counts `10` + runners `5` + open snapshots `5` + exact
  train runs `10` = `35`; exactly five remaining job/log calls can reach `40`.
- Mixed cadence `d = [1,0,1,0,1]`: fixed calls `25` + merged snapshots `3` + exact train runs `10`
  = `38`; exactly two remaining job/log calls can reach `40`.
- All-due worst case: fixed calls `25` + merged snapshots `5` + exact train runs `10` = `40`;
  issue `0` job and `0` log calls. Returned active trains have `jobsComplete:false`, add `"jobs"`,
  label jobs incomplete, and make no blocking or infra-classification claim.
- Fewer than two trains per repo releases unused exact-query reservations to jobs/logs. Branch refs
  remain fields in the open snapshot and add zero calls.

Six-repo live rotation arithmetic:
- Poll 1 starts at repo 1: `B = [1..5]`; repo 6 is budget-degraded empty or stale. Set next cursor
  to `(0 + 5) % 6 = 5`.
- Poll 2 starts at repo 6: `B = [6,1..4]`; repo 5 is budget-degraded empty or stale. Every repo
  receives all fixed calls across two polls; cursor MUST NOT remain `0` after poll 1.
- History `total_count > 100` sets only `historyComplete:false`; complete active-count, ref, PR, and
  exact branch/OID responses still yield complete queue/train state and eligible train actions.

Ten-repo rotation arithmetic:
- Poll 1 uses `B = [1..5]`; poll 2 uses `B = [6..10]`; every repo receives fixed polling across
  two polls. Poll 2 advances cursor to `0` by `(5 + 5) % 10`, not by `R % R`.

Eleven-repo rotation arithmetic and cursor regression:
- Polls use `B = [1..5]`, `[6..10]`, then `[11,1..4]`; every repo receives fixed polling by poll 3.
- Cursor values after polls 1 and 2 are `5` and `10`; call failures do not prevent advancement or
  eventual admission.

Twenty-one-repo rotation arithmetic and required degradation:
- Polls use `B = [1..5]`, `[6..10]`, `[11..15]`, `[16..20]`, then `[21,1..4]`.
- Every admitted repo receives fixed polling. Across five polls every repo receives all fixed calls;
  repos outside each `B` are budget-degraded empty or stale.
- Every poll costs `5R..40` according to due latches and discovered trains and never exceeds `40`
  after jobs/logs. Fixed/merged/exact-query failure does not prevent cursor advancement or eventual
  admission.

Dispatch in strict global phases; finish each phase across its admitted repos before starting next:

1. Select `B`, materialize budget-degraded cached/empty rows for repos outside it, then issue
   history, both active-count, and runner calls for every repo in `B` order. Isolate per-call
   failure; an early repo
   failure MUST NOT abort later admitted repos.
2. Admit every repo in `B` for enhanced polling with all due merged cost reserved; issue open
   snapshots for every admitted repo, then due merged snapshots for each repo whose five fixed calls
   succeeded. A skipped due call remains reserved and latched.
3. Issue exact branch/OID run queries for the first two non-merged trains per repo in T2 canonical
   order. More identities or any skipped/failed/truncated exact query makes `trainRunsComplete:false`.
4. From remaining ledger, issue job pages for newest two admitted non-merged trains per repo using
   persistent rotating job-repo cursor.
5. Issue failed-job logs from remaining budget using separate persistent rotating log-repo cursor.

For phases 4 and 5, order eligible repos cyclically from that phase's cursor and allocate one call
per repo before a second pass. Advance phase cursor on call admission, before dispatch, to configured
successor of last admitted repo; call failure still advances it. If phase admits no call, retain its
cursor. Separate cursors ensure repeatedly constrained job or log slots cannot permanently favor
earlier configured repos.

Count fixed and enhanced admission before dispatch in every phase. For repos outside `B`, retain all
cached data or emit an empty row and add `"budget"`. A failed enhanced fetch keeps cached enhanced
data and marks relevant completeness false. Jobs/logs NEVER consume slots reserved for an earlier
phase. Never issue call 41.

**Behavior:** Remove fixed two-repo truncation. Keep argv-array execution and stable existing
failure items. Map checks deterministically: non-completed/`PENDING`/`EXPECTED` → `pending`;
`SUCCESS`/`NEUTRAL` → `pass`; `FAILURE`/`ERROR`/`TIMED_OUT`/`ACTION_REQUIRED`/
`STARTUP_FAILURE` → `fail`; `SKIPPED` → `skipping`; `CANCELLED`/`STALE` → `cancel`.

**Acceptance:**
- [ ] `pnpm --filter overdeck-collector test -- ghci` passes exact argv and exact
  `refs(refPrefix:"refs/heads/", query:"integration/batch-train-", first:100,
  orderBy:{field:ALPHABETICAL,direction:ASC})` query with ref target OIDs, open/merged PR
  `headRefOid`, exact active-status count queries, exact branch/OID run queries, REST run `head_sha`,
  separate
  tenth-poll date-bounded merged search, UTC day boundaries, and branch-in-open-snapshot accounting
  with zero dedicated branch/ref calls. It also passes five-repo ordinary/mixed/all-due arithmetic,
  per-repo successful-poll counter advancement, due-latch retention outside `B` or after
  skipped/failed merged fetch, all independent completeness flags, pagination gaps, blocked call 41,
  and exact five-repo ordinary/mixed/all-due sequences plus config-order admission and degradation
  assertions above. Add an early-repo fixture with two trains and enough failed jobs/logs to saturate
  remaining budget; assert all admitted repos receive all five fixed calls and exact train queries
  before any job/log, and total calls remain at most 40. Add `history.total_count = 1217` with one
  complete exact train branch/OID response; assert `historyComplete:false` while `queueComplete`,
  `trainRunsComplete`, `refsComplete`, `trainsComplete`, derived train state, and action eligibility
  remain complete.
- [ ] Exact 6-, 10-, and 11-repo rotation tests assert `B` sequences `[1..5]` then `[6,1..4]`,
  `[1..5]` then `[6..10]`, and `[1..5]`, `[6..10]`, `[11,1..4]` respectively; every admitted repo
  receives all fixed calls, cursors advance by admitted count, every repo is admitted within the
  stated polls, and call failure does not stop rotation.
- [ ] Exact 21-repo tests execute five consecutive polls: assert `B = [1..5]`, `[6..10]`,
  `[11..15]`, `[16..20]`, then `[21,1..4]`; every poll dispatches at most `40` calls; every admitted
  repo receives all fixed calls; every repo is admitted by poll 5; cached unadmitted rows remain
  stale/budget-degraded; and uncached unadmitted rows are empty/budget-degraded.
- [ ] Repeated constrained-budget tests give phase 4 and phase 5 one or two slots per poll and assert
  independent job/log cursors eventually admit every eligible repo; dispatch failure still advances
  the relevant cursor and no early repo permanently owns a slot.
- [ ] Commit `Extend ghci repository snapshots`.

### T2: train states, jobs, stale branches, and runner occupancy

**Files:**
- Modify `collector/src/adapters/ghci.ts`.
- Modify `collector/src/adapters/ghci.test.ts`.
- Add train/job/log fixtures under `collector/test/fixtures/ghci/`.

**Contract:** Add per repo:

```ts
degraded: Array<"budget" | "pagination" | "jobs">
queueComplete: boolean
historyComplete: boolean
trainRunsComplete: boolean
runnersComplete: boolean
refsComplete: boolean
prsComplete: boolean
trainsComplete: boolean
prQueueDepth: number | null
oldestQueuedAgeH: number | null
prs: Array<{
  repo: string; number: number; title: string; url: string; branch: string; createdAt: string; ageH: number;
  mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
  staleVsMain: boolean | null;
  checksComplete: boolean;
  checks: Array<{ name: string; bucket: "pass" | "fail" | "pending" | "skipping" | "cancel" }>;
  trainPr: number | null;
}>
trains: Array<{
  repo: string; prNumber: number | null; prUrl: string | null; branch: string; createdAt: string | null;
  members: number[];
  state: "building" | "gating" | "failed" | "rerunning" | "green" | "merged" | null;
  stateGap: string | null;
  gateRun: null | {
    id: number; attempt: number; status: string; conclusion: string | null;
    jobsComplete: boolean;
    jobs: Array<{
      id: number; name: string; status: string; conclusion: string | null;
      runnerId: number | null; runner: string | null;
      startedAt: string | null; completedAt: string | null;
      infraFlag: boolean | null;
    }>;
  };
  blockedBy: string | null;
  infraFlag: boolean | null;
}>
runners: Array<{
  id: number; name: string; status: string; busy: boolean;
  currentJob: string | null; currentRepo: string | null; currentKnown: boolean;
}>
```

`degraded` is deduped in fixed `budget`, `pagination`, `jobs` order; `[]` means complete. Preserve
simultaneous gaps. Add `pagination` for any truncated REST/GraphQL connection and `jobs` when any
train has `jobsComplete:false`; never overwrite one reason with another.

Train entity = union of train refs, open train PRs, and cached merged train PRs. Parse members from
authoritative `Train-Members:` trailer; merge/dedupe fallback `- #NN ` bullets. Resolve current
commit OID per branch before lifecycle derivation: branch-ref `target.oid` wins when ref exists;
otherwise use open PR `headRefOid`, then cached merged PR `headRefOid`. When ref exists, retain an
open or cached merged PR for lifecycle only when its `headRefOid` equals current ref OID. Latest run
= exact branch/OID match from call 6, max tuple
`(created_at, run_attempt, id)`. Mismatched PR/run OIDs are obsolete and MUST NOT determine state,
populate `gateRun`, fetch jobs, emit train items, or grant action eligibility.

Sort trains with one canonical total order: parse terminal numeric `batch-train-N`; numeric suffixes
sort descending and invalid/missing suffixes sort last, then `createdAt` descending with null last,
then `prNumber` descending with null last, then `branch` ascending. Use this order for collector
output and per-repo exact-query/job admission. T8 appends `repo` ascending only when otherwise equal
across repos. Never rely on discovery/object order.

`prQueueDepth` = complete open-PR connection count and `oldestQueuedAgeH` = age of its oldest PR.
`prsComplete:false` makes both null; when `refsComplete:true`, it also makes every retained train
`state:null` with exact `stateGap:"PRs incomplete"`, regardless of cached PR/run identity or prior
state. Preserve existing
`queueDepth` meaning (queued/in-progress Actions runs); `queueComplete:false` makes `queueDepth` null.
Never reuse it as PR queue depth.
Runner busy/total derivations require `runnersComplete:true`; otherwise both aggregate values are
null even though returned runner rows remain visible as incomplete.

Apply completeness guards before lifecycle derivation: `refsComplete:false` → `state:null` plus exact
`stateGap:"train refs incomplete"`; otherwise `prsComplete:false` → exact `PRs incomplete` gap;
otherwise `trainRunsComplete:false` → exact `train runs incomplete` gap. Every guard suppresses
`gateRun`, actions, and train reconciliation scopes; `trainsComplete` is false. Only when
`trainsComplete:true` follow branch lifecycle before run status:

1. Open PR exists: ignore any older merged record for reused branch and derive state from latest run:
   - `run_attempt > 1 && status == "in_progress"` → `rerunning`.
   - `status in {"queued","in_progress"}` → `gating`.
   - `status == "completed" && conclusion == "failure"` → `failed`.
   - `status == "completed" && conclusion == "success"` → `green`.
   - Any unsupported/missing combination → `state:null` plus `stateGap`; NEVER coerce cancelled,
     skipped, stale, or missing runs into a spec state.
2. Otherwise, cached merged PR exists → `merged`, regardless of run status.
3. Otherwise, branch ref exists → `building`, regardless of queued, failed, successful, or missing
   run status.
4. Otherwise → `state:null` plus `stateGap`.

For first two non-merged train PRs per repo in canonical order, call exact
`GET /repos/{owner}/{repo}/actions/runs/{runId}/jobs?filter=latest&per_page=100`. More trains or
`total_count > 100` set `jobsComplete:false` and repo degradation. `staleVsMain` is `true` only
when PR targets `defaultBranchRef.name` and `mergeStateStatus == BEHIND`; `false` for other known
default-branch statuses; non-default base, `UNKNOWN`, or incomplete metadata → `null`.

Classify each failed job with exact
`GET /repos/{owner}/{repo}/actions/jobs/{jobId}/logs`, once per unseen job ID, while budget remains.
Stream the response while retaining only its final 65,536 bytes (64 KiB); after EOF, decode and
classify only that retained suffix. Never buffer the full log or allow bytes before the suffix to
match.
Cache `{infraFlag, matchedSignature}` by job ID in adapter memory. Regex:
`ENOSPC|ENOTEMPTY|No space left|runner.*lost|The self-hosted runner.*lost communication`.
Budget deferral leaves `infraFlag:null` and retries next poll; non-match caches `false`.

`blockedBy` is deterministic: for `failed`, choose earliest failed job by `(startedAt,id)` and
emit `<name> — <matched signature> (infra)` or `<name> — <conclusion>`; for gating/rerunning,
choose in-progress before queued, then `(startedAt,id)`; other states → `null`. Join runner
`currentJob/currentRepo` from fetched `status=="in_progress"` jobs by `runner_id`; tie-break latest
`startedAt`, then greatest job ID. Busy runner without a fetched match uses null fields and
`currentKnown:false`.

**Behavior:** Preserve current runs/runners fields and failure dedupe. Expose job-level
`infraFlag`; train-level infra is `true` iff any job is `true`, `false` iff complete classification
contains no true, otherwise `null`. Never infer current jobs from runner names.
`trainsComplete:false` ALWAYS suppresses every retained train action and makes state unknown using
the guard-specific gap above. No cached/open-snapshot/exact-run identity exception exists.

**Acceptance:**
- [ ] `pnpm --filter overdeck-collector test -- ghci` passes branch-without-PR `building`, all six
  spec states, null gap, latest-run ordering, `staleVsMain`, runner join/unknown, pagination,
  per-job cache, budget deferral, deterministic `blockedBy`, trailer+bullet parsing, and
  queue/history/train-run/runner/ref/PR/train/check completeness propagation. Include a discovered
  exact-run-identity fixture with `trainRunsComplete:false`; assert latest-run-dependent `state:null`
  and `stateGap:"train runs incomplete"`.
  Include cached failed/gating/green trains with exact run IDs and PR URLs under
  `prsComplete:false`; assert every retained train has `state:null`, `stateGap:"PRs incomplete"`, and
  no action eligibility.
  Add branch-only fixtures with queued, failed, and successful latest runs; assert all three remain
  `building`. Add multiple branch-only trains `batch-train-12`, `batch-train-9`, and a same-rank
  tie-break fixture in scrambled discovery order; assert numeric suffix descending, then
  created-at/PR/branch tie-breakers produce stable output and rail admission order.
  Add both branch-reuse regressions: an open PR/current ref at a new OID with an obsolete
  failed or queued run at the prior `head_sha` yields no derived failed/gating state, no `gateRun`,
  and no action eligibility; a reused ref at a new OID before a new PR ignores the prior merged PR
  with the same branch name and derives `building`, never stale `merged`.
  Add truncated-ref fixture with complete PR/exact-run responses; assert `refsComplete:false`,
  `trainsComplete:false`, every retained train has exact `train refs incomplete`, and no gate run,
  action eligibility, or train reconciliation scope.
  Add a log larger than 64 KiB with an infra signature only before the retained suffix; assert
  classification is `false`. Cover a signature inside the retained suffix as `true`.
- [ ] Commit `Derive merge train state`.

### T3: in-memory lag and amplification history

**Files:**
- Modify `collector/src/adapters/ghci.ts`.
- Modify `collector/src/adapters/ghci.test.ts`.
- Add merged-history/amplification fixtures under `collector/test/fixtures/ghci/`.

**Contract:** Add repo field:

```ts
lag: {
  landsToday: number | null;
  medianOpenToMergeH7d: number | null;
  gates: Array<{ runId: number; computeMin: number; wallMin: number }>;
  warmedAt: string | null;
}
```

Use per-repo in-memory ring buffer, keyed by `runId`. Retain internal completion timestamp from
`run.updated_at`; insert only when `run.status == "completed"`, the job page is complete, and
`run.created_at`/`run.updated_at` are valid timestamps ordered `created_at <= updated_at`.
`computeMin = sum(completedAt-startedAt)`; `wallMin = run.updated_at-run.created_at`. Sort by
completion timestamp descending, then numeric `runId` descending. Cap after sorting so the ring
retains the newest 50, regardless of arrival order. Emit `lag.gates` in that exact newest-first
order without exposing the internal completion timestamp.

Derive both merged metrics from the single T1 UTC date-bounded search result. `landsToday` counts
records with `mergedAt >= UTC start-of-today && mergedAt <= now()`. Seven-day median uses records
with `mergedAt >= UTC start-of-today-minus-6d && mergedAt <= now()` and valid
`createdAt <= mergedAt`; require at least three samples. If `issueCount > nodes.length`,
`pageInfo.hasNextPage`, any node lacks required timestamps, or query fails, mark pagination/data
gap and set BOTH metrics to `null`; never compute from first 100 or preserve a partial value.
`warmedAt` advances only after a complete merged search.
`historyComplete` describes only T1 call 1. It MUST NOT gate merged metrics or `lag.gates` built from
complete exact train/job data.

**Behavior:** Add NO file, path, schema, snapshot, atomic writer, or panel restore. New adapter
process starts with empty ring, empty infra cache, and empty merged-PR cache: `gates:[]`,
`landsToday:null`, `medianOpenToMergeH7d:null`, `warmedAt:null` until polls repopulate them. Existing
`items.jsonl` continues restoring inbox items through `CollectorState`; it does not restore panels
or adapter history. Do not recompute lost history from unrelated runs.

**Acceptance:**
- [ ] `pnpm --filter overdeck-collector test -- ghci` passes run dedupe, completion ordering with
  numeric `runId` tie-break, out-of-order arrival, newest-50 eviction, valid-time filtering, UTC
  midnight boundaries, inclusive seven-calendar-day window, search truncation, fewer-than-three
  median gap, and fresh-adapter restart reset. Assert queued and in-progress runs never enter the
  amplification ring, including when their job pages are complete or empty.
- [ ] Commit `Record in-memory CI amplification`.

### T4: mediated train actions

**Files:**
- Modify `collector/src/actions.ts`.
- Modify `collector/src/actions.test.ts`.

**Contract:** Allowlist `ci.rerunFailed {repo,runId}` and `ci.cancelRun {repo,runId}`. Validate
request bodies as `args: Record<string,string>` only. Use strict schemas with no extra keys:
`repo` MUST match exact regex
`/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/` and `runId` MUST
remain a string matching `/^[1-9]\d*$/`, then parse to a positive safe integer. Before spawning
either mutation, read `state.adapterStatuses()` and require a `ghci` status with defined
`lastAttempt` and `lastSuccess`, `lastAttempt === lastSuccess`, and `stale === false`. Then read
current `ci` panel and require exact matching repo plus `gateRun.id`, `refsComplete:true`,
`trainRunsComplete:true`, `prsComplete:true`, and `trainsComplete:true`:

- `ci.rerunFailed`: train `state == "failed"`; argv
  `gh run rerun <runId> --failed -R <repo>`.
- `ci.cancelRun`: train `state in {"gating","rerunning"}` and gate run status queued/in-progress;
  argv `gh run cancel <runId> -R <repo>`.

Reject stale repo/run pairs, wrong repo with valid run ID, any false ref/PR/train-run/train
completeness, ineligible state, extra args, and shell metacharacters before spawn. Journal accepted and rejected
eligible-verb attempts to existing `actions.jsonl` through current gateway pattern.

**Behavior:** Keep default-deny verb dispatch, argv arrays, injected spawn, result capture, and
action-error annotation. UI recommendation does not grant authorization.

**Acceptance:**
- [ ] `pnpm --filter overdeck-collector test -- actions` passes exact argv/journal success,
  string-only args, repo-regex boundaries, extra-key rejection, bad-shape, cross-repo run, stale
  run, reused-branch obsolete-run rejection with zero spawn, independent `refsComplete:false`,
  `trainRunsComplete:false`, `prsComplete:false`, and `trainsComplete:false` rejection with zero spawn,
  plus `historyComplete:false` with every train dimension true still spawning the eligible action,
  ineligible-state, and non-allowlisted cases. Add a successful `ghci` poll that produces an
  eligible panel, then a later failed poll, then an action request; assert rejection and zero spawn.
- [ ] Commit `Mediate CI train actions`.

### T5: train inbox items

**Files:**
- Modify `collector/src/schema.ts`.
- Modify `collector/src/adapter.ts`.
- Modify `collector/src/collector.ts`.
- Modify `collector/src/state.ts`.
- Modify `collector/src/adapters/ghci.ts`.
- Modify `collector/src/adapters/ghci.test.ts`.
- Modify `collector/test/state.test.ts`.
- Modify `apps/web/src/components/inbox/InboxTriageRow.tsx`.
- Modify `apps/web/src/components/inbox/inbox-actions.ts`.
- Modify `apps/web/tests/fixture-collector.ts`.
- Modify `apps/web/tests/fixtures/collector-fixtures.ts`.
- Modify `apps/web/tests/inbox.spec.ts`.

**Contract:** Emit reconciler snapshots:

- Infra-failed train → `act`, stable ID `ghci:train:<repo>:<pr>:infra-failed`, recommended
  `ci.rerunFailed {repo,runId}` with both values serialized as strings.
- Green unmerged train for more than 10 minutes from run completion → `act`, stable ID
  `ghci:train:<repo>:<pr>:green-unmerged`, `open` action to PR URL.
- Open PR queue depth at least 4 with zero trains in `gating`/`rerunning` → `warn`, stable ID
  `ghci:train:<repo>:queue-no-train`.
- Extend `ItemSchema`/`Item` with persisted optional `reconciliationScope?: string`; journal append/load
  preserves it. Missing scope retains legacy adapter-wide reconciliation except narrow ghci
  run-failure migration below. Scope existing ghci run-failure item family per repo: every emitted
  item MUST set exact scope `run-failure:<repo>`. Every new train item MUST set exact scope
  `train:<repo>:infra-failed`, `train:<repo>:green-unmerged`, or `train:<repo>:queue-no-train`
  matching its condition kind.
- Extend `AdapterResultSchema`/`AdapterResult` with optional `completeScopes?: string[]`. Missing or
  empty list marks no named scope complete and preserves existing successful-snapshot reconciliation
  only for unscoped legacy items. Update `Adapter` contract comment and scheduler plumbing so parsed
  `completeScopes` reaches `CollectorState.recordSuccess`.
- `CollectorState` owns IDs by `(adapterId,reconciliationScope)`; journal replay rebuilds that map from
  persisted item `source` plus `reconciliationScope`. Before building ownership, migrate only
  replayed unscoped records with `source === "ghci"`, `kind === "ci"`, existing
  `ghci:<repo>:<runId>:<runAttempt>` identity, and `project === <repo>` to
  `run-failure:<repo>`; append the upgraded item so migration survives another restart. Do not
  migrate train IDs, other adapters, or malformed legacy IDs. Upsert emitted items first. Resolve an
  omitted scoped item only when its exact scope appears in current result's `completeScopes`; retain
  named scopes not listed without tombstones or adapter-local cache. A listed complete scope with no
  emitted items resolves only that scope. Adapter and scope isolation are mandatory.
- `ghci` adds `run-failure:<repo>` to `completeScopes` only when that repo's `historyComplete` is
  true. Failed/skipped history fetch, history pagination, or exclusion from `B` MUST omit that scope;
  runners, PR, job, and log completeness do not control it. Other repos reconcile independently.
- `ghci` adds a repo/kind scope to `completeScopes` only after complete evaluation. Every train scope
  requires `refsComplete:true` and `trainsComplete:true`. `infra-failed` additionally requires
  complete gate jobs and required failed-job classifications; `green-unmerged` requires no extra
  dimension; `queue-no-train` additionally requires `queueComplete:true`. Failed/skipped fetch,
  relevant budget deferral, relevant pagination, incomplete job page, or unresolved required job
  classification excludes only affected scopes. Do NOT re-emit prior full item records: persisted
  scope ownership in `CollectorState` retains them across process restart. Other repos and condition
  kinds reconcile independently in same successful adapter poll.
- Add only `ci.rerunFailed` and `ci.cancelRun` to `GATEWAY_ACTION_VERBS`; keep default deny.
  `open` is NEVER a gateway verb and NEVER sends POST `/actions/open`. In `InboxTriageRow`, route
  `ActionButton` through separate props `onOpen(url: string)` and `onGatewayAction(action)`; an
  `open` action calls only `onOpen(action.args.url)`. Validate before navigation: args contain only
  string `url`, parsed URL uses `https:`, hostname is exactly `github.com`, and credentials are
  empty. Invalid/missing URLs stay on-page, emit a danger toast, and send no POST. Valid URLs
  navigate client-side via `window.location.assign(url)`.
- Fixture collector mirrors both train mutations with exact current-panel `{repo,runId}` plus
  `refsComplete && trainRunsComplete && prsComplete && trainsComplete` authorization and JSON
  `{error}` failures. Inbox E2E covers enabled valid Open navigation,
  invalid URL rejection, zero `/actions/open` calls, exact string train-action args, parsed server
  error text, danger toast, and success toast only after HTTP success.

**Behavior:** Omit item only when its repo/kind evaluation is complete and condition clears; existing
scoped reconciler resolves it. Unknown classification/state/pagination never emits new positive
claims, never marks affected scope complete, and never falsely resolves previously owned item.

**Acceptance:**
- [ ] `pnpm --filter overdeck-collector test -- ghci` passes emit, stable repeat, and complete
  resolve-by-omission for existing run failures and all three train items plus exact persisted scope
  identity and unknown-data suppression. Assert each emitted run failure has
  `run-failure:<repo>`; a complete history snapshot lists that scope even when empty; history failure,
  pagination, or exclusion from `B` omits it. Seed prior items in two repos, then fail required input
  for repo A while repo B completes without its condition; assert result excludes repo A scope,
  includes repo B scope, emits no invented repo-A claim, retains repo A IDs in state, and resolves
  only repo B IDs. Repeat scope incompleteness for budget, pagination, incomplete jobs, and unresolved
  job classification; include ref truncation and assert every train scope is omitted while the
  unrelated run-failure scope and `queueComplete` remain independent. Later complete empty scope
  resolves only its retained IDs.
- [ ] `pnpm --filter overdeck-collector test -- state` passes legacy unscoped reconciliation plus
  named-scope isolation before and after restart. Replay unscoped legacy ghci run failures for repos
  A and B; assert both migrate to persisted `run-failure:<repo>` ownership before reconciliation.
  Report only repo B's run-failure scope complete and empty: repo B tombstones/resolves while repo A
  remains across another journal replay. A later repo A complete empty scope resolves it. Assert
  train IDs, malformed ghci IDs, and other adapters remain unscoped; same scope text under different
  adapters never cross-resolves; and `completeScopes:[]` resolves no named scope.
- [ ] `pnpm exec playwright test apps/web/tests/inbox.spec.ts --project=chromium` passes Open
  navigation/no-POST plus both train mutation success/rejection paths.
- [ ] `pnpm --filter @overdeck/web typecheck` passes Open routing and both new Inbox gateway verbs.
- [ ] Commit `Emit merge train inbox items`.

### T6: train semantic tokens

**Files:**
- Modify `packages/deck-ui/src/tokens.css`.

**Contract:** Add `--mod-color-train-*`, `--mod-color-lane-*`, and infra-stripe semantic tokens in
both themes. Map existing palette values; add no raw component hues.

**Behavior:** State/lane components consume semantic tokens only. Infra stripe preserves text and
segment contrast in dark/light themes.

**Acceptance:**
- [ ] `pnpm --filter @overdeck/deck-ui typecheck` passes.
- [ ] `pnpm --filter @overdeck/deck-ui test` passes.
- [ ] Commit `Add CI train tokens`.

### T7: `RepoCiStrip` and `RepoCiTile`

**Files:**
- Create `packages/deck-ui/src/RepoCiStrip.tsx`, `RepoCiStrip.test.tsx`, `RepoCiTile.tsx`, and
  `RepoCiTile.test.tsx`.
- Create/extend `packages/deck-ui/src/fixtures/ci-trains.ts`.
- Modify `packages/deck-ui/src/index.ts`.

**Contract:** `RepoCiTile` receives typed repo summary props: queue depth, busy/total runners,
lands today or gap, oldest queued age, all degradation reasons, and `onScope(repo)`.
`RepoCiStrip` owns complete repo-selector UI:

```ts
interface RepoCiSummary {
  repo: string
  queueComplete: boolean
  historyComplete: boolean
  trainRunsComplete: boolean
  runnersComplete: boolean
  refsComplete: boolean
  prsComplete: boolean
  trainsComplete: boolean
  prQueueDepth: number | null
  runnersBusy: number | null
  runnersTotal: number | null
  landsToday: number | null
  oldestQueuedAgeH: number | null
  degraded: Array<"budget" | "pagination" | "jobs">
}
interface RepoCiTileProps {
  summary: RepoCiSummary
  selected: boolean
  onScope: (repo: string) => void
}
interface RepoCiStripProps {
  repos: RepoCiSummary[]
  selectedRepo: string | null
  onScope: (repo: string | null) => void
}
```

Render explicit `All` control first. `selectedRepo === null` marks `All` selected and
`onScope(null)` selects it; each tile marks/selects only its exact repo via `onScope(repo)`.
Preserve input/config order. Empty repos render named gap state inside `RepoCiStrip`.

**Behavior:** Oldest queued age over 30 minutes uses act-red. Unknown/truncated data renders
labeled gap, never zero. `prsComplete:false` labels PR metrics incomplete;
`runnersComplete:false` labels runner occupancy incomplete; `historyComplete:false` labels run
history incomplete; `queueComplete:false` labels Actions queue incomplete; `refsComplete:false`,
`trainRunsComplete:false`, or `trainsComplete:false` labels train data incomplete. Preserve
simultaneous gaps.

**Acceptance:**
- [ ] `pnpm --filter @overdeck/deck-ui test -- RepoCiStrip RepoCiTile` passes All default,
  All/repo callbacks, selection, order, empty, real, each completeness gap, multi-degraded, and
  threshold fixtures.
- [ ] Commit `Add repository CI selector`.

### T8: `TrainRail`, `TrainCard`, `LaneBar`, and `LaneSegment`

**Files:**
- Create `packages/deck-ui/src/TrainRail.tsx`, `TrainRail.test.tsx`, `TrainCard.tsx`,
  `TrainCard.test.tsx`, `LaneBar.tsx`, `LaneBar.test.tsx`, `LaneSegment.tsx`, and
  `LaneSegment.test.tsx`.
- Modify `packages/deck-ui/src/fixtures/ci-trains.ts`.
- Modify `packages/deck-ui/src/index.ts`.

**Contract:** `TrainRail` owns non-merged train composition in T2 canonical total order and exact
spec empty state.
`TrainCard` owns member PR links, nullable state/gap badge, deterministic blocking line, separate
mutation callback `onAction(verb, args: Record<string,string>)`, and separate navigation callback
`onOpen(url)`. Open MUST call only `onOpen(prUrl)`; it MUST NEVER flow through `onAction`.
Every train view model consumed by `TrainRail`/`TrainCard` carries `repo:string`,
`refsComplete:boolean`, `prsComplete:boolean`, `trainRunsComplete:boolean`, and
`trainsComplete:boolean` copied from its parent repo. Build every member
link exclusively as `https://github.com/${train.repo}/pull/${number}`; NEVER use `prUrl`, selected
scope, or ambient parent state for member links. Build mutation args exclusively from
`train.repo` and `gateRun.id`.

`TrainRail` also receives completeness for every repo in current scope, including repos with zero
returned trains. If any scoped repo has `trainsComplete:false`, render its guard-specific incomplete
gap and NEVER render the complete `No trains` empty state for that scope. `TrainCard` uses its
train's copied flags; this prevents a truncated ref connection from hiding omitted trains behind a
false empty claim.

Pin action rendering independently per available action:
- `trainsComplete && state == "failed" && gateRun != null` renders rerun via
  `ci.rerunFailed`.
- `trainsComplete && state in {"gating","rerunning"} && gateRun != null` renders cancel
  via `ci.cancelRun`.
- `trainsComplete` plus valid credential-free `https://github.com` `prUrl` renders Open.
- Otherwise omit that action. Recommend rerun only when `infraFlag === true`; eligibility alone
  MUST NOT imply an infra recommendation.

Require `jobsComplete:boolean` in `TrainRail`, `TrainCard`, and `LaneBar` prop contracts whenever a
gate run exists; propagate exact collector `gateRun.jobsComplete` and NEVER default missing/false
to true.
`jobsComplete:false` keeps returned lanes visible under exact `jobs incomplete` label but suppresses
`blockedBy`, infra/non-infra classification, job striping, and rerun recommendation claims.
`LaneSegment` calls `useDeckTooltip` exactly once per component instance to expose job name/duration.
`LaneBar` maps jobs to `LaneSegment`; it MUST NOT call hooks inside the jobs map. `TrainRail`
composition renders exactly one `DeckTooltipLayer`; cards and lanes render none. Carry repo
`refsComplete`, `prsComplete`, `trainRunsComplete`, and `trainsComplete` into rail/card props. Apply
T2 gap precedence exactly. Any `trainsComplete:false` suppresses rerun, cancel, and Open.

**Behavior:** Running/queued/pass/fail colors follow tokens; with complete jobs,
`infraFlag:true` stripes only that job and null classification stays honestly unknown. Empty rail
uses spec copy.

**Acceptance:**
- [ ] `pnpm --filter @overdeck/deck-ui test -- TrainRail TrainCard LaneBar LaneSegment` passes T2
  canonical ordering including multiple branch-only trains, states, independent ref/train-run/PR
  incomplete gaps, member links, exact action matrix, infra-only rerun
  recommendation,
  Open URL validation with no mutation call, exact string mutation args, and complete/incomplete
  `jobsComplete` propagation. Unit cases prove returned incomplete lanes show `jobs incomplete` and
  omit blocking/classification/striping/recommendation claims. They also prove each false train
  completeness input renders its exact gap and no train action; each `LaneSegment` attaches one
  `useDeckTooltip` target across variable job counts without hook-order violations, and exactly one
  `DeckTooltipLayer` renders per rail. Cross-repo duplicate PR numbers prove member URLs and mutation
  repos come from each train's `repo`. A zero-return truncated-ref repo renders train refs incomplete,
  never complete empty state. Also cover complete empty rail.
- [ ] Commit `Add merge train cards`.

### T9: `PrQueueTable`

**Files:**
- Create `packages/deck-ui/src/PrQueueTable.tsx` and `PrQueueTable.test.tsx`.
- Modify `packages/deck-ui/src/fixtures/ci-trains.ts`.
- Modify `packages/deck-ui/src/index.ts`.

**Contract:** Columns from spec; age-desc default sort; expandable checks; conflicting badge;
train membership; `staleVsMain:true` needs-rebase chip; null renders unknown, not false. Accept repo
`prsComplete` and per-row `checksComplete`. False PR completeness labels returned rows `PR list
incomplete`; false check completeness shows returned contexts plus `checks incomplete` and never a
complete/pass rollup. Every PR row view model carries `repo:string`. Accept explicit all-scope state;
all scope renders a Repo column for every row, while single-repo scope omits that column.

**Behavior:** Cross-repo/all scope and single-repo scope preserve deterministic sorting.

**Acceptance:**
- [ ] `pnpm --filter @overdeck/deck-ui test -- PrQueueTable` passes sorting, expansion,
  conflict/train/stale/null states, PR/check incomplete states, all-scope Repo column with duplicate
  PR numbers across repos, single-repo column omission, and empty fixture.
- [ ] Commit `Add PR queue table`.

### T10: `RunnerFleetStrip`

**Files:**
- Create `packages/deck-ui/src/RunnerFleetStrip.tsx` and `RunnerFleetStrip.test.tsx`.
- Modify `packages/deck-ui/src/fixtures/ci-trains.ts`.
- Modify `packages/deck-ui/src/index.ts`.

**Contract:** Runner box shows online/offline, busy/idle, current job/repo, or explicit unknown.
Show head-of-line saturation only when `onlineRunners.length > 0 &&
onlineRunners.every(runner => runner.busy)` and oldest queued age is known above 15 minutes. Accept
`runnersComplete`; false labels fleet incomplete, makes aggregate
busy/total unknown, and suppresses saturation even when every returned runner is busy.

**Behavior:** Unknown current job never displays guessed repo/job. Incomplete runner pagination
suppresses saturation claim.

**Acceptance:**
- [ ] `pnpm --filter @overdeck/deck-ui test -- RunnerFleetStrip` passes busy, idle, unknown,
  saturation, incomplete-runner suppression, empty, and all-offline-with-old-queue fixtures; the
  all-offline fixture MUST NOT render saturation.
- [ ] Commit `Add CI runner fleet strip`.

### T11: `AmplificationSpark`

**Files:**
- Create `packages/deck-ui/src/AmplificationSpark.tsx` and `AmplificationSpark.test.tsx`.
- Modify `packages/deck-ui/src/fixtures/ci-trains.ts`.
- Modify `packages/deck-ui/src/index.ts`.

**Contract:** Receive `lag.gates` newest-first and render exactly `gates.slice(0,10)` as paired
compute/wall bars. Headline median `wallMin/computeMin`; zero/invalid denominator excluded.
Empty/reset/incomplete history renders gap with warm-up status.

**Behavior:** Never convert null history to zero or claim persisted history after restart.

**Acceptance:**
- [ ] `pnpm --filter @overdeck/deck-ui test -- AmplificationSpark` passes `slice(0,10)` order and
  cap, paired bars, median, invalid sample exclusion, empty, and restart-warmup fixtures.
- [ ] Commit `Add CI amplification sparkline`.

### T12: extend existing static `/ci` U5 page

**Files:**
- Preserve unchanged: `apps/web/src/pages/ci.astro`, `apps/web/src/components/ci/CiApp.tsx`.
- Modify `apps/web/src/components/ci/CiContent.tsx` and
  `apps/web/src/components/ci/ci-mappers.ts`.
- Modify `apps/web/src/components/inbox/InboxTriageRow.tsx`.
- Modify `apps/web/src/lib/panel-data.ts`.
- Modify `apps/web/src/lib/page-mappers.ts` and `apps/web/src/lib/overview-mappers.ts`.
- Modify `apps/web/src/lib/action-client.ts`.
- Modify `apps/web/tests/fixtures/ci-build-fixtures.ts`.
- Modify `apps/web/tests/fixtures/collector-fixtures.ts` only for shared `ci` panel shape.
- Modify `apps/web/tests/fixture-collector.ts`.
- Modify `apps/web/tests/ci-build.spec.ts`.
- Modify `apps/web/tests/inbox.spec.ts`.
- Modify `apps/web/tests/map.spec.ts` and `apps/web/tests/overview.spec.ts`.
- Create `apps/web/tests/ci-trains.spec.ts`.

**Contract:** Extend `CiContent` inside existing `CiApp`/`CollectorPageApp`. Exported `CiContent`
MUST render `<ToastProvider><CiContentInner /></ToastProvider>`; only `CiContentInner` calls
`useToast`. Preserve `CiApp`, `CollectorPageApp`, query-client ownership, SSE bridge, and collector
state query unchanged. Import mutation client ONLY from
`apps/web/src/lib/action-client.ts`; do not import or call the duplicate
`apps/web/src/lib/collector-client.ts` action function. `action-client.ts` keeps
`args: Record<string,string>`, parses JSON `{error}` on non-2xx responses, and throws that exact
server message for caller danger toasts. Add repo-scope state and typed panel mappers.
`apps/web/src/lib/panel-data.ts` must mirror complete collector T1–T3 shape without narrowing:

```ts
interface GhciPanelData { repos: GhciRepoPanel[] }
interface GhciRepoPanel {
  repo: string
  degraded: Array<"budget" | "pagination" | "jobs">
  queueComplete: boolean
  historyComplete: boolean
  trainRunsComplete: boolean
  runnersComplete: boolean
  refsComplete: boolean
  prsComplete: boolean
  trainsComplete: boolean
  queueDepth: number | null
  runs: Array<{
    id: number; name: string; headBranch: string; status: string;
    conclusion: string | null; htmlUrl: string; updatedAt: string;
  }>
  runners: Array<{
    id: number; name: string; status: string; busy: boolean;
    currentJob: string | null; currentRepo: string | null; currentKnown: boolean;
  }>
  prQueueDepth: number | null
  oldestQueuedAgeH: number | null
  prs: Array<{
    repo: string; number: number; title: string; url: string; branch: string; createdAt: string; ageH: number;
    mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; staleVsMain: boolean | null;
    checksComplete: boolean;
    checks: Array<{
      name: string; bucket: "pass" | "fail" | "pending" | "skipping" | "cancel";
    }>;
    trainPr: number | null;
  }>
  trains: Array<{
    repo: string; prNumber: number | null; prUrl: string | null; branch: string; createdAt: string | null;
    members: number[];
    state: "building" | "gating" | "failed" | "rerunning" | "green" | "merged" | null;
    stateGap: string | null;
    gateRun: null | {
      id: number; attempt: number; status: string; conclusion: string | null;
      jobsComplete: boolean;
      jobs: Array<{
        id: number; name: string; status: string; conclusion: string | null;
        runnerId: number | null; runner: string | null;
        startedAt: string | null; completedAt: string | null; infraFlag: boolean | null;
      }>;
    };
    blockedBy: string | null; infraFlag: boolean | null;
  }>
  lag: {
    landsToday: number | null; medianOpenToMergeH7d: number | null;
    gates: Array<{ runId: number; computeMin: number; wallMin: number }>;
    warmedAt: string | null;
  }
}
```

Collector `prs[]` and `trains[]` carry `repo:string`; web mappers MUST enrich every deck-ui PR/train
view model from authoritative parent `GhciRepoPanel.repo`. Train mapping copies
`refsComplete`, `prsComplete`, `trainRunsComplete`, and `trainsComplete` from that same parent; NEVER
default or infer them from state. NEVER derive repo from URLs, selected scope, or array position.
Train member links use exclusively
`https://github.com/${train.repo}/pull/${number}`. Train mutations use exclusively mapped
`train.repo` plus `gateRun.id` for exact string `{repo,runId}` args.

Compose new sections in spec order from named deck-ui components only: `RepoCiStrip`, `TrainRail`,
`PrQueueTable`, `RunnerFleetStrip`, `AmplificationSpark`. Keep existing U5 offload surface after
them inside stable `data-testid="ci-build-offload"` container.

Scope rules: initialize `selectedRepo` to `null`; repo strip always receives all repos. `null`
passes every repo, PR, non-merged train, runner group, and per-repo amplification series to named
components. Non-null passes exact matching repo only. Reset missing selection to `null` after SSE
refresh. Pass explicit all-scope state to `PrQueueTable`; it renders its Repo column only while
`selectedRepo === null`. Named deck-ui components own section and empty/gap rendering; `CiContent`
owns data selection and callbacks only.

Fixture collector must allow both train verbs and validate exact current panel `{repo,runId}` plus
eligible state, `refsComplete:true`, `trainRunsComplete:true`, `prsComplete:true`, and
`trainsComplete:true`. Inbox E2E must prove
`ci.rerunFailed` dispatches exact args/requester, both new verbs
render enabled, success is reported only after HTTP success, and stale/ineligible rejection surfaces
server error without optimistic success.

Map `queueComplete`, `historyComplete`, `trainRunsComplete`, `runnersComplete`, `refsComplete`,
`prsComplete`, `trainsComplete`, `checksComplete`, and `jobsComplete` without defaults that turn
missing/false into true. Every named component receives its relevant completeness flag; shared
fixtures include complete, independently incomplete, and simultaneous-incomplete cases. Existing
U5 run-history claims use only `historyComplete`; train components/actions use `refsComplete`,
`trainRunsComplete`, `prsComplete`, and `trainsComplete` and never `historyComplete`.
Existing map queue aggregation MUST require every repo to have `queueComplete:true` and non-null
`queueDepth`; render exact `queue <sum>` only then, never a partial total. Otherwise render exact
`queue incomplete`. Existing overview queue rows render `<queueDepth> waiting` only for repos with
`queueComplete:true` and non-null `queueDepth`; otherwise render exact `queue incomplete`.
Train mutation handlers accept exact `{repo:string,runId:string}` and call
`postCollectorAction(verb, {args, requestedBy:'overdeck-web'})`. Open handlers are separate:
validate `https://github.com` URL with no credentials, then call `window.location.assign(url)`;
never call `postCollectorAction` for `open`. `CiContentInner` uses `useToast`: emit success only
after resolved 2xx JSON; emit danger with exact parsed server `error` on rejection.

**Behavior:** Do NOT create `CiPageApp.tsx`, change route title, add nav, edit `ci.astro`, or set
`prerender=false`. Keep static route mode and `ci-build.spec.ts` behaviors green. Every new visual
element comes from `@overdeck/deck-ui`; plumbing owns no one-off widget markup. Mutation buttons
post exact string `{repo,runId}` and surface parsed server failure without optimistic success. Open
buttons perform validated client navigation and send no gateway request. Scope existing U5 action-verb
assertion to `[data-testid="ci-build-offload"]`; assert its values remain `X1_OFFLOAD_VERBS`.
Separately assert train dashboard exact action matrix, infra-only rerun recommendation, exact posted
string `{repo,runId}`, Open navigation with zero `/actions/open` requests, delayed success/danger
toasts, cross-repo member links/Repo column, and each completeness gap in `ci-trains.spec.ts`. Add a
  cached-train cases with independently false refs, PRs, train runs, and aggregate train
  completeness; assert `state:null`, exact gap, and no rerun/cancel/Open control. Add render/action
  case that loads `/ci`, invokes one eligible train
mutation, observes its toast, and
asserts zero provider/context page errors; this MUST fail if `useToast` executes outside
`ToastProvider`. E2E MUST include `jobsComplete:false`: returned lanes show `jobs incomplete`;
blocking/classification claims are absent.

**Acceptance:**
- [ ] `pnpm exec playwright test apps/web/tests/ci-build.spec.ts apps/web/tests/ci-trains.spec.ts apps/web/tests/inbox.spec.ts --project=chromium`
  passes both themes, All/repo scope, state/gap rendering, separate U5/train verb assertions, exact
  CI/Inbox action posts, Open no-POST navigation, parsed rejection toasts, cross-repo member URLs,
  all-scope Repo column, all completeness gaps including incomplete jobs, SSE refresh, successful
  `/ci` toast-provider render/action coverage, and zero console/page errors.
- [ ] `pnpm exec playwright test apps/web/tests/map.spec.ts apps/web/tests/overview.spec.ts --project=chromium`
  passes complete queue totals plus independently incomplete history/queue and null queue fixtures;
  `historyComplete:false` does not suppress a complete queue, while map and overview render exact
  `queue incomplete` for `queueComplete:false` and never partial or `null waiting` values.
- [ ] `pnpm --filter @overdeck/web build` passes with `/ci` still prerendered static.
- [ ] Commit `Extend CI page with merge trains`.

### T13: honest U5 partial state and `/ci` fixture regression

**Files:**
- Modify `apps/web/src/components/ci/CiContent.tsx` and
  `apps/web/src/components/ci/ci-mappers.ts`.
- Modify `apps/web/tests/fixture-collector.ts`.
- Modify `apps/web/tests/fixtures/collector-fixtures.ts`.
- Modify `apps/web/tests/fixtures/ci-build-fixtures.ts` if U5 source fixtures require coverage.
- Modify `apps/web/tests/pages.spec.ts`, `apps/web/tests/ci-build.spec.ts`, and
  `apps/web/tests/ci-trains.spec.ts`.

**Contract:** Remove every fabricated U5 KPI/value from `offloadKpiTiles`: hardcoded remote build
count/success, epoch-discard count, pull-back duration, p95/subcopy, host attribution, and incident
counts. Values with collector sources remain derived; absent source renders labeled `not recorded`
gap. Render sections independently: missing offload panels show U5 gaps but do not hide available
`ci` panel content. Preserve T0's honest `CI runners` heading and
`alexcodeplace/multideal #4821` fixture snippet contract; never reintroduce `CI & Runners` test text.

**Behavior:** `/ci` with only shared `CI_PANEL` renders non-empty CI content and honest offload
gaps. Full offload fixture preserves current U5 cards/modals. No invented numeric fallback uses
`0` unless source explicitly reports zero.

**Acceptance:**
- [ ] `pnpm exec playwright test apps/web/tests/pages.spec.ts --project=chromium --grep '/ci renders non-empty fixture content'`
  passes.
- [ ] `pnpm exec playwright test apps/web/tests/ci-build.spec.ts apps/web/tests/ci-trains.spec.ts --project=chromium`
  passes partial/full fixtures, exact action authorization, existing U5 behavior, and no fabricated
  KPI assertions.
- [ ] Commit `Render honest CI partial state`.

### T14: verification gate

**Files:** None; verification only.

**Contract:** Every command must match a real workspace package/spec. Any warning, skipped target,
`No projects matched`, console error, or failed test blocks completion.

**Behavior:** Run from repository root after T0–T13. Do not narrow failing suites.

**Acceptance:**
- [ ] `pnpm --filter overdeck-collector test` passes.
- [ ] `pnpm --filter @overdeck/deck-ui test` passes.
- [ ] `pnpm exec playwright test apps/web/tests/pages.spec.ts apps/web/tests/ci-build.spec.ts apps/web/tests/ci-trains.spec.ts apps/web/tests/inbox.spec.ts --project=chromium` passes.
- [ ] `pnpm --filter @overdeck/web build` passes.
- [ ] `pnpm typecheck` passes for repository.
- [ ] `git status --short` contains only intended implementation files.

## Post-land operation O1: configure live GitHub repos

**Files:** Modify `/home/user/.config/overdeck/config.toml` only after code lands.

**Contract:** Set `[adapters.ghci].repos` exactly, in this order:

```toml
[adapters.ghci]
repos = [
  "alexcodeplace/multideal",
  "alexcodeplace/zync.is",
  "alexcodeplace/press.zone",
  "alexcodeplace/overdeck",
  "alexcodeplace/mega-plan-harness",
  "platform-modules/mod",
]
```

Do not hardcode these six slugs in adapter defaults.

**Behavior:** Preserve unrelated live config keys. Restart collector only after TOML validation.

**Acceptance:**
- [ ] `rg -U '^repos = \[\n  "alexcodeplace/multideal",\n  "alexcodeplace/zync\.is",\n  "alexcodeplace/press\.zone",\n  "alexcodeplace/overdeck",\n  "alexcodeplace/mega-plan-harness",\n  "platform-modules/mod",\n\]$' /home/user/.config/overdeck/config.toml` matches once.
- [ ] `systemctl --user restart overdeck-collector.service` succeeds.
- [ ] `systemctl --user is-active overdeck-collector.service` prints `active`.

## GOLIVE candidate ACs

Do NOT edit `GOLIVE.md`; owner accepts separately.

- AC-19: `/ci` shows every configured GitHub repo's PR queue + trains; infra-failed train offers
  mediated, journaled rerun-failed action.
- AC-20: queue-without-train and green-unmerged items reach Inbox and resolve by omission.
