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/README.md CHANGED
@@ -4,13 +4,25 @@
4
4
 
5
5
  **English** | [繁體中文](README.zh-TW.md)
6
6
 
7
- Run a **team of agents** (Claude Code and/or Codex) on your repo. They talk through file mailboxes, and each has its own persona (`AGENT.md`) and long-term memory. An agent can only write its own memory and outbox, never other agents' context or its own persona.
7
+ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one overloaded session. A lead splits the work, specialists such as a frontend dev, a reviewer or a PM do their part in your repo, and the run ends when the lead says it is done.
8
+
9
+ ## Why
10
+
11
+ - **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
12
+ - **Agents can't rewrite the rules.** An agent can only write its own memory and outbox. It can't touch other agents' context or its own persona, and this is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
13
+ - **Mix runtimes.** Claude Code and Codex agents work in the same team.
14
+ - **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
15
+
16
+ **Not a fit** for small, one-shot edits: a single Claude Code or Codex session is faster and cheaper. Use a team when the task is big enough to split, review or run in parallel.
8
17
 
9
18
  ## Quick start
10
19
 
11
20
  Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (logged in). macOS and Linux (Linux needs `bwrap` for OS sandboxing); Windows only gets warnings.
12
21
 
13
22
  ```bash
23
+ npx agent-lyceum demo # 3 agents fix a failing test in a throwaway sandbox (real calls)
24
+
25
+ # on your own repo:
14
26
  npx agent-lyceum init # create the home + global agent library
15
27
  npx agent-lyceum project add web --dir ~/code/web-app
16
28
  npx agent-lyceum doctor --project web # check config, show enforcement levels
@@ -27,7 +39,7 @@ Latest unreleased code: `npx github:gogogohuang/agent-lyceum <command>`.
27
39
  3. Agents reply by writing mail to their own `outbox/`; the dispatcher checks `can_message` and delivers it.
28
40
  4. The run ends when the lead sends `done`.
29
41
 
30
- Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo. Agents edit your repo directly, or in git worktrees when running in parallel. agent-lyceum never merges. More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
42
+ Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo. Concurrency needs no configuration. In a git repo with a commit, every run works in its own git worktree and ends on its own branch (`agent-lyceum/<run>/…`), members with pending mail wake in parallel, and any number of runs can be active at once; the result is not written into your working tree, so use `git merge <branch>` or `open_pr`, and note that a run starts from `HEAD` and does not see your uncommitted edits. In a non-git folder, a repo with no commit, or a repo using reftable, the run works directly in the folder, one agent at a time (an info line says why), and only one such run can be active. agent-lyceum never merges. Upgrading from a version with `owns`, `max_parallel`, `max_concurrent_runs` or `workspace_mode`: see [Upgrading](docs/upgrading-run-v2.md). More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
31
43
 
32
44
  ## Commands
33
45
 
@@ -41,6 +53,7 @@ Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyce
41
53
  | `resume` | Continue an interrupted run, or reopen a finished one with a follow-up. [details](docs/commands.md#resume) |
42
54
  | `clear` | Delete a run, or remove a stale lock. [details](docs/commands.md#clear) |
43
55
  | `call <agent>` | Ask one global agent on its own, outside any project. [details](docs/commands.md#call) |
56
+ | `demo` | Try a 3-agent team on a throwaway sample repo. [details](docs/commands.md#demo) |
44
57
  | `tidy` | Have agents tidy their own memory (archives, never deletes). [details](docs/commands.md#tidy) |
45
58
 
46
59
  `agent-lyceum <command> --help` prints the full options. Ctrl-C cancels cleanly; [exit codes](docs/commands.md#exit-codes) tell scripts how a run ended.
@@ -55,7 +68,7 @@ dir: ~/code/web-app
55
68
  team: { lead: lead }
56
69
  agents:
57
70
  lead: { can_message: all, can_edit_agent_md: true }
58
- fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
71
+ fe-member: { runtime: codex, can_message: [lead] }
59
72
  pm: { can_message: [lead], allow_web: fetch }
60
73
  ```
61
74
 
package/README.zh-TW.md CHANGED
@@ -4,13 +4,25 @@
4
4
 
5
5
  [English](README.md) | **繁體中文**
6
6
 
7
- 在你的 repo 上執行一個由多個 **agent**(Claude Code 和/或 Codex)組成的團隊。agent 之間透過檔案信箱溝通,各自有人設(`AGENT.md`)與長期記憶。agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設。
7
+ 把任務交給一個 **agent 團隊**(Claude Code 和/或 Codex),而不是塞給單一個負荷過重的 session。lead 拆分工作,前端、reviewer、PM 等專長角色在你的 repo 裡各做各的,lead 回報 done 即結束。
8
+
9
+ ## 為什麼用它
10
+
11
+ - **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
12
+ - **agent 改不了規則。** agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設;能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
13
+ - **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
14
+ - **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
15
+
16
+ **不適合**小而單次的修改:單一 Claude Code 或 Codex session 更快更省。任務大到值得拆分、review 或平行執行時,再用團隊。
8
17
 
