# Harness Observability 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:** Make a running v2 harness explain itself live — which task failed, its failure class, the real cause, what the agent did, and the log tail — in the terminal and in `/plans`, never buffered to the end and never lost to a crash.

**Architecture:** Producer-side only. The `2026-07-22-overdeck-observability-b3` consumer half is already on `origin/main` and is written against a dead v1 producer; this plan makes the live v2 engine emit what those consumers already read. The one structural change is collapsing the engine's two emit paths (`report()` journals+prints, `journal()` only journals) into a single `emit()` with a display policy — filter for display, never for recording. Everything else is closing stubs, adding fields, and two routing hops.

**Tech Stack:** Node (ESM) `modules/harness/v2`, NDJSON journal + JSON-Schema validator, bash `lib/gates.sh`, Bun/Hono collector, Astro API routes, React/TSX panels.

**Source spec:** `docs/specs/2026-08-07-harness-observability-design.md` — canonical. Every task below points into it; do not re-derive its decisions.

**Do NOT rebuild (already landed, producer-blind):** `collector/src/adapters/harness.ts`, `collector/src/actions.ts`, `apps/web/src/lib/collector-client.ts`, `apps/web/src/lib/harness-types.ts`, `packages/deck-ui/src/useSseStream.ts`, `AgentStatusBar.tsx`, `AgentFeed.tsx`, `DistanceToDone.tsx`, `RunCommandBar.tsx`, `apps/web/src/lib/panel-data.ts`.

**Never touch:** `modules/harness/src/**` (v1, untracked, undeployed).

**Engine test invocation:** any acceptance command that shells out to `lib/gates.sh` or `runplan` MUST export `HARNESS_ENGINE_DEV=1` **scoped to that subprocess** (`HARNESS_ENGINE_DEV=1 node ...`), never as ambient env. `modules/harness/run-tests.sh` unsets it deliberately.

**Not a task:** the engine bundle bump (`bin/harness-release.sh`). Landing is not deploying; the bump is post-run main-thread work.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1a, Task 3 | T1a: `v2/display-policy.js` `test/display-policy.sh` · T3: `collector/src/server.ts` `collector/src/adapters/harness.ts` `apps/web/src/pages/api/collector` | ✅ no overlap |
| 2 | Task 1b | `v2/run.js` | single task |
| 3 | Task 1c | `v2/bin/runplan.js` | single task |
| 4 | Task 2, Task 9 | T2: `v2/run.js` `v2/dispatch.js` `spec/journal-events.schema.json` · T9: `v2/bin/runplan.js` | ✅ no overlap |
| 5 | Task 4 | `v2/log-phases.js`, `v2/run.js`, `v2/control-api.js` | single task (`run.js` overlaps T2) |
| 6 | Task 5 | `v2/quality.js`, `v2/dispatch.js`, `spec/journal-events.schema.json` | single task |
| 7 | Task 6 | `v2/control-api.js` | single task (all five projections share one file) |
| 8 | Task 7, Task 8 | T7: `v2/quality.js` `lib/gates.sh` `v2/run.js` `v2/dispatch.js` `spec/journal-events.schema.json` · T8: `v2/control-api.js` `v2/supervision.js` | ✅ no overlap |
| 9 | Task 10, Task 11 | T10: `apps/web/src/components/plans/*` `packages/deck-ui/src/SegmentDetailDrawer.tsx` · T11: `modules/harness/test/observability-canary.sh` | ✅ no overlap |

Task 1 is split into 1a/1b/1c — one file each — because a single dispatch creating a
module, rewriting every emit site in `run.js`, and restructuring `runplan.js`'s progress
path is four contracts in one diff, and a strict gate red on a diff that size names
nothing. Waves 2/3 are forced serial: 1b consumes 1a, 1c consumes 1b. Wave 4/5 split is
forced: Tasks 2 and 4 both edit `v2/run.js`. Wave 7 is one task because 2.1–2.5 all
project out of `v2/control-api.js`.

---

## File Structure

