@profoundry-us/highball 0.1.0 → 0.3.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/ONBOARDING.md CHANGED
@@ -21,6 +21,13 @@ 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:
package/README.md CHANGED
@@ -13,6 +13,12 @@ block, they just aren't recorded.
13
13
 
14
14
  Published releases: `npm install --save-dev @profoundry-us/highball`.
15
15
 
16
+ Install before running anything: with the package in `node_modules`, the
17
+ short `npx highball …` form resolves to this runner's binary. Without it,
18
+ bare `npx highball` would fetch the unrelated unscoped `highball` package
19
+ from the registry — for uninstalled one-offs, always use the scoped form
20
+ (`npx @profoundry-us/highball <command>`).
21
+
16
22
  From a local tarball (pre-release):
17
23
 
18
24
  ```bash
@@ -80,6 +86,40 @@ The runner computes the branch's changed-file list once (it owns git) and
80
86
  hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
81
87
  analyzers and need no git in their execution context.
82
88
 
89
+ ## The MCP dashboard widget
90
+
91
+ `highball mcp` serves the journal over MCP (stdio) with three tools —
92
+ `list_runs`, `get_run`, `run_checks` — and an
93
+ [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) widget:
94
+ in hosts that render Apps (Claude Desktop and friends), asking about your
95
+ checks produces an interactive inline dashboard — click a run for per-rule
96
+ detail with expandable command output, re-run fast or full checks from a
97
+ button. In hosts without Apps support the same tools answer in plain text,
98
+ per the extension's graceful-degradation rule. Register it as
99
+ `command: npx`, `args: ["highball", "mcp"]` (or absolute paths for hosts
100
+ that spawn outside your shell PATH).
101
+
102
+ The split is capability-driven, not guesswork: the server reads the
103
+ client's initialize capabilities (`io.modelcontextprotocol/ui`) — hosts
104
+ that render MCP Apps get a short text summary plus the widget; everything
105
+ else gets the full picture as aligned plain text. Widget development has
106
+ its own harness — `npm run harness`, open http://localhost:3777 — which
107
+ plays the host role against the real `assets/dashboard.html` and live
108
+ journal data, so widget edits are a reload away instead of a Claude
109
+ Desktop restart.
110
+
111
+ ## Run history without a dashboard
112
+
113
+ Every run also appends to a local journal (`~/.highball/runs/<project>.jsonl`,
114
+ pruned to the last 200) — unconditionally, whether or not reporting is
115
+ configured. `npx highball runs` lists recent runs; `npx highball runs 3`
116
+ shows one run's detail with failure output, and `--logs` prints every
117
+ rule's captured output, GitHub-Actions-style — the journal keeps the last
118
+ 10KB per rule, pass or fail, while the dashboard receives failure tails
119
+ only. So the runner is self-sufficient out of the box: the hosted
120
+ dashboard adds team visibility, history beyond your machine, and
121
+ attribution — it's never required to see what happened.
122
+
83
123
  ## Roadmap
84
124
 
85
125
  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/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.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",
@@ -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"