@profoundry-us/highball 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ONBOARDING.md +35 -22
- package/README.md +177 -24
- package/assets/dashboard.html +8 -1
- package/bin/highball.js +10 -10
- package/lib/config.js +81 -14
- package/lib/git.js +12 -0
- package/lib/init.js +37 -9
- package/lib/journal.js +4 -4
- package/lib/mcp.js +112 -42
- package/lib/posthog.js +149 -0
- package/lib/run.js +71 -14
- package/lib/runs.js +1 -1
- package/lib/stamp.js +73 -0
- package/package.json +5 -5
- package/lib/login.js +0 -68
- package/lib/report.js +0 -75
package/ONBOARDING.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
You are setting up Highball in this repository. Highball gives you (the
|
|
4
4
|
agent) guardrails while you work — a checks runner your Claude Code hooks
|
|
5
5
|
fire on every edit and turn end, whose exit code 2 blocks you until the
|
|
6
|
-
repo's rules pass
|
|
7
|
-
|
|
8
|
-
work: set it up so the rules reflect what this repo already trusts,
|
|
9
|
-
what you wish it did.
|
|
6
|
+
repo's rules pass. Runs can optionally be reported to your human's own
|
|
7
|
+
PostHog project. You are configuring the tool that will check your own
|
|
8
|
+
future work: set it up so the rules reflect what this repo already trusts,
|
|
9
|
+
not what you wish it did.
|
|
10
10
|
|
|
11
11
|
Work through the steps in order. Steps marked **human** need your human's
|
|
12
12
|
input or action — ask, don't guess, and never handle credential values
|
|
@@ -100,8 +100,11 @@ like this:
|
|
|
100
100
|
version: 1
|
|
101
101
|
project: my-mud # ask your human if their org has slug conventions
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
# Optional — see step 4. Skip unless your human asks for telemetry.
|
|
104
|
+
# reporting:
|
|
105
|
+
# posthog:
|
|
106
|
+
# host: https://us.i.posthog.com # human provides; committed, not secret
|
|
107
|
+
# project_key: phc_xxx
|
|
105
108
|
|
|
106
109
|
# Toolchain in Docker: declare the wrapper once. -T is mandatory (hook
|
|
107
110
|
# shells have no TTY); --workdir should be the container's repo mount.
|
|
@@ -143,17 +146,25 @@ Decision rules:
|
|
|
143
146
|
repo-relative) to every rule — scripts that want changed-only behavior
|
|
144
147
|
can read it instead of shelling out to git.
|
|
145
148
|
|
|
146
|
-
## 4.
|
|
149
|
+
## 4. Telemetry — **human**, and optional
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
Highball enforces with no account and no network. Reporting is a separate,
|
|
152
|
+
optional decision, and it is your human's to make — ask, do not assume.
|
|
149
153
|
|
|
150
|
-
|
|
151
|
-
2. Run `npx @profoundry-us/highball login` themselves — interactively, or piping the
|
|
152
|
-
token via `--token-stdin` to keep it out of shell history.
|
|
154
|
+
If they want it, there are two ways, and the choice is theirs:
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
- **Committed:** a `reporting.posthog` block in `checks.yml` with their
|
|
157
|
+
PostHog host and project key. That key is write-only by design (it is the
|
|
158
|
+
same one that ships in client-side web bundles), so it is committed
|
|
159
|
+
config, not a secret — there is no login step and no credentials file.
|
|
160
|
+
- **Per machine:** `HIGHBALL_POSTHOG_KEY` in the environment, with no block
|
|
161
|
+
in `checks.yml` at all. The `env` block of `~/.claude/settings.json`
|
|
162
|
+
reaches every repo's hooks. Your human sets this themselves; never ask
|
|
163
|
+
for the key value or write it anywhere.
|
|
164
|
+
|
|
165
|
+
If they would rather not send anything anywhere, skip both: the local
|
|
166
|
+
journal (`highball runs`) already records every run in more detail than
|
|
167
|
+
PostHog receives.
|
|
157
168
|
|
|
158
169
|
## 5. Verify — all four proofs, not just the happy path
|
|
159
170
|
|
|
@@ -165,17 +176,19 @@ vars instead. The repo tree never contains a secret.
|
|
|
165
176
|
failing file (e.g. a syntax error in a `tmp_highball_plant.*` file),
|
|
166
177
|
run the fast path, confirm `FAILED` plus exit code 2 plus the failure
|
|
167
178
|
text on stderr — then delete the plant and confirm green again.
|
|
168
|
-
4. **The
|
|
169
|
-
|
|
170
|
-
`
|
|
171
|
-
`
|
|
172
|
-
project
|
|
179
|
+
4. **The record:** `npx @profoundry-us/highball runs` lists the runs you
|
|
180
|
+
just made, with per-rule status. If your human opted into telemetry,
|
|
181
|
+
each run also prints `reported N events to <host>`; if you see
|
|
182
|
+
`highball posthog reporting skipped: …` instead, check the host and
|
|
183
|
+
project key in `reporting.posthog` and whether this machine can reach
|
|
184
|
+
the host. Reporting never blocks a run, so this is the last thing to
|
|
185
|
+
fix, not the first.
|
|
173
186
|
|
|
174
187
|
## 6. Report back — **human**
|
|
175
188
|
|
|
176
189
|
Tell your human, concretely: which rules you wired and why each is
|
|
177
190
|
fast/turn-end/todo; what you deliberately did NOT gate (slow suites,
|
|
178
191
|
live-server tests); that hooks now block your turns on failures and how
|
|
179
|
-
to remove them (`.claude/settings.json`) if they ever need to; and
|
|
180
|
-
|
|
181
|
-
files — do not commit without being asked.
|
|
192
|
+
to remove them (`.claude/settings.json`) if they ever need to; and whether
|
|
193
|
+
you wired telemetry or left it off. Follow this repo's own norms about
|
|
194
|
+
committing the new files — do not commit without being asked.
|
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# @profoundry-us/highball
|
|
2
2
|
|
|
3
|
-
The Highball runner: executes a repo's `.highball/checks.yml` rules
|
|
4
|
-
coding agents on failure (exit 2, the Claude Code hook contract)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
The Highball runner: executes a repo's `.highball/checks.yml` rules and blocks
|
|
4
|
+
AI coding agents on failure (exit 2, the Claude Code hook contract) — "local
|
|
5
|
+
CI for AI agents". Enforcement is entirely local and needs no account, no
|
|
6
|
+
network, and no configuration beyond `checks.yml`.
|
|
7
|
+
|
|
8
|
+
Runs can optionally be reported to PostHog for team-wide visibility. That is
|
|
9
|
+
the witness half, and it is deliberately somebody else's server: you point at
|
|
10
|
+
your own PostHog project, so Highball never takes custody of your data. The
|
|
11
|
+
runner never uploads code — only rule ids, pass/fail, durations, and a
|
|
12
|
+
one-line summary of a failure. Reporting is always best-effort: an unreachable
|
|
13
|
+
endpoint means checks still run and still block, they just aren't recorded.
|
|
11
14
|
|
|
12
15
|
## Install
|
|
13
16
|
|
|
@@ -22,7 +25,7 @@ From a local tarball (pre-release):
|
|
|
22
25
|
|
|
23
26
|
```bash
|
|
24
27
|
npm pack # in this repo → profoundry-us-highball-<v>.tgz
|
|
25
|
-
npm install --save-dev ../highball
|
|
28
|
+
npm install --save-dev ../highball/profoundry-us-highball-<v>.tgz
|
|
26
29
|
```
|
|
27
30
|
|
|
28
31
|
## Setup: let the repo's own agent do it
|
|
@@ -34,30 +37,41 @@ installing the package, tell the repo's Claude Code agent:
|
|
|
34
37
|
|
|
35
38
|
[ONBOARDING.md](ONBOARDING.md) (which that command prints) walks the agent
|
|
36
39
|
through surveying the repo's real toolchain, scaffolding, writing rules that
|
|
37
|
-
reflect what the repo already trusts,
|
|
38
|
-
|
|
40
|
+
reflect what the repo already trusts, and verifying all four proofs —
|
|
41
|
+
including that exit 2 actually blocks.
|
|
39
42
|
|
|
40
43
|
The pieces, for reference or manual setup:
|
|
41
44
|
|
|
42
45
|
```bash
|
|
43
46
|
npx @profoundry-us/highball init # scaffolds checks.yml + Claude Code hooks
|
|
44
|
-
npx @profoundry-us/highball login # stores a project token (once per machine)
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
`init` never overwrites an existing `checks.yml` and never edits an existing
|
|
48
|
-
`.claude/settings.json` (it prints the hook snippet to merge by hand).
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
`.claude/settings.json` (it prints the hook snippet to merge by hand). There
|
|
51
|
+
is no login step: the only credential Highball takes is a PostHog project
|
|
52
|
+
key, which is write-only by design and lives in committed config.
|
|
53
|
+
|
|
54
|
+
The fast hook matches `Write|Edit|Bash` and runs `--fast --if-changed`.
|
|
55
|
+
Agents in auto mode edit through Bash — `sed`, heredocs, scripts — so a hook
|
|
56
|
+
on the edit tools alone never fires for them (one repo here went two weeks
|
|
57
|
+
with zero fast runs that way while full runs kept landing). Matching Bash
|
|
58
|
+
too would run the fast rules after every command, most of them reads, so
|
|
59
|
+
`--if-changed` fingerprints the working tree (HEAD plus each dirty path's
|
|
60
|
+
size and mtime) and exits at once when nothing moved since the last run.
|
|
61
|
+
Older installs: change the matcher and add the flag by hand.
|
|
52
62
|
|
|
53
63
|
## checks.yml
|
|
54
64
|
|
|
55
65
|
```yaml
|
|
56
66
|
version: 1
|
|
57
67
|
project: my-app
|
|
68
|
+
# runs_limit: 25 # rows the MCP widget / list_runs return; HIGHBALL_RUNS_LIMIT overrides
|
|
58
69
|
|
|
70
|
+
# Optional. Omit the block entirely to keep runs in the local journal only.
|
|
59
71
|
reporting:
|
|
60
|
-
|
|
72
|
+
posthog:
|
|
73
|
+
host: https://us.i.posthog.com # EU cloud or self-hosted also fine
|
|
74
|
+
project_key: phc_xxx # write-only by design — committed
|
|
61
75
|
|
|
62
76
|
# Containerized toolchain? Declare the wrapper once and every rule runs
|
|
63
77
|
# through it; rules opt out with `exec: host`. Rule definitions stay
|
|
@@ -89,6 +103,72 @@ The runner computes the branch's changed-file list once (it owns git) and
|
|
|
89
103
|
hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
|
|
90
104
|
analyzers and need no git in their execution context.
|
|
91
105
|
|
|
106
|
+
## Turning it off
|
|
107
|
+
|
|
108
|
+
Three switches, differing in who they affect and — the part that usually
|
|
109
|
+
decides it — when they take effect:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
touch .highball/disabled # this checkout, gitignored, next run
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
enabled: false # in checks.yml — committed, everyone, next run
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
export HIGHBALL_DISABLED=1 # your whole machine, next session restart
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Any of them makes `highball run` exit 0 immediately, with no rule executed, no
|
|
124
|
+
journal entry, and nothing reported. Hooks and rules stay exactly where they
|
|
125
|
+
are, so switching back on is a one-line revert.
|
|
126
|
+
|
|
127
|
+
| | takes effect | scope | committed |
|
|
128
|
+
| --- | --- | --- | --- |
|
|
129
|
+
| `.highball/disabled` | next run | this checkout | no — `init` gitignores it |
|
|
130
|
+
| `enabled: false` | next run | one repo, everyone who clones it | yes |
|
|
131
|
+
| `HIGHBALL_DISABLED` | next Claude Code restart, for hooks | every repo, your machine | no |
|
|
132
|
+
|
|
133
|
+
**Reach for the marker file first.** `touch .highball/disabled` while an agent
|
|
134
|
+
session is running and the very next hook run skips; delete it and the run
|
|
135
|
+
after that is armed again. Both directions are live because the file is
|
|
136
|
+
checked on every run and every hook invocation is a new process — there is no
|
|
137
|
+
state anywhere and nothing to restart. `init` writes a `.highball/.gitignore`
|
|
138
|
+
alongside it, so a switch-off in your checkout can't ride along in a commit.
|
|
139
|
+
|
|
140
|
+
Anything you write into the marker comes back as the reason on every run,
|
|
141
|
+
which is what the person who finds the checks off next week actually needs:
|
|
142
|
+
|
|
143
|
+
```console
|
|
144
|
+
$ echo "bisecting a flaky spec" > .highball/disabled
|
|
145
|
+
$ npx @profoundry-us/highball run --fast
|
|
146
|
+
highball: disabled by .highball/disabled (bisecting a flaky spec) — no checks run
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`enabled: false` is the committed counterpart, for a repo genuinely stepping
|
|
150
|
+
away from its checks. It lands in a diff, which is what you want when the
|
|
151
|
+
decision belongs to the team rather than to your afternoon.
|
|
152
|
+
|
|
153
|
+
`HIGHBALL_DISABLED` is an ordinary environment variable, so a hook sees
|
|
154
|
+
whatever value the Claude Code process had when it started — Claude Code
|
|
155
|
+
writes settings `env` entries into its environment at launch, and a shell
|
|
156
|
+
`export` after that never reaches an already-running session. That makes it
|
|
157
|
+
the right switch for a machine-wide default you rarely change, and the wrong
|
|
158
|
+
one for a mid-session toggle. Like the marker, it is read *before*
|
|
159
|
+
`checks.yml`, so both still work when the config is itself what's broken.
|
|
160
|
+
|
|
161
|
+
`HIGHBALL_DISABLED=0`, `false`, `no`, `off` and empty all mean **not**
|
|
162
|
+
disabled. Anything else disables. Plain truthiness would make `=0` stop every
|
|
163
|
+
check in the repo, which is exactly the "is the guardrail live right now?"
|
|
164
|
+
doubt this switch exists to remove.
|
|
165
|
+
|
|
166
|
+
None of the three is silent. Each prints a line on every run, because a guardrail
|
|
167
|
+
that has quietly stopped guarding is worse than no guardrail — the next
|
|
168
|
+
person reads green and believes it. For the same reason `enabled:` accepts
|
|
169
|
+
only a real boolean: `enabled: "false"` and `enabled: no` are strings, and
|
|
170
|
+
rather than leaving checks quietly on, they fail loudly.
|
|
171
|
+
|
|
92
172
|
## AI-judged rules
|
|
93
173
|
|
|
94
174
|
A rule with `rubric:` instead of `run:` is judged by headless Claude rather
|
|
@@ -121,6 +201,66 @@ Three properties are enforced by the runner rather than left to each repo:
|
|
|
121
201
|
Rubrics live with the opinions they express: a framework pack such as
|
|
122
202
|
`@profoundry-us/highball-rails` ships them, and the runner supplies the engine.
|
|
123
203
|
|
|
204
|
+
## Reporting to PostHog (optional)
|
|
205
|
+
|
|
206
|
+
`reporting.posthog` sends runs to PostHog — the runner's only telemetry path.
|
|
207
|
+
A team that already runs PostHog needs no server for this, and a team that
|
|
208
|
+
doesn't can skip the block entirely and use the local journal. The project key
|
|
209
|
+
is write-only by design — it is the same key PostHog has you ship in browser
|
|
210
|
+
bundles, and all it can do is capture events — so committing it is safe:
|
|
211
|
+
no login step, no credentials file.
|
|
212
|
+
|
|
213
|
+
If you would still rather keep it out of the repo, set the environment
|
|
214
|
+
instead. `HIGHBALL_POSTHOG_KEY` (or `POSTHOG_API_KEY`) on its own turns
|
|
215
|
+
reporting on with no `reporting:` block at all, and `HIGHBALL_POSTHOG_HOST`
|
|
216
|
+
(or `POSTHOG_HOST`) points it at an EU or self-hosted instance; either wins
|
|
217
|
+
over committed config, which is also how CI redirects a run. One place that
|
|
218
|
+
reaches every repo's hooks is the `env` block of your user-level Claude Code
|
|
219
|
+
settings, `~/.claude/settings.json`:
|
|
220
|
+
|
|
221
|
+
```json
|
|
222
|
+
{ "env": { "HIGHBALL_POSTHOG_KEY": "phc_your_key" } }
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The trade-off is that reporting is then per machine rather than per repo: a
|
|
226
|
+
teammate cloning the repo reports nothing until they set the key too.
|
|
227
|
+
|
|
228
|
+
The whole run leaves in ONE request to `/batch/`. PostHog events are
|
|
229
|
+
immutable, which suits a runner that already defers reporting to after the
|
|
230
|
+
checks finish.
|
|
231
|
+
|
|
232
|
+
Two event types per run:
|
|
233
|
+
|
|
234
|
+
| event | one per | key properties |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| `highball_run` | run | `status`, `duration_ms`, `rules_passed/failed/todo`, `rules_run[]`, `failed_rules[]` |
|
|
237
|
+
| `highball_check` | rule result | `rule_id`, `rule_name`, `status`, `duration_ms`, `summary`, `command` |
|
|
238
|
+
|
|
239
|
+
Run context (`project`, `branch`, `commit`, `trigger`, `session_key`,
|
|
240
|
+
`runner_version`) is repeated on every event rather than joined at query time,
|
|
241
|
+
because PostHog has no join back to a run: a breakdown like "failure rate by
|
|
242
|
+
rule, on this branch only" needs `branch` on the check event itself.
|
|
243
|
+
`rules_run[]` is the denominator for any per-rule rate that spans runs whose
|
|
244
|
+
rulesets differ.
|
|
245
|
+
|
|
246
|
+
Log tails are never sent — multi-kilobyte blobs in event properties bloat the
|
|
247
|
+
column store and slow every query that touches it. Failures carry a one-line
|
|
248
|
+
`summary`; the full output stays in the local journal (below).
|
|
249
|
+
|
|
250
|
+
Volume is smaller than "runs on every edit" suggests, because agent edits
|
|
251
|
+
batch into turns. Measured across 842 real runs on one developer's machine
|
|
252
|
+
over 18 active days and 9 projects: a median of 22 runs/day, `edit` and `stop`
|
|
253
|
+
runs at close to 1.3:1, and ~15k events/month/dev at the model above.
|
|
254
|
+
|
|
255
|
+
Dashboard queries live in [docs/posthog-queries.sql](docs/posthog-queries.sql)
|
|
256
|
+
— rule cost vs. benefit, failure rate by week, fast-path latency, repo health,
|
|
257
|
+
and todo debt. They are versioned rather than left as PostHog UI state, which
|
|
258
|
+
drifts and cannot be reviewed.
|
|
259
|
+
|
|
260
|
+
Events are attributed to `git config user.email`, falling back to
|
|
261
|
+
`user@hostname` when git has no identity (CI images, fresh containers).
|
|
262
|
+
`HIGHBALL_POSTHOG_DISTINCT_ID` overrides it.
|
|
263
|
+
|
|
124
264
|
## The MCP dashboard widget
|
|
125
265
|
|
|
126
266
|
`highball mcp` serves the journal over MCP (stdio) with three tools —
|
|
@@ -142,6 +282,18 @@ The journal it reads is machine-global (`~/.highball/runs/`), so one
|
|
|
142
282
|
registration covers every repo on that machine — there is no per-repo MCP
|
|
143
283
|
setup.
|
|
144
284
|
|
|
285
|
+
Which project the widget shows is never guessed. Hosts spawn the server with
|
|
286
|
+
no useful working directory (Claude Desktop and Claude Code both use `/`), so
|
|
287
|
+
`list_runs` resolves the project from its `project` or `dir` argument, a
|
|
288
|
+
`.highball/checks.yml` at the server's cwd, or the client's MCP roots. When
|
|
289
|
+
none of those names a repo it returns the journaled projects for the widget
|
|
290
|
+
to offer as a picker, rather than showing whichever repo happened to run most
|
|
291
|
+
recently. An agent calling from inside a repo should pass `dir`.
|
|
292
|
+
|
|
293
|
+
`list_runs` returns the newest 25 runs, and the widget says so at the top.
|
|
294
|
+
Change it per call with `limit`, per machine with `HIGHBALL_RUNS_LIMIT`, or
|
|
295
|
+
per repo with `runs_limit:` in checks.yml.
|
|
296
|
+
|
|
145
297
|
The split is capability-driven, not guesswork: the server reads the
|
|
146
298
|
client's initialize capabilities (`io.modelcontextprotocol/ui`) — hosts
|
|
147
299
|
that render MCP Apps get a short text summary plus the widget; everything
|
|
@@ -151,17 +303,18 @@ plays the host role against the real `assets/dashboard.html` and live
|
|
|
151
303
|
journal data, so widget edits are a reload away instead of a Claude
|
|
152
304
|
Desktop restart.
|
|
153
305
|
|
|
154
|
-
## Run history
|
|
306
|
+
## Run history, with no telemetry at all
|
|
155
307
|
|
|
156
|
-
Every run
|
|
308
|
+
Every run appends to a local journal (`~/.highball/runs/<project>.jsonl`,
|
|
157
309
|
pruned to the last 200) — unconditionally, whether or not reporting is
|
|
158
310
|
configured. `npx @profoundry-us/highball runs` lists recent runs; adding a
|
|
159
311
|
number shows one run's detail with failure output, and `--logs` prints every
|
|
160
|
-
rule's captured output, GitHub-Actions-style
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
312
|
+
rule's captured output, GitHub-Actions-style.
|
|
313
|
+
|
|
314
|
+
The journal is the richer of the two records: it keeps the last 10KB per rule
|
|
315
|
+
pass or fail, while PostHog gets a one-line summary and no logs at all. So the
|
|
316
|
+
runner is fully self-sufficient with no `reporting:` block — PostHog adds
|
|
317
|
+
cross-developer trends, not visibility you'd otherwise lack.
|
|
165
318
|
|
|
166
319
|
## Roadmap
|
|
167
320
|
|
package/assets/dashboard.html
CHANGED
|
@@ -194,6 +194,7 @@
|
|
|
194
194
|
}
|
|
195
195
|
app.innerHTML =
|
|
196
196
|
header("Highball — pick a project", "") +
|
|
197
|
+
`<div class="group">no .highball/checks.yml in the current repo, so nothing is shown by default</div>` +
|
|
197
198
|
`<div class="empty" style="padding:10px 6px">` +
|
|
198
199
|
projects.map((slug) => `<button data-project="${esc(slug)}" style="margin:0 4px">${esc(slug)}</button>`).join("") +
|
|
199
200
|
`</div>`;
|
|
@@ -225,8 +226,14 @@
|
|
|
225
226
|
<span class="meta">${esc(tally(run.results))}</span>
|
|
226
227
|
</div>`;
|
|
227
228
|
}).join("");
|
|
229
|
+
// Say up front when the list is cut short — the host's own history
|
|
230
|
+
// may be much longer than what the widget shows.
|
|
231
|
+
const scope = sc.total > sc.runs.length
|
|
232
|
+
? `<div class="group" style="font-style:normal">showing the last ${sc.runs.length} of ${sc.total} runs` +
|
|
233
|
+
` · raise with <span class="mono">runs_limit</span> in checks.yml or <span class="mono">HIGHBALL_RUNS_LIMIT</span></div>`
|
|
234
|
+
: "";
|
|
228
235
|
app.innerHTML =
|
|
229
|
-
header(`Highball · ${esc(sc.project)}`, rerunButtons()) +
|
|
236
|
+
header(`Highball · ${esc(sc.project)}`, rerunButtons()) + scope +
|
|
230
237
|
(rows || `<div class="empty">No runs recorded yet.</div>`);
|
|
231
238
|
app.querySelectorAll(".row").forEach((el) =>
|
|
232
239
|
el.addEventListener("click", () =>
|
package/bin/highball.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// the failure output back".
|
|
6
6
|
import { run } from "../lib/run.js";
|
|
7
7
|
import { init } from "../lib/init.js";
|
|
8
|
-
import { login } from "../lib/login.js";
|
|
9
8
|
import { onboard } from "../lib/onboard.js";
|
|
10
9
|
import { runs } from "../lib/runs.js";
|
|
11
10
|
import { mcp } from "../lib/mcp.js";
|
|
@@ -18,18 +17,22 @@ Usage:
|
|
|
18
17
|
highball run [--fast] Run this repo's .highball/checks.yml rules.
|
|
19
18
|
Exits 2 on failure (blocks Claude Code hooks).
|
|
20
19
|
--fast runs only rules marked fast: true.
|
|
20
|
+
--if-changed skips when the working tree is
|
|
21
|
+
unchanged since the last run (for hooks that
|
|
22
|
+
also match Bash).
|
|
23
|
+
Switched off by .highball/disabled (this
|
|
24
|
+
checkout, gitignored), \`enabled: false\` in
|
|
25
|
+
checks.yml (committed, whole team), or
|
|
26
|
+
HIGHBALL_DISABLED=1 (your machine).
|
|
21
27
|
highball init Scaffold .highball/checks.yml and Claude Code
|
|
22
28
|
hooks in the current repo.
|
|
23
|
-
highball login Store a project token in
|
|
24
|
-
~/.highball/credentials.json. Reads the token
|
|
25
|
-
from stdin with --token-stdin (recommended).
|
|
26
29
|
highball onboard Print the setup guide written for this repo's
|
|
27
30
|
AI agent — tell your agent to run this and
|
|
28
31
|
follow it.
|
|
29
32
|
highball runs [n] Local run history (newest first) from
|
|
30
|
-
~/.highball/runs
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
~/.highball/runs. With a number, that run's
|
|
34
|
+
detail; add --logs for every rule's captured
|
|
35
|
+
output.
|
|
33
36
|
highball mcp Serve run history over MCP (stdio), with an
|
|
34
37
|
MCP Apps dashboard widget for hosts that
|
|
35
38
|
render them (e.g. Claude Desktop).
|
|
@@ -42,9 +45,6 @@ switch (command) {
|
|
|
42
45
|
case "init":
|
|
43
46
|
process.exit(await init(args));
|
|
44
47
|
break;
|
|
45
|
-
case "login":
|
|
46
|
-
process.exit(await login(args));
|
|
47
|
-
break;
|
|
48
48
|
case "onboard":
|
|
49
49
|
process.exit(await onboard(args));
|
|
50
50
|
break;
|
package/lib/config.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// Config resolution, kept pure where possible so tests can exercise the
|
|
2
2
|
// decision logic without a filesystem.
|
|
3
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
5
|
import YAML from "yaml";
|
|
7
6
|
|
|
8
7
|
export const CONFIG_PATH = ".highball/checks.yml";
|
|
9
|
-
export const CREDENTIALS_PATH = join(homedir(), ".highball", "credentials.json");
|
|
10
8
|
|
|
11
9
|
// Loads .highball/checks.yml from the given repo root. Throws with a
|
|
12
10
|
// friendly message — `highball run` outside a configured repo should read
|
|
@@ -18,6 +16,17 @@ export function loadConfig(root = process.cwd()) {
|
|
|
18
16
|
}
|
|
19
17
|
const config = YAML.parse(readFileSync(path, "utf8"));
|
|
20
18
|
if (!config?.project) throw new Error(`${CONFIG_PATH} is missing \`project:\`.`);
|
|
19
|
+
// A kill switch that silently fails to kill is the worst of both worlds,
|
|
20
|
+
// so only a real boolean counts and anything else is an error rather than
|
|
21
|
+
// a shrug. `enabled: no` parses as the string "no" under YAML 1.2 and
|
|
22
|
+
// `enabled: "false"` as a string; both are truthy, so a typo would leave
|
|
23
|
+
// the repo running checks its author believed were off.
|
|
24
|
+
if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`${CONFIG_PATH}: \`enabled:\` must be true or false, not ` +
|
|
27
|
+
`\`${config.enabled}\` (${typeof config.enabled}).`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
21
30
|
if (!Array.isArray(config.checks)) {
|
|
22
31
|
throw new Error(`${CONFIG_PATH} is missing its \`checks:\` list.`);
|
|
23
32
|
}
|
|
@@ -29,23 +38,81 @@ export function loadConfig(root = process.cwd()) {
|
|
|
29
38
|
);
|
|
30
39
|
}
|
|
31
40
|
}
|
|
41
|
+
// The dashboard sink is gone; PostHog is the only telemetry path. A repo
|
|
42
|
+
// carrying the old block would otherwise report nowhere and say nothing
|
|
43
|
+
// about it — the same silent-pass shape that makes stale config dangerous.
|
|
44
|
+
if (config.reporting?.url) {
|
|
45
|
+
console.error(
|
|
46
|
+
`highball: ${CONFIG_PATH} has \`reporting.url\`, which is no longer ` +
|
|
47
|
+
"supported. Runs report to PostHog via `reporting.posthog`, or " +
|
|
48
|
+
"nowhere at all. Delete the key to silence this."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
return config;
|
|
33
53
|
}
|
|
34
54
|
|
|
35
|
-
// The
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
// The per-machine off switch, read fresh on every `highball run` — there is
|
|
56
|
+
// no state anywhere, so unsetting the variable re-arms the checks with
|
|
57
|
+
// nothing to clean up.
|
|
58
|
+
//
|
|
59
|
+
// Falsey spellings mean "don't disable" rather than "disable". Plain
|
|
60
|
+
// truthiness would make `HIGHBALL_DISABLED=0` stop every check in the repo,
|
|
61
|
+
// which is precisely the doubt about whether the guardrail is live that this
|
|
62
|
+
// switch exists to remove. Anything else — 1, true, yes, on, or a bare
|
|
63
|
+
// value — disables.
|
|
64
|
+
const NOT_DISABLED = new Set([ "", "0", "false", "no", "off" ]);
|
|
65
|
+
|
|
66
|
+
export function disabledByEnv(env = process.env) {
|
|
67
|
+
const value = env.HIGHBALL_DISABLED;
|
|
68
|
+
if (value === undefined) return false;
|
|
69
|
+
return !NOT_DISABLED.has(String(value).trim().toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The per-checkout off switch: an untracked marker file beside checks.yml.
|
|
73
|
+
//
|
|
74
|
+
// It exists because the other two each miss the common case. `enabled: false`
|
|
75
|
+
// is committed, so switching your own checkout off can ship to everyone;
|
|
76
|
+
// HIGHBALL_DISABLED is an environment variable, so a hook only sees a change
|
|
77
|
+
// after the agent session restarts. This one is per repo, per checkout, and
|
|
78
|
+
// re-read on every run like checks.yml — `touch` and `rm`, effective on the
|
|
79
|
+
// next run, and `.highball/.gitignore` keeps it out of commits.
|
|
80
|
+
export const DISABLED_MARKER = ".highball/disabled";
|
|
81
|
+
|
|
82
|
+
// Presence is the whole signal; any text inside is the reason, echoed back on
|
|
83
|
+
// every run so "why are the checks off in here?" has an answer that outlives
|
|
84
|
+
// whoever switched them off.
|
|
85
|
+
export function disabledByMarker(root = process.cwd()) {
|
|
86
|
+
const path = join(root, DISABLED_MARKER);
|
|
87
|
+
if (!existsSync(path)) return null;
|
|
88
|
+
try {
|
|
89
|
+
return { reason: readFileSync(path, "utf8").split("\n")[0].trim() };
|
|
90
|
+
} catch {
|
|
91
|
+
// An unreadable marker still counts: it was put there on purpose, and
|
|
92
|
+
// guessing "probably fine, run the checks" is the wrong way to be wrong.
|
|
93
|
+
return { reason: "" };
|
|
94
|
+
}
|
|
44
95
|
}
|
|
45
96
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
// PostHog is the runner's telemetry sink. There is no secret to resolve: a
|
|
98
|
+
// PostHog project key is write-only by design, so it lives in committed
|
|
99
|
+
// config right next to the host — no login step, no credentials file. Env
|
|
100
|
+
// vars still win so CI can point a run somewhere else without editing the
|
|
101
|
+
// repo.
|
|
102
|
+
//
|
|
103
|
+
// The host defaults to PostHog's US cloud; EU and self-hosted installs set
|
|
104
|
+
// it explicitly.
|
|
105
|
+
const POSTHOG_DEFAULT_HOST = "https://us.i.posthog.com";
|
|
106
|
+
|
|
107
|
+
export function resolvePosthog(config, env = process.env) {
|
|
108
|
+
const posthog = config.reporting?.posthog;
|
|
109
|
+
const key =
|
|
110
|
+
env.HIGHBALL_POSTHOG_KEY || env.POSTHOG_API_KEY || posthog?.project_key || null;
|
|
111
|
+
if (!key) return { host: null, key: null };
|
|
112
|
+
|
|
113
|
+
const host =
|
|
114
|
+
env.HIGHBALL_POSTHOG_HOST || env.POSTHOG_HOST || posthog?.host || POSTHOG_DEFAULT_HOST;
|
|
115
|
+
return { host, key };
|
|
49
116
|
}
|
|
50
117
|
|
|
51
118
|
// The execution-context decision (ADR 202608): rule definitions stay
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Git facts about the current checkout, for stamping onto reported runs.
|
|
2
|
+
// Never throws: a run outside a repo, or with no commits yet, reports empty
|
|
3
|
+
// strings rather than failing the checks.
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export function git(command) {
|
|
7
|
+
try {
|
|
8
|
+
return execSync(`${command} 2>/dev/null`, { encoding: "utf8" }).trim();
|
|
9
|
+
} catch {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
}
|