# Harness Gated Build 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:** Build every harness sub-project deliverable that DEPENDS on Plan A (`mega-plan-harness`) having landed on `main` — the control-api (session continuity + supervisor + HTTP/SSE), the adapter catalog + validator cross-check, and the full Web UI.

**Architecture:** Three sequential-then-fan-out tracks behind gate `g1` (Plan A merged to main). (1) Control-api: per-wrapper session-continuity + `--health`/`--list-models` edits to the now-canonical `wrappers/*.sh` (one editor per file) → events normalizer + liveness sidecar + global run-registry pointer → `--supervised` control plane → Node-stdlib HTTP+SSE server. (2) Adapter catalog: `presets/adapters.json` + schema → `presets/_validate.mjs` binding↔catalog cross-check (both Plan A artifacts). (3) Web UI: Astro+React+Tailwind scaffold → gateway endpoints fronting run sockets + `okf` + `harness-adapter` → run-store + SSE hook + DAG island → Overview/Job/Agent/Adapter/OKF pages. The Web UI ALSO consumes `bin/okf` + `bin/harness-adapter` from the `harness-nongated` plan — gated by `g2` (that plan landed).

**Tech Stack:** Node.js stdlib (control-api, zero-dep), POSIX shell (wrappers), JSON-Schema 2020-12 (ajv-free validator), Astro + `@astrojs/react` + `@astrojs/node` standalone + Tailwind + `@xyflow/react` + `zustand` (web), SSE (`text/event-stream`).

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | ca-T1, ca-T2, ca-T3, ca-T4, ca-T5, ca-T9, adpg-T6 | `wrappers/na.sh`, `wrappers/ca.sh`, `wrappers/codex.sh`, `lib/normalize-events`, `lib/liveness`, `src/runner.js`+`src/dynwf-driver.js`, `presets/adapters.json` | ✅ no overlap |
| 2 | ca-T7, adpg-T8 | `spec/WRAPPER-CONTRACT.md`+`wrappers/_contract-probe.sh`, `presets/_validate.mjs` | ✅ no overlap |
| 3 | ca-T10 | `src/supervisor.js` | single task |
| 4 | ca-T11 | `src/control-api.js` | single task |
| 5 | ui-T12 | `web/` scaffold | single task |
| 6 | ui-T13 | `web/src/pages/api/**` | single task |
| 7 | ui-T14, ui-T15, ui-T16 | `web/src/lib/run-store.ts`, `web/src/lib/use-run-stream.ts`, `web/src/islands/Dag.tsx` | ✅ no overlap |
| 8 | ui-T17, ui-T18, ui-T19, ui-T20, ui-T21 | `index.astro`+`Overview.tsx`, `runs/[runId].astro`+`Job.tsx`, `agents/[agentId].astro`+`AgentChat.tsx`, `adapters.astro`+`Adapters.tsx`, `okf.astro`+`OkfKb.tsx` | ✅ no overlap |

**Execution strategy:** `dag-parallel` — waves 1, 2, 7, and 8 each hold ≥2 independent file-disjoint tasks.

> **Gates (pre-flight, resolved once each):**
> - **g1 = "Plan A (`mega-plan-harness`) merged to main"** — gates the wave-1 frontier ONLY (ca-T1/T2/T3/T4/T5/ca-T9, adpg-T6). Every downstream task (incl. adpg-T8, ca-T7/T10/T11) is transitively gated through its `deps` — the engine never reaches wave 2 until wave 1 clears the gate, so only the frontier carries `requires_decision`.
> - **g2 = "plan `harness-nongated` landed (`bin/okf` + `bin/harness-adapter` on main)"** — gates ui-T13 (the gateway shells both binaries). The Web UI adapter/OKF pages (ui-T20/ui-T21) inherit it transitively via ui-T13.
> Cross-plan dependencies on `okf-T6` / `adp-T9` / `adpg-T6` are expressed as gate `g2` + task-description notes, NOT as task→task `deps` edges (those ids live in other graphs).

---

## Control-API Track

GATE g1 = "Plan A merged to main". Wrapper edits target repo-vendored canonical copies `wrappers/*.sh`, NOT global `~/.claude/skills/...`. Source specs: `docs/specs/2026-06-30-harness-control-api-design.md`, `docs/specs/2026-06-30-harness-adapter-config-design.md` (component 4: `--health` + `--list-models`).

