@profoundry-us/highball 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ONBOARDING.md CHANGED
@@ -14,13 +14,20 @@ yourself.
14
14
 
15
15
  ## 0. Preconditions
16
16
 
17
- `npx highball --help` must work (Node >= 18, package installed as a dev
17
+ `npx @profoundry-us/highball --help` must work (Node >= 18, package installed as a dev
18
18
  dependency). If it doesn't, ask your human whether to install from the npm
19
19
  registry (`npm install --save-dev @profoundry-us/highball`) or from a
20
20
  local tarball path they provide. In a repo with no `package.json`, create
21
21
  a minimal private one first (`{ "name": "<repo>", "private": true }`) and
22
22
  gitignore `node_modules/` if it isn't already.
23
23
 
24
+ **Never run bare `npx highball` where the package is NOT installed**: the
25
+ unscoped npm name `highball` belongs to an unrelated package, and npx
26
+ would fetch that instead of this runner. Installed locally, the bare name
27
+ is safe (npx resolves `node_modules/.bin` first — that's why install is
28
+ step 0); for a one-off without installing, use the scoped form,
29
+ `npx @profoundry-us/highball <command>`.
30
+
24
31
  ## 1. Survey the repo before writing anything
25
32
 
26
33
  Answer these by reading, not assuming:
@@ -29,19 +36,57 @@ Answer these by reading, not assuming:
29
36
  repo *already trust* — look at `package.json` scripts, a `justfile` or
30
37
  `Makefile`, CI workflows, README instructions. Wire what exists; invent
31
38
  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.
39
+ - **Host or container?** Settle this before writing a single rule see the
40
+ callout immediately below. Getting it wrong makes every rule fail for the
41
+ same uninteresting reason, and it is the most common way this setup
42
+ stalls.
37
43
  - **What's fast?** Time candidate commands. Only sub-~2s commands belong
38
44
  on the per-edit path; test suites belong at turn end; anything needing a
39
45
  live server or long setup should not gate turns at all (leave it to the
40
46
  repo's existing workflow, or declare it `todo`).
41
47
 
48
+ ### If the repo's toolchain lives in Docker
49
+
50
+ Plenty of repos run *everything* through containers — the host may have no
51
+ Ruby, no Python, no database at all. Highball handles this, but only if you
52
+ declare it. **The runner itself always stays on the host** (that's where the
53
+ hooks fire and where the journal lives); only the rule commands move.
54
+
55
+ Find the dev service and confirm what it actually sees, rather than assuming
56
+ the layout:
57
+
58
+ ```bash
59
+ docker compose ps --services
60
+ docker compose exec -T <service> sh -c 'pwd && ls'
61
+ ```
62
+
63
+ Then declare the wrapper once, and every rule runs through it:
64
+
65
+ ```yaml
66
+ exec:
67
+ via: docker compose exec -T --workdir /app app
68
+ ```
69
+
70
+ Four traps, each of which has bitten a real onboarding:
71
+
72
+ 1. **`-T` is mandatory.** Hook shells have no TTY; without it commands hang
73
+ or die with "the input device is not a TTY".
74
+ 2. **Set `--workdir`** to wherever the repo is mounted (verify with `pwd`
75
+ above). Containers frequently start somewhere other than the mount root.
76
+ 3. **Self-orchestrating commands must opt out with `exec: host`.** A
77
+ `just test` / `make test` target that runs its *own* `docker compose
78
+ exec` would otherwise be double-wrapped into nonsense.
79
+ 4. **Host-only tools opt out too.** If a linter or parser exists on the host
80
+ but not in the image (`node --check` against a JS bundle, say), mark that
81
+ rule `exec: host`.
82
+
83
+ A stopped container makes every wrapped rule fail. That's correct behavior —
84
+ unverifiable is not passing — but say so plainly to your human rather than
85
+ quietly dropping the rules.
86
+
42
87
  ## 2. Scaffold
43
88
 
44
- Run `npx highball init`. It never overwrites: an existing
89
+ Run `npx @profoundry-us/highball init`. It never overwrites: an existing
45
90
  `.highball/checks.yml` is kept, and if `.claude/settings.json` already
46
91
  exists it prints the hook snippet for you to merge by hand — merge it
47
92
  without disturbing existing hooks. Otherwise it creates both files.
@@ -103,7 +148,7 @@ Decision rules:
103
148
  You must never see, type, or store a token value. Ask your human to:
104
149
 
105
150
  1. Create this project (and a token for it) in their Highball app.
106
- 2. Run `npx highball login` themselves — interactively, or piping the
151
+ 2. Run `npx @profoundry-us/highball login` themselves — interactively, or piping the
107
152
  token via `--token-stdin` to keep it out of shell history.
108
153
 
109
154
  This stores the token in `~/.highball/credentials.json` (machine-local,
@@ -112,8 +157,8 @@ vars instead. The repo tree never contains a secret.
112
157
 
113
158
  ## 5. Verify — all four proofs, not just the happy path
114
159
 
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
160
+ 1. **Fast path:** `npx @profoundry-us/highball run --fast` exits 0, every rule passed.
161
+ 2. **Full path:** `npx @profoundry-us/highball run` exits 0 (or fails honestly on real
117
162
  pre-existing issues — surface those to your human rather than papering
118
163
  over them).
119
164
  3. **The guardrail:** prove exit 2 works. Create an obviously-temporary
package/README.md CHANGED
@@ -13,6 +13,11 @@ block, they just aren't recorded.
13
13
 
14
14
  Published releases: `npm install --save-dev @profoundry-us/highball`.
15
15
 
16
+ **Always use the scoped name.** The unscoped npm name `highball` belongs to
17
+ an unrelated package, so a bare `npx highball` — in a committed hook, a
18
+ README, or a one-off — is a single uninstalled checkout away from fetching
19
+ a stranger's code and running it.
20
+
16
21
  From a local tarball (pre-release):
17
22
 
18
23
  ```bash
@@ -25,7 +30,7 @@ npm install --save-dev ../highball-runner/profoundry-us-highball-<v>.tgz
25
30
  Highball is installed *by the AI agent that will be checked by it*. After
26
31
  installing the package, tell the repo's Claude Code agent:
27
32
 
28
- > Run `npx highball onboard` and follow the instructions.
33
+ > Run `npx @profoundry-us/highball onboard` and follow the instructions.
29
34
 
30
35
  [ONBOARDING.md](ONBOARDING.md) (which that command prints) walks the agent
31
36
  through surveying the repo's real toolchain, scaffolding, writing rules that
@@ -35,8 +40,8 @@ human, and verifying all four proofs — including that exit 2 actually blocks.
35
40
  The pieces, for reference or manual setup:
36
41
 
37
42
  ```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)
43
+ npx @profoundry-us/highball init # scaffolds checks.yml + Claude Code hooks
44
+ npx @profoundry-us/highball login # stores a project token (once per machine)
40
45
  ```
41
46
 
42
47
  `init` never overwrites an existing `checks.yml` and never edits an existing
@@ -80,6 +85,48 @@ The runner computes the branch's changed-file list once (it owns git) and
80
85
  hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
81
86
  analyzers and need no git in their execution context.
82
87
 
88
+ ## The MCP dashboard widget
89
+
90
+ `highball mcp` serves the journal over MCP (stdio) with three tools —
91
+ `list_runs`, `get_run`, `run_checks` — and an
92
+ [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) widget:
93
+ in hosts that render Apps (Claude Desktop and friends), asking about your
94
+ checks produces an interactive inline dashboard — click a run for per-rule
95
+ detail with expandable command output, re-run fast or full checks from a
96
+ button. In hosts without Apps support the same tools answer in plain text,
97
+ per the extension's graceful-degradation rule. Register it with the scoped
98
+ name — hosts spawn the server from an arbitrary directory, so it resolves
99
+ from the registry rather than a local install:
100
+
101
+ ```json
102
+ "highball": { "command": "npx", "args": ["-y", "@profoundry-us/highball", "mcp"] }
103
+ ```
104
+
105
+ The journal it reads is machine-global (`~/.highball/runs/`), so one
106
+ registration covers every repo on that machine — there is no per-repo MCP
107
+ setup.
108
+
109
+ The split is capability-driven, not guesswork: the server reads the
110
+ client's initialize capabilities (`io.modelcontextprotocol/ui`) — hosts
111
+ that render MCP Apps get a short text summary plus the widget; everything
112
+ else gets the full picture as aligned plain text. Widget development has
113
+ its own harness — `npm run harness`, open http://localhost:3777 — which
114
+ plays the host role against the real `assets/dashboard.html` and live
115
+ journal data, so widget edits are a reload away instead of a Claude
116
+ Desktop restart.
117
+
118
+ ## Run history without a dashboard
119
+
120
+ Every run also appends to a local journal (`~/.highball/runs/<project>.jsonl`,
121
+ pruned to the last 200) — unconditionally, whether or not reporting is
122
+ configured. `npx @profoundry-us/highball runs` lists recent runs; adding a
123
+ number shows one run's detail with failure output, and `--logs` prints every
124
+ rule's captured output, GitHub-Actions-style — the journal keeps the last
125
+ 10KB per rule, pass or fail, while the dashboard receives failure tails
126
+ only. So the runner is self-sufficient out of the box: the hosted
127
+ dashboard adds team visibility, history beyond your machine, and
128
+ attribution — it's never required to see what happened.
129
+
83
130
  ## Roadmap
84
131
 
85
132
  AI-judged rules (`rubric:` — headless Claude applying a markdown rubric to
@@ -0,0 +1,331 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ :root {
7
+ --bg: #ffffff; --fg: #1a1a2e; --muted: #6b7280; --line: #e5e7eb;
8
+ --card: #f9fafb; --green: #16a34a; --green-bg: #dcfce7;
9
+ --red: #dc2626; --red-bg: #fee2e2; --gray-bg: #f3f4f6;
10
+ --accent: #4f39fa;
11
+ }
12
+ @media (prefers-color-scheme: dark) {
13
+ :root {
14
+ --bg: #111118; --fg: #e5e7eb; --muted: #9ca3af; --line: #2a2a35;
15
+ --card: #1a1a24; --green: #4ade80; --green-bg: #14321f;
16
+ --red: #f87171; --red-bg: #3b1518; --gray-bg: #23232e;
17
+ --accent: #8f85ff;
18
+ }
19
+ }
20
+ * { box-sizing: border-box; }
21
+ body {
22
+ margin: 0; padding: 12px; background: var(--bg); color: var(--fg);
23
+ font: 13px/1.5 -apple-system, "Segoe UI", system-ui, sans-serif;
24
+ }
25
+ .head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
26
+ .head h1 { font-size: 14px; margin: 0; }
27
+ .head .spacer { flex: 1; }
28
+ button {
29
+ font: inherit; font-size: 12px; border: 1px solid var(--line); border-radius: 6px;
30
+ background: var(--card); color: var(--fg); padding: 3px 10px; cursor: pointer;
31
+ }
32
+ button:hover { border-color: var(--accent); }
33
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
34
+ .row {
35
+ display: flex; align-items: center; gap: 10px; padding: 7px 10px;
36
+ border: 1px solid var(--line); border-radius: 8px; background: var(--card);
37
+ margin-bottom: 6px; cursor: pointer;
38
+ }
39
+ .row:hover { border-color: var(--accent); }
40
+ .row .when, .row .meta { color: var(--muted); font-size: 12px; }
41
+ .group {
42
+ color: var(--muted); font-size: 11.5px; font-style: italic;
43
+ margin: 12px 2px 6px; white-space: nowrap; overflow: hidden;
44
+ text-overflow: ellipsis;
45
+ }
46
+ .group:first-child { margin-top: 2px; }
47
+ .row .spacer { flex: 1; }
48
+ .chip {
49
+ font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
50
+ white-space: nowrap;
51
+ }
52
+ .chip.passed { color: var(--green); background: var(--green-bg); }
53
+ .chip.failed { color: var(--red); background: var(--red-bg); }
54
+ .chip.todo { color: var(--muted); background: var(--gray-bg); }
55
+ .mono { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
56
+ details.rule {
57
+ border: 1px solid var(--line); border-radius: 8px; background: var(--card);
58
+ margin-bottom: 6px;
59
+ }
60
+ details.rule > summary {
61
+ display: flex; align-items: center; gap: 10px; padding: 7px 10px;
62
+ cursor: pointer; list-style: none;
63
+ }
64
+ details.rule > summary::-webkit-details-marker { display: none; }
65
+ /* A rule with nothing to reveal (todo: no command ran) is NOT a details —
66
+ it must not advertise a chevron or a pointer cursor it can't honor. */
67
+ .rule.static > .rowline {
68
+ display: flex; align-items: center; gap: 10px; padding: 7px 10px;
69
+ }
70
+ .rule.static {
71
+ border: 1px solid var(--line); border-radius: 8px; background: var(--card);
72
+ margin-bottom: 6px;
73
+ }
74
+ .panel { border-top: 1px solid var(--line); padding: 4px 12px 10px; }
75
+ .panel-label {
76
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
77
+ opacity: 0.55; margin: 8px 0 4px;
78
+ }
79
+ .panel-note { font-size: 12px; opacity: 0.6; margin: 8px 0 2px; }
80
+ details.rule pre {
81
+ margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--gray-bg);
82
+ overflow-x: auto; font-size: 11.5px; line-height: 1.45;
83
+ font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap;
84
+ }
85
+ .empty { color: var(--muted); padding: 18px 6px; text-align: center; }
86
+ .glyph { width: 14px; text-align: center; font-weight: 700; }
87
+ .glyph.passed { color: var(--green); }
88
+ .glyph.failed { color: var(--red); }
89
+ .glyph.todo { color: var(--muted); }
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <div id="app"><div class="empty">Loading Highball runs…</div></div>
94
+ <script>
95
+ (() => {
96
+ // Minimal MCP Apps client: JSON-RPC 2.0 over postMessage to the host.
97
+ // Kept dependency-free on purpose — the protocol surface this widget
98
+ // needs is initialize, tool-result notifications, and tools/call.
99
+ const pending = new Map();
100
+ let nextId = 1;
101
+ const post = (msg) => window.parent.postMessage(msg, "*");
102
+ const request = (method, params) => new Promise((resolve, reject) => {
103
+ const id = nextId++;
104
+ pending.set(id, { resolve, reject });
105
+ post({ jsonrpc: "2.0", id, method, params });
106
+ });
107
+ const notify = (method, params) => post({ jsonrpc: "2.0", method, params });
108
+
109
+ window.addEventListener("message", (event) => {
110
+ const msg = event.data;
111
+ if (!msg || msg.jsonrpc !== "2.0") return;
112
+ if (msg.id !== undefined && msg.method === undefined) {
113
+ const waiter = pending.get(msg.id);
114
+ if (waiter) {
115
+ pending.delete(msg.id);
116
+ msg.error ? waiter.reject(msg.error) : waiter.resolve(msg.result);
117
+ }
118
+ return;
119
+ }
120
+ if (msg.method === "ui/notifications/tool-result") {
121
+ toolInFlight = false;
122
+ const result = msg.params?.result ?? msg.params ?? {};
123
+ if (result.structuredContent) render(result.structuredContent);
124
+ return;
125
+ }
126
+ // The host streams tool-input while the originating tool is still
127
+ // executing (e.g. run_checks taking seconds) — don't let the
128
+ // impatient self-fetch clobber the view; the result is coming.
129
+ if (msg.method === "ui/notifications/tool-input" ||
130
+ msg.method === "ui/notifications/tool-input-partial") {
131
+ toolInFlight = true;
132
+ return;
133
+ }
134
+ if (msg.id !== undefined) {
135
+ post({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "not supported" } });
136
+ }
137
+ });
138
+
139
+ async function callTool(name, args) {
140
+ const result = await request("tools/call", { name, arguments: args || {} });
141
+ if (result?.structuredContent) render(result.structuredContent);
142
+ }
143
+
144
+ // --- rendering ---------------------------------------------------------
145
+
146
+ const app = document.getElementById("app");
147
+ let context = { project: null, dir: null };
148
+ let toolInFlight = false;
149
+
150
+ function esc(text) {
151
+ return String(text ?? "").replace(/[&<>"]/g, (c) =>
152
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
153
+ }
154
+ function timeAgo(iso) {
155
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
156
+ if (s < 60) return "just now";
157
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
158
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
159
+ return `${Math.round(s / 86400)}d ago`;
160
+ }
161
+ function chip(status) {
162
+ return `<span class="chip ${esc(status)}">${esc(status)}</span>`;
163
+ }
164
+ function tally(results) {
165
+ const counts = {};
166
+ for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1;
167
+ return [
168
+ counts.passed ? `${counts.passed}✓` : "",
169
+ counts.failed ? `${counts.failed}✗` : "",
170
+ counts.todo ? `${counts.todo} todo` : ""
171
+ ].filter(Boolean).join(" ");
172
+ }
173
+ function header(title, buttons) {
174
+ return `<div class="head"><h1>${title}</h1><span class="spacer"></span>${buttons}</div>`;
175
+ }
176
+ function rerunButtons() {
177
+ if (!context.dir) return "";
178
+ return `<button data-act="fast">Re-run fast</button>` +
179
+ `<button data-act="full">Full suite</button>`;
180
+ }
181
+
182
+ function render(sc) {
183
+ if (sc.runs && sc.project !== undefined) return renderList(sc);
184
+ if (sc.run) return renderDetail(sc);
185
+ if (sc.projects) return renderPicker(sc.projects);
186
+ }
187
+
188
+ // Nothing resolved server-side: offer the journaled projects as
189
+ // buttons instead of a dead end.
190
+ function renderPicker(projects) {
191
+ if (projects.length === 0) {
192
+ app.innerHTML = `<div class="empty">No runs journaled on this machine yet.</div>`;
193
+ return resize();
194
+ }
195
+ app.innerHTML =
196
+ header("Highball — pick a project", "") +
197
+ `<div class="empty" style="padding:10px 6px">` +
198
+ projects.map((slug) => `<button data-project="${esc(slug)}" style="margin:0 4px">${esc(slug)}</button>`).join("") +
199
+ `</div>`;
200
+ app.querySelectorAll("[data-project]").forEach((el) =>
201
+ el.addEventListener("click", () =>
202
+ callTool("list_runs", { project: el.dataset.project })));
203
+ resize();
204
+ }
205
+
206
+ function renderList(sc) {
207
+ context = { project: sc.project, dir: sc.dir ?? context.dir };
208
+ // Group header whenever the work (or session) changes between
209
+ // adjacent runs — the list is chronological, so contiguous runs with
210
+ // the same prompt are one stretch of work.
211
+ let lastGroup;
212
+ const rows = sc.runs.map((run) => {
213
+ const group = `${run.session ?? ""}·${run.work ?? ""}`;
214
+ const heading = group === lastGroup ? "" :
215
+ `<div class="group">${run.work ? "&ldquo;" + esc(run.work) + "&rdquo;" : "no session context"}</div>`;
216
+ lastGroup = group;
217
+ return heading + `
218
+ <div class="row" data-index="${run.index}">
219
+ <span class="chip ${esc(run.status)}">${run.status === "passed" ? "✓ passed" : "✗ failed"}</span>
220
+ <span class="when">${timeAgo(run.started_at)}</span>
221
+ <span class="meta">${run.trigger === "edit" ? "fast" : "full"}</span>
222
+ <span class="mono">${esc(run.branch || "-")}</span>
223
+ <span class="spacer"></span>
224
+ <span class="meta">${run.duration_ms != null ? (run.duration_ms / 1000).toFixed(1) + "s" : ""}</span>
225
+ <span class="meta">${esc(tally(run.results))}</span>
226
+ </div>`;
227
+ }).join("");
228
+ app.innerHTML =
229
+ header(`Highball · ${esc(sc.project)}`, rerunButtons()) +
230
+ (rows || `<div class="empty">No runs recorded yet.</div>`);
231
+ app.querySelectorAll(".row").forEach((el) =>
232
+ el.addEventListener("click", () =>
233
+ callTool("get_run", { project: context.project, index: Number(el.dataset.index) })));
234
+ wireRerun();
235
+ resize();
236
+ }
237
+
238
+ function renderDetail(sc) {
239
+ context = { project: sc.project ?? context.project, dir: sc.dir ?? context.dir };
240
+ const run = sc.run;
241
+ const rules = run.results.map((result) => {
242
+ const duration = result.duration_ms != null
243
+ ? `${(result.duration_ms / 1000).toFixed(1)}s` : "";
244
+ const row =
245
+ `<span class="glyph ${esc(result.status)}">${result.status === "passed" ? "✓" : result.status === "todo" ? "•" : "✗"}</span>` +
246
+ `<span>${esc(result.name)}</span>` +
247
+ `<span class="spacer"></span>` +
248
+ chip(result.status) +
249
+ `<span class="meta">${duration}</span>`;
250
+
251
+ // What the panel can actually show. Quiet rules (the AI judges print
252
+ // nothing when they pass) still have their command, so every rule that
253
+ // really ran stays worth opening.
254
+ const parts = [];
255
+ if (result.command) {
256
+ parts.push(`<div class="panel-label">Command</div><pre>$ ${esc(result.command)}</pre>`);
257
+ }
258
+ if (result.output_tail) {
259
+ parts.push(`<div class="panel-label">Output</div><pre>${esc(result.output_tail)}</pre>`);
260
+ }
261
+ // Journal records written before commands were recorded: keep real
262
+ // rules openable rather than silently turning them inert.
263
+ if (parts.length === 0 && result.status !== "todo") {
264
+ parts.push(`<div class="panel-note">No output captured for this rule.</div>`);
265
+ }
266
+
267
+ if (parts.length === 0) {
268
+ return `<div class="rule static"><div class="rowline">${row}</div></div>`;
269
+ }
270
+ return `<details class="rule"${result.status === "failed" ? " open" : ""}>` +
271
+ `<summary>${row}</summary><div class="panel">${parts.join("")}</div></details>`;
272
+ }).join("");
273
+ app.innerHTML =
274
+ header(
275
+ `Run #${run.index} · ${esc(context.project)} · ${run.trigger === "edit" ? "fast" : "full"} · ${esc(run.branch || "-")} <span class="meta">${timeAgo(run.started_at)}</span>`,
276
+ `<button data-act="back">← All runs</button>` + rerunButtons()
277
+ ) + rules;
278
+ app.querySelector('[data-act="back"]').addEventListener("click", () =>
279
+ callTool("list_runs", { project: context.project }));
280
+ wireRerun();
281
+ resize();
282
+ }
283
+
284
+ function wireRerun() {
285
+ app.querySelectorAll("[data-act=fast],[data-act=full]").forEach((el) =>
286
+ el.addEventListener("click", async () => {
287
+ el.disabled = true;
288
+ el.textContent = "Running…";
289
+ try {
290
+ await callTool("run_checks", { dir: context.dir, fast: el.dataset.act === "fast" });
291
+ } finally {
292
+ callTool("list_runs", { project: context.project });
293
+ }
294
+ }));
295
+ }
296
+
297
+ function resize() {
298
+ notify("ui/notifications/size-changed", {
299
+ height: Math.min(600, document.documentElement.scrollHeight + 8)
300
+ });
301
+ }
302
+
303
+ // --- boot --------------------------------------------------------------
304
+
305
+ (async () => {
306
+ try {
307
+ await request("ui/initialize", {
308
+ protocolVersion: "2026-01-26",
309
+ appInfo: { name: "highball-dashboard", version: "0.3.0" },
310
+ appCapabilities: {}
311
+ });
312
+ notify("ui/notifications/initialized");
313
+ } catch {
314
+ // Host may predate the handshake shape; the fallback fetch below
315
+ // still gives us data if tools/call works.
316
+ }
317
+ // If the host doesn't push the originating tool result promptly,
318
+ // fetch the list ourselves — makes the widget self-sufficient. But
319
+ // never while a tool is executing (tool-input arrived): the widget
320
+ // may be preloaded seconds before a slow run_checks resolves.
321
+ const selfFetch = (attempt) => {
322
+ if (!app.querySelector(".empty") || attempt > 20) return;
323
+ if (toolInFlight) return setTimeout(() => selfFetch(attempt + 1), 900);
324
+ callTool("list_runs", {}).catch(() => {});
325
+ };
326
+ setTimeout(() => selfFetch(0), 900);
327
+ })();
328
+ })();
329
+ </script>
330
+ </body>
331
+ </html>
package/bin/highball.js CHANGED
@@ -7,6 +7,8 @@ import { run } from "../lib/run.js";
7
7
  import { init } from "../lib/init.js";