9
18
  ## 快速開始
10
19
 
11
20
  需求:Node 20+,以及 `PATH` 中有 `claude` 和/或 `codex`(已登入)。支援 macOS 與 Linux(Linux 需要 `bwrap` 才有 OS 沙箱);Windows 只會顯示警告。
12
21
 
13
22
  ```bash
23
+ npx agent-lyceum demo # 3 個 agent 在拋棄式沙箱裡修好一個失敗的測試(真實呼叫)
24
+
25
+ # 用在你自己的 repo:
14
26
  npx agent-lyceum init # 建立 home 與全域 agent 庫
15
27
  npx agent-lyceum project add web --dir ~/code/web-app
16
28
  npx agent-lyceum doctor --project web # 檢查設定並顯示各項防護等級
@@ -27,7 +39,7 @@ npx agent-lyceum status
27
39
  3. agent 把回信寫進自己的 `outbox/`,dispatcher 檢查 `can_message` 後投遞。
28
40
  4. lead 寄出 `done` 時,run 結束。
29
41
 
30
- 工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo。agent 直接修改你的 repo;平行執行時則在各自的 git worktree 修改。agent-lyceum 不會幫你合併。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
42
+ 工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo。並行不需要任何設定。在有 commit 的 git repo 裡,每個 run 都在自己的 git worktree 工作,並結束在自己的 branch(`agent-lyceum/<run>/…`);有待處理信件的成員會平行喚醒,同時也可有任意多個 run;結果不會寫進你的工作目錄,請用 `git merge <branch>` 或 `open_pr`,且 run 從 `HEAD` 起跑,看不到你未提交的修改。在非 git 資料夾、沒有 commit 的 repo 或使用 reftable 的 repo,run 直接在該資料夾工作、一次只跑一個 agent(會有一行說明原因),且同時只能有一個這樣的 run。agent-lyceum 不會幫你合併。從有 `owns`、`max_parallel`、`max_concurrent_runs` 或 `workspace_mode` 的版本升級:見[升級](docs/upgrading-run-v2.md)。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
31
43
 
32
44
  ## 指令
33
45
 
@@ -41,6 +53,7 @@ npx agent-lyceum status
41
53
  | `resume` | 繼續中斷的 run,或用追加需求重開已完成的 run。 [詳細](docs/commands.zh-TW.md#resume) |
42
54
  | `clear` | 刪除 run,或移除殘留的鎖。 [詳細](docs/commands.zh-TW.md#clear) |
43
55
  | `call <agent>` | 在任何專案之外單獨呼叫一個全域 agent。 [詳細](docs/commands.zh-TW.md#call) |
56
+ | `demo` | 在拋棄式範例 repo 上試跑 3 個 agent 的團隊。 [詳細](docs/commands.zh-TW.md#demo) |
44
57
  | `tidy` | 讓 agent 整理自己的記憶(只封存,不刪除)。 [詳細](docs/commands.zh-TW.md#tidy) |
45
58
 
46
59
  `agent-lyceum <指令> --help` 會印出完整選項。Ctrl-C 可乾淨取消;腳本可依[結束碼](docs/commands.zh-TW.md#退出碼)判斷 run 的結果。
@@ -55,7 +68,7 @@ dir: ~/code/web-app
55
68
  team: { lead: lead }
56
69
  agents:
57
70
  lead: { can_message: all, can_edit_agent_md: true }
58
- fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
71
+ fe-member: { runtime: codex, can_message: [lead] }
59
72
  pm: { can_message: [lead], allow_web: fetch }
60
73
  ```
61
74
 
@@ -27,13 +27,7 @@ export function buildClaudeSettings(project, agent) {
27
27
  allow.push("WebSearch");
28
28
  if (agent.allowWeb === "fetch")
29
29
  allow.push("WebFetch");
30
- if (pol.restrictRepoToOwns) {
31
- for (const g of pol.owns)
32
- allow.push(rule("Edit", path.join(repo, g)));
33
- }
34
- else {
35
- allow.push(rule("Edit", `${repo}/**`));
36
- }
30
+ allow.push(rule("Edit", `${repo}/**`));
37
31
  for (const d of pol.allowDirs)
38
32
  allow.push(rule("Edit", `${d}/**`));
39
33
  for (const f of pol.allowFiles)
package/dist/call.js CHANGED
@@ -33,7 +33,6 @@ export function resolveCallAgent(home, name) {
33
33
  canMessage: [],
34
34
  canAskUser: false,
35
35
  canEditAgentMd: false,
36
- owns: [],
37
36
  };
38
37
  }
