An orrery: a bright central hub with three elliptical orbits carrying eleven bodies, and a dashed path sweeping in from outside the widest orbit back to the hub
AI Engineering

The harness that only tested itself

August 8, 202611 min read

I built a shared standards harness so Claude would follow the same rules across eleven repos. It had tests. The tests checked the harness, not the repos, and the first real audit came back with 34 failures. Then the standard started paying for itself.

claude-codeagentsstandardscideveloper-experience

I have eleven repos in one folder and one of me. Specter, Reps, Dossier, Singular, Dockerizit, TouchDown, StageZero, LGTM, LaunchPad, the brand site, an icon library. Different stacks, same opinions: semantic design tokens instead of raw hex, a deliberate light theme rather than an inverted dark one, releases that happen the same way everywhere so nobody has to remember which repo does what.

For a while I explained those opinions to Claude once per repo. Which means I explained them eleven times, slightly differently each time, and then watched them drift apart.

So I built a harness. This is what it looks like, what it got right, and the fairly embarrassing thing I found when I finally went looking.

Claude Code reads CLAUDE.md from every parent directory of wherever you started it. That is the whole trick. My apps are siblings under a folder called CodeLifter-Platform, so a single file at the top of that folder reaches every session in every repo underneath it.

The file itself is a symlink:

cd /path/to/CodeLifter-Platform
ln -s Platform-Standards/HARNESS.md CLAUDE.md

Platform-Standards is a real repo, versioned and reviewed like anything else. The symlink means there is no copy to keep in sync, which matters more than it sounds like it should.

My first attempt was @import lines pointing at the detail docs. That does not work. Imports only expand when the file is at the session's own project level, not when it is picked up from a parent directory. I burned an afternoon on that before testing it properly.

Keep the always-loaded part small

Everything in that file costs context on every single request, in every repo, forever. So it holds two things: the rules that must never be violated, and a table telling Claude where to read the rest.

Doing…Read first
Desktop UI or .NET app architecturearchitecture/desktop.md
Any theming, colors, or typographydesign/design-system.md + design/tokens.md
CI, versioning, releases, signingprocess/versioning-ci.md
Creating a new repoprocess/repo-scaffolding.md

The core is 87 lines. Behind it sit about fifteen detail docs that get read on demand, only when relevant. Working on a Blazor page? You never load the iOS standard.

This is the part I would tell anyone to copy. The instinct is to put everything in the always-loaded file because that guarantees Claude sees it. The result is a wall of text that competes with the actual task for attention, and a file nobody wants to edit.

Version the standards like software, I said

Every push to main cut a release: a zip of the doc set, a version, a row in the README's release history. An app could say it conformed to harness 0.9.13 and that would mean something.

That felt slightly ridiculous when I set it up. Versioned releases, for markdown. I did it anyway, on the grounds that treating the standards like software was the whole spirit of the thing.

It was ridiculous. I checked, eventually: 29 releases, zero downloads. Not zero recently. Zero. Nothing consumes the standards by version, because the symlink reads a file off the local disk and every repo's pointer line fetches the default branch. Each of those releases also committed a row to the README, which is how a release job I had forgotten about rejected my push twice in one afternoon while I was editing that same README.

The standards repo does not release anymore. The test I should have applied at the start, and now write down where the next person will find it, is whether anything actually resolves the thing by version. If nothing does, a release per merge buys you a tag, a table row, and a commit that other work has to rebase around.

Then I tested it

CI on the standards repo gates on three things:

  • HARNESS.md stays under 150 lines. Go over and the build fails, which forces detail into the detail docs instead of letting the core bloat.
  • Every path in the routing table exists.
  • Every relative link in every doc resolves.

I was pleased with this. Tests, for a documentation repo. Very grown up.

The embarrassing part

Those tests check that the harness is well-formed. Not one of them checks whether a single repo actually follows it.

