agent-coord-mcp 0.19.0 → 0.19.1

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.
@@ -118,11 +118,22 @@ export async function pruneTool(args: {
118
118
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
119
119
  const decisionCutoff = Date.now() - decisionDays * 24 * 60 * 60 * 1000;
120
120
  const dryRun = args.dryRun ?? false;
121
- // room-scoped prune touches only that channel's messages; targets narrows
122
- // the sweep otherwise. Default remains "everything".
121
+ // `room` scopes every sweep to that channel; `targets` narrows which sweeps
122
+ // run. They compose: `room` only changes the DEFAULT target set, so an
123
+ // explicit `targets` always wins.
124
+ //
125
+ // WHY (regression): this previously read `scopedRoom ? ["rooms"] : args.targets`,
126
+ // which silently DISCARDED an explicit `targets` whenever `room` was passed.
127
+ // `prune {room, targets:["members"], dryRun:true}` therefore reported
128
+ // `orphanMembers: []` because the member sweep never ran — a caller asking
129
+ // "which members are phantoms in this room" got a clean bill of health that
130
+ // had not been computed, while `roomMessages` (the one target the override
131
+ // left enabled) reported real messages a live run would have archived. Field
132
+ // report: two members that `ping` called `unregistered` were invisible here.
133
+ // A sweep must never answer a question it did not evaluate.
123
134
  const scopedRoom = args.room ? normalizeRoom(args.room) : undefined;
124
135
  const targets = new Set<PruneTarget>(
125
- scopedRoom ? ["rooms"] : args.targets ?? [...PRUNE_TARGETS]
136
+ args.targets ?? (scopedRoom ? ["rooms"] : [...PRUNE_TARGETS])
126
137
  );
127
138
  const keep = (e: { ts: number; kind?: string }) => keepEntry(e, cutoff, decisionCutoff);
128
139
 
@@ -133,6 +144,10 @@ export async function pruneTool(args: {
133
144
  const staleAgent = (m: string) => !knownAgents.has(m) || (reg[m]?.lastHeartbeat ?? 0) <= cutoff;
134
145
 
135
146
  const channels = scopedRoom ? [scopedRoom] : Object.keys(await getRooms());
147
+ // The membership sweeps walk the room registry rather than `channels`, so they
148
+ // need the scope predicate explicitly — without it, `room` scoped the message
149
+ // sweep while membership silently swept EVERY room.
150
+ const inScope = (chan: string) => !scopedRoom || chan === scopedRoom;
136
151
 
137
152
  if (dryRun) {
138
153
  let roomMessages = 0;
@@ -159,6 +174,7 @@ export async function pruneTool(args: {
159
174
  if (targets.has("members")) {
160
175
  const rooms = await getRooms();
161
176
  for (const [chan, e] of Object.entries(rooms)) {
177
+ if (!inScope(chan)) continue;
162
178
  const remaining: string[] = [];
163
179
  for (const m of e.members ?? []) {
164
180
  if (!knownAgents.has(m)) orphanMembers.add(m);
@@ -264,7 +280,8 @@ export async function pruneTool(args: {
264
280
  const archivedRooms: string[] = [];
265
281
  if (targets.has("members")) {
266
282
  await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
267
- for (const e of Object.values(current)) {
283
+ for (const [chan, e] of Object.entries(current)) {
284
+ if (!inScope(chan)) continue;
268
285
  if ((e.members?.length ?? 0) === 0) continue;
269
286
  e.members = (e.members ?? []).filter((m) => {
270
287
  if (!knownAgents.has(m)) {
@@ -284,7 +301,7 @@ export async function pruneTool(args: {
284
301
  if (args.archiveEmptyRooms ?? true) {
285
302
  const rooms = await getRooms();
286
303
  for (const [chan, e] of Object.entries(rooms)) {
287
- if (chan === DEFAULT_ROOM || (e.members?.length ?? 0) > 0) continue;
304
+ if (chan === DEFAULT_ROOM || !inScope(chan) || (e.members?.length ?? 0) > 0) continue;
288
305
  const file = roomFile(chan);
289
306
  const msgs = await readJsonl<Message>(file);
290
307
  const lastTs = msgs[msgs.length - 1]?.ts ?? 0;
@@ -46,7 +46,10 @@ import {
46
46
  transportFile,
47
47
  TRANSPORT_DIR,
48
48
  updateJson,
49
+ listSessionFiles,
50
+ readJsonStrict,
49
51
  type RoomRegistry,
52
+ type SessionBinding,
50
53
  } from "../store.js";
51
54
  import { recordAuthorityFor, resolveRole, roleInputSchema, type RoleArg } from "../roles.js";
52
55
  import {
@@ -73,6 +76,11 @@ export const registerSchema = {
73
76
  agentId: z.string().min(1),
74
77
  project: z.string().optional(),
75
78
  role: roleInputSchema.optional(),
79
+ // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
80
+ // that is LIVE on the bus refuses unless the call presents that agent's
81
+ // token (tokens.json / coord-token) or force:true. Ignored once bound.
82
+ token: z.string().optional(),
83
+ force: z.boolean().optional(),
76
84
  };
77
85
 
78
86
  // Work out what `role`/`roleId` should become, or why the update is refused.
@@ -299,6 +307,94 @@ export function isPidAlive(pid: number): boolean {
299
307
  }
300
308
  }
301
309
 
310
+ // ---------- first-claim liveness evidence ----------
311
+ // What the TOFU binding guard (server.ts guardFirstClaim) consults before a
312
+ // fresh session may claim an id. Three independent signals say "this id is
313
+ // currently active": a fresh registry heartbeat, a live transport marker, and
314
+ // a live session-binding marker from another pid. The verdict distinguishes
315
+ // VERIFIED ABSENT (state readable, id not live → free to bind; refusing here
316
+ // would break all onboarding) from CANNOT VERIFY (a state file exists but is
317
+ // unreadable → the guard must refuse rather than treat corruption as absence).
318
+ // `samePane`: a live local pusher for the claimed id types into THIS process's
319
+ // own tmux pane — two sessions cannot share a pane, so this is the same seat
320
+ // restarting in place, not a second session claiming a live id.
321
+
322
+ export type ClaimEvidence = {
323
+ live: boolean;
324
+ verifiable: boolean;
325
+ samePane: boolean;
326
+ boundElsewhere: number;
327
+ reasons: string[];
328
+ };
329
+
330
+ export async function liveClaimEvidence(agentId: string, now: number): Promise<ClaimEvidence> {
331
+ const reasons: string[] = [];
332
+ let verifiable = true;
333
+ let samePane = false;
334
+ let heartbeatFresh = false;
335
+ let markerLive = false;
336
+ let boundElsewhere = 0;
337
+
338
+ let reg: AgentRegistry = {};
339
+ try {
340
+ reg = await readJsonStrict<AgentRegistry>(AGENTS_FILE, {});
341
+ } catch {
342
+ verifiable = false;
343
+ reasons.push("agents.json exists but cannot be parsed — heartbeat liveness is unverifiable");
344
+ }
345
+ const entry = reg[agentId];
346
+ if (entry && now - entry.lastHeartbeat < STALE_MS) {
347
+ heartbeatFresh = true;
348
+ reasons.push(`fresh registry heartbeat ${Math.floor((now - entry.lastHeartbeat) / 1000)}s ago`);
349
+ }
350
+
351
+ let marker: TransportMarker | null = null;
352
+ try {
353
+ marker = await readJsonStrict<TransportMarker | null>(transportFile(agentId), null);
354
+ } catch {
355
+ verifiable = false;
356
+ reasons.push("transport marker exists but cannot be parsed — transport liveness is unverifiable");
357
+ }
358
+ if (marker && isMarkerLive(marker, reg, now)) {
359
+ markerLive = true;
360
+ reasons.push(
361
+ `live ${marker.transport} transport (pid ${marker.pid}${marker.tmuxTarget ? `, pane ${marker.tmuxTarget}` : ""})`,
362
+ );
363
+ if (
364
+ marker.transport === "tmux-push" &&
365
+ marker.tmuxTarget &&
366
+ process.env.TMUX_PANE &&
367
+ marker.tmuxTarget === process.env.TMUX_PANE
368
+ ) {
369
+ samePane = true;
370
+ }
371
+ }
372
+
373
+ for (const file of await listSessionFiles()) {
374
+ let s: SessionBinding | null = null;
375
+ try {
376
+ s = await readJsonStrict<SessionBinding | null>(file, null);
377
+ } catch {
378
+ verifiable = false;
379
+ reasons.push(`session binding ${path.basename(file)} cannot be parsed — unverifiable (doctor fix cleans it)`);
380
+ continue;
381
+ }
382
+ if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
383
+ if (isPidAlive(s.pid)) {
384
+ boundElsewhere++;
385
+ reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`);
386
+ }
387
+ }
388
+
389
+ return {
390
+ live: heartbeatFresh || markerLive || boundElsewhere > 0,
391
+ verifiable,
392
+ samePane,
393
+ boundElsewhere,
394
+ reasons,
395
+ };
396
+ }
397
+
302
398
  // ---------- rename_agent (NICK) ----------
303
399
 
304
400
  export const renameAgentSchema = {
@@ -31,7 +31,9 @@ import {
31
31
  inboxFile,
32
32
  listCursorFiles,
33
33
  listInboxFiles,
34
+ listSessionFiles,
34
35
  listTransportFiles,
36
+ type SessionBinding,
35
37
  logFile,
36
38
  memberRooms,
37
39
  normalizeRoom,
@@ -192,7 +194,21 @@ export const sendCommandSchema = {
192
194
 
193
195
  // A receipt as the pusher writes it. `submitted` is present only on control
194
196
  // receipts from a pusher new enough to VERIFY submission (v0.19.0+).
195
- type Receipt = { id: string; ts: number; control?: boolean; submitted?: boolean; verified?: boolean; reason?: string };
197
+ // `scriptMtime` is the reporting pusher's build identity — the same
198
+ // module-graph stamp the transport marker carries (newest mtime across the
199
+ // pusher's entry file AND its hooks/ imports, per #28: the stale part is as
200
+ // likely submit.mjs as the entrypoint). It is honesty, not security: a lying
201
+ // pusher defeats it, exactly like report_transport. The value is that a
202
+ // "confirmed" can be tied to the code that did the confirming.
203
+ type Receipt = {
204
+ id: string;
205
+ ts: number;
206
+ control?: boolean;
207
+ submitted?: boolean;
208
+ verified?: boolean;
209
+ reason?: string;
210
+ scriptMtime?: number;
211
+ };
196
212
 
197
213
  // Poll an agent's receipt log until a receipt for `msgId` appears or the
198
214
  // deadline passes. Returns the receipt, or null on timeout. File-only — no
@@ -221,18 +237,38 @@ async function waitForReceipt(agentId: string, msgId: string, timeoutMs: number)
221
237
  // pending with a reason the caller can act on. Reporting an unverified
222
238
  // submission as confirmed is the defect this exists to remove: a check that
223
239
  // cannot fail loudly is worse than no check.
240
+ //
241
+ // `pusherSourceMtime` (the caller passes newestPusherSourceMtime()) lets a
242
+ // CONFIRMED verdict carry a note when the reporting pusher's build identity
243
+ // is behind the on-disk pusher source, or absent entirely. The note never
244
+ // downgrades the verdict — the command demonstrably ran — it says whose
245
+ // verification logic said so. Absence of the stamp reads as UNKNOWN, never
246
+ // as fresh (same ruling as doctor's stale-pusher-script / provenance
247
+ // checks: absence is not exemption), and the absence note is issued before
248
+ // the on-disk comparison so an unstattable hooks dir cannot silence it.
224
249
  export function deliveryOutcome(
225
250
  agentId: string,
226
251
  receipt: Receipt | null,
227
252
  timeoutMs: number,
228
- ): { delivery: "confirmed" | "pending"; at?: number; reason?: string } {
253
+ pusherSourceMtime?: number,
254
+ ): { delivery: "confirmed" | "pending"; at?: number; reason?: string; note?: string } {
229
255
  if (!receipt) {
230
256
  return {
231
257
  delivery: "pending",
232
258
  reason: `no delivery receipt from '${agentId}' within ${timeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
233
259
  };
234
260
  }
235
- if (receipt.submitted === true) return { delivery: "confirmed", at: receipt.ts };
261
+ if (receipt.submitted === true) {
262
+ let note: string | undefined;
263
+ if (receipt.scriptMtime === undefined) {
264
+ note = `'${agentId}' confirmed the submission, but its pusher carries no build-identity stamp — the pusher predates receipt provenance and this confirmation cannot be tied to any known code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
265
+ } else if (pusherSourceMtime !== undefined && receipt.scriptMtime < pusherSourceMtime - 1) {
266
+ const loaded = new Date(receipt.scriptMtime).toISOString();
267
+ const ondisk = new Date(pusherSourceMtime).toISOString();
268
+ note = `'${agentId}' confirmed the submission, but its pusher loaded its code at ${loaded} and the on-disk pusher source is newer (${ondisk}) — the verification logic behind this confirmation predates the current code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
269
+ }
270
+ return { delivery: "confirmed", at: receipt.ts, ...(note ? { note } : {}) };
271
+ }
236
272
  if (receipt.submitted === false) {
237
273
  return {
238
274
  delivery: "pending",
@@ -351,7 +387,7 @@ export async function sendCommandTool(args: {
351
387
  const wait = args.waitForDelivery ?? true;
352
388
  const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
353
389
  const receipt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
354
- const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs) : null;
390
+ const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs, newestPusherSourceMtime()) : null;
355
391
  const confirmed = outcome?.delivery === "confirmed";
356
392
  // After /clear the receiver forgets its identity and that it's bus-attached
357
393
  // (the system prompt isn't re-applied because /clear isn't a session
@@ -373,7 +409,10 @@ export async function sendCommandTool(args: {
373
409
  delivery: outcome.delivery,
374
410
  confirmed,
375
411
  ...(outcome.at !== undefined ? { deliveredAt: outcome.at } : {}),
376
- ...(confirmed ? {} : { warning: outcome.reason }),
412
+ // A confirmed delivery can still warn: the note names a reporting
413
+ // pusher whose build identity is stale or absent. `confirmed`
414
+ // stays true — the command ran; the warning is about who said so.
415
+ ...(confirmed ? (outcome.note ? { warning: outcome.note } : {}) : { warning: outcome.reason }),
377
416
  }
378
417
  : {}),
379
418
  ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
@@ -409,11 +448,13 @@ export async function sendCommandTool(args: {
409
448
  let confirmed: string[] = [];
410
449
  let pending: string[] = [];
411
450
  let pendingReasons: string[] = [];
451
+ let confirmNotes: string[] = [];
412
452
  if (wait) {
453
+ const sourceMtime = newestPusherSourceMtime();
413
454
  const results = await Promise.all(
414
455
  delivered.map(async (m) => ({
415
456
  m,
416
- outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs),
457
+ outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs, sourceMtime),
417
458
  })),
418
459
  );
419
460
  confirmed = results.filter((r) => r.outcome.delivery === "confirmed").map((r) => r.m);
@@ -421,6 +462,9 @@ export async function sendCommandTool(args: {
421
462
  pendingReasons = results
422
463
  .filter((r) => r.outcome.delivery !== "confirmed")
423
464
  .map((r) => `${r.m}: ${r.outcome.reason}`);
465
+ confirmNotes = results
466
+ .filter((r) => r.outcome.delivery === "confirmed" && r.outcome.note)
467
+ .map((r) => r.outcome.note!);
424
468
  }
425
469
  // Same post-/clear re-anchor as the DM path — one reminder per delivered
426
470
  // member, in their own inbox, with their own agentId in the body.
@@ -444,6 +488,9 @@ export async function sendCommandTool(args: {
444
488
  warning: `not confirmed as submitted within ${deliveryTimeoutMs}ms — ${pendingReasons.join(" | ")}`,
445
489
  }
446
490
  : {}),
491
+ // Confirmed members whose reporting pusher is stale or unstamped —
492
+ // the confirmations stand, the notes say whose code issued them.
493
+ ...(confirmNotes.length ? { notes: confirmNotes } : {}),
447
494
  }
448
495
  : {}),
449
496
  ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
@@ -712,6 +759,11 @@ export const joinSchema = {
712
759
  // false → never; object → attach with overrides.
713
760
  attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
714
761
  readInbox: z.boolean().optional(),
762
+ // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
763
+ // that is LIVE on the bus refuses unless the call presents that agent's
764
+ // token (tokens.json / coord-token) or force:true. Ignored once bound.
765
+ token: z.string().optional(),
766
+ force: z.boolean().optional(),
715
767
  };
716
768
 
717
769
  export async function joinTool(args: {
@@ -827,6 +879,13 @@ export const reportReceiptSchema = {
827
879
  submitted: z.boolean().optional(),
828
880
  verified: z.boolean().optional(),
829
881
  reason: z.string().optional(),
882
+ // Build identity of the reporting pusher: newest mtime across its loaded
883
+ // module graph (entry file + hooks/ imports), sampled once at its startup —
884
+ // the same basis report_transport's scriptMtime uses. Absent → the receipt's
885
+ // provenance is UNKNOWN and deliveryOutcome says so; the server never
886
+ // defaults it (a default here would be assume-fresh, the twin of the
887
+ // assume-success `submitted` refuses to invent).
888
+ scriptMtime: z.number().optional(),
830
889
  };
831
890
 
832
891
  // Wire-callable counterpart to the local pusher's receipt stamp (writeReceipts
@@ -852,6 +911,7 @@ export async function reportReceiptTool(args: {
852
911
  submitted?: boolean;
853
912
  verified?: boolean;
854
913
  reason?: string;
914
+ scriptMtime?: number;
855
915
  }) {
856
916
  const receipt: Receipt & { agentId: string; from?: string } = {
857
917
  id: args.id,
@@ -862,6 +922,7 @@ export async function reportReceiptTool(args: {
862
922
  ...(args.submitted !== undefined ? { submitted: args.submitted } : {}),
863
923
  ...(args.verified !== undefined ? { verified: args.verified } : {}),
864
924
  ...(args.reason !== undefined ? { reason: args.reason } : {}),
925
+ ...(args.scriptMtime !== undefined ? { scriptMtime: args.scriptMtime } : {}),
865
926
  };
866
927
  await appendJsonl(receiptFile(args.agentId), receipt);
867
928
  return { ok: true, receipt };
@@ -1184,6 +1245,55 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1184
1245
  });
1185
1246
  }
1186
1247
 
1248
+ // 1d. Duplicate session bindings — two live MCP sessions bound to one agent
1249
+ // id means two processes are ACTING as the same agent (the
1250
+ // disavow-liaison shape: a dev session bound onto a live worker's id;
1251
+ // force/token make that possible on purpose, this makes it visible).
1252
+ // Bindings are per-process closure state, so this reads the on-disk
1253
+ // session markers stdio servers write at bind time. A marker whose pid
1254
+ // is dead is litter from a killed session (default signal death skips
1255
+ // exit handlers) — cleaned under fix. Live duplicates are NOT auto-
1256
+ // fixable: doctor cannot know which of two running sessions is the
1257
+ // impostor; the wrong session should `quit` (its marker clears on exit).
1258
+ {
1259
+ const byAgent = new Map<string, { pid: number; via: string; boundAt: number }[]>();
1260
+ const stale: string[] = [];
1261
+ for (const file of await listSessionFiles()) {
1262
+ const s = await readJson<SessionBinding | null>(file, null);
1263
+ if (!s || typeof s.pid !== "number" || !s.agentId || !isPidAlive(s.pid)) {
1264
+ stale.push(path.basename(file));
1265
+ if (fix) {
1266
+ await deleteFile(file);
1267
+ fixed.push(`deleted stale session binding ${path.basename(file)}`);
1268
+ }
1269
+ continue;
1270
+ }
1271
+ const list = byAgent.get(s.agentId) ?? [];
1272
+ list.push({ pid: s.pid, via: s.via ?? "unknown", boundAt: s.boundAt ?? 0 });
1273
+ byAgent.set(s.agentId, list);
1274
+ }
1275
+ const dupes: string[] = [];
1276
+ for (const [id, list] of byAgent) {
1277
+ if (list.length < 2) continue;
1278
+ dupes.push(
1279
+ `${id} — ${list
1280
+ .map((b) => `pid ${b.pid} (via ${b.via}, bound ${b.boundAt ? new Date(b.boundAt).toISOString() : "unknown"})`)
1281
+ .join(" AND ")}`,
1282
+ );
1283
+ }
1284
+ findings.push({
1285
+ check: "duplicate-session-binding",
1286
+ level: dupes.length ? "warn" : "ok",
1287
+ detail: dupes.length
1288
+ ? `${dupes.length} agent id(s) bound by more than one live session — two processes are acting as the same agent. Decide which is legitimate; the other should quit (its binding clears on exit).`
1289
+ : stale.length
1290
+ ? `no duplicate session bindings (${stale.length} stale binding file(s) from dead sessions${fix ? " — cleaned" : "; run doctor with fix:true to clean"})`
1291
+ : "no duplicate session bindings",
1292
+ fixable: true,
1293
+ items: dupes.length ? dupes : undefined,
1294
+ });
1295
+ }
1296
+
1187
1297
  // 2. Orphan room memberships (member not in the registry).
1188
1298
  {
1189
1299
  const orphans = new Set<string>();
package/src/tools/work.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  parseWorkDoc,
14
14
  queueItemsOf,
15
15
  renderWorkDoc,
16
+ workDocIssues,
16
17
  } from "../work.js";
17
18
  import { loadScopes, ownsDocument } from "./scopes.js";
18
19
  import { AGENTS_FILE } from "../store.js";
@@ -110,18 +111,28 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
110
111
  const state: WorkState = { project: args.project, repo, importedAt: Date.now(), docs };
111
112
  await saveState(state);
112
113
 
114
+ const allIssues = docs.flatMap((d) => workDocIssues(d.doc).map((issue) => `${d.path}: ${issue}`));
113
115
  return {
114
116
  ok: true as const,
115
117
  project: args.project,
116
118
  repo,
117
119
  file: workFile(args.project),
118
- imported: docs.map((d) => ({
119
- path: d.path,
120
- kind: d.kind,
121
- queue: queueItemsOf(d.doc).length,
122
- done: doneEntriesOf(d.doc).length,
123
- board: boardRowsOf(d.doc).length,
124
- })),
120
+ imported: docs.map((d) => {
121
+ const issues = workDocIssues(d.doc);
122
+ return {
123
+ path: d.path,
124
+ kind: d.kind,
125
+ queue: queueItemsOf(d.doc).length,
126
+ done: doneEntriesOf(d.doc).length,
127
+ board: boardRowsOf(d.doc).length,
128
+ ...(issues.length ? { issues } : {}),
129
+ };
130
+ }),
131
+ ...(allIssues.length
132
+ ? {
133
+ warning: `${allIssues.length} table row(s) were refused a record (wrong column count) and kept verbatim — the documents round-trip unchanged, but these rows are invisible to board consumers until fixed. See imported[].issues.`,
134
+ }
135
+ : {}),
125
136
  note: "the markdown remains authoritative — this store is a derived index",
126
137
  };
127
138
  }
@@ -161,10 +172,12 @@ export async function listWorkTool(args: {
161
172
  const queue: QueueItem[] = [];
162
173
  const done: DoneEntry[] = [];
163
174
  const board: BoardRow[] = [];
175
+ const issues: string[] = [];
164
176
  for (const d of state.docs) {
165
177
  queue.push(...queueItemsOf(d.doc));
166
178
  done.push(...doneEntriesOf(d.doc));
167
179
  board.push(...boardRowsOf(d.doc));
180
+ issues.push(...workDocIssues(d.doc).map((issue) => `${d.path}: ${issue}`));
168
181
  }
169
182
 
170
183
  const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
@@ -178,6 +191,9 @@ export async function listWorkTool(args: {
178
191
  ...(args.kind === "queue" || args.kind === undefined ? { queue: filtered } : {}),
179
192
  ...(args.kind === "done" || args.kind === undefined ? { done } : {}),
180
193
  ...(args.kind === "board" || args.kind === undefined ? { board } : {}),
194
+ // A row refused for wrong arity is absent from `board` — say so rather
195
+ // than let the absence read as "that lane doesn't exist".
196
+ ...(issues.length ? { issues } : {}),
181
197
  };
182
198
  }
183
199
 
@@ -245,11 +261,16 @@ export async function exportWorkTool(args: {
245
261
  : undefined;
246
262
 
247
263
  if (write && rendered !== current) await fsp.writeFile(target, rendered, "utf8");
264
+ const issues = workDocIssues(d.doc);
248
265
  files.push({
249
266
  path: d.path,
250
267
  bytes: Buffer.byteLength(rendered, "utf8"),
251
268
  identical: rendered === current,
252
269
  written: write && rendered !== current,
270
+ // Refused rows replay verbatim — the write is byte-faithful — but a
271
+ // caller rewriting a document should hear that some rows carry no
272
+ // record, rather than infer health from `identical:true`.
273
+ ...(issues.length ? { issues } : {}),
253
274
  ...(scope ? { scope } : {}),
254
275
  });
255
276
  }
package/src/work.ts CHANGED
@@ -63,6 +63,33 @@ export type BoardRow = {
63
63
  raw?: string;
64
64
  };
65
65
 
66
+ // The one arity BoardRow can hold. boardCells() and the parser's arity guard
67
+ // both reference this constant so the two sides cannot drift; a test pins
68
+ // boardCells().length === BOARD_ARITY.
69
+ export const BOARD_ARITY = 5;
70
+
71
+ // A table row whose column count does not match BoardRow. It is REFUSED a
72
+ // record (boardRowsOf never returns it) but PRESERVED byte-exactly — the
73
+ // alternative, destructuring whatever arity into a fixed 5-tuple, silently
74
+ // narrowed 6-column rows and widened 4-column ones on export (hit live
75
+ // 2026-07-29: #38's extra `Pane` column was dropped on write-back). Refusing
76
+ // the row rather than throwing keeps one bad row from taking down the whole
77
+ // document's import. The schema decision stays with the board owner: widening
78
+ // BoardRow is a deliberate act (change BOARD_ARITY, boardCells, and this
79
+ // guard together), never something a docs commit does by accident.
80
+ export type MalformedRow = {
81
+ malformed: true;
82
+ // Replayed byte-exactly on render — never narrowed, never widened.
83
+ verbatim: string;
84
+ // Names the line, quotes the row, states expected/actual — loud enough to
85
+ // fix from the message alone.
86
+ issue: string;
87
+ };
88
+
89
+ export function isMalformedRow(r: BoardRow | MalformedRow): r is MalformedRow {
90
+ return (r as MalformedRow).malformed === true;
91
+ }
92
+
66
93
  // A parsed document: an ordered block list. `text` blocks are verbatim lines
67
94
  // (never interpreted); the others carry records rendered back in place.
68
95
  export type Block =
@@ -71,7 +98,7 @@ export type Block =
71
98
  | { kind: "done"; entries: DoneEntry[] }
72
99
  // `header` and `align` are kept as raw lines for the same reason as
73
100
  // BoardRow.raw — the alignment row (`|---|---|`) has no canonical form.
74
- | { kind: "board"; header: string; align: string; rows: BoardRow[] };
101
+ | { kind: "board"; header: string; align: string; rows: (BoardRow | MalformedRow)[] };
75
102
 
76
103
  export type WorkDoc = {
77
104
  // Kept so an export can be written back with the exact byte tail it had.
@@ -182,11 +209,13 @@ function boardCells(r: BoardRow): string[] {
182
209
  }
183
210
 
184
211
  // Replay the original line when the fields still say what it said; re-render
185
- // once anything actually changed.
186
- export function renderBoardRow(r: BoardRow): string {
212
+ // once anything actually changed. A malformed row has no fields to have
213
+ // changed — it replays its bytes, always.
214
+ export function renderBoardRow(r: BoardRow | MalformedRow): string {
215
+ if (isMalformedRow(r)) return r.verbatim;
187
216
  if (r.raw !== undefined) {
188
217
  const cells = splitRow(r.raw);
189
- if (cells.length === 5 && cells.every((c, i) => c === boardCells(r)[i])) return r.raw;
218
+ if (cells.length === BOARD_ARITY && cells.every((c, i) => c === boardCells(r)[i])) return r.raw;
190
219
  }
191
220
  return renderRow(boardCells(r));
192
221
  }
@@ -280,11 +309,28 @@ export function parseWorkDoc(source: string): WorkDoc {
280
309
 
281
310
  // Lanes table: a header row followed by an alignment row.
282
311
  if (TABLE_ROW.test(line) && TABLE_ALIGN.test(lines[i + 1] ?? "")) {
283
- const rows: BoardRow[] = [];
312
+ const rows: (BoardRow | MalformedRow)[] = [];
284
313
  let j = i + 2;
285
314
  for (; j < lines.length && TABLE_ROW.test(lines[j] ?? ""); j++) {
286
315
  const raw = lines[j] ?? "";
287
- const [lane = "", owner = "", state = "", currentSlice = "", nextGo = ""] = splitRow(raw);
316
+ const cells = splitRow(raw);
317
+ // Arity guard: a row BoardRow cannot hold is refused a record, kept
318
+ // verbatim, and named loudly — destructuring it into the 5-tuple is
319
+ // exactly the silent narrow/widen this exists to prevent. An empty
320
+ // trailing cell (`| a | b | c | d | |`) is still five columns and
321
+ // still a legitimate row.
322
+ if (cells.length !== BOARD_ARITY) {
323
+ rows.push({
324
+ malformed: true,
325
+ verbatim: raw,
326
+ issue:
327
+ `board row at line ${j + 1} has ${cells.length} column(s), expected ${BOARD_ARITY} — ` +
328
+ `refused (kept verbatim, excluded from board records). Fix the row, or widen BoardRow ` +
329
+ `deliberately (BOARD_ARITY + boardCells + this guard together). Row: ${raw}`,
330
+ });
331
+ continue;
332
+ }
333
+ const [lane = "", owner = "", state = "", currentSlice = "", nextGo = ""] = cells;
288
334
  rows.push({ id: idFor("b", lane), lane, owner, state, currentSlice, nextGo, raw });
289
335
  }
290
336
  flushText();
@@ -325,5 +371,16 @@ export function doneEntriesOf(doc: WorkDoc): DoneEntry[] {
325
371
  }
326
372
 
327
373
  export function boardRowsOf(doc: WorkDoc): BoardRow[] {
328
- return doc.blocks.flatMap((b) => (b.kind === "board" ? b.rows : []));
374
+ return doc.blocks.flatMap((b) =>
375
+ b.kind === "board" ? b.rows.filter((r): r is BoardRow => !isMalformedRow(r)) : [],
376
+ );
377
+ }
378
+
379
+ // Every refused row's issue, in document order — what makes the parse-time
380
+ // rejection LOUD at the tool layer (import_work/list_work/export_work all
381
+ // surface it) instead of a filtered-out record nobody notices.
382
+ export function workDocIssues(doc: WorkDoc): string[] {
383
+ return doc.blocks.flatMap((b) =>
384
+ b.kind === "board" ? b.rows.filter(isMalformedRow).map((r) => r.issue) : [],
385
+ );
329
386
  }