agent-coord-mcp 0.26.23 → 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.
@@ -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
+ }
@@ -34,8 +34,8 @@
34
34
  * same reason (identical evidence for a typo and a default).
35
35
  */
36
36
  import { spawnSync } from "node:child_process";
37
- import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker } from "./types.js";
38
- import { HERDR, targetOf } from "./types.js";
37
+ import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
38
+ import { HERDR, TICK_READS_AS, TICK_STORED_AS, targetOf } from "./types.js";
39
39
 
40
40
  export type HerdrError = { code: string; message: string };
41
41
  export type HerdrResult = {
@@ -285,6 +285,80 @@ export class HerdrTransport implements Transport {
285
285
  };
286
286
  }
287
287
 
288
+ /**
289
+ * ⟨q-1c95f7d4⟩ 5.1 — THE EXTERNAL TICK, CONSUMED. herdr's own `agent_status` for the
290
+ * pane, which is the signal this fleet has never had: our liveness is pid-existence and
291
+ * our activity is VCS commits, so a THINKING lane and a WEDGED lane are identical to us.
292
+ *
293
+ * ⛔ `unknown` AND `done` ARE NOT SEAT STATES AND MUST NOT READ AS CALM. `unknown` is
294
+ * herdr saying it has no agent there (the shape a plain shell pane returns, measured);
295
+ * `done` is a finished session, not a moving seat. Both come back UNREADABLE, so the
296
+ * caller counts them as unmeasured rather than crediting coverage — the exact inversion
297
+ * (blind read as quiet) that `stall_check` shipped and this task exists to end.
298
+ */
299
+ async readTick(marker: TransportMarker): Promise<TickReading> {
300
+ if (marker.transport !== HERDR) return { readable: false, why: `transport "${marker.transport}" has no external tick — only a herdr marker does` };
301
+ const target = targetOf(marker);
302
+ if (!target) return { readable: false, why: "no target recorded on the marker" };
303
+ const avail = this.availability();
304
+ if (!avail.available) return { readable: false, why: avail.reason };
305
+ const got = this.#run(["pane", "get", target]);
306
+ if (!got.ok) {
307
+ if (got.error?.code === "pane_not_found") return { readable: false, why: `herdr reports pane ${target} not found — a gone pane has no tick (and probe calls it dead)` };
308
+ return { readable: false, why: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` };
309
+ }
310
+ const status = String(paneOf(got)?.agent_status ?? "unknown");
311
+ const state = TICK_READS_AS[status];
312
+ if (state) return { readable: true, state, source: `herdr pane ${target}` };
313
+ return { readable: false, why: `herdr reports agent_status "${status}" for pane ${target} — no agent state is published there, which is a MISSING signal, not a calm seat` };
314
+ }
315
+
316
+ /**
317
+ * ⟨q-1c95f7d4⟩ Task 1's open question — PUBLISH, and it needs a receipt it does not get.
318
+ *
319
+ * MEASURED 2026-09-15 in a workspace of my own: `herdr pane report-agent <pane> --source
320
+ * coord-mcp --agent <label> --state blocked` prints NOTHING and exits 0, and the pane
321
+ * then reads `"agent":"<label>","agent_status":"blocked"` where a second earlier it read
322
+ * `unknown` with no agent field. ⛔ A WRITE WITH NO OUTPUT IS A WRITE WITH NO RECEIPT:
323
+ * exit 0 here says the CLI parsed the arguments, not that the pane carries the state, and
324
+ * a second `report-agent` overwrites the first with the same silence. So this method does
325
+ * not trust the exit code — it READS THE PANE BACK and reports `ok` only when the pane
326
+ * carries both the agent label we published and the state we published. An unverifiable
327
+ * write is returned as an error naming what the pane actually says.
328
+ *
329
+ * ⚠ WHAT A WRONG PUBLISH COSTS, stated because the write is silent and destructive:
330
+ * herdr keeps ONE agent state per pane, so publishing to a pane this bus does not own
331
+ * overwrites whatever that pane's real agent last reported, with no history and no
332
+ * notification to the owner — a fleet watching herdr would then read our fiction as the
333
+ * seat's own signal. Hence the marker gate above (`transport !== HERDR` refuses): we
334
+ * publish only to panes recorded as herdr seats of this bus, never to a pane id passed in
335
+ * from anywhere else.
336
+ */
337
+ async publishTick(marker: TransportMarker, state: TickState, opts: { message?: string; source?: string } = {}): Promise<{ ok: boolean; error?: string; verified?: boolean }> {
338
+ if (marker.transport !== HERDR) return { ok: false, error: `refusing to publish state for a "${marker.transport}" seat — herdr keeps one agent state per pane and a publish to a pane this bus does not own would silently overwrite its owner's signal` };
339
+ const target = targetOf(marker);
340
+ if (!target) return { ok: false, error: "no target recorded on the marker" };
341
+ const avail = this.availability();
342
+ if (!avail.available) return { ok: false, error: avail.reason };
343
+ const source = opts.source ?? "coord-mcp";
344
+ const args = ["pane", "report-agent", target, "--source", source, "--agent", marker.agentId, "--state", state];
345
+ if (opts.message) args.push("--message", opts.message);
346
+ const wrote = this.#run(args);
347
+ if (!wrote.ok) return { ok: false, error: `herdr refused the report: ${wrote.error?.message ?? wrote.stderr}` };
348
+ // THE RECEIPT IS THE READ-BACK, never the exit code.
349
+ const back = this.#run(["pane", "get", target]);
350
+ if (!back.ok) return { ok: false, verified: false, error: `published, but the pane could not be read back to verify it: ${back.error?.message ?? back.stderr}` };
351
+ const pane = paneOf(back);
352
+ const gotAgent = String(pane?.agent ?? "");
353
+ const gotState = String(pane?.agent_status ?? "");
354
+ // VERIFIED AGAINST WHAT herdr STORES, not against what we sent: a published `idle`
355
+ // comes back as `done` (TICK_STORED_AS, measured). Comparing to the sent word would
356
+ // call every idle publish a failure; comparing to the stored word is the receipt.
357
+ const expected = TICK_STORED_AS[state];
358
+ if (gotAgent === marker.agentId && gotState === expected) return { ok: true, verified: true };
359
+ return { ok: false, verified: false, error: `published --agent ${marker.agentId} --state ${state} (herdr stores that as "${expected}"), but pane ${target} reads agent "${gotAgent || "(none)"}" state "${gotState || "(none)"}" — the write did not land as published` };
360
+ }
361
+
288
362
  /**
289
363
  * No pusher to kill. A herdr marker whose pane is gone is reaped (the caller deletes
290
364
  * the marker); an unknown answer is unprobeable; a live pane is left alone.
@@ -154,6 +154,53 @@ export type Liveness =
154
154
  | { state: "dead"; reason: string }
155
155
  | { state: "unknown"; reason: string };
156
156
 
157
+ /**
158
+ * ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — THE EXTERNAL TICK'S VOCABULARY, TAKEN FROM THE TOOL THAT
159
+ * OWNS IT. Measured 2026-09-15 in a workspace of my own: `herdr pane report-agent --help`
160
+ * enumerates exactly `idle, working, blocked, unknown`, and a pane carries the state an
161
+ * external source published (`"agent":"<label>","agent_status":"blocked"` on a pane that
162
+ * read `unknown` a second earlier). So these four are herdr's own set, not ours.
163
+ *
164
+ * ⛔ `unknown` IS THE ABSENCE OF A SIGNAL, NOT A STATE OF THE SEAT. A tick that reads
165
+ * `unknown` is UNREADABLE — reported as such and counted as unmeasured — because the whole
166
+ * defect this task answers is a fleet that reads CALM when it is BLIND (Task 5.3).
167
+ */
168
+ export const TICK_STATES = ["idle", "working", "blocked"] as const;
169
+ export type TickState = (typeof TICK_STATES)[number];
170
+
171
+ /**
172
+ * ⛔⛆ THE WRITE VOCABULARY AND THE READ VOCABULARY ARE NOT THE SAME, AND ONLY A READ-BACK
173
+ * FINDS IT. Measured 2026-09-16 on a fresh pane of my own, six writes and three repeats:
174
+ *
175
+ * published --state working → agent_status "working"
176
+ * published --state blocked → agent_status "blocked"
177
+ * published --state idle → agent_status "done" ⬅ 3 of 3, with and without --seq
178
+ * published --state unknown → agent_status "unknown"
179
+ *
180
+ * herdr ACCEPTS `idle` (its own `--help` lists it) and STORES `done`. Nothing in the CLI
181
+ * says so: the write prints nothing and exits 0 either way. This is exactly what the
182
+ * receipt exists to catch, and it was caught by the read-back failing, not by reasoning.
183
+ *
184
+ * For the tick's purpose the two mean the same thing — a seat that is not working — so a
185
+ * `done` READS as `idle`, and a publish of `idle` is verified against `done`. The
186
+ * translation is named here rather than hidden in a comparison, so a herdr release that
187
+ * changes it fails a test instead of quietly inverting a verdict.
188
+ *
189
+ * ⚠ THE TWO DIRECTIONS ARE NOT THE SAME SET, AND THE `done` ENTRY IS NOT DEAD CODE — qa
190
+ * measured the asymmetry from the other side on herdr 0.9.0: `report-agent --state done`
191
+ * is REFUSED ("expected idle, working, blocked, or unknown"), so nothing we publish can
192
+ * produce it. It is still REACHABLE ON READ, which is the side `TICK_READS_AS` serves:
193
+ * `agent_status` is what a pane REPORTS, and herdr's own `agent wait --until` enumerates
194
+ * `idle, working, blocked, done, unknown`. A herdr-managed Claude session that finishes
195
+ * hands us `done` without anyone publishing it. So: refused on write, reachable on read,
196
+ * measured in both directions — do not delete this entry as unreachable.
197
+ */
198
+ export const TICK_STORED_AS: Readonly<Record<TickState, string>> = Object.freeze({ idle: "done", working: "working", blocked: "blocked" });
199
+ export const TICK_READS_AS: Readonly<Record<string, TickState>> = Object.freeze({ idle: "idle", done: "idle", working: "working", blocked: "blocked" });
200
+ export type TickReading =
201
+ | { readable: true; state: TickState; source: string }
202
+ | { readable: false; why: string };
203
+
157
204
  /** Keystroke-shaped commands a transport must deliver to an interactive pane. */
158
205
  export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"] as const;
159
206
  export type ControlCommand = (typeof CONTROL_COMMANDS)[number];
@@ -198,4 +245,23 @@ export interface Transport {
198
245
  sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }>;
199
246
 
200
247
  reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }>;
248
+
249
+ /**
250
+ * ⟨q-1c95f7d4⟩ 5.1 — OPTIONAL, AND ITS ABSENCE IS THE HONEST ANSWER FOR tmux.
251
+ *
252
+ * A transport implements `readTick` only when something OUTSIDE this process observes
253
+ * the seat and can be asked. tmux cannot: a pane is a terminal, and "a pid exists" is
254
+ * what `probe` already says. Declaring the method optional is what keeps `stall_check`
255
+ * from inventing a tick for a tmux seat and reading a fabricated calm — those seats keep
256
+ * the unmeasurable story they had (5.3).
257
+ */
258
+ readTick?(marker: TransportMarker): Promise<TickReading>;
259
+
260
+ /**
261
+ * ⟨q-1c95f7d4⟩ Task 1's open question, answered by measurement: THE PIPE RUNS BOTH WAYS.
262
+ * `coord-mcp` knows what herdr cannot infer — which seat holds which lane, whether a
263
+ * DONE: awaits a verdict, whether a seat is parked on a David decision — so a transport
264
+ * that can PUBLISH state to its external observer exposes it here.
265
+ */
266
+ publishTick?(marker: TransportMarker, state: TickState, opts?: { message?: string; source?: string }): Promise<{ ok: boolean; error?: string }>;
201
267
  }