@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 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
- likewise, along with per-framework starter packs (`highball-rails`,
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
- const rules = fastOnly ? config.checks.filter((rule) => rule.fast) : config.checks;
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: result.rule.run ?? null,
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.2",
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",