### Task ca-T1: na.sh — session continuity + interactive stub + adapter health/list-models
**Wave:** 1
**Blocks:** ca-T7, ca-T10 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Modify `wrappers/na.sh` (claude/north via CCR proxy) — add session continuity, live-claude STUB, two adapter modes. Touch: dispatch flag-assembly (assign `--session-id`, flip output-format), status-line emit, new pre-dispatch mode dispatch. Foreground/timeout/stdin-closed/env-scoping UNCHANGED.
- Test `wrappers/test/na-session.sh`
**Contract:**
- Caller passes `--session-id <uuid>` (harness-generated at dispatch, known BEFORE the turn → kill-then-resume possible).
- Flip `--output-format json` → `--output-format stream-json`.
- Status line on completed coding dispatch: `{"ok":true,"session_id":"<id>",...}` — `session_id` MUST be present (absent ⇒ steer/resume unavailable; fail-closed, never fabricated).
- `<wrapper> --health`: precondition check ONLY (auth present, CCR proxy reachable, CLI on PATH), dispatches nothing. exit `0` + optional `{"ok":true,"detail":"…","latencyMs":N}` ⇒ healthy; exit `3` + `{"ok":false,"detail":"…"}` ⇒ down.
- `<wrapper> --list-models`: prints full available-model list (one id/line OR `{"models":[…]}` JSON line), dispatches nothing.
- `--interactive` capability STUB: declared, non-functional, NOT wired to plan dispatch (stdin-closed contract).
**Behavior:**
- VERIFY-AT-BUILD: confirm CCR proxy (`ccr-up.sh north`) relays stream-json frames UNBUFFERED before shipping the flip; if proxy buffers/normalizes to a single JSON blob, live `/stream` for north engine degrades to turn-end-only (kill/steer/durable views unaffected). Probe, don't assume.
- Exit-code map + fixed flag set otherwise unchanged. Exit codes 0/124/2/3 unchanged.
- `--health`/`--list-models` are pre-dispatch modes: never dispatch real work.
**Acceptance:** Run: `wrappers/na.sh --health` on a healthy box AND a dispatch with `--session-id <uuid>` → Expected: `--health` exits 0 with `{"ok":true,...}`; completed dispatch status line carries `"session_id":"<uuid>"` and logfile is stream-json.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add wrappers/na.sh wrappers/test/na-session.sh && git commit -m "feat(control-api): na.sh session continuity + --health/--list-models"`

---

### Task ca-T2: ca.sh — create-chat session continuity + adapter health/list-models
**Wave:** 1
**Blocks:** ca-T7, ca-T10 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Modify `wrappers/ca.sh` (cursor) — add `create-chat` pre-step + bind dispatch + emit session_id + two adapter modes. Stream already `--output-format stream-json --stream-partial-output` (conformant). Touch: pre-dispatch chat creation, dispatch bind (`--resume <chatId>`), status-line emit, new mode dispatch.
- Test `wrappers/test/ca-session.sh`
**Contract:**
- `cursor-agent create-chat` pre-step → capture returned chatId → dispatch bound to it (`--resume <chatId>`) → emit as `session_id`.
- Status line: `{"ok":true,"session_id":"<id>",...}` — present on completed dispatch (fail-closed if absent).
- `<wrapper> --health`: precondition-only; exit `0`/`{"ok":true,...}` healthy, exit `3`/`{"ok":false,"detail":"…"}` down.
- `<wrapper> --list-models`: full model list (one/line OR `{"models":[…]}`), no dispatch.
**Behavior:**
- chatId assignable pre-turn → kill-then-resume possible before first token.
- Stream format unchanged (already stream-json). Exit codes 0/124/2/3 unchanged.
**Acceptance:** Run: `wrappers/ca.sh --health` and a dispatch → Expected: `--health` exits 0; completed dispatch status line carries `"session_id":"<chatId>"` matching the create-chat id.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add wrappers/ca.sh wrappers/test/ca-session.sh && git commit -m "feat(control-api): ca.sh create-chat session continuity + --health/--list-models"`

---

### Task ca-T3: codex.sh — --json thread capture + verdict reparse + steer-forbidden + adapter health/list-models
**Wave:** 1
**Blocks:** ca-T7, ca-T10 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Modify `wrappers/codex.sh` (codex verdict seat) — flip exec→--json, capture thread_id, reparse verdict, two adapter modes. Touch: dispatch invocation (`exec`→`--json`), first-event capture, verdict parse, status-line emit, new mode dispatch.
- Test `wrappers/test/codex-session.sh`
**Contract:**
- Flip `exec` → `--json`.
- Capture `session_id` from FIRST emitted event: `{"type":"thread.started","thread_id":"<uuid>"}`. NO `~/.codex/sessions/` snapshot, NO `--last`, NO start-time/cwd matching.
- Status line: `{"ok":true,"session_id":"<id>",...}`.
- Verdict reparsed from final `item.completed` `agent_message` text (NOT raw stdout). Exit-code contract unchanged; only stdout parsing moves.
- `<wrapper> --health`: precondition-only; 0/`{"ok":true,...}` healthy, 3/`{"ok":false,"detail":"…"}` down.
- `<wrapper> --list-models`: full model list, no dispatch.
**Behavior:**
- codex is a verdict seat, NOT a coding turn. `steer` is FORBIDDEN on it (steering a review verdict is meaningless ⇒ `409`); `kill` IS allowed (universal SIGTERM-pgid escape hatch — must not be removed).
- session_id stream-captured (server-assigned, not known pre-turn). Exit codes 0/124/2/3 unchanged.
**Acceptance:** Run: a codex dispatch with `--json` → Expected: status line carries `"session_id"` equal to the first event's `thread_id`; verdict matches the final `item.completed` agent_message text; `--health` exits 0 on a healthy box.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add wrappers/codex.sh wrappers/test/codex-session.sh && git commit -m "feat(control-api): codex.sh --json thread capture + verdict reparse + --health/--list-models"`

---

