How to Buildan Open Harnessfor AgenticEngineering

Fifteen steps from a stock Pi install to OpenHarness, a package that won't let an agent call a task done until the checks pass and a second agent signs off. The best ideas come from OhMyPi, rebuilt as small extensions you can read in an afternoon.

A model is not an engineer

Give a capable model a shell and it will write code. It will also say "done" with a failing test, edit a file that moved, and touch things nobody asked it to touch. Engineering is the part around the typing: a plan, a check, a second pair of eyes, and a record of what happened.

That part is the harness. A climber's harness doesn't climb for you. It ties you to anchors, so a slip costs a few feet and not the whole route. The model does the climbing, and the harness decides how far it can fall.

Trust the rope, not the climber.

guard
Read, write, and exec tiers, with paths and commands that are never allowed
edits
Hash-anchored edits that refuse to land on a file that moved
rules
Stream rules that stop the model mid-sentence and retry
plan
A read-only phase that ends with a typed plan for you to approve
workers
Parallel subagents in their own git worktrees, returning typed reports
gates
Verify, then review, against checks the model cannot rewrite
ledger
A record of every run, and an exit code your CI can trust

Four rules for the climb

  1. Build on Pi, don't fork it. A package upgrades with a version bump. A fork upgrades with a merge.
  2. Take ideas, give credit. OhMyPi solved these problems first. This guide reimplements its ideas in small files, and copies none of its code.
  3. Fail closed. A broken policy file, a missing reviewer, or a gate that can't run is a failure, never a pass.
  4. Test the harness without a model. Pi can script one, so every gate gets a repeatable test that costs nothing.

You need Node 22.19 or later, git, and a model for Pi to talk to: a subscription, an API key, or a local server. Tick each step as you finish it. Your progress stays in this browser.

Prefer to read the finished thing first? Download the whole package (openharness.zip), tests included.

Credit where it's due. Pi is Mario Zechner's minimal agent harness. OhMyPi is a fork of Pi from Stencil Labs. Both are MIT licensed, and this guide would not exist without them.

taskplanbuildverifyreviewdoneguard, anchors,stream rulesread-onlyyour checksa second agentevery step is written to the ledgernot done yet
The whole harness as a route. There is one way to finish, and it passes through both anchors.

Base camp

Learn the ground, then set up the package everything else hangs from.

1. Install Pi and meet the core

Pi is deliberately small. It gives a model four tools (read, bash, edit, and write), a session log, and a loop. Its README says it skips features like sub-agents and plan mode, and invites you to build them. That invitation is why it makes a good foundation.

npm install -g --ignore-scripts @earendil-works/pi-coding-agent@1.5.0
pi --version
1.5.0
cd ~/your-project
pi
# Inside Pi, run /login, then give it a small task.

Everything in this guide uses four of Pi's seams: extensions that hook its events, tools a model can call, packages that install them as one unit, and an SDK that runs a session inside your own process.

Pi has no permission system. Its docs are plain about this: tools run with your account's permissions, and safety comes from limiting what that account can reach. Step 5 adds a guard, but a guard is a seatbelt. For anything unattended, also run inside a container.

2. Read OhMyPi as a map

OhMyPi started as a fork of Pi and grew into a batteries-included agent, with a Rust core, an LSP, a debugger, and a browser. Install it and use it for a day. Its installer is a shell script, so read it first.

curl -fsSL https://omp.sh/install -o omp-install.sh
less omp-install.sh
sh omp-install.sh --binary
omp --version
omp/18.16.0

Its argument is one worth taking seriously: the harness matters as much as the model. Its README reports one model going from 6.7% to 68.3% on an edit benchmark just by changing the edit format. That's their number, and their write-up is worth your time.

You could fork it, or fork Pi. A fork gives you total control, and it hands you every upstream merge from then on. For a harness whose value is policy, not plumbing, a package is the better trade. So read OhMyPi as a map, and pick the ideas that matter for engineering work:

Idea from OhMyPiWhat it doesPi seamStep
Approval modesTiers tools into read, write, and exectool_call5
Hashline editsEdits by content hash, not by retypingregisterTool6
Stream rulesAborts mid-token, injects a rule, retriesmessage_update7
Plan modeExplores read-only before it changes anythingsetActiveTools8
SubagentsWorktrees and schema-checked resultsSDK9
Code reviewFindings ranked P0 to P3, with a verdictagent_before_settle10

Leave the rest. The LSP, debugger, browser, and native core are real engineering, and they are the reason to use OhMyPi itself. They are not what makes work trustworthy.

Could one package serve both? Not this one. OhMyPi can load many Pi packages, but version 18.16.0 has neither of the two settle events the gates in step 10 depend on, and a direct load of OpenHarness failed on its Pi imports. This is a Pi package.

3. Create the package

A Pi package is an ordinary folder. The pi key in package.json says what to load, and the pi-package keyword makes it discoverable.

mkdir openharness && cd openharness
git init -q
mkdir -p src/features extensions bin skills/engineering-loop prompts test
printf 'node_modules/\n.openharness/\n' > .gitignore
package.json
{
  "name": "openharness",
  "version": "0.1.0",
  "description": "An open engineering harness on Pi: gates, typed subagents, and a run ledger.",
  "type": "module",
  "license": "MIT",
  "keywords": [
    "pi-package"
  ],
  "bin": {
    "openharness": "./bin/openharness.ts"
  },
  "pi": {
    "extensions": [
      "./extensions/openharness.ts"
    ],
    "skills": [
      "./skills"
    ],
    "prompts": [
      "./prompts/*.md"
    ]
  },
  "scripts": {
    "test": "node --test \"test/*.test.ts\"",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@earendil-works/pi-ai": "1.5.0",
    "@earendil-works/pi-coding-agent": "1.5.0",
    "@types/node": "24.10.1",
    "typebox": "1.3.27",
    "typescript": "5.9.3"
  },
  "peerDependencies": {
    "@earendil-works/pi-ai": "*",
    "@earendil-works/pi-coding-agent": "*",
    "typebox": "*"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "bin", "extensions", "test"]
}
npm install --ignore-scripts

Peer dependencies are not optional. Pi supplies its own packages to extensions. List them in dependencies and you can load duplicate copies, which Pi warns about at startup. Declare them as peers with "*", and pin them in devDependencies for your own tests.

There is no build step. Pi loads TypeScript directly, and Node 22.19 strips types when it runs the tests.

4. Put the rules in one file

A team's standards should live in the repo, in a file you can review. Each project gets a harness.json:

harness.json
{
  "mode": "write",
  "protect": [".env*", "*.pem", "secrets/**", ".git/**"],
  "frozen": ["test/**", "*.test.ts"],
  "verify": ["npm run typecheck", "npm test"],
  "maxRetries": 3,
  "hashline": true,
  "rules": [
    {
      "name": "no-console",
      "pattern": "console\\.log",
      "message": "Use the project logger, not console.log."
    }
  ],
  "review": { "enabled": true, "blockOn": ["P0", "P1"] }
}

Two lists do different jobs. protect is off limits entirely, even to read. frozen can be read but never changed, which is what you want for your tests. Step 10 shows why that second list exists.

The loader has one job that matters. A typo in a safety file must never quietly switch the safety off, so a broken file throws, and the caller fails closed.

src/config.ts
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

export type Mode = "ask" | "write" | "yolo";
export type Priority = "P0" | "P1" | "P2" | "P3";
export type Rule = { name: string; pattern: string; message: string };

export type Config = {
  mode: Mode;
  protect: string[];
  frozen: string[];
  verify: string[];
  maxRetries: number;
  hashline: boolean;
  rules: Rule[];
  review: { enabled: boolean; model?: string; blockOn: Priority[] };
};

export const defaults: Config = {
  mode: "write",
  protect: [".env*", "*.pem", "secrets/**", ".git/**"],
  frozen: [],
  verify: [],
  maxRetries: 3,
  hashline: true,
  rules: [],
  review: { enabled: true, blockOn: ["P0", "P1"] },
};

/** A broken harness.json throws. Callers must fail closed, not fall back to defaults. */
export function loadConfig(cwd: string): Config {
  const file = join(cwd, "harness.json");
  if (!existsSync(file)) return defaults;

  const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<Config>;
  const config = { ...defaults, ...raw, review: { ...defaults.review, ...raw.review } };

  if (!["ask", "write", "yolo"].includes(config.mode)) {
    throw new Error(`harness.json: unknown mode "${config.mode}"`);
  }
  for (const rule of config.rules) new RegExp(rule.pattern);
  return config;
}

Every feature shares one small state object. It loads the policy on first use, because an extension only learns the working folder from an event. Its folder ignores itself, so the harness's own files never look like a code change.

src/state.ts
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type Config, loadConfig } from "./config.ts";

