agent-coord-mcp 0.26.12 → 0.26.14

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.
@@ -93,9 +93,9 @@ export function markRunFailure(reason: string): void {
93
93
  writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
94
94
  }
95
95
 
96
- type RunMark = { at: number; hits: number; checked: number; failed?: string };
96
+ type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string };
97
97
 
98
- function markRun(result: { hits: StallHit[]; checked: number }) {
98
+ function markRun(result: { hits: StallHit[]; checked: number; measurable?: number }) {
99
99
  mkdirSync(ROOT, { recursive: true });
100
100
  let history: RunMark[] = [];
101
101
  try {
@@ -103,7 +103,12 @@ function markRun(result: { hits: StallHit[]; checked: number }) {
103
103
  } catch {
104
104
  /* first run */
105
105
  }
106
- history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked });
106
+ // COVERAGE IS RECORDED WITH THE RUN, because a status tool that cannot
107
+ // express its own blindness is worse than no status tool. Measured: the clock
108
+ // reported `runs 8, failures 0` — all green — while every one of those runs
109
+ // had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
110
+ // instrument meant to cover an absence.
111
+ history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
107
112
  // A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
108
113
  // list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
109
114
  // are different states and only the first is a healthy fleet.
@@ -114,7 +119,7 @@ export const lastRanSchema = { maxAgeMinutes: z.number().optional() };
114
119
 
115
120
  /**
116
121
  * Is the clock alive? Readable by a human or another check, which is the point —
117
- * MISS is silent to the duty officer, not to the record.
122
+ * MISS is silent to the WATCHER, not to the record.
118
123
  */
119
124
  export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
120
125
  const maxAge = (args.maxAgeMinutes ?? 60) * 60 * 1000;
@@ -150,11 +155,19 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
150
155
  const stale = age >= maxAge;
151
156
  const misses = history.filter((h) => !h.failed && h.hits === 0).length;
152
157
  const hits = history.filter((h) => !h.failed && h.hits > 0).length;
158
+ // A RUN THAT COVERED NOTHING IS NOT A RUN THAT FOUND NOTHING. Marks written
159
+ // before coverage was recorded carry no `measurable` field: they are reported
160
+ // as UNKNOWN rather than assumed covered, because assuming is what produced
161
+ // the green this exists to correct.
162
+ const graded = history.filter((h) => !h.failed && typeof h.measurable === "number");
163
+ const blindRuns = graded.filter((h) => h.checked > 0 && h.measurable === 0).length;
164
+ const ungraded = history.filter((h) => !h.failed && typeof h.measurable !== "number").length;
165
+ const lastBlind = last && !last.failed && (last.checked ?? 0) > 0 && last.measurable === 0;
153
166
  return {
154
167
  // A FAILING CLOCK IS NOT A HEALTHY ONE. It writes marks on schedule, so the
155
168
  // age looks fresh while nothing is being measured — a fresh heartbeat from
156
169
  // a stuck agent, one level up.
157
- ok: !stale && !lastFailed,
170
+ ok: !stale && !lastFailed && !lastBlind,
158
171
  ...(stale
159
172
  ? {
160
173
  error: `stall_check last ran ${Math.round(age / 60000)}m ago, past the ${args.maxAgeMinutes ?? 60}m window — THE CLOCK IS STOPPED. No alerts is not the same as no stalls.`,
@@ -163,12 +176,28 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
163
176
  ? {
164
177
  error: `the clock is RUNNING but its last run FAILED: ${lastFailed}. It is firing on schedule and measuring nothing, which reads as fresh and is not.`,
165
178
  }
166
- : {}),
179
+ : lastBlind
180
+ ? {
181
+ error:
182
+ `the clock is RUNNING and BLIND: its last run checked ${last?.checked} in-flight row(s) and could measure ${last?.measurable} of them. ` +
183
+ `It fires on schedule, reports no hits, and that "no hits" is not evidence of a healthy fleet — it is evidence of nothing. ` +
184
+ `Read stall_check's own 'unmeasurable' list for the cause; the usual one is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref.`,
185
+ }
186
+ : {}),
167
187
  lastRanMinutesAgo: Math.round(age / 60000),
168
188
  runs: history.length,
169
189
  misses,
170
190
  hits,
171
191
  failures: failures.length,
192
+ // The number the old shape could not express.
193
+ coverage: {
194
+ lastRun: last && !last.failed ? { checked: last.checked, measurable: last.measurable ?? null } : null,
195
+ blindRuns,
196
+ ungraded,
197
+ note:
198
+ "blindRuns are runs that checked rows and measured none of them — green by every other field. " +
199
+ "ungraded are marks written before coverage was recorded: UNKNOWN, never assumed covered.",
200
+ },
172
201
  };
173
202
  }
