@profoundry-us/highball 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ONBOARDING.md +55 -10
- package/README.md +50 -3
- package/assets/dashboard.html +331 -0
- package/bin/highball.js +15 -0
- package/lib/init.js +5 -2
- 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
|
@@ -14,13 +14,20 @@ yourself.
|
|
|
14
14
|
|
|
15
15
|
## 0. Preconditions
|
|
16
16
|
|
|
17
|
-
`npx highball --help` must work (Node >= 18, package installed as a dev
|
|
17
|
+
`npx @profoundry-us/highball --help` must work (Node >= 18, package installed as a dev
|
|
18
18
|
dependency). If it doesn't, ask your human whether to install from the npm
|
|
19
19
|
registry (`npm install --save-dev @profoundry-us/highball`) or from a
|
|
20
20
|
local tarball path they provide. In a repo with no `package.json`, create
|
|
21
21
|
a minimal private one first (`{ "name": "<repo>", "private": true }`) and
|
|
22
22
|
gitignore `node_modules/` if it isn't already.
|
|
23
23
|
|
|
24
|
+
**Never run bare `npx highball` where the package is NOT installed**: the
|
|
25
|
+
unscoped npm name `highball` belongs to an unrelated package, and npx
|
|
26
|
+
would fetch that instead of this runner. Installed locally, the bare name
|
|
27
|
+
is safe (npx resolves `node_modules/.bin` first — that's why install is
|
|
28
|
+
step 0); for a one-off without installing, use the scoped form,
|
|
29
|
+
`npx @profoundry-us/highball <command>`.
|
|
30
|
+
|
|
24
31
|
## 1. Survey the repo before writing anything
|
|
25
32
|
|
|
26
33
|
Answer these by reading, not assuming:
|
|
@@ -29,19 +36,57 @@ Answer these by reading, not assuming:
|
|
|
29
36
|
repo *already trust* — look at `package.json` scripts, a `justfile` or
|
|
30
37
|
`Makefile`, CI workflows, README instructions. Wire what exists; invent
|
|
31
38
|
no new tooling in the first pass.
|
|
32
|
-
- **Host or container?**
|
|
33
|
-
|
|
34
|
-
and
|
|
35
|
-
|
|
36
|
-
know what paths they see.
|
|
39
|
+
- **Host or container?** Settle this before writing a single rule — see the
|
|
40
|
+
callout immediately below. Getting it wrong makes every rule fail for the
|
|
41
|
+
same uninteresting reason, and it is the most common way this setup
|
|
42
|
+
stalls.
|
|
37
43
|
- **What's fast?** Time candidate commands. Only sub-~2s commands belong
|
|
38
44
|
on the per-edit path; test suites belong at turn end; anything needing a
|
|
39
45
|
live server or long setup should not gate turns at all (leave it to the
|
|
40
46
|
repo's existing workflow, or declare it `todo`).
|
|
41
47
|
|
|
48
|
+
### If the repo's toolchain lives in Docker
|
|
49
|
+
|
|
50
|
+
Plenty of repos run *everything* through containers — the host may have no
|
|
51
|
+
Ruby, no Python, no database at all. Highball handles this, but only if you
|
|
52
|
+
declare it. **The runner itself always stays on the host** (that's where the
|
|
53
|
+
hooks fire and where the journal lives); only the rule commands move.
|
|
54
|
+
|
|
55
|
+
Find the dev service and confirm what it actually sees, rather than assuming
|
|
56
|
+
the layout:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
docker compose ps --services
|
|
60
|
+
docker compose exec -T <service> sh -c 'pwd && ls'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then declare the wrapper once, and every rule runs through it:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
exec:
|
|
67
|
+
via: docker compose exec -T --workdir /app app
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Four traps, each of which has bitten a real onboarding:
|
|
71
|
+
|
|
72
|
+
1. **`-T` is mandatory.** Hook shells have no TTY; without it commands hang
|
|
73
|
+
or die with "the input device is not a TTY".
|
|
74
|
+
2. **Set `--workdir`** to wherever the repo is mounted (verify with `pwd`
|
|
75
|
+
above). Containers frequently start somewhere other than the mount root.
|
|
76
|
+
3. **Self-orchestrating commands must opt out with `exec: host`.** A
|
|
77
|
+
`just test` / `make test` target that runs its *own* `docker compose
|
|
78
|
+
exec` would otherwise be double-wrapped into nonsense.
|
|
79
|
+
4. **Host-only tools opt out too.** If a linter or parser exists on the host
|
|
80
|
+
but not in the image (`node --check` against a JS bundle, say), mark that
|
|
81
|
+
rule `exec: host`.
|
|
82
|
+
|
|
83
|
+
A stopped container makes every wrapped rule fail. That's correct behavior —
|
|
84
|
+
unverifiable is not passing — but say so plainly to your human rather than
|
|
85
|
+
quietly dropping the rules.
|
|
86
|
+
|
|
42
87
|
## 2. Scaffold
|
|
43
88
|
|
|
44
|
-
Run `npx highball init`. It never overwrites: an existing
|
|
89
|
+
Run `npx @profoundry-us/highball init`. It never overwrites: an existing
|
|
45
90
|
`.highball/checks.yml` is kept, and if `.claude/settings.json` already
|
|
46
91
|
exists it prints the hook snippet for you to merge by hand — merge it
|
|
47
92
|
without disturbing existing hooks. Otherwise it creates both files.
|
|
@@ -103,7 +148,7 @@ Decision rules:
|
|
|
103
148
|
You must never see, type, or store a token value. Ask your human to:
|
|
104
149
|
|
|
105
150
|
1. Create this project (and a token for it) in their Highball app.
|
|
106
|
-
2. Run `npx highball login` themselves — interactively, or piping the
|
|
151
|
+
2. Run `npx @profoundry-us/highball login` themselves — interactively, or piping the
|
|
107
152
|
token via `--token-stdin` to keep it out of shell history.
|
|
108
153
|
|
|
109
154
|
This stores the token in `~/.highball/credentials.json` (machine-local,
|
|
@@ -112,8 +157,8 @@ vars instead. The repo tree never contains a secret.
|
|
|
112
157
|
|
|
113
158
|
## 5. Verify — all four proofs, not just the happy path
|
|
114
159
|
|
|
115
|
-
1. **Fast path:** `npx highball run --fast` exits 0, every rule passed.
|
|
116
|
-
2. **Full path:** `npx highball run` exits 0 (or fails honestly on real
|
|
160
|
+
1. **Fast path:** `npx @profoundry-us/highball run --fast` exits 0, every rule passed.
|
|
161
|
+
2. **Full path:** `npx @profoundry-us/highball run` exits 0 (or fails honestly on real
|
|
117
162
|
pre-existing issues — surface those to your human rather than papering
|
|
118
163
|
over them).
|
|
119
164
|
3. **The guardrail:** prove exit 2 works. Create an obviously-temporary
|
package/README.md
CHANGED
|
@@ -13,6 +13,11 @@ block, they just aren't recorded.
|
|
|
13
13
|
|
|
14
14
|
Published releases: `npm install --save-dev @profoundry-us/highball`.
|
|
15
15
|
|
|
16
|
+
**Always use the scoped name.** The unscoped npm name `highball` belongs to
|
|
17
|
+
an unrelated package, so a bare `npx highball` — in a committed hook, a
|
|
18
|
+
README, or a one-off — is a single uninstalled checkout away from fetching
|
|
19
|
+
a stranger's code and running it.
|
|
20
|
+
|
|
16
21
|
From a local tarball (pre-release):
|
|
17
22
|
|
|
18
23
|
```bash
|
|
@@ -25,7 +30,7 @@ npm install --save-dev ../highball-runner/profoundry-us-highball-<v>.tgz
|
|
|
25
30
|
Highball is installed *by the AI agent that will be checked by it*. After
|
|
26
31
|
installing the package, tell the repo's Claude Code agent:
|
|
27
32
|
|
|
28
|
-
> Run `npx highball onboard` and follow the instructions.
|
|
33
|
+
> Run `npx @profoundry-us/highball onboard` and follow the instructions.
|
|
29
34
|
|
|
30
35
|
[ONBOARDING.md](ONBOARDING.md) (which that command prints) walks the agent
|
|
31
36
|
through surveying the repo's real toolchain, scaffolding, writing rules that
|
|
@@ -35,8 +40,8 @@ human, and verifying all four proofs — including that exit 2 actually blocks.
|
|
|
35
40
|
The pieces, for reference or manual setup:
|
|
36
41
|
|
|
37
42
|
```bash
|
|
38
|
-
npx highball init # scaffolds
|
|
39
|
-
npx highball login # stores
|
|
43
|
+
npx @profoundry-us/highball init # scaffolds checks.yml + Claude Code hooks
|
|
44
|
+
npx @profoundry-us/highball login # stores a project token (once per machine)
|
|
40
45
|
```
|
|
41
46
|
|
|
42
47
|
`init` never overwrites an existing `checks.yml` and never edits an existing
|
|
@@ -80,6 +85,48 @@ The runner computes the branch's changed-file list once (it owns git) and
|
|
|
80
85
|
hands it to every rule via `HIGHBALL_CHANGED_FILES` — check scripts stay pure
|
|
81
86
|
analyzers and need no git in their execution context.
|
|
82
87
|
|
|
88
|
+
## The MCP dashboard widget
|
|
89
|
+
|
|
90
|
+
`highball mcp` serves the journal over MCP (stdio) with three tools —
|
|
91
|
+
`list_runs`, `get_run`, `run_checks` — and an
|
|
92
|
+
[MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) widget:
|
|
93
|
+
in hosts that render Apps (Claude Desktop and friends), asking about your
|
|
94
|
+
checks produces an interactive inline dashboard — click a run for per-rule
|
|
95
|
+
detail with expandable command output, re-run fast or full checks from a
|
|
96
|
+
button. In hosts without Apps support the same tools answer in plain text,
|
|
97
|
+
per the extension's graceful-degradation rule. Register it with the scoped
|
|
98
|
+
name — hosts spawn the server from an arbitrary directory, so it resolves
|
|
99
|
+
from the registry rather than a local install:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
"highball": { "command": "npx", "args": ["-y", "@profoundry-us/highball", "mcp"] }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The journal it reads is machine-global (`~/.highball/runs/`), so one
|
|
106
|
+
registration covers every repo on that machine — there is no per-repo MCP
|
|
107
|
+
setup.
|
|
108
|
+
|
|
109
|
+
The split is capability-driven, not guesswork: the server reads the
|
|
110
|
+
client's initialize capabilities (`io.modelcontextprotocol/ui`) — hosts
|
|
111
|
+
that render MCP Apps get a short text summary plus the widget; everything
|
|
112
|
+
else gets the full picture as aligned plain text. Widget development has
|
|
113
|
+
its own harness — `npm run harness`, open http://localhost:3777 — which
|
|
114
|
+
plays the host role against the real `assets/dashboard.html` and live
|
|
115
|
+
journal data, so widget edits are a reload away instead of a Claude
|
|
116
|
+
Desktop restart.
|
|
117
|
+
|
|
118
|
+
## Run history without a dashboard
|
|
119
|
+
|
|
120
|
+
Every run also appends to a local journal (`~/.highball/runs/<project>.jsonl`,
|
|
121
|
+
pruned to the last 200) — unconditionally, whether or not reporting is
|
|
122
|
+
configured. `npx @profoundry-us/highball runs` lists recent runs; adding a
|
|
123
|
+
number shows one run's detail with failure output, and `--logs` prints every
|
|
124
|
+
rule's captured output, GitHub-Actions-style — the journal keeps the last
|
|
125
|
+
10KB per rule, pass or fail, while the dashboard receives failure tails
|
|
126
|
+
only. So the runner is self-sufficient out of the box: the hosted
|
|
127
|
+
dashboard adds team visibility, history beyond your machine, and
|
|
128
|
+
attribution — it's never required to see what happened.
|
|
129
|
+
|
|
83
130
|
## Roadmap
|
|
84
131
|
|
|
85
132
|
AI-judged rules (`rubric:` — headless Claude applying a markdown rubric to
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
:root {
|
|
7
|
+
--bg: #ffffff; --fg: #1a1a2e; --muted: #6b7280; --line: #e5e7eb;
|
|
8
|
+
--card: #f9fafb; --green: #16a34a; --green-bg: #dcfce7;
|
|
9
|
+
--red: #dc2626; --red-bg: #fee2e2; --gray-bg: #f3f4f6;
|
|
10
|
+
--accent: #4f39fa;
|
|
11
|
+
}
|
|
12
|
+
@media (prefers-color-scheme: dark) {
|
|
13
|
+
:root {
|
|
14
|
+
--bg: #111118; --fg: #e5e7eb; --muted: #9ca3af; --line: #2a2a35;
|
|
15
|
+
--card: #1a1a24; --green: #4ade80; --green-bg: #14321f;
|
|
16
|
+
--red: #f87171; --red-bg: #3b1518; --gray-bg: #23232e;
|
|
17
|
+
--accent: #8f85ff;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body {
|
|
22
|
+
margin: 0; padding: 12px; background: var(--bg); color: var(--fg);
|
|
23
|
+
font: 13px/1.5 -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
24
|
+
}
|
|
25
|
+
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
26
|
+
.head h1 { font-size: 14px; margin: 0; }
|
|
27
|
+
.head .spacer { flex: 1; }
|
|
28
|
+
button {
|
|
29
|
+
font: inherit; font-size: 12px; border: 1px solid var(--line); border-radius: 6px;
|
|
30
|
+
background: var(--card); color: var(--fg); padding: 3px 10px; cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
button:hover { border-color: var(--accent); }
|
|
33
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
34
|
+
.row {
|
|
35
|
+
display: flex; align-items: center; gap: 10px; padding: 7px 10px;
|
|
36
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--card);
|
|
37
|
+
margin-bottom: 6px; cursor: pointer;
|
|
38
|
+
}
|
|
39
|
+
.row:hover { border-color: var(--accent); }
|
|
40
|
+
.row .when, .row .meta { color: var(--muted); font-size: 12px; }
|
|
41
|
+
.group {
|
|
42
|
+
color: var(--muted); font-size: 11.5px; font-style: italic;
|
|
43
|
+
margin: 12px 2px 6px; white-space: nowrap; overflow: hidden;
|
|
44
|
+
text-overflow: ellipsis;
|
|
45
|
+
}
|
|
46
|
+
.group:first-child { margin-top: 2px; }
|
|
47
|
+
.row .spacer { flex: 1; }
|
|
48
|
+
.chip {
|
|
49
|
+
font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
|
|
50
|
+
white-space: nowrap;
|
|
51
|
+
}
|
|
52
|
+
.chip.passed { color: var(--green); background: var(--green-bg); }
|
|
53
|
+
.chip.failed { color: var(--red); background: var(--red-bg); }
|
|
54
|
+
.chip.todo { color: var(--muted); background: var(--gray-bg); }
|
|
55
|
+
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 12px; }
|
|
56
|
+
details.rule {
|
|
57
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--card);
|
|
58
|
+
margin-bottom: 6px;
|
|
59
|
+
}
|
|
60
|
+
details.rule > summary {
|
|
61
|
+
display: flex; align-items: center; gap: 10px; padding: 7px 10px;
|
|
62
|
+
cursor: pointer; list-style: none;
|
|
63
|
+
}
|
|
64
|
+
details.rule > summary::-webkit-details-marker { display: none; }
|
|
65
|
+
/* A rule with nothing to reveal (todo: no command ran) is NOT a details —
|
|
66
|
+
it must not advertise a chevron or a pointer cursor it can't honor. */
|
|
67
|
+
.rule.static > .rowline {
|
|
68
|
+
display: flex; align-items: center; gap: 10px; padding: 7px 10px;
|
|
69
|
+
}
|
|
70
|
+
.rule.static {
|
|
71
|
+
border: 1px solid var(--line); border-radius: 8px; background: var(--card);
|
|
72
|
+
margin-bottom: 6px;
|
|
73
|
+
}
|
|
74
|
+
.panel { border-top: 1px solid var(--line); padding: 4px 12px 10px; }
|
|
75
|
+
.panel-label {
|
|
76
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
|
|
77
|
+
opacity: 0.55; margin: 8px 0 4px;
|
|
78
|
+
}
|
|
79
|
+
.panel-note { font-size: 12px; opacity: 0.6; margin: 8px 0 2px; }
|
|
80
|
+
details.rule pre {
|
|
81
|
+
margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--gray-bg);
|
|
82
|
+
overflow-x: auto; font-size: 11.5px; line-height: 1.45;
|
|
83
|
+
font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap;
|
|
84
|
+
}
|
|
85
|
+
.empty { color: var(--muted); padding: 18px 6px; text-align: center; }
|
|
86
|
+
.glyph { width: 14px; text-align: center; font-weight: 700; }
|
|
87
|
+
.glyph.passed { color: var(--green); }
|
|
88
|
+
.glyph.failed { color: var(--red); }
|
|
89
|
+
.glyph.todo { color: var(--muted); }
|
|
90
|
+
</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<div id="app"><div class="empty">Loading Highball runs…</div></div>
|
|
94
|
+
<script>
|
|
95
|
+
(() => {
|
|
96
|
+
// Minimal MCP Apps client: JSON-RPC 2.0 over postMessage to the host.
|
|
97
|
+
// Kept dependency-free on purpose — the protocol surface this widget
|
|
98
|
+
// needs is initialize, tool-result notifications, and tools/call.
|
|
99
|
+
const pending = new Map();
|
|
100
|
+
let nextId = 1;
|
|
101
|
+
const post = (msg) => window.parent.postMessage(msg, "*");
|
|
102
|
+
const request = (method, params) => new Promise((resolve, reject) => {
|
|
103
|
+
const id = nextId++;
|
|
104
|
+
pending.set(id, { resolve, reject });
|
|
105
|
+
post({ jsonrpc: "2.0", id, method, params });
|
|
106
|
+
});
|
|
107
|
+
const notify = (method, params) => post({ jsonrpc: "2.0", method, params });
|
|
108
|
+
|
|
109
|
+
window.addEventListener("message", (event) => {
|
|
110
|
+
const msg = event.data;
|
|
111
|
+
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
112
|
+
if (msg.id !== undefined && msg.method === undefined) {
|
|
113
|
+
const waiter = pending.get(msg.id);
|
|
114
|
+
if (waiter) {
|
|
115
|
+
pending.delete(msg.id);
|
|
116
|
+
msg.error ? waiter.reject(msg.error) : waiter.resolve(msg.result);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (msg.method === "ui/notifications/tool-result") {
|
|
121
|
+
toolInFlight = false;
|
|
122
|
+
const result = msg.params?.result ?? msg.params ?? {};
|
|
123
|
+
if (result.structuredContent) render(result.structuredContent);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// The host streams tool-input while the originating tool is still
|
|
127
|
+
// executing (e.g. run_checks taking seconds) — don't let the
|
|
128
|
+
// impatient self-fetch clobber the view; the result is coming.
|
|
129
|
+
if (msg.method === "ui/notifications/tool-input" ||
|
|
130
|
+
msg.method === "ui/notifications/tool-input-partial") {
|
|
131
|
+
toolInFlight = true;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (msg.id !== undefined) {
|
|
135
|
+
post({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "not supported" } });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
async function callTool(name, args) {
|
|
140
|
+
const result = await request("tools/call", { name, arguments: args || {} });
|
|
141
|
+
if (result?.structuredContent) render(result.structuredContent);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- rendering ---------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
const app = document.getElementById("app");
|
|
147
|
+
let context = { project: null, dir: null };
|
|
148
|
+
let toolInFlight = false;
|
|
149
|
+
|
|
150
|
+
function esc(text) {
|
|
151
|
+
return String(text ?? "").replace(/[&<>"]/g, (c) =>
|
|
152
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[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/init.js
CHANGED
|
@@ -36,17 +36,20 @@ checks:
|
|
|
36
36
|
# fast: true
|
|
37
37
|
`;
|
|
38
38
|
|
|
39
|
+
// Always the SCOPED command. The unscoped npm name belongs to an unrelated
|
|
40
|
+
// package, so a bare `npx highball` in a committed hook is one uninstalled
|
|
41
|
+
// checkout away from fetching a stranger's code and running it on every edit.
|
|
39
42
|
const HOOKS_JSON = {
|
|
40
43
|
hooks: {
|
|
41
44
|
PostToolUse: [
|
|
42
45
|
{
|
|
43
46
|
matcher: "Write|Edit",
|
|
44
|
-
hooks: [{ type: "command", command: "npx highball run --fast" }]
|
|
47
|
+
hooks: [{ type: "command", command: "npx @profoundry-us/highball run --fast" }]
|
|
45
48
|
}
|
|
46
49
|
],
|
|
47
50
|
Stop: [
|
|
48
51
|
{
|
|
49
|
-
hooks: [{ type: "command", command: "npx highball run", timeout: 900 }]
|
|
52
|
+
hooks: [{ type: "command", command: "npx @profoundry-us/highball run", timeout: 900 }]
|
|
50
53
|
}
|
|
51
54
|
]
|
|
52
55
|
}
|
package/lib/journal.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The local run journal: every run appends one JSONL line to
|
|
2
|
+
// ~/.highball/runs/<project>.jsonl, whether or not remote reporting is
|
|
3
|
+
// configured. This is what makes the runner useful with no dashboard at
|
|
4
|
+
// all — `highball runs` reads it — and it lives outside the repo tree
|
|
5
|
+
// (like credentials) so there's no gitignore to manage and no state to
|
|
6
|
+
// leak into commits.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// Per-project cap. At ~1-2KB a line this bounds each file around a
|
|
12
|
+
// couple hundred KB — enough history to be useful, never enough to care
|
|
13
|
+
// about.
|
|
14
|
+
const MAX_RUNS = 200;
|
|
15
|
+
|
|
16
|
+
export function journalDir() {
|
|
17
|
+
return join(homedir(), ".highball", "runs");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function journalPath(project, dir = journalDir()) {
|
|
21
|
+
return join(dir, `${project}.jsonl`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function appendRun(project, record, dir = journalDir()) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const path = journalPath(project, dir);
|
|
27
|
+
const lines = existsSync(path)
|
|
28
|
+
? readFileSync(path, "utf8").split("\n").filter(Boolean)
|
|
29
|
+
: [];
|
|
30
|
+
lines.push(JSON.stringify(record));
|
|
31
|
+
writeFileSync(path, lines.slice(-MAX_RUNS).join("\n") + "\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Newest first — the order every "recent runs" view wants.
|
|
35
|
+
export function readRuns(project, dir = journalDir()) {
|
|
36
|
+
const path = journalPath(project, dir);
|
|
37
|
+
if (!existsSync(path)) return [];
|
|
38
|
+
return readFileSync(path, "utf8")
|
|
39
|
+
.split("\n")
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.map((line) => {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(line);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.reverse();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function journaledProjects(dir = journalDir()) {
|
|
53
|
+
if (!existsSync(dir)) return [];
|
|
54
|
+
return readdirSync(dir)
|
|
55
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
56
|
+
.map((name) => name.slice(0, -".jsonl".length))
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// `highball mcp` — a stdio MCP server over the local run journal, with
|
|
2
|
+
// an MCP Apps dashboard widget. This is the no-install view layer: hosts
|
|
3
|
+
// that support MCP Apps (Claude Desktop et al.) render the widget inline;
|
|
4
|
+
// every tool also returns meaningful text, per the extension's graceful-
|
|
5
|
+
// degradation rule, so plain MCP hosts lose nothing but the pixels.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import {
|
|
12
|
+
getUiCapability, registerAppResource, registerAppTool, RESOURCE_MIME_TYPE
|
|
13
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { loadConfig } from "./config.js";
|
|
16
|
+
import { journaledProjects, readRuns } from "./journal.js";
|
|
17
|
+
|
|
18
|
+
const DASHBOARD_URI = "ui://highball/dashboard.html";
|
|
19
|
+
const BIN_PATH = fileURLToPath(new URL("../bin/highball.js", import.meta.url));
|
|
20
|
+
const VERSION = JSON.parse(
|
|
21
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
22
|
+
).version;
|
|
23
|
+
|
|
24
|
+
// The server may be launched from a repo (project inferable) or from a
|
|
25
|
+
// host like Claude Desktop whose cwd is nowhere useful. Resolution order:
|
|
26
|
+
// explicit argument, the cwd's checks.yml, then the journal with the
|
|
27
|
+
// newest run — the repo being actively worked in IS the current project.
|
|
28
|
+
// Journal records carry the repo dir (since runs record process.cwd()),
|
|
29
|
+
// so every path out of here can ground the widget's re-run buttons.
|
|
30
|
+
function resolveProject(explicit) {
|
|
31
|
+
if (explicit) return { project: explicit, dir: latestDirFor(explicit) };
|
|
32
|
+
try {
|
|
33
|
+
return { project: loadConfig().project, dir: process.cwd() };
|
|
34
|
+
} catch {
|
|
35
|
+
let current = null;
|
|
36
|
+
for (const project of journaledProjects()) {
|
|
37
|
+
const newest = readRuns(project)[0];
|
|
38
|
+
if (!newest) continue;
|
|
39
|
+
if (!current || newest.started_at > current.started_at) {
|
|
40
|
+
current = { project, started_at: newest.started_at };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!current) return { project: null, dir: null };
|
|
44
|
+
return { project: current.project, dir: latestDirFor(current.project) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Newest journal record that knows its repo dir (older records predate
|
|
49
|
+
// the field).
|
|
50
|
+
function latestDirFor(project) {
|
|
51
|
+
return readRuns(project).find((run) => run.dir)?.dir ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// List payloads stay lean — output tails ride only on get_run.
|
|
55
|
+
function summarize(run, i) {
|
|
56
|
+
return {
|
|
57
|
+
index: i + 1,
|
|
58
|
+
started_at: run.started_at,
|
|
59
|
+
duration_ms: run.duration_ms,
|
|
60
|
+
trigger: run.trigger,
|
|
61
|
+
branch: run.branch,
|
|
62
|
+
commit: run.commit,
|
|
63
|
+
status: run.status,
|
|
64
|
+
session: run.session ?? null,
|
|
65
|
+
work: run.work ?? null,
|
|
66
|
+
reported_run_id: run.reported_run_id,
|
|
67
|
+
results: (run.results || []).map(({ id, name, status, duration_ms }) =>
|
|
68
|
+
({ id, name, status, duration_ms }))
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reply(text, structuredContent) {
|
|
73
|
+
return { content: [ { type: "text", text } ], structuredContent };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- text rendering ------------------------------------------------------
|
|
77
|
+
// Two audiences: hosts that advertised the MCP Apps UI capability get a
|
|
78
|
+
// short summary (the widget carries the detail), everyone else gets the
|
|
79
|
+
// full picture as aligned plain text — the extension's graceful-
|
|
80
|
+
// degradation rule made concrete. Exported for tests.
|
|
81
|
+
|
|
82
|
+
const glyphFor = (status) =>
|
|
83
|
+
status === "passed" ? "✓" : status === "todo" ? "•" : "✗";
|
|
84
|
+
|
|
85
|
+
export function listText(project, runs) {
|
|
86
|
+
if (runs.length === 0) return `No runs recorded for ${project} yet.`;
|
|
87
|
+
const rows = runs.map((run) => [
|
|
88
|
+
`#${run.index}`,
|
|
89
|
+
run.status === "passed" ? "✓ passed" : "✗ FAILED",
|
|
90
|
+
run.trigger === "edit" ? "fast" : "full",
|
|
91
|
+
run.branch || "-",
|
|
92
|
+
run.duration_ms != null ? `${(run.duration_ms / 1000).toFixed(1)}s` : "-",
|
|
93
|
+
run.started_at,
|
|
94
|
+
run.results.map((result) => glyphFor(result.status)).join("")
|
|
95
|
+
]);
|
|
96
|
+
const widths = rows[0].map((_, col) =>
|
|
97
|
+
Math.max(...rows.map((row) => row[col].length)));
|
|
98
|
+
const lines = [ `Runs for ${project} (newest first):` ];
|
|
99
|
+
let lastGroup;
|
|
100
|
+
runs.forEach((run, i) => {
|
|
101
|
+
// Group header whenever the work (or its session) changes between
|
|
102
|
+
// adjacent runs — the journal is chronological, so contiguous runs
|
|
103
|
+
// with the same prompt are one stretch of work.
|
|
104
|
+
const group = `${run.session ?? ""}·${run.work ?? ""}`;
|
|
105
|
+
if (group !== lastGroup) {
|
|
106
|
+
lastGroup = group;
|
|
107
|
+
lines.push(run.work ? `» ${run.work}` : "» (no session context)");
|
|
108
|
+
}
|
|
109
|
+
lines.push(" " + rows[i].map((cell, col) => cell.padEnd(widths[col])).join(" "));
|
|
110
|
+
});
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function detailText(project, index, run) {
|
|
115
|
+
const took = run.duration_ms != null
|
|
116
|
+
? ` · took ${(run.duration_ms / 1000).toFixed(1)}s` : "";
|
|
117
|
+
const rules = run.results.map((result) => {
|
|
118
|
+
const duration = result.duration_ms != null
|
|
119
|
+
? ` (${(result.duration_ms / 1000).toFixed(1)}s)` : "";
|
|
120
|
+
let line = ` ${glyphFor(result.status)} ${result.name} — ${result.status}${duration}`;
|
|
121
|
+
if (result.status === "failed" && result.output_tail) {
|
|
122
|
+
line += "\n" + result.output_tail.split("\n").map((l) => ` ${l}`).join("\n");
|
|
123
|
+
}
|
|
124
|
+
return line;
|
|
125
|
+
}).join("\n");
|
|
126
|
+
return `Run #${index} — ${project} · ${run.trigger === "edit" ? "fast checks" : "full suite"}` +
|
|
127
|
+
` · ${run.branch || "-"} · ${(run.commit || "").slice(0, 7)} · ${run.status}${took}\n${rules}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function mcp() {
|
|
131
|
+
const server = new McpServer({ name: "highball", version: VERSION });
|
|
132
|
+
|
|
133
|
+
// Did this client advertise MCP Apps support in its initialize
|
|
134
|
+
// capabilities (io.modelcontextprotocol/ui)? Checked at call time —
|
|
135
|
+
// capabilities aren't known yet when tools are registered.
|
|
136
|
+
const uiHost = () => {
|
|
137
|
+
const capability = getUiCapability(server.server.getClientCapabilities());
|
|
138
|
+
return !!capability &&
|
|
139
|
+
(!capability.mimeTypes || capability.mimeTypes.includes(RESOURCE_MIME_TYPE));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
registerAppTool(server, "list_runs", {
|
|
143
|
+
title: "Highball runs",
|
|
144
|
+
description:
|
|
145
|
+
"Recent Highball check runs for a project, from the machine-local " +
|
|
146
|
+
"journal (~/.highball/runs). Renders the runs dashboard widget.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
project: z.string().optional()
|
|
149
|
+
.describe("Project slug; defaults to the current repo's project")
|
|
150
|
+
},
|
|
151
|
+
_meta: { ui: { resourceUri: DASHBOARD_URI } }
|
|
152
|
+
}, async ({ project: explicit }) => {
|
|
153
|
+
const { project, dir } = resolveProject(explicit);
|
|
154
|
+
if (!project) {
|
|
155
|
+
const known = journaledProjects();
|
|
156
|
+
return reply(
|
|
157
|
+
`No project resolved. Journaled projects: ${known.join(", ") || "(none)"}`,
|
|
158
|
+
{ projects: known }
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const runs = readRuns(project).map(summarize);
|
|
162
|
+
return reply(
|
|
163
|
+
uiHost()
|
|
164
|
+
? `${runs.length} runs for ${project} — rendered in the dashboard widget.`
|
|
165
|
+
: listText(project, runs.slice(0, 20)),
|
|
166
|
+
{ project, dir, runs }
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
registerAppTool(server, "get_run", {
|
|
171
|
+
title: "Highball run detail",
|
|
172
|
+
description:
|
|
173
|
+
"One Highball run's full detail — per-rule statuses, durations, and " +
|
|
174
|
+
"captured command output. index counts from 1, newest first.",
|
|
175
|
+
inputSchema: {
|
|
176
|
+
index: z.number().int().min(1).describe("1-based index, newest first"),
|
|
177
|
+
project: z.string().optional()
|
|
178
|
+
.describe("Project slug; defaults to the current repo's project")
|
|
179
|
+
},
|
|
180
|
+
_meta: { ui: { resourceUri: DASHBOARD_URI } }
|
|
181
|
+
}, async ({ index, project: explicit }) => {
|
|
182
|
+
const { project, dir } = resolveProject(explicit);
|
|
183
|
+
if (!project) return reply("No project resolved.", {});
|
|
184
|
+
const history = readRuns(project);
|
|
185
|
+
const run = history[index - 1];
|
|
186
|
+
if (!run) return reply(`No run #${index} (${history.length} recorded).`, {});
|
|
187
|
+
return reply(
|
|
188
|
+
uiHost()
|
|
189
|
+
? `Run #${index} for ${project}: ${run.status} — rendered in the dashboard widget.`
|
|
190
|
+
: detailText(project, index, run),
|
|
191
|
+
{ project, dir, run: { ...summarize(run, index - 1), results: run.results } }
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
registerAppTool(server, "run_checks", {
|
|
196
|
+
title: "Run Highball checks",
|
|
197
|
+
description:
|
|
198
|
+
"Execute a repo's Highball checks (fast rules or the full suite). " +
|
|
199
|
+
"Blocks until done; the run lands in the journal and, when reporting " +
|
|
200
|
+
"is configured, on the dashboard.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
dir: z.string().optional()
|
|
203
|
+
.describe("Repo root containing .highball/checks.yml; defaults to cwd"),
|
|
204
|
+
fast: z.boolean().optional().describe("Only rules marked fast: true")
|
|
205
|
+
},
|
|
206
|
+
_meta: { ui: { resourceUri: DASHBOARD_URI } }
|
|
207
|
+
}, async ({ dir, fast }) => {
|
|
208
|
+
// No dir given → the current project's repo (from its journal), so
|
|
209
|
+
// widget-initiated re-runs work from hosts with no useful cwd.
|
|
210
|
+
const cwd = dir || resolveProject(null).dir || process.cwd();
|
|
211
|
+
const child = spawnSync(
|
|
212
|
+
process.execPath,
|
|
213
|
+
[ BIN_PATH, "run", ...(fast ? [ "--fast" ] : []) ],
|
|
214
|
+
{ cwd, encoding: "utf8", timeout: 900_000, env: { ...process.env, NO_COLOR: "1" } }
|
|
215
|
+
);
|
|
216
|
+
const output = `${child.stdout || ""}${child.stderr || ""}`.slice(-4000);
|
|
217
|
+
let project = null;
|
|
218
|
+
let latest = null;
|
|
219
|
+
try {
|
|
220
|
+
project = loadConfig(cwd).project;
|
|
221
|
+
latest = readRuns(project)[0] || null;
|
|
222
|
+
} catch {
|
|
223
|
+
// No checks.yml at cwd — the child's own error output says so.
|
|
224
|
+
}
|
|
225
|
+
return reply(
|
|
226
|
+
`exit ${child.status}\n${output}`,
|
|
227
|
+
{
|
|
228
|
+
project,
|
|
229
|
+
dir: cwd,
|
|
230
|
+
exitCode: child.status,
|
|
231
|
+
run: latest ? { ...summarize(latest, 0), results: latest.results } : null
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
registerAppResource(server, "Highball Dashboard", DASHBOARD_URI, {}, async () => ({
|
|
237
|
+
contents: [ {
|
|
238
|
+
uri: DASHBOARD_URI,
|
|
239
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
240
|
+
text: readFileSync(new URL("../assets/dashboard.html", import.meta.url), "utf8")
|
|
241
|
+
} ]
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
await server.connect(new StdioServerTransport());
|
|
245
|
+
}
|
package/lib/report.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
6
|
import { hostname } from "node:os";
|
|
7
7
|
|
|
8
|
-
|
|
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.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Highball runner — local CI for AI coding agents: runs a repo's .highball/checks.yml rules, blocks the agent on failure, and reports runs to a Highball dashboard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"bin",
|
|
27
27
|
"lib",
|
|
28
|
+
"assets",
|
|
28
29
|
"README.md",
|
|
29
30
|
"ONBOARDING.md"
|
|
30
31
|
],
|
|
@@ -32,9 +33,12 @@
|
|
|
32
33
|
"node": ">=18"
|
|
33
34
|
},
|
|
34
35
|
"scripts": {
|
|
35
|
-
"test": "node --test"
|
|
36
|
+
"test": "node --test",
|
|
37
|
+
"harness": "node dev/harness/serve.js"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
42
|
"yaml": "^2.5.0"
|
|
39
43
|
},
|
|
40
44
|
"license": "MIT"
|