@profoundry-us/highball 0.5.0 → 0.6.1
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 +12 -5
- package/README.md +86 -3
- package/bin/highball.js +5 -1
- package/lib/config.js +53 -0
- package/lib/init.js +21 -1
- package/lib/journal.js +4 -4
- package/lib/mcp.js +1 -1
- package/lib/run.js +42 -9
- package/lib/runs.js +1 -1
- package/lib/stamp.js +13 -5
- package/package.json +2 -2
package/ONBOARDING.md
CHANGED
|
@@ -151,11 +151,18 @@ Decision rules:
|
|
|
151
151
|
Highball enforces with no account and no network. Reporting is a separate,
|
|
152
152
|
optional decision, and it is your human's to make — ask, do not assume.
|
|
153
153
|
|
|
154
|
-
If they want it,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
If they want it, there are two ways, and the choice is theirs:
|
|
155
|
+
|
|
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
|
|
159
166
|
journal (`highball runs`) already records every run in more detail than
|
|
160
167
|
PostHog receives.
|
|
161
168
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @profoundry-us/highball
|
|
2
2
|
|
|
3
|
+
**Website:** [highball.profoundry.us](https://highball.profoundry.us)
|
|
4
|
+
|
|
3
5
|
The Highball runner: executes a repo's `.highball/checks.yml` rules and blocks
|
|
4
6
|
AI coding agents on failure (exit 2, the Claude Code hook contract) — "local
|
|
5
7
|
CI for AI agents". Enforcement is entirely local and needs no account, no
|
|
@@ -103,6 +105,72 @@ The runner computes the branch's changed-file list once (it owns git) and
|
|
|
103
105
|
hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
|
|
104
106
|
analyzers and need no git in their execution context.
|
|
105
107
|
|
|
108
|
+
## Turning it off
|
|
109
|
+
|
|
110
|
+
Three switches, differing in who they affect and — the part that usually
|
|
111
|
+
decides it — when they take effect:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
touch .highball/disabled # this checkout, gitignored, next run
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
enabled: false # in checks.yml — committed, everyone, next run
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export HIGHBALL_DISABLED=1 # your whole machine, next session restart
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Any of them makes `highball run` exit 0 immediately, with no rule executed, no
|
|
126
|
+
journal entry, and nothing reported. Hooks and rules stay exactly where they
|
|
127
|
+
are, so switching back on is a one-line revert.
|
|
128
|
+
|
|
129
|
+
| | takes effect | scope | committed |
|
|
130
|
+
| --- | --- | --- | --- |
|
|
131
|
+
| `.highball/disabled` | next run | this checkout | no — `init` gitignores it |
|
|
132
|
+
| `enabled: false` | next run | one repo, everyone who clones it | yes |
|
|
133
|
+
| `HIGHBALL_DISABLED` | next Claude Code restart, for hooks | every repo, your machine | no |
|
|
134
|
+
|
|
135
|
+
**Reach for the marker file first.** `touch .highball/disabled` while an agent
|
|
136
|
+
session is running and the very next hook run skips; delete it and the run
|
|
137
|
+
after that is armed again. Both directions are live because the file is
|
|
138
|
+
checked on every run and every hook invocation is a new process — there is no
|
|
139
|
+
state anywhere and nothing to restart. `init` writes a `.highball/.gitignore`
|
|
140
|
+
alongside it, so a switch-off in your checkout can't ride along in a commit.
|
|
141
|
+
|
|
142
|
+
Anything you write into the marker comes back as the reason on every run,
|
|
143
|
+
which is what the person who finds the checks off next week actually needs:
|
|
144
|
+
|
|
145
|
+
```console
|
|
146
|
+
$ echo "bisecting a flaky spec" > .highball/disabled
|
|
147
|
+
$ npx @profoundry-us/highball run --fast
|
|
148
|
+
highball: disabled by .highball/disabled (bisecting a flaky spec) — no checks run
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`enabled: false` is the committed counterpart, for a repo genuinely stepping
|
|
152
|
+
away from its checks. It lands in a diff, which is what you want when the
|
|
153
|
+
decision belongs to the team rather than to your afternoon.
|
|
154
|
+
|
|
155
|
+
`HIGHBALL_DISABLED` is an ordinary environment variable, so a hook sees
|
|
156
|
+
whatever value the Claude Code process had when it started — Claude Code
|
|
157
|
+
writes settings `env` entries into its environment at launch, and a shell
|
|
158
|
+
`export` after that never reaches an already-running session. That makes it
|
|
159
|
+
the right switch for a machine-wide default you rarely change, and the wrong
|
|
160
|
+
one for a mid-session toggle. Like the marker, it is read *before*
|
|
161
|
+
`checks.yml`, so both still work when the config is itself what's broken.
|
|
162
|
+
|
|
163
|
+
`HIGHBALL_DISABLED=0`, `false`, `no`, `off` and empty all mean **not**
|
|
164
|
+
disabled. Anything else disables. Plain truthiness would make `=0` stop every
|
|
165
|
+
check in the repo, which is exactly the "is the guardrail live right now?"
|
|
166
|
+
doubt this switch exists to remove.
|
|
167
|
+
|
|
168
|
+
None of the three is silent. Each prints a line on every run, because a guardrail
|
|
169
|
+
that has quietly stopped guarding is worse than no guardrail — the next
|
|
170
|
+
person reads green and believes it. For the same reason `enabled:` accepts
|
|
171
|
+
only a real boolean: `enabled: "false"` and `enabled: no` are strings, and
|
|
172
|
+
rather than leaving checks quietly on, they fail loudly.
|
|
173
|
+
|
|
106
174
|
## AI-judged rules
|
|
107
175
|
|
|
108
176
|
A rule with `rubric:` instead of `run:` is judged by headless Claude rather
|
|
@@ -140,9 +208,24 @@ Rubrics live with the opinions they express: a framework pack such as
|
|
|
140
208
|
`reporting.posthog` sends runs to PostHog — the runner's only telemetry path.
|
|
141
209
|
A team that already runs PostHog needs no server for this, and a team that
|
|
142
210
|
doesn't can skip the block entirely and use the local journal. The project key
|
|
143
|
-
is write-only by design
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
is write-only by design — it is the same key PostHog has you ship in browser
|
|
212
|
+
bundles, and all it can do is capture events — so committing it is safe:
|
|
213
|
+
no login step, no credentials file.
|
|
214
|
+
|
|
215
|
+
If you would still rather keep it out of the repo, set the environment
|
|
216
|
+
instead. `HIGHBALL_POSTHOG_KEY` (or `POSTHOG_API_KEY`) on its own turns
|
|
217
|
+
reporting on with no `reporting:` block at all, and `HIGHBALL_POSTHOG_HOST`
|
|
218
|
+
(or `POSTHOG_HOST`) points it at an EU or self-hosted instance; either wins
|
|
219
|
+
over committed config, which is also how CI redirects a run. One place that
|
|
220
|
+
reaches every repo's hooks is the `env` block of your user-level Claude Code
|
|
221
|
+
settings, `~/.claude/settings.json`:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{ "env": { "HIGHBALL_POSTHOG_KEY": "phc_your_key" } }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The trade-off is that reporting is then per machine rather than per repo: a
|
|
228
|
+
teammate cloning the repo reports nothing until they set the key too.
|
|
146
229
|
|
|
147
230
|
The whole run leaves in ONE request to `/batch/`. PostHog events are
|
|
148
231
|
immutable, which suits a runner that already defers reporting to after the
|
package/bin/highball.js
CHANGED
|
@@ -20,7 +20,11 @@ Usage:
|
|
|
20
20
|
--if-changed skips when the working tree is
|
|
21
21
|
unchanged since the last run (for hooks that
|
|
22
22
|
also match Bash).
|
|
23
|
-
|
|
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).
|
|
27
|
+
highball init Scaffold .highball/checks.yml and Claude Code
|
|
24
28
|
hooks in the current repo.
|
|
25
29
|
highball onboard Print the setup guide written for this repo's
|
|
26
30
|
AI agent — tell your agent to run this and
|
package/lib/config.js
CHANGED
|
@@ -16,6 +16,17 @@ export function loadConfig(root = process.cwd()) {
|
|
|
16
16
|
}
|
|
17
17
|
const config = YAML.parse(readFileSync(path, "utf8"));
|
|
18
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
|
+
}
|
|
19
30
|
if (!Array.isArray(config.checks)) {
|
|
20
31
|
throw new Error(`${CONFIG_PATH} is missing its \`checks:\` list.`);
|
|
21
32
|
}
|
|
@@ -41,6 +52,48 @@ export function loadConfig(root = process.cwd()) {
|
|
|
41
52
|
return config;
|
|
42
53
|
}
|
|
43
54
|
|
|
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
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
44
97
|
// PostHog is the runner's telemetry sink. There is no secret to resolve: a
|
|
45
98
|
// PostHog project key is write-only by design, so it lives in committed
|
|
46
99
|
// config right next to the host — no login step, no credentials file. Env
|
package/lib/init.js
CHANGED
|
@@ -8,13 +8,21 @@ 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
|
+
# 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
|
+
|
|
16
21
|
# Optional telemetry. The PostHog project key is write-only by design, so
|
|
17
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.
|
|
18
26
|
# reporting:
|
|
19
27
|
# posthog:
|
|
20
28
|
# host: https://us.i.posthog.com
|
|
@@ -77,6 +85,18 @@ export async function init() {
|
|
|
77
85
|
console.log(`created .highball/checks.yml (project: ${project}) — add your rules`);
|
|
78
86
|
}
|
|
79
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
|
+
|
|
80
100
|
const settingsPath = join(root, ".claude", "settings.json");
|
|
81
101
|
if (existsSync(settingsPath)) {
|
|
82
102
|
console.log(
|
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
|
@@ -266,7 +266,7 @@ export async function mcp() {
|
|
|
266
266
|
description:
|
|
267
267
|
"Execute a repo's Highball checks (fast rules or the full suite). " +
|
|
268
268
|
"Blocks until done; the run lands in the journal and, when reporting " +
|
|
269
|
-
"is configured,
|
|
269
|
+
"is configured, in PostHog.",
|
|
270
270
|
inputSchema: {
|
|
271
271
|
dir: z.string().optional()
|
|
272
272
|
.describe("Repo root containing .highball/checks.yml; defaults to cwd"),
|
package/lib/run.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
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
7
|
import { readFileSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_PATH, DISABLED_MARKER, disabledByEnv, disabledByMarker,
|
|
10
|
+
loadConfig, resolvePosthog, commandFor
|
|
11
|
+
} from "./config.js";
|
|
9
12
|
import { appendRun } from "./journal.js";
|
|
10
13
|
import { readStamp, treeFingerprint, writeStamp } from "./stamp.js";
|
|
11
14
|
import { git } from "./git.js";
|
|
@@ -26,6 +29,25 @@ export async function run(args) {
|
|
|
26
29
|
// loop.
|
|
27
30
|
if (process.env.HIGHBALL_JUDGE) return 0;
|
|
28
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
|
+
|
|
29
51
|
const fastOnly = args.includes("--fast");
|
|
30
52
|
let config;
|
|
31
53
|
try {
|
|
@@ -35,6 +57,17 @@ export async function run(args) {
|
|
|
35
57
|
return 1;
|
|
36
58
|
}
|
|
37
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
|
+
|
|
38
71
|
// --if-changed: a fast hook that also matches Bash fires after every
|
|
39
72
|
// command, and most commands are reads. Skip outright when the working
|
|
40
73
|
// tree is exactly where the last run left it. The fingerprint is taken
|
|
@@ -42,7 +75,7 @@ export async function run(args) {
|
|
|
42
75
|
// mid-run makes the next call run again rather than trust a stale pass.
|
|
43
76
|
const fingerprint = treeFingerprint();
|
|
44
77
|
if (args.includes("--if-changed") && fingerprint &&
|
|
45
|
-
fingerprint === readStamp(config.project)) {
|
|
78
|
+
fingerprint === readStamp(config.project, process.cwd())) {
|
|
46
79
|
console.log("highball: no changes since the last run — skipped");
|
|
47
80
|
return 0;
|
|
48
81
|
}
|
|
@@ -67,7 +100,7 @@ export async function run(args) {
|
|
|
67
100
|
process.stdout.write(`→ ${rule.name} ... `);
|
|
68
101
|
|
|
69
102
|
// Placeholder rules are tracked, not run: they report as "todo" so
|
|
70
|
-
// the
|
|
103
|
+
// the widget and PostHog show the full intended ruleset, and they can never
|
|
71
104
|
// fail a run — an aspiration shouldn't block anyone.
|
|
72
105
|
if (rule.todo) {
|
|
73
106
|
console.log("todo (not implemented yet)");
|
|
@@ -109,7 +142,7 @@ export async function run(args) {
|
|
|
109
142
|
|
|
110
143
|
// Stamped pass or fail: after a failure the agent's next reads must not
|
|
111
144
|
// re-run and re-block; its next edit moves the tree and runs again.
|
|
112
|
-
if (fingerprint) writeStamp(config.project, fingerprint);
|
|
145
|
+
if (fingerprint) writeStamp(config.project, process.cwd(), fingerprint);
|
|
113
146
|
|
|
114
147
|
const failures = results.filter((result) => !result.passed);
|
|
115
148
|
const durationMs = Date.now() - startedAt.getTime();
|
|
@@ -125,7 +158,7 @@ export async function run(args) {
|
|
|
125
158
|
}
|
|
126
159
|
|
|
127
160
|
// The local journal is unconditional — `highball runs` works with no
|
|
128
|
-
//
|
|
161
|
+
// reporting configured at all. Journal failures never fail the checks,
|
|
129
162
|
// same policy as reporting.
|
|
130
163
|
try {
|
|
131
164
|
appendRun(config.project, {
|
|
@@ -157,7 +190,7 @@ export async function run(args) {
|
|
|
157
190
|
command:
|
|
158
191
|
result.rule.run ??
|
|
159
192
|
(result.rule.rubric ? `judge ${result.rule.rubric}` : null),
|
|
160
|
-
// Unlike
|
|
193
|
+
// Unlike PostHog (one-line summaries only), the journal keeps
|
|
161
194
|
// every rule's output GitHub-Actions-style — it's the user's own
|
|
162
195
|
// disk, and `highball runs <n> --logs` is the payoff.
|
|
163
196
|
output_tail: result.output ? result.output.slice(-10_000) : null
|
|
@@ -180,7 +213,7 @@ export async function run(args) {
|
|
|
180
213
|
|
|
181
214
|
// Claude Code hooks pass a JSON payload on stdin (session_id and
|
|
182
215
|
// friends); that id groups this run with the rest of the agent's session
|
|
183
|
-
//
|
|
216
|
+
// in the journal and in PostHog. A TTY means a human at a terminal — don't block on
|
|
184
217
|
// read.
|
|
185
218
|
//
|
|
186
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.
|
package/lib/stamp.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The fingerprint makes those free: HEAD plus every dirty path with its
|
|
7
7
|
// size and mtime, hashed. A further edit to an already-dirty file moves
|
|
8
8
|
// its mtime, so the stamp tracks edits rather than just the set of dirty
|
|
9
|
-
// paths. Stamps live
|
|
9
|
+
// paths. Stamps live outside the repo, like the journal.
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
12
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -17,6 +17,14 @@ export function stampDir() {
|
|
|
17
17
|
return join(homedir(), ".highball", "stamps");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// One stamp per CHECKOUT, not per project: git worktrees and parallel
|
|
21
|
+
// clones share a project name but not a working tree, and a run in one
|
|
22
|
+
// must never let the other skip a run it needs.
|
|
23
|
+
export function stampPath(project, cwd, dir = stampDir()) {
|
|
24
|
+
const checkout = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
25
|
+
return join(dir, `${project}-${checkout}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
// null outside a git repo: with no way to tell whether anything changed,
|
|
21
29
|
// --if-changed must never skip.
|
|
22
30
|
export function treeFingerprint(cwd = process.cwd()) {
|
|
@@ -45,9 +53,9 @@ export function treeFingerprint(cwd = process.cwd()) {
|
|
|
45
53
|
return hash.digest("hex");
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
export function readStamp(project, dir = stampDir()) {
|
|
56
|
+
export function readStamp(project, cwd, dir = stampDir()) {
|
|
49
57
|
try {
|
|
50
|
-
return readFileSync(
|
|
58
|
+
return readFileSync(stampPath(project, cwd, dir), "utf8").trim();
|
|
51
59
|
} catch {
|
|
52
60
|
return null;
|
|
53
61
|
}
|
|
@@ -55,10 +63,10 @@ export function readStamp(project, dir = stampDir()) {
|
|
|
55
63
|
|
|
56
64
|
// Best-effort, like the journal: a stamp that fails to write costs one
|
|
57
65
|
// extra fast run, never a failed one.
|
|
58
|
-
export function writeStamp(project, fingerprint, dir = stampDir()) {
|
|
66
|
+
export function writeStamp(project, cwd, fingerprint, dir = stampDir()) {
|
|
59
67
|
try {
|
|
60
68
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
-
writeFileSync(
|
|
69
|
+
writeFileSync(stampPath(project, cwd, dir), `${fingerprint}\n`);
|
|
62
70
|
} catch {
|
|
63
71
|
// ignore
|
|
64
72
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profoundry-us/highball",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Highball runner — local CI for AI coding agents: runs a repo's .highball/checks.yml rules, blocks the agent on failure, and optionally reports runs to PostHog.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git+https://github.com/profoundry-us/highball.git"
|
|
16
16
|
},
|
|
17
|
-
"homepage": "https://
|
|
17
|
+
"homepage": "https://highball.profoundry.us",
|
|
18
18
|
"bugs": {
|
|
19
19
|
"url": "https://github.com/profoundry-us/highball/issues"
|
|
20
20
|
},
|