A working agentic harness built from first principles: a small Go sample app, a set of real sensors that inspect it, and a live dashboard that shows what each sensor can — and cannot — see.
The premise: a coding agent gets things right more often when the repo around it carries the knowledge. That repo-side scaffolding is the harness. This project builds one sensor at a time and keeps asking the same question of each: what does this control actually regulate, and what is it blind to?
go run ./cmd/harness-dashboard
# Harness Tutor dashboard: http://localhost:PORTThe dashboard serves the Harness Map and one panel per sensor. Panels fetch their endpoint live, so a code change shows up on refresh.
| Tool | Version used | Install |
|---|---|---|
| Go | 1.26 | brew install go |
| golangci-lint | v2.12.2 | brew install golangci-lint |
| go-arch-lint | v1.16.0 | brew install go-arch-lint |
Both linters are optional. If either is missing, its sensor reports absent rather than clean — see Graceful degradation.
Every control in the harness is placed on a 2×2 of direction × execution, served from
internal/harness/ via /api/harness/map.
| Computational | Inferential | |
|---|---|---|
| Guide (feedforward) | scaffolds, templates | conventions docs, CLAUDE.md |
| Sensor (feedback) | linters, tests, type checks | LLM-as-judge review |
- Direction — a guide shapes the agent's work before it acts; a sensor reacts after code exists.
- Execution — computational controls are deterministic and cheap; inferential ones need a model's judgment, and cost and vary accordingly.
Controls also carry a category — the subject being regulated — which maps to how mature tooling is in that space (control.go:35-44):
| Category | Maturity |
|---|---|
maintainability |
mature |
architecture |
emerging |
behavior |
immature |
The category follows the subject regulated, not the tool's name. go-arch-lint sounds
architectural, but what it guards here is internal module structure — so it registers as
maintainability.
Controls are registered in seed.go. Four are live today: go vet,
layer conventions doc, linting, and layered dependency rule.
The thing being regulated lives in internal/ — a deliberately small layered app so violations are legible:
routes HTTP handlers
services orchestration
clients outbound I/O
domain pure types + logic
shared cross-cutting helpers (importable by everyone)
harness the control registry (not part of the app's layering)
Layering is declared in .go-arch-lint.yml. Dependencies point down only:
routes→services,clientsservices→domainclients,domain→ nothing (siblings don't reach sideways;domainstays pure)sharedis acommonComponent— anyone may import it
Note what services → domain leaves out: not clients. That forbids services from
reaching I/O directly and preserves dependency inversion — services declares the interface
it needs and lets a caller supply the client.
Three commands under cmd/. Each one runs a tool, reshapes its output, and prints JSON
on stdout. The dashboard shells out to them (go run ./cmd/<name>) and streams the result
straight to a panel.
Runs golangci-lint over ./internal/... and rewrites each issue's message for the agent
reading it.
The default funlen message tells you a function is 74 lines. That's a measurement, not an
instruction — and the cheapest way to make it go away is to raise the limit. So the wrapper
swaps in guidance that names the right fix and pre-empts the wrong one
(main.go:18-21):
This function is too long. Extract cohesive steps into named helpers in the same package so each function states one intent. Do NOT raise the funlen limit to hide length. If length is genuinely essential, add
//nolint:funlen // <reason>inline so the exception is reviewable rather than silent.
The guidance map is keyed by linter, so entries stay generic — no function names, no
numbers. The linter's own position and original text carry the where and how much; the map
carries the what to do. Both are kept in the output (original and message).
Enabled in .golangci.yml: funlen (60 lines / 60 statements, comments counted)
and gocyclo (min-complexity 10). One setting is load-bearing:
issues:
uniq-by-line: falseThe default true keeps only one issue per source line — so funlen and gocyclo firing on
the same function signature silently collapse into one. A harness must not drop a sensor's
message.
Runs go-arch-lint check --json against the layering spec and rewrites violations the same way.
This sensor sees something the linter structurally cannot: golangci-lint reads one function at
a time and can never tell you a lower layer imported an upper one. That fact only exists in the
import graph between files.
The agent-facing message again forecloses the cheap fix (main.go:21-24):
Do NOT fix this by widening
mayDependOnin.go-arch-lint.yml— that deletes the rule instead of the violation. Invert the dependency: declare the interface you need in the LOWER package and let the upper layer satisfy it.
It reports three distinct problems, not one:
| Reported as | Meaning |
|---|---|
go-arch-lint |
a real layering violation — rewritten for the agent |
go-arch-lint/spec |
the spec failed to load, so the rule never ran — never rewritten |
go-arch-lint/uncovered |
a file no component claims, so no rule guards it |
The last two are holes in the rule, not the code. Reporting them as "clean" would be the harness lying to you.
archwrap emits the exact same outIssue shape as lintwrap, so the dashboard renders
both through one renderIssues() path with zero frontend changes.
Builds the internal import graph via go list -json ./internal/... and computes, per package:
- Ca (afferent) — how many internal packages import this one
- Ce (efferent) — how many it imports
- I = Ce / (Ca + Ce) — instability,
nullfor isolated packages
Current output:
| package | Ca | Ce | I |
|---|---|---|---|
| shared | 3 | 0 | 0.00 ◀ hub |
| routes | 0 | 3 | 1.00 |
| services | 1 | 2 | 0.67 |
| clients | 1 | 1 | 0.50 |
| domain | 1 | 0 | 0.00 |
| harness | 0 | 0 | isolated |
shared is flagged as the highest-Ca hub. That flag is a candidate, not a verdict — and
that limitation is the whole point of this sensor.
A stable kernel of shared types and a god module hiding misplaced responsibility produce
identical numbers. Ca=3, I=0 cannot distinguish them. Deciding which one shared actually is
requires reading what's inside it and judging whether those things belong together — an
inferential call that no metric can make.
This is the first place the computational sensors run out of road.
These are the conventions worth copying into another repo.
The consumer of a lint message is a coding agent, not a human skimming CI. Terse output invites the cheapest fix — which is usually suppression. Every wrapper here replaces the message with guidance that names the correct fix and the tempting wrong one, while preserving the original for context.
A missing tool must never look like a passing check. Each sensor probes first and reports what's actually true:
{"issues":[],"count":0,"note":"go-arch-lint not found on PATH — install it to activate this sensor."}archwrap applies the same rule to a missing spec file. The distinction is absent vs. clean —
collapsing them manufactures a false sense of security.
Both linters exit non-zero when they find issues, which is expected, not an error. The wrappers
ignore the exit code and read the report. Even on internal failure, lintwrap emits JSON and
exits 0 (main.go:107-110) so the dashboard always has something
to render.
lintwrap scopes to ./internal/.... A real errcheck violation in cmd/lintwrap/main.go was
therefore invisible to the dashboard while the panel showed clean — a bare golangci-lint run
found it immediately. What a sensor covers is as much a design decision as what it checks.
go-arch-lint once reported count: 0, clean while go build was failing on an undefined
type. It was right: it checks the import graph, not type correctness, and the imports were valid.
Green on one sensor is not green overall.
cmd/
harness-dashboard/ stdlib net/http server + embedded static/index.html
lintwrap/ golangci-lint -> agent-facing JSON
archwrap/ go-arch-lint -> agent-facing JSON
coupling/ go list -> Ca / Ce / instability JSON
internal/
routes services clients domain shared the sample app under regulation
harness/ control registry -> the Harness Map
.golangci.yml lint sensor config
.go-arch-lint.yml layering rule
.harness-tutor/ module progress (served at /api/progress)
| Path | Serves |
|---|---|
/api/harness/map |
registered controls + category maturity |
/api/progress |
module progress (GET reads, POST writes) |
/api/sensors/lint |
cmd/lintwrap |
/api/sensors/arch |
cmd/archwrap |
/api/sensors/coupling |
cmd/coupling |
/api/sensors/* |
placeholder for sensors not yet built |
Modules M0–M3 are complete. M4 (coupling & modularity) is in progress: the computational half
(cmd/coupling + panel) is wired, and the inferential modularity review — plus registering the
coupling control in seed.go — is still to come.