@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 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 and gives your human a hosted dashboard recording
7
- every run. You are configuring the tool that will check your own future
8
- work: set it up so the rules reflect what this repo already trusts, not
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
- reporting:
104
- url: https://highball.example.com # human provides; committed, not secret
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. Credentials — **human**
149
+ ## 4. Telemetry — **human**, and optional
147
150
 
148
- You must never see, type, or store a token value. Ask your human to:
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
- 1. Create this project (and a token for it) in their Highball app.
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
- This stores the token in `~/.highball/credentials.json` (machine-local,
155
- 0600, keyed host project). CI uses `HIGHBALL_URL`/`HIGHBALL_TOKEN` env
156
- vars instead. The repo tree never contains a secret.
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 witness:** every run above should print
169
- `reported to <host> (run <id>)`. If you see
170
- `highball reporting skipped: …` instead, diagnose in order: is
171
- `reporting.url` set? did the human run `login` for this exact URL and
172
- project slug? is the Highball app reachable from this machine?
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 where
180
- credentials live. Follow this repo's own norms about committing the new
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, blocks AI
4
- coding agents on failure (exit 2, the Claude Code hook contract), and reports
5
- every run to a Highball dashboard think "local CI for AI agents": the checks
6
- run and enforce on your machine while the dashboard records what happened.
7
- Enforcement stays local; Highball is the witness and system of record — the
8
- runner never uploads code, only pass/fail plus log tails. Reporting is always
9
- best-effort: no token or no reachable dashboard means checks still run and
10
- block, they just aren't recorded.
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-runner/profoundry-us-highball-<v>.tgz
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, handing the credentials step to the
38
- human, and verifying all four proofs — including that exit 2 actually blocks.
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
- `login` writes `~/.highball/credentials.json` (host project token, 0600);
50
- pipe the token via `--token-stdin` to keep it out of shell history. CI uses
51
- `HIGHBALL_URL` / `HIGHBALL_TOKEN` env vars instead.
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
- url: https://highball.example.com # per-team, not a secret — committed
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 without a dashboard
306
+ ## Run history, with no telemetry at all
155
307
 
156
- Every run also appends to a local journal (`~/.highball/runs/<project>.jsonl`,
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 — the journal keeps the last
161
- 10KB per rule, pass or fail, while the dashboard receives failure tails
162
- only. So the runner is self-sufficient out of the box: the hosted
163
- dashboard adds team visibility, history beyond your machine, and
164
- attribution it's never required to see what happened.
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
 
@@ -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
+ ` &middot; 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 no dashboard needed.
31
- With a number, that run's detail; add
32
- --logs for every rule's captured output.
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 reporting URL is per-team committed config; the token never lives
36
- // in the repo tree: env var (CI) wins, else the machine-local credentials
37
- // file keyed host → project (the .npmrc / gh-hosts pattern, written by
38
- // `highball login`).
39
- export function resolveReporting(config, env = process.env, credentials = readCredentials()) {
40
- const url = env.HIGHBALL_URL || config.reporting?.url || null;
41
- const token =
42
- env.HIGHBALL_TOKEN || (url && credentials?.[url]?.[config.project]) || null;
43
- return { url, token };
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
- export function readCredentials(path = CREDENTIALS_PATH) {
47
- if (!existsSync(path)) return {};
48
- return JSON.parse(readFileSync(path, "utf8"));
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
+ }