### Task ca-T4: lib/normalize-events + spec/events.schema.json — 3 dialects → events/v1
**Wave:** 1
**Blocks:** ca-T10, ca-T11 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Create `lib/normalize-events` — the deepest seam; per-adapter parse table, stateless per line.
- Create `spec/events.schema.json` — pins `events/v1` record + per-type `data` shape.
- Test `lib/test/normalize-events.test.*` (+ fixtures `lib/test/fixtures/{claude,cursor,codex}.raw`)
**Contract:**
- `events/v1` record (verbatim): `{"v":"events/v1","run":"<slug>","task":"<id>","session":"<session_id>","seq":12,"ts":"<iso8601>","type":"<T>","data":{}}`
- `type` ∈ `turn_start | token_delta | tool_call | tool_result | turn_end | error | exit`. `data` shape per type pinned in schema (e.g. `token_delta.text`, `tool_call.{name,args}`, `exit.rc`).
- Seam: `normalize-events <adapter> < raw-stream-chunk → events/v1 JSON lines on stdout`.
- `seq` = 1-based ordinal of the raw logfile line that produced the event (position-derived, deterministic; NOT a runtime counter). Fan-out: `<line>.<subindex>`. Skipped lines still consume an ordinal — gap-tolerant, NEVER renumbered/reassigned.
**Behavior:**
- 3 input dialects: claude stream-json, cursor stream-json, codex json-events.
- Unknown/garbled line → emit ONE `{type:"error",data:{raw,reason}}` event. NEVER drop silently, NEVER crash.
- Unmapped raw event kind → degrades to `error` event (fail-visible).
- Pure, streaming, fully stateless per line — `seq` from line position, not retained state. Single source for SSE path and any future batch replay.
**Acceptance:** Run: `normalize-events codex < lib/test/fixtures/codex.raw` twice → Expected: exact events/v1 sequence with identical `seq` on both runs (determinism guard); a garbled fixture line yields one `error` event and no crash.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add lib/normalize-events spec/events.schema.json lib/test/normalize-events.test.* lib/test/fixtures/ && git commit -m "feat(control-api): normalize-events 3-dialect → events/v1 + schema"`

---

### Task ca-T5: lib/liveness — liveness/v1 ephemeral sidecar (set/get/reap)
**Wave:** 1
**Blocks:** ca-T10, ca-T11 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Create `lib/liveness` — sidecar reader/writer, fast-path ONLY (NOT the journal).
- Test `lib/test/liveness.test.*`
**Contract:**
- Sidecar file per run: `runstate/<slug>.live.json` (mutable ephemeral; NOT append-only journal).
- Maps `task → {pid, pgid, session_id, logfile, state, started_ts}` + run-level `sock` field (supervisor control-socket path).
- Verbs:
  - `liveness.set <slug> <task> <json>` — supervisor writes on spawn / state change.
  - `liveness.get <slug> [<task>]` — api reads for live tier.
  - `liveness.reap <slug>` — drop dead pids (kill -0 check); vanished pid ⇒ consult journal.
**Behavior:**
- NEVER source of truth: on any disagreement with git+JSONL, the journal wins (`journal.sh reconcile`).
- Reap = `kill -0` check; dead pid dropped, vanished pid defers to journal.
- Deleting the file mid-run loses only liveness (re-derivable), never run state.
**Acceptance:** Run: liveness.set a record then liveness.reap with a dead pid → Expected: get returns the live record; reap drops the dead-pid task and a vanished pid consults the journal (journal-wins on conflict).
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add lib/liveness lib/test/liveness.test.* && git commit -m "feat(control-api): liveness/v1 ephemeral sidecar set/get/reap"`

---

### Task ca-T9: runner.js + dynwf-driver.js — global registry pointer write at run start
**Wave:** 1
**Blocks:** ca-T10, ca-T11 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Modify `src/runner.js` — additive one-line-at-run-start registry pointer write (both planes).
- Modify `src/dynwf-driver.js` — same additive pointer write for DynWF plane.
- Test `src/test/registry-pointer.test.*`
**Contract:**
- `runId = sha256(repoRoot + "\0" + worktree)[:12]--<slug>`
- Pointer record: `{runId, slug, repoRoot, project, repo, branch, worktree, runstateDir, created}`
- Path: `~/.harness/runs/<runId>.json` (override `$HARNESS_HOME`, default `~/.harness`).
**Behavior:**
- One-line additive step at run start; does not alter dispatch.
- A pointer whose `runstateDir` is gone is reaped on read (fail-soft, logged).
- Both planes write identically — runstate itself stays per-repo (`<repoRoot>/runstate/`); only the discovery index is global.
- File-disjoint from all other wave-1 tasks (`src/runner.js`, `src/dynwf-driver.js` touched by no other wave-1 task) → runs in wave 1, not serialized.
**Acceptance:** Run: a fixture run (each plane) → Expected: `~/.harness/runs/<runId>.json` exists with the verbatim pointer record; a pointer whose runstateDir is removed is reaped on the next read (logged, no crash).
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add src/runner.js src/dynwf-driver.js src/test/registry-pointer.test.* && git commit -m "feat(control-api): global run registry pointer write at run start"`

---

### Task ca-T7: WRAPPER-CONTRACT.md + _contract-probe.sh — session-continuity + two-modes sections
**Wave:** 2
**Blocks:** — | **Blocked by:** ca-T1, ca-T2, ca-T3
**Files:**
- Modify `spec/WRAPPER-CONTRACT.md` — add "Optional: session continuity" section + "Optional modes: --health / --list-models" section. ADDITIVE: exit-code/flag contract UNTOUCHED.
- Modify `wrappers/_contract-probe.sh` — extend assertions.
**Contract:**
- Session-continuity section documents the additive status-line field `{"ok":true,"session_id":"<id>",...}` and per-adapter capture timing (claude/cursor assignable pre-turn; codex stream-captured from first `thread.started` event).
- Two-modes section documents `--health` (precondition-only; exit 0/`{"ok":true,...}` healthy, exit 3/`{"ok":false,"detail":"…"}` down; absent ⇒ caller reports `unknown`) and `--list-models` (full list one/line OR `{"models":[…]}`; absent ⇒ fall back to curated `models`).
- `_contract-probe.sh` asserts: `session_id` emitted on engine completion; `--health` responds with the exit/JSON contract; `--list-models` responds.
**Behavior:**
- Additive only — no change to fixed flag set or exit-code map.
**Acceptance:** Run: `wrappers/_contract-probe.sh` against each extended wrapper → Expected: passes, asserting session_id on completion and both modes responding to contract.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add spec/WRAPPER-CONTRACT.md wrappers/_contract-probe.sh && git commit -m "feat(control-api): wrapper-contract session continuity + --health/--list-models sections"`

