Custom Turn-Based Checks + Telemetry

Local CI for AI Coding Agents

Highball runs your repo's own checks after every agent edit, and the full checks when the turn ends. A failure blocks the agent and hands it the output. One YAML file. No account, no server.

Read the onboarding guide
Claude Desktop · webtree › list_runs
Highball run history widget in Claude Desktop: recent runs for the webtree project, each with status, trigger, branch, duration and check counts
Stop hook · highball run
$ npx @profoundry-us/highball run
→ Lint & formatting ... passed (2.1s)
→ lib/ types hold ... FAILED (4.8s)
→ Coverage never decreases ... todo (not implemented yet)
reported 3 events to us.i.posthog.com

### lib/ types hold (lib-types) failed. Fix before finishing:
lib/locations.js(248,3): error TS2339: Property 'moved'
  does not exist on type '{ id: string; root: string; ... }'.
exit 2 — the agent gets this back as its next instruction
Two kinds of run

Cheap after every edit. Everything at turn end. Loud when it fails.

Fast checks are the ones you'd happily run a hundred times a session. The full checks are the ones that decide whether the turn is over.

fastafter an edit · 11 checks · 1.0s
webtree › get_run #1
A fast run in the widget: eleven checks, each passed in about a tenth of a second

Every check here is a file-scoped script or a grep. They fire after each Write, Edit and Bash call, and --if-changed keeps the misses free.

fullturn end · 15 checks + 1 todo · 21.1s
webtree › get_run #2
A full run in the widget: RSpec at 18 seconds, two AI-judged checks, a todo check, and one check expanded to show its command and output

The suite, two AI-judged rubrics, and a todo check that's tracked but can't fail. Any check opens to the exact command and its captured output.

✗ failedafter an edit · 13 checks · 2 failed · 19.9s

When one fails, the agent is told exactly which, and why

Failed checks open on their own, command and output first. The same text goes back to the agent on stderr with exit 2 — it doesn't get to finish the turn until both are green.

stderr · what the agent receivesexit 2
### The blueprint lints (blueprint-lint) failed. Fix before finishing:
ERRORS
  ✗ [coverage] status.spec.steps-are-required: a check references
    this rule id, but no such rule exists
✗ 137 rules, 7 screens, 57 anchors — 1 error(s), 6 warning(s)

### Unit tests (unit-tests) failed. Fix before finishing:
tests 316 · pass 315 · fail 1
  ✖ failing tests: test/delivery.test.js:80:1
walkdown › get_run #6
A failed run in the widget: the blueprint lint check expanded to its command and error output, eleven passing checks, and a failed unit tests check
What the hook contract allows

Enforcement is local. Everything else is optional.

Two hooks, one YAML file, and an exit code Claude Code already respects. Nothing here needs a login.

// .claude/settings.json
"PostToolUse": [{
  "matcher": "Write|Edit|Bash",
  "command": "npx @profoundry-us/highball
            run --fast --if-changed"
}]

Fast checks on every edit

Checks marked fast: true run after every Write, Edit — and Bash, because agents in auto mode edit through the shell.

"Stop": [{
  "command": "npx @profoundry-us/highball run",
  "timeout": 900
}]

→ 12 checks · 41.6s · passed

Full checks at turn end

The Stop hook runs every check in the file. The turn can't end until all of them pass.

### Unit tests (unit-tests) failed.
Fix before finishing:
Failures:
  1) journal keeps every rule's output
     expected 3, got 2

$? 2

Blocking exit codes

Exit 2 is the Claude Code hook contract: it stops the agent and hands stderr back as its next instruction. No dashboard to ignore.

$ highball run --fast --if-changed
highball: no changes since the last run — skipped

fingerprint  HEAD 8981d5b + dirty paths (size, mtime)
stamp        ~/.highball/stamps/webtree-3f9a2c
cost         11ms · no checks spawned

Change detection

--if-changed fingerprints the working tree and exits in milliseconds when nothing has moved. That is what keeps a Bash hook free: it fires after every command, and most commands are reads. Per checkout, so worktrees never share a skip.

.highball/disabled     this checkout   gitignored   next run
enabled: false         the whole team  committed    next run
HIGHBALL_DISABLED=1    this machine    env          next session

highball: disabled by .highball/disabled
  (bisecting a flaky test) — no checks run

Three off switches

This checkout, the whole team, or this machine. Each one announces itself on every run, with the reason you left in the file — a guardrail that stopped guarding should never read green.

# .highball/checks.yml
- id: no-new-todos
  name: No new TODOs in the diff
  rubric: .highball/rubrics/no-new-todos.md

judged by claude · turn end only

Rubric-based AI judgements

A check can be a markdown rubric instead of a command. Claude judges the diff against it at turn end — never on the fast path, where latency and cost would be paid on every edit.

exec:
  via: docker compose exec -T app

- id: rspec
  run: bundle exec rspec
- id: gitleaks
  run: gitleaks detect
  exec: host

Container-aware execution

Declare exec.via once and every check runs inside your container. Checks that belong on the host opt out with exec: host.

reported 3 events to us.i.posthog.com

highball_run    status, duration_ms,
                rules_run, failed_rules
highball_check  rule_id, status, summary

never code · never logs · key is write-only

Telemetry via your own PostHog

Check ids, pass/fail, durations, and a one-line failure summary, sent to a PostHog project you own. Never code, never logs — and an unreachable endpoint never blocks a check.

Two halves

Enforcement, then witness.

Enforcement runs on your machine and can stop the agent. Telemetry is optional and best-effort, sent only to a PostHog project you own.

Enforcement · exit 2

Blocked, with the output in hand

When a check fails the agent doesn't get a red X to scroll past. It gets the failing output as its next instruction, and the turn can't end until the check passes.

stdin · Stop hook payloadJSON
{
  "session_id": "8f2c1a…41ab",
  "hook_event_name": "Stop",
  "transcript_path": "~/.claude/projects/…/8f2c1a…41ab.jsonl",
  "cwd": "/Users/you/src/webtree"
}
Stop hook blockedexit 2 · 14.2s

lib/ types hold (lib-types) failed. Fix before finishing: lib/locations.js(248,3): error TS2339: Property 'moved' does not exist…

Adding moved to the location type, then re-running the checks before I finish.

Witness · PostHog

Every run, recorded in your own PostHog

Telemetry is best-effort and goes to a PostHog project you own. If the endpoint is down, checks still run and still block — they just aren't recorded.

POST /batch · highball_runJSON
{
  "event": "highball_run",
  "properties": {
    "project": "webtree",
    "trigger": "stop",
    "branch": "main",
    "status": "failed",
    "duration_ms": 14200,
    "rules_run": ["lint", "lib-types", "coverage-ratchet"],
    "failed_rules": ["lib-types"]
  }
}
failed2m agofullmain14.2s2✓ 1✗
passed9h agofullmain8.2s3✓
passed4d agofastrelease-0.6.07.8s1✓
One command

Ask your agent to install and onboard today!

Highball is set up by the agent it will check. The onboarding guide walks it through the repo's real toolchain, writes checks that reflect what the repo already trusts, and proves that exit 2 actually blocks.

tell your agent
 Run npx @profoundry-us/highball onboard
  and follow the instructions.