Skip to content

About

Harness Engineering Lab

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

harness-lab

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:PORT

The dashboard serves the Harness Map and one panel per sensor. Panels fetch their endpoint live, so a code change shows up on refresh.

Prerequisites

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.

The Harness Map

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 sample app

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, clients
  • services → domain
  • clients, domain → nothing (siblings don't reach sideways; domain stays pure)
  • shared is a commonComponent — 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.

The sensors

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.

cmd/lintwrap → /api/sensors/lint

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: false

The 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.

cmd/archwrap → /api/sensors/arch

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 mayDependOn in .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.

cmd/coupling → /api/sensors/coupling

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, null for 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.

Design rules every sensor follows

These are the conventions worth copying into another repo.

Rewrite tool output for the reader that will act on it

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.

Graceful degradation

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.

Always exit 0, always emit JSON

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.

Scope is part of a sensor's definition

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.

A green sensor certifies only its own slice

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.

Layout

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)

Endpoints

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

Status

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.

About

Harness Engineering Lab

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages