# harness-engine-foundation — Design

audience: AI coding agents first. Contract-level: seams + decisions, NOT code bodies.
slug: `harness-engine-foundation` · date: 2026-07-02

## Purpose

Turn the mega-plan-harness engine (`src/runner.js` + its supporting infra: `lib/resolve-seat.sh`,
`wrappers/*.sh`, `presets/*.json`, `spec/*.schema.json`, the web UI) from "only runs inside this one repo"
into a **centrally-installed, versioned engine any repo can launch a plan against**. This is sub-project
**1 of 2** in the larger migration off `run-plan.js` (the Workflow-tool controller) onto this engine
(sub-project 2: `ship`/`run-plan` skill routing on top of this foundation — separate spec, later, gated on
this one landing).

## Non-goals

- No skill-doc changes (`run-plan/SKILL.md`, `ship/SKILL.md`) — sub-project 2.
- `run-plan.js` is NOT touched or deleted — stays exactly as-is so in-flight runs launched on it complete
  normally.
- No auto-migration of currently-running plans.
- No new web UI features — `$HARNESS_HOME/runs/<runId>.json` pointer registry and the web server already
  exist (`docs/specs/2026-06-30-harness-control-api-design.md`, live at `~/.harness/runs/`,
  `~/.harness/web.*`); reused as-is.

## Ground truth (read from source this session, not assumed)

- `shellPath(...segments)` (`src/runner.js:1041`) resolves `path.join(__dirname, "..", ...segments)` —
  **already location-relative, not repoRoot-relative.** Every `lib/*.sh` call in the engine
  (`journal.sh`, `gates.sh`, `resolve-seat.sh`) already goes through `shellPath`. Placing the engine file
  centrally makes these resolve centrally for free — **zero code change needed for lib/wrappers/presets
  path resolution.**
- `ensureWebUi(repoRoot)` (`src/runner.js:1025`) is the one exception — it builds
  `path.join(repoRoot, "bin", "ensure-web.sh")`, i.e. repoRoot-relative. Once `repoRoot` is an arbitrary
  target repo, that path won't exist there. **This is the one real bug to fix**, not a new mechanism: change
  it to `shellPath("bin", "ensure-web.sh")` so it resolves next to the engine, like everything else.
- `repoRoot = process.cwd()` (`src/runner.js:18`, inside `main()`) is the only place target-repo identity is
  established. No `--repo` flag exists today.
- `$HARNESS_HOME` (default `~/.harness`, override via env var) is an **already-live** convention
  (`src/supervisor.js:42`, and used throughout the control-api / adapter-config specs for
  `~/.harness/runs/`, `~/.harness/sock/`, `~/.harness/adapters.json`). Reuse verbatim — do not invent a new
  env var name.
- `bin/runplan`'s `--preset <name>` is documented (its own usage string, `run-plan/SKILL.md`) but not
  implemented — `runner.js`'s `parseArgs()` only accepts `--plan`/`--runconfig`/`--concurrency`/`--resume`.
  Confirmed by running it: `unknown argument: --preset`.

## Architecture

### Two path roots the engine resolves from

1. **Engine-relative** (`shellPath`, i.e. `__dirname/..`) — the shared, centrally-installed infra:
   `lib/`, `wrappers/`, `presets/`, `spec/`, `bin/ensure-web.sh`, the built `web/` UI. Same for every run,
   every target repo.
2. **`--repo <path>`** (new flag) — the target repo: git worktrees, task branches, commits, and the journal
   (`<repo>/runstate/<slug>.jsonl`). Defaults to `process.cwd()` — preserves today's in-repo behavior and
   `test/runner-integration.sh` unchanged.

### Central install layout (`$HARNESS_HOME`, default `~/.harness`)

