agent-coord-mcp 0.26.4 → 0.26.6

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.
@@ -0,0 +1,264 @@
1
+ /*
2
+ * `ensure_worktree` — the worktree invariant as a verb.
3
+ *
4
+ * `git worktree add` lives on five cards as a RECIPE, which means it is followed
5
+ * from memory and skipped under pressure. The failure it prevents is not
6
+ * theoretical: a shared checkout is how two agents' edits land in one tree, and
7
+ * the primary checkout is the one everybody reaches for because it is the path
8
+ * they already have.
9
+ *
10
+ * CUT FROM `origin/<base>`, NEVER A LOCAL BRANCH. A local `main` is whatever the
11
+ * last person left there; `origin/<base>` is what everyone else will merge into.
12
+ * This is the same rule as `land`'s target-tip check, one step earlier: the
13
+ * question is always "what does the thing I am merging into have?".
14
+ */
15
+ import { execFileSync } from "node:child_process";
16
+ import { existsSync, realpathSync } from "node:fs";
17
+ import path from "node:path";
18
+ import { z } from "zod";
19
+ /**
20
+ * Compare paths by their REAL path, never by string.
21
+ *
22
+ * git reports `/private/var/...` where a caller passes `/var/...` — macOS's
23
+ * symlink — so `path.resolve` comparison silently MISSES the primary and the
24
+ * guard that refuses it never fires. The same symlink cost a carrier check its
25
+ * main-module guard earlier today: it exited 0 having done nothing.
26
+ */
27
+ const samePath = (a, b) => {
28
+ const real = (x) => {
29
+ try {
30
+ return realpathSync(x);
31
+ }
32
+ catch {
33
+ return path.resolve(x);
34
+ }
35
+ };
36
+ return real(a) === real(b);
37
+ };
38
+ const git = (repo, args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
39
+ /** Every worktree git knows about: `{path, branch, bare}` in list order. The
40
+ * FIRST entry is the primary checkout — that is what `--porcelain` guarantees. */
41
+ export function listWorktrees(repo) {
42
+ const out = [];
43
+ let cur = null;
44
+ for (const line of git(repo, ["worktree", "list", "--porcelain"]).split("\n")) {
45
+ if (line.startsWith("worktree ")) {
46
+ if (cur)
47
+ out.push(cur);
48
+ cur = { path: line.slice(9), branch: null };
49
+ }
50
+ else if (line.startsWith("branch ") && cur) {
51
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
52
+ }
53
+ }
54
+ if (cur)
55
+ out.push(cur);
56
+ return out;
57
+ }
58
+ /** The primary checkout — the tree nobody may be given for a slice. */
59
+ export function primaryOf(repo) {
60
+ const all = listWorktrees(repo);
61
+ return all.length ? all[0].path : null;
62
+ }
63
+ const isDirty = (repo) => {
64
+ try {
65
+ return git(repo, ["status", "--porcelain"]).length > 0;
66
+ }
67
+ catch {
68
+ return true; // unreadable is not clean
69
+ }
70
+ };
71
+ export const ensureWorktreeSchema = {
72
+ agentId: z.string().min(1),
73
+ repo: z.string().min(1),
74
+ base: z.string().min(1),
75
+ task: z.string().optional(),
76
+ ephemeral: z.boolean().optional(),
77
+ parent: z.string().optional(),
78
+ };
79
+ export async function ensureWorktreeTool(args) {
80
+ const { agentId, repo, base } = args;
81
+ if (!path.isAbsolute(repo))
82
+ return { ok: false, error: `repo must be an absolute path, got '${repo}'` };
83
+ if (!existsSync(repo))
84
+ return { ok: false, error: `no such repo: ${repo}` };
85
+ let primary;
86
+ try {
87
+ primary = primaryOf(repo);
88
+ }
89
+ catch (e) {
90
+ return { ok: false, error: `not a git repository (${String(e.message).split("\n")[0]})` };
91
+ }
92
+ // `repo` IS the primary checkout in normal use — that is how you address the
93
+ // repository. What must never happen is HANDING AN AGENT THE PRIMARY as its
94
+ // slice tree, so the refusal is on the computed TARGET, not on the input.
95
+ //
96
+ // Reading spec 1.4 literally ("calling with the primary path exits non-zero")
97
+ // would refuse every call and make the verb unusable, since `repo` is always
98
+ // the primary. The invariant it protects is the one implemented here: two
99
+ // agents editing one tree. Flagged in the PR rather than silently chosen.
100
+ return await create({ ...args, primary: primary ?? repo });
101
+ }
102
+ async function create(a) {
103
+ const { repo, base, agentId, primary } = a;
104
+ // The BASE must exist as a REMOTE ref. A missing `origin/<base>` is refused
105
+ // rather than silently falling back to a local branch of the same name — the
106
+ // local one is whatever the last person left there.
107
+ const ref = `origin/${base}`;
108
+ let sha;
109
+ try {
110
+ git(repo, ["fetch", "--quiet", "origin", base]);
111
+ }
112
+ catch {
113
+ /* offline or no remote: fall through to the rev-parse, which is the real test */
114
+ }
115
+ try {
116
+ sha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
117
+ }
118
+ catch {
119
+ return {
120
+ ok: false,
121
+ error: `${ref} does not resolve — refusing to cut a tree from a local '${base}', which is whatever the last person left there. Fetch the remote, or name a base that exists on origin.`,
122
+ };
123
+ }
124
+ // ONE PROJECT PARENT: trees live beside the primary, named for the agent, so a
125
+ // stray tree is identifiable without opening it.
126
+ const parent = a.parent ?? path.dirname(primary);
127
+ const leaf = `${path.basename(primary)}-${agentId}${a.ephemeral ? "-ephemeral" : ""}`;
128
+ const target = path.join(parent, leaf);
129
+ const branch = a.task ? `${agentId}/${a.task}` : `${agentId}/work`;
130
+ // DEFENCE IN DEPTH, AND IT IS CURRENTLY UNREACHABLE — stated because a guard
131
+ // that cannot fire is worth nothing until someone knows it cannot.
132
+ //
133
+ // The leaf is always `<primary-basename>-<agentId>`, so the target can never
134
+ // equal the primary while that naming holds. I tried to write a test that
135
+ // reaches this branch and could not without symlink contrivance; the honest
136
+ // conclusion is that the NAMING is the real invariant and this is a backstop
137
+ // for the day someone changes it. It is kept rather than deleted for exactly
138
+ // that day, and labelled rather than left looking load-bearing.
139
+ //
140
+ // What actually protects the invariant is tested instead: the target always
141
+ // differs from the primary, for any agent id.
142
+ if (samePath(target, primary)) {
143
+ return {
144
+ ok: false,
145
+ error: `refusing to hand back the PRIMARY checkout (${primary}) as a slice tree — that is the path everyone already has, ` +
146
+ `so it is the one two agents end up editing at once. Pass a different parent or agentId.`,
147
+ };
148
+ }
149
+ const existing = listWorktrees(repo).find((w) => samePath(w.path, target));
150
+ if (existing) {
151
+ // IDEMPOTENT, and it reports what it FOUND rather than what it would have
152
+ // made: a verb that silently returns a tree on a different branch than asked
153
+ // for is the adjacent-answer shape.
154
+ const head = git(existing.path, ["rev-parse", "HEAD"]);
155
+ return {
156
+ ok: true,
157
+ path: existing.path,
158
+ sha: head,
159
+ branch: existing.branch,
160
+ created: false,
161
+ base: ref,
162
+ ...(existing.branch !== branch
163
+ ? { warning: `existing tree is on '${existing.branch}', not the '${branch}' this call would have created — reusing it, NOT re-pointing it` }
164
+ : {}),
165
+ };
166
+ }
167
+ try {
168
+ git(repo, ["worktree", "add", "-q", "-b", branch, target, sha]);
169
+ }
170
+ catch (e) {
171
+ return { ok: false, error: `git worktree add failed: ${String(e.message).split("\n")[0]}` };
172
+ }
173
+ return {
174
+ ok: true,
175
+ path: target,
176
+ sha,
177
+ branch,
178
+ created: true,
179
+ base: ref,
180
+ ...(a.ephemeral
181
+ ? { ephemeral: true, removeWith: `git -C ${repo} worktree remove --force ${target} && git -C ${repo} branch -D ${branch}` }
182
+ : {}),
183
+ };
184
+ }
185
+ // ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
186
+ export const refreshWorktreesSchema = {
187
+ repo: z.string().min(1),
188
+ base: z.string().min(1),
189
+ apply: z.boolean().optional(),
190
+ };
191
+ /**
192
+ * Fast-forward IDLE trees onto `origin/<base>`. Never `--force`, never a
193
+ * mid-slice tree.
194
+ *
195
+ * A tree is MID-SLICE when it is dirty or holds commits the base does not.
196
+ * Pulling under someone's feet is worse than staleness: staleness is visible in
197
+ * a diff, a clobbered work-in-progress is not. So dirty or diverged is REFUSED
198
+ * per tree and named, and the run continues for the others.
199
+ */
200
+ export async function refreshWorktreesTool(args) {
201
+ const { repo, base } = args;
202
+ const ref = `origin/${base}`;
203
+ try {
204
+ git(repo, ["fetch", "--quiet", "origin", base]);
205
+ }
206
+ catch {
207
+ /* reported per tree below */
208
+ }
209
+ let tip;
210
+ try {
211
+ tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
212
+ }
213
+ catch {
214
+ return { ok: false, error: `${ref} does not resolve — nothing to fast-forward onto` };
215
+ }
216
+ const results = [];
217
+ for (const w of listWorktrees(repo)) {
218
+ const at = w.path;
219
+ if (samePath(at, primaryOf(repo) ?? "")) {
220
+ results.push({ path: w.path, action: "skipped", why: "primary checkout — not a slice tree" });
221
+ continue;
222
+ }
223
+ if (isDirty(at)) {
224
+ results.push({ path: w.path, action: "refused", why: "MID-SLICE: uncommitted changes. Pulling here would clobber work a diff cannot show." });
225
+ continue;
226
+ }
227
+ let head;
228
+ try {
229
+ head = git(at, ["rev-parse", "HEAD"]);
230
+ }
231
+ catch {
232
+ results.push({ path: w.path, action: "refused", why: "unreadable HEAD" });
233
+ continue;
234
+ }
235
+ if (head === tip) {
236
+ results.push({ path: w.path, action: "current", why: `already at ${ref}` });
237
+ continue;
238
+ }
239
+ let ahead = "0";
240
+ try {
241
+ ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
242
+ }
243
+ catch {
244
+ /* treated as diverged below */
245
+ }
246
+ if (ahead !== "0") {
247
+ results.push({ path: w.path, action: "refused", why: `MID-SLICE: ${ahead} commit(s) not on ${ref}. Refusing rather than --force.` });
248
+ continue;
249
+ }
250
+ if (!args.apply) {
251
+ results.push({ path: w.path, action: "would-fast-forward", why: `behind ${ref}` });
252
+ continue;
253
+ }
254
+ try {
255
+ git(at, ["merge", "--ff-only", tip]);
256
+ results.push({ path: w.path, action: "fast-forwarded", why: `to ${tip.slice(0, 8)}` });
257
+ }
258
+ catch (e) {
259
+ results.push({ path: w.path, action: "refused", why: `ff-only failed: ${String(e.message).split("\n")[0]}` });
260
+ }
261
+ }
262
+ return { ok: true, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results };
263
+ }
264
+ //# sourceMappingURL=worktrees.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../src/tools/worktrees.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.4",
3
+ "version": "0.26.6",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  "@modelcontextprotocol/sdk": "^1.30.0",
47
47
  "proper-lockfile": "^4.1.2",
48
48
  "zod": "^4.4.3",
49
- "@davidbalzan/groundwork-seam": "0.1.6"
49
+ "@davidbalzan/groundwork-seam": "0.1.8"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^26.2.0",
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { spawn } from "node:child_process";
24
24
 
25
- const EXPECTED_TESTS = 347;
25
+ const EXPECTED_TESTS = 452;
26
26
 
27
27
  const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
28
28
  // Same glob the suite always used — `--test test/` would recurse differently
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,8 @@ 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";
9
11
  import {
10
12
  ensureDirs,
11
13
  getTokenMap,
@@ -79,6 +81,24 @@ import {
79
81
  listScopesTool,
80
82
  importWorkSchema,
81
83
  importWorkTool,
84
+ stallCheckSchema,
85
+ stallCheckTool,
86
+ setHaltSchema,
87
+ setHaltTool,
88
+ lastRanSchema,
89
+ stallClockStatusTool,
90
+ ensureWorktreeSchema,
91
+ ensureWorktreeTool,
92
+ refreshWorktreesSchema,
93
+ refreshWorktreesTool,
94
+ claimSchema,
95
+ claimTool,
96
+ landSchema,
97
+ landTool,
98
+ mergeSchema,
99
+ mergeTool,
100
+ nextUnblockedSchema,
101
+ nextUnblockedTool,
82
102
  listWorkSchema,
83
103
  listWorkTool,
84
104
  exportWorkSchema,
@@ -250,9 +270,18 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
250
270
  inputSchema: ZodRawShape,
251
271
  cb: (args: Record<string, unknown>) => Promise<ReturnType<typeof jsonResult>>,
252
272
  ) => {
253
- server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) =>
254
- cb((args ?? {}) as Record<string, unknown>),
255
- );
273
+ server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) => {
274
+ const a = (args ?? {}) as Record<string, unknown>;
275
+ // DUTY-OFFICER ALLOWLIST, ENFORCED IN THE ONE PATH EVERY TOOL IS
276
+ // REGISTERED THROUGH. Placed here rather than in `gate` because `gate`
277
+ // does not know the tool's NAME, and because a guard applied per call
278
+ // site is a guard someone forgets at one call site — where the omission
279
+ // looks identical to the guarded ones from outside (kit#125).
280
+ const caller = bound ?? (typeof a["agentId"] === "string" ? (a["agentId"] as string) : typeof a["from"] === "string" ? (a["from"] as string) : undefined);
281
+ const refusal = awayRefusal(readAway(), caller, name);
282
+ if (refusal) throw new Error(refusal);
283
+ return cb(a);
284
+ });
256
285
  };
