agent-coord-mcp 0.26.12 → 0.26.13

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.
package/src/tools/away.ts CHANGED
@@ -1,41 +1,97 @@
1
1
  /*
2
- * `coord_away` — Phase 5 Task 4.1/4.2.
2
+ * `coord_away` — DAVID IS AWAY AND THE AIDE LEADS.
3
3
  *
4
- * WHAT THIS IS NOT: a way to hand the coordinator's judgement to someone else.
5
- * A duty officer keeps the lane MOVING while the coordinator is away; it does
6
- * not inherit the authority to decide what the lane is for. The distinction is
7
- * the whole point, so it is enforced as an ALLOWLIST rather than a denylist:
8
- * a new tool added tomorrow is refused by default, and someone must decide, in
9
- * writing, that a duty officer may call it.
4
+ * REDEFINED (David, 2026-08-30). The verb used to mean "the coordinator stepped
5
+ * out and a duty officer keeps its seat warm". That meaning is RETIRED, not
6
+ * deprecated: same skill label, one meaning. A verb with two meanings is read
7
+ * as whichever one the reader already had in mind, and the two differ on who
8
+ * holds authority the most expensive thing to be ambiguous about.
10
9
  *
11
- * A denylist would have the opposite failure every tool anyone forgets to
12
- * list is silently granted which is the same shape as an empty check rollup
13
- * scoring green (kit#125): absence read as permission.
10
+ * WHAT IT NOW MEANS: David is away, and the aide LEADS in his absence. That is
11
+ * a grant of DECISION authority, not of execution authority, and the boundary
12
+ * is the feature:
14
13
  *
15
- * ENFORCEMENT LIVES IN `addTool`, THE SINGLE PATH EVERY TOOL IS REGISTERED
16
- * THROUGH, for the reason kit#125 folded the check into the merge call: a
17
- * guard repeated at call sites is a guard someone forgets at one call site,
18
- * and the forgotten one looks identical to the guarded ones from outside.
14
+ * DECIDES planning · priority · queue curation · roadmap · canon · releases
15
+ * under standing publish authorisation.
16
+ * NEVER merges, gates, or takes a code lane. Unchanged by the grant a
17
+ * lead is not a second worker, and the aide's nevers are what keep
18
+ * the gate non-author.
19
+ * PARKS licence · funding · public-vs-private · credentials · anything
20
+ * altering scope or authority · destructive machine actions.
21
+ *
22
+ * WHY PARK RATHER THAN REFUSE-AND-FORGET: a parked decision is one David
23
+ * returns to. The whole point of leading in an absence is that the absence ends,
24
+ * and the fleet should hand back a DIFF rather than a transcript — which is why
25
+ * `decisionLog` is required to turn this on rather than being a nice-to-have.
26
+ *
27
+ * THE PRECONDITION IS MEASURED COVERAGE, NOT A RUN. Going away is a promise
28
+ * that the fleet is observed while nobody is watching, and the fleet was
29
+ * measured making that promise with a stall clock that had been stopped for
30
+ * eight hours — and then, once started, reporting `checked 3 · measurable 0`.
31
+ * "The clock ran" and "the fleet is observed" are different facts. So the
32
+ * precondition asks what the clock could actually SEE, and going away blind is
33
+ * a deliberate, recorded act rather than a silent default.
19
34
  */
20
35
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
21
36
  import path from "node:path";
22
37
  import { z } from "zod";
23
38
  import { ROOT } from "../store.js";
39
+ import { stallCheckTool } from "./stall.js";
24
40
 
25
41
  const awayFile = () => path.join(ROOT, "coord-away.json");
26
42
 
