@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/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
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
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: [{
|
|
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,
|
|
86
|
-
"\
|
|
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
|
|
4
|
-
// all — `highball runs`
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
53
|
+
return loadConfig(root);
|
|
34
54
|
} catch {
|
|
35
|
-
|
|
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
|
-
|
|
203
|
+
`journal (~/.highball/runs). Newest ${DEFAULT_RUNS_LIMIT} by default. ` +
|
|
204
|
+
"Renders the runs dashboard widget.",
|
|
147
205
|
inputSchema: {
|
|
148
|
-
project:
|
|
149
|
-
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
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} —
|
|
165
|
-
|
|
166
|
-
|
|
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:
|
|
178
|
-
|
|
245
|
+
project: projectArg,
|
|
246
|
+
dir: dirArg
|
|
179
247
|
},
|
|
180
248
|
_meta: { ui: { resourceUri: DASHBOARD_URI } }
|
|
181
|
-
}, async ({ index, project: explicit }) => {
|
|
182
|
-
const { project, dir } =
|
|
183
|
-
|
|
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,
|
|
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
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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:
|
|
5
|
-
//
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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.
|