# `@agent-contract/plan` v1.1.6 — canonical text

This repo is the contract's home. `src/core/plan.ts` must match the fenced block below byte-for-byte
(drift-tested). tend and umbel vendor from this file. Schema changes happen here first — doc + source +
drift test in one commit, with an entry in [`CHANGES.md`](CHANGES.md). The version line in the title
above moves with any change to this text, seam block included: `test/unit/plan-version.test.ts` fails
when this file differs from its content at the commit that last changed the version line unless the
version line changed too.

Provenance: v0.3 §1 of `tend-umbel-build-spec.md` (ratified) + the v1.1 verification deltas
(`docs/ledger.md`): id charset (SEC3), `setup` (C3), `policy.timeoutMs` (D1), `Verdict.status +=
'blocked'` (D2), inert-field annotations (D6), `diffRef` required-on-close (B1/B2). Proposed to the tend
side in `missoula/docs/internal/bridge/plan-schema-v1.1-proposed.md`; amendments from their ratification
response land here as v1.1.x entries.

```ts
import { z } from 'zod';

const Work = z.union([
  z.object({ prompt: z.string() }),                         // agent, single send/wait
  z.object({
    test: z.string(),                                       // command the phase-gates run (RED must fail, GREEN must pass)
    phases: z.array(z.object({
      phase: z.enum(['red', 'impl', 'green']),
      prompt: z.string(),                                   // tend: the Test: / Implement: prose
    })),
  }),                                                       // agent, test-cycle (v2)
  z.object({ command: z.string() }),                        // command/worktree — exit-code gated, no test wrap
]);

const Node = z.object({
  id: z.string()                                            // v1.1.1: dot-separated alnum/_/- segments — legal as a git
    .regex(/^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9][A-Za-z0-9_-]*)*$/)  // refname + shell-safe; '.' is the composite separator
    .refine((s) => !s.endsWith('.lock'), 'git refuses refname components ending .lock'),
  worker: z.object({
    provider: z.string().optional(),
    model: z.string().optional(),
    cwd: z.string().optional(),                             // usually filled by isolate()
  }).default({}),
  work: Work,
  setup: z.string().optional(),                             // v1.1: run once post-isolate (dep install) before work + gates
  needs: z.array(z.string()).default([]),                   // tend: step.deps ∪ feature.deps — ordering AND file-flow edges
  accept: z.object({
    smoke: z.string().optional(),                           // exit 0 = pass (layer 1)
    audit: z.object({
      command: z.string(),                                  // e.g. `tend audit <feature>` (intent-read + negctrl)
      provider: z.string(),                                 // MUST ≠ worker.provider — model diversity
      // OPTIONAL — pin the auditor's model; the provider still MUST differ (diversity).
      model: z.string().optional(),
      // OPTIONAL (v1.1.5) — the command self-checks its audit target's integrity
      // (e.g. a scoreboard-normalized payload pin) and resolves its executable
      // out-of-tree; the conductor's tamper rule then exempts this command's
      // argv tokens. Obligations: "Self-integral audits" prose.
      selfIntegrity: z.boolean().optional(),
    }).optional(),
  }).default({}),
  policy: z.object({
    maxAttempts: z.number().int().default(2),
    timeoutMs: z.number().int().positive().optional(),      // v1.1: per-attempt wall clock — wait() AND every exec()
    onDead: z.enum(['resume', 'fail']).default('resume'),
    reauditWhen: z.array(z.enum(['compacted', 'retried', 'context-hot'])).default(['compacted']),  // v1-inert until telemetry
    budget: z.object({ tokens: z.number().optional(), usd: z.number().optional() }).optional(),    // v1-inert (unenforced)
  }).prefault({}),
  closes: z.array(z.string()).default([]),                  // tend: traces_to — ledger metadata; the loop does not consume it
});

const Plan = z.object({
  goal: z.string(),
  source: z.string(),                                       // the tend polyglot — the DURABLE home
  maxConcurrency: z.number().int().positive().optional(),   // conductor defaults to cores-2 (schema stays os-free)
  nodes: z.array(Node),
});

const Verdict = z.object({
  node: z.string(),
  status: z.enum(['done', 'failed', 'dead', 'timeout', 'rejected', 'aborted', 'blocked']),  // v1.1: +blocked (held at a prompt) — mechanical, no `verified`
  output: z.unknown(),                                      // audit nodes: an AuditResult
  evidence: z.object({
    traceRef: z.string().optional(),
    diffRef: z.string().optional(),                         // v1.1: verified commit SHA — REQUIRED on close (resume base, ledger B1/B2)
    blockedReason: z.string().optional(),                   // v1.1.1: the blocking prompt text when status === 'blocked'
    filesTouched: z.array(z.string()).default([]),
    gate: z.object({ ran: z.string(), exitCode: z.number() }).optional(),
  }),
  telemetry: z.object({
    tokens: z.number().optional(),
    contextPct: z.number().optional(),
    compacted: z.boolean().optional(),                      // [ROADMAP] empty v1 → reauditWhen dormant-but-safe
  }).default({}),
  attempts: z.number().int(),
});

const AuditResult = z.object({
  verdicts: z.array(z.object({
    check: z.string(),                                       // tend: check_id (adapter maps)
    verdict: z.enum(['pass', 'partial', 'fail']),            // three-valued — partial is tend's honest-middle
    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([]),
  // NO agent-authored `result` — the ingester derives it (§4).
});

export type Plan = z.infer<typeof Plan>;
export type Node = z.infer<typeof Node>;
export type Verdict = z.infer<typeof Verdict>;
export type AuditResult = z.infer<typeof AuditResult>;
export { Plan as PlanSchema, Node as NodeSchema, Verdict as VerdictSchema, AuditResult as AuditResultSchema };
```

