# Mini agent harness — the script, the plan format, the runs, the cleanup

Companion artifact to the project "Build a mini agent harness, then use
a real one" at https://lyceumagents.com/projects/mini-agent-harness/

Copy §1 into `scripts/mini_harness.ts` in your clone of the
melchizedek-agents repo (https://github.com/jhwadman/melchizedek-agents).
It needs Node 22+, `npm install` done, and a Gemini key in `.env` (or set
`HARNESS_MODEL` to any registered id: `claude-*`, `gpt-*`, `grok-*`,
`ollama/*`). It is site-authored teaching code: for understanding how a
harness works, not for daily use. Verified 2026-09-03 against the public
repo: plan mode (11 tool calls), execute mode (15 tool calls, 4 exact
replacements, `npm test` exit 0), and the resulting `--json` flag printing
valid JSON.

---

## 1. scripts/mini_harness.ts

```ts
/**
 * scripts/mini_harness.ts — a minimal coding-agent harness, built to be read.
 *
 * A harness is the loop around a model: the tools it may call, the fence
 * around what those tools may touch, a plan the human approves before any
 * edit, a budget that ends the loop, and a check that runs outside the
 * model. This file is all five in under two hundred lines, on the same
 * ADK engine the course's syndicates run on. It is for understanding how
 * the real harnesses work; for daily use, run one of those instead.
 *
 * Usage (from the repo root):
 *   node --disable-warning=DEP0040 --experimental-strip-types scripts/mini_harness.ts plan "<task>"
 *   node --disable-warning=DEP0040 --experimental-strip-types scripts/mini_harness.ts execute plans/<file>.md
 *
 * Plan mode holds read-only tools plus write_plan, which writes ONE file
 * under plans/ with `approved: no`. You read it, edit it, and change that
 * line to `approved: yes` by hand. Execute mode refuses any plan without
 * that line, then adds str_replace, write_file, and run_check.
 */

import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { resolve, relative, join, sep, dirname } from 'node:path';
import { execSync } from 'node:child_process';
import { z } from 'zod';
import {
  LlmAgent,
  Runner,
  InMemorySessionService,
  getFunctionCalls,
  setLogLevel,
  LogLevel,
} from '@google/adk';

import { loadEnv } from '../lib/loadEnv.ts';
import { registerAvailableProviders } from '../lib/models/registry.ts';
import { defineTool, toFunctionTool } from '../lib/tools/toolContract.ts';

loadEnv(import.meta.url);
setLogLevel(LogLevel.WARN);
registerAvailableProviders();

// ── The fences ───────────────────────────────────────────────────────────────
const ROOT = process.cwd();
const MODEL = process.env.HARNESS_MODEL ?? 'gemini-3.8-flash';
const STEP_BUDGET = 40; // tool calls per run; the loop ends here, whatever the next call would have been
const DENY = ['node_modules', '.git', '.env', 'dist'];
const CHECKS: Record<string, string> = { test: 'npm test' }; // the only commands run_check may run
const READ_CAP = 12_000; // chars per read_file call
const MATCH_CAP = 60; // lines per search_files call

let steps = 0;
/** Files read this run. write_plan and str_replace refuse a file that is not
 *  in here: "read before you edit" is enforced by code, not requested by prompt. */
const readThisRun = new Set<string>();
function spend(): string | null {
  steps += 1;
  return steps > STEP_BUDGET
    ? `Error: the step budget of ${STEP_BUDGET} tool calls is exhausted. Stop and report what is done and what is not.`
    : null;
}

/** Every path the model names is resolved inside ROOT and outside DENY, or refused. */
function jail(rel: string): string | Error {
  const abs = resolve(ROOT, rel);
  const inside = relative(ROOT, abs);
  if (inside.startsWith('..')) return new Error(`Error: "${rel}" is outside the repository.`);
  if (inside.split(sep).some((seg) => DENY.includes(seg))) return new Error(`Error: "${rel}" is off limits.`);
  return abs;
}

// ── Read-only tools (both modes) ─────────────────────────────────────────────
const listFiles = defineTool({
  name: 'list_files',
  description: 'List files under a directory of the repository, recursively, relative paths, up to 200. Skips node_modules, .git, dist.',
  schema: z.object({ dir: z.string().default('.').describe('Directory relative to the repo root') }),
  execute: async ({ dir }) => {
    const over = spend(); if (over) return over;
    const abs = jail(dir); if (abs instanceof Error) return abs.message;
    const out: string[] = [];
    const walk = (d: string) => {
      for (const name of readdirSync(d)) {
        if (DENY.includes(name) || out.length >= 200) continue;
        const p = join(d, name);
        if (statSync(p).isDirectory()) walk(p); else out.push(relative(ROOT, p));
      }
    };
    walk(abs);
    return out.join('\n') || '(empty)';
  },
});

const readFile = defineTool({
  name: 'read_file',
  description: 'Read a file with line numbers. Pass start_line and end_line to read a subset; a whole large file is cut at the cap. Read before you edit.',
  schema: z.object({
    path: z.string(),
    start_line: z.number().int().min(1).optional(),
    end_line: z.number().int().min(1).optional(),
  }),
  execute: async ({ path, start_line, end_line }) => {
    const over = spend(); if (over) return over;
    const abs = jail(path); if (abs instanceof Error) return abs.message;
    if (!existsSync(abs)) return `Error: ${path} does not exist.`;
    readThisRun.add(relative(ROOT, abs));
    const lines = readFileSync(abs, 'utf-8').split('\n');
    const from = (start_line ?? 1) - 1;
    const to = end_line ?? lines.length;
    const body = lines.slice(from, to).map((l, i) => `${from + i + 1}\t${l}`).join('\n');
    return body.length > READ_CAP ? body.slice(0, READ_CAP) + `\n[cut at ${READ_CAP} chars; read a narrower range]` : body;
  },
});

const searchFiles = defineTool({
  name: 'search_files',
  description: 'Search every file under a directory for a regular expression. Returns path:line: text, up to 60 matches. Use it to find where something lives before reading it.',
  schema: z.object({ pattern: z.string(), dir: z.string().default('.') }),
  execute: async ({ pattern, dir }) => {
    const over = spend(); if (over) return over;
    const abs = jail(dir); if (abs instanceof Error) return abs.message;
    let re: RegExp;
    try { re = new RegExp(pattern); } catch (e) { return `Error: bad pattern (${(e as Error).message}).`; }
    const hits: string[] = [];
    const walk = (d: string) => {
      for (const name of readdirSync(d)) {
        if (DENY.includes(name) || hits.length >= MATCH_CAP) continue;
        const p = join(d, name);
        if (statSync(p).isDirectory()) { walk(p); continue; }
        if (statSync(p).size > 1_000_000) continue;
        let text: string;
        try { text = readFileSync(p, 'utf-8'); } catch { continue; }
        text.split('\n').forEach((line, i) => {
          if (hits.length < MATCH_CAP && re.test(line)) hits.push(`${relative(ROOT, p)}:${i + 1}: ${line.trim().slice(0, 160)}`);
        });
      }
    };
    walk(abs);
    return hits.join('\n') || 'No matches.';
  },
});

// ── Plan mode: one artifact, written once ────────────────────────────────────
const writePlan = defineTool({
  name: 'write_plan',
  description: 'Write the implementation plan as a markdown artifact under plans/. Call it ONCE, after reading the code the task touches. The human approves it by hand before anything is edited.',
  schema: z.object({
    slug: z.string().regex(/^[a-z0-9-]{3,40}$/).describe('kebab-case name for the plan file'),
    title: z.string(),
    summary: z.string().describe('Two or three sentences: what changes and why'),
    files: z.array(z.string()).describe('Every file the change will touch, relative paths'),
    steps: z.array(z.string()).min(1).describe('Ordered edits, each naming its file and what changes'),
    risks: z.array(z.string()).describe('What could break, and what you will check'),
    verify: z.string().describe('The check that proves it works, e.g. "run_check test"'),
  }),
  execute: async (p) => {
    const over = spend(); if (over) return over;
    const unread = p.files.filter((f) => existsSync(resolve(ROOT, f)) && !readThisRun.has(relative(ROOT, resolve(ROOT, f))));
    if (unread.length) return `Error: read these files with read_file before planning changes to them: ${unread.join(', ')}`;
    mkdirSync(join(ROOT, 'plans'), { recursive: true });
    const file = `plans/${new Date().toISOString().slice(0, 10)}-${p.slug}.md`;
    const abs = jail(file); if (abs instanceof Error) return abs.message;
    const md = [
      '---', `title: ${p.title}`, `approved: no`, `verify: ${p.verify}`, '---', '',
      `# ${p.title}`, '', p.summary, '', '## files', ...p.files.map((f) => `- ${f}`), '',
      '## steps', ...p.steps.map((s, i) => `${i + 1}. ${s}`), '',
      '## risks', ...(p.risks.length ? p.risks.map((r) => `- ${r}`) : ['- none named']), '',
    ].join('\n');
    writeFileSync(abs, md);
    return `Plan written to ${file}. Waiting for approval: the human edits the file and sets "approved: yes". Nothing else happens in this run.`;
  },
});

