agent-coord-mcp 0.26.17 → 0.26.19

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 (44) hide show
  1. package/dist/server.js +5 -4
  2. package/dist/server.js.map +1 -1
  3. package/dist/tools/admin.js +8 -2
  4. package/dist/tools/admin.js.map +1 -1
  5. package/dist/tools/away.js +45 -3
  6. package/dist/tools/away.js.map +1 -1
  7. package/dist/tools/records.js +269 -20
  8. package/dist/tools/records.js.map +1 -1
  9. package/dist/tools/rooms.js +2 -2
  10. package/dist/tools/rooms.js.map +1 -1
  11. package/dist/tools/shared.js +18 -0
  12. package/dist/tools/shared.js.map +1 -1
  13. package/dist/tools/stall.js +28 -1
  14. package/dist/tools/stall.js.map +1 -1
  15. package/dist/tools/transport.js +12 -5
  16. package/dist/tools/transport.js.map +1 -1
  17. package/dist/tools/work.js +59 -5
  18. package/dist/tools/work.js.map +1 -1
  19. package/dist/tools/worktrees.js +245 -3
  20. package/dist/tools/worktrees.js.map +1 -1
  21. package/dist/work.js +1 -1
  22. package/dist/work.js.map +1 -1
  23. package/hooks/peek-coord.mjs +0 -0
  24. package/hooks/tmux-pusher.mjs +0 -0
  25. package/package.json +11 -14
  26. package/scripts/coord-attention-clock.mjs +0 -0
  27. package/scripts/coord-node.sh +0 -0
  28. package/scripts/coord-stall-clock.mjs +0 -0
  29. package/scripts/coord-token.mjs +0 -0
  30. package/scripts/probe-tmux-liveness.sh +0 -0
  31. package/scripts/spawn-agent.sh +0 -0
  32. package/scripts/stop-agent.sh +0 -0
  33. package/scripts/typed-record-stats.mjs +0 -0
  34. package/src/server.ts +11 -3
  35. package/src/tools/admin.ts +8 -1
  36. package/src/tools/away.ts +48 -4
  37. package/src/tools/records.ts +274 -18
  38. package/src/tools/rooms.ts +2 -1
  39. package/src/tools/shared.ts +18 -0
  40. package/src/tools/stall.ts +29 -1
  41. package/src/tools/transport.ts +12 -4
  42. package/src/tools/work.ts +70 -4
  43. package/src/tools/worktrees.ts +267 -3
  44. package/src/work.ts +4 -0
package/src/tools/work.ts CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  projectV1ToLanes,
14
14
  queueItemsOf,
15
15
  renderWorkDoc,
16
+ renderWorkDocForWrite,
17
+ zeroIsUnparsed,
16
18
  workDocIssues,
17
19
  workDocLegacyWriteIssues,
18
20
  LANES_V0_WRITE_ISSUE,
@@ -55,6 +57,13 @@ export type StoredWorkDoc = {
55
57
  kind: "queue" | "done" | "board" | "legacy";
56
58
  path: string;
57
59
  doc: WorkDoc;
60
+ /**
61
+ * The bytes as read. Kept because a COUNT OF ZERO cannot be interpreted
62
+ * without them: `zeroIsUnparsed` needs to know whether the document had
63
+ * content the parser did not understand, and the parsed doc has already
64
+ * discarded that distinction (q-3e7c81a5).
65
+ */
66
+ source: string;
58
67
  };
59
68
 
60
69
  export type StoredFactsDoc = {
@@ -77,7 +86,7 @@ async function loadDoc(repo: string, f: { kind: WorkFileKind; path: string }): P
77
86
  const parsed = parseFactsDoc(source);
78
87
  return { kind: "facts", path: f.path, source, entries: parsed.entries, issues: parsed.issues };
79
88
  }
80
- return { kind: f.kind, path: f.path, doc: parseWorkDoc(source) };
89
+ return { kind: f.kind, path: f.path, doc: parseWorkDoc(source), source };
81
90
  }
82
91
 
83
92
  function docIssues(d: StoredDoc): string[] {
@@ -97,12 +106,56 @@ function importedSummary(d: StoredDoc) {
97
106
  };
98
107
  }
99
108
  const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)];
