# Extending pleach: adapters and contracts

pleach is the deterministic conductor between a feature ledger and an execution boundary. It
takes a validated Plan (a DAG of nodes), isolates each node in a detached git worktree merged
from its dependencies' verified branches, enforces gates (conflict-marker scan, smoke command,
cross-provider audit), and publishes a `node/<id>` branch only for verified work.

To pair pleach with your own tools, you implement at most **two small adapters**. The
isolation layer, gate logic, git worktree lifecycle, and commit-before-emit invariant are
pleach's own code. They are not pluggable and do not change. The integration surface is:
one data contract (the plan) plus two code seams (runner, ledger).

---

## The three integration points

| Role | What it does | How it integrates | pleach calls it? |
|---|---|---|---|
| **Plan source** (planner) | Emits the DAG of work pleach will run | Data contract — a `plan.json` file | No — you produce it; pleach reads it |
| **Runner** (executor) | Spawns and drives an interactive agent per node | `RunnerSeam` interface — runtime injection | Yes — once per node (plus once per audit node) |
| **Ledger** (verifier) | Tracks closed nodes; decides whether a `done` verdict is verified | `LedgerSeam` interface — runtime injection | Yes — `readClosed` at startup; `emitVerdict` per node |

---

### Plan source

Emit a `plan.json` whose schema conforms to `@agent-contract/plan`.
The canonical specification lives in [`docs/contract/plan-schema.md`](contract/plan-schema.md).

Two commands let you check conformance without running the loop:

```
pleach validate <plan.json>   # validates and prints precise typed errors; exit 2 on an invalid plan
pleach schema                 # emits the JSON Schema — feed it to an LLM or a codegen tool
```

The planner is completely decoupled. tend's polyglot exporter is one implementation; an LLM
that emits a plan over a set of GitHub issues is another; a hand-authored JSON file is a third.
pleach never calls the planner.

Key fields to note from `src/core/plan.ts`:

```ts
// Plan — the top-level input
const Plan = z.object({
  goal: z.string(),
  source: z.string(),          // the tend polyglot — the DURABLE home; ledger key
  maxConcurrency: z.number().int().positive().optional(),
  nodes: z.array(Node),
});

// Node — one unit of work
const Node = z.object({
  id: z.string(),              // dot-separated, git-safe, shell-safe
  worker: z.object({
    provider: z.string().optional(),
    model: z.string().optional(),
    cwd: z.string().optional(),
  }).default({}),
  work: Work,                  // prompt | test-cycle | command
  setup: z.string().optional(),
  needs: z.array(z.string()).default([]),
  accept: z.object({
    smoke: z.string().optional(),
    audit: z.object({
      command: z.string(),     // e.g. `tend audit <feature>`
      provider: z.string(),    // MUST differ from worker.provider
    }).optional(),
  }).default({}),
  policy: z.object({
    maxAttempts: z.number().int().default(2),
    timeoutMs: z.number().int().positive().optional(),
    onDead: z.enum(['resume', 'fail']).default('resume'),
    // ...
  }).prefault({}),
  closes: z.array(z.string()).default([]),  // ledger metadata; loop does not consume
});
```

`plan.source` is the durable key the ledger uses to scope `readClosed` and `emitVerdict`
calls. For tend-backed plans it is the feature polyglot path; for other planners it can be
any stable string that identifies the plan's origin.

`node.worker.provider` selects the runner's provider per node, making plans portable
across tool configurations: the same plan runs whether the config wires umbel or another
runner.

---

### Runner adapter (`RunnerSeam`)

The full interface, from `src/loop/deps.ts`:

