agent-coord-mcp 0.26.36 → 0.26.40

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 (41) hide show
  1. package/dist/auth-faults.js +145 -0
  2. package/dist/auth-faults.js.map +1 -0
  3. package/dist/capabilities.js +113 -2
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/launch-unit.js +282 -0
  6. package/dist/launch-unit.js.map +1 -0
  7. package/dist/server.js +46 -7
  8. package/dist/server.js.map +1 -1
  9. package/dist/store.js +139 -1
  10. package/dist/store.js.map +1 -1
  11. package/dist/test-count-key.js +22 -0
  12. package/dist/test-count-key.js.map +1 -0
  13. package/dist/tools/away.js +80 -17
  14. package/dist/tools/away.js.map +1 -1
  15. package/dist/tools/events.js +77 -3
  16. package/dist/tools/events.js.map +1 -1
  17. package/dist/tools/herdr-delivery.js +51 -36
  18. package/dist/tools/herdr-delivery.js.map +1 -1
  19. package/dist/tools/herdr-tail.js +99 -77
  20. package/dist/tools/herdr-tail.js.map +1 -1
  21. package/dist/tools/registry.js +50 -3
  22. package/dist/tools/registry.js.map +1 -1
  23. package/dist/transports/herdr.js +92 -2
  24. package/dist/transports/herdr.js.map +1 -1
  25. package/package.json +1 -1
  26. package/scripts/check-test-count.mjs +80 -6
  27. package/scripts/coord-attention-clock.mjs +19 -21
  28. package/scripts/coord-stall-clock.mjs +23 -24
  29. package/scripts/lib/clock-unit-cli.mjs +134 -0
  30. package/src/auth-faults.ts +166 -0
  31. package/src/capabilities.ts +115 -2
  32. package/src/launch-unit.ts +350 -0
  33. package/src/server.ts +47 -6
  34. package/src/store.ts +146 -1
  35. package/src/test-count-key.ts +21 -0
  36. package/src/tools/away.ts +113 -28
  37. package/src/tools/events.ts +89 -4
  38. package/src/tools/herdr-delivery.ts +46 -31
  39. package/src/tools/herdr-tail.ts +84 -65
  40. package/src/tools/registry.ts +55 -3
  41. package/src/transports/herdr.ts +93 -2
package/src/tools/away.ts CHANGED
@@ -112,8 +112,26 @@ export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { age
112
112
  const stale = ageMs > COVERAGE_STALE_MINUTES * 60_000;
113
113
  return { ageMs, stale, label: `measured ${fmtAge(ageMs)} ago${stale ? ` — STALE (older than ${COVERAGE_STALE_MINUTES}m; re-arm or run stall_check for a current reading)` : ""}` };
114
114
  }
115
- /** Met when nothing scored is unseen — an empty board (0 of 0) is idle, not blind, and needs no special case. */
116
- export const meetsCoverageThreshold = (c: { checked: number; measurable: number }): boolean => c.measurable >= c.checked;
115
+ /*
116
+ * ⛔⛆ ⟨q-b0d4e45a⟩ THE DENOMINATOR IS THE IN-FLIGHT POPULATION, NOT THE SCORED SUBSET. This armed a
117
+ * fleet on `checked: 0, measurable: 0, blind: []` while its own population said `rows: 4, inFlight: 2`:
118
+ * both in-flight lanes carried a PROSE cell, so each one hit `deliberate` and `continue`d out of the
119
+ * scoring loop before `scored.push`, and `checked` counted only what survived that filter. `0 >= 0` armed.
120
+ *
121
+ * ⛔ SO THE GUARD INVERTED AT ITS OWN LIMIT: `2 of 5` REFUSED while `0 of 2` ARMED SILENTLY — the
122
+ * partially blind fleet was stopped and the totally blind one waved through. An EMPTY blind-list and a
123
+ * blind-list THAT WAS NEVER POPULATED are indistinguishable, and this read the second as the first.
124
+ *
125
+ * ⭐ `deliberate` STAYS a category — a deliberately unwatched lane is real. The bug was that it was
126
+ * subtracted from the POPULATION rather than reported within it: a classification is a REASON a lane is
127
+ * unscored, never an exemption from being counted.
128
+ *
129
+ * ⚠ `inFlight === 0` is the ONE case that still arms, and it is a different sentence: nothing is in
130
+ * flight, so there is nothing to see — idle, not blind. That distinction is why the old `checked > 0`
131
+ * reasoning existed; it was right about idle and wrong about what `checked` measured.
132
+ */
133
+ export const meetsCoverageThreshold = (c: { measurable: number; inFlight: number }): boolean =>
134
+ c.inFlight === 0 ? true : c.measurable >= c.inFlight;
117
135
 
