agent-coord-mcp 0.26.23 → 0.26.25

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 (63) hide show
  1. package/dist/capabilities.js +180 -5
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/gated-head.js +172 -6
  4. package/dist/gated-head.js.map +1 -1
  5. package/dist/server-spread.js +60 -53
  6. package/dist/server-spread.js.map +1 -1
  7. package/dist/server.js +41 -2
  8. package/dist/server.js.map +1 -1
  9. package/dist/tools/away.js +20 -2
  10. package/dist/tools/away.js.map +1 -1
  11. package/dist/tools/herdr-delivery.js +86 -26
  12. package/dist/tools/herdr-delivery.js.map +1 -1
  13. package/dist/tools/herdr-tail.js +221 -0
  14. package/dist/tools/herdr-tail.js.map +1 -0
  15. package/dist/tools/jsonl-offsets.js +37 -0
  16. package/dist/tools/jsonl-offsets.js.map +1 -0
  17. package/dist/tools/messaging.js +18 -0
  18. package/dist/tools/messaging.js.map +1 -1
  19. package/dist/tools/queue-write.js +4 -1
  20. package/dist/tools/queue-write.js.map +1 -1
  21. package/dist/tools/record-events.js +42 -4
  22. package/dist/tools/record-events.js.map +1 -1
  23. package/dist/tools/records.js +24 -5
  24. package/dist/tools/records.js.map +1 -1
  25. package/dist/tools/registry.js +56 -21
  26. package/dist/tools/registry.js.map +1 -1
  27. package/dist/tools/stall.js +73 -9
  28. package/dist/tools/stall.js.map +1 -1
  29. package/dist/tools/tick.js +77 -0
  30. package/dist/tools/tick.js.map +1 -0
  31. package/dist/tools/transport.js +4 -3
  32. package/dist/tools/transport.js.map +1 -1
  33. package/dist/transports/herdr.js +203 -19
  34. package/dist/transports/herdr.js.map +1 -1
  35. package/dist/transports/index.js +1 -1
  36. package/dist/transports/index.js.map +1 -1
  37. package/dist/transports/types.js +41 -0
  38. package/dist/transports/types.js.map +1 -1
  39. package/hooks/control-bytes.mjs +69 -0
  40. package/hooks/submit.mjs +227 -9
  41. package/hooks/tier.mjs +7 -1
  42. package/hooks/tmux-pusher.mjs +5 -1
  43. package/package.json +1 -1
  44. package/scripts/coord-pusher.mjs +5 -1
  45. package/src/capabilities.ts +183 -4
  46. package/src/gated-head.ts +193 -6
  47. package/src/server-spread.ts +52 -4
  48. package/src/server.ts +38 -1
  49. package/src/tools/away.ts +38 -2
  50. package/src/tools/herdr-delivery.ts +92 -24
  51. package/src/tools/herdr-tail.ts +244 -0
  52. package/src/tools/jsonl-offsets.ts +29 -0
  53. package/src/tools/messaging.ts +20 -0
  54. package/src/tools/queue-write.ts +4 -1
  55. package/src/tools/record-events.ts +46 -4
  56. package/src/tools/records.ts +24 -4
  57. package/src/tools/registry.ts +56 -20
  58. package/src/tools/stall.ts +80 -12
  59. package/src/tools/tick.ts +117 -0
  60. package/src/tools/transport.ts +5 -4
  61. package/src/transports/herdr.ts +251 -19
  62. package/src/transports/index.ts +1 -1
  63. package/src/transports/types.ts +71 -0
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
1
+ import { loadLiveTransports, isMarkerLive, isPidAlive, markerHoldsLiveProcess } from "./registry.js";
2
2
  import {
3
3
  TMUX_PUSH,
4
4
  registerTmuxHost,
@@ -685,7 +685,8 @@ export async function attachAgentTool(args: {
685
685
  agentId: args.agentId,
686
686
  transport: HERDR,
687
687
  target: marker.target,
688
- pid: 0,
688
+ pid: marker.pid,
689
+ pidWhy: marker.pidWhy,
689
690
  rooms: marker.rooms,
690
691
  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
  };
@@ -711,10 +712,10 @@ export async function attachAgentTool(args: {
711
712
 
712
713
  // If something's already attached, refuse rather than spawn a second pusher.
713
714
  const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
714
- if (existing && isPidAlive(existing.pid)) {
715
+ if (markerHoldsLiveProcess(existing)) {
715
716
  return {
716
717
  ok: false,
717
- error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
718
+ error: `agent '${args.agentId}' already has a live ${existing!.transport} attached (pid ${existing!.pid}). Call detach_agent first.`,
718
719
  existing,
719
720
  };
720
721
  }