export type Status = "idle" | "unchanged" | "passed" | "failed";
export type LedgerEntry = { kind: string; [key: string]: unknown };

/** One object shared by every feature in a session. */
export class HarnessState {
  status: Status = "idle";
  planning = false;
  expectChange = false;
  entries: LedgerEntry[] = [];
  private loaded?: { config?: Config; error?: string };
  private cwd?: string;
  private runId = new Date().toISOString().replace(/[:.]/g, "-");

  /** Loaded once, on first use, because extensions only learn the cwd from an event. */
  load(cwd: string) {
    if (this.loaded) return this.loaded;
    this.cwd = cwd;
    try {
      this.loaded = { config: loadConfig(cwd) };
    } catch (error) {
      this.loaded = { error: String(error) };
    }
    return this.loaded;
  }

  /** The harness's own folder ignores itself, so it never looks like a code change. */
  folder(name = "") {
    const root = join(this.cwd ?? process.cwd(), ".openharness");
    mkdirSync(join(root, name), { recursive: true });
    writeFileSync(join(root, ".gitignore"), "*\n");
    return join(root, name);
  }

  record(entry: LedgerEntry) {
    this.entries.push(entry);
    const line = JSON.stringify({ at: new Date().toISOString(), ...entry });
    appendFileSync(join(this.folder("runs"), `${this.runId}.jsonl`), `${line}\n`);
  }
}

Place the protection

Three anchors that hold on every single tool call.

5. Tier every tool call

Borrowed from OhMyPi's approval modes: every tool is read, write, or exec, and the mode decides which need your approval. Protected paths and critical commands are denied in every mode, including yolo. An unknown tool gets the strictest tier, so a new tool is never trusted by accident.

The decision is a pure function. It has no Node imports, which keeps it fast to test and lets it run right here on the page.

src/policy.ts
/** Decides what a tool call may do. Pure, so it runs anywhere and tests in microseconds. */
export type Tier = "read" | "write" | "exec";
export type Decision = "allow" | "ask" | "deny";
export type Verdict = { decision: Decision; reason: string };
export type Policy = { mode: "ask" | "write" | "yolo"; protect: string[]; frozen?: string[] };
export type Call = { tool: string; input: Record<string, unknown> };

const TIERS: Record<string, Tier> = {
  read: "read", grep: "read", find: "read", ls: "read", read_lines: "read", submit_plan: "read", report: "read",
  write: "write", edit: "write", edit_lines: "write", delegate: "write",
  bash: "exec",
};

/** Never allowed, in any mode. */
const CRITICAL: [RegExp, string][] = [
  [/\bsudo\b/, "runs as root"],
  [/\brm\s+-\w*r\w*\s+(\/|~|\$HOME)(\s|$)/, "deletes a home or root folder"],
  [/\b(curl|wget)\b[^|;&]*\|\s*(ba|z)?sh\b/, "pipes the network into a shell"],
  [/\bgit\s+push\b[^;&|]*\s(--force|-f)\b/, "force-pushes"],
  [/\bgit\s+reset\s+--hard\b/, "throws away work"],
  [/\b(mkfs|shutdown|reboot)\b/, "touches the machine"],
];

/** Unknown tools get the strictest tier, so a new tool is never silently trusted. */
export const tierOf = (tool: string): Tier => TIERS[tool] ?? "exec";

function globToRegExp(glob: string): RegExp {
  const body = glob
    .replace(/[.+^${}()|[\]\\]/g, "\\$&")
    .replace(/\*\*/g, "\0")
    .replace(/\*/g, "[^/]*")
    .replace(/\0/g, ".*");
  return new RegExp(`^${body}$`);
}

/** Resolves . and .. segments. Returns null when the path leaves the project. */
export function normalize(path: string, cwd = ""): string | null {
  const inside = cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path;
  if (inside.startsWith("/") || inside.startsWith("~")) return null;
  const parts: string[] = [];
  for (const part of inside.split("/")) {
    if (part === "..") {
      if (!parts.length) return null;
      parts.pop();
    } else if (part && part !== ".") {
      parts.push(part);
    }
  }
  return parts.join("/");
}

export function isProtected(path: string, protect: string[]) {
  const name = path.split("/").pop() ?? path;
  return protect.some((glob) => globToRegExp(glob).test(glob.includes("/") ? path : name));
}