```ts
export interface RunnerSeam {
  spawnWorker(spec: {
    provider?: string;
    model?: string;
    cwd: string;
    env?: Record<string, string>;
  }): Promise<Worker>;
  // The provider names this runner can spawn. The face hands them (plus the
  // config's cast names) to validatePlan so a typo is refused before any spawn.
  // Absent means the runner accepts any provider name (the scripted runner).
  providers?: readonly string[];
  // Reap a session this runner spawned (issue #115): 'killed' when the reap
  // ended a live session, 'gone' when the session no longer exists. Runners
  // that cannot name or kill a session leave this out; `pleach clean` then
  // says so rather than guessing.
  reap?(session: string): Promise<'killed' | 'gone'>;
}

export interface Worker {
  send(text: string): Promise<void>;
  wait(opts?: { timeoutMs?: number }): Promise<WorkerResult>;
  kill(): Promise<void>;
  // The runner's own name for the session, when it has one (umbel sets it to
  // `pl-…`); the loop journals it as `worker-spawned`/`worker-killed` so
  // `pleach clean` can reap what a crash left alive (issue #115).
  session?: string;
}

export interface WorkerResult {
  // Read UNTRUNCATED — audit-result egress depends on it (ledger C4).
  finalMessage: string;
  actions?: unknown;
  diff?: string;
  filesTouched: string[];
  exitCode?: number;
  reason?:
    | 'stop'
    | 'file'
    | 'pattern'
    | 'provider-error'
    | 'idle'
    | 'timeout'
    | 'dead'
    | 'input'
    | 'aborted';
  // What the runner observed when the reason is not stop — the blocking prompt of
  // input/idle (carried into Verdict.evidence.blockedReason), a provider error's text.
  message?: string;
  telemetry: { tokens?: number; contextPct?: number; compacted?: boolean };
}
```

**What "umbel-like" means.** The `RunnerSeam` contract describes an interactive agent you
drive via a send→wait→kill cycle:

1. `spawnWorker` starts an agent session in the given `cwd` (which pleach has already
   prepared as an isolated git worktree).
2. `send` delivers one prompt to the agent.
3. `wait` blocks until the agent produces a terminal event, then returns a `WorkerResult`.
4. `kill` tears down the session. pleach always calls `kill` after `wait` returns,
   regardless of outcome.

The `reason` field names how the wait ended, and its values are the nine reasons of the runner
contract, `contracts/runner.md` on jahala/plotplot. umbel's `wait --json` prints
`{"reason": …, "message"?: …}` and exits non-zero for every reason but stop, file and pattern; the
umbel adapter reads the reason from that JSON whatever the exit code, and throws `WorkerSeamError`,
naming the exit code and the output, only when no reason parses. pleach classifies each reason as the
contract does (`classifyWorkerReason` in `src/core/classify.ts`; a vendored copy of the contract's
fixture pins the table in `test/unit/runner-contract.test.ts`):

| `reason` | umbel exit | class | pleach's action |
|---|---|---|---|
| `stop` | 0 | terminal | The worker says it is done: read `finalMessage`, stage files, run gates |
| `file` | 0 | terminal | Settles failed with `gate.ran` `wait:file` — pleach waits on the stop hook, so this end is not its own |
| `pattern` | 0 | terminal | Settles failed with `gate.ran` `wait:pattern`, as `file` does |
| `provider-error` | 122 | retryable | Re-run inside the same attempt after a backoff (`--provider-backoff-ms`, 15 s doubling, five waits), same tree, the provider's text as evidence; once the waits are spent the attempt counts, and after the last attempt the node fails with `gate.ran` `wait:provider-error` and the cause in the verdict's detail (D34) |
| `idle` | 123 | blocked | Verdict `status:'blocked'`, the tree kept in quarantine — the operator re-runs |
| `timeout` | 124 | retryable | Same-tree retry with evidence while attempts remain, then failed with `gate.ran` `wait:timeout` |
| `dead` | 125 | dead | Retry on the fallback provider or fail per `onDead` policy |
| `input` | 126 | blocked | Verdict `status:'blocked'` with the prompt text as `blockedReason`, the tree kept in quarantine — the operator fixes the permission mode and re-runs |
| `aborted` | 130 | terminal | The wait was interrupted: the node settles `aborted`, its tree kept in quarantine |

A reason the contract does not name falls to terminal and is journaled as `seam-violation` with its
text. Any other surprise from a seam settles the node failed with its tree kept (see the `verdict` row
of [`docs/journal.md`](journal.md)).

