agent-coord-mcp 0.26.22 → 0.26.24

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 (54) hide show
  1. package/dist/capabilities.js +70 -0
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/gated-head.js +160 -5
  4. package/dist/gated-head.js.map +1 -1
  5. package/dist/tools/away.js +20 -2
  6. package/dist/tools/away.js.map +1 -1
  7. package/dist/tools/event-kinds.js.map +1 -1
  8. package/dist/tools/events.js +9 -2
  9. package/dist/tools/events.js.map +1 -1
  10. package/dist/tools/herdr-delivery.js +99 -0
  11. package/dist/tools/herdr-delivery.js.map +1 -0
  12. package/dist/tools/messaging.js +8 -0
  13. package/dist/tools/messaging.js.map +1 -1
  14. package/dist/tools/queue-write.js +4 -1
  15. package/dist/tools/queue-write.js.map +1 -1
  16. package/dist/tools/record-events.js +42 -4
  17. package/dist/tools/record-events.js.map +1 -1
  18. package/dist/tools/records.js +85 -9
  19. package/dist/tools/records.js.map +1 -1
  20. package/dist/tools/registry.js +15 -1
  21. package/dist/tools/registry.js.map +1 -1
  22. package/dist/tools/seat-build.js +10 -1
  23. package/dist/tools/seat-build.js.map +1 -1
  24. package/dist/tools/stall.js +73 -9
  25. package/dist/tools/stall.js.map +1 -1
  26. package/dist/tools/tick.js +77 -0
  27. package/dist/tools/tick.js.map +1 -0
  28. package/dist/tools/transport.js +50 -1
  29. package/dist/tools/transport.js.map +1 -1
  30. package/dist/transports/herdr.js +381 -0
  31. package/dist/transports/herdr.js.map +1 -0
  32. package/dist/transports/index.js +10 -4
  33. package/dist/transports/index.js.map +1 -1
  34. package/dist/transports/types.js +41 -0
  35. package/dist/transports/types.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/capabilities.ts +73 -0
  38. package/src/gated-head.ts +181 -5
  39. package/src/tools/away.ts +38 -2
  40. package/src/tools/event-kinds.ts +2 -1
  41. package/src/tools/events.ts +9 -2
  42. package/src/tools/herdr-delivery.ts +87 -0
  43. package/src/tools/messaging.ts +8 -0
  44. package/src/tools/queue-write.ts +4 -1
  45. package/src/tools/record-events.ts +46 -4
  46. package/src/tools/records.ts +74 -8
  47. package/src/tools/registry.ts +14 -1
  48. package/src/tools/seat-build.ts +8 -1
  49. package/src/tools/stall.ts +80 -12
  50. package/src/tools/tick.ts +117 -0
  51. package/src/tools/transport.ts +48 -0
  52. package/src/transports/herdr.ts +385 -0
  53. package/src/transports/index.ts +10 -4
  54. package/src/transports/types.ts +66 -0
@@ -1,5 +1,5 @@
1
1
  import { detachAgentTool } from "./transport.js";
2
- import { isLocallyProbeable, isRemoteTmuxKind, targetOf } from "../transports/index.js";
2
+ import { isLocallyProbeable, isRemoteTmuxKind, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js";
3
3
  import { readAway, secondCoordinatorRefusal } from "./away.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { existsSync, openSync, statSync, watch } from "node:fs";
@@ -525,6 +525,19 @@ export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: n
525
525
  const entry = reg[marker.agentId];
526
526
  return !!entry && now - entry.lastHeartbeat < STALE_MS;
527
527
  }
528
+ // Phase 5.4 Task 4 — a herdr marker has no pid (0): liveness is the PANE, asked of
529
+ // herdr itself. "Could not ask" keeps the marker (unknown is not dead); only herdr
530
+ // saying pane_not_found lets the registry drop it.
531
+ if (marker.transport === HERDR) {
532
+ const t = targetOf(marker);
533
+ if (!t) return false;
534
+ // Ask through the WIRED herdr transport (its runner is what tests inject and what the
535
+ // fleet configured). A server with no herdr transport wired cannot ask, and "cannot
536
+ // ask" keeps the marker: the herdr-configured server reaps its own dead panes.
537
+ const active = activeTransport();
538
+ const exists = active?.kind === HERDR && active instanceof HerdrTransport ? active.paneExists(t) : null;
539
+ return exists !== false;
540
+ }
528
541
  return isPidAlive(marker.pid);