8
8
  import { login } from "../lib/login.js";
9
9
  import { onboard } from "../lib/onboard.js";
10
+ import { runs } from "../lib/runs.js";
11
+ import { mcp } from "../lib/mcp.js";
10
12
 
11
13
  const [command, ...args] = process.argv.slice(2);
12
14
 
@@ -24,6 +26,13 @@ Usage:
24
26
  highball onboard Print the setup guide written for this repo's
25
27
  AI agent — tell your agent to run this and
26
28
  follow it.
29
+ highball runs [n] Local run history (newest first) from
30
+ ~/.highball/runs — no dashboard needed.
31
+ With a number, that run's detail; add
32
+ --logs for every rule's captured output.
33
+ highball mcp Serve run history over MCP (stdio), with an
34
+ MCP Apps dashboard widget for hosts that
35
+ render them (e.g. Claude Desktop).
27
36
  `;
28
37
 
29
38
  switch (command) {
@@ -39,6 +48,12 @@ switch (command) {
39
48
  case "onboard":
40
49
  process.exit(await onboard(args));
41
50
  break;
51
+ case "runs":
52
+ process.exit(await runs(args));
53
+ break;
54
+ case "mcp":
55
+ await mcp();
56
+ break;
42
57
  default:
43
58
  console.log(USAGE);
44
59
  process.exit(command === undefined || command === "--help" ? 0 : 1);
package/lib/init.js CHANGED
@@ -36,17 +36,20 @@ checks:
36
36
  # fast: true
37
37
  `;