39
38
  /** The made-up project a call runs in: the working directory is its repo, and every other path is inside the call's own folder. */
@@ -54,7 +53,7 @@ export function callProject(home, agent, dir, callDir) {
54
53
  name: CALL_PROJECT,
55
54
  dir,
56
55
  lead: agent.name,
57
- dispatcher: { ...DISPATCHER_DEFAULTS, max_parallel: 1, workspace_mode: "shared" },
56
+ dispatcher: DISPATCHER_DEFAULTS,
58
57
  agents: { [agent.name]: agent },
59
58
  paths,
60
59
  solo: "call",
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline/promises";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { Command } from "commander";
6
7
  import { ConfigError, findProjectForCwd, flattenResolved, listProjects, loadGlobal, resolveProject, resolveProjectWithSources } from "./config.js";
7
8
  import { openInEditor, prepareAnswers } from "./ask-answer.js";
@@ -10,9 +11,10 @@ import { RESULT_FILE, runTeam } from "./dispatcher.js";
10
11
  import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
11
12
  import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
12
13
  import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
14
+ import { planWorkspace } from "./worktree.js";
13
15
  import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
14
16
  import { failureGuide } from "./run-outcome.js";
15
- import { exitCodeForOutcome } from "./schema.js";
17
+ import { exitCodeForOutcome, RUNTIMES } from "./schema.js";
16
18
  import { absPath, assertName, NAME_RE, projectPaths, resolveHome } from "./paths.js";
17
19
  import { addProject, initHome, removeProject } from "./scaffold.js";
18
20
  import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, listRuns, reopenCandidates, runIsAlive } from "./status.js";
@@ -21,6 +23,7 @@ import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
21
23
  import { formatEnforcement, validateProject } from "./validate.js";
22
24
  import { must } from "./assert.js";
23
25
  import { onlyAgent, soloProject } from "./solo.js";
26
+ import { DEMO_PROJECT, DEMO_TASK, defaultDemoDir, detectRuntime, prepareDemo } from "./demo.js";
24
27
  const program = new Command();
25
28
  program
26
29
  .name("agent-lyceum")
@@ -130,10 +133,10 @@ function findRun(pr, query) {
130
133
  const list = r.candidates.map((c) => ` ${c.state.name ?? "(no name)"} ${c.state.run_id} ${c.state.task_summary}`).join("\n");
131
134
  return fail(`"${query}" matches several runs:\n${list}\nGive more of the name or id.`);
132
135
  }
