# Competitive Gap #4: Durable Plan mode on top of checkpoint()

> **Verdict: ADAPT, DO NOT VENDOR.** pi-maestro-flow ships a session-scoped, read-only Plan mode with a dedicated Plan model and disk-persisted approval Markdown. We already have the harder half — a first-class, deterministic, journaled `checkpoint()` with AFK-safe default replay. The gap is a thin *plan-then-approve-then-execute* orchestration pattern layered on top, plus an optional Plan-model binding and an approval-history artifact. We build the pattern; we do not port their TUI, their `~/.pi/workspaces/.../plans/` tree, or their tool-gating carousel.

---

## Problem (what we lack, with evidence from the competitive analysis)

pi-maestro-flow advertises **"Durable Plan mode — read-only Markdown draft, approve-before-act, dedicated Plan model"** ([README](https://github.com/catlog22/pi-maestro-flow/blob/master/packages/pi-maestro-flow/README.md)). Concretely they offer:

- A `plan-enter` / `plan-update` / `plan-review` / `plan-confirm` / `plan-exit` tool surface that loads a Markdown draft, blocks mutating tools while planning, and atomically approves before returning to Act mode.
- Plans persisted **outside the project** under `~/.pi/workspaces/<workspace>-<hash>/sessions/<session>-<hash>/plans/current.md` plus `manifest.json` and an `approvals/<timestamp>-<revision>-<checksum>.md` history trail.
- A dedicated **Plan model** selected via `plan.model` in settings, restored to the session model on the next Act turn.
- Tool gating: in Plan mode, BM25 and read-only LSP actions stay available; browser and LSP *mutations* are blocked until confirmation.

Our current state in `pi-dynamic-workflows`:

- We have a first-class **`checkpoint(promptText, options?)`** in `src/workflow.ts` (`workflow.ts:2564-2599`) — a deterministic, journaled, replayable human-decision gate. It is hash-stable (`hashCheckpoint`, `workflow.ts:2833-2840`), counts against `maxAgents`, replays journaled replies on resume without re-prompting (`tests/checkpoint.test.ts:51-76`), and is **AFK-safe**: a background/detached run has no human, so `checkpoint()` replays its journaled default — every consequential checkpoint MUST pass an explicit conservative `default: false` (or `headless: 'abort'`) so an AFK run stops or declines instead of silently approving (`tests/checkpoint.test.ts:87-93`, `AGENTS.md`).
- We have **Issue Delivery** (`src/issue-delivery.ts`) with a Scout → Thinker → Worker → Verifier → PR → finalization pipeline, and a `--prototype` / `dryRun` lane that stops before mutation. The Thinker already *produces a plan* (`issue-delivery.ts:269-288`) but there is **no approval gate between Thinker and Worker** — the plan runs straight into execution unless the user used `--prototype`/`dryRun`.
- We have `agentType` definitions (`src/agent-registry.ts:42-71`) and role/tier routing, so a dedicated Plan model is already expressible as an agentType `model` pin or an `opts.model` override.
- We have `run-persistence.ts` with a `PersistedRunState` / `JournalEntry` surface — a natural home for an approval-history artifact — but no plan/approval record is persisted today.

**The gap:** there is no first-class, reusable *Plan mode* that (a) generates a plan via a planning agent, (b) gates execution on a `checkpoint()` approval with `default:false` for AFK safety, and (c) persists the approved plan + approval decision to the run journal as a durable artifact. Issue Delivery hardcodes the plan-then-execute shape; `--prototype` approximates "plan but don't act" but is a separate lane, not an approval gate. We want the approval gate to be a primitive that *any* workflow (not just Issue Delivery) can use, and we want the approved plan to survive resume so a resumed run executes the *approved* plan, not a freshly regenerated one.

---

## Peer reference (pi-maestro-flow approach, with source URL)

**Source:** <https://github.com/catlog22/pi-maestro-flow/blob/master/packages/pi-maestro-flow/README.md> (section "Durable Plan Mode") and the upstream `prepare/plan.md` contract at <https://github.com/catlog22/maestro-flow/blob/master/prepare/plan.md>.

### What they do (concept, not code)

1. **Plan-then-approve-then-act lifecycle.** A `plan` phase produces a Markdown plan + a task DAG (`plan.json`, `tasks/TASK-NNN.json`, `waves.json`, `dependency-graph.json`). Execution is gated on a `plan-confirmed` exit gate — artifacts register only after user confirmation (`execute`/`modify`/`cancel`). The plan phase is a *separate stage*, not inline planning.
2. **Dedicated Plan model.** `plan.model` selects a `provider/model` for Plan-mode turns; the session model is restored for Act turns. If the configured model is unavailable, Plan mode warns and continues with the session model.
3. **Disk-persisted approval history.** `current.md` + `manifest.json` (carrying `sessionId`/`sessionFile`/`sessionName`) + `approvals/<timestamp>-<revision>-<checksum>.md`. Revisions and approval state are per-chat-session, with a transaction lock and a legacy-path migration story.
4. **Tool gating during planning.** Read-only tools (BM25 discovery, read-only LSP) stay available; browser + LSP mutations are blocked until `plan-confirm`.
5. **Convergence-criteria discipline.** The `plan` contract forbids subjective phrasings ("looks correct", "properly configured") and requires grep-verifiable convergence criteria with exact strings/values — an independent-oracle discipline we already follow for tests.

### What we borrow conceptually

- **The plan-then-approve-then-execute shape** as a reusable primitive, not a hardcoded Issue Delivery path.
- **A dedicated Plan model binding** so planning can run on a higher-quality model while execution stays on cheaper tiers.
- **An approval-history artifact** keyed by a content checksum, so a resumed run can prove it is executing the *same* plan that was approved.
- **The fail-closed posture**: no plan ⇒ no execution; an unapproved plan in an AFK run must not silently proceed.

### What we reject (per #137 adapt-don't-merge)

- **Their TUI / full-screen multiline editor / `Ctrl+S` / `Ctrl+Enter` / `Alt+Shift+P` keybindings** — not our surface; our plan mode is a workflow-script primitive surfaced through `checkpoint()`, not a host editor mode.
- **Their `~/.pi/workspaces/<workspace>-<hash>/sessions/<session>-<hash>/plans/` tree outside the project.** We persist to our existing per-run journal (`run-persistence.ts`), not a parallel session-scoped filesystem layout. We do not want a second source of truth that can drift from the run journal.
- **Their tool-gating carousel / permission-mode cycling / `Shift+Tab` rebinding.** Tool policy in our world is resolved by `tools`/`disallowedTools`/agentType/harness policy (`workflow.ts:539-603`), not a Plan-mode overlay. A Plan-phase agent simply gets a read-only tool set via its agentType.
- **Their 5-tool `plan-enter`/`plan-update`/`plan-review`/`plan-confirm`/`plan-exit` LLM-facing surface.** We expose one workflow primitive (`planApprove`) and let saved workflows compose it; we do not vend their tool set.
- **Their project-trust gating for Plan-model settings.** Out of scope for the first PR; our Plan model is an explicit `opts.model` / agentType pin, not a settings-file discovery problem.

### Second reference: mattpocock/skills — planning toolset

**Source:** <https://github.com/mattpocock/skills> (skill files: `skills/engineering/wayfinder/SKILL.md`, `skills/engineering/to-spec/SKILL.md`, `skills/engineering/to-tickets/SKILL.md`, `skills/engineering/triage/SKILL.md` + `AGENT-BRIEF.md`, `skills/engineering/implement/SKILL.md`, `skills/engineering/grill-with-docs/SKILL.md`). Matt Pocock's daily-use agent skills for "real engineering, not vibe coding" — a composable plan→approve→execute pipeline that works with any model.

**What they do (concept, not code):**

1. **`/wayfinder` — the planning skill.** "Plan a huge chunk of work as a shared map of decision tickets, resolve them one at a time until the way to the destination is clear." The **map** is a single tracker issue (label `wayfinder:map`) that is an **index, not a store** — each decision lives in exactly one place (its child ticket); the map gists + links. **"Plan, don't do" by default**: each ticket resolves a *decision*, and the map is done when the way is clear — nothing left to decide before someone goes and does the thing. The pull to just-do is the signal you've reached the edge of the map and time to hand off.
2. **Fog of war** — a first-class primitive. The map is *deliberately incomplete*; a `Not yet specified` section holds suspected questions you can't yet phrase sharply. **Fog-or-ticket test**: can you state the question *precisely* now (not answer it)? Ticket if sharp; fog if not. Fog graduates into tickets as the frontier advances — never pre-sliced into fake tickets.
3. **HITL vs AFK ticket types.** Every ticket is either human-in-the-loop (worked *with* a human who speaks for themselves — grilling/prototype) or agent-alone (research/task). A HITL ticket only resolves through live exchange; the agent never stands in for the human's side. This is the cleanest expression of the human-gate vs autonomous-work distinction we need.
4. **`/to-tickets` — tracer-bullet vertical slices with blocking edges.** Each slice cuts a narrow but *complete* path through every layer (schema/API/UI/tests), is demoable on its own, and is sized to one fresh context window. **Wide-refactor exception** = expand-contract (add new beside old → migrate in blast-radius-sized batches keeping CI green → delete old) — the exact discipline we already encode for mechanical refactors.
5. **`/triage` + `AGENT-BRIEF.md` — the durable plan-output contract.** The agent brief is the authoritative spec an AFK agent works from. Principles: **durability over precision** (describe interfaces/types/contracts, NOT file paths/line numbers — they go stale), **behavioral not procedural** (describe WHAT not HOW), **complete independently-verifiable acceptance criteria**, **explicit scope boundaries**.
6. **The pipeline:** `/grill-with-docs` (align + build shared language/ADR) → `/to-spec` (synthesize spec, no interview) → `/to-tickets` (tracer-bullet slices w/ blocking edges) → `/triage` (state machine → `ready-for-agent` + agent brief) → `/implement` (build, with `/tdd`) → `/code-review`.

**What we borrow conceptually (refines our adaptation):**

- **The plan as a *map* (index, not store) + decision tickets + fog-of-war.** Our `planApprove(plan)` `plan` payload should be shaped as a wayfinder-style map: decisions-so-far (each in one place), open decision tickets (each a HITL or AFK gate), and a `fog`/`not-yet-specified` list that graduates into tickets as the plan matures. This is a richer, more honest plan artifact than a flat task list.
- **HITL/AFK per decision maps directly onto `checkpoint()`.** A HITL decision ticket = a `checkpoint({ default: false })` gate (the agent never stands in for the human — exactly our AFK-safe semantics); an AFK decision ticket = autonomous agent work under the existing per-agent budget. This *validates* our `checkpoint()`-first design rather than requiring new infra.
- **The agent-brief contract as the plan-output shape.** What an approved plan hands to execution agents: durable (interfaces/contracts, not file paths), behavioral (what not how), complete acceptance criteria, explicit out-of-scope. We adopt this as the documented contract for what `planApprove` returns to downstream `agent()` calls.
- **"Plan, don't do" as the fail-closed posture.** Plan mode produces decisions, not code; the pull-to-do = signal to hand off. Reinforces `planApprove` returning the plan and never executing — execution is a separate, post-approval phase. Matches our fail-closed principle exactly.
- **Refer-by-name** for plan artifacts in logs/herdr/issue bodies — human-readable planning surface.

**What we reject (per #137):**

- **Their issue-tracker-as-plan-store coupling** (`wayfinder:map` + child issues on GitHub/Linear/local). We persist the map to our per-run journal (`run-persistence.ts`), not a parallel tracker-scoped store — same rejection as pi-maestro-flow's session tree. We *can* optionally mirror the map to a GitHub epic (as we already do for epic #172) but the journal is the source of truth.
- **Their `/grilling` + `/domain-modeling` skills as Plan-mode internals.** We already have grilling (`~/.agents/skills/grilling`) and domain-modeling (`~/.agents/skills/domain-modeling`) as available skills; Plan mode *may invoke* them via the planner agent but does not vendor them.
- **Their per-ticket `agents/openai.yaml` model pinning.** Our routing is role-based (`model-tier-config.ts`); a Plan-mode planner uses an explicit `opts.model`/`agentType`, not a per-ticket YAML.

---

## Our adaptation (how it fits OUR deterministic VM / disk journal / checkpoint() / herdr / role-based routing / Foundation fail-closed)

### Core primitive: `planApprove(plan, options)` layered on `checkpoint()`

We add **one** workflow global, `planApprove(plan, options)`, that composes the primitives we already have:

```ts
// New global exposed to workflow scripts (alongside checkpoint())
export interface PlanApproveOptions {
  /** Human-facing prompt for the approval checkpoint. Default: "Approve this plan?" */
  prompt?: string;
  /** AFK-safe default. MUST default to false (fail-closed) — never true. */
  default?: boolean;            // default: false
  /** Headless behavior. Default "default" (take `default`); "abort" to stop an AFK run. */
  headless?: "default" | "abort";
  /** Optional dedicated Plan model to regenerate/validate the plan with (overrides tier). */
  planModel?: string;
  /** Optional agentType used to (re)produce the plan, e.g. "issue-thinker". */
  planAgentType?: string;
}

export interface ApprovedPlan<T = unknown> {
  /** The plan object that was approved (verbatim, frozen). */
  plan: T;
  /** sha256 of the canonical plan JSON — the approval is bound to this checksum. */
  checksum: string;
  /** The approval decision from checkpoint(): true=approved, false/declined, string=modify instructions. */
  decision: boolean | string;
  /** Whether the approval was replayed from the journal on resume (vs. freshly asked). */
  replayed: boolean;
  /** ISO timestamp of the approval (for the history artifact). */
  approvedAt?: string;
}
declare function planApprove<T = unknown>(plan: T, options?: PlanApproveOptions): Promise<ApprovedPlan<T>>;
```

**Semantics:**

1. `planApprove` computes `checksum = sha256(canonicalJSON(plan))`.
2. It calls the existing `checkpoint(prompt, { default: false, headless, kind: "confirm" })` — so AFK runs replay the journaled default (`false` ⇒ decline ⇒ the workflow must stop, never silently proceed).
3. The `(plan checksum, decision, approvedAt)` triple is journaled through the existing `onAgentJournal` path (checkpoint already journals via `options.onAgentJournal?.({ index, hash, result })` at `workflow.ts:2586-2596`). We extend `JournalEntry` (`workflow.ts:110-133`) with an optional `planApproval?: { checksum; decision; approvedAt }` field so the approval is a durable artifact in `PersistedRunState`.
4. On **resume**, `planApprove` replays the journaled `(checksum, decision)` *and* asserts the caller's plan checksum matches the journaled one. A mismatch (plan drifted between runs) invalidates the journal entry (same `hash !== callHash` path checkpoint already uses at `workflow.ts:2575-2580`) and forces a fresh approval — so a resumed run executes the *approved* plan, never a silently-regenerated one.
5. `decision === false` or `headless === "abort"` ⇒ the workflow must stop. `planApprove` returns the declined `ApprovedPlan`; the workflow author is responsible for `return`ing (we document this and enforce it in the Issue Delivery integration). `decision` may be a string ("modify X") — the workflow can feed it back to the planner.

### Plan model binding

A dedicated Plan model is **already expressible** without new infra: the planner agent is invoked with `opts.model` or an `agentType` whose `.model` pins the Plan model. `planApprove` does not *run* the planner — it only approves the plan the planner already produced. For the *validate-the-plan-with-a-different-model* use case (peer's "Plan model reviews the plan"), we add an optional `planModel`/`planAgentType` on `PlanApproveOptions` that, if set, spawns a read-only validation agent on that model before the checkpoint. Default: unset (no extra model spend). This keeps role-based routing (`model-tier-config.ts`, `model-routing.ts`) authoritative — we do not introduce a parallel `plan.model` settings discovery path.

### Approval-history persistence

We do **not** create a `~/.pi/workspaces/.../plans/` tree. The approval history lives in the existing per-run journal:

- `JournalEntry.planApproval` (new optional field) carries `{ checksum, decision, approvedAt }`.
- `run-persistence.ts` already serializes `JournalEntry[]` into `PersistedRunState` and rehydrates via `hydrateAgentHistoryFromJournal` (`run-persistence.ts:469-506`). The new field rides this path with no new filesystem layout.
- An operator-debug artifact (a human-readable `plan-<checksum>.md` next to the run transcript) is optional and advisory only — the journal entry is the source of truth, per `AGENTS.md` ("Persisted run state and subagent transcripts are operator-debug artifacts").

### Interaction with Issue Delivery (plan-then-deliver)

Issue Delivery currently goes Scout → Thinker (plan) → Worker (execute) with no approval gate. The integration is a *one-line* insertion after the Thinker phase (`issue-delivery.ts:288-292`):

```js
// after: const plan = await agent(... THINKER_SCHEMA ...)
const approval = await planApprove(plan, {
  prompt: 'Approve this execution plan for: "' + TASK_CONTEXT + '"?',
  default: false,            // AFK-safe: a background run declines and stops
  planAgentType: 'issue-thinker',  // optional: re-validate on the same tier
})
if (approval.decision !== true) {
  log('[IssueDelivery] Plan not approved (' + String(approval.decision) + '). Stopping before mutation.')
  setSemanticStatus({ status: 'needs-human', reason: 'Plan was not approved.', nextAction: 'Modify the task and rerun, or approve interactively.' })
  return { success: false, stoppedBy: 'plan-approval', plan, approval }
}
// proceed to Worker phase, executing the *approved* plan
```

`--prototype`/`dryRun` stays as-is: it is the "plan + checks + review, stop before mutation" lane. `planApprove` is orthogonal — it is the approval gate for the *real* lane. A `dryRun` run can still call `planApprove` to record that the user saw the plan (useful audit), but it stops regardless.

### Prototype mode (dryRun) relationship

- `dryRun=true` ⇒ no mutation regardless of approval. `planApprove` is still useful to record the plan checksum and an approval decision for audit, but the stop condition is the `dryRun` flag, not the approval.
- `dryRun=false` (real lane) ⇒ `planApprove` is the gate. `default:false` means a background/AFK run declines and returns `{ success: false, stoppedBy: 'plan-approval' }` rather than silently shipping — matching the Foundation fail-closed posture and the merge-gate discipline in `AGENTS.md`.

### Plan artifact shape (wayfinder-influenced)

The `plan` payload passed to `planApprove` SHOULD follow the wayfinder map shape (mattpocock reference). We document (not enforce in v1) a recommended structure:

```ts
interface PlanMap {
  destination: string;            // what reaching the end looks like — orients every session
  notes?: string;                 // domain; skills to consult; standing preferences
  decisionsSoFar: PlanDecision[];  // each decision lives in ONE place; the map gists + links
  openTickets: PlanTicket[];       // each is HITL (checkpoint gate) or AFK (autonomous)
  notYetSpecified?: string[];      // fog-of-war: suspected questions too dim to ticket now
  outOfScope?: string[];          // ruled beyond the destination; never graduates
}
interface PlanTicket { id: string; title: string; type: 'research'|'prototype'|'grilling'|'task'; hitl: boolean; blockedBy: string[]; }
```

`notYetSpecified` is the **fog-of-war** primitive: track what you can't yet phrase sharply without faking it as tickets. As the plan matures, fog graduates into `openTickets`. This is a first-class honesty primitive we adopt from mattpocock — it prevents the failure mode of pre-slicing the unknown into fake tasks. v1 may accept a free-form `plan` and only *recommend* this shape; a later PR can enforce it.

### Foundation / herdr / role routing alignment

- **Foundation fail-closed:** `planApprove` defaults `default:false` and treats any unapproved/declined plan as a hard stop. No path through `planApprove` can produce an "approved" result without an explicit human `true` (foreground) or a journaled `true` replay (resume of a previously-approved run).
- **Role-based routing:** the planner agent keeps using `tier`/`agentType`; `planModel` is an *optional* override, not a new routing tier. We do not weaken `enforceRoutingPolicy` (`workflow.ts:613-633`) or the google-ai-studio billing opt-in.
- **herdr:** the approval is a deterministic, journaled, replayable event — exactly the herdr model. The checksum binding adds a content-addressed invariant on top of the existing call-sequence journal.
- **Determinism:** `planApprove` spends no tokens (it delegates the human decision to `checkpoint()`, which spends none — `workflow-telemetry-report.ts:609-612` already notes checkpoint spends no tokens). The optional `planModel` validation agent is the only token-spending path and is opt-in.

---

## Scope (in-scope for the first PR)

1. **New workflow global `planApprove(plan, options)`** in `src/workflow.ts`, layered on `checkpoint()`:
   - Compute `checksum = sha256(canonicalJSON(plan))`.
   - Call `checkpoint(prompt, { default: false, headless, kind: "confirm" })`.
   - Return `ApprovedPlan { plan, checksum, decision, replayed, approvedAt }`.
   - On resume: assert journaled checksum matches caller's plan; mismatch ⇒ invalidate journal entry, force fresh approval.
2. **`JournalEntry.planApproval`** optional field + serialization in `run-persistence.ts` (rides existing `hydrateAgentHistoryFromJournal`).
3. **`hashCheckpoint` extension** (or a sibling `hashPlanApproval`) so the plan checksum is part of the journaled identity — a plan change invalidates the replay, exactly like a prompt change invalidates a checkpoint replay.
4. **Issue Delivery integration:** insert `planApprove` between Thinker and Worker in `src/issue-delivery.ts`, with `default:false` and a clean `stoppedBy: 'plan-approval'` return.
5. **Tool guideline update** in `src/workflow-tool.ts` so the LLM knows `planApprove` exists and that it is AFK-safe/fail-closed.
6. **Tests** (see Test plan).

---

## Non-goals (what NOT to build; per #137 adapt-don't-merge)

- **No host-level Plan *mode*** (no `plan-enter`/`plan-exit`, no full-screen editor, no `Alt+Shift+P`, no `Shift+Tab` rebinding). Our plan mode is a workflow-script primitive, not a host editor mode.
- **No `~/.pi/workspaces/.../plans/` tree**, no `current.md`/`manifest.json`/`approvals/*.md` filesystem layout. The run journal is the source of truth.
- **No `plan.model` settings discovery** (user/project/local settings merge with trust gating). The Plan model is an explicit `opts.model`/`agentType` pin or a `planApprove({ planModel })` opt-in.
- **No tool-gating carousel / permission-mode cycling.** Tool policy stays on `tools`/`disallowedTools`/agentType/harness.
- **No porting of their `plan.json`/`TASK-NNN.json`/`waves.json` schema.** We use whatever structured output the planner already produces (e.g. Issue Delivery's `THINKER_SCHEMA`); `planApprove` is schema-agnostic (`plan: T`).
- **No new slash command for the first PR.** A `/plan`-style command is a follow-up; the first PR ships the primitive + Issue Delivery integration.
- **No BM25/LSP tool gating tied to plan state.** Out of scope; a Plan-phase agent gets read-only tools via its agentType, which already works.

---

## File-touch list (exact files/symbols that change, with the role of each)

| File | Symbol / region | Role of change |
|------|-----------------|----------------|
| `src/workflow.ts` | new `planApprove` global (near `checkpoint` at 2564-2599) | The primitive: checksum + checkpoint + journal, exposed to scripts. |
| `src/workflow.ts` | `CheckpointOptions` / `JournalEntry` (110-133, 464-475) | Add `planApproval` to `JournalEntry`; add optional `planChecksum` to the checkpoint hash identity so a plan change invalidates replay. |
| `src/workflow.ts` | `hashCheckpoint` (2833-2840) or new `hashPlanApproval` | Include plan checksum in the journaled identity (content-addressed approval). |
| `src/workflow.ts` | globals object exposed to script (the `agent`, `checkpoint`, … map near 2613) | Register `planApprove`. |
| `src/run-persistence.ts` | `PersistedAgentState` (51-70) / `hydrateAgentHistoryFromJournal` (469-506) | Serialize/rehydrate `planApproval` on journal entries. |
| `src/issue-delivery.ts` | Thinker→Worker boundary (288-318) | Insert `planApprove` with `default:false`; add `stoppedBy: 'plan-approval'` return. |
| `src/workflow-tool.ts` | tool guideline string (287-340) | Document `planApprove` and its AFK-safe/fail-closed contract. |
| `tests/checkpoint.test.ts` or new `tests/plan-approve.test.ts` | — | New tests for `planApprove` (see Test plan). |
| `docs/workflows/catalog.md` | — | Note `planApprove` as a workflow primitive. |

No changes to `model-routing.ts`, `model-tier-config.ts`, `agent-registry.ts`, `prototype-safety.ts`, or the conductor finalization path — by design.

---

## Test plan (tautological-test warning: expected values from an independent oracle, not recomputed the same way)

> Independent oracle rule: the expected checksum/decision must come from a hand-computed or spec-fixed source, never recomputed by the same code path under test.

1. **`planApprove` declines headlessly by default (AFK-safe).** Run a script with `planApprove({}, {})` headless (no `confirm`). Expected: `decision === false`, `result.success === false` when the script returns on decline. Oracle: the `default:false` is a spec constant, not computed.
2. **`planApprove` approves when a human confirms (foreground).** Thread `confirm: async () => true`. Expected: `decision === true`, `checksum` equals a **precomputed** sha256 of canonical JSON (compute the expected hex in the test with an independent `node:crypto` call over a frozen literal, NOT via `planApprove`).
3. **Resume replays the approval and rejects a drifted plan.** Journal an approval for plan `P1` with checksum `C1`. Resume with the same script but pass plan `P2` (different literal). Expected: `replayed === false`, a fresh checkpoint is required, and the journaled `C1` is NOT reused. Oracle: `C1` and `C2` are hand-written literals.
4. **Resume replays an unchanged plan without re-prompting.** Journal approval for `P1`; resume with `P1`. Expected: `replayed === true`, `confirm` not called, `decision === true` (journaled). Mirrors the existing checkpoint resume test (`tests/checkpoint.test.ts:51-76`).
5. **`headless: 'abort'` throws on an unapproved plan.** `planApprove({}, { headless: 'abort' })` headless ⇒ throws `/human input|headless/i`, matching `tests/checkpoint.test.ts:28-33`.
6. **`planApprove` counts against `maxAgents`.** It delegates to `checkpoint()`, which already counts (`tests/checkpoint.test.ts:78-85`); assert the same bound applies.
7. **Issue Delivery integration stops on declined plan.** Run Issue Delivery with a `confirm` that returns `false` at the plan checkpoint. Expected: `result.success === false`, `result.stoppedBy === 'plan-approval'`, and **no** Worker-phase agent calls (assert the mock agent's call log has only Scout + Thinker, no Worker). Oracle: the expected agent-call sequence is a hand-written array.
8. **Journal carries `planApproval`.** After an approved run, inspect the `onAgentJournal` entries; assert exactly one entry has `planApproval.checksum === <precomputed>` and `planApproval.decision === true`. Oracle: precomputed checksum literal.

Run: `npm run test:unit -- tests/plan-approve.test.ts` then the full gate `npm test` before merge.

---

## Risks + guardrails (fail-closed, budget bounds, determinism, no silent shipping)

- **Silent approval in AFK runs (P0 risk).** Guardrail: `default` is `false` unless the caller explicitly overrides — and `AGENTS.md` already forbids `default: true` for consequential gates. The Issue Delivery integration hardcodes `default:false`. A declined plan returns `stoppedBy: 'plan-approval'`; the workflow must not continue. Test #1 and #7 enforce this.
- **Resume executes a different plan than was approved (P0 risk).** Guardrail: the plan checksum is part of the journaled identity; a mismatch invalidates the replay and forces a fresh approval (test #3). This is the content-addressed invariant the peer gets from `approvals/<checksum>.md` — we get it from the journal hash.
- **Plan-model spend surprise (P2 risk).** Guardrail: `planModel`/`planAgentType` validation is opt-in (default unset ⇒ no extra agent call). `planApprove` itself spends zero tokens (it calls `checkpoint()`, which spends none). Document this in the tool guideline.
- **Determinism / herdr.** `planApprove` is fully deterministic: same `(plan, prompt, default, headless)` ⇒ same `checksum` ⇒ same journal identity ⇒ same replay behavior. No `Date.now()`/`Math.random()` (workflow scripts already forbid them). `approvedAt` is the only non-deterministic field and is advisory-only (not part of the hash).
- **Scope creep toward a host Plan mode.** Guardrail: the first PR ships only the primitive + Issue Delivery integration. No slash command, no TUI, no settings discovery. Non-goals section enforces this.
- **Journal bloat.** `planApproval` is one small object per approval (checksum + decision + timestamp). It rides the existing journal compaction/retention path (`compactStateForSave`, `run-persistence.ts:519-564`); no new retention policy needed.
- **Merge-gate discipline.** Per `AGENTS.md`: closed_loop Verifier stage passed + CI green (`test`, `pi-compatibility` floor+latest, `package-smoke`) + conductor diff review of the safety-critical path (the `default:false` and checksum-mismatch logic) + Codex recheck workflow. Do not merge on CI-green alone.