export function decide({ tool, input }: Call, policy: Policy, cwd = ""): Verdict {
  const tier = tierOf(tool);

  if (typeof input.path === "string") {
    const path = normalize(input.path, cwd);
    if (path === null && tier !== "read") {
      return { decision: "deny", reason: `${input.path} is outside the project` };
    }
    if (path !== null && isProtected(path, policy.protect)) {
      return { decision: "deny", reason: `${path} is protected` };
    }
    if (path !== null && tier !== "read" && isProtected(path, policy.frozen ?? [])) {
      return { decision: "deny", reason: `${path} is frozen. Find the code it tests and fix that instead` };
    }
  }

  if (tool === "bash") {
    const command = String(input.command ?? "");
    for (const [pattern, why] of CRITICAL) {
      if (pattern.test(command)) return { decision: "deny", reason: `this command ${why}` };
    }
    for (const word of command.split(/[\s"'=;|&<>]+/)) {
      const path = word && normalize(word, cwd);
      if (path && isProtected(path, policy.protect)) {
        return { decision: "deny", reason: `the command mentions ${path}, which is protected` };
      }
    }
  }

  const free = policy.mode === "yolo" || tier === "read" || (policy.mode === "write" && tier === "write");
  return free
    ? { decision: "allow", reason: `${tier} is allowed in ${policy.mode} mode` }
    : { decision: "ask", reason: `${tier} needs approval in ${policy.mode} mode` };
}
  • ask Ask you exec needs approval in ask mode
  • write Ask you exec needs approval in write mode
  • yolo Allow exec is allowed in yolo mode
This is src/policy.ts itself, running in your browser. Try a command of your own.

Pi's tool_call event runs before a tool does, and can block it. Blocking isn't a crash: the model gets the reason as a tool result and carries on.

src/features/guard.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { decide, tierOf } from "../policy.ts";
import type { HarnessState } from "../state.ts";

export function installGuard(pi: ExtensionAPI, state: HarnessState) {
  pi.on("tool_call", async (event, ctx) => {
    const { config, error } = state.load(ctx.cwd);

    // Fail closed: with a broken policy file, only reads get through.
    if (!config) {
      if (tierOf(event.toolName) === "read") return undefined;
      return { block: true, reason: `Blocked: ${error}` };
    }

    const verdict = decide({ tool: event.toolName, input: event.input }, config, ctx.cwd);
    if (verdict.decision === "allow") return undefined;

    if (verdict.decision === "ask" && ctx.hasUI) {
      const yes = await ctx.ui.confirm("OpenHarness", `${event.toolName}: ${verdict.reason}. Allow it?`);
      if (yes) return undefined;
    }

    state.record({ kind: "blocked", tool: event.toolName, reason: verdict.reason });
    return { block: true, reason: `Blocked by OpenHarness: ${verdict.reason}.` };
  });
}

With no person at the keyboard, ask becomes no. And a broken harness.json leaves only reads working.

Know what this is. Matching words in a shell command is a speed bump. cat .env is caught, and cat $(echo .e)nv is not. It stops accidents, not an attacker. That's what the container in step 1 is for.

6. Anchor every edit

Stock edits make the model retype the old text exactly, which wastes tokens and fails on a stray space. OhMyPi's hashline idea flips it: every line the model reads carries an anchor, a line number plus a short hash of its content, and an edit points at anchors.

The real gain is what happens when the file changes underneath. If a formatter, a teammate, or a parallel worker got there first, the hash no longer matches and the edit is refused before it can land on the wrong line.

src/hashline.ts
/** Every line gets an anchor like 12#a3f1, its number plus a hash of its text. */
export type Edit = { from: string; to?: string; lines: string[] } | { after: string; lines: string[] };

export class StaleAnchor extends Error {}

/** FNV-1a, folded to 16 bits. Small, fast, and identical on every platform. */
export function lineHash(line: string) {
  let hash = 0x811c9dc5;
  for (let i = 0; i < line.length; i++) {
    hash ^= line.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return ((hash ^ (hash >>> 16)) & 0xffff).toString(16).padStart(4, "0");
}

const split = (text: string) => {
  const lines = text.split("\n");
  if (lines.at(-1) === "") lines.pop();
  return lines;
};

export function render(text: string, offset = 1, limit = 400) {
  return split(text)
    .map((line, index) => `${index + 1}#${lineHash(line)}|${line}`)
    .slice(offset - 1, offset - 1 + limit)
    .join("\n");
}

/** Returns the zero-based index an anchor points to, or throws if the file moved on. */
function resolve(anchor: string, lines: string[]) {
  const match = anchor.match(/^(\d+)#([0-9a-f]{4})$/);
  if (!match) throw new StaleAnchor(`"${anchor}" is not an anchor. Use the form 12#a3f1.`);
  const index = Number(match[1]) - 1;
  if (index < 0 || index >= lines.length || lineHash(lines[index]) !== match[2]) {
    throw new StaleAnchor(`${anchor} is stale. Read the file again before editing.`);
  }
  return index;
}

/** All anchors are checked before anything changes, so a bad edit leaves no trace. */
export function applyEdits(text: string, edits: Edit[]) {
  const lines = split(text);
  const spans = edits.map((edit) => {
    if ("after" in edit) {
      const at = edit.after === "0" ? 0 : resolve(edit.after, lines) + 1;
      return { start: at, end: at, lines: edit.lines };
    }
    const start = resolve(edit.from, lines);
    const end = edit.to ? resolve(edit.to, lines) + 1 : start + 1;
    if (end <= start) throw new StaleAnchor(`${edit.to} comes before ${edit.from}.`);
    return { start, end, lines: edit.lines };
  });

  const ordered = spans.sort((a, b) => b.start - a.start);
  ordered.forEach((span, index) => {
    const next = ordered[index - 1];
    if (next && span.end > next.start) throw new StaleAnchor("Two edits overlap.");
  });
  for (const span of ordered) lines.splice(span.start, span.end - span.start, ...span.lines);

  return lines.join("\n") + (text.endsWith("\n") || !text ? "\n" : "");
}

The model read this file and wants to replace line 2#f70e with let sum = 0;

1#48d3|export function total(items) {
2#f70e|  let sum = 1;
3#78f2|  for (const item of items) sum += item;
4#1016|  return sum;
5#b0a4|}

This is src/hashline.ts itself, running in your browser.

Now expose it as two tools, and take the stock read and edit away, so there's only one way to edit.

src/features/edits.ts
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { applyEdits, type Edit, render } from "../hashline.ts";
import type { HarnessState } from "../state.ts";

const readLines = defineTool({
  name: "read_lines",
  label: "Read lines",
  description: "Read a text file. Each line comes back as N#hash|text. Edit with those anchors.",
  parameters: Type.Object({
    path: Type.String(),
    offset: Type.Optional(Type.Number({ description: "First line, from 1" })),
    limit: Type.Optional(Type.Number()),
  }),
  async execute(_id, { path, offset, limit }, _signal, _update, ctx) {
    const text = readFileSync(resolve(ctx.cwd, path), "utf8");
    return { content: [{ type: "text", text: render(text, offset, limit) }], details: {} };
  },
});

const editLines = defineTool({
  name: "edit_lines",
  label: "Edit lines",
  description:
    "Edit a file by anchor. Replace with {from, to?, lines}, or insert with {after, lines}. " +
    'Use {after: "0"} for the top. Every anchor must be current, or nothing changes.',
  parameters: Type.Object({
    path: Type.String(),
    edits: Type.Array(
      Type.Object({
        from: Type.Optional(Type.String()),
        to: Type.Optional(Type.String()),
        after: Type.Optional(Type.String()),
        lines: Type.Array(Type.String()),
      }),
    ),
  }),
  async execute(_id, { path, edits }, _signal, _update, ctx) {
    const file = resolve(ctx.cwd, path);
    const next = applyEdits(readFileSync(file, "utf8"), edits as Edit[]);
    writeFileSync(file, next);
    return { content: [{ type: "text", text: `Edited. Current lines:\n${render(next)}` }], details: {} };
  },
});

export function installEdits(pi: ExtensionAPI, state: HarnessState) {
  pi.registerTool(readLines);
  pi.registerTool(editLines);

  // Swap the stock editor out, so the model has one way to edit and cannot mix them.
  pi.on("before_agent_start", (_event, ctx) => {
    if (state.planning) return;
    const active = new Set(pi.getActiveTools());
    const anchored = state.load(ctx.cwd).config?.hashline;
    for (const name of anchored ? ["read", "edit"] : ["read_lines", "edit_lines"]) active.delete(name);
    if (anchored) active.add("read_lines").add("edit_lines");
    pi.setActiveTools([...active]);
  });
}

Simpler than the original. OhMyPi's version has block operations, registers, and moves. This one has replace and insert, which is enough to get the safety property.

This swap is nearly free. Pi's stock prompt and tools came to 1,426 tokens in a test project, and OpenHarness to 1,438, because the new edit tool's schema is smaller than the one it replaces.

7. Catch mistakes mid-sentence

A rule in a system prompt costs tokens on every turn, and models still forget it. OhMyPi's stream rules cost nothing until they fire. They watch the output as it streams, and a match stops the model on the spot.

src/rules.ts
export type Rule = { name: string; pattern: string; message: string };

type Block = { type: string; text?: string; arguments?: unknown; partialJson?: string };

/** Everything the model has produced so far, tool arguments included. */
export function produced(content: Block[]) {
  return content
    .map((block) => {
      if (block.type === "text") return block.text ?? "";
      if (block.type === "toolCall") return block.partialJson ?? JSON.stringify(block.arguments ?? {});
      return "";
    })
    .join("\n");
}

/** Rules that already fired are skipped, so one rule can never cause an endless retry. */
export function firstHit(text: string, rules: Rule[], fired: Set<string>) {
  return rules.find((rule) => !fired.has(rule.name) && new RegExp(rule.pattern).test(text));
}

Three Pi events make this work. message_update sees each token, including tool arguments as they stream, so a bad write dies before it ever runs. context removes the failed attempt from what the model sees next. agent_settled retries, once the run has fully stopped.

src/features/stream-rules.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { firstHit, produced, type Rule } from "../rules.ts";
import type { HarnessState } from "../state.ts";

export function installStreamRules(pi: ExtensionAPI, state: HarnessState) {
  const fired = new Set<string>();
  let pending: Rule | undefined;
  let dropped: number | undefined;

  pi.on("input", () => fired.clear());

  // Watch the stream. On a match, stop the model mid-sentence.
  pi.on("message_update", (event, ctx) => {
    if (pending || event.message.role !== "assistant") return;
    const rules = state.load(ctx.cwd).config?.rules ?? [];
    const hit = firstHit(produced(event.message.content), rules, fired);
    if (!hit) return;
    pending = hit;
    dropped = event.message.timestamp;
    fired.add(hit.name);
    state.record({ kind: "rule", rule: hit.name });
    ctx.abort();
  });

  // The time travel: the bad attempt never reaches the model again.
  pi.on("context", (event) => {
    if (dropped === undefined) return;
    const kept = event.messages.filter((m) => !(m.role === "assistant" && m.timestamp === dropped));
    return { messages: kept };
  });

  // Once the run has stopped, retry with the rule in hand.
  pi.on("agent_settled", () => {
    if (!pending) return;
    const rule = pending;
    pending = undefined;
    pi.sendMessage(
      {
        customType: "openharness-rule",
        content: `Rule "${rule.name}": ${rule.message} Your last attempt was discarded. Try again.`,
        display: true,
      },
      { triggerTurn: true },
    );
  });
}

Removing the attempt is the part that's easy to miss. Without it, the bad output stays in context and the model is likely to repeat it. And each rule fires once per task, so a stubborn model can't loop forever. If it repeats the mistake, the gates still get a say.

Climb the loop

Protection stops bad moves. Now add the process that makes good ones: plan, delegate, and prove.

8. Plan before you touch anything

/plan removes every tool that can change something, so "read-only" is enforced, not requested. The phase ends with submit_plan, a typed tool, so a plan has a goal, steps, and a way to verify it. Then you approve it, or it stops.

src/features/plan.ts
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import type { HarnessState } from "../state.ts";

const READ_ONLY = ["read", "grep", "find", "ls", "submit_plan"];

export function installPlan(pi: ExtensionAPI, state: HarnessState) {
  let previous: string[] = [];

  pi.registerTool(
    defineTool({
      name: "submit_plan",
      label: "Submit plan",
      description: "Submit the finished plan for approval. Call this once, when planning is done.",
      parameters: Type.Object({
        goal: Type.String(),
        steps: Type.Array(Type.String(), { minItems: 1 }),
        verification: Type.String({ description: "How we will know it worked" }),
      }),
      async execute(_id, plan, _signal, _update, ctx) {
        const text = [`# ${plan.goal}`, "", ...plan.steps.map((s, i) => `${i + 1}. ${s}`), "", `Verify: ${plan.verification}`];
        writeFileSync(join(state.folder(), "plan.md"), `${text.join("\n")}\n`);
        state.record({ kind: "plan", goal: plan.goal, steps: plan.steps.length });

        const approved = ctx.hasUI ? await ctx.ui.confirm("Plan ready", text.join("\n")) : false;
        state.planning = false;
        pi.setActiveTools(previous);
        return {
          content: [{ type: "text", text: approved ? "Approved. Build it." : "Saved to .openharness/plan.md. Stop here and wait." }],
          details: {},
          terminate: !approved,
        };
      },
    }),
  );

  // The tool only exists while planning, so it can never be called by accident.
  pi.on("before_agent_start", () => {
    if (state.planning) return;
    pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "submit_plan"));
  });

  pi.registerCommand("plan", {
    description: "Explore read-only, then submit a plan for approval",
    handler: async (args) => {
      previous = pi.getActiveTools();
      state.planning = true;
      pi.setActiveTools(READ_ONLY);
      pi.sendUserMessage(`Plan this task. Read what you need, change nothing, then call submit_plan.\n\n${args}`);
    },
  });
}

Headless runs never auto-approve. The plan is saved to .openharness/plan.md and the run ends, because nobody was there to say yes.

9. Delegate to workers in worktrees

Two things go wrong with subagents: they trample each other's files, and they return prose you have to parse. OhMyPi answers with isolated worktrees and schema-checked results, and so does this.

Start with a helper that runs a fresh agent with its own context. The only way out is a report tool whose schema you define. If the child finishes without calling it, that's an error, not an empty success.

src/child.ts
import { type Model } from "@earendil-works/pi-ai";
import {
  createAgentSession,
  DefaultResourceLoader,
  defineTool,
  getAgentDir,
  type InlineExtension,
  type ModelRuntime,
  SessionManager,
  SettingsManager,
} from "@earendil-works/pi-coding-agent";
import type { Static, TSchema } from "typebox";

export type ChildOptions<T extends TSchema> = {
  cwd: string;
  role: string;
  task: string;
  schema: T;
  tools: string[];
  model?: Model<any>;
  modelRuntime?: ModelRuntime;
  extensions?: InlineExtension[];
};

/** Runs a fresh agent with its own context. The only way out is a typed report. */
export async function runChild<T extends TSchema>(options: ChildOptions<T>) {
  let result: Static<T> | undefined;

  const report = defineTool({
    name: "report",
    label: "Report",
    description: "Finish by reporting your result. This is the only output anyone will read.",
    parameters: options.schema,
    async execute(_id, params) {
      result = params as Static<T>;
      return { content: [{ type: "text", text: "Reported." }], details: {}, terminate: true };
    },
  });

  const agentDir = getAgentDir();
  const resourceLoader = new DefaultResourceLoader({
    cwd: options.cwd,
    agentDir,
    noExtensions: true,
    noSkills: true,
    noPromptTemplates: true,
    noThemes: true,
    extensionFactories: options.extensions ?? [],
    appendSystemPrompt: [`You are the ${options.role}. When finished, call report. Do not reply in prose.`],
  });
  await resourceLoader.reload();

  const { session } = await createAgentSession({
    cwd: options.cwd,
    agentDir,
    model: options.model,
    modelRuntime: options.modelRuntime,
    resourceLoader,
    tools: [...options.tools, "report"],
    customTools: [report],
    sessionManager: SessionManager.inMemory(options.cwd),
    settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
  });

  try {
    await session.prompt(options.task);
  } finally {
    session.dispose();
  }
  if (result === undefined) throw new Error(`The ${options.role} finished without a report.`);
  return result;
}

Then a tool that gives each worker its own git worktree and branch. Pi runs tool calls from one turn in parallel, so two delegate calls are two workers at once. Each runs under its own guard, and the worktree is removed even if the worker fails.

src/features/delegate.ts
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { defineTool, type ExtensionAPI, type ModelRuntime } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { runChild } from "../child.ts";
import { HarnessState } from "../state.ts";
import { installGuard } from "./guard.ts";

const WorkerReport = Type.Object({
  summary: Type.String(),
  risks: Type.Array(Type.String()),
});

export function installDelegate(pi: ExtensionAPI, state: HarnessState, modelRuntime?: ModelRuntime) {
  pi.registerTool(
    defineTool({
      name: "delegate",
      label: "Delegate",
      description:
        "Hand one self-contained task to a worker in its own git worktree. Calls in the same turn " +
        "run in parallel. Returns a typed report and a branch for you to merge.",
      parameters: Type.Object({ task: Type.String() }),
      async execute(_id, { task }, signal, _update, ctx) {
        const id = randomBytes(3).toString("hex");
        const branch = `openharness/${id}`;
        const cwd = join(state.folder("worktrees"), id);
        const git = async (args: string[], at = ctx.cwd) => {
          const result = await pi.exec("git", args, { cwd: at, signal });
          if (result.code !== 0) throw new Error(`git ${args[0]}: ${result.stderr.trim()}`);
          return result.stdout;
        };

        await git(["worktree", "add", "-q", "-b", branch, cwd]);
        try {
          const child = new HarnessState();
          child.load(cwd);
          const report = await runChild({
            cwd,
            role: "worker",
            task: `${task}\n\nThis task is all you know. Change only what it needs.`,
            schema: WorkerReport,
            tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
            model: ctx.model,
            modelRuntime,
            extensions: [(childPi) => installGuard(childPi, child)],
          });

          await git(["add", "-A"], cwd);
          await git(["commit", "-q", "--allow-empty", "-m", `openharness: ${task.slice(0, 60)}`], cwd);
          const files = (await git(["diff", "--name-only", "HEAD~1"], cwd)).trim().split("\n").filter(Boolean);

          state.record({ kind: "delegate", branch, files: files.length });
          const result = { ...report, branch, files };
          return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
        } finally {
          await git(["worktree", "remove", "--force", cwd]);
        }
      },
    }),
  );
}

The main agent gets back a typed report and a branch to merge. Workers run with no person attached, so anything that would ask is refused. To let them run shell commands, use "mode": "yolo", inside a container.

10. Hold the door shut

This is the step that turns a coding agent into an engineering harness. Pi fires agent_before_settle when a run is about to end, and a handler can refuse. So "I'm done" stops being the model's call.

src/features/gates.ts
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext, ModelRuntime } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { runChild } from "../child.ts";
import type { Config } from "../config.ts";
import { isProtected } from "../policy.ts";
import type { HarnessState } from "../state.ts";

const SNAPSHOT = "git status --porcelain; git diff HEAD; git ls-files -o --exclude-standard | xargs shasum 2>/dev/null";

export const Review = Type.Object({
  verdict: Type.Union([Type.Literal("ship"), Type.Literal("fix")]),
  findings: Type.Array(
    Type.Object({
      priority: Type.Union(["P0", "P1", "P2", "P3"].map((p) => Type.Literal(p))),
      file: Type.String(),
      issue: Type.String(),
    }),
  ),
});

export function installGates(pi: ExtensionAPI, state: HarnessState, modelRuntime?: ModelRuntime) {
  let baseline: string | undefined;
  let attempts = 0;
  const originals = new Map<string, string>();

  const sh = (command: string, ctx: ExtensionContext) =>
    pi.exec("bash", ["-c", command], { cwd: ctx.cwd, signal: ctx.signal, timeout: 600_000 });

  const snapshot = async (ctx: ExtensionContext) => (await sh(SNAPSHOT, ctx)).stdout;

  const push = (text: string) => ({ type: "custom_message" as const, customType: "openharness-gate", content: text, display: true });

  /** Remembers frozen files as they were when the task began. */
  async function freeze(config: Config, ctx: ExtensionContext) {
    originals.clear();
    for (const file of (await sh("git ls-files", ctx)).stdout.split("\n")) {
      if (file && isProtected(file, config.frozen)) {
        originals.set(file, readFileSync(join(ctx.cwd, file), "utf8"));
      }
    }
  }

  /** Puts frozen files back, whichever tool changed them. Bash cannot get around this. */
  function thaw(ctx: ExtensionContext) {
    const changed: string[] = [];
    for (const [file, text] of originals) {
      const path = join(ctx.cwd, file);
      if (existsSync(path) && readFileSync(path, "utf8") === text) continue;
      writeFileSync(path, text);
      changed.push(file);
    }
    return changed;
  }

  async function verify(config: Config, ctx: ExtensionContext) {
    for (const command of config.verify) {
      const result = await sh(command, ctx);
      state.record({ kind: "verify", command, ok: result.code === 0 });
      if (result.code !== 0) {
        const tail = `${result.stdout}\n${result.stderr}`.trim().slice(-3000);
        return `\`${command}\` failed with exit code ${result.code}:\n\n${tail}`;
      }
    }
    return undefined;
  }

  async function review(config: Config, ctx: ExtensionContext) {
    if (!config.review.enabled) return undefined;
    const diff = (await sh("git diff HEAD; git ls-files -o --exclude-standard", ctx)).stdout;
    const [provider, ...id] = config.review.model?.split("/") ?? [];
    const model = provider ? ctx.modelRegistry.find(provider, id.join("/")) : ctx.model;
    if (!model) throw new Error(`Unknown review model: ${config.review.model}`);

    const result = await runChild({
      cwd: ctx.cwd,
      role: "reviewer",
      task: `You did not write this change. Review it for bugs, not style. Read files if you need more.\n\n${diff}`,
      schema: Review,
      tools: ["read", "grep", "find", "ls"],
      model,
      modelRuntime,
    });

    state.record({ kind: "review", verdict: result.verdict, findings: result.findings.length });
    const blocking = result.findings.filter((f) => (config.review.blockOn as string[]).includes(f.priority));
    if (!blocking.length) return undefined;
    return `Review found:\n${blocking.map((f) => `- ${f.priority} ${f.file}: ${f.issue}`).join("\n")}`;
  }

  pi.on("input", async (_event, ctx) => {
    attempts = 0;
    baseline = await snapshot(ctx);
    const { config } = state.load(ctx.cwd);
    if (config) await freeze(config, ctx);
  });

  // The run wants to finish. This is the only door, and the gates hold it shut.
  pi.on("agent_before_settle", async (event, ctx) => {
    const { config, error } = state.load(ctx.cwd);
    if (event.outcome !== "completed" || state.planning) return undefined;
    if (!config) {
      state.status = "failed";
      state.record({ kind: "status", status: "failed", reason: error });
      return { entries: [...event.entries, push(`OpenHarness is blocking writes: ${error}`)] };
    }

    const thawed = thaw(ctx);
    if (thawed.length) state.record({ kind: "tamper", files: thawed });

    const unchanged = !thawed.length && (await snapshot(ctx)) === baseline;
    if (unchanged && !state.expectChange) {
      state.status = "unchanged";
      return undefined;
    }

    let problem: string | undefined;
    try {
      if (thawed.length) {
        problem = `You changed ${thawed.join(", ")}, which is frozen. It has been restored. Find the code it tests and fix that instead.`;
      } else if (unchanged) {
        problem = "You have not changed anything yet. Find the code under test, read it, and fix it there.";
      } else {
        problem = (await verify(config, ctx)) ?? (await review(config, ctx));
      }
    } catch (failure) {
      // A gate that cannot run has not passed. Never turn that into a green result.
      state.status = "failed";
      state.record({ kind: "status", status: "failed", reason: String(failure) });
      return { entries: [...event.entries, push(`OpenHarness could not finish its checks: ${failure}`)] };
    }

    if (!problem) {
      state.status = "passed";
      state.record({ kind: "status", status: "passed" });
      return undefined;
    }

    attempts += 1;
    if (attempts > config.maxRetries) {
      state.status = "failed";
      state.record({ kind: "status", status: "failed", reason: "out of retries" });
      return { entries: [...event.entries, push(`Giving up after ${config.maxRetries} retries.\n\n${problem}`)] };
    }
    return { entries: [...event.entries, push(`Not done yet. ${problem}`)], continue: true };
  });
}

Follow the logic. If nothing changed, a question stays a question and no checks run. Otherwise your verify commands run, and failing output goes straight back to the model. Then a reviewer that never saw the work reads the diff and must return a typed verdict. Findings at or above your threshold send the work back too.

The first version of this gate was fooled almost every time. On a benchmark with a local 7B model, 7 of 8 runs came back passed, and in every one the model had rewritten the test. The gate had checked whatever was on disk. Telling the model not to change the test made no difference.

That's why freeze and thaw are here. When a task starts, the gate remembers every frozen file. Before it verifies anything, it puts them back, whichever tool changed them, and tells the model why. The guard refuses direct writes for fast feedback, but this is the part bash can't get around. Your checks are only worth something if the model can't edit them.

Four details carry the weight:

  • Retries are capped, so a model that can't fix it fails loudly instead of burning tokens all night.
  • A task has to change something. Refused one edit, that same model simply gave up and asked for help, which looked like success. With expectChange on, doing nothing gets sent back too.
  • A gate that cannot run is a failure. If the reviewer crashes or never reports, the status is failed, and the catch block says so on screen.
  • The reviewer can be a different model. Set review.model to provider/id so one model isn't marking its own homework.

Tie in

Wire it together, install it, and make it usable by people and by CI.

11. Keep a ledger

Pi already saves full sessions. The ledger is the compact version: one line per tool, turn, block, and gate, so a run can be summarized and compared.

src/features/ledger.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { HarnessState, LedgerEntry } from "../state.ts";

export function installLedger(pi: ExtensionAPI, state: HarnessState) {
  pi.on("tool_execution_end", (event) => {
    state.record({ kind: "tool", tool: event.toolName, ok: !event.isError });
  });

  pi.on("message_end", (event) => {
    if (event.message.role !== "assistant") return;
    const { totalTokens, cost } = event.message.usage;
    state.record({ kind: "turn", tokens: totalTokens, cost: cost.total });
  });

  pi.registerCommand("report", {
    description: "Summarize this run",
    handler: async (_args, ctx) => ctx.ui.notify(JSON.stringify(summarize(state.entries), null, 2), "info"),
  });
}

export function summarize(entries: LedgerEntry[]) {
  const count = (kind: string, test: (e: LedgerEntry) => boolean = () => true) =>
    entries.filter((e) => e.kind === kind && test(e)).length;
  const turns = entries.filter((e) => e.kind === "turn");
  const last = entries.findLast((e) => e.kind === "status");

  return {
    status: (last?.status as string) ?? "unchanged",
    turns: turns.length,
    tools: count("tool"),
    toolErrors: count("tool", (e) => e.ok === false),
    blocked: count("blocked"),
    tampered: count("tamper"),
    rules: count("rule"),
    verifyFailures: count("verify", (e) => e.ok === false),
    reviews: count("review"),
    tokens: turns.reduce((sum, e) => sum + Number(e.tokens), 0),
    cost: turns.reduce((sum, e) => sum + Number(e.cost), 0),
  };
}

12. Wire it up and install it

One function installs every feature. The order is deliberate: the guard goes first, so nothing runs before it.

src/install.ts
import type { ExtensionAPI, ModelRuntime } from "@earendil-works/pi-coding-agent";
import { installDelegate } from "./features/delegate.ts";
import { installEdits } from "./features/edits.ts";
import { installGates } from "./features/gates.ts";
import { installGuard } from "./features/guard.ts";
import { installLedger } from "./features/ledger.ts";
import { installPlan } from "./features/plan.ts";
import { installStreamRules } from "./features/stream-rules.ts";
import { HarnessState } from "./state.ts";

export type InstallOptions = { state?: HarnessState; modelRuntime?: ModelRuntime };

export function install(pi: ExtensionAPI, { state = new HarnessState(), modelRuntime }: InstallOptions = {}) {
  installGuard(pi, state);
  installEdits(pi, state);
  installStreamRules(pi, state);
  installPlan(pi, state);
  installDelegate(pi, state, modelRuntime);
  installGates(pi, state, modelRuntime);
  installLedger(pi, state);
  return state;
}
extensions/openharness.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { install } from "../src/install.ts";

export default function openharness(pi: ExtensionAPI) {
  install(pi);
}
npm run typecheck
pi install "$PWD"
pi list

That's it. Pi now loads OpenHarness in every project. To share it with a team, push the folder and install it with pi install git:github.com/you/openharness.

13. Teach the model the route

Gates work whether the model knows about them or not, but it wastes fewer turns if it does. A skill is instructions that load on demand, so it costs a line in the prompt until it's needed.

skills/engineering-loop/SKILL.md
---
name: engineering-loop
description: How to work inside OpenHarness. Use for any change to this codebase that will go through verification and review.
---

# The engineering loop

1. Read before you write. Use `read_lines`, because its anchors are how you edit.
2. Make the smallest change that solves the task.
3. Run the project's checks yourself before you say you are done.
4. Stop when you are done. The harness runs `verify` and review when you finish, and it will send you back with the reason if either fails.
5. If you are sent back, fix the cause. Never edit a test only to make it pass.
6. Split work that has independent parts with `delegate`, then merge each branch it returns.

A prompt template turns the whole loop into one command.

prompts/ship.md
---
description: Take a task from plan to a reviewed change
argument-hint: "<task>"
---
Use the engineering-loop skill. Task: $@

Make the change, get the checks green, and finish with a short summary of what changed and why.
cd ~/your-project
pi
/plan add input validation to the signup form
/ship fix the failing date test
/report

14. Give CI a real exit code

A harness you can only drive by hand isn't finished. Pi's SDK runs the same session inside your own process, so one function can serve the command line and the tests.

src/harness.ts
import { fileURLToPath } from "node:url";
import type { Model } from "@earendil-works/pi-ai";
import {
  createAgentSession,
  DefaultResourceLoader,
  getAgentDir,
  type ModelRuntime,
  SessionManager,
  SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { summarize } from "./features/ledger.ts";
import { install } from "./install.ts";
import { HarnessState } from "./state.ts";

const root = (path: string) => fileURLToPath(new URL(`../${path}`, import.meta.url));

export type HarnessOptions = { cwd: string; model?: Model<any>; modelRuntime?: ModelRuntime };

/** Pi, with OpenHarness installed, as a plain function. The CLI and the tests both use this. */
export async function createHarness({ cwd, model, modelRuntime }: HarnessOptions) {
  const state = new HarnessState();
  state.load(cwd);

  const agentDir = getAgentDir();
  const resourceLoader = new DefaultResourceLoader({
    cwd,
    agentDir,
    noExtensions: true,
    additionalSkillPaths: [root("skills")],
    additionalPromptTemplatePaths: [root("prompts")],
    extensionFactories: [(pi) => void install(pi, { state, modelRuntime })],
  });
  await resourceLoader.reload();

  const { session } = await createAgentSession({
    cwd,
    agentDir,
    model,
    modelRuntime,
    resourceLoader,
    sessionManager: SessionManager.inMemory(cwd),
    settingsManager: SettingsManager.inMemory({ compaction: { enabled: true } }),
  });

  return {
    session,
    state,
    /** A task is meant to change something. Pass expectChange: false for a question. */
    async run(task: string, { expectChange = true } = {}) {
      state.expectChange = expectChange;
      await session.prompt(task);
      await session.waitForIdle();
      return summarize(state.entries);
    },
  };
}
bin/openharness.ts
#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { defaults } from "../src/config.ts";
import { summarize } from "../src/features/ledger.ts";
import { createHarness } from "../src/harness.ts";

const [command, ...rest] = process.argv.slice(2);
const cwd = process.cwd();

function flag(name: string) {
  const index = rest.indexOf(name);
  return index < 0 ? undefined : rest.splice(index, 2)[1];
}

async function run() {
  const wanted = flag("--model");
  const modelRuntime = await ModelRuntime.create();
  const [provider, ...id] = wanted?.split("/") ?? [];
  const model = provider ? modelRuntime.getModel(provider, id.join("/")) : undefined;
  if (wanted && !model) throw new Error(`Unknown model: ${wanted}`);

  const harness = await createHarness({ cwd, model, modelRuntime });
  const summary = await harness.run(rest.join(" "));
  harness.session.dispose();
  console.log(JSON.stringify(summary, null, 2));
  process.exitCode = summary.status === "passed" ? 0 : 1;
}

function report() {
  const folder = join(cwd, ".openharness/runs");
  const last = existsSync(folder) ? readdirSync(folder).sort().at(-1) : undefined;
  if (!last) throw new Error("No runs yet.");
  const lines = readFileSync(join(folder, last), "utf8").trim().split("\n");
  console.log(JSON.stringify(summarize(lines.map((line) => JSON.parse(line))), null, 2));
}

function init() {
  const file = join(cwd, "harness.json");
  if (existsSync(file)) throw new Error("harness.json already exists.");
  writeFileSync(file, `${JSON.stringify({ ...defaults, verify: ["npm test"] }, null, 2)}\n`);
  console.log("Wrote harness.json. Set verify to your project's checks.");
}

const commands: Record<string, () => void | Promise<void>> = { init, run, report };
const chosen = commands[command];
if (!chosen) {
  console.log('usage: openharness init | run "<task>" [--model provider/id] | report');
  process.exit(2);
}
try {
  await chosen();
} catch (error) {
  console.error(`openharness: ${(error as Error).message}`);
  process.exit(1);
}
npm link
cd ~/your-project
openharness init
openharness run "fix the failing date test"
echo "exit code: $?"
openharness report

Now a pipeline can run openharness run "..." and trust the exit code. It is 0 only when the status is passed, however confident the model sounded.

Before you publish to npm: this file runs because npm link keeps it outside node_modules. Node won't strip types inside node_modules, so a published CLI needs a build step. Installing through pi install isn't affected.

Prove it

A harness makes claims about failure. So test the failures.

15. Test the harness without a model

Pi ships a scripted model. It runs the real loop, real tools, and real gates, and replies from a list you write. That means you can prove what happens when a model lies about being done, for free, in milliseconds.

test/helpers.ts
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFauxProvider } from "@earendil-works/pi-ai/compat";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { createHarness } from "../src/harness.ts";

const BUGGY = "export const add = (a, b) => a - b;\n";
const TEST = 'import { add } from "./math.js";\nif (add(2, 2) !== 4) throw new Error("add(2, 2) should be 4");\n';

/** A tiny git repo with one bug and one failing check. */
export function sandbox(config: object = {}) {
  const cwd = mkdtempSync(join(tmpdir(), "openharness-"));
  writeFileSync(join(cwd, "package.json"), '{ "type": "module" }\n');
  writeFileSync(join(cwd, "math.js"), BUGGY);
  writeFileSync(join(cwd, "check.js"), TEST);
  writeFileSync(join(cwd, "harness.json"), JSON.stringify({ verify: ["node check.js"], ...config }));
  const git = (...args: string[]) => execFileSync("git", args, { cwd, stdio: "pipe" }).toString();
  git("init", "-q");
  git("config", "user.email", "test@example.com");
  git("config", "user.name", "Test");
  git("add", "-A");
  git("commit", "-q", "-m", "start");
  return { cwd, git };
}

/** Real Pi, real tools, real gates. Only the model is scripted. */
export async function scripted(cwd: string, tokensPerSecond = 0) {
  const faux = registerFauxProvider({ tokensPerSecond });
  const model = faux.getModel();
  const modelRuntime = await ModelRuntime.create({ modelsPath: null, allowModelNetwork: false });
  modelRuntime.registerProvider(model.provider, {
    baseUrl: model.baseUrl,
    api: model.api,
    apiKey: "scripted",
    models: [{ ...model }],
  });
  const harness = await createHarness({ cwd, model, modelRuntime });
  return { ...harness, faux };
}

The pure logic gets plain unit tests:

test/pure.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { applyEdits, lineHash, render, StaleAnchor } from "../src/hashline.ts";
import { decide, normalize, type Policy } from "../src/policy.ts";
import { firstHit, produced } from "../src/rules.ts";

const write: Policy = { mode: "write", protect: [".env*", "secrets/**", ".git/**"] };
const decision = (tool: string, input: object, policy = write) => decide({ tool, input: { ...input } }, policy).decision;

test("tiers follow the mode", () => {
  assert.equal(decision("read", { path: "a.ts" }), "allow");
  assert.equal(decision("write", { path: "a.ts" }), "allow");
  assert.equal(decision("bash", { command: "npm test" }), "ask");
  assert.equal(decision("write", { path: "a.ts" }, { ...write, mode: "ask" }), "ask");
  assert.equal(decision("bash", { command: "npm test" }, { ...write, mode: "yolo" }), "allow");
});

test("an unknown tool is treated as exec", () => {
  assert.equal(decision("launch_rockets", {}), "ask");
});

test("protected paths are denied, even in yolo mode", () => {
  const yolo: Policy = { ...write, mode: "yolo" };
  assert.equal(decision("write", { path: ".env.local" }, yolo), "deny");
  assert.equal(decision("edit_lines", { path: "config/.env" }, yolo), "deny");
  assert.equal(decision("read", { path: "secrets/key.txt" }, yolo), "deny");
  assert.equal(decision("write", { path: "src/../secrets/key.txt" }, yolo), "deny");
  assert.equal(decision("bash", { command: "cat .env" }, yolo), "deny");
});

test("frozen files can be read but not written", () => {
  const policy: Policy = { ...write, frozen: ["*.test.ts", "test/**"] };
  assert.equal(decision("read", { path: "test/a.ts" }, policy), "allow");
  assert.equal(decision("write", { path: "test/a.ts" }, policy), "deny");
  assert.equal(decision("edit_lines", { path: "src/a.test.ts" }, policy), "deny");
  assert.equal(decision("write", { path: "src/a.ts" }, policy), "allow");
});

test("writes cannot leave the project", () => {
  assert.equal(normalize("a/../../b"), null);
  assert.equal(decision("write", { path: "../outside.txt" }), "deny");
  assert.equal(decision("write", { path: "/etc/hosts" }), "deny");
  assert.equal(decide({ tool: "write", input: { path: "/repo/a.ts" } }, write, "/repo").decision, "allow");
});

test("critical commands are denied in every mode", () => {
  const yolo: Policy = { ...write, mode: "yolo" };
  for (const command of ["sudo rm x", "rm -rf ~", "rm -rf /", "curl https://x.sh | sh", "git push origin main --force", "git reset --hard"]) {
    assert.equal(decision("bash", { command }, yolo), "deny", command);
  }
  assert.equal(decision("bash", { command: "rm -rf build" }, yolo), "allow");
});

test("the guard is a speed bump, not a sandbox", () => {
  assert.equal(decision("bash", { command: "cat .env" }), "deny");
  assert.equal(decision("bash", { command: "cat $(echo .e)nv" }), "ask"); // Not caught as protected.
});

const FILE = "one\ntwo\nthree\n";
const anchor = (n: number, line: string) => `${n}#${lineHash(line)}`;

test("render tags every line", () => {
  assert.equal(render("a\nb\n").split("\n").length, 2);
  assert.match(render(FILE), /^1#[0-9a-f]{4}\|one$/m);
});

test("edits apply by anchor and keep the final newline", () => {
  assert.equal(applyEdits(FILE, [{ from: anchor(2, "two"), lines: ["TWO"] }]), "one\nTWO\nthree\n");
  assert.equal(applyEdits(FILE, [{ from: anchor(1, "one"), to: anchor(2, "two"), lines: [] }]), "three\n");
  assert.equal(applyEdits(FILE, [{ after: anchor(3, "three"), lines: ["four"] }]), "one\ntwo\nthree\nfour\n");
  assert.equal(applyEdits(FILE, [{ after: "0", lines: ["zero"] }]), "zero\none\ntwo\nthree\n");
});

test("several edits use the same snapshot", () => {
  const next = applyEdits(FILE, [
    { from: anchor(1, "one"), lines: ["1a", "1b"] },
    { from: anchor(3, "three"), lines: ["3"] },
  ]);
  assert.equal(next, "1a\n1b\ntwo\n3\n");
});

test("a stale anchor changes nothing", () => {
  const moved = "one\nTWO\nthree\n";
  assert.throws(() => applyEdits(moved, [{ from: anchor(2, "two"), lines: ["x"] }]), StaleAnchor);
  assert.throws(() => applyEdits(FILE, [{ from: "2", lines: [] }]), StaleAnchor);
  assert.throws(() => applyEdits(FILE, [{ from: "9#abcd", lines: [] }]), StaleAnchor);
});

test("overlapping edits are refused", () => {
  const edits = [
    { from: anchor(1, "one"), to: anchor(2, "two"), lines: [] },
    { from: anchor(2, "two"), lines: [] },
  ];
  assert.throws(() => applyEdits(FILE, edits), StaleAnchor);
});

test("rules see tool arguments and fire once", () => {
  const rule = { name: "no-log", pattern: "console\\.log", message: "Use the logger." };
  const text = produced([{ type: "toolCall", arguments: { content: "console.log(1)" } }]);
  assert.equal(firstHit(text, [rule], new Set())?.name, "no-log");
  assert.equal(firstHit(text, [rule], new Set(["no-log"])), undefined);
});

And the harness gets scenarios. Read the test names: each one is a promise.

test/harness.test.ts
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { setTimeout } from "node:timers/promises";
import { fauxAssistantMessage as say, fauxToolCall as call } from "@earendil-works/pi-ai";
import { lineHash } from "../src/hashline.ts";
import { scripted, sandbox } from "./helpers.ts";

const BUG = "export const add = (a, b) => a - b;";
const STILL_WRONG = "export const add = (a, b) => a * b + 1;";
const FIXED = "export const add = (a, b) => a + b;";
const edit = (from: string, to: string) =>
  say(call("edit_lines", { path: "math.js", edits: [{ from: `1#${lineHash(from)}`, lines: [to] }] }));
const ship = say(call("report", { verdict: "ship", findings: [] }));
const text = (path: string, cwd: string) => readFileSync(join(cwd, path), "utf8");

test("the verify gate refuses to finish until the checks pass", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([edit(BUG, STILL_WRONG), say("Done!"), edit(STILL_WRONG, FIXED), say("Done now."), ship, say("Summary.")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "passed");
  assert.equal(result.verifyFailures, 1);
  assert.equal(result.reviews, 1);
  assert.equal(text("math.js", cwd), `${FIXED}\n`);
  h.session.dispose();
});

test("a model that never fixes it runs out of retries and fails", async () => {
  const { cwd } = sandbox({ maxRetries: 1 });
  const h = await scripted(cwd);
  h.faux.setResponses([edit(BUG, STILL_WRONG), say("Done!"), say("Still done!")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "failed");
  assert.equal(result.verifyFailures, 2);
  h.session.dispose();
});

test("the failing output reaches the model", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  let seen = "";
  h.faux.setResponses([
    edit(BUG, STILL_WRONG),
    say("Done!"),
    (context) => {
      seen = JSON.stringify(context.messages.at(-1));
      return edit(STILL_WRONG, FIXED);
    },
    say("Fixed."),
    ship,
    say("Summary."),
  ]);

  await h.run("fix add");

  assert.match(seen, /add\(2, 2\) should be 4/);
  h.session.dispose();
});

test("review can send the work back", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  const finding = { priority: "P1", file: "math.js", issue: "No test for negatives." };
  h.faux.setResponses([
    edit(BUG, FIXED),
    say("Done."),
    say(call("report", { verdict: "fix", findings: [finding] })),
    say(call("write", { path: "negatives.js", content: "// covered\n" })),
    say("Added."),
    ship,
    say("Summary."),
  ]);

  const result = await h.run("fix add");

  assert.equal(result.status, "passed");
  assert.equal(result.reviews, 2);
  h.session.dispose();
});

test("a reviewer that never reports fails the run, not passes it", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([edit(BUG, FIXED), say("Done."), say("Looks fine to me, no report.")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "failed");
  h.session.dispose();
});

test("a typo in the review model fails, it does not fall back", async () => {
  const { cwd } = sandbox({ review: { model: "nobody/nothing" } });
  const h = await scripted(cwd);
  h.faux.setResponses([edit(BUG, FIXED), say("Done.")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "failed");
  h.session.dispose();
});

test("a frozen check cannot be edited, but it can be read", async () => {
  const { cwd } = sandbox({ frozen: ["check.js"] });
  const h = await scripted(cwd);
  h.faux.setResponses([
    say(call("read_lines", { path: "check.js" })),
    say(call("write", { path: "check.js", content: "// gone\n" })),
    say("OK."),
  ]);

  const result = await h.run("fix add", { expectChange: false });

  assert.equal(result.blocked, 1);
  assert.equal(result.toolErrors, 1, "only the write fails");
  assert.match(text("check.js", cwd), /should be 4/);
  h.session.dispose();
});

test("rewriting a frozen check through bash is undone and sent back", async () => {
  const { cwd } = sandbox({ mode: "yolo", frozen: ["check.js"] });
  const h = await scripted(cwd);
  let told = "";
  h.faux.setResponses([
    say(call("bash", { command: "echo 'console.log(1)' > check.js" })),
    say("All green!"),
    (context) => {
      told = JSON.stringify(context.messages.at(-1));
      return edit(BUG, FIXED);
    },
    say("Fixed for real."),
    ship,
    say("Summary."),
  ]);

  const result = await h.run("fix add");

  assert.match(told, /which is frozen/);
  assert.match(text("check.js", cwd), /should be 4/);
  assert.equal(result.tampered, 1);
  assert.equal(result.status, "passed");
  h.session.dispose();
});

test("without frozen, a rewritten check passes: the hole this closes", async () => {
  const { cwd } = sandbox({ mode: "yolo", review: { enabled: false } });
  const h = await scripted(cwd);
  h.faux.setResponses([say(call("bash", { command: "echo 'console.log(1)' > check.js" })), say("All green!")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "passed");
  assert.equal(text("math.js", cwd), `${BUG}\n`);
  h.session.dispose();
});

test("a task that changes nothing is sent back, then fails", async () => {
  const { cwd } = sandbox({ maxRetries: 1 });
  const h = await scripted(cwd);
  h.faux.setResponses([say("Please make the change yourself."), say("I still won't."), say("No.")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "failed");
  assert.equal(result.turns, 2);
  h.session.dispose();
});

test("a question that changes nothing skips the gates", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([say("add subtracts. That looks like a bug.")]);

  const result = await h.run("what does add do?", { expectChange: false });

  assert.equal(result.status, "unchanged");
  assert.equal(result.verifyFailures, 0);
  h.session.dispose();
});

test("the guard blocks a protected write and the run carries on", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([say(call("write", { path: ".env", content: "KEY=1" })), say("Understood.")]);

  const result = await h.run("save the key", { expectChange: false });

  assert.equal(result.blocked, 1);
  assert.equal(existsSync(join(cwd, ".env")), false);
  h.session.dispose();
});

test("with no UI, a call that needs approval is refused", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([say(call("bash", { command: "touch made-by-bash" })), say("OK.")]);

  const result = await h.run("run it", { expectChange: false });

  assert.equal(result.blocked, 1);
  assert.equal(existsSync(join(cwd, "made-by-bash")), false);
  h.session.dispose();
});

test("a broken harness.json fails closed", async () => {
  const { cwd } = sandbox();
  writeFileSync(join(cwd, "harness.json"), "{ not json");
  const h = await scripted(cwd);
  h.faux.setResponses([say(call("write", { path: "a.txt", content: "x" })), say("OK.")]);

  await h.run("write a file", { expectChange: false });

  assert.equal(existsSync(join(cwd, "a.txt")), false);
  assert.equal(h.state.status, "failed");
  h.session.dispose();
});

test("a stale edit is rejected and the file is untouched", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([edit("this is not the file", FIXED), say("Hm.")]);

  const result = await h.run("fix add", { expectChange: false });

  assert.equal(result.toolErrors, 1);
  assert.equal(text("math.js", cwd), `${BUG}\n`);
  h.session.dispose();
});