133
- /** Take the lock of one run, or exit with the reason. `ignoreLimit`: the caller does not start a run (e.g. `clear`). `exclusive`: it needs the whole project (memory tidy). */
136
+ /** Take the lock of one run, or exit with the reason. `checkout`: the run works in the project checkout, so only one may be active. `exclusive`: it needs the whole project (memory tidy). */
134
137
  function takeLock(pr, runId, opts = {}) {
135
138
  try {
136
- return acquireProjectLock(pr.paths.root, runId, { maxConcurrent: opts.ignoreLimit ? Number.MAX_SAFE_INTEGER : pr.dispatcher.max_concurrent_runs, exclusive: opts.exclusive });
139
+ return acquireProjectLock(pr.paths.root, runId, { checkout: opts.checkout, exclusive: opts.exclusive, dir: pr.dir, reason: opts.reason });
137
140
  }
138
141
  catch (e) {
139
142
  return fail(e.message);
@@ -195,17 +198,23 @@ function reportRun(summary, runDir) {
195
198
  const firstLine = (t) => t.trim().split("\n")[0].trim();
196
199
  /** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
197
200
  async function continueRun(pr, dir, runId, followup) {
198
- const lease = takeLock(pr, runId);
201
+ const planned = loadRunState(dir);
202
+ // Where the run works is decided (from its state) before the lock is taken, and handed to the session as is.
203
+ const plan = planWorkspace(pr, planned);
204
+ const lease = takeLock(pr, runId, { checkout: !plan.leadWorktree, reason: "it was started without its own worktree" });
199
205
  const cancel = cancelOnSignals();
200
206
  try {
201
- console.log(`Project ${pr.name} — repo ${pr.dir}`);
207
+ // Read the state again under the lock: another process may have changed it while we were checking.
202
208
  const state = loadRunState(dir);
209
+ if (state.lead_workspace !== planned.lead_workspace || state.workspace_mode !== planned.workspace_mode) {
210
+ throw new Error(`Run ${runId} changed while it was starting (where it works was ${planned.lead_workspace ? "its own worktree" : planned.workspace_mode ?? "unset"}, now ${state.lead_workspace ? "its own worktree" : state.workspace_mode ?? "unset"}); try again.`);
211
+ }
212
+ console.log(`Project ${pr.name} — repo ${pr.dir}`);
203
213
  if (followup)
204
214
  console.log(`Reopening run ${runLabel(state)} (follow-up ${(state.reopened?.length ?? 0) + 1}): ${firstLine(followup.text)}`);
205
215
  else
206
216
  console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${roundsLabel(state.rounds, roundLimit(state, pr.dispatcher.max_rounds))}: ${state.task_summary}`);
207
- // Read the state again under the lock: another process may have changed it while we were checking.
208
- return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal, followup });
217
+ return await runTeam({ project: pr, resume: state, plan, runDir: dir, signal: cancel.signal, followup });
209
218
  }
210
219
  finally {
211
220
  cancel.dispose();
@@ -287,7 +296,8 @@ program
287
296
  }
288
297
  const runId = newRunId();
289
298
  const runDir = path.join(pr.paths.runs, runId);
290
- const lease = takeLock(pr, runId);
299
+ const plan = planWorkspace(pr);
300
+ const lease = takeLock(pr, runId, { checkout: !plan.leadWorktree, reason: plan.reason });
291
301
  let summary;
292
302
  const cancel = cancelOnSignals();
293
303
  try {
@@ -295,7 +305,7 @@ program
295
305
  console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
296
306
  console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
297
307
  const name = opts.name ?? uniqueName(slugify(taskMessageBody(prepared).subject), taken);
298
- summary = await runTeam({ project: pr, name, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
308
+ summary = await runTeam({ project: pr, name, plan, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
299
309
  }
300
310
  finally {
301
311
  cancel.dispose();
@@ -512,7 +522,7 @@ program
512
522
  refuse(plan);
513
523
  return;
514
524
  }
515
- const lease = takeLock(pr, full, { ignoreLimit: true });
525
+ const lease = takeLock(pr, full, { checkout: false });
516
526
  try {
517
527
  const plan = planRunCleanup(pr, full, { keepWorktrees: opts.keepWorktrees });
518
528
  if (plan.refusals.length)
@@ -772,11 +782,63 @@ program
772
782
  fail(e instanceof ConfigError ? e.message : e.message);
773
783
  }
774
784
  });
785
+ program
786
+ .command("demo")
787
+ .summary("Try a 3-agent team on a tiny sample repo (real agents, a few minutes)")
788
+ .description("Build a throwaway sandbox (a tiny git repo with one failing test, plus its own agent-lyceum home), then run a lead, a frontend member and a QA member on it until `npm test` passes.\n\nIt uses real `claude` / `codex` calls, so it spends a little of your quota. Nothing outside the sandbox directory is touched; your own home and repos are not used.")
789
+ .option("--dir <path>", "the sandbox directory, must be empty or new (default: a new folder in the system temp dir)")
790
+ .option("--runtime <runtime>", `${RUNTIMES.join(" | ")} (default: whichever CLI is on your PATH)`)
791
+ .option("--setup-only", "build the sandbox and print the commands, but run no agents")
792
+ .option("-y, --yes", "do not ask before running real agents")
793
+ .action(async (opts) => {
794
+ try {
795
+ let runtime = opts.runtime;
796
+ if (runtime && !RUNTIMES.includes(runtime))
797
+ fail(`Unknown runtime "${runtime}". Use one of: ${RUNTIMES.join(", ")}.`);
798
+ runtime ??= detectRuntime();
799
+ if (!runtime) {
800
+ if (!opts.setupOnly)
801
+ fail("Neither `claude` nor `codex` was found on your PATH (and logged in). Install one, or use --setup-only to just look at the sandbox.");
802
+ runtime = "claude-code";
803
+ }
804
+ if (!opts.setupOnly && !opts.yes && interactive()) {
805
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
806
+ const ans = (await rl.question(`This runs a 3-agent team with real ${runtime} calls on a sample repo (a few minutes, a small amount of quota). Continue? [Y/n] `)).trim().toLowerCase();
807
+ rl.close();
808
+ if (ans === "n" || ans === "no")
809
+ fail("Aborted.");
810
+ }
811
+ const demo = prepareDemo(opts.dir ?? defaultDemoDir(), runtime);
812
+ const h = demo.home;
813
+ console.log(`Sandbox: ${demo.dir} (runtime: ${runtime})\nRepo with one failing test: ${demo.repo}\n`);
814
+ const next = [
815
+ `agent-lyceum --home ${h} status -p ${DEMO_PROJECT} # agents, mail, runs`,
816
+ `git -C ${demo.repo} log --oneline --stat # what the team changed`,
817
+ `rm -rf ${demo.dir} # clean up when done`,
818
+ ];
819
+ if (opts.setupOnly) {
820
+ fs.writeFileSync(path.join(demo.dir, "task.md"), `${DEMO_TASK}\n`);
821
+ console.log(`Run the team yourself:\n agent-lyceum --home ${h} run -p ${DEMO_PROJECT} --task-file ${path.join(demo.dir, "task.md")}\n\nThen:\n${next.map((l) => ` ${l}`).join("\n")}`);
822
+ return;
823
+ }
824
+ console.log(`Task for the lead:\n${DEMO_TASK.split("\n").map((l) => ` | ${l}`).join("\n")}\n`);
825
+ // The real `run`, in a child: the demo shows exactly what a normal run does (progress, Ctrl-C, exit codes).
826
+ const r = spawnSync(process.execPath, [...process.execArgv, process.argv[1], "--home", h, "run", DEMO_TASK, "-p", DEMO_PROJECT, "--name", "demo-slugify"], { stdio: "inherit", cwd: demo.repo });
827
+ const code = r.status ?? 1;
828
+ console.log(code === 0 ? "\nDone. See what happened:" : `\nThe run ended with exit code ${code} (see docs/commands.md#exit-codes). Look around:`);
829
+ console.log(next.map((l) => ` ${l}`).join("\n"));
830
+ process.exit(code);
831
+ }
832
+ catch (e) {
833
+ fail(e.message);
834
+ }
835
+ });
775
836
  // `--help` lists the commands in three layers.
776
837
  const HELP_LAYERS = {
777
838
  "Project:": ["init", "project"],
778
839
  "Diagnose:": ["doctor", "status"],
779
840
  "Run:": ["run", "resume", "clear", "call", "tidy"],
841
+ "Try it:": ["demo"],
780
842
  };
781
843
  for (const [heading, names] of Object.entries(HELP_LAYERS))
782
844
  for (const c of program.commands)
package/dist/config.d.ts CHANGED
@@ -22,7 +22,6 @@ export interface ResolvedAgent {
22
22
  canAskUser: boolean;
23
23
  /** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
24
24
  allowWeb?: WebLevel;
25
- owns: string[];
26
25
  /** Where each field's value came from (for `validate` output). */
27
26
  sources: Record<string, Source>;
28
27
  }
@@ -35,6 +34,11 @@ export interface ResolvedProject {
35
34
  dispatcher: DispatcherSettings;
36
35
  agents: Record<string, ResolvedAgent>;
37
36
  paths: ProjectPaths;
37
+ /** Removed settings still present in config files (ignored); `validate` warns once per file. */
38
+ ignoredSettings?: {
39
+ file: string;
40
+ keys: string[];
41
+ }[];
38
42
  /** Set once the project is bound to one run (see `bindRunProject`): which run, and where its mail lives. */
39
43
  run?: {
40
44
  id: string;
@@ -61,7 +65,7 @@ export declare function loadGlobal(home: string): {
61
65
  can_edit_agent_md?: boolean | undefined;
62
66
  can_ask_user?: boolean | undefined;
63
67
  allow_web?: "fetch" | "none" | "search" | undefined;
64
- owns?: string[] | undefined;
68
+ owns?: unknown;
65
69
  }>;
66
70
  };
67
71
  export declare function loadProjectRaw(home: string, name: string): {
@@ -71,12 +75,12 @@ export declare function loadProjectRaw(home: string, name: string): {
71
75
  };
72
76
  dispatcher: {
73
77
  max_rounds?: number | undefined;
74
- max_parallel?: number | undefined;
75
- max_concurrent_runs?: number | undefined;
78
+ max_parallel?: unknown;
79
+ max_concurrent_runs?: unknown;
76
80
  wake_timeout_sec?: number | undefined;
77
81
  retry?: number | undefined;
78
82
  strict?: boolean | undefined;
79
- workspace_mode?: "auto" | "shared" | "worktree" | undefined;
83
+ workspace_mode?: unknown;
80
84
  log_max_bytes?: number | undefined;
81
85
  };
82
86
  agents: Record<string, {
@@ -93,7 +97,7 @@ export declare function loadProjectRaw(home: string, name: string): {
93
97
  can_edit_agent_md?: boolean | undefined;
94
98
  can_ask_user?: boolean | undefined;
95
99
  allow_web?: "fetch" | "none" | "search" | undefined;
96
- owns?: string[] | undefined;
100
+ owns?: unknown;
97
101
  }>;
98
102
  };
99
103
  export declare function resolveProject(home: string, name: string): ResolvedProject;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { absPath, homePaths, projectPaths, } from "./paths.js";
5
- import { DISPATCHER_DEFAULTS, GlobalConfig, ProjectConfig, inferRuntime, } from "./schema.js";
5
+ import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, } from "./schema.js";
6
6
  export class ConfigError extends Error {
7
7
  }
8
8
  function readYaml(file, schema, label) {
@@ -58,7 +58,8 @@ export function resolveProjectFrom(home, name, g, p) {
58
58
  const paths = projectPaths(home, name);
59
59
  const hp = homePaths(home);
60
60
  const lead = p.team.lead;
61
- const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(p.dispatcher) };
61
+ const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, ...liveDispatcher } = p.dispatcher;
62
+ const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
62
63
  const agents = {};
63
64
  for (const agentName of Object.keys(p.agents)) {
64
65
  const gl = resolveLayer(g.agents[agentName], home);
@@ -134,13 +135,11 @@ export function resolveProjectFrom(home, name, g, p) {
134
135
  const canEdit = pick("can_edit_agent_md");
135
136
  const canAsk = pick("can_ask_user");
136
137
  const allowWeb = pick("allow_web");
137
- const owns = pick("owns");
138
138
  for (const [k, v] of [
139
139
  ["resume", resume],
140
140
  ["can_message", canMessage],
141
141
  ["can_edit_agent_md", canEdit],
142
142
  ["can_ask_user", canAsk],
143
- ["owns", owns],
144
143
  ]) {
145
144
  if (v === undefined)
146
145
  sources[k] = "default";
@@ -157,7 +156,6 @@ export function resolveProjectFrom(home, name, g, p) {
157
156
  canEditAgentMd: canEdit ?? isLead,
158
157
  canAskUser: canAsk ?? isLead,
159
158
  allowWeb,
160
- owns: owns ?? [],
161
159
  sources,
162
160
  };
163
161
  }
@@ -169,8 +167,28 @@ export function resolveProjectFrom(home, name, g, p) {
169
167
  dispatcher,
170
168
  agents,
171
169
  paths,
170
+ ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
172
171
  };
173
172
  }
173
+ /** The removed keys present in the global and project files, by full path, per file (empty files omitted). */
174
+ function collectIgnored(globalFile, g, projectFile, p) {
175
+ const out = [];
176
+ const scan = (file, dispatcher, agents) => {
177
+ const keys = [];
178
+ for (const k of REMOVED_DISPATCHER_KEYS)
179
+ if (dispatcher?.[k] !== undefined)
180
+ keys.push(`dispatcher.${k}`);
181
+ for (const [name, a] of Object.entries(agents))
182
+ for (const k of REMOVED_AGENT_KEYS)
183
+ if (a[k] !== undefined)
184
+ keys.push(`agents.${name}.${k}`);
185
+ if (keys.length)
186
+ out.push({ file, keys });
187
+ };
188
+ scan(globalFile, undefined, g.agents);
189
+ scan(projectFile, p.dispatcher, p.agents);
190
+ return out;
191
+ }
174
192
  function stripUndef(o) {
175
193
  return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
176
194
  }
@@ -205,7 +223,7 @@ export function findProjectForCwd(home, cwd) {
205
223
  }
206
224
  return best?.name;
207
225
  }
208
- const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "owns"];
226
+ const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web"];
209
227
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
210
228
  export function resolveProjectWithSources(home, name) {
211
229
  const project = resolveProject(home, name);
@@ -264,7 +282,6 @@ export function flattenResolved(project) {
264
282
  v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
265
283
  v[`${p}.can_ask_user`] = a.canAskUser;
266
284
  v[`${p}.allow_web`] = a.allowWeb;
267
- v[`${p}.owns`] = a.owns;
268
285
  }
269
286
  return v;
270
287
  }
package/dist/demo.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Runtime } from "./schema.js";
2
+ export declare const DEMO_PROJECT = "demo";
3
+ export declare const DEMO_TASK = "The sample repo has a failing test: `npm test` fails because slugify() in src/slug.js is not implemented.\n\nImplement slugify(text) so that it lowercases the text, turns every run of non-alphanumeric characters into a single \"-\", and trims leading and trailing \"-\". Do not edit the tests.\n\nPlan: the lead hands the implementation to fe-member, then asks qa-member to run `npm test` and review the change. Finish with `done` only after qa-member's verdict is pass.";
4
+ /** Which runtime CLI is on the PATH and answers `--version`; claude-code wins when both are. */
5
+ export declare function detectRuntime(): Runtime | undefined;
6
+ export interface DemoSetup {
7
+ dir: string;
8
+ home: string;
9
+ repo: string;
10
+ }
11
+ /** A throwaway sandbox: a tiny git repo with one failing test, plus its own agent-lyceum home with a registered project. Touches nothing outside `dir`. */
12
+ export declare function prepareDemo(dir: string, runtime: Runtime): DemoSetup;
13
+ export declare function defaultDemoDir(): string;
package/dist/demo.js ADDED
@@ -0,0 +1,90 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { projectPaths } from "./paths.js";
6
+ import { addProject, initHome } from "./scaffold.js";
7
+ export const DEMO_PROJECT = "demo";
8
+ export const DEMO_TASK = `The sample repo has a failing test: \`npm test\` fails because slugify() in src/slug.js is not implemented.
9
+
10
+ Implement slugify(text) so that it lowercases the text, turns every run of non-alphanumeric characters into a single "-", and trims leading and trailing "-". Do not edit the tests.
11
+
12
+ Plan: the lead hands the implementation to fe-member, then asks qa-member to run \`npm test\` and review the change. Finish with \`done\` only after qa-member's verdict is pass.`;
13
+ const FILES = {
14
+ "package.json": `{
15
+ "name": "lyceum-demo",
16
+ "private": true,
17
+ "type": "module",
18
+ "scripts": { "test": "node --test" }
19
+ }
20
+ `,
21
+ "src/slug.js": `// Turn a title into a URL slug: "Hello, World!" -> "hello-world".
22
+ export function slugify(text) {
23
+ throw new Error("not implemented");
24
+ }
25
+ `,
26
+ "test/slug.test.js": `import assert from "node:assert/strict";
27
+ import { test } from "node:test";
28
+ import { slugify } from "../src/slug.js";
29
+
30
+ test("lowercases and joins words with a dash", () => {
31
+ assert.equal(slugify("Hello World"), "hello-world");
32
+ });
33
+
34
+ test("collapses punctuation and repeated separators", () => {
35
+ assert.equal(slugify("Hello, World!!"), "hello-world");
36
+ });
37
+
38
+ test("trims leading and trailing separators", () => {
39
+ assert.equal(slugify(" --Agent Lyceum-- "), "agent-lyceum");
40
+ });
41
+
42
+ test("keeps digits", () => {
43
+ assert.equal(slugify("Top 10 tips"), "top-10-tips");
44
+ });
45
+ `,
46
+ };
47
+ /** Which runtime CLI is on the PATH and answers `--version`; claude-code wins when both are. */
48
+ export function detectRuntime() {
49
+ for (const [runtime, bin] of [["claude-code", "claude"], ["codex", "codex"]]) {
50
+ const r = spawnSync(bin, ["--version"], { stdio: "ignore" });
51
+ if (r.status === 0)
52
+ return runtime;
53
+ }
54
+ return undefined;
55
+ }
56
+ /** A throwaway sandbox: a tiny git repo with one failing test, plus its own agent-lyceum home with a registered project. Touches nothing outside `dir`. */
57
+ export function prepareDemo(dir, runtime) {
58
+ const root = path.resolve(dir);
59
+ if (fs.existsSync(root) && fs.readdirSync(root).length)
60
+ throw new Error(`${root} is not empty. Choose another --dir (or remove it).`);
61
+ const home = path.join(root, "home");
62
+ const repo = path.join(root, "repo");
63
+ fs.mkdirSync(repo, { recursive: true });
64
+ for (const [file, text] of Object.entries(FILES)) {
65
+ fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
66
+ fs.writeFileSync(path.join(repo, file), text);
67
+ }
68
+ const git = (...args) => execFileSync("git", ["-C", repo, "-c", "user.name=agent-lyceum demo", "-c", "user.email=demo@example.com", ...args], { stdio: "ignore" });
69
+ try {
70
+ git("init", "-q", "-b", "main");
71
+ git("add", "-A");
72
+ git("commit", "-q", "-m", "demo: slugify is not implemented yet");
73
+ }
74
+ catch {
75
+ throw new Error("The demo needs `git` on your PATH.");
76
+ }
77
+ initHome(home);
78
+ addProject(home, DEMO_PROJECT, repo);
79
+ const cfg = projectPaths(home, DEMO_PROJECT).config;
80
+ let yaml = fs.readFileSync(cfg, "utf8");
81
+ yaml = yaml.replace("max_rounds: 0 ", "max_rounds: 20");
82
+ // One runtime for the whole team, and the roles scoped to the sample repo.
83
+ for (const agent of ["lead", "fe-member", "qa-member"])
84
+ yaml = yaml.replace(` ${agent}:\n`, ` ${agent}:\n runtime: ${runtime}\n`);
85
+ fs.writeFileSync(cfg, yaml);
86
+ return { dir: root, home, repo };
87
+ }
88
+ export function defaultDemoDir() {
89
+ return fs.mkdtempSync(path.join(os.tmpdir(), "agent-lyceum-demo-"));
90
+ }
@@ -3,4 +3,5 @@ import { newRunId } from "./run-store.js";
3
3
  export { newRunId };
4
4
  export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic } from "./run-store.js";
