agent-coord-mcp 0.26.13 → 0.26.15

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 (39) hide show
  1. package/dist/capabilities.js +158 -0
  2. package/dist/capabilities.js.map +1 -0
  3. package/dist/server-identity.js +82 -0
  4. package/dist/server-identity.js.map +1 -0
  5. package/dist/server.js +4 -2
  6. package/dist/server.js.map +1 -1
  7. package/dist/tools/events.js +64 -7
  8. package/dist/tools/events.js.map +1 -1
  9. package/dist/tools/record-events.js +11 -6
  10. package/dist/tools/record-events.js.map +1 -1
  11. package/dist/tools/records.js +23 -2
  12. package/dist/tools/records.js.map +1 -1
  13. package/dist/tools/transport.js +26 -56
  14. package/dist/tools/transport.js.map +1 -1
  15. package/dist/tools/work.js +37 -1
  16. package/dist/tools/work.js.map +1 -1
  17. package/dist/tools/worktrees.js +46 -1
  18. package/dist/tools/worktrees.js.map +1 -1
  19. package/hooks/peek-coord.mjs +0 -0
  20. package/hooks/tmux-pusher.mjs +0 -0
  21. package/package.json +14 -11
  22. package/scripts/check-test-count.mjs +1 -1
  23. package/scripts/coord-attention-clock.mjs +0 -0
  24. package/scripts/coord-node.sh +0 -0
  25. package/scripts/coord-stall-clock.mjs +0 -0
  26. package/scripts/coord-token.mjs +0 -0
  27. package/scripts/probe-tmux-liveness.sh +0 -0
  28. package/scripts/spawn-agent.sh +0 -0
  29. package/scripts/stop-agent.sh +0 -0
  30. package/scripts/typed-record-stats.mjs +0 -0
  31. package/src/capabilities.ts +188 -0
  32. package/src/server-identity.ts +83 -0
  33. package/src/server.ts +10 -2
  34. package/src/tools/events.ts +89 -9
  35. package/src/tools/record-events.ts +11 -6
  36. package/src/tools/records.ts +23 -1
  37. package/src/tools/transport.ts +26 -57
  38. package/src/tools/work.ts +54 -2
  39. package/src/tools/worktrees.ts +45 -1
@@ -5,6 +5,15 @@ import { execFileSync } from "node:child_process";
5
5
  import { registerTool } from "./registry.js";
6
6
  import { roleInputSchema, type RoleArg } from "../roles.js";
7
7
  import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
8
+ // Moved to a LEAF so `status` can report capabilities without a cycle — see
9
+ // src/server-identity.ts. Re-exported so every existing importer is untouched.
10
+ export { resolveServerIdentity } from "../server-identity.js";
11
+ import { resolveServerIdentity } from "../server-identity.js";
12
+ // ONE-WAY EDGE, and the direction is the point: `status` CALLS the capabilities
13
+ // verb rather than re-deriving its probes. Two probes of the same thing is the
14
+ // four-matchers defect committed deliberately, which is why 17.4 was deferred
15
+ // behind 13.7 instead of built beside it.
16
+ import { capabilitiesTool } from "../capabilities.js";
8
17
  import { sendMessageTool, readMessagesTool } from "./messaging.js";
9
18
  import { randomUUID } from "node:crypto";
10
19
  import { existsSync, openSync, readFileSync, watch } from "node:fs";
@@ -835,63 +844,6 @@ function resolvePusherPath(): string {
835
844
  return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
836
845
  }
837
846
 
838
- /** Running server package + optional git identity. `fromDir` is a test seam. */
839
- export function resolveServerIdentity(fromDir?: string): {
840
- path: string;
841
- version: string;
842
- branch?: string;
843
- sha?: string;
844
- } {
845
- const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
846
- let dir = start;
847
- let pkgFile: string | undefined;
848
- for (let i = 0; i < 8; i++) {
849
- const candidate = path.join(dir, "package.json");
850
- if (existsSync(candidate)) {
851
- pkgFile = candidate;
852
- break;
853
- }
854
- const parent = path.dirname(dir);
855
- if (parent === dir) break;
856
- dir = parent;
857
- }
858
- const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
859
- const pkgDir = path.dirname(pkgPath);
860
- let version = "unknown";
861
- try {
862
- const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
863
- if (typeof raw.version === "string" && raw.version) version = raw.version;
864
- } catch {
865
- /* leave unknown — never invent a version */
866
- }
867
-
868
- const out: { path: string; version: string; branch?: string; sha?: string } = {
869
- path: pkgDir,
870
- version,
871
- };
872
-
873
- dir = pkgDir;
874
- let gitRoot: string | undefined;
875
- for (let i = 0; i < 10; i++) {
876
- if (existsSync(path.join(dir, ".git"))) {
877
- gitRoot = dir;
878
- break;
879
- }
880
- const parent = path.dirname(dir);
881
- if (parent === dir) break;
882
- dir = parent;
883
- }
884
- if (gitRoot && isGitRepo(gitRoot)) {
885
- const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
886
- const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
887
- const b = branch.status === 0 ? String(branch.stdout).trim() : "";
888
- const s = sha.status === 0 ? String(sha.stdout).trim() : "";
889
- if (b) out.branch = b;
890
- if (s) out.sha = s;
891
- }
892
- return out;
893
- }
894
-
895
847
  /** An epoch-ms stamp as ISO-8601 UTC, or an explicit absence — never a blank. */
896
848
  function stamp(ms: number | undefined): string {
897
849
  return ms === undefined ? "(no stamp)" : new Date(ms).toISOString();
@@ -925,6 +877,22 @@ export async function statusTool(args: { agentId: string }) {
925
877
  const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
926
878
  const inboxOffset = cursor.inboxOffset ?? 0;
927
879
  const unread = Math.max(0, inbox.length - inboxOffset);
880
+ // WHAT THIS PROCESS CAN ACTUALLY DO, embedded WHOLE.
881
+ //
882
+ // A new tool otherwise lands silently: an agent reading an old listing
883
+ // asserts the tool does not exist, and it is right about its own listing and
884
+ // wrong about the world. Three wrong conclusions in two days came from
885
+ // exactly that, each about which build a pane was running.
886
+ //
887
+ // The full report travels, not a summary. Flattening it to a boolean would
888
+ // hand the caller a tidier answer and a worse one: `versionLabel` stays
889
+ // CONTEXT rather than evidence, a THROWN probe stays an absent capability
890
+ // rather than a broken check, and `answeredBy` keeps naming which pid
891
+ // answered — two servers disagreeing mid-rollout is the normal case, and it
892
+ // is the case every other instrument here reports as if the fleet were one
893
+ // thing.
894
+ const capabilities = await capabilitiesTool();
895
+
928
896
  return {
929
897
  agentId: args.agentId,
930
898
  registered: !!entry,
@@ -935,6 +903,7 @@ export async function statusTool(args: { agentId: string }) {
935
903
  inboxUnread: unread,
936
904
  inTmux: !!process.env.TMUX_PANE,
937
905
  tmuxPane: process.env.TMUX_PANE,
906
+ capabilities,
938
907
  };
939
908
  }
940
909
 
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)) {