# bin/runplan Dependency Provisioning — Plan

Audience: AI coding agents first. Contract-level plan — each task pins the seam (CLI surface, file, signature, behavior); the implementer writes the body by reading `src/runner.js`, `lib/gates.sh`, and `lib/resolve-seat.sh` directly.

**Source of truth for design + rationale:** `docs/specs/2026-07-02-runplan-worktree-isolation-design.md` § "Dependency provisioning". Do NOT re-derive the design here — read that section. This doc pins only the per-task seams and build order.

**Goal:** a per-task worktree carries no `node_modules`; `gate0` strict runs the repo's own build/test there and fails closed on the resulting environmental red. Add a deterministic dependency-provisioning step so every worktree has deps before the coder and gate run — via a lockfile-keyed immutable cache + copy-on-write materialization, package-manager-agnostic, concurrency-safe, offline-preferring.

**Pattern to match:** provisioning is deterministic and non-LLM → it lives in `lib/provision-deps.sh` (a shell lib like `lib/gates.sh`), invoked from `src/runner.js` via `runCli`, exactly as `gate0` is. NEVER a seat.

**Precondition to running THIS plan:** the repo's own `make test` suite must be green on `main` (strict gate0 blocks on any red). If it is not, run with `gate0_mode: baseline-ratchet` after a `gate0 baseline-ratchet-init` — see the design doc's gate0 modes.

---

### Task 1

Create `lib/provision-deps.sh` — toolchain detection + CLI contract skeleton. NO cache/install logic yet (that is Task 2/3); this task pins the CLI surface every later task and `runner.js` depend on.

Pin the CLI exactly:
- `provision-deps.sh detect <worktree>` → prints one token to stdout: `pnpm` | `npm` | `yarn-classic` | `yarn-berry` | `bun` | `none`. Exit 0. `none` = no recognized toolchain (no lockfile and no `packageManager` field).
- `provision-deps.sh provision <worktree> <repoRoot> <slug>` → materializes deps into `<worktree>` (Task 2/3 fill this). Exit 0 on success; exit **17** (distinct `dep-provision-failed` code) on any provisioning failure; exit 0 no-op when `detect` yields `none`.

Detection ladder (first rung that holds):
1. `packageManager` field in `<worktree>/package.json` (corepack) — authoritative.
2. Lockfile precedence: `pnpm-lock.yaml`→pnpm; `package-lock.json`/`npm-shrinkwrap.json`→npm; `yarn.lock`→yarn (`yarn-berry` if `.yarnrc.yml` or a `yarnPath` present, else `yarn-classic`); `bun.lockb`→bun.
3. `package.json` present but no lockfile → exit 2 with an actionable "no lockfile — cannot provision deterministically" message, UNLESS env `PROVISION_ALLOW_NO_LOCKFILE=1`, then treat as npm non-frozen.
4. No `package.json` → print `none`, exit 0.

`set -uo pipefail`; fail closed on unexpected state (never guess). Files: `lib/provision-deps.sh`.

### Task 2

Cache key + atomic populate in `lib/provision-deps.sh`. Deps: Task 1.

- **Cache key** = `sha256( pmId + "\0" + nodeVersion + "\0" + arch + "\0" + <lockfileBytes of the worktree's own lockfile> )`. Hash the worktree's lockfile at its current HEAD — NEVER the parent repo's. Do NOT fold `package.json` into the key.
- **Cache root** = `<repoRoot>/.runplan-cache/deps/`. NEVER `/tmp` (tmpfs / cross-device breaks reflink+hardlink and gets wiped).
- **Populate once per key, atomically, serialized:** if `<cacheRoot>/<key>/` exists → reuse, no install. Else `flock <cacheRoot>/<key>.lock`, install into `<cacheRoot>/<key>.tmp.$$`, then `mv` (rename) `<key>.tmp.$$` → `<key>/`. Rename is the commit point. After rename, `chmod -R a-w <key>/`.
- **Frozen install command per pmId** (offline-preferring, deterministic): npm `npm ci --prefer-offline --no-audit --no-fund`; yarn-classic `yarn install --frozen-lockfile --prefer-offline`; bun `bun install --frozen-lockfile`. (pnpm / yarn-berry handled in Task 3 via native store — no custom cache entry.) A frozen install erroring on a `package.json`/lock mismatch is the CORRECT failure — propagate it as exit 17, do NOT paper over it.

Files: `lib/provision-deps.sh`.

### Task 3

Materialization + native-store delegation in `lib/provision-deps.sh`. Deps: Task 2.

- **Materialize** the immutable cache entry into `<worktree>/node_modules` by the cheapest isolation-preserving method, probed ONCE (memoize the probe result), first rung that holds: (1) `cp --reflink=auto -a` (reflink/CoW); (2) `cp -al` (hardlink); (3) `cp -a` (plain copy). Probe reflink support with a throwaway file on the cache filesystem; fall through on failure.
- **Native-store delegation** (no custom cache): pnpm → run `pnpm install --frozen-lockfile --prefer-offline` directly in `<worktree>` (its global store IS the cache; worktree `node_modules` is a symlink farm). yarn-berry → `yarn install --immutable` in `<worktree>` (PnP: there may be NO `node_modules` — MUST NOT assume the dir exists). For these two, Task 2's cache key/populate path is skipped entirely.

Files: `lib/provision-deps.sh`.

### Task 4

Wire provisioning into `src/runner.js`. Deps: Task 3.

- Call `provision-deps.sh provision <taskWt> <repoRoot> <slug>` via `runCli`, in `runTask`, immediately AFTER `ensureTaskWorktree` returns `taskWt` and BEFORE the coder dispatch (`dispatchWithFallback` for the implement step). Not at the gate — the coder needs deps to verify its own work.
- Provision the integration worktree `intWt` the same way, once, after it is ensured.
- A non-zero (exit 17) provision result throws a DISTINCT error `dep-provision-failed: <task.id>` — NEVER conflated with `gate-not-green`. Match the throw style of `runGateLoop`.
- Skip provisioning when the task is already fully `done` (mirror the worktree-ensure skip).

Files: `src/runner.js`.

### Task 5

Cache GC + gitignore. Deps: Task 4.

- `provision-deps.sh gc <repoRoot>` (also called opportunistically at run start from `runner.js`): LRU-evict `<cacheRoot>/<key>/` entries by mtime, keeping the newest N (default 5) AND capping total bytes (default 5 GiB) — enforce BOTH bounds. NEVER evict an entry whose key matches a currently-live task/integration worktree. Sweep orphan `<cacheRoot>/*.tmp.*` older than 1h (crash-interrupted installs).
- Add `.runplan-cache/` to `.gitignore` (root).

Files: `lib/provision-deps.sh`, `src/runner.js`, `.gitignore`.

### Task 6

Tests. Deps: Task 5. Extend `test/runner-integration.sh` per the design doc's "Testing" list. Assert: (a) strict-mode gate goes green in a fresh task worktree with NO manual install; (b) same-key install count == 1 across two same-key tasks; (c) a lockfile-mutating task gets a DISTINCT cache entry; (d) a broken install yields `dep-provision-failed`, NOT `gate-not-green`; (e) `<repoRoot>/node_modules` is byte-unchanged after the run (no leak into the real store). Use a fixture repo with a lockfile and a `build`/`test` script whose success depends on an installed dep.

Files: `test/runner-integration.sh`.
