agent-coord-mcp 0.26.21 → 0.26.22

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 (62) hide show
  1. package/dist/capabilities.js +250 -2
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/closing-line.js +83 -0
  4. package/dist/closing-line.js.map +1 -0
  5. package/dist/commit-cite.js +55 -0
  6. package/dist/commit-cite.js.map +1 -0
  7. package/dist/gated-head.js +67 -20
  8. package/dist/gated-head.js.map +1 -1
  9. package/dist/server-spread.js +195 -0
  10. package/dist/server-spread.js.map +1 -0
  11. package/dist/server.js +2 -2
  12. package/dist/server.js.map +1 -1
  13. package/dist/store.js +32 -0
  14. package/dist/store.js.map +1 -1
  15. package/dist/tools/away.js +67 -7
  16. package/dist/tools/away.js.map +1 -1
  17. package/dist/tools/board-ref.js +44 -4
  18. package/dist/tools/board-ref.js.map +1 -1
  19. package/dist/tools/event-kinds.js +5 -1
  20. package/dist/tools/event-kinds.js.map +1 -1
  21. package/dist/tools/events.js +31 -2
  22. package/dist/tools/events.js.map +1 -1
  23. package/dist/tools/messaging.js +64 -6
  24. package/dist/tools/messaging.js.map +1 -1
  25. package/dist/tools/record-events.js +85 -5
  26. package/dist/tools/record-events.js.map +1 -1
  27. package/dist/tools/records.js +231 -38
  28. package/dist/tools/records.js.map +1 -1
  29. package/dist/tools/registry.js +52 -2
  30. package/dist/tools/registry.js.map +1 -1
  31. package/dist/tools/seat-build.js +173 -0
  32. package/dist/tools/seat-build.js.map +1 -0
  33. package/dist/tools/shared.js.map +1 -1
  34. package/dist/tools/stall.js +1095 -18
  35. package/dist/tools/stall.js.map +1 -1
  36. package/dist/tools/transport.js +21 -2
  37. package/dist/tools/transport.js.map +1 -1
  38. package/dist/tools/worktrees.js +14 -0
  39. package/dist/tools/worktrees.js.map +1 -1
  40. package/package.json +1 -1
  41. package/scripts/coord-attention-clock.mjs +2 -0
  42. package/scripts/coord-stall-clock.mjs +52 -11
  43. package/src/capabilities.ts +264 -2
  44. package/src/closing-line.ts +85 -0
  45. package/src/commit-cite.ts +58 -0
  46. package/src/gated-head.ts +128 -26
  47. package/src/server-spread.ts +233 -0
  48. package/src/server.ts +2 -2
  49. package/src/store.ts +32 -0
  50. package/src/tools/away.ts +82 -9
  51. package/src/tools/board-ref.ts +70 -3
  52. package/src/tools/event-kinds.ts +17 -1
  53. package/src/tools/events.ts +33 -2
  54. package/src/tools/messaging.ts +63 -6
  55. package/src/tools/record-events.ts +78 -5
  56. package/src/tools/records.ts +248 -38
  57. package/src/tools/registry.ts +54 -3
  58. package/src/tools/seat-build.ts +194 -0
  59. package/src/tools/shared.ts +22 -0
  60. package/src/tools/stall.ts +1266 -23
  61. package/src/tools/transport.ts +21 -2
  62. package/src/tools/worktrees.ts +13 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ⟨q-cc0819dc⟩ — A `done` MAY CITE A COMMIT WHEN THE WORK HAS NO PR BY RULE.
