agent-lyceum 0.4.9
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/LICENSE +21 -0
- package/README.md +132 -0
- package/README.zh-TW.md +133 -0
- package/dist/adapters/claude.d.ts +14 -0
- package/dist/adapters/claude.js +155 -0
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +109 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/types.d.ts +61 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +471 -0
- package/dist/config.d.ts +115 -0
- package/dist/config.js +253 -0
- package/dist/dispatcher.d.ts +37 -0
- package/dist/dispatcher.js +467 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +151 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +100 -0
- package/dist/fs-util.d.ts +2 -0
- package/dist/fs-util.js +9 -0
- package/dist/guard.d.ts +20 -0
- package/dist/guard.js +72 -0
- package/dist/mailbox.d.ts +97 -0
- package/dist/mailbox.js +259 -0
- package/dist/message-store.d.ts +91 -0
- package/dist/message-store.js +225 -0
- package/dist/paths.d.ts +30 -0
- package/dist/paths.js +62 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +143 -0
- package/dist/process-runner.d.ts +21 -0
- package/dist/process-runner.js +165 -0
- package/dist/project-lock.d.ts +30 -0
- package/dist/project-lock.js +186 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +125 -0
- package/dist/run-cleanup.d.ts +51 -0
- package/dist/run-cleanup.js +120 -0
- package/dist/run-store.d.ts +111 -0
- package/dist/run-store.js +119 -0
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +126 -0
- package/dist/schema.d.ts +303 -0
- package/dist/schema.js +84 -0
- package/dist/status.d.ts +142 -0
- package/dist/status.js +399 -0
- package/dist/task.d.ts +24 -0
- package/dist/task.js +71 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +116 -0
- package/dist/worktree.d.ts +103 -0
- package/dist/worktree.js +395 -0
- package/package.json +58 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
import type { DispatcherSettings } from "./schema.js";
|
|
3
|
+
export type WorkspaceMode = "shared" | "worktree";
|
|
4
|
+
/** 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. */
|
|
5
|
+
export declare function resolveWorkspaceMode(d: Pick<DispatcherSettings, "workspace_mode" | "max_parallel">): WorkspaceMode;
|
|
6
|
+
export interface AgentWorkspace {
|
|
7
|
+
agent: string;
|
|
8
|
+
/** Root of the git worktree. */
|
|
9
|
+
root: string;
|
|
10
|
+
/** Where the agent works: the worktree's counterpart of the project dir (differs from `root` when the project is a subfolder of the repo). */
|
|
11
|
+
dir: string;
|
|
12
|
+
/** The worktree's private git dir inside the main repo's `.git/worktrees/`. */
|
|
13
|
+
gitDir: string;
|
|
14
|
+
branch: string;
|
|
15
|
+
/** Commit of the snapshot this workspace's changes are measured against. */
|
|
16
|
+
base: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string;
|
|
19
|
+
export declare function isGitRepo(dir: string): boolean;
|
|
20
|
+
/** Files in the repo with changes nobody committed (modified, staged or new and not ignored). Paths are relative to the repo root. */
|
|
21
|
+
export declare function dirtyPaths(dir: string): string[];
|
|
22
|
+
/** Refuse to start a worktree run when it cannot be isolated; returns nothing when it is fine. */
|
|
23
|
+
export declare function assertWorktreeRunnable(project: ResolvedProject, opts: {
|
|
24
|
+
fresh: boolean;
|
|
25
|
+
}): void;
|
|
26
|
+
/**
|
|
27
|
+
* Commit the repo's current working tree (tracked changes and new, non-ignored files) as an unreferenced-by-branch
|
|
28
|
+
* snapshot `refs/agent-lyceum/<run>/base-<n>`. Uses a throwaway index, so HEAD, branches, the real index and the
|
|
29
|
+
* working tree are not touched. Returns the ref.
|
|
30
|
+
*/
|
|
31
|
+
export declare function snapshotBase(project: ResolvedProject, runId: string, n: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
|
|
34
|
+
* An existing checkout is reused: untouched, when it already is on this snapshot (resume) or holds unfinished
|
|
35
|
+
* work; moved to the new snapshot when it is clean and nothing was done in it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function prepareAgentWorkspace(project: ResolvedProject, runId: string, agent: string, baseRef: string): AgentWorkspace;
|
|
38
|
+
/** Remove an agent's worktree, its branch and its metadata. The caller decides whether the work in it may be discarded. */
|
|
39
|
+
export declare function removeAgentWorkspace(project: ResolvedProject, runId: string, agent: string): void;
|
|
40
|
+
export interface ChangedFile {
|
|
41
|
+
/** Relative to the repo root. */
|
|
42
|
+
path: string;
|
|
43
|
+
status: "A" | "M" | "D" | "T";
|
|
44
|
+
/** File mode after the change ("120000" = symlink, "160000" = submodule). */
|
|
45
|
+
mode: string;
|
|
46
|
+
}
|
|
47
|
+
export interface ChangeSet {
|
|
48
|
+
workspace: AgentWorkspace;
|
|
49
|
+
/** The snapshot commit the changes are measured against. */
|
|
50
|
+
base: string;
|
|
51
|
+
/** The agent's branch tip after its changes were committed. */
|
|
52
|
+
head: string;
|
|
53
|
+
files: ChangedFile[];
|
|
54
|
+
/** Why these changes may not be integrated (empty when they may). */
|
|
55
|
+
violations: string[];
|
|
56
|
+
}
|
|
57
|
+
/** Glob as used in `owns`: `**` crosses folders, `*` and `?` stay within one, `{a,b}` alternates. */
|
|
58
|
+
export declare function globMatch(glob: string, file: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
|
|
61
|
+
* changed path (modified, added, deleted, or either end of a rename) against the agent's `owns`, the project
|
|
62
|
+
* folder, the protected instruction files, and symlink/submodule rules. Nothing is applied here.
|
|
63
|
+
*/
|
|
64
|
+
export declare function collectAgentChanges(workspace: AgentWorkspace, owns: string[]): ChangeSet;
|
|
65
|
+
export type IntegrationResult = {
|
|
66
|
+
status: "integrated";
|
|
67
|
+
files: ChangedFile[];
|
|
68
|
+
applied: number;
|
|
69
|
+
} | {
|
|
70
|
+
status: "blocked";
|
|
71
|
+
reason: string;
|
|
72
|
+
branch: string;
|
|
73
|
+
workspace: string;
|
|
74
|
+
report: string;
|
|
75
|
+
patch: string;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Bring an agent's collected changes into the main working tree (not the index, not HEAD), all or nothing.
|
|
79
|
+
* Blocked, with nothing applied and the agent's work kept, when the changes break the rules, when a file
|
|
80
|
+
* changed in the repo since the agent's snapshot, or when git cannot apply the patch. Repeating a call whose
|
|
81
|
+
* changes are already in place is harmless.
|
|
82
|
+
*/
|
|
83
|
+
export declare function integrateAgentChanges(project: ResolvedProject, changes: ChangeSet): IntegrationResult;
|
|
84
|
+
/** An agent's existing worktree for this run, if it has one. */
|
|
85
|
+
export declare function existingAgentWorkspace(project: ResolvedProject, runId: string, agent: string): AgentWorkspace | undefined;
|
|
86
|
+
export interface WorkspaceWork {
|
|
87
|
+
agent: string;
|
|
88
|
+
root: string;
|
|
89
|
+
branch: string;
|
|
90
|
+
/** Uncommitted changes in the worktree (repo-relative paths). */
|
|
91
|
+
dirty: string[];
|
|
92
|
+
/** Files the agent committed that are not in the repo yet. */
|
|
93
|
+
unintegrated: string[];
|
|
94
|
+
/** Set when the state could not be determined; treat as work that must not be deleted. */
|
|
95
|
+
unknown?: string;
|
|
96
|
+
}
|
|
97
|
+
/** What is in an agent's worktree that exists nowhere else: uncommitted changes, and commits whose files are not in the main repo. */
|
|
98
|
+
export declare function inspectWorkspace(project: ResolvedProject, runId: string, agent: string): WorkspaceWork | undefined;
|
|
99
|
+
/** Names of the agents that have a worktree for this run. */
|
|
100
|
+
export declare function listWorkspaceAgents(project: ResolvedProject, runId: string): string[];
|
|
101
|
+
/** The snapshot refs (`refs/agent-lyceum/<run>/...`) of a run. */
|
|
102
|
+
export declare function listSnapshotRefs(project: ResolvedProject, runId: string): string[];
|
|
103
|
+
export declare function deleteSnapshotRefs(project: ResolvedProject, runId: string): void;
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
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. */
|
|
8
|
+
export function resolveWorkspaceMode(d) {
|
|
9
|
+
if (d.workspace_mode === "worktree")
|
|
10
|
+
return "worktree";
|
|
11
|
+
if (d.workspace_mode === "shared")
|
|
12
|
+
return "shared";
|
|
13
|
+
return d.max_parallel > 1 ? "worktree" : "shared";
|
|
14
|
+
}
|
|
15
|
+
const IDENTITY = {
|
|
16
|
+
GIT_AUTHOR_NAME: "agent-lyceum",
|
|
17
|
+
GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
|
|
18
|
+
GIT_COMMITTER_NAME: "agent-lyceum",
|
|
19
|
+
GIT_COMMITTER_EMAIL: "agent-lyceum@localhost",
|
|
20
|
+
};
|
|
21
|
+
export function git(cwd, args, env = {}) {
|
|
22
|
+
return execFileSync("git", args, {
|
|
23
|
+
cwd,
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
env: { ...process.env, ...env },
|
|
28
|
+
}).replace(/\n$/, "");
|
|
29
|
+
}
|
|
30
|
+
function tryGit(cwd, args, env) {
|
|
31
|
+
try {
|
|
32
|
+
return git(cwd, args, env);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function isGitRepo(dir) {
|
|
39
|
+
return tryGit(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
40
|
+
}
|
|
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 });
|
|
45
|
+
const parts = out.split("\0").filter(Boolean);
|
|
46
|
+
const paths = [];
|
|
47
|
+
for (let i = 0; i < parts.length; i++) {
|
|
48
|
+
const entry = parts[i];
|
|
49
|
+
paths.push(entry.slice(3));
|
|
50
|
+
if (entry[0] === "R" || entry[0] === "C")
|
|
51
|
+
i++; // a rename lists its old name next
|
|
52
|
+
}
|
|
53
|
+
return paths;
|
|
54
|
+
}
|
|
55
|
+
/** Refuse to start a worktree run when it cannot be isolated; returns nothing when it is fine. */
|
|
56
|
+
export function assertWorktreeRunnable(project, opts) {
|
|
57
|
+
if (!isGitRepo(project.dir)) {
|
|
58
|
+
throw new Error(`Parallel runs (worktree workspaces) need ${project.dir} to be a git repository. Run sequentially instead: dispatcher.max_parallel: 1.`);
|
|
59
|
+
}
|
|
60
|
+
if (!opts.fresh)
|
|
61
|
+
return;
|
|
62
|
+
const dirty = dirtyPaths(project.dir);
|
|
63
|
+
if (dirty.length) {
|
|
64
|
+
const shown = dirty.slice(0, 5).join(", ") + (dirty.length > 5 ? `, ... (${dirty.length} in all)` : "");
|
|
65
|
+
throw new Error(`Parallel runs start from a clean repo, but ${project.dir} has uncommitted changes: ${shown}. ` +
|
|
66
|
+
`Commit or stash them first (agent-lyceum never touches your own edits), or run sequentially with dispatcher.max_parallel: 1.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Commit the repo's current working tree (tracked changes and new, non-ignored files) as an unreferenced-by-branch
|
|
71
|
+
* snapshot `refs/agent-lyceum/<run>/base-<n>`. Uses a throwaway index, so HEAD, branches, the real index and the
|
|
72
|
+
* working tree are not touched. Returns the ref.
|
|
73
|
+
*/
|
|
74
|
+
export function snapshotBase(project, runId, n) {
|
|
75
|
+
const top = toplevel(project.dir);
|
|
76
|
+
const index = path.join(os.tmpdir(), `agent-lyceum-index-${crypto.randomBytes(6).toString("hex")}`);
|
|
77
|
+
const env = { ...IDENTITY, GIT_INDEX_FILE: index };
|
|
78
|
+
try {
|
|
79
|
+
const head = tryGit(top, ["rev-parse", "--verify", "HEAD"]);
|
|
80
|
+
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);
|
|
85
|
+
const ref = `refs/agent-lyceum/${runId}/base-${n}`;
|
|
86
|
+
git(top, ["update-ref", ref, commit]);
|
|
87
|
+
return ref;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
fs.rmSync(index, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
|
|
94
|
+
function describeWorkspace(project, root, agent, meta) {
|
|
95
|
+
const top = toplevel(project.dir);
|
|
96
|
+
// `top` is a real path (git resolves symlinks, e.g. /var -> /private/var on macOS), so compare against the real project dir.
|
|
97
|
+
const rel = path.relative(top, fs.realpathSync(project.dir));
|
|
98
|
+
return {
|
|
99
|
+
agent,
|
|
100
|
+
root,
|
|
101
|
+
dir: rel ? path.join(root, rel) : root,
|
|
102
|
+
gitDir: git(root, ["rev-parse", "--absolute-git-dir"]),
|
|
103
|
+
branch: meta.branch,
|
|
104
|
+
base: meta.base,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
|
|
109
|
+
* An existing checkout is reused: untouched, when it already is on this snapshot (resume) or holds unfinished
|
|
110
|
+
* work; moved to the new snapshot when it is clean and nothing was done in it.
|
|
111
|
+
*/
|
|
112
|
+
export function prepareAgentWorkspace(project, runId, agent, baseRef) {
|
|
113
|
+
const top = toplevel(project.dir);
|
|
114
|
+
const root = workspaceRoot(project, runId, agent);
|
|
115
|
+
const metaFile = `${root}.json`;
|
|
116
|
+
const newBase = git(top, ["rev-parse", `${baseRef}^{commit}`]);
|
|
117
|
+
let meta;
|
|
118
|
+
try {
|
|
119
|
+
meta = JSON.parse(fs.readFileSync(metaFile, "utf8"));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
meta = undefined;
|
|
123
|
+
}
|
|
124
|
+
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;
|
|
127
|
+
if (untouched) {
|
|
128
|
+
git(root, ["reset", "-q", "--hard", newBase]);
|
|
129
|
+
meta = { ...meta, base: newBase };
|
|
130
|
+
atomicWrite(metaFile, JSON.stringify(meta, null, 2));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return describeWorkspace(project, root, agent, meta);
|
|
134
|
+
}
|
|
135
|
+
const branch = `agent-lyceum/${runId}/${agent}`;
|
|
136
|
+
fs.mkdirSync(path.dirname(root), { recursive: true });
|
|
137
|
+
git(top, ["worktree", "add", "-q", "-B", branch, root, newBase]);
|
|
138
|
+
meta = { branch, base: newBase };
|
|
139
|
+
atomicWrite(metaFile, JSON.stringify(meta, null, 2));
|
|
140
|
+
return describeWorkspace(project, root, agent, meta);
|
|
141
|
+
}
|
|
142
|
+
/** Remove an agent's worktree, its branch and its metadata. The caller decides whether the work in it may be discarded. */
|
|
143
|
+
export function removeAgentWorkspace(project, runId, agent) {
|
|
144
|
+
const top = toplevel(project.dir);
|
|
145
|
+
const root = workspaceRoot(project, runId, agent);
|
|
146
|
+
let branch;
|
|
147
|
+
try {
|
|
148
|
+
branch = JSON.parse(fs.readFileSync(`${root}.json`, "utf8")).branch;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* no metadata */
|
|
152
|
+
}
|
|
153
|
+
if (fs.existsSync(root))
|
|
154
|
+
tryGit(top, ["worktree", "remove", "--force", root]);
|
|
155
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
156
|
+
tryGit(top, ["worktree", "prune"]);
|
|
157
|
+
if (branch)
|
|
158
|
+
tryGit(top, ["branch", "-D", branch]);
|
|
159
|
+
fs.rmSync(`${root}.json`, { force: true });
|
|
160
|
+
}
|
|
161
|
+
/** Glob as used in `owns`: `**` crosses folders, `*` and `?` stay within one, `{a,b}` alternates. */
|
|
162
|
+
export function globMatch(glob, file) {
|
|
163
|
+
let re = "";
|
|
164
|
+
for (let i = 0; i < glob.length; i++) {
|
|
165
|
+
const c = glob[i];
|
|
166
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
167
|
+
if (glob[i + 2] === "/") {
|
|
168
|
+
re += "(?:.*/)?";
|
|
169
|
+
i += 2;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
re += ".*";
|
|
173
|
+
i += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (c === "*")
|
|
177
|
+
re += "[^/]*";
|
|
178
|
+
else if (c === "?")
|
|
179
|
+
re += "[^/]";
|
|
180
|
+
else if (c === "{") {
|
|
181
|
+
const end = glob.indexOf("}", i);
|
|
182
|
+
if (end < 0)
|
|
183
|
+
re += "\\{";
|
|
184
|
+
else {
|
|
185
|
+
re += `(?:${glob.slice(i + 1, end).split(",").map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`;
|
|
186
|
+
i = end;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else
|
|
190
|
+
re += c.replace(/[.+^$()|[\]\\}]/g, "\\$&");
|
|
191
|
+
}
|
|
192
|
+
return new RegExp(`^${re}$`).test(file);
|
|
193
|
+
}
|
|
194
|
+
const PROTECTED_NAMES = ["CLAUDE.md", "AGENTS.md"];
|
|
195
|
+
/**
|
|
196
|
+
* Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
|
|
197
|
+
* changed path (modified, added, deleted, or either end of a rename) against the agent's `owns`, the project
|
|
198
|
+
* folder, the protected instruction files, and symlink/submodule rules. Nothing is applied here.
|
|
199
|
+
*/
|
|
200
|
+
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"]);
|
|
208
|
+
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 });
|
|
210
|
+
const parts = raw.split("\0").filter(Boolean);
|
|
211
|
+
const files = [];
|
|
212
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
213
|
+
const m = /^:(\d+) (\d+) \w+ \w+ (\w)$/.exec(parts[i]);
|
|
214
|
+
if (!m)
|
|
215
|
+
continue;
|
|
216
|
+
files.push({ path: parts[i + 1], status: m[3], mode: m[2] });
|
|
217
|
+
}
|
|
218
|
+
const rel = path.relative(workspace.root, workspace.dir).split(path.sep).join("/");
|
|
219
|
+
const violations = [];
|
|
220
|
+
for (const f of files) {
|
|
221
|
+
const inProject = rel === "" || f.path === rel || f.path.startsWith(`${rel}/`);
|
|
222
|
+
if (!inProject) {
|
|
223
|
+
violations.push(`${f.path}: outside the project directory (${rel})`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const p = rel === "" ? f.path : path.posix.relative(rel, f.path);
|
|
227
|
+
if (PROTECTED_NAMES.includes(p))
|
|
228
|
+
violations.push(`${f.path}: protected instruction file; only the lead may change it, by hand`);
|
|
229
|
+
const allowed = (candidate) => owns.length === 0 || owns.some((g) => globMatch(g, candidate));
|
|
230
|
+
if (!allowed(p))
|
|
231
|
+
violations.push(`${f.path}: outside this agent's owns (${owns.join(", ")})`);
|
|
232
|
+
if (f.mode === "160000")
|
|
233
|
+
violations.push(`${f.path}: submodule changes are not integrated`);
|
|
234
|
+
if (f.mode === "120000" && f.status !== "D") {
|
|
235
|
+
const target = git(root, ["show", `${head}:${f.path}`]);
|
|
236
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(p), target));
|
|
237
|
+
if (path.posix.isAbsolute(target))
|
|
238
|
+
violations.push(`${f.path}: symlink to an absolute path (${target})`);
|
|
239
|
+
else if (resolved.startsWith(".."))
|
|
240
|
+
violations.push(`${f.path}: symlink leaves the project directory (${target})`);
|
|
241
|
+
else if (!allowed(resolved))
|
|
242
|
+
violations.push(`${f.path}: symlink points outside this agent's owns (${target})`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { workspace, base, head, files, violations };
|
|
246
|
+
}
|
|
247
|
+
function blobAt(top, commit, file) {
|
|
248
|
+
return tryGit(top, ["rev-parse", "--verify", "-q", `${commit}:${file}`]) ?? "absent";
|
|
249
|
+
}
|
|
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);
|
|
253
|
+
let st;
|
|
254
|
+
try {
|
|
255
|
+
st = fs.lstatSync(abs);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return "absent";
|
|
259
|
+
}
|
|
260
|
+
if (st.isDirectory())
|
|
261
|
+
return "directory";
|
|
262
|
+
const input = st.isSymbolicLink() ? fs.readlinkSync(abs) : fs.readFileSync(abs);
|
|
263
|
+
return execFileSync("git", ["hash-object", "--stdin"], { cwd: top, input, encoding: "utf8" }).trim();
|
|
264
|
+
}
|
|
265
|
+
function writeBlockedReport(project, changes, reason, patch) {
|
|
266
|
+
const dir = path.join(project.run?.dir ?? project.paths.root, "integration");
|
|
267
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
268
|
+
const stamp = new Date().toISOString().replace(/[-:.]/g, "");
|
|
269
|
+
const base = path.join(dir, `${changes.workspace.agent}-${stamp}`);
|
|
270
|
+
fs.writeFileSync(`${base}.patch`, patch);
|
|
271
|
+
fs.writeFileSync(`${base}.md`, [
|
|
272
|
+
`# ${changes.workspace.agent}: changes not integrated`,
|
|
273
|
+
"",
|
|
274
|
+
reason,
|
|
275
|
+
"",
|
|
276
|
+
`- agent branch: \`${changes.workspace.branch}\` (kept)`,
|
|
277
|
+
`- agent worktree: \`${changes.workspace.root}\` (kept)`,
|
|
278
|
+
`- snapshot it started from: \`${changes.base}\``,
|
|
279
|
+
`- its changes as a patch: \`${base}.patch\` (apply with \`git apply\`)`,
|
|
280
|
+
"",
|
|
281
|
+
"## Files",
|
|
282
|
+
...changes.files.map((f) => `- ${f.status} ${f.path}`),
|
|
283
|
+
"",
|
|
284
|
+
].join("\n"));
|
|
285
|
+
return { report: `${base}.md`, patch: `${base}.patch` };
|
|
286
|
+
}
|
|
287
|
+
function advanceBase(workspace, newBase) {
|
|
288
|
+
const metaFile = `${workspace.root}.json`;
|
|
289
|
+
atomicWrite(metaFile, JSON.stringify({ branch: workspace.branch, base: newBase }, null, 2));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Bring an agent's collected changes into the main working tree (not the index, not HEAD), all or nothing.
|
|
293
|
+
* Blocked, with nothing applied and the agent's work kept, when the changes break the rules, when a file
|
|
294
|
+
* changed in the repo since the agent's snapshot, or when git cannot apply the patch. Repeating a call whose
|
|
295
|
+
* changes are already in place is harmless.
|
|
296
|
+
*/
|
|
297
|
+
export function integrateAgentChanges(project, changes) {
|
|
298
|
+
const top = toplevel(project.dir);
|
|
299
|
+
const { workspace, base, head } = changes;
|
|
300
|
+
const fullPatch = (paths) => paths.length === 0 ? "" : git(top, ["--literal-pathspecs", "diff", "--binary", "--full-index", base, head, "--", ...paths]) + "\n";
|
|
301
|
+
const block = (reason, paths = changes.files.map((f) => f.path)) => ({
|
|
302
|
+
status: "blocked",
|
|
303
|
+
reason,
|
|
304
|
+
branch: workspace.branch,
|
|
305
|
+
workspace: workspace.root,
|
|
306
|
+
...writeBlockedReport(project, changes, reason, fullPatch(paths)),
|
|
307
|
+
});
|
|
308
|
+
if (changes.violations.length)
|
|
309
|
+
return block(`These changes break the rules, so none were applied:\n${changes.violations.map((v) => `- ${v}`).join("\n")}`);
|
|
310
|
+
if (changes.files.length === 0)
|
|
311
|
+
return { status: "integrated", files: [], applied: 0 };
|
|
312
|
+
const pending = [];
|
|
313
|
+
const conflicts = [];
|
|
314
|
+
for (const f of changes.files) {
|
|
315
|
+
const work = workingBlob(top, f.path);
|
|
316
|
+
if (work === blobAt(top, head, f.path))
|
|
317
|
+
continue; // already in place
|
|
318
|
+
if (work === blobAt(top, base, f.path))
|
|
319
|
+
pending.push(f);
|
|
320
|
+
else
|
|
321
|
+
conflicts.push(f.path);
|
|
322
|
+
}
|
|
323
|
+
if (conflicts.length) {
|
|
324
|
+
return block(`${conflicts.join(", ")} changed since the snapshot the agent started from (the file in the repo now differs from both the agent's start and its result), so none of its changes were applied.`);
|
|
325
|
+
}
|
|
326
|
+
if (pending.length) {
|
|
327
|
+
const patch = fullPatch(pending.map((f) => f.path));
|
|
328
|
+
try {
|
|
329
|
+
execFileSync("git", ["apply", "--binary", "--whitespace=nowarn", "-"], { cwd: top, input: patch, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
const err = e;
|
|
333
|
+
return block(`git could not apply the patch, so none of the changes were applied: ${(err.stderr || err.message).trim()}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
advanceBase(workspace, head);
|
|
337
|
+
return { status: "integrated", files: changes.files, applied: pending.length };
|
|
338
|
+
}
|
|
339
|
+
/** An agent's existing worktree for this run, if it has one. */
|
|
340
|
+
export function existingAgentWorkspace(project, runId, agent) {
|
|
341
|
+
const root = workspaceRoot(project, runId, agent);
|
|
342
|
+
try {
|
|
343
|
+
const meta = JSON.parse(fs.readFileSync(`${root}.json`, "utf8"));
|
|
344
|
+
return fs.existsSync(root) ? describeWorkspace(project, root, agent, meta) : undefined;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** 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) {
|
|
352
|
+
const root = workspaceRoot(project, runId, agent);
|
|
353
|
+
if (!fs.existsSync(root))
|
|
354
|
+
return undefined;
|
|
355
|
+
const ws = existingAgentWorkspace(project, runId, agent);
|
|
356
|
+
const branch = ws?.branch ?? `agent-lyceum/${runId}/${agent}`;
|
|
357
|
+
const work = { agent, root, branch, dirty: [], unintegrated: [] };
|
|
358
|
+
if (!ws)
|
|
359
|
+
return { ...work, unknown: "its metadata is missing, so what it holds cannot be compared with the repo" };
|
|
360
|
+
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 });
|
|
366
|
+
const parts = raw.split("\0").filter(Boolean);
|
|
367
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
368
|
+
if (workingBlob(top, parts[i]) !== blobAt(top, head, parts[i]))
|
|
369
|
+
work.unintegrated.push(parts[i]);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
work.unknown = `its state could not be read (${e.message.split("\n")[0]})`;
|
|
375
|
+
}
|
|
376
|
+
return work;
|
|
377
|
+
}
|
|
378
|
+
/** Names of the agents that have a worktree for this run. */
|
|
379
|
+
export function listWorkspaceAgents(project, runId) {
|
|
380
|
+
const dir = path.join(project.paths.root, "worktrees", runId);
|
|
381
|
+
if (!fs.existsSync(dir))
|
|
382
|
+
return [];
|
|
383
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
384
|
+
}
|
|
385
|
+
/** The snapshot refs (`refs/agent-lyceum/<run>/...`) of a run. */
|
|
386
|
+
export function listSnapshotRefs(project, runId) {
|
|
387
|
+
if (!isGitRepo(project.dir))
|
|
388
|
+
return [];
|
|
389
|
+
const out = tryGit(project.dir, ["for-each-ref", "--format=%(refname)", `refs/agent-lyceum/${runId}/`]) ?? "";
|
|
390
|
+
return out.split("\n").filter(Boolean);
|
|
391
|
+
}
|
|
392
|
+
export function deleteSnapshotRefs(project, runId) {
|
|
393
|
+
for (const ref of listSnapshotRefs(project, runId))
|
|
394
|
+
git(project.dir, ["update-ref", "-d", ref]);
|
|
395
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-lyceum",
|
|
3
|
+
"version": "0.4.9",
|
|
4
|
+
"description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"agent",
|
|
8
|
+
"multi-agent",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"cli",
|
|
12
|
+
"mailbox"
|
|
13
|
+
],
|
|
14
|
+
"author": "Jin-Ze Huang",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/gogogohuang/agent-lyceum.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/gogogohuang/agent-lyceum#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/gogogohuang/agent-lyceum/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"agent-lyceum": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/**/*.map"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx src/cli.ts",
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"prepare": "npm run build",
|
|
44
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
45
|
+
"release": "bash scripts/release.sh"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"commander": "^12.1.0",
|
|
49
|
+
"yaml": "^2.5.0",
|
|
50
|
+
"zod": "^3.23.8"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"tsx": "^4.19.0",
|
|
55
|
+
"typescript": "^5.6.0",
|
|
56
|
+
"vitest": "^2.1.0"
|
|
57
|
+
}
|
|
58
|
+
}
|