100
- return {
101
- path: d.path,
102
- kind: d.kind,
109
+ const counts = {
103
110
  queue: queueItemsOf(d.doc).length,
104
111
  done: doneEntriesOf(d.doc).length,
105
112
  board: listWorkBoardOf(d.doc).length,
113
+ };
114
+ // A ZERO THAT CANNOT BE TRUSTED IS NAMED, NOT RETURNED BARE.
115
+ //
116
+ // These three were plain `.length`, so a document the parser did not
117
+ // understand reported the same zero as an empty one. A consumer's dashboard
118
+ // read that and showed a fleet as IDLE on a night it merged 27 PRs; they
119
+ // declined to enable these verbs rather than ship a display they could not
120
+ // trust (q-3e7c81a5).
121
+ //
122
+ // The counts keep their shape and meaning — a reader that only wants numbers
123
+ // is unaffected — and `unparsed` says which of them are unanswered questions
124
+ // rather than answers. The predicate is the SEAM's, shared with `doctor`'s
125
+ // queue-done-loop rather than restated here.
126
+ // ONLY THE AXES THIS DOCUMENT CLAIMS TO CARRY.
127
+ //
128
+ // Caught by the test's own output rather than by review: qualifying all three
129
+ // for every file reported `docs/QUEUE.md` as having unparsed `done` and `board`
130
+ // axes, which is nonsense — a queue document is not expected to hold done
131
+ // entries, so its zero there is not an unanswered question, it is a category
132
+ // error on my part. Scoping the qualification to the document's KIND is the
133
+ // same population discipline the count itself needed: a claim about an axis a
134
+ // file never carried is noise, and noise is what teaches a reader to skip the
135
+ // real one. `legacy` is the single-file BACKLOG.md, which carries both regions.
136
+ const AXES_BY_KIND = {
137
+ queue: ["queue"],
138
+ done: ["done"],
139
+ board: ["board"],
140
+ legacy: ["queue", "done"],
141
+ } as const;
142
+ const unparsed = (AXES_BY_KIND[d.kind] as readonly (keyof typeof counts)[]).filter((axis) =>
143
+ zeroIsUnparsed(counts[axis], d.source),
144
+ );
145
+ return {
146
+ path: d.path,
147
+ kind: d.kind,
148
+ ...counts,
149
+ ...(unparsed.length
150
+ ? {
151
+ unparsed,
152
+ unparsedNote:
153
+ `${d.path} has content the parser did not understand: ${unparsed.join(", ")} parsed to ZERO. ` +
154
+ `That is an unanswered question, not an empty document — do not render it as idle, empty or done. ` +
155
+ `Either the file uses a shape this grammar does not accept, or it is malformed; both need a human, and ` +
156
+ `neither is "nothing there".`,
157
+ }
158
+ : {}),
106
159
  ...(issues.length ? { issues } : {}),
107
160
  };
108
161
  }
@@ -359,6 +412,19 @@ export async function exportWorkTool(args: {
359
412
  });
360
413
  continue;
361
414
  }
415
+ // DELIBERATELY THE PURE RENDER, NOT THE STAMPING ONE (q-c50e9b83).
416
+ //
417
+ // I routed this through `renderWorkDocForWrite` first and it broke three
418
+ // pre-existing tests that assert `import → export` writes the real documents
419
+ // back BYTE-IDENTICALLY. They were right and the change was wrong: this is a
420
+ // TRANSPORT, not an authoring path. Its contract is to reproduce a document,
421
+ // and a transport that silently edits content the caller never asked it to
422
+ // touch is a worse defect than the one being fixed — it would also mean
423
+ // exporting into ANOTHER project's documents mutates them.
424
+ //
425
+ // So the primary is scoped to the AUTHORING writer (`land`, via records.ts),
426
+ // and an unstamped item arriving through this path is the backstop's
427
+ // population: it cannot reach `main` without passing a push.
362
428
  const rendered = renderWorkDoc(d.doc);
363
429
  const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null;
364
430
  const declared = scopes.documents.find((s) => s.path === d.path);
@@ -227,11 +227,58 @@ async function create(a: {
227
227
  atBase: true,
228
228
  behindBy: 0,
229
229
  ...(a.ephemeral
230
- ? { ephemeral: true, removeWith: `git -C ${repo} worktree remove --force ${target} && git -C ${repo} branch -D ${branch}` }
230
+ ? {
231
+ ephemeral: true,
232
+ // A VERB NAME, NOT A COMMAND LINE. This used to hand back the shell to
233
+ // paste — the tool knew the repo, path and branch and spent the agent's
234
+ // tokens on work it could do itself.
235
+ releaseWith: { verb: "release_worktree", args: { repo, path: target, apply: true } },
236
+ }
231
237
  : {}),
232
238
  };
233
239
  }
234
240
 