test("the stock editor is swapped for the anchored one", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([say("Hello.")]);

  await h.run("hi", { expectChange: false });

  const tools = h.session.getActiveToolNames();
  assert.ok(tools.includes("edit_lines") && tools.includes("read_lines"));
  assert.ok(!tools.includes("edit") && !tools.includes("read"));
  h.session.dispose();
});

test("a stream rule stops the model, drops the attempt, and retries", async () => {
  const rule = { name: "no-log", pattern: "console\\.log", message: "Use the logger." };
  const { cwd } = sandbox({ rules: [rule] });
  const h = await scripted(cwd, 400);
  let retry = "";
  h.faux.setResponses([
    say(call("write", { path: "bad.js", content: `console.log("hi"); // ${"padding ".repeat(60)}` })),
    (context) => {
      retry = JSON.stringify(context.messages);
      return say("I will use the logger.");
    },
  ]);

  const result = await h.run("add logging");

  assert.equal(result.rules, 1);
  assert.equal(existsSync(join(cwd, "bad.js")), false, "the tool call must never run");
  assert.match(retry, /Use the logger/);
  assert.doesNotMatch(retry, /padding/, "the bad attempt must be gone");
  h.session.dispose();
});

test("/plan is read-only, and an unapproved plan stops", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  let tools: string[] = [];
  h.faux.setResponses([
    () => {
      tools = h.session.getActiveToolNames();
      return say(call("submit_plan", { goal: "Fix add", steps: ["Change - to +"], verification: "node check.js" }));
    },
  ]);

  await h.session.prompt("/plan fix add");
  while (h.faux.getPendingResponseCount() > 0) await setTimeout(5); // commands queue their run
  await h.session.waitForIdle();

  assert.deepEqual(tools.sort(), ["find", "grep", "ls", "read", "submit_plan"]);
  assert.match(text(".openharness/plan.md", cwd), /Fix add/);
  assert.equal(text("math.js", cwd), `${BUG}\n`);
  h.session.dispose();
});