38
38
 
39
+ // Always the SCOPED command. The unscoped npm name belongs to an unrelated
40
+ // package, so a bare `npx highball` in a committed hook is one uninstalled
41
+ // checkout away from fetching a stranger's code and running it on every edit.
39
42
  const HOOKS_JSON = {
40
43
  hooks: {
41
44
  PostToolUse: [
42
45
  {
43
46
  matcher: "Write|Edit",
44
- hooks: [{ type: "command", command: "npx highball run --fast" }]
47
+ hooks: [{ type: "command", command: "npx @profoundry-us/highball run --fast" }]
45
48
  }
46
49
  ],
47
50
  Stop: [
48
51
  {
49
- hooks: [{ type: "command", command: "npx highball run", timeout: 900 }]
52
+ hooks: [{ type: "command", command: "npx @profoundry-us/highball run", timeout: 900 }]
50
53
  }
51
54
  ]
52
55
  }
package/lib/journal.js ADDED
@@ -0,0 +1,58 @@
1
+ // The local run journal: every run appends one JSONL line to
2
+ // ~/.highball/runs/<project>.jsonl, whether or not remote reporting is
3
+ // configured. This is what makes the runner useful with no dashboard at
4
+ // all — `highball runs` reads it — and it lives outside the repo tree
5
+ // (like credentials) so there's no gitignore to manage and no state to
6
+ // leak into commits.
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ // Per-project cap. At ~1-2KB a line this bounds each file around a
12
+ // couple hundred KB — enough history to be useful, never enough to care
13
+ // about.
14
+ const MAX_RUNS = 200;
15
+
16
+ export function journalDir() {
17
+ return join(homedir(), ".highball", "runs");
18
+ }
19
+
20
+ export function journalPath(project, dir = journalDir()) {
21
+ return join(dir, `${project}.jsonl`);
22
+ }
23
+
24
+ export function appendRun(project, record, dir = journalDir()) {
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ const path = journalPath(project, dir);
27
+ const lines = existsSync(path)
28
+ ? readFileSync(path, "utf8").split("\n").filter(Boolean)
29
+ : [];
30
+ lines.push(JSON.stringify(record));
31
+ writeFileSync(path, lines.slice(-MAX_RUNS).join("\n") + "\n");
32
+ }
33
+
34
+ // Newest first — the order every "recent runs" view wants.
35
+ export function readRuns(project, dir = journalDir()) {
36
+ const path = journalPath(project, dir);
37
+ if (!existsSync(path)) return [];
38
+ return readFileSync(path, "utf8")
39
+ .split("\n")
40
+ .filter(Boolean)
41
+ .map((line) => {
42
+ try {
43
+ return JSON.parse(line);
44
+ } catch {
45
+ return null;
46
+ }
47
+ })
48
+ .filter(Boolean)
49
+ .reverse();
50
+ }
51
+
52
+ export function journaledProjects(dir = journalDir()) {
53
+ if (!existsSync(dir)) return [];
54
+ return readdirSync(dir)
55
+ .filter((name) => name.endsWith(".jsonl"))
56
+ .map((name) => name.slice(0, -".jsonl".length))
57
+ .sort();
58
+ }
package/lib/mcp.js ADDED
@@ -0,0 +1,245 @@
1
+ // `highball mcp` — a stdio MCP server over the local run journal, with
2
+ // an MCP Apps dashboard widget. This is the no-install view layer: hosts
3
+ // that support MCP Apps (Claude Desktop et al.) render the widget inline;
4
+ // every tool also returns meaningful text, per the extension's graceful-
5
+ // degradation rule, so plain MCP hosts lose nothing but the pixels.
6
+ import { readFileSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import {
12
+ getUiCapability, registerAppResource, registerAppTool, RESOURCE_MIME_TYPE
13
+ } from "@modelcontextprotocol/ext-apps/server";
14
+ import { z } from "zod";
15
+ import { loadConfig } from "./config.js";
16
+ import { journaledProjects, readRuns } from "./journal.js";
17
+
18
+ const DASHBOARD_URI = "ui://highball/dashboard.html";
19
+ const BIN_PATH = fileURLToPath(new URL("../bin/highball.js", import.meta.url));
20
+ const VERSION = JSON.parse(
21
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
22
+ ).version;
23
+
24
+ // The server may be launched from a repo (project inferable) or from a
25
+ // host like Claude Desktop whose cwd is nowhere useful. Resolution order:
26
+ // explicit argument, the cwd's checks.yml, then the journal with the
27
+ // newest run — the repo being actively worked in IS the current project.
28
+ // Journal records carry the repo dir (since runs record process.cwd()),
29
+ // so every path out of here can ground the widget's re-run buttons.
30
+ function resolveProject(explicit) {
31
+ if (explicit) return { project: explicit, dir: latestDirFor(explicit) };
32
+ try {
33
+ return { project: loadConfig().project, dir: process.cwd() };
34
+ } catch {
35
+ let current = null;
36
+ for (const project of journaledProjects()) {
37
+ const newest = readRuns(project)[0];
38
+ if (!newest) continue;
39
+ if (!current || newest.started_at > current.started_at) {
40
+ current = { project, started_at: newest.started_at };
41
+ }
42
+ }
43
+ if (!current) return { project: null, dir: null };
44
+ return { project: current.project, dir: latestDirFor(current.project) };
45
+ }
46
+ }
47
+
48
+ // Newest journal record that knows its repo dir (older records predate
49
+ // the field).
50
+ function latestDirFor(project) {
51
+ return readRuns(project).find((run) => run.dir)?.dir ?? null;
52
+ }
53
+
54
+ // List payloads stay lean — output tails ride only on get_run.
55
+ function summarize(run, i) {
56
+ return {
57
+ index: i + 1,
58
+ started_at: run.started_at,
59
+ duration_ms: run.duration_ms,
60
+ trigger: run.trigger,
61
+ branch: run.branch,
62
+ commit: run.commit,
63
+ status: run.status,
64
+ session: run.session ?? null,
65
+ work: run.work ?? null,
66
+ reported_run_id: run.reported_run_id,
67
+ results: (run.results || []).map(({ id, name, status, duration_ms }) =>
68
+ ({ id, name, status, duration_ms }))
69
+ };
70
+ }
71
+
72
+ function reply(text, structuredContent) {
73
+ return { content: [ { type: "text", text } ], structuredContent };
74
+ }
75
+
76
+ // --- text rendering ------------------------------------------------------
77
+ // Two audiences: hosts that advertised the MCP Apps UI capability get a
78
+ // short summary (the widget carries the detail), everyone else gets the
79
+ // full picture as aligned plain text — the extension's graceful-
80
+ // degradation rule made concrete. Exported for tests.
81
+
82
+ const glyphFor = (status) =>
83
+ status === "passed" ? "✓" : status === "todo" ? "•" : "✗";
84
+
85
+ export function listText(project, runs) {
86
+ if (runs.length === 0) return `No runs recorded for ${project} yet.`;
87
+ const rows = runs.map((run) => [
88
+ `#${run.index}`,
89
+ run.status === "passed" ? "✓ passed" : "✗ FAILED",
90
+ run.trigger === "edit" ? "fast" : "full",
91
+ run.branch || "-",
92
+ run.duration_ms != null ? `${(run.duration_ms / 1000).toFixed(1)}s` : "-",
93
+ run.started_at,
94
+ run.results.map((result) => glyphFor(result.status)).join("")
95
+ ]);
96
+ const widths = rows[0].map((_, col) =>
97
+ Math.max(...rows.map((row) => row[col].length)));
98
+ const lines = [ `Runs for ${project} (newest first):` ];
99
+ let lastGroup;
100
+ runs.forEach((run, i) => {
101
+ // Group header whenever the work (or its session) changes between
102
+ // adjacent runs — the journal is chronological, so contiguous runs
103
+ // with the same prompt are one stretch of work.
104
+ const group = `${run.session ?? ""}·${run.work ?? ""}`;
105
+ if (group !== lastGroup) {
106
+ lastGroup = group;
107
+ lines.push(run.work ? `» ${run.work}` : "» (no session context)");
108
+ }
109
+ lines.push(" " + rows[i].map((cell, col) => cell.padEnd(widths[col])).join(" "));
110
+ });
111
+ return lines.join("\n");
112
+ }
113
+
114
+ export function detailText(project, index, run) {
115
+ const took = run.duration_ms != null
116
+ ? ` · took ${(run.duration_ms / 1000).toFixed(1)}s` : "";
117
+ const rules = run.results.map((result) => {
118
+ const duration = result.duration_ms != null
119
+ ? ` (${(result.duration_ms / 1000).toFixed(1)}s)` : "";
120
+ let line = ` ${glyphFor(result.status)} ${result.name} — ${result.status}${duration}`;
121
+ if (result.status === "failed" && result.output_tail) {
122
+ line += "\n" + result.output_tail.split("\n").map((l) => ` ${l}`).join("\n");
123
+ }
124
+ return line;
125
+ }).join("\n");
126
+ return `Run #${index} — ${project} · ${run.trigger === "edit" ? "fast checks" : "full suite"}` +
127
+ ` · ${run.branch || "-"} · ${(run.commit || "").slice(0, 7)} · ${run.status}${took}\n${rules}`;
128
+ }
129
+
130
+ export async function mcp() {
131
+ const server = new McpServer({ name: "highball", version: VERSION });
132
+
133
+ // Did this client advertise MCP Apps support in its initialize
134
+ // capabilities (io.modelcontextprotocol/ui)? Checked at call time —
135
+ // capabilities aren't known yet when tools are registered.
136
+ const uiHost = () => {
137
+ const capability = getUiCapability(server.server.getClientCapabilities());
138
+ return !!capability &&
139
+ (!capability.mimeTypes || capability.mimeTypes.includes(RESOURCE_MIME_TYPE));
140
+ };
141
+
142
+ registerAppTool(server, "list_runs", {
143
+ title: "Highball runs",
144
+ description:
145
+ "Recent Highball check runs for a project, from the machine-local " +
146
+ "journal (~/.highball/runs). Renders the runs dashboard widget.",
147
+ inputSchema: {
148
+ project: z.string().optional()
149
+ .describe("Project slug; defaults to the current repo's project")
150
+ },
151
+ _meta: { ui: { resourceUri: DASHBOARD_URI } }
152
+ }, async ({ project: explicit }) => {
153
+ const { project, dir } = resolveProject(explicit);
154
+ if (!project) {
155
+ const known = journaledProjects();
156
+ return reply(
157
+ `No project resolved. Journaled projects: ${known.join(", ") || "(none)"}`,
158
+ { projects: known }
159
+ );
160
+ }
161
+ const runs = readRuns(project).map(summarize);
162
+ return reply(
163
+ uiHost()
164
+ ? `${runs.length} runs for ${project} — rendered in the dashboard widget.`
165
+ : listText(project, runs.slice(0, 20)),
166
+ { project, dir, runs }
167
+ );
168
+ });
169
+
170
+ registerAppTool(server, "get_run", {
171
+ title: "Highball run detail",
172
+ description:
173
+ "One Highball run's full detail — per-rule statuses, durations, and " +
174
+ "captured command output. index counts from 1, newest first.",
175
+ inputSchema: {
176
+ index: z.number().int().min(1).describe("1-based index, newest first"),
177
+ project: z.string().optional()
178
+ .describe("Project slug; defaults to the current repo's project")
179
+ },
180
+ _meta: { ui: { resourceUri: DASHBOARD_URI } }
181
+ }, async ({ index, project: explicit }) => {
182
+ const { project, dir } = resolveProject(explicit);
183
+ if (!project) return reply("No project resolved.", {});
184
+ const history = readRuns(project);
185
+ const run = history[index - 1];
186
+ if (!run) return reply(`No run #${index} (${history.length} recorded).`, {});
187
+ return reply(
188
+ uiHost()
189
+ ? `Run #${index} for ${project}: ${run.status} — rendered in the dashboard widget.`
190
+ : detailText(project, index, run),
191
+ { project, dir, run: { ...summarize(run, index - 1), results: run.results } }
192
+ );
193
+ });
194
+
195
+ registerAppTool(server, "run_checks", {
196
+ title: "Run Highball checks",
197
+ description:
198
+ "Execute a repo's Highball checks (fast rules or the full suite). " +
199
+ "Blocks until done; the run lands in the journal and, when reporting " +
200
+ "is configured, on the dashboard.",
201
+ inputSchema: {
202
+ dir: z.string().optional()
203
+ .describe("Repo root containing .highball/checks.yml; defaults to cwd"),
204
+ fast: z.boolean().optional().describe("Only rules marked fast: true")
205
+ },
206
+ _meta: { ui: { resourceUri: DASHBOARD_URI } }
207
+ }, async ({ dir, fast }) => {
208
+ // No dir given → the current project's repo (from its journal), so
209
+ // widget-initiated re-runs work from hosts with no useful cwd.
210
+ const cwd = dir || resolveProject(null).dir || process.cwd();
211
+ const child = spawnSync(
212
+ process.execPath,
213
+ [ BIN_PATH, "run", ...(fast ? [ "--fast" ] : []) ],
214
+ { cwd, encoding: "utf8", timeout: 900_000, env: { ...process.env, NO_COLOR: "1" } }
215
+ );
216
+ const output = `${child.stdout || ""}${child.stderr || ""}`.slice(-4000);
217
+ let project = null;
218
+ let latest = null;
219
+ try {
220
+ project = loadConfig(cwd).project;
221
+ latest = readRuns(project)[0] || null;
222
+ } catch {
223
+ // No checks.yml at cwd — the child's own error output says so.
224
+ }
225
+ return reply(
226
+ `exit ${child.status}\n${output}`,
227
+ {
228
+ project,
229
+ dir: cwd,
230
+ exitCode: child.status,
231
+ run: latest ? { ...summarize(latest, 0), results: latest.results } : null
232
+ }
233
+ );
234
+ });
235
+
236
+ registerAppResource(server, "Highball Dashboard", DASHBOARD_URI, {}, async () => ({
237
+ contents: [ {
238
+ uri: DASHBOARD_URI,
239
+ mimeType: RESOURCE_MIME_TYPE,
240
+ text: readFileSync(new URL("../assets/dashboard.html", import.meta.url), "utf8")
241
+ } ]
242
+ }));
243
+
244
+ await server.connect(new StdioServerTransport());
245
+ }
package/lib/report.js CHANGED
@@ -5,7 +5,9 @@
5
5
  import { execSync } from "node:child_process";
6
6
  import { hostname } from "node:os";
7
7
 
8
- export async function report({ url, token, rules, results, hook, fastOnly, startedAt }) {
8
+ // Returns the server's run id on success, null when reporting was
9
+ // skipped or failed — the caller journals it either way.
10
+ export async function report({ url, token, rules, results, hook, fastOnly, startedAt, branch, commitSha }) {
9
11
  try {
10
12
  const base = new URL(url);
11
13
  const request = async (method, path, body) => {
@@ -29,8 +31,8 @@ export async function report({ url, token, rules, results, hook, fastOnly, start
29
31
  session_key:
30
32
  hook.session_id || process.env.HIGHBALL_SESSION_KEY || `manual-${hostname()}`,
31
33
  trigger: fastOnly ? "edit" : "stop",
32
- branch: git("git branch --show-current"),
33
- commit_sha: git("git rev-parse HEAD"),
34
+ branch,
35
+ commit_sha: commitSha,
34
36
  rules_snapshot: rules,
35
37
  started_at: startedAt.toISOString()
36
38
  });
@@ -57,12 +59,14 @@ export async function report({ url, token, rules, results, hook, fastOnly, start
57
59
  status: results.every((result) => result.passed) ? "passed" : "failed"
58
60
  });
59
61
  console.log(`reported to ${base.host} (run ${runId})`);
62
+ return runId;
60
63
  } catch (error) {
61
64
  console.error(`highball reporting skipped: ${error.message}`);
65
+ return null;
62
66
  }
63
67
  }
64
68
 
65
- function git(command) {
69
+ export function git(command) {
66
70
  try {
67
71
  return execSync(`${command} 2>/dev/null`, { encoding: "utf8" }).trim();
68
72
  } catch {
package/lib/run.js CHANGED
@@ -5,7 +5,9 @@
5
5
  // dashboard must never block the agent.
6
6
  import { execSync, spawnSync } from "node:child_process";
7
7
  import { loadConfig, resolveReporting, commandFor } from "./config.js";
8
- import { report } from "./report.js";
8
+ import { appendRun } from "./journal.js";
9
+ import { git, report } from "./report.js";
10
+ import { latestUserPrompt } from "./transcript.js";
9
11
 
10
12
  export async function run(args) {
11
13
  // When an AI-judged rule spawns a judge session inside this repo, the
@@ -66,10 +68,58 @@ export async function run(args) {
66
68
  }
67
69
 
68
70
  const failures = results.filter((result) => !result.passed);
71
+ const durationMs = Date.now() - startedAt.getTime();
72
+ const branch = git("git branch --show-current");
73
+ const commitSha = git("git rev-parse HEAD");
69
74
 
70
75
  const { url, token } = resolveReporting(config);
76
+ let reportedRunId = null;
71
77
  if (url && token) {
72
- await report({ url, token, project: config.project, rules, results, hook, fastOnly, startedAt });
78
+ reportedRunId = await report({
79
+ url, token, rules, results, hook, fastOnly, startedAt, branch, commitSha
80
+ });
81
+ }
82
+
83
+ // The local journal is unconditional — `highball runs` works with no
84
+ // dashboard configured at all. Journal failures never fail the checks,
85
+ // same policy as reporting.
86
+ try {
87
+ appendRun(config.project, {
88
+ started_at: startedAt.toISOString(),
89
+ // The repo this run happened in — how the MCP server later resolves
90
+ // "the current project" and grounds the widget's re-run buttons.
91
+ dir: process.cwd(),
92
+ // Which agent session, and what it was working on — the grouping
93
+ // key and label for run history views. The hook payload points at
94
+ // the session transcript; its last real user prompt is the work.
95
+ session: hook.session_id || null,
96
+ work: latestUserPrompt(hook.transcript_path),
97
+ duration_ms: durationMs,
98
+ trigger: fastOnly ? "edit" : "stop",
99
+ branch,
100
+ commit: commitSha,
101
+ status: failures.length === 0 ? "passed" : "failed",
102
+ reported_run_id: reportedRunId,
103
+ results: results.map((result) => ({
104
+ id: result.rule.id,
105
+ name: result.rule.name,
106
+ status: result.todo ? "todo" : result.passed ? "passed" : "failed",
107
+ duration_ms: result.durationMs,
108
+ // The command that produced this result. Quiet rules journal no
109
+ // output at all (the AI judges print nothing when they pass), which
110
+ // left viewers with an expandable row wrapping an empty panel; the
111
+ // command is the one detail every real rule can always show. todo
112
+ // rules have no command — nothing ran — which is exactly what makes
113
+ // them inert rather than falsely clickable.
114
+ command: result.rule.run ?? null,
115
+ // Unlike the dashboard (failure tails only), the journal keeps
116
+ // every rule's output GitHub-Actions-style — it's the user's own
117
+ // disk, and `highball runs <n> --logs` is the payoff.
118
+ output_tail: result.output ? result.output.slice(-10_000) : null
119
+ }))
120
+ });
121
+ } catch (error) {
122
+ console.error(`highball journal skipped: ${error.message}`);
73
123
  }
74
124
 
75
125
  if (failures.length === 0) return 0;
@@ -105,8 +155,10 @@ async function readHookPayload() {
105
155
  // every rule invocation fail; scripts fall back to their own git.
106
156
  function changedFiles() {
107
157
  try {
108
- const tracked = execSync("git diff --name-only HEAD", { encoding: "utf8" });
109
- const untracked = execSync("git ls-files --others --exclude-standard", {
158
+ const tracked = execSync("git diff --name-only HEAD 2>/dev/null", {
159
+ encoding: "utf8"
160
+ });
161
+ const untracked = execSync("git ls-files --others --exclude-standard 2>/dev/null", {
110
162
  encoding: "utf8"
111
163
  });
112
164
  const list = `${tracked}\n${untracked}`
package/lib/runs.js ADDED
@@ -0,0 +1,156 @@
1
+ // `highball runs [n] [--logs]` — the no-dashboard view of run history,
2
+ // read from the local journal. Bare: a table of recent runs, newest
3
+ // first. With a number: that run's detail — failure output by default,
4
+ // every rule's captured output with --logs.
5
+ import { loadConfig } from "./config.js";
6
+ import { readRuns, journaledProjects } from "./journal.js";
7
+
8
+ // TTY-gated ANSI (NO_COLOR respected, FORCE_COLOR overrides) — hook
9
+ // shells and pipes get plain text.
10
+ const COLORS_ON = process.env.FORCE_COLOR
11
+ ? true
12
+ : Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
13
+ const paint = (code, text) => (COLORS_ON ? `\x1b[${code}m${text}\x1b[0m` : text);
14
+ const green = (text) => paint("32", text);
15
+ const red = (text) => paint("31;1", text);
16
+ const dim = (text) => paint("2", text);
17
+
18
+ // Column math must use what the eye sees, not what the terminal parses.
19
+ const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "");
20
+ const padEnd = (text, width) => text + " ".repeat(Math.max(0, width - visible(text).length));
21
+ const padStart = (text, width) => " ".repeat(Math.max(0, width - visible(text).length)) + text;
22
+
23
+ export async function runs(args) {
24
+ let project;
25
+ try {
26
+ project = loadConfig().project;
27
+ } catch {
28
+ const known = journaledProjects();
29
+ console.error(
30
+ "highball: not inside a configured repo (no .highball/checks.yml)."
31
+ );
32
+ if (known.length > 0) {
33
+ console.error(`Projects with local history: ${known.join(", ")}`);
34
+ }
35
+ return 1;
36
+ }
37
+
38
+ const history = readRuns(project);
39
+ if (history.length === 0) {
40
+ console.log(`No local runs recorded for ${project} yet — run \`highball run\`.`);
41
+ return 0;
42
+ }
43
+
44
+ const index = args.find((arg) => /^\d+$/.test(arg));
45
+ return index
46
+ ? detail(project, history, Number(index), args.includes("--logs"))
47
+ : list(project, history);
48
+ }
49
+
50
+ // Shared column formatter: widths from visible lengths, chosen columns
51
+ // right-aligned so numbers and ✓/✗ line up whatever the text does.
52
+ function formatRows(rows, rightAligned) {
53
+ const widths = rows[0].map((_, col) =>
54
+ Math.max(...rows.map((row) => visible(row[col]).length))
55
+ );
56
+ return rows.map((row) =>
57
+ " " + row
58
+ .map((cell, col) =>
59
+ rightAligned.has(col) ? padStart(cell, widths[col]) : padEnd(cell, widths[col])
60
+ )
61
+ .join(" ")
62
+ );
63
+ }
64
+
65
+ function list(project, history) {
66
+ console.log(`Recent runs for ${project} (newest first):\n`);
67
+ const rows = history.map((run, i) => [
68
+ dim(`#${i + 1}`),
69
+ timeAgo(run.started_at),
70
+ run.trigger === "edit" ? "fast" : "full",
71
+ run.branch || "-",
72
+ dim(run.duration_ms != null ? `${(run.duration_ms / 1000).toFixed(1)}s` : "-"),
73
+ run.status === "passed" ? green("✓ passed") : red("✗ FAILED"),
74
+ tally(run.results)
75
+ ]);
76
+ const lines = formatRows(rows, new Set([ 4, 5, 6 ]));
77
+ // Group header whenever the work (or session) changes between adjacent
78
+ // runs — contiguous runs with the same prompt are one stretch of work.
79
+ let lastGroup;
80
+ history.forEach((run, i) => {
81
+ const group = `${run.session ?? ""}·${run.work ?? ""}`;
82
+ if (group !== lastGroup) {
83
+ lastGroup = group;
84
+ if (i > 0) console.log("");
85
+ console.log(dim(run.work ? `» ${run.work}` : "» (no session context)"));
86
+ }
87
+ console.log(lines[i]);
88
+ });
89
+ console.log(`\nDetail: highball runs <number> [--logs]`);
90
+ return 0;
91
+ }
92
+
93
+ function detail(project, history, number, showLogs) {
94
+ const run = history[number - 1];
95
+ if (!run) {
96
+ console.error(`highball: no run #${number} (${history.length} recorded).`);
97
+ return 1;
98
+ }
99
+
100
+ const took = run.duration_ms != null ? ` · took ${(run.duration_ms / 1000).toFixed(1)}s` : "";
101
+ const status = run.status === "passed" ? green("passed") : red("FAILED");
102
+ console.log(
103
+ `Run #${number} — ${project} · ${run.trigger === "edit" ? "fast checks" : "full suite"}` +
104
+ ` · ${run.branch || "-"} · ${(run.commit || "").slice(0, 7)}` +
105
+ ` · ${timeAgo(run.started_at)} · ${status}${dim(took)}`
106
+ );
107
+ if (run.reported_run_id) console.log(dim(`reported as run ${run.reported_run_id}`));
108
+ console.log("");
109
+
110
+ // Same table treatment as the list view: rule rows aligned in
111
+ // columns, output blocks interleaved beneath their rule's row.
112
+ const rows = run.results.map((result) => [
113
+ result.status === "passed" ? green("✓") : result.status === "todo" ? dim("•") : red("✗"),
114
+ result.status === "failed" ? red(result.name) : result.name,
115
+ result.status === "passed" ? green(result.status) : result.status === "todo" ? dim(result.status) : red(result.status.toUpperCase()),
116
+ result.duration_ms != null ? dim(`${(result.duration_ms / 1000).toFixed(1)}s`) : ""
117
+ ]);
118
+ const lines = formatRows(rows, new Set([ 2, 3 ]));
119
+ run.results.forEach((result, i) => {
120
+ console.log(lines[i]);
121
+ const wantOutput = result.status === "failed" || showLogs;
122
+ if (wantOutput && result.output_tail) {
123
+ console.log(indent(result.output_tail));
124
+ } else if (showLogs && !result.output_tail) {
125
+ console.log(indent(dim("(no output captured)")));
126
+ }
127
+ });
128
+ if (!showLogs) console.log(dim(`\nAll captured output: highball runs ${number} --logs`));
129
+ return 0;
130
+ }
131
+
132
+ function indent(text) {
133
+ return text
134
+ .replace(/\n$/, "")
135
+ .split("\n")
136
+ .map((line) => ` ${line}`)
137
+ .join("\n");
138
+ }
139
+
140
+ function tally(results) {
141
+ const counts = { passed: 0, failed: 0, todo: 0 };
142
+ for (const result of results) counts[result.status] = (counts[result.status] || 0) + 1;
143
+ return [
144
+ counts.passed ? green(`${counts.passed}✓`) : null,
145
+ counts.failed ? red(`${counts.failed}✗`) : null,
146
+ counts.todo ? dim(`${counts.todo} todo`) : null
147
+ ].filter(Boolean).join(" ");
148
+ }
149
+
150
+ function timeAgo(iso) {
151
+ const seconds = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
152
+ if (seconds < 60) return "just now";
153
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
154
+ if (seconds < 86_400) return `${Math.round(seconds / 3600)}h ago`;
155
+ return `${Math.round(seconds / 86_400)}d ago`;
156
+ }
@@ -0,0 +1,60 @@
1
+ // Extracts "the work being done" from a Claude Code session transcript:
2
+ // the last real user prompt. Hooks hand the runner transcript_path on
3
+ // stdin, and the message that started the current turn is the best
4
+ // zero-cost description of why these checks are running — no AI
5
+ // summarization, no config, just the tail of a JSONL file.
6
+ import { existsSync, readFileSync } from "node:fs";
7
+
8
+ const MAX_LENGTH = 120;
9
+
10
+ export function latestUserPrompt(transcriptPath) {
11
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
12
+ let lines;
13
+ try {
14
+ lines = readFileSync(transcriptPath, "utf8").split("\n");
15
+ } catch {
16
+ return null;
17
+ }
18
+
19
+ for (let i = lines.length - 1; i >= 0; i--) {
20
+ if (!lines[i].trim()) continue;
21
+ let entry;
22
+ try {
23
+ entry = JSON.parse(lines[i]);
24
+ } catch {
25
+ continue;
26
+ }
27
+ if (entry.type !== "user" || entry.isMeta) continue;
28
+ const text = messageText(entry.message);
29
+ if (text) return truncate(text);
30
+ }
31
+ return null;
32
+ }
33
+
34
+ // A "user" line is only a prompt when it carries actual text — tool
35
+ // results and command wrappers ride the user role too and must not
36
+ // become work descriptions.
37
+ function messageText(message) {
38
+ if (!message) return null;
39
+ const { content } = message;
40
+ let text = null;
41
+ if (typeof content === "string") text = content;
42
+ else if (Array.isArray(content)) {
43
+ text = content
44
+ .filter((item) => item.type === "text" && typeof item.text === "string")
45
+ .map((item) => item.text)
46
+ .join(" ");
47
+ }
48
+ if (!text) return null;
49
+ text = text.trim();
50
+ if (!text || text.startsWith("<")) return null;
51
+ if (text.startsWith("[Request interrupted")) return null;
52
+ return text;
53
+ }
54
+
55
+ function truncate(text) {
56
+ const collapsed = text.replace(/\s+/g, " ").trim();
57
+ return collapsed.length > MAX_LENGTH
58
+ ? collapsed.slice(0, MAX_LENGTH - 1) + "…"
59
+ : collapsed;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profoundry-us/highball",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Highball runner — local CI for AI coding agents: runs a repo's .highball/checks.yml rules, blocks the agent on failure, and reports runs to a Highball dashboard.",
5
5
  "keywords": [
6
6
  "ai",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "bin",
27
27
  "lib",
28
+ "assets",
28
29
  "README.md",
29
30
  "ONBOARDING.md"
30
31
  ],
@@ -32,9 +33,12 @@
32
33
  "node": ">=18"
33
34
  },
34
35
  "scripts": {
35
- "test": "node --test"
36
+ "test": "node --test",
37
+ "harness": "node dev/harness/serve.js"
36
38
  },
37
39
  "dependencies": {
40
+ "@modelcontextprotocol/ext-apps": "^1.7.5",
41
+ "@modelcontextprotocol/sdk": "^1.30.0",
38
42
  "yaml": "^2.5.0"
39
43
  },
40
44
  "license": "MIT"