// ── Execute mode: targeted edits, one allowlisted check ──────────────────────
const strReplace = defineTool({
  name: 'str_replace',
  description: 'Replace ONE exact occurrence of old_text with new_text in a file. Fails if old_text is absent or appears more than once, so read the file first and quote it exactly.',
  schema: z.object({ path: z.string(), old_text: z.string().min(1), new_text: z.string() }),
  execute: async ({ path, old_text, new_text }) => {
    const over = spend(); if (over) return over;
    const abs = jail(path); if (abs instanceof Error) return abs.message;
    if (!existsSync(abs)) return `Error: ${path} does not exist; use write_file for a new file.`;
    if (!readThisRun.has(relative(ROOT, abs))) return `Error: read ${path} with read_file before editing it.`;
    const text = readFileSync(abs, 'utf-8');
    const count = text.split(old_text).length - 1;
    if (count !== 1) return `Error: old_text occurs ${count} times in ${path}; it must occur exactly once.`;
    writeFileSync(abs, text.replace(old_text, new_text));
    return `Replaced one occurrence in ${path}.`;
  },
});

const writeFile = defineTool({
  name: 'write_file',
  description: 'Create a NEW file with the given content. Refuses to overwrite an existing file; edit those with str_replace.',
  schema: z.object({ path: z.string(), content: z.string() }),
  execute: async ({ path, content }) => {
    const over = spend(); if (over) return over;
    const abs = jail(path); if (abs instanceof Error) return abs.message;
    if (existsSync(abs)) return `Error: ${path} exists; use str_replace.`;
    mkdirSync(dirname(abs), { recursive: true });
    writeFileSync(abs, content);
    return `Wrote ${path} (${content.length} chars).`;
  },
});