174
203
 
@@ -190,10 +219,27 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
190
219
  const hits: StallHit[] = [];
191
220
  /** Rows whose VCS activity could not be measured. NOT stall claims. */
192
221
  const unmeasurable: { agentId: string; value: string; why: string }[] = [];
222
+ /**
223
+ * Rows a predicate actually reached a VERDICT about — hit or clean.
224
+ *
225
+ * Tracked explicitly rather than inferred from "2 predicates minus the
226
+ * unmeasurable ones", because a predicate that is SKIPPED pushes no
227
+ * unmeasurable entry and the arithmetic then counts it as having measured.
228
+ * That is the same defect as the `continue` above, one level up: a silent
229
+ * skip and a clean pass produce the same number.
230
+ */
231
+ const measured = new Set<string>();
193
232
 
194
233
  for (const row of inFlight) {
195
234
  const agentId = row.owner.replace(/[`*]/g, "").trim();
196
235
  const entry = reg[agentId];
236
+ if (!entry) {
237
+ // NOT SKIPPED IN SILENCE. An owner with no registry entry has no
238
+ // heartbeat to read, which is a missing signal and must be reported as
239
+ // one — a row whose owner the bus has never heard of is exactly the row
240
+ // you would want named before going away.
241
+ unmeasurable.push({ agentId, value: "", why: "no registry entry for this owner — there is no heartbeat to read, and the name may not be an agent id at all" });
242
+ }
197
243
  if (entry) {
198
244
  const age = now - entry.lastHeartbeat;
199
245
  // A HEARTBEAT IS ONLY EVIDENCE WHERE SOMETHING WRITES ONE.
@@ -230,12 +276,51 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
230
276
  value: marker.transport,
231
277
  why: `heartbeat is ${Math.round(age / 60000)}m old, but nothing writes heartbeats for a local 'tmux-push' transport — hooks/tmux-pusher.mjs does not call heartbeat, and this marker's liveness is its pid. There is no heartbeat SOURCE here, so the age measures nothing about this agent`,
232
278
  });
233
- continue;
234
- }
235
- if (age > limit) {
279
+ // FALL THROUGH TO THE VCS PREDICATE — do not `continue`.
280
+ //
281
+ // This used to skip the row entirely, and the consequence was total: on
282
+ // this fleet, 3 of 3 in-flight rows were local tmux-push agents, so
283
+ // EVERY row exited here and the check reported `checked 3 · measurable
284
+ // 0`. The half that was blind (heartbeat, deliberately, for the reason
285
+ // above) was taking the half that works (vcs activity) down with it.
286
+ //
287
+ // The two predicates answer different questions and only one of them is
288
+ // unanswerable for a local transport. "Is it alive" has no source here;
289
+ // "is its branch moving" has a perfectly good one, and it is the half
290
+ // that catches the case this verb exists for — alive and not
291
+ // progressing. An unmeasurable heartbeat is a missing signal, not a
292
+ // reason to stop measuring the signal that is present.
293
+ } else if (age > limit) {
236
294
  hits.push({ kind: "no-heartbeat", agentId, stream: row.stream.slice(0, 60), minutes: Math.round(age / 60000) });
295
+ measured.add(agentId);
237
296
  continue;
238
297
  }