Every line in that file is an assertion about eleven repos the standards repo cannot see. I had written the assertions and never once verified them. So I wrote a checker: six rules, read straight off the non-negotiables, run against every repo.

First run: 34 failures across 14 repos.

The harness reaches every repo through a symlink, routes to detail docs on demand, and the conformance checker closes the loop by auditing the repos backCLAUDE.md(symlink)design/ architecture/HARNESS.md87 linesprocess/ services/11 app repos.NET, Swift, Next.jsconformance checker: audits the repos back
The symlink was the easy half. The dashed line is the half I forgot.

Some of what it found:

Zero repos carried the pointer line. The symlink only works for sessions started under that folder. A GitHub Actions runner, a cloud session, a lone git clone on a different machine: none of them see the parent directory. My own scaffolding checklist said every repo's CLAUDE.md should open with a pointer to the harness for exactly that case. Eleven repos, zero pointers. Ten of them had no CLAUDE.md at all.

Two repos had copied a rule instead of routing to it. The cross-platform parity rule lived in the harness, and also verbatim in Reps, and again in Specter. Three copies of one rule, in a system built specifically to stop that.

One pipeline had been failing since December. Iconizit.Core's release workflow ran on every push to main and failed on every push to main, for eight months. It built the whole solution, which drags in a MAUI head nobody was publishing. Nothing was watching, so nobody knew.

A repo published on hand-pushed git tags, which is the one thing the versioning rule explicitly rules out. Another had a version job that predated half the scheme.

The correction I owe the story

The finding I was most excited about was macOS CI. GitHub bills macOS runners at ten times the Linux rate, and the harness says private repos run their mac jobs on the MacBook sitting under my desk. Only one repo out of five did. I had a lovely narrative about money quietly leaking.

Then I actually read the workflows. The others were controlling cost a different way: the whole pipeline was gated to manual dispatch, so those jobs almost never ran at all. Same goal, different mechanism, and not the bleed I wanted it to be.

It is still real drift, and there is a genuine cost to it, just not the one I first reported. Gated jobs do not run on pushes, so a regression in the mac build waits until somebody dispatches by hand to introduce itself. But "your CI is misconfigured in a way that delays feedback" is a duller sentence than the one I had drafted, and the duller one is true.

Which is the actual argument for a checker. Left to myself I will believe the interesting version.

Exceptions, recorded rather than silenced

Two failures turned out to be legitimate. The brand site deploys to Vercel, so its release step is a deployment and a versioned artifact makes no sense. And Dossier's iOS leg cannot move to my MacBook because the .NET iOS workload wants a newer Xcode than the machine has.

The tempting fix is to soften the rule until everything passes. Instead each exception is a line in a manifest with a reason attached, and the report prints it as an exemption rather than a pass. The macOS check goes further: exemptions are per job, not per repo, so a known blocker on one job cannot hide a new hosted job appearing next to it.

That distinction is most of the value. A check that everything passes tells you nothing. A check with three named, dated exceptions tells you exactly where the debt is.

After a day of fixing, it is down to 7 failures, and every remaining one is real work I chose not to do blind. Central package management across five repos means hoisting every dependency version out of every project file and turning warnings into errors in codebases that were never held to that. Doing it carelessly to make a report go green would be the exact failure mode the report exists to catch.

What the standard actually bought

The checker was the interesting part of that day. It was not the valuable part.

Once every repo carried the same pointer and the same rules, a change to how things work stopped being eleven conversations and started being one. Three things landed across the whole platform in a month that I would not have attempted one repo at a time, because doing them eleven times by hand is how you end up with eleven slightly different versions of them.

One version scheme, taken from the tags

The old rule was a repo variable holding major.minor, and the CI run number as the patch. It sounds reasonable. Here is what it produced in Dockerizit:

v0.9.9
v0.9.14
v0.9.52
v0.9.56

