agent-lyceum 0.16.2 → 0.17.1
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 +40 -186
- package/README.zh-TW.md +40 -186
- package/dist/cli.js +7 -4
- package/dist/mailbox.js +60 -0
- package/dist/prompt.js +1 -1
- package/dist/run-outcome.d.ts +10 -0
- package/dist/run-outcome.js +22 -2
- package/dist/run-session.d.ts +9 -0
- package/dist/run-session.js +22 -23
- package/dist/run-store.d.ts +10 -1
- package/dist/run-store.js +7 -3
- package/dist/scaffold.js +2 -2
- package/dist/schema.js +3 -3
- package/dist/status.d.ts +7 -0
- package/dist/status.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,216 +4,70 @@
|
|
|
4
4
|
|
|
5
5
|
**English** | [繁體中文](README.zh-TW.md)
|
|
6
6
|
|
|
7
|
-
|
|
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.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
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.
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
|
-
npx agent-lyceum init
|
|
14
|
+
npx agent-lyceum init # create the home + global agent library
|
|
13
15
|
npx agent-lyceum project add web --dir ~/code/web-app
|
|
14
|
-
npx agent-lyceum doctor --project web
|
|
15
|
-
npx agent-lyceum run --task-file spec.md
|
|
16
|
+
npx agent-lyceum doctor --project web # check config, show enforcement levels
|
|
17
|
+
npx agent-lyceum run --task-file spec.md # inside the repo the project is auto-detected
|
|
16
18
|
npx agent-lyceum status
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged in). macOS and Linux (Linux needs `bwrap` for OS sandboxing); Windows only gets warnings.
|
|
22
|
-
|
|
23
|
-
## Commands
|
|
24
|
-
|
|
25
|
-
| Layer | Command | What it does |
|
|
26
|
-
|---|---|---|
|
|
27
|
-
| Project | `init [-y]` | Create the home and the global agent library. [details](docs/commands.md#init) |
|
|
28
|
-
| Project | `project add <name> --dir <repo>` | Register a project. [details](docs/commands.md#project-add) |
|
|
29
|
-
| Project | `project` / `project remove <name> [--purge]` | List or unregister projects. [details](docs/commands.md#project-and-project-remove) |
|
|
30
|
-
| Diagnose | `doctor [-p name] [--task-file f] [--resolved] [--json]` | Check config (with enforcement levels), git, the lock and runtime CLIs without running agents; `--resolved` prints every effective setting and its source. [details](docs/commands.md#doctor) |
|
|
31
|
-
| Diagnose | `status [-p name] [--monitor]` | Show agents, mail, the current wake-up and past runs (`--json`, `--monitor`); `--task-id` takes an id, a name or a unique prefix. [details](docs/commands.md#status) |
|
|
32
|
-
| Run | `run ["task"] [--task-file f] [--name n] [--agent a] [-p name]` | Give the task to the lead and run until done; `--name` names the run; with `--agent`, to one member alone. [details](docs/commands.md#run) |
|
|
33
|
-
| Run | `resume [run] ["follow-up"] [--task-file f] [--answer] [-p name]` | Continue an interrupted or failed run, or reopen a finished one with a follow-up; `--answer` first answers the questions it is waiting on. [details](docs/commands.md#resume) |
|
|
34
|
-
| Run | `clear <run> [-p name] [--dry-run] [--keep-worktrees]` / `clear --lock [run-id] --force` | Delete a run (id, name or unique prefix) with its task memory and worktrees; or remove the lock a crashed run left behind. [details](docs/commands.md#clear) |
|
|
35
|
-
| Run | `call <agent> ["task"] [--task-file f] [--dir d]` | Call one global agent on its own, outside any project; prints its answer. [details](docs/commands.md#call) |
|
|
36
|
-
| Run | `tidy [-p name] [--agent a] [--layer project\|global] [--dry-run]` | Have agents tidy their own memory (manual only; archives, never deletes). [details](docs/commands.md#tidy) |
|
|
37
|
-
| Run | `tidy --undo <timestamp> -p name --agent a` | Undo a tidy: move its archived files back. [details](docs/commands.md#tidy) |
|
|
38
|
-
|
|
39
|
-
`agent-lyceum --help` lists each command with a one-line summary; `agent-lyceum <command> --help` prints its full description and options.
|
|
21
|
+
Latest unreleased code: `npx github:gogogohuang/agent-lyceum <command>`.
|
|
40
22
|
|
|
41
|
-
|
|
23
|
+
## How it works
|
|
42
24
|
|
|
43
|
-
|
|
25
|
+
1. Your task becomes the first mail to the **lead**.
|
|
26
|
+
2. The dispatcher wakes whichever agent has unread mail (`claude -p` / `codex exec`, headless).
|
|
27
|
+
3. Agents reply by writing mail to their own `outbox/`; the dispatcher checks `can_message` and delivers it.
|
|
28
|
+
4. The run ends when the lead sends `done`.
|
|
44
29
|
|
|
45
|
-
|
|
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).
|
|
46
31
|
|
|
47
|
-
|
|
48
|
-
npx agent-lyceum run "tidy the form" --agent fe-member -p web # a member of the project, as an ordinary run
|
|
49
|
-
npx agent-lyceum call pm "is this ready to build?" --dir ~/code/web-app # a global agent, outside any project
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
`run --agent` has a run id and the project lock, shows in `status`, and can be resumed; the agent ends the run with its own `done` mail. `call` needs no project at all: the agent comes from `team.yaml`, works in `--dir` (default: the current directory, which must not be inside or above the agent-lyceum home), prints only its final answer on stdout, and keeps `task.md`, `log/` and `result.md` in `<home>/calls/<call-id>/`. Details: [run](docs/commands.md#run) and [call](docs/commands.md#call).
|
|
53
|
-
|
|
54
|
-
## Monitor mod (Claude Code)
|
|
55
|
-
|
|
56
|
-
`plugins/status-monitor` is a Claude Code **mod**, not an ordinary plugin: it is written against Claude Code's mod hooks API (`import type { Register } from 'claude-code'`) and runs inside the Claude Code session, where it adds toasts and a `/status-monitor` pane that poll `agent-lyceum status --json`. It needs a Claude Code build that supports mods; it does not change how agent-lyceum itself runs. It shows live run status for one or several projects.
|
|
57
|
-
|
|
58
|
-
**Load it.** Build first (`npm run build`; the mod runs `dist/cli.js`), then start a session with:
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
`--plugin-dir` only applies when a session starts; an already-open session must be restarted (`claude --resume --plugin-dir ...` keeps the conversation). To load it every time, add `alias claude='claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor'` to your shell rc, or, for hosts where you cannot pass a flag (desktop app, SDK), set `CLAUDE_CODE_PLUGIN_DIRS` to the mod folder. While a session is running, saving a file in the mod folder hot-reloads it.
|
|
65
|
-
|
|
66
|
-
**Use it.**
|
|
67
|
-
- **Toasts:** a message pops up when a run finishes, is interrupted, or an agent's wake fails (prefixed `[project]` when several are watched). Nothing to do.
|
|
68
|
-
- **`/status-monitor`:** type it to open (or, if already open, close) the pane. It is off at session start unless `autoOpen` is set. In the **fullscreen layout** (`"tui": "fullscreen"` in `~/.claude/settings.json`, restart the session to apply; `/tui fullscreen` does the same) from 110 terminal columns it docks on the right at half the terminal width, and tabs and **關閉** (Close) can be clicked. Otherwise it shows above the prompt; the layout is fixed when the session starts, so a session started on the main screen stays that way. Keyboard (works in both): `x` closes, `v` switches graph/text, `1`-`9` or Tab/Enter switch task, Esc returns to the prompt, running the command again also closes. It draws one block per agent, with the links between the lead and each member taken from `can_message` (`▼` the lead may write to the member, `▲` the member to the lead, `↕` both; yellow marks the latest hand-off). Each block shows what that agent is doing now, the mail it is handling and the mail queued for it as short readable sections by type (task: goal and acceptance criteria; reply: changes, verification, open items, risks; done: result and what is not done), and its wake count and output tokens. An agent that is working right now is drawn larger: its block gets a row of its own at the full width, and its text wraps instead of being cut, with the message it is handling shown in full (every line of every section); idle and queued blocks stay compact. Above the graph: the run id and state, task, progress bar and checklist; below it: blocked integrations, notes and the result summary. `v` switches to the plain text list (per-runtime tokens, per-agent statistics, the last 8 wakes). Every block lists the agents it may message in a "→" line, with the latest hand-off marked "●" (this also covers member-to-member targets, which get no glyph). When the graph area is narrower than 70 columns (compared with the pane's own width, as the host reports it), the blocks stack in one column and the link glyphs are left out. Each running task is a tab, newest first; finished and interrupted runs are not listed. It refreshes with the polling interval while open.
|
|
69
|
-
|
|
70
|
-
**Configure it.** In `~/.claude/settings.json` under `pluginConfigs."status-monitor@inline"` (`options`):
|
|
71
|
-
|
|
72
|
-
| Option | Meaning | Default |
|
|
73
|
-
|---|---|---|
|
|
74
|
-
| `command` | CLI to run, split on spaces, e.g. `node /path/to/agent-lyceum/dist/cli.js` | `agent-lyceum` |
|
|
75
|
-
| `project` | Registered project name(s) (`-p`); comma-separate to watch several; empty infers from the session's directory | empty |
|
|
76
|
-
| `intervalSeconds` | How often status is polled | `1` |
|
|
77
|
-
| `autoOpen` | Open the pane when a session starts (it seats only from 144 columns when opened unasked) | `false` |
|
|
32
|
+
## Commands
|
|
78
33
|
|
|
79
|
-
|
|
34
|
+
| Command | What it does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `init` | Create the home and the global agent library. [details](docs/commands.md#init) |
|
|
37
|
+
| `project add` / `project` / `project remove` | Register, list or remove projects. [details](docs/commands.md#project-add) |
|
|
38
|
+
| `doctor` | Check config, git, lock and runtime CLIs without running agents. [details](docs/commands.md#doctor) |
|
|
39
|
+
| `status` | Show agents, mail, the current wake-up and past runs. [details](docs/commands.md#status) |
|
|
40
|
+
| `run` | Give the task to the lead and run until done. [details](docs/commands.md#run) |
|
|
41
|
+
| `resume` | Continue an interrupted run, or reopen a finished one with a follow-up. [details](docs/commands.md#resume) |
|
|
42
|
+
| `clear` | Delete a run, or remove a stale lock. [details](docs/commands.md#clear) |
|
|
43
|
+
| `call <agent>` | Ask one global agent on its own, outside any project. [details](docs/commands.md#call) |
|
|
44
|
+
| `tidy` | Have agents tidy their own memory (archives, never deletes). [details](docs/commands.md#tidy) |
|
|
80
45
|
|
|
81
|
-
|
|
46
|
+
`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.
|
|
82
47
|
|
|
83
|
-
|
|
84
|
-
~/agent-lyceum-config/
|
|
85
|
-
├── team.yaml # global agent library
|
|
86
|
-
├── agents/<agent>/{AGENT.md, memory/} # global persona + cross-project memory
|
|
87
|
-
├── calls/<call-id>/{task.md, log/, result.md} # one folder per `call` (outside any project), plus .lock-<agent>
|
|
88
|
-
└── projects/<project>/
|
|
89
|
-
├── project.yaml # team, repo dir, overrides
|
|
90
|
-
├── agents/<agent>/{AGENT.md?, memory/} # project-level persona (optional) + project memory
|
|
91
|
-
├── shared/common/COMMON.md
|
|
92
|
-
├── locks/<run-id>.json # held while that run is active (up to dispatcher.max_concurrent_runs at once)
|
|
93
|
-
├── task-memory/<run-id>/{<agent>/, _shared/} # per-run memory, never shared between runs; _shared = the lead's handoff notes for follow-ups
|
|
94
|
-
└── runs/<run-id>/{task.md, log.jsonl, state.json, result.md, done-<n>.md, followups/<n>.md, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
|
|
95
|
-
```
|
|
48
|
+
## Configuration
|
|
96
49
|
|
|
97
|
-
`team.yaml` defines reusable agents; `project.yaml` picks the team and overrides fields
|
|
50
|
+
`team.yaml` defines reusable agents; `project.yaml` picks the team and overrides fields:
|
|
98
51
|
|
|
99
52
|
```yaml
|
|
100
53
|
# project.yaml
|
|
101
54
|
dir: ~/code/web-app
|
|
102
55
|
team: { lead: lead }
|
|
103
|
-
dispatcher: { max_rounds: 30, max_parallel: 1, max_concurrent_runs: 1, wake_timeout_sec: 600, retry: 1, strict: false, workspace_mode: auto, log_max_bytes: 8388608 }
|
|
104
56
|
agents:
|
|
105
|
-
lead: {
|
|
57
|
+
lead: { can_message: all, can_edit_agent_md: true }
|
|
106
58
|
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
107
|
-
|
|
108
|
-
pm: { can_message: [lead], allow_web: fetch } # pm may search and read pages; the others have no web tools
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Agent fields: `runtime` (`claude-code`|`codex`; optional when `model` is recognizable: `opus`/`sonnet`/`haiku`/`claude-*` → Claude Code, `gpt-*`/`o3`/`*codex*` → Codex; precedence: project runtime > project model > global runtime > global model), `model`, `effort` (Claude Code: `low`|`medium`|`high`|`xhigh`|`max` via `--effort`; Codex: `minimal`|`low`|`medium`|`high`|`xhigh` via `model_reasoning_effort`; unset = CLI default), `agent_md`, `memory.global` / `memory.project`, `resume`, `can_message` (`all` or list; default `[lead]`, lead default `all`), `can_edit_agent_md` (default only the lead), `can_ask_user` (may send `type: ask` mail to you and pause the run for an answer; default only the lead), `allow_web` (`none`|`search`|`fetch`; what the agent may use of the web, off unless set; see "Web access" below), `owns` (repo globs).
|
|
112
|
-
|
|
113
|
-
Rules checked by `doctor`: ≥ 2 agents (the lead and one member; the template has three), the lead is a listed agent, every agent has a runtime (explicit or inferred from `model`) and an existing `AGENT.md`, `effort` is valid for the agent's runtime (warning if `runtime` contradicts a recognizable `model`), `can_message` targets exist, memory dirs don't overlap, and with `max_parallel > 1` every non-lead agent needs non-overlapping `owns` and the repo must be a git repository, and with `max_concurrent_runs > 1` the repo must be a git repository and `workspace_mode` must not be shared.
|
|
114
|
-
|
|
115
|
-
## How a run works
|
|
116
|
-
|
|
117
|
-
1. The task (text, or a copy of `--task-file` kept read-only as `runs/<id>/task.md`) becomes the first mail to the lead. Tasks ≤ 16 KB are inlined; larger ones are passed by reference. Files over 1 MB are rejected.
|
|
118
|
-
2. The dispatcher wakes whichever agent has unread mail with `claude -p` or `codex exec` (headless). Each wake-up is a fresh session unless `resume: true`.
|
|
119
|
-
3. Every wake-up prompt contains the agent's `AGENT.md`, the team protocol, `COMMON.md` (read-only, ≤ 8 KB), the `MEMORY.md` index of each memory dir, the **oldest** unread message in full (one message per wake-up; the lead instead takes a run of up to 5 `reply`/`failure` messages at once and decides on them together), and titles of the queued ones, which stay unread until their own wake-up.
|
|
120
|
-
4. Agents send mail by writing a Markdown file with frontmatter (`to`, `type`, `subject`) into **their own** `outbox/`. The dispatcher checks `can_message`, stamps the real sender, and moves it to the recipient's `inbox/`. Handled mail goes to `inbox/<agent>/read/`. Mailboxes belong to one run (`runs/<run-id>/mail/`), so mail never crosses runs. Runs started by older versions keep using the shared `shared/{inbox,outbox}/` mailboxes (their mail is never moved); resuming such a run shows that legacy layout.
|
|
121
|
-
The body of every `task` and `reply` should carry fixed `##` headings (injected into each agent's prompt): `task` → `Goal`, `Acceptance criteria`, `Scope`, `Upstream`; `reply` → `Changes`, `Verification`, `Open items`, `Risks` (write `None` when empty; `done` is exempt). Mail missing a heading is still delivered, with a warning note prepended and a `format-warning` entry in the run log.
|
|
122
|
-
5. The run ends when the lead sends `type: done`, when all mailboxes are empty, or after `max_rounds` wake-ups. A `done` must declare `outcome: completed|partial|blocked|failed` in its frontmatter and carry `## Result`, `## Files`, `## Verification`, `## Not done`; `completed` also needs every `## Steps` item ticked. A `done` that breaks this is sent back to the lead (twice at most, then it is kept as `partial`). Agent-lyceum does not verify what the lead reports. A failed wake-up is retried once, then reported to the lead as a failure message (if the lead itself fails, the run aborts).
|
|
123
|
-
|
|
124
|
-
Parallelism (`max_parallel > 1`) only runs agents with disjoint `owns`, and never alongside the lead. Each non-lead agent then works in its **own git worktree** (`projects/<name>/worktrees/<run-id>/<agent>`, branch `agent-lyceum/<run-id>/<agent>`) cut from a snapshot of the repo as it is when the agent is woken (`refs/agent-lyceum/<run-id>/base-<n>`; it includes the lead's uncommitted and new, non-ignored files; your HEAD, branches and working tree are not touched). `dispatcher.workspace_mode` is `auto` (worktrees when `max_parallel > 1`), `worktree` or `shared`; `shared` cannot be combined with parallel agents. A parallel run refuses to start while the repo has uncommitted changes of yours, and when the repo is not a git repository (run sequentially instead). When a member finishes, the dispatcher commits its worktree and brings the changes into your working tree (not your index or HEAD) before the lead reads the member's mail. Every changed path is checked first: it must be inside the member's `owns` (renames and deletions included), must not be `CLAUDE.md`/`AGENTS.md`, and symlinks may not point out of the project or the member's `owns`. If a file also changed in your repo since the member's snapshot, if a check fails, or if `git apply` fails, nothing is applied: the work stays on its branch and worktree, a report and patch are written to `runs/<run-id>/integration/`, the lead is told, and the run cannot end as `completed` (it ends `blocked`). A worktree isolates files only: it cannot isolate side effects on external services, databases or the network.
|
|
125
|
-
|
|
126
|
-
### Several runs at once
|
|
127
|
-
|
|
128
|
-
By default a project runs one task at a time. Set `dispatcher.max_concurrent_runs: N` (N > 1, needs a git repository) to let up to N runs of the same project be active together; starting one more is refused and names the runs in the way. In this mode **every** run, including a run that has no parallel members and a solo `run --agent <name>`, works in its own git worktree: the lead in `projects/<name>/worktrees/<run-id>/<lead>` on branch `agent-lyceum/<run-id>/<lead>`, members in worktrees cut from the lead's. A run starts from your repo's `HEAD`, so your uncommitted changes are allowed and are **not** part of it (the start line says how many files that is). Your working tree, index and HEAD are never touched.
|
|
129
|
-
|
|
130
|
-
- **Ending:** when a run ends (done, cancelled, failed, idle or round limit), the lead's work is committed once on its branch and written as `runs/<run-id>/lead.patch`, and the run prints `git merge <branch>`. A run that stops to ask you (`waiting`) is not finished: nothing is committed and no merge hint is printed; its work stays in the lead's worktree and `resume` continues in the same one.
|
|
131
|
-
- **Decided at start:** a run records whether its lead works in a worktree (`lead_workspace` in its state). `resume` follows what the run was started with, not the current setting: a run started concurrent resumes in its worktree even if you lowered `max_concurrent_runs`, and a run started non-concurrent resumes non-concurrently even if you raised it. The setting only governs how many runs may be active at once and what new runs do.
|
|
132
|
-
- **`tidy`:** `tidy` and `tidy --undo` need the project to themselves: they are refused while any run is active, and no run can start while they run, whatever `max_concurrent_runs` is.
|
|
133
|
-
- **`clear`:** `clear <run-id>` refuses while the lead's branch is not merged (use `--keep-worktrees` to keep it). A lock left by a crashed run is removed with `clear --lock [run-id] [-p name] --force`; with several locks, name the run.
|
|
134
|
-
|
|
135
|
-
Known limits:
|
|
136
|
-
|
|
137
|
-
- The number of agent processes can reach `max_concurrent_runs × max_parallel`; there is no global cap.
|
|
138
|
-
- A lead with `can_edit_agent_md: true` edits the `CLAUDE.md`/`AGENTS.md` in its own worktree; the change reaches your repo only when you merge its branch.
|
|
139
|
-
- `COMMON.md` and the `AGENT.md` files are shared by all runs of the project. Each run's guard only judges what changes while its own agents are working, so an edit by one run is kept; but if two runs' wake-ups overlap in time, a change one makes while the other's agents are working can be reverted by the other.
|
|
140
|
-
- Two runs that edit the same file are for you to reconcile when merging; agent-lyceum never merges.
|
|
141
|
-
|
|
142
|
-
### Continuing a finished run
|
|
143
|
-
|
|
144
|
-
Every run has a **name**: made from the first line of the task (lowercase letters, digits and `-`, at most 30 characters; `task` if nothing usable is left; `-2`, `-3` on repeats), or chosen with `run --name <name>` (same characters and length; refused if the name is already used). `resume`, `clear` and `status --task-id` accept the full run id, the name, or a unique prefix of either; an ambiguous prefix lists the candidates and exits `1`, and an empty one is refused. `status` shows the name next to the id.
|
|
145
|
-
|
|
146
|
-
A finished (`done`) run can be reopened with a follow-up request: `agent-lyceum resume [run] "<follow-up>"` (or `--task-file f`). The lead gets the text as a new `task`, and the run goes on with the same run dir, task memory, sessions and lead worktree.
|
|
147
|
-
|
|
148
|
-
- **Which runs:** only a finished run can be reopened. A run that is still running is refused; a run that is not finished is refused when given text (resume it without text first). Without text nothing changes: a finished run just prints its result. Without a run, the candidates are the finished runs, newest first: exactly one is used, none is an error, and several are listed (in a terminal you pick one by number or name; without a terminal the command fails and lists them). A lone argument counts as follow-up text only when it cannot be a run reference (it contains whitespace or a character a run name cannot contain); a one-word follow-up needs `resume <run> "<text>"` or `--task-file`.
|
|
149
|
-
- **What is kept:** the follow-up is saved as `runs/<run-id>/followups/<n>.md`. Each `done` report is kept as `done-<n>.md`, and `result.md` is always the latest. `state.json` gains `name`, `reopened`, `round_floor` and `step_base` (all optional, so older runs still load). The progress (`## Steps`) carries on: the checklist at the reopen stays, and the steps the lead lists for the follow-up are added after it (a step it lists again only takes the new tick); for the follow-up's `completed` only its own steps must be ticked. Unresolved blocked integrations stay; the done-contract reminders start afresh for each reopened segment.
|
|
150
|
-
- **Round limit:** `max_rounds` counts from the reopen: `rounds` keeps accumulating, and the limit is the rounds at reopen plus `max_rounds`.
|
|
151
|
-
- **The lead's branch** (runs with a lead worktree): if the branch was already merged into your `HEAD` and the lead worktree is clean and untouched since the run ended, the worktree and the branch (same name, `agent-lyceum/<run-id>/<lead>`) are re-cut from your current `HEAD`, and a line says so. Otherwise the same worktree and branch continue. If a pull request was already recorded (`pr_url`) and the branch was not re-cut, a new `open_pr: true` only pushes ("pull request updated") while that pull request is still open; if it was merged (a squash merge included) or closed, a new pull request is opened instead. If the branch was re-cut, the recorded `pr_url` is cleared (the old URL stays in the run's notes) and a new pull request is opened when asked. agent-lyceum still never merges.
|
|
152
|
-
- **Handoff notes:** before it sends `done`, the lead writes what a follow-up will need into `task-memory/<run-id>/_shared/` in the agent-lyceum home (`MEMORY.md` is the index): decisions and why, interfaces agreed, current state, what is left and the next steps, pitfalls met. Members can read this folder but not write it (a run started with `run --agent` has one agent, which is its own lead and writes it; `call` has none). Once a run is reopened, every wake-up of every agent gets `MEMORY.md` in its prompt (the first 8 KB; a longer index is cut and flagged), and the reopened `task` mail's `Upstream` names it; the first segment's prompts do not contain it. If the lead sends `done` without having changed `MEMORY.md` in that segment, the run is not blocked: a note `shared-not-updated` is added to the run's notes. `clear` deletes the folder with the task memory; `tidy` does not touch it. It holds notes about this task only; lessons that outlive it belong in project or global memory.
|
|
153
|
-
|
|
154
|
-
### Opening a pull request
|
|
155
|
-
|
|
156
|
-
When your task asks for a pull request, the lead puts `open_pr: true` in the frontmatter of its `done` report. If the run then ends `completed` and the lead's branch has changes, agent-lyceum — not an agent — pushes `agent-lyceum/<run-id>/<lead>` to `origin` and runs `gh pr create` against the repo's default branch, using the lead's report as the description (ending with `🤖 Opened by agent-lyceum`). The run result prints the PR URL, and `state.json` records it as `pr_url`. Only the lead can ask: nobody else can send `done`, and no agent's sandbox is loosened — `git push` and `gh` run outside it, with your own credentials.
|
|
157
|
-
|
|
158
|
-
- It needs a lead branch, so it works only when the run has its own worktree (`dispatcher.max_concurrent_runs` of 2 or more). Otherwise the request is reported and skipped.
|
|
159
|
-
- A request that cannot be honoured (the run did not complete, no changes, no `gh`, no `origin`, push or `gh` failed) never changes how the run turned out. The run says why it was not opened and keeps the branch; when a push or `gh pr create` failed it also prints the commands to finish by hand.
|
|
160
|
-
- Opening a pull request publishes the lead's branch and report to `origin`. An agent that asks for one (`open_pr: true`) can therefore get anything it can read inside its sandbox onto the remote, so only ask for a pull request in a task when that is acceptable. The push and `gh pr create` run from your main repository, never inside the agent's worktree, because an agent can write that directory.
|
|
161
|
-
- agent-lyceum never merges and does not wait for CI or review. With `max_concurrent_runs` of 2 or more, `doctor` tells you whether `gh` and `origin` are there (it does not check that `gh` is logged in).
|
|
162
|
-
|
|
163
|
-
## Web access (`allow_web`)
|
|
164
|
-
|
|
165
|
-
By default no agent can use the web. Claude Code agents are woken in headless `dontAsk` mode, where anything not explicitly allowed is refused, so `WebSearch` and `WebFetch` are denied unless you opt in per agent with `allow_web` (in `team.yaml` or `project.yaml`; the project value wins). It only decides whether the agent may use the web tools; the write scope below is unchanged.
|
|
166
|
-
|
|
167
|
-
| Value | Claude Code | Codex |
|
|
168
|
-
|---|---|---|
|
|
169
|
-
| unset | no web tools (as before) | nothing is passed: Codex keeps its own default |
|
|
170
|
-
| `none` | no web tools | `-c web_search="disabled"` |
|
|
171
|
-
| `search` | `WebSearch` | `-c web_search="cached"` |
|
|
172
|
-
| `fetch` | `WebSearch` and `WebFetch` | `-c web_search="live"` |
|
|
173
|
-
|
|
174
|
-
### `search` or `fetch`?
|
|
175
|
-
|
|
176
|
-
- **`search`**: the agent can run a search and gets a list of results (typically titles, links and short snippets). It cannot open a page, so it only knows what the results say. That is enough for "which Node versions are still supported?", "what is the latest release of this library?" or "is this API deprecated?".
|
|
177
|
-
- **`fetch`**: everything `search` does, plus reading the page behind a URL, so the page's text goes into the agent's context. You need it when the answer is inside a page: a specification, one section of an API reference, an issue thread, or a URL you put in the task.
|
|
178
|
-
|
|
179
|
-
The two are separate levels because the risk is different. Text read from a page can carry instructions written for the agent ("ignore your rules and ..."), known as prompt injection. With `fetch` that text arrives in full; with `search` only snippets do. The more an agent can do (edit your repository, run commands), the more this matters.
|
|
180
|
-
|
|
181
|
-
**Which agents to give it to.** Start with nothing. Give `search` to roles that look things up. Give `fetch` only to roles that really must read specific pages and do little editing, such as a requirements or research role like `pm`. Keep the members that edit your repo (for example `fe-member`, `qa-member`) off, and have the lead or the pm look things up and put the result in the task.
|
|
182
|
-
|
|
183
|
-
### Things to know
|
|
184
|
-
|
|
185
|
-
- **Unset is not `none`.** Unset leaves each runtime's own default alone: Claude Code has no web tools, but Codex may already have web search on. Set `none` to turn it off explicitly.
|
|
186
|
-
- **Codex has one `web_search` setting, not two tools**, so `search` and `fetch` map to `cached` and `live`. As Codex describes them, `cached` answers from an index instead of browsing, and `live` searches the web in real time; this is an approximation by risk. `codex exec` takes no `--search` flag, so agent-lyceum passes `-c web_search="..."`.
|
|
187
|
-
- **Not run end to end.** The Codex setting and its allowed values were checked against Codex 0.160.0, but the behaviour of `cached` / `live` and of Claude's two tools was not exercised against the real CLIs (that costs tokens). Try it once on the agent before relying on it.
|
|
188
|
-
- `call` reads the field from `team.yaml`, and `run --agent` keeps it.
|
|
189
|
-
|
|
190
|
-
```yaml
|
|
191
|
-
# team.yaml
|
|
192
|
-
agents:
|
|
193
|
-
pm:
|
|
194
|
-
runtime: claude-code
|
|
195
|
-
allow_web: fetch # may search and read pages; agents without the field have no web tools
|
|
59
|
+
pm: { can_message: [lead], allow_web: fetch }
|
|
196
60
|
```
|
|
197
61
|
|
|
198
|
-
|
|
62
|
+
Folder layout, every agent field and the rules `doctor` checks: [Configuration](docs/configuration.md).
|
|
199
63
|
|
|
200
|
-
|
|
64
|
+
## Topics
|
|
201
65
|
|
|
202
|
-
|
|
|
66
|
+
| Topic | Read |
|
|
203
67
|
|---|---|
|
|
204
|
-
|
|
|
205
|
-
|
|
|
206
|
-
|
|
|
207
|
-
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- **Claude Code**: per-run `--settings` with `Edit(...)` rules **plus** the OS sandbox (`allowWrite`/`denyWrite`, `allowUnsandboxedCommands: false`). Without the sandbox, `python -c` and similar bypass Edit rules, so the sandbox is required for the `os` level.
|
|
212
|
-
- **Codex**: `-s workspace-write` with `-c sandbox_workspace_write.writable_roots=[...]` (OS-enforced). Codex cannot forbid a single file inside the repo, so repo `CLAUDE.md`/`AGENTS.md` are protected by **post-hoc detection** only.
|
|
213
|
-
- **All runtimes**: protected files are hashed before the run; after each wake-up unauthorized changes are reverted, logged, and reported to the lead; what was written is kept in `runs/<run-id>/violations/` first.
|
|
214
|
-
|
|
215
|
-
`doctor` and `run` print every agent's level per category (`os`, `tool-rules`, `post-hoc`, `prompt-only`) and the paths it can write outside the repo. With `dispatcher.strict: true`, a run is refused unless memory, `AGENT.md`, other agents' context and (for non-lead agents) the handoff notes are OS-enforced.
|
|
216
|
-
|
|
217
|
-
Git in agent worktrees: an agent can write its worktree's `.git` pointer file and its private git dir, which could make git read a config or hooks the agent planted. So every git call the dispatcher makes into an agent's worktree pins the git dir, common dir and work tree recorded when the worktree was made, turns off hooks, fsmonitor, external diff and textconv, and refuses a worktree whose git files were redirected or linked elsewhere, whose HEAD is not its own branch, or that contains a nested repository. Claude additionally may not write those pointer files (`denyWrite`); Codex cannot forbid single files and relies on the pinning alone. Residual risks are listed in [spec 15](docs/specs/15-worktree-git-isolation.md#156-殘留風險). This does not claim the dispatcher can never run code an agent planted. A repository using the `reftable` ref backend is refused in worktree modes.
|
|
218
|
-
|
|
219
|
-
Known gaps: Claude Code's built-in Edit/Write tools are not sandboxed (covered by the `Edit` rules); Codex MCP tools and hooks run outside its sandbox; `--dangerously-bypass-approvals-and-sandbox` / `danger-full-access` disable everything. With `can_edit_agent_md`, a Codex lead's writable roots widen to whole agent directories.
|
|
68
|
+
| Run lifecycle, parallel agents, several runs at once, follow-ups, opening a PR | [How a run works](docs/how-it-works.md) |
|
|
69
|
+
| What agents may write, sandbox levels, known gaps | [Write scope](docs/write-scope.md) |
|
|
70
|
+
| Letting an agent search or fetch the web | [Web access](docs/web-access.md) |
|
|
71
|
+
| Live run status inside Claude Code | [Monitor mod](docs/monitor.md) |
|
|
72
|
+
| All commands in full | [Commands](docs/commands.md) |
|
|
73
|
+
| Coming from an older version | [Upgrading to run v2](docs/upgrading-run-v2.md) |
|
package/README.zh-TW.md
CHANGED
|
@@ -4,216 +4,70 @@
|
|
|
4
4
|
|
|
5
5
|
[English](README.md) | **繁體中文**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
在你的 repo 上執行一個由多個 **agent**(Claude Code 和/或 Codex)組成的團隊。agent 之間透過檔案信箱溝通,各自有人設(`AGENT.md`)與長期記憶。agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## 快速開始
|
|
10
|
+
|
|
11
|
+
需求:Node 20+,以及 `PATH` 中有 `claude` 和/或 `codex`(已登入)。支援 macOS 與 Linux(Linux 需要 `bwrap` 才有 OS 沙箱);Windows 只會顯示警告。
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
|
-
npx agent-lyceum init
|
|
14
|
+
npx agent-lyceum init # 建立 home 與全域 agent 庫
|
|
13
15
|
npx agent-lyceum project add web --dir ~/code/web-app
|
|
14
|
-
npx agent-lyceum doctor --project web
|
|
15
|
-
npx agent-lyceum run --task-file spec.md
|
|
16
|
+
npx agent-lyceum doctor --project web # 檢查設定並顯示各項防護等級
|
|
17
|
+
npx agent-lyceum run --task-file spec.md # 在 repo 內執行時會自動偵測專案
|
|
16
18
|
npx agent-lyceum status
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
需求:Node 20+,以及 `PATH` 中有 `claude` 和/或 `codex`(且已登入)。支援 macOS 與 Linux(Linux 需要 `bwrap` 才有 OS 沙箱);Windows 只會顯示警告。
|
|
22
|
-
|
|
23
|
-
## 指令
|
|
24
|
-
|
|
25
|
-
| 層 | 指令 | 功能 |
|
|
26
|
-
|---|---|---|
|
|
27
|
-
| 專案 | `init [-y]` | 建立 home 與全域 agent 庫。 [詳細](docs/commands.zh-TW.md#init) |
|
|
28
|
-
| 專案 | `project add <name> --dir <repo>` | 註冊專案。 [詳細](docs/commands.zh-TW.md#project-add) |
|
|
29
|
-
| 專案 | `project` / `project remove <name> [--purge]` | 列出或取消註冊專案。 [詳細](docs/commands.zh-TW.md#project-and-project-remove) |
|
|
30
|
-
| 診斷 | `doctor [-p name] [--task-file f] [--resolved] [--json]` | 不執行 agent,檢查設定(含防護等級)、git、鎖與 runtime CLI;`--resolved` 印出每項生效設定與其來源。 [詳細](docs/commands.zh-TW.md#doctor) |
|
|
31
|
-
| 診斷 | `status [-p name] [--monitor]` | 顯示 agent、信件、目前執行與歷次 run(支援 `--json`、`--monitor`);`--task-id` 接受 id、名稱或唯一前綴。 [詳細](docs/commands.zh-TW.md#status) |
|
|
32
|
-
| 執行 | `run ["task"] [--task-file f] [--name n] [--agent a] [-p name]` | 把任務交給 lead 並執行到結束;`--name` 為 run 命名;加 `--agent` 則只交給某個成員單獨做。 [詳細](docs/commands.zh-TW.md#run) |
|
|
33
|
-
| 執行 | `resume [run] ["追加要求"] [--task-file f] [--answer] [-p name]` | 接續被中斷或失敗的 run,或用追加要求重開已完成的 run;`--answer` 會先回答它等待中的問題。 [詳細](docs/commands.zh-TW.md#resume) |
|
|
34
|
-
| 執行 | `clear <run> [-p name] [--dry-run] [--keep-worktrees]` / `clear --lock [run-id] --force` | 刪除 run(id、名稱或唯一前綴)與其任務記憶、worktree;或移除當機 run 留下的專案鎖。 [詳細](docs/commands.zh-TW.md#clear) |
|
|
35
|
-
| 執行 | `call <agent> ["task"] [--task-file f] [--dir d]` | 單獨呼叫一個 global agent(不需專案),印出它的回答。 [詳細](docs/commands.zh-TW.md#call) |
|
|
36
|
-
| 執行 | `tidy [-p name] [--agent a] [--layer project\|global] [--dry-run]` | 讓 agent 整理自己的記憶(只能手動觸發;封存而非刪除)。 [詳細](docs/commands.zh-TW.md#tidy) |
|
|
37
|
-
| 執行 | `tidy --undo <時間戳> -p name --agent a` | 還原一次整理:把封存的檔案搬回。 [詳細](docs/commands.zh-TW.md#tidy) |
|
|
38
|
-
|
|
39
|
-
`agent-lyceum --help` 只列出每個指令的一行簡述;`agent-lyceum <指令> --help` 會印出該指令的完整說明與選項。
|
|
21
|
+
要跑尚未發佈的最新程式碼:`npx github:gogogohuang/agent-lyceum <command>`。
|
|
40
22
|
|
|
41
|
-
|
|
23
|
+
## 運作方式
|
|
42
24
|
|
|
43
|
-
|
|
25
|
+
1. 你的任務會成為寄給 **lead** 的第一封信。
|
|
26
|
+
2. dispatcher 喚醒有未讀信的 agent(無頭模式的 `claude -p` / `codex exec`)。
|
|
27
|
+
3. agent 把回信寫進自己的 `outbox/`,dispatcher 檢查 `can_message` 後投遞。
|
|
28
|
+
4. lead 寄出 `done` 時,run 結束。
|
|
44
29
|
|
|
45
|
-
|
|
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)。
|
|
46
31
|
|
|
47
|
-
|
|
48
|
-
npx agent-lyceum run "整理表單" --agent fe-member -p web # 專案裡的一個成員,當作一般 run
|
|
49
|
-
npx agent-lyceum call pm "這個需求可以開工了嗎?" --dir ~/code/web-app # global agent,不屬於任何專案
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
`run --agent` 有 run id 與專案鎖、會出現在 `status`、可以 `resume`;agent 用自己寄出的 `done` 結束 run。`call` 完全不需要專案:agent 取自 `team.yaml`,在 `--dir`(預設是目前目錄,且不可位於 agent-lyceum home 之內或之上)工作,stdout 只印它的最終回答,並在 `<home>/calls/<call-id>/` 留下 `task.md`、`log/` 與 `result.md`。細節見 [run](docs/commands.zh-TW.md#run) 與 [call](docs/commands.zh-TW.md#call)。
|
|
53
|
-
|
|
54
|
-
## 監控 mod(Claude Code)
|
|
55
|
-
|
|
56
|
-
`plugins/status-monitor` 是 Claude Code **mod**,不是一般的 plugin:它依 Claude Code 的 mod hooks API 撰寫(`import type { Register } from 'claude-code'`),在 Claude Code session 內執行,提供通知與 `/status-monitor` 面板,並輪詢 `agent-lyceum status --json`。需要支援 mod 的 Claude Code 版本;不會改變 agent-lyceum 本身的運作。會顯示一個或多個專案的即時執行狀態。
|
|
57
|
-
|
|
58
|
-
**載入。** 先 build(`npm run build`;mod 會執行 `dist/cli.js`),再用下面的指令啟動 session:
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
`--plugin-dir` 只在 session 啟動時生效,已開著的 session 要重開(`claude --resume --plugin-dir ...` 可保留對話)。想每次都載入,就在 shell rc 加 `alias claude='claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor'`;無法帶參數的環境(桌面 app、SDK)則把 `CLAUDE_CODE_PLUGIN_DIRS` 設成 mod 資料夾。session 執行中儲存 mod 資料夾內的檔案會自動 hot reload。
|
|
65
|
-
|
|
66
|
-
**操作。**
|
|
67
|
-
- **通知:** 執行完成、被中斷或某個 agent 喚醒失敗時會跳出訊息(監看多個專案時前面加 `[專案名]`),不需要任何操作。
|
|
68
|
-
- **`/status-monitor`:** 輸入即開啟面板(已開著則關閉)。session 啟動時預設不開,除非設定 `autoOpen`。在 **fullscreen 版面**(`~/.claude/settings.json` 設 `"tui": "fullscreen"` 後重開 session;`/tui fullscreen` 效果相同)且終端寬度 110 欄以上時,面板會靠右 dock 在終端寬度的一半,分頁與 **關閉** 都可以用滑鼠點。否則顯示在輸入框上方;版面在 session 啟動時就固定,從主畫面啟動的 session 不會變。鍵盤操作兩種版面都能用:`x` 關閉,`v` 切換圖形/文字,`1`-`9` 或 Tab/Enter 切換任務,Esc 回到輸入框,再執行一次指令也會關閉。面板為每個 agent 畫一個區塊,並依 `can_message` 在 lead 與各成員之間畫出連結(`▼` lead 可寄給成員、`▲` 成員可寄給 lead、`↕` 雙向;黃色表示最近一次寄信的那條邊)。每個區塊顯示該 agent 現在在做什麼、正在處理與排隊中的信(依信種整理成可讀的重點:task 看目標與驗收,reply 看變更、驗證、待辦與風險,done 看結果與未完成),以及喚醒次數與輸出 token。正在運作的 agent 會被放大:它的區塊獨佔一整列、使用全寬,文字自動換行而不截斷,正在處理的那封信完整顯示(每個段落的每一行);閒置與排隊中的區塊維持精簡。圖形上方是執行 id 與狀態、任務、進度條與清單;下方是受阻的整合、備註與結果摘要。按 `v` 切換成純文字清單(各 runtime 的 token、各 agent 統計、最近 8 次喚醒)。每個區塊以「→」一行列出它可以寄信的對象,最近一次寄信的對象標 「●」(成員之間的對象也在此,不畫符號)。當圖形區域寬度少於 70 欄(以面板本身的寬度比較,由 host 回報),區塊改成單欄堆疊,不畫連結符號。每個執行中的任務是一個分頁,最新的排最前面;已結束或已中斷的不列出。面板開著時會依輪詢間隔自動更新。
|
|
69
|
-
|
|
70
|
-
**設定。** 在 `~/.claude/settings.json` 的 `pluginConfigs."status-monitor@inline"`(`options`):
|
|
71
|
-
|
|
72
|
-
| 選項 | 說明 | 預設 |
|
|
73
|
-
|---|---|---|
|
|
74
|
-
| `command` | 要執行的 CLI,以空白分隔,例如 `node /path/to/agent-lyceum/dist/cli.js` | `agent-lyceum` |
|
|
75
|
-
| `project` | 已註冊的專案名稱(`-p`);用逗號分隔可同時監看多個;留空則依 session 所在目錄推斷 | 空 |
|
|
76
|
-
| `intervalSeconds` | 輪詢狀態的間隔(秒) | `1` |
|
|
77
|
-
| `autoOpen` | session 啟動時自動開啟面板(未經操作開啟時,終端需 144 欄以上才會顯示) | `false` |
|
|
32
|
+
## 指令
|
|
78
33
|
|
|
79
|
-
|
|
34
|
+
| 指令 | 功能 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `init` | 建立 home 與全域 agent 庫。 [詳細](docs/commands.zh-TW.md#init) |
|
|
37
|
+
| `project add` / `project` / `project remove` | 註冊、列出或移除專案。 [詳細](docs/commands.zh-TW.md#project-add) |
|
|
38
|
+
| `doctor` | 不執行 agent,檢查設定、git、鎖與 runtime CLI。 [詳細](docs/commands.zh-TW.md#doctor) |
|
|
39
|
+
| `status` | 顯示 agent、信件、目前的喚醒與過去的 run。 [詳細](docs/commands.zh-TW.md#status) |
|
|
40
|
+
| `run` | 把任務交給 lead,執行到完成。 [詳細](docs/commands.zh-TW.md#run) |
|
|
41
|
+
| `resume` | 繼續中斷的 run,或用追加需求重開已完成的 run。 [詳細](docs/commands.zh-TW.md#resume) |
|
|
42
|
+
| `clear` | 刪除 run,或移除殘留的鎖。 [詳細](docs/commands.zh-TW.md#clear) |
|
|
43
|
+
| `call <agent>` | 在任何專案之外單獨呼叫一個全域 agent。 [詳細](docs/commands.zh-TW.md#call) |
|
|
44
|
+
| `tidy` | 讓 agent 整理自己的記憶(只封存,不刪除)。 [詳細](docs/commands.zh-TW.md#tidy) |
|
|
80
45
|
|
|
81
|
-
|
|
46
|
+
`agent-lyceum <指令> --help` 會印出完整選項。Ctrl-C 可乾淨取消;腳本可依[結束碼](docs/commands.zh-TW.md#退出碼)判斷 run 的結果。
|
|
82
47
|
|
|
83
|
-
|
|
84
|
-
~/agent-lyceum-config/
|
|
85
|
-
├── team.yaml # 全域 agent 庫
|
|
86
|
-
├── agents/<agent>/{AGENT.md, memory/} # 全域人設 + 跨專案記憶
|
|
87
|
-
├── calls/<call-id>/{task.md, log/, result.md} # 每次 `call`(不屬於任何專案)一個資料夾,另有 .lock-<agent>
|
|
88
|
-
└── projects/<project>/
|
|
89
|
-
├── project.yaml # 團隊、repo 路徑、覆寫設定
|
|
90
|
-
├── agents/<agent>/{AGENT.md?, memory/} # 專案層人設(選用)+ 專案記憶
|
|
91
|
-
├── shared/common/COMMON.md
|
|
92
|
-
├── locks/<run-id>.json # 該 run 執行期間持有(同時最多 dispatcher.max_concurrent_runs 個)
|
|
93
|
-
├── task-memory/<run-id>/{<agent>/, _shared/} # 任務記憶:每次 run 獨立,不與其他任務共用;_shared 是 lead 為追加要求寫的交接筆記
|
|
94
|
-
└── runs/<run-id>/{task.md, log.jsonl, state.json, result.md, done-<n>.md, followups/<n>.md, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
|
|
95
|
-
```
|
|
48
|
+
## 設定
|
|
96
49
|
|
|
97
|
-
`team.yaml` 定義可重複使用的 agent;`project.yaml`
|
|
50
|
+
`team.yaml` 定義可重複使用的 agent;`project.yaml` 選定團隊並覆寫欄位:
|
|
98
51
|
|
|
99
52
|
```yaml
|
|
100
53
|
# project.yaml
|
|
101
54
|
dir: ~/code/web-app
|
|
102
55
|
team: { lead: lead }
|
|
103
|
-
dispatcher: { max_rounds: 30, max_parallel: 1, max_concurrent_runs: 1, wake_timeout_sec: 600, retry: 1, strict: false, workspace_mode: auto, log_max_bytes: 8388608 }
|
|
104
56
|
agents:
|
|
105
|
-
lead: {
|
|
57
|
+
lead: { can_message: all, can_edit_agent_md: true }
|
|
106
58
|
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
107
|
-
|
|
108
|
-
pm: { can_message: [lead], allow_web: fetch } # pm 可以搜尋並讀網頁;其他人沒有網路工具
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可省略:`opus`/`sonnet`/`haiku`/`claude-*` → Claude Code,`gpt-*`/`o3`/`*codex*` → Codex;優先順序:專案 runtime > 專案 model > 全域 runtime > 全域 model)、`model`、`effort`(Claude Code:`low`|`medium`|`high`|`xhigh`|`max`,經 `--effort`;Codex:`minimal`|`low`|`medium`|`high`|`xhigh`,經 `model_reasoning_effort`;未設則用 CLI 預設)、`agent_md`、`memory.global` / `memory.project`、`resume`、`can_message`(`all` 或清單;預設 `[lead]`,lead 預設 `all`)、`can_edit_agent_md`(預設只有 lead)、`can_ask_user`(可用 `type: ask` 信件向你提問並暫停 run 等待回答;預設只有 lead)、`allow_web`(`none`|`search`|`fetch`:agent 可以使用多少網路,不設就是不開;見下方「網路存取」)、`owns`(repo glob)。
|
|
112
|
-
|
|
113
|
-
`doctor` 檢查的規則:至少 2 個 agent(lead 加一位成員;範本預設是三位)、lead 在 agent 清單內、每個 agent 都有 runtime(明設或由 `model` 推斷)且 `AGENT.md` 存在、`effort` 對該 runtime 合法(若 `runtime` 與可辨識的 `model` 矛盾則警告)、`can_message` 的目標存在、記憶資料夾互不重疊,且當 `max_parallel > 1` 時,每個非 lead 的 agent 都必須有互不重疊的 `owns`,repo 也必須是 git repository;而 `max_concurrent_runs > 1` 時,repo 必須是 git repository,且 `workspace_mode` 不能是 `shared`。
|
|
114
|
-
|
|
115
|
-
## 一次執行如何運作
|
|
116
|
-
|
|
117
|
-
1. 任務(文字,或 `--task-file` 的唯讀副本 `runs/<id>/task.md`)會成為寄給 lead 的第一封信。≤ 16 KB 的任務直接內嵌,更大的改以引用方式傳遞。超過 1 MB 的檔案會被拒絕。
|
|
118
|
-
2. dispatcher 用 `claude -p` 或 `codex exec`(headless)喚醒有未讀信件的 agent。除非設定 `resume: true`,否則每次喚醒都是全新的 session。
|
|
119
|
-
3. 每次喚醒的 prompt 都包含:該 agent 的 `AGENT.md`、團隊協定、`COMMON.md`(唯讀,≤ 8 KB)、每個記憶資料夾的 `MEMORY.md` 索引、**最舊**一封未讀信件全文(每次喚醒只處理一封;lead 例外,會一次拿到連續最多 5 封 `reply`/`failure` 並一併決策),以及排隊中信件的標題;排隊的信維持未讀,等各自的喚醒再處理。
|
|
120
|
-
4. agent 寄信的方式,是在**自己的** `outbox/` 寫一個含 frontmatter(`to`、`type`、`subject`)的 Markdown 檔。dispatcher 會檢查 `can_message`、蓋上真正的寄件者,並移到收件者的 `inbox/`。處理完的信件移至 `inbox/<agent>/read/`。信箱屬於單一 run(`runs/<run-id>/mail/`),信件不會跨 run。舊版本啟動的 run 繼續使用共用的 `shared/{inbox,outbox}/` 信箱(信件不會被搬移),接續這類 run 時會沿用舊版配置。
|
|
121
|
-
每封 `task` 和 `reply` 的內文應包含固定的 `##` 標題(會注入每個 agent 的 prompt):`task` → `Goal`、`Acceptance criteria`、`Scope`、`Upstream`;`reply` → `Changes`、`Verification`、`Open items`、`Risks`(沒內容就寫 `None`;`done` 免檢)。缺標題的信仍會送達,但開頭會加上警告說明,run log 也會記一筆 `format-warning`。
|
|
122
|
-
5. 結束條件:lead 寄出 `type: done`、所有信箱都空了,或喚醒次數達到 `max_rounds`。`done` 的 frontmatter 必須有 `outcome: completed|partial|blocked|failed`,內文要有 `## Result`、`## Files`、`## Verification`、`## Not done`;`completed` 還要求 `## Steps` 全部勾選。不符合的 `done` 會退回給 lead(最多兩次,之後以 `partial` 收下)。agent-lyceum 不會驗證 lead 回報的內容是否屬實。喚醒失敗會重試一次,之後以失敗訊息通知 lead(若 lead 本身失敗,則整個 run 中止)。
|
|
123
|
-
|
|
124
|
-
平行執行(`max_parallel > 1`)只會同時跑 `owns` 互不相交的 agent,且絕不與 lead 同時執行。此時每個非 lead 的 agent 都在**自己的 git worktree**(`projects/<name>/worktrees/<run-id>/<agent>`,branch `agent-lyceum/<run-id>/<agent>`)工作;worktree 是從喚醒當下 repo 的快照(`refs/agent-lyceum/<run-id>/base-<n>`,包含 lead 尚未提交的修改與新增、未被 ignore 的檔案)切出來的,你的 HEAD、branch 與工作目錄都不會被動到。`dispatcher.workspace_mode` 可設為 `auto`(`max_parallel > 1` 時用 worktree)、`worktree` 或 `shared`;`shared` 不能與平行 agent 並用。平行 run 在 repo 有你自己未提交的修改時,或 repo 不是 git repository 時,會拒絕啟動(請改為序列執行)。成員完成後,dispatcher 會提交它 worktree 中的修改,並在 lead 讀到該成員的信之前,把變更套用到你的工作目錄(不動你的 index 與 HEAD)。每個被改動的路徑都會先檢查:必須在該成員的 `owns` 內(rename 與刪除也算)、不得是 `CLAUDE.md`/`AGENTS.md`,symlink 不得指向專案或該成員 `owns` 之外。若同一檔案在成員的快照之後又在你的 repo 被改過、檢查未通過,或 `git apply` 失敗,就一個檔案都不套用:成果保留在它的 branch 與 worktree,報告與 patch 寫到 `runs/<run-id>/integration/`,並通知 lead,且這個 run 不能以 `completed` 結束(會以 `blocked` 結束)。worktree 只隔離檔案,無法隔離對外部服務、資料庫或網路的副作用。
|
|
125
|
-
|
|
126
|
-
### 同時執行多個 task
|
|
127
|
-
|
|
128
|
-
預設一個專案一次只跑一個 task。設定 `dispatcher.max_concurrent_runs: N`(N > 1,需要 git repository)後,同一專案最多可有 N 個 run 同時進行;再多開一個會被拒絕,並列出擋住的 run。此模式下**每個** run(包括沒有平行成員的 run,以及單獨的 `run --agent <name>`)都在自己的 git worktree 工作:lead 在 `projects/<name>/worktrees/<run-id>/<lead>`、branch 為 `agent-lyceum/<run-id>/<lead>`,成員的 worktree 則從 lead 的內容分出。run 從你 repo 的 `HEAD` 起跑,所以允許你有未提交的修改,但這些修改**不會**在其中(啟動訊息會寫出有幾個檔案)。你的工作目錄、index 與 HEAD 一律不會被動到。
|
|
129
|
-
|
|
130
|
-
- **結束:** run 結束時(done、cancelled、failed、idle 或達到輪數上限),lead 的成果會一次提交在它的 branch 上,並寫成 `runs/<run-id>/lead.patch`,run 會印出 `git merge <branch>`。停下來向你提問(`waiting`)的 run 還沒結束:不會提交、也不會印 merge 提示;成果留在 lead 的 worktree,`resume` 會在同一個 worktree 繼續。
|
|
131
|
-
- **啟動時決定:** run 會在狀態中記錄 lead 是否在 worktree 工作(`lead_workspace`)。`resume` 沿用 run 啟動時的方式,而不是看目前的設定:以併發模式啟動的 run,即使你之後調低 `max_concurrent_runs`,仍會在它的 worktree 接續;以非併發模式啟動的 run,即使你之後調高,仍以非併發方式接續。這個設定只決定同時可有幾個 run,以及新的 run 怎麼做。
|
|
132
|
-
- **`tidy`:** `tidy` 與 `tidy --undo` 需要獨占專案:有任何 run 在執行時會拒絕,執行期間也不能啟動任何 run,與 `max_concurrent_runs` 的值無關。
|
|
133
|
-
- **`clear`:** lead 的 branch 尚未合併時,`clear <run-id>` 會拒絕(用 `--keep-worktrees` 可保留它)。當機 run 留下的鎖用 `clear --lock [run-id] [-p name] --force` 移除;有多把鎖時要指定是哪個 run。
|
|
134
|
-
|
|
135
|
-
已知限制:
|
|
136
|
-
|
|
137
|
-
- agent 程序數量最多可達 `max_concurrent_runs × max_parallel`,沒有全域上限。
|
|
138
|
-
- `can_edit_agent_md: true` 的 lead 修改的是它自己 worktree 裡的 `CLAUDE.md`/`AGENTS.md`;這些修改要等你合併它的 branch 才會進到你的 repo。
|
|
139
|
-
- `COMMON.md` 與各 `AGENT.md` 由專案的所有 run 共用。每個 run 的防護只判斷它自己的 agent 工作期間發生的修改,所以某個 run 的修改會被保留;但若兩個 run 的喚醒時間重疊,一個 run 在另一個 run 的 agent 工作期間所做的修改,可能被另一個 run 還原。
|
|
140
|
-
- 兩個 run 修改同一個檔案時,要由你在合併時自行整合;agent-lyceum 從不自動合併。
|
|
141
|
-
|
|
142
|
-
### 接續已完成的 run
|
|
143
|
-
|
|
144
|
-
每個 run 都有**名稱**:由 task 第一行產生(小寫字母、數字與 `-`,最多 30 字元;沒有可用字元時用 `task`;重複時加 `-2`、`-3`),或用 `run --name <name>` 指定(字元與長度限制相同;名稱已被使用會拒絕)。`resume`、`clear` 與 `status --task-id` 接受完整 run id、名稱,或兩者之一的唯一前綴;前綴有歧義時會列出候選並以 `1` 結束,空字串會被拒絕。`status` 會在 id 旁顯示名稱。
|
|
145
|
-
|
|
146
|
-
已完成(`done`)的 run 可以用追加要求重開:`agent-lyceum resume [run] "<追加要求>"`(或 `--task-file f`)。lead 會把這段文字當成新的 `task` 收到,run 沿用同一個 run 目錄、任務記憶、session 與 lead 的 worktree 繼續。
|
|
147
|
-
|
|
148
|
-
- **哪些 run:** 只有已完成的 run 能重開。仍在執行的 run 會拒絕;尚未完成的 run 若帶文字也會拒絕(先不帶文字 resume 它)。不帶文字時行為不變:已完成的 run 只印出結果。沒指定 run 時,候選是已完成的 run,由新到舊:剛好一個就用它,沒有就報錯,有多個就列出(在終端機可用編號或名稱選;非互動環境則報錯並列出候選)。只有一個參數時,只有在它不可能是 run 參照(含空白,或含 run 名稱不能用的字元)才會被當成追加要求;單一個詞的追加要求要寫成 `resume <run> "<text>"` 或用 `--task-file`。
|
|
149
|
-
- **保留什麼:** 追加要求存成 `runs/<run-id>/followups/<n>.md`。每份 `done` 報告保留為 `done-<n>.md`,`result.md` 永遠是最新一份。`state.json` 新增 `name`、`reopened`、`round_floor`、`step_base`(都是選填,舊的 run 照樣能讀)。進度(`## Steps`)會接續:重開時的清單保留,lead 為追加要求列的步驟接在後面(再次列出的舊步驟只更新勾選狀態);追加要求的 `completed` 只要求它自己的步驟全部勾選。未解決的 blocked integration 會保留;每段重開的 done 規範提醒重新計算。
|
|
150
|
-
- **輪數上限:** `max_rounds` 從重開時起算:`rounds` 持續累加,上限是重開當下的輪數加 `max_rounds`。
|
|
151
|
-
- **lead 的 branch**(lead 有 worktree 的 run):若該 branch 已合併進你的 `HEAD`,且 lead 的 worktree 乾淨、自 run 結束後沒被動過,worktree 與 branch(同名,`agent-lyceum/<run-id>/<lead>`)會從你目前的 `HEAD` 重新切出,並印一行說明。否則沿用原本的 worktree 與 branch。若已記錄 PR(`pr_url`)且 branch 沒有重切,新的 `open_pr: true` 只會 push(「pull request updated」),而且只在該 PR 仍開啟時;若它已被合併(含 squash merge)或關閉,就改開一個新的 PR;若 branch 重切了,已記錄的 `pr_url` 會被清掉(舊網址留在 run 的 notes),要求時再開一個新的 PR。agent-lyceum 仍然從不合併。
|
|
152
|
-
- **交接筆記:** lead 在送 `done` 之前,把追加要求會用到的內容寫進 agent-lyceum home 裡的 `task-memory/<run-id>/_shared/`(`MEMORY.md` 為索引):做過的決定與理由、介面約定、目前狀態、剩下的事與下一步、踩過的坑。成員可讀不可寫(用 `run --agent` 啟動的 run 只有一個 agent,它就是自己的 lead,所以可寫;`call` 沒有這個資料夾)。run 重開後,每個 agent 的每次喚醒都在 prompt 裡拿到 `MEMORY.md`(前 8 KB,較長的索引會被截斷並標示),重開的 `task` 信的 `Upstream` 也會指向它;第一段的 prompt 不含它。若 lead 在該段沒有更動 `MEMORY.md` 就送出 `done`,run 不會被擋,只會在 run 的 notes 加一筆 `shared-not-updated`。`clear` 會連同任務記憶一併刪除;`tidy` 不會動它。只放這個 task 的筆記,跨 task 的教訓仍放 project 或 global memory。
|
|
153
|
-
|
|
154
|
-
### 開 PR
|
|
155
|
-
|
|
156
|
-
當你的 task 要求開 PR 時,lead 會在它的 `done` 報告的 frontmatter 加上 `open_pr: true`。如果 run 之後以 `completed` 結束,且 lead 的 branch 有變更,就由 agent-lyceum(不是 agent)把 `agent-lyceum/<run-id>/<lead>` 推到 `origin`,並對 repo 的預設分支執行 `gh pr create`,以 lead 的報告作為描述(結尾為 `🤖 Opened by agent-lyceum`)。run 的結果會印出 PR 網址,`state.json` 也會把它記為 `pr_url`。只有 lead 能提出要求:其他人都不能送出 `done`,也沒有任何 agent 的沙箱被放寬——`git push` 與 `gh` 在沙箱外以你自己的憑證執行。
|
|
157
|
-
|
|
158
|
-
- 它需要 lead 的 branch,所以只有在 run 有自己的 worktree 時(`dispatcher.max_concurrent_runs` 為 2 或以上)才有作用。否則這個要求會被回報並略過。
|
|
159
|
-
- 無法照辦的要求(run 沒有完成、沒有變更、沒有 `gh`、沒有 `origin`、push 或 `gh` 失敗)絕不會改變 run 的結果。run 會說明沒有開的原因並保留 branch;push 或 `gh pr create` 失敗時,還會印出可手動完成的指令。
|
|
160
|
-
- 開 PR 會把 lead 的 branch 與報告發布到 `origin`。因此,提出要求(`open_pr: true`)的 agent 能把它在沙箱內讀得到的任何東西送上遠端,所以只有在可以接受這件事時,才在 task 裡要求開 PR。push 與 `gh pr create` 從你的主 repo 執行,絕不在 agent 的 worktree 裡執行,因為 agent 能寫入那個目錄。
|
|
161
|
-
- agent-lyceum 從不合併,也不等待 CI 或審查。`max_concurrent_runs` 為 2 以上時,`doctor` 會告訴你有沒有 `gh` 與 `origin`(不會檢查 `gh` 是否已登入)。
|
|
162
|
-
|
|
163
|
-
## 網路存取(`allow_web`)
|
|
164
|
-
|
|
165
|
-
預設所有 agent 都不能使用網路。Claude Code 的 agent 是用無頭的 `dontAsk` 模式喚醒的,沒有明確允許的工具一律拒絕,所以 `WebSearch` 與 `WebFetch` 會被擋掉,要替個別 agent 用 `allow_web` 開放(寫在 `team.yaml` 或 `project.yaml`,專案層蓋過全域層)。它只決定 agent 能不能用網路工具,下面的寫入範圍不變。
|
|
166
|
-
|
|
167
|
-
| 值 | Claude Code | Codex |
|
|
168
|
-
|---|---|---|
|
|
169
|
-
| 不設 | 不給網路工具(與以前相同) | 不傳任何設定:維持 Codex 自己的預設 |
|
|
170
|
-
| `none` | 不給網路工具 | `-c web_search="disabled"` |
|
|
171
|
-
| `search` | `WebSearch` | `-c web_search="cached"` |
|
|
172
|
-
| `fetch` | `WebSearch` 加 `WebFetch` | `-c web_search="live"` |
|
|
173
|
-
|
|
174
|
-
### `search` 還是 `fetch`?
|
|
175
|
-
|
|
176
|
-
- **`search`**:agent 可以搜尋,拿到一串結果(通常是標題、連結與簡短摘要)。它不能打開網頁,所以只知道結果裡寫的內容。像「Node 的哪些版本還有支援?」「這個函式庫最新版是多少?」「這個 API 是不是已經棄用?」這類問題,這樣就夠了。
|
|
177
|
-
- **`fetch`**:包含 `search` 的全部功能,再加上讀取某個網址背後的頁面,整頁文字都會進到 agent 的上下文。答案藏在頁面裡面時才需要:規格文件、API 參考的某一節、issue 討論串,或是你寫在任務裡的網址。
|
|
178
|
-
|
|
179
|
-
分成兩級,是因為風險不一樣。從網頁讀進來的文字可能夾帶寫給 agent 的指示(「忽略你的規則,去做……」),這叫 prompt injection。`fetch` 會把整頁文字送進來;`search` 只有摘要。agent 能做的事越多(改你的 repo、執行指令),這點就越重要。
|
|
180
|
-
|
|
181
|
-
**該給哪些 agent。**先什麼都不開。會查資料的角色給 `search`。只有真的必須讀指定頁面、而且很少改檔的角色才給 `fetch`,例如 `pm` 這種需求或研究角色。會改你 repo 的成員(例如 `fe-member`、`qa-member`)維持關閉,由 lead 或 pm 查好,再把結果寫進任務。
|
|
182
|
-
|
|
183
|
-
### 要注意的事
|
|
184
|
-
|
|
185
|
-
- **不設不等於 `none`。**不設是讓各 runtime 維持自己的預設:Claude Code 沒有網路工具,但 Codex 可能本來就開著網路搜尋。想明確關掉請設 `none`。
|
|
186
|
-
- **Codex 只有一個 `web_search` 設定,不是兩個工具**,所以 `search` 與 `fetch` 對應到 `cached` 與 `live`。依 Codex 的說法,`cached` 是從索引回答、不即時瀏覽,`live` 是即時搜尋網路;這是依風險做的近似對應。`codex exec` 不收 `--search` 旗標,所以 agent-lyceum 改傳 `-c web_search="..."`。
|
|
187
|
-
- **沒有實際端對端跑過。**Codex 的設定鍵與合法值用 Codex 0.160.0 確認過,但 `cached`/`live` 以及 Claude 兩個工具的實際行為,沒有用真的 CLI 跑過(會花 token)。正式依賴前,請先在該 agent 上手動試一次。
|
|
188
|
-
- `call` 會讀 `team.yaml` 裡的這個欄位,`run --agent` 也會保留。
|
|
189
|
-
|
|
190
|
-
```yaml
|
|
191
|
-
# team.yaml
|
|
192
|
-
agents:
|
|
193
|
-
pm:
|
|
194
|
-
runtime: claude-code
|
|
195
|
-
allow_web: fetch # 可以搜尋並讀網頁;沒設這個欄位的 agent 就沒有網路工具
|
|
59
|
+
pm: { can_message: [lead], allow_web: fetch }
|
|
196
60
|
```
|
|
197
61
|
|
|
198
|
-
|
|
62
|
+
資料夾結構、所有 agent 欄位與 `doctor` 檢查的規則:[設定](docs/configuration.zh-TW.md)。
|
|
199
63
|
|
|
200
|
-
|
|
64
|
+
## 主題
|
|
201
65
|
|
|
202
|
-
|
|
|
66
|
+
| 主題 | 閱讀 |
|
|
203
67
|
|---|---|
|
|
204
|
-
|
|
|
205
|
-
|
|
|
206
|
-
|
|
|
207
|
-
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- **Claude Code**:每次執行帶入 `--settings`,內含 `Edit(...)` 規則,**加上** OS 沙箱(`allowWrite`/`denyWrite`、`allowUnsandboxedCommands: false`)。沒有沙箱時,`python -c` 之類的做法能繞過 Edit 規則,因此 `os` 等級必須啟用沙箱。
|
|
212
|
-
- **Codex**:`-s workspace-write` 搭配 `-c sandbox_workspace_write.writable_roots=[...]`(由 OS 強制)。Codex 無法禁止 repo 內的單一檔案,所以 repo 的 `CLAUDE.md`/`AGENTS.md` 只能靠**事後偵測**保護。
|
|
213
|
-
- **所有 runtime**:執行前會對受保護檔案做雜湊;每次喚醒後,未經授權的變更會被還原、記錄,並通報 lead;被寫入的內容會先留存在 `runs/<run-id>/violations/`。
|
|
214
|
-
|
|
215
|
-
`doctor` 與 `run` 會依類別(`os`、`tool-rules`、`post-hoc`、`prompt-only`)印出每個 agent 的防護等級,以及它能寫入 repo 之外的哪些路徑。設定 `dispatcher.strict: true` 時,除非記憶、`AGENT.md`、其他 agent 的 context 與(非 lead 的 agent 的)交接筆記都由 OS 強制保護,否則拒絕執行。
|
|
216
|
-
|
|
217
|
-
agent worktree 內的 git:agent 能寫它 worktree 的 `.git` 指標檔與私有 git 目錄,可能讓 git 去讀 agent 埋的設定或 hook。因此 dispatcher 對 agent worktree 的每一次 git 呼叫,都釘住 worktree 建立當下記錄的 git dir、common dir 與 work tree,關閉 hook、fsmonitor、external diff 與 textconv,並拒絕使用 git 檔案被改向或連到別處、HEAD 不是它自己的分支、或內含巢狀 repository 的 worktree。Claude 另外不得寫那幾個指標檔(`denyWrite`);Codex 無法禁止單一檔案,只靠釘住。殘留風險列在 [spec 15](docs/specs/15-worktree-git-isolation.md#156-殘留風險)。這**不**表示 dispatcher 絕不會執行 agent 埋下的程式碼。使用 `reftable` ref 後端的 repository 在 worktree 模式下會被拒絕。
|
|
218
|
-
|
|
219
|
-
已知缺口:Claude Code 內建的 Edit/Write 工具不在沙箱內(由 `Edit` 規則涵蓋);Codex 的 MCP 工具與 hooks 在其沙箱之外執行;`--dangerously-bypass-approvals-and-sandbox` / `danger-full-access` 會停用所有防護。啟用 `can_edit_agent_md` 時,Codex lead 的可寫根目錄會擴大到整個 agent 目錄。
|
|
68
|
+
| run 的生命週期、平行 agent、同時多個 run、追加需求、開 PR | [一次執行如何運作](docs/how-it-works.zh-TW.md) |
|
|
69
|
+
| agent 能寫什麼、沙箱等級、已知缺口 | [寫入範圍](docs/write-scope.zh-TW.md) |
|
|
70
|
+
| 讓 agent 搜尋或讀取網頁 | [網路存取](docs/web-access.zh-TW.md) |
|
|
71
|
+
| 在 Claude Code 內看即時 run 狀態 | [監控 mod](docs/monitor.zh-TW.md) |
|
|
72
|
+
| 所有指令的完整說明 | [指令](docs/commands.zh-TW.md) |
|
|
73
|
+
| 從舊版升級 | [升級到 run v2](docs/upgrading-run-v2.md) |
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,8 @@ import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
|
10
10
|
import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
|
|
11
11
|
import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
12
12
|
import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
|
|
13
|
-
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
13
|
+
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
14
|
+
import { failureGuide } from "./run-outcome.js";
|
|
14
15
|
import { exitCodeForOutcome } from "./schema.js";
|
|
15
16
|
import { absPath, assertName, NAME_RE, projectPaths, resolveHome } from "./paths.js";
|
|
16
17
|
import { addProject, initHome, removeProject } from "./scaffold.js";
|
|
@@ -167,6 +168,8 @@ function printRun(summary, runDir) {
|
|
|
167
168
|
console.log(`\nRun ${runLabel({ name: summary.name, run_id: summary.runId })} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
|
|
168
169
|
if (summary.outcomeNote)
|
|
169
170
|
console.log(`Note: ${summary.outcomeNote}`);
|
|
171
|
+
if (summary.failure)
|
|
172
|
+
console.log(failureGuide(summary.project, summary.name ?? summary.runId, summary.failure, runDir));
|
|
170
173
|
if (summary.verification)
|
|
171
174
|
console.log(`Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
|
|
172
175
|
if (summary.resultBranch?.files) {
|
|
@@ -200,7 +203,7 @@ async function continueRun(pr, dir, runId, followup) {
|
|
|
200
203
|
if (followup)
|
|
201
204
|
console.log(`Reopening run ${runLabel(state)} (follow-up ${(state.reopened?.length ?? 0) + 1}): ${firstLine(followup.text)}`);
|
|
202
205
|
else
|
|
203
|
-
console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${state.rounds
|
|
206
|
+
console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${roundsLabel(state.rounds, roundLimit(state, pr.dispatcher.max_rounds))}: ${state.task_summary}`);
|
|
204
207
|
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
205
208
|
return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal, followup });
|
|
206
209
|
}
|
|
@@ -393,8 +396,8 @@ program
|
|
|
393
396
|
process.exit(exitCodeForOutcome(o.outcome));
|
|
394
397
|
}
|
|
395
398
|
// A reopen recalculates the round limit from where the run stands.
|
|
396
|
-
if (!reopen && state.rounds >= roundLimit(state, pr.dispatcher.max_rounds))
|
|
397
|
-
fail(`Run ${runLabel(state)} used ${state.rounds}/${roundLimit(state, pr.dispatcher.max_rounds)} rounds; raise dispatcher.max_rounds in project.yaml first.`);
|
|
399
|
+
if (!reopen && roundLimit(state, pr.dispatcher.max_rounds) > 0 && state.rounds >= roundLimit(state, pr.dispatcher.max_rounds))
|
|
400
|
+
fail(`Run ${runLabel(state)} used ${state.rounds}/${roundLimit(state, pr.dispatcher.max_rounds)} rounds; raise dispatcher.max_rounds in project.yaml (or set it to 0 for no limit) first.`);
|
|
398
401
|
if (opts.answer && state.end_reason !== "waiting")
|
|
399
402
|
fail(`Run ${runLabel(state)} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
|
|
400
403
|
if (state.end_reason === "waiting") {
|
package/dist/mailbox.js
CHANGED
|
@@ -166,6 +166,37 @@ export function rejectDone(file) {
|
|
|
166
166
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
167
167
|
fs.renameSync(file, dest);
|
|
168
168
|
}
|
|
169
|
+
/** A body with whitespace collapsed and the dispatcher's own format warning removed, so the same text compares equal. */
|
|
170
|
+
function sameText(body) {
|
|
171
|
+
return body.replace(/^\[agent-lyceum\][^\n]*(\n|$)\s*/, "").replace(/\s+/g, " ").trim();
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
|
|
175
|
+
* sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
|
|
176
|
+
*/
|
|
177
|
+
function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
178
|
+
const text = sameText(body);
|
|
179
|
+
if (text.length < 20)
|
|
180
|
+
return undefined;
|
|
181
|
+
const dir = inboxDir(project, to);
|
|
182
|
+
for (const d of [dir, path.join(dir, "read")]) {
|
|
183
|
+
if (!fs.existsSync(d))
|
|
184
|
+
continue;
|
|
185
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
186
|
+
if (!e.isFile() || !e.name.endsWith(".md") || e.name.startsWith("."))
|
|
187
|
+
continue;
|
|
188
|
+
try {
|
|
189
|
+
const m = readMessage(path.join(d, e.name));
|
|
190
|
+
if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text)
|
|
191
|
+
return m;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* an unreadable file is not a duplicate of anything */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
169
200
|
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
170
201
|
export function routeOutboxes(project) {
|
|
171
202
|
const res = { delivered: [], rejected: [], warnings: [], asks: [] };
|
|
@@ -250,6 +281,33 @@ export function routeOutboxes(project) {
|
|
|
250
281
|
}
|
|
251
282
|
const replyTo = d.reply_to ? String(d.reply_to) : undefined;
|
|
252
283
|
const thread = d.thread ? String(d.thread) : undefined;
|
|
284
|
+
// Mail whose delivery was already planned (a replay after a crash) is not a repeat of itself.
|
|
285
|
+
const repeats = new Map();
|
|
286
|
+
for (const to of targets) {
|
|
287
|
+
if (journal?.get(source, to))
|
|
288
|
+
continue;
|
|
289
|
+
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body);
|
|
290
|
+
if (earlier)
|
|
291
|
+
repeats.set(to, earlier);
|
|
292
|
+
}
|
|
293
|
+
const repeatNote = () => [...repeats]
|
|
294
|
+
.map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
|
|
295
|
+
.join("; ");
|
|
296
|
+
const REPEAT_HELP = "Do not send it again: if that mail has not been answered yet, wait for the reply; if you need something different, change what you ask for; if the work is finished, the lead sends `done`.";
|
|
297
|
+
if (repeats.size === targets.length) {
|
|
298
|
+
reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (repeats.size) {
|
|
302
|
+
deliverOnce(project, journal, `dup:${source}`, {
|
|
303
|
+
from: "dispatcher",
|
|
304
|
+
to: sender.name,
|
|
305
|
+
type: "failure",
|
|
306
|
+
subject: `Part of ${path.basename(file)} was not sent`,
|
|
307
|
+
body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
|
|
308
|
+
});
|
|
309
|
+
res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
|
|
310
|
+
}
|
|
253
311
|
const missing = missingSections(type, parsed.body);
|
|
254
312
|
const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
|
|
255
313
|
if (sender.name === project.lead) {
|
|
@@ -258,6 +316,8 @@ export function routeOutboxes(project) {
|
|
|
258
316
|
res.steps = st;
|
|
259
317
|
}
|
|
260
318
|
for (const to of targets) {
|
|
319
|
+
if (repeats.has(to))
|
|
320
|
+
continue;
|
|
261
321
|
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo });
|
|
262
322
|
if (!fresh)
|
|
263
323
|
continue;
|
package/dist/prompt.js
CHANGED
|
@@ -88,7 +88,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
88
88
|
? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
|
|
89
89
|
: `Report results to the lead by mail; the lead decides what happens next.`, isLead
|
|
90
90
|
? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
|
|
91
|
-
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
91
|
+
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "Mail that repeats an earlier mail of yours to the same agent (same type, same text) is not sent again; you are told, and should wait for the answer or ask for something different instead of resending.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
92
92
|
? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run. " + OPEN_PR_RULE
|
|
93
93
|
: "- `done` has no required headings.", ...(agent.canAskUser
|
|
94
94
|
? [
|
package/dist/run-outcome.d.ts
CHANGED
|
@@ -16,7 +16,17 @@ export declare function resolveDoneOutcome(contract: DoneContract, rejections: n
|
|
|
16
16
|
export declare function endOutcome(endReason: EndReason, done?: {
|
|
17
17
|
outcome: RunOutcome;
|
|
18
18
|
note?: string;
|
|
19
|
+
}, failure?: {
|
|
20
|
+
agent: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
attempts: number;
|
|
19
23
|
}): {
|
|
20
24
|
outcome: RunOutcome;
|
|
21
25
|
note?: string;
|
|
22
26
|
};
|
|
27
|
+
/** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
|
|
28
|
+
export declare function failureGuide(project: string, run: string, f: {
|
|
29
|
+
agent: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
attempts: number;
|
|
32
|
+
}, runDir: string): string;
|
package/dist/run-outcome.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
/** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
|
|
2
3
|
export function resolveDoneOutcome(contract, rejections, unintegrated) {
|
|
3
4
|
const ok = contract.missing.length === 0;
|
|
@@ -10,12 +11,18 @@ export function resolveDoneOutcome(contract, rejections, unintegrated) {
|
|
|
10
11
|
return { outcome, note, verification: contract.verification };
|
|
11
12
|
}
|
|
12
13
|
/** How a run turned out, from why it stopped (`done` carries the lead's own verdict). */
|
|
13
|
-
export function endOutcome(endReason, done) {
|
|
14
|
+
export function endOutcome(endReason, done, failure) {
|
|
14
15
|
switch (endReason) {
|
|
15
16
|
case "done":
|
|
16
17
|
return { outcome: done?.outcome ?? "partial", note: done?.note };
|
|
17
18
|
case "lead_failed":
|
|
18
|
-
|
|
19
|
+
case "agent_failed":
|
|
20
|
+
return {
|
|
21
|
+
outcome: "failed",
|
|
22
|
+
note: failure
|
|
23
|
+
? `${failure.agent} failed ${failure.attempts} time(s) in a row (${failure.error ?? "unknown error"}), so the run stopped. The message it was handling is still unread.`
|
|
24
|
+
: "The lead's wake-up failed.",
|
|
25
|
+
};
|
|
19
26
|
case "waiting":
|
|
20
27
|
return { outcome: "waiting", note: "Waiting for your answers. Fill in ask-reply.md or run `agent-lyceum resume <run-id> --answer`, then continue with `agent-lyceum resume`." };
|
|
21
28
|
case "cancelled":
|
|
@@ -26,3 +33,16 @@ export function endOutcome(endReason, done) {
|
|
|
26
33
|
return { outcome: "partial", note: "Stopped at the round limit before the lead sent done." };
|
|
27
34
|
}
|
|
28
35
|
}
|
|
36
|
+
/** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
|
|
37
|
+
export function failureGuide(project, run, f, runDir) {
|
|
38
|
+
return [
|
|
39
|
+
"",
|
|
40
|
+
`${f.agent} was called ${f.attempts} time(s) and failed every time (${f.error ?? "unknown error"}). The run stopped; nothing was lost: the message ${f.agent} was handling is still unread and the work done so far is kept.`,
|
|
41
|
+
"To fix it:",
|
|
42
|
+
` 1. Read why it failed: ${path.join(runDir, "log.jsonl")} (event "wake"), and the full output of each attempt in ${path.join(runDir, "mail", "attempts")}`,
|
|
43
|
+
` 2. Fix the cause: the ${f.agent} CLI (installed, logged in, model name), a timeout (dispatcher.wake_timeout_sec), or its AGENT.md / the message it keeps failing on. Check with: agent-lyceum doctor -p ${project}`,
|
|
44
|
+
"To go on:",
|
|
45
|
+
` agent-lyceum resume ${run} -p ${project} (the same message is handled again; the task is not re-sent)`,
|
|
46
|
+
` Or give up on it: agent-lyceum clear ${run} -p ${project}`,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
package/dist/run-session.d.ts
CHANGED
|
@@ -33,6 +33,14 @@ export interface RunSummary {
|
|
|
33
33
|
resultBranch?: ResultBranch;
|
|
34
34
|
/** The lead asked for a pull request: what became of it. */
|
|
35
35
|
pr?: PrOutcome;
|
|
36
|
+
/** The project's name, for the commands printed after the run. */
|
|
37
|
+
project: string;
|
|
38
|
+
/** The run stopped because this agent kept failing. */
|
|
39
|
+
failure?: {
|
|
40
|
+
agent: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
attempts: number;
|
|
43
|
+
};
|
|
36
44
|
}
|
|
37
45
|
export interface RunOptions {
|
|
38
46
|
project: ResolvedProject;
|
|
@@ -78,6 +86,7 @@ export declare class RunSession {
|
|
|
78
86
|
private pendingDoneFile;
|
|
79
87
|
private doneOutcome;
|
|
80
88
|
private doneNote;
|
|
89
|
+
private failure;
|
|
81
90
|
private doneVerification;
|
|
82
91
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
83
92
|
private openPrRequested;
|
package/dist/run-session.js
CHANGED
|
@@ -13,7 +13,7 @@ import { outboxDir, ownsDirs, sharedIndex } from "./policy.js";
|
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
14
|
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
15
15
|
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
|
-
import { bindRunProject, newRunId, newRunState, roundLimit, saveRunState } from "./run-store.js";
|
|
16
|
+
import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
|
|
17
17
|
import { soloProject } from "./solo.js";
|
|
18
18
|
import { taskMessageBody } from "./task.js";
|
|
19
19
|
import { must } from "./assert.js";
|
|
@@ -58,6 +58,7 @@ export class RunSession {
|
|
|
58
58
|
pendingDoneFile;
|
|
59
59
|
doneOutcome;
|
|
60
60
|
doneNote;
|
|
61
|
+
failure;
|
|
61
62
|
doneVerification;
|
|
62
63
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
63
64
|
openPrRequested = false;
|
|
@@ -124,7 +125,7 @@ export class RunSession {
|
|
|
124
125
|
}
|
|
125
126
|
let state;
|
|
126
127
|
if (resume) {
|
|
127
|
-
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
128
|
+
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, failure: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
128
129
|
// A wake that was running when the process died never finished; keep it in the history as interrupted.
|
|
129
130
|
for (const [agent, a] of Object.entries(resume.active ?? {})) {
|
|
130
131
|
state.wakes.push({
|
|
@@ -304,14 +305,16 @@ export class RunSession {
|
|
|
304
305
|
};
|
|
305
306
|
let result;
|
|
306
307
|
for (let attempt = 0; attempt <= cfg.retry; attempt++) {
|
|
307
|
-
if (state.rounds >= state.max_rounds) {
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
|
|
309
|
+
// The round limit (when one is set) ends the run; this message stays unread for the resume.
|
|
310
|
+
abandonClaim(runDir, claim.id);
|
|
311
|
+
claims.delete(agent.name);
|
|
312
|
+
return { ok: false, cancelled: true };
|
|
310
313
|
}
|
|
311
314
|
const round = ++state.rounds;
|
|
312
315
|
const started = Date.now();
|
|
313
316
|
const att = beginAttempt(runDir, claim.id);
|
|
314
|
-
say(`[round ${state.rounds
|
|
317
|
+
say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
|
|
315
318
|
const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
|
|
316
319
|
const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
|
|
317
320
|
// What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
|
|
@@ -380,16 +383,10 @@ export class RunSession {
|
|
|
380
383
|
if (result?.ok)
|
|
381
384
|
markOutputReady(runDir, claim.id);
|
|
382
385
|
if (!result?.ok) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
to: project.lead,
|
|
388
|
-
type: "failure",
|
|
389
|
-
subject: `${agent.name} failed`,
|
|
390
|
-
body: `Agent "${agent.name}" could not handle message(s) "${unread.map((m) => m.meta.subject).join('", "')}".\n\nError: ${result?.error ?? "unknown"}\n\nDecide whether to retry, reassign, or adjust the plan.`,
|
|
391
|
-
});
|
|
392
|
-
return { ok: false };
|
|
386
|
+
// The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
|
|
387
|
+
abandonClaim(runDir, claim.id);
|
|
388
|
+
claims.delete(agent.name);
|
|
389
|
+
return { ok: false, error: result?.error, attempts: cfg.retry + 1 };
|
|
393
390
|
}
|
|
394
391
|
return { ok: true };
|
|
395
392
|
}
|
|
@@ -553,10 +550,12 @@ export class RunSession {
|
|
|
553
550
|
this.endReason = "waiting";
|
|
554
551
|
return;
|
|
555
552
|
}
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
this.
|
|
553
|
+
const failedIdx = results.findIndex((r) => !r.ok && !r.cancelled);
|
|
554
|
+
if (failedIdx >= 0) {
|
|
555
|
+
const failed = must(batch[failedIdx], "failed agent");
|
|
556
|
+
this.failure = { agent: failed.name, error: results[failedIdx]?.error, attempts: results[failedIdx]?.attempts ?? this.cfg.retry + 1 };
|
|
557
|
+
state.failure = this.failure;
|
|
558
|
+
this.endReason = failed.name === project.lead ? "lead_failed" : "agent_failed";
|
|
560
559
|
return;
|
|
561
560
|
}
|
|
562
561
|
}
|
|
@@ -757,7 +756,7 @@ export class RunSession {
|
|
|
757
756
|
this.endReason = "idle";
|
|
758
757
|
break;
|
|
759
758
|
}
|
|
760
|
-
if (state.rounds >= state.max_rounds) {
|
|
759
|
+
if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
|
|
761
760
|
this.endReason = "max_rounds";
|
|
762
761
|
break;
|
|
763
762
|
}
|
|
@@ -799,7 +798,7 @@ export class RunSession {
|
|
|
799
798
|
this.endReason = "cancelled";
|
|
800
799
|
}
|
|
801
800
|
const endReason = must(this.endReason, "end reason");
|
|
802
|
-
const { outcome, note: outcomeNote } = endOutcome(endReason, endReason === "done" ? { outcome: this.doneOutcome ?? "partial", note: this.doneNote } : undefined);
|
|
801
|
+
const { outcome, note: outcomeNote } = endOutcome(endReason, endReason === "done" ? { outcome: this.doneOutcome ?? "partial", note: this.doneNote } : undefined, this.failure);
|
|
803
802
|
state.end_reason = endReason;
|
|
804
803
|
state.outcome = outcome;
|
|
805
804
|
state.outcome_note = outcomeNote;
|
|
@@ -819,6 +818,6 @@ export class RunSession {
|
|
|
819
818
|
this.saveState();
|
|
820
819
|
if (this.pendingDoneFile)
|
|
821
820
|
finishDone(this.pendingDoneFile);
|
|
822
|
-
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
821
|
+
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr, project: project.name, failure: this.failure };
|
|
823
822
|
}
|
|
824
823
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { RunOutcome } from "./schema.js";
|
|
|
4
4
|
import type { ResultBranch } from "./worktree.js";
|
|
5
5
|
export declare const STATE_FILE = "state.json";
|
|
6
6
|
export declare const STATE_SCHEMA_VERSION = 2;
|
|
7
|
-
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled" | "waiting";
|
|
7
|
+
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "agent_failed" | "cancelled" | "waiting";
|
|
8
8
|
export type MailLayout = "run" | "legacy";
|
|
9
9
|
export interface WakeTopic {
|
|
10
10
|
from: string;
|
|
@@ -97,6 +97,12 @@ export interface RunState {
|
|
|
97
97
|
active: Record<string, ActiveWake>;
|
|
98
98
|
/** Finished wakes, oldest first. */
|
|
99
99
|
wakes: WakeRecord[];
|
|
100
|
+
/** Set when the run stopped because an agent kept failing: who, the last error, how many times it was called. */
|
|
101
|
+
failure?: {
|
|
102
|
+
agent: string;
|
|
103
|
+
error?: string;
|
|
104
|
+
attempts: number;
|
|
105
|
+
};
|
|
100
106
|
/** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps` (after a reopen, added to `step_base`). */
|
|
101
107
|
steps?: Step[];
|
|
102
108
|
/** The checklist as it stood at the last reopen: the follow-up's steps are shown after it, and its open steps do not hold up the follow-up's done. */
|
|
@@ -154,4 +160,7 @@ export type Resolved = {
|
|
|
154
160
|
};
|
|
155
161
|
/** Find a run by full id, exact name, or a prefix of either that matches exactly one run. An exact id or name always wins. */
|
|
156
162
|
export declare function resolveRun(runs: RunRef[], query: string): Resolved;
|
|
163
|
+
/** The round the run stops at; 0 (`dispatcher.max_rounds: 0`) means there is no limit. */
|
|
157
164
|
export declare const roundLimit: (state: Pick<RunState, "round_floor">, maxRounds: number) => number;
|
|
165
|
+
/** `rounds`, or `rounds/limit` when the run has a limit. */
|
|
166
|
+
export declare const roundsLabel: (rounds: number, limit: number) => string;
|
package/dist/run-store.js
CHANGED
|
@@ -23,7 +23,7 @@ const WireState = z
|
|
|
23
23
|
ended_at: z.string().optional(),
|
|
24
24
|
rounds: nonNeg.optional(),
|
|
25
25
|
max_rounds: nonNeg.optional(),
|
|
26
|
-
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled", "waiting"]).optional(),
|
|
26
|
+
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "agent_failed", "cancelled", "waiting"]).optional(),
|
|
27
27
|
outcome: z.enum(["completed", "partial", "blocked", "failed", "waiting", "cancelled"]).optional(),
|
|
28
28
|
outcome_note: z.string().optional(),
|
|
29
29
|
verification: z.string().optional(),
|
|
@@ -39,6 +39,7 @@ const WireState = z
|
|
|
39
39
|
wakes: z.array(z.any()).optional(),
|
|
40
40
|
solo_agent: z.string().optional(),
|
|
41
41
|
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
42
|
+
failure: z.object({ agent: z.string(), error: z.string().optional(), attempts: z.number() }).optional(),
|
|
42
43
|
step_base: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
43
44
|
name: z.string().optional(),
|
|
44
45
|
reopened: z.array(z.object({ at: z.string() })).optional(),
|
|
@@ -121,7 +122,7 @@ export function outcomeOf(s) {
|
|
|
121
122
|
return { outcome: s.outcome, verified: true };
|
|
122
123
|
if (!s.end_reason)
|
|
123
124
|
return undefined;
|
|
124
|
-
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
125
|
+
return { outcome: s.end_reason === "lead_failed" || s.end_reason === "agent_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
125
126
|
}
|
|
126
127
|
const NAME_MAX = 30;
|
|
127
128
|
export function slugify(text) {
|
|
@@ -157,4 +158,7 @@ export function resolveRun(runs, query) {
|
|
|
157
158
|
return { ok: true, run: pool[0] };
|
|
158
159
|
return { ok: false, reason: pool.length ? "ambiguous" : "none", candidates: pool };
|
|
159
160
|
}
|
|
160
|
-
|
|
161
|
+
/** The round the run stops at; 0 (`dispatcher.max_rounds: 0`) means there is no limit. */
|
|
162
|
+
export const roundLimit = (state, maxRounds) => (maxRounds > 0 ? (state.round_floor ?? 0) + maxRounds : 0);
|
|
163
|
+
/** `rounds`, or `rounds/limit` when the run has a limit. */
|
|
164
|
+
export const roundsLabel = (rounds, limit) => (limit > 0 ? `${rounds}/${limit}` : String(rounds));
|
package/dist/scaffold.js
CHANGED
|
@@ -85,12 +85,12 @@ dir: ${repo} # the repo the agents work in (the tool never writes con
|
|
|
85
85
|
team:
|
|
86
86
|
lead: lead
|
|
87
87
|
dispatcher:
|
|
88
|
-
max_rounds:
|
|
88
|
+
max_rounds: 0 # 0 = no limit on wake-ups; set a number to stop a run after that many
|
|
89
89
|
max_parallel: 1 # >1 needs non-overlapping "owns" for every non-lead agent, and a git repo
|
|
90
90
|
max_concurrent_runs: 1 # >1 lets several runs of this project be active at once, each in its own git worktree (needs a git repo); every run's work stays on its own branch
|
|
91
91
|
workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
|
|
92
92
|
wake_timeout_sec: 600
|
|
93
|
-
retry:
|
|
93
|
+
retry: 2 # an agent that fails is called again up to this many times; if it still fails the run stops (resume it after fixing the cause)
|
|
94
94
|
log_max_bytes: 8388608 # log.jsonl is renamed to log.1.jsonl past this size; 0 = never
|
|
95
95
|
strict: false # true = refuse to start unless context protection is OS-enforced
|
|
96
96
|
agents:
|
package/dist/schema.js
CHANGED
|
@@ -43,7 +43,7 @@ export const AgentPartial = z
|
|
|
43
43
|
.strict();
|
|
44
44
|
export const DispatcherPartial = z
|
|
45
45
|
.object({
|
|
46
|
-
max_rounds: z.number().int().
|
|
46
|
+
max_rounds: z.number().int().min(0).optional(),
|
|
47
47
|
max_parallel: z.number().int().positive().optional(),
|
|
48
48
|
max_concurrent_runs: z.number().int().positive().optional(),
|
|
49
49
|
wake_timeout_sec: z.number().int().positive().optional(),
|
|
@@ -67,11 +67,11 @@ export const ProjectConfig = z
|
|
|
67
67
|
})
|
|
68
68
|
.strict();
|
|
69
69
|
export const DISPATCHER_DEFAULTS = {
|
|
70
|
-
max_rounds:
|
|
70
|
+
max_rounds: 0,
|
|
71
71
|
max_parallel: 1,
|
|
72
72
|
max_concurrent_runs: 1,
|
|
73
73
|
wake_timeout_sec: 600,
|
|
74
|
-
retry:
|
|
74
|
+
retry: 2,
|
|
75
75
|
strict: false,
|
|
76
76
|
workspace_mode: "auto",
|
|
77
77
|
log_max_bytes: 8 * 1024 * 1024,
|
package/dist/status.d.ts
CHANGED
|
@@ -69,7 +69,14 @@ export interface RunReport {
|
|
|
69
69
|
started_at: string;
|
|
70
70
|
ended_at?: string;
|
|
71
71
|
rounds: number;
|
|
72
|
+
/** 0 = no limit. */
|
|
72
73
|
max_rounds: number;
|
|
74
|
+
/** Set when an agent kept failing and the run stopped: `resume` after fixing the cause. */
|
|
75
|
+
failure?: {
|
|
76
|
+
agent: string;
|
|
77
|
+
error?: string;
|
|
78
|
+
attempts: number;
|
|
79
|
+
};
|
|
73
80
|
output_tokens: number;
|
|
74
81
|
task_source: "text" | "file";
|
|
75
82
|
task_path?: string;
|
package/dist/status.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { askReplyPath, readAskReply } from "./ask-reply.js";
|
|
4
4
|
import { RESULT_FILE } from "./dispatcher.js";
|
|
5
|
-
import { bindRunProject, loadRunState, outcomeOf, runLabel } from "./run-store.js";
|
|
5
|
+
import { bindRunProject, loadRunState, outcomeOf, roundsLabel, runLabel } from "./run-store.js";
|
|
6
6
|
import { briefOf, digestOf } from "./format.js";
|
|
7
7
|
import { listUnread } from "./mailbox.js";
|
|
8
8
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
@@ -204,6 +204,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
204
204
|
ended_at: s.ended_at,
|
|
205
205
|
rounds: s.rounds,
|
|
206
206
|
max_rounds: s.max_rounds,
|
|
207
|
+
...(s.failure ? { failure: s.failure } : {}),
|
|
207
208
|
output_tokens: s.output_tokens,
|
|
208
209
|
task_source: s.task_source,
|
|
209
210
|
task_path: s.task_path,
|
|
@@ -289,11 +290,11 @@ function summaryBlock(run, now, c) {
|
|
|
289
290
|
const s = run;
|
|
290
291
|
const live = s.live;
|
|
291
292
|
const lines = [
|
|
292
|
-
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 第 ${s.rounds
|
|
293
|
+
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 第 ${roundsLabel(s.rounds, s.max_rounds)} 輪`,
|
|
293
294
|
`${L("任務:")}${taskText(s)}`,
|
|
294
295
|
];
|
|
295
296
|
const steps = stepsLine(s.steps, c, s.end_reason === "done");
|
|
296
|
-
lines.push(`${L("進度:")}${steps ?? `無清單;已用 ${s.rounds
|
|
297
|
+
lines.push(`${L("進度:")}${steps ?? `無清單;已用 ${roundsLabel(s.rounds, s.max_rounds)} 輪(lead 可在信中加入 "## Steps" 清單)`}`);
|
|
297
298
|
const active = Object.entries(s.active ?? {});
|
|
298
299
|
if (active.length) {
|
|
299
300
|
for (const [i, [agent, a]] of active.entries()) {
|
|
@@ -319,6 +320,9 @@ function summaryBlock(run, now, c) {
|
|
|
319
320
|
const lastWake = (s.wakes ?? []).at(-1);
|
|
320
321
|
if (lastWake)
|
|
321
322
|
lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
|
|
323
|
+
if (s.failure) {
|
|
324
|
+
lines.push(`${L("需要處理:")}${c.red(`${s.failure.agent} 呼叫 ${s.failure.attempts} 次都失敗`)}(${clip(s.failure.error ?? "未知錯誤", 80)})`, c.dim(` 查 log 找原因並修正後,執行 agent-lyceum resume ${s.run_id} 從同一封信繼續(或 agent-lyceum clear ${s.run_id} 放棄)`));
|
|
325
|
+
}
|
|
322
326
|
if (s.ask)
|
|
323
327
|
lines.push(`${L("等你回答:")}${s.ask.unanswered}/${s.ask.total} 題未答 → ${s.ask.path}`, c.dim(` 填完後執行 agent-lyceum resume ${s.run_id} --answer`));
|
|
324
328
|
lines.push(`${L("流程:")}${flowLine(s, c)}`);
|
|
@@ -327,7 +331,7 @@ function summaryBlock(run, now, c) {
|
|
|
327
331
|
/** What a run is doing / did, one line per wake. */
|
|
328
332
|
function describeRun(s, now, c, limit = Infinity) {
|
|
329
333
|
const lines = [
|
|
330
|
-
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${s.rounds
|
|
334
|
+
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${roundsLabel(s.rounds, s.max_rounds)} 輸出 tokens ${tokens(s.output_tokens)}`,
|
|
331
335
|
` 任務:${taskText(s)}`,
|
|
332
336
|
];
|
|
333
337
|
const steps = stepsLine(s.steps, c);
|
|
@@ -401,7 +405,7 @@ export function formatTaskListReport(report, color = false) {
|
|
|
401
405
|
...(named ? [s.name ?? ""] : []),
|
|
402
406
|
s.run_id,
|
|
403
407
|
reportLabel(s),
|
|
404
|
-
|
|
408
|
+
roundsLabel(s.rounds, s.max_rounds),
|
|
405
409
|
s.steps?.length ? (s.current_step ? clip(s.current_step, 30) : "全部完成") : "-",
|
|
406
410
|
clip(taskText(s), 60),
|
|
407
411
|
]);
|