27
43
  /**
28
- * WHAT A DUTY OFFICER MAY DO: move claimed work along and report.
44
+ * WHAT THE LEAD MAY NOT DO, enforced rather than trusted to a card line.
45
+ *
46
+ * ALLOWLIST FOR THE OLD MEANING, DENYLIST FOR THIS ONE — and the asymmetry is
47
+ * deliberate rather than a relaxation. A duty officer's grant was NARROW ("keep
48
+ * claimed work moving"), so absence had to mean refusal or every unlisted tool
49
+ * was a silent grant. A lead's grant is WIDE by design: it decides planning,
50
+ * priority, curation, roadmap, canon and releases, which is most of the bus. An
51
+ * allowlist over a wide grant would have to enumerate nearly every tool, and the
52
+ * one someone forgets would refuse legitimate work in the middle of an absence —
53
+ * failing in the direction that leaves the fleet stuck with nobody to ask.
29
54
  *
30
- * Deliberately absent, each for its own reason rather than by omission:
31
- * `merge` merging is a judgement about whether work is DONE.
32
- * `register`/`join` w/ coordinator role see `secondCoordinatorRefusal`.
33
- * grow-fleet verbs — a stand-in does not get to change the fleet's shape.
34
- * CANON writes — canon outlives the absence that created the stand-in.
55
+ * What is refused is small, closed, and named for its reason: the aide's
56
+ * `never merges / never gates` rule. That rule does not widen when David leaves;
57
+ * it is load-bearing precisely because the gate must stay non-author.
58
+ */
59
+ export const LEAD_REFUSED: Record<string, string> = {
60
+ merge: "merging is the gate acting. The aide never gates, and leading in David's absence does not change who may — a lead that merges is an author gating its own plan.",
61
+ land: "`land` writes docs/DONE.md, which is the coordinator's file. Leading does not transfer file ownership; ask the coordinator to record it.",
62
+ };
63
+
64
+ /**
65
+ * Decisions that WAIT for David. Not refused because they are dangerous to
66
+ * execute — refused because they are not the aide's to make, and an absence is
67
+ * not a transfer of that authority.
35
68
  */
36
- export const DUTY_OFFICER_ALLOWLIST = ["next_unblocked", "claim", "land", "stall_check", "stall_clock_status", "post_status", "send_message", "read_messages", "status", "heartbeat", "list_work"] as const;
69
+ export const PARKED_CATEGORIES = [
70
+ "licence",
71
+ "funding",
72
+ "public-vs-private",
73
+ "credentials",
74
+ "anything altering scope or authority",
75
+ "destructive machine actions",
76
+ ] as const;
37
77
 
38
- export type AwayState = { on: boolean; project: string; coordinatorId: string; dutyOfficerId: string; until?: string; at: string };
78
+ export type AwayCoverage = { checked: number; measurable: number; blind: string[]; at: string };
79
+
80
+ export type AwayState = {
81
+ on: boolean;
82
+ project: string;
83
+ /** Who is away. The verb is about DAVID's absence now. */
84
+ away: string;
85
+ /** The agent leading in that absence — the aide. */
86
+ leadId: string;
87
+ until?: string;
88
+ at: string;
89
+ /** Where decisions taken during the absence are written, so David returns to a diff. */
90
+ decisionLog: string;
91
+ coverage: AwayCoverage;
92
+ /** True when this was turned on knowing the fleet is unobserved. */
93
+ blindAcknowledged?: boolean;
94
+ };
39
95
 