const runCheck = defineTool({
  name: 'run_check',
  description: `Run one of the allowlisted checks (${Object.keys(CHECKS).join(', ')}) and return its exit code and output tail. This is the only way to verify; reading a diff is not verification.`,
  schema: z.object({ name: z.enum(Object.keys(CHECKS) as [string, ...string[]]) }),
  execute: async ({ name }) => {
    const over = spend(); if (over) return over;
    try {
      const out = execSync(CHECKS[name], { cwd: ROOT, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 300_000 });
      return `exit 0\n${out.slice(-2_000)}`;
    } catch (e: any) {
      return `exit ${e.status ?? 1}\n${String(e.stdout ?? '').slice(-1_500)}\n${String(e.stderr ?? '').slice(-1_500)}`;
    }
  },
});

// ── The two modes ────────────────────────────────────────────────────────────
const [mode, ...rest] = process.argv.slice(2).filter((a) => a !== '--');
if (mode !== 'plan' && mode !== 'execute') {
  console.error('usage: mini_harness.ts plan "<task>"  |  mini_harness.ts execute plans/<file>.md');
  process.exit(2);
}

let instruction: string;
let tools;
let message: string;

if (mode === 'plan') {
  message = rest.join(' ');
  if (!message) { console.error('plan mode needs a task in quotes'); process.exit(2); }
  tools = [listFiles, readFile, searchFiles, writePlan].map(toFunctionTool);
  instruction = [
    'You are a coding agent in PLAN mode inside one repository. You can read and search files and write exactly one plan; you cannot edit code in this mode.',
    'Method: list the relevant directory, search for the names the task mentions, read only the files and line ranges the change touches, then call write_plan once with every file, every ordered step, the risks, and the check that proves it works.',
    'A step names the file and the exact change. Never propose a change to a file you did not read. After write_plan, reply with one sentence and stop.',
  ].join('\n');
} else {
  const planPath = rest[0];
  if (!planPath) { console.error('execute mode needs a plan path'); process.exit(2); }
  const abs = jail(planPath); if (abs instanceof Error) { console.error(abs.message); process.exit(2); }
  const plan = readFileSync(abs, 'utf-8');
  if (!/^approved:\s*yes\s*$/m.test(plan)) {
    console.error(`✗ ${planPath} is not approved. Open it, read it, and set "approved: yes" to execute it.`);
    process.exit(1);
  }
  tools = [listFiles, readFile, searchFiles, strReplace, writeFile, runCheck].map(toFunctionTool);
  message = 'Execute the approved plan.';
  instruction = [
    'You are a coding agent in EXECUTE mode inside one repository. The plan below was approved by a human. Do what it says and nothing else.',
    'Method: for each step, read the file at the lines you will change, then make one targeted str_replace. Never rewrite a whole file that exists. When every step is done, call run_check with the check the plan names, and read its exit code.',
    'Report: one line per step (DONE, FAILED with the error, or SKIPPED with the reason), the check name and its exit code, and the sentence "Nothing outside the plan was changed." If the check fails, say what failed; do not claim it passed.',
    '',
    '--- APPROVED PLAN ---',
    plan,
  ].join('\n');
}

