@profoundry-us/highball 0.3.2 → 0.4.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/README.md +40 -4
- package/lib/config.js +13 -0
- package/lib/judge.js +205 -0
- package/lib/run.js +23 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,10 @@ checks:
|
|
|
76
76
|
exec: host # host-side tool, opts out
|
|
77
77
|
fast: true # cheap → runs on every agent edit
|
|
78
78
|
|
|
79
|
+
- id: architecture-quality
|
|
80
|
+
name: Architecture & naming (AI)
|
|
81
|
+
rubric: .highball/packs/rails/rubrics/architecture.md
|
|
82
|
+
|
|
79
83
|
- id: coverage-ratchet
|
|
80
84
|
name: Coverage never decreases
|
|
81
85
|
todo: true # declared, tracked, not yet built
|
|
@@ -85,6 +89,38 @@ The runner computes the branch's changed-file list once (it owns git) and
|
|
|
85
89
|
hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
|
|
86
90
|
analyzers and need no git in their execution context.
|
|
87
91
|
|
|
92
|
+
## AI-judged rules
|
|
93
|
+
|
|
94
|
+
A rule with `rubric:` instead of `run:` is judged by headless Claude rather
|
|
95
|
+
than by a script: the runner bundles the changed files the rubric asks for,
|
|
96
|
+
applies the rubric, and turns the verdict into the same pass/fail contract
|
|
97
|
+
every other rule uses.
|
|
98
|
+
|
|
99
|
+
The rubric is markdown with optional YAML front matter, which carries the only
|
|
100
|
+
language-specific part:
|
|
101
|
+
|
|
102
|
+
```markdown
|
|
103
|
+
---
|
|
104
|
+
include: "**/*.rb" # default: every changed file
|
|
105
|
+
exclude: [db/, config/] # path prefixes
|
|
106
|
+
model: claude-haiku-4-5-20251001
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
A comment VIOLATES this rubric when it restates what the code already says.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Three properties are enforced by the runner rather than left to each repo:
|
|
113
|
+
|
|
114
|
+
- **Never on the fast path.** Rubric rules are dropped from `--fast` runs even
|
|
115
|
+
if marked `fast: true` — otherwise you pay model latency on every edit.
|
|
116
|
+
- **Always host-side.** The judge needs the `claude` CLI, so `exec.via` never
|
|
117
|
+
wraps it and no `exec: host` annotation is required.
|
|
118
|
+
- **No evidence, no call.** When nothing in the changed set matches `include`,
|
|
119
|
+
the rule passes without spawning the model at all.
|
|
120
|
+
|
|
121
|
+
Rubrics live with the opinions they express: a framework pack such as
|
|
122
|
+
`@profoundry-us/highball-rails` ships them, and the runner supplies the engine.
|
|
123
|
+
|
|
88
124
|
## The MCP dashboard widget
|
|
89
125
|
|
|
90
126
|
`highball mcp` serves the journal over MCP (stdio) with three tools —
|
|
@@ -129,8 +165,8 @@ attribution — it's never required to see what happened.
|
|
|
129
165
|
|
|
130
166
|
## Roadmap
|
|
131
167
|
|
|
132
|
-
AI-judged rules (`rubric:` — headless Claude applying a markdown rubric to
|
|
133
|
-
changed files) land here as a first-class rule type in a future release.
|
|
134
168
|
Built-in generic rules (spec pairing, focused-spec detection, diff budgets)
|
|
135
|
-
|
|
136
|
-
`highball-python`, `highball-go`) carrying recommended check scripts.
|
|
169
|
+
land here in a future release, along with more per-framework starter packs
|
|
170
|
+
(`highball-python`, `highball-go`) carrying recommended check scripts. The
|
|
171
|
+
Rails pack ([`@profoundry-us/highball-rails`](https://github.com/profoundry-us/highball-rails))
|
|
172
|
+
and AI-judged `rubric:` rules have shipped.
|
package/lib/config.js
CHANGED
|
@@ -21,6 +21,14 @@ export function loadConfig(root = process.cwd()) {
|
|
|
21
21
|
if (!Array.isArray(config.checks)) {
|
|
22
22
|
throw new Error(`${CONFIG_PATH} is missing its \`checks:\` list.`);
|
|
23
23
|
}
|
|
24
|
+
for (const rule of config.checks) {
|
|
25
|
+
if (!rule.run && !rule.rubric && !rule.todo) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${CONFIG_PATH}: rule \`${rule.id ?? "(unnamed)"}\` needs ` +
|
|
28
|
+
"`run:`, `rubric:`, or `todo: true`."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
24
32
|
return config;
|
|
25
33
|
}
|
|
26
34
|
|
|
@@ -45,6 +53,11 @@ export function readCredentials(path = CREDENTIALS_PATH) {
|
|
|
45
53
|
// rule runs through it unless it opts out with `exec: host`. No declared
|
|
46
54
|
// context means everything runs on the host unchanged.
|
|
47
55
|
export function commandFor(rule, config) {
|
|
56
|
+
// Rubric rules never become shell commands: the runner executes them
|
|
57
|
+
// in-process and host-side, because the `claude` CLI lives on the machine
|
|
58
|
+
// rather than in a project's container (see lib/judge.js).
|
|
59
|
+
if (rule.rubric) return null;
|
|
60
|
+
|
|
48
61
|
const via = config.exec?.via;
|
|
49
62
|
if (!via || rule.exec === "host") return rule.run;
|
|
50
63
|
return `${via} ${rule.run}`;
|
package/lib/judge.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The AI judge: hands a rubric plus the files this branch touched to
|
|
2
|
+
// headless Claude and turns the verdict into the same pass/fail contract
|
|
3
|
+
// the deterministic rules use.
|
|
4
|
+
//
|
|
5
|
+
// This lives in the runner, not in a language pack, because almost none of
|
|
6
|
+
// it is language-specific: bundling, the prompt contract, the recursion
|
|
7
|
+
// guard, verdict extraction and exit codes are identical whether the repo
|
|
8
|
+
// is Rails, Django or Next. Packs own the *rubrics* — the actual opinions,
|
|
9
|
+
// which are entirely framework-specific — and declare their language policy
|
|
10
|
+
// in rubric front matter. Before this split, a JS-only shop needed Ruby
|
|
11
|
+
// installed to run an AI rule, and the recursion guard lived in this file's
|
|
12
|
+
// repo while the thing it guarded lived in another.
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import YAML from "yaml";
|
|
16
|
+
|
|
17
|
+
// Judging against a narrow rubric is exactly the fast-and-cheap tier's job.
|
|
18
|
+
// A rubric that needs deeper reasoning overrides this in its front matter —
|
|
19
|
+
// which the previous hardcoded implementation only aspired to.
|
|
20
|
+
const DEFAULT_MODEL = "claude-haiku-4-5-20251001";
|
|
21
|
+
|
|
22
|
+
// Caps the evidence bundle so the judge stays fast and cheap. Anything
|
|
23
|
+
// dropped is reported, never silently skipped — a cap that lies reads as
|
|
24
|
+
// "covered everything" when it didn't.
|
|
25
|
+
const DEFAULT_MAX_BYTES = 48_000;
|
|
26
|
+
|
|
27
|
+
// Rubrics are markdown with optional YAML front matter:
|
|
28
|
+
//
|
|
29
|
+
// ---
|
|
30
|
+
// include: "**/*.rb"
|
|
31
|
+
// exclude: [db/, config/]
|
|
32
|
+
// model: claude-haiku-4-5-20251001
|
|
33
|
+
// ---
|
|
34
|
+
//
|
|
35
|
+
// Everything after the fence is the rubric prose sent to the judge.
|
|
36
|
+
export function parseRubric(text) {
|
|
37
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
38
|
+
if (!match) return { meta: {}, body: text };
|
|
39
|
+
return { meta: YAML.parse(match[1]) ?? {}, body: text.slice(match[0].length) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Minimal glob support — `**`, `*`, `?` — rather than a dependency. Rubric
|
|
43
|
+
// patterns are file-extension filters in practice ("**/*.rb"), not the kind
|
|
44
|
+
// of brace/extglob expressions that would justify pulling in picomatch.
|
|
45
|
+
export function globToRegExp(pattern) {
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
48
|
+
const char = pattern[i];
|
|
49
|
+
if (char === "*" && pattern[i + 1] === "*") {
|
|
50
|
+
i += 1;
|
|
51
|
+
// `**/` spans zero or more directories; a trailing `**` matches the rest.
|
|
52
|
+
if (pattern[i + 1] === "/") {
|
|
53
|
+
i += 1;
|
|
54
|
+
out += "(?:.*/)?";
|
|
55
|
+
} else {
|
|
56
|
+
out += ".*";
|
|
57
|
+
}
|
|
58
|
+
} else if (char === "*") {
|
|
59
|
+
out += "[^/]*";
|
|
60
|
+
} else if (char === "?") {
|
|
61
|
+
out += "[^/]";
|
|
62
|
+
} else {
|
|
63
|
+
out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return new RegExp(`^${out}$`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The runner already computed the changed list once (ADR 202608) — the judge
|
|
70
|
+
// consumes it rather than shelling out to git again, which is what let the
|
|
71
|
+
// old Ruby implementation drift out of step with its own sibling checks.
|
|
72
|
+
export function selectFiles(changed, meta = {}, exists = existsSync) {
|
|
73
|
+
const include = [ meta.include ?? "**/*" ].flat().map(globToRegExp);
|
|
74
|
+
const exclude = [ meta.exclude ?? [] ].flat();
|
|
75
|
+
|
|
76
|
+
return [ ...new Set(String(changed).split("\n").map((line) => line.trim())) ]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.filter((path) => include.some((pattern) => pattern.test(path)))
|
|
79
|
+
.filter((path) => !exclude.some((prefix) => path.startsWith(prefix)))
|
|
80
|
+
.filter((path) => exists(path));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildBundle(files, maxBytes, read) {
|
|
84
|
+
let bundle = "";
|
|
85
|
+
const included = [];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const source = read(file);
|
|
88
|
+
if (Buffer.byteLength(bundle) + Buffer.byteLength(source) > maxBytes) break;
|
|
89
|
+
bundle += `\n===== ${file} =====\n${source}`;
|
|
90
|
+
included.push(file);
|
|
91
|
+
}
|
|
92
|
+
return { bundle, included, skipped: files.length - included.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildPrompt(rubric, bundle) {
|
|
96
|
+
return `You are a code-review judge. Apply ONLY the rubric below to the files
|
|
97
|
+
provided. Do not invent rules the rubric does not state. When uncertain,
|
|
98
|
+
pass — report only clear violations.
|
|
99
|
+
|
|
100
|
+
RUBRIC:
|
|
101
|
+
${rubric}
|
|
102
|
+
|
|
103
|
+
Respond with ONLY a JSON object, no markdown fences, in this shape:
|
|
104
|
+
{"status":"passed","offenses":[]}
|
|
105
|
+
or
|
|
106
|
+
{"status":"failed","offenses":[{"file":"path","line":1,"message":"..."}]}
|
|
107
|
+
|
|
108
|
+
FILES:
|
|
109
|
+
${bundle}
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Models occasionally wrap the verdict in fences or append a prose recap
|
|
114
|
+
// despite the "ONLY a JSON object" instruction — extract the outermost object
|
|
115
|
+
// instead of trusting the envelope to be bare JSON.
|
|
116
|
+
export function extractVerdict(stdout) {
|
|
117
|
+
let result;
|
|
118
|
+
try {
|
|
119
|
+
result = JSON.parse(stdout).result;
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error(`AI judge returned unreadable output:\n${stdout}`);
|
|
122
|
+
}
|
|
123
|
+
const json = String(result ?? "").match(/\{[\s\S]*\}/);
|
|
124
|
+
if (!json) throw new Error(`AI judge returned no JSON verdict:\n${result}`);
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(json[0]);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`AI judge returned an unparseable verdict:\n${result}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Always runs host-side: it needs the `claude` CLI, which lives on the
|
|
133
|
+
// machine rather than in a project's container. Being a runner builtin makes
|
|
134
|
+
// that structural instead of an `exec: host` annotation every adopter has to
|
|
135
|
+
// remember to write.
|
|
136
|
+
export function judge(options) {
|
|
137
|
+
const {
|
|
138
|
+
rubricPath,
|
|
139
|
+
changed,
|
|
140
|
+
spawn = spawnSync,
|
|
141
|
+
exists = existsSync,
|
|
142
|
+
read = (path) => readFileSync(path, "utf8")
|
|
143
|
+
} = options;
|
|
144
|
+
|
|
145
|
+
if (!exists(rubricPath)) {
|
|
146
|
+
return { passed: false, output: `rubric not found: ${rubricPath}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { meta, body } = parseRubric(read(rubricPath));
|
|
150
|
+
const files = selectFiles(changed, meta, exists);
|
|
151
|
+
if (files.length === 0) {
|
|
152
|
+
return { passed: true, output: `AI-judged 0 file(s) against ${rubricPath}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { bundle, included, skipped } =
|
|
156
|
+
buildBundle(files, meta.max_bytes ?? DEFAULT_MAX_BYTES, read);
|
|
157
|
+
|
|
158
|
+
// HIGHBALL_JUDGE guards recursion: the judge session inherits this repo's
|
|
159
|
+
// Claude Code hooks, and the runner exits immediately when it sees this
|
|
160
|
+
// variable — otherwise the judge's own Stop hook would spawn another judge,
|
|
161
|
+
// forever. Guard and guarded now live in the same package.
|
|
162
|
+
const child = spawn(
|
|
163
|
+
"claude",
|
|
164
|
+
[ "-p", "--model", meta.model ?? DEFAULT_MODEL, "--output-format", "json" ],
|
|
165
|
+
{
|
|
166
|
+
input: buildPrompt(body, bundle),
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
env: { ...process.env, HIGHBALL_JUDGE: "1" },
|
|
169
|
+
maxBuffer: 32 * 1024 * 1024
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (child.error?.code === "ENOENT") {
|
|
174
|
+
return {
|
|
175
|
+
passed: false,
|
|
176
|
+
output: "AI judge needs the `claude` CLI on PATH, and it wasn't found."
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (child.status !== 0) {
|
|
180
|
+
return { passed: false, output: `AI judge failed to run: ${child.stderr ?? ""}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let verdict;
|
|
184
|
+
try {
|
|
185
|
+
verdict = extractVerdict(child.stdout ?? "");
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return { passed: false, output: error.message };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const note = skipped > 0 ? ` (${skipped} file(s) skipped for size)` : "";
|
|
191
|
+
const header = `AI-judged ${included.length} file(s)${note} against ${rubricPath}`;
|
|
192
|
+
const offenses = verdict.offenses ?? [];
|
|
193
|
+
|
|
194
|
+
if (verdict.status === "passed" || offenses.length === 0) {
|
|
195
|
+
return { passed: true, output: `${header}\n0 offense(s)` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const lines = offenses.map(
|
|
199
|
+
(offense) => `${offense.file}:${offense.line}: ${offense.message}`
|
|
200
|
+
);
|
|
201
|
+
return {
|
|
202
|
+
passed: false,
|
|
203
|
+
output: `${header}\n${lines.join("\n")}\n${offenses.length} offense(s)`
|
|
204
|
+
};
|
|
205
|
+
}
|
package/lib/run.js
CHANGED
|
@@ -7,6 +7,7 @@ import { execSync, spawnSync } from "node:child_process";
|
|
|
7
7
|
import { loadConfig, resolveReporting, commandFor } from "./config.js";
|
|
8
8
|
import { appendRun } from "./journal.js";
|
|
9
9
|
import { git, report } from "./report.js";
|
|
10
|
+
import { judge } from "./judge.js";
|
|
10
11
|
import { latestUserPrompt } from "./transcript.js";
|
|
11
12
|
|
|
12
13
|
export async function run(args) {
|
|
@@ -25,7 +26,12 @@ export async function run(args) {
|
|
|
25
26
|
return 1;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
// Rubric rules never join a fast run, even if a config marks one `fast`:
|
|
30
|
+
// LLM latency and cost would be paid on every edit. That belongs at turn
|
|
31
|
+
// end, and the invariant is enforced here rather than left to each repo.
|
|
32
|
+
const rules = fastOnly
|
|
33
|
+
? config.checks.filter((rule) => rule.fast && !rule.rubric)
|
|
34
|
+
: config.checks;
|
|
29
35
|
const hook = await readHookPayload();
|
|
30
36
|
const changed = changedFiles();
|
|
31
37
|
|
|
@@ -48,6 +54,19 @@ export async function run(args) {
|
|
|
48
54
|
continue;
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
// Rubric rules run in-process instead of shelling out: the judge needs
|
|
58
|
+
// the `claude` CLI, which lives on the host, so it bypasses `exec.via`
|
|
59
|
+
// by construction rather than by annotation.
|
|
60
|
+
if (rule.rubric) {
|
|
61
|
+
const t0 = process.hrtime.bigint();
|
|
62
|
+
const { passed, output } = judge({ rubricPath: rule.rubric, changed });
|
|
63
|
+
const durationMs = Math.round(Number(process.hrtime.bigint() - t0) / 1e6);
|
|
64
|
+
|
|
65
|
+
console.log(`${passed ? "passed" : "FAILED"} (${(durationMs / 1000).toFixed(1)}s)`);
|
|
66
|
+
results.push({ rule, passed, todo: false, durationMs, output });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
const command = commandFor(rule, config);
|
|
52
71
|
const t0 = process.hrtime.bigint();
|
|
53
72
|
// The runner owns git (ADR 202608): check scripts get the changed
|
|
@@ -111,7 +130,9 @@ export async function run(args) {
|
|
|
111
130
|
// command is the one detail every real rule can always show. todo
|
|
112
131
|
// rules have no command — nothing ran — which is exactly what makes
|
|
113
132
|
// them inert rather than falsely clickable.
|
|
114
|
-
command:
|
|
133
|
+
command:
|
|
134
|
+
result.rule.run ??
|
|
135
|
+
(result.rule.rubric ? `judge ${result.rule.rubric}` : null),
|
|
115
136
|
// Unlike the dashboard (failure tails only), the journal keeps
|
|
116
137
|
// every rule's output GitHub-Actions-style — it's the user's own
|
|
117
138
|
// disk, and `highball runs <n> --logs` is the payoff.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profoundry-us/highball",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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 reports runs to a Highball dashboard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|