# Building a Harness From Zero

> Source: <https://dev.to/cristianbonomo/building-a-harness-from-zero-17if>
> Published: 2026-09-14 13:08:21+00:00

We're going to build a harness from zero, explaining step by step how to do it and why.

A harness is the set of files, conventions, and rules that give an AI agent everything it needs to operate in a repository without depending on someone feeding it context in a chat. It defines how the agent starts, what it can and can't do, how it knows a task is done, and how it leaves a record of what it did so the next session (another agent, or you) can continue without rebuilding everything from scratch.

The underlying idea is simple: if the criterion for "done" only lives in your head, or in a chat message that's going to disappear, any agent (or any person) who continues the work is going to make different decisions than you would. A harness takes those decisions out of the chat and puts them in the repository, as the single source of truth.

We're going to build a minimal sandbox project, since it's not the important part here — what matters is what we build around it.

[https://github.com/crisbonomodev/ledger-cli](https://github.com/crisbonomodev/ledger-cli)

The project is a simple ledger that lets you record credit and debit operations, search by date, and get an account's balance, built on top of a LinkedList and a Map.

We'll also need a verification mechanism: check and test scripts the harness can run to validate its own changes.

Once we have the base of our ledger, let's set up the initial structure of our harness.

We need an entry point for our harness — an AGENTS.md file (or CLAUDE.md, in Claude Code's case). We'll use `/init` in Claude to generate an initial CLAUDE.md, and then modify it.

Reference commit with the generated base file: [https://github.com/crisbonomodev/ledger-cli/commit/e0cb08c81b1d92d3c7fc831bdc05f931b683908f#diff-6ebdb617a8104a7756d0cf36578ab01103dc9f07e4dc6feb751296b9c402faf7](https://github.com/crisbonomodev/ledger-cli/commit/e0cb08c81b1d92d3c7fc831bdc05f931b683908f#diff-6ebdb617a8104a7756d0cf36578ab01103dc9f07e4dc6feb751296b9c402faf7)

CLAUDE.md should contain only the instructions the harness needs to operate, plus references to other files the agent can reach if it needs them. It shouldn't go over 100 lines.

Our CLAUDE.md had architecture details in it — let's move those to a new ARCHITECTURE.md file.

You can see the state after that refactor in this commit:

[https://github.com/crisbonomodev/ledger-cli/commit/7ef78c14c334748a29a7b22e1aaf67d77a993e54](https://github.com/crisbonomodev/ledger-cli/commit/7ef78c14c334748a29a7b22e1aaf67d77a993e54)

Now we need to add our startup script, which takes care of initializing everything related to the project: installing dependencies, running verification scripts, and the start command, all in a single step — so the Operating Loop has something real to run.

You can see it in this commit:

[https://github.com/crisbonomodev/ledger-cli/commit/bed716002edb4678412f85a4544e17fbc912018e](https://github.com/crisbonomodev/ledger-cli/commit/bed716002edb4678412f85a4544e17fbc912018e)

Once this script is validated, we can move on to structuring feature_list.json. But first, we need to define what our ledger-cli's features will be. For this example, they'll be:

`history(account)`
Now let's define the structure of our feature_list.json. Each feature needs:

You can see the feature_list.json in this commit:

[https://github.com/crisbonomodev/ledger-cli/commit/50fdc573689144e858cba83226cdeeca059d2f3b](https://github.com/crisbonomodev/ledger-cli/commit/50fdc573689144e858cba83226cdeeca059d2f3b)

Now we need a file where we can log every session and task we work through. For this, we use claude-progress.md (or just PROGRESS.md).

You can see it here: [https://github.com/crisbonomodev/ledger-cli/commit/19cf16927c6fcfa83511010a61fc37945182021e](https://github.com/crisbonomodev/ledger-cli/commit/19cf16927c6fcfa83511010a61fc37945182021e)

With this, we now have the base of our harness. Let's run our first feature. The history feature needs to:

For now, we'll just leave it the information it has in feature_list.json and see what it does. We open Claude in a terminal and give it the following prompt:

Follow the operating loop from CLAUDE.md and work on the top priority feature pending on @harness/feature_list.json

Commit with the result of that run:

[https://github.com/crisbonomodev/ledger-cli/commit/9e7fbe53907d0a0227de1ec54084b01b2cbb6632](https://github.com/crisbonomodev/ledger-cli/commit/9e7fbe53907d0a0227de1ec54084b01b2cbb6632)

Looking at the result, we can see the harness completed the task, but took quite a few liberties with how the code was written. For example:

```
history(account: string): { transaction: Transaction, balance: number }[] {
    const transactions = this.load().toArray()
        .filter((tx) => tx.account === account)
        .sort((a, b) => a.date.localeCompare(b.date))

    let balance = 0
    return transactions.map((transaction) => {
        balance += transaction.type === TransactionType.CREDIT ? transaction.amount : -transaction.amount
        return { transaction, balance }
    })
}
```

Notice it used an anonymous type, when by convention it would've been better to declare a new `AccountHistoryEntry { transaction: Transaction; runningBalance: number }` interface in src/types.ts, following the convention we'd been building toward. But we never wrote that convention down anywhere, and since the repository is the single source of truth, it implemented it however it saw fit.

To improve this, we have a few options:

You can see the files in this commit: [https://github.com/crisbonomodev/ledger-cli/commit/261dd0fb7a3c2dc596fcd06693770ef525d9173d](https://github.com/crisbonomodev/ledger-cli/commit/261dd0fb7a3c2dc596fcd06693770ef525d9173d)

Now let's implement the next task and see the result.

Before dispatching the implementer, we wrote this task's sprint contract (void). What's interesting isn't just the Scope section, but the Exclusions one:

## Exclusions (explicitly out of scope for this sprint)

- Behavior when
`id` does not exist — undefined, not required, not tested.- Preventing or detecting double-voiding the same transaction — undefined, not required, not tested.

Explicitly saying what *not* to solve is just as important as saying what to solve — without this, an agent might "improve" the task by adding error handling nobody asked for, or worse, leave it half-done.

Using our Claude terminal in the project, we run `/clear` and then:

use the implementer subagent for ledger-002

Commit with the implementation: [https://github.com/crisbonomodev/ledger-cli/commit/d8d3d187a0f93a8944886ead3ae2802b0e37f04c](https://github.com/crisbonomodev/ledger-cli/commit/d8d3d187a0f93a8944886ead3ae2802b0e37f04c)

And then, to have it evaluated:

use the evaluator subagent for ledger-002

The evaluator doesn't give a free-form verdict — it fills out evaluator-rubric.md, which scores 6 dimensions from 0 to 2: correctness (does it do what feature_list.json asks for?), contract fidelity (did it respect the names and locations from the sprint contract, not just "does it work"?), verification (did it re-run the checks itself, or trust the implementer's notes?), scope discipline (did it stay within what the contract allowed?), reliability (does it survive a `rm -rf dist && ./init.sh` from clean?), and handoff readiness (was progress properly recorded?). Only if everything scores 2 (or there's a documented exception) is the verdict Accept.

Now we can see that different agents were used, conventions were respected, the sprint contract was implemented, and the rubric was completed. But the progress file didn't record the commits, and we still aren't really validating that the app stays in a consistent state and runs correctly, beyond the tests.

To fix that, we need to add a file listing the checks the evaluator agent has to run as part of the passing requirement.

The underlying problem is that "tests pass" isn't the same as "the repository is left in a consistent state." An agent can leave the build broken, progress badly recorded, or stray work files behind, and the tests for that specific feature will still pass just fine. The clean state checklist is a short list of non-negotiable conditions (build compiles, tests pass, recorded progress matches what actually happened, no leftover temporary artifacts) that the evaluator has to confirm one by one before considering the session closed — it's not enough for just the feature at hand to work.

One detail worth calling out: the check that recorded progress is accurate has to happen *after* committing, not before — the commit hash depends on the entire tree, including the progress file itself, so you can't verify a reference that doesn't exist yet.

We implemented the clean-state-checklist.md file in the harness. And while we're at it, remember we'd blocked the evaluator agent from committing — that has to change, because if the evaluator is the one running the final checklist, it also has to be the one closing the session with the commit.

[https://github.com/crisbonomodev/ledger-cli/commit/bdf3b8a1122dd843109dbd1db78b9e8db55348bf](https://github.com/crisbonomodev/ledger-cli/commit/bdf3b8a1122dd843109dbd1db78b9e8db55348bf)

With these changes in place, we can now implement the third task (ledger-003, transfers). Since we already wrote the sprint contract before dispatching it, the prompt is straightforward after a `/clear`:

implement the next task by priority

[https://github.com/crisbonomodev/ledger-cli/commit/51fa2cbadc51773ec72d167d83fb58347b6a6d7b](https://github.com/crisbonomodev/ledger-cli/commit/51fa2cbadc51773ec72d167d83fb58347b6a6d7b)

This time the whole cycle (contract → implementer → evaluator → checklist) held up without any manual adjustment on my part. The difference from the first task isn't that the agent got better — it's that this time the design decisions were already in the repository before it started writing code, instead of living in my head.

We now have our three features implemented, but our agents left loose fixes and validations scattered along the way. We're going to tackle error handling and validation across the flows. But to do that, we need to split the implementation into subtasks — and that's where we need a safe mechanism for agents to record what they did, so each agent can pick up exactly where the last one left off.

We add a new session-handoff file to handle this, update our CLAUDE.md and agent definitions, add the task to the feature list, and write our sprint contract.

[https://github.com/crisbonomodev/ledger-cli/commit/555b93016eec16974985ee0d1b16cfdd6e9a8e0e](https://github.com/crisbonomodev/ledger-cli/commit/555b93016eec16974985ee0d1b16cfdd6e9a8e0e)

Now we can start implementing our task. Session A builds the shared mechanism (the error hierarchy) and proves it out on a single flow:

You are the implementer. Work on ledger-004 following harness/sprint-contracts/ledger-004-validation-errors.md —

but ONLY the "Session A" part of the Delivery Plan: create a complete src/errors.ts (the 4-exception hierarchy)

and implement/test only record()'s guard. Don't touch transfer() or void(). When you're done, write

harness/session-handoff.md instead of harness/claude-progress.md, and leave the feature in_progress.

[https://github.com/crisbonomodev/ledger-cli/commit/e854260551a37c401e9121154bc89550c3211b40](https://github.com/crisbonomodev/ledger-cli/commit/e854260551a37c401e9121154bc89550c3211b40)

Now we move on to Session B. This is the key point of the whole experiment: it starts with a `/clear`, with nothing from Session A's chat, and the only context it receives is session-handoff.md:

You are the implementer. Start by reading harness/session-handoff.md — don't assume anything else about what the

previous session did beyond what that file says. Follow the rest of CLAUDE.md's Operating Loop and finish

ledger-004 according to the "Session B" part of harness/sprint-contracts/ledger-004-validation-errors.md: transfer(),

void(), and the CLI's try/catch. Write harness/claude-progress.md as usual.

(Note: between Session A, Session B, and the evaluator, the intermediate commits were made by me, by hand, in the terminal, orchestrating each dispatch. The implementer is explicitly forbidden from committing — so if you spot a commit "mid-task" anywhere, that's the human operating the harness, not the agent breaking its own rule.)

Finally, we run the evaluator, which is the one that actually closes out the task with a single commit:

You are the evaluator. Evaluate ledger-004 against harness/sprint-contracts/ledger-004-validation-errors.md and

harness/evaluator-rubric.md, reviewing both sessions' work together (A and B) — including whether Session B's code

is genuinely consistent with the classes Session A defined, without redefining them or drifting from them.

[https://github.com/crisbonomodev/ledger-cli/commit/35ffd50101e43dc736be0bd7869e1436c0f9a6a3](https://github.com/crisbonomodev/ledger-cli/commit/35ffd50101e43dc736be0bd7869e1436c0f9a6a3)

What matters here isn't that Session B finished the work — it's that it did so without having seen a single line of the chat where Session A was designed. Everything it needed — which error classes already existed, where they lived, what they were called — was in session-handoff.md. And I'm not taking the evaluator's word for it: I checked the code myself afterward — zero redefinitions, zero naming drift. Session B used exactly the classes Session A left behind.

That's the point of the whole exercise: a harness isn't a tidy set of files, it's the answer to a concrete question — *what does the next session need to know so it doesn't repeat or contradict what's already been decided?* When that answer lives in the repo instead of in your memory or in a chat that's about to close, the work becomes pickable-up by anyone: another agent, another session of yours, or someone else on the team. And that habit of not trusting an agent's declared state without checking the evidence yourself is, to me, the one that holds up all the rest.

All this talk of contracts, roles, and checklists can sound abstract if you never see the result actually working. After building (`npm run build`), here's a real terminal session using the four features we built:

``` bash
$ node dist/index.js record checking 1 500 "Paycheck"
$ node dist/index.js record checking debit 120 "Groceries"
$ node dist/index.js balance checking
balance of checking: 380

$ node dist/index.js history checking
2026-09-13 #1 1 500 - Paycheck | balance: 500
2026-09-13 #2 0 120 - Groceries | balance: 380

$ node dist/index.js void 2
#3 voids #2 - checking 1 120

$ node dist/index.js transfer checking savings 100
#4 checking -> #5 savings - 100 (transferId 4)

$ node dist/index.js balance checking
balance of checking: 400
$ node dist/index.js balance savings
balance of savings: 100
```

(Side note: `1` as the second argument to `record` means CREDIT — a known, never-fixed bug means comparing it against the string `"credit"` doesn't actually work; anything else falls back to DEBIT by default. It's exactly the kind of minor technical debt a sprint contract deliberately leaves out of scope, and that stays documented instead of quietly fixed by nobody's request.)

The numbers check out: 500 − 120 + 120 (reversed) − 100 (transferred) = 400 in checking, 100 in savings. That's the result of all four features, built across separate agent sessions, working together without friction.

`ledger-cli` is a toy project on purpose — the idea was to see the problem and the solution in a full cycle in a couple of hours, not to build something production-grade. Depending on the complexity of the project it's applied to, these files will need to be more or less detailed — and in fact, building and maintaining a harness turns out to be an iterative, ongoing process.

The full code — CLAUDE.md, sprint contracts, the implementer/evaluator split, all of it — is on GitHub: ledger-cli.

Curious how others handle this: when two agent sessions can't share chat context, what do you pin outside the conversation so the second one doesn't quietly redefine what the first one built?
