agent-coord-mcp 0.26.19 → 0.26.21

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 (56) hide show
  1. package/README.md +82 -0
  2. package/dist/capabilities.js +57 -1
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/gated-head.js +130 -0
  5. package/dist/gated-head.js.map +1 -0
  6. package/dist/server.js +23 -0
  7. package/dist/server.js.map +1 -1
  8. package/dist/tools/queue-write.js +431 -0
  9. package/dist/tools/queue-write.js.map +1 -0
  10. package/dist/tools/records.js +406 -70
  11. package/dist/tools/records.js.map +1 -1
  12. package/dist/tools/registry.js +34 -5
  13. package/dist/tools/registry.js.map +1 -1
  14. package/dist/tools/shared.js.map +1 -1
  15. package/dist/tools/stall.js +2 -1
  16. package/dist/tools/stall.js.map +1 -1
  17. package/dist/tools/transport.js +82 -42
  18. package/dist/tools/transport.js.map +1 -1
  19. package/dist/tools/tree-provenance.js +107 -0
  20. package/dist/tools/tree-provenance.js.map +1 -0
  21. package/dist/tools/work.js +95 -3
  22. package/dist/tools/work.js.map +1 -1
  23. package/dist/transports/config.js +82 -0
  24. package/dist/transports/config.js.map +1 -0
  25. package/dist/transports/index.js +113 -0
  26. package/dist/transports/index.js.map +1 -0
  27. package/dist/transports/tmux.js +140 -0
  28. package/dist/transports/tmux.js.map +1 -0
  29. package/dist/transports/types.js +86 -0
  30. package/dist/transports/types.js.map +1 -0
  31. package/hooks/peek-coord.mjs +0 -0
  32. package/hooks/tmux-pusher.mjs +33 -3
  33. package/package.json +14 -11
  34. package/scripts/coord-attention-clock.mjs +0 -0
  35. package/scripts/coord-node.sh +0 -0
  36. package/scripts/coord-stall-clock.mjs +0 -0
  37. package/scripts/coord-token.mjs +0 -0
  38. package/scripts/probe-tmux-liveness.sh +0 -0
  39. package/scripts/spawn-agent.sh +0 -0
  40. package/scripts/stop-agent.sh +0 -0
  41. package/scripts/typed-record-stats.mjs +0 -0
  42. package/src/capabilities.ts +104 -1
  43. package/src/gated-head.ts +134 -0
  44. package/src/server.ts +29 -0
  45. package/src/tools/queue-write.ts +485 -0
  46. package/src/tools/records.ts +409 -40
  47. package/src/tools/registry.ts +36 -5
  48. package/src/tools/shared.ts +12 -36
  49. package/src/tools/stall.ts +2 -1
  50. package/src/tools/transport.ts +96 -43
  51. package/src/tools/tree-provenance.ts +136 -0
  52. package/src/tools/work.ts +95 -3
  53. package/src/transports/config.ts +110 -0
  54. package/src/transports/index.ts +126 -0
  55. package/src/transports/tmux.ts +177 -0
  56. package/src/transports/types.ts +201 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * THE TMUX TRANSPORT — every `tmux` shell-out in this package, in one file.
