agent-coord-mcp 0.26.5 → 0.26.7

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 (43) hide show
  1. package/dist/prefix.js +64 -0
  2. package/dist/prefix.js.map +1 -0
  3. package/dist/server.js +32 -2
  4. package/dist/server.js.map +1 -1
  5. package/dist/tools/attention.js +73 -0
  6. package/dist/tools/attention.js.map +1 -0
  7. package/dist/tools/away.js +122 -0
  8. package/dist/tools/away.js.map +1 -0
  9. package/dist/tools/events.js +171 -0
  10. package/dist/tools/events.js.map +1 -0
  11. package/dist/tools/index.js +3 -0
  12. package/dist/tools/index.js.map +1 -1
  13. package/dist/tools/records.js +651 -0
  14. package/dist/tools/records.js.map +1 -0
  15. package/dist/tools/registry.js +45 -5
  16. package/dist/tools/registry.js.map +1 -1
  17. package/dist/tools/rotate.js +143 -0
  18. package/dist/tools/rotate.js.map +1 -0
  19. package/dist/tools/shared.js +9 -0
  20. package/dist/tools/shared.js.map +1 -1
  21. package/dist/tools/stall.js +294 -0
  22. package/dist/tools/stall.js.map +1 -0
  23. package/dist/tools/transport.js +92 -16
  24. package/dist/tools/transport.js.map +1 -1
  25. package/dist/tools/worktrees.js +294 -0
  26. package/dist/tools/worktrees.js.map +1 -0
  27. package/package.json +2 -2
  28. package/scripts/check-test-count.mjs +1 -1
  29. package/scripts/coord-attention-clock.mjs +122 -0
  30. package/scripts/coord-stall-clock.mjs +125 -0
  31. package/src/prefix.ts +72 -0
  32. package/src/server.ts +138 -3
  33. package/src/tools/attention.ts +91 -0
  34. package/src/tools/away.ts +121 -0
  35. package/src/tools/events.ts +199 -0
  36. package/src/tools/index.ts +3 -0
  37. package/src/tools/records.ts +747 -0
  38. package/src/tools/registry.ts +45 -5
  39. package/src/tools/rotate.ts +180 -0
  40. package/src/tools/shared.ts +24 -0
  41. package/src/tools/stall.ts +311 -0
  42. package/src/tools/transport.ts +99 -3
  43. package/src/tools/worktrees.ts +311 -0
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Emit on MERGEABLE-AND-UNATTENDED.
4
+ *
5
+ * Two mergeable, CI-green PRs sat 17 hours; two more sat 40 minutes and 1h48 on
6
+ * the day the coordinator said it was checking for that. Nothing in the room
7
+ * said so — a worker went looking. This is the mechanism that was missing.
8
+ *
9
+ * A TIMER, NOT A TRIGGER, for the reason the stall clock is: a mechanism
10
+ * without a schedule measures inbound traffic rather than elapsed time, and
11
+ * cannot fire during exactly the quiet it exists to cover.
12
+ *
13
+ * Named unit: com.davidbalzan.coord-attention-clock (see --install).
14
+ *
15
+ * UNKNOWN IS NOT UNATTENDED. `mergeable` is UNKNOWN for a while after every
16
+ * push and an empty rollup is ambiguous; both are reported as unknown, never as
17
+ * an alert. Inventing an alert from a question GitHub has not answered is the
18
+ * fabrication class this fleet has spent three days removing.
19
+ *
20
+ * REPORTED ONCE, not every tick: delivery goes through the same subscription
21
+ * machinery as record events, so the idempotency key suppresses a repeat. A DM
22
+ * every 30 minutes about the same PR trains its own dismissal.
23
+ */
24
+ import { execFileSync } from "node:child_process";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { homedir } from "node:os";
28
+
29
+ const argv = Object.fromEntries(
30
+ process.argv.slice(2).flatMap((a) => {
31
+ const m = /^--([^=]+)(?:=(.*))?$/.exec(a);
32
+ return m ? [[m[1], m[2] ?? true]] : [];
33
+ }),
34
+ );
35
+ const here = path.dirname(fileURLToPath(import.meta.url));
36
+ const dist = path.join(here, "..", "dist");
37
+ const LABEL = "com.davidbalzan.coord-attention-clock";
38
+
39
+ if (argv.install) {
40
+ const repo = String(argv.repo ?? process.cwd());
41
+ const to = String(argv.to ?? "<coordinator-agent-id>");
42
+ const every = Number(argv.every ?? 15);
43
+ const self = path.join(here, "coord-attention-clock.mjs");
44
+ console.log(`# macOS launchd — Label: ${LABEL}
45
+ # launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${LABEL}.plist
46
+ <?xml version="1.0" encoding="UTF-8"?>
47
+ <plist version="1.0"><dict>
48
+ <key>Label</key><string>${LABEL}</string>
49
+ <key>ProgramArguments</key><array>
50
+ <string>${process.execPath}</string><string>${self}</string>
51
+ <string>--repo=${repo}</string><string>--to=${to}</string>
52
+ </array>
53
+ <key>StartInterval</key><integer>${every * 60}</integer>
54
+ <key>RunAtLoad</key><true/>
55
+ <key>StandardErrorPath</key><string>${path.join(homedir(), "agent-coord", "logs", "attention-clock.err")}</string>
56
+ </dict></plist>
57
+
58
+ # Linux cron:
59
+ # */${every} * * * * ${process.execPath} ${self} --repo=${repo} --to=${to}
60
+ `);
61
+ process.exit(0);
62
+ }
63
+
64
+ const repo = String(argv.repo ?? process.cwd());
65
+ const to = argv.to ? String(argv.to) : null;
66
+ const from = String(argv.from ?? "coord-attention-clock");
67
+ const quietMinutes = Number(argv["quiet-minutes"] ?? 20);
68
+
69
+ const { partition } = await import(path.join(dist, "tools/attention.js"));
70
+ const { readSubs, evaluate, commitEvaluation } = await import(path.join(dist, "tools/events.js"));
71
+ const { sendMessageTool } = await import(path.join(dist, "tools/messaging.js"));
72
+
73
+ let prs;
74
+ try {
75
+ const out = execFileSync(
76
+ "gh",
77
+ ["pr", "list", "--state", "open", "--json", "number,state,mergeable,updatedAt,statusCheckRollup"],
78
+ { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
79
+ );
80
+ prs = JSON.parse(out).map((p) => ({ ...p, checks: p.statusCheckRollup ?? [] }));
81
+ } catch (e) {
82
+ // NOT FETCHED IS NOT NOTHING-TO-REPORT. A silent failure here is exactly the
83
+ // quiet that let the PRs sit.
84
+ console.error(`[attention-clock] FAILED to list PRs: ${(e && e.message) || e}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ const now = Date.now();
89
+ const { unattended, unknown, attended } = partition(prs, now, quietMinutes * 60_000);
90
+
91
+ let delivered = 0;
92
+ if (to) {
93
+ const subs = readSubs();
94
+ let next = subs;
95
+ const fresh = [];
96
+ for (const u of unattended) {
97
+ const ev = { kind: "pr", target: "mergeable-unattended", ref: `#${u.pr.number}`, summary: u.why };
98
+ const r = evaluate(next, ev, now);
99
+ next = r.subs;
100
+ if (r.deliveries.some((d) => d.status === "delivered")) fresh.push(u);
101
+ }
102
+ commitEvaluation(next);
103
+ if (fresh.length) {
104
+ await sendMessageTool({
105
+ from,
106
+ to,
107
+ text:
108
+ `AGENT_ACTION: ${fresh.length} PR(s) MERGEABLE AND UNATTENDED.\n` +
109
+ fresh.map((u) => `- ${u.why}`).join("\n") +
110
+ `\n\nScheduled check (${LABEL}), not a person. Verify before merging: this reports GATE-READINESS, never that the change is correct.`,
111
+ });
112
+ delivered = fresh.length;
113
+ }
114
+ }
115
+
116
+ // The null result every run: a check that only speaks when it fires cannot be
117
+ // told from a broken one. `unknown` is printed separately from `attended`
118
+ // because collapsing them is the defect this script refuses to commit.
119
+ console.log(
120
+ `[attention-clock] ${prs.length} open · unattended ${unattended.length} (DM'd ${delivered}) · unknown ${unknown.length} · attended ${attended}` +
121
+ (unknown.length ? `\n unknown:\n${unknown.map((u) => ` ${u.why}`).join("\n")}` : ""),
122
+ );
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * THE CLOCK. Runs `stall_check` on a schedule and DMs the duty officer on HIT.
4
+ *
5
+ * WHY A SCHEDULER AND NOT AN AGENT LOOP: a mechanism without a schedule is
6
+ * REACTIVE, and its null reports measure inbound traffic rather than elapsed
7
+ * time. console-worker-1's standing trigger ran zero times in seventeen hours
8
+ * because it only fired when a message woke it — so it could not run during
9
+ * exactly the period it existed to cover, a fleet gone quiet. `wait_for_message`
10
+ * is disqualified for the same reason (Task 3.3).
11
+ *
12
+ * THE UNIT IS NAMED, because "standing" without a named scheduler is a habit:
13
+ * macOS launchd com.davidbalzan.coord-stall-clock (see --install)
14
+ * Linux cron the line --install prints
15
+ *
16
+ * WHAT IT MEASURES WHEN NOBODY IS WATCHING: that is the whole point, and it is
17
+ * why this is a timer rather than a trigger.
18
+ *
19
+ * MISS IS SILENT TO THE DUTY OFFICER AND NEVER SILENT TO THE RECORD. A DM every
20
+ * 30 minutes trains its own dismissal; a missing run mark makes a dead clock
21
+ * look like a healthy fleet. `stall_check` writes the mark itself; this script
22
+ * additionally records FAILURES, without which a clock that throws every time
23
+ * leaves no marks at all and is identical on disk to one never installed.
24
+ *
25
+ * It does NOT speak MCP: `stallCheckTool` is a plain function over
26
+ * ~/agent-coord and the repo's board, so a timer needs no server and no
27
+ * transport. One less thing that can be up while the thing it watches is down.
28
+ */
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { homedir } from "node:os";
32
+
33
+ const argv = Object.fromEntries(
34
+ process.argv.slice(2).flatMap((a) => {
35
+ const m = /^--([^=]+)(?:=(.*))?$/.exec(a);
36
+ return m ? [[m[1], m[2] ?? true]] : [];
37
+ }),
38
+ );
39
+
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ const dist = path.join(here, "..", "dist");
42
+
43
+ const LABEL = "com.davidbalzan.coord-stall-clock";
44
+
45
+ if (argv.install) {
46
+ const repo = String(argv.repo ?? process.cwd());
47
+ const duty = String(argv.duty ?? "<duty-officer-agent-id>");
48
+ const every = Number(argv.every ?? 30);
49
+ const self = path.join(here, "coord-stall-clock.mjs");
50
+ const node = process.execPath;
51
+ console.log(`# macOS — launchd. Label: ${LABEL}
52
+ # Write to ~/Library/LaunchAgents/${LABEL}.plist, then:
53
+ # launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${LABEL}.plist
54
+ <?xml version="1.0" encoding="UTF-8"?>
55
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
56
+ <plist version="1.0"><dict>
57
+ <key>Label</key><string>${LABEL}</string>
58
+ <key>ProgramArguments</key><array>
59
+ <string>${node}</string><string>${self}</string>
60
+ <string>--repo=${repo}</string><string>--duty=${duty}</string>
61
+ </array>
62
+ <key>StartInterval</key><integer>${every * 60}</integer>
63
+ <key>RunAtLoad</key><true/>
64
+ <key>StandardErrorPath</key><string>${path.join(homedir(), "agent-coord", "logs", "stall-clock.err")}</string>
65
+ </dict></plist>
66
+
67
+ # Linux — cron:
68
+ # */${every} * * * * ${node} ${self} --repo=${repo} --duty=${duty}
69
+ `);
70
+ process.exit(0);
71
+ }
72
+
73
+ const repo = String(argv.repo ?? process.cwd());
74
+ const duty = argv.duty ? String(argv.duty) : null;
75
+ const from = String(argv.from ?? "coord-stall-clock");
76
+ const stallMinutes = argv["stall-minutes"] ? Number(argv["stall-minutes"]) : undefined;
77
+
78
+ const { stallCheckTool, markRunFailure } = await import(path.join(dist, "tools/stall.js"));
79
+ const { sendMessageTool } = await import(path.join(dist, "tools/messaging.js"));
80
+
81
+ try {
82
+ const r = await stallCheckTool({ repo, ...(stallMinutes ? { stallMinutes } : {}) });
83
+
84
+ // `ok:false` is a FAILURE, not a quiet fleet — e.g. no board at that path.
85
+ // Recorded, or the next `stall_clock_status` reads a stopped clock as absent
86
+ // rather than broken.
87
+ if (!r.ok) {
88
+ markRunFailure(r.error ?? "stall_check returned ok:false with no reason");
89
+ console.error(`[stall-clock] FAILED: ${r.error}`);
90
+ process.exit(1);
91
+ }
92
+
93
+ if (r.hits.length && duty) {
94
+ const lines = r.hits.map((h) =>
95
+ h.kind === "no-heartbeat"
96
+ ? `- ${h.agentId}: no heartbeat for ${h.minutes}m (${h.stream})`
97
+ : `- ${h.agentId}: no commits on ${h.branch} for ${h.minutes}m`,
98
+ );
99
+ await sendMessageTool({
100
+ from,
101
+ to: duty,
102
+ text: `AGENT_ACTION: stall_check HIT — ${r.hits.length} of ${r.checked} in-flight row(s) stalled.\n${lines.join("\n")}\n\nThis is a scheduled check (${LABEL}), not a person watching. Verify before acting: a stall is a claim about the BOARD, not about the agent.`,
103
+ });
104
+ }
105
+
106
+ // Null result to stdout every run: the log is the record a human reads, and a
107
+ // trigger that only speaks when it fires cannot be told from a broken one.
108
+ const um = r.unmeasurable?.length ? `, ${r.unmeasurable.length} unmeasurable` : "";
109
+ console.log(
110
+ r.hits.length
111
+ ? `[stall-clock] HIT ${r.hits.length}/${r.checked}${um}${duty ? ` — DM sent to ${duty}` : " — NO DUTY OFFICER SET, no DM"}`
112
+ : `[stall-clock] MISS 0/${r.checked}${um} — no DM, run recorded`,
113
+ );
114
+ } catch (e) {
115
+ // THE CLAUSE MOST EASILY SKIPPED. A clock that throws every time leaves no
116
+ // marks, which on disk is identical to a clock that was never installed.
117
+ const reason = (e && e.message) || String(e);
118
+ try {
119
+ markRunFailure(reason);
120
+ } catch {
121
+ /* if even the mark cannot be written, stderr is all that is left */
122
+ }
123
+ console.error(`[stall-clock] FAILED: ${reason}`);
124
+ process.exit(1);
125
+ }
package/src/prefix.ts ADDED
@@ -0,0 +1,72 @@
1
+ /*
2
+ * WHERE WILL A NEW GLOBAL INSTALL LAND, AND IS IT WHERE THE FLEET LOADS FROM?
3
+ *
4
+ * These are two different questions and neither answers the other. The
5
+ * `server-build-drift` check names which copy is RUNNING; this names where the
6
+ * NEXT copy will be written. A run of `npm i -g agent-coord-mcp` that prints
7
+ * "added 1 package" is not evidence it landed anywhere that runs.
8
+ *
9
+ * MEASURED ON THIS BOX, 2026-08-28 — the divergence is not hypothetical:
10
+ * npm prefix -g -> .../node/v22.22.2
11
+ * every live fleet server-> .../node/v22.21.1/lib/node_modules/agent-coord-mcp
12
+ * Three prefixes exist here (two nvm, one /opt/homebrew), `npm prefix -g` is
13
+ * PATH-dependent, and nvm switches it per shell. So the install target and the
14
+ * load target had silently diverged, and a successful install would have
15
+ * updated a copy nothing loads. That gap cost the fleet a day.
16
+ */
17
+ import path from "node:path";
18
+
19
+ /**
20
+ * The global root a module path sits under, or null if it is not in one.
21
+ * `/p/lib/node_modules/agent-coord-mcp/dist` -> `/p`
22
+ */
23
+ export function prefixOf(modulePath: string | undefined): string | null {
24
+ if (!modulePath) return null;
25
+ // Split on the LAST occurrence: a global prefix can itself live under a path
26
+ // containing `node_modules`, and taking the first match would name an
27
+ // ancestor that installs nothing.
28
+ const marker = `${path.sep}lib${path.sep}node_modules${path.sep}`;
29
+ const i = modulePath.lastIndexOf(marker);
30
+ if (i === -1) return null;
31
+ return modulePath.slice(0, i);
32
+ }
33
+
34
+ export type PrefixVerdict =
35
+ | { level: "ok"; detail: string }
36
+ | { level: "warn"; detail: string }
37
+ | { level: "error"; detail: string };
38
+
39
+ /**
40
+ * `loadPrefix` is where THIS server was loaded from; `installPrefix` is what
41
+ * `npm prefix -g` answered, and `npmPath` is which npm answered it — because a
42
+ * prefix without the binary that reported it cannot be reproduced by anyone.
43
+ */
44
+ export function prefixVerdict(loadPrefix: string | null, installPrefix: string | null, npmPath?: string): PrefixVerdict {
45
+ const via = npmPath ? ` (asked: ${npmPath})` : "";
46
+
47
+ // NOT DETERMINED IS NOT MATCHING. Both unknown branches are warnings that say
48
+ // what could not be established, never an "ok" over an unasked question.
49
+ // NOT APPLICABLE IS NOT THE SAME AS UNCHECKED, and conflating them is how a
50
+ // check earns its way into being ignored. A dev checkout has no load prefix
51
+ // BY CONSTRUCTION: this process is not the copy the fleet loads, so there is
52
+ // no divergence for it to have. Warning on every dev run would fire on every
53
+ // test run and every local session — noise, which is what a denylist does.
54
+ //
55
+ // The question is still ASKED where it can be answered: a session running
56
+ // from a global install has a load prefix, and that is where the fleet lives.
57
+ if (!loadPrefix)
58
+ return { level: "ok", detail: `not applicable: this server runs from a dev checkout, not a global install${via}, so it is not the copy the fleet loads and has no prefix to diverge from. Run \`doctor\` in an installed session to compare install target against load target.` };
59
+ if (!installPrefix)
60
+ return { level: "warn", detail: `could not determine the global install prefix${via} — \`npm prefix -g\` gave no answer, so where a new copy would land is UNKNOWN. Running from ${loadPrefix}.` };
61
+
62
+ if (path.resolve(loadPrefix) === path.resolve(installPrefix))
63
+ return { level: "ok", detail: `a global install would land where this server loads from (${loadPrefix})${via}` };
64
+
65
+ return {
66
+ level: "error",
67
+ detail:
68
+ `INSTALL PREFIX AND LOAD PREFIX DIVERGE. A global install from this shell writes to ${installPrefix}${via}, but this server is running from ${loadPrefix}. ` +
69
+ `A successful "added 1 package" would update a copy nothing loads, and every check that reads a VERSION would keep reporting the old one truthfully. ` +
70
+ `Install with an explicit prefix (\`npm i -g --prefix ${loadPrefix} <pkg>\`) or switch node/nvm to the version owning ${loadPrefix} before installing.`,
71
+ };
72
+ }
package/src/server.ts CHANGED
@@ -6,6 +6,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
7
  import { unlinkSync, writeFileSync } from "node:fs";
8
8
  import { z, type ZodRawShape } from "zod";
9
+ import { coordAwaySchema, coordAwayTool, readAway, awayRefusal, secondCoordinatorRefusal } from "./tools/away.js";
10
+ import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
11
+ import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
9
12
  import {
10
13
  ensureDirs,
11
14
  getTokenMap,
@@ -79,6 +82,24 @@ import {
79
82
  listScopesTool,
80
83
  importWorkSchema,
81
84
  importWorkTool,
85
+ stallCheckSchema,
86
+ stallCheckTool,
87
+ setHaltSchema,
88
+ setHaltTool,
89
+ lastRanSchema,
90
+ stallClockStatusTool,
91
+ ensureWorktreeSchema,
92
+ ensureWorktreeTool,
93
+ refreshWorktreesSchema,
94
+ refreshWorktreesTool,
95
+ claimSchema,
96
+ claimTool,
97
+ landSchema,
98
+ landTool,
99
+ mergeSchema,
100
+ mergeTool,
101
+ nextUnblockedSchema,
102
+ nextUnblockedTool,
82
103
  listWorkSchema,
83
104
  listWorkTool,
84
105
  exportWorkSchema,
@@ -250,9 +271,18 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
250
271
  inputSchema: ZodRawShape,
251
272
  cb: (args: Record<string, unknown>) => Promise<ReturnType<typeof jsonResult>>,
252
273
  ) => {
253
- server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) =>
254
- cb((args ?? {}) as Record<string, unknown>),
255
- );
274
+ server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) => {
275
+ const a = (args ?? {}) as Record<string, unknown>;
276
+ // DUTY-OFFICER ALLOWLIST, ENFORCED IN THE ONE PATH EVERY TOOL IS
277
+ // REGISTERED THROUGH. Placed here rather than in `gate` because `gate`
278
+ // does not know the tool's NAME, and because a guard applied per call
279
+ // site is a guard someone forgets at one call site — where the omission
280
+ // looks identical to the guarded ones from outside (kit#125).
281
+ const caller = bound ?? (typeof a["agentId"] === "string" ? (a["agentId"] as string) : typeof a["from"] === "string" ? (a["from"] as string) : undefined);
282
+ const refusal = awayRefusal(readAway(), caller, name);
283
+ if (refusal) throw new Error(refusal);
284
+ return cb(a);
285
+ });
256
286
  };