test("parallel workers get isolated worktrees and typed reports", async () => {
  const { cwd, git } = sandbox();
  const h = await scripted(cwd);
  const worker = (name: string) => [
    say(call("write", { path: `${name}.txt`, content: name })),
    say(call("report", { summary: `Wrote ${name}`, risks: [] })),
  ];
  h.faux.setResponses([
    say([call("delegate", { task: "write a" }), call("delegate", { task: "write b" })]),
    ...worker("a"),
    ...worker("b"),
    say("Both done."),
  ]);

  await h.run("do two things", { expectChange: false });

  const branches = git("branch", "--list", "openharness/*").trim().split("\n");
  assert.equal(branches.length, 2);
  assert.equal(git("worktree", "list").trim().split("\n").length, 1, "worktrees are cleaned up");
  assert.equal(existsSync(join(cwd, "a.txt")), false, "the main tree stays clean");
  h.session.dispose();
});

test("a worker cannot reach protected files either", async () => {
  const { cwd, git } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([
    say(call("delegate", { task: "save the key" })),
    say(call("write", { path: ".env", content: "KEY=1" })),
    say(call("report", { summary: "Blocked.", risks: ["Could not write .env"] })),
    say("OK."),
  ]);

  await h.run("delegate it", { expectChange: false });

  const branch = git("branch", "--list", "openharness/*").trim();
  assert.equal(git("show", "--stat", "--format=", branch).trim(), "");
  h.session.dispose();
});

