agent-coord-mcp 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +8 -1
  2. package/dist/build.js +113 -0
  3. package/dist/build.js.map +1 -0
  4. package/dist/roles.js +92 -0
  5. package/dist/roles.js.map +1 -0
  6. package/dist/server.js +30 -14
  7. package/dist/server.js.map +1 -1
  8. package/dist/store.js +54 -1
  9. package/dist/store.js.map +1 -1
  10. package/dist/tools/admin.js +5 -3
  11. package/dist/tools/admin.js.map +1 -1
  12. package/dist/tools/index.js +2 -0
  13. package/dist/tools/index.js.map +1 -1
  14. package/dist/tools/messaging.js +187 -8
  15. package/dist/tools/messaging.js.map +1 -1
  16. package/dist/tools/registry.js +54 -3
  17. package/dist/tools/registry.js.map +1 -1
  18. package/dist/tools/render.js +84 -0
  19. package/dist/tools/render.js.map +1 -0
  20. package/dist/tools/scopes.js +126 -0
  21. package/dist/tools/scopes.js.map +1 -0
  22. package/dist/tools/shared.js +18 -0
  23. package/dist/tools/shared.js.map +1 -1
  24. package/dist/tools/transport.js +393 -40
  25. package/dist/tools/transport.js.map +1 -1
  26. package/dist/tools/work.js +209 -0
  27. package/dist/tools/work.js.map +1 -0
  28. package/dist/work.js +260 -0
  29. package/dist/work.js.map +1 -0
  30. package/hooks/marker.mjs +18 -0
  31. package/hooks/roles.mjs +79 -0
  32. package/hooks/submit.mjs +393 -0
  33. package/hooks/tier.mjs +104 -11
  34. package/hooks/tmux-pusher.mjs +137 -51
  35. package/package.json +5 -4
  36. package/scripts/check-self-dependency.mjs +54 -0
  37. package/scripts/check-test-count.mjs +83 -0
  38. package/scripts/coord-pusher.mjs +150 -33
  39. package/src/build.ts +111 -0
  40. package/src/roles.ts +111 -0
  41. package/src/server.ts +79 -14
  42. package/src/store.ts +60 -1
  43. package/src/tools/admin.ts +10 -4
  44. package/src/tools/index.ts +2 -0
  45. package/src/tools/messaging.ts +206 -7
  46. package/src/tools/registry.ts +63 -4
  47. package/src/tools/render.ts +80 -0
  48. package/src/tools/scopes.ts +177 -0
  49. package/src/tools/shared.ts +120 -0
  50. package/src/tools/transport.ts +413 -40
  51. package/src/tools/work.ts +265 -0
  52. package/src/work.ts +329 -0
@@ -39,11 +39,24 @@
39
39
  * while this daemon runs — both consume the same cursor and would race.
40
40
  *
41
41
  * Caveats:
42
- * - Pasting types into whatever pane state exists. If you're mid-typing in
43
- * the same pane, your buffer gets corrupted. Run the receiving agent in a
44
- * dedicated pane you don't normally edit in.
45
- * - The pusher can't tell if the agent is idle, mid-tool, or showing a
46
- * permission prompt. send-keys is unconditional.
42
+ * - PEER MESSAGES paste into whatever pane state exists. If you're mid-typing
43
+ * in the same pane, your buffer gets corrupted. Run the receiving agent in
44
+ * a dedicated pane you don't normally edit in.
45
+ * - CONTROL COMMANDS (/clear, /compact) are the exception: since they paste
46
+ * RAW, a pane that is busy or holds an unsent draft would either queue them
47
+ * behind a running turn or append them to the draft and ship the result to
48
+ * the model as ordinary text — both observed live, both reporting
49
+ * delivery:"confirmed". So control submission is CONDITIONAL: it waits
50
+ * (bounded) for a busy pane to go idle, refuses to paste onto a draft, and
51
+ * verifies via capture-pane that the command left the input. An unverified
52
+ * submission reports delivery:"pending" with a reason, never "confirmed".
53
+ * See hooks/submit.mjs, shared with scripts/coord-pusher.mjs.
54
+ * - CLEANUP: never `pkill -f tmux-pusher.mjs` — the pattern matches EVERY
55
+ * pusher on the bus, and one agent's test-rig cleanup doing exactly that
56
+ * silently detached all four live agents (2026-07-28). attach_agent puts
57
+ * `--agent <id>` in argv so a pattern kill can be scoped to one agent
58
+ * (`pkill -f "tmux-pusher.mjs --agent <id>"`); better still, use
59
+ * detach_agent or the pid file — both target the exact pid.
47
60
  */
