@profoundry-us/highball 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profoundry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/ONBOARDING.md ADDED
@@ -0,0 +1,136 @@
1
+ # Highball onboarding — instructions for this repo's AI agent
2
+
3
+ You are setting up Highball in this repository. Highball gives you (the
4
+ agent) guardrails while you work — a checks runner your Claude Code hooks
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.
10
+
11
+ Work through the steps in order. Steps marked **human** need your human's
12
+ input or action — ask, don't guess, and never handle credential values
13
+ yourself.
14
+
15
+ ## 0. Preconditions
16
+
17
+ `npx highball --help` must work (Node >= 18, package installed as a dev
18
+ dependency). If it doesn't, ask your human whether to install from the npm
19
+ registry (`npm install --save-dev @profoundry-us/highball`) or from a
20
+ local tarball path they provide. In a repo with no `package.json`, create
21
+ a minimal private one first (`{ "name": "<repo>", "private": true }`) and
22
+ gitignore `node_modules/` if it isn't already.
23
+
24
+ ## 1. Survey the repo before writing anything
25
+
26
+ Answer these by reading, not assuming:
27
+
28
+ - **What languages and toolchains live here**, and which commands does the
29
+ repo *already trust* — look at `package.json` scripts, a `justfile` or
30
+ `Makefile`, CI workflows, README instructions. Wire what exists; invent
31
+ no new tooling in the first pass.
32
+ - **Host or container?** If the toolchain runs in Docker (compose files, a
33
+ devcontainer), find the dev service's name, then verify the repo mount
34
+ and working directory by running `pwd` and `ls` through
35
+ `docker compose exec -T <service>`. Rules will run there; you need to
36
+ know what paths they see.
37
+ - **What's fast?** Time candidate commands. Only sub-~2s commands belong
38
+ on the per-edit path; test suites belong at turn end; anything needing a
39
+ live server or long setup should not gate turns at all (leave it to the
40
+ repo's existing workflow, or declare it `todo`).
41
+
42
+ ## 2. Scaffold
43
+
44
+ Run `npx highball init`. It never overwrites: an existing
45
+ `.highball/checks.yml` is kept, and if `.claude/settings.json` already
46
+ exists it prints the hook snippet for you to merge by hand — merge it
47
+ without disturbing existing hooks. Otherwise it creates both files.
48
+
49
+ ## 3. Write `.highball/checks.yml`
50
+
51
+ Fill the scaffold using what the survey found. A containerized repo looks
52
+ like this:
53
+
54
+ ```yaml
55
+ version: 1
56
+ project: my-mud # ask your human if their org has slug conventions
57
+
58
+ reporting:
59
+ url: https://highball.example.com # human provides; committed, not secret
60
+
61
+ # Toolchain in Docker: declare the wrapper once. -T is mandatory (hook
62
+ # shells have no TTY); --workdir should be the container's repo mount.
63
+ exec:
64
+ via: docker compose exec -T --workdir /app mud
65
+
66
+ checks:
67
+ - id: python-syntax
68
+ name: Game code compiles
69
+ run: python -m compileall -q game
70
+ fast: true # sub-2s → runs on every edit
71
+
72
+ - id: js-syntax
73
+ name: Web viewer JS parses
74
+ run: node --check web/app.js
75
+ exec: host # host tool → opts out of the container
76
+ fast: true
77
+
78
+ - id: unit-tests
79
+ name: Unit tests (offline)
80
+ run: just test-unit # orchestrates its own docker exec
81
+ exec: host # → must run on the host
82
+
83
+ - id: coverage-ratchet
84
+ name: Coverage never decreases
85
+ todo: true # declared aspiration; tracked, never run
86
+ ```
87
+
88
+ Decision rules:
89
+
90
+ - `exec.via` wraps every rule by default; a rule opts out with
91
+ `exec: host` when it invokes a host tool **or** is self-orchestrating
92
+ (a `just`/`make` target that runs `docker compose` itself must not be
93
+ double-wrapped).
94
+ - Start minimal: one or two fast syntax/lint rules plus the repo's unit
95
+ test command at turn end. You can grow the ruleset later; a wrong rule
96
+ that blocks every turn erodes trust immediately.
97
+ - The runner exports `HIGHBALL_CHANGED_FILES` (newline-separated,
98
+ repo-relative) to every rule — scripts that want changed-only behavior
99
+ can read it instead of shelling out to git.
100
+
101
+ ## 4. Credentials — **human**
102
+
103
+ You must never see, type, or store a token value. Ask your human to:
104
+
105
+ 1. Create this project (and a token for it) in their Highball app.
106
+ 2. Run `npx highball login` themselves — interactively, or piping the
107
+ token via `--token-stdin` to keep it out of shell history.
108
+
109
+ This stores the token in `~/.highball/credentials.json` (machine-local,
110
+ 0600, keyed host → project). CI uses `HIGHBALL_URL`/`HIGHBALL_TOKEN` env
111
+ vars instead. The repo tree never contains a secret.
112
+
113
+ ## 5. Verify — all four proofs, not just the happy path
114
+
115
+ 1. **Fast path:** `npx highball run --fast` exits 0, every rule passed.
116
+ 2. **Full path:** `npx highball run` exits 0 (or fails honestly on real
117
+ pre-existing issues — surface those to your human rather than papering
118
+ over them).
119
+ 3. **The guardrail:** prove exit 2 works. Create an obviously-temporary
120
+ failing file (e.g. a syntax error in a `tmp_highball_plant.*` file),
121
+ run the fast path, confirm `FAILED` plus exit code 2 plus the failure
122
+ text on stderr — then delete the plant and confirm green again.
123
+ 4. **The witness:** every run above should print
124
+ `reported to <host> (run <id>)`. If you see
125
+ `highball reporting skipped: …` instead, diagnose in order: is
126
+ `reporting.url` set? did the human run `login` for this exact URL and
127
+ project slug? is the Highball app reachable from this machine?
128
+
129
+ ## 6. Report back — **human**
130
+
131
+ Tell your human, concretely: which rules you wired and why each is
132
+ fast/turn-end/todo; what you deliberately did NOT gate (slow suites,
133
+ live-server tests); that hooks now block your turns on failures and how
134
+ to remove them (`.claude/settings.json`) if they ever need to; and where
135
+ credentials live. Follow this repo's own norms about committing the new
136
+ files — do not commit without being asked.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @profoundry-us/highball
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.
11
+
12
+ ## Install
13
+
14
+ Published releases: `npm install --save-dev @profoundry-us/highball`.
15
+
16
+ From a local tarball (pre-release):
17
+
18
+ ```bash
19
+ npm pack # in this repo → profoundry-us-highball-<v>.tgz
20
+ npm install --save-dev ../highball-runner/profoundry-us-highball-<v>.tgz
21
+ ```
22
+
23
+ ## Setup: let the repo's own agent do it
24
+
25
+ Highball is installed *by the AI agent that will be checked by it*. After
26
+ installing the package, tell the repo's Claude Code agent:
27
+
28
+ > Run `npx highball onboard` and follow the instructions.
29
+
30
+ [ONBOARDING.md](ONBOARDING.md) (which that command prints) walks the agent
31
+ through surveying the repo's real toolchain, scaffolding, writing rules that
32
+ reflect what the repo already trusts, handing the credentials step to the
33
+ human, and verifying all four proofs — including that exit 2 actually blocks.
34
+
35
+ The pieces, for reference or manual setup:
36
+
37
+ ```bash
38
+ npx highball init # scaffolds .highball/checks.yml + Claude Code hooks
39
+ npx highball login # stores this machine's project token (once per machine)
40
+ ```
41
+
42
+ `init` never overwrites an existing `checks.yml` and never edits an existing
43
+ `.claude/settings.json` (it prints the hook snippet to merge by hand).
44
+ `login` writes `~/.highball/credentials.json` (host → project → token, 0600);
45
+ pipe the token via `--token-stdin` to keep it out of shell history. CI uses
46
+ `HIGHBALL_URL` / `HIGHBALL_TOKEN` env vars instead.
47
+
48
+ ## checks.yml
49
+
50
+ ```yaml
51
+ version: 1
52
+ project: my-app
53
+
54
+ reporting:
55
+ url: https://highball.example.com # per-team, not a secret — committed
56
+
57
+ # Containerized toolchain? Declare the wrapper once and every rule runs
58
+ # through it; rules opt out with `exec: host`. Rule definitions stay
59
+ # environment-agnostic on purpose — the *where* is per-checkout config.
60
+ exec:
61
+ via: docker compose exec -T app
62
+
63
+ checks:
64
+ - id: unit-tests
65
+ name: Unit tests
66
+ run: bundle exec rspec spec # runs through exec.via
67
+
68
+ - id: js-syntax
69
+ name: Playback JS parses
70
+ run: node --check web/app.js
71
+ exec: host # host-side tool, opts out
72
+ fast: true # cheap → runs on every agent edit
73
+
74
+ - id: coverage-ratchet
75
+ name: Coverage never decreases
76
+ todo: true # declared, tracked, not yet built
77
+ ```
78
+
79
+ The runner computes the branch's changed-file list once (it owns git) and
80
+ hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
81
+ analyzers and need no git in their execution context.
82
+
83
+ ## Roadmap
84
+
85
+ AI-judged rules (`rubric:` — headless Claude applying a markdown rubric to
86
+ changed files) land here as a first-class rule type in a future release.
87
+ Built-in generic rules (spec pairing, focused-spec detection, diff budgets)
88
+ likewise, along with per-framework starter packs (`highball-rails`,
89
+ `highball-python`, `highball-go`) carrying recommended check scripts.
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // The `highball` CLI: a thin dispatcher so each subcommand stays an
3
+ // importable, testable module. `run` owns the process exit code — exit 2
4
+ // is the contract Claude Code hooks read as "block the agent and feed
5
+ // the failure output back".
6
+ import { run } from "../lib/run.js";
7
+ import { init } from "../lib/init.js";
8
+ import { login } from "../lib/login.js";
9
+ import { onboard } from "../lib/onboard.js";
10
+
11
+ const [command, ...args] = process.argv.slice(2);
12
+
13
+ const USAGE = `highball — hosted checks for local AI development
14
+
15
+ Usage:
16
+ highball run [--fast] Run this repo's .highball/checks.yml rules.
17
+ Exits 2 on failure (blocks Claude Code hooks).
18
+ --fast runs only rules marked fast: true.
19
+ highball init Scaffold .highball/checks.yml and Claude Code
20
+ hooks in the current repo.
21
+ highball login Store a project token in
22
+ ~/.highball/credentials.json. Reads the token
23
+ from stdin with --token-stdin (recommended).
24
+ highball onboard Print the setup guide written for this repo's
25
+ AI agent — tell your agent to run this and
26
+ follow it.
27
+ `;
28
+
29
+ switch (command) {
30
+ case "run":
31
+ process.exit(await run(args));
32
+ break;
33
+ case "init":
34
+ process.exit(await init(args));
35
+ break;
36
+ case "login":
37
+ process.exit(await login(args));
38
+ break;
39
+ case "onboard":
40
+ process.exit(await onboard(args));
41
+ break;
42
+ default:
43
+ console.log(USAGE);
44
+ process.exit(command === undefined || command === "--help" ? 0 : 1);
45
+ }
package/lib/config.js ADDED
@@ -0,0 +1,51 @@
1
+ // Config resolution, kept pure where possible so tests can exercise the
2
+ // decision logic without a filesystem.
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import YAML from "yaml";
7
+
8
+ export const CONFIG_PATH = ".highball/checks.yml";
9
+ export const CREDENTIALS_PATH = join(homedir(), ".highball", "credentials.json");
10
+
11
+ // Loads .highball/checks.yml from the given repo root. Throws with a
12
+ // friendly message — `highball run` outside a configured repo should read
13
+ // as "set me up", not as a stack trace.
14
+ export function loadConfig(root = process.cwd()) {
15
+ const path = join(root, CONFIG_PATH);
16
+ if (!existsSync(path)) {
17
+ throw new Error(`${CONFIG_PATH} not found — run \`highball init\` first.`);
18
+ }
19
+ const config = YAML.parse(readFileSync(path, "utf8"));
20
+ if (!config?.project) throw new Error(`${CONFIG_PATH} is missing \`project:\`.`);
21
+ if (!Array.isArray(config.checks)) {
22
+ throw new Error(`${CONFIG_PATH} is missing its \`checks:\` list.`);
23
+ }
24
+ return config;
25
+ }
26
+
27
+ // The reporting URL is per-team committed config; the token never lives
28
+ // in the repo tree: env var (CI) wins, else the machine-local credentials
29
+ // file keyed host → project (the .npmrc / gh-hosts pattern, written by
30
+ // `highball login`).
31
+ export function resolveReporting(config, env = process.env, credentials = readCredentials()) {
32
+ const url = env.HIGHBALL_URL || config.reporting?.url || null;
33
+ const token =
34
+ env.HIGHBALL_TOKEN || (url && credentials?.[url]?.[config.project]) || null;
35
+ return { url, token };
36
+ }
37
+
38
+ export function readCredentials(path = CREDENTIALS_PATH) {
39
+ if (!existsSync(path)) return {};
40
+ return JSON.parse(readFileSync(path, "utf8"));
41
+ }
42
+
43
+ // The execution-context decision (ADR 202608): rule definitions stay
44
+ // environment-agnostic; the checkout declares `exec.via` once and every
45
+ // rule runs through it unless it opts out with `exec: host`. No declared
46
+ // context means everything runs on the host unchanged.
47
+ export function commandFor(rule, config) {
48
+ const via = config.exec?.via;
49
+ if (!via || rule.exec === "host") return rule.run;
50
+ return `${via} ${rule.run}`;
51
+ }
package/lib/init.js ADDED
@@ -0,0 +1,90 @@
1
+ // `highball init` — scaffolds the .highball/ install unit and the Claude
2
+ // Code hooks. Deliberately conservative: it never overwrites an existing
3
+ // checks.yml, and it never edits an existing .claude/settings.json (hook
4
+ // merging is a human decision — it prints the snippet instead).
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { basename, join } from "node:path";
7
+
8
+ const CHECKS_TEMPLATE = (project) => `# ${project}'s Highball rules — the file \`highball run\` reads and
9
+ # reports from. \`fast: true\` marks rules cheap enough to run on every
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,
12
+ # never a failure.
13
+ version: 1
14
+ project: ${project}
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).
19
+ # reporting:
20
+ # url: https://your-highball-host
21
+
22
+ # If this repo's toolchain lives in a container, declare the wrapper once
23
+ # and every rule runs through it; rules that belong on the host opt out
24
+ # with \`exec: host\`. Omit entirely for host-based setups.
25
+ # exec:
26
+ # via: docker compose exec -T app
27
+
28
+ checks:
29
+ # - id: unit-tests
30
+ # name: Unit tests
31
+ # run: npm test
32
+
33
+ # - id: lint
34
+ # name: Lint & formatting
35
+ # run: npx eslint .
36
+ # fast: true
37
+ `;
38
+
39
+ const HOOKS_JSON = {
40
+ hooks: {
41
+ PostToolUse: [
42
+ {
43
+ matcher: "Write|Edit",
44
+ hooks: [{ type: "command", command: "npx highball run --fast" }]
45
+ }
46
+ ],
47
+ Stop: [
48
+ {
49
+ hooks: [{ type: "command", command: "npx highball run", timeout: 900 }]
50
+ }
51
+ ]
52
+ }
53
+ };
54
+
55
+ export async function init() {
56
+ const root = process.cwd();
57
+ const project = basename(root);
58
+
59
+ mkdirSync(join(root, ".highball"), { recursive: true });
60
+
61
+ const checksPath = join(root, ".highball", "checks.yml");
62
+ if (existsSync(checksPath)) {
63
+ console.log(`kept existing ${relative(checksPath, root)}`);
64
+ } else {
65
+ writeFileSync(checksPath, CHECKS_TEMPLATE(project));
66
+ console.log(`created .highball/checks.yml (project: ${project}) — add your rules`);
67
+ }
68
+
69
+ const settingsPath = join(root, ".claude", "settings.json");
70
+ if (existsSync(settingsPath)) {
71
+ console.log(
72
+ "\n.claude/settings.json already exists — merge these hooks yourself:\n" +
73
+ JSON.stringify(HOOKS_JSON, null, 2)
74
+ );
75
+ } else {
76
+ mkdirSync(join(root, ".claude"), { recursive: true });
77
+ writeFileSync(settingsPath, JSON.stringify(HOOKS_JSON, null, 2) + "\n");
78
+ console.log("created .claude/settings.json (fast checks on edit, full suite at turn end)");
79
+ }
80
+
81
+ console.log(
82
+ "\nnext: fill in .highball/checks.yml, uncomment reporting.url, and run" +
83
+ "\n highball login # stores this machine's project token"
84
+ );
85
+ return 0;
86
+ }
87
+
88
+ function relative(path, root) {
89
+ return path.startsWith(root) ? path.slice(root.length + 1) : path;
90
+ }
package/lib/login.js ADDED
@@ -0,0 +1,68 @@
1
+ // `highball login` — stores a project token in the machine-local
2
+ // credentials file (~/.highball/credentials.json, host → project →
3
+ // token). The token is read from stdin with --token-stdin (recommended:
4
+ // no shell history, no process listing) or prompted interactively.
5
+ // Tokens never touch the repo tree and are never echoed back.
6
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { CREDENTIALS_PATH, loadConfig, readCredentials } from "./config.js";
10
+
11
+ export async function login(args) {
12
+ const options = parse(args);
13
+
14
+ // The local checks.yml, when present, already knows the url + project —
15
+ // don't make the user repeat what the repo declares.
16
+ let config = null;
17
+ try {
18
+ config = loadConfig();
19
+ } catch {
20
+ // Not in a configured repo; url/project must come from flags/prompts.
21
+ }
22
+
23
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
24
+ try {
25
+ const url =
26
+ options.url ||
27
+ config?.reporting?.url ||
28
+ (await rl.question("Highball URL (e.g. https://highball.example.com): "));
29
+ const project =
30
+ options.project || config?.project || (await rl.question("Project slug: "));
31
+ const token = options.tokenStdin
32
+ ? (await readAllStdin()).trim()
33
+ : (await rl.question("Project token (input is visible — prefer --token-stdin): ")).trim();
34
+
35
+ if (!url || !project || !token) {
36
+ console.error("highball: url, project, and token are all required.");
37
+ return 1;
38
+ }
39
+
40
+ const credentials = readCredentials();
41
+ (credentials[url] ??= {})[project] = token;
42
+
43
+ mkdirSync(dirname(CREDENTIALS_PATH), { recursive: true, mode: 0o700 });
44
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2) + "\n");
45
+ chmodSync(CREDENTIALS_PATH, 0o600);
46
+
47
+ console.log(`stored token for ${project} @ ${url} in ${CREDENTIALS_PATH}`);
48
+ return 0;
49
+ } finally {
50
+ rl.close();
51
+ }
52
+ }
53
+
54
+ function parse(args) {
55
+ const options = { tokenStdin: false };
56
+ for (let i = 0; i < args.length; i++) {
57
+ if (args[i] === "--url") options.url = args[++i];
58
+ else if (args[i] === "--project") options.project = args[++i];
59
+ else if (args[i] === "--token-stdin") options.tokenStdin = true;
60
+ }
61
+ return options;
62
+ }
63
+
64
+ async function readAllStdin() {
65
+ let text = "";
66
+ for await (const chunk of process.stdin) text += chunk;
67
+ return text;
68
+ }
package/lib/onboard.js ADDED
@@ -0,0 +1,12 @@
1
+ // `highball onboard` — prints the agent-facing setup guide. The whole
2
+ // point of shipping it as a command: any repo's AI agent can be told
3
+ // "run `npx highball onboard` and follow it", and the instructions
4
+ // arrive versioned with the runner they describe.
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ export async function onboard() {
9
+ const path = fileURLToPath(new URL("../ONBOARDING.md", import.meta.url));
10
+ process.stdout.write(readFileSync(path, "utf8"));
11
+ return 0;
12
+ }
package/lib/report.js ADDED
@@ -0,0 +1,71 @@
1
+ // The witness half: best-effort reporting to the Highball app — open
2
+ // run, per-check results, finalize. Payloads mirror the Ruby
3
+ // proto-runner exactly so the ingestion API sees one dialect. Failures
4
+ // here warn and return; they NEVER fail the checks.
5
+ import { execSync } from "node:child_process";
6
+ import { hostname } from "node:os";
7
+
8
+ export async function report({ url, token, rules, results, hook, fastOnly, startedAt }) {
9
+ try {
10
+ const base = new URL(url);
11
+ const request = async (method, path, body) => {
12
+ const response = await fetch(new URL(path, base), {
13
+ method,
14
+ headers: {
15
+ Authorization: `Bearer ${token}`,
16
+ "Content-Type": "application/json"
17
+ },
18
+ body: JSON.stringify(body),
19
+ signal: AbortSignal.timeout(15_000)
20
+ });
21
+ if (response.status >= 300) {
22
+ throw new Error(`${path} -> ${response.status}: ${await response.text()}`);
23
+ }
24
+ return response.json();
25
+ };
26
+
27
+ const opened = await request("POST", "/api/v1/runs", {
28
+ agent: hook.session_id ? "claude-code" : "manual",
29
+ session_key:
30
+ hook.session_id || process.env.HIGHBALL_SESSION_KEY || `manual-${hostname()}`,
31
+ trigger: fastOnly ? "edit" : "stop",
32
+ branch: git("git branch --show-current"),
33
+ commit_sha: git("git rev-parse HEAD"),
34
+ rules_snapshot: rules,
35
+ started_at: startedAt.toISOString()
36
+ });
37
+ const runId = opened.run_id;
38
+
39
+ for (const result of results) {
40
+ await request("POST", `/api/v1/runs/${runId}/results`, {
41
+ check_key: result.rule.id,
42
+ name: result.rule.name,
43
+ status: result.todo ? "todo" : result.passed ? "passed" : "failed",
44
+ duration_ms: result.durationMs,
45
+ // Full logs stay local; the tail is enough to read a failure on
46
+ // the dashboard without shipping megabytes per keystroke.
47
+ log_tail: result.passed ? null : result.output.slice(-4000),
48
+ summary: result.todo
49
+ ? "planned — not implemented yet"
50
+ : result.passed
51
+ ? null
52
+ : result.output.split("\n")[0]?.trim().slice(0, 120)
53
+ });
54
+ }
55
+
56
+ await request("PATCH", `/api/v1/runs/${runId}`, {
57
+ status: results.every((result) => result.passed) ? "passed" : "failed"
58
+ });
59
+ console.log(`reported to ${base.host} (run ${runId})`);
60
+ } catch (error) {
61
+ console.error(`highball reporting skipped: ${error.message}`);
62
+ }
63
+ }
64
+
65
+ function git(command) {
66
+ try {
67
+ return execSync(`${command} 2>/dev/null`, { encoding: "utf8" }).trim();
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
package/lib/run.js ADDED
@@ -0,0 +1,121 @@
1
+ // `highball run [--fast]` — the enforcement half. Runs each rule, prints
2
+ // progress, exits 2 with failures on stderr (the Claude Code hook
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.
6
+ import { execSync, spawnSync } from "node:child_process";
7
+ import { loadConfig, resolveReporting, commandFor } from "./config.js";
8
+ import { report } from "./report.js";
9
+
10
+ export async function run(args) {
11
+ // When an AI-judged rule spawns a judge session inside this repo, the
12
+ // judge inherits the repo's hooks — and its Stop hook would re-enter
13
+ // this runner and spawn another judge, forever. The env var breaks the
14
+ // loop.
15
+ if (process.env.HIGHBALL_JUDGE) return 0;
16
+
17
+ const fastOnly = args.includes("--fast");
18
+ let config;
19
+ try {
20
+ config = loadConfig();
21
+ } catch (error) {
22
+ console.error(`highball: ${error.message}`);
23
+ return 1;
24
+ }
25
+
26
+ const rules = fastOnly ? config.checks.filter((rule) => rule.fast) : config.checks;
27
+ const hook = await readHookPayload();
28
+ const changed = changedFiles();
29
+
30
+ // Captured before the loop and reported as the run's started_at: the
31
+ // run is opened AFTER checks finish (one reporting burst, no mid-run
32
+ // network stalls), so without this the server would clock the run at
33
+ // the length of the reporting window instead of the checks themselves.
34
+ const startedAt = new Date();
35
+ const results = [];
36
+
37
+ for (const rule of rules) {
38
+ process.stdout.write(`→ ${rule.name} ... `);
39
+
40
+ // Placeholder rules are tracked, not run: they report as "todo" so
41
+ // the dashboard shows the full intended ruleset, and they can never
42
+ // fail a run — an aspiration shouldn't block anyone.
43
+ if (rule.todo) {
44
+ console.log("todo (not implemented yet)");
45
+ results.push({ rule, passed: true, todo: true, durationMs: null, output: "" });
46
+ continue;
47
+ }
48
+
49
+ const command = commandFor(rule, config);
50
+ const t0 = process.hrtime.bigint();
51
+ // The runner owns git (ADR 202608): check scripts get the changed
52
+ // list handed to them and stay pure analyzers — no git, no network
53
+ // required in their execution context (which may be a container).
54
+ const child = spawnSync(`${command} 2>&1`, {
55
+ shell: true,
56
+ encoding: "utf8",
57
+ env: { ...process.env, HIGHBALL_CHANGED_FILES: changed },
58
+ maxBuffer: 32 * 1024 * 1024
59
+ });
60
+ const durationMs = Math.round(Number(process.hrtime.bigint() - t0) / 1e6);
61
+ const output = child.stdout ?? "";
62
+ const passed = child.status === 0;
63
+
64
+ console.log(`${passed ? "passed" : "FAILED"} (${(durationMs / 1000).toFixed(1)}s)`);
65
+ results.push({ rule, passed, todo: false, durationMs, output });
66
+ }
67
+
68
+ const failures = results.filter((result) => !result.passed);
69
+
70
+ const { url, token } = resolveReporting(config);
71
+ if (url && token) {
72
+ await report({ url, token, project: config.project, rules, results, hook, fastOnly, startedAt });
73
+ }
74
+
75
+ if (failures.length === 0) return 0;
76
+
77
+ for (const failure of failures) {
78
+ console.error(
79
+ `\n### ${failure.rule.name} (${failure.rule.id}) failed. ` +
80
+ `Fix before finishing:\n${failure.output}`
81
+ );
82
+ }
83
+ return 2;
84
+ }
85
+
86
+ // Claude Code hooks pass a JSON payload on stdin (session_id and
87
+ // friends); that id groups this run with the rest of the agent's session
88
+ // on the dashboard. A TTY means a human at a terminal — don't block on
89
+ // read.
90
+ async function readHookPayload() {
91
+ if (process.stdin.isTTY) return {};
92
+ try {
93
+ let text = "";
94
+ for await (const chunk of process.stdin) text += chunk;
95
+ return JSON.parse(text);
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ // Changed = branch work in progress: tracked edits vs HEAD plus
102
+ // untracked files. Handed to check scripts via HIGHBALL_CHANGED_FILES
103
+ // (newline-separated, repo-relative). Skipped past 100KB — env vars
104
+ // share the OS arg-space budget, and a monster refactor shouldn't make
105
+ // every rule invocation fail; scripts fall back to their own git.
106
+ function changedFiles() {
107
+ try {
108
+ const tracked = execSync("git diff --name-only HEAD", { encoding: "utf8" });
109
+ const untracked = execSync("git ls-files --others --exclude-standard", {
110
+ encoding: "utf8"
111
+ });
112
+ const list = `${tracked}\n${untracked}`
113
+ .split("\n")
114
+ .map((line) => line.trim())
115
+ .filter(Boolean)
116
+ .join("\n");
117
+ return list.length > 100_000 ? "" : list;
118
+ } catch {
119
+ return "";
120
+ }
121
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@profoundry-us/highball",
3
+ "version": "0.1.0",
4
+ "description": "Highball runner — local CI for AI coding agents: runs a repo's .highball/checks.yml rules, blocks the agent on failure, and reports runs to a Highball dashboard.",
5
+ "keywords": [
6
+ "ai",
7
+ "claude-code",
8
+ "hooks",
9
+ "checks",
10
+ "ci",
11
+ "code-quality"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/profoundry-us/highball-runner.git"
16
+ },
17
+ "homepage": "https://github.com/profoundry-us/highball-runner#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/profoundry-us/highball-runner/issues"
20
+ },
21
+ "type": "module",
22
+ "bin": {
23
+ "highball": "bin/highball.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "lib",
28
+ "README.md",
29
+ "ONBOARDING.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test"
36
+ },
37
+ "dependencies": {
38
+ "yaml": "^2.5.0"
39
+ },
40
+ "license": "MIT"
41
+ }