298
+ // A FRESH HEARTBEAT IS *NOT* A VERDICT, AND THIS IS A CORRECTION TO WHAT
299
+ // THIS CODE CLAIMED WHEN IT MERGED.
300
+ //
301
+ // It read "a fresh heartbeat IS a verdict — alive" and credited coverage
302
+ // for it. Measured since, by reading agents.json directly (`list_agents`
303
+ // refreshes the CALLER's mark, so it cannot be used to measure the
304
+ // caller): an agent's mark aged from 9611s to 9623s across a `post_status`
305
+ // call. BUS TOOL CALLS DO NOT WRITE HEARTBEATS.
306
+ //
307
+ // `heartbeat` age is TIME SINCE JOIN. Not activity, and not even
308
+ // time-since-restart — a server can restart mid-session without the mark
309
+ // moving, because nothing rejoined. So freshness says "recently joined",
310
+ // and crediting it as coverage lets `/coord-away` arm on a fleet whose
311
+ // only evidence is that somebody reconnected.
312
+ //
313
+ // The inverse is worse and is why this is not merely tidiness: reading
314
+ // age as activity marks the two most CONTINUOUSLY ACTIVE agents on a bus
315
+ // as stale at 2.6h, while a freshly-rejoined idle agent reads healthy —
316
+ // a false-stall generator aimed at exactly the agents that must not be
317
+ // false-stalled while nobody is watching.
318
+ //
319
+ // The HIT above is kept: for an agent with no transport at all, "has not
320
+ // rejoined and has no live marker" is still the death signal Task 3.5
321
+ // specifies. What is removed is the coverage credit for freshness, which
322
+ // leaves coverage resting on the vcs predicate — the one that measures
323
+ // something the agent DID.
239
324
  }
240
325
  // A FRESH HEARTBEAT IS NOT PROGRESS. An agent can be alive and stuck, which is
241
326
  // the case "notice the room" never catches: the pane is responsive, so nobody
@@ -286,6 +371,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
286
371
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
287
372
  }).trim();
288
373
  const age = now - Date.parse(iso);
374
+ measured.add(agentId);
289
375
  if (age > limit) {
290
376
  hits.push({ kind: "no-vcs-activity", agentId, branch: raw, minutes: Math.round(age / 60000) });
291
377
  }
@@ -294,8 +380,15 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
294
380
  }
295
381
  }
296
382
 
297
- const result = { hits, checked: inFlight.length, unmeasurable };
298
- markRun(result);
383
+ // COVERAGE, not "did it run". A row is COVERED when at least one predicate
384
+ // produced a verdict about it; a row where every predicate came back
385
+ // unmeasurable was looked at and not measured, and counting it as checked is
386
+ // how "the clock ran" gets mistaken for "the fleet is observed".
387
+ const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
388
+ const blind = [...new Set(owners)].filter((id) => !measured.has(id));
389
+ const measurable = Math.max(0, inFlight.length - blind.length);
390
+ const result = { hits, checked: inFlight.length, unmeasurable, measurable, blind };
391
+ markRun({ hits, checked: inFlight.length, measurable });
299
392
  return {
300
393
  ok: true as const,
301
394
  ...result,
@@ -124,7 +124,20 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
124
124
  }
125
125
 
126
126
  const reachable = transportLive && paneAlive !== false;
127
- const alive = reachable || heartbeatFresh;
127
+ // heartbeatFresh is only valid EVIDENCE where something writes a heartbeat.
128
+ // A local tmux-push transport's liveness is its pid (isPidAlive) —
129
+ // hooks/tmux-pusher.mjs never calls `heartbeat`, so the field measures time
130
+ // since JOIN, not activity (Task 13.3/13.4, stall.ts's own established
131
+ // rule for exactly this transport type). Crediting it here for a
132
+ // tmux-push agent produced the incident this fix exists for: `alive` and
133
+ // `reachable` read fully healthy, `heartbeatFresh` sat in `checks` reading
134
+ // false, and the top-line boolean never surfaced the disagreement — a
135
+ // status layer discarding what its own lower layer already reported, the
136
+ // same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
137
+ // an agent with no probeable marker at all, has no pid to fall back on —
138
+ // there heartbeat genuinely IS the liveness mechanism, unchanged.
139
+ const heartbeatIsValidSignal = !marker || marker.transport !== "tmux-push";
140
+ const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
128
141
 
129
142
  let echoSent = false;