| File | Responsibility |
|---|---|
| `modules/harness/v2/display-policy.js` (new) | kind → `DisplayDecision \| null`; the ONLY place that decides what prints |
| `modules/harness/v2/log-phases.js` (new) | the child-log phase set; single source for the writer and the route regex |
| `modules/harness/v2/run.js` | the single `emit()` seam; per-task orchestration |
| `modules/harness/v2/bin/runplan.js` | CLI: consumes `displayPolicy`, terminal rendering, run settlement |
| `modules/harness/v2/dispatch.js` | attempt lifecycle events (`attempt.*`), heartbeat, usage |
| `modules/harness/v2/quality.js` | gate/review/fix ladder; gate evidence and decision records |
| `modules/harness/v2/control-api.js` | journal → HTTP projections (events, timeline, decisions, attempts, capabilities) |
| `modules/harness/spec/journal-events.schema.json` | fail-closed kind + payload declaration |

---

## Task 1a: The display policy module

**Wave:** 1
**Blocks:** Task 1b, Task 11
**Blocked by:** —

**Files:**
- Create: `modules/harness/v2/display-policy.js` — kind→display decision table + exhaustiveness export
- Test: `modules/harness/test/display-policy.sh`

Pure addition. Do NOT touch `run.js` or `runplan.js` in this task — Task 1b and Task 1c own them.

