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.
@@ -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
 
@@ -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.15",
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 number of tests we expect.
2
+ // Run the suite and assert it ran the tests we expect — PER FILE.
3
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.
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
- // So the count is an assertion, not a statistic. A short run fails loudly.
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
- // 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.
17
+ // WHY THE EXPECTATION IS PER FILE (Phase 5.2 Task 16b.3):
18
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";
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
- const EXPECTED_TESTS = 602;
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 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
- // 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
- let out = "";
42
- child.stdout.on("data", (chunk) => {
43
- out += chunk;
44
- process.stdout.write(chunk);
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
- child.on("exit", (code, signal) => {
48
- if (signal) process.exit(1);
49
- // TAP is forced above, so `# pass N` is the contract; the `ℹ` alternative
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
- if (code !== 0 || (fail ?? 0) > 0) process.exit(code || 1);
71
+ const actual = new Map(files.map((f) => [f, 0]));
72
+ const failures = [];
73
+ let total = 0;
67
74
 
68
- if (expected === 0) {
69
- console.log(`[check-test-count] count assertion disabled (ran ${tests ?? "?"} tests)`);
70
- process.exit(0);
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 (pass === null) {
73
- console.error("[check-test-count] FAIL — no '# pass' summary line in the runner output");
74
- process.exit(1);
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
- // The runner's own totals disagreeing is its own finding — report it as such
77
- // rather than as a count mismatch, so nobody chases a missing test file.
78
- if (tests !== null && tests !== pass + (fail ?? 0) + (skipped ?? 0) + (todo ?? 0)) {
79
- console.error(
80
- `\n[check-test-count] FAIL — the runner's totals do not add up: ` +
81
- `${tests} tests = ${pass} pass + ${fail ?? 0} fail + ${skipped ?? 0} skip + ${todo ?? 0} todo leaves ` +
82
- `${tests - pass - (fail ?? 0) - (skipped ?? 0) - (todo ?? 0)} result(s) in NO bucket.\n` +
83
- ` The cause is NOT named here on purpose: a reporter gap and an unmodelled bucket look identical from these ` +
84
- `numbers, and this message used to assert the first while the answer was the second.\n`,
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
- if ((skipped ?? 0) > 0) {
89
- // NAMED, ALWAYS. A skip that reconciles arithmetically is still a test that
90
- // did not run, and the one that skipped here was the empirical control the
91
- // whole change rested on — the suite reported 347 green around it.
92
- console.error(
93
- `\n[check-test-count] ${skipped} test(s) SKIPPED. A skip is not a pass: if a skipped test is the one that ` +
94
- `establishes the claim under review, this run did not check it. Name it in the PR or make it runnable.\n`,
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
- if (pass !== expected) {
98
- console.error(
99
- `\n[check-test-count] FAIL — ${pass} tests passed, expected ${expected}.\n` +
100
- (pass < expected
101
- ? ` ${expected - pass} result(s) missing. Zero failures does NOT mean green here: a file that fails to load reports nothing.\n` +
102
- ` Re-run; if the count is stable, a test file is missing or erroring at import.\n`
103
- : ` ${pass - expected} test(s) were added. Update EXPECTED_TESTS in scripts/check-test-count.mjs in the same commit.\n`),
104
- );
105
- process.exit(1);
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
- console.log(`[check-test-count] ${pass} tests passed, as expected.`);
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);
@@ -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
- // The per-message PARSE CONTRACT line — MUST stay byte-identical to
252
- // hooks/tier.mjs's injectLine (agent harnesses parse from/room/text out of
253
- // it). Compact form (v0.14.0): ` [<tag> <HH:MM> <from>] <text>`, tag drops
254
- // the leading "room ", timestamp is HH:MM UTC, no "from=" label. This pusher
255
- // is standalone (may deploy without hooks/), so the helper is duplicated
256
- // rather than imported; test/tier.test.mjs locks both to the same shape.
257
- function injectLine(m) {
258
- const tag = String(m.tag ?? "").replace(/^room /, "");
259
- const d = new Date(m.ts ?? 0);
260
- const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
261
- let text = m.text ?? "";
262
- // Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
263
- // ONE attributed line — first line, a count of what was withheld, and the
264
- // message id as the retrieval handle. Continuation lines used to arrive bare,
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):"];