257
286
 
258
287
  addTool(
@@ -513,6 +542,90 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
513
542
  gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
514
543
  );
515
544
 
545
+ addTool(
546
+ "stall_check",
547
+ "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.",
548
+ stallCheckSchema,
549
+ gate(null, stallCheckTool as (a: Record<string, unknown>) => Promise<unknown>),
550
+ );
551
+
552
+ addTool(
553
+ "stall_clock_status",
554
+ "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.",
555
+ lastRanSchema,
556
+ gate(null, stallClockStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
557
+ );
558
+
559
+ addTool(
560
+ "set_halt",
561
+ "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.",
562
+ setHaltSchema,
563
+ gate(null, setHaltTool as (a: Record<string, unknown>) => Promise<unknown>),
564
+ );
565
+
566
+ addTool(
567
+ "ensure_worktree",
568
+ "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.",
569
+ ensureWorktreeSchema,
570
+ gate(null, ensureWorktreeTool as (a: Record<string, unknown>) => Promise<unknown>),
571
+ );
572
+
573
+ addTool(
574
+ "refresh_worktrees",
575
+ "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.",
576
+ refreshWorktreesSchema,
577
+ gate(null, refreshWorktreesTool as (a: Record<string, unknown>) => Promise<unknown>),
578
+ );
579
+
580
+ addTool(
581
+ "next_unblocked",
582
+ "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.",
583
+ nextUnblockedSchema,
584
+ gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
585
+ );
586
+
587
+ addTool(
588
+ "claim",
589
+ "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.",
590
+ claimSchema,
591
+ gate(null, claimTool as (a: Record<string, unknown>) => Promise<unknown>),
592
+ );
593
+
594
+ addTool(
595
+ "land",
596
+ "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.",
597
+ landSchema,
598
+ gate(null, landTool as (a: Record<string, unknown>) => Promise<unknown>),
599
+ );
600
+
601
+ addTool(
602
+ "merge",
603
+ "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.",
604
+ mergeSchema,
605
+ gate(null, mergeTool as (a: Record<string, unknown>) => Promise<unknown>),
606
+ );
607
+
608
+ addTool(
609
+ "coord_away",
610
+ "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.",
611
+ coordAwaySchema,
612
+ gate("coordinatorId" as "agentId", coordAwayTool as (a: Record<string, unknown>) => Promise<unknown>),
613
+ );
614
+
615
+ addTool(
616
+ "rotate",
617
+ "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.",
618
+ rotateSchema,
619
+ gate("agentId", rotateTool as (a: Record<string, unknown>) => Promise<unknown>),
620
+ );
621
+
622
+ addTool(
623
+ "rotate_reconcile",
624
+ "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.",
625
+ rotateReconcileSchema,
626
+ gate("agentId", rotateReconcileTool as (a: Record<string, unknown>) => Promise<unknown>),
627
+ );
628
+
516
629
  addTool(
517
630
  "export_work",
518
631
  "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,121 @@
1
+ /*
2
+ * `coord_away` — Phase 5 Task 4.1/4.2.
3
+ *
4
+ * WHAT THIS IS NOT: a way to hand the coordinator's judgement to someone else.
5
+ * A duty officer keeps the lane MOVING while the coordinator is away; it does
6
+ * not inherit the authority to decide what the lane is for. The distinction is
7
+ * the whole point, so it is enforced as an ALLOWLIST rather than a denylist:
8
+ * a new tool added tomorrow is refused by default, and someone must decide, in
9
+ * writing, that a duty officer may call it.
10
+ *
11
+ * A denylist would have the opposite failure — every tool anyone forgets to
12
+ * list is silently granted — which is the same shape as an empty check rollup
13
+ * scoring green (kit#125): absence read as permission.
14
+ *
15
+ * ENFORCEMENT LIVES IN `addTool`, THE SINGLE PATH EVERY TOOL IS REGISTERED
16
+ * THROUGH, for the reason kit#125 folded the check into the merge call: a
17
+ * guard repeated at call sites is a guard someone forgets at one call site,
18
+ * and the forgotten one looks identical to the guarded ones from outside.
19
+ */
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { z } from "zod";
23
+ import { ROOT } from "../store.js";
24
+
25
+ const awayFile = () => path.join(ROOT, "coord-away.json");
26
+
27
+ /**
28
+ * WHAT A DUTY OFFICER MAY DO: move claimed work along and report.
29
+ *
30
+ * Deliberately absent, each for its own reason rather than by omission:
31
+ * `merge` — merging is a judgement about whether work is DONE.
32
+ * `register`/`join` w/ coordinator role — see `secondCoordinatorRefusal`.
33
+ * grow-fleet verbs — a stand-in does not get to change the fleet's shape.
34
+ * CANON writes — canon outlives the absence that created the stand-in.
35
+ */
36
+ export const DUTY_OFFICER_ALLOWLIST = ["next_unblocked", "claim", "land", "stall_check", "stall_clock_status", "post_status", "send_message", "read_messages", "status", "heartbeat", "list_work"] as const;
37
+
38
+ export type AwayState = { on: boolean; project: string; coordinatorId: string; dutyOfficerId: string; until?: string; at: string };
39
+
40
+ export function readAway(): Record<string, AwayState> {
41
+ const f = awayFile();
42
+ if (!existsSync(f)) return {};
43
+ try {
44
+ return JSON.parse(readFileSync(f, "utf8")) as Record<string, AwayState>;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function writeAway(state: Record<string, AwayState>): void {
51
+ mkdirSync(ROOT, { recursive: true });
52
+ writeFileSync(awayFile(), `${JSON.stringify(state, null, 2)}\n`);
53
+ }
54
+
55
+ /**
56
+ * The refusal a duty officer gets, or null if the call is allowed.
57
+ *
58
+ * Only the DUTY OFFICER is constrained. Everyone else's lane is unchanged:
59
+ * `coord-away` is not a freeze on the project, and a worker whose tools stop
60
+ * working because the coordinator stepped out would simply stop calling them.
61
+ */
62
+ export function awayRefusal(state: Record<string, AwayState>, agentId: string | undefined, tool: string): string | null {
63
+ if (!agentId) return null;
64
+ const held = Object.values(state).find((s) => s.on && s.dutyOfficerId === agentId);
65
+ if (!held) return null;
66
+ if ((DUTY_OFFICER_ALLOWLIST as readonly string[]).includes(tool)) return null;
67
+ return `'${tool}' is not on the duty-officer allowlist. You are standing in for '${held.coordinatorId}' on '${held.project}' while coord-away is ON: a duty officer keeps the lane MOVING and does not inherit the authority to decide what it is for. Allowed: ${DUTY_OFFICER_ALLOWLIST.join(", ")}. If this genuinely needs doing, it needs ${held.coordinatorId} back or David — not a wider allowlist added in the moment.`;
68
+ }
69
+
70
+ /**
71
+ * 4.2 — a SECOND coordinator may not join while the first is away.
72
+ *
73
+ * Not a lock for its own sake: two coordinators is the condition under which
74
+ * two GOs can be issued for one lane, and the absent one cannot see the other
75
+ * arrive. The duty officer exists precisely so the seat is not empty, so a
76
+ * second claimant is a fleet-shape change, which is the category `coord_away`
77
+ * refuses by construction.
78
+ */
79
+ export function secondCoordinatorRefusal(state: Record<string, AwayState>, agentId: string, roleId: string | undefined): string | null {
80
+ if (roleId !== "coordinator") return null;
81
+ const held = Object.values(state).find((s) => s.on && s.coordinatorId !== agentId);
82
+ if (!held) return null;
83
+ return `coord-away is HELD on '${held.project}' by '${held.coordinatorId}' (duty officer '${held.dutyOfficerId}'). A second coordinator cannot join until it is RELEASED — two coordinators is the condition under which one lane gets two GOs, and the absent one cannot see the second arrive.`;
84
+ }
85
+
86
+ export const coordAwaySchema = {
87
+ project: z.string().min(1),
88
+ on: z.boolean(),
89
+ coordinatorId: z.string().min(1),
90
+ dutyOfficerId: z.string().optional(),
91
+ until: z.string().optional(),
92
+ };
93
+
94
+ export async function coordAwayTool(args: { project: string; on: boolean; coordinatorId: string; dutyOfficerId?: string; until?: string }) {
95
+ const state = readAway();
96
+ const prior = state[args.project];
97
+
98
+ if (args.on) {
99
+ // A STAND-IN WITH NO NAME IS AN EMPTY SEAT DESCRIBED AS COVERED, which is
100
+ // strictly worse than a seat everyone can see is empty.
101
+ if (!args.dutyOfficerId) return { ok: false as const, error: `coord-away ON requires a dutyOfficerId. Turning it on without naming a stand-in reports the lane as covered while leaving it uncovered.` };
102
+ if (args.dutyOfficerId === args.coordinatorId) return { ok: false as const, error: `'${args.coordinatorId}' cannot be its own duty officer.` };
103
+ if (prior?.on && prior.coordinatorId !== args.coordinatorId) return { ok: false as const, error: `coord-away is already HELD on '${args.project}' by '${prior.coordinatorId}'. Release it first.` };
104
+ state[args.project] = { on: true, project: args.project, coordinatorId: args.coordinatorId, dutyOfficerId: args.dutyOfficerId, until: args.until, at: new Date().toISOString() };
105
+ writeAway(state);
106
+ return {
107
+ ok: true as const,
108
+ state: state[args.project],
109
+ allowlist: DUTY_OFFICER_ALLOWLIST,
110
+ announce: `AGENT_ACTION: coord-away ON — '${args.coordinatorId}' away${args.until ? ` until ${args.until}` : ""}; '${args.dutyOfficerId}' is duty officer, limited to: ${DUTY_OFFICER_ALLOWLIST.join(", ")}. A second coordinator cannot join until released.`,
111
+ };
112
+ }
113
+
114
+ // RELEASE IS THE COORDINATOR'S. A duty officer releasing its own limits is
115
+ // the limit not existing — the one call it must not be able to make.
116
+ if (!prior?.on) return { ok: false as const, error: `coord-away is not on for '${args.project}' — nothing to release.` };
117
+ if (prior.coordinatorId !== args.coordinatorId) return { ok: false as const, error: `coord-away on '${args.project}' is held by '${prior.coordinatorId}' and only they can release it. A stand-in that can lift its own limits does not have any.` };
118
+ state[args.project] = { ...prior, on: false, at: new Date().toISOString() };
119
+ writeAway(state);
120
+ return { ok: true as const, state: state[args.project], announce: `AGENT_ACTION: coord-away RELEASED on '${args.project}' — '${prior.coordinatorId}' is back; '${prior.dutyOfficerId}' stands down.` };
121
+ }
@@ -6,3 +6,6 @@ export * from "./transport.js";
6
6
  export * from "./admin.js";
7
7
  export * from "./scopes.js";
8
8
  export * from "./work.js";
9
+ export * from "./records.js";
10
+ export * from "./worktrees.js";
11
+ export * from "./stall.js";