## Seam interfaces (informative — code home is `src/loop/deps.ts`)

```ts
export interface LedgerSeam {
  readClosed(source: string): Promise<Map<string, string | null>>;   // id → verified commit SHA (null = legacy)
  emitVerdict(v: Verdict, source: string): Promise<{ closed: boolean }>;
}
export interface RunnerSeam {
  spawnWorker(spec: { provider?: string; model?: string; cwd: string }): Promise<Worker>;
}
export interface Worker {
  send(text: string): Promise<void>;
  wait(opts?: { timeoutMs?: number; idleMs?: number }): Promise<WorkerResult>;  // idleMs: end a quiet wait (conductor policy)
  kill(): Promise<void>;
}
export interface WorkerResult {
  finalMessage: string;                       // read UNTRUNCATED — audit egress depends on it
  actions?: unknown;
  diff?: string;
  filesTouched: string[];
  exitCode?: number;
  reason?: 'stop' | 'file' | 'pattern' | 'provider-error' | 'idle'
         | 'timeout' | 'dead' | 'input' | 'aborted';
  message?: string;                           // blocking prompt text → Verdict.evidence.blockedReason
  telemetry: { tokens?: number; contextPct?: number; compacted?: boolean };
}
```

## Ratified spec prose (v1.1.1 — binding, from the tend-side ratification)

- **Setup failure is an environment failure, not a work failure**: Verdict `status: 'failed'`,
  `evidence.gate = { ran: <setup command>, exitCode }` (the `gate.ran` string is what distinguishes a
  setup failure from a RED-gate failure in the ledger). It consumes an attempt. `policy.timeoutMs`
  covers setup like every other exec.
- **Timeout default**: an omitted `policy.timeoutMs` means the conductor's default (30 minutes), never
  unbounded. **`blocked` preempts `timeout`**: when `wait()` reports `input`, return `blocked` promptly —
  the attempt clock is for silent hangs; a prompt is a signal, not a hang.
- **Model diversity is checked against the RESOLVED provider**: when `worker.provider` is omitted, the
  conductor must enforce `accept.audit.provider ≠ <resolved default>` at spawn time (the schema cannot
  see the default).