118
136
  /**
119
137
  * ⟨q-1c95f7d4⟩ Task 5.4 — WHAT THE EXTERNAL TICK CONTRIBUTES TO THE AWAY PRECONDITION.
@@ -139,8 +157,19 @@ export type AwayCoverage = {
139
157
  tick?: AwayTick;
140
158
  /** ⟨q-5d1c8e04⟩ — standing seats on the board: present, not scored, and therefore not blind. */
141
159
  roles?: number;
142
- /** Lanes that declare no per-agent branch: out of the population by their own statement. */
160
+ /**
161
+ * ⛔ ⟨q-b0d4e45a⟩ THE IN-FLIGHT POPULATION — the denominator. `checked` is the SCORED SUBSET and is
162
+ * reported beside it, never as the divisor: a lane filtered out before scoring still has to be counted,
163
+ * or the reading congratulates itself for the rows it dropped.
164
+ */
165
+ inFlight: number;
166
+ /**
167
+ * Lanes in flight that declare no per-agent branch. COUNTED IN `inFlight`, unscored, and named with the
168
+ * offending cell — a reason, not an exemption ⟨q-b0d4e45a⟩.
169
+ */
143
170
  deliberate?: number;
171
+ /** The `deliberate` lanes themselves, so a refusal can name the ROW and the CELL rather than a count. */
172
+ deliberateRows?: { agentId: string; stream: string; cell: string }[];
144
173
  /** ⟨q-3d82f1a9⟩ — the board could not be read: rows present vs parsed, as stall_check stated it. */
145
174
  unreadable?: { rowsPresent: number; rowsParsed: number; why: string };
146
175
  at: string;
@@ -245,11 +274,20 @@ export const coordAwaySchema = {
245
274
  agentId: z.string().optional(),
246
275
  };
247
276
 
