@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 +21 -0
- package/ONBOARDING.md +136 -0
- package/README.md +89 -0
- package/bin/highball.js +45 -0
- package/lib/config.js +51 -0
- package/lib/init.js +90 -0
- package/lib/login.js +68 -0
- package/lib/onboard.js +12 -0
- package/lib/report.js +71 -0
- package/lib/run.js +121 -0
- package/package.json +41 -0
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.
|
package/bin/highball.js
ADDED
|
@@ -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
|
+
}
|