@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/lib/init.js CHANGED
@@ -8,16 +8,25 @@ import { basename, join } from "node:path";
8
8
  const CHECKS_TEMPLATE = (project) => `# ${project}'s Highball rules — the file \`highball run\` reads and
9
9
  # reports from. \`fast: true\` marks rules cheap enough to run on every
10
10
  # agent edit; the rest join at turn end. \`todo: true\` declares a rule
11
- # you're committed to but haven't built — tracked on the dashboard,
11
+ # you're committed to but haven't built — tracked in run history,
12
12
  # never a failure.
13
13
  version: 1
14
14
  project: ${project}
15
15
 
16
- # Where runs get reported. The URL is per-team and not a secret, so it
17
- # lives here in committed config; the token never enters the repo — run
18
- # \`highball login\` once per machine (or set HIGHBALL_TOKEN in CI).
16
+ # Set \`enabled: false\` to switch this repo's checks off for everyone,
17
+ # without deleting rules or hooks. To switch them off only in YOUR
18
+ # checkout, leave this alone and \`touch .highball/disabled\` instead
19
+ # it is gitignored, and takes effect on the next run.
20
+
21
+ # Optional telemetry. The PostHog project key is write-only by design, so
22
+ # it is committed config — there is no login step and no credentials file.
23
+ # To keep it out of the repo instead, set HIGHBALL_POSTHOG_KEY in the
24
+ # environment (e.g. the env block of ~/.claude/settings.json) and leave
25
+ # this block out entirely.
19
26
  # reporting:
20
- # url: https://your-highball-host
27
+ # posthog:
28
+ # host: https://us.i.posthog.com
29
+ # project_key: phc_your_key
21
30
 
22
31
  # If this repo's toolchain lives in a container, declare the wrapper once
23
32
  # and every rule runs through it; rules that belong on the host opt out
@@ -39,12 +48,19 @@ checks:
39
48
  // Always the SCOPED command. The unscoped npm name belongs to an unrelated
40
49
  // package, so a bare `npx highball` in a committed hook is one uninstalled
41
50
  // checkout away from fetching a stranger's code and running it on every edit.
51
+ //
52
+ // The fast hook matches Bash as well as the edit tools: agents in auto mode
53
+ // edit through Bash, and a hook on Write|Edit alone never fires for them.
54
+ // --if-changed keeps the Bash firings free when the tree hasn't moved.
42
55
  const HOOKS_JSON = {
43
56
  hooks: {
44
57
  PostToolUse: [
45
58
  {
46
- matcher: "Write|Edit",
47
- hooks: [{ type: "command", command: "npx @profoundry-us/highball run --fast" }]
59
+ matcher: "Write|Edit|Bash",
60
+ hooks: [{
61
+ type: "command",
62
+ command: "npx @profoundry-us/highball run --fast --if-changed"
63
+ }]
48
64
  }
49
65
  ],
50
66
  Stop: [
@@ -69,6 +85,18 @@ export async function init() {
69
85
  console.log(`created .highball/checks.yml (project: ${project}) — add your rules`);
70
86
  }
71
87
 
88
+ // Ignore the `disabled` marker from inside .highball/ rather than by
89
+ // editing the repo's root .gitignore, which init doesn't own. It is
90
+ // committed, so every clone inherits the protection and nobody has to
91
+ // remember the setup step that keeps a local switch-off local.
92
+ const ignorePath = join(root, ".highball", ".gitignore");
93
+ if (existsSync(ignorePath)) {
94
+ console.log("kept existing .highball/.gitignore");
95
+ } else {
96
+ writeFileSync(ignorePath, "# A local, uncommitted `highball run` off switch.\ndisabled\n");
97
+ console.log("created .highball/.gitignore (keeps the `disabled` marker out of commits)");
98
+ }
99
+
72
100
  const settingsPath = join(root, ".claude", "settings.json");
73
101
  if (existsSync(settingsPath)) {
74
102
  console.log(
@@ -82,8 +110,8 @@ export async function init() {
82
110
  }
83
111
 
84
112
  console.log(
85
- "\nnext: fill in .highball/checks.yml, uncomment reporting.url, and run" +
86
- "\n highball login # stores this machine's project token"
113
+ "\nnext: fill in .highball/checks.yml, and optionally uncomment the" +
114
+ "\nreporting.posthog block to send runs to PostHog."
87
115
  );
88
116
  return 0;
89
117
  }
package/lib/journal.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // The local run journal: every run appends one JSONL line to
2
2
  // ~/.highball/runs/<project>.jsonl, whether or not remote reporting is
3
- // configured. This is what makes the runner useful with no dashboard at
4
- // all — `highball runs` reads it — and it lives outside the repo tree
5
- // (like credentials) so there's no gitignore to manage and no state to
6
- // leak into commits.
3
+ // configured. This is what makes the runner useful with no telemetry at
4
+ // all — `highball runs` and the MCP widget read it — and it lives outside
5
+ // the repo tree so there's no gitignore to manage and no state to leak
6
+ // into commits.
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
package/lib/mcp.js CHANGED
@@ -21,34 +21,61 @@ const VERSION = JSON.parse(
21
21
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
22
22
  ).version;
23
23
 
24
- // The server may be launched from a repo (project inferable) or from a
25
- // host like Claude Desktop whose cwd is nowhere useful. Resolution order:
26
- // explicit argument, the cwd's checks.yml, then the journal with the
27
- // newest run the repo being actively worked in IS the current project.
28
- // Journal records carry the repo dir (since runs record process.cwd()),
29
- // so every path out of here can ground the widget's re-run buttons.
30
- function resolveProject(explicit) {
31
- if (explicit) return { project: explicit, dir: latestDirFor(explicit) };
24
+ // The host decides the server's working directory, and real hosts give it
25
+ // nothing useful Claude Desktop and Claude Code both spawn it at `/`. So
26
+ // "the current repo" has to arrive some other way: an explicit `project`
27
+ // or `dir` argument, a checks.yml at cwd, or the client's MCP roots.
28
+ // Nothing else is guessed. In particular the journal is NOT a fallback:
29
+ // "whichever project ran most recently on this machine" is usually some
30
+ // other repo, and a widget that silently shows another project's runs
31
+ // reads as this project's. Unresolved means the widget offers a picker.
32
+ //
33
+ // Journal records carry the repo dir (runs record process.cwd()), so an
34
+ // explicit project still grounds the widget's re-run buttons. The loaded
35
+ // config rides along so per-repo settings (runs_limit) can apply.
36
+ export function resolveProject({
37
+ project, dir, cwd = process.cwd(), roots = [], journalDir
38
+ } = {}) {
39
+ if (project) {
40
+ const home = dir ?? latestDirFor(project, journalDir);
41
+ return { project, dir: home, config: tryLoad(home) };
42
+ }
43
+ for (const candidate of [ dir, cwd, ...roots ].filter(Boolean)) {
44
+ const config = tryLoad(candidate);
45
+ if (config) return { project: config.project, dir: candidate, config };
46
+ }
47
+ return { project: null, dir: null, config: null };
48
+ }
49
+
50
+ function tryLoad(root) {
51
+ if (!root) return null;
32
52
  try {
33
- return { project: loadConfig().project, dir: process.cwd() };
53
+ return loadConfig(root);
34
54
  } catch {
35
- let current = null;
36
- for (const project of journaledProjects()) {
37
- const newest = readRuns(project)[0];
38
- if (!newest) continue;
39
- if (!current || newest.started_at > current.started_at) {
40
- current = { project, started_at: newest.started_at };
41
- }
42
- }
43
- if (!current) return { project: null, dir: null };
44
- return { project: current.project, dir: latestDirFor(current.project) };
55
+ return null;
45
56
  }
46
57
  }
47
58
 
48
59
  // Newest journal record that knows its repo dir (older records predate
49
60
  // the field).
50
- function latestDirFor(project) {
51
- return readRuns(project).find((run) => run.dir)?.dir ?? null;
61
+ function latestDirFor(project, journalDir) {
62
+ return readRuns(project, journalDir).find((run) => run.dir)?.dir ?? null;
63
+ }
64
+
65
+ // How many runs list_runs returns. 200 journaled runs is a fine history and
66
+ // a terrible tool result — the structured payload alone reaches ~300KB,
67
+ // which text-only hosts hand straight to the model. Resolution: the call's
68
+ // `limit`, then HIGHBALL_RUNS_LIMIT, then `runs_limit:` in checks.yml (only
69
+ // present when the project resolved through a repo), then the default.
70
+ export const DEFAULT_RUNS_LIMIT = 25;
71
+
72
+ export function resolveRunsLimit({ limit, env = process.env, config } = {}) {
73
+ for (const candidate of [ limit, env.HIGHBALL_RUNS_LIMIT, config?.runs_limit ]) {
74
+ if (candidate == null || candidate === "") continue;
75
+ const n = Number(candidate);
76
+ if (Number.isInteger(n) && n > 0) return n;
77
+ }
78
+ return DEFAULT_RUNS_LIMIT;
52
79
  }
53
80
 
54
81
  // List payloads stay lean — output tails ride only on get_run.
@@ -82,8 +109,12 @@ function reply(text, structuredContent) {
82
109
  const glyphFor = (status) =>
83
110
  status === "passed" ? "✓" : status === "todo" ? "•" : "✗";
84
111
 
85
- export function listText(project, runs) {
112
+ export function listText(project, runs, total = runs.length) {
86
113
  if (runs.length === 0) return `No runs recorded for ${project} yet.`;
114
+ const scope = total > runs.length
115
+ ? `, last ${runs.length} of ${total} — raise with limit, HIGHBALL_RUNS_LIMIT, ` +
116
+ "or runs_limit in checks.yml"
117
+ : "";
87
118
  const rows = runs.map((run) => [
88
119
  `#${run.index}`,