48
61
 
49
62
  import {
@@ -56,10 +69,14 @@ import {
56
69
  unlinkSync,
57
70
  watch,
58
71
  } from "node:fs";
72
+ import { readdirSync, statSync } from "node:fs";
59
73
  import { homedir } from "node:os";
74
+ import { fileURLToPath } from "node:url";
60
75
  import path from "node:path";
61
76
  import { spawn, spawnSync } from "node:child_process";
62
77
  import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.mjs";
78
+ import { mergeTransportMarker } from "./marker.mjs";
79
+ import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
63
80
 
64
81
  const AGENT_ID = process.env.AGENT_COORD_ID;
65
82
  const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
@@ -103,7 +120,9 @@ function refreshTierCtx() {
103
120
  try {
104
121
  const reg = JSON.parse(readFileSync(path.join(ROOT, "agents.json"), "utf8"));
105
122
  for (const [id, entry] of Object.entries(reg ?? {})) {
106
- if (isGateRunnerRole(entry?.role)) trusted.add(id);
123
+ // Pass the whole entry: the frozen roleId lives beside `role`, and
124
+ // reading only the display string would throw the identity away.
125
+ if (isGateRunnerRole(entry)) trusted.add(id);
107
126
  }
108
127
  gateRunner = trusted.has(AGENT_ID);
109
128
  } catch {
@@ -129,6 +148,30 @@ const TRANSPORT_FILE = path.join(ROOT, "transports", `${SAFE_ID}.json`);
129
148
  // entering any agent's context. Mirrors RECEIPTS_DIR in src/store.ts.
130
149
  const RECEIPTS_FILE = path.join(ROOT, "receipts", `${SAFE_ID}.jsonl`);
131
150
  const BUFFER_NAME = `coord-${SAFE_ID}`;
151
+ // Newest mtime across this script AND its sibling hooks modules, sampled once
152
+ // at startup — an upgrade to any of them afterwards leaves this value behind,
153
+ // which is exactly what doctor's stale-pusher-script check compares against.
154
+ // The entry file alone is not enough: control submission lives in submit.mjs
155
+ // and tiering in tier.mjs/roles.mjs, so a fix touching only an import would
156
+ // leave a single-file stamp unchanged and a still-running pusher on the old
157
+ // code would read as fresh. Scanning the whole dir over-covers (a hooks file
158
+ // we never import can flag us stale) — that is the safe direction: a spurious
159
+ // re-attach is loud and cheap, a false green silently invalidates the rollout
160
+ // verification of every pusher-side fix.
161
+ const SCRIPT_MTIME = (() => {
162
+ try {
163
+ const dir = path.dirname(fileURLToPath(import.meta.url));
164
+ let newest;
165
+ for (const f of readdirSync(dir)) {
166
+ if (!f.endsWith(".mjs")) continue;
167
+ const m = statSync(path.join(dir, f)).mtimeMs;
168
+ if (newest === undefined || m > newest) newest = m;
169
+ }
170
+ return newest;
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ })();
132
175
 
133
176
  mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
134
177
  mkdirSync(path.dirname(TRANSPORT_FILE), { recursive: true });
@@ -234,7 +277,15 @@ function collectSource(label, file, cursorKey, cur, staged) {
234
277
  if (fresh.length === 0) return;
235
278
  for (const m of fresh) {
236
279
  if (shouldInject(m)) {
237
- const tagged = { kind: label, ...m };
280
+ // The channel tag lives in `tag`, not `kind`. A stored Message carries
281
+ // its own `kind` ("decision"/"status"/"chatter" — retention weight);
282
+ // sharing the name meant a room post tagged kind:"decision" (what the
283
+ // README recommends for GOs and verdicts) overwrote the channel tag and
284
+ // rendered as `[decision …]` instead of `[#general …]`, breaking
285
+ // injectLine's parse contract. Distinct names remove the collision at
286
+ // the source; assignment still follows the spread so a sender cannot
287
+ // smuggle a `tag` in either.
288
+ const tagged = { ...m, tag: label };
238
289
  // Assigned after the spread so a sender can't smuggle a tier field in.
239
290
  tagged.tier = effectiveTier(tagged, tierCtx);
240
291
  staged.msgs.push(tagged);
@@ -259,7 +310,7 @@ function collectRoomChannel(chan, cur, staged) {
259
310
  if (fresh.length === 0) return;
260
311
  for (const m of fresh) {
261
312
  if (shouldInject(m)) {
262
- const tagged = { kind: `room #${c}`, ...m };
313
+ const tagged = { ...m, tag: `room #${c}` }; // see channel-tag note above
263
314
  tagged.tier = effectiveTier(tagged, tierCtx);
264
315
  staged.msgs.push(tagged);
265
316
  }
@@ -378,8 +429,11 @@ async function injectViaTmux(batch) {
378
429
  for (const m of batch) {
379
430
  if (isControl(m)) {
380
431
  await flushRun();
381
- await pasteAndSubmit(m.text.trim(), false); // validated control: raw slash command
382
- writeReceipts([m]);
432
+ // Control commands are VERIFIED: capture-pane confirms the command
433
+ // actually left the input. A receipt that says "typed" was being read
434
+ // as "ran" — see writeReceipts.
435
+ const outcome = await submitControlCommand(m.text.trim());
436
+ writeReceipts([m], outcome);
383
437
  } else {
384
438
  run.push(m);
385
439
  }
@@ -391,7 +445,12 @@ async function injectViaTmux(batch) {
391
445
  // pane. Receipts are out-of-band proof for the sender (it polls this file),
392
446
  // never injected into any agent's context — so verification costs zero tokens.
393
447
  // Best-effort: a failed receipt write must not break delivery, so we swallow.
394
- function writeReceipts(msgs) {
448
+ // `outcome` (control commands only) carries what verification actually saw.
449
+ // WITHOUT it a receipt proved the pusher TYPED the command, and send_command
450
+ // reported that as delivery:"confirmed" — proof of typing sold as proof of
451
+ // execution. `submitted` is the honest field: false means it is still sitting
452
+ // in the input, and the sender is told so instead of being reassured.
453
+ function writeReceipts(msgs, outcome) {
395
454
  const lines = [];
396
455
  for (const m of msgs) {
397
456
  if (!m || !m.id) continue;
@@ -402,6 +461,13 @@ function writeReceipts(msgs) {
402
461
  ts: Date.now(),
403
462
  from: m.from,
404
463
  control: m.control === true,
464
+ ...(outcome
465
+ ? {
466
+ submitted: outcome.submitted === true,
467
+ verified: outcome.verified === true,
468
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
469
+ }
470
+ : {}),
405
471
  }),
406
472
  );
407
473
  }
@@ -417,45 +483,29 @@ function writeReceipts(msgs) {
417
483
  // a compliant TUI treats the payload as inert data — embedded newlines can't
418
484
  // submit lines or smuggle a "/command". Control commands (/clear, /compact) must
419
485
  // paste RAW (bracketed=false) so the TUI still runs them as slash commands.
486
+ //
487
+ // The pipeline itself lives in ./submit.mjs, shared with scripts/coord-pusher.mjs
488
+ // so the two cannot drift again. This wrapper only supplies tmux.
489
+ const tmuxDeps = {
490
+ target: TMUX_TARGET,
491
+ buffer: BUFFER_NAME,
492
+ run: (args) => spawnSync("tmux", args, { encoding: "utf8" }),
493
+ runStdin: (args, payload) =>
494
+ new Promise((resolve, reject) => {
495
+ const load = spawn("tmux", args);
496
+ load.on("error", reject);
497
+ load.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tmux ${args[0]} exit ${code}`))));
498
+ load.stdin.end(payload);
499
+ }),
500
+ };
501
+
420
502
  function pasteAndSubmit(payload, bracketed = false) {
421
- return new Promise((resolve, reject) => {
422
- const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
423
- load.on("error", reject);
424
- load.on("exit", (code) => {
425
- if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
426
- const paste = spawnSync("tmux", [
427
- "paste-buffer",
428
- ...(bracketed ? ["-p"] : []),
429
- "-b",
430
- BUFFER_NAME,
431
- "-t",
432
- TMUX_TARGET,
433
- "-d",
434
- ]);
435
- if (paste.status !== 0) {
436
- return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
437
- }
438
- // Multi-line paste + Enter is racy: long buffers can still be flushing
439
- // to the target app's input when Enter arrives, and many TUIs (Claude
440
- // Code in particular) treat that first Enter as "still drafting,
441
- // add newline" rather than "submit." Wait briefly so paste settles,
442
- // then send Enter. Some apps additionally need a second Enter to
443
- // exit paste-mode + submit; sending two Enters with a small gap is
444
- // safe (worst case: harmless empty submit ignored by the app). For a
445
- // slash command this also dismisses the autocomplete menu and submits.
446
- setTimeout(() => {
447
- const e1 = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
448
- if (e1.status !== 0) {
449
- return reject(new Error(`tmux send-keys: ${(e1.stderr ?? "").toString().trim()}`));
450
- }
451
- setTimeout(() => {
452
- spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
453
- resolve();
454
- }, 50);
455
- }, 100);
456
- });
457
- load.stdin.end(payload);
458
- });
503
+ return sharedPasteAndSubmit(tmuxDeps, payload, { bracketed });
504
+ }
505
+
506
+ // Control commands go through the preflight + verify path, never the plain one.
507
+ function submitControlCommand(payload) {
508
+ return sharedSubmitControl(tmuxDeps, payload);
459
509
  }
460
510
 
461
511
  // Publish transport marker so list_agents can show this agent is push-capable.
@@ -470,24 +520,60 @@ const cleanupMarker = () => {
470
520
  // already gone
471
521
  }
472
522
  };
523
+ // Log BEFORE cleaning up. A silent exit that also removes its own marker is
524
+ // indistinguishable from "never attached" — on 2026-07-28 all four pushers on
525
+ // the bus vanished at once and the cause could not be determined afterwards,
526
+ // because every other exit path logs and this one did not. Ruled out at the
527
+ // time: dead-pane self-exit (logs), die() (logs), SIGKILL/OOM (the marker
528
+ // would have survived), a normal exit (the poll intervals are not unref'd),
529
+ // doctor's reaper (has-session returned 0 for every live pane), tests (all
530
+ // isolate AGENT_COORD_DIR), coord-chat (not running), stop-agent.sh (would
531
+ // have killed the session too) and a parent process-group signal (the pusher
532
+ // has its own PGID and SID). That left a signal from outside the repo, with
533
+ // nothing recorded to identify it.
534
+ function logSignal(sig) {
535
+ process.stderr.write(
536
+ `[tmux-pusher] received ${sig} — cleaning up marker for '${AGENT_ID}' (pane ${TMUX_TARGET}, pid ${process.pid}) and exiting\n`,
537
+ );
538
+ }
539
+
473
540
  process.on("SIGINT", () => {
541
+ logSignal("SIGINT");
474
542
  cleanupMarker();
475
543
  process.exit(0);
476
544
  });
477
545
  process.on("SIGTERM", () => {
546
+ logSignal("SIGTERM");
478
547
  cleanupMarker();
479
548
  process.exit(0);
480
549
  });
481
550
  process.on("exit", cleanupMarker);
482
551
 
483
552
  function writeTransportMarker() {
484
- const marker = {
553
+ // Merge over the existing marker, never rebuild from scratch: attach_agent
554
+ // wrote it first and owns fields only the server can know (its
555
+ // serverBuildMtime provenance stamp, and whatever comes next). A
556
+ // from-scratch rewrite here once dropped scriptMtime and silently disabled
557
+ // doctor's stale-pusher-script check — see hooks/marker.mjs.
558
+ let existing;
559
+ try {
560
+ existing = JSON.parse(readFileSync(TRANSPORT_FILE, "utf8"));
561
+ } catch {
562
+ existing = undefined; // first writer, or unreadable — our own fields stand alone
563
+ }
564
+ const marker = mergeTransportMarker(existing, {
485
565
  agentId: AGENT_ID,
486
566
  transport: "tmux-push",
487
567
  pid: process.pid,
488
568
  tmuxTarget: TMUX_TARGET,
489
569
  since: Date.now(),
490
- };
570
+ // MUST be included: attach_agent stamps this too, but this write happens
571
+ // afterwards and would otherwise clobber it — and doctor SKIPS markers
572
+ // without it (pre-v0.8.2 shape), so dropping it silently disabled the
573
+ // stale-pusher-script check for every local pusher. Our own stamp is the
574
+ // more truthful value anyway: it reflects the code THIS process loaded.
575
+ scriptMtime: SCRIPT_MTIME,
576
+ });
491
577
  const tmp = TRANSPORT_FILE + ".tmp";
492
578
  writeFileSync(tmp, JSON.stringify(marker));
493
579
  renameSync(tmp, TRANSPORT_FILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,11 +10,12 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
13
- "prepare": "tsc",
13
+ "prepare": "node scripts/check-self-dependency.mjs && tsc",
14
14
  "start": "node dist/server.js",
15
15
  "dev": "tsx src/server.ts",
16
- "pretest": "tsc",
17
- "test": "node --test \"test/*.test.mjs\""
16
+ "pretest": "node scripts/check-self-dependency.mjs && tsc",
17
+ "test": "node scripts/check-test-count.mjs",
18
+ "test:raw": "node --test \"test/*.test.mjs\""
18
19
  },
19
20
  "files": [
20
21
  "dist",
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Guards against this package's own name appearing in its own dependency
3
+ // graph. A self-dependency makes `npm install` resolve against an old
4
+ // published copy instead of the local source, silently breaking dev installs.
5
+ //
6
+ // It has happened ONCE, not repeatedly — verified 2026-07-29 by `git log -S`
7
+ // over the whole history under both the current name and the pre-rename
8
+ // `claude-coord-mcp`: commit de4e1ee (2026-06-06) added
9
+ // "agent-coord-mcp": "^0.8.0" to dependencies, and 3bce3ce removed it six
10
+ // days later. An earlier version of this comment claimed two reintroductions,
11
+ // one of them manual; that second one is not in the history.
12
+ //
13
+ // What made the one occurrence dangerous is worth keeping, because it
14
+ // generalises: de4e1ee is titled `chore: npm audit fix` and its body says
15
+ // "Lockfile-only dependency bumps, non-breaking" — while also rewriting a
16
+ // dependency block. It passed review BECAUSE the message said the manifest
17
+ // was untouched; a reviewer reads the claim, not the diff. This guard is
18
+ // cheap insurance against that class, not evidence of a recurring bug.
19
+ //
20
+ // Runs on `prepare` (every install), `pretest`, and `prepublishOnly`.
21
+ import { readFileSync } from "node:fs";
22
+
23
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
24
+ const DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
25
+
26
+ function selfDepFields(manifest) {
27
+ return DEP_FIELDS.filter(
28
+ (field) => manifest[field] && Object.prototype.hasOwnProperty.call(manifest[field], pkg.name),
29
+ );
30
+ }
31
+
32
+ const offenders = selfDepFields(pkg);
33
+
34
+ let lockOffender = false;
35
+ try {
36
+ const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8"));
37
+ const rootPkg = lock.packages?.[""];
38
+ if (rootPkg && selfDepFields(rootPkg).length) lockOffender = true;
39
+ } catch {
40
+ /* no lockfile yet (fresh checkout pre-install) — nothing to check */
41
+ }
42
+
43
+ if (offenders.length || lockOffender) {
44
+ console.error(
45
+ `[check-self-dependency] "${pkg.name}" depends on itself` +
46
+ (offenders.length ? ` in package.json's ${offenders.join(", ")}` : "") +
47
+ (lockOffender ? `${offenders.length ? " and" : " in"} package-lock.json's root package` : "") +
48
+ `.\nThis happened once before, added by an "npm audit fix" whose commit message said it was ` +
49
+ `lockfile-only (de4e1ee, removed in 3bce3ce — see docs/DONE.md). If you just ran an audit or ` +
50
+ `dependency update, check what else it changed in package.json. ` +
51
+ `Remove the self-reference from both files and re-run "npm install".`,
52
+ );
53
+ process.exit(1);
54
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // Run the suite and assert it ran the number of tests we expect.
3
+ //
4
+ // WHY: `npm test` was twice observed reporting FOUR fewer tests with zero
5
+ // failures (119→115, 145→141), never reproducible on a re-run. A suite whose
6
+ // count silently varies cannot be used as a gate signal — "all green" and "a
7
+ // file failed to load" look identical. This is the same silent-undercount
8
+ // class as a QUEUE parser returning zero items while passing every test.
9
+ //
10
+ // So the count is an assertion, not a statistic. A short run fails loudly.
11
+ //
12
+ // It compares PASS, not `# tests`. The first version compared `# tests`, and
13
+ // the sighting that followed showed why that was the wrong number: the runner
14
+ // reported `# tests 186 / # pass 182 / # fail 0` — its own totals not adding
15
+ // up, four results landing in no bucket at all. `# tests` read the full 186, so
16
+ // the guard would have passed a run in which four results were lost. The
17
+ // anomaly is a reporter gap, not a short run, and only the pass count sees it.
18
+ //
19
+ // When you add or remove tests, update EXPECTED_TESTS in the same commit —
20
+ // that is the point, not an inconvenience. `AGENT_COORD_EXPECTED_TESTS=n`
21
+ // overrides for a one-off (bisecting, a partial run); `=0` disables the check.
22
+
23
+ import { spawn } from "node:child_process";
24
+
25
+ const EXPECTED_TESTS = 226;
26
+
27
+ const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
28
+ // Same glob the suite always used — `--test test/` would recurse differently
29
+ // on some node versions, and a runner that selects a different set of files
30
+ // is exactly the failure this script exists to catch.
31
+ const child = spawn(process.execPath, ["--test", "test/*.test.mjs"], {
32
+ stdio: ["inherit", "pipe", "inherit"],
33
+ shell: false,
34
+ });
35
+
36
+ let out = "";
37
+ child.stdout.on("data", (chunk) => {
38
+ out += chunk;
39
+ process.stdout.write(chunk);
40
+ });
41
+
42
+ child.on("exit", (code, signal) => {
43
+ if (signal) process.exit(1);
44
+ // The TAP summary lines the runner prints once, at the end.
45
+ const num = (label) => {
46
+ const m = out.match(new RegExp(`^# ${label} (\\d+)$`, "m"));
47
+ return m ? Number(m[1]) : null;
48
+ };
49
+ const tests = num("tests");
50
+ const pass = num("pass");
51
+ const fail = num("fail");
52
+
53
+ if (code !== 0 || (fail ?? 0) > 0) process.exit(code || 1);
54
+
55
+ if (expected === 0) {
56
+ console.log(`[check-test-count] count assertion disabled (ran ${tests ?? "?"} tests)`);
57
+ process.exit(0);
58
+ }
59
+ if (pass === null) {
60
+ console.error("[check-test-count] FAIL — no '# pass' summary line in the runner output");
61
+ process.exit(1);
62
+ }
63
+ // The runner's own totals disagreeing is its own finding — report it as such
64
+ // rather than as a count mismatch, so nobody chases a missing test file.
65
+ if (tests !== null && tests !== pass + (fail ?? 0)) {
66
+ console.error(
67
+ `\n[check-test-count] FAIL — the runner's totals do not add up: ${tests} tests, ${pass} pass, ${fail ?? 0} fail.\n` +
68
+ ` ${tests - pass - (fail ?? 0)} result(s) landed in no bucket. This is a reporter gap, not a missing test file.\n`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+ if (pass !== expected) {
73
+ console.error(
74
+ `\n[check-test-count] FAIL — ${pass} tests passed, expected ${expected}.\n` +
75
+ (pass < expected
76
+ ? ` ${expected - pass} result(s) missing. Zero failures does NOT mean green here: a file that fails to load reports nothing.\n` +
77
+ ` Re-run; if the count is stable, a test file is missing or erroring at import.\n`
78
+ : ` ${pass - expected} test(s) were added. Update EXPECTED_TESTS in scripts/check-test-count.mjs in the same commit.\n`),
79
+ );
80
+ process.exit(1);
81
+ }
82
+ console.log(`[check-test-count] ${pass} tests passed, as expected.`);
83
+ });