40
96
  export function readAway(): Record<string, AwayState> {
41
97
  const f = awayFile();
@@ -53,69 +109,179 @@ function writeAway(state: Record<string, AwayState>): void {
53
109
  }
54
110
 
55
111
  /**
56
- * The refusal a duty officer gets, or null if the call is allowed.
112
+ * The refusal the LEAD gets, or null if the call is allowed.
57
113
  *
58
- * Only the DUTY OFFICER is constrained. Everyone else's lane is unchanged:
59
- * `coord-away` is not a freeze on the project, and a worker whose tools stop
60
- * working because the coordinator stepped out would simply stop calling them.
114
+ * Only the lead is constrained, and only on the two verbs above. Everyone
115
+ * else's lane is untouched: `coord_away` is not a freeze on the project, and a
116
+ * worker whose tools stopped working because David went out would simply stop
117
+ * calling them.
61
118
  */
62
119
  export function awayRefusal(state: Record<string, AwayState>, agentId: string | undefined, tool: string): string | null {
63
120
  if (!agentId) return null;
64
- const held = Object.values(state).find((s) => s.on && s.dutyOfficerId === agentId);
121
+ const held = Object.values(state).find((s) => s.on && s.leadId === agentId);
65
122
  if (!held) return null;
66
- if ((DUTY_OFFICER_ALLOWLIST as readonly string[]).includes(tool)) return null;
67
- return `'${tool}' is not on the duty-officer allowlist. You are standing in for '${held.coordinatorId}' on '${held.project}' while coord-away is ON: a duty officer keeps the lane MOVING and does not inherit the authority to decide what it is for. Allowed: ${DUTY_OFFICER_ALLOWLIST.join(", ")}. If this genuinely needs doing, it needs ${held.coordinatorId} back or David — not a wider allowlist added in the moment.`;
123
+ const why = LEAD_REFUSED[tool];
124
+ if (!why) return null;
125
+ return (
126
+ `'${tool}' is refused while you LEAD on '${held.project}' in David's absence: ${why} ` +
127
+ `Leading grants DECISION authority — planning, priority, curation, roadmap, canon, releases — not execution authority. ` +
128
+ `If this genuinely needs doing, it needs the coordinator, or David back. Record it in ${held.decisionLog} either way.`
129
+ );
68
130
  }
69
131
 
70
132
  /**
71
- * 4.2 a SECOND coordinator may not join while the first is away.
133
+ * RETIRED WITH THE OLD MEANING. Kept as a no-op so the call site in
134
+ * `registerTool` keeps compiling and the retirement is visible where the guard
135
+ * used to be, rather than as an absence someone re-derives later.
136
+ *
137
+ * Under the old meaning the coordinator was the one away, so a second
138
+ * coordinator joining was a fleet-shape change made behind its back. Under this
139
+ * one the coordinator is present and working, and blocking a second coordinator
140
+ * while DAVID is out would refuse a legitimate handoff at exactly the moment
141
+ * nobody can approve it.
72
142
  *
73
- * Not a lock for its own sake: two coordinators is the condition under which
74
- * two GOs can be issued for one lane, and the absent one cannot see the other
75
- * arrive. The duty officer exists precisely so the seat is not empty, so a
76
- * second claimant is a fleet-shape change, which is the category `coord_away`
77
- * refuses by construction.
143
+ * RECORDED GAP, deliberately not solved here: retiring the duty-officer meaning
144
+ * leaves COORDINATOR absence uncovered. If the coordinator goes down while David
145
+ * is away, nothing covers the seat. That is a decision, not an oversight — it is
146
+ * named so the next reader finds a choice rather than a hole.
78
147
  */
79
- export function secondCoordinatorRefusal(state: Record<string, AwayState>, agentId: string, roleId: string | undefined): string | null {
80
- if (roleId !== "coordinator") return null;
81
- const held = Object.values(state).find((s) => s.on && s.coordinatorId !== agentId);
82
- if (!held) return null;
83
- return `coord-away is HELD on '${held.project}' by '${held.coordinatorId}' (duty officer '${held.dutyOfficerId}'). A second coordinator cannot join until it is RELEASED — two coordinators is the condition under which one lane gets two GOs, and the absent one cannot see the second arrive.`;
148
+ export function secondCoordinatorRefusal(
149
+ _state: Record<string, AwayState>,
150
+ _agentId: string,
151
+ _roleId: string | undefined,
152
+ ): string | null {
153
+ return null;
84
154
  }
85
155
 
86
156
  export const coordAwaySchema = {
87
157
  project: z.string().min(1),
88
158
  on: z.boolean(),
89
- coordinatorId: z.string().min(1),
90
- dutyOfficerId: z.string().optional(),
159
+ /** The agent leading in David's absence — the aide. */
160
+ leadId: z.string().optional(),
161
+ /** Repo-relative path where decisions taken during the absence are written. */
162
+ decisionLog: z.string().optional(),
91
163
  until: z.string().optional(),
164
+ /** Repo to measure stall coverage against. */
165
+ repo: z.string().optional(),
166
+ /** Turn it on knowing the fleet is unobserved. Recorded in the state. */
167
+ acknowledgeBlindFleet: z.boolean().optional(),
168
+ /** Who is calling — the lead may turn it on and release it. */
169
+ agentId: z.string().optional(),
92
170
  };
93
171
 
94
- export async function coordAwayTool(args: { project: string; on: boolean; coordinatorId: string; dutyOfficerId?: string; until?: string }) {
172
+ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
173
+ if (!repo) return null;
174
+ try {
175
+ const r = (await stallCheckTool({ repo })) as unknown as {
176
+ ok?: boolean; checked?: number; measurable?: number; blind?: string[];
177
+ };
178
+ if (!r?.ok) return null;
179
+ return { checked: r.checked ?? 0, measurable: r.measurable ?? 0, blind: r.blind ?? [], at: new Date().toISOString() };
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ export async function coordAwayTool(args: {
186
+ project: string;
187
+ on: boolean;
188
+ leadId?: string;
189
+ decisionLog?: string;
190
+ until?: string;
191
+ repo?: string;
192
+ acknowledgeBlindFleet?: boolean;
193
+ agentId?: string;
194
+ }) {
95
195
  const state = readAway();
96
196
  const prior = state[args.project];
97
197
 
98
198
  if (args.on) {
99
- // A STAND-IN WITH NO NAME IS AN EMPTY SEAT DESCRIBED AS COVERED, which is
100
- // strictly worse than a seat everyone can see is empty.
101
- if (!args.dutyOfficerId) return { ok: false as const, error: `coord-away ON requires a dutyOfficerId. Turning it on without naming a stand-in reports the lane as covered while leaving it uncovered.` };
102
- if (args.dutyOfficerId === args.coordinatorId) return { ok: false as const, error: `'${args.coordinatorId}' cannot be its own duty officer.` };
103
- if (prior?.on && prior.coordinatorId !== args.coordinatorId) return { ok: false as const, error: `coord-away is already HELD on '${args.project}' by '${prior.coordinatorId}'. Release it first.` };
104
- state[args.project] = { on: true, project: args.project, coordinatorId: args.coordinatorId, dutyOfficerId: args.dutyOfficerId, until: args.until, at: new Date().toISOString() };
199
+ // A LEAD WITH NO NAME IS AN ABSENCE DESCRIBED AS COVERED, which is strictly
200
+ // worse than an absence everyone can see is uncovered.
201
+ if (!args.leadId) {
202
+ return { ok: false as const, error: `coord-away ON requires a leadId — the agent leading in David's absence. Turning it on without naming one reports the fleet as led while leaving it unled.` };
203
+ }
204
+ if (!args.decisionLog) {
205
+ return {
206
+ ok: false as const,
207
+ error:
208
+ `coord-away ON requires a decisionLog path. The point of leading through an absence is that the absence ENDS: ` +
209
+ `David should return to a diff he can read, not a transcript he has to reconstruct. A log promised at the start ` +
210
+ `and written at the end is a log nobody wrote.`,
211
+ };
212
+ }
213
+ if (prior?.on) {
214
+ return { ok: false as const, error: `coord-away is already ON for '${args.project}' — '${prior.leadId}' leads since ${prior.at}. Release it before re-arming.` };
215
+ }
216
+
217
+ // THE PRECONDITION: what could the clock actually SEE?
218
+ const coverage = await measureCoverage(args.repo);
219
+ if (!coverage) {
220
+ return {
221
+ ok: false as const,
222
+ error:
223
+ `cannot measure stall coverage for '${args.project}'${args.repo ? "" : " — pass 'repo'"}. ` +
224
+ `Going away is a promise that the fleet is watched while nobody is watching, and an UNMEASURED promise ` +
225
+ `is the one this verb exists to stop making. Fix the measurement, or pass acknowledgeBlindFleet:true to ` +
226
+ `record that the promise is being made anyway.`,
227
+ };
228
+ }
229
+ // `checked > 0` MATTERS: an empty board is an IDLE fleet, not a blind one.
230
+ // Without it, arming while nothing is in flight refuses with "the clock
231
+ // covers 0 of 0" — a refusal that reads as a fleet nobody can see when in
232
+ // fact there is nothing to see, and the only way past it would be to
233
+ // acknowledge a blindness that does not exist.
234
+ if (coverage.checked > 0 && coverage.measurable === 0 && !args.acknowledgeBlindFleet) {
235
+ return {
236
+ ok: false as const,
237
+ error:
238
+ `REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s). ` +
239
+ `It would RUN and see nothing${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}. ` +
240
+ `"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
241
+ `The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
242
+ `git and measures the wrong thing, so the check reports it unmeasurable rather than guessing. Fix those cells and ` +
243
+ `coverage returns. If you mean to go anyway, pass acknowledgeBlindFleet:true — it is recorded in the state and in the announcement.`,
244
+ };
245
+ }
246
+
247
+ state[args.project] = {
248
+ on: true,
249
+ project: args.project,
250
+ away: "david",
251
+ leadId: args.leadId,
252
+ until: args.until,
253
+ at: new Date().toISOString(),
254
+ decisionLog: args.decisionLog,
255
+ coverage,
256
+ ...(coverage.checked > 0 && coverage.measurable === 0 ? { blindAcknowledged: true } : {}),
257
+ };
105
258
  writeAway(state);
106
259
  return {
107
260
  ok: true as const,
108
261
  state: state[args.project],
109
- allowlist: DUTY_OFFICER_ALLOWLIST,
110
- announce: `AGENT_ACTION: coord-away ON — '${args.coordinatorId}' away${args.until ? ` until ${args.until}` : ""}; '${args.dutyOfficerId}' is duty officer, limited to: ${DUTY_OFFICER_ALLOWLIST.join(", ")}. A second coordinator cannot join until released.`,
262
+ refused: LEAD_REFUSED,
263
+ parked: PARKED_CATEGORIES,
264
+ announce:
265
+ `AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
266
+ `Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
267
+ `Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
268
+ `Decisions logged to ${args.decisionLog}. ` +
269
+ `Stall coverage ${coverage.measurable}/${coverage.checked}${coverage.checked > 0 && coverage.measurable === 0 ? " — ACKNOWLEDGED BLIND: the clock runs and sees nothing" : ""}.`,
111
270
  };
112
271
  }
113
272
 
114
- // RELEASE IS THE COORDINATOR'S. A duty officer releasing its own limits is
115
- // the limit not existing — the one call it must not be able to make.
116
273
  if (!prior?.on) return { ok: false as const, error: `coord-away is not on for '${args.project}' — nothing to release.` };
117
- if (prior.coordinatorId !== args.coordinatorId) return { ok: false as const, error: `coord-away on '${args.project}' is held by '${prior.coordinatorId}' and only they can release it. A stand-in that can lift its own limits does not have any.` };
274
+ // RELEASE IS OBSERVING THAT DAVID IS BACK, not lifting a limit on yourself
275
+ // which is why the lead may do it and a duty officer could not. The parked
276
+ // decisions are NOT unparked by it: they were never the lead's to make, and
277
+ // release does not retroactively authorise one.
118
278
  state[args.project] = { ...prior, on: false, at: new Date().toISOString() };
119
279
  writeAway(state);
120
- return { ok: true as const, state: state[args.project], announce: `AGENT_ACTION: coord-away RELEASED on '${args.project}' — '${prior.coordinatorId}' is back; '${prior.dutyOfficerId}' stands down.` };
280
+ return {
281
+ ok: true as const,
282
+ state: state[args.project],
283
+ announce:
284
+ `AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
285
+ `Anything parked during the absence is still parked: release does not authorise it. Decisions taken: ${prior.decisionLog}.`,
286
+ };
121
287
  }
@@ -0,0 +1,60 @@
1
+ /*
2
+ * The subscribable kind vocabulary — a LEAF module, importing nothing.
3
+ *
4
+ * It lives alone because `events.ts` and `record-events.ts` both need it and
5
+ * import each other: with the registry inside `record-events.ts`, loading that
6
+ * module FIRST threw `Cannot access 'EVENT_KIND_IDS' before initialization`,
7
+ * since `events.ts` reads it at module scope while `record-events.ts` is still
8
+ * evaluating. It worked only because `tools/index.ts` happens to export
9
+ * `events.js` first — a load-ORDER dependency, invisible until something
10
+ * imports the module directly, which is exactly what a consumer or a test does.
11
+ *
12
+ * A cycle that works by luck is not a working cycle.
13
+ */
14
+ /**
15
+ * The kind vocabulary lives HERE, beside the emitters, and `events.ts` imports
16
+ * it. That direction is deliberate: it is what makes the enum impossible to
17
+ * widen without adding an emitter in the same file.
18
+ */
19
+ export type SubKind = keyof typeof EVENT_KINDS;
20
+ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
21
+
22
+ /*
23
+ * THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
24
+ *
25
+ * `SubKind` used to be a hand-written union next to a hand-written zod enum next
26
+ * to an emitter that covered one of the four. Nothing connected them, so a kind
27
+ * could be offered for subscription while nothing could ever emit it — and
28
+ * `list_subscriptions` would report its permanent `never evaluated` forever,
29
+ * honestly and uselessly.
30
+ *
31
+ * Now the union, the wire enum, and the emitter set are all derived from THIS
32
+ * object. A kind cannot be offered without an emitter because the enum is
33
+ * generated from the emitters; `test/record-events.test.mjs` closes the other
34
+ * half by asserting each kind actually fires. Unsatisfiable BY CONSTRUCTION,
35
+ * rather than by a reviewer noticing.
36
+ */
37
+ export const EVENT_KINDS = {
38
+ item: {
39
+ record: "docs/QUEUE.md + docs/DONE.md",
40
+ what: "a queue item closed — it left QUEUE.md and a DONE.md entry appeared in the same commit",
41
+ targetIs: "the queue item id",
42
+ },
43
+ pr: {
44
+ record: "docs/DONE.md",
45
+ what: "a PR recorded in the completion log",
46
+ targetIs: "the PR ref, e.g. owner/repo#163",
47
+ },
48
+ task: {
49
+ record: "docs/phases/**/PHASE*_TASKS.md",
50
+ what: "a phase task checkbox newly ticked",
51
+ targetIs: "the task key, e.g. 5:12.1",
52
+ },
53
+ phase: {
54
+ record: "docs/phases/**/PHASE*_TASKS.md",
55
+ what: "the last open checkbox in a phase document ticked",
56
+ targetIs: "the phase number, e.g. 5",
57
+ },
58
+ } as const;
59
+
60
+ export const EVENT_KIND_IDS = Object.keys(EVENT_KINDS) as [SubKind, ...SubKind[]];
@@ -13,7 +13,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
13
13
  import path from "node:path";
14
14
  import { z } from "zod";
15
15
  import { ROOT } from "../store.js";
16
- import { EVENT_KINDS, EVENT_KIND_IDS, type RecordEvent, type SubKind } from "./record-events.js";
16
+ import { EVENT_KINDS, EVENT_KIND_IDS, type RecordEvent, type SubKind } from "./event-kinds.js";
17
17
 
18
18
  const subsFile = () => path.join(ROOT, "subscriptions.json");
19
19
 
@@ -26,53 +26,10 @@
26
26
  */
27
27
  import { execFileSync } from "node:child_process";
28
28
  import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf } from "@davidbalzan/groundwork-seam";
29
- /**
30
- * The kind vocabulary lives HERE, beside the emitters, and `events.ts` imports
31
- * it. That direction is deliberate: it is what makes the enum impossible to
32
- * widen without adding an emitter in the same file.
33
- */
34
- export type SubKind = keyof typeof EVENT_KINDS;
35
- export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
29
+ import { EVENT_KINDS, type RecordEvent, type SubKind } from "./event-kinds.js";
30
+ export { EVENT_KINDS, EVENT_KIND_IDS } from "./event-kinds.js";
31
+ export type { RecordEvent, SubKind } from "./event-kinds.js";
36
32
 
37
- /*
38
- * THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
39
- *
40
- * `SubKind` used to be a hand-written union next to a hand-written zod enum next
41
- * to an emitter that covered one of the four. Nothing connected them, so a kind
42
- * could be offered for subscription while nothing could ever emit it — and
43
- * `list_subscriptions` would report its permanent `never evaluated` forever,
44
- * honestly and uselessly.
45
- *
46
- * Now the union, the wire enum, and the emitter set are all derived from THIS
47
- * object. A kind cannot be offered without an emitter because the enum is
48
- * generated from the emitters; `test/record-events.test.mjs` closes the other
49
- * half by asserting each kind actually fires. Unsatisfiable BY CONSTRUCTION,
50
- * rather than by a reviewer noticing.
51
- */
52
- export const EVENT_KINDS = {
53
- item: {
54
- record: "docs/QUEUE.md + docs/DONE.md",
55
- what: "a queue item closed — it left QUEUE.md and a DONE.md entry appeared in the same commit",
56
- targetIs: "the queue item id",
57
- },
58
- pr: {
59
- record: "docs/DONE.md",
60
- what: "a PR recorded in the completion log",
61
- targetIs: "the PR ref, e.g. owner/repo#163",
62
- },
63
- task: {
64
- record: "docs/phases/**/PHASE*_TASKS.md",
65
- what: "a phase task checkbox newly ticked",
66
- targetIs: "the task key, e.g. 5:12.1",
67
- },
68
- phase: {
69
- record: "docs/phases/**/PHASE*_TASKS.md",
70
- what: "the last open checkbox in a phase document ticked",
71
- targetIs: "the phase number, e.g. 5",
72
- },
73
- } as const;
74
-
75
- export const EVENT_KIND_IDS = Object.keys(EVENT_KINDS) as [SubKind, ...SubKind[]];
76
33
 
77
34
  /**
78
35
  * One pass over the diff → per-file added and removed lines.
@@ -122,6 +79,39 @@ const linesWhere = (byFile: ReturnType<typeof diffByFile>, match: (p: string) =>
122
79
  */
123
80
  const PR_REF = /^(?:[\w.-]+\/[\w.-]+#\d+|#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)$/;
124
81
 
82
+
83
+ /**
84
+ * EVERY pr ref in a citation field, not "is the whole field one ref".
85
+ *
86
+ * MEASURED, and it is the mirror image of the garbage-ref defect: an anchored
87
+ * whole-field match rejects `owner/repo#170, #173` — a perfectly good citation
88
+ * that happens to name TWO PRs. A real closure was reported unattributed for
89
+ * exactly this, and a pair of PRs is the repo's normal house style for work
90
+ * that landed across two.
91
+ *
92
+ * Tightening to reject prose was right. Rejecting a real citation because it
93
+ * cites more than one thing was not, and from inside a function that only asks
94
+ * "does the field EQUAL a ref" the two failures are indistinguishable.
95
+ *
96
+ * A BARE `#N` IS EXPANDED against a qualified ref in the same field: in
97
+ * `owner/repo#170, #173` the second plainly means the same repository, and
98
+ * emitting a bare `#173` would be an under-qualified target that no
99
+ * subscription written against `owner/repo#173` could ever match.
100
+ */
101
+ export function prRefsIn(field: string | undefined): string[] {
102
+ const text = String(field ?? "");
103
+ const out: string[] = [];
104
+ for (const m of text.matchAll(/(?:[\w.-]+\/[\w.-]+#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)/g)) out.push(m[0]);
105
+ const owner = out[0]?.match(/^([\w.-]+\/[\w.-]+)#/)?.[1];
106
+ for (const m of text.matchAll(/(?:^|[\s,\u00b7;])#(\d+)\b/g)) {
107
+ const ref = owner ? `${owner}#${m[1]}` : `#${m[1]}`;
108
+ if (!out.includes(ref)) out.push(ref);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** A citation that closes an item without naming a PR — `owner/repo@sha`. */
114
+ const COMMIT_REF = /@[0-9a-f]{7,40}\b/;
125
115
  /** Queue and done text, compared for the pairing below. */
126
116
  const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
127
117
 
@@ -149,9 +139,12 @@ export function eventsFromCommittedChange(
149
139
  const newEntries = doneEntriesOf(parseWorkDoc(`## Done\n${addedDone.join("\n")}\n`)) as Array<{ ref?: string; text?: string }>;
150
140
 
151
141
  for (const entry of newEntries) {
152
- const ref = entry.ref?.trim();
153
- if (!ref || !PR_REF.test(ref)) continue;
154
- events.push({ kind: "pr", target: ref, ref, summary: entry.text?.slice(0, 120) || ref });
142
+ // ONE EVENT PER PR NAMED. An entry closing work that spanned two PRs should
143
+ // wake a subscriber to either; collapsing to the first makes the second
144
+ // silently unwatchable.
145
+ for (const ref of prRefsIn(entry.ref)) {
146
+ events.push({ kind: "pr", target: ref, ref, summary: entry.text?.slice(0, 120) || ref });
147
+ }
155
148
  }
156
149
 
157
150
  // ── item: a queue item that closed ────────────────────────────────────────
@@ -170,13 +163,17 @@ export function eventsFromCommittedChange(
170
163
  const removedQueue = linesWhere(byFile, isQueue, "removed");
171
164
  const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
172
165
  .filter((i) => i.id);
173
- const prEntries = newEntries.filter((e) => e.ref && PR_REF.test(e.ref.trim()));
166
+ // An entry qualifies to CLOSE an item if it cites anything resolvable — a PR,
167
+ // or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
168
+ // record actually says, and a commit-cited entry is still the record stating
169
+ // that the item closed.
170
+ const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || COMMIT_REF.test(e.ref ?? ""));
174
171
  const unattributed: string[] = [];
175
172
 
176
173
  for (const item of removedItems) {
177
174
  const itemText = norm(item.text ?? "");
178
175
  let entry = itemText
179
- ? prEntries.find((e) => {
176
+ ? citedEntries.find((e) => {
180
177
  const t = norm(e.text ?? "");
181
178
  return t && (t === itemText || t.startsWith(itemText) || itemText.startsWith(t));
182
179
  })
@@ -184,12 +181,19 @@ export function eventsFromCommittedChange(
184
181
  // The unambiguous-commit case: one out, one in. This is the shape the fleet
185
182
  // actually commits ("close its queue item"), and it is the case the aide's
186
183
  // dead `item` subscriptions need.
187
- if (!entry && removedItems.length === 1 && prEntries.length === 1) entry = prEntries[0];
184
+ if (!entry && removedItems.length === 1 && citedEntries.length === 1) entry = citedEntries[0];
188
185
  if (!entry) {
189
186
  unattributed.push(item.id!);
190
187
  continue;
191
188
  }
192
- events.push({ kind: "item", target: item.id!, ref: entry.ref!.trim(), summary: entry.text?.slice(0, 120) ?? item.id! });
189
+ // The FIRST pr ref is the item's, falling back to the commit citation: an
190
+ // item closes once, so it gets one ref, and it is the one a reader follows.
191
+ const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim();
192
+ if (!ref) {
193
+ unattributed.push(item.id!);
194
+ continue;
195
+ }
196
+ events.push({ kind: "item", target: item.id!, ref, summary: entry.text?.slice(0, 120) ?? item.id! });
193
197
  }
194
198
  if (unattributed.length) unattributedItems.push(...unattributed);
195
199
 
@@ -363,8 +363,17 @@ export async function listAgentsTool() {
363
363
  const { lastHeartbeat, ...rest } = a;
364
364
  const heartbeatFields = transport
365
365
  ? {
366
+ // NOT "refreshed BY this call" — that write was removed in #137
367
+ // precisely because it stamped every OTHER fleet's agents too
368
+ // (agents.json is shared, list_agents takes no project argument).
369
+ // Measured after the removal (Task 13.5): calling list_agents and
370
+ // re-reading agents.json directly leaves lastHeartbeat UNCHANGED.
371
+ // The field is omitted because the value is STALE-BY-DESIGN for a
372
+ // live-transport agent, not because this call would overwrite it —
373
+ // an explanation that outlived the code it explained is the same
374
+ // carrier-gap shape as a stated cause nobody re-checked.
366
375
  heartbeatSource:
367
- "omitted — this agent has a live transport, so its heartbeat is refreshed BY this call and would report the caller's own timestamp. Liveness is `online`/`transport`; for per-agent freshness read from server state, use `ping`.",
376
+ "omitted — a live-transport agent's raw lastHeartbeat measures time since it last JOINED, not activity (nothing else writes it for a local transport; Task 13.3/13.4). Liveness is `online`/`transport`; for per-agent freshness read from server state, use `ping`.",
368
377
  }
369
378
  : { lastHeartbeat, secondsSinceHeartbeat: Math.floor((now - lastHeartbeat) / 1000) };
370
379
  return {