agent-lyceum 0.11.2 → 0.12.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/README.md +29 -4
- package/README.zh-TW.md +29 -4
- package/dist/ask-answer.js +4 -3
- package/dist/ask-reply.d.ts +2 -0
- package/dist/ask-reply.js +13 -0
- package/dist/cli.js +62 -22
- package/dist/config.d.ts +1 -0
- package/dist/doctor.js +32 -6
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +8 -1
- package/dist/open-pr.d.ts +47 -0
- package/dist/open-pr.js +71 -0
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +4 -2
- package/dist/project-lock.d.ts +24 -7
- package/dist/project-lock.js +178 -80
- package/dist/prompt.js +4 -2
- package/dist/run-cleanup.js +6 -3
- package/dist/run-session.d.ts +27 -0
- package/dist/run-session.js +163 -20
- package/dist/run-store.d.ts +7 -0
- package/dist/scaffold.js +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +2 -0
- package/dist/status.d.ts +1 -1
- package/dist/status.js +4 -4
- package/dist/validate.js +10 -3
- package/dist/worktree.d.ts +74 -7
- package/dist/worktree.js +372 -62
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
278
|
+
if (from)
|
|
279
|
+
assertNoNestedRepos(from);
|
|
280
|
+
const head = tryGitAt(at, ["rev-parse", "--verify", "HEAD"]);
|
|
80
281
|
if (head)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const tree =
|
|
84
|
-
const commit =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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 {
|
|
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 ? "" :
|
|
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 =
|
|
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(
|
|
248
|
-
return
|
|
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
|
|
251
|
-
function workingBlob(
|
|
252
|
-
const abs = path.join(
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ? "" :
|
|
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(
|
|
316
|
-
if (work === blobAt(
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
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(
|
|
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);
|