# harness-tier-backlog Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Rename the tier taxonomy (`critical/regular/simple` → `low/medium/high`) with a closed-enum, all-or-nothing coverage lint, then build the tier-downgrade backlog/triage feature on top of it (auto-detection, manual flag, per-plan/project/global views, manual re-run).

**Architecture:** Wave 1 lands the tier rename as a self-contained, mechanical change (schema, presets, runner defaults, a new preset-lint rule) with zero new features — it must pass validation before wave 2 starts, since every wave-2 record/detector speaks the new taxonomy. Wave 2 adds the backlog feature per `docs/specs/2026-07-01-harness-tier-backlog-design.md`: a `rung` field on `resolve-seat.sh`'s output, a new `lib/backlog.sh` persistence helper (independent of `lib/journal.sh`, which cannot accept a `backlog/v1` record), detection + `tierExplicit` capture + a `runSingleTaskWithOverrides` wrapper in `runner.js`, three gateway routes, and a new Backlog web UI page.

**Tech Stack:** Bash (`lib/*.sh`), Node.js (`src/runner.js`, `presets/_validate.mjs`), Astro + React (`mega-plan-harness/web`), JSON Schema.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1, Task 2 | `spec/presets.schema.json`, `presets/_validate.mjs` | ✅ no overlap |
| 2 | Task 3, Task 5, Task 5b | `presets/codex.json`, `src/runner.js`, `lib/test-resolve-seat.sh` + `test/runner-integration.sh` | ✅ no overlap |
| 3 | Task 6 | none (validation + smoke test only) | single task — **gate**: wave 4 does not start until this passes |
| 4 | Task 7, Task 8 | `lib/resolve-seat.sh`, `lib/backlog.sh` | ✅ no overlap |
| 5 | Task 9, Task 10, Task 11 | `src/runner.js` (tierExplicit capture, detector, `runSingleTaskWithOverrides`) | single file — sequential sub-steps, kept as 3 tasks for bite-sizing but same wave slot (see note) |
| 6 | Task 12, Task 13 | `web/src/pages/api/gateway/runs/[runId]/tasks/[taskId]/backlog.js`, `web/src/pages/api/gateway/backlog/[backlogId]/requeue.js` | ✅ no overlap |
| 7 | Task 14 | `web/src/pages/api/gateway/backlog.js` | single task |
| 8 | Task 15 | `web/src/pages/backlog.astro`, `web/src/components/Backlog.tsx` | single task |

**Note on Wave 5:** Tasks 9–11 all touch `src/runner.js`. They are listed as three bite-sized tasks for reviewability but execute **sequentially within the same wave slot** (one file, no parallel dispatch) — the executor should treat this as a 3-step sequential sub-chain, not three independent dispatches.

**`meta.scheduler`: `dag-parallel`** — waves 1, 2, 4, 6 each hold ≥2 independent file-disjoint tasks.

## Decision-enumeration

No `gated` records. Every fork here is mechanical (rename, add a field, add a file) with a single correct answer already pinned by the spec and this plan; nothing is irreversible, no run-level policy choice, no graph-changing ambiguity. Wave 3 (Task 6) is the "tier rename must land before backlog work" checkpoint from the spec's Non-goals — enforced by wave ordering + dependency edges, not a human gate.

---

## Task 1: Close the tier enum in the preset schema

**Wave:** 1
**Blocks:** Task 3, Task 6
**Blocked by:** —

**Files:**
- Modify: `spec/presets.schema.json` — tier-name description currently reads "Tier names OPEN (simple|regular|critical|...)"; change to a closed enum.

**Contract:**
- The schema's tier-key pattern/enum for `seats.<seat>.<tier>` must accept exactly `low`, `medium`, `high` and reject any other tier name (including the old `simple`/`regular`/`critical`).

**Behavior:** No other schema fields change. Update the field's description text to match (`"Tier names: low|medium|high (closed enum)."`).

**Acceptance:**
- Run: `node presets/_validate.mjs --self-test`
- Expected: fails until Task 2's fixtures are updated too — acceptable at this point in isolation; re-run after Task 2 lands (same wave) and confirm PASS.

- [ ] Update the schema
- [ ] Commit: `git add spec/presets.schema.json && git commit -m "tier taxonomy: close preset schema tier enum to low/medium/high"`

## Task 2: Add all-or-nothing tier-coverage lint + update self-test fixtures

