@stacknyu/pi-check 0.0.0-stage → 1.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # pi-check changelog
2
+
3
+ ## 1.1.1
4
+
5
+ - Run command helpers asynchronously so native Pi and Pi Web remain responsive; report failed, killed or truncated command results as warnings.
6
+ - Skip invisible status polling in print/JSON mode while retaining native/RPC status support and session-local shutdown behavior.
7
+ - Add isolated command and lifecycle compatibility regressions.
8
+
9
+ ## 1.1.0
10
+
11
+ - First standalone public npm package, `@stacknyu/pi-check`, maintained in the Stack Pi Plugins monorepo. pi-flow is not installed as a dependency.
12
+ - Includes shared-result revalidation, cancellation fixes, staggered CPU allocation and Go execution/input correctness from the collection's v0.2.0 release.
13
+ - Ships its own Pi extension, skill, CLI, release notes and runtime files; supports independent Pi-managed updates.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stacknyu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # pi-check
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One check runner per repo, shared by every Pi session and every worktree. It handles tests, type checks and linters for **Go, TypeScript/JavaScript and Python**:
4
+
5
+ - **Only what changed.** A check is split into small units: a Go package, a test file, a project's typecheck, a file's lint. Each unit knows which files it depends on, through the import graph. An agent's check runs only the units its changes touch.
6
+ - **Same files, one run.** Each unit is keyed by the content of everything it depends on. If five worktrees ask for the same unit with the same files, it runs once and all five get the result. A unit already running is joined, not repeated.
7
+ - **Ahead of time.** The broker watches every checkout. When files change, the affected units run at low priority, so results are often ready before the agent asks. These runs never take the last free slot, and they stop when the machine is busy, when an agent's request needs the slot, or when their files change again.
8
+ - **Pre-existing failures are separated.** Failures that also fail on the base branch are reported as pre-existing and don't fail the check. Agents fix what they broke, not old debt.
9
+ - **Short checks have reserved capacity.** One broker/cache, with fast and heavy scheduling lanes—not independent unlimited queues per language. Fast checks cannot be trapped behind long Go suites, and heavy work retains its own slots. With three job slots, two heavy jobs and one fast job can coexist.
10
+ - **Useful throughput, not a utilization ceiling.** Agent count is independent of check concurrency. A shared CPU budget is divided among runnable jobs; Go runs one package per job, rather than multiplying package and runtime parallelism. For tests sharing ports/databases, set the project's `parallel: 1`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:@stacknyu/pi-check # then reload Pi / Pi Web
16
+ ```
17
+
18
+ This installs only pi-check; pi-flow is not required. Update only this plugin with `pi update npm:@stacknyu/pi-check`, or remove it with `pi remove npm:@stacknyu/pi-check`. See the [repository README](https://github.com/stacknyu/stack-pi-plugins#readme) for publication status, migration, local development and release procedures. The first check starts the broker. It runs in the background and stops after 3 idle hours.
19
+
20
+ ## Use
21
+
22
+ **Agents** use the `check` tool. They also get a prompt rule that asks them to use it instead of running `go test`, `pytest` or `vitest` in bash. If they run those anyway:
23
+ - `go test` / `go vet`, including inside `make`, go through a `go` shim to the broker, per package, with Go's normal output.
24
+ - Other test, typecheck and lint commands (`pnpm test`, `npx tsc --noEmit`, `pytest`, `ruff`…) run through the broker as a whole command. That run is shared and cached for identical files, and throttled.
25
+ - Commands that change files (`--fix`, `-u`, `install`, `--watch`) always run normally.
26
+
27
+ **You:**
28
+
29
+ | | |
30
+ |---|---|
31
+ | `/check` | status: what runs, what waits, time saved |
32
+ | `/check run [all]` | run what your changes affect, or everything |
33
+ | `/check baseline` | run everything on the main checkout (records pre-existing failures) |
34
+ | `/check known [clear\|remove <text>]` | the pre-existing failure list |
35
+ | `/check speculate [all]` | queue affected (or all) units ahead of time |
36
+ | `/check cancel` | drop queued ahead-of-time work for this checkout |
37
+ | `/check init` | write `pi-check.json` with what was detected |
38
+ | `/check reload` | apply configuration once requested work drains; never restart active checks |
39
+ | `/check projects` / `log` / `stop` / `restart` | |
40
+
41
+ The same commands are on the command line: `node /path/to/pi-check/cli.mjs run`, `status`, `exec -- <cmd>`, and so on (`--json` for scripts).
42
+
43
+ ## What it detects
44
+
45
+ | Language | Units | Tools |
46
+ |---|---|---|
47
+ | Go | per package: `vet`, `test` | `go vet`, `go test -json`. Inputs: package files, `testdata/`, `//go:embed` files, every in-repo package it and its tests import, `go.mod`/`go.sum` |
48
+ | TypeScript / JS | `typecheck` per package, `lint` per file, `test` per test file | `tsc`/`vue-tsc` (incremental), `eslint`/`oxlint`, `vitest`/`jest`. Inputs: static import graph incl. tsconfig `paths` and workspace packages, configs, setup files, lockfile |
49
+ | Python | `test` per test file, `lint` per file, `typecheck` per project | `pytest` (project `.venv` first, then `uv`/`poetry`, then PATH), `ruff`, `mypy`/`pyright` when configured. Inputs: import closure, `conftest.py` chain, configs |
50
+ | Custom | one unit | any command, with the input globs you give it |
51
+
52
+ Projects are found by `go.mod`, `package.json` (+ `tsconfig.json` / vitest / jest / eslint / oxlint), and `pyproject.toml` / `setup.cfg` / `pytest.ini`.
53
+
54
+ ## Settings: `pi-check.json` at the repo root (commit it; `.pi-check/config.json` overrides locally)
55
+
56
+ ```json
57
+ {
58
+ "maxJobs": 0,
59
+ "speculate": true,
60
+ "intercept": "route",
61
+ "ignore": ["**/generated/**"],
62
+ "projects": {
63
+ "apps/pocketbase": {
64
+ "test": { "timeout": "30m", "env": { "PB_SQL_LOG": "0" }, "inputs": ["migrations/**", "pb_schema.json"] },
65
+ "vet": false
66
+ },
67
+ "apps/dashboard": { "lint": { "exclude": ["src/legacy/**"] } }
68
+ },
69
+ "custom": [{ "name": "openapi", "cwd": "docs", "cmd": "node scripts/check-openapi.mjs", "inputs": ["docs/api/**"] }]
70
+ }
71
+ ```
72
+
73
+ | Setting | Default | |
74
+ |---|---|---|
75
+ | `maxJobs` | 0 = CPU budget | maximum jobs across all checkouts |
76
+ | `cpuBudget` | 0 = available cores | total cooperative CPU allocation; independent of agent count |
77
+ | `threads` | 0 = automatic | per-job ceiling when positive; automatic allocation divides spare capacity at dispatch |
78
+ | `fastLane` | true | reserve one job slot and one CPU allocation for short checks; disabled automatically with only one slot/core |
79
+ | `nice` | 10 | process priority of check runs |
80
+ | `speculate`, `warmBase` | true, false | affected-only ahead-of-time work; full baseline warming is opt-in |
81
+ | `speculateMaxLoad` | 0.75 | ahead-of-time work only while load < cores × this |
82
+ | `intercept` | `"route"` | raw check commands in agent bash: `route` through the broker, `block` (use the tool), `off` |
83
+ | `shimGo` | true | `go test`/`go vet` in agent bash go through the shim |
84
+ | `base` | the branch the main checkout is on | what "changed" and "pre-existing" compare against |
85
+ | `strict` | false | count pre-existing failures as failures |
86
+ | `projects.<dir>.<kind>` | | `false` disables. `{ args, env, timeout, parallel, inputs, exclude, cmd, lane }`. `inputs` are extra globs (project-relative) the tests read at run time. |
87
+ | `groups` | | concurrency per group, e.g. `{ "go:apps/pocketbase:test": 2 }` |
88
+ | `custom` | [] | extra checks: `{ name, cwd, cmd, inputs, timeout, env, lane }` |
89
+ | `ignore` | [] | globs that are never inputs (generated files, data dirs written by tests) |
90
+
91
+ ## Fast and heavy lanes
92
+
93
+ `maxJobs` remains the total limit. With `fastLane: true`, one slot/CPU stays reserved even when no fast check is waiting; a long job cannot borrow it and block a later short request. The remaining slots/CPU serve heavy checks. Use `maxJobs: 3` for two heavy jobs plus one short job; with two slots the split is one plus one. `fastLane: false` retains the original shared scheduler. No running requested job is preempted or resized. Automatic per-job allocation leaves capacity for a second heavy requester even when the first arrives alone, rather than leasing the whole heavy budget.
94
+
95
+ Native lint, vet, typecheck and TypeScript tests default to fast. Fast test batches are bounded to eight units. Recognizable raw type/lint and Node test commands also use that lane; Go tests, builds and unknown commands default to heavy. These are hints, not promises about duration: move slow integration suites or large typechecks to `projects.<dir>.<kind>.lane: "heavy"`, or set `lane` on a custom check. Use `"fast"` for verified short checks. Group limits, priorities/FIFO within each lane, content-cache keys and cross-worktree joins are preserved. The fast lane has at most one running job, so it cannot consume every heavy slot. Status JSON exposes the scheduler revision and per-lane running/queued counts; progress names each running job's lane. Tool allocations are cooperative, not an OS CPU/memory cap.
96
+
97
+ Source updates require a fresh broker process; `/check reload` reloads configuration, not JavaScript. Keep active checks on their current process and wait for a natural idle exit before starting the updated broker. Do not restart a busy broker to activate scheduling changes.
98
+
99
+ ## With pi-flow
100
+
101
+ pi-flow uses pi-check automatically when both are installed. Queue checks go through the broker, and item agents are told about the `check` tool. `"checks": ["pi-check"]` in a task runs exactly what each item affects, with pre-existing failures ignored. See pi-flow's README.
102
+
103
+ ## Good to know
104
+
105
+ - Configuration changes use `/check reload`, not restart. Active and already queued requested work keeps its original configuration. Allocation changes never kill/restart a running job. These are cooperative tool limits, not an OS CPU cap; arbitrary commands can override them.
106
+ - Whole-command/custom jobs already own a broker allocation. Nested pi-check/Go shims run inside that allocation instead of acquiring a second slot. Prefer the native `check` tool for per-unit sharing; arbitrary shell commands are cached as whole commands, not decomposed.
107
+ - Broker connection errors do not automatically launch duplicate direct checks. Inspect status before retrying. An upgrade refuses to replace a busy broker.
108
+ - Status includes elapsed time, allocation and the latest Go test event. Reused unit-runtime is not wall-clock savings. Quiet tests may produce no events until completion.
109
+ - Producers refresh input structure before caching; requesters revalidate their own checkout after execution and baseline classification. Changed inputs produce a stale failure, not an implicit rerun on different content. Cancelling a tool detaches its request without cancelling other waiters.
110
+ - Go ignores unrelated automatic JavaScript/Python lockfile inputs, but preserves explicit runtime inputs. Native sources, transitive embeds, effective toolchain/environment and timeouts participate in keys. Conditional packages use Go's build eligibility. Identical in-repo Go workspaces can share across worktrees; runtime fixtures outside the dependency graph still need explicit inputs.
111
+ - Benchmarks, profiling and artifact-producing Go fallback commands execute rather than reuse whole-command results. CPU allocations override forwarded `GOMAXPROCS`; arbitrary shell commands can still override cooperative limits themselves. Changed key semantics cause a one-time cold cache.
112
+ - Plugin discovery and shims honor `PI_CODING_AGENT_DIR`, including isolated profiles.
113
+
114
+ - **Pre-existing** means: the same unit fails on the base branch tip in the main checkout, or in any worktree with identical inputs. `/check baseline` records them all at once. The broker also does this by itself when it starts (`warmBase`).
115
+ - **Tests that read files at run time** outside their package (`../../fixtures`) need those files in `inputs`. Otherwise a change there won't rerun them. Embedded files and `testdata/` are already covered.
116
+ - **Tests that write into the repo** (a data dir that isn't git-ignored) change their own inputs. pi-check notices and stops running them ahead of time. Git-ignore that dir, or add it to `ignore`.
117
+ - **Timeouts are never cached.** A Go test that hits its timeout is reported, with a hint to raise `timeout`, and runs again next time.
118
+ - State lives in `.pi-check/` in the main checkout (kept out of git automatically): `cache/`, `known.json`, `state.json`, `broker.log`.
119
+ - The collection requires Node 22.19+, Pi, git, and the tools your projects already use. macOS and Linux; tested with Pi 1.5.0.
package/cli.mjs ADDED
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ // pi-check command line. Agents normally use the `check` tool; this is for people, scripts, the pi-flow
3
+ // runner, and the `go` shim.
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { CLI, SHIM_DIR, call, cleanPath, connect, installShims } from "./lib/client.mjs";
9
+ import { CONFIG_FILE } from "./lib/repo.mjs";
10
+ import { STATE_DIR, VERSION, fmtMs, normRel, repoRoots, shq, tailLines, which } from "./lib/util.mjs";
11
+
12
+ const HELP = `pi-check ${VERSION} — one shared, cached, speculative check runner per repo (Go, TypeScript, Python)
13
+
14
+ pi-check run [changed|all] [options] run checks; prints a report, exit 1 on new failures
15
+ --files a,b only units that depend on these files
16
+ --kind test,lint test | typecheck | lint | vet | custom
17
+ --lang go,ts go | ts | python | custom
18
+ --project dir only this project (repeatable)
19
+ --base ref compare with this branch (default: the branch the main checkout is on)
20
+ --strict pre-existing (base) failures fail too
21
+ --refresh ignore cached results
22
+ --json machine-readable report
23
+ pi-check exec [--cwd dir] -- <command> run a raw command through the broker (shared + cached + throttled)
24
+ pi-check status [--json] what runs, what waits, cache savings
25
+ pi-check projects detected projects and checks
26
+ pi-check baseline run everything on the main checkout; failures there become "known"
27
+ pi-check known [list|add ID…|remove ID|PATTERN…|clear]
28
+ pi-check speculate [--all] pre-run what the current changes affect (--all: everything)
29
+ pi-check cancel drop queued/speculative work for this checkout
30
+ pi-check init write ${CONFIG_FILE} with the detected setup
31
+ pi-check log [-n 80] broker log
32
+ pi-check reload apply config after requested work drains
33
+ pi-check start | stop | restart | forget (clear the cache) | install-shims
34
+ `;
35
+
36
+ function parse(argv) {
37
+ const o = { _: [], project: [] };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a === "--") {
41
+ o.rest = argv.slice(i + 1);
42
+ break;
43
+ }
44
+ if (a.startsWith("--")) {
45
+ const [k, v] = a.slice(2).split("=");
46
+ const bool = ["strict", "refresh", "json", "all", "quiet", "help"].includes(k);
47
+ const val = bool ? true : (v ?? argv[++i]);
48
+ if (k === "project") o.project.push(val);
49
+ else o[k] = val;
50
+ } else if (a === "-n") o.n = Number(argv[++i]);
51
+ else o._.push(a);
52
+ }
53
+ return o;
54
+ }
55
+
56
+ const list = (v) => (v ? String(v).split(",").map((s) => s.trim()).filter(Boolean) : undefined);
57
+ const err = (s) => process.stderr.write(`${s}\n`);
58
+
59
+ async function main() {
60
+ const argv = process.argv.slice(2);
61
+ const cmd = argv[0];
62
+ if (cmd === "shim") return shim(argv.slice(1));
63
+ const o = parse(argv.slice(1));
64
+ const cwd = resolve(o.cwd ?? process.cwd());
65
+ switch (cmd) {
66
+ case undefined:
67
+ case "help":
68
+ case "--help":
69
+ case "-h":
70
+ process.stdout.write(HELP);
71
+ return 0;
72
+ case "run": {
73
+ const scope = o._[0] === "all" ? "all" : o.files ? "files" : "changed";
74
+ const r = await call(cwd, "/run", { scope, files: list(o.files)?.map((f) => toRepoRel(cwd, f)), kinds: list(o.kind), langs: list(o.lang), projects: o.project.length ? o.project : undefined, base: o.base, strict: o.strict, refresh: o.refresh, priority: o.priority ? Number(o.priority) : undefined, label: o.label ?? "cli" }, o.json || o.quiet ? undefined : progress);
75
+ if (o.json) process.stdout.write(`${JSON.stringify(r.report, null, 2)}\n`);
76
+ else process.stdout.write(`${r.report.text}\n`);
77
+ return r.report.ok ? 0 : 1;
78
+ }
79
+ case "exec": {
80
+ const command = (o.rest ?? o._).join(" ");
81
+ if (!command) return err("usage: pi-check exec [--cwd dir] -- <command>"), 2;
82
+ return execCommand(cwd, command);
83
+ }
84
+ case "status": {
85
+ const c = await connect(cwd, { start: false });
86
+ if (!c.info) {
87
+ process.stdout.write("pi-check broker is not running in this repo (it starts on the first check).\n");
88
+ return 0;
89
+ }
90
+ const s = await call(cwd, "/status");
91
+ if (o.json) process.stdout.write(`${JSON.stringify(s, null, 2)}\n`);
92
+ else process.stdout.write(`${statusText(s)}\n`);
93
+ return 0;
94
+ }
95
+ case "projects": {
96
+ const r = await call(cwd, "/projects", {});
97
+ for (const p of r.projects) process.stdout.write(`${p.lang.padEnd(7)} ${p.dir.padEnd(32)} ${Object.entries(p.units).map(([k, n]) => `${k}:${n}`).join(" ")}${p.meta?.tester ? ` [${p.meta.tester}]` : ""}${p.meta?.linter ? ` [${p.meta.linter}]` : ""}\n`);
98
+ if (!r.projects.length) process.stdout.write("No Go modules, TypeScript/JavaScript packages or Python projects with checks found.\n");
99
+ return 0;
100
+ }
101
+ case "baseline": {
102
+ const rr = repoRoots(cwd);
103
+ const r = await call(rr.main, "/run", { scope: "all", priority: 5, label: "baseline" }, progress);
104
+ const k = await call(rr.main, "/known", {});
105
+ process.stdout.write(`${r.report.text}\n\nKnown failures recorded from the main checkout: ${Object.keys(k.known).length}\n`);
106
+ return 0;
107
+ }
108
+ case "known": {
109
+ const sub = o._[0] ?? "list";
110
+ const ids = o._.slice(1);
111
+ const body = sub === "add" ? { action: "add", ids } : sub === "remove" ? { action: "remove", ids, pattern: ids.length === 1 ? ids[0] : undefined } : sub === "clear" ? { action: "clear" } : {};
112
+ const r = await call(cwd, "/known", body);
113
+ const entries = Object.entries(r.known);
114
+ if (o.json) process.stdout.write(`${JSON.stringify(r.known, null, 2)}\n`);
115
+ else {
116
+ for (const [id, k] of entries) process.stdout.write(`${k.source === "manual" ? "[manual] " : ""}${k.title ?? id}\n`);
117
+ process.stdout.write(`${entries.length} known failure${entries.length === 1 ? "" : "s"}\n`);
118
+ }
119
+ return 0;
120
+ }
121
+ case "speculate": {
122
+ const r = await call(cwd, "/speculate", { all: !!o.all });
123
+ process.stdout.write(`queued ${r.queued} unit${r.queued === 1 ? "" : "s"} at low priority\n`);
124
+ return 0;
125
+ }
126
+ case "cancel": {
127
+ const rr = repoRoots(cwd);
128
+ const r = await call(cwd, "/cancel", { root: o.all ? undefined : rr.root });
129
+ process.stdout.write(`cancelled ${r.cancelled}\n`);
130
+ return 0;
131
+ }
132
+ case "reload": {
133
+ const r = await call(cwd, "/reload", {});
134
+ process.stdout.write(r.deferred ? "configuration queued; applies after requested checks finish\n" : "configuration applied\n");
135
+ return 0;
136
+ }
137
+ case "start":
138
+ await connect(cwd);
139
+ process.stdout.write("pi-check broker running\n");
140
+ return 0;
141
+ case "stop":
142
+ case "restart": {
143
+ const c = await connect(cwd, { start: false });
144
+ if (c.info) {
145
+ try {
146
+ await call(cwd, "/stop", {}, undefined, { start: false, timeoutMs: 3000 });
147
+ } catch {}
148
+ await new Promise((r) => setTimeout(r, 600));
149
+ }
150
+ if (cmd === "restart") await connect(cwd);
151
+ process.stdout.write(cmd === "restart" ? "pi-check broker restarted\n" : "pi-check broker stopped\n");
152
+ return 0;
153
+ }
154
+ case "forget":
155
+ await call(cwd, "/forget", {});
156
+ process.stdout.write("cache cleared\n");
157
+ return 0;
158
+ case "log": {
159
+ const rr = repoRoots(cwd);
160
+ const p = join(rr.main, STATE_DIR, "broker.log");
161
+ process.stdout.write(`${existsSync(p) ? tailLines(readFileSync(p, "utf8"), o.n || 80) : "(no log yet)"}\n`);
162
+ return 0;
163
+ }
164
+ case "init": {
165
+ const rr = repoRoots(cwd);
166
+ const p = join(rr.main, CONFIG_FILE);
167
+ if (existsSync(p)) {
168
+ process.stdout.write(`${CONFIG_FILE} already exists — edit it, then: pi-check reload\n`);
169
+ return 0;
170
+ }
171
+ const r = await call(cwd, "/projects", {});
172
+ const projects = {};
173
+ for (const pr of r.projects) projects[pr.dir] = Object.fromEntries(pr.kinds.map((k) => [k, k === "test" ? { timeout: pr.lang === "go" ? "10m" : "15m", env: {} } : {}]));
174
+ writeFileSync(p, `${JSON.stringify({ $comment: "pi-check settings, shared with the team. Per project: <kind>: false disables it; inputs: extra globs (project-relative) that tests read at runtime; env/args/timeout/parallel per kind.", maxJobs: 0, cpuBudget: 0, threads: 0, speculate: true, warmBase: false, intercept: "route", ignore: [], projects, custom: [] }, null, 2)}\n`);
175
+ await call(cwd, "/reload", {});
176
+ process.stdout.write(`wrote ${p}\n`);
177
+ return 0;
178
+ }
179
+ case "install-shims":
180
+ process.stdout.write(`${installShims()}\n`);
181
+ return 0;
182
+ default:
183
+ process.stdout.write(HELP);
184
+ return 2;
185
+ }
186
+ }
187
+
188
+ function toRepoRel(cwd, f) {
189
+ const rr = repoRoots(cwd);
190
+ const abs = resolve(cwd, f);
191
+ return abs.startsWith(`${rr.root}/`) ? normRel(abs.slice(rr.root.length + 1)) : normRel(f);
192
+ }
193
+
194
+ let lastProgress = 0;
195
+ function progress(ev) {
196
+ if (ev.type === "start") err(`pi-check: ${ev.units} units — ${ev.cached} cached, ${ev.joined} already running elsewhere, ${ev.queued} to run`);
197
+ else if (ev.type === "progress" && Date.now() - lastProgress > 15000) {
198
+ lastProgress = Date.now();
199
+ err(`pi-check: ${ev.done ?? "…"}/${ev.total ?? "…"} done · ${ev.text ?? ""}`);
200
+ }
201
+ }
202
+
203
+ function statusText(s) {
204
+ const st = s.stats;
205
+ const lines = [
206
+ `pi-check ${s.version} · pid ${s.pid} · up ${s.uptime} · ${s.cpus} cores, load ${s.load} · ${s.maxJobs} job slots, ${s.allocatedCpus ?? "?"}/${s.cpuBudget ?? s.cpus} CPU budget allocated${s.configPending ? " · config waiting for idle" : ""} · speculation ${s.speculate ? "on" : "off"}`,
207
+ `running (${s.running.length}): ${s.running.map((j) => `${j.root}: ${j.label}${j.speculative ? " [speculative]" : ""} ${j.for}`).join(" | ") || "nothing"}`,
208
+ `queued: ${s.pending.requested} requested, ${s.pending.speculative} speculative`,
209
+ `checkouts: ${s.roots.map((r) => `${r.name}${r.watching ? " (watching)" : ""}`).join(", ")}`,
210
+ `units asked ${st.unitsAsked}: ${st.cacheHits} cached, ${st.joined} joined a run in progress, ${st.ran} ran (+${st.speculativeRan} speculative, ${st.speculativeUsed} of those used) · exec ${st.execRuns} ran / ${st.execHits} reused`,
211
+ `reused unit-runtime ≈ ${st.saved} (not wall-time savings) · execution time ${st.spent} · preempted ${st.preempted}`,
212
+ `known (pre-existing) failures: ${s.known}`,
213
+ ];
214
+ if (s.recent?.length) lines.push("recent:", ...s.recent.slice(0, 6).map((r) => ` ${new Date(r.at).toLocaleTimeString()} ${r.root} ${r.scope} ${r.units}u ${r.ok ? "PASS" : `FAIL(${r.newFailures})`} ${fmtMs(r.ms)} ${r.label}`));
215
+ return lines.join("\n");
216
+ }
217
+
218
+ async function execCommand(cwd, command, env) {
219
+ if (process.env.PI_CHECK_BYPASS === "1") {
220
+ const r = spawnSync("/bin/sh", ["-c", command], { cwd, stdio: "inherit", env: { ...process.env, PATH: cleanPath() } });
221
+ return r.status ?? 1;
222
+ }
223
+ try {
224
+ const r = await call(cwd, "/exec", { cwd, cmd: command, env }, (ev) => ev.type === "progress" && progress(ev));
225
+ process.stdout.write(r.output ?? "");
226
+ if (r.output && !r.output.endsWith("\n")) process.stdout.write("\n");
227
+ if (r.source !== "ran") process.stdout.write(Date.now() - r.at < 3000 ? `[pi-check] the same command on identical files was already running${r.source === "shared" ? ` in ${r.root}` : ""} — waited for it and reused its result\n` : `[pi-check] same command on identical files already ran${r.source === "shared" ? ` in ${r.root}` : ""} ${fmtMs(Date.now() - r.at)} ago — result reused, not rerun\n`);
228
+ if (r.stale) process.stdout.write("[pi-check] files changed while this ran; the result may not match the current files\n");
229
+ return r.stale ? 1 : r.code ?? 1;
230
+ } catch (e) {
231
+ err(`[pi-check] broker unavailable (${e.message}); inspect status before retrying. No unbudgeted duplicate was started.`);
232
+ return 2;
233
+ }
234
+ }
235
+
236
+ // ---------- go shim ----------
237
+
238
+ const GO_VALUE_FLAGS = new Set(["run", "skip", "timeout", "count", "p", "parallel", "tags", "shuffle", "cpu", "vet", "coverpkg", "covermode", "ldflags", "gcflags", "mod", "modfile", "C", "exec", "o", "outputdir", "coverprofile", "cpuprofile", "memprofile", "blockprofile", "mutexprofile", "trace", "bench", "benchtime", "fuzz", "fuzztime", "list"]);
239
+ const GO_PASSTHROUGH = new Set(["bench", "benchtime", "benchmem", "c", "o", "exec", "coverprofile", "cpuprofile", "memprofile", "blockprofile", "mutexprofile", "trace", "fuzz", "fuzztime", "list", "args", "json", "outputdir", "i", "n", "x", "work", "C", "modfile", "toolexec", "test.v"]);
240
+
241
+ export function parseGoArgs(a) {
242
+ const flags = [];
243
+ const pkgs = [];
244
+ let verbose = false;
245
+ let pass = false;
246
+ let refresh = false;
247
+ for (let i = 0; i < a.length; i++) {
248
+ const x = a[i];
249
+ if (x.startsWith("-") && x !== "-") {
250
+ const body = x.replace(/^--?/, "");
251
+ const [name, inline] = body.split(/=(.*)/s);
252
+ if (GO_PASSTHROUGH.has(name) || name.startsWith("test.")) pass = true;
253
+ let val = inline;
254
+ const takesValue = GO_VALUE_FLAGS.has(name) && inline === undefined;
255
+ if (takesValue) val = a[++i];
256
+ if (name === "v") {
257
+ verbose = val !== "false";
258
+ continue;
259
+ }
260
+ if (name === "p") continue; // the broker owns build concurrency
261
+ if (name === "count") refresh = true;
262
+ const f = val === undefined ? `-${name}` : `-${name}=${val}`;
263
+ flags.push(f);
264
+
265
+ } else {
266
+ if (x.endsWith(".go")) pass = true;
267
+ pkgs.push(x);
268
+ }
269
+ }
270
+ return { flags, pkgs, verbose, pass, refresh };
271
+ }
272
+
273
+ async function shim(args) {
274
+ const tool = args[0], rest = args.slice(1);
275
+ const real = which(tool, { skipDir: process.env.PI_CHECK_SHIM_DIR || SHIM_DIR }) ?? tool;
276
+ const passthrough = () => {
277
+ const r = spawnSync(real, rest, { stdio: "inherit", env: { ...process.env, PATH: cleanPath() } });
278
+ process.exit(r.status ?? 1);
279
+ };
280
+ if (tool !== "go" || process.env.PI_CHECK_BYPASS === "1" || !["test", "vet"].includes(rest[0])) return passthrough();
281
+ const sub = rest[0];
282
+ const { flags, pkgs, verbose, pass, refresh } = parseGoArgs(rest.slice(1));
283
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => /^(GO[A-Z0-9_]*|CGO_[A-Z0-9_]+|CC|CXX|PKG_CONFIG)$/.test(k)).sort());
284
+ env.PATH = cleanPath();
285
+ const cwd = process.cwd();
286
+ const rr = repoRoots(cwd);
287
+ if (!rr) return passthrough();
288
+ const viaExec = async () => process.exit(await execCommand(cwd, ["go", ...rest].map((s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : shq(s))).join(" "), env));
289
+ if (pass) return viaExec();
290
+ const cwdRel = cwd === rr.root ? "." : normRel(cwd.slice(rr.root.length + 1));
291
+ let r;
292
+ try {
293
+ r = await call(cwd, "/run", { goSelect: { cwdRel, patterns: pkgs }, kinds: [sub], extraArgs: flags, extraKey: JSON.stringify(flags), env, refresh, detail: true, label: `go ${sub} (agent)` }, (ev) => ev.type === "progress" && progress(ev));
294
+ } catch (e) {
295
+ err(`[pi-check] broker unavailable (${e.message}); inspect status before retrying. No unbudgeted duplicate was started.`);
296
+ process.exit(2);
297
+ }
298
+ const rep = r.report;
299
+ if (rep.go?.unsupported) return viaExec();
300
+ const byLabel = new Map((rep.results ?? []).map((x) => [x.label, x]));
301
+ let anyNew = !rep.ok;
302
+ const knownLines = [];
303
+ for (const ip of rep.go?.order ?? []) {
304
+ const x = byLabel.get(ip);
305
+ if (!x) {
306
+ if (sub === "test") process.stdout.write(`? \t${ip}\t[no test files]\n`);
307
+ continue;
308
+ }
309
+ const tag = x.source === "ran" ? "" : `\t(pi-check: ${x.source === "shared" ? `shared from ${x.root}` : "cached"})`;
310
+ if (sub === "vet") {
311
+ if (x.status !== "pass") {
312
+ const known = new Set(x.known ?? []);
313
+ process.stderr.write(`# ${ip}\n${(x.failures ?? []).map((f) => `${f.title}${known.has(f.id) ? " [pre-existing on base]" : ""}`).join("\n")}\n`);
314
+ if ((x.failures ?? []).some((f) => !known.has(f.id))) anyNew = true;
315
+ }
316
+ continue;
317
+ }
318
+ if (verbose && x.output) {
319
+ process.stdout.write(x.output.endsWith("\n") ? x.output : `${x.output}\n`);
320
+ if (tag) process.stdout.write(`${tag.trim()}\n`);
321
+ if (x.status === "pass") continue;
322
+ }
323
+ if (x.status === "pass") {
324
+ process.stdout.write(`ok \t${ip}\t${(x.seconds ?? x.ms / 1000 ?? 0).toFixed(3)}s${tag}\n`);
325
+ continue;
326
+ }
327
+ const known = new Set(x.known ?? []);
328
+ for (const f of x.failures ?? []) {
329
+ if (!verbose) process.stdout.write(f.output ? (f.output.endsWith("\n") ? f.output : `${f.output}\n`) : `--- FAIL: ${f.title}\n`);
330
+ if (known.has(f.id)) knownLines.push(f.title);
331
+ else anyNew = true;
332
+ }
333
+ if (!verbose) process.stdout.write(`FAIL\t${ip}\t${(x.seconds ?? 0).toFixed(3)}s${tag}\n`);
334
+ }
335
+ if (knownLines.length) process.stdout.write(`[pi-check] ${knownLines.length} failure${knownLines.length > 1 ? "s" : ""} above also fail on the base branch (pre-existing, not caused by these changes):\n${knownLines.map((l) => ` - ${l}`).join("\n")}\n`);
336
+ if (anyNew) process.stdout.write("FAIL\n");
337
+ process.exit(anyNew ? 1 : 0);
338
+ }
339
+
340
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) main().then(
341
+ (code) => process.exit(code ?? 0),
342
+ (e) => {
343
+ err(`pi-check: ${e?.message ?? e}`);
344
+ process.exit(2);
345
+ },
346
+ );
347
+
348
+ export { CLI };