test("a worker that fails still cleans up its worktree", async () => {
  const { cwd, git } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([say(call("delegate", { task: "do it" })), say("I will not report."), say("It failed.")]);

  const result = await h.run("delegate it", { expectChange: false });

  assert.equal(result.toolErrors, 1);
  assert.equal(git("worktree", "list").trim().split("\n").length, 1);
  h.session.dispose();
});

test("the /ship prompt and the skill ship with the package", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  let seen = "";
  h.faux.setResponses([
    (context) => {
      seen = JSON.stringify(context.messages);
      return say("Nothing to do.");
    },
  ]);

  await h.run("/ship fix add", { expectChange: false });

  assert.match(seen, /Use the engineering-loop skill\. Task: fix add/);
  assert.match(seen, /engineering-loop/);
  h.session.dispose();
});

test("every run leaves a ledger", async () => {
  const { cwd } = sandbox();
  const h = await scripted(cwd);
  h.faux.setResponses([edit(BUG, FIXED), say("Done."), ship, say("Summary.")]);

  const result = await h.run("fix add");

  assert.equal(result.status, "passed");
  assert.ok(result.turns >= 2 && result.tools >= 1);
  assert.ok(readdirSync(join(cwd, ".openharness/runs")).length === 1);
  h.session.dispose();
});
npm test
ℹ tests 36
ℹ pass 36
ℹ fail 0