3
+ *
4
+ * Docs-direct work (queue curation, canon, board moves) is pushed straight to the shared
5
+ * branch by policy; the `done` gate demanded a `{kind:'pr'}` cite, so the compliant path did
6
+ * not exist and the aide downgraded to `fyi` (2026-09-12 15:50). The fix accepts a commit
7
+ * cite — and only a commit that is REAL: a fabricated sha must not become a citable DONE.
8
+ *
9
+ * What "real" means here, stated so the ancestry call below is not mistaken for a merge test:
10
+ * is this exact commit object reachable from origin/main in `repo`? A docs-direct push puts
11
+ * THE COMMIT ITSELF on the shared branch, so reachability is the right question for it — this
12
+ * is not "was this branch's work squashed in", which `--is-ancestor` cannot answer
13
+ * (docs/LANDEDNESS.md); a squash-merged PR is cited by its PR, never by its branch sha.
14
+ *
15
+ * Full 40-hex only, REFUSED rather than normalised: a prefix is a claim about a sha the
16
+ * joiners (verdict/landing/closure readers) compare in full; normalising here would write a
17
+ * sha the sender never saw. No network: one local `git` in `repo`, which the caller passes
18
+ * because the send path has no repository of its own.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ export const FULL_SHA = /^[0-9a-f]{40}$/;
23
+ export const SHARED_BRANCH_CANDIDATES = ["origin/main", "origin/master"];
24
+
25
+ export type CommitCiteVerdict =
26
+ | { ok: true; sha: string; branch: string }
27
+ | { ok: false; why: string };
28
+
29
+ function git(repo: string, args: string[]): { status: number; out: string } {
30
+ try {
31
+ const out = execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
32
+ return { status: 0, out: out.trim() };
33
+ } catch (e) {
34
+ return { status: Number((e as { status?: number }).status ?? 1), out: "" };
35
+ }
36
+ }
37
+
38
+ /** Verify one commit cite against the shared branch of `repo`. */
39
+ export function verifyCommitCite(ref: string, repo: string | undefined): CommitCiteVerdict {
40
+ const sha = String(ref ?? "").trim();
41
+ if (!FULL_SHA.test(sha)) {
42
+ return { ok: false, why: `commit cite '${sha}' is not a full 40-hex sha — refused, not normalised: a prefix is a claim the joiners compare in full` };
43
+ }
44
+ if (!repo) {
45
+ return { ok: false, why: `commit cite ${sha.slice(0, 7)} cannot be verified without \`repo\` — pass the repository whose origin/main carries it (no network is used)` };
46
+ }
47
+ if (git(repo, ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`]).status !== 0) {
48
+ return { ok: false, why: `commit ${sha.slice(0, 7)} does not exist in ${repo} — a sha the repository has never seen cannot be a citable DONE` };
49
+ }
50
+ for (const branch of SHARED_BRANCH_CANDIDATES) {
51
+ if (git(repo, ["rev-parse", "--verify", "--quiet", branch]).status !== 0) continue;
52
+ // Is the cited commit reachable from the shared branch? (the reachability question,
53
+ // asked of the commit object itself — see the header for why that is the right one here)
54
+ if (git(repo, ["merge-base", "--is-ancestor", sha, branch]).status === 0) return { ok: true, sha, branch };
55
+ return { ok: false, why: `commit ${sha.slice(0, 7)} exists but is not reachable from ${branch} — work that is done is on the shared branch; a commit that is not is not done` };
56
+ }
57
+ return { ok: false, why: `${repo} has no origin/main or origin/master to verify commit ${sha.slice(0, 7)} against — fetch the shared branch first` };
58
+ }
package/src/gated-head.ts CHANGED
@@ -51,7 +51,20 @@ export function shaAgrees(a: string | undefined, b: string | undefined): boolean
51
51
  return x.length <= y.length ? y.startsWith(x) : x.startsWith(y);
52
52
  }
53
53
 
54
- export type PassVerdict = { head: string; ts: number; from: string; result: string };
54
+ /*
55
+ * ⟨q-dcbaf544⟩ — A VERDICT NAMES ITS SENDER AND NEVER ITS GATER. The record's
56
+ * `from` is whoever SENT it; when a gate is routed to a seat whose role cannot
57
+ * emit `verdict` (repo-owner, console — David's ruling: authority does not
58
+ * move), a gate-runner SCRIBES it, and the gater then survives only in prose.
59
+ * A name in prose is a mention; a name in a field is a position — only the
60
+ * position survives a scanner. So the record carries `gatedBy` (who judged)
61
+ * and `scribe` (who transcribed), the spelling the three live scribed records
62
+ * already used, and every reader attributes by `gatedBy ?? from`.
63
+ */
64
+ export type PassVerdict = { head: string; ts: number; from: string; result: string; channel?: "bus" | "pr"; gatedBy?: string; scribe?: string };
65
+ /** Who JUDGED: the typed gater when scribed, else the sender. Never `from` alone. */
66
+ export const gaterOf = (v: Pick<PassVerdict, "from" | "gatedBy">): string => v.gatedBy ?? v.from;
67
+ const nonEmpty = (x: unknown): string | undefined => (typeof x === "string" && x.trim() ? x : undefined);
55
68
 
56
69
  /** The PR number a verdict record is about, from its `cites`. */
57
70
  function citedPr(cites: unknown): string | null {
@@ -86,49 +99,138 @@ export function verdictsFor(logText: string, pr: string): { verdicts: PassVerdic
86
99
  const r = o.record as { type?: string; payload?: Record<string, unknown>; cites?: unknown } | undefined;
87
100
  if (!r || r.type !== "verdict") continue;
88
101
  if (citedPr(r.cites) !== pr) continue;
102
+ const gatedBy = nonEmpty(r.payload?.gatedBy);
103
+ const scribe = nonEmpty(r.payload?.scribe);
89
104
  verdicts.push({
90
105
  head: String(r.payload?.headRefOid ?? ""),
91
106
  ts: Number(o.ts ?? 0),
92
107
  from: String(o.from ?? ""),
93
108
  result: String(r.payload?.result ?? ""),
109
+ ...(gatedBy ? { gatedBy } : {}),
110
+ ...(scribe ? { scribe } : {}),
94
111
  });
95
112
  }
96
113
  return { verdicts, lines: lines.length, unparsed };
97
114
  }
98
115
 
99
- export type GateAnswer =
100
- | { gated: true; by: PassVerdict }
101
- | { gated: false; reason: string; crossed?: { gatedSha: string; at: number }[] };
102
-
116
+ /*
117
+ * ⟨q-5a93c2d7⟩ GATEDNESS IS A PROPERTY OF (HEAD SHA, TYPED VERDICT), NOT OF
118
+ * CLOCK ORDERING. Measured on the two UNGATED records `land` had written:
119
+ *
120
+ * #309 qa's PASS comment on the PR 16:10:12Z · merged 16:10:16Z · bus record 16:10:19Z
121
+ * #310 PR comment 16:20:14Z · merged 16:20:18Z · bus record 16:20:21Z
122
+ *
123
+ * The PR carried a clean typed PASS four seconds BEFORE each merge; the bus
124
+ * record trailed the merge by three; `gatedAt` read only the bus and only
125
+ * records with ts ≤ mergedAt, and called both merges UNGATED. With one true
126
+ * (#299) and two false UNGATEDs on the record, the marker had stopped
127
+ * discriminating — the one-label-two-states failure, on the artefact that
128
+ * exists to police merges.
129
+ *
130
+ * So: a verdict NAMING THE MERGED HEAD is a verdict ABOUT that head whenever
131
+ * it was written and wherever it was recorded — the bus (any room) or the PR
132
+ * page (the typed line, #313's grammar; #299's "not a verdict" review does
133
+ * not match it). `recordedAt > mergedAt` imports a precondition the question
134
+ * never had. THE COST IS ACCEPTED AND DISCLOSED: #268's own records now read
135
+ * gated — qa re-issued a PASS bound to the merged head 4m44s after the merge
136
+ * — and the report says so IN WORDS beside "gated", because a reader who sees
137
+ * "gated" without "recorded 4m44s after the merge" has been told half a fact.
138
+ */
139
+ /*
140
+ * ⟨q-6f4a19c8⟩ — THE EARLIEST MATCH IS USUALLY THE PR COMMENT, AND THE PR
141
+ * CHANNEL CANNOT NAME A GATER: one GitHub account serves six seats, so a
142
+ * citation built from it reads "by davidbalzan" for a merge QA gated. Measured
143
+ * on qa's own land dry run at #316: #310 and #316, both gated by qa, neither
144
+ * citation said so. The earliest-match rule is CORRECT (it is what stops a late
145
+ * bus record shadowing a PR comment that preceded the merge) and stays; the
146
+ * remedy sits beside it — the earliest TIME and the identifiable AUTHOR need
147
+ * not be the same record. When the earliest match is a PR comment and a bus
148
+ * verdict names the same head, the citation carries both: the SEAT from the
149
+ * bus, first seen on the PR at the comment's time. A PR-only verdict still
150
+ * names the account, and says in words that it is an account, not a seat.
151
+ */
152
+ export type GateVerdict =
153
+ | {
154
+ gated: true;
155
+ by: PassVerdict;
156
+ lateByMs: number | null;
157
+ verified: string;
158
+ /** `gatedBy ?? from` of the record that can NAME a seat; the PR account only when no channel can. */
159
+ gater: string;
160
+ attribution: "seat" | "account";
161
+ /** The bus record that named the seat when `by` is a PR comment. */
162
+ seatRecord?: PassVerdict;
163
+ }
164
+ | { gated: false; reason: string; crossed?: { gatedSha: string; at: number; channel: string }[] };
165
+ export const fmtLate = (ms: number): string => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : ms < 3_600_000 ? `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s` : `${Math.floor(ms / 3_600_000)}h${Math.floor((ms % 3_600_000) / 60_000)}m`);
103
166
  /**
104
- * Was `head` gated by a PASS that existed at or before `at`?
167
+ * Was `head` gated by a typed PASS bound to it in any channel, at any time?
105
168
  *
106
- * `at` is the decisive argument. Pass the merge time to ask "was this merge
107
- * gated"; pass `Date.now()` to ask "is this head gated right now", which is the
108
- * pre-merge question. Same predicate, two positions.
169
+ * `mergedAt` is DISCLOSURE, not a filter: when known and the earliest matching
170
+ * PASS postdates it, the answer is still gated and `verified` says by how long
171
+ * the record trailed the merge. The EARLIEST matching PASS is the one cited,
172
+ * so a PR comment that preceded the merge is not shadowed by a bus record that
173
+ * followed it (#309's shape).
109
174
  */
110
- export function gatedAt(verdicts: PassVerdict[], head: string, at: number): GateAnswer {
175
+ export function gatedBy(verdicts: PassVerdict[], head: string, mergedAt: number | null = null): GateVerdict {
111
176
  const passes = verdicts.filter((v) => v.result === "pass");
112
177
  if (passes.length === 0) {
113
- return { gated: false, reason: `no PASS verdict was ever recorded for this PR — not checked, which is not the same as checked and passing` };
178
+ return { gated: false, reason: `no typed PASS verdict exists for this PR in any channel — not checked, which is not the same as checked and passing` };
114
179
  }
115
- const inTime = passes.filter((v) => v.ts <= at);
116
- if (inTime.length === 0) {
180
+ const matches = passes.filter((v) => shaAgrees(v.head, head)).sort((a, b) => a.ts - b.ts);
181
+ const by = matches[0];
182
+ if (!by) {
117
183
  return {
118
184
  gated: false,
119
- reason:
120
- `every PASS for this PR was recorded AFTER the moment being judged — a verdict posted later says the content ` +
121
- `was verified, never that this merge was gated`,
122
- crossed: passes.map((v) => ({ gatedSha: v.head, at: v.ts })),
185
+ reason: `the head being judged (${head.slice(0, 7)}) matches no typed PASS in any channel — the thing merged is not the thing gated`,
186
+ crossed: passes.map((v) => ({ gatedSha: v.head, at: v.ts, channel: v.channel ?? "bus" })),
123
187
  };
124
188
  }
125
- const match = inTime.find((v) => shaAgrees(v.head, head));
126
- if (match) return { gated: true, by: match };
127
- return {
128
- gated: false,
129
- reason:
130
- `the head being judged (${head.slice(0, 7)}) matches no PASS recorded at or before that moment ` +
131
- `the thing merged is not the thing gated`,
132
- crossed: inTime.map((v) => ({ gatedSha: v.head, at: v.ts })),
133
- };
189
+ const lateByMs = mergedAt !== null && Number.isFinite(mergedAt) ? Math.max(0, by.ts - mergedAt) : null;
190
+ // ⟨q-6f4a19c8⟩ the seat-naming record, when the earliest match cannot name one.
191
+ const seatRecord = by.channel === "pr" ? matches.find((v) => v.channel !== "pr") : undefined;
192
+ const gater = seatRecord ? gaterOf(seatRecord) : gaterOf(by);
193
+ const attribution: "seat" | "account" = by.channel === "pr" && !seatRecord ? "account" : "seat";
194
+ const scribed = (v: PassVerdict) => (v.gatedBy && v.gatedBy !== v.from ? `, scribed by ${v.from}` : "");
195
+ const who = seatRecord
196
+ ? `${gater} (bus${scribed(seatRecord)}), first seen on the PR at ${new Date(by.ts).toISOString()} as ${by.from}`
197
+ : by.channel === "pr"
198
+ ? `${by.from} (on the PR — a shared account, not a seat; no bus verdict names this head)`
199
+ : `${gater} (on the bus${scribed(by)})`;
200
+ const verified =
201
+ lateByMs === null
202
+ ? `verified at head ${head.slice(0, 7)} by ${who}`
203
+ : lateByMs > 0
204
+ ? `verified at head ${head.slice(0, 7)} by ${who}; the verdict was RECORDED ${fmtLate(lateByMs)} AFTER the merge — the content was judged, the merge was not covered when it happened`
205
+ : `verified at head ${head.slice(0, 7)} by ${who} before the merge`;
206
+ return { gated: true, by, lateByMs, verified, gater, attribution, ...(seatRecord ? { seatRecord } : {}) };
207
+ }
208
+
209
+ /**
210
+ * The typed verdict line on a PR page (#313's grammar): `QA GATE — **PASS** @ \`sha\``
211
+ * or the merge-time `**GATE: PASS** … @ \`sha\``. Uppercase PASS/FAIL as a whole
212
+ * word on a line that opens with the gate marker and carries an `@ sha`. Prose
213
+ * that says "passes", "verdict" or "FAILs" does not match — #299's shape, where
214
+ * a PASS|FAIL substring scan would have counted an explicit non-verdict review.
215
+ */
216
+ export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[^\n]*?\b(PASS|FAIL)\b(?:\s*\([^)\n]*\))?[^\n]*?@\s*`?([0-9a-f]{7,40})`?/;
217
+ export function verdictShasIn(comments: { body: string }[]): { result: string; sha: string }[] {
218
+ const out: { result: string; sha: string }[] = [];
219
+ for (const c of comments) {
220
+ for (const line of String(c.body ?? "").split("\n")) {
221
+ const m = VERDICT_COMMENT.exec(line);
222
+ if (m) out.push({ result: m[1]!.toUpperCase(), sha: m[2]! });
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+ /** PR comments as verdict records — the second channel. A comment with no typed line yields nothing. */
228
+ export function prVerdictsIn(comments: { body: string; createdAt?: string; author?: string }[]): PassVerdict[] {
229
+ const out: PassVerdict[] = [];
230
+ for (const c of comments) {
231
+ for (const v of verdictShasIn([c])) {
232
+ out.push({ head: v.sha, ts: Date.parse(String(c.createdAt ?? "")) || 0, from: String(c.author ?? "pr-comment"), result: v.result.toLowerCase(), channel: "pr" });
233
+ }
234
+ }
235
+ return out;
134
236
  }
@@ -0,0 +1,233 @@
1
+ /*
2
+ * ⛔⛆⛆ TWO SEATS RAN THE SAME VERB, GOT `153` AND `138`, AND BOTH SERVERS HONESTLY
3
+ * REPORTED `versionLabel 0.26.20` — `⟨q-cec42e20⟩`.
4
+ *
5
+ * Measured 2026-09-12: the aide's server (pid 61459, started 08:20:48Z) returned
6
+ * `parsedOpen 153` with a `delivered` axis; the coordinator's (pid 65516, started
7
+ * 2026-09-11T09:40:42Z) returned `open 138` and NO `delivered` axis at all. Identical
8
+ * labels, different loaded code, both answering honestly.
9
+ *
10
+ * ⭐⭐ THE LABEL IS NOT MERELY UNINFORMATIVE — IT IS ACTIVELY MISLEADING, because it is
11
+ * the ONE FIELD a seat reaches for to check exactly this, and it AGREES while the
12
+ * behaviour differs. Nothing detected the spread; every seat had to VOLUNTEER it. Five
13
+ * unprompted self-reports are why nothing broke that day, and a system that works
14
+ * because its operators are honest is one incident away from working because they were.
15
+ *
16
+ * ⛔ SO THIS DOES NOT KEY ON `versionLabel`, AND IT DOES NOT KEY ON `serverBuildMtime`
17
+ * EITHER — which is the near-miss worth recording, because that field LOOKS like the
18
+ * answer. It is stamped at ATTACH, and measured across all six live transports it held
19
+ * ONE distinct value (2026-09-11T09:27:20) for every seat. It would have reported the
20
+ * fleet uniform while the spread was live: `versionLabel`'s failure in a different field.
21
+ *
22
+ * ⭐ THE AXIS THAT DISCRIMINATES IS START TIME AGAINST THE INSTALLED BUILD — BUT ONLY
23
+ * FOR A SERVER THAT IS RUNNING THAT BUILD, AND THAT PROVISO IS NOT PEDANTRY. A server
24
+ * loads its code once, at spawn, so one that started BEFORE the current install runs
25
+ * different code from one that started AFTER it, whatever either calls itself.
26
+ *
27
+ * ⛔⛆ THE THIRD NEAR-MISS, AND THIS ONE WAS NOT A NEAR-MISS — IT WAS MEASURED ON THIS
28
+ * FILE. When `agent-coord-mcp@0.26.21` was installed at 2026-09-14T11:19:30Z, the
29
+ * time-only version of this module was run against the REAL population:
30
+ *
31
+ * SERVER SPREAD: AGREED — 12 seat(s) placed, 0 unreadable
32
+ * pre-install: (all twelve)
33
+ *
34
+ * AGREED. Over a fleet loading THREE DISTINCT BUILDS: nine on the installed one, two on
35
+ * a dev `dist/` in the primary checkout, and one on `0.19.1` from July. Every seat had
36
+ * started before the install, so every seat landed in one cohort and the fleet read
37
+ * uniform — `versionLabel`'s failure a THIRD time, now in the field this module chose
38
+ * as its remedy. "Which side of the install did you start" is not even a question about
39
+ * a server that never loads the installed build.
40
+ *
41
+ * ⭐ SO THE MODULE PATH IS THE PRIMARY KEY AND THE TIMESTAMP IS SUBORDINATE TO IT. A
42
+ * seat on a different build is its own cohort and is never folded into `pre-install`;
43
+ * a seat that does not publish a module path is UNCOMPARABLE, because it cannot be
44
+ * shown to be running the installed build at all. Today that makes the honest answer
45
+ * CANNOT_COMPARE for the whole fleet — which is the correct answer, and the one the
46
+ * time-only axis was hiding behind a clean AGREED.
47
+ */
48
+
49
+ /** What one seat publishes about the process actually answering for it. */
50
+ export type ServerIdentity = {
51
+ agentId: string;
52
+ /** The ANSWERING process. Never the pusher — that is a different process (⟨q-f14692ca⟩). */
53
+ serverPid?: number;
54
+ /** Epoch ms at which that process began. Captured from uptime, not from a file. */
55
+ serverStartedAt?: number;
56
+ /**
57
+ * The module root the answering process is EXECUTING, resolved from its own location.
58
+ * ⛔ Absent is not "the installed one" — it is unknown, and it makes the seat
59
+ * uncomparable rather than silently placing it with the majority.
60
+ */
61
+ serverModule?: string;
62
+ };
63
+
64
+ /** What the answering process was installed from: both halves, or nothing. */
65
+ export type InstalledBuild = { mtime: number; module: string };
66
+
67
+ export type SpreadVerdict =
68
+ | { state: "AGREED"; cohorts: Cohort[]; comparable: string[]; uncomparable: Uncomparable[] }
69
+ | { state: "DIVERGED"; cohorts: Cohort[]; comparable: string[]; uncomparable: Uncomparable[] }
70
+ | { state: "CANNOT_COMPARE"; why: string; cohorts: Cohort[]; comparable: string[]; uncomparable: Uncomparable[] };
71
+
72
+ export type Cohort = { side: "pre-install" | "post-install" | "other-build"; agents: string[]; module?: string };
73
+ export type Uncomparable = { agentId: string; why: string };
74
+
75
+ /**
76
+ * Which side of the installed build a seat's server started on.
77
+ *
78
+ * ⚠ EXCLUSIVE ON PURPOSE: a server started at exactly the install's mtime is counted
79
+ * POST. The boundary belongs to one side or the other and putting it with the newer
80
+ * build is the direction that under-reports divergence rather than inventing it.
81
+ */
82
+ const sideOf = (startedAt: number, installMtime: number): Cohort["side"] =>
83
+ startedAt < installMtime ? "pre-install" : "post-install";
84
+
85
+ /**
86
+ * Do the live seats' servers disagree about what they are running?
87
+ *
88
+ * ⛔ THREE OUTCOMES, AND THE THIRD IS NOT A KIND OF AGREEMENT. A seat that publishes no
89
+ * identity cannot be placed, and reporting AGREED over a population you could not read
90
+ * is the defect this row exists to end — it is `exit 0` meaning "asked and resolved" and
91
+ * "never reached the registry" at once, one layer up.
92
+ *
93
+ * ⭐ SO AN UNREADABLE SEAT POISONS THE VERDICT RATHER THAN BEING DROPPED FROM IT: with
94
+ * any seat uncomparable the answer is CANNOT_COMPARE, even when every seat that COULD be
95
+ * read agrees. The cohorts are still returned, so a reader sees what was established as
96
+ * well as what was not.
97
+ */
98
+ export function detectSpread(
99
+ identities: ServerIdentity[],
100
+ installed: InstalledBuild | null,
101
+ ): SpreadVerdict {
102
+ const uncomparable: Uncomparable[] = [];
103
+ const placed: { agentId: string; side: Cohort["side"]; module?: string }[] = [];
104
+
105
+ for (const id of identities) {
106
+ // ⛔ THE MODULE PATH IS READ FIRST, and a missing one is not a default. A seat that
107
+ // does not say what it is running cannot be compared against the installed build,
108
+ // and placing it by timestamp alone is exactly the AGREED-over-three-builds result.
109
+ if (typeof id.serverModule !== "string" || !id.serverModule) {
110
+ uncomparable.push({
111
+ agentId: id.agentId,
112
+ why: "publishes no module path — it cannot be shown to be running the installed build at all, and a timestamp cannot answer that",
113
+ });
114
+ continue;
115
+ }
116
+ if (typeof id.serverStartedAt !== "number" || !Number.isFinite(id.serverStartedAt)) {
117
+ uncomparable.push({
118
+ agentId: id.agentId,
119
+ why: "no serverStartedAt published — this seat's server has not stamped its identity since the field existed",
120
+ });
121
+ continue;
122
+ }
123
+ if (installed === null) continue;
124
+ // ⭐ A DIFFERENT BUILD IS ITS OWN COHORT, never folded into `pre-install`: "which
125
+ // side of the install did you start" is not a question about a process that does
126
+ // not load the install. Measured 2026-09-14 — 3 of 12 seats were in this case.
127
+ if (id.serverModule !== installed.module) {
128
+ placed.push({ agentId: id.agentId, side: "other-build", module: id.serverModule });
129
+ continue;
130
+ }
131
+ placed.push({ agentId: id.agentId, side: sideOf(id.serverStartedAt, installed.mtime) });
132
+ }
133
+
134
+ const cohorts: Cohort[] = [];
135
+ for (const side of ["pre-install", "post-install"] as const) {
136
+ const agents = placed.filter((p) => p.side === side).map((p) => p.agentId).sort();
137
+ if (agents.length) cohorts.push({ side, agents });
138
+ }
139
+ // One cohort PER FOREIGN BUILD, so two seats on different foreign builds never read
140
+ // as one group that agrees with itself.
141
+ for (const module of [...new Set(placed.filter((p) => p.side === "other-build").map((p) => p.module!))].sort()) {
142
+ cohorts.push({
143
+ side: "other-build",
144
+ module,
145
+ agents: placed.filter((p) => p.module === module).map((p) => p.agentId).sort(),
146
+ });
147
+ }
148
+ const comparable = placed.map((p) => p.agentId).sort();
149
+
150
+ if (installed === null) {
151
+ return {
152
+ state: "CANNOT_COMPARE",
153
+ why: "the installed build could not be read, so no seat can be placed against it",
154
+ cohorts,
155
+ comparable,
156
+ uncomparable,
157
+ };
158
+ }
159
+ if (uncomparable.length) {
160
+ return {
161
+ state: "CANNOT_COMPARE",
162
+ why:
163
+ `${uncomparable.length} of ${identities.length} live seat(s) publish no usable server identity, so the fleet ` +
164
+ `cannot be shown uniform — what the readable seats agree about is reported, but it is not an answer about the fleet`,
165
+ cohorts,
166
+ comparable,
167
+ uncomparable,
168
+ };
169
+ }
170
+ if (comparable.length < 2) {
171
+ return {
172
+ state: "CANNOT_COMPARE",
173
+ why: `only ${comparable.length} seat(s) could be placed — a disagreement needs two parties`,
174
+ cohorts,
175
+ comparable,
176
+ uncomparable,
177
+ };
178
+ }
179
+ return { state: cohorts.length > 1 ? "DIVERGED" : "AGREED", cohorts, comparable, uncomparable };
180
+ }
181
+
182
+ /**
183
+ * What a reader is told. The POPULATION is mandatory for the same reason the replay
184
+ * instrument's is (⟨q-a83b56af⟩): "0 diverged of 6 read" and "0 of 0" are different
185
+ * facts and only the second is a reason to distrust the run.
186
+ */
187
+ export function reportSpread(v: SpreadVerdict, installed: InstalledBuild | null): string {
188
+ const iso = (ms: number) => new Date(ms).toISOString();
189
+ const lines = [
190
+ `SERVER SPREAD: ${v.state} — ${v.comparable.length} seat(s) placed, ${v.uncomparable.length} unreadable` +
191
+ (installed === null
192
+ ? " · installed build UNREADABLE"
193
+ : ` · installed build ${iso(installed.mtime)} ${installed.module}`),
194
+ ];
195
+ if ("why" in v) lines.push(` ⛔ ${v.why}`);
196
+ for (const c of v.cohorts) {
197
+ lines.push(` ${c.side}${c.module ? ` (${c.module})` : ""}: ${c.agents.join(", ")}`);
198
+ }
199
+ for (const u of v.uncomparable) lines.push(` ⚠ ${u.agentId}: ${u.why}`);
200
+ if (v.state === "DIVERGED") {
201
+ lines.push(
202
+ ` These seats are running DIFFERENT CODE. A version label will agree anyway — it did`,
203
+ ` on 2026-09-12 while one server answered 153 and the other 138.`,
204
+ );
205
+ }
206
+ return lines.join("\n");
207
+ }
208
+
209
+ /**
210
+ * The build this process was INSTALLED from — its module root AND its mtime — or null.
211
+ *
212
+ * ⛔ NULL IS A REAL ANSWER AND MUST STAY ONE. If this cannot be read, no seat can be
213
+ * placed on either side of it and the only truthful verdict is CANNOT_COMPARE —
214
+ * returning a 0 or a now() would place every seat on one side and report the fleet
215
+ * uniform, which is the failure this whole row is about.
216
+ *
217
+ * Resolved from THIS module's own location rather than from a configured path: the
218
+ * question is "what build is the answering process running", and the answering process
219
+ * is the one executing this file.
220
+ */
221
+ export function installedBuild(
222
+ fromUrl: string,
223
+ statSync: (p: string) => { mtimeMs: number },
224
+ ): InstalledBuild | null {
225
+ try {
226
+ // dist/server-spread.js -> the package root two levels up
227
+ const here = new URL(fromUrl).pathname;
228
+ const root = here.replace(/\/dist\/[^/]*$/, "");
229
+ return { mtime: statSync(`${root}/package.json`).mtimeMs, module: root };
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
package/src/server.ts CHANGED
@@ -445,7 +445,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
445
445
 
446
446
  addTool(
447
447
  "send_message",
448
- "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured. EVERY AGENT→AGENT MESSAGE MUST CARRY 'record' with a typed 'type' (decision · verdict · done · blocker · risk · fyi · action · go · scope): a typed multi-line message is delivered as ONE attributed line plus a retrieve_message handle, while an untyped one arrives in full in every reader's context. 'fyi' is the honest catch-all — use it rather than forcing a false 'decision'/'risk'. Untyped sends WARN today and are REFUSED from 2026-09-15. Messages TO a human are exempt (David-facing traffic stays prose), as is a sender that declared proseOnly:true at join.",
448
+ "Send a message. A `done` record cites its PR, or — for work that has no PR by rule — a full commit sha with `repo` (verified reachable from origin/main, no network). If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured. EVERY AGENT→AGENT MESSAGE MUST CARRY 'record' with a typed 'type' (decision · verdict · done · blocker · risk · fyi · action · go · scope): a typed multi-line message is delivered as ONE attributed line plus a retrieve_message handle, while an untyped one arrives in full in every reader's context. 'fyi' is the honest catch-all — use it rather than forcing a false 'decision'/'risk'. Untyped sends WARN today and are REFUSED from 2026-09-15. Messages TO a human are exempt (David-facing traffic stays prose), as is a sender that declared proseOnly:true at join.",
449
449
  sendMessageSchema,
450
450
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
451
451
  );
@@ -631,7 +631,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
631
631
 
632
632
  addTool(
633
633
  "stall_check",
634
- "The stall predicate over the board and the bus: a 🚧 In Progress or 🔍 In Review row (both are lanes somebody is working; a review that never gets re-gated is the stall shape of a QA-gated fleet) whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
634
+ "The stall predicate over the board and the bus: a 🚧 In Progress or 🔍 In Review row (both are lanes somebody is working; a review that never gets re-gated is the stall shape of a QA-gated fleet) whose agent's heartbeat is older than the window, or — for 🚧 — whose claimed branch has no commits in the window; a 🔍 row is scored on TIME IN REVIEW read from the board's git history (`reviewMinutes`, default 120), never on branch activity, because a branch under review is frozen for the correct reason. Every scored row carries its board freshness (when its line was last written). HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
635
635
  stallCheckSchema,
636
636
  gate(null, stallCheckTool as (a: Record<string, unknown>) => Promise<unknown>),
637
637
  );
package/src/store.ts CHANGED
@@ -9,6 +9,38 @@ export const ROOT =
9
9
  process.env.CLAUDE_COORD_DIR ??
10
10
  path.join(homedir(), "agent-coord");
11
11
  export const AGENTS_FILE = path.join(ROOT, "agents.json");
12
+ /**
13
+ * ⟨q-178878aa⟩ — THE HUMANS THE BUS KNOWS, durably. A human is not an agent: no pusher, no
14
+ * heartbeat, so a registry entry for one is evicted after EVICT_MS and the typed-record
15
+ * exemption ("messages TO a human are exempt") could not see the one human it exists for —
16
+ * the first David-facing send after the cutover was refused. This file is written when a
17
+ * seat registers with a human role and never evicted; `AGENT_COORD_HUMANS` (comma list)
18
+ * names humans for a store nobody has registered them in yet. Read at SEND time.
19
+ */
20
+ export const HUMANS_FILE = path.join(ROOT, "humans.json");
21
+ export type HumanEntry = { since: number; displayName?: string; by?: string };
22
+ export type HumanRegistry = Record<string, HumanEntry>;
23
+ export function envHumans(): string[] {
24
+ return String(process.env.AGENT_COORD_HUMANS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
25
+ }
26
+ /** Every known human: the durable file, unioned with the environment list (`by: "env"`). */
27
+ export async function readHumans(): Promise<HumanRegistry> {
28
+ const file = await readJson<HumanRegistry>(HUMANS_FILE, {});
29
+ const out: HumanRegistry = { ...file };
30
+ for (const id of envHumans()) if (!out[id]) out[id] = { since: 0, by: "env" };
31
+ return out;
32
+ }
33
+ export async function isKnownHuman(id: string): Promise<boolean> {
34
+ return Boolean((await readHumans())[id]);
35
+ }
36
+ /** Record a human durably; an existing entry keeps its `since`. */
37
+ export async function recordHuman(id: string, meta: { displayName?: string; by?: string } = {}): Promise<HumanEntry> {
38
+ const reg = await updateJson<HumanRegistry>(HUMANS_FILE, {}, (cur) => {
39
+ cur[id] = { since: cur[id]?.since ?? Date.now(), ...(meta.displayName ? { displayName: meta.displayName } : {}), ...(meta.by ? { by: meta.by } : {}) };
40
+ return cur;
41
+ });
42
+ return reg[id];
43
+ }
12
44
  export const ROOM_FILE = path.join(ROOT, "room.jsonl");
13
45
  export const STATUS_FILE = path.join(ROOT, "status.jsonl");
14
46
  export const INBOX_DIR = path.join(ROOT, "inbox");