**Contract (pin EXACTLY):**
```text
displayPolicy(event): DisplayDecision | null
DisplayDecision = { level: 'info'|'warn'|'error', text: string, detail?: string[] }
declaredKinds(): string[]               // read from spec/journal-events.schema.json
formatFailure(event): string[]          // the three verbatim lines below
heartbeatEdge(state, event): { print: boolean, state: object }   // pure, no timers
```
Failure lines, verbatim (three lines, exactly as `runplan.js`'s `formatFailure` renders them today):
```text
task <id> blocked: <failureClass> (<cause>)
  log: <logPath>
  | <logTail>
```

**Behavior:**
- The table is exhaustive over every kind declared in `spec/journal-events.schema.json`. An undeclared/unmapped kind falls back to one `info` line `<kind> <taskId?>` — a backstop for a schema/table race, never silence.
- These kinds resolve to `error`/`warn` decisions that MUST print: `quarantine` (with `reason`), `verify.failed` (with tail), `retry.attempt`, `budget.exhausted`, `rate-limit.parked`, `gate.failed`, `fix.rung`, and `task-end` whose outcome is not success.
- `attempt.heartbeat` is edge-triggered, never per tick. `heartbeatEdge` is a pure reducer over caller-held state: it returns `print: true` once on the first heartbeat whose `lastActivity` age crosses the stall threshold, and once on the first heartbeat after activity resumes. No repeats, no cooldown timer, no clock reads of its own — the event carries the timestamps.
- This module decides display only. It never records, never writes, never imports `journal.js`.

**Acceptance (one executable check):**
- Run: `HARNESS_ENGINE_DEV=1 bash modules/harness/test/display-policy.sh`
- Expected: PASS — every kind in `spec/journal-events.schema.json` resolves to a decision or the documented default; a synthetic `quarantine` event yields the three-line failure block; ten consecutive `attempt.heartbeat` events past the threshold yield exactly one `print: true`.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/display-policy.js modules/harness/test/display-policy.sh && git commit -m "add the harness display policy table"`

---

## Task 1b: One emitter in run.js

**Wave:** 2
**Blocks:** Task 1c, Task 2, Task 4, Task 11
**Blocked by:** Task 1a

**Files:**
- Modify: `modules/harness/v2/run.js:71-74` — collapse `report()` / `journal()` into one `emit()`; fix `:654` identity spread; `:669` quarantine path
- Test: `modules/harness/test/emit-single-path.sh`

**Contract (pin EXACTLY):**
```text
emit(event): void    // records to the journal AND offers the event to the progress sink — the ONLY path
```

**Behavior:**
- No call site in `run.js` may reach the journal without also passing the progress sink. `journal()` as a separate exported path is deleted, not deprecated.
- Recording is never filtered — the journal keeps every kind at full fidelity. The display decision (Task 1a) is applied by the consumer, never by `emit`.
- `run.js:654` currently wraps as `journal({ ...event, task, ...identity })`; identity lands last and overwrites `quarantine`'s `phase:'quality'`. Identity MUST spread first so caller fields win.
- The quarantine path at `:669` emits its `failureClass`, `cause`, `logPath` and `logTail` on the event itself, so a consumer can render the failure block without reaching back into run state.

**Acceptance (one executable check):**
- Run: `HARNESS_ENGINE_DEV=1 bash modules/harness/test/emit-single-path.sh`
- Expected: PASS — grep of `run.js` finds zero journal writes outside `emit`; a fixture run's progress sink receives one offer per journaled record; a synthetic quarantine event carries `failureClass`, `cause`, `logPath`, `logTail`, and keeps `phase:'quality'`.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/run.js modules/harness/test/emit-single-path.sh && git commit -m "collapse the harness emit paths into one"`

---

## Task 1c: The CLI prints failures at block time

**Wave:** 3
**Blocks:** Task 9, Task 11
**Blocked by:** Task 1b

**Files:**
- Modify: `modules/harness/v2/bin/runplan.js:167-208` — `reportProgress` consumes `displayPolicy`; `formatFailure` (`:184-189`) fires at block time, not only at end of run
- Test: `modules/harness/test/runplan-live-failure.sh`

**Contract:** `reportProgress` is the sole consumer of `displayPolicy` from Task 1a. It holds the heartbeat-edge state and passes it through `heartbeatEdge`. `formatFailure` moves into `display-policy.js` (Task 1a owns the strings); `runplan.js` calls it.

**Behavior:**
- Every event the engine offers is routed through `displayPolicy`; a non-null decision prints immediately, at the moment the event arrives — never buffered to run end.
- The three-line failure block prints at block time for every quarantined task, including one whose block carries no log path (print the two lines that exist; never suppress the whole block for a missing field).
- The end-of-run failure summary stays, as a recap of lines already printed.

**Acceptance (one executable check):**
- Run: `HARNESS_ENGINE_DEV=1 bash modules/harness/test/runplan-live-failure.sh`
- Expected: PASS — a fixture feeding a quarantine event mid-stream produces the failure block on stdout before the stream ends; ten consecutive heartbeats past the threshold produce exactly one line; every non-null decision prints exactly once.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/bin/runplan.js modules/harness/test/runplan-live-failure.sh && git commit -m "print harness failure blocks at block time"`

---

## Task 2: taskId / attemptId correlation on every task event

**Wave:** 4
**Blocks:** Task 4, Task 5
**Blocked by:** Task 1b

**Files:**
- Modify: `modules/harness/v2/run.js` — emit sites for `verify.passed|failed|retry`, `reply.unmarked`, `budget.exhausted`
- Modify: `modules/harness/v2/dispatch.js` — every `attempt.*` emit site
- Modify: `modules/harness/spec/journal-events.schema.json` — declare `taskId` / `attemptId` on those kinds
- Test: `modules/harness/test/event-correlation.sh`

**Contract:**
- Every event belonging to a task carries `taskId` at the emit site. `task` is retained as a **deprecated alias** for legacy journals; consumers read `taskId ?? task`.
- Every `attempt.*` event carries both `taskId` and `attemptId`.
- Schema entries ship in the same commit as the emits (`v2/journal.js:289` throws `undeclared journal event kind` — fail-closed, never relax it).

**Behavior:**
- `control-api.js:88` (`replay`'s `event.taskId &&` gate) and `eventsOf` (`:474`) currently drop these events, leaving per-task views empty even after Task 1 streams them. After this task they pass the gate unchanged — do not edit `control-api.js` here.
- A journal written before this change still parses: absent `taskId` falls back to `task`; absent both → the event is run-scoped, not task-scoped.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/event-correlation.sh`
- Expected: PASS — a fixture run's journal has `taskId` on every `verify.*`, `reply.unmarked`, `budget.exhausted`, and `attempt.*` record; `attempt.*` additionally has `attemptId`; a legacy fixture carrying only `task` still projects.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/run.js modules/harness/v2/dispatch.js modules/harness/spec/journal-events.schema.json modules/harness/test/event-correlation.sh && git commit -m "stamp taskId and attemptId on every task-scoped journal event"`

---

## Task 3: Attempt routes reachable from the browser

**Wave:** 1
**Blocks:** Task 10
**Blocked by:** —

**Files:**
- Modify: `collector/src/server.ts` — three read-only routes proxying the harness control-api
- Modify: `collector/src/adapters/harness.ts` — APPEND the three upstream calls the routes need. This is the one carve-out from the do-not-rebuild list: the adapter is where harness calls are declared, so a new route needs a new call here. Append only — never re-author an existing function.
- Modify: `apps/web/src/pages/api/collector/[...path].ts:15-22` — allowlist regexes (`:125` default-denies)
- Test: `collector/test/attempts-routes.test.ts` (new) — collector-side route behavior
- Test: `apps/web/src/lib/collector-proxy.test.ts` — extend with the Astro allowlist cases; this is the repo's existing home for proxy allowlist tests, do not create a parallel one

**Contract (routes already exist upstream at `control-api.js:751-768` — proxy exactly these three, no generic passthrough):**
```text
GET /runs/:id/attempts
GET /attempts/:id/prompt
GET /attempts/:id/reply
```

**Behavior:**
- Read-only. Mutations stay under the existing `/actions/:verb` surface; do not add a write path here.
- No harness token, URL, or auth header may appear in any browser-visible response body or header.
- Upstream status and body are preserved through the existing `HarnessApiError` handling — do not swallow a 404 into a 200, do not invent an empty-array success for an upstream error.
- The Astro allowlist gains regexes for exactly these three paths; the default-deny at `:125` stays.
- No UI file changes: this mounts `AutopsyStrip`, `BurnPanel`, `AttemptTimeline`, and `AttemptDetailDrawer` as-is, because `PlanRunApp.tsx:630-638` stops short-circuiting once the fetch succeeds.

**Acceptance (one executable check):**
- Run: `cd collector && bun test test/attempts-routes.test.ts && cd .. && pnpm --filter web test -- collector-proxy`
- Expected: PASS — each of the three paths returns the stubbed upstream body and status; an upstream 404 surfaces as 404; a path outside the three is denied by the Astro allowlist; no response contains the harness token.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add collector/src/server.ts collector/src/adapters/harness.ts collector/test/attempts-routes.test.ts apps/web/src/lib/collector-proxy.test.ts "apps/web/src/pages/api/collector/[...path].ts" && git commit -m "route harness attempt endpoints through the collector"`

---

## Task 4: Agent transcripts are reachable

**Wave:** 5
**Blocks:** Task 11
**Blocked by:** Task 2

**Files:**
- Create: `modules/harness/v2/log-phases.js` — the child-log phase set, single source of truth
- Modify: `modules/harness/v2/run.js:1074-1076` — `childLogPath` consumes the constant
- Modify: `modules/harness/v2/control-api.js:554-569` — `taskLogPath` regex derived from the constant
- Test: `modules/harness/test/transcript-routes.sh`

**Contract:**
```text
export const LOG_PHASES = ['coder','fallback','gate','verify','verify-fixer','verify-retry','review','fixer','dependency-repair','stronger-fixer']
```
Routes serving any declared phase: `/tasks/:id/transcript`, `/tasks/:id/activity`, `/runs/:id/tasks/:task/stream`. Query `?phase=<name>` selects; default = the most recent log for that task.

**Behavior:**
- `taskLogPath` today matches only `/-(dispatch|gate)-(\d+)\.log$/` while `childLogPath` never writes phase `dispatch` — so the coder's own transcript is served by nothing. The regex is **derived** from `LOG_PHASES`, never re-typed; adding a phase must require editing one file.
- The constant stays inside `modules/harness/v2/**`. Nothing outside the engine bundle may import it — a collector-side consumer that needs the phase list receives it over the wire in the capabilities payload.
- Unknown `?phase=` value → 404 with the declared phase list in the body, never a silent fallback to another phase.
- No log path may escape the run's log directory (reject `..` and absolute paths in `:task` / `?phase=`).

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/transcript-routes.sh`
- Expected: PASS — for a fixture run with a `coder` log on disk, `/tasks/:id/transcript` returns it; `?phase=review` selects the review log; `?phase=bogus` returns 404 listing `LOG_PHASES`; a traversal attempt is rejected.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/log-phases.js modules/harness/v2/run.js modules/harness/v2/control-api.js modules/harness/test/transcript-routes.sh && git commit -m "serve every child-log phase from one derived phase set"`

---

## Task 5: Decision records + per-attempt duration at the emit site

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

**Files:**
- Modify: `modules/harness/v2/quality.js` — every escalation rung emits a decision record
- Modify: `modules/harness/v2/dispatch.js` — attempt-terminal events carry `durationMs`
- Modify: `modules/harness/spec/journal-events.schema.json` — declare the new kind and field
- Test: `modules/harness/test/decision-records.sh`

**Contract:**
```text
kind: 'decision'
payload: {
  id, taskId, rung,            // rung: 'dependency-repair'|'fixer'|'stronger-fixer'|'resolver'
  seat: { wrapper, model, account },
  differsFrom: { wrapper, model, account } | null,   // the seat that failed
  why: string,                 // why this binding differs from the failed seat
  inputs: { failureClass, outputTail, diffStat },
  verdict: string,
  action: string
}
```
`durationMs`: integer ms on every attempt-terminal event, computed by the engine from the attempt's start `ts` and its terminal `ts`.

**Behavior:**
- Standing user requirement, verbatim: *"needs full observability about what the resolver decided and why"*. A rung that fires and records no decision is a defect, including the rung that decides **not** to escalate.
- `differsFrom` + `why` exist because a resolver bound to the same wrapper *and* model as the failed seat cannot resolve anything — the record must make that visible.
- `outputTail` is verbatim and unparsed (see Task 7 for the shared cap).
- `PlanRunApp.tsx:167-172` already sums `durationMs` and reads 0 today because no event carries it. Do not change that panel here.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/decision-records.sh`
- Expected: PASS — a canary run driven into the fix ladder journals one `decision` per rung with all fields populated and `differsFrom` set when the binding changed; every attempt-terminal event carries a positive integer `durationMs`.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/quality.js modules/harness/v2/dispatch.js modules/harness/spec/journal-events.schema.json modules/harness/test/decision-records.sh && git commit -m "emit a decision record for every escalation rung"`

---

## Task 6: Control-api projections stop lying

**Wave:** 7
**Blocks:** Task 8, Task 10
**Blocked by:** Task 5

**Files:**
- Modify: `modules/harness/v2/control-api.js` — `timelineOf` (`:293-328`), `eventsOf` (`:475`), `CAPABILITIES` (`:20`), decisions route (`:732`), ratelimits route (`:720`)
- Test: `modules/harness/test/control-api-projections.sh`

**Contract — `timelineOf` segment gains:**
```text
agentId: string            // seat binding that ran the segment (wrapper+model+account), stable per attempt
startedAt: number          // ABSOLUTE epoch ms, alongside the existing relative t0
journalSeq: number         // the exact journal record this segment came from
durationMs: number | null  // from the attempt-terminal event (Task 5)
note: string | null        // terminal cause as `<failureClass>: <cause>`; null when the segment succeeded
```
Decisions route returns the shape the 2026-07-18 design pinned: `{ id, category?, needs?, why?, blast_radius?, options, task?, status }`, legacy string `options` preserved.

**Behavior:**
- `t0` stays (layout depends on it); `startedAt` is added, not substituted. `t0` is `starts.get(taskId) - planStart` — a relative offset — and rendering it as an epoch is what produces "1/1/1970". Never patch the UI to hide that.
- `agentId` is what `agentHrefForTask` (`PlanRunApp.tsx:292-304`) needs; without it the live-feed link never renders and a direct URL hits "Known agents: none recorded".
- `eventsOf` stops hardcoding `attemptId: null` and projects the stamped value; absent in a legacy journal → `null`, never inferred.
- `CAPABILITIES` becomes computed per run from what that run's journal actually contains, not a frozen literal. A run predating this work reports honestly.
- Decisions route projects the `decision` kind from Task 5; ratelimits route projects observed `rate-limit.parked` records (which account, parked when, until when, how many times this run). **Never synthesize a quota number the wrapper did not report.**
- Any field the journal does not contain projects as `null` — never a zero, never a placeholder string.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/control-api-projections.sh`
- Expected: PASS — against an enriched fixture journal every segment has `agentId`, an absolute `startedAt` within the run window, `journalSeq`, and `note` on the failed segment; decisions and ratelimits are non-empty; capabilities reflect the fixture. Against a legacy fixture the same routes return `null` fields and `decisions: []` without throwing.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/control-api.js modules/harness/test/control-api-projections.sh && git commit -m "project honest timeline, decision, capability and ratelimit fields"`

---

## Task 7: Gate evidence + verbatim failure tails

**Wave:** 8
**Blocks:** Task 11
**Blocked by:** Task 5

**Files:**
- Modify: `modules/harness/v2/quality.js` — gate0 returns evidence, not a boolean; gate/verify failures carry the tail
- Modify: `modules/harness/lib/gates.sh` — per-check result output at the engine boundary
- Modify: `modules/harness/v2/run.js` — verify-failure emits carry the tail
- Modify: `modules/harness/v2/dispatch.js` — dispatch-failure emits carry the tail
- Modify: `modules/harness/spec/journal-events.schema.json` — declare `checks`, `violations`, `outputTail`
- Test: `modules/harness/test/gate-evidence.sh`

**Contract:**
```text
GateCheck  = { item: string, ok: boolean, note: string }
GateReport = { checks: GateCheck[], violations: string[], passed: boolean }
TAIL_CHARS = 4000     // tail-biased truncation; the LAST 4000 chars, never the first
```
`gate_pass` / `gate_fail` events carry `attempt`, `checks`, `violations`. Gate, verify, and dispatch failure events carry `outputTail`.

**Behavior:**
- **A green gate must say what it verified.** A gate that returns `passed: true` with an empty `checks` array is a failure of this task, not a pass.
- The tail is verbatim and unparsed — never regex-summarized before display. Summarizing is exactly what drops the one line that explains the failure.
- Truncation keeps the tail (the end of the output) and marks that it truncated; it never keeps the head.
- `lib/gates.sh` is invoked with `HARNESS_ENGINE_DEV=1` scoped to the subprocess in tests.
- gate0 stays fail-closed on a repo with no check commands (`no check commands found FAILCLASS=infra`) — that path now also emits its `checks` list explaining what it looked for.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/gate-evidence.sh`
- Expected: PASS — a passing fixture emits `gate_pass` with a non-empty `checks` array; a failing fixture emits `gate_fail` with the offending check `ok:false`, a populated `violations`, and an `outputTail` whose last line matches the real command output; a >4000-char output truncates to its last 4000 chars.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/quality.js modules/harness/lib/gates.sh modules/harness/v2/run.js modules/harness/v2/dispatch.js modules/harness/spec/journal-events.schema.json modules/harness/test/gate-evidence.sh && git commit -m "return gate evidence and verbatim failure tails"`

---

## Task 8: Process table + usage projections; delete the dead supervision projections

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

**Files:**
- Modify: `modules/harness/v2/control-api.js` — process-table and usage projections + their routes
- Modify: `modules/harness/v2/supervision.js:150,171` — delete `projectHeartbeat` and `projectRestartHistory`
- Test: `modules/harness/test/process-usage-projections.sh`

**Contract:**
```text
ProcessRecord  = { runId, taskId, attemptId, kind, pid, command, startedAt, endedAt: number|null, lastActivity: number, stalled: boolean }
UsageBreakdown = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, inputCost, outputCost, totalCost }  // mergeable: per-attempt → per-task → per-run
```

**Behavior:**
- Source data already exists and is journal-only today: `attempt.heartbeat` (`dispatch.js:12`, carries `pid elapsedSecs timeoutSecs eventCount lastActivity`) and `attempt.usage` (`dispatch.js:192`). This task projects them; it does not add emits.
- A hung agent emits nothing, which is exactly when its pid matters. Silence past the stall threshold projects `stalled: true` with `lastActivity` — it MUST NOT project as "running".
- `projectHeartbeat` and `projectRestartHistory` read four kinds (`supervision.heartbeat`, `supervision.crashed`, `supervision.restart-scheduled`, `supervision.restart-refused`) that nothing emits and the schema does not declare. The process table is fed by `attempt.heartbeat` instead, so both projections are **deleted** along with any now-orphaned helpers they alone used. Do not resurrect the supervision kinds.
- Usage totals merge upward; a run whose journal has no `attempt.usage` projects `null`, never zeros.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/process-usage-projections.sh`
- Expected: PASS — a fixture with heartbeats projects one `ProcessRecord` per attempt with the real pid; one whose last heartbeat is older than the threshold projects `stalled: true`; usage totals equal the sum of the fixture's `attempt.usage` records; a fixture with no usage records projects `null`; `grep -c projectHeartbeat modules/harness/v2/supervision.js` returns 0.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/control-api.js modules/harness/v2/supervision.js modules/harness/test/process-usage-projections.sh && git commit -m "project the process table and usage totals; drop the unfed supervision projections"`

---

## Task 9: One-call run settlement

**Wave:** 4
**Blocks:** Task 11
**Blocked by:** Task 1c

**Files:**
- Modify: `modules/harness/v2/bin/runplan.js` — single terminal settlement
- Test: `modules/harness/test/run-settlement.sh`

**Contract:**
```text
settleRun(journal): { status: 'success'|'failed'|'partial', banner: string, exitCode: number }
```

**Behavior:**
- Status, banner, and process exit code are derived from **one** projection of the journal, in one call, at one place. No second site may compute an exit code.
- **Failure-first defaults:** a phase with no terminal record settles `fail`; a run that dies mid-flight settles `failed`. Success is asserted from evidence in the journal, never assumed from the absence of an error.
- Exit code is non-zero for `failed` and `partial`.
- The per-block failure lines already streamed by Task 1c are not re-derived here; the banner is a recap.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/run-settlement.sh`
- Expected: PASS — an all-green fixture settles `success` / exit 0; a fixture truncated mid-run settles `failed` / non-zero; a fixture with one quarantined task settles `partial` / non-zero and its banner names that task.

- [ ] Write the test covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/v2/bin/runplan.js modules/harness/test/run-settlement.sh && git commit -m "settle run status, banner and exit code in one call"`

---

## Task 10: UI defects — stop rendering fabricated values

**Wave:** 9
**Blocks:** —
**Blocked by:** Task 3, Task 6, Task 8

**Files:**
- Modify: `apps/web/src/components/plans/PlanRunApp.tsx:170` — `fixLoopShare` keys on `llm-fixer`, a kind the engine never emits; key on `fix.rung`
- Modify: `apps/web/src/components/plans/PlanRunApp.tsx:404,438` — `[]` is truthy, so both the decisions block and its own gap notice are skipped; branch on length
- Modify: `apps/web/src/components/plans/PlanRunApp.tsx:586` — "Run control is not supported by this run" is unconditional; make it conditional on the now-computed `capabilities.control`
- Modify: `apps/web/src/components/plans/PlanRunApp.tsx:592-599` — delete the hardcoded A8/A10 coverage-gap rows now that usage and idle are real
- Modify: `apps/web/src/components/plans/AgentApp.tsx:304` — `IdleTimer idle={null}` hardcoded; feed from the process record's `lastActivity`
- Modify: `packages/deck-ui/src/SegmentDetailDrawer.tsx:26` — render the absolute `startedAt`, not the relative `t0`
- Test: `packages/deck-ui/test/SegmentDetailDrawer.test.tsx`, `apps/web` typecheck

**Contract:** consumes only fields pinned by Tasks 6 and 8 — `agentId`, `startedAt`, `journalSeq`, `durationMs`, `note`, `capabilities.control`, `ProcessRecord.lastActivity`. No new primitive; compose existing `@overdeck/deck-ui` exports (`.claude/skills/od-ui-dev/SKILL.md`).

**Behavior:**
- Honest data only: a missing field renders its honest-gap label, never a zero, never a fabricated date, never invented prose.
- A hardcoded gap notice that outlives its gap is a lie — the A8/A10 rows go when their data lands.
- Tokens only, both themes. No new deck-ui primitive without explicit user approval — stop and report instead.

**Acceptance (one executable check):**
- Run: `pnpm --filter @overdeck/deck-ui test && pnpm --filter @overdeck/deck-ui typecheck && pnpm --filter web typecheck`
- Expected: PASS — `SegmentDetailDrawer` renders the fixture's real start date (not 1/1/1970) and renders the honest-gap label when `startedAt` is null; both typechecks clean.

- [ ] Write the tests covering the behavior above
- [ ] Implement to satisfy the contract
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add apps/web/src/components/plans/PlanRunApp.tsx apps/web/src/components/plans/AgentApp.tsx packages/deck-ui/src/SegmentDetailDrawer.tsx packages/deck-ui/test/SegmentDetailDrawer.test.tsx && git commit -m "render real timeline, capability and idle values in the run panels"`

---

## Task 11: The decisive integration test — a failing canary explains itself live

**Wave:** 9
**Blocks:** —
**Blocked by:** Task 1a, Task 1b, Task 1c, Task 4, Task 7, Task 9

**Files:**
- Create: `modules/harness/test/observability-canary.sh` — end-to-end acceptance
- Test: itself

**Contract:** drive a `presets/canary.json` run (free, offline, deterministic, zero network, zero cost) whose task is engineered to quarantine, capturing the engine's stdout **while the process is still running**.

**Behavior:**
- Assert on the **streamed** output, not on the end-of-run summary: the failure class, the cause, and the log tail for the quarantined task MUST appear in stdout before the process exits. Capture with a streaming read; a test that only inspects final output does not test this spec.
- Assert the same task's transcript is retrievable from the control-api while the run is live (Task 4).
- Assert the journal records everything the terminal printed, and more — display filtering must not have removed a record.
- Export `HARNESS_ENGINE_DEV=1` scoped to the subprocess only.
- Never mutate `wrappers/canary-stub.sh`'s existing arms — add an arm if one is needed.
- No fork/OOM/pid-exhaustion/disk-fill/stress behavior of any kind.

**Acceptance (one executable check):**
- Run: `bash modules/harness/test/observability-canary.sh`
- Expected: PASS — stdout captured mid-run contains `task <id> blocked: <failureClass> (<cause>)`, its `log:` line, and a non-empty tail line; the control-api serves that task's coder transcript during the run; the run settles non-zero.

- [ ] Write the test
- [ ] Run it, confirm it fails against the pre-change engine behavior it guards
- [ ] Run the acceptance check; report its real output
- [ ] Commit: `git add modules/harness/test/observability-canary.sh && git commit -m "prove a failing canary explains itself while the run is live"`

---

## Self-Review

**1. Spec coverage.** 1.1→T1 · 1.2→T2 · 1.3→T4 · 1.4→T3 · 2.1→T5 (`durationMs` emit) + T6 (projection) · 2.2→T5 (emit) + T6 (route) · 2.3→T6 · 2.4→T6 · 2.5→T6 · 3.1→T7 · 3.2→T7 · 3.3→T8 · 3.4→T8 · 3.5→T9 · wave 4 UI→T10 · Testing (integration)→T11. Architecture decision "feed or delete the supervision projections" is resolved in T8: **delete**. No gaps.

**2. Vagueness / body-bloat.** No TBD/TODO. No task carries an implementation body; every seam is a signature or a shape. Every acceptance is a single command with a stated expected result. No literal-code task qualified (`impl_LOC ≤ contract_LOC` holds for none of these).

**3. Contract/seam consistency.** `emit`/`displayPolicy`/`DisplayDecision` (T1) referenced by T11. `taskId`/`attemptId` (T2) consumed by T6. `LOG_PHASES` (T4) used by T4 only, plus T11's transcript assertion. `decision` payload + `durationMs` (T5) consumed by T6. `GateCheck`/`GateReport`/`TAIL_CHARS` (T7) referenced by T5's `outputTail`. `ProcessRecord`/`UsageBreakdown` (T8) consumed by T10. `agentId`/`startedAt`/`journalSeq`/`note` (T6) consumed by T10. Every type a task references is pinned in some task's Contract or already exists in the repo.

**4. Wave plan.** Every task carries Wave/Blocks/Blocked-by. Same-wave file sets checked pairwise: W2 (T2 engine vs T3 collector+astro) disjoint · W6 (T7 `quality.js`+`gates.sh`+`run.js`+`dispatch.js`+schema, T8 `control-api.js`+`supervision.js`, T9 `runplan.js`) disjoint · W7 (T10 `apps/web`+`deck-ui`, T11 `modules/harness/test`) disjoint. `run.js` is touched by T1, T2, T4, T7 — all in different waves. `control-api.js` by T4, T6, T8 — all different waves. `quality.js` by T5, T7 — different waves. `dispatch.js` by T2, T5, T7 — different waves. `journal-events.schema.json` by T2, T5, T7 — different waves.

**Decision-enumeration pass.** No task is irreversible, no fork, no external input, no policy or architecture choice left open — the supervision feed-or-delete question is resolved here (delete). No `gated` records authored. Publication mechanism is the project's frozen `merge-to-main` wrapper, resolved by the delivery controller, never surfaced.