- **Single writer during a run**: `emitVerdict` is the only writer to the source polyglots while a run
  holds the lock. A human or MCP edit mid-run staleifies evidence out from under the ledger.
- **`diffRef` REQUIRED-on-close is enforced by tend's ingester** (refuses `{closed:true}` without it),
  not just annotated. `AuditResult.verdicts[].evidenceSha` is advisory — tend hashes evidence itself at
  write time.
- **Work-union mapping pin** (tend's seven-beat pipeline → this contract): PRE → `setup`; VERIFY →
  `accept`; POST/DOC → conductor ledger + tend ingestion. `{command}` nodes are exit-code gated with no
  test wrap.

## Shared law (v1.1.3 — binding prose, tend2-ratified 2026-08-17)

Two laws every implementation of this contract — planner, conductor, runner, ledger — must
uphold. Converged in the tend2 × pleach dialogue of 2026-08-17; the explanatory home is
tend2's `docs/bridge/working-together.md`.

- **LAW 1 — the checker must not be writable by the checked.** No gate, verifier, or audit
  command may be modifiable by the worker whose output it judges. Instances: pleach's
  gate-integrity check + auditor inoculation (SEC4); tend2's verifyBin resolved outside the
  worktree (#76); verifier-only stamps; `accept.audit.provider ≠` builder provider; audits
  run from checkouts the worker cannot reach.
- **LAW 2 — status is computed, never asserted.** No agent's claim of done, pass, or verified
  is ever recorded directly; status is derived by code from evidence. Instances: a pass only
  a verifier writes; branches only gates publish; an undiscriminated pass caps at `partial`
  (negctrl); a bug closes only by its RED check going green; exit codes are claims — the
  verifier reads evidence content.

## Exec semantics (v1.1.4 — binding prose, tend2-ratified 2026-08-17)

Every plan-authored command string — `work.command`, `work.test`, `setup`,
`accept.smoke` — is parsed to an argv by POSIX-quoting rules and executed
**without a shell** (deterministic, injection-resistant; the conductor never
interprets `&&`, `|`, `;`, or redirection). A bare shell-operator token is
refused loudly with the escape hatch named: authors wanting shell features
write them explicitly — `bash -lc '<command>'`. (`accept.audit.command` is
relayed as text to the auditor agent, which runs it in its own shell; the
no-shell rule governs what the CONDUCTOR execs.) Discovered by the joint
canary's first run, 2026-08-17: an emitter assumed shell semantics, the
conductor's argv-exec made `mkdir -p tools && printf … > tools/wordcount.mjs`
create a directory named `tools/wordcount.mjs`, exit 0 — silent wrongness now
structurally refused.

## Self-integral audits (v1.1.5 — tend2-ratified 2026-08-17)

`accept.audit.selfIntegrity: true` declares that the audit command carries its own
gate-integrity check, and obliges it to: **(1)** refuse — by its own exit — any
fitness-function change to its audit target since emission (e.g. a
scoreboard-normalized payload pin: verifier-written stamps and check-state marks do
not move it; edits to claims, evidence, or structure do); **(2)** resolve its
executable and every script it runs from OUTSIDE worker-writable paths (LAW 1 — a
worker-writable wrapper must never carry this declaration). In exchange the
conductor's tamper rule (v1.1.4 era: refuse when any audit-command argv token was
touched by the builder) stands down for that command — which is what makes WRITING
gates (a verifier that stamps its own target file during the worker's mandated
self-runs) conductible at all. Discovered by the phase-2 hub collision, 2026-08-17:
two correct rules — stamps-live-in-the-file and the-checked-may-not-touch-the-checker
— fired against each other on the only audited node; the fix distinguishes the
scoreboard from the fitness function, the same distinction that fixed the payload
pin that morning. A self-integral audit command also emits the audit egress fence ITSELF (e.g. `--audit-egress`) — the auditor runs it and relays; the agent's own exit code can never carry the command's verdict (v1.1.6, pending ratification).