3
+ *
4
+ * Before this, `spawnSync("tmux", …)` appeared at 7 sites in `tools/transport.ts`
5
+ * and the `has-session` caveat below was written out TWICE, verbatim, at two of
6
+ * them. A rule duplicated in two comments is a rule that will be re-derived
7
+ * wrongly at the third site somebody adds.
8
+ *
9
+ * WHAT THIS FILE DOES NOT DO, on purpose: spawn or reap the pusher process,
10
+ * read receipts, or schedule reminders. Delivery is a second process for THIS
11
+ * transport and will not be for a socket one, so that machinery stays with the
12
+ * tools that own it and reaches this file through `TmuxHost` below. An interface
13
+ * that baked in a pusher could not host an implementation that has none.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { TMUX_PUSH, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js";
17
+ /** `tmux -V` — is tmux on this host at all? */
18
+ export function tmuxAvailable() {
19
+ return spawnSync("tmux", ["-V"]).status === 0;
20
+ }
21
+ /**
22
+ * DOES THIS TARGET EXIST? The only tmux probe with discriminating power.
23
+ *
24
+ * `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
25
+ * DOES NOT — tmux exits 0 for any target, including a pane killed a moment ago,
26
+ * so that probe had ZERO discriminating power and reported every dead pane
27
+ * alive. Pinned to the BEHAVIOUR, not a version: measured identical on tmux
28
+ * 3.6b, 3.7b. A version-pinned claim rots on the next upgrade.
29
+ *
30
+ * Positive control, both directions, re-run on tmux 3.7b while extracting this:
31
+ * bogus target `%99999` -> has-session exit 1, display-message exit 0; live pane
32
+ * `%8` -> both exit 0. The wrong probe is wrong in only one direction, which is
33
+ * why it survived: it never reports a live pane dead.
34
+ */
35
+ export function paneExists(target) {
36
+ return spawnSync("tmux", ["has-session", "-t", target]).status === 0;
37
+ }
38
+ /**
39
+ * `has-session` with the failure text, for the one caller that reports it.
40
+ *
41
+ * `paneExists` is the boolean most sites want; attach quotes tmux's own stderr
42
+ * back to the user, and losing that text would make a refusal less useful while
43
+ * still typechecking — the exact class of silent regression this task guards.
44
+ */
45
+ export function probePane(target) {
46
+ const probe = spawnSync("tmux", ["has-session", "-t", target]);
47
+ return { exists: probe.status === 0, stderr: (probe.stderr ?? "").toString().trim() };
48
+ }
49
+ /** `tmux -V` output, or undefined when tmux is absent. */
50
+ export function tmuxVersion() {
51
+ const probe = spawnSync("tmux", ["-V"]);
52
+ if (probe.status !== 0)
53
+ return undefined;
54
+ return (probe.stdout ?? "").toString().trim() || undefined;
55
+ }
56
+ export class TmuxTransport {
57
+ kind;
58
+ #host;
59
+ constructor(host, kind = TMUX_PUSH) {
60
+ this.#host = host;
61
+ this.kind = kind;
62
+ }
63
+ available() {
64
+ return tmuxAvailable();
65
+ }
66
+ attach(args) {
67
+ return this.#host.attach(args);
68
+ }
69
+ detach(agentId) {
70
+ return this.#host.detach(agentId);
71
+ }
72
+ push(marker, text) {
73
+ return this.#host.push(marker, text);
74
+ }
75
+ sendControl(marker, cmd) {
76
+ return this.#host.sendControl(marker, cmd);
77
+ }
78
+ /**
79
+ * THREE ANSWERS, AND THE THIRD IS LOAD-BEARING.
80
+ *
81
+ * Each `unknown` below was previously a `continue` with a comment. The states
82
+ * are preserved exactly, because "we could not look" is not evidence of death
83
+ * and a reaper that cannot tell them apart kills live sessions:
84
+ *
85
+ * · not a tmux marker -> unknown (this transport cannot speak for it)
86
+ * · remote (foreign host) -> unknown (no local pane; heartbeat decides)
87
+ * · no target recorded -> unknown (nothing to probe)
88
+ * · tmux missing on host -> unknown (the instrument is absent, not the pane)
89
+ * · target absent -> dead
90
+ * · pusher gone, pane alive -> dead, and it says which half failed
91
+ */
92
+ async probe(marker) {
93
+ if (!isTmuxKind(marker.transport)) {
94
+ return { state: "unknown", reason: `transport "${marker.transport}" is not tmux` };
95
+ }
96
+ if (!isLocallyProbeable(marker.transport)) {
97
+ return {
98
+ state: "unknown",
99
+ reason: `${marker.transport} runs on another host${marker.host ? ` (${marker.host})` : ""} — no local pane to probe; liveness is heartbeat-based`,
100
+ };
101
+ }
102
+ const target = targetOf(marker);
103
+ if (!target)
104
+ return { state: "unknown", reason: "no target recorded on the marker" };
105
+ if (!tmuxAvailable())
106
+ return { state: "unknown", reason: "tmux is not available on this host" };
107
+ if (!paneExists(target))
108
+ return { state: "dead", reason: `pane ${target} does not exist` };
109
+ if (!this.#host.pusherAlive(marker)) {
110
+ return { state: "dead", reason: `pane ${target} is alive but its pusher (pid ${marker.pid}) is gone` };
111
+ }
112
+ return { state: "live" };
113
+ }
114
+ /**
115
+ * Reap pushers whose pane has gone, and say which ones could not be judged.
116
+ *
117
+ * `unprobeable` is NOT a residual list nobody reads — it is the distinction
118
+ * PRODUCTION_ROADMAP Phase 5.3 requires between "dead" and "cannot probe".
119
+ * An agent whose liveness is unknown is left alone and reported, never reaped.
120
+ */
121
+ async reapWedged(markers) {
122
+ const reaped = [];
123
+ const unprobeable = [];
124
+ for (const marker of markers) {
125
+ const live = await this.probe(marker);
126
+ if (live.state === "unknown") {
127
+ unprobeable.push(marker.agentId);
128
+ continue;
129
+ }
130
+ if (live.state === "live")
131
+ continue;
132
+ if (this.#host.killPusher(marker))
133
+ reaped.push(marker.agentId);
134
+ else
135
+ unprobeable.push(marker.agentId);
136
+ }
137
+ return { reaped, unprobeable };
138
+ }
139
+ }
140
+ //# sourceMappingURL=tmux.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tmux.js","sourceRoot":"","sources":["../../src/transports/tmux.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * THE TRANSPORT SEAM (Phase 5.4 Task 2).
3
+ *
4
+ * This interface is DERIVED from the 12 `"tmux-push"` literals and the 7
5
+ * `spawnSync("tmux", …)` shell-outs that were spread across `tools/`, not
6
+ * designed top-down. Where the phase doc's sketch and the code disagreed, the
7
+ * code won — three times, each recorded below, because the gate for this task is
8
+ * that a running fleet cannot tell the difference.
9
+ *
10
+ * 1. `tmux-push-remote` IS A LIVE KIND. The doc's sketch had two kinds; the code
11
+ * has three. `registry.ts` gives remote markers heartbeat-based liveness
12
+ * because their pid is 0 on a foreign host, and `server.ts` documents the
13
+ * value as the wire contract for `scripts/coord-pusher.mjs`. A union without
14
+ * it silently reclassifies every remote agent as not-tmux.
15
+ * 2. THE MARKER HAS NINE FIELDS, NOT SIX. `scriptMtime`, `serverBuildMtime` and
16
+ * `rooms` are absent from the sketch and each one closes a defect that
17
+ * actually shipped. They are kept verbatim, including the rule they share:
18
+ * ABSENT MEANS UNKNOWN, NEVER "ON" — the same principle as `Liveness.unknown`
19
+ * below, one field deeper.
20
+ * 3. `target` IS ADDITIVE, NOT A RENAME. See `targetOf`.
21
+ */
22
+ /**
23
+ * Every transport value that can appear in a marker on disk.
24
+ *
25
+ * Not `string`: the whole defect being fixed is code branching on a literal
26
+ * instead of reading the field, and a `string` discriminant cannot tell the
27
+ * compiler that a branch was missed. Not a two-member union either — see (1).
28
+ */
29
+ export const TMUX_PUSH = "tmux-push";
30
+ export const TMUX_PUSH_REMOTE = "tmux-push-remote";
31
+ export const HERDR = "herdr";
32
+ export const TRANSPORT_KINDS = [TMUX_PUSH, TMUX_PUSH_REMOTE, HERDR];
33
+ /**
34
+ * The tmux family. Both members are delivered by a pusher process typing into a
35
+ * pane; they differ in WHERE that pane is, which is why liveness splits below.
36
+ *
37
+ * THIS IS THE ONE PLACE THE TMUX LITERALS LIVE. Twelve call sites used to spell
38
+ * them; they now ask.
39
+ */
40
+ const TMUX_KINDS = new Set([TMUX_PUSH, TMUX_PUSH_REMOTE]);
41
+ /** Is this marker carried by the tmux family (local OR remote)? */
42
+ export function isTmuxKind(transport) {
43
+ return transport !== undefined && TMUX_KINDS.has(transport);
44
+ }
45
+ /**
46
+ * Is this marker's pane on THIS host, so that a local probe means anything?
47
+ *
48
+ * The distinction every `marker.transport !== "tmux-push"` site was making by
49
+ * hand, with a comment explaining "remote = can't verify". Naming it stops the
50
+ * next person re-deriving it — and re-deriving it wrongly is how a remote agent
51
+ * gets reported dead because a pane that was never local did not answer.
52
+ */
53
+ export function isLocallyProbeable(transport) {
54
+ return transport === TMUX_PUSH;
55
+ }
56
+ /**
57
+ * Is liveness for this marker decided by the registry heartbeat rather than by a
58
+ * local pid? True only for the remote kind, whose pid is 0 on a foreign host.
59
+ */
60
+ export function isRemoteTmuxKind(transport) {
61
+ return transport === TMUX_PUSH_REMOTE;
62
+ }
63
+ /**
64
+ * Read a marker's address, preferring the generic field and falling back to the
65
+ * tmux-specific one.
66
+ *
67
+ * This is the read half of the dual-write. Call it rather than touching either
68
+ * field: a consumer that reads only `tmuxTarget` goes blind the day a herdr
69
+ * marker appears, and one that reads only `target` is blind to every marker on
70
+ * disk today.
71
+ */
72
+ export function targetOf(marker) {
73
+ return marker.target ?? marker.tmuxTarget;
74
+ }
75
+ /**
76
+ * Stamp an address into BOTH fields, so the marker is legible to the current
77
+ * code and to whatever a revert restores.
78
+ */
79
+ export function withTarget(marker, target) {
80
+ if (target === undefined)
81
+ return marker;
82
+ return { ...marker, target, tmuxTarget: target };
83
+ }
84
+ /** Keystroke-shaped commands a transport must deliver to an interactive pane. */
85
+ export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"];
86
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transports/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAC;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAA2B,CAAC;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAG,OAAgB,CAAC;AAItC,MAAM,CAAC,MAAM,eAAe,GAA6B,CAAC,SAAS,EAAE,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAEvG;;;;;;GAMG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElE,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,SAA6B;IACtD,OAAO,SAAS,KAAK,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAA6B;IAC9D,OAAO,SAAS,KAAK,SAAS,CAAC;AACjC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAA6B;IAC5D,OAAO,SAAS,KAAK,gBAAgB,CAAC;AACxC,CAAC;AAoDD;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAsD;IAC7E,OAAO,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAmB,MAAS,EAAE,MAA0B;IAChF,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAeD,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC"}
File without changes
@@ -288,7 +288,11 @@ function readJsonl(file) {
288
288
  // Allowlisted control commands the bus may type into this CLI. Mirrors
289
289
  // CONTROL_COMMANDS in src/tools/transport.ts and scripts/coord-pusher.mjs
290
290
  // (test/allowlist-parity.test.mjs locks the three); kept tiny + literal so a bugged/compromised
291
- // sender can't smuggle an arbitrary slash command past the guard below.
291
+ // sender can't smuggle an arbitrary slash command onto the RAW submit path.
292
+ // ⚠ THIS SET IS THE GATE. It used to be backed by a second check that dropped any
293
+ // text starting with "/"; that check was removed in ⟨q-f14692ca⟩ because it silently
294
+ // discarded legitimate messages and never saw the multi-line shape it was aimed at.
295
+ // Membership here, plus bracketed paste on the peer path, is now the whole defence.
292
296
  const CONTROL_COMMANDS = new Set(["/clear", "/compact", "/reload-skills"]);
293
297
 
294
298
  // Shells we refuse to inject into: if the pane's foreground command is one of
@@ -321,9 +325,35 @@ function isControl(m) {
321
325
  function shouldInject(m) {
322
326
  if (!m || m.from === AGENT_ID) return false;
323
327
  if (ALLOWLIST.length > 0 && !ALLOWLIST.includes(m.from)) return false;
324
- // Authorized control command — bypass the slash guard, injected raw later.
328
+ // Authorized control command — injected RAW later, so the allowlist in
329
+ // `isControl` is the gate that matters. Checked first and unchanged.
325
330
  if (isControl(m)) return true;
326
- if (typeof m.text === "string" && m.text.trimStart().startsWith("/")) return false;
331
+
332
+ // ⛔ THE SLASH DROP USED TO LIVE HERE AND IT IS GONE ON PURPOSE (⟨q-f14692ca⟩).
333
+ //
334
+ // It returned false for any text starting with "/", which SILENTLY discarded
335
+ // the message: the sender was told nothing, and `collectSource` advances the
336
+ // offset past rejected lines, so it was never retried either. Measured in an
337
+ // isolated pane: a plain message and a trailing message both arrived, the
338
+ // slash-leading one between them did not, and the pusher log said nothing.
339
+ //
340
+ // AND IT WAS PROTECTING NOTHING THAT IS NOT ALREADY PROTECTED TWICE, measured
341
+ // rather than reasoned:
342
+ // 1. `formatBatch` prefixes every peer line with ` [DM hh:mm from] `, so a
343
+ // single-line "/foo" reaches the pane with ZERO lines starting with "/" —
344
+ // it is chat text, not a command.
345
+ // 2. Ordinary batches paste with `bracketed = true`, which makes the whole
346
+ // payload inert: embedded newlines cannot submit a line or smuggle a
347
+ // "/command". That is the defence that actually covers the multi-line
348
+ // case, which this check never caught — `trimStart()` only ever examined
349
+ // the FIRST line, so "opener\n/clear" passed straight through it.
350
+ //
351
+ // So the old guard was wrong in both directions at once: it dropped safe
352
+ // single-line messages, and it did not see the multi-line shape it was aimed
353
+ // at. Removing it restores delivery; the protection that matters is the
354
+ // allowlist above plus bracketed paste, and BOTH are pinned by tests in
355
+ // test/slash-message-delivery.test.mjs. ⚠ If anyone makes the peer path paste
356
+ // raw, those tests fail — which is the price of not having this check.
327
357
  return true;
328
358
  }
329
359
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.19",
3
+ "version": "0.26.21",
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": {
@@ -8,6 +8,18 @@
8
8
  "coord-chat": "scripts/coord-chat.mjs",
9
9
  "coord-pusher": "scripts/coord-pusher.mjs"
10
10
  },
11
+ "scripts": {
12
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
13
+ "build": "pnpm run clean && tsc",
14
+ "prepare": "node scripts/check-self-dependency.mjs && pnpm run clean && tsc",
15
+ "prepack": "node scripts/check-self-dependency.mjs && pnpm run build",
16
+ "prepublishOnly": "node scripts/check-publish-tool.mjs",
17
+ "start": "node dist/server.js",
18
+ "dev": "tsx src/server.ts",
19
+ "pretest": "node scripts/check-self-dependency.mjs && node scripts/check-optional-call.mjs && tsc",
20
+ "test": "node scripts/check-test-count.mjs",
21
+ "test:raw": "node --test \"test/*.test.mjs\""
22
+ },
11
23
  "files": [
12
24
  "dist",
13
25
  "src",
@@ -53,14 +65,5 @@
53
65
  "@types/proper-lockfile": "^4.1.4",
54
66
  "tsx": "^4.23.12",
55
67
  "typescript": "^7.0.2"
56
- },
57
- "scripts": {
58
- "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
59
- "build": "pnpm run clean && tsc",
60
- "start": "node dist/server.js",
61
- "dev": "tsx src/server.ts",
62
- "pretest": "node scripts/check-self-dependency.mjs && node scripts/check-optional-call.mjs && tsc",
63
- "test": "node scripts/check-test-count.mjs",
64
- "test:raw": "node --test \"test/*.test.mjs\""
65
68
  }
66
- }
69
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -39,6 +39,14 @@ import { suggestRecordType, typedRecordMode } from "./typed-records.js";
39
39
  import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
40
40
  import { recordAuthorityFor } from "./roles.js";
41
41
  import { subscriptionHealth } from "./tools/events.js";
42
+ import {
43
+ configuredTransport,
44
+ runningTransport,
45
+ isTmuxKind,
46
+ targetOf,
47
+ type TransportKind,
48
+ } from "./transports/index.js";
49
+ import { readAllTransportMarkers } from "./tools/registry.js";
42
50
 
43
51
  export type ProbeResult = {
44
52
  id: string;
@@ -129,6 +137,44 @@ const PROBES: Probe[] = [
129
137
  },
130
138
  ];
131
139
 
140
+ /**
141
+ * TWO CLAIMS, AND THE TYPE MAKES YOU CARRY BOTH (Phase 5.4 Task 3.3).
142
+ *
143
+ * *A config value is a label someone typed.* `configured` is that label;
144
+ * `running` is answered by CALLING the transport in this process. They are
145
+ * separate fields because they are separate facts, and `agrees` exists so a
146
+ * reader cannot skim one and believe the other.
147
+ *
148
+ * ⛔ THIS IS THE MACHINE-READABLE FORM OF A MISTAKE MADE IN PROSE. An hour
149
+ * before this shipped, a Task 2 contract asserted that "every seat in this fleet
150
+ * is running on the code you are refactoring." Measured afterwards: every server
151
+ * and every pusher loads the global install at 0.26.19 while `main` declared
152
+ * 0.26.20 — the fleet was running PRE-refactor code and always had been. The
153
+ * claim came from what the repo said, not from what any process answered. That
154
+ * is `configured` reported as `running`, in English instead of in a type.
155
+ *
156
+ * `running` is `undefined` when nothing is wired, which is NOT "tmux by
157
+ * default": a process with no transport delivers nothing, and substituting a
158
+ * default there would restage the same substitution one layer down.
159
+ */
160
+ export type TransportCapability = {
161
+ configured: TransportKind;
162
+ /** Answered by calling the transport, never by reading config. */
163
+ running: TransportKind | undefined;
164
+ agrees: boolean;
165
+ /** What was called and what came back. */
166
+ evidence: string;
167
+ /** Where `configured` came from — config file, env, or the built-in default. */
168
+ configuredSource: "config" | "env" | "default";
169
+ /**
170
+ * MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
171
+ * than the running one. Whole-fleet is the rule; this is the code noticing
172
+ * when reality disagrees with the rule rather than trusting it — at 2-of-5
173
+ * stall coverage a silent disagreement is a failure nobody can see.
174
+ */
175
+ disagreeingAgents: { agentId: string; marker: string }[];
176
+ };
177
+
132
178
  export type CapabilityReport = {
133
179
  /**
134
180
  * WHICH PROCESS ANSWERED. Not evidence of anything — context, so a reader can
@@ -140,6 +186,8 @@ export type CapabilityReport = {
140
186
  probes: ProbeResult[];
141
187
  missing: string[];
142
188
  ok: boolean;
189
+ /** Absent only if the probe itself threw — see `probeTransport`. */
190
+ transport?: TransportCapability;
143
191
  note: string;
144
192
  };
145
193
 
@@ -182,7 +230,62 @@ import { resolveServerIdentity } from "./server-identity.js";
182
230
 
183
231
  export const capabilitiesSchema = {} as const;
184
232
 
233
+ /**
234
+ * Build the transport capability by CALLING things, then compare.
235
+ *
236
+ * Deliberately async and deliberately separate from `probeCapabilities`, which
237
+ * is synchronous: the transport answer requires I/O (a `tmux -V`, a pane probe),
238
+ * and making the sync report do I/O to obtain it would have meant reading the
239
+ * config instead — which is the substitution this whole field exists to refuse.
240
+ */
241
+ export async function probeTransport(): Promise<TransportCapability> {
242
+ const conf = configuredTransport();
243
+ const running = await runningTransport();
244
+
245
+ // 3.4 — a mixed fleet must be detectable and LOUD. Every marker on disk is
246
+ // read and any that names a different transport is listed by agent, because a
247
+ // count alone tells you something is wrong and not where to look.
248
+ const disagreeingAgents: { agentId: string; marker: string }[] = [];
249
+ try {
250
+ // READ-ONLY on purpose. The reaping loader would delete markers it judged
251
+ // not-live, so a diagnostic would mutate the fleet it is describing — and it
252
+ // would hide the remote kind, whose liveness needs a registry heartbeat.
253
+ for (const { marker, live } of await readAllTransportMarkers()) {
254
+ if (running.kind !== undefined && marker.transport !== running.kind) {
255
+ const where = isTmuxKind(marker.transport) ? targetOf(marker) : undefined;
256
+ disagreeingAgents.push({
257
+ agentId: marker.agentId,
258
+ marker: `${marker.transport}${where ? ` (${where})` : ""}${live ? "" : " [stale]"}`,
259
+ });
260
+ }
261
+ }
262
+ } catch {
263
+ // An unreadable transports dir is not evidence of a uniform fleet. Left
264
+ // empty, and the caller can see `running` was still answered.
265
+ }
266
+
267
+ return {
268
+ configured: conf.kind,
269
+ running: running.kind,
270
+ agrees: running.kind === conf.kind,
271
+ evidence: running.evidence,
272
+ configuredSource: conf.source,
273
+ disagreeingAgents,
274
+ };
275
+ }
276
+
185
277
  export async function capabilitiesTool() {
186
278
  const id = resolveServerIdentity();
187
- return probeCapabilities({ module: id.path, versionLabel: id.version });
279
+ const report = probeCapabilities({ module: id.path, versionLabel: id.version });
280
+ try {
281
+ return { ...report, transport: await probeTransport() };
282
+ } catch (e) {
283
+ // A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
284
+ // refuses at startup by design, and this verb is exactly what an operator
285
+ // reaches for to find out why — so it must still answer, and say what threw.
286
+ return {
287
+ ...report,
288
+ transportError: `transport probe threw: ${(e as Error).message}`,
289
+ };
290
+ }
188
291
  }
@@ -0,0 +1,134 @@
1
+ /*
2
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — AND NOTHING COMPARED THEM.
3
+ *
4
+ * `⟨q-6a4f0c38⟩`, from a real breach on kit#268. The sequence, which no single
5
+ * seat could see alone:
6
+ *
7
+ * 10:23:14Z QA posts PASS bound to 6051193
8
+ * 10:23:22Z the merge runs <- 8 seconds later
9
+ * but the branch head was 1d6deab by then: an author force-pushed
10
+ * 10:28:06Z QA re-issues PASS bound to 1d6deab, AFTER the fact
11
+ *
12
+ * Three controls had to fail together: the force-push created the opportunity,
13
+ * the merge did not re-read the head, and the recording step did not compare.
14
+ * This module is the predicate for the two that are mechanisable.
15
+ *
16
+ * ⭐ WHY THE CHECK MUST BE TEMPORAL, and this is the trap that makes the naive
17
+ * version certify the very breach it was built for: by the time anyone looks,
18
+ * a PASS bound to `1d6deab` EXISTS. "Is there a PASS for the merged head?" is
19
+ * TRUE for #268 today. Only "was there one AT OR BEFORE the merge" is false.
20
+ * A re-issued verdict is an honest record that the CONTENT was verified; it is
21
+ * not evidence that the MERGE was gated, and conflating them erases the event.
22
+ *
23
+ * ⭐ WHY THE SHA COMPARISON IS PREFIX-TOLERANT RATHER THAN EQUALITY. Measured
24
+ * across all 45 verdict records on this fleet's room log:
25
+ *
26
+ * headRefOid length: 7 chars -> 14 records, 40 chars -> 31 records
27
+ *
28
+ * Verdicts are written by hand and abbreviate. An exact compare against `gh`'s
29
+ * 40-char head would report "never gated" on ~31% of verdicts that DID gate —
30
+ * false positives on correct merges, which is the failure that gets a check
31
+ * switched off. MIN_SHA guards the other direction: a 4-character "sha" is not
32
+ * an identifier, it is a collision, so it is REFUSED rather than matched.
33
+ *
34
+ * ⛔ AND "COULD NOT CHECK" IS NEVER "CHECKED AND CLEAN" — the rule this repo
35
+ * already applies in `versionDrift`. No verdict found, an unreadable log and an
36
+ * unreachable `gh` are each a REFUSAL with a reason, never a pass.
37
+ */
38
+
39
+ /** Below this, an abbreviation is a collision rather than an identifier. */
40
+ export const MIN_SHA = 7;
41
+
42
+ /**
43
+ * Do two object names refer to the same commit, allowing either to be
44
+ * abbreviated? Case-insensitive; hex only; both must reach MIN_SHA.
45
+ */
46
+ export function shaAgrees(a: string | undefined, b: string | undefined): boolean {
47
+ const x = (a ?? "").trim().toLowerCase();
48
+ const y = (b ?? "").trim().toLowerCase();
49
+ if (!/^[0-9a-f]+$/.test(x) || !/^[0-9a-f]+$/.test(y)) return false;
50
+ if (x.length < MIN_SHA || y.length < MIN_SHA) return false;
51
+ return x.length <= y.length ? y.startsWith(x) : x.startsWith(y);
52
+ }
53
+
54
+ export type PassVerdict = { head: string; ts: number; from: string; result: string };
55
+
56
+ /** The PR number a verdict record is about, from its `cites`. */
57
+ function citedPr(cites: unknown): string | null {
58
+ if (!Array.isArray(cites)) return null;
59
+ for (const c of cites) {
60
+ const ref = String((c as { ref?: unknown })?.ref ?? "");
61
+ const m = ref.match(/#(\d+)/);
62
+ if (m) return m[1];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Every verdict record in a room log that is about PR `pr`.
69
+ *
70
+ * Tolerant of unparseable lines by SKIPPING them and reporting how many, rather
71
+ * than throwing: a log this cannot fully read still answers the question for the
72
+ * lines it can, and the caller is told the denominator it actually saw.
73
+ */
74
+ export function verdictsFor(logText: string, pr: string): { verdicts: PassVerdict[]; lines: number; unparsed: number } {
75
+ const lines = logText.split("\n").filter((l) => l.trim());
76
+ let unparsed = 0;
77
+ const verdicts: PassVerdict[] = [];
78
+ for (const l of lines) {
79
+ let o: Record<string, unknown>;
80
+ try {
81
+ o = JSON.parse(l) as Record<string, unknown>;
82
+ } catch {
83
+ unparsed++;
84
+ continue;
85
+ }
86
+ const r = o.record as { type?: string; payload?: Record<string, unknown>; cites?: unknown } | undefined;
87
+ if (!r || r.type !== "verdict") continue;
88
+ if (citedPr(r.cites) !== pr) continue;
89
+ verdicts.push({
90
+ head: String(r.payload?.headRefOid ?? ""),
91
+ ts: Number(o.ts ?? 0),
92
+ from: String(o.from ?? ""),
93
+ result: String(r.payload?.result ?? ""),
94
+ });
95
+ }
96
+ return { verdicts, lines: lines.length, unparsed };
97
+ }
98
+
99
+ export type GateAnswer =
100
+ | { gated: true; by: PassVerdict }
101
+ | { gated: false; reason: string; crossed?: { gatedSha: string; at: number }[] };
102
+
103
+ /**
104
+ * Was `head` gated by a PASS that existed at or before `at`?
105
+ *
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.
109
+ */
110
+ export function gatedAt(verdicts: PassVerdict[], head: string, at: number): GateAnswer {
111
+ const passes = verdicts.filter((v) => v.result === "pass");
112
+ 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` };
114
+ }
115
+ const inTime = passes.filter((v) => v.ts <= at);
116
+ if (inTime.length === 0) {
117
+ return {
118
+ 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 })),
123
+ };
124
+ }
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
+ };
134
+ }