89
120
  run.status === "passed" ? "✓ passed" : "✗ FAILED",
@@ -95,7 +126,7 @@ export function listText(project, runs) {
95
126
  ]);
96
127
  const widths = rows[0].map((_, col) =>
97
128
  Math.max(...rows.map((row) => row[col].length)));
98
- const lines = [ `Runs for ${project} (newest first):` ];
129
+ const lines = [ `Runs for ${project} (newest first${scope}):` ];
99
130
  let lastGroup;
100
131
  runs.forEach((run, i) => {
101
132
  // Group header whenever the work (or its session) changes between
@@ -139,31 +170,68 @@ export async function mcp() {
139
170
  (!capability.mimeTypes || capability.mimeTypes.includes(RESOURCE_MIME_TYPE));
140
171
  };
141
172
 
173
+ // The client's MCP roots are the one thing a host can tell us about
174
+ // which repo is open. Best-effort: only asked of clients that advertise
175
+ // the capability, and a slow or failing answer resolves to nothing.
176
+ const clientRoots = async () => {
177
+ try {
178
+ if (!server.server.getClientCapabilities()?.roots) return [];
179
+ const { roots } = await server.server.listRoots(undefined, { timeout: 3000 });
180
+ return roots
181
+ .map((root) => root.uri)
182
+ .filter((uri) => uri.startsWith("file:"))
183
+ .map((uri) => fileURLToPath(uri));
184
+ } catch {
185
+ return [];
186
+ }
187
+ };
188
+
189
+ const projectArg = z.string().optional().describe(
190
+ "Project slug. Defaults to the repo at `dir`, at the server's cwd, or " +
191
+ "at the client's MCP roots; when none has a .highball/checks.yml the " +
192
+ "result lists journaled projects to choose from instead of guessing."
193
+ );
194
+ const dirArg = z.string().optional().describe(
195
+ "Repo root containing .highball/checks.yml. Pass the current working " +
196
+ "directory when calling from inside a repo."
197
+ );
198
+
142
199
  registerAppTool(server, "list_runs", {
143
200
  title: "Highball runs",
144
201
  description:
145
202
  "Recent Highball check runs for a project, from the machine-local " +
146
- "journal (~/.highball/runs). Renders the runs dashboard widget.",
203
+ `journal (~/.highball/runs). Newest ${DEFAULT_RUNS_LIMIT} by default. ` +
204
+ "Renders the runs dashboard widget.",
147
205
  inputSchema: {
148
- project: z.string().optional()
149
- .describe("Project slug; defaults to the current repo's project")
206
+ project: projectArg,
207
+ dir: dirArg,
208
+ limit: z.number().int().min(1).optional().describe(
209
+ `Max runs to return, newest first (default ${DEFAULT_RUNS_LIMIT}; ` +
210
+ "HIGHBALL_RUNS_LIMIT or runs_limit in checks.yml also override)"
211
+ )
150
212
  },
151
213
  _meta: { ui: { resourceUri: DASHBOARD_URI } }
152
- }, async ({ project: explicit }) => {
153
- const { project, dir } = resolveProject(explicit);
214
+ }, async ({ project: explicit, dir: explicitDir, limit }) => {
215
+ const { project, dir, config } =
216
+ resolveProject({ project: explicit, dir: explicitDir, roots: await clientRoots() });
154
217
  if (!project) {
155
218
  const known = journaledProjects();
156
219
  return reply(
157
- `No project resolved. Journaled projects: ${known.join(", ") || "(none)"}`,
220
+ "No project resolved: no .highball/checks.yml at the working " +
221
+ "directory or the client's roots. Pass `project` or `dir`. " +
222
+ `Journaled projects: ${known.join(", ") || "(none)"}`,
158
223
  { projects: known }
159
224
  );
160
225
  }
161
- const runs = readRuns(project).map(summarize);
226
+ const history = readRuns(project);
227
+ const max = resolveRunsLimit({ limit, config });
228
+ const runs = history.slice(0, max).map(summarize);
162
229
  return reply(
163
230
  uiHost()
164
- ? `${runs.length} runs for ${project} — rendered in the dashboard widget.`
165
- : listText(project, runs.slice(0, 20)),
166
- { project, dir, runs }
231
+ ? `${runs.length} of ${history.length} runs for ${project} — ` +
232
+ "rendered in the dashboard widget."
233
+ : listText(project, runs, history.length),
234
+ { project, dir, runs, total: history.length, limit: max }
167
235
  );
168
236
  });
169
237
 
@@ -174,13 +242,14 @@ export async function mcp() {
174
242
  "captured command output. index counts from 1, newest first.",
175
243
  inputSchema: {
176
244
  index: z.number().int().min(1).describe("1-based index, newest first"),
177
- project: z.string().optional()
178
- .describe("Project slug; defaults to the current repo's project")
245
+ project: projectArg,
246
+ dir: dirArg
179
247
  },
180
248
  _meta: { ui: { resourceUri: DASHBOARD_URI } }
181
- }, async ({ index, project: explicit }) => {
182
- const { project, dir } = resolveProject(explicit);
183
- if (!project) return reply("No project resolved.", {});
249
+ }, async ({ index, project: explicit, dir: explicitDir }) => {
250
+ const { project, dir } =
251
+ resolveProject({ project: explicit, dir: explicitDir, roots: await clientRoots() });
252
+ if (!project) return reply("No project resolved — pass `project` or `dir`.", {});
184
253
  const history = readRuns(project);
185
254
  const run = history[index - 1];
186
255
  if (!run) return reply(`No run #${index} (${history.length} recorded).`, {});
@@ -197,7 +266,7 @@ export async function mcp() {
197
266
  description:
198
267
  "Execute a repo's Highball checks (fast rules or the full suite). " +
199
268
  "Blocks until done; the run lands in the journal and, when reporting " +
200
- "is configured, on the dashboard.",
269
+ "is configured, in PostHog.",
201
270
  inputSchema: {
202
271
  dir: z.string().optional()
203
272
  .describe("Repo root containing .highball/checks.yml; defaults to cwd"),
@@ -205,9 +274,10 @@ export async function mcp() {
205
274
  },
206
275
  _meta: { ui: { resourceUri: DASHBOARD_URI } }
207
276
  }, async ({ dir, fast }) => {
208
- // No dir given the current project's repo (from its journal), so
209
- // widget-initiated re-runs work from hosts with no useful cwd.
210
- const cwd = dir || resolveProject(null).dir || process.cwd();
277
+ // The widget passes the dir it was grounded with, so re-runs work from
278
+ // hosts with no useful cwd; otherwise resolve the same way list_runs
279
+ // does and let the child's own error explain a missing checks.yml.
280
+ const cwd = dir || resolveProject({ roots: await clientRoots() }).dir || process.cwd();
211
281
  const child = spawnSync(
212
282
  process.execPath,
213
283
  [ BIN_PATH, "run", ...(fast ? [ "--fast" ] : []) ],
package/lib/posthog.js ADDED
@@ -0,0 +1,149 @@
1
+ // The runner's telemetry sink. Enforcement is local and always works;
2
+ // this is the witness half, and it is deliberately somebody else's
3
+ // server. An earlier iteration shipped a bespoke Rails dashboard, which
4
+ // meant hosting, auth, and data custody for what is ultimately a
5
+ // warehouse query — so a team points at their own PostHog instead.
6
+ //
7
+ // The whole run leaves in ONE request. The old protocol opened a run,
8
+ // POSTed each result, then PATCHed the status: twenty round trips for an
9
+ // eighteen-rule run. PostHog events are immutable and append-only, which
10
+ // suits a runner that already defers all reporting to after the checks
11
+ // finish — nothing to open, nothing to finalize.
12
+ //
13
+ // Best-effort, always: failures warn and return. A dead analytics
14
+ // endpoint must never block an agent.
15
+ import { hostname, userInfo } from "node:os";
16
+ import { git } from "./git.js";
17
+
18
+ const TIMEOUT_MS = 15_000;
19
+
20
+ // Two event types, and deliberately no third. `highball_run` answers
21
+ // "how are runs doing" and `highball_check` answers "which rules are
22
+ // earning their keep"; every question we set out to ask breaks down
23
+ // from one of those.
24
+ export const RUN_EVENT = "highball_run";
25
+ export const CHECK_EVENT = "highball_check";
26
+
27
+ // Returns true when the batch was accepted, false when reporting was
28
+ // skipped or failed — the caller journals either way.
29
+ export async function reportPosthog({
30
+ host, key, project, results, hook, fastOnly, startedAt, durationMs, branch,
31
+ commitSha, version
32
+ }) {
33
+ try {
34
+ const batch = buildEvents({
35
+ project, results, hook, fastOnly, startedAt, durationMs, branch, commitSha,
36
+ version, distinctId: distinctId()
37
+ });
38
+
39
+ const response = await fetch(new URL("/batch/", host), {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ api_key: key, batch }),
43
+ signal: AbortSignal.timeout(TIMEOUT_MS)
44
+ });
45
+ if (response.status >= 300) {
46
+ throw new Error(`${response.status}: ${(await response.text()).slice(0, 200)}`);
47
+ }
48
+
49
+ console.log(`reported ${batch.length} events to ${new URL(host).host}`);
50
+ return true;
51
+ } catch (error) {
52
+ console.error(`highball posthog reporting skipped: ${error.message}`);
53
+ return false;
54
+ }
55
+ }
56
+
57
+ // Pure, so the event shape can be tested without a network or a clock.
58
+ export function buildEvents({
59
+ project, results, hook, fastOnly, startedAt, durationMs, branch, commitSha,
60
+ version, distinctId
61
+ }) {
62
+ const timestamp = startedAt.toISOString();
63
+ const trigger = fastOnly ? "edit" : "stop";
64
+ const sessionKey =
65
+ hook?.session_id || process.env.HIGHBALL_SESSION_KEY || `manual-${hostname()}`;
66
+
67
+ // Repeated on every event rather than joined at query time. PostHog has
68
+ // no joins back to a "run" table, so a breakdown like "failure rate by
69
+ // rule, on this branch only" needs branch to sit on the check event
70
+ // itself.
71
+ const shared = {
72
+ project,
73
+ branch,
74
+ commit: commitSha,
75
+ trigger,
76
+ session_key: sessionKey,
77
+ agent: hook?.session_id ? "claude-code" : "manual",
78
+ runner_version: version,
79
+ // PostHog surfaces these in its UI as the sending client.
80
+ $lib: "highball",
81
+ $lib_version: version
82
+ };
83
+
84
+ const failed = results.filter((result) => !result.passed);
85
+ const todo = results.filter((result) => result.todo);
86
+
87
+ const events = [ {
88
+ event: RUN_EVENT,
89
+ distinct_id: distinctId,
90
+ timestamp,
91
+ properties: {
92
+ ...shared,
93
+ status: failed.length === 0 ? "passed" : "failed",
94
+ duration_ms: durationMs,
95
+ rules_total: results.length,
96
+ rules_passed: results.length - failed.length - todo.length,
97
+ rules_failed: failed.length,
98
+ rules_todo: todo.length,
99
+ // The denominator for any "how often does rule X fail" question that
100
+ // spans runs whose rulesets differ.
101
+ rules_run: results.map((result) => result.rule.id),
102
+ failed_rules: failed.map((result) => result.rule.id)
103
+ }
104
+ } ];
105
+
106
+ for (const result of results) {
107
+ events.push({
108
+ event: CHECK_EVENT,
109
+ distinct_id: distinctId,
110
+ timestamp,
111
+ properties: {
112
+ ...shared,
113
+ rule_id: result.rule.id,
114
+ rule_name: result.rule.name,
115
+ status: result.todo ? "todo" : result.passed ? "passed" : "failed",
116
+ duration_ms: result.durationMs,
117
+ // A one-line summary, never the log tail. Multi-kilobyte blobs in
118
+ // event properties bloat the column store and slow every query that
119
+ // touches it; the full output is already on disk in the journal.
120
+ summary: summarize(result),
121
+ command:
122
+ result.rule.run ?? (result.rule.rubric ? `judge ${result.rule.rubric}` : null)
123
+ }
124
+ });
125
+ }
126
+
127
+ return events;
128
+ }
129
+
130
+ function summarize(result) {
131
+ if (result.todo) return "planned — not implemented yet";
132
+ if (result.passed) return null;
133
+ return result.output.split("\n").find((line) => line.trim())?.trim().slice(0, 120) ?? null;
134
+ }
135
+
136
+ // Who the run belongs to. The git identity is the one that means anything
137
+ // across machines — the same person on a laptop and a devcontainer should
138
+ // be one person — with the machine as the fallback when git has no
139
+ // identity configured (CI images, fresh containers).
140
+ export function distinctId(env = process.env) {
141
+ if (env.HIGHBALL_POSTHOG_DISTINCT_ID) return env.HIGHBALL_POSTHOG_DISTINCT_ID;
142
+ const email = git("git config user.email");
143
+ if (email) return email;
144
+ try {
145
+ return `${userInfo().username}@${hostname()}`;
146
+ } catch {
147
+ return `host-${hostname()}`;
148
+ }
149
+ }
package/lib/run.js CHANGED
@@ -1,15 +1,27 @@
1
1
  // `highball run [--fast]` — the enforcement half. Runs each rule, prints
2
2
  // progress, exits 2 with failures on stderr (the Claude Code hook
3
3
  // contract: a Stop hook reading exit 2 blocks the agent and feeds the
4
- // output back). Reporting is the witness half and is best-effort: a dead
5
- // dashboard must never block the agent.
4
+ // output back). Reporting is the witness half and is best-effort: an
5
+ // unreachable PostHog must never block the agent.
6
6
  import { execSync, spawnSync } from "node:child_process";
7
- import { loadConfig, resolveReporting, commandFor } from "./config.js";
7
+ import { readFileSync } from "node:fs";
8
+ import {
9
+ CONFIG_PATH, DISABLED_MARKER, disabledByEnv, disabledByMarker,
10
+ loadConfig, resolvePosthog, commandFor
11
+ } from "./config.js";
8
12
  import { appendRun } from "./journal.js";
9
- import { git, report } from "./report.js";
13
+ import { readStamp, treeFingerprint, writeStamp } from "./stamp.js";
14
+ import { git } from "./git.js";
15
+ import { reportPosthog } from "./posthog.js";
10
16
  import { judge } from "./judge.js";
11
17
  import { latestUserPrompt } from "./transcript.js";
12
18
 
19
+ // Stamped onto reported events so a query can tell which runner
20
+ // produced them — rule semantics change between releases.
21
+ const VERSION = JSON.parse(
22
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
23
+ ).version;
24
+
13
25
  export async function run(args) {
14
26
  // When an AI-judged rule spawns a judge session inside this repo, the
15
27
  // judge inherits the repo's hooks — and its Stop hook would re-enter
@@ -17,6 +29,25 @@ export async function run(args) {
17
29
  // loop.
18
30
  if (process.env.HIGHBALL_JUDGE) return 0;
19
31
 
32
+ // Off switch #1, deliberately ahead of the config load: HIGHBALL_DISABLED
33
+ // is the one to reach for mid-task, so it has to work even when
34
+ // checks.yml is itself the thing in the way. Per machine, nothing to
35
+ // commit, nothing to accidentally push at a teammate.
36
+ if (disabledByEnv()) {
37
+ console.log("highball: disabled by HIGHBALL_DISABLED — no checks run");
38
+ return 0;
39
+ }
40
+
41
+ // Off switch #2, also ahead of the config load and for the same reason:
42
+ // the marker is a bare file next to checks.yml, so it still works when
43
+ // checks.yml is itself what's in the way.
44
+ const marker = disabledByMarker();
45
+ if (marker) {
46
+ const why = marker.reason ? ` (${marker.reason})` : "";
47
+ console.log(`highball: disabled by ${DISABLED_MARKER}${why} — no checks run`);
48
+ return 0;
49
+ }
50
+
20
51
  const fastOnly = args.includes("--fast");
21
52
  let config;
22
53
  try {
@@ -26,6 +57,29 @@ export async function run(args) {
26
57
  return 1;
27
58
  }
28
59
 
60
+ // Off switch #2, the committed counterpart: this repo isn't using
61
+ // Highball right now, for everyone who clones it. Neither switch is ever
62
+ // silent — a guardrail that has stopped guarding should say so on every
63
+ // single run, or the next person reads green and believes it.
64
+ if (config.enabled === false) {
65
+ console.log(
66
+ `highball: disabled by \`enabled: false\` in ${CONFIG_PATH} — no checks run`
67
+ );
68
+ return 0;
69
+ }
70
+
71
+ // --if-changed: a fast hook that also matches Bash fires after every
72
+ // command, and most commands are reads. Skip outright when the working
73
+ // tree is exactly where the last run left it. The fingerprint is taken
74
+ // now and stamped after the checks, so a formatter that rewrites files
75
+ // mid-run makes the next call run again rather than trust a stale pass.
76
+ const fingerprint = treeFingerprint();
77
+ if (args.includes("--if-changed") && fingerprint &&
78
+ fingerprint === readStamp(config.project, process.cwd())) {
79
+ console.log("highball: no changes since the last run — skipped");
80
+ return 0;
81
+ }
82
+
29
83
  // Rubric rules never join a fast run, even if a config marks one `fast`:
30
84
  // LLM latency and cost would be paid on every edit. That belongs at turn
31
85
  // end, and the invariant is enforced here rather than left to each repo.
@@ -46,7 +100,7 @@ export async function run(args) {
46
100
  process.stdout.write(`→ ${rule.name} ... `);
47
101
 
48
102
  // Placeholder rules are tracked, not run: they report as "todo" so
49
- // the dashboard shows the full intended ruleset, and they can never
103
+ // the widget and PostHog show the full intended ruleset, and they can never
50
104
  // fail a run — an aspiration shouldn't block anyone.
51
105
  if (rule.todo) {
52
106
  console.log("todo (not implemented yet)");
@@ -86,21 +140,25 @@ export async function run(args) {
86
140
  results.push({ rule, passed, todo: false, durationMs, output });
87
141
  }
88
142
 
143
+ // Stamped pass or fail: after a failure the agent's next reads must not
144
+ // re-run and re-block; its next edit moves the tree and runs again.
145
+ if (fingerprint) writeStamp(config.project, process.cwd(), fingerprint);
146
+
89
147
  const failures = results.filter((result) => !result.passed);
90
148
  const durationMs = Date.now() - startedAt.getTime();
91
149
  const branch = git("git branch --show-current");
92
150
  const commitSha = git("git rev-parse HEAD");
93
151
 
94
- const { url, token } = resolveReporting(config);
95
- let reportedRunId = null;
96
- if (url && token) {
97
- reportedRunId = await report({
98
- url, token, rules, results, hook, fastOnly, startedAt, branch, commitSha
152
+ const { host, key } = resolvePosthog(config);
153
+ if (host && key) {
154
+ await reportPosthog({
155
+ host, key, project: config.project, results, hook, fastOnly, startedAt,
156
+ durationMs, branch, commitSha, version: VERSION
99
157
  });
100
158
  }
101
159
 
102
160
  // The local journal is unconditional — `highball runs` works with no
103
- // dashboard configured at all. Journal failures never fail the checks,
161
+ // reporting configured at all. Journal failures never fail the checks,
104
162
  // same policy as reporting.
105
163
  try {
106
164
  appendRun(config.project, {
@@ -118,7 +176,6 @@ export async function run(args) {
118
176
  branch,
119
177
  commit: commitSha,
120
178
  status: failures.length === 0 ? "passed" : "failed",
121
- reported_run_id: reportedRunId,
122
179
  results: results.map((result) => ({
123
180
  id: result.rule.id,
124
181
  name: result.rule.name,
@@ -133,7 +190,7 @@ export async function run(args) {
133
190
  command:
134
191
  result.rule.run ??
135
192
  (result.rule.rubric ? `judge ${result.rule.rubric}` : null),
136
- // Unlike the dashboard (failure tails only), the journal keeps
193
+ // Unlike PostHog (one-line summaries only), the journal keeps
137
194
  // every rule's output GitHub-Actions-style — it's the user's own
138
195
  // disk, and `highball runs <n> --logs` is the payoff.
139
196
  output_tail: result.output ? result.output.slice(-10_000) : null
@@ -156,7 +213,7 @@ export async function run(args) {
156
213
 
157
214
  // Claude Code hooks pass a JSON payload on stdin (session_id and
158
215
  // friends); that id groups this run with the rest of the agent's session
159
- // on the dashboard. A TTY means a human at a terminal — don't block on
216
+ // in the journal and in PostHog. A TTY means a human at a terminal — don't block on
160
217
  // read.
161
218
  //
162
219
  // The deadline matters: a non-TTY stdin that nobody writes to and nobody
package/lib/runs.js CHANGED
@@ -1,4 +1,4 @@
1
- // `highball runs [n] [--logs]` — the no-dashboard view of run history,
1
+ // `highball runs [n] [--logs]` — the terminal view of run history,
2
2
  // read from the local journal. Bare: a table of recent runs, newest
3
3
  // first. With a number: that run's detail — failure output by default,
4
4
  // every rule's captured output with --logs.