// ── The loop: the runner calls tools until the model stops calling them ──────
const agent = new LlmAgent({ name: `harness_${mode}`, model: MODEL, instruction, tools });
const appName = 'mini-harness';
const sessionService = new InMemorySessionService();
const runner = new Runner({ agent, appName, sessionService });
const session = await sessionService.createSession({ appName, userId: 'local-user' });

console.log(`\n[harness] mode=${mode} model=${MODEL} root=${ROOT} budget=${STEP_BUDGET} tool calls\n`);
for await (const event of runner.runAsync({
  userId: 'local-user',
  sessionId: session.id,
  newMessage: { role: 'user', parts: [{ text: message }] },
})) {
  for (const call of getFunctionCalls(event) ?? []) {
    console.log(`  [tool] ${call.name} ${JSON.stringify(call.args).slice(0, 140)}`);
  }
  const ev = event as any;
  if ((ev.errorCode || ev.errorMessage) && ev.errorCode !== 'STOP') {
    console.error(`  ⚠ [${ev.errorCode ?? 'ERROR'}] ${ev.errorMessage ?? ''}`);
  }
  for (const part of event.content?.parts ?? []) {
    if (part.text && !(part as any).thought) process.stdout.write(part.text);
  }
}

console.log(`\n\n[harness] ${steps} tool calls`);
if (mode === 'execute') {
  try {
    console.log('[harness] git diff --stat\n' + execSync('git diff --stat', { cwd: ROOT, encoding: 'utf-8' }));
  } catch { /* not a git repo */ }
}
```

## 2. The runs

```bash
# plan mode — read-only tools + write_plan; writes plans/<date>-<slug>.md with approved: no
node --disable-warning=DEP0040 --experimental-strip-types scripts/mini_harness.ts plan "In scripts/direct_call.ts, add a --json flag: when present, print the final reply as a JSON object {model, prompt, reply} instead of plain text. Keep the existing behavior when the flag is absent."

# read the plan; strike what is wrong; set  approved: yes  by hand

# execute mode — refuses an unapproved plan; then str_replace / write_file / run_check
node --disable-warning=DEP0040 --experimental-strip-types scripts/mini_harness.ts execute plans/<the file it wrote>.md

# verify with your own hands
node --disable-warning=DEP0040 --experimental-strip-types scripts/direct_call.ts --json "In five words, what is an agent?"
npm test

# put the repo back; keep the plan as the record
git checkout scripts/direct_call.ts
```

Between tool-call lines the engine prints one `[OTEL_SPAN_JSON]` line per
model call with token counts and latency. Read them: they are the window
growing on every call.

## 3. The plan format the tool writes

```markdown
---
title: <what changes>
approved: no          ← you change this line, by hand, after reading
verify: <the check>   ← only names in CHECKS can actually run
---

# <what changes>

<two or three sentences>

## files
- <every file the change touches>

## steps
1. In <file>, <the exact change>
2. …

## risks
- <what could break, and what the check must catch>
```

## 4. The fences, and where each one lives

| fence | where | what it makes impossible |
|---|---|---|
| root jail + deny list | `jail()` | reading or writing above the repo, or in node_modules, .git, .env, dist |
| step budget | `spend()` in every tool | a loop that never ends; the 41st call returns an error |
| plan gate | `approved:` check before execute mode builds its agent | editing without a plan a human read |
| read-before-edit | `readThisRun` in write_plan and str_replace | planning or editing a file the run never opened |
| one exact replacement | `str_replace` count check | ambiguous edits, whole-file rewrites of existing files |
| allowlisted check | `CHECKS` table + `run_check` enum | running any command but the ones you named |

## 5. What to add for your own repo

- the real check in `CHECKS` (`pytest`, `cargo test`, `npm run build`)
- a second denied directory, or a smaller `STEP_BUDGET`
- a `write_file` that only accepts paths under one folder
- `HARNESS_MODEL=ollama/qwen3:8b` for a keyless, local run (expect the plan to be rougher; module 1.02 says why)

Then choose a real harness by the fence you found yourself adding: Claude
Code (hooks, permission modes, skills, MCP), Cursor (inline diff review,
background agents, per-agent MCP), Google Antigravity (implementation-plan
artifacts, browser verification), OpenCode (open source, any provider,
local models). The project page carries the rationale for each.
