@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 +7 -0
- package/README.md +40 -0
- package/assets/dashboard.html +331 -0
- package/bin/highball.js +15 -0
- package/lib/journal.js +58 -0
- package/lib/mcp.js +245 -0
- package/lib/report.js +8 -4
- package/lib/run.js +56 -4
- package/lib/runs.js +156 -0
- package/lib/transcript.js +60 -0
- package/package.json +6 -2
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[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 ? "“" + esc(run.work) + "”" : "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
|
-
|
|
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
|
|
33
|
-
commit_sha:
|
|
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 {
|
|
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({
|
|
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", {
|
|
109
|
-
|
|
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.
|
|
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"
|