529
542
  }
530
543
 
@@ -104,7 +104,11 @@ export function seatBuildOf(input: { agentId: string; marker: TransportMarker |
104
104
  // ---- pusher half
105
105
  let pusher: SeatBuild["pusher"];
106
106
  const empty = { pid: null, alive: null, keyedByAgentArg: null, startedAt: null, hookPath: null, hookMtime: null, sameTreeAsServer: null, current: null };
107
- if (!marker || typeof marker.pid !== "number") {
107
+ if (marker && marker.transport === "herdr") {
108
+ // Phase 5.4 Task 4 — a herdr seat has NO pusher: delivery is in-process by the server,
109
+ // so the seat's build is the server half alone, and this says so instead of reading pid 0.
110
+ pusher = { ...empty, note: `herdr socket transport (pane ${marker.target ?? marker.tmuxTarget ?? "?"}): no pusher process — delivery is in-process by the answering server, so the server half is the whole seat` };
111
+ } else if (!marker || typeof marker.pid !== "number") {
108
112
  pusher = { ...empty, note: "no transport marker for this seat — no pusher pid to read" };
109
113
  } else {
110
114
  const facts = ps(marker.pid);
@@ -183,6 +187,9 @@ export function seatBuildOf(input: { agentId: string; marker: TransportMarker |
183
187
  } else if (s === false && p === true) {
184
188
  verdict = "half-restarted";
185
189
  statement = `${agentId}: HALF-RESTARTED the other way — pusher on the installed hook, server started before the ${day(server!.buildMtime!)} install. Reload the server (relaunch the MCP client for this seat).`;
190
+ } else if (marker?.transport === "herdr" && s !== null) {
191
+ verdict = s ? "current" : "stale";
192
+ statement = `${agentId}: ${s ? "current" : "stale"} — herdr socket transport, no pusher; the server ${s ? "started after" : "predates"} the ${day(server!.buildMtime!)} install and is the whole seat.`;
186
193
  } else if (s === null && p !== null) {
187
194
  verdict = "unknown";
188
195
  statement = `${agentId}: pusher ${p ? "current" : "STALE — started before the installed hook"}; server UNOBSERVABLE from here (${srv.note}).`;
@@ -15,7 +15,8 @@
15
15
  * from "nothing ran". A check that only speaks when it fires cannot be told from
16
16
  * a broken one.
17
17
  */
18
- import { isLocallyProbeable } from "../transports/index.js";
18
+ import { activeTransport, isLocallyProbeable } from "../transports/index.js";
19
+ import { readFleetTick, tickByAgent, tickVerdict, type FleetTick, type SeatTick } from "./tick.js";
19
20
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
20
21
  import { execFileSync } from "node:child_process";
21
22
  import path from "node:path";
@@ -24,7 +25,7 @@ import { parseWorkDoc, workstreamsV1RowsOf, workstreamsExtensionsOf, isMalformed
24
25
  import { ROOT, AGENTS_FILE, readJson } from "../store.js";
25
26
  import { classifyBoardRef, refInCell, rowKindOf, cellKindOf } from "./board-ref.js";
26
27
  import { loadLiveTransports } from "./registry.js";
27
- import { verdictsFor, shaAgrees, verdictShasIn } from "../gated-head.js";
28
+ import { verdictsFor, shaAgrees, verdictShasIn, verdictBoundTo } from "../gated-head.js";
28
29
  import { closingsIn, ACCEPTED_FORMS, type Closing } from "../closing-line.js";
29
30
 
30
31
  const BOARD_DOC = "docs/WORKSTREAMS.md";
@@ -142,6 +143,8 @@ export type StallHit = StallHitBody & { audience: HitAudience };
142
143
  export type StallHitBody =
143
144
  | { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
144
145
  | { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
146
+ /** ⟨q-1c95f7d4⟩ Task 5 — an EXTERNAL observer says the seat is waiting on a person. Not inferred from the branch, and not a verdict about the lane. */
147
+ | { kind: "seat-blocked"; agentId: string; stream: string; source: string; why: string }
145
148
  /**
146
149
  * ⟨q-7b2f6c04⟩ — THE CLAIM AXIS: a lane claimed (its row entered in-flight,
147
150
  * read from the board's git history) whose branch has nothing on origin —
@@ -262,7 +265,28 @@ export type RepoArtefacts = {
262
265
  */
263
266
  recentMerges?: RecentMerge[] | null;
264
267
  };
265
- export type RecentMerge = { n: number; headRefOid: string; mergedAt: string; comments: { body: string }[] };
268
+ export type RecentMerge = { n: number; headRefOid: string; mergedAt: string; comments: { body: string }[]; files?: string[] };
269
+
270
+ /*
271
+ * ⛔ AN EXPRESS-BY-DESIGN MERGE WITH NO VERDICT IS NOT A DEFECT — ruled by the
272
+ * coordinator 2026-09-15 after this predicate reported `#966` as a stall. `#966` is
273
+ * ONE FILE, `+3/-3`, on `docs/WORKSTREAMS.md`, and David's standing rule is that ANY
274
+ * `.md` change goes direct to main: no PR, no branch, no CI. It legitimately required
275
+ * no verdict, so reporting it trained the room to ignore the check.
276
+ *
277
+ * ⭐ THE EXEMPTION IS MEASURED FROM THE MERGE ITSELF, NOT FROM A LABEL SOMEBODY HAS TO
278
+ * REMEMBER TO SET. A docs-only file set is a fact `gh` already returns; a lane marker
279
+ * is a convention, and this row exists because a convention with zero adoption silently
280
+ * broke the instrument that depended on it.
281
+ *
282
+ * ⚠ AND IT COVERS ONLY THE DERIVABLE HALF, WHICH IS SAID RATHER THAN IMPLIED: a SMALL
283
+ * CODE PR merged express is also exempt by policy and is NOT visible in the file set.
284
+ * That needs a real lane input from the caller — `expressPrs` below — which nothing
285
+ * populates yet. So: docs-only is closed here; express-code remains open, and a caller
286
+ * that knows the lane can pass it without touching this file.
287
+ */
288
+ export const isDocsOnlyMerge = (files: string[] | undefined): boolean =>
289
+ Array.isArray(files) && files.length > 0 && files.every((f) => /\.mdx?$/i.test(String(f)));
266
290
  const NET_TIMEOUT_MS = 15_000;
267
291
  export const realArtefacts = (repo: string): RepoArtefacts => {
268
292
  let openPrs: OpenPrFact[] | null = null;
@@ -283,11 +307,12 @@ export const realArtefacts = (repo: string): RepoArtefacts => {
283
307
  } catch { /* landedness falls back to patch ids; said so by the caller */ }
284
308
  let recentMerges: RecentMerge[] | null = null;
285
309
  try {
286
- const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments"], {
310
+ const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments,files"], {
287
311
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
288
312
  });
289
- recentMerges = (JSON.parse(out) as { number: number; headRefOid: string; mergedAt: string; comments?: { body?: string }[] }[]).map((p) => ({
313
+ recentMerges = (JSON.parse(out) as { number: number; headRefOid: string; mergedAt: string; comments?: { body?: string }[]; files?: { path?: string }[] }[]).map((p) => ({
290
314
  n: p.number, headRefOid: p.headRefOid, mergedAt: p.mergedAt, comments: (p.comments ?? []).map((c) => ({ body: String(c.body ?? "") })),
315
+ files: (p.files ?? []).map((f) => String(f.path ?? "")).filter(Boolean),
291
316
  }));
292
317
  } catch { /* said by the caller */ }
293
318
  let remoteHeads: { name: string; sha: string }[] | null = null;
@@ -344,7 +369,7 @@ export const CONVENTION_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
344
369
  */
345
370
  export const CONVENTION_E_ADOPTED_MS = Date.parse("2026-09-14T13:28:00Z");
346
371
  // The verdict grammar lives with the gate predicate now (⟨q-5a93c2d7⟩); re-exported so nothing that reached it here breaks.
347
- export { VERDICT_COMMENT, verdictShasIn } from "../gated-head.js";
372
+ export { VERDICT_COMMENT, verdictShasIn, verdictClaimsIn, verdictBoundTo } from "../gated-head.js";
348
373
  /** Items named ON A GO LINE of a `go` record — position, not mention. */
349
374
  export function routedItemsIn(logText: string): { itemId: string; by: string; ts: number }[] {
350
375
  const out: { itemId: string; by: string; ts: number }[] = [];
@@ -467,6 +492,8 @@ export function mergeWindowChecks(input: { logText: string; writes: RecordWrite[
467
492
 
468
493
  export function conventionChecks(input: {
469
494
  recentMerges: RecentMerge[] | null | undefined;
495
+ /** PRs the caller knows merged express. Nothing populates this yet — see isDocsOnlyMerge. */
496
+ expressPrs?: number[];
470
497
  routingLogText: string;
471
498
  queueText: string | null;
472
499
  boardRows: WorkstreamsV1Row[];
@@ -475,7 +502,7 @@ export function conventionChecks(input: {
475
502
  }): {
476
503
  hits: StallHit[];
477
504
  unmeasurable: { agentId: string; value: string; why: string }[];
478
- merges: { pr: number; head: string; minutes: number; verdictAtHead: boolean; inWindow: boolean; beforeAdoption: boolean }[] | null;
505
+ merges: { pr: number; head: string; minutes: number; verdictAtHead: boolean; inWindow: boolean; beforeAdoption: boolean; exempt?: string }[] | null;
479
506
  routed: { itemId: string; by: string; minutes: number; open: boolean | null; onBoard: boolean; inWindow: boolean }[];
480
507
  closings: (Closing & { inWindow: boolean })[];
481
508
  } {
@@ -483,6 +510,7 @@ export function conventionChecks(input: {
483
510
  const hits: StallHitBody[] = [];
484
511
  const unmeasurable: { agentId: string; value: string; why: string }[] = [];
485
512
  let merges: ReturnType<typeof conventionChecks>["merges"] = null;
513
+ const expressPrs = new Set(input.expressPrs ?? []);
486
514
  if (input.recentMerges === null || input.recentMerges === undefined) {
487
515
  unmeasurable.push({ agentId: "-", value: "gh pr list --state merged (with comments)", why: "recent merges could not be read from gh — convention (e), verdicts on the PR, is UNMEASURED, which is not the same as kept" });
488
516
  } else {
@@ -492,12 +520,25 @@ export function conventionChecks(input: {
492
520
  const minutes = Math.max(0, Math.round((input.now - mergedMs) / 60000));
493
521
  const inWindow = input.now - mergedMs <= windowMs;
494
522
  const beforeAdoption = mergedMs < CONVENTION_E_ADOPTED_MS;
495
- const verdictAtHead = verdictShasIn(m.comments).some((v) => shaAgrees(v.sha, m.headRefOid));
496
- merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption });
497
- if (inWindow && !beforeAdoption && !verdictAtHead) {
523
+ // ⛔ KEYED ON THE CLAIM, NOT ON A HEADER PHRASE. This used to test `verdictShasIn`,
524
+ // i.e. #313's `QA GATE — **PASS** @ sha` wording, which had ZERO adoption: on the 9
525
+ // merges of 2026-09-15 it matched 0 lines while all 9 carried a verdict, so this hit
526
+ // fired 9 times on 9 correct merges. `verdictBoundTo` asks the binding question
527
+ // instead — a disposition, on one line, at a sha equal to the head that merged — and
528
+ // accepts the split halves (#964's CONTENT PASS and READINESS RELEASED were two
529
+ // separate comments).
530
+ const bound = verdictBoundTo(m.comments, m.headRefOid);
531
+ const verdictAtHead = bound.bound;
532
+ // ⛔ EXEMPT BEFORE REPORTING, NEVER AFTER. A docs-only merge (or one the caller
533
+ // names as express) owed no verdict, so it is not a hit — it is not a defect
534
+ // whose report we suppress. `exempt` is carried on the merge row so a reader can
535
+ // see the population and why a member left it.
536
+ const exemptWhy = isDocsOnlyMerge(m.files) ? "docs-only" : expressPrs.has(m.n) ? "express lane (caller-supplied)" : null;
537
+ merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption, ...(exemptWhy ? { exempt: exemptWhy } : {}) });
538
+ if (inWindow && !beforeAdoption && !verdictAtHead && !exemptWhy) {
498
539
  hits.push({
499
540
  kind: "unverdicted-merge", pr: m.n, head: m.headRefOid, mergedAt: m.mergedAt, minutes,
500
- why: `#${m.n} merged at ${m.headRefOid.slice(0, 7)} with no verdict comment bound to that head on the PR (${m.comments.length} comment(s), none a typed \`QA GATE — **PASS|FAIL** @ sha\` line for it). Convention (e): the verdict lives on the PR, not only on the bus.`,
541
+ why: `#${m.n} merged at ${m.headRefOid.slice(0, 7)} with no verdict comment bound to that head on the PR — ${bound.why} (${m.comments.length} comment(s)). Convention (e): the verdict lives on the PR, not only on the bus. ⚠ This predicate does NOT know about the express lane, so an express merge (no QA verdict by design) still reports here — see the kit row on #966.`,
501
542
  });
502
543
  }
503
544
  }
@@ -1221,6 +1262,8 @@ export async function stallCheckTool(
1221
1262
  artefacts: (repo: string) => RepoArtefacts = realArtefacts,
1222
1263
  readRooms: () => string = readAllRoomLogs,
1223
1264
  readInboxes: () => string = readAllInboxLogs,
1265
+ /** ⟨q-1c95f7d4⟩ the transport the external tick is read through; injected by tests. */
1266
+ tickTransport: Parameters<typeof readFleetTick>[1] = activeTransport(),
1224
1267
  ) {
1225
1268
  const repo = args.repo ?? process.cwd();
1226
1269
  const limit = (args.stallMinutes ?? 30) * 60 * 1000;
@@ -1230,6 +1273,11 @@ export async function stallCheckTool(
1230
1273
  if (!existsSync(board)) return { ok: false as const, error: `no ${BOARD_DOC} under '${repo}'` };
1231
1274
 
1232
1275
  const liveTransports = await loadLiveTransports();
1276
+ // ⟨q-1c95f7d4⟩ Task 5 — the EXTERNAL tick, read once per run for the whole fleet. On a
1277
+ // fleet with no herdr seat this asks nothing and answers "measured NOTHING", which is
1278
+ // what keeps a blind axis from reading as a calm one.
1279
+ const fleetTick: FleetTick = await readFleetTick(liveTransports.values(), tickTransport);
1280
+ const tickOf = tickByAgent(fleetTick);
1233
1281
  const boardText = readFileSync(board, "utf8");
1234
1282
  const boardDoc = parseWorkDoc(boardText);
1235
1283
  const rows = workstreamsV1RowsOf(boardDoc);
@@ -1338,7 +1386,7 @@ export async function stallCheckTool(
1338
1386
  * axes is how all three stayed hidden; every scored row now says which
1339
1387
  * predicate reached a verdict about it, or that none could.
1340
1388
  */
1341
- type Axis = "vcs" | "review" | "claim" | "landed";
1389
+ type Axis = "vcs" | "review" | "claim" | "landed" | "tick";
1342
1390
  const axes: { agentId: string; stream: string; axis: Axis | null; measurable: boolean; why?: string }[] = [];
1343
1391
  const onAxis = (agentId: string, row: { stream: string }, axis: Axis) => { measured.add(agentId); axes.push({ agentId, stream: row.stream.slice(0, 60), axis, measurable: true }); };
1344
1392
  const offAxis = (agentId: string, row: { stream: string }, why: string) => axes.push({ agentId, stream: row.stream.slice(0, 60), axis: null, measurable: false, why });
@@ -1375,6 +1423,21 @@ export async function stallCheckTool(
1375
1423
  measured.add(agentId);
1376
1424
  continue;
1377
1425
  }
1426
+ // ⟨q-1c95f7d4⟩ 5.2 — THE TICK IS APPLIED FIRST AND SUPPRESSES NOTHING. It may add a
1427
+ // hit and it may credit coverage; every branch and heartbeat predicate below still
1428
+ // runs and still reaches its own verdict, because "the seat is moving" and "the work
1429
+ // is moving" are different questions and only one of them is answered here.
1430
+ const tickSeat: SeatTick | undefined = tickOf.get(agentId);
1431
+ const tick = tickVerdict(tickSeat);
1432
+ if (tick && tickSeat) {
1433
+ if (tick.measured) {
1434
+ onAxis(agentId, row, "tick");
1435
+ if (tick.hit && tickSeat.readable) hits.push({ kind: "seat-blocked", agentId, stream: row.stream.slice(0, 60), source: tickSeat.source, why: tick.why });
1436
+ } else {
1437
+ // 5.3 — A HERDR SEAT WHOSE SIGNAL CANNOT BE READ IS UNMEASURABLE, NEVER HEALTHY.
1438
+ unmeasurable.push({ agentId, value: tickSeat.transport, why: `external tick unreadable: ${tick.why}` });
1439
+ }
1440
+ }
1378
1441
  const entry = reg[agentId];
1379
1442
  if (!entry) {
1380
1443
  // NOT SKIPPED IN SILENCE. An owner with no registry entry has no
@@ -1670,6 +1733,11 @@ export async function stallCheckTool(
1670
1733
  notWatched,
1671
1734
  // ⟨q-7b2f6c04⟩ — per-row axis, beside the one number.
1672
1735
  axes,
1736
+ // ⟨q-1c95f7d4⟩ Task 5 — ONE ENTRY PER SEAT WITH AN EXTERNAL OBSERVER, and nothing that
1737
+ // grows with the fleet's branches or history. Measured on this fleet the day it was
1738
+ // added: 0 seats carry a herdr marker, so this key is 121 bytes of an answer whose
1739
+ // other keys are 76 KB (`stranded.pushedBranches` alone is 35.7 KB).
1740
+ tick: fleetTick,
1673
1741
  stranded: { openPrs: stranded.openPrs, pushedBranches: stranded.pushedBranches, unmeasurable: stranded.unmeasurable },
1674
1742
  conventions: { merges: conventions.merges, routed: conventions.routed, closings: conventions.closings, unmeasurable: conventions.unmeasurable },
1675
1743
  mergeWindows: { windows: mergeWindows.windows, floor: new Date(MERGE_WINDOW_ADOPTED_MS).toISOString(), unmeasurable: mergeWindows.unmeasurable },
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ⟨q-1c95f7d4⟩ PHASE 5.4 TASK 5 — THE EXTERNAL TICK, OVER THE FLEET.
3
+ *
4
+ * `stall_check` infers. Its liveness axis is pid-existence (and for a local `tmux-push`
5
+ * seat nothing writes heartbeats at all), its activity axis is VCS commits, and between
6
+ * them a seat that is THINKING and a seat that is WEDGED are the same reading. herdr
7
+ * observes the pane from outside the process and answers `idle | working | blocked`, which
8
+ * is a signal about the SEAT rather than about its branch.
9
+ *
10
+ * ⛔ EVIDENCE, NEVER A VERDICT (5.2). Nothing here decides a lane's state: the board says
11
+ * what work exists, the existing axes say whether it moved, and the tick says whether the
12
+ * seat is moving. A `working` tick does NOT cancel a stalled branch — a seat can be busy on
13
+ * the wrong thing — and this module therefore never removes a hit and never marks a row
14
+ * healthy. It adds evidence and it credits COVERAGE, which is the one thing a real
15
+ * observation is allowed to do.
16
+ *
17
+ * ⛔ A SEAT WITH NO TICK SOURCE PRODUCES NO ENTRY, AND THAT IS THE POINT (5.3). tmux seats
18
+ * are not given an invented tick: they keep exactly the unmeasurable story they had. A
19
+ * HERDR seat whose signal cannot be read produces an entry that says so, so `0 of N
20
+ * readable` reads BLIND and never CALM.
21
+ */
22
+ import type { Transport, TransportMarker, TickState } from "../transports/types.js";
23
+ import { activeTransport } from "../transports/index.js";
24
+
25
+ export type SeatTick =
26
+ | { agentId: string; transport: string; readable: true; state: TickState; source: string }
27
+ | { agentId: string; transport: string; readable: false; why: string };
28
+
29
+ export type FleetTick = {
30
+ /** One entry per seat whose transport HAS an external observer. Never a tmux seat. */
31
+ seats: SeatTick[];
32
+ /** Seats whose transport has no tick source at all — named, never silently skipped. */
33
+ withoutSource: string[];
34
+ readable: number;
35
+ states: Partial<Record<TickState, number>>;
36
+ /** Said in the answer itself: a fleet with a tick source and nothing readable is BLIND. */
37
+ note: string;
38
+ };
39
+
40
+ export const NO_SOURCE = "this seat's transport has no external observer to ask — its coverage is unchanged by the tick";
41
+
42
+ /**
43
+ * Read the tick for every live marker. `transport` is injected by tests; in the server it
44
+ * is whatever the configured transport resolved to, so a fleet running tmux asks nothing
45
+ * and gets an empty, explicitly-blind answer rather than a fabricated one.
46
+ */
47
+ export async function readFleetTick(
48
+ markers: Iterable<TransportMarker>,
49
+ transport: Transport | null | undefined = activeTransport(),
50
+ ): Promise<FleetTick> {
51
+ const seats: SeatTick[] = [];
52
+ const withoutSource: string[] = [];
53
+ const states: Partial<Record<TickState, number>> = {};
54
+ for (const marker of markers) {
55
+ // The ACTIVE transport is the only thing that can answer, and only for its own kind:
56
+ // a tmux-push server holding a herdr marker (the 3.4 disagreement state) must not
57
+ // claim a reading it cannot take.
58
+ if (!transport || typeof transport.readTick !== "function" || marker.transport !== transport.kind) {
59
+ withoutSource.push(marker.agentId);
60
+ continue;
61
+ }
62
+ let reading;
63
+ try {
64
+ reading = await transport.readTick(marker);
65
+ } catch (e) {
66
+ reading = { readable: false as const, why: `the transport threw while reading the tick: ${(e as Error).message}` };
67
+ }
68
+ if (reading.readable) {
69
+ seats.push({ agentId: marker.agentId, transport: marker.transport, readable: true, state: reading.state, source: reading.source });
70
+ states[reading.state] = (states[reading.state] ?? 0) + 1;
71
+ } else {
72
+ seats.push({ agentId: marker.agentId, transport: marker.transport, readable: false, why: reading.why });
73
+ }
74
+ }
75
+ const readable = seats.filter((s) => s.readable).length;
76
+ return {
77
+ seats,
78
+ withoutSource,
79
+ readable,
80
+ states,
81
+ note:
82
+ seats.length === 0
83
+ ? `no seat on this bus has an external tick source (${withoutSource.length} seat(s) on a transport that cannot be asked) — the tick measured NOTHING, which is not the same as a calm fleet`
84
+ : readable === 0
85
+ ? `${seats.length} seat(s) have a tick source and NONE answered — this fleet is BLIND on the tick axis, not quiet`
86
+ : `${readable} of ${seats.length} seat(s) with a tick source answered`,
87
+ };
88
+ }
89
+
90
+ /** Index by agentId for the per-row lookup in `stall_check`. */
91
+ export const tickByAgent = (tick: FleetTick): Map<string, SeatTick> => new Map(tick.seats.map((s) => [s.agentId, s]));
92
+
93
+ /**
94
+ * ⟨q-1c95f7d4⟩ 5.2 — WHAT A READING MEANS FOR A SCORED LANE, as one function so the rule
95
+ * is testable without a board, a repo or a registry.
96
+ *
97
+ * `blocked` is the reading this task was filed for: herdr's blocked means the seat is
98
+ * WAITING ON A PERSON, and an in-flight lane whose seat waits on a person while the fleet
99
+ * is away is unattended work that no VCS or heartbeat axis can see. It is reported as a
100
+ * hit. `working` and `idle` are coverage and nothing else — explicitly NOT a clean bill,
101
+ * because the branch axes answer a different question and keep their own verdicts.
102
+ */
103
+ export function tickVerdict(seat: SeatTick | undefined): { measured: boolean; hit: boolean; why: string } | null {
104
+ if (!seat) return null;
105
+ if (!seat.readable) return { measured: false, hit: false, why: seat.why };
106
+ if (seat.state === "blocked")
107
+ return {
108
+ measured: true,
109
+ hit: true,
110
+ why: `${seat.source} reports the seat BLOCKED — waiting on a person, which no branch or heartbeat axis can see. Evidence about the SEAT, not a verdict about the lane: the row's own axes still say whether the work moved.`,
111
+ };
112
+ return {
113
+ measured: true,
114
+ hit: false,
115
+ why: `${seat.source} reports the seat ${seat.state} — coverage, not a clean bill: a seat can be busy on the wrong thing, so the branch axes keep their own verdicts.`,
116
+ };
117
+ }
@@ -2,6 +2,8 @@ import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
2
2
  import {
3
3
  TMUX_PUSH,
4
4
  registerTmuxHost,
5
+ activeTransport,
6
+ HERDR,
5
7
  type TmuxHost,
6
8
  isLocallyProbeable,
7
9
  isTmuxKind,
@@ -480,6 +482,20 @@ export async function sendCommandTool(args: {
480
482
  // DM: target must itself be tmux-attached.
481
483
  if (args.to) {
482
484
  const marker = liveTmux.get(args.to);
485
+ // Phase 5.4 Task 4 — a herdr seat takes the control IN-PROCESS: no inbox message, no
486
+ // pusher receipt; the transport types it, verifies it left the input, and answers.
487
+ const herdrMarker = marker ? undefined : (await loadLiveTransports()).get(args.to);
488
+ const activeT = activeTransport();
489
+ if (herdrMarker?.transport === HERDR) {
490
+ if (activeT?.kind !== HERDR) {
491
+ return { ok: false, error: `'${args.to}' is attached through herdr but this server's active transport is ${activeT?.kind ?? "none"} — a mixed fleet; the server configured for herdr must send this control` };
492
+ }
493
+ const r = await activeT.sendControl(herdrMarker, cmd as "clear" | "compact" | "reload-skills");
494
+ const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
495
+ if (r.ok && reminderMs > 0) scheduleReminders(args.from, [args.to], reminderMs, args.reminderText);
496
+ const extra = r as { enters?: number; note?: string };
497
+ return { ok: r.ok, command: text, delivered: r.ok ? [args.to] : [], transport: HERDR, delivery: r.ok ? "confirmed" : "refused", ...(r.error ? { error: r.error } : {}), ...(extra.enters !== undefined ? { enters: extra.enters } : {}), ...(extra.note ? { note: extra.note } : {}) };
498
+ }
483
499
  if (!marker) {
484
500
  return {
485
501
  ok: false,
@@ -648,6 +664,32 @@ export async function attachAgentTool(args: {
648
664
  debounceMs?: number;
649
665
  }) {
650
666
  // Resolve target: explicit arg > MCP server's own TMUX_PANE env.
667
+ // Phase 5.4 Task 4 — a fleet configured for herdr attaches through the transport: no
668
+ // pusher is spawned, the marker carries pid 0 and a herdr pane id, and every refusal
669
+ // names herdr (an absent binary is never a silent fall-through to tmux).
670
+ const activeT = activeTransport();
671
+ if (activeT?.kind === HERDR) {
672
+ const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
673
+ if (existing) await deleteFile(transportFile(args.agentId));
674
+ let marker: TransportMarker;
675
+ try {
676
+ marker = await activeT.attach({ agentId: args.agentId, target: args.tmuxTarget, includeRoom: args.includeRoom, allowlist: args.allowlist, debounceMs: args.debounceMs });
677
+ } catch (e) {
678
+ return { ok: false, error: (e as Error).message };
679
+ }
680
+ marker = { ...marker, serverBuildMtime: SERVER_BUILD_MTIME };
681
+ await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
682
+ await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
683
+ return {
684
+ ok: true,
685
+ agentId: args.agentId,
686
+ transport: HERDR,
687
+ target: marker.target,
688
+ pid: 0,
689
+ rooms: marker.rooms,
690
+ note: "herdr socket transport: no pusher process — delivery is made in-process by this server through herdr's socket API; liveness is herdr's own pane status",
691
+ };
692
+ }
651
693
  const target = args.tmuxTarget ?? process.env.TMUX_PANE;
652
694
  if (!target) {
653
695
  return {
@@ -808,6 +850,12 @@ export async function detachAgentTool(args: { agentId: string }) {
808
850
  const marker = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
809
851
  let killed = false;
810
852
  let unverified = false;
853
+ if (marker?.transport === HERDR) {
854
+ // No pusher to kill: the socket transport has no process of its own. The marker is
855
+ // the whole attachment, and removing it is the detach.
856
+ await deleteFile(transportFile(args.agentId));
857
+ return { ok: true, agentId: args.agentId, killed: false, hadMarker: true, transport: HERDR, note: "herdr socket transport: no pusher process to stop; marker removed" };
858
+ }
811
859
  if (marker && isPidAlive(marker.pid)) {
812
860
  // ALIVE IS NOT ENOUGH — VERIFY IT IS A PUSHER BEFORE SIGNALLING IT.
813
861
  //