```
~/.harness/
  engine/
    versions/
      0.1.0/
        src/runner.js          ← engine at that version
        lib/  wrappers/  presets/  spec/  bin/  web/   ← full infra snapshot, frozen with it
      0.1.1/
        src/runner.js
        lib/  wrappers/  presets/  spec/  bin/  web/
    CURRENT                     ← one line, e.g. "0.1.1" — version NEW launches resolve to
  runs/         ← existing pointer registry, unchanged
  sock/         ← existing, unchanged
```

**Why each version is a self-contained bundle, including presets:** `shellPath(...segments)` resolves
`path.join(__dirname, "..", ...segments)` — placing `src/runner.js` one level inside its own version
directory (`versions/0.1.1/src/runner.js`) makes `shellPath("lib", …)`/`shellPath("presets", …)` resolve
to `versions/0.1.1/lib/…`/`versions/0.1.1/presets/…` with **zero code change**. This closes the isolation
hole a shared-infra design would have: a run pinned to `0.1.1` must keep executing `0.1.1`'s
`lib/resolve-seat.sh`, wrapper contracts, AND preset bindings even after any of them change for `0.1.2` —
"older runs complete with old working file, nothing broken" applies to the whole shell/config surface, not
just the orchestration loop. A preset maps seat→model; mutating it under a live run would change which
model handles a seat mid-flight — exactly the "changes underneath a running plan" the pin exists to
prevent. An emergency override (e.g. a model withdrawn mid-run) goes through the existing per-run
`runconfig.overrides` + resume path, not a global mutation that would hit every running plan at once.
Duplicating `lib/`/`wrappers/`/`presets/` per version costs disk, not correctness risk; that trade favors
isolation.

### Versioning + release mechanics (binds the user's explicit rule)

- **Git-tracked source of truth in this repo:** `VERSION` file at repo root (e.g. `0.1.0`) plus
  `src/runner.js`/`lib/`/`wrappers/`/`spec/`/`bin/` themselves. Both live, normal git history — no
  duplication inside the repo.
- **`bin/harness-release.sh`** (new, git-tracked in this repo) — two explicit subcommands, none
  auto-invoked:
  - `bump` — **patch only.** Reads `VERSION`, increments the 3rd number, snapshots
    `src/runner.js`+`lib/`+`wrappers/`+`presets/`+`spec/*.schema.json`+`bin/ensure-web.sh`+built `web/dist`
    into `$HARNESS_HOME/engine/versions/<new>/{src,lib,wrappers,presets,spec,bin,web}`, writes the new
    `VERSION`. Refuses (fails closed) if invoked to change the minor or major component — those require the
    user to edit `VERSION` by hand first, which is the explicit-approval gate the user asked for.
  - `release [version]` — writes `$HARNESS_HOME/engine/CURRENT` = `<version>` (default: latest bumped).
    This is the "make new launches use it" step — the ONLY thing `release` does. Existing runs are
    unaffected (see next) — their bundle directory is never
    touched by a later `bump`/`release`.

### Per-run version pin (fixes the resume-drift bug)

**Contract callers (the future skill-routing layer, sub-project 2) MUST follow:** at first launch, resolve
`$HARNESS_HOME/engine/CURRENT` once, and persist the resolved version string alongside that run's own state
(e.g. a sibling `.version` file next to whatever per-run script is generated). On resume, read the persisted
version back and use `$HARNESS_HOME/engine/versions/<pinned>/src/runner.js` — **never re-resolve `CURRENT`
on resume.** Because each version is a self-contained bundle (`shellPath` resolves inside
`versions/<pinned>/`), the resumed run keeps executing that version's own `lib/`/`wrappers/`/`spec/`, not
whatever `bump`/`release` most recently produced. This is what makes `release` safe to run while other
plans are mid-flight: a bump+release only changes what *new* launches pick up; anything already running
keeps resolving its own pinned bundle, which `bump`/`release` never mutates or deletes.

### Per-repo thin wrapper

