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,283 @@
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
+ /**
21
+ * Compare paths by their REAL path, never by string.
22
+ *
23
+ * git reports `/private/var/...` where a caller passes `/var/...` — macOS's
24
+ * symlink — so `path.resolve` comparison silently MISSES the primary and the
25
+ * guard that refuses it never fires. The same symlink cost a carrier check its
26
+ * main-module guard earlier today: it exited 0 having done nothing.
27
+ */
28
+ const samePath = (a: string, b: string): boolean => {
29
+ const real = (x: string) => {
30
+ try {
31
+ return realpathSync(x);
32
+ } catch {
33
+ return path.resolve(x);
34
+ }
35
+ };
36
+ return real(a) === real(b);
37
+ };
38
+
39
+ const git = (repo: string, args: string[]): string =>
40
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
41
+
42
+ /** Every worktree git knows about: `{path, branch, bare}` in list order. The
43
+ * FIRST entry is the primary checkout — that is what `--porcelain` guarantees. */
44
+ export function listWorktrees(repo: string): { path: string; branch: string | null }[] {
45
+ const out: { path: string; branch: string | null }[] = [];
46
+ let cur: { path: string; branch: string | null } | null = null;
47
+ for (const line of git(repo, ["worktree", "list", "--porcelain"]).split("\n")) {
48
+ if (line.startsWith("worktree ")) {
49
+ if (cur) out.push(cur);
50
+ cur = { path: line.slice(9), branch: null };
51
+ } else if (line.startsWith("branch ") && cur) {
52
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
53
+ }
54
+ }
55
+ if (cur) out.push(cur);
56
+ return out;
57
+ }
58
+
59
+ /** The primary checkout — the tree nobody may be given for a slice. */
60
+ export function primaryOf(repo: string): string | null {
61
+ const all = listWorktrees(repo);
62
+ return all.length ? (all[0] as { path: string }).path : null;
63
+ }
64
+
65
+ const isDirty = (repo: string): boolean => {
66
+ try {
67
+ return git(repo, ["status", "--porcelain"]).length > 0;
68
+ } catch {
69
+ return true; // unreadable is not clean
70
+ }
71
+ };
72
+
73
+ export const ensureWorktreeSchema = {
74
+ agentId: z.string().min(1),
75
+ repo: z.string().min(1),
76
+ base: z.string().min(1),
77
+ task: z.string().optional(),
78
+ ephemeral: z.boolean().optional(),
79
+ parent: z.string().optional(),
80
+ };
81
+
82
+ export async function ensureWorktreeTool(args: {
83
+ agentId: string;
84
+ repo: string;
85
+ base: string;
86
+ task?: string;
87
+ ephemeral?: boolean;
88
+ parent?: string;
89
+ }) {
90
+ const { agentId, repo, base } = args;
91
+ if (!path.isAbsolute(repo)) return { ok: false as const, error: `repo must be an absolute path, got '${repo}'` };
92
+ if (!existsSync(repo)) return { ok: false as const, error: `no such repo: ${repo}` };
93
+
94
+ let primary: string | null;
95
+ try {
96
+ primary = primaryOf(repo);
97
+ } catch (e) {
98
+ return { ok: false as const, error: `not a git repository (${String((e as Error).message).split("\n")[0]})` };
99
+ }
100
+
101
+ // `repo` IS the primary checkout in normal use — that is how you address the
102
+ // repository. What must never happen is HANDING AN AGENT THE PRIMARY as its
103
+ // slice tree, so the refusal is on the computed TARGET, not on the input.
104
+ //
105
+ // Reading spec 1.4 literally ("calling with the primary path exits non-zero")
106
+ // would refuse every call and make the verb unusable, since `repo` is always
107
+ // the primary. The invariant it protects is the one implemented here: two
108
+ // agents editing one tree. Flagged in the PR rather than silently chosen.
109
+ return await create({ ...args, primary: primary ?? repo });
110
+ }
111
+
112
+ async function create(a: {
113
+ agentId: string;
114
+ repo: string;
115
+ base: string;
116
+ task?: string;
117
+ ephemeral?: boolean;
118
+ parent?: string;
119
+ primary: string;
120
+ }) {
121
+ const { repo, base, agentId, primary } = a;
122
+
123
+ // The BASE must exist as a REMOTE ref. A missing `origin/<base>` is refused
124
+ // rather than silently falling back to a local branch of the same name — the
125
+ // local one is whatever the last person left there.
126
+ const ref = `origin/${base}`;
127
+ let sha: string;
128
+ try {
129
+ git(repo, ["fetch", "--quiet", "origin", base]);
130
+ } catch {
131
+ /* offline or no remote: fall through to the rev-parse, which is the real test */
132
+ }
133
+ try {
134
+ sha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
135
+ } catch {
136
+ return {
137
+ ok: false as const,
138
+ 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.`,
139
+ };
140
+ }
141
+
142
+ // ONE PROJECT PARENT: trees live beside the primary, named for the agent, so a
143
+ // stray tree is identifiable without opening it.
144
+ const parent = a.parent ?? path.dirname(primary);
145
+ const leaf = `${path.basename(primary)}-${agentId}${a.ephemeral ? "-ephemeral" : ""}`;
146
+ const target = path.join(parent, leaf);
147
+ const branch = a.task ? `${agentId}/${a.task}` : `${agentId}/work`;
148
+
149
+ // DEFENCE IN DEPTH, AND IT IS CURRENTLY UNREACHABLE — stated because a guard
150
+ // that cannot fire is worth nothing until someone knows it cannot.
151
+ //
152
+ // The leaf is always `<primary-basename>-<agentId>`, so the target can never
153
+ // equal the primary while that naming holds. I tried to write a test that
154
+ // reaches this branch and could not without symlink contrivance; the honest
155
+ // conclusion is that the NAMING is the real invariant and this is a backstop
156
+ // for the day someone changes it. It is kept rather than deleted for exactly
157
+ // that day, and labelled rather than left looking load-bearing.
158
+ //
159
+ // What actually protects the invariant is tested instead: the target always
160
+ // differs from the primary, for any agent id.
161
+ if (samePath(target, primary)) {
162
+ return {
163
+ ok: false as const,
164
+ error:
165
+ `refusing to hand back the PRIMARY checkout (${primary}) as a slice tree — that is the path everyone already has, ` +
166
+ `so it is the one two agents end up editing at once. Pass a different parent or agentId.`,
167
+ };
168
+ }
169
+
170
+ const existing = listWorktrees(repo).find((w) => samePath(w.path, target));
171
+ if (existing) {
172
+ // IDEMPOTENT, and it reports what it FOUND rather than what it would have
173
+ // made: a verb that silently returns a tree on a different branch than asked
174
+ // for is the adjacent-answer shape.
175
+ const head = git(existing.path, ["rev-parse", "HEAD"]);
176
+ return {
177
+ ok: true as const,
178
+ path: existing.path,
179
+ sha: head,
180
+ branch: existing.branch,
181
+ created: false,
182
+ base: ref,
183
+ ...(existing.branch !== branch
184
+ ? { warning: `existing tree is on '${existing.branch}', not the '${branch}' this call would have created — reusing it, NOT re-pointing it` }
185
+ : {}),
186
+ };
187
+ }
188
+
189
+ try {
190
+ git(repo, ["worktree", "add", "-q", "-b", branch, target, sha]);
191
+ } catch (e) {
192
+ return { ok: false as const, error: `git worktree add failed: ${String((e as Error).message).split("\n")[0]}` };
193
+ }
194
+ return {
195
+ ok: true as const,
196
+ path: target,
197
+ sha,
198
+ branch,
199
+ created: true,
200
+ base: ref,
201
+ ...(a.ephemeral
202
+ ? { ephemeral: true, removeWith: `git -C ${repo} worktree remove --force ${target} && git -C ${repo} branch -D ${branch}` }
203
+ : {}),
204
+ };
205
+ }
206
+
207
+ // ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
208
+
209
+ export const refreshWorktreesSchema = {
210
+ repo: z.string().min(1),
211
+ base: z.string().min(1),
212
+ apply: z.boolean().optional(),
213
+ };
214
+
215
+ /**
216
+ * Fast-forward IDLE trees onto `origin/<base>`. Never `--force`, never a
217
+ * mid-slice tree.
218
+ *
219
+ * A tree is MID-SLICE when it is dirty or holds commits the base does not.
220
+ * Pulling under someone's feet is worse than staleness: staleness is visible in
221
+ * a diff, a clobbered work-in-progress is not. So dirty or diverged is REFUSED
222
+ * per tree and named, and the run continues for the others.
223
+ */
224
+ export async function refreshWorktreesTool(args: { repo: string; base: string; apply?: boolean }) {
225
+ const { repo, base } = args;
226
+ const ref = `origin/${base}`;
227
+ try {
228
+ git(repo, ["fetch", "--quiet", "origin", base]);
229
+ } catch {
230
+ /* reported per tree below */
231
+ }
232
+ let tip: string;
233
+ try {
234
+ tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
235
+ } catch {
236
+ return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
237
+ }
238
+
239
+ const results = [];
240
+ for (const w of listWorktrees(repo)) {
241
+ const at = w.path;
242
+ if (samePath(at, primaryOf(repo) ?? "")) {
243
+ results.push({ path: w.path, action: "skipped", why: "primary checkout — not a slice tree" });
244
+ continue;
245
+ }
246
+ if (isDirty(at)) {
247
+ results.push({ path: w.path, action: "refused", why: "MID-SLICE: uncommitted changes. Pulling here would clobber work a diff cannot show." });
248
+ continue;
249
+ }
250
+ let head: string;
251
+ try {
252
+ head = git(at, ["rev-parse", "HEAD"]);
253
+ } catch {
254
+ results.push({ path: w.path, action: "refused", why: "unreadable HEAD" });
255
+ continue;
256
+ }
257
+ if (head === tip) {
258
+ results.push({ path: w.path, action: "current", why: `already at ${ref}` });
259
+ continue;
260
+ }
261
+ let ahead = "0";
262
+ try {
263
+ ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
264
+ } catch {
265
+ /* treated as diverged below */
266
+ }
267
+ if (ahead !== "0") {
268
+ results.push({ path: w.path, action: "refused", why: `MID-SLICE: ${ahead} commit(s) not on ${ref}. Refusing rather than --force.` });
269
+ continue;
270
+ }
271
+ if (!args.apply) {
272
+ results.push({ path: w.path, action: "would-fast-forward", why: `behind ${ref}` });
273
+ continue;
274
+ }
275
+ try {
276
+ git(at, ["merge", "--ff-only", tip]);
277
+ results.push({ path: w.path, action: "fast-forwarded", why: `to ${tip.slice(0, 8)}` });
278
+ } catch (e) {
279
+ results.push({ path: w.path, action: "refused", why: `ff-only failed: ${String((e as Error).message).split("\n")[0]}` });
280
+ }
281
+ }
282
+ return { ok: true as const, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results };
283
+ }