agent-lyceum 0.11.2 → 0.13.0

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.
package/dist/worktree.js CHANGED
@@ -4,8 +4,10 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { atomicWrite } from "./fs-util.js";
7
- /** Agents share the repo, or each non-lead agent gets its own git worktree. `auto` picks worktrees as soon as agents can run in parallel. */
7
+ /** Agents share the repo, or each non-lead agent gets its own git worktree. `auto` picks worktrees as soon as agents can run in parallel; several concurrent runs always do. */
8
8
  export function resolveWorkspaceMode(d) {
9
+ if ((d.max_concurrent_runs ?? 1) > 1)
10
+ return "worktree";
9
11
  if (d.workspace_mode === "worktree")
10
12
  return "worktree";
11
13
  if (d.workspace_mode === "shared")
@@ -18,15 +20,19 @@ const IDENTITY = {
18
20
  GIT_COMMITTER_NAME: "agent-lyceum",
19
21
  GIT_COMMITTER_EMAIL: "agent-lyceum@localhost",
20
22
  };
21
- export function git(cwd, args, env = {}) {
23
+ function runGit(cwd, args, env = {}, input) {
22
24
  return execFileSync("git", args, {
23
25
  cwd,
24
26
  encoding: "utf8",
25
27
  maxBuffer: 256 * 1024 * 1024,
26
- stdio: ["ignore", "pipe", "pipe"],
28
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
29
+ input,
27
30
  env: { ...process.env, ...env },
28
31
  }).replace(/\n$/, "");
29
32
  }
33
+ export function git(cwd, args, env = {}) {
34
+ return runGit(cwd, args, env);
35
+ }
30
36
  function tryGit(cwd, args, env) {
31
37
  try {
32
38
  return git(cwd, args, env);
@@ -35,13 +41,173 @@ function tryGit(cwd, args, env) {
35
41
  return undefined;
36
42
  }
37
43
  }
38
- export function isGitRepo(dir) {
39
- return tryGit(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
44
+ // ---- git in an agent's worktree ----
45
+ /**
46
+ * What the dispatcher never lets git take from a place an agent can write. Each one was seen running a planted program:
47
+ * fsmonitor and hooks (post-commit runs despite --no-verify); a nested repository in the worktree, which `status` and `add`
48
+ * otherwise visit with git reading that repository's own config (its fsmonitor and filters); `submodule.recurse` / `diff.submodule`
49
+ * would do the same for reset and diff; signing runs gpg.program.
50
+ */
51
+ const PINNED_CONFIG = [
52
+ "-c", "core.fsmonitor=false",
53
+ "-c", "core.hooksPath=/dev/null",
54
+ "-c", "diff.ignoreSubmodules=dirty",
55
+ "-c", "submodule.recurse=false",
56
+ "-c", "diff.submodule=short",
57
+ "-c", "commit.gpgsign=false",
58
+ ];
59
+ /** Added to every diff/show/log: no external diff program, no textconv program. */
60
+ const DIFF_PINS = ["--no-ext-diff", "--no-textconv"];
61
+ const DIFF_COMMANDS = new Set(["diff", "show", "log"]);
62
+ const fingerprint = (file) => {
63
+ let st;
64
+ try {
65
+ st = fs.lstatSync(file);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ if (!st.isFile())
71
+ return `not a regular file (${st.isSymbolicLink() ? "symlink" : "other"})`;
72
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
73
+ };
74
+ const samePath = (a, b) => {
75
+ try {
76
+ return fs.realpathSync(a) === fs.realpathSync(b);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ };
82
+ /**
83
+ * Refuse a worktree whose git files were rewritten in a way pinning cannot neutralise:
84
+ * - `commondir`: git's ref store follows it even with GIT_COMMON_DIR set, so refs would be read from and written to the agent's dir;
85
+ * - `config.worktree` (read when the user enabled extensions.worktreeConfig): it can name filters, chosen by the worktree's own
86
+ * .gitattributes, and a signing program — more than any `-c` list can switch off;
87
+ * - files git writes by following links (COMMIT_EDITMSG, logs/HEAD): a symlink or hard link would make git write outside the sandbox;
88
+ * - `HEAD` not the worktree's own branch: the ref store is shared, so a HEAD naming another branch (say `main`) or a detached
89
+ * commit would make the dispatcher commit onto, or measure against, a ref the agent chose. git writes exactly
90
+ * `ref: refs/heads/<branch>\n` and keeps it so through the dispatcher's commits and `reset --hard`;
91
+ * - the worktree or its git dir replaced by a symlink.
92
+ */
93
+ export function assertWorkspaceIntact(ws) {
94
+ const problems = [];
95
+ const realDir = (p) => {
96
+ try {
97
+ return fs.lstatSync(p).isDirectory();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ };
103
+ if (!realDir(ws.root))
104
+ problems.push(`${ws.root} is not a plain directory`);
105
+ if (!realDir(ws.gitDir))
106
+ problems.push(`its git dir ${ws.gitDir} is not a plain directory`);
107
+ else {
108
+ let commondir;
109
+ try {
110
+ commondir = fs.readFileSync(path.join(ws.gitDir, "commondir"), "utf8").trim();
111
+ }
112
+ catch {
113
+ commondir = undefined;
114
+ }
115
+ if (commondir === undefined || !samePath(path.resolve(ws.gitDir, commondir), ws.commonDir))
116
+ problems.push(`${path.join(ws.gitDir, "commondir")} no longer points at ${ws.commonDir}`);
117
+ if (fingerprint(path.join(ws.gitDir, "config.worktree")) !== ws.worktreeConfig)
118
+ problems.push(`${path.join(ws.gitDir, "config.worktree")} changed since the worktree was made`);
119
+ for (const name of ["logs", "logs/HEAD", "COMMIT_EDITMSG"]) {
120
+ let st;
121
+ try {
122
+ st = fs.lstatSync(path.join(ws.gitDir, name));
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ const ok = name === "logs" ? st.isDirectory() : st.isFile() && st.nlink === 1;
128
+ if (!ok)
129
+ problems.push(`${path.join(ws.gitDir, name)} is a link or not what git made`);
130
+ }
131
+ const head = path.join(ws.gitDir, "HEAD");
132
+ let headOk = false;
133
+ try {
134
+ const st = fs.lstatSync(head);
135
+ headOk = st.isFile() && st.nlink === 1 && fs.readFileSync(head, "utf8") === `ref: refs/heads/${ws.branch}\n`;
136
+ }
137
+ catch {
138
+ headOk = false;
139
+ }
140
+ if (!headOk)
141
+ problems.push(`${head} is not a plain file naming its own branch ${ws.branch}`);
142
+ }
143
+ if (problems.length) {
144
+ throw new Error(`Refusing to run git in the worktree ${ws.root}: ${problems.join("; ")}. Its git files were changed after it was made (an agent can write them); nothing in it was touched. ` +
145
+ `The work in it is still there and nothing was deleted. \`agent-lyceum clear\` will refuse this worktree too, because it counts as holding work: inspect it, and once you are sure it is safe, remove it by hand with \`git worktree remove --force <path>\`.`);
146
+ }
40
147
  }
41
- const toplevel = (dir) => git(dir, ["rev-parse", "--show-toplevel"]);
42
- /** Files in the repo with changes nobody committed (modified, staged or new and not ignored). Paths are relative to the repo root. */
43
- export function dirtyPaths(dir) {
44
- const out = execFileSync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
148
+ function pinnedArgs(args) {
149
+ const i = args.findIndex((a) => !a.startsWith("-"));
150
+ const withDiffPins = i >= 0 && DIFF_COMMANDS.has(args[i] ?? "") ? [...args.slice(0, i + 1), ...DIFF_PINS, ...args.slice(i + 1)] : args;
151
+ return [...PINNED_CONFIG, ...withDiffPins];
152
+ }
153
+ function pinnedEnv(ws, env) {
154
+ return { ...env, GIT_DIR: ws.gitDir, GIT_COMMON_DIR: ws.commonDir, GIT_WORK_TREE: ws.root };
155
+ }
156
+ /**
157
+ * Run git against an agent's worktree without letting git discover anything from it. The agent may rewrite the worktree's `.git`
158
+ * pointer file and its per-worktree git dir; git is told exactly where the git dir, the common dir and the work tree are
159
+ * (`ws.gitDir` / `ws.commonDir` come from the workspace meta, recorded before any agent ran in it), and the worktree is checked
160
+ * for what pinning cannot neutralise first (`assertWorkspaceIntact`).
161
+ * The cwd is the worktree root: git does not discover from it (GIT_DIR is set), and commands that take paths relative to the cwd
162
+ * (`git apply`) would otherwise write wherever the cwd is.
163
+ */
164
+ export function agentGit(ws, args, env = {}, input) {
165
+ assertWorkspaceIntact(ws);
166
+ return runGit(ws.root, pinnedArgs(args), pinnedEnv(ws, env), input);
167
+ }
168
+ /** `agentGit`, with a failing git command giving `undefined`. A worktree that fails `assertWorkspaceIntact` still throws. */
169
+ export function tryAgentGit(ws, args, env) {
170
+ assertWorkspaceIntact(ws);
171
+ try {
172
+ return runGit(ws.root, pinnedArgs(args), pinnedEnv(ws, env ?? {}));
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ const zList = (out) => out.split("\0").filter(Boolean);
179
+ /** Paths of the gitlink (mode 160000) entries in `git ls-files -s -z` / `git ls-tree -r -z` output. */
180
+ const gitlinks = (out) => zList(out)
181
+ .filter((e) => e.startsWith("160000 "))
182
+ .map((e) => e.slice(e.indexOf("\t") + 1));
183
+ /**
184
+ * Refuse a worktree that holds a git repository of its own. `status`, `add -A` and `commit` can visit a nested repository and run
185
+ * git there with that repository's config — which the agent wrote. Run before any command that walks the working tree.
186
+ * - untracked: `ls-files -o` (it does not enter them, and runs nothing) lists a nested repository as one entry ending in `/`;
187
+ * - a submodule entry in the (agent-writable) index that the snapshot did not have;
188
+ * - a submodule path from the snapshot that now holds a repository (`git worktree add` never fills it, and an agent cannot).
189
+ * A repo that uses submodules keeps working: its submodule entries come from the snapshot and stay empty in a worktree.
190
+ */
191
+ export function assertNoNestedRepos(ws) {
192
+ const problems = [];
193
+ for (const p of zList(agentGit(ws, ["ls-files", "-o", "--exclude-standard", "-z"])).filter((e) => e.endsWith("/"))) {
194
+ problems.push(`it contains a nested git repository at ${p.slice(0, -1)}; remove it, or add it to .gitignore if it must stay`);
195
+ }
196
+ const allowed = new Set(gitlinks(agentGit(ws, ["ls-tree", "-r", "-z", ws.base])));
197
+ for (const p of gitlinks(agentGit(ws, ["ls-files", "-s", "-z"]))) {
198
+ if (!allowed.has(p))
199
+ problems.push(`its index has a submodule entry ${p} that the snapshot it started from does not have`);
200
+ else if (fs.existsSync(path.join(ws.root, p, ".git")))
201
+ problems.push(`a git repository was placed at the submodule path ${p}`);
202
+ }
203
+ if (problems.length)
204
+ throw new Error(`Refusing to run git in the worktree ${ws.root}: ${problems.join("; ")}. Nothing in it was touched.`);
205
+ }
206
+ const gitAt = (at, args, env, input) => ("ws" in at ? agentGit(at.ws, args, env, input) : runGit(at.top, args, env, input));
207
+ const tryGitAt = (at, args, env) => ("ws" in at ? tryAgentGit(at.ws, args, env) : tryGit(at.top, args, env));
208
+ const workTreeOf = (at) => ("ws" in at ? at.ws.root : at.top);
209
+ /** Paths in `git status --porcelain -z` output. */
210
+ function parseStatus(out) {
45
211
  const parts = out.split("\0").filter(Boolean);
46
212
  const paths = [];
47
213
  for (let i = 0; i < parts.length; i++) {
@@ -52,13 +218,37 @@ export function dirtyPaths(dir) {
52
218
  }
53
219
  return paths;
54
220
  }
221
+ export function isGitRepo(dir) {
222
+ return tryGit(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
223
+ }
224
+ /** The main repository's top-level directory for `dir`. */
225
+ export const toplevel = (dir) => git(dir, ["rev-parse", "--show-toplevel"]);
226
+ /** Files in the repo with changes nobody committed (modified, staged or new and not ignored). Paths are relative to the repo root. */
227
+ export function dirtyPaths(dir) {
228
+ return parseStatus(execFileSync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }));
229
+ }
55
230
  /** Refuse to start a worktree run when it cannot be isolated; returns nothing when it is fine. */
56
231
  export function assertWorktreeRunnable(project, opts) {
57
232
  if (!isGitRepo(project.dir)) {
58
233
  throw new Error(`Parallel runs (worktree workspaces) need ${project.dir} to be a git repository. Run sequentially instead: dispatcher.max_parallel: 1.`);
59
234
  }
235
+ // With git's reftable ref storage a linked worktree's HEAD lives in `<gitDir>/reftable/`, a place the agent can write, and the
236
+ // file `<gitDir>/HEAD` only says `ref: refs/heads/.invalid`: the check that a worktree's HEAD is its own branch cannot hold
237
+ // there, so every worktree would be refused in the middle of the run. Say so up front instead. (`rev-parse --show-ref-format`
238
+ // does not exist in older git, which only knows `files`.)
239
+ if (tryGit(project.dir, ["rev-parse", "--show-ref-format"]) === "reftable") {
240
+ throw new Error(`${project.dir} uses git's reftable ref storage, whose worktrees agent-lyceum cannot yet verify as isolated, so parallel agents and concurrent runs are not supported there. ` +
241
+ `Use a repository with the default "files" ref storage, or run one agent at a time without worktrees: dispatcher.max_parallel: 1 and dispatcher.max_concurrent_runs: 1.`);
242
+ }
60
243
  if (!opts.fresh)
61
244
  return;
245
+ if (opts.concurrent) {
246
+ // A concurrent run starts from HEAD and never touches the working tree, so uncommitted changes are fine.
247
+ if (tryGit(project.dir, ["rev-parse", "--verify", "-q", "HEAD"]) === undefined) {
248
+ throw new Error(`Concurrent runs start from HEAD, but ${project.dir} has no commit yet. Make an initial commit, or set dispatcher.max_concurrent_runs: 1.`);
249
+ }
250
+ return;
251
+ }
62
252
  const dirty = dirtyPaths(project.dir);
63
253
  if (dirty.length) {
64
254
  const shown = dirty.slice(0, 5).join(", ") + (dirty.length > 5 ? `, ... (${dirty.length} in all)` : "");
@@ -66,24 +256,35 @@ export function assertWorktreeRunnable(project, opts) {
66
256
  `Commit or stash them first (agent-lyceum never touches your own edits), or run sequentially with dispatcher.max_parallel: 1.`);
67
257
  }
68
258
  }
259
+ /** `refs/agent-lyceum/<run>/base-0` pointing at the repo's HEAD: where a concurrent run starts. Nothing in the repo changes. */
260
+ export function snapshotHead(project, runId) {
261
+ const top = toplevel(project.dir);
262
+ const sha = git(top, ["rev-parse", "--verify", "HEAD^{commit}"]);
263
+ const ref = `refs/agent-lyceum/${runId}/base-0`;
264
+ git(top, ["update-ref", ref, sha]);
265
+ return ref;
266
+ }
69
267
  /**
70
268
  * Commit the repo's current working tree (tracked changes and new, non-ignored files) as an unreferenced-by-branch
71
269
  * snapshot `refs/agent-lyceum/<run>/base-<n>`. Uses a throwaway index, so HEAD, branches, the real index and the
72
270
  * working tree are not touched. Returns the ref.
271
+ * `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
73
272
  */
74
- export function snapshotBase(project, runId, n) {
75
- const top = toplevel(project.dir);
273
+ export function snapshotBase(project, runId, n, from) {
274
+ const at = from ? { ws: from } : { top: toplevel(project.dir) };
76
275
  const index = path.join(os.tmpdir(), `agent-lyceum-index-${crypto.randomBytes(6).toString("hex")}`);
77
276
  const env = { ...IDENTITY, GIT_INDEX_FILE: index };
78
277
  try {
79
- const head = tryGit(top, ["rev-parse", "--verify", "HEAD"]);
278
+ if (from)
279
+ assertNoNestedRepos(from);
280
+ const head = tryGitAt(at, ["rev-parse", "--verify", "HEAD"]);
80
281
  if (head)
81
- git(top, ["read-tree", head], env);
82
- git(top, ["add", "-A"], env);
83
- const tree = git(top, ["write-tree"], env);
84
- const commit = git(top, ["commit-tree", tree, ...(head ? ["-p", head] : []), "-m", `agent-lyceum snapshot ${runId} #${n}`], env);
282
+ gitAt(at, ["read-tree", head], env);
283
+ gitAt(at, ["add", "-A"], env);
284
+ const tree = gitAt(at, ["write-tree"], env);
285
+ const commit = gitAt(at, ["commit-tree", tree, ...(head ? ["-p", head] : []), "-m", `agent-lyceum snapshot ${runId} #${n}`], env);
85
286
  const ref = `refs/agent-lyceum/${runId}/base-${n}`;
86
- git(top, ["update-ref", ref, commit]);
287
+ gitAt(at, ["update-ref", ref, commit]);
87
288
  return ref;
88
289
  }
89
290
  finally {
@@ -91,15 +292,63 @@ export function snapshotBase(project, runId, n) {
91
292
  }
92
293
  }
93
294
  const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
295
+ /** The user's repo's git common dir, as a real absolute path. Asked of the user's repo, never of a worktree. */
296
+ const commonDirOf = (top) => fs.realpathSync(git(top, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
297
+ /** The admin dirs under `<commonDir>/worktrees/` whose `gitdir` file names `<root>/.git` (the file may hold a relative path). */
298
+ function adminDirsNaming(commonDir, root, names) {
299
+ const admin = path.join(commonDir, "worktrees");
300
+ let entries;
301
+ try {
302
+ entries = names ?? fs.readdirSync(admin);
303
+ }
304
+ catch {
305
+ entries = [];
306
+ }
307
+ const want = path.join(root, ".git");
308
+ return entries
309
+ .map((n) => path.join(admin, n))
310
+ .filter((d) => {
311
+ try {
312
+ return samePath(path.resolve(d, fs.readFileSync(path.join(d, "gitdir"), "utf8").trim()), want);
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ });
318
+ }
319
+ function writeMeta(root, meta) {
320
+ atomicWrite(`${root}.json`, JSON.stringify(meta, null, 2));
321
+ }
322
+ /**
323
+ * The meta of a worktree made by an older version lacks `gitDir`/`commonDir`: take the common dir from the user's repo and the git
324
+ * dir from the one admin dir that names this worktree. Anything but exactly one is refused rather than guessed (an agent can
325
+ * rewrite its own admin dir's `gitdir` to claim another worktree). The result is written back to the meta.
326
+ */
327
+ function completeLegacyMeta(top, root, meta) {
328
+ if (meta.gitDir && meta.commonDir)
329
+ return meta;
330
+ const commonDir = commonDirOf(top);
331
+ const found = adminDirsNaming(commonDir, root);
332
+ if (found.length !== 1) {
333
+ throw new Error(`Cannot tell which git dir belongs to the worktree ${root}: its metadata predates recording it, and ${found.length} git dirs under ${path.join(commonDir, "worktrees")} name it${found.length ? ` (${found.join(", ")})` : ""}. ` +
334
+ `Refusing to guess. Inspect it by hand, or remove it with \`git worktree remove\`.`);
335
+ }
336
+ const done = { ...meta, gitDir: found[0], commonDir };
337
+ writeMeta(root, done);
338
+ return done;
339
+ }
94
340
  function describeWorkspace(project, root, agent, meta) {
95
341
  const top = toplevel(project.dir);
342
+ const full = completeLegacyMeta(top, root, meta);
96
343
  // `top` is a real path (git resolves symlinks, e.g. /var -> /private/var on macOS), so compare against the real project dir.
97
344
  const rel = path.relative(top, fs.realpathSync(project.dir));
98
345
  return {
99
346
  agent,
100
347
  root,
101
348
  dir: rel ? path.join(root, rel) : root,
102
- gitDir: git(root, ["rev-parse", "--absolute-git-dir"]),
349
+ gitDir: full.gitDir,
350
+ commonDir: full.commonDir,
351
+ ...(full.worktreeConfig ? { worktreeConfig: full.worktreeConfig } : {}),
103
352
  branch: meta.branch,
104
353
  base: meta.base,
105
354
  };
@@ -122,23 +371,43 @@ export function prepareAgentWorkspace(project, runId, agent, baseRef) {
122
371
  meta = undefined;
123
372
  }
124
373
  if (meta && fs.existsSync(root)) {
125
- if (meta.base !== newBase) {
126
- const untouched = git(root, ["status", "--porcelain", "--untracked-files=all"]) === "" && git(root, ["rev-parse", "HEAD"]) === meta.base;
374
+ const ws = describeWorkspace(project, root, agent, meta);
375
+ if (ws.base !== newBase) {
376
+ assertNoNestedRepos(ws);
377
+ const untouched = agentGit(ws, ["status", "--porcelain", "--untracked-files=all"]) === "" && agentGit(ws, ["rev-parse", "HEAD"]) === ws.base;
127
378
  if (untouched) {
128
- git(root, ["reset", "-q", "--hard", newBase]);
129
- meta = { ...meta, base: newBase };
130
- atomicWrite(metaFile, JSON.stringify(meta, null, 2));
379
+ agentGit(ws, ["reset", "-q", "--hard", newBase]);
380
+ writeMeta(root, metaOf({ ...ws, base: newBase }));
381
+ return { ...ws, base: newBase };
131
382
  }
132
383
  }
133
- return describeWorkspace(project, root, agent, meta);
384
+ return ws;
134
385
  }
135
386
  const branch = `agent-lyceum/${runId}/${agent}`;
136
387
  fs.mkdirSync(path.dirname(root), { recursive: true });
388
+ // The new worktree's git dir is the admin dir `worktree add` creates: the one under <commonDir>/worktrees/ that was not there
389
+ // before and whose `gitdir` names this root. Only the user's repo and what git just wrote are read — no agent has run in it yet,
390
+ // and another dispatcher adding a worktree at the same moment cannot be mistaken for it.
391
+ const commonDir = commonDirOf(top);
392
+ const adminDir = path.join(commonDir, "worktrees");
393
+ const before = new Set(fs.existsSync(adminDir) ? fs.readdirSync(adminDir) : []);
137
394
  git(top, ["worktree", "add", "-q", "-B", branch, root, newBase]);
138
- meta = { branch, base: newBase };
139
- atomicWrite(metaFile, JSON.stringify(meta, null, 2));
395
+ const created = adminDirsNaming(commonDir, root, fs.readdirSync(adminDir).filter((n) => !before.has(n)));
396
+ if (created.length !== 1)
397
+ throw new Error(`git worktree add made ${root}, but ${created.length} new git dirs under ${adminDir} name it; refusing to guess which is its own.`);
398
+ const gitDir = created[0];
399
+ const worktreeConfig = fingerprint(path.join(gitDir, "config.worktree"));
400
+ meta = { branch, base: newBase, gitDir, commonDir, ...(worktreeConfig ? { worktreeConfig } : {}) };
401
+ writeMeta(root, meta);
140
402
  return describeWorkspace(project, root, agent, meta);
141
403
  }
404
+ const metaOf = (ws) => ({
405
+ branch: ws.branch,
406
+ base: ws.base,
407
+ gitDir: ws.gitDir,
408
+ commonDir: ws.commonDir,
409
+ ...(ws.worktreeConfig ? { worktreeConfig: ws.worktreeConfig } : {}),
410
+ });
142
411
  /** Remove an agent's worktree, its branch and its metadata. The caller decides whether the work in it may be discarded. */
143
412
  export function removeAgentWorkspace(project, runId, agent) {
144
413
  const top = toplevel(project.dir);
@@ -192,21 +461,27 @@ export function globMatch(glob, file) {
192
461
  return new RegExp(`^${re}$`).test(file);
193
462
  }
194
463
  const PROTECTED_NAMES = ["CLAUDE.md", "AGENTS.md"];
464
+ /** Commit whatever is in an agent's worktree (no hooks, no signing, dispatcher identity). Returns the HEAD afterwards and whether a commit was made. */
465
+ export function commitWorkspace(ws, message) {
466
+ assertNoNestedRepos(ws);
467
+ agentGit(ws, ["add", "-A"]);
468
+ let committed = false;
469
+ if (tryAgentGit(ws, ["diff", "--cached", "--quiet"]) === undefined) {
470
+ // agentGit already switches hooks and signing off; --no-verify says so here too
471
+ agentGit(ws, ["commit", "-q", "--no-verify", "-m", message], IDENTITY);
472
+ committed = true;
473
+ }
474
+ return { head: agentGit(ws, ["rev-parse", "HEAD"]), committed };
475
+ }
195
476
  /**
196
477
  * Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
197
478
  * changed path (modified, added, deleted, or either end of a rename) against the agent's `owns`, the project
198
479
  * folder, the protected instruction files, and symlink/submodule rules. Nothing is applied here.
199
480
  */
200
481
  export function collectAgentChanges(workspace, owns) {
201
- const { root } = workspace;
202
- git(root, ["add", "-A"]);
203
- if (tryGit(root, ["diff", "--cached", "--quiet"]) === undefined) {
204
- // hooks belong to the user's repo (a worktree shares them): do not run them for the dispatcher's own commit
205
- git(root, ["-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "-m", `agent-lyceum: changes by ${workspace.agent}`], IDENTITY);
206
- }
207
- const head = git(root, ["rev-parse", "HEAD"]);
482
+ const { head } = commitWorkspace(workspace, `agent-lyceum: changes by ${workspace.agent}`);
208
483
  const base = workspace.base;
209
- const raw = base === head ? "" : execFileSync("git", ["diff", "--raw", "-z", "--no-renames", "--no-abbrev", base, head], { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
484
+ const raw = base === head ? "" : agentGit(workspace, ["diff", "--raw", "-z", "--no-renames", "--no-abbrev", base, head]);
210
485
  const parts = raw.split("\0").filter(Boolean);
211
486
  const files = [];
212
487
  for (let i = 0; i + 1 < parts.length; i += 2) {
@@ -232,7 +507,7 @@ export function collectAgentChanges(workspace, owns) {
232
507
  if (f.mode === "160000")
233
508
  violations.push(`${f.path}: submodule changes are not integrated`);
234
509
  if (f.mode === "120000" && f.status !== "D") {
235
- const target = git(root, ["show", `${head}:${f.path}`]);
510
+ const target = agentGit(workspace, ["show", `${head}:${f.path}`]);
236
511
  const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(p), target));
237
512
  if (path.posix.isAbsolute(target))
238
513
  violations.push(`${f.path}: symlink to an absolute path (${target})`);
@@ -244,12 +519,12 @@ export function collectAgentChanges(workspace, owns) {
244
519
  }
245
520
  return { workspace, base, head, files, violations };
246
521
  }
247
- function blobAt(top, commit, file) {
248
- return tryGit(top, ["rev-parse", "--verify", "-q", `${commit}:${file}`]) ?? "absent";
522
+ function blobAt(at, commit, file) {
523
+ return tryGitAt(at, ["rev-parse", "--verify", "-q", `${commit}:${file}`]) ?? "absent";
249
524
  }
250
- /** Git's blob id of what is in the main working tree at `file`, or "absent". */
251
- function workingBlob(top, file) {
252
- const abs = path.join(top, file);
525
+ /** Git's blob id of what is in the working tree at `file`, or "absent". */
526
+ function workingBlob(at, file) {
527
+ const abs = path.join(workTreeOf(at), file);
253
528
  let st;
254
529
  try {
255
530
  st = fs.lstatSync(abs);
@@ -260,7 +535,7 @@ function workingBlob(top, file) {
260
535
  if (st.isDirectory())
261
536
  return "directory";
262
537
  const input = st.isSymbolicLink() ? fs.readlinkSync(abs) : fs.readFileSync(abs);
263
- return execFileSync("git", ["hash-object", "--stdin"], { cwd: top, input, encoding: "utf8" }).trim();
538
+ return gitAt(at, ["hash-object", "--stdin"], {}, input).trim();
264
539
  }
265
540
  function writeBlockedReport(project, changes, reason, patch) {
266
541
  const dir = path.join(project.run?.dir ?? project.paths.root, "integration");
@@ -284,20 +559,27 @@ function writeBlockedReport(project, changes, reason, patch) {
284
559
  ].join("\n"));
285
560
  return { report: `${base}.md`, patch: `${base}.patch` };
286
561
  }
562
+ /** A member's changes that could not even be collected (e.g. its worktree was refused): reported like any blocked integration, with nothing applied. */
563
+ export function refusedIntegration(project, workspace, reason) {
564
+ const changes = { workspace, base: workspace.base, head: workspace.base, files: [], violations: [] };
565
+ return { status: "blocked", reason, branch: workspace.branch, workspace: workspace.root, ...writeBlockedReport(project, changes, reason, "") };
566
+ }
287
567
  function advanceBase(workspace, newBase) {
288
- const metaFile = `${workspace.root}.json`;
289
- atomicWrite(metaFile, JSON.stringify({ branch: workspace.branch, base: newBase }, null, 2));
568
+ writeMeta(workspace.root, metaOf({ ...workspace, base: newBase }));
290
569
  }
291
570
  /**
292
571
  * Bring an agent's collected changes into the main working tree (not the index, not HEAD), all or nothing.
293
572
  * Blocked, with nothing applied and the agent's work kept, when the changes break the rules, when a file
294
573
  * changed in the repo since the agent's snapshot, or when git cannot apply the patch. Repeating a call whose
295
574
  * changes are already in place is harmless.
575
+ * `target`: the checkout to bring the changes into — the lead's worktree in a concurrent run; by default your repo's working tree.
296
576
  */
297
- export function integrateAgentChanges(project, changes) {
298
- const top = toplevel(project.dir);
577
+ export function integrateAgentChanges(project, changes, target) {
578
+ const at = target ? { ws: target } : { top: toplevel(project.dir) };
579
+ if (target)
580
+ assertNoNestedRepos(target); // `apply` does not walk the tree, but a tampered lead worktree is not written to either
299
581
  const { workspace, base, head } = changes;
300
- const fullPatch = (paths) => paths.length === 0 ? "" : git(top, ["--literal-pathspecs", "diff", "--binary", "--full-index", base, head, "--", ...paths]) + "\n";
582
+ const fullPatch = (paths) => paths.length === 0 ? "" : gitAt(at, ["--literal-pathspecs", "diff", "--binary", "--full-index", base, head, "--", ...paths]) + "\n";
301
583
  const block = (reason, paths = changes.files.map((f) => f.path)) => ({
302
584
  status: "blocked",
303
585
  reason,
@@ -312,10 +594,10 @@ export function integrateAgentChanges(project, changes) {
312
594
  const pending = [];
313
595
  const conflicts = [];
314
596
  for (const f of changes.files) {
315
- const work = workingBlob(top, f.path);
316
- if (work === blobAt(top, head, f.path))
597
+ const work = workingBlob(at, f.path);
598
+ if (work === blobAt(at, head, f.path))
317
599
  continue; // already in place
318
- if (work === blobAt(top, base, f.path))
600
+ if (work === blobAt(at, base, f.path))
319
601
  pending.push(f);
320
602
  else
321
603
  conflicts.push(f.path);
@@ -326,7 +608,7 @@ export function integrateAgentChanges(project, changes) {
326
608
  if (pending.length) {
327
609
  const patch = fullPatch(pending.map((f) => f.path));
328
610
  try {
329
- execFileSync("git", ["apply", "--binary", "--whitespace=nowarn", "-"], { cwd: top, input: patch, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
611
+ gitAt(at, ["apply", "--binary", "--whitespace=nowarn", "-"], {}, patch);
330
612
  }
331
613
  catch (e) {
332
614
  const err = e;
@@ -336,37 +618,57 @@ export function integrateAgentChanges(project, changes) {
336
618
  advanceBase(workspace, head);
337
619
  return { status: "integrated", files: changes.files, applied: pending.length };
338
620
  }
339
- /** An agent's existing worktree for this run, if it has one. */
621
+ /**
622
+ * An agent's existing worktree for this run, if it has one (no worktree or no metadata: undefined).
623
+ * Throws when the metadata predates recording the git dir and the git dir cannot be told for certain.
624
+ */
340
625
  export function existingAgentWorkspace(project, runId, agent) {
341
626
  const root = workspaceRoot(project, runId, agent);
627
+ let meta;
342
628
  try {
343
- const meta = JSON.parse(fs.readFileSync(`${root}.json`, "utf8"));
344
- return fs.existsSync(root) ? describeWorkspace(project, root, agent, meta) : undefined;
629
+ meta = JSON.parse(fs.readFileSync(`${root}.json`, "utf8"));
345
630
  }
346
631
  catch {
347
632
  return undefined;
348
633
  }
634
+ return fs.existsSync(root) ? describeWorkspace(project, root, agent, meta) : undefined;
349
635
  }
350
636
  /** What is in an agent's worktree that exists nowhere else: uncommitted changes, and commits whose files are not in the main repo. */
351
- export function inspectWorkspace(project, runId, agent) {
637
+ export function inspectWorkspace(project, runId, agent, target) {
352
638
  const root = workspaceRoot(project, runId, agent);
353
639
  if (!fs.existsSync(root))
354
640
  return undefined;
355
- const ws = existingAgentWorkspace(project, runId, agent);
356
- const branch = ws?.branch ?? `agent-lyceum/${runId}/${agent}`;
641
+ let ws;
642
+ let wsError;
643
+ try {
644
+ ws = existingAgentWorkspace(project, runId, agent);
645
+ }
646
+ catch (e) {
647
+ wsError = e.message.split("\n")[0];
648
+ }
649
+ let branch = `agent-lyceum/${runId}/${agent}`;
650
+ try {
651
+ branch = ws?.branch ?? JSON.parse(fs.readFileSync(`${root}.json`, "utf8")).branch ?? branch;
652
+ }
653
+ catch {
654
+ /* no metadata */
655
+ }
357
656
  const work = { agent, root, branch, dirty: [], unintegrated: [] };
657
+ if (wsError)
658
+ return { ...work, unknown: `its git dir cannot be determined (${wsError})` };
358
659
  if (!ws)
359
660
  return { ...work, unknown: "its metadata is missing, so what it holds cannot be compared with the repo" };
360
661
  try {
361
- work.dirty = dirtyPaths(root);
362
- const top = toplevel(project.dir);
363
- const head = git(root, ["rev-parse", "HEAD"]);
364
- if (head !== ws.base && tryGit(top, ["merge-base", "--is-ancestor", head, "HEAD"]) === undefined) {
365
- const raw = execFileSync("git", ["diff", "--raw", "-z", "--no-renames", "--no-abbrev", ws.base, head], { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
662
+ assertNoNestedRepos(ws);
663
+ work.dirty = parseStatus(agentGit(ws, ["status", "--porcelain", "-z", "--untracked-files=all"]));
664
+ const at = target ? { ws: target } : { top: toplevel(project.dir) };
665
+ const head = agentGit(ws, ["rev-parse", "HEAD"]);
666
+ if (head !== ws.base && tryGitAt(at, ["merge-base", "--is-ancestor", head, "HEAD"]) === undefined) {
667
+ const raw = agentGit(ws, ["diff", "--raw", "-z", "--no-renames", "--no-abbrev", ws.base, head]);
366
668
  const parts = raw.split("\0").filter(Boolean);
367
669
  for (let i = 1; i < parts.length; i += 2) {
368
670
  const file = parts[i] ?? "";
369
- if (workingBlob(top, file) !== blobAt(top, head, file))
671
+ if (workingBlob(at, file) !== blobAt(at, head, file))
370
672
  work.unintegrated.push(file);
371
673
  }
372
674
  }
@@ -376,6 +678,14 @@ export function inspectWorkspace(project, runId, agent) {
376
678
  }
377
679
  return work;
378
680
  }
681
+ /** Commit the lead's checkout on its branch (once, at the end of a run) and write what changed as a patch. Never touches your repo's working tree. */
682
+ export function finishLeadBranch(ws, message, patchFile) {
683
+ const { head } = commitWorkspace(ws, message);
684
+ const files = head === ws.base ? [] : agentGit(ws, ["diff", "--name-only", ws.base, head]).split("\n").filter(Boolean);
685
+ if (files.length)
686
+ fs.writeFileSync(patchFile, agentGit(ws, ["diff", "--binary", "--full-index", ws.base, head]) + "\n");
687
+ return { branch: ws.branch, head, base: ws.base, files: files.length, patch: files.length ? patchFile : undefined };
688
+ }
379
689
  /** Names of the agents that have a worktree for this run. */
380
690
  export function listWorkspaceAgents(project, runId) {
381
691
  const dir = path.join(project.paths.root, "worktrees", runId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",