agent-lyceum 0.17.2 → 0.18.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,16 +4,6 @@ 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; several concurrent runs always do. */
8
- export function resolveWorkspaceMode(d) {
9
- if ((d.max_concurrent_runs ?? 1) > 1)
10
- return "worktree";
11
- if (d.workspace_mode === "worktree")
12
- return "worktree";
13
- if (d.workspace_mode === "shared")
14
- return "shared";
15
- return d.max_parallel > 1 ? "worktree" : "shared";
16
- }
17
7
  const IDENTITY = {
18
8
  GIT_AUTHOR_NAME: "agent-lyceum",
19
9
  GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
@@ -227,33 +217,42 @@ export const toplevel = (dir) => git(dir, ["rev-parse", "--show-toplevel"]);
227
217
  export function dirtyPaths(dir) {
228
218
  return parseStatus(execFileSync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }));
229
219
  }
230
- /** Refuse to start a worktree run when it cannot be isolated; returns nothing when it is fine. */
231
- export function assertWorktreeRunnable(project, opts) {
232
- if (!isGitRepo(project.dir)) {
233
- throw new Error(`Parallel runs (worktree workspaces) need ${project.dir} to be a git repository. Run sequentially instead: dispatcher.max_parallel: 1.`);
234
- }
220
+ /** Why `dir` cannot give every run and agent its own git worktree, or undefined when it can. */
221
+ export function isolationBlocker(dir) {
222
+ if (!isGitRepo(dir))
223
+ return "not a git repository";
224
+ if (tryGit(dir, ["rev-parse", "--verify", "-q", "HEAD"]) === undefined)
225
+ return "no commit yet";
235
226
  // With git's reftable ref storage a linked worktree's HEAD lives in `<gitDir>/reftable/`, a place the agent can write, and the
236
227
  // 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
- }
243
- if (!opts.fresh)
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;
228
+ // there, so every worktree would be refused in the middle of the run. (`rev-parse --show-ref-format` does not exist in older
229
+ // git, which only knows `files`.)
230
+ if (tryGit(dir, ["rev-parse", "--show-ref-format"]) === "reftable")
231
+ return "reftable ref storage";
232
+ return undefined;
233
+ }
234
+ /**
235
+ * Where a run's agents work. A new run is isolated when the project dir can be (see `isolationBlocker`), otherwise it shares the
236
+ * checkout. A resumed run keeps what its state recorded, whatever the repo looks like now. Decided once, before the run's lock is taken.
237
+ */
238
+ export function planWorkspace(project, resume) {
239
+ if (resume) {
240
+ if (resume.lead_workspace === true)
241
+ return { mode: "worktree", leadWorktree: true };
242
+ if (resume.workspace_mode === "worktree")
243
+ return { mode: "worktree", leadWorktree: false };
244
+ return { mode: "shared", leadWorktree: false };
245
+ }
246
+ const reason = isolationBlocker(project.dir);
247
+ return reason === undefined ? { mode: "worktree", leadWorktree: true } : { mode: "shared", leadWorktree: false, reason };
248
+ }
249
+ /** Refuse to resume a worktree run when its repo can no longer be isolated; returns nothing when it is fine. */
250
+ export function assertWorktreeRunnable(project) {
251
+ if (!isGitRepo(project.dir)) {
252
+ throw new Error(`This run works in git worktrees, but ${project.dir} is no longer a git repository.`);
251
253
  }
252
- const dirty = dirtyPaths(project.dir);
253
- if (dirty.length) {
254
- const shown = dirty.slice(0, 5).join(", ") + (dirty.length > 5 ? `, ... (${dirty.length} in all)` : "");
255
- throw new Error(`Parallel runs start from a clean repo, but ${project.dir} has uncommitted changes: ${shown}. ` +
256
- `Commit or stash them first (agent-lyceum never touches your own edits), or run sequentially with dispatcher.max_parallel: 1.`);
254
+ if (tryGit(project.dir, ["rev-parse", "--show-ref-format"]) === "reftable") {
255
+ throw new Error(`This run works in git worktrees, but ${project.dir} now uses git's reftable ref storage, whose worktrees agent-lyceum cannot yet verify as isolated.`);
257
256
  }
258
257
  }
259
258
  /** `refs/agent-lyceum/<run>/base-0` pointing at the repo's HEAD: where a concurrent run starts. Nothing in the repo changes. */
@@ -427,39 +426,6 @@ export function removeAgentWorkspace(project, runId, agent) {
427
426
  tryGit(top, ["branch", "-D", branch]);
428
427
  fs.rmSync(`${root}.json`, { force: true });
429
428
  }
430
- /** Glob as used in `owns`: `**` crosses folders, `*` and `?` stay within one, `{a,b}` alternates. */
431
- export function globMatch(glob, file) {
432
- let re = "";
433
- for (let i = 0; i < glob.length; i++) {
434
- const c = glob.charAt(i);
435
- if (c === "*" && glob[i + 1] === "*") {
436
- if (glob[i + 2] === "/") {
437
- re += "(?:.*/)?";
438
- i += 2;
439
- }
440
- else {
441
- re += ".*";
442
- i += 1;
443
- }
444
- }
445
- else if (c === "*")
446
- re += "[^/]*";
447
- else if (c === "?")
448
- re += "[^/]";
449
- else if (c === "{") {
450
- const end = glob.indexOf("}", i);
451
- if (end < 0)
452
- re += "\\{";
453
- else {
454
- re += `(?:${glob.slice(i + 1, end).split(",").map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`;
455
- i = end;
456
- }
457
- }
458
- else
459
- re += c.replace(/[.+^$()|[\]\\}]/g, "\\$&");
460
- }
461
- return new RegExp(`^${re}$`).test(file);
462
- }
463
429
  const PROTECTED_NAMES = ["CLAUDE.md", "AGENTS.md"];