Those gaps are not missing releases. They are builds that happened. The patch counted CI runs, so it told you how busy the pipeline had been and nothing at all about the software.

It was worse than untidy. Every repo also set RELEASE_LEVEL=beta, which marks every release a prerelease, and GitHub's /releases/latest skips prereleases. So the "get the latest version" link resolved to nothing whatsoever for Specter, Dossier and Singular, and to year-old builds for Reps and Dockerizit. Five apps with a broken download link, produced faithfully by a rule I had written down as a standard.

The version now comes from the git tags. Take the highest, increment it, done. No repo variable, nothing to keep in sync, nothing that can drift from what was actually published, and no token needed to write state back.

Two details in that are load-bearing and both are quietly easy to get wrong. The sort has to be version-aware, because lexically 0.9.9 sorts above 0.9.56, and a naive sort would have handed Dockerizit a version that already existed. And the checkout has to fetch full history, because a shallow clone has no tags at all and the whole scheme restarts at 0.0.1 without complaining.

One release process, and two buttons

Merging into main now publishes the next patch as a signed prerelease. That is the entire routine release path, and nobody decides anything.

Deliberate releases happen from two buttons in the Actions tab, RELEASE MINOR and RELEASE MAJOR. They are dispatch-only, and dispatch requires write access, so the gate comes free.

My first version of those buttons re-uploaded the prerelease's artifacts unchanged under the new tag. The appeal is obvious: ship exactly the bits you tested, still carrying their original signature. The problem is that the version is compiled in, so a build made as 0.9.57-pre and released as 0.10.0 still reported 0.9.57-pre and its filenames still said so. I wrote that up as an acceptable trade. It was not, and I was told so, and the correction was right.

They rebuild now, from the prerelease's commit, with the release version compiled in. Same source, tests re-run, signing re-exercised, honest version.

That change introduced a trap worth naming, because it is invisible and it ships. The buttons dispatch from main. A build job that lets its checkout default therefore builds main, not the commit the prerelease was cut from. If anything landed on main in between, you have just published unreviewed code under a release tag and nothing anywhere says so. Every build job now checks out the resolved commit explicitly, and the tag points at that commit rather than at the branch head. I tested it by deliberately moving main on after the prerelease and confirming the release still came from the older commit.

One set of platform builds

The parity rule in the harness says a user-facing feature ships on every platform the app supports, or you stop and report the blocker. That rule was doing nothing for a long time, because most of the apps only really built for macOS.

Linux packaging now exists as templates in the standards repo, and six apps use it: Specter, Reps, Dossier, Singular and Dockerizit ship AppImage, .deb and .tar.gz, and LGTM ships AppImage and .deb through electron-builder. Reps and Dossier build macOS, Windows, Linux and iOS from a single workflow. Dockerizit builds macOS, Windows and Linux, plus the WSL root filesystem that the Windows artifact embeds.

Doing that across six repos at once is what surfaced the things one repo would have hidden. A security-driven version bump on Tmds.DBus.Protocol had pinned it to a release that Avalonia's X11 backend cannot use, so Specter built fine everywhere and failed only on Linux. There is a glibc floor of 2.33 coming from the SQLite provider, which is not a bug but is a fact that belongs in the package metadata rather than in a crash. And LGTM turned out to have no way to open its own window on Linux, because the design assumed a tray icon and Linux tray support is a lottery.

None of those were findable from inside a single repo. They were findable because the same job ran six times against the same standard.

What I would tell you

Keep the always-loaded core small and make it route rather than explain. Use a symlink so there is no copy to drift. Version it. Put a hard line limit in CI, because you will want to add just one more paragraph, forever.

And then write the boring checker. The one that reads your own rules back and asks each repo whether it is actually doing them. It is an afternoon of work and it will tell you something you do not want to hear on the first run.

A standard nobody verifies is not a standard. It is a preference you wrote down.

Loading discussion…

Written by

Andrew @ CodeLifter

All Posts