**Wave:** 1
**Blocks:** Task 3, Task 6
**Blocked by:** —

**Files:**
- Modify: `presets/_validate.mjs:257-292` (`validateRepositoryState`) — add a call to a new `validateTierCoverage({ presetName, seats })` per preset entry, alongside the existing `validatePresetWrapperPaths` call.
- Modify: `presets/_validate.mjs` — update the self-test fixture data (`runSelfTest`, ~lines 73-256, and the `defaultExampleRunconfigs` overrides at lines ~15-48) that reference `coder.critical`/`coder.regular`/`coder.simple` to the new `low`/`medium`/`high` names.

**Contract:**
- New function `function validateTierCoverage({ presetName, seats })` — for every seat in `seats` that is a *tiered* object (has no top-level `wrapper` key, i.e. rung-3 shape per `lib/resolve-seat.sh:248`), assert its keys are exactly `{low, medium, high}` (order-independent, no subset, no superset). Throw `Error` with a message naming `presetName` + seat + the missing/extra tier(s) on violation. A flat seat (has `wrapper` directly) is unaffected — this rule only applies to seats that opted into tiering at all.

**Behavior:** Applies to every preset file already loaded by `loadPresetEntries` (`presets/_validate.mjs:404`). `presets/codex.json` is currently flat and will become fully tiered in Task 3, so it must pass once that task lands. `presets/anthropic-less.json` is the only current tiered preset and remains untouched by this plan's preset edits; it must continue to pass with its existing legacy vocabulary. `presets/cursor.json` is fully flat — untouched, passes trivially (no tiered seats to check).

**Acceptance:**
- Run: `node presets/_validate.mjs --self-test`
- Expected: `self-test: ok`

- [ ] Add `validateTierCoverage` and wire it into `validateRepositoryState`
- [ ] Update self-test fixtures to `low/medium/high`
- [ ] Run acceptance check → `self-test: ok`
- [ ] Commit: `git add presets/_validate.mjs && git commit -m "tier taxonomy: add all-or-nothing tier-coverage lint, update fixtures"`

## Task 3: Add `low`/`medium`/`high` tier layer to `presets/codex.json`

**Wave:** 2
**Blocks:** Task 6
**Blocked by:** Task 1, Task 2

**Files:**
- Modify: `presets/codex.json` — replace the current flat `seats.coder` and flat `seats.reviewer` bindings with 3-tier objects keyed by `low`, `medium`, `high`, each tier holding a `{wrapper, model}` binding.