241
+ // ---------- shared: is this tree's work already in the base? ----------
242
+
243
+ /**
244
+ * Are the commits HEAD has beyond `tip` ALREADY LANDED there?
245
+ *
246
+ * `rev-list --count <tip>..HEAD` is pure ANCESTRY, and under squash merge a landed
247
+ * branch's own shas never enter the base's history — so `ahead` stays > 0 FOREVER
248
+ * for work that shipped (q-5c40db91; 20 of the last 20 merges on this repo's main
249
+ * are single-parent). Reading `ahead > 0` as "this tree holds unlanded work" makes
250
+ * the verb whose job is fast-forwarding idle trees classify a FINISHED tree as
251
+ * mid-slice, permanently.
252
+ *
253
+ * MEASURED, and it cost a lane cycle: a `claim` was refused because a worker's tree
254
+ * "reads as 1 commit not on origin/main" while that branch had been squash-merged an
255
+ * hour earlier. Same inverted alarm as the item's headline — landed work reported as
256
+ * unmerged, inviting preservation of work already shipped — inside our own tooling.
257
+ *
258
+ * `git cherry` compares PATCH IDS, so every line marked `-` means every commit's patch
259
+ * is already upstream. It is NOT complete: several commits squashed into one carry a
260
+ * different combined patch id, so a negative is INCONCLUSIVE and is reported as
261
+ * mid-slice exactly as before. Only a positive changes the verdict, which keeps the
262
+ * failure direction the same as today's for everything this cannot prove.
263
+ */
264
+ function aheadIsLanded(at: string, tip: string): boolean {
265
+ // `git` here THROWS rather than returning null, so the catch is what keeps an
266
+ // UNREADABLE answer out of the landed bucket: not measured is not landed, and
267
+ // the tree stays mid-slice, which is the direction that refuses rather than
268
+ // the direction that tells someone their work is safe to discard.
269
+ let cherry: string;
270
+ try {
271
+ cherry = git(at, ["cherry", tip, "HEAD"]);
272
+ } catch {
273
+ return false;
274
+ }
275
+ if (cherry.trim().length === 0) return false;
276
+ return cherry
277
+ .trim()
278
+ .split("\n")
279
+ .every((l) => l.trim().startsWith("-"));
280
+ }
281
+
235
282
  // ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
236
283
 
237
284
  export const refreshWorktreesSchema = {
@@ -264,6 +311,7 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
264
311
  return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
265
312
  }
266
313
 
314
+
267
315
  const results = [];
268
316
  for (const w of listWorktrees(repo)) {
269
317
  const at = w.path;
@@ -308,7 +356,9 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
308
356
  (dirty
309
357
  ? " and DIRTY — not fast-forwarded; uncommitted work is what a diff cannot show"
310
358
  : ahead !== "0"
311
- ? ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not`
359
+ ? aheadIsLanded(at, tip)
360
+ ? ` and ${ahead} ahead whose patches are ALREADY UPSTREAM — squash-merged, so ancestry will never agree; not fast-forwarded, but nothing here needs preserving`
361
+ : ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not`
312
362
  : args.apply
313
363
  ? ""
314
364
  : " — pass apply:true to fast-forward it"),
@@ -337,7 +387,28 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
337
387
  /* treated as diverged below */
338
388
  }
339
389
  if (ahead !== "0") {
340
- results.push({ path: w.path, action: "refused", why: `MID-SLICE: ${ahead} commit(s) not on ${ref}. Refusing rather than --force.` });
390
+ // LANDED, NOT MID-SLICE — the ancestry count cannot tell these apart.
391
+ if (aheadIsLanded(at, tip)) {
392
+ results.push({
393
+ path: w.path,
394
+ action: "landed",
395
+ why:
396
+ `${ahead} commit(s) are not ancestors of ${ref}, but every one of their PATCHES is already upstream — ` +
397
+ `this branch was SQUASH-MERGED and its work has shipped. Ancestry can never say so: a squash writes a new ` +
398
+ `commit, so these shas will read as "not on ${ref}" forever. Nothing here needs preserving; detach to ${ref} ` +
399
+ `(or delete the branch) and this tree is reusable. NOT fast-forwarded automatically — moving a tree off ` +
400
+ `committed work is a decision, not a refresh.`,
401
+ });
402
+ continue;
403
+ }
404
+ results.push({
405
+ path: w.path,
406
+ action: "refused",
407
+ why:
408
+ `MID-SLICE: ${ahead} commit(s) not on ${ref}, and their patches are NOT upstream. Refusing rather than --force. ` +
409
+ `(A squash of SEVERAL commits into one changes the combined patch id, so this cannot prove the negative — ` +
410
+ `it reports mid-slice, which is the same direction it always failed in.)`,
411
+ });
341
412
  continue;
342
413
  }
