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.
- package/dist/capabilities.js +250 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/closing-line.js +83 -0
- package/dist/closing-line.js.map +1 -0
- package/dist/commit-cite.js +55 -0
- package/dist/commit-cite.js.map +1 -0
- package/dist/gated-head.js +67 -20
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +32 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/away.js +67 -7
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/board-ref.js +44 -4
- package/dist/tools/board-ref.js.map +1 -1
- package/dist/tools/event-kinds.js +5 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +31 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/messaging.js +64 -6
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +231 -38
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +52 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +173 -0
- package/dist/tools/seat-build.js.map +1 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +1095 -18
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +21 -2
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/package.json +1 -1
- package/scripts/coord-attention-clock.mjs +2 -0
- package/scripts/coord-stall-clock.mjs +52 -11
- package/src/capabilities.ts +264 -2
- package/src/closing-line.ts +85 -0
- package/src/commit-cite.ts +58 -0
- package/src/gated-head.ts +128 -26
- package/src/server-spread.ts +233 -0
- package/src/server.ts +2 -2
- package/src/store.ts +32 -0
- package/src/tools/away.ts +82 -9
- package/src/tools/board-ref.ts +70 -3
- package/src/tools/event-kinds.ts +17 -1
- package/src/tools/events.ts +33 -2
- package/src/tools/messaging.ts +63 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +248 -38
- package/src/tools/registry.ts +54 -3
- package/src/tools/seat-build.ts +194 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +21 -2
- 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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
167
|
+
* Was `head` gated by a typed PASS bound to it — in any channel, at any time?
|
|
105
168
|
*
|
|
106
|
-
* `
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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");
|