fable-agent/docs/specs/execution-pipeline.md

4.0 KiB

SPEC: fable-agent execution pipeline

Date: 2026-07-04 Status: draft — pending approval Author: harness build session

Problem

Five harness components exist in isolation. None are wired together. The pipeline is the 20% that delivers 80%.

Existing components (all built, all tested)

Component File Lines Role
WorktreeManager worktree-isolation.ts 170 Create/remove disposable git worktrees
buildRepoMap / minifiedTree repo-mapper.ts 95 Generate absolute repo tree for prompt injection
runVerifyGate / verifyAndClean verify-gate.ts 102 Build → lint → test, short-circuit on build fail, wipe worktree on fail
humanInterruptGate / requireApproval interrupt-gate.ts 53 Stdin TTY approval gate (allow/deny/skip/quit)
FlatLedger flat-ledger.ts 77 JSONL state file outside LLM context

Total: 497 lines of real code, 14 tests, all passing.

What we're building

One file: src/fable5/execution-pipeline.ts (~80 lines).

A single async function that chains the 5 components in order:

1. create worktree
2. inject repo map into prompt
3. (caller runs agent task in worktree)
4. verify-gate: build → lint → test
5. on fail → wipe worktree, record to ledger, done
6. on pass → interrupt-gate: human approval
7. on deny → wipe worktree, record to ledger, done
8. on allow → record to ledger, return success

Interface

export interface PipelineSpec {
  taskName: string;          // becomes worktree branch + ledger session
  agentPrompt: string;       // prompt with repo map injected by pipeline
  repoRoot: string;          // root for buildRepoMap + worktree base
  buildCmd: string[];        // e.g. ["npm", "run", "-s", "build"]
  testCmd: string[];         // e.g. ["npm", "run", "-s", "test"]
  lintCmd?: string[];        // optional
  runAgent: (cwd: string, prompt: string) => Promise<string>;  // caller provides
}

export interface PipelineResult {
  worktreePath: string | null;
  repoMapInjected: boolean;
  verify: VerifyGateResult;
  approved: boolean;
  worktreeCleaned: boolean;
  ledgerEntry: LedgerEntry;
  durationMs: number;
}

export async function runPipeline(spec: PipelineSpec): Promise<PipelineResult>;

Design decisions (Carmack-style)

  1. One function, one file. No class. No factory. No config object with 20 optional fields. One async function with a clear spec.

  2. runAgent is a callback. The pipeline doesn't know about Pi, subagents, or Claude. Caller provides async (cwd, prompt) => string. This keeps the pipeline testable without a live model.

  3. Ledger records every outcome. Pass, fail, denied — all go to the JSONL ledger. No hidden state.

  4. Worktree cleaned on fail OR deny. Only survives if verify passes AND human approves.

  5. Repo map injected into prompt automatically. Caller passes the raw prompt; pipeline prepends the minified tree.

  6. No deploy. Pipeline stops at "approved worktree exists." Merging to main is a separate explicit action.

What we're NOT building

  • No deploy integration (pipeline output is a verified worktree, not a deploy)
  • No model routing (caller provides runAgent)
  • No retry loop (one shot, one verification)
  • No parallel agents (single task, single worktree)
  • No new dependencies
  • No config files
  • No abstractions for "future" pipelines

Test plan

One test file: execution-pipeline.test.ts (~60 lines).

  • Mock runAgent to return success/fail strings
  • Mock verify-gate by passing build/test commands that succeed/fail
  • Assert ledger records correct outcome
  • Assert worktree cleaned on fail
  • Assert interrupt-gate default-allow on non-TTY

Acceptance criteria

  • execution-pipeline.ts exports runPipeline, PipelineSpec, PipelineResult
  • All 291 existing tests still pass
  • New pipeline tests pass
  • npx tsc --noEmit clean
  • Total new code: ~80 lines pipeline + ~60 lines tests = ~140 lines
  • No new dependencies in package.json