343
414
  if (!args.apply) {
@@ -353,3 +424,196 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
353
424
  }
354
425
  return { ok: true as const, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results };
355
426
  }
427
+
428
+ // ---------- the counterpart to ensure_worktree: give the tree back ----------
429
+
430
+ /*
431
+ * `ensure_worktree` CREATED trees and nothing RELEASED them, so teardown stayed a
432
+ * hand step in five role cards — `git worktree prune` in coord-audit, coord-ci,
433
+ * coord-qa and coord-worker, `git worktree remove` twice in coordinator, which
434
+ * says so itself: "it never removes, so `git worktree remove`/`prune` remain hand
435
+ * steps". Seven commands for one act the bus already had the inputs for.
436
+ *
437
+ * WORSE THAN ABSENT: `ensure_worktree` COMPUTED the teardown command and handed it
438
+ * back as a string for the agent to paste (`removeWith`). The tool knew the repo,
439
+ * the path and the branch, and spent the agent's tokens on shell it could have run.
440
+ * That is the shape David ruled against: "where possible we should hide the
441
+ * plumbing from the agents".
442
+ *
443
+ * THE REFUSALS ARE THE POINT, and they inherit `refresh_worktrees`' direction —
444
+ * report by default, refuse per tree with the reason named, never clobber:
445
+ *
446
+ * - THE PRIMARY CHECKOUT IS NEVER RELEASED. It is the path everyone already has.
447
+ * - A DIRTY TREE IS REFUSED. Uncommitted work is what a diff cannot show and git
448
+ * cannot give back, so this is the one refusal with no override.
449
+ * - A TREE HOLDING UNLANDED COMMITS IS REFUSED — and "unlanded" is decided by
450
+ * `aheadIsLanded` (patch ids), NOT by ancestry. Under squash merge a landed
451
+ * branch's shas never enter the base, so ancestry calls every finished tree
452
+ * mid-slice forever (q-5c40db91). Using ancestry here would refuse to release
453
+ * exactly the trees that are safe to release, which is how the hand step
454
+ * survived: the guard would have been wrong and everyone would have forced it.
455
+ *
456
+ * A NEGATIVE FROM `aheadIsLanded` IS INCONCLUSIVE, NOT A NO. Several commits squashed
457
+ * into one carry a different combined patch id, so it cannot prove landedness for
458
+ * every shape. It therefore refuses and says the check was inconclusive rather than
459
+ * claiming the work is unlanded — the caller lands it, or passes `force:true`, which
460
+ * is recorded in the result so a forced release is findable afterwards.
461
+ */
462
+ export const releaseWorktreeSchema = {
463
+ repo: z.string().min(1),
464
+ agentId: z.string().min(1).optional(),
465
+ path: z.string().min(1).optional(),
466
+ base: z.string().min(1).optional(),
467
+ apply: z.boolean().optional(),
468
+ force: z.boolean().optional(),
469
+ };
470
+
471
+ export async function releaseWorktreeTool(args: {
472
+ repo: string;
473
+ agentId?: string;
474
+ path?: string;
475
+ base?: string;
476
+ apply?: boolean;
477
+ force?: boolean;
478
+ }) {
479
+ const { repo } = args;
480
+ if (!args.agentId && !args.path) {
481
+ return { ok: false as const, error: "name the tree to release: pass `agentId` or `path`" };
482
+ }
483
+
484
+ const trees = listWorktrees(repo);
485
+ const primary = primaryOf(repo) ?? "";
486
+ const target = args.path
487
+ ? trees.find((w) => samePath(w.path, args.path as string))
488
+ : trees.find((w) => (w.branch ?? "").includes(args.agentId as string) || w.path.includes(args.agentId as string));
489
+
490
+ if (!target) {
491
+ return {
492
+ ok: false as const,
493
+ error: args.path
494
+ ? `no worktree at '${args.path}' — nothing to release`
495
+ : `no worktree found for '${args.agentId}' — nothing to release`,
496
+ // An ABSENCE is reported with what WAS found, so a misaimed lookup is
497
+ // distinguishable from an empty world (the positive-control rule).
498
+ known: trees.map((w) => ({ path: w.path, branch: w.branch })),
499
+ };
500
+ }
501
+
502
+ const at = target.path;
503
+ const branch = target.branch;
504
+
505
+ if (samePath(at, primary)) {
506
+ return {
507
+ ok: false as const,
508
+ error: `refusing to release ${at}: that is the PRIMARY checkout, the path everyone already has`,
509
+ path: at,
510
+ };
511
+ }
512
+
513
+ if (isDirty(at)) {
514
+ return {
515
+ ok: false as const,
516
+ error:
517
+ `refusing to release ${at}: the tree is DIRTY. Uncommitted work is what a diff cannot show and ` +
518
+ `git cannot give back, so this refusal has no override — commit it, stash it, or delete it by hand.`,
519
+ path: at,
520
+ branch,
521
+ dirty: true,
522
+ };
523
+ }
524
+
525
+ const base = args.base ?? "main";
526
+ const ref = `origin/${base}`;
527
+ let tip: string | null = null;
528
+ try {
529
+ git(repo, ["fetch", "--quiet", "origin", base]);
530
+ } catch {
531
+ /* a stale tip is reported below rather than guessed at */
532
+ }
533
+ try {
534
+ tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
535
+ } catch {
536
+ tip = null;
537
+ }
538
+
539
+ let ahead = "unknown";
540
+ if (tip) {
541
+ try {
542
+ ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
543
+ } catch {
544
+ /* left unknown — an unreadable count must not read as zero */
545
+ }
546
+ }
547
+
548
+ const holdsWork = ahead !== "0";
549
+ const landed = tip && holdsWork ? aheadIsLanded(at, tip) : true;
550
+
551
+ if (!tip) {
552
+ return {
553
+ ok: false as const,
554
+ error: `refusing to release ${at}: ${ref} does not resolve, so whether this tree's work landed cannot be measured`,
555
+ path: at,
556
+ branch,
557
+ };
558
+ }
559
+
560
+ if (holdsWork && !landed && !args.force) {
561
+ return {
562
+ ok: false as const,
563
+ error:
564
+ `refusing to release ${at}: it is ${ahead} commit(s) ahead of ${ref} and the patch-id check was ` +
565
+ `INCONCLUSIVE — not proof the work is unlanded, but not proof it landed either (several commits ` +
566
+ `squashed into one carry a different combined patch id). Land it, or pass force:true, which is recorded.`,
567
+ path: at,
568
+ branch,
569
+ ahead,
570
+ landedCheck: "inconclusive",
571
+ };
572
+ }
573
+
574
+ const plan = {
575
+ path: at,
576
+ branch,
577
+ ahead,
578
+ landedCheck: holdsWork ? (landed ? "landed" : "inconclusive") : "nothing-ahead",
579
+ ...(args.force && holdsWork && !landed ? { forced: true } : {}),
580
+ };
581
+
582
+ if (!args.apply) {
583
+ return {
584
+ ok: true as const,
585
+ applied: false,
586
+ would: "remove the worktree and delete its branch",
587
+ ...plan,
588
+ note: "reports by default; pass apply:true to remove",
589
+ };
590
+ }
591
+
592
+ try {
593
+ git(repo, ["worktree", "remove", "--force", at]);
594
+ } catch (e) {
595
+ return {
596
+ ok: false as const,
597
+ error: `worktree remove failed: ${String((e as Error).message).split("\n")[0]}`,
598
+ ...plan,
599
+ };
600
+ }
601
+ // PRUNE IS PART OF THE ACT, not a step the caller remembers. Every card that
602
+ // said `git worktree remove` also said `git worktree prune`, which is the
603
+ // clearest evidence they were one operation split across two lines of prose.
604
+ try {
605
+ git(repo, ["worktree", "prune"]);
606
+ } catch {
607
+ /* removal already succeeded; a failed prune is not worth failing the call */
608
+ }
609
+ let branchDeleted = false;
610
+ if (branch) {
611
+ try {
612
+ git(repo, ["branch", "-D", branch.replace(/^refs\/heads\//, "")]);
613
+ branchDeleted = true;
614
+ } catch {
615
+ /* a branch that will not delete is reported, not fatal */
616
+ }
617
+ }
618
+ return { ok: true as const, applied: true, removed: true, branchDeleted, ...plan };
619
+ }
package/src/work.ts CHANGED
@@ -17,6 +17,10 @@ export {
17
17
  renderBoardRow,
18
18
  parseWorkDoc,
19
19
  renderWorkDoc,
20
+ renderWorkDocForWrite,
21
+ stampQueueIds,
22
+ zeroIsUnparsed,
23
+ substantiveLines,
20
24
  queueItemsOf,
21
25
  doneEntriesOf,
22
26
  boardRowsOf,