5
5
  export { MAX_DONE_REJECTIONS, RESULT_FILE, type RunOptions, type RunSummary } from "./run-session.js";
6
- export declare function runTeam(opts: RunOptions): Promise<RunSummary>;
6
+ /** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
7
+ export declare function runTeam(opts: Omit<RunOptions, "plan"> & Partial<Pick<RunOptions, "plan">>): Promise<RunSummary>;
@@ -1,7 +1,9 @@
1
1
  import { RunSession } from "./run-session.js";
2
2
  import { newRunId } from "./run-store.js";
3
+ import { planWorkspace } from "./worktree.js";
3
4
  export { newRunId };
4
5
  export { MAX_DONE_REJECTIONS, RESULT_FILE } from "./run-session.js";
6
+ /** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
5
7
  export async function runTeam(opts) {
6
- return new RunSession(opts).run();
8
+ return new RunSession({ ...opts, plan: opts.plan ?? planWorkspace(opts.project, opts.resume) }).run();
7
9
  }
package/dist/doctor.js CHANGED
@@ -6,7 +6,7 @@ import { tidyAdvice } from "./memory-tidy.js";
6
6
  import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
7
7
  import { runInvocation } from "./process-runner.js";
8
8
  import { validateProject } from "./validate.js";
9
- import { dirtyPaths, git, isGitRepo, resolveWorkspaceMode } from "./worktree.js";
9
+ import { dirtyPaths, git, isolationBlocker } from "./worktree.js";
10
10
  const BINARY = { "claude-code": "claude", codex: "codex" };
11
11
  /** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
12
12
  async function query(cmd, args, opts) {
@@ -116,20 +116,19 @@ export async function diagnoseProject(project, opts = {}) {
116
116
  checks.push({ level: i.level === "error" ? "error" : "warn", subject: "config", message: i.message });
117
117
  if (!v.issues.length)
118
118
  checks.push({ level: "ok", subject: "config", message: "Configuration is valid." });
119
- if (fs.existsSync(project.dir) && isGitRepo(project.dir)) {
120
- if (resolveWorkspaceMode(project.dispatcher) === "worktree") {
119
+ const blocker = fs.existsSync(project.dir) ? isolationBlocker(project.dir) : undefined;
120
+ if (fs.existsSync(project.dir)) {
121
+ if (blocker) {
122
+ checks.push({ level: "info", subject: "git", message: `Runs work directly in ${project.dir}, one agent at a time (${blocker}).` });
123
+ }
124
+ else {
121
125
  const dirty = dirtyPaths(project.dir);
122
- if (dirty.length && project.dispatcher.max_concurrent_runs > 1) {
123
- checks.push({ level: "info", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: concurrent runs start from HEAD and do not see them.` });
124
- }
125
- else if (dirty.length) {
126
- checks.push({ level: "warn", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: a parallel run would refuse to start until they are committed or stashed.` });
127
- }
128
- else
129
- checks.push({ level: "ok", subject: "git", message: "Repo is clean; parallel runs can start." });
126
+ checks.push(dirty.length
127
+ ? { level: "info", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: runs start from HEAD and do not see them.` }
128
+ : { level: "ok", subject: "git", message: "git repo with a commit: runs and agents work in their own worktrees." });
130
129
  }
131
130
  }
132
- if (project.dispatcher.max_concurrent_runs > 1 && fs.existsSync(project.dir) && isGitRepo(project.dir)) {
131
+ if (fs.existsSync(project.dir) && !blocker) {
133
132
  const missing = [];
134
133
  if (!findOnPath("gh", opts.env?.PATH ?? process.env.PATH))
135
134
  missing.push("`gh` is not on PATH");
package/dist/paths.d.ts CHANGED
@@ -26,5 +26,3 @@ export interface ProjectPaths {
26
26
  export declare function projectPaths(home: string, project: string): ProjectPaths;
27
27
  /** True when `child` is `parent` or inside it. */
28
28
  export declare function isInside(parent: string, child: string): boolean;
29
- /** Directory part of a glob that precedes the first wildcard segment. */
30
- export declare function globStaticPrefix(glob: string): string;
package/dist/paths.js CHANGED
@@ -49,14 +49,3 @@ export function isInside(parent, child) {
49
49
  const rel = path.relative(parent, child);
50
50
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
51
51
  }
52
- /** Directory part of a glob that precedes the first wildcard segment. */
53
- export function globStaticPrefix(glob) {
54
- const segs = glob.split("/");
55
- const out = [];
56
- for (const s of segs) {
57
- if (/[*?[\]{}]/.test(s))
58
- break;
59
- out.push(s);
60
- }
61
- return out.join("/");
62
- }