Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Harness engineering

The six chapters before this one are processes, and each ended with a section called “What holds it”: the rules the work follows and the checks that catch it when it does not. This chapter is about those rules and checks themselves, around any of the six: what they are, where each one acts, which of them ever does anything, and how they get better.

The words are Birgitta Böckeler’s, from Harness engineering for coding agent users (martinfowler.com, 2 April 2026). In our words, and briefly:

  • A coding agent’s harness is the set of controls around it that make what it produces more trustworthy. A control is a guide or a sensor.
  • A guide acts before the agent does (feedforward): a rule in AGENTS.md, a brief, a template it starts from. A sensor acts after (feedback): it observes what was done and refuses, warns or reports, ideally in words the agent can act on. Guides alone are rules nobody checks; sensors alone let an agent make the same mistake every time.
  • Each control is computational (a program decides: a test, a linter, a hook that refuses) or inferential (a model or a person decides: a review, a judge, a dispatcher reading the evidence). Computational controls are cheap and deterministic, so they can run on every change; inferential ones see what a program cannot, at a cost and without a guarantee.
  • Controls act at stages of a change’s life: as the agent calls a tool, at commit, in CI, at merge, and outside any one change (a scheduled drift scan, a periodic audit). The earlier a control acts, the cheaper the mistake it stops.
  • Controls regulate categories: maintainability, architecture fitness and behavior. The gonk starter adds a fourth, authority and safety, for the rules about who may do what: claim, merge, close.
  • The steering loop is the person’s part. When a failure recurs, it becomes a new control or a better one, so the human’s job is to “steer the agent by iterating on the harness”.
  • A harness template packages guides and sensors for one kind of project, so a new repository starts with them rather than growing them one incident at a time.

Her article ends on open questions, and two of them are what this chapter is about. How would you measure a harness’s coverage, the way code coverage measures tests? And when a sensor never fires, is that a sign the work is good, or that the sensor cannot see?

The worked example is the gonk starter, which is a harness template: every repository it generates carries its harness as a graph, logs every time a sensor fires, and asks gonk four coverage questions of both. Its loop is this chapter’s loop, so where the six chapters before it end with “With the starter”, this one is the starter from the beginning.

Who

rolewhodoes
the person who steerswhoever owns the processreads the coverage answers; turns a recurring failure into a new or better control
the agentany agent working in the repositoryreads the guides; trips the sensors; is told why
the sensorsthe repository’s own tools and hooksrefuse or warn, and record one line each time
the dispatcherthe owner of the gonk the work lives inloads the harness into gonk and asks the coverage questions
gonkthe same gonk as the ledgerholds the harness graph and the firings beside the ledger, and answers SPARQL over them