Then point it at a real model

A scripted model proves the gates work. It can't show what a real model does when it meets them. So the harness also ran against a small local one, Qwen2.5-Coder 7B, on eight tiny JavaScript bugs. Every task said the same thing: fix the bug so node check.js passes, and don't change check.js. A run only counted as solved if the test passed and git showed it unchanged.

SetupSolvedSaid passedFalse passesTest left modified
Stock Pi0 of 8No verdictn/a4 of 8
Verify gate only2 of 86 of 846 of 8
Gate and frozen3 of 83 of 800 of 8
Gate, frozen, and hashline3 of 83 of 800 of 8

Look at false passes first. With a gate alone, the harness said passed six times and four of those were wrong, because the model had rewritten the test. That's worse than stock Pi, which at least promises nothing. With frozen, false passes went to zero, and every passed was real.

Trust the solved column least. The frozen setup solved 0 of 8 until its refusal messages were reworded to tell the model where to look, as in "Find the code it tests and fix that instead." Your refusals are prompts, so write them like prompts. The last two rows also solved mostly different tasks, so 3 of 8 is not a stable rate, and hashline made no measurable difference here.

What this table can't tell you. This is one model at 4-bit, eight tiny tasks, and one greedy run per cell, with the reviewer off. It ran through a test-only adapter that starts llama-completion on every turn, not a normal provider. The stock row comes from the run before the rewording, which stock Pi never sees. No frontier model was tested, so this says nothing about how much a harness adds to a strong one.