**Contract:** `seats.coder` and `seats.reviewer` each become tiered objects with exactly `low`, `medium`, `high`, each a genuinely distinct binding — no two tiers may share the same `model` string. `wrappers/codex.sh`'s `parse_model_effort` already supports a real `medium` effort value (`gpt-5.X-{low,medium,high,xhigh}` — verified: its regex accepts `medium`, it's simply unused anywhere yet), so use it instead of reusing an existing tier's value. `coder` today is `gpt-5.5-high`; keep that as `coder.high` (it's already the strongest setting), add `coder.medium: gpt-5.5-medium` and `coder.low: gpt-5.5-low`. `reviewer` today is `gpt-5.5-low`; keep that as `reviewer.low`, add `reviewer.medium: gpt-5.5-medium` and `reviewer.high: gpt-5.5-high`. All tiers use `wrapper: wrappers/codex.sh`. Add fallback strings following the existing tiered-preset pattern (`presets/anthropic-less.json`'s `coder.critical.fallback: "coder.regular"`): `high -> <seat>.medium`, `medium -> <seat>.low`. `low` has no fallback entry.

**Behavior:** `seats.fixer` is absent from this preset and remains absent. This is a structural change, not a rename: `presets/codex.json` is the live codex-only preset, so the new tier layer must be real and self-contained rather than derived from legacy `simple`/`regular`/`critical` keys that never existed here.

**Acceptance:**
- Run: `node -e "JSON.parse(require('fs').readFileSync('presets/codex.json','utf8')).seats.coder.high.model && console.log('ok')"`
- Expected: `ok` (proves the renamed key round-trips as valid JSON with the new tier name present)

- [ ] Add the new tier layer for coder + reviewer, reusing the current flat bindings as `medium`
- [ ] Run acceptance check → `ok`
- [ ] Commit: `git add presets/codex.json && git commit -m "tier taxonomy: add low/medium/high tier layer to codex preset"`

## Task 5: Rename the default tier in `src/runner.js`

**Wave:** 2
**Blocks:** Task 6
**Blocked by:** Task 1, Task 2

**Files:**
- Modify: `src/runner.js:182,231,356,389,394,521` — every `task.tier || "regular"` / literal `"regular"` fallback becomes `"medium"`.

**Contract:** No signature changes. Pure string-literal substitution: `"regular"` → `"medium"` at the six call sites above (verify by grep — do not rely on this list alone: `grep -n '"regular"' src/runner.js` must return zero matches after this task).

**Behavior:** Unrelated to Task 9 (`tierExplicit` capture, Wave 5) — that task lands later and touches `loadPlan` (`src/runner.js:512-524`), a different region; no conflict.

**Acceptance:**
- Run: `grep -n '"regular"' src/runner.js; echo "exit:$?"`
- Expected: `exit:1` (grep finds nothing)

- [ ] Replace all `"regular"` literals with `"medium"`
- [ ] Run acceptance check → `exit:1`
- [ ] Commit: `git add src/runner.js && git commit -m "tier taxonomy: default tier is now medium, not regular"`

## Task 5b: Rename tier vocabulary in the resolve-seat/runner shell test suites

**Wave:** 2
**Blocks:** Task 6
**Blocked by:** Task 1, Task 2

**Files:**
- Modify: `lib/test-resolve-seat.sh` — every `simple`/`regular`/`critical` tier literal (25 occurrences: preset fixture at lines ~44-46, tier args and override keys throughout the `run_test`/`run_negative_test` calls) → `low`/`medium`/`high` respectively (`simple→low`, `regular→medium`, `critical→high`), including inside JSON override strings (e.g. `"coder.regular"` → `"coder.medium"`) and the tier-comparison guard at line 61 (`"$tier" != "regular"` → `"$tier" != "medium"`).
- Modify: `test/runner-integration.sh:57` — `"tier":"regular"` → `"tier":"medium"`.

**Contract:** No test *behavior* changes — this is a vocabulary-only rename so these suites keep testing the same resolution paths (tiered/flat/override/gate0/risk-rejection) under the new tier names. Do not touch any `anthropic-less` preset-name references in these files if present — that pre-existing preset-name drift is still out of scope for this task. Only codex-tier literals that changed because `presets/codex.json` now uses `low`/`medium`/`high` belong here.

**Behavior:** These two files are part of the project's frozen ship `TESTCMD` (`bash lib/test-resolve-seat.sh; ...; bash test/runner-integration.sh`, see `.claude/scripts/ship.sh`) — if left on the old tier vocabulary, Tasks 3 and 5 would make these suites test against tier keys that no longer exist in `presets/codex.json`, and the final `ship.sh land` step (run automatically at the end of this plan) would fail on unrelated-looking test breakage. Leave any `anthropic-less` preset-name literals untouched; only codex-tier literals change here.

**Acceptance:**
- Run: `grep -n '"regular"\|"critical"\|"simple"\|!= "regular"' lib/test-resolve-seat.sh test/runner-integration.sh`
- Expected: no matches (exit 1) — confirms no old tier literal survives (preset-name literals like `anthropic-less`, if any, are untouched and don't match this pattern)
- Run: `bash lib/test-resolve-seat.sh && bash test/runner-integration.sh`
- Expected: both exit 0 (pre-existing `anthropic-less` preset-reference breakage, if it exists independent of tier vocabulary, is out of scope for this task — if this acceptance check fails for that unrelated reason, note it in the commit message and let Task 6/the final ship gate surface it, do not silently fix or silently ignore it)

- [ ] Rename all tier literals in both files
- [ ] Run first acceptance check → no matches
- [ ] Run second acceptance check → both suites pass (or note pre-existing unrelated failure per above)
- [ ] Commit: `git add lib/test-resolve-seat.sh test/runner-integration.sh && git commit -m "tier taxonomy: rename tier vocabulary in shell test suites"`

## Task 6: Full validation gate — tier rename complete

**Wave:** 3
**Blocks:** Task 7, Task 8 (all of wave 4+)
**Blocked by:** Task 3, Task 5, Task 5b

**Files:** none (verification only)

**Behavior:** This is the wave-1-must-land-before-wave-2 checkpoint from the spec's Non-goals. Confirms the live codex-path tier change is internally consistent before any backlog-feature code (which speaks only `low/medium/high`) is written. Legacy `anthropic-less.json` remains intentionally untouched and is not part of this rename gate.

**Acceptance:**
- Run: `node presets/_validate.mjs --self-test && node presets/_validate.mjs`
- Expected: both exit 0, with the live codex preset shape accepted and no new coverage/fallback errors introduced
- Run: `grep -n '"critical"\|"regular"\|"simple"' presets/codex.json`
- Expected: no matches (exit 1)

- [ ] Run both acceptance checks
- [ ] If either fails, fix the offending file from Tasks 1–5 before proceeding — do not start Wave 4
- [ ] No commit (verification-only task)

## Task 7: Add `rung` field to `resolve-seat.sh`'s resolved binding

**Wave:** 4
**Blocks:** Task 9 (uses `rung` in the detector)
**Blocked by:** Task 6

**Files:**
- Modify: `lib/resolve-seat.sh:244-262` — the resolution ladder's four branches (lines 244, 246, 248, 250) each already correspond to one rung (1, 2, 3, 4). Modify the final `jq` construction at line 262 to also emit `rung`.

**Contract:**
- Track which branch fired (e.g. a shell variable `RUNG` set to `1`/`2`/`3`/`4` at the matching `elif`/first `if`).
- Line 262's output becomes: `echo "$RESOLVED_BINDING" | jq --arg seat "$SEAT" --arg tier "$TIER" --argjson rung "$RUNG" '. + {seat: $seat, tier: $tier, rung: $rung}'`
- `die_json` paths (line 253, and the binding-missing-keys path at 257-259) are unaffected — `rung` only appears on the success (exit 0) path.

**Behavior:** No other resolver output field changes. This is purely additive — nothing currently parsing this binding breaks (existing callers ignore unknown JSON keys).

**Acceptance:**
- Run: `bash lib/resolve-seat.sh --plan <fixture-plan-with-a-flat-seat-task> --runconfig <fixture-runconfig>` (use an existing test fixture for a flat seat, e.g. `fixer` in `presets/codex.json`)
- Expected: stdout JSON includes `"rung":4`
- Run same against a tiered seat/tier combination (e.g. `coder`/`medium` in `presets/codex.json`)
- Expected: stdout JSON includes `"rung":3`

- [ ] Add rung tracking + emit in output
- [ ] Run both acceptance checks → `rung:4` and `rung:3` respectively
- [ ] Commit: `git add lib/resolve-seat.sh && git commit -m "resolve-seat: report which resolution rung fired"`

## Task 8: `lib/backlog.sh` — backlog persistence helper

**Wave:** 4
**Blocks:** Task 10, Task 12, Task 13
**Blocked by:** Task 6

**Files:**
- Create: `lib/backlog.sh` — modeled on `lib/journal.sh`'s locking/validation pattern (`lib/journal.sh:12-93`), independent schema.

**Contract:**
- `bash lib/backlog.sh append <jsonl> <record-json>` — validates the record is valid JSON with `v == "backlog/v1"` and all required fields present: `id, slug, task, wave, reason, requested, resolved, note, priority, order, status, created_ts, requeue` (shape per `docs/specs/2026-07-01-harness-tier-backlog-design.md` Data model, "Backlog record" section — `reason` ∈ `{"auto-downgrade","manual-flag"}`, `note` non-empty when `reason=="manual-flag"`). Acquires an exclusive flock on `<jsonl>.lock` (same pattern as `lib/journal.sh:76-77`) for the whole operation, including the idempotency check below.
  - **Idempotency:** before appending, scan `<jsonl>` for an existing record with the same `id`. If found with `status` ∈ `{"open","requeued"}` → no-op, exit 0, print nothing to stdout. If found with `status` ∈ `{"resolved","dismissed"}` → this is a treated-as-new recurrence; the caller (per spec) is responsible for passing a fresh `id` in this case (the caller re-derives the hash including `created_ts`) — `lib/backlog.sh append` itself does not mutate the incoming `id`, it only enforces the open/requeued no-op rule.
  - On success: append the compact JSON line to `<jsonl>`, exit 0.
  - Also upserts the caller-supplied repo-registry row: `bash lib/backlog.sh append <jsonl> <record-json> --repo-registry <registry-jsonl> --repo-root <repoRoot> --runstate-dir <runstateDir> --project <project>` — unlocked read-modify-write (rewrite the row for `repoRoot` in place if present, else append) per the spec's explicit accepted-race note (Architecture Decisions: "lastSeen cache, lost race is benign").
- `bash lib/backlog.sh set-status <jsonl> <id> <new-status> [--requeue-json <json>]` — validates `<new-status>` ∈ `{"open","requeued","resolved","dismissed"}`; under the same flock, rewrites the single line matching `id` (read all lines, replace the matching one, write to a temp file, atomic `mv` over `<jsonl>`); if `--requeue-json` given, also sets that record's `requeue` field. Exits 3 with a message to stderr if no record with `id` exists.
- Both subcommands print nothing to stdout on success except where noted; non-zero exit + stderr message on any validation failure (mirrors `lib/journal.sh`'s fail-closed style).

**Behavior:** No dependency on `lib/journal.sh` — a separate lock file (`<jsonl>.lock`), separate validation, separate schema. `<jsonl>` here is always `<repoRoot>/runstate/backlog.jsonl` in practice; the repo-registry file is always `~/.harness/backlog-repos.jsonl`.

**Acceptance:**
- Run: `bash lib/backlog.sh append /tmp/t-backlog.jsonl '{"v":"backlog/v1","id":"abc","slug":"s","task":"t1","wave":1,"reason":"manual-flag","requested":{"seat":"coder","tier":"high"},"resolved":{"wrapper":"w","model":"m","tier":"medium","rung":4},"note":"test","priority":null,"order":0,"status":"open","created_ts":"2026-07-01T00:00:00Z","requeue":null}'` then re-run the identical command
- Expected: first run exits 0 and appends one line; second run exits 0 and the file still has exactly one line (idempotent no-op) — verify via `wc -l /tmp/t-backlog.jsonl` → `1`
- Run: `bash lib/backlog.sh set-status /tmp/t-backlog.jsonl abc resolved` then `grep resolved /tmp/t-backlog.jsonl`
- Expected: the single line now shows `"status":"resolved"`

- [ ] Write `lib/backlog.sh` (append, set-status, validation, locking)
- [ ] Run acceptance checks → idempotent append (1 line after 2 calls), status rewrite confirmed
- [ ] Commit: `git add lib/backlog.sh && git commit -m "add lib/backlog.sh: backlog/v1 persistence, independent of journal.sh"`

## Task 9: Capture `tierExplicit` at plan-parse time

**Wave:** 5 (sequential sub-step 1 of 3, same file as Tasks 10–11)
**Blocks:** Task 10
**Blocked by:** Task 6

**Files:**
- Modify: `src/runner.js:512-524` (`loadPlan`) — the `.map((task) => ({...}))` that currently sets `tier: task.tier || "regular"` (post-Task-5, `"medium"`).

**Contract:**
- Add `tierExplicit: task.tier != null` to the mapped task object, computed **before** the `tier: task.tier || "medium"` default is applied (both read the same raw `task.tier`, order in the object literal doesn't matter since both read the pre-existing `task.tier`, not each other).

**Behavior:** Every task object flowing through the rest of `runner.js` now carries `tierExplicit: true` when the plan JSONL's task record had a non-null `tier` field, `false` when it was omitted.

**Acceptance:**
- Run: `node -e "const {loadPlan}=require('./src/runner.js'); /* or invoke via existing test harness if loadPlan isn't exported — use the project's existing plan-fixture test pattern */"` — concretely: extend/add a fixture plan JSONL with one task carrying `"tier":"high"` and one task omitting `tier` entirely; assert the first loads with `tierExplicit:true` and the second with `tierExplicit:false`.
- Expected: both assertions pass

- [ ] Add `tierExplicit` capture
- [ ] Write/extend fixture test per acceptance
- [ ] Run acceptance check → both pass
- [ ] Commit: `git add src/runner.js && git commit -m "runner: capture tierExplicit before tier defaulting"`

## Task 10: Auto-downgrade detector

**Wave:** 5 (sequential sub-step 2 of 3)
**Blocks:** Task 11
**Blocked by:** Task 7, Task 8, Task 9

**Files:**
- Modify: `src/runner.js:182-231` (around the existing `dispatchWithFallback` calls in the implement/review flow) — add a post-resolution check.

**Contract:**
- New function `async function recordAutoDowngradeIfNeeded(context, task, binding)`: if `task.tierExplicit === true` and `binding.rung === 4`, build a `backlog/v1` record per the Data model in `docs/specs/2026-07-01-harness-tier-backlog-design.md` (`reason:"auto-downgrade"`, `requested: {seat: task.seat, tier: task.tier}`, `resolved: {wrapper: binding.wrapper, model: binding.model, tier: binding.tier, rung: binding.rung}`, `priority: null`, `order: 0`, `status: "open"`, `created_ts: new Date().toISOString()`, `requeue: null`, deterministic `id = sha1(slug, task.id, requested.seat, requested.tier, resolved.wrapper, resolved.model)`), then invoke `lib/backlog.sh append` (via the same `execFileAsync`/`runCli`-style helper `runner.js` already uses for shelling out to `lib/*.sh`, e.g. the pattern at `src/runner.js:376-389`) against `<repoRoot>/runstate/backlog.jsonl` with the repo-registry flags pointed at `~/.harness/backlog-repos.jsonl`.
- Call `recordAutoDowngradeIfNeeded` immediately after each successful `binding` resolution in the implement/review flow (`src/runner.js:188-189` and `:202`), non-blocking: wrap in try/catch, log an `error`-type event on the run's event stream on failure (per spec's Error handling — a missed backlog entry must never fail the task), never throw.

**Behavior:** No call when `tierExplicit` is false (tier was defaulted, not authored) or `rung` is 1/2/3 (override or successful tiered resolution — not a downgrade).

**Acceptance:**
- Fixture: a preset with a flat-only role, a task with `tierExplicit:true, tier:"high"` dispatched against it → assert exactly one `backlog/v1` line appended to that fixture repo's `runstate/backlog.jsonl` with `reason:"auto-downgrade"` and `resolved.rung:4`.
- Fixture: same task shape but `tierExplicit:false` → assert zero lines appended.
- Run: the project's existing test runner against these two fixtures (follow the pattern of existing `runner.js` tests in the repo's test directory — same command project already uses for runner tests)
- Expected: both fixture assertions pass

- [ ] Implement `recordAutoDowngradeIfNeeded` + wire into dispatch flow
- [ ] Write fixture tests per acceptance
- [ ] Run acceptance check → both pass
- [ ] Commit: `git add src/runner.js && git commit -m "runner: auto-detect silent tier downgrades into backlog"`

## Task 11: `runSingleTaskWithOverrides` wrapper

**Wave:** 5 (sequential sub-step 3 of 3)
**Blocks:** Task 13
**Blocked by:** Task 9

**Files:**
- Modify: `src/runner.js` — add new exported function near `resolveSyntheticSeat` (`src/runner.js:403-`).

**Contract:**
- `async function runSingleTaskWithOverrides(context, task, overrides)` where `overrides: {seat: string, tier: string}` — constructs a `runconfig.overrides["<seat>.<tier>"]` entry (same shape resolve-seat.sh already validates, `lib/resolve-seat.sh:104-207`), calls `resolveSyntheticSeat(context, task, overrides.seat, overrides.tier)` (existing function, unchanged), then dispatches exactly as `resolveTaskSeat`'s callers already do (reuse `dispatchWithFallback`). Returns whatever `dispatchWithFallback` returns (existing return shape — no new type).
- Export it alongside the existing `module.exports` entries (`src/runner.js:760`, next to `dispatchWithFallback`).

**Behavior:** This is the sole entry point the gateway's requeue route (Task 13) is allowed to call — the gateway must never construct `runconfig.overrides` or call `resolveSyntheticSeat` directly.

**Acceptance:**
- Run: a fixture test calling `runSingleTaskWithOverrides` with a known task + `{seat:"coder", tier:"high"}` against a preset where that tier exists → assert the binding it dispatches with has `tier:"high"`.
- Expected: assertion passes

- [ ] Implement + export `runSingleTaskWithOverrides`
- [ ] Write fixture test per acceptance
- [ ] Run acceptance check → passes
- [ ] Commit: `git add src/runner.js && git commit -m "runner: add runSingleTaskWithOverrides for backlog requeue"`

## Task 12: Gateway route — manual flag

**Wave:** 6
**Blocks:** Task 15
**Blocked by:** Task 8

**Files:**
- Create: `web/src/pages/api/gateway/runs/[runId]/tasks/[taskId]/backlog.js` — follow the existing route-file convention (see `web/src/pages/api/gateway/runs/[runId]/control/[verb].js` for the pattern: resolve `runId` → `repoRoot`/`runstateDir` via the same lookup every other per-run route uses, parse the Astro route params, call a shared helper from `_gateway.js`).

**Contract:**
- `POST /api/gateway/runs/:runId/tasks/:taskId/backlog` — body `{note: string}`. 400 if `note` is empty/missing. Resolves `runId` → `repoRoot`/`slug` (existing lookup), reads that task's actual journal binding record (existing journal-read helper) to fill `requested`/`resolved`, then shells out to `lib/backlog.sh append` (Task 8) with `reason:"manual-flag"`.
- Response: `200 {backlogId}` on success; `404` if `runId` or `taskId` unknown (same pattern as the existing steer route's 404/409 handling in the web-ui spec).

**Acceptance:**
- Run: `curl -X POST http://127.0.0.1:<port>/api/gateway/runs/<fixture-run>/tasks/<fixture-task>/backlog -d '{"note":"flag me"}'`
- Expected: `200` with a `backlogId` in the response body; a corresponding line appears in that repo's `runstate/backlog.jsonl`

- [ ] Implement route
- [ ] Run acceptance check → 200 + line appended
- [ ] Commit: `git add web/src/pages/api/gateway/runs/\[runId\]/tasks/\[taskId\]/backlog.js && git commit -m "gateway: manual backlog-flag route"`

## Task 13: Gateway route — requeue

**Wave:** 6
**Blocks:** Task 15
**Blocked by:** Task 11

**Files:**
- Create: `web/src/pages/api/gateway/backlog/[backlogId]/requeue.js`

**Contract:**
- `POST /api/gateway/backlog/:backlogId/requeue` — body `{tier: string}`. Resolves the backlog record (scans known repos via the repo registry, `~/.harness/backlog-repos.jsonl`, then that repo's `runstate/backlog.jsonl` for `id == backlogId`) → `repoRoot`/`slug`/`task`. Calls `runSingleTaskWithOverrides` (Task 11) with `{seat: record.requested.seat, tier}`. On dispatch, calls `lib/backlog.sh set-status <jsonl> <backlogId> requeued --requeue-json '{"runId":...,"task":...,"ts":...}'`.
- If `resolve-seat.sh` BLOCKs for the requested `{seat, tier}` (unknown seat/tier — should be rare post-Task-2's coverage lint, but the preset could still lack the seat entirely) → propagate as `422` with the resolver's error detail, same pattern as the existing steer route's 409 handling.
- Response: `200 {runId, task}` on success.

**Acceptance:**
- Run: `curl -X POST http://127.0.0.1:<port>/api/gateway/backlog/<fixture-backlog-id>/requeue -d '{"tier":"high"}'`
- Expected: `200` with `{runId, task}`; the backlog record's `status` is now `"requeued"` in `runstate/backlog.jsonl`

- [ ] Implement route
- [ ] Run acceptance check → 200 + status rewritten
- [ ] Commit: `git add web/src/pages/api/gateway/backlog/\[backlogId\]/requeue.js && git commit -m "gateway: backlog requeue route"`

## Task 14: Gateway route — list backlog (scope tabs)

**Wave:** 7
**Blocks:** Task 15
**Blocked by:** Task 12, Task 13

**Files:**
- Create: `web/src/pages/api/gateway/backlog.js`

**Contract:**
- `GET /api/gateway/backlog?scope=plan|project|global&runId=<runId>` — `scope=plan`: read `runId`'s repo's `runstate/backlog.jsonl`, filter to that `runId`'s `slug`. `scope=project`: same file, all slugs (no filter). `scope=global`: read `~/.harness/backlog-repos.jsonl`, reap rows whose `runstateDir` no longer exists (write the reaped file back, same reap-on-read pattern the run registry spec already establishes), then read + concatenate every remaining repo's `runstate/backlog.jsonl`.
- Response: `200 [backlog/v1 records...]` (array, as stored — no transformation).
- 400 if `scope` is not one of the three values.

**Acceptance:**
- Run: `curl "http://127.0.0.1:<port>/api/gateway/backlog?scope=plan&runId=<fixture-run>"`
- Expected: `200` with a JSON array containing the fixture record(s) created by Tasks 12–13's acceptance runs, scoped correctly (plan-scope excludes other slugs' records; project-scope includes them; global-scope includes records from a second fixture repo)

- [ ] Implement route
- [ ] Run acceptance check → correct scoping for all three tabs
- [ ] Commit: `git add web/src/pages/api/gateway/backlog.js && git commit -m "gateway: list backlog by scope"`

## Task 15: Backlog web UI page

**Wave:** 8
**Blocks:** —
**Blocked by:** Task 14

**Files:**
- Create: `web/src/pages/backlog.astro` — page shell, follows the existing page pattern (see any existing `web/src/pages/*.astro` for the convention: minimal shell that mounts a React island).
- Create: `web/src/components/Backlog.tsx` — React island.

**Contract:**
- `Backlog.tsx` renders three scope tabs (Plan/Project/Global) that call `GET /api/gateway/backlog?scope=...` (Task 14). List grouped by `priority` (`null` last), sorted by `order` within a group, each row shows `task`/`slug`/`requested.tier` vs `resolved.tier`/a reason badge (`auto-downgrade` vs `manual-flag`).
- Two row actions: **set priority** — free-text input, `PATCH`-equivalent call (reuse the requeue route's sibling pattern: add a minimal `POST /api/gateway/backlog/:backlogId/priority {priority, order}` route in this same task, since the spec calls for it and no earlier task created it) that calls `lib/backlog.sh set-status`-adjacent field write (extend `lib/backlog.sh` with a `set-field <jsonl> <id> <field> <value>` subcommand if `set-status` doesn't already generalize — keep the same locked-rewrite-by-id mechanism as `set-status`). **Re-run at tier** — dropdown of the tiers the active preset defines for that role (`low`/`medium`/`high`, all three always present post-Task-2's coverage lint), calls Task 13's requeue route; disabled when the record's `resolved.tier` already equals every available tier (i.e. no alternate tier exists to try — should not occur post-rename, guard anyway).

**Behavior:** Matches the existing Web UI spec's SSE-free, plain-fetch-on-tab-change pattern (this page has no live-run aspect, unlike the Job/Agent pages — simple `GET`-on-mount + refetch-on-action is sufficient, no SSE needed here).

**Acceptance:**
- Manual: start the dev server (`bash bin/ensure-web.sh` or project's existing dev-server entry point), navigate to `/backlog`, confirm all three scope tabs load without error against the fixture data from Tasks 12–14, set a priority on one row and confirm it persists on refetch, click re-run on one row and confirm the requeue route fires (network tab shows `200`).

- [ ] Implement `backlog.astro` + `Backlog.tsx`
- [ ] Add the small `priority`-set route/`lib/backlog.sh set-field` subcommand this task needs
- [ ] Manual browser verification per acceptance
- [ ] Commit: `git add web/src/pages/backlog.astro web/src/components/Backlog.tsx lib/backlog.sh web/src/pages/api/gateway/backlog/\[backlogId\]/priority.js && git commit -m "web: add Backlog page (scope tabs, priority, re-run)"`

---

## Self-Review

**Spec coverage:** Purpose (1) detection/surfacing → Tasks 7, 10, 15. (2) durable triageable backlog, auto+manual, scoped plan/project/global → Tasks 8, 10, 12, 14, 15. (3) manual re-run against explicit override → Tasks 11, 13, 15. Ground-truth prerequisites (tier rename, rung-3.5-replaced-by-lint) → Tasks 1–6. All Data model fields appear in Task 8/10's contracts. Non-goals (auto-close, cross-repo self-heal, heuristic priority) are correctly absent from every task above — none accidentally implement them.

**Vagueness/body-bloat scan:** No task says "handle edge cases" without naming them (each names its specific guard conditions). No full function bodies pasted — every task pins signature + behavior bullets + one executable acceptance check. Task 15 is the largest but still contract-level (interaction bullets, not JSX).

**Contract/seam consistency:** `runSingleTaskWithOverrides(context, task, overrides)` (Task 11) is the exact name Task 13 calls. `lib/backlog.sh append`/`set-status` (Task 8) are the exact subcommands Tasks 10, 12, 13 invoke. `tierExplicit` (Task 9) is the exact field Task 10 reads. `rung` (Task 7) is the exact field Task 10 reads. `~/.harness/backlog-repos.jsonl` (Task 8) is the exact path Task 14 reads.

**Wave plan check:** Every task has Wave/Blocks/Blocked-by. No same-wave file overlap except the flagged Wave 5 sequential sub-chain (documented above as an explicit exception, not silently unsafe). Task 6 has no forward dep on same-wave tasks. `meta.scheduler: dag-parallel` justified by waves 1, 2, 4, 6 each holding ≥2 disjoint tasks.