What runs where

  the repository (committed)                      this working tree (git-ignored)
  ┌─────────────────────────────────────┐         ┌──────────────────────────────────┐
  │ AGENTS.md ───────────── the guides  │         │ .gonk/state/harness/firings.log  │
  │ .gonk/harness/harness.ttl           │         │  one line per refusal or warning │
  │   every guide and sensor, as RDF    │         └─────────────────▲────────────────┘
  │ .gonk/harness/vocabulary.ttl        │                           │
  │ tools/, hooks/ ──────── the sensors ├───────── each firing ─────┘
  │ .gonk/queries/harness-*.rq          │
  └──────────────────┬──────────────────┘
                     │ python3 tools/gonk.py harness  (the owner's socket)
                     ▼
  gonk: the named graph urn:iki:harness:graph:<ledger>, beside the ledger's own,
        holding the description and every tree's firings; the four questions asked of it

Everything but gonk is files in the repository. The graph is committed, so a change to a control is a reviewed change like any other; the firing log is per working tree and never committed.

The loop, step by step

This page’s gonk, as in every chapter before it: if the one from Periodic unled audits is still running, stop it first, since this one takes the same port.

$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…

1. Generate a repository with a profile

The starter’s tools/new.py generates a repository for one process, here Solo on one machine’s. The book checks the starter at the revision in books/gonk/starter.rev, which carries the harness and the vocabulary’s complete definition:

$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process solo
generated the library-lending sample (python, process solo) in …/library: 59 files
next, in that directory:
  git init, then commit everything it wrote (the one commit where every file is yours)
  python3 scripts/test.py                      every test, as CI runs them
  python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
  python3 tools/gonk.py seed                   file the open changes' tasks as items
  python3 tools/gonk.py next                   what to do first, and why

AGENTS.md holds this process's rules; README.md walks through the sample.
$ LC_ALL=C ls library/.gonk/harness library/.gonk/queries library/tools
library/.gonk/harness:
harness.ttl
vocabulary.ttl

library/.gonk/queries:
README.md
harness-firings-by-control.rq
harness-guides-without-sensor.rq
harness-sensors-never-fired.rq
harness-sensors-without-guide.rq
open-items-for-artifact.rq
open-work-by-change.rq
requirements-with-open-work.rq

library/tools:
gonk.py
harness.py

harness.ttl is assembled the way AGENTS.md is, from one fragment per section of the rules, so a repository describes the controls of the process it was generated for and no others. Another profile, or several, gives a different graph.

2. The harness as a graph

Each rule of AGENTS.md is a guide, and each place the tools enforce one is a sensor, both with a stable IRI whose last segment never changes, so that every firing recorded under it stays joined to it. (guide: and ctl: abbreviate urn:iki:harness:starter:guide: and urn:iki:harness:starter:control:.) One rule, from AGENTS.md’s fifth section:

$ awk -v RS= '/^guide:report-with-evidence /' library/.gonk/harness/harness.ttl
guide:report-with-evidence a harness:Guide ;
    rdfs:label "report with evidence" ;
    rdfs:comment "Finished work is reported with the evidence that it is done, a pull request's URL or the reproduction that shows it is not a defect, recorded as a comment a later reader can check." ;
    harness:statedIn "AGENTS.md" ; harness:section "5. Report with evidence; the dispatcher closes" ;
    harness:direction harness:feedforward ; harness:execution harness:inferential ;
    harness:stage harness:toolCall ; harness:category harness:behavior .

A guide is always feedforward and inferential: an agent reads it and applies it, or does not. The sensor that checks this one:

$ awk -v RS= '/^ctl:evidence-required /' library/.gonk/harness/harness.ttl
ctl:evidence-required a harness:Control ;
    rdfs:label "report and close need evidence" ;
    rdfs:comment "`tools/gonk.py report` and `close` refuse without `--evidence`, or with one that is not a URL, before anything reaches the ledger." ;
    harness:direction harness:feedback ; harness:execution harness:computational ;
    harness:stage harness:toolCall ; harness:category harness:behavior ;
    harness:outcome harness:refuse ;
    harness:enforcedAt "tools/gonk.py#evidence_note" ;
    harness:statedBy guide:report-with-evidence ;
    harness:firedBy test:test_harness:SensorsFire.test_evidence_required .

Feedback and computational, acting as the tool is called, and it refuses. enforcedAt says where its code is, statedBy names the rule it mechanizes, and firedBy names the test that drives it and sees the firing recorded, which is the proof that it can fire. A graph like this drifts from the code it describes unless something holds the two together, and tools/harness.py check is that something: it reads the control ids the code names, holds them to the graph in both directions, the graph to its vocabulary, and each guide’s section to a heading of AGENTS.md:

$ cd library && python3 tools/harness.py check
harness check: 17 guide(s) and 14 other control(s) agree with the code

3. A rule fires

The repository is wired to this page’s gonk as in Solo. Two rows of init matter here: harness, which says the coverage questions are asked rather than published (a script runs under a ceiling of ledger reads, and these read the harness graph), and hook, which says the Claude Code claim hook is off. Step 5 comes back to that one.

$ cd library && git init -q && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder ada
gonk init: …/library
  ok       layout    openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
  note     harness   4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
  note     openspec  8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
  ok       process   solo (.gonk/process.toml)
  note     solo      one person and their agent: you are your own dispatcher, and close after checking your own evidence
  note     role      you: both the dispatcher and whoever does the work
  note     hook      the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
  ok       ikigai    ikigai 0.1.61 at …/ikigai
  ok       ledger    `default` (.gonk/gonk.toml)
  ok       holder    `ada` (.gonk/local.toml, this working tree only)
  ok       identity  the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
  ok       gonk      socket …/.ikigai/gonk.sock answers
  note     items     `default` holds nothing yet, or this identity may not read it. A ledger has no create: it exists once something is filed in it (`python3 tools/gonk.py file "<title>"`)
  ok       scripts   gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
  ok       scripts   published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
  unknown  nl        could not ask gonk about `urn:nl:sparql`

agents over MCP (configure the agent with this command):
  ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock

ready.

(The unknown row is this page’s gonk mounting no model, as in Periodic unled audits.) Now an item, a claim, and a hand-back that forgets its evidence:

$ cd library && python3 tools/gonk.py file 'Renew a loan before it is due'
#1 urn:iki:ledger:default:item:…
$ cd library && python3 tools/gonk.py claim 1
#1 claimed by ada until …
$ cd library && python3 tools/gonk.py report 1 --note 'renewal works'
gonk.py: --evidence is a URL (the pull request, or the reproduction that shows it is not a defect): a report without evidence is a claim nobody can check

That refusal is the sensor working, and its message says what to do instead, which is the part an agent reads. It also wrote one line to this working tree’s firing log:

$ cat library/.gonk/state/harness/firings.log
# ikigai-log v1
@prefix harness: <https://ikigai-rs.dev/ns/harness#> .
@prefix log:     <https://ikigai-rs.dev/ns/log#> .
@name     urn:log:harness:tree-…
@instance urn:iki:harness:tree:…
@level    log:info
@started  …
@prev     genesis
# This working tree's harness firings: one line each time a sensor in this
# repository's tools refused or warned. UNSEALED: no key, and no last line is
# known. Written by tools/harness.py; `python3 tools/gonk.py harness` loads it.

… harness:Refusal urn:iki:harness:starter:control:evidence-required seq=1 cmd=sha256:…

The file is one ikigai-log segment, so ikigai-log can read it as well as the starter can. The line is a time, the kind of firing, the control’s IRI, a sequence number, and the first twelve hex digits of a digest of the command, enough to tie repeated firings of one command together. Never the command’s text, a file’s contents, a path or a name. Recording is best effort and the starter’s tests prove it: an unwritable directory or a corrupt segment leaves the refusal and its message exactly as they were. Read back, by control:

$ cd library && python3 tools/harness.py firings
    1  refusal  evidence-required                    last …
1 firing(s) in urn:log:harness:tree-…

4. Ask the four coverage questions

gonk.py harness loads the graph and this tree’s firings into gonk’s named graph urn:iki:harness:graph:default with one SPARQL update, then asks the four questions in .gonk/queries/harness-*.rq and prints the answers. Writing that graph needs a store grant no ledger grant includes, and gonk’s owner-only socket holds it, so it is the dispatcher’s command, from gonk’s machine; here this page’s gonk is the owner’s:

$ cd library && python3 tools/gonk.py harness
harness: 17 rule(s), 14 sensor(s) and 0 other control(s) in .gonk/harness/harness.ttl; 1 firing(s) in this tree
loaded:  updated <urn:iki:harness:graph:default>: +324 -0 quads

harness-firings-by-control: How often has each control fired, refusing or warning, and when first and last?
  control                label                           outcome  firings  first…last
  ctl:evidence-required  report and close need evidence  Refusal  1        …

harness-guides-without-sensor: Which rules does no computational control check?
  guide                                label                                               section
  guide:a-claim-is-a-lease             a claim is a lease                                  1. Claim the item before you write
  guide:hand-back-when-you-stop        hand it back when you stop                          1. Claim the item before you write
  guide:reproduce-before-fix           reproduce before you fix                            3. Reproduce before you fix
  guide:last-command-exit-status       a pipeline's status is its last command's           4. Every change through a pull request with green CI
  guide:stage-files-by-name            stage files by name                                 4. Every change through a pull request with green CI
  guide:dispatcher-closes-on-evidence  the dispatcher closes, after checking the evidence  5. Report with evidence; the dispatcher closes
  guide:no-bare-numbers                never a bare #N                                     6. Never a bare `#N`

harness-sensors-never-fired: Which sensors of this repository's tools have never fired, and has a test shown each one can?
  control                         label                                             proof
  ctl:claim-held-by-another       claim: held by another holder                     fired by a test
  ctl:claim-lease-expired         claim: an expired lease is not free               fired by a test
  ctl:edit-hook-not-wired         claim hook: no claim can be checked               fired by a test
  ctl:edit-hook-unreadable-input  claim hook: input it cannot read                  fired by a test
  ctl:edit-without-live-claim     claim hook: an edit needs a live claim            fired by a test
  ctl:one-claim-per-tree          a working tree holds one claim                    fired by a test
  ctl:reject-needs-reason         a rejection says why                              fired by a test
  ctl:takeover-refused            takeover: only an expired lease, from its holder  fired by a test

harness-sensors-without-guide: Which sensors state no rule, so an agent meets them only by tripping them?
  sensor                   label                 execution      stages
  ctl:reject-needs-reason  a rejection says why  computational  merge

Four answers:

  • Firings by control. What has fired, how often, first and last. Over a real month this is the list of rules that are not landing: a sensor that fires often is doing its job, and saying that agents keep making the move its rule warns against.
  • Rules with no sensor. Seven of the seventeen rules are text an agent has to apply, with nothing that notices when it does not. Some can only ever be applied by a reader; each row is a candidate for a sensor, not a defect.
  • Sensors never fired. Eight of the fourteen, every one with a test that shows it can. evidence-required is not on the list, because step 3 fired it.
  • Sensors with no rule. reject-needs-reason refuses a rejection without a reason, and no section of AGENTS.md says that a rejection needs one, so an agent first hears of it from the refusal. The fix is a sentence in the rules, not another sensor.

The load is idempotent: a firing’s IRI is its log and its sequence number, so loading again adds nothing, and every working tree’s firings land in the same graph beside each other’s:

$ cd library && python3 tools/gonk.py harness | grep '^loaded'
loaded:  updated <urn:iki:harness:graph:default>: +0 -0 quads

5. Steer: a rule with no sensor, and a sensor that never fired

The answers are where the steering loop starts. Take one rule from the second answer:

$ awk -v RS= '/^guide:stage-files-by-name /' library/.gonk/harness/harness.ttl
guide:stage-files-by-name a harness:Guide ;
    rdfs:label "stage files by name" ;
    rdfs:comment "Stage the files you changed by name: `git add -A` picks up files that are not yours." ;
    harness:statedIn "AGENTS.md" ; harness:section "4. Every change through a pull request with green CI" ;
    harness:direction harness:feedforward ; harness:execution harness:inferential ;
    harness:stage harness:preCommit ; harness:category harness:authoritySafety .

A sensor for it is easy to describe and cheap to run: a hook on the agent’s shell commands (the same kind of hook as the claim hook) that refuses git add -A, git add ., git add -u and git commit -a before the shell runs them, and says why: name the files. Computational, outcome refuse, and acting at the tool call, one stage earlier than the commit the rule is about. Adding it is one pull request with three parts: the hook, which records each refusal under a new control id; that control in the graph, with statedBy guide:stage-files-by-name; and a test that fires it, named in firedBy. harness.py check fails if the code names an id the graph does not describe, or the graph describes a sensor nothing shows can fire, so a part left out is caught, and the next coverage answer has one row fewer. Whether the sensor is worth its friction is the steering question. Before it exists the evidence comes from outside the graph (how often review or the dispatcher catches a stray file in a commit); once it exists, the first answer counts it.

Now take one sensor from the third answer, edit-without-live-claim: the claim hook refusing an edit while the working tree holds no live claim. It has never fired. Is that because nobody edits without a claim? Not here: init said the hook is off, because it is optional and nothing merged its settings. A sensor that is not installed never fires, and its silence looks exactly like health. The hook is a program that reads a Claude Code tool call as JSON, so it can be run by hand the way Claude Code would run it, after the claim is released:

$ cd library && python3 tools/gonk.py release 1 --note 'stopping here'
#1 released
$ printf '{"tool_name": "Edit", "tool_input": {"file_path": "%s/library/README.md"}}' "$PWD" | python3 library/hooks/claude/require-claim.py
require-claim: refused an edit to README.md: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
$ cd library && python3 tools/harness.py firings
    1  refusal  evidence-required                    last …
    1  refusal  edit-without-live-claim              last …
2 firing(s) in urn:log:harness:tree-…

So the sensor works, and was silent because nothing ran it. The firedBy proof answered a narrower question than it seems to: it shows the code can fire, not that it is wired into anyone’s work, nor that its trigger still matches what it watches. The claim refusals in the same answer show the third gap: tools/gonk.py recognizes the ledger’s refusal by its wording, so a change of wording in the ledger would silence the firing while the refusal goes on (the graph’s own comment on claim-held-by-another says so, and an integration test holds the wording).

That is as far as the starter answers the question Böckeler leaves open. “Never fired” splits into three: cannot fire (no test shows it can; harness.py check refuses that state), is not wired (it can fire, and nothing runs it), and is quiet (it runs and nothing trips it). Only the last is evidence of good work, and telling it from the second takes knowing what is installed where, which no query of the graph can know yet. A real gap, stated rather than solved.

What holds it

The harness has a harness:

  • tools/harness.py check, the guide-and-code agreement. A control the code names and the graph does not describe, a control the graph describes and no code names, a term the vocabulary does not define, or a guide whose section is not a heading of AGENTS.md, each fails it by name.
  • One test per sensor, named in the graph’s firedBy, that drives the sensor and sees the firing recorded; the starter’s own tests hold firedBy to the tests that exist.
  • Stable ids. A control’s id never changes, so its firings stay joined to it across renames, and a firing outlives the control it names (the first answer shows it with an empty label).
  • Recording that cannot change a decision. A firing is written best effort, never to standard output, and the tests prove a failed write leaves the refusal unchanged, so turning the log on cannot break the tools it watches.
  • The questions as files, .gonk/queries/harness-*.rq, each with its question and what an empty answer means in its header, so a reader can run them and a reviewer can change them.

What it costs, and when to choose it

Very little to keep: the graph is a few hundred lines of Turtle the starter generated, the log is a line per firing, and the questions are four queries. What costs is the discipline it asks for when the harness changes: a new sensor is a control in the graph and a test that fires it as well as the code, in the same pull request. That is the point. A control nobody has described is a control nobody can ask about.

Choose it whenever agents do real work in a repository, because the alternative is the harness everyone already has: rules in prose, checks in scripts, and no way to ask which rules are checked, which checks have ever done anything, or which refusals the rules never warned of. Choose the starter’s template to begin with it rather than retrofit it.

The authors run a larger harness the same way, in the authors’ private tooling: a graph of the controls, a firing log, and coverage questions asked of both.

The vocabulary

The terms are in the namespace https://ikigai-rs.dev/ns/harness# (harness:), whose definition is the starter’s .gonk/harness/vocabulary.ttl: every term, including those a generated repository does not use. It is not served at that address yet, so the starter’s file is the definition.