A **spawn** is different from a wait. A `spawnWorker` that cannot start its session throws
`WorkerSpawnError`, carrying the spawn's exit code. One the runner refuses because the provider's CLI
needs a person to sign in exits **126** (`contracts/runner.md`: "a spawn that needs a person is
blocked, and says so by its exit code"). pleach settles that node `blocked` at once with umbel's
message and "sign in to `<provider>` once, outside the conductor, then re-run" as the next step, the
tree kept in quarantine — it is never retried on that provider, because a retry meets the same
sign-in screen. `--fallback-provider` re-casts it once, audit diversity re-checked, exactly as it
re-casts a `dead` attempt. Any other non-zero spawn keeps today's behaviour: the node settles failed.

**Runner post-condition (verified from `src/loop/run-node.ts:191–194`).** When `wait()`
returns `reason: 'stop'`, the runner is expected to have populated the working tree with
the agent's output. It does NOT need to stage or commit anything. pleach calls
`isolate.changedFiles(cwd)` to enumerate all tracked modifications and untracked-unignored
files in the worktree, unions that with `result.filesTouched` from the worker manifest, and
calls `isolate.stage(cwd, stagedFiles)` itself. The commit to `node/<id>` is entirely
pleach's.

```ts
// src/loop/run-node.ts:191-194
const changed = await deps.isolate.changedFiles(cwd);
const stagedFiles = dedup([...result.filesTouched, ...changed]);
await deps.isolate.stage(cwd, stagedFiles);
```

`result.filesTouched` is the hint the adapter provides (e.g. from `umbel actions --json`);
`changedFiles` is pleach's own fallback scan. Either path works: if your adapter cannot
enumerate touched files precisely, return `filesTouched: []` and pleach's git status scan
covers the rest. If your agent is self-committing (e.g. aider), the adapter must normalize
its output to this post-condition: **undo any commits the agent made and leave the changes
staged or unstaged in the working tree** before `wait()` resolves, so pleach's staging
and `commitBranch` call produce the canonical `node/<id>` commit.

---

### Ledger adapter (`LedgerSeam`)

The full interface, from `src/loop/deps.ts`:

```ts
export interface LedgerSeam {
  // id → verified commit SHA; null when the ledger has no SHA recorded.
  // The SHA is the durable resume base (ledger B1).
  readClosed(source: string): Promise<Map<string, string | null>>;
  emitVerdict(v: Verdict, source: string): Promise<{ closed: boolean }>;
}
```

`readClosed` is called once at plan startup (before any node runs) and returns all previously
verified node ids for this plan source. The SHA value, when present, is the commit pleach
will use as the base ref for dependent nodes if the `node/<id>` branch has been GC'd or is
unavailable. Returning `null` for an id is valid (legacy / external verification with no
SHA recorded); pleach falls back to searching the git refstore.

`emitVerdict` is called after each node reaches a terminal state. It must decide
**synchronously** (no blocking on a human). Return `{ closed: true }` to mark the node
verified; `{ closed: false }` to decline (the node's branch is still published, but
dependents are skipped and the run summary records it as `partial`). For non-audit nodes,
pleach ignores `emitVerdict`'s return value and closes unconditionally (`run-plan.ts:230`):
`const shouldClose = node.accept.audit ? decision.closed : true;`, so your ledger's
`closed` flag is only consequential for nodes that carry an `accept.audit` block.

`gitLedger` is the trivial reference: `readClosed` lists `node/*` branches in the local
repo, **scoped to the plan source**: pleach writes `source: <plan.source>` into every
node commit, and only branches carrying that exact line count as this plan's verified
work (two plans sharing a repo cannot cross-resume; a hand-made `node/*` branch is never
trusted). `emitVerdict` returns `{ closed: verdict.status === 'done' }`. It is entirely
local and requires no external service.

---

## The config model

`src/faces/config.ts` exports:

```ts
export interface PleachConfig {
  runner: RunnerSeam;
  ledger: LedgerSeam | Promise<LedgerSeam>;
  casts?: Record<
    string,
    {
      provider: string;
      model?: string;
      env?: Record<string, string | { fromEnv: string }>;
    }
  >;
}
```

`resolveSeams` resolves a runner and ledger in this precedence order:

1. **`--config <path>`.** If given, loads exactly that file (and errors if it is absent).
2. **`pleach.config.ts` at the repo root.** Used when no `--config` is given. If present, its
   default export must be a `PleachConfig`; both `runner` and `ledger` must be non-null objects,
   else `ConfigError`.
3. **Zero-config default.** Neither of the above present: `umbelRunner` + either `tendLedger`
   (when `--tend-module` is supplied) or `gitLedger`. The `--umbel-bin` and `--permission-mode`
   flags thread into the default runner.

The config imports `pleach/*`, so pleach must resolve from the repo it lives in.
`bunx github:jahala/pleach` puts pleach in bun's cache, not in your `node_modules`, so
those imports answer `Cannot find module`. To make `pleach/*` resolve, install pleach as
a dependency first:

```sh
bun add -d github:jahala/pleach
```

A minimal `pleach.config.ts` using the bundled adapters:

```ts
import { umbelRunner } from 'pleach/adapters/umbel';
import { gitLedger } from 'pleach/adapters/git';
import type { PleachConfig } from 'pleach/config';

export default {
  runner: umbelRunner({ bin: 'umbel', permissionMode: 'bypassPermissions' }),
  ledger: gitLedger({ repo: '.' }),
} satisfies PleachConfig;
```

Swapping in a different runner (e.g. one backed by the Anthropic API directly rather than
umbel/tmux) requires only changing the `runner` line. The plan does not change: `worker.provider`
names the provider the plan expects, and the config decides how that provider is reached.

**Plan vs. config separation.** `worker.provider` in the plan is intent; the config is
tooling. A plan that says `provider: 'codex'` runs under any runner that can dispatch to
codex: umbel, a hypothetical cloud runner, or a test double. Plans stay portable across
environments; configs are environment-specific.

### Named casts — two endpoints of one CLI

A node's `worker.provider` is a free string, so a config can declare **casts**: a name the
plan uses that the config resolves to a provider, model and environment. Two casts can reach
two endpoints of one provider — the plan names the cast, the config decides how it is reached.
The loop does not change: it passes the plan's name, and the face layer rewrites the spawn to
the cast's `provider`, `model` (the plan's own model wins over the cast's) and `env`.

```ts
import { umbelRunner } from 'pleach/adapters/umbel';
import { gitLedger } from 'pleach/adapters/git';
import type { PleachConfig } from 'pleach/config';

export default {
  runner: umbelRunner({ bin: 'umbel', permissionMode: 'bypassPermissions' }),
  ledger: gitLedger({ repo: '.' }),
  casts: {
    // Outline and audit nodes on Claude Opus, by the subscription.
    opus: { provider: 'claude', model: 'claude-opus-4-5' },
    // Test and fill nodes on DeepSeek, through the Anthropic-compatible endpoint.
    deepseek: {
      provider: 'claude',
      env: {
        ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic',
        ANTHROPIC_AUTH_TOKEN: { fromEnv: 'DEEPSEEK_API_KEY' },
        ANTHROPIC_MODEL: 'deepseek-chat',
      },
    },
  },
} satisfies PleachConfig;
```

An `env` value is a literal string, or `{ fromEnv: '<NAME>' }` to read `NAME` from the
environment at spawn. A missing variable is a typed error naming the cast and the variable —
never an empty value. Env values pass to the worker **by name only**: the umbel adapter runs
`--env KEY` (the bare-name form) and puts the value in the spawn process's own environment,
never `KEY=VALUE` on the command line where `ps` would show it.

The runner's `providers` plus the cast names are the known set `pleach validate` and
`pleach run` refuse a typo against: a node (or audit) naming neither is refused with the
known names listed, before any spawn. A runner that names no `providers` (the scripted
runner) refuses nothing.

---

## The cross-provider audit egress

When a node carries an `accept.audit` block, pleach spawns a second agent using
`audit.provider` (which must differ from `worker.provider`, because provider diversity is a
preflight invariant enforced before any spawn). The auditor is sent a prompt constructed by
`buildAuditPrompt` (from `src/core/audit-egress.ts`) that wraps `audit.command` with
machine-readable extraction instructions.

The auditor must reproduce the command's output as a fenced block in its reply message:

````
```tend-audit-result
{ ... }
```
````

The fence label is `tend-audit-result` (constant `FENCE_LABEL` in `src/core/audit-egress.ts`,
line 9). This label is pleach's egress contract; the name is historically tend-specific but
applies to any auditor. `extractAuditJson` finds the **last** block so labelled in
`finalMessage` (read untruncated, ledger C4) and parses it. If no such block is present or
the content is not valid JSON, pleach re-runs the auditor up to `REAUDIT_BUDGET` (2) times
before failing the node.

**Gate integrity (ledger SEC4).** The auditor works in the tree the builder wrote, which
makes the worktree a collusion channel. Two defenses: audit commands should live outside
worker-writable paths (the `tend audit` pattern). For repo-local commands, pleach
refuses to spawn the auditor when any file named in `audit.command` was touched by the
builder (retryable with revert evidence, terminal at `maxAttempts`). The audit prompt also
instructs the auditor to treat repository content as untrusted data and run only the given
command.

The parsed JSON must match `AuditResult` from `src/core/plan.ts`:

```ts
const AuditResult = z.object({
  verdicts: z.array(z.object({
    check: z.string(),
    verdict: z.enum(['pass', 'partial', 'fail']),
    negctrl: z.object({ ran: z.boolean(), discriminated: z.boolean() }).optional(),
    evidencePath: z.string().optional(),
    evidenceSha: z.string().optional(),
    validatesJobSatisfied: z.boolean().nullable().optional(),
    reasons: z.array(z.string()).default([]),
  })),
  drift: z.array(z.object({
    finding: z.string(),
    file: z.string().optional(),
    action: z.enum(['implement', 'investigate', 'document']).optional(),
  })).default([]),
});
```

The tend-specific fields (`negctrl`, `evidencePath`, `evidenceSha`, `validatesJobSatisfied`)
are all `.optional()`. A non-tend auditor emits only the required minimum:

```json
{
  "verdicts": [
    { "check": "my-check-id", "verdict": "pass", "reasons": ["all assertions green"] }
  ]
}
```

A `verdict: 'fail'` on any check triggers a retryable re-prompt of the build worker with
the failing `reasons[]` as evidence, up to `maxAttempts`. A `verdict: 'partial'` is
pleach's honest-middle: the audit passed but the ledger (e.g. tend) declined to
verify-close. The node's branch is still published; its dependents are skipped.

**For audit-command authors: verdicts carry reasons, and reasons should name the
falsifying evidence.** The `reasons[]` you emit are the exact text the build worker sees
on its retry. "check failed" teaches it nothing; "expected `parseDate('2026-02-30')` to
return an error, got a Date" names the case to fix. Design each check so its failing case
is constructible (run it against the pre-work tree once: if it passes there, it proves
nothing), and on failure, put that discriminating case in the reason. Verdicts also land
verbatim in the node's close receipt, so a reason is permanent evidence, not transient
chat.

**The command must print a `tend-audit-result` block.** `buildAuditPrompt` tells the
auditor the command prints a fenced `tend-audit-result` block and asks it to relay that
block verbatim. If the command prints none, the auditor relays prose and the egress fails
(`audit-egress-unparseable`) even though the command exited 0 — a full build plus an audit
lost. An ordinary check (`npx pnpm check`) prints no such block, so wrap it:

```
pleach audit-wrap [--check <id>] -- <your check>
```

`pleach audit-wrap` runs the command argv-style (no shell), captures its output, and
prints exactly one fenced `tend-audit-result` block holding a valid `AuditResult`: one
verdict whose `check` is `--check` (or the command text), `verdict` is `pass` on exit 0 and
`fail` otherwise, and `reasons` are the exit code plus the last 20 lines of output. It
exits 0 when it printed a block — the verdict is in the block, and the egress reads the
block, not the exit code.

The auditor runs the audit command in its own shell, so `pleach` must be on its PATH
(`bun link` in a pleach checkout puts it there). Without that, write the command as
`bunx github:jahala/pleach audit-wrap -- <your check>`.

---

## Bundled adapters

Five adapters ship in `src/adapters/` as the batteries-included configuration:

- **`umbelRunner`** (`src/adapters/umbel.ts`): `RunnerSeam` backed by the `umbel` binary
  over tmux. Drives claude, codex, and gemini workers through spawn/send/wait/read/kill
  verbs. The public factory: `umbelRunner(opts: UmbelSeamOpts): RunnerSeam`.

- **`directCliRunner`** (`src/adapters/direct-cli.ts`): `RunnerSeam` over headless agent
  CLIs (`claude -p`, `codex exec`) as one-shot subprocesses; no umbel, no tmux. Selected
  with `pleach run --runner direct-cli` or imported in a config. Single-turn `{prompt}`
  work only (a second `send` throws, because `{phases}` needs session resumption; use umbel).
  Its argv table tracks external CLIs and is pinned by unit tests so drift breaks CI,
  the installed CLI versions are your substrate responsibility. The public factory:
  `directCliRunner(opts?: DirectCliOpts): RunnerSeam`.

- **`scriptedRunner`** (`src/adapters/scripted.ts`): deterministic no-LLM `RunnerSeam`
  driven by canned scenarios; the CI backbone for example plans and a template for
  test doubles. The public factory: `scriptedRunner(scenarios): RunnerSeam`.

- **`tendLedger`** (`src/adapters/tend.ts`): `LedgerSeam` backed by tend's ingester module.
  Wraps the transport in a serial promise-chain queue (single-ingester invariant). Accepts a
  `Set<string>` (pre-T1 tend) or `Map<string, string | null>` (T1+) from `readClosed`.
  The public factory: `async tendLedger(opts: { module: string }): Promise<LedgerSeam>`.

- **`gitLedger`** (`src/adapters/git.ts`): trivial `LedgerSeam` backed by `node/*` branches
  in the local git repo. Zero external dependencies; `emitVerdict` is pure (`{ closed: verdict.status === 'done' }`).
  The public factory: `gitLedger(opts?: GitLedgerOpts): LedgerSeam`.

The zero-config default wires `umbelRunner` + `gitLedger` (or `tendLedger` when
`--tend-module` is given). This is the standalone "run and verify locally" configuration.

> **Upstream note (2026-08-18):** tend v1 has left the tend2 repo (final state at their tag
> `v1-final`); `@plotplot/tend2` ships the tend2 lane only. pleach's `tendLedger` +
> `--tend-module` remain for frozen v1 estates; new gardens use the tend2 lane above.

**The tend2 lane (no adapter needed).** tend's successor, tend2 (`@plotplot/tend2`),
integrates through the thin waist alone: its emitter produces plans whose gate is its own
verifier run as `accept.smoke` (`tend2 verify <file> --expect-payload <sha>`, deterministic and
content-pinned), with `gitLedger` recording the close. No ledger adapter and no audit block,
the verifier IS the check, per the shared law (a pass only a verifier writes). This repo's
integration canary (`scripts/canary.sh`, `test/canary/`) runs exactly that lane end-to-end
with zero agents. `tend audit <id>` and `tendLedger` remain the v1 tend lane, unchanged.

---

## Boundaries (honest)

pleach conducts **local executors** over git worktrees. It is not a cloud agent orchestrator:
the runner must be a process pleach can spawn locally (umbel/tmux, or a local API wrapper).
Cloud-autonomous agents that accept work and return a result asynchronously (e.g. Devin,
Claude Code in headless mode with no shell) require an adapter that bridges their async
protocol into the sync send→wait→kill contract. Doable, but the adapter owns that
translation.

Verification must be **automatable**. A `LedgerSeam` whose `emitVerdict` blocks waiting for
a human to approve does not fit the autonomous loop: pleach cannot wait indefinitely for an
external decision after committing a branch. The ledger decides deterministically from the
`Verdict` it receives. Gating on human review is better modelled as a `blocked` worker
outcome (the human unblocks the agent session) or a post-run CI step that re-runs pleach
with the human's approval reflected in the ledger's closed set.