248
- async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
277
+ /**
278
+ * ⛔ THE SEAM EXISTS TO FEED THE GUARD BELOW, NOT TO BYPASS IT ⟨q-b0d4e45a⟩. `checkStall` replaces only
279
+ * the CLOCK'S REPLY; every line of the arithmetic and every refusal still runs on it. A seam that stood
280
+ * in for `measureCoverage` itself could not test the missing-population guard, because the guard is the
281
+ * thing under test — so the injection point is deliberately UPSTREAM of it.
282
+ */
283
+ export type StallReplyForCoverage = { ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[]; population?: { rows?: number; inFlight?: number }; tick?: unknown; boardParse?: unknown };
284
+ async function measureCoverage(repo?: string, checkStall: (a: { repo: string }) => Promise<unknown> = stallCheckTool): Promise<AwayCoverage | null> {
249
285
  if (!repo) return null;
250
286
  try {
251
- const r = (await stallCheckTool({ repo })) as unknown as {
252
- ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[];
287
+ const r = (await checkStall({ repo })) as unknown as {
288
+ ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[];
289
+ deliberate?: { agentId?: string; stream?: string; cell?: string }[];
290
+ population?: { rows?: number; inFlight?: number };
253
291
  tick?: { seats?: unknown[]; readable?: number; states?: Record<string, number>; note?: string };
254
292
  boardParse?: { readable: boolean; rowsPresent: number; rowsParsed: number; why: string };
255
293
  };
@@ -258,21 +296,42 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
258
296
  // promise a watched fleet over a table nobody can read.
259
297
  if (r?.boardParse && !r.boardParse.readable) {
260
298
  return {
261
- checked: 0, measurable: 0, blind: [], roles: 0, deliberate: 0,
299
+ checked: 0, measurable: 0, inFlight: 0, blind: [], roles: 0, deliberate: 0,
262
300
  unreadable: { rowsPresent: r.boardParse.rowsPresent, rowsParsed: r.boardParse.rowsParsed, why: r.boardParse.why },
263
301
  at: new Date().toISOString(),
264
302
  };
265
303
  }
266
304
  if (!r?.ok) return null;
305
+ // ⛔⛆ ⟨q-b0d4e45a⟩ A MISSING POPULATION IS NOT A POPULATION OF ZERO, and `?? 0` here would rebuild the
306
+ // defect one layer up: `inFlight: 0` is the one value that passes the threshold unconditionally. If the
307
+ // clock cannot state its own denominator the fleet is UNMEASURED, and `null` sends it to the
308
+ // "cannot measure" refusal.
309
+ // ⭐ MEASURED, by removing this line in the COMPILED dist (tsc refuses the mutation, so the matrix runs
310
+ // against dist — qa's technique) — and it is worth stating exactly, because the danger is NOT uniform:
311
+ // • plain call → refuses, but INCOHERENTLY: "covers 0 of undefined … (NaN short)"
312
+ // • acknowledgeBlindFleet → ⛔ ARMS, recording `coverage.inFlight: undefined` and announcing
313
+ // "Stall coverage 0/undefined — ACKNOWLEDGED BLIND"
314
+ // ⚠ So the acknowledged path is the one that actually goes away on an unmeasured fleet, and an earlier
315
+ // draft of this comment said "would ARM" without that split. Both outcomes are wrong; only one ships a
316
+ // fleet unwatched. The test covers BOTH, including that the flag cannot buy past it.
317
+ const inFlight = r.population?.inFlight;
318
+ if (typeof inFlight !== "number") return null;
319
+ const deliberateRows = (r.deliberate ?? []).map((d) => ({
320
+ agentId: String(d?.agentId ?? "-"),
321
+ stream: String(d?.stream ?? "-"),
322
+ cell: String(d?.cell ?? "").trim(),
323
+ }));
267
324
  return {
268
325
  checked: r.checked ?? 0,
269
326
  measurable: r.measurable ?? 0,
327
+ inFlight,
270
328
  blind: r.blind ?? [],
271
329
  tick: r.tick
272
330
  ? { seats: r.tick.seats?.length ?? 0, readable: r.tick.readable ?? 0, states: (r.tick.states ?? {}) as Record<string, number>, note: r.tick.note ?? "" }
273
331
  : undefined,
274
332
  roles: r.roles?.length ?? 0,
275
- deliberate: r.deliberate?.length ?? 0,
333
+ deliberate: deliberateRows.length,
334
+ deliberateRows,
276
335
  at: new Date().toISOString(),
277
336
  };
278
337
  } catch {
@@ -280,16 +339,23 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
280
339
  }
281
340
  }
282
341
 
283
- export async function coordAwayTool(args: {
284
- project: string;
285
- on?: boolean;
286
- leadId?: string;
287
- decisionLog?: string;
288
- until?: string;
289
- repo?: string;
290
- acknowledgeBlindFleet?: boolean;
291
- agentId?: string;
292
- }) {
342
+ export async function coordAwayTool(
343
+ args: {
344
+ project: string;
345
+ on?: boolean;
346
+ leadId?: string;
347
+ decisionLog?: string;
348
+ until?: string;
349
+ repo?: string;
350
+ acknowledgeBlindFleet?: boolean;
351
+ agentId?: string;
352
+ },
353
+ /**
354
+ * ⚠ A SECOND PARAMETER RATHER THAN A FIELD ON `args`, so nothing here can reach the MCP tool schema:
355
+ * a caller on the bus cannot supply its own clock. Tests only.
356
+ */
357
+ deps: { checkStall?: (a: { repo: string }) => Promise<unknown> } = {},
358
+ ) {
293
359
  const state = readAway();
294
360
  const prior = state[args.project];
295
361
 
@@ -320,7 +386,10 @@ export async function coordAwayTool(args: {
320
386
  summary:
321
387
  `'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
322
388
  `${prior.until ? ` until ${prior.until}` : ""}. ` +
323
- `Coverage ${prior.coverage.measurable}/${prior.coverage.checked} lane(s) ${coverageAgeOf(prior.coverage).label}` +
389
+ // ⚠ A RECORD WRITTEN BEFORE ⟨q-b0d4e45a⟩ HAS NO POPULATION, and a query must not invent one.
390
+ // Printing `/${checked}` for it would quote the scored subset as the denominator — the very
391
+ // reading this row fixed — so an unrecorded population says so in words.
392
+ `Coverage ${prior.coverage.measurable}/${typeof prior.coverage.inFlight === "number" ? prior.coverage.inFlight : `?? (population not recorded at arm time; ${prior.coverage.checked} scored)`} lane(s) ${coverageAgeOf(prior.coverage).label}` +
324
393
  `${prior.coverage.roles ? ` · ${prior.coverage.roles} role(s) present, not scored` : ""}` +
325
394
  `${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
326
395
  `Decisions logged to ${prior.decisionLog}.`,
@@ -347,7 +416,7 @@ export async function coordAwayTool(args: {
347
416
  }
348
417
 
349
418
  // THE PRECONDITION: what could the clock actually SEE?
350
- const coverage = await measureCoverage(args.repo);
419
+ const coverage = await measureCoverage(args.repo, deps.checkStall);
351
420
  if (!coverage) {
352
421
  return {
353
422
  ok: false as const,
@@ -370,18 +439,29 @@ export async function coordAwayTool(args: {
370
439
  `Away mode promises a watched fleet; a board the clock cannot read is not a quiet fleet, it is an unwatched one. Fix the row and re-arm.`,
371
440
  };
372
441
  }
373
- // `checked > 0` MATTERS: an empty board is an IDLE fleet, not a blind one.
374
- // Without it, arming while nothing is in flight refuses with "the clock
375
- // covers 0 of 0" — a refusal that reads as a fleet nobody can see when in
376
- // fact there is nothing to see, and the only way past it would be to
377
- // acknowledge a blindness that does not exist.
378
442
  // ⟨q-5a71fc3b⟩ — below the threshold, not only at zero: "2 of 5" refuses.
443
+ // ⛔⛆ ⟨q-b0d4e45a⟩ — AND "0 of 2" REFUSES THE SAME WAY. The denominator is the in-flight
444
+ // population, so a lane filtered out of `checked` before scoring cannot buy silence. An empty
445
+ // board still arms, because `inFlight === 0` is idle rather than blind — the one thing the old
446
+ // `checked > 0` reasoning got right, now expressed against the population instead of against
447
+ // the scored subset it was mistaken for.
379
448
  if (!meetsCoverageThreshold(coverage) && !args.acknowledgeBlindFleet) {
449
+ // ⭐ NAME THE ROW AND THE CELL, not a count. "1 deliberate" tells a reader nothing they can act
450
+ // on; the stream and the offending cell text tell them which line to fix — or that the lane is
451
+ // legitimately unscoreable because nothing has been pushed yet.
452
+ const unscored = coverage.deliberateRows ?? [];
453
+ const unscoredSentence = unscored.length
454
+ ? ` UNSCORED BUT COUNTED — ${unscored.length} in-flight lane(s) declare no branch ref, and each is counted in the ${coverage.inFlight}: ` +
455
+ unscored.map((d) => `${d.agentId} (${d.stream}) cell reads ${JSON.stringify(d.cell)}`).join(" · ") +
456
+ `. A cell like this is a REASON a lane is unscored, never an exemption from being counted: if the lane has genuinely pushed nothing there is no ref to write, and that is UNSCOREABLE-AND-COUNTED, not covered.`
457
+ : "";
380
458
  return {
381
459
  ok: false as const,
382
460
  error:
383
- `REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s); the threshold is that ${COVERAGE_THRESHOLD} ` +
384
- `(${coverage.checked - coverage.measurable} short)${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}. ` +
461
+ `REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.inFlight} in-flight row(s) ` +
462
+ `(${coverage.checked} scored); the threshold is that ${COVERAGE_THRESHOLD} ` +
463
+ `(${coverage.inFlight - coverage.measurable} short)${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}.` +
464
+ unscoredSentence + " " +
385
465
  `${coverage.measurable === 0 ? "It would RUN and see nothing. " : "The seats it cannot see are exactly the ones an absence leaves unattended. "}` +
386
466
  `"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
387
467
  `The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
@@ -417,7 +497,12 @@ export async function coordAwayTool(args: {
417
497
  `Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
418
498
  `Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
419
499
  `Decisions logged to ${args.decisionLog}. ` +
420
- `Stall coverage ${coverage.measurable}/${coverage.checked}${!meetsCoverageThreshold(coverage) ? ` — ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.` +
500
+ // ⭐ ⟨q-b0d4e45a⟩ TWO DIFFERENT SENTENCES, AND ONLY ONE OF THEM IS REASSURING. An idle fleet says
501
+ // "0 in flight"; it must never render as "0/0 covered", which reads as a measured fleet and is
502
+ // exactly how a never-populated reading passed for a clean one.
503
+ (coverage.inFlight === 0
504
+ ? `Stall coverage: 0 in flight — nothing to watch, so nothing was measured (idle, not blind; ${coverage.checked} scored).`
505
+ : `Stall coverage ${coverage.measurable}/${coverage.inFlight}${!meetsCoverageThreshold(coverage) ? ` — ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.`) +
421
506
  tickSentence(coverage.tick),
422
507
  };
423
508
  }
@@ -23,11 +23,31 @@ const subsFile = () => path.join(ROOT, "subscriptions.json");
23
23
  // only if the enum cannot be widened without an emitter.
24
24
  export { EVENT_KINDS, EVENT_KIND_IDS };
25
25
  export type { RecordEvent, SubKind };
26
+ /**
27
+ * ⟨q-8e00b367⟩(a) — HOW WIDELY A SUBSCRIPTION IS ASKING, as an EXPLICIT FIELD.
28
+ *
29
+ * ⛔ THE ALTERNATIVE WAS AN IN-BAND SENTINEL AND IT IS FORBIDDEN BY DESIGN. `target: "*"` would make
30
+ * one string mean two things — a literal target that happens to be an asterisk, and "everything" —
31
+ * and nothing in the wire shape would say which was meant. A separate field cannot be ambiguous.
32
+ *
33
+ * ⚠ `prefix` IS A PREFIX OF WHATEVER `target` HOLDS FOR THAT KIND, which differs per kind and is not
34
+ * guessable from the name: `pr` → the full ref (`owner/repo#402`), `item` → the queue id, `task` →
35
+ * the KEY (its `ref` is the ticked LINE, so a prefix here is NOT a prefix of the line), `phase` →
36
+ * the phase. Measured from the emitters in record-events.ts, not from documentation.
37
+ */
38
+ export const SUB_SCOPES = ["exact", "kind", "prefix"] as const;
39
+ export type SubScope = (typeof SUB_SCOPES)[number];
40
+
26
41
  export type Subscription = {
27
42
  id: string;
28
43
  agentId: string;
29
44
  kind: SubKind;
30
45
  target: string;
46
+ /**
47
+ * Absent ⇒ `"exact"`, which is what every subscription stored before this field meant. The
48
+ * widening is ADDITIVE: no migration, and no stored registration changes meaning.
49
+ */
50
+ scope?: SubScope;
31
51
  createdAt: number;
32
52
  /**
33
53
  * null until an event OF THIS KIND was evaluated against this subscription.
@@ -195,9 +215,33 @@ export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true
195
215
  return { ok: true };
196
216
  }
197
217
 
198
- /** Subscriptions matching an event. Exact target match; no wildcards yet. */
218
+ /**
219
+ * Subscriptions matching an event, by the scope each one asked for.
220
+ *
221
+ * ⛔ THE GAP THIS CLOSES IS A DELIVERY OF NOTHING REPORTED AS A DELIVERY. With exact-only matching,
222
+ * "tell me when a new PR arrives" is inexpressible — a `pr` event's `target` IS its ref, so you would
223
+ * have to name `owner/repo#402` at subscribe time, and the entire point is that you do not know it
224
+ * yet. A poller could then scan correctly, emit a well-formed event, and match ZERO subscriptions
225
+ * while every instrument reported success: the tick exits 0, the scan finds a change, the emit
226
+ * succeeds, `list_subscriptions` shows healthy watches. Nothing anywhere reads as wrong.
227
+ *
228
+ * ⚠ `exact` REMAINS THE DEFAULT, so a widened match is always something a subscriber opted into.
229
+ */
199
230
  export const matching = (subs: Subscription[], ev: RecordEvent): Subscription[] =>
200
- subs.filter((s) => s.kind === ev.kind && s.target === ev.target);
231
+ subs.filter((s) => {
232
+ if (s.kind !== ev.kind) return false;
233
+ switch (s.scope ?? "exact") {
234
+ case "kind":
235
+ return true;
236
+ // `startsWith` on a non-empty prefix only: an EMPTY prefix matches every target, which is the
237
+ // `"*"` sentinel this design rejected, arriving through the field that replaced it. `subscribe`
238
+ // refuses to create one; this guard means a hand-edited store cannot smuggle one in either.
239
+ case "prefix":
240
+ return s.target !== "" && ev.target.startsWith(s.target);
241
+ default:
242
+ return s.target === ev.target;
243
+ }
244
+ });
201
245
 
202
246
  export type Delivery = { subscriptionId: string; agentId: string; key: string; status: "delivered" | "duplicate-suppressed" };
203
247
 
@@ -244,18 +288,59 @@ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): {
244
288
  export const subscribeSchema = {
245
289
  agentId: z.string().min(1),
246
290
  kind: z.enum(EVENT_KIND_IDS),
291
+ // ⚠ STILL REQUIRED AND STILL `min(1)`. `scope:"kind"` does not relax it — it REFUSES it (below), so
292
+ // there is no shape where an empty or absent target is accepted. Relaxing the schema instead would
293
+ // have made `target:""` representable, which is the wildcard this design exists to keep out.
247
294
  target: z.string().min(1),
295
+ // A CLOSED SET, never a free string. The registry generates the kind enum so that a subscription
296
+ // which no emitter can satisfy cannot be registered; a free-form scope would reopen exactly that
297
+ // hole one field over — an unsatisfiable scope is an unsatisfiable subscription.
298
+ scope: z.enum(SUB_SCOPES).optional(),
248
299
  };
249
300
 
250
- export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string }) {
301
+ export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string; scope?: SubScope }) {
302
+ const scope: SubScope = args.scope ?? "exact";
303
+
304
+ // ⛔ `kind` + A TARGET IS REFUSED, NOT IGNORED. An accepted-and-ignored target leaves a
305
+ // subscription that READS NARROWER THAN IT BEHAVES: an inspector sees one specific target and
306
+ // concludes the watch is narrow, while it fires on everything of that kind.
307
+ //
308
+ // ⭐ AND THE ASYMMETRY IS WHAT DECIDES IT RATHER THAN TASTE: refusing is relaxable later, whereas
309
+ // silently-ignoring is NOT tightenable later — the moment such subscriptions exist on disk,
310
+ // tightening changes what an already-stored registration means.
311
+ if (args.scope === "kind" && args.target)
312
+ return {
313
+ ok: false as const,
314
+ error:
315
+ `scope "kind" watches EVERY ${args.kind} event, so the target '${args.target}' would be ignored — refusing rather than ` +
316
+ `accepting a subscription that reads narrower than it behaves. Omit the target for kind-wide, ` +
317
+ `or use scope "prefix" with '${args.target}' to watch the ${args.kind} events whose target starts with it.`,
318
+ };
319
+
320
+ // ⛔ AN EMPTY PREFIX IS THE `"*"` WILDCARD WEARING THE FIELD THAT REPLACED IT. `"".startsWith()` is
321
+ // true of every string, so `scope:"prefix"` with an empty target subscribes to everything of the
322
+ // kind while LOOKING like a narrow, targeted watch — the same back door as `target:"*"`, one layer
323
+ // down and through an approved field. `kind` is how you ask for everything, and it says so.
324
+ if (scope === "prefix" && args.target.trim() === "")
325
+ return {
326
+ ok: false as const,
327
+ error:
328
+ `scope "prefix" with an empty target matches EVERY ${args.kind} event — that is the wildcard this ` +
329
+ `field exists to avoid spelling in-band. Use scope "kind" to watch all of them explicitly, or ` +
330
+ `give a non-empty prefix (e.g. 'davidbalzan/groundwork-kit#' for one repo's PRs).`,
331
+ };
332
+
251
333
  const subs = readSubs();
252
- const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target);
334
+ // Identity includes the SCOPE: `exact` on 'q-1' and `prefix` on 'q-1' are different watches, and
335
+ // collapsing them would silently hand back the narrower one.
336
+ const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target && (s.scope ?? "exact") === scope);
253
337
  if (dupe) return { ok: true as const, subscription: dupe, note: "already subscribed — returning the existing registration rather than a second one" };
254
338
  const sub: Subscription = {
255
339
  id: randomUUID(),
256
340
  agentId: args.agentId,
257
341
  kind: args.kind,
258
342
  target: args.target,
343
+ scope,
259
344
  createdAt: Date.now(),
260
345
  // EXPLICIT null, never left absent. `null` means "we know no scan has run
261
346
  // for this"; `undefined` means "this record predates the field and we
@@ -23,8 +23,8 @@
23
23
  import { mkdirSync, statSync } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
- import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
27
- import { ROOT, inboxFile, roomFile } from "../store.js";
26
+ import { activeTransport, HERDR, targetOf, type TransportMarker } from "../transports/index.js";
27
+ import { ROOT, inboxFile, roomFile, withDeliveryLock } from "../store.js";
28
28
  import { loadLiveTransports } from "./registry.js";
29
29
  import { newMessagesIn } from "./jsonl-offsets.js";
30
30
  import type { Message } from "./shared.js";
@@ -46,6 +46,8 @@ const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
46
46
 
47
47
 
48
48
  export type Where = { kind: "dm" } | { kind: "room"; chan: string };
49
+ /** The (seat, source) key the delivery lock is taken on — ONE spelling, shared by the send-time path and the tail. */
50
+ export const sourceKey = (where: Where) => (where.kind === "dm" ? "dm" : `room:${where.chan}`);
49
51
 
50
52
  /**
51
53
  * ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path
@@ -102,9 +104,15 @@ export async function readPushCursorFor(agentId: string): Promise<Record<string,
102
104
  }
103
105
 
104
106
  export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string };
107
+ /** Every push, whichever path made it, is logged NAMING THE PATH — so a duplicate is separable from a retried write without transcript archaeology (⟨q-524305dd⟩). */
108
+ export const pushLogLine = (path: "send-time" | "tail", id: unknown, target: string | undefined, outcome: string, error?: string) =>
109
+ `[herdr-${path === "tail" ? "tail" : "delivery"}] push id=${String(id ?? "?")} target=${target ?? "?"} outcome=${outcome}` +
110
+ (error ? ` reason=${JSON.stringify(error)}` : "") +
111
+ ` path=${path}`;
105
112
 
106
113
  /** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
107
- export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
114
+ export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }, opts: { log?: (line: string) => void } = {}): Promise<HerdrDeliveryOutcome[]> {
115
+ const log = opts.log ?? ((l: string) => console.error(l));
108
116
  const t = activeTransport();
109
117
  if (!t || t.kind !== HERDR) return [];
110
118
  let markers: Map<string, TransportMarker>;
@@ -122,34 +130,41 @@ export async function deliverToHerdrSeats(msg: Message, recipients: string[], wh
122
130
  // The coordinator's gate named the old call as "still advances the cursor to EOF"; measured,
123
131
  // it did exactly that. So: find THIS message after the persisted cursor; if any earlier
124
132
  // message to someone else precedes it, defer to the recipient's tail, which delivers in order.
125
- const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
126
- const c = await readPushCursorFor(agentId);
127
- const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
128
- const after = newMessagesIn(file, from);
129
- const at = after.findIndex((m) => m.msg.id === msg.id);
130
- const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
131
- if (at === -1 || owed.length > 0) {
132
- out.push({
133
- agentId,
134
- delivered: false,
135
- cursorAdvanced: false,
136
- deferred: at === -1
137
- ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
138
- : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
139
- });
140
- continue;
141
- }
142
- const rendered = await renderForPane(msg, agentId, where);
143
- // A control never comes through here: send_command hands a herdr seat's control to the
144
- // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
145
- // mutation survived, so it is gone). Everything delivered here is a rendered message.
146
- let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
147
- try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
148
- // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
149
- const verified = r.delivered && r.verified === true;
150
- // Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
151
- const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
152
- out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
133
+ // ⛔ ⟨q-524305dd⟩ read → type → verify → advance as the ONLY deliverer to this seat's source, across
134
+ // processes: the seat's own tail takes the same lock, so it cannot read this cursor mid-verify and
135
+ // type the message a second time. WAIT, never skip: the seat may have no tail at all (⟨q-cdb5b007⟩).
136
+ const locked = await withDeliveryLock(agentId, sourceKey(where), async () => {
137
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
138
+ const c = await readPushCursorFor(agentId);
139
+ const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
140
+ const after = newMessagesIn(file, from);
141
+ const at = after.findIndex((m) => m.msg.id === msg.id);
142
+ const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
143
+ if (at === -1 || owed.length > 0) {
144
+ out.push({
145
+ agentId,
146
+ delivered: false,
147
+ cursorAdvanced: false,
148
+ deferred: at === -1
149
+ ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
150
+ : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
151
+ });
152
+ return;
153
+ }
154
+ const rendered = await renderForPane(msg, agentId, where);
155
+ // A control never comes through here: send_command hands a herdr seat's control to the
156
+ // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
157
+ // mutation survived, so it is gone). Everything delivered here is a rendered message.
158
+ let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
159
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
160
+ // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
161
+ const verified = r.delivered && r.verified === true;
162
+ log(pushLogLine("send-time", msg.id, targetOf(marker), verified ? "delivered" : r.delivered ? "typed-unconfirmed" : "not-delivered", r.error));
163
+ // Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
164
+ const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
165
+ out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
166
+ }, { wait: true, log });
167
+ if (!locked.ran) out.push({ agentId, delivered: false, cursorAdvanced: false, deferred: `${locked.why} — left to that deliverer, which types in order` });
153
168
  }
154
169
  return out;
155
170
  }