So the claim is narrow. The harness didn't make a weak model smart. It made its failures visible, and that's what a pipeline needs from it.

Where this stops working

A harness narrows the ways things go wrong. It doesn't remove them.

  • It won't make a weak model good. The best setup in step 15 solved 3 of 8 tiny bugs. What changed is that the other 5 said failed.
  • Your checks are the ceiling. The verify gate is only as good as your tests. Weak tests give you a green run and a bad change.
  • The guard is not a sandbox. It matches words, and bash is expressive. Run unattended work in a container with no secrets.
  • Models will game whatever you leave open. frozen stops a rewritten test, but not a hard-coded answer that satisfies it. Review helps, and so does reading the diff.
  • Review is one opinion. A reviewer can miss things, and it costs tokens on every change. Use a different model, and turn it off where it doesn't pay.
  • It tracks a moving target. Pi changes fast. Pin the version, and let the tests tell you when an upgrade breaks a hook.

Top of the route

Every anchor held. The model did the climbing, and the harness decided how far it could fall. Now put your own rules in harness.json and go.

Built and tested in November 2026 against Pi 1.5.0 and OhMyPi 18.16.0 on macOS 26.7 with Node 24. Pi and OhMyPi are MIT licensed. Every TypeScript file on this page was typechecked and tested as the package you can download.

Mountains are steep and APIs change. No em-dashes were harmed in the making of this guide.