**`bin/harness-init.sh <repoRoot>`** (new, git-tracked in this repo, mirrors the existing
`~/.claude/workflows/lib/ship-init.sh` per-project-wrapper pattern exactly) materializes
`<repoRoot>/.claude/scripts/runplan` — gitignored via `.git/info/exclude` (same convention `ship.sh`
already uses in every project). Body:

```bash
CURRENT="$(cat "$HARNESS_HOME_OR_DEFAULT/engine/CURRENT")"
exec node "$HARNESS_HOME_OR_DEFAULT/engine/versions/${CURRENT}/src/runner.js" run --repo "<repoRoot>" "$@"
```

Resolves `CURRENT` dynamically on every invocation (so a fresh launch always picks up whatever's released);
a resume caller passes an explicit pin instead — see next.

### `--preset` fix

Add `--preset <name>` and `--engine-version <X.Y.Z>` to `parseArgs()` directly (not left to a shell
translation layer, since both `bin/runplan` and the new per-repo wrapper need it identically):
- `--preset <name>` with no `--runconfig` → synthesize `{"version":"runconfig/v1","preset":"<name>"}` in
  memory, same as if that JSON had been passed via `--runconfig`. `--preset` + `--runconfig` together is a
  usage error (ambiguous).
- `--engine-version` is accepted and ignored by `runner.js` itself (it's meaningless once the correct
  versioned file is already running) — it exists so the **launcher** (thin wrapper / future skill logic)
  can pass it through uniformly without the engine needing to know; document this as a no-op flag, not
  silently rejected (`parseArgs` must not throw `unknown argument` on it).

## Architecture Decisions

- **Central single install vs. per-repo full copy** — central chosen (user decision, this session). Full
  copy rejected: duplicates far more than `ship.sh`'s ~2KB (engine + wrappers + presets + schemas + web UI)
  and drifts independently per repo.
- **Self-contained per-version bundle (lib/wrappers/presets/spec/bin/web) vs. shared unversioned infra** —
  self-contained bundle chosen (reversed twice from earlier drafts after two rounds of `advisor()`
  review). First reversal: a shared `sync` that updates `lib/resolve-seat.sh` for every version at once
  breaks "older runs complete with old working file, nothing broken" the moment a live run's shell
  contract changes underneath it. Second reversal: an initial fix kept `presets/` shared as "runtime
  config" — but `shellPath` is script-relative, so a shared presets dir sitting outside the bundle either
  silently fails to resolve or requires an unspecified code change, voiding the zero-code-change property;
  and semantically, a preset is seat→model config whose mutation mid-run is exactly the kind of drift the
  pin exists to prevent. Presets are bundled like everything else; emergency overrides go through
  per-run `runconfig.overrides`, not global mutation.
- **`CURRENT` pointer file vs. hand-editing call sites on release** — pointer file chosen (confirmed with
  user over the literal "edit the skills" phrasing), because a hardcoded version in N per-repo wrappers
  would require regenerating all N on every release; one pointer file is the single source of truth every
  wrapper reads fresh. Sub-project 2's skill-routing layer still reads/writes this same file — no
  duplicate mechanism.
- **Central `~/.harness` install vs. versioning `~/.claude/workflows/run-plan*.js` in place** — central
  harness install chosen (confirmed with user), since the goal is running the mega-plan-harness engine
  (worktree/journal/gate0 mechanics) against arbitrary repos, not re-versioning the old Workflow-controller
  engine in place.

## Testing

- Extend `test/runner-integration.sh` with a `--repo <tmpdir-not-cwd>` case confirming journal/worktrees
  land under the passed repo, not cwd.
- New `test/harness-release.sh`: `bump` refuses a minor/major request; `release` writes `CURRENT`; a
  fixture plan run pinned to an old version after a `bump`+`release` still resolves the old file.
- New `test/ensure-web-central.sh` (or extend existing web test): `ensureWebUi` resolves via `shellPath`
  when the engine file lives outside the repo it's targeting.