---

### Task ca-T10: src/supervisor.js — --supervised control plane (kill/steer/pause/resume)
**Wave:** 3
**Blocks:** ca-T11 | **Blocked by:** ca-T1, ca-T2, ca-T3, ca-T4, ca-T5, ca-T9
**Files:**
- Create `src/supervisor.js` — extends the runner; control flow ONLY (no judgment, no model — FORK iron rule).
- Test `src/test/supervisor.test.*` (fixture plan + stub wrapper that echoes a stream + a `session_id`)
**Contract:**
- Activated by `harness run … --supervised` (default OFF → today's batch behavior unchanged).
- Registers each spawned wrapper into liveness/v1 (pid/pgid via `spawn` detached pgroup).
- Binds Unix domain socket at derived path `$HARNESS_HOME/sock/<runId>.sock` + writes that path into the liveness `sock` field.
- Verbs (EACH ⇒ a journal record so it survives resume):
  - **kill `<task>`** → `kill -TERM -<pgid>` (process group), `-KILL` after grace. Journals the task at its wrapper rc (124-class non-completion); wave loop treats as halted (existing fail-closed path).
  - **steer `<task>` `<msg>`** → enqueue; applied at the task turn boundary via adapter session-resume (`claude --resume <id>` / `codex exec resume <id>` / `cursor --resume <chatId>`) with `msg` appended to the prompt. `interrupt:true` ⇒ kill the current turn first, then resume-with-msg. NEVER mid-turn for batch adapters.
  - **pause `<run>` / resume `<run>`** → set/clear a run-level hold flag the wave scheduler checks before dispatching the NEXT task/turn (cannot freeze an in-flight LLM call — documented limit).
- **Journal state-machine (verbatim):** states `leased → implemented → gated → [reviewed] → committed`. Steer allowed ONLY on a non-terminal task (`leased | implemented | gated | reviewed`). A steered re-dispatch transitions the task back to `leased` (re-runs gate0/risk and review like any dispatch — steer never bypasses gates). Steer on a `committed` task ⇒ `409 {detail:"task already committed"}`. codex steer FORBIDDEN (`409`); codex kill ALLOWED.
- **DynWF plane** has NO socket: the same verbs are appends to `runstate/<slug>.steer.jsonl` inbox read at each task boundary (observe + queue-steer only; no live kill).
**Behavior:**
- Kill of an already-dead task ⇒ idempotent no-op + current journal state returned.
- Pure control flow; every verb journals.
**Acceptance:** Run: supervisor over a fixture plan with the stub wrapper → Expected: kill→journaled-halt; steer→resume-with-appended-prompt (back to `leased`); pause→next-task-held; steer on a committed task → `409`; stub exit 3 → fail-closed.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add src/supervisor.js src/test/supervisor.test.* && git commit -m "feat(control-api): supervisor --supervised kill/steer/pause/resume"`

---

### Task ca-T11: src/control-api.js — Node stdlib http, runId-keyed routes + SSE stream
**Wave:** 4
**Blocks:** ui-T13 | **Blocked by:** ca-T4, ca-T5, ca-T9, ca-T10
**Files:**
- Create `src/control-api.js` — Node stdlib `http`, binds `127.0.0.1`. Zero-dep.
- Test `src/test/control-api.test.*` (durable GETs over a fixture run dir; `/stream` SSE smoke; POST to unsupervised run)
**Contract — full route table (verbatim, runId-keyed):**
```
GET  /runs                                    → [{runId,slug,repoRoot,project,repo,branch,worktree,status,wave,counts,base_branch}]  (enumerate the GLOBAL run registry)
GET  /runs/:runId                             → job: meta + plan DAG (waves/tasks) + per-task last state
GET  /runs/:runId/tasks/:tid                  → agent: binding, session_id, pid/runtime/idle (liveness), log path
GET  /runs/:runId/tasks/:tid/stream           → SSE: events/v1 (tail logfile → normalize-events → text/event-stream)
POST /runs/:runId/tasks/:tid/steer  {msg,interrupt?}  → supervisor.steer | DynWF inbox append
POST /runs/:runId/tasks/:tid/kill                     → supervisor.kill
POST /runs/:runId/pause | /runs/:runId/resume         → supervisor.pause/resume
```
**Behavior:**
- Durable GETs read journals + plan + logs — NO daemon required; work live OR finished, either plane.
- `GET /runs` enumerates the global registry (`~/.harness/runs/*.json`), per pointer reads that run's per-repo journal/liveness; a pointer whose `runstateDir` is gone reaped on read (fail-soft, logged).
- Live `/stream` SSE = tail logfile → normalize-events → `text/event-stream`. `Last-Event-ID` resume = re-tail + replay events whose position-derived `seq` > client's last id (deterministic, exact cut across reconnects).
- Backpressure: bounded ring buffer per stream; on overflow emit ONE `error` event noting dropped range (never silently lose).
- Live `/stream` + all POSTs require the owning run's supervisor reachable; if socket absent or `connect` fails ⇒ `409 {ok:false,detail:"run not supervised / not live"}` (fail-closed, never silent no-op).
- Auth: none (localhost). `?token=` shared-secret hook STUBBED for eventual non-local case.
**Acceptance:** Run: durable GETs over a fixture run dir (no daemon) + `/stream` SSE smoke + POST to an unsupervised run → Expected: correct Overview/Job/Agent JSON; SSE events arrive and `Last-Event-ID` resumes at `seq`; POST to unsupervised run → `409 {ok:false,detail:"run not supervised / not live"}`.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add src/control-api.js src/test/control-api.test.* && git commit -m "feat(control-api): control-api http server runId-keyed routes + SSE"`

---

## Adapter Catalog Track

Catalog + schema + validator cross-check. Touches `presets/` dir + `presets/_validate.mjs`, both Plan A artifacts → gated on `g1` [Plan A landed]. Zero-dep, ajv-free (repo no-npm policy).

### Task adpg-T6: adapter catalog + schema
**Wave:** 1
**Blocks:** adpg-T8 | **Blocked by:** g1
**Requires decision:** g1
**Files:**
- Create `presets/adapters.json` — repo-tracked catalog, `adapters/v1`.
- Create `spec/adapters.schema.json` — JSON-Schema draft 2020-12, `additionalProperties:false`, parity with `presets.schema.json` conventions.
- Test `test/adapters-schema.sh` — schema accepts worked catalog, rejects missing `wrapper`/`id` + unknown top-level keys.
**Contract:**
- Catalog shape (`adapters/v1`), entry verbatim:
  ```jsonc
  { "version": "adapters/v1", "adapters": [
    { "id": "codex",
      "kind": "cli",
      "wrapper": "wrappers/codex.sh",
      "models": ["gpt-5.5-high","gpt-5.5-low"],
      "listModels": true,
      "envFile": "wrappers/CODEX.env" } ]
  }
  ```
  - `id` kebab, registry key, stable. `kind` open enum `cli|proxy|api` (informational). `wrapper` path per WRAPPER-CONTRACT. `models` = curated allowlist a preset binding MAY pin. `listModels` OPTIONAL bool — wrapper implements `--list-models`. `envFile` OPTIONAL pointer to gitignored per-machine secret file; value NEVER inlined.
- Schema encodes the **wrapper-path-UNIQUE** invariant (no two catalog entries share a `wrapper` path).
**Behavior:**
- Schema validates SHAPE only — wrapper existence/health is a CLI concern, not schema.
- `additionalProperties:false` at top level + entry level (unknown keys rejected); `id` + `wrapper` required.
- No arbitrary-exec field: full model list routes only through declared `--list-models` wrapper mode (gated by `listModels`), never an inline command string in this committed file.
**Acceptance:** Run: validate `presets/adapters.json` against `spec/adapters.schema.json` via the ajv-free validator → Expected: worked catalog passes; a variant missing `wrapper`, missing `id`, or carrying an unknown top-level key each fails; two entries sharing a `wrapper` path fails the uniqueness invariant.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add presets/adapters.json spec/adapters.schema.json test/adapters-schema.sh && git commit -m "feat(adapter): adapters/v1 catalog + schema with unique-wrapper invariant"`

---

### Task adpg-T8: validator binding↔catalog cross-check
**Wave:** 2
**Blocks:** — | **Blocked by:** adpg-T6
**Requires decision:** — (transitively gated on g1 via `deps`→adpg-T6; wave-2, so no direct `requires_decision`)
**Files:**
- Modify `presets/_validate.mjs` — add catalog cross-check (still zero-dep, ajv-free).
- Test `test/validate-crosscheck.sh` — unknown wrapper fails, unlisted model fails, clean preset passes.
**Contract:**
- For every preset binding AND run-config override, `_validate.mjs` asserts:
  - (a) `binding.wrapper` resolves to a catalog adapter — matched by UNIQUE `wrapper` path (bindings carry `{wrapper, model}`, no adapter id).
  - (b) `binding.model ∈ that adapter.models` (the catalog curated allowlist).
- Unknown wrapper OR unlisted model ⇒ validation error at author/CI time (NOT dispatch time).
**Behavior:**
- Match keys on the unique wrapper path (relies on adpg-T6 uniqueness invariant); ambiguous/absent wrapper ⇒ error.
- A model not in the adapter's catalog `models` ⇒ error (closes the WRAPPER-CONTRACT silent-wrong-model footgun statically).
- Does NOT alter runtime dispatch resolution — author/CI-time check only; clean preset still passes unchanged.
**Acceptance:** Run: `presets/_validate.mjs` over a fixture set {clean preset, preset naming an uninstalled-wrapper path, preset pinning a model absent from that adapter's `models`} → Expected: exit `0` on clean; non-zero validation error naming the offending binding for both the unknown-wrapper and unlisted-model cases.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add presets/_validate.mjs test/validate-crosscheck.sh && git commit -m "feat(adapter): validator binding↔catalog cross-check (wrapper+model)"`

---

## Web UI Track

Contract-level. Literals copied VERBATIM from `docs/specs/2026-06-30-harness-web-ui-design.md`. Build-order AFTER control-api (ca-*) tasks in this plan. The gateway (ui-T13) ALSO consumes `bin/okf` (okf-T6) + `bin/harness-adapter` (adp-T9) from plan `harness-nongated` → gate `g2`.

### Task ui-T12: Scaffold `web/` app + toolchain
**Wave:** 5
**Blocks:** ui-T13 | **Blocked by:** —
**Files:**
- Create `mega-plan-harness/web/package.json` — app manifest + deps.
- Create `mega-plan-harness/web/astro.config.*` — `@astrojs/react` + `@astrojs/node` standalone adapter.
- Create `mega-plan-harness/web/.npmrc` — GitHub Packages registry scope; NEVER committed token.
- Create `mega-plan-harness/web/tailwind.config.*` — Tailwind config.
- Test: `mega-plan-harness/web/test/scaffold.test.ts`
**Contract:**
- `.npmrc`: `@platform-modules:registry=https://npm.pkg.github.com`, `NODE_AUTH_TOKEN` from env (never committed).
- Astro adapter: `@astrojs/node` standalone (same process serves SPA + gateway endpoints) + `@astrojs/react`.
- Deps pinned: `@platform-modules/ui-primitives ^0.3`, `ui-tokens ^0.2`, `ui-editor ^0.1`; `@xyflow/react`; `elkjs` (or `dagre`); `react-markdown` + `remark-gfm`; `zustand`.
**Behavior:**
- `web/` heavy toolchain isolated in its own subdir; does not pollute harness core.
- `NODE_AUTH_TOKEN` sourced from developer env at install; `.npmrc` carries no literal token; `.gitignore` excludes any token file.
- Node standalone adapter so one local process serves SPA + `/api/**`.
**Acceptance:** Run: `cd mega-plan-harness/web && npm run build` → Expected: clean Astro build (Node standalone), all pinned deps resolve from GitHub Packages, no token in tracked files.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add mega-plan-harness/web/package.json mega-plan-harness/web/astro.config.* mega-plan-harness/web/.npmrc mega-plan-harness/web/tailwind.config.* mega-plan-harness/web/test/scaffold.test.ts && git commit -m "feat(web): scaffold astro+react+tailwind app"`

---

### Task ui-T13: Gateway endpoints `web/src/pages/api/**`
**Wave:** 6
**Blocks:** ui-T14, ui-T15, ui-T16, ui-T17, ui-T18, ui-T19, ui-T20, ui-T21 | **Blocked by:** ui-T12, ca-T11 (+ cross-plan: okf-T6, adp-T9 from `harness-nongated`)
**Requires decision:** g2
**Files:**
- Create `web/src/pages/api/**` (Astro server routes) — browser-facing HTTP bridge fronting per-run unix sockets + `okf` + `harness-adapter`.
- Test: `web/test/gateway.test.ts` (against fixture registry + fake unix socket)
**Contract (route table VERBATIM):**
```
GET  /api/runs                        → 200 [{runId, slug, project, repo, branch, worktree, liveness, created}]
GET  /api/runs/:runId                 → 200 {meta, journal: {tasks:[{id,wave,phase,desc,status,deps,agentId?}]}}
GET  /api/runs/events                 → ONE multiplexed summary SSE stream; frames {runId,status,liveness,doneCount,taskCount} for ALL not-done runs
GET  /api/runs/:runId/events          → per-run SSE proxy of the run socket's event stream; relays seq + forwards Last-Event-ID upstream
POST /api/runs/:runId/tasks/:taskId/steer       {text}  → forward to control-api steer; 409 if committed
POST /api/runs/:runId/tasks/:taskId/redispatch          → forward; transitions task back to leased
POST /api/runs/:runId/agents/:agentId/kill              → forward kill (allowed on all seats incl. codex)
GET  /api/okf/tree   [?scope=]        → `okf index` projection (progressive index.md tree as JSON)
GET  /api/okf/file   ?path=           → raw concept markdown + parsed frontmatter
POST /api/okf/file   {path, body}     → write concept (atomic) + `okf index`; validates frontmatter via `okf doctor`
POST /api/okf/sync                    → `okf sync`
GET  /api/adapters                    → adapter registry + health
POST /api/adapters/:id                {enabled, models, config} → persist adapter config
```
- `runId`, not slug, is the key. SPA never sees socket paths; gateway resolves `sock` from the liveness record.
- Gateway = thin relay + shape-normalizer; holds NO authoritative state.
**Behavior:**
- Two stream tiers: `GET /api/runs/events` = ONE multiplexed summary stream driving every badge; `GET /api/runs/:runId/events` = per-run, opened on drill-in.
- Reconnect: gateway forwards browser `Last-Event-ID` UPSTREAM on the re-dialed socket; control-api owns logfile + `seq` + replay; gateway NEVER re-tails.
- Fail-closed: dead/absent socket → `502 {detail, liveness:"dead"}` (never a hung request); registry pointer missing → `404`; `okf`/`harness-adapter` non-zero exit → surface stderr + exit code (`500`/`422`), no silent partial.
- `/api/okf/*` shells `bin/okf`; `/api/adapters` shells `bin/harness-adapter`.
- Cross-plan dep: `bin/okf` + `bin/harness-adapter` come from plan `harness-nongated` (gate `g2`).
**Acceptance:** Run: `cd web && npm test -- gateway` → Expected: `/api/runs` + `/api/runs/:runId` shapes match; `/events` relays frames with `seq` intact; dead socket → `502`; `Last-Event-ID` replay returns only `seq >` client id; missing pointer → `404`; okf nonzero → surfaced.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/pages/api web/test/gateway.test.ts && git commit -m "feat(web): gateway endpoints proxying run sockets + okf + adapters"`

---

### Task ui-T14: Client run-store `web/src/lib/run-store.ts`
**Wave:** 7
**Blocks:** ui-T17, ui-T18, ui-T19 | **Blocked by:** ui-T13
**Files:**
- Create `web/src/lib/run-store.ts` (Zustand) — single client-truth; the only place SSE frames are applied.
- Test: `web/test/run-store.test.ts`
**Contract (key shape VERBATIM):**
```
runId → {meta, tasks, waves, agents:{agentId→{pid,runtime,idle,seat,status,settings}}, messages, liveness}
```
- Seeded by `GET /api/runs/:runId`; patched by SSE events; selectors feed every view; no view fetches the socket directly.
**Behavior:**
- Apply-by-`seq` idempotent dedupe: ignore any frame with `seq ≤` last applied.
- Single place SSE frames apply, so reconnect/replay idempotency lives in one spot.
- Out-of-order + duplicate `seq` stream → state converges, no double-apply.
**Acceptance:** Run: `cd web && npm test -- run-store` → Expected: applying an out-of-order + duplicate `seq` stream converges to correct state with no double-apply.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/lib/run-store.ts web/test/run-store.test.ts && git commit -m "feat(web): zustand run-store with apply-by-seq dedupe"`

---

### Task ui-T15: SSE client hook `web/src/lib/use-run-stream.ts`
**Wave:** 7
**Blocks:** ui-T17, ui-T18, ui-T19 | **Blocked by:** ui-T13
**Files:**
- Create `web/src/lib/use-run-stream.ts` — wraps native `EventSource`; transport gap built here.
- Test: `web/test/use-run-stream.test.ts`
**Contract:**
- `useRunStream(runId): {connected, lastSeq}` — native `EventSource('/api/runs/:runId/events')`, parse each frame → dispatch into run-store.
**Behavior:**
- Relies on browser built-in `Last-Event-ID` reconnect — NO manual backoff lib.
- Views blind to transport; SSE↔WS swap later touches only this hook + run-store apply path.
**Acceptance:** Run: `cd web && npm test -- use-run-stream` → Expected: frames from a mocked EventSource parse and dispatch into the run-store; reconnect relies on native `Last-Event-ID` (no custom backoff).
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/lib/use-run-stream.ts web/test/use-run-stream.test.ts && git commit -m "feat(web): EventSource run-stream hook"`

---

### Task ui-T16: DAG view `web/src/islands/Dag.tsx`
**Wave:** 7
**Blocks:** ui-T17, ui-T18 | **Blocked by:** ui-T13
**Files:**
- Create `web/src/islands/Dag.tsx` — `@xyflow/react` + elk/dagre interactive graph; reused by Job (full) + Overview (mini-preview).
- Test: `web/test/dag.test.ts`
**Contract (node shapes VERBATIM):**
```
WaveNode  = { id:`w<n>`, kind:'wave', status, taskCount, doneCount }
TaskNode  = { id:taskId, kind:'task', wave, phase, desc, status, agentId? }
edge      = { source, target }   // from task.deps (task→task) and wave ordering
```
**Behavior:**
- Layout elk/dagre, left→right. wave = node, dep = edge.
- Node click: wave → expands to its tasks; task → routes to Agent view.
- `status` drives node/badge color (Running/Paused/Error/Done/Interrupted).
- Custom nodes built from `ui-primitives` (Card/Badge/Tooltip).
- Overview mini-preview = same renderer, `fitView`, interaction disabled.
**Acceptance:** Run: `cd web && npm test -- dag` → Expected: a multi-wave/deps/mixed-status journal fixture produces correct node+edge graph + status→color mapping; mini-preview renders non-interactive.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/islands/Dag.tsx web/test/dag.test.ts && git commit -m "feat(web): xyflow DAG island (wave=node, dep=edge)"`

---

### Task ui-T17: Overview page `index.astro` + `Overview.tsx`
**Wave:** 8
**Blocks:** — | **Blocked by:** ui-T14, ui-T16
**Files:**
- Create `web/src/pages/index.astro` — Overview page shell.
- Create `web/src/islands/Overview.tsx` — tile grid island.
- Test: `web/test/overview.test.ts`
**Contract (CTA-per-status map VERBATIM):**
```
Running     → [Pause, Info]
Paused      → [Start, Delete, Info]
Error       → [Start, Delete, Info]
Interrupted → [Start, Delete, Info]
Done        → [Info]
```
- Tiles from `ui-primitives` Card/DataList over not-done runs (`GET /api/runs`).
**Behavior:**
- ALL badges driven by the ONE `GET /api/runs/events` summary stream — NOT a stream per tile.
- Each tile: static DAG mini-preview (ui-T16, `fitView`, interaction off) + status badge + status-conditional hover CTAs (SVG icons).
- CTA → POST lifecycle (resume = redispatch leased task / run start; pause/kill = gateway). Tile click → Job page.
**Acceptance:** Run: `cd web && npm test -- overview` → Expected: island smoke mounts under Node adapter vs mocked gateway; badges update from a single `/api/runs/events` mock; CTAs render exactly per the status→CTA map.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/pages/index.astro web/src/islands/Overview.tsx web/test/overview.test.ts && git commit -m "feat(web): overview tile grid driven by summary stream"`

---

### Task ui-T18: Job page `runs/[runId].astro` + `Job.tsx`
**Wave:** 8
**Blocks:** — | **Blocked by:** ui-T14, ui-T15, ui-T16
**Files:**
- Create `web/src/pages/runs/[runId].astro` — Job page shell.
- Create `web/src/islands/Job.tsx` — metadata panel + interactive DAG island.
- Test: `web/test/job.test.ts`
**Contract:**
- Metadata panel = registry fields ONLY: `project, repo, branch, worktree, runstateDir, slug, created` + `land_mode` from journal `meta`. NO `type` field (no source in registry record).
- Full interactive DAG (ui-T16) + per-wave status badges.
**Behavior:**
- Opens the per-run detail stream (`GET /api/runs/:runId/events` via ui-T15) on mount; closes on navigate-away.
- Wave expand → tasks; task → Agent view link.
**Acceptance:** Run: `cd web && npm test -- job` → Expected: island smoke vs mocked gateway renders metadata panel with the 7 registry fields + `land_mode` and NO `type`; opens per-run stream on mount; per-wave badges + interactive DAG present.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/pages/runs/[runId].astro web/src/islands/Job.tsx web/test/job.test.ts && git commit -m "feat(web): job page metadata + interactive DAG + detail stream"`

---

### Task ui-T19: Agent view `runs/[runId]/agents/[agentId].astro` + `AgentChat.tsx`
**Wave:** 8
**Blocks:** — | **Blocked by:** ui-T14, ui-T15
**Files:**
- Create `web/src/pages/runs/[runId]/agents/[agentId].astro` — Agent page shell.
- Create `web/src/islands/AgentChat.tsx` — chat timeline + detail rail + steer composer.
- Test: `web/test/agent-chat.test.ts`
**Contract:**
- Timeline: `react-markdown` + `remark-gfm`, SSE-fed from run-store filtered to `agentId`.
- Detail rail: `pid / runtime / idle / seat+tier / settings / killswitch`.
- Steer composer POSTs `/api/runs/:runId/tasks/:taskId/steer {text}`.
**Behavior:**
- Composer DISABLED when seat is steer-forbidden (codex verdict seat) OR task is `committed`; `409` reason surfaced inline (mirror control-api rule, fail visibly rather than POST-and-error).
- If a race still POSTs, the `409` reason renders inline.
- Killswitch → `POST …/kill` (allowed on all seats incl. codex).
- Attachment send = Non-goal v1 — text-only steer.
**Acceptance:** Run: `cd web && npm test -- agent-chat` → Expected: steer-forbidden seat / committed task → composer disabled + reason shown; allowed → POST fires; timeline filtered to `agentId`.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/pages/runs/[runId]/agents/[agentId].astro web/src/islands/AgentChat.tsx web/test/agent-chat.test.ts && git commit -m "feat(web): agent chat timeline + steer composer + killswitch"`

---

### Task ui-T20: Adapter page `adapters.astro` + `Adapters.tsx`
**Wave:** 8
**Blocks:** — | **Blocked by:** ui-T13, adpg-T6 (+ cross-plan: adp-T9 from `harness-nongated`)
**Files:**
- Create `web/src/pages/adapters.astro` — Adapter setup page shell.
- Create `web/src/islands/Adapters.tsx` — adapter list island.
- Test: `web/test/adapters.test.ts`
**Contract:**
- Reads/writes via `GET /api/adapters` + `POST /api/adapters/:id {enabled, models, config}`.
- Adapter descriptor `{id, kind, enabled, models:[…], availableModels?, config, health}` (owned by `harness-adapter-config` spec).
**Behavior:**
- List every adapter (claude / codex / cursor / opencode / openrouter / …) with enable/disable, per-adapter config, granular model selection (e.g. OpenRouter — operator picks the 1–2 usable), health indicator.
- Build-gated on `harness-adapter-config` spec landing (gateway shells `bin/harness-adapter`).
- Cross-dep: catalog (adpg-T6, this plan) + adapter CLI (adp-T9, plan `harness-nongated` via gate `g2`).
**Acceptance:** Run: `cd web && npm test -- adapters` → Expected: enable/disable + model-select round-trip through `/api/adapters`; health reflects a stubbed probe; island smoke vs mocked gateway.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/pages/adapters.astro web/src/islands/Adapters.tsx web/test/adapters.test.ts && git commit -m "feat(web): adapter setup page (enable/model-select/health)"`

---

### Task ui-T21: OKF KB page `okf.astro` + island
**Wave:** 8
**Blocks:** — | **Blocked by:** ui-T13 (+ cross-plan: okf-T6 from `harness-nongated`)
**Files:**
- Create `web/src/pages/okf.astro` — OKF KB page shell.
- Create `web/src/islands/OkfKb.tsx` — index tree + concept read/edit island.
- Test: `web/test/okf.test.ts`
**Contract:**
- `GET /api/okf/tree [?scope=]` → progressive index tree JSON; `GET /api/okf/file ?path=` → markdown + frontmatter; `POST /api/okf/file {path, body}` → atomic write + `okf index`, validated via `okf doctor`; `POST /api/okf/sync`.
**Behavior:**
- Progressive index tree; concept read/edit (`ui-editor` optional); sync.
- Write that fails `okf doctor` → editor shows `okf doctor` output, write rejected (atomic — never a partial concept).
- Cross-dep: `bin/okf` from plan `harness-nongated` (gate `g2`).
**Acceptance:** Run: `cd web && npm test -- okf` → Expected: reads tree from a fixture store; a write failing `okf doctor` is rejected with output shown; a clean write accepted; island smoke vs mocked gateway.
- [ ] Write tests covering behavior
- [ ] Implement to contract
- [ ] Run acceptance → expected
- [ ] Commit: `git add web/src/pages/okf.astro web/src/islands/OkfKb.tsx web/test/okf.test.ts && git commit -m "feat(web): okf kb page (tree/read/edit/sync)"`
