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.
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
Build on Pi, don't fork it. A package upgrades with a version bump. A fork upgrades with a merge.
Take ideas, give credit. OhMyPi solved these problems first. This guide reimplements its ideas in small files, and copies none of its code.
Fail closed. A broken policy file, a missing reviewer, or a gate that can't run is a failure, never a pass.
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.
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.
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.
~/openharness/01-pibase-camp✓
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.
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.
~/openharness/02-ohmypibase-camp✓
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 --binaryomp--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 OhMyPi
What it does
Pi seam
Step
Approval modes
Tiers tools into read, write, and exec
tool_call
5
Hashline edits
Edits by content hash, not by retyping
registerTool
6
Stream rules
Aborts mid-token, injects a rule, retries
message_update
7
Plan mode
Explores read-only before it changes anything
setActiveTools
8
Subagents
Worktrees and schema-checked results
SDK
9
Code review
Findings ranked P0 to P3, with a verdict
agent_before_settle
10
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.
~/openharness/03-packagebase-camp✓
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 -qmkdir-p src/features extensions bin skills/engineering-loop prompts test
printf'node_modules/\n.openharness/\n' > .gitignore
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.
~/openharness/04-policybase-camp✓
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:
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.
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 { typeConfig, loadConfig } from"./config.ts";
exporttypeStatus = "idle" | "unchanged" | "passed" | "failed";
exporttypeLedgerEntry = { kind: string; [key: string]: unknown };
/** One object shared by every feature in a session. */exportclassHarnessState {
status: Status = "idle";
planning = false;
expectChange = false;
entries: LedgerEntry[] = [];
private loaded?: { config?: Config; error?: string };
private cwd?: string;
private runId = newDate().toISOString().replace(/[:.]/g, "-");
/** Loaded once, on first use, because extensions only learn the cwd from an event. */
load(cwd: string) {
if (this.loaded) returnthis.loaded;
this.cwd = cwd;
try {
this.loaded = { config: loadConfig(cwd) };
} catch (error) {
this.loaded = { error: String(error) };
}
returnthis.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: newDate().toISOString(), ...entry });
appendFileSync(join(this.folder("runs"), `${this.runId}.jsonl`), `${line}\n`);
}
}
Place the protection
Three anchors that hold on every single tool call.
~/openharness/05-guardprotection✓
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. */exporttypeTier = "read" | "write" | "exec";
exporttypeDecision = "allow" | "ask" | "deny";
exporttypeVerdict = { decision: Decision; reason: string };
exporttypePolicy = { mode: "ask" | "write" | "yolo"; protect: string[]; frozen?: string[] };
exporttypeCall = { tool: string; input: Record<string, unknown> };
constTIERS: 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. */constCRITICAL: [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. */exportconst 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, ".*");
returnnewRegExp(`^${body}$`);
}
/** Resolves . and .. segments. Returns null when the path leaves the project. */exportfunction normalize(path: string, cwd = ""): string | null {
const inside = cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path;
if (inside.startsWith("/") || inside.startsWith("~")) returnnull;
const parts: string[] = [];
for (const part of inside.split("/")) {
if (part === "..") {
if (!parts.length) returnnull;
parts.pop();
} elseif (part && part !== ".") {
parts.push(part);
}
}
return parts.join("/");
}
exportfunction isProtected(path: string, protect: string[]) {
const name = path.split("/").pop() ?? path;
return protect.some((glob) => globToRegExp(glob).test(glob.includes("/") ? path : name));
}
exportfunction 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] ofCRITICAL) {
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` };
}
askAsk youexec needs approval in ask mode
writeAsk youexec needs approval in write mode
yoloAllowexec 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.
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.
~/openharness/06-hashlineprotection✓
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. */exporttypeEdit = { from: string; to?: string; lines: string[] } | { after: string; lines: string[] };
exportclassStaleAnchorextendsError {}
/** FNV-1a, folded to 16 bits. Small, fast, and identical on every platform. */exportfunction 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;
};
exportfunction 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) thrownewStaleAnchor(`"${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]) {
thrownewStaleAnchor(`${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. */exportfunction 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) thrownewStaleAnchor(`${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) thrownewStaleAnchor("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, typeExtensionAPI } from"@earendil-works/pi-coding-agent";
import { Type } from"typebox";
import { applyEdits, typeEdit, render } from"../hashline.ts";
importtype { 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 asEdit[]);
writeFileSync(file, next);
return { content: [{ type: "text", text: `Edited. Current lines:\n${render(next)}` }], details: {} };
},
});
exportfunction 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 = newSet(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.
~/openharness/07-rulesprotection✓
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
exporttypeRule = { name: string; pattern: string; message: string };
typeBlock = { type: string; text?: string; arguments?: unknown; partialJson?: string };
/** Everything the model has produced so far, tool arguments included. */exportfunction 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. */exportfunction firstHit(text: string, rules: Rule[], fired: Set<string>) {
return rules.find((rule) => !fired.has(rule.name) && newRegExp(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
importtype { ExtensionAPI } from"@earendil-works/pi-coding-agent";
import { firstHit, produced, typeRule } from"../rules.ts";
importtype { HarnessState } from"../state.ts";
exportfunction installStreamRules(pi: ExtensionAPI, state: HarnessState) {
const fired = newSet<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.
~/openharness/08-planloop✓
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, typeExtensionAPI } from"@earendil-works/pi-coding-agent";
import { Type } from"typebox";
importtype { HarnessState } from"../state.ts";
const READ_ONLY = ["read", "grep", "find", "ls", "submit_plan"];
exportfunction 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.
~/openharness/09-workersloop✓
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 { typeModel } from"@earendil-works/pi-ai";
import {
createAgentSession,
DefaultResourceLoader,
defineTool,
getAgentDir,
typeInlineExtension,
typeModelRuntime,
SessionManager,
SettingsManager,
} from"@earendil-works/pi-coding-agent";
importtype { Static, TSchema } from"typebox";
exporttypeChildOptions<TextendsTSchema> = {
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. */exportasyncfunction runChild<TextendsTSchema>(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 asStatic<T>;
return { content: [{ type: "text", text: "Reported." }], details: {}, terminate: true };
},
});
const agentDir = getAgentDir();
const resourceLoader = newDefaultResourceLoader({
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) thrownewError(`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, typeExtensionAPI, typeModelRuntime } 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";
constWorkerReport = Type.Object({
summary: Type.String(),
risks: Type.Array(Type.String()),
});
exportfunction 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) thrownewError(`git ${args[0]}: ${result.stderr.trim()}`);
return result.stdout;
};
await git(["worktree", "add", "-q", "-b", branch, cwd]);
try {
const child = newHarnessState();
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.
~/openharness/10-gatesloop✓
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";
importtype { ExtensionAPI, ExtensionContext, ModelRuntime } from"@earendil-works/pi-coding-agent";
import { Type } from"typebox";
import { runChild } from"../child.ts";
importtype { Config } from"../config.ts";
import { isProtected } from"../policy.ts";
importtype { HarnessState } from"../state.ts";
constSNAPSHOT = "git status --porcelain; git diff HEAD; git ls-files -o --exclude-standard | xargs shasum 2>/dev/null";
exportconstReview = 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(),
}),
),
});
exportfunction installGates(pi: ExtensionAPI, state: HarnessState, modelRuntime?: ModelRuntime) {
let baseline: string | undefined;
let attempts = 0;
const originals = newMap<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"asconst, customType: "openharness-gate", content: text, display: true });
/** Remembers frozen files as they were when the task began. */asyncfunction 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;
}
asyncfunction 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}`;
}
}
returnundefined;
}
asyncfunction review(config: Config, ctx: ExtensionContext) {
if (!config.review.enabled) returnundefined;
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) thrownewError(`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) returnundefined;
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) returnundefined;
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";
returnundefined;
}
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.`;
} elseif (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" });
returnundefined;
}
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.
~/openharness/11-ledgertie-in✓
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.
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.
~/openharness/13-skilltie-in✓
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
~/openharness/14-clitie-in✓
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.
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.
~/openharness/15-proofsummit✓
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.
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";
constBUG = "export const add = (a, b) => a - b;";
const STILL_WRONG = "export const add = (a, b) => a * b + 1;";
constFIXED = "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 runawait 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.
Setup
Solved
Said passed
False passes
Test left modified
Stock Pi
0 of 8
No verdict
n/a
4 of 8
Verify gate only
2 of 8
6 of 8
4
6 of 8
Gate and frozen
3 of 8
3 of 8
0
0 of 8
Gate, frozen, and hashline
3 of 8
3 of 8
0
0 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.