agent-coord-mcp 0.26.15 → 0.26.17
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/dist/server.js +88 -12
- package/dist/server.js.map +1 -1
- package/dist/tools/board-ref.js +208 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/messaging.js +42 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +114 -8
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +8 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +25 -24
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +68 -7
- package/dist/tools/transport.js.map +1 -1
- package/hooks/replay.mjs +107 -0
- package/hooks/tier.mjs +11 -0
- package/hooks/tmux-pusher.mjs +27 -0
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +152 -91
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +89 -9
- package/src/tools/board-ref.ts +239 -0
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +122 -8
- package/src/tools/registry.ts +22 -2
- package/src/tools/shared.ts +10 -0
- package/src/tools/stall.ts +38 -24
- package/src/tools/transport.ts +67 -6
package/hooks/replay.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* IS THIS INSTRUCTION ARRIVING FOR THE FIRST TIME, OR AGAIN?
|
|
3
|
+
*
|
|
4
|
+
* Phase 5.3 Task 21.2 asks for a property and deliberately names no mechanism:
|
|
5
|
+
* A REPLAYED INSTRUCTION MUST BE DISTINGUISHABLE FROM A LIVE ONE IN THE PANE,
|
|
6
|
+
* WITHOUT RELYING ON ANY AGENT'S RETAINED CONTEXT.
|
|
7
|
+
*
|
|
8
|
+
* WHY RETAINED CONTEXT CANNOT BE THE ANSWER — this is the whole reason the task
|
|
9
|
+
* forbids it. DM delivery is at-least-once by design (losing a message is worse
|
|
10
|
+
* than repeating one), so a crash, SIGTERM or dead pane between paste and cursor
|
|
11
|
+
* commit REDELIVERS. Today the redelivered bytes are IDENTICAL to the original,
|
|
12
|
+
* so the only thing that has ever caught a duplicate is an agent recognising the
|
|
13
|
+
* instruction — and that memory is destroyed two ways:
|
|
14
|
+
*
|
|
15
|
+
* /clear at a VISIBLE moment, at a task boundary, by an operator
|
|
16
|
+
* compaction SILENTLY and CONTINUOUSLY, with no command and no moment
|
|
17
|
+
* anyone notices
|
|
18
|
+
*
|
|
19
|
+
* The aide's framing, which is stronger than the one I brought: the second is
|
|
20
|
+
* the case that matters, because there is no point at which anyone knows the
|
|
21
|
+
* memory is gone. A control whose only failure mode is invisible is not a
|
|
22
|
+
* control. Both were hit twice in one reconnect.
|
|
23
|
+
*
|
|
24
|
+
* THE SIGNAL IS THE RECEIPT LOG, WHICH IS ALREADY ON DISK. Receipts are stamped
|
|
25
|
+
* AFTER the payload is typed into the pane and the push cursor advances only
|
|
26
|
+
* after that, so the redelivery window is EXACTLY the window in which a receipt
|
|
27
|
+
* exists and the cursor has not moved. The evidence of a prior delivery is
|
|
28
|
+
* therefore already written down, by construction, before the redelivery can
|
|
29
|
+
* happen — nothing new has to be recorded and no agent has to remember anything.
|
|
30
|
+
*
|
|
31
|
+
* WHAT THE ABSENCE OF A MARKER DOES NOT MEAN. `prune` deletes receipts (default
|
|
32
|
+
* 7 days), and a receipt file that has been swept cannot report the delivery it
|
|
33
|
+
* once held. So an unmarked line means "no earlier delivery IS RECORDED", never
|
|
34
|
+
* "this is provably the first". That gap is bounded and not load-bearing —
|
|
35
|
+
* redelivery follows the original by seconds to minutes, while the sweep cutoff
|
|
36
|
+
* is days — but it is stated here rather than left for a reader to assume
|
|
37
|
+
* totality, which is the recurring defect this phase exists to remove.
|
|
38
|
+
*
|
|
39
|
+
* ONE GRAMMAR, TWO PRODUCERS. The local pusher reads its own receipts file; the
|
|
40
|
+
* remote pusher cannot see the server's filesystem, so the server annotates the
|
|
41
|
+
* message instead (readMessagesTool). Those two compute the same DATA by
|
|
42
|
+
* different routes and both render it through `replayMarker` here, so the pane
|
|
43
|
+
* cannot receive two different vocabularies for one fact.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prior deliveries per message id, from the text of a receipts JSONL file.
|
|
48
|
+
*
|
|
49
|
+
* Takes TEXT rather than a path so the parsing is exercisable without a
|
|
50
|
+
* filesystem — the reason cursor IO was moved into push-cursor.mjs.
|
|
51
|
+
* Unparseable lines are skipped: a corrupt tail must not blind the whole log.
|
|
52
|
+
*/
|
|
53
|
+
export function priorDeliveries(text) {
|
|
54
|
+
const out = new Map();
|
|
55
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
let r;
|
|
58
|
+
try {
|
|
59
|
+
r = JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!r || typeof r.id !== "string") continue;
|
|
64
|
+
const seen = out.get(r.id);
|
|
65
|
+
const ts = Number.isFinite(r.ts) ? r.ts : undefined;
|
|
66
|
+
if (!seen) out.set(r.id, { deliveries: 1, ...(ts !== undefined ? { firstAt: ts } : {}) });
|
|
67
|
+
else {
|
|
68
|
+
seen.deliveries += 1;
|
|
69
|
+
// The FIRST delivery is the useful one to name: it tells a reader how
|
|
70
|
+
// long ago the instruction they are looking at was already handled.
|
|
71
|
+
if (ts !== undefined && (seen.firstAt === undefined || ts < seen.firstAt)) seen.firstAt = ts;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `replay` annotation for a message about to be pasted, or undefined when
|
|
79
|
+
* no earlier delivery is recorded.
|
|
80
|
+
*
|
|
81
|
+
* `deliveries` counts receipts ALREADY on disk, so the attempt number of the
|
|
82
|
+
* delivery being rendered is that plus one.
|
|
83
|
+
*/
|
|
84
|
+
export function replayInfo(prior, id) {
|
|
85
|
+
const seen = id ? prior?.get(id) : undefined;
|
|
86
|
+
if (!seen || !(seen.deliveries > 0)) return undefined;
|
|
87
|
+
return { attempt: seen.deliveries + 1, ...(seen.firstAt !== undefined ? { firstAt: seen.firstAt } : {}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The pane-visible marker for a replay, or "" for a first delivery.
|
|
92
|
+
*
|
|
93
|
+
* It says three things, and the third is the one that makes it a control rather
|
|
94
|
+
* than a label: THIS IS NOT THE FIRST TIME, WHEN THE FIRST TIME WAS, and WHAT TO
|
|
95
|
+
* DO ABOUT IT. A reader with no retained context cannot infer the third, and
|
|
96
|
+
* "you may ignore a duplicate, an executor may not" is exactly the case where
|
|
97
|
+
* guessing is expensive — re-running a merge or a push because the instruction
|
|
98
|
+
* arrived twice is the failure at-least-once delivery pays for.
|
|
99
|
+
*/
|
|
100
|
+
export function replayMarker(replay) {
|
|
101
|
+
if (!replay || !(replay.attempt > 1)) return "";
|
|
102
|
+
const at = Number.isFinite(replay.firstAt) ? new Date(replay.firstAt) : null;
|
|
103
|
+
const when = at
|
|
104
|
+
? ` · first delivered ${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")}Z`
|
|
105
|
+
: "";
|
|
106
|
+
return `⟲ REPLAY (delivery ${replay.attempt}${when}) — ALREADY DELIVERED BEFORE; check server truth before acting, do not re-run it · `;
|
|
107
|
+
}
|
package/hooks/tier.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// by convention, and a lowercase lookalike is chatter, not a work order.
|
|
11
11
|
|
|
12
12
|
import { isGateRunner } from "./roles.mjs";
|
|
13
|
+
import { replayMarker } from "./replay.mjs";
|
|
13
14
|
|
|
14
15
|
// Typed protocol record → tier (Phase 8). The prefix table below is the same
|
|
15
16
|
// vocabulary parsed out of text; reading the field instead removes the parse
|
|
@@ -211,6 +212,16 @@ export function injectLine(m) {
|
|
|
211
212
|
const held = text.split("\n").length - 1;
|
|
212
213
|
text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
|
|
213
214
|
}
|
|
215
|
+
// Phase 5.3 Task 21.2 — A REPLAY IS MARKED IN THE PANE, so telling it from a
|
|
216
|
+
// live instruction needs no retained context. Applied LAST so the marker is
|
|
217
|
+
// the first thing after the attribution header even when the record digest
|
|
218
|
+
// above has already rewritten `text` — a warning below the fold is an absent
|
|
219
|
+
// warning, and this line is often long.
|
|
220
|
+
//
|
|
221
|
+
// The header stays byte-identical, so the parse contract holds: attribution
|
|
222
|
+
// is still `[tag HH:MM from] ` and the marker is part of the TEXT, which is
|
|
223
|
+
// correct — it is something the reader must see, not metadata about routing.
|
|
224
|
+
text = `${replayMarker(m.replay)}${text}`;
|
|
214
225
|
return ` [${tag} ${hhmm} ${m.from}] ${text}`;
|
|
215
226
|
}
|
|
216
227
|
|
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -102,6 +102,7 @@ import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.
|
|
|
102
102
|
import { isCi } from "./roles.mjs";
|
|
103
103
|
import { mergeTransportMarker } from "./marker.mjs";
|
|
104
104
|
import { readPushCursor, writePushCursor } from "./push-cursor.mjs";
|
|
105
|
+
import { priorDeliveries, replayInfo } from "./replay.mjs";
|
|
105
106
|
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
|
|
106
107
|
|
|
107
108
|
const AGENT_ID = process.env.AGENT_COORD_ID;
|
|
@@ -583,6 +584,32 @@ async function injectViaTmux(batch) {
|
|
|
583
584
|
);
|
|
584
585
|
return [...batch];
|
|
585
586
|
}
|
|
587
|
+
// Phase 5.3 Task 21.2 — STAMP THE REPLAY FACT BEFORE PASTING.
|
|
588
|
+
//
|
|
589
|
+
// Read once per inject, not per message: the receipt log is append-only and a
|
|
590
|
+
// single read is a consistent snapshot for the whole batch.
|
|
591
|
+
//
|
|
592
|
+
// WHY THIS FILE IS THE RIGHT PLACE, and why it can work at all: receipts are
|
|
593
|
+
// stamped AFTER a payload is typed (writeReceipts, below) and the push cursor
|
|
594
|
+
// advances only after the whole batch lands (commitOffsets, in flush). So a
|
|
595
|
+
// death anywhere in that window redelivers a message whose receipt is ALREADY
|
|
596
|
+
// on disk — the evidence exists before the duplicate can, without anyone
|
|
597
|
+
// having recorded anything for this purpose or remembered anything at all.
|
|
598
|
+
//
|
|
599
|
+
// Best-effort: an unreadable receipt log yields an empty map and therefore no
|
|
600
|
+
// markers, which is the honest direction. A missing marker means "no earlier
|
|
601
|
+
// delivery is RECORDED", never "this is provably the first" — see replay.mjs.
|
|
602
|
+
let prior = new Map();
|
|
603
|
+
try {
|
|
604
|
+
prior = priorDeliveries(readFileSync(RECEIPTS_FILE, "utf8"));
|
|
605
|
+
} catch {
|
|
606
|
+
// no receipts yet, or unreadable — every message renders as a first delivery
|
|
607
|
+
}
|
|
608
|
+
batch = batch.map((m) => {
|
|
609
|
+
const replay = replayInfo(prior, m?.id);
|
|
610
|
+
return replay ? { ...m, replay } : m;
|
|
611
|
+
});
|
|
612
|
+
|
|
586
613
|
const undelivered = [];
|
|
587
614
|
let run = [];
|
|
588
615
|
const flushRun = async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.17",
|
|
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": {
|
|
@@ -1,108 +1,169 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Run the suite and assert it ran the
|
|
2
|
+
// Run the suite and assert it ran the tests we expect — PER FILE.
|
|
3
3
|
//
|
|
4
|
-
// WHY: `npm test` was
|
|
5
|
-
// failures (119→115, 145→141), never reproducible on a
|
|
6
|
-
// count silently varies cannot be used as a gate signal —
|
|
7
|
-
// file failed to load" look identical.
|
|
8
|
-
//
|
|
4
|
+
// WHY THE CHECK EXISTS AT ALL: `npm test` was observed reporting fewer tests
|
|
5
|
+
// with zero failures (119→115, 145→141, 556/557), never reproducible on a
|
|
6
|
+
// re-run. A suite whose count silently varies cannot be used as a gate signal —
|
|
7
|
+
// "all green" and "a file failed to load" look identical. So the count is an
|
|
8
|
+
// assertion, not a statistic, and a short run fails loudly.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// It compares PASS, not `# tests`. The first version compared `# tests`, and the
|
|
11
|
+
// sighting that followed showed why that was wrong: the runner reported
|
|
12
|
+
// `# tests 186 / # pass 182 / # fail 0` — its own totals not adding up, four
|
|
13
|
+
// results landing in no bucket at all. `# tests` read the full 186, so the guard
|
|
14
|
+
// would have passed a run in which four results were lost. The anomaly is a
|
|
15
|
+
// reporter gap, not a short run, and only the pass count sees it.
|
|
11
16
|
//
|
|
12
|
-
//
|
|
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.
|
|
17
|
+
// WHY THE EXPECTATION IS PER FILE (Phase 5.2 Task 16b.3):
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
// It was ONE hand-maintained integer, and that integer conflicted on six
|
|
20
|
+
// separate rebases in two days — every resolution the same mechanical act: read
|
|
21
|
+
// main's number, add mine, verify. Worse than the tedium is how it PRESENTED:
|
|
22
|
+
// GitHub schedules no checks for a `DIRTY` head, so a conflict in this file
|
|
23
|
+
// looked like a CI outage, and a jam was filed over it.
|
|
24
|
+
//
|
|
25
|
+
// A per-file record fixes the conflict STRUCTURALLY rather than by being easier
|
|
26
|
+
// to remember: two branches adding tests to different files touch different
|
|
27
|
+
// lines, so they no longer collide at all. Only a branch that changes the SAME
|
|
28
|
+
// test file conflicts, which is a real conflict about real content.
|
|
29
|
+
//
|
|
30
|
+
// AND IT IS A STRICTLY STRONGER ASSERTION, which is the part that mattered more
|
|
31
|
+
// than the conflict. A global total masks compensation: a file that silently
|
|
32
|
+
// loads ZERO tests passes the check as long as another file grew by the same
|
|
33
|
+
// amount. That is precisely the silent-undercount class this script was written
|
|
34
|
+
// for, and the global integer could not see it. Per file, it cannot hide.
|
|
35
|
+
//
|
|
36
|
+
// WHY NOT DERIVE THE EXPECTATION FROM THE RUN: because then a short run derives
|
|
37
|
+
// its own smaller number and agrees with itself. The expectation has to come
|
|
38
|
+
// from OUTSIDE the run — a committed record — or the guard asserts nothing. That
|
|
39
|
+
// is also why a static count of `test(` declarations is not used: measured, it
|
|
40
|
+
// reads 602 against a real 606, because some tests are declared in loops. An
|
|
41
|
+
// inexact source cannot back an equality assertion.
|
|
42
|
+
//
|
|
43
|
+
// `AGENT_COORD_EXPECTED_TESTS=n` still overrides with a single global total for
|
|
44
|
+
// a one-off (bisecting, a partial run); `=0` disables the check.
|
|
24
45
|
|
|
25
|
-
|
|
46
|
+
import { run } from "node:test";
|
|
47
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { glob } from "node:fs/promises";
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
26
51
|
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
-
// Reporter pinned to TAP: newer Node defaults to the spec reporter even when
|
|
32
|
-
// piped (and env like FORCE_COLOR can style its summary lines), so grepping
|
|
33
|
-
// the summary was machine-dependent — it broke David's publish run 2026-08-20.
|
|
34
|
-
// TAP is stable, uncolored, and the format the totals-check below was written
|
|
35
|
-
// against.
|
|
36
|
-
const child = spawn(process.execPath, ["--test", "--test-reporter=tap", "test/*.test.mjs"], {
|
|
37
|
-
stdio: ["inherit", "pipe", "inherit"],
|
|
38
|
-
shell: false,
|
|
39
|
-
});
|
|
52
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
53
|
+
const RECORD = path.join(pkgRoot, "test/expected-counts.json");
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
// Same glob the suite always used — `--test test/` would recurse differently on
|
|
56
|
+
// some node versions, and a runner that selects a different set of files is
|
|
57
|
+
// exactly the failure this script exists to catch.
|
|
58
|
+
// `AGENT_COORD_CTC_GLOB` narrows the population for the guard's own tests, so a
|
|
59
|
+
// case costs two files rather than all 68. A TEST SEAM and nothing else: the
|
|
60
|
+
// default is unchanged, because a script that selects a different file set than
|
|
61
|
+
// the suite is precisely the failure this one exists to catch.
|
|
62
|
+
const GLOB = process.env.AGENT_COORD_CTC_GLOB ?? "test/*.test.mjs";
|
|
63
|
+
const files = [];
|
|
64
|
+
for await (const f of glob(GLOB, { cwd: pkgRoot })) files.push(f);
|
|
65
|
+
files.sort();
|
|
46
66
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// stays as a belt against a runner that ignores the flag.
|
|
51
|
-
const num = (label) => {
|
|
52
|
-
const m = out.match(new RegExp(`^(?:#|ℹ) ${label} (\\d+)\\s*$`, "m"));
|
|
53
|
-
return m ? Number(m[1]) : null;
|
|
54
|
-
};
|
|
55
|
-
const tests = num("tests");
|
|
56
|
-
const pass = num("pass");
|
|
57
|
-
const fail = num("fail");
|
|
58
|
-
// SKIPS AND TODOS ARE BUCKETS, and this gate could not represent them. A
|
|
59
|
-
// conditionally-skipped test made the totals disagree and the message blamed
|
|
60
|
-
// "a reporter gap" — a cause it had not established and which was wrong. It
|
|
61
|
-
// caught something real and explained it incorrectly, which is worse than
|
|
62
|
-
// silence because it sends the reader somewhere specific.
|
|
63
|
-
const skipped = num("skipped");
|
|
64
|
-
const todo = num("todo");
|
|
67
|
+
const expected = JSON.parse(readFileSync(RECORD, "utf8"));
|
|
68
|
+
const override = process.env.AGENT_COORD_EXPECTED_TESTS;
|
|
69
|
+
const globalOverride = override === undefined ? null : Number(override);
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
const actual = new Map(files.map((f) => [f, 0]));
|
|
72
|
+
const failures = [];
|
|
73
|
+
let total = 0;
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
for await (const ev of run({ files: files.map((f) => path.join(pkgRoot, f)), concurrency: true })) {
|
|
76
|
+
const d = ev.data ?? {};
|
|
77
|
+
if (ev.type === "test:stderr" || ev.type === "test:stdout") {
|
|
78
|
+
process.stdout.write(String(d.message ?? ""));
|
|
79
|
+
continue;
|
|
71
80
|
}
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
if (d.nesting !== 0) continue;
|
|
82
|
+
if (ev.type === "test:fail") {
|
|
83
|
+
failures.push(`${rel(d.file)}: ${d.name}`);
|
|
84
|
+
continue;
|
|
75
85
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
if (ev.type !== "test:pass") continue;
|
|
87
|
+
total++;
|
|
88
|
+
const key = rel(d.file);
|
|
89
|
+
actual.set(key, (actual.get(key) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rel(file) {
|
|
93
|
+
return file ? `test/${path.basename(file)}` : "(unattributed)";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (failures.length) {
|
|
97
|
+
console.error(`[check-test-count] ${failures.length} test(s) FAILED — the count is not the story here:`);
|
|
98
|
+
for (const f of failures) console.error(` ${f}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `=0` disables; any other override asserts the old single global total.
|
|
103
|
+
if (globalOverride === 0) {
|
|
104
|
+
console.log(`[check-test-count] disabled by AGENT_COORD_EXPECTED_TESTS=0 — ${total} test(s) passed, unchecked`);
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
if (globalOverride !== null && Number.isFinite(globalOverride)) {
|
|
108
|
+
if (total !== globalOverride) {
|
|
109
|
+
console.error(`[check-test-count] FAIL — ${total} tests passed, override expected ${globalOverride}.`);
|
|
86
110
|
process.exit(1);
|
|
87
111
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
console.log(`[check-test-count] ${total} test(s) passed, matching the AGENT_COORD_EXPECTED_TESTS override`);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── the real comparison, per file ──────────────────────────────────────────
|
|
117
|
+
const drifted = [];
|
|
118
|
+
for (const f of files) {
|
|
119
|
+
const want = expected[f];
|
|
120
|
+
const got = actual.get(f) ?? 0;
|
|
121
|
+
if (want === undefined) {
|
|
122
|
+
drifted.push(`${f}: ran ${got} test(s) and is NOT in the record — a new test file must be recorded in the same commit`);
|
|
123
|
+
} else if (got !== want) {
|
|
124
|
+
drifted.push(`${f}: ran ${got}, record says ${want}`);
|
|
96
125
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
126
|
+
}
|
|
127
|
+
// A file in the record that no longer exists. Reported rather than ignored: a
|
|
128
|
+
// deleted test file is a legitimate change and a RENAMED one is a silent loss of
|
|
129
|
+
// coverage, and only the author can say which.
|
|
130
|
+
// KEYED ON EXISTENCE, NOT ON SELECTION. A record entry outside a narrowed glob
|
|
131
|
+
// was simply not selected, which is not drift; an entry naming a file that is
|
|
132
|
+
// not THERE is drift under any population. The first version skipped this whole
|
|
133
|
+
// branch under the test seam, which meant the seam disabled the check the seam
|
|
134
|
+
// exists to exercise — the guard's own test caught it.
|
|
135
|
+
for (const f of Object.keys(expected)) {
|
|
136
|
+
if (!existsSync(path.join(pkgRoot, f))) {
|
|
137
|
+
drifted.push(`${f}: in the record but no such file exists — deleted, or renamed and its coverage lost`);
|
|
138
|
+
} else if (files.includes(f) === false && GLOB === "test/*.test.mjs") {
|
|
139
|
+
drifted.push(`${f}: exists but did not run — the glob no longer selects it`);
|
|
106
140
|
}
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
}
|
|
142
|
+
// An unattributed pass would make every per-file number meaningless.
|
|
143
|
+
const unattributed = actual.get("(unattributed)") ?? 0;
|
|
144
|
+
if (unattributed) drifted.push(`${unattributed} test(s) passed with no file attribution — the per-file record cannot be verified against this run`);
|
|
145
|
+
|
|
146
|
+
if (!drifted.length) {
|
|
147
|
+
console.log(
|
|
148
|
+
`[check-test-count] ${total} test(s) across ${files.length} file(s), each matching its recorded count — ` +
|
|
149
|
+
`per FILE, so a file that silently loads zero cannot be masked by another that grew`,
|
|
150
|
+
);
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (process.argv.includes("--write")) {
|
|
155
|
+
const next = {};
|
|
156
|
+
for (const f of files) next[f] = actual.get(f) ?? 0;
|
|
157
|
+
writeFileSync(RECORD, `${JSON.stringify(next, null, 2)}\n`);
|
|
158
|
+
console.log(`[check-test-count] rewrote the record for ${files.length} file(s):`);
|
|
159
|
+
for (const d of drifted) console.log(` ${d}`);
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.error(`[check-test-count] FAIL — the suite does not match its recorded per-file counts:`);
|
|
164
|
+
for (const d of drifted) console.error(` ${d}`);
|
|
165
|
+
console.error(
|
|
166
|
+
` Run \`node scripts/check-test-count.mjs --write\` to record the new counts IN THE SAME COMMIT — that is the point, not an inconvenience.\n` +
|
|
167
|
+
` A drop with no test removed in the diff is the silent-undercount this check exists to catch: re-run before recording it.`,
|
|
168
|
+
);
|
|
169
|
+
process.exit(1);
|
package/scripts/coord-pusher.mjs
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
import { hostname } from "node:os";
|
|
39
39
|
import { spawn, spawnSync } from "node:child_process";
|
|
40
40
|
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "../hooks/submit.mjs";
|
|
41
|
+
// The pane's parse-contract line, single-sourced — see the note above formatBatch.
|
|
42
|
+
import { injectLine } from "../hooks/tier.mjs";
|
|
41
43
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
42
44
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
43
45
|
|
|
@@ -248,38 +250,20 @@ async function flush() {
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// with no `[tag HH:MM from]` header, so a parser could not attribute them.
|
|
266
|
-
//
|
|
267
|
-
// The handle is the message id, NOT a stashed copy: the full record is
|
|
268
|
-
// already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
|
|
269
|
-
// retrieve_message reads it back by id (falling through to the append-only
|
|
270
|
-
// archive if compaction moved it). A cache would have had a TTL and lost the
|
|
271
|
-
// record permanently on expiry.
|
|
272
|
-
//
|
|
273
|
-
// Gated on `m.record`: a record-LESS multi-line message is untouched and
|
|
274
|
-
// still arrives unattributed past line 1, exactly as today. Task 6 does not
|
|
275
|
-
// fix hand-typed multi-line messages, and must not change their bytes.
|
|
276
|
-
const nl = text.indexOf("\n");
|
|
277
|
-
if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
|
|
278
|
-
const held = text.split("\n").length - 1;
|
|
279
|
-
text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
|
|
280
|
-
}
|
|
281
|
-
return ` [${tag} ${hhmm} ${m.from}] ${text}`;
|
|
282
|
-
}
|
|
253
|
+
// THE PER-MESSAGE PARSE CONTRACT LINE IS IMPORTED, NOT COPIED.
|
|
254
|
+
//
|
|
255
|
+
// This was a duplicate of hooks/tier.mjs's `injectLine`, kept "byte-identical"
|
|
256
|
+
// by a comment and locked by nothing, on the rationale that this pusher is
|
|
257
|
+
// standalone and "may deploy without hooks/". THAT RATIONALE IS ALREADY FALSE:
|
|
258
|
+
// the file imports ../hooks/submit.mjs at the top, so it cannot run without
|
|
259
|
+
// hooks/ today and has not been able to for several releases. A copy kept in
|
|
260
|
+
// step by a comment is two grammars the moment one is fixed — the same argument
|
|
261
|
+
// the seam's phase-citation grammar records, where a second copy would have
|
|
262
|
+
// kept a fixed bug alive in the other package with nothing to find it.
|
|
263
|
+
//
|
|
264
|
+
// Deleting the copy is what makes Task 21.2 land in BOTH panes: the replay
|
|
265
|
+
// marker had to be added once rather than twice-and-hopefully-identically.
|
|
266
|
+
// (the import itself sits with the other imports at the top of the file)
|
|
283
267
|
|
|
284
268
|
function formatBatch(batch) {
|
|
285
269
|
const lines = ["[agent-coord] msgs (pre-consumed, don't re-read):"];
|