464
430
  /** 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
431
  export function commitWorkspace(ws, message) {
@@ -475,10 +441,10 @@ export function commitWorkspace(ws, message) {
475
441
  }
476
442
  /**
477
443
  * Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
478
- * changed path (modified, added, deleted, or either end of a rename) against the agent's `owns`, the project
444
+ * changed path (modified, added, deleted, or either end of a rename) against the project
479
445
  * folder, the protected instruction files, and symlink/submodule rules. Nothing is applied here.
480
446
  */
481
- export function collectAgentChanges(workspace, owns) {
447
+ export function collectAgentChanges(workspace) {
482
448
  const { head } = commitWorkspace(workspace, `agent-lyceum: changes by ${workspace.agent}`);
483
449
  const base = workspace.base;
484
450
  const raw = base === head ? "" : agentGit(workspace, ["diff", "--raw", "-z", "--no-renames", "--no-abbrev", base, head]);
@@ -501,9 +467,6 @@ export function collectAgentChanges(workspace, owns) {
501
467
  const p = rel === "" ? f.path : path.posix.relative(rel, f.path);
502
468
  if (PROTECTED_NAMES.includes(p))
503
469
  violations.push(`${f.path}: protected instruction file; only the lead may change it, by hand`);
504
- const allowed = (candidate) => owns.length === 0 || owns.some((g) => globMatch(g, candidate));
505
- if (!allowed(p))
506
- violations.push(`${f.path}: outside this agent's owns (${owns.join(", ")})`);
507
470
  if (f.mode === "160000")
508
471
  violations.push(`${f.path}: submodule changes are not integrated`);
509
472
  if (f.mode === "120000" && f.status !== "D") {
@@ -513,8 +476,6 @@ export function collectAgentChanges(workspace, owns) {
513
476
  violations.push(`${f.path}: symlink to an absolute path (${target})`);
514
477
  else if (resolved.startsWith(".."))
515
478
  violations.push(`${f.path}: symlink leaves the project directory (${target})`);
516
- else if (!allowed(resolved))
517
- violations.push(`${f.path}: symlink points outside this agent's owns (${target})`);
518
479
  }
519
480
  }
520
481
  return { workspace, base, head, files, violations };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.17.2",
3
+ "version": "0.18.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",