130
143
  if (args.echo && alive) {
@@ -146,6 +159,11 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
146
159
  registered: true,
147
160
  heartbeatFresh,
148
161
  heartbeatAgeSec,
162
+ // Whether heartbeatFresh above is real evidence for this transport, or
163
+ // just time-since-join. A reader who sees `heartbeatFresh: false` next
164
+ // to `heartbeatValid: false` should not read that as a dissenting
165
+ // signal — there is no signal there to dissent.
166
+ heartbeatValid: heartbeatIsValidSignal,
149
167
  transport: marker?.transport ?? null,
150
168
  transportLive,
151
169
  ...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
package/src/tools/work.ts CHANGED
@@ -17,10 +17,11 @@ import {
17
17
  workDocLegacyWriteIssues,
18
18
  LANES_V0_WRITE_ISSUE,
19
19
  } from "../work.js";
20
- import { parseFactsDoc, type FactEntry } from "@davidbalzan/groundwork-seam";
20
+ import { parseFactsDoc, type FactEntry, type Priority } from "@davidbalzan/groundwork-seam";
21
21
  import { loadScopes, ownsDocument } from "./scopes.js";
22
22
  import { AGENTS_FILE } from "../store.js";
23
23
  import type { AgentRegistry } from "./shared.js";
24
+ import { summarize, blockedBy } from "./records.js";
24
25
 
25
26
  // ---------- work state as data (Phase 8 Task 5) ----------
26
27
  //
@@ -189,8 +190,42 @@ export const listWorkSchema = {
189
190
  // native (default): discriminated v1 | lanes-v0. lanes: lossy projection of v1.
190
191
  view: z.enum(["native", "lanes"]).optional(),
191
192
  repo: z.string().optional(),
193
+ // Fetch ONE full record by id — the second call Task 15.1 asks for.
194
+ // Identity rows (below) never carry the body; this is how a caller gets it,
195
+ // and only for the one row it actually needs rather than every row's.
196
+ id: z.string().optional(),
192
197
  };
193
198
 
199
+ // A queue row without its body — id, priority, a bounded headline, and
200
+ // blocked-by, the fields the doc names. `owner` is NOT included: QueueItem
201
+ // carries no structured owner field, and the free-text `origin:` phrase some
202
+ // items end with ("origin: coordinator retracting its own broadcast canon…")
203
+ // is prose, not an id — extracting one heuristically would fabricate a field
204
+ // this data model does not have. Omitted rather than guessed, same
205
+ // discipline as `list_agents`' heartbeatSource (Task 13.5).
206
+ export type QueueIdentityRow = {
207
+ id: string;
208
+ priority: Priority | null;
209
+ headline: string;
210
+ truncated: boolean;
211
+ blockedBy: string | null;
212
+ };
213
+
214
+ function identityRowOf(item: QueueItem): QueueIdentityRow {
215
+ const flat = String(item.text).replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
216
+ return {
217
+ id: item.id,
218
+ priority: item.priority,
219
+ headline: summarize(item.text),
220
+ // `summarize` itself decides where to cut; re-deriving "did it cut" here
221
+ // from a second pass over the same text would be the two-matchers-one-
222
+ // question shape this phase keeps finding elsewhere (refsIn, COMMIT_REF).
223
+ // Comparing against its own bound is exact and free.
224
+ truncated: flat.length > 96,
225
+ blockedBy: blockedBy(item),
226
+ };
227
+ }
228
+
194
229
  export async function listWorkTool(args: {
195
230
  project: string;
196
231
  kind?: "queue" | "done" | "board" | "facts";
@@ -198,6 +233,7 @@ export async function listWorkTool(args: {
198
233
  includeDone?: boolean;
199
234
  view?: "native" | "lanes";
200
235
  repo?: string;
236
+ id?: string;
201
237
  }) {
202
238
  // No import yet (or the store was deleted) → read the documents directly.
203
239
  // This is the fallback that makes "the markdown stands alone" true in code
@@ -231,6 +267,17 @@ export async function listWorkTool(args: {
231
267
  }
232
268
  if (args.view === "lanes") board = board.map(projectV1ToLanes);
233
269
 
270
+ // THE SECOND CALL — one row, whole. Searches queue then done (a done entry
271
+ // shares the id space; an id is unique within a project's work state), so
272
+ // the caller does not have to know which kind its own id belongs to.
273
+ if (args.id !== undefined) {
274
+ const queueHit = queue.find((q) => q.id === args.id);
275
+ if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "queue" as const, item: queueHit };
276
+ const doneHit = done.find((d) => d.id === args.id);
277
+ if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "done" as const, item: doneHit };
278
+ return { ok: false as const, error: `no queue item or DONE entry with id '${args.id}' in project '${state.project}'` };
279
+ }
280
+
234
281
  const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
235
282
  const filtered = args.priority ? openQueue.filter((q) => q.priority === args.priority) : openQueue;
236
283
 
@@ -239,7 +286,12 @@ export async function listWorkTool(args: {
239
286
  project: state.project,
240
287
  repo: state.repo,
241
288
  source,
242
- ...(args.kind === "queue" || args.kind === undefined ? { queue: filtered } : {}),
289
+ // IDENTITY ONLY (Task 15.1) — id, priority, a bounded headline, and
290
+ // blocked-by; never the full item text. Call again with `id` for one
291
+ // row's body. This is the change that makes the tool cheap: the same
292
+ // question that used to return every open item's ~1,500-char body now
293
+ // returns a fixed-width row per item.
294
+ ...(args.kind === "queue" || args.kind === undefined ? { queue: filtered.map(identityRowOf) } : {}),
243
295
  ...(args.kind === "done" || args.kind === undefined ? { done } : {}),
244
296
  ...(args.kind === "board" || args.kind === undefined ? { board } : {}),
245
297
  ...(args.kind === "facts" || args.kind === undefined ? { facts } : {}),
@@ -268,7 +268,51 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
268
268
  for (const w of listWorktrees(repo)) {
269
269
  const at = w.path;
270
270
  if (samePath(at, primaryOf(repo) ?? "")) {
271
- results.push({ path: w.path, action: "skipped", why: "primary checkout — not a slice tree" });
271
+ // THE PRIMARY CHECKOUT IS REPORTED, ALWAYS — it used to be skipped with
272
+ // "not a slice tree", which is true and useless. It is the tree David
273
+ // reads, the one every hand-run measurement runs in, and a silent skip
274
+ // let it sit arbitrarily far behind while this verb reported a clean
275
+ // sweep. "Not ours to fast-forward" and "nothing to say about it" are
276
+ // different claims and only the first one was intended.
277
+ let behind = "unknown";
278
+ let ahead = "unknown";
279
+ try {
280
+ behind = git(at, ["rev-list", "--count", `HEAD..${tip}`]);
281
+ ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
282
+ } catch {
283
+ /* left as unknown — an unreadable count must not read as zero */
284
+ }
285
+ const dirty = isDirty(at);
286
+ // FAST-FORWARD ONLY, AND ONLY WHEN CLEAN. A ff-only merge cannot rewrite
287
+ // history or resolve a conflict, so the worst case is a refusal. Dirty is
288
+ // refused for the same reason a slice tree is: uncommitted work is what a
289
+ // diff cannot show and git cannot give back.
290
+ const canFf = !dirty && ahead === "0" && behind !== "0" && behind !== "unknown";
291
+ if (args.apply && canFf) {
292
+ try {
293
+ git(at, ["merge", "--ff-only", ref]);
294
+ results.push({ path: w.path, action: "fast-forwarded", why: `primary checkout advanced ${behind} commit(s) to ${ref}` });
295
+ continue;
296
+ } catch (e) {
297
+ results.push({ path: w.path, action: "refused", why: `primary checkout: ff-only merge failed — ${(e as Error).message}` });
298
+ continue;
299
+ }
300
+ }
301
+ results.push({
302
+ path: w.path,
303
+ action: behind === "0" ? "current" : "stale",
304
+ why:
305
+ behind === "0"
306
+ ? `primary checkout is at ${ref}`
307
+ : `primary checkout is ${behind} commit(s) behind ${ref}` +
308
+ (dirty
309
+ ? " and DIRTY — not fast-forwarded; uncommitted work is what a diff cannot show"
310
+ : ahead !== "0"
311
+ ? ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not`
312
+ : args.apply
313
+ ? ""
314
+ : " — pass apply:true to fast-forward it"),
315
+ });
272
316
  continue;
273
317
  }
274
318
  if (isDirty(at)) {