257
287
 
258
288
  addTool(
@@ -513,6 +543,111 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
513
543
  gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
514
544
  );
515
545
 
546
+ addTool(
547
+ "stall_check",
548
+ "The stall predicate over the board and the bus: a 🚧 row whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
549
+ stallCheckSchema,
550
+ gate(null, stallCheckTool as (a: Record<string, unknown>) => Promise<unknown>),
551
+ );
552
+
553
+ addTool(
554
+ "stall_clock_status",
555
+ "Is the stall clock alive? Reports when stall_check last ran, how many runs, and how many were misses — so 'no alerts' is distinguishable from 'nothing ran'. Never having run is an ERROR, not a quiet fleet.",
556
+ lastRanSchema,
557
+ gate(null, stallClockStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
558
+ );
559
+
560
+ addTool(
561
+ "set_halt",
562
+ "Set or clear a NAMED halt. While set, `claim` and `next_unblocked` refuse. The reason is required because a halt blocks every lane in the fleet: it must name a board cutover, a cited BLOCKER: or a documented red pipeline — 'production feels down' is not a halt.",
563
+ setHaltSchema,
564
+ gate(null, setHaltTool as (a: Record<string, unknown>) => Promise<unknown>),
565
+ );
566
+
567
+ addTool(
568
+ "ensure_worktree",
569
+ "Create or reuse an isolated git worktree for an agent, cut from origin/<base> (never a local branch of the same name, which is whatever the last person left there). Refuses to hand back the PRIMARY checkout as a slice tree — that is the path everyone already has, so it is the one two agents end up editing at once. Idempotent: an existing tree is reported as found, and if it sits on a different branch that is said rather than re-pointed.",
570
+ ensureWorktreeSchema,
571
+ gate(null, ensureWorktreeTool as (a: Record<string, unknown>) => Promise<unknown>),
572
+ );
573
+
574
+ addTool(
575
+ "refresh_worktrees",
576
+ "Fast-forward IDLE worktrees onto origin/<base>. Never --force and never a mid-slice tree: dirty or holding commits the base does not is REFUSED per tree and named, because staleness is visible in a diff and a clobbered work-in-progress is not. Reports by default; pass apply:true to move them.",
577
+ refreshWorktreesSchema,
578
+ gate(null, refreshWorktreesTool as (a: Record<string, unknown>) => Promise<unknown>),
579
+ );
580
+
581
+ addTool(
582
+ "next_unblocked",
583
+ "The next queue item to work: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
584
+ nextUnblockedSchema,
585
+ gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
586
+ );
587
+
588
+ addTool(
589
+ "claim",
590
+ "Bind a queue item to an agent and produce the 🚧 board row. With no itemId it takes next_unblocked. WARNS LOUDLY while `ensure_worktree` (Phase 5 Task 1) does not exist: the claim binds the item and the row only, and creating an isolated worktree at origin/<base> is still yours. The warning is conditional on that verb's absence, so it stops once Task 1 lands.",
591
+ claimSchema,
592
+ gate(null, claimTool as (a: Record<string, unknown>) => Promise<unknown>),
593
+ );
594
+
595
+ addTool(
596
+ "land",
597
+ "Record a merged PR: refuses without a cited PR number, and refuses unless the PR is on the TARGET TIP (origin/<base>) rather than a merge base — an item merged after your branch was cut is missing from the base too, so the base cannot answer 'what does the thing I am merging into have that I do not?'. Closes the queue item STATUS ONLY (priority and body bytes unchanged), proposes the DONE entry, and reports by default: pass write:true to apply.",
598
+ landSchema,
599
+ gate(null, landTool as (a: Record<string, unknown>) => Promise<unknown>),
600
+ );
601
+
602
+ addTool(
603
+ "merge",
604
+ "Merge a PR ONLY as the consequence of its check verdict: reads the status rollup and merges in the same call, so there is no ordering in which the check runs and the merge ignores it. Refuses on any failing check, on any check not yet terminal, on a CONFLICTING base, and on ZERO checks \u2014 no checks is not passing checks, an empty rollup has zero failures and evidences nothing. Every return carries the POPULATION it judged. Reports by default; pass write:true to merge.",
605
+ mergeSchema,
606
+ gate(null, mergeTool as (a: Record<string, unknown>) => Promise<unknown>),
607
+ );
608
+
609
+ addTool(
610
+ "coord_away",
611
+ "Declare the coordinator AWAY with a named duty officer, or RELEASE it. While ON, the duty officer is restricted to an ALLOWLIST (next_unblocked, claim, land, stall_check, and reporting) \u2014 an allowlist rather than a denylist, so a tool added tomorrow is refused by default instead of silently granted. Refuses ON without a dutyOfficerId (an unnamed stand-in reports the lane covered while leaving it uncovered), refuses a self-appointed officer, and refuses RELEASE by anyone but the coordinator who set it \u2014 a stand-in that can lift its own limits does not have any. While ON, a SECOND coordinator cannot join until released.",
612
+ coordAwaySchema,
613
+ gate("coordinatorId" as "agentId", coordAwayTool as (a: Record<string, unknown>) => Promise<unknown>),
614
+ );
615
+
616
+ addTool(
617
+ "rotate",
618
+ "Build a handover-to-self packet from LIVE state (git + gh), never chat memory \u2014 memory is the one source that cannot be re-derived after the reset it is meant to survive. REFUSES while the tree is dirty: uncommitted work is the one thing a packet cannot carry, and after /clear nothing can discover it existed. Jobs are an allowlist (reseed-only | phase-boundary); 'archive-done' is deliberately absent. Reports by default; write:true persists the packet.",
619
+ rotateSchema,
620
+ gate("agentId", rotateTool as (a: Record<string, unknown>) => Promise<unknown>),
621
+ );
622
+
623
+ addTool(
624
+ "rotate_reconcile",
625
+ "FIRST ACT AFTER A RESEED. Re-checks every packet claim against live state and REFUSES to report ready on any divergence \u2014 a PR that merged during the reset is the dangerous direction, because the agent resumes a branch already in main and every next step is coherent and wrong. 'missionHint' is reported as UNVERIFIABLE rather than counted as reconciled.",
626
+ rotateReconcileSchema,
627
+ gate("agentId", rotateReconcileTool as (a: Record<string, unknown>) => Promise<unknown>),
628
+ );
629
+
630
+ addTool(
631
+ "subscribe",
632
+ "Register for a record event: a task completing, a phase completing, or a queue item closing. Events are DERIVED from the record \u2014 emitted by `land` after the DONE entry is written, and refused if the ref is not in the record \u2014 so the stream can never claim something the authoritative markdown does not. Re-subscribing returns the existing registration rather than a duplicate.",
633
+ subscribeSchema,
634
+ gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
635
+ );
636
+
637
+ addTool(
638
+ "unsubscribe",
639
+ "Remove one of YOUR subscriptions. Refuses another agent's: silently dropping someone else's notification is how a miss is manufactured.",
640
+ unsubscribeSchema,
641
+ gate("agentId", unsubscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
642
+ );
643
+
644
+ addTool(
645
+ "list_subscriptions",
646
+ "List subscriptions with their health. A subscription NEVER EVALUATED is an ERROR, not a quiet zero \u2014 'no events yet' and 'never ran' are the same output and only one is healthy. Carries its population, because 'no subscriptions' and 'none listed for you' are different claims.",
647
+ listSubscriptionsSchema,
648
+ gate(null, listSubscriptionsTool as (a: Record<string, unknown>) => Promise<unknown>),
649
+ );
650
+
516
651
  addTool(
517
652
  "export_work",
518
653
  "Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Refuses write:true when that write would emit a new 5-col lanes-v0 table (parse-only; write grammar is workstreams.v1). Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
@@ -0,0 +1,91 @@
1
+ /*
2
+ * Emit on the state that has twice cost hours: a PR that is MERGEABLE and
3
+ * UNATTENDED.
4
+ *
5
+ * Two mergeable, CI-green PRs sat 17 hours; two more sat 40 minutes and 1h48
6
+ * on the day the coordinator said it was checking for exactly that. Nothing in
7
+ * the room said so — a worker went looking and found it. There is no mechanism
8
+ * that reports "these PRs are mergeable and nobody is acting".
9
+ *
10
+ * 6.2 APPLIES HERE TOO: the event is DERIVED from observable state, never a
11
+ * parallel claim. Everything below is computed from what GitHub actually
12
+ * reports, and anything it has not answered yet is `unknown`.
13
+ */
14
+ import { normalizeChecks } from "./records.js";
15
+
16
+ export type PrFactsForAttention = {
17
+ number: number;
18
+ state: string;
19
+ mergeable: string;
20
+ updatedAt: string;
21
+ checks: unknown[];
22
+ repo?: string;
23
+ };
24
+
25
+ export type Attention =
26
+ | { state: "unattended"; why: string }
27
+ | { state: "unknown"; why: string }
28
+ | { state: "attended"; why: string };
29
+
30
+ /**
31
+ * UNKNOWN IS NOT UNATTENDED, and this is the clause the coordinator and I have
32
+ * both been bitten by today.
33
+ *
34
+ * `mergeable` is `UNKNOWN` for a while after every push — GitHub computes it
35
+ * asynchronously — and an empty check rollup is ambiguous between "not yet" and
36
+ * "never will be". Reading either as "nobody is acting" invents an alert out of
37
+ * a question GitHub has not answered, which is the fabrication class this fleet
38
+ * has spent three days removing.
39
+ */
40
+ export function classifyPr(pr: PrFactsForAttention, nowMs: number, quietMs: number): Attention {
41
+ if (pr.state !== "OPEN") return { state: "attended", why: `#${pr.number} is ${pr.state}` };
42
+
43
+ if (!pr.mergeable || pr.mergeable === "UNKNOWN")
44
+ return {
45
+ state: "unknown",
46
+ why: `#${pr.number}: GitHub has not computed mergeability yet (frequently the case right after a push). UNKNOWN is not unattended — reporting it would invent an alert from an unanswered question.`,
47
+ };
48
+
49
+ if (pr.mergeable === "CONFLICTING")
50
+ return { state: "attended", why: `#${pr.number} is CONFLICTING — it needs its author, not a gate` };
51
+
52
+ const checks = normalizeChecks(pr.checks ?? []);
53
+ if (checks.length === 0)
54
+ return {
55
+ state: "unknown",
56
+ why: `#${pr.number} reports ZERO checks — ambiguous between "not started" and "never will". No checks is not passing checks, and it is not unattended either.`,
57
+ };
58
+
59
+ const pending = checks.filter((c) => c.verdict === "pending");
60
+ if (pending.length)
61
+ return { state: "unknown", why: `#${pr.number}: ${pending.length} check(s) still running — not yet a gate's turn` };
62
+
63
+ const failed = checks.filter((c) => c.verdict === "fail");
64
+ if (failed.length)
65
+ return { state: "attended", why: `#${pr.number} has failing checks (${failed.map((c) => c.name).join(", ")}) — the author's move, not the gate's` };
66
+
67
+ // Quiet is measured from the PR's own last update. A PR touched a minute ago
68
+ // is not unattended; its author may still be pushing.
69
+ const quietFor = nowMs - Date.parse(pr.updatedAt);
70
+ if (!Number.isFinite(quietFor))
71
+ return { state: "unknown", why: `#${pr.number}: unreadable updatedAt '${pr.updatedAt}' — cannot say how long it has waited` };
72
+ if (quietFor < quietMs)
73
+ return { state: "attended", why: `#${pr.number} was updated ${Math.round(quietFor / 60000)}m ago — still moving` };
74
+
75
+ return {
76
+ state: "unattended",
77
+ why: `#${pr.number} is MERGEABLE, all ${checks.length} check(s) pass, and nothing has touched it for ${Math.round(quietFor / 60000)}m`,
78
+ };
79
+ }
80
+
81
+ /** The three states are distinct on purpose; a caller must not collapse them. */
82
+ export function partition(prs: PrFactsForAttention[], nowMs: number, quietMs: number) {
83
+ const out = { unattended: [] as { pr: PrFactsForAttention; why: string }[], unknown: [] as { pr: PrFactsForAttention; why: string }[], attended: 0 };
84
+ for (const pr of prs) {
85
+ const c = classifyPr(pr, nowMs, quietMs);
86
+ if (c.state === "unattended") out.unattended.push({ pr, why: c.why });
87
+ else if (c.state === "unknown") out.unknown.push({ pr, why: c.why });
88
+ else out.attended++;
89
+ }
90
+ return out;
91
+ }