agent-lyceum 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -4
- package/README.zh-TW.md +29 -4
- package/dist/ask-answer.js +4 -3
- package/dist/ask-reply.d.ts +2 -0
- package/dist/ask-reply.js +13 -0
- package/dist/cli.js +62 -22
- package/dist/config.d.ts +1 -0
- package/dist/doctor.js +32 -6
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +8 -1
- package/dist/open-pr.d.ts +47 -0
- package/dist/open-pr.js +71 -0
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +4 -2
- package/dist/project-lock.d.ts +24 -7
- package/dist/project-lock.js +178 -80
- package/dist/prompt.js +10 -4
- package/dist/run-cleanup.js +6 -3
- package/dist/run-session.d.ts +27 -0
- package/dist/run-session.js +163 -20
- package/dist/run-store.d.ts +7 -0
- package/dist/scaffold.js +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +2 -0
- package/dist/status.d.ts +1 -1
- package/dist/status.js +4 -4
- package/dist/validate.js +10 -3
- package/dist/worktree.d.ts +74 -7
- package/dist/worktree.js +372 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged i
|
|
|
31
31
|
| Diagnose | `status [-p name] [--monitor]` | Show agents, mail, the current wake-up and past runs (`--json`, `--monitor`). [details](docs/commands.md#status) |
|
|
32
32
|
| Run | `run ["task"] [--task-file f] [--agent a] [-p name]` | Give the task to the lead and run until done; with `--agent`, to one member alone. [details](docs/commands.md#run) |
|
|
33
33
|
| Run | `resume [run-id] [--answer] [-p name]` | Continue an interrupted or failed run; `--answer` first answers the questions it is waiting on. [details](docs/commands.md#resume) |
|
|
34
|
-
| Run | `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` / `clear --lock --force` | Delete a run with its task memory and worktrees; or remove the lock a crashed run left behind. [details](docs/commands.md#clear) |
|
|
34
|
+
| Run | `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` / `clear --lock [run-id] --force` | Delete a run with its task memory and worktrees; or remove the lock a crashed run left behind. [details](docs/commands.md#clear) |
|
|
35
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
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
37
|
| Run | `tidy --undo <timestamp> -p name --agent a` | Undo a tidy: move its archived files back. [details](docs/commands.md#tidy) |
|
|
@@ -89,7 +89,7 @@ Changes take effect on the next session start. If the pane shows `無法取得
|
|
|
89
89
|
├── project.yaml # team, repo dir, overrides
|
|
90
90
|
├── agents/<agent>/{AGENT.md?, memory/} # project-level persona (optional) + project memory
|
|
91
91
|
├── shared/common/COMMON.md
|
|
92
|
-
├──
|
|
92
|
+
├── locks/<run-id>.json # held while that run is active (up to dispatcher.max_concurrent_runs at once)
|
|
93
93
|
└── runs/<run-id>/{task.md, log.jsonl, state.json, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
|
|
94
94
|
```
|
|
95
95
|
|
|
@@ -99,7 +99,7 @@ Changes take effect on the next session start. If the pane shows `無法取得
|
|
|
99
99
|
# project.yaml
|
|
100
100
|
dir: ~/code/web-app
|
|
101
101
|
team: { lead: lead }
|
|
102
|
-
dispatcher: { max_rounds: 30, max_parallel: 1, wake_timeout_sec: 600, retry: 1, strict: false, workspace_mode: auto, log_max_bytes: 8388608 }
|
|
102
|
+
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 }
|
|
103
103
|
agents:
|
|
104
104
|
lead: { resume: true, can_message: all, can_edit_agent_md: true }
|
|
105
105
|
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
@@ -109,7 +109,7 @@ agents:
|
|
|
109
109
|
|
|
110
110
|
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).
|
|
111
111
|
|
|
112
|
-
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.
|
|
112
|
+
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.
|
|
113
113
|
|
|
114
114
|
## How a run works
|
|
115
115
|
|
|
@@ -122,6 +122,31 @@ Rules checked by `doctor`: ≥ 2 agents (the lead and one member; the template h
|
|
|
122
122
|
|
|
123
123
|
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.
|
|
124
124
|
|
|
125
|
+
### Several runs at once
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
- **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.
|
|
130
|
+
- **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.
|
|
131
|
+
- **`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.
|
|
132
|
+
- **`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.
|
|
133
|
+
|
|
134
|
+
Known limits:
|
|
135
|
+
|
|
136
|
+
- The number of agent processes can reach `max_concurrent_runs × max_parallel`; there is no global cap.
|
|
137
|
+
- 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.
|
|
138
|
+
- `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.
|
|
139
|
+
- Two runs that edit the same file are for you to reconcile when merging; agent-lyceum never merges.
|
|
140
|
+
|
|
141
|
+
### Opening a pull request
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
- 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.
|
|
146
|
+
- 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.
|
|
147
|
+
- 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.
|
|
148
|
+
- 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).
|
|
149
|
+
|
|
125
150
|
## Web access (`allow_web`)
|
|
126
151
|
|
|
127
152
|
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.
|
package/README.zh-TW.md
CHANGED
|
@@ -31,7 +31,7 @@ npx agent-lyceum status
|
|
|
31
31
|
| 診斷 | `status [-p name] [--monitor]` | 顯示 agent、信件、目前執行與歷次 run(支援 `--json`、`--monitor`)。 [詳細](docs/commands.zh-TW.md#status) |
|
|
32
32
|
| 執行 | `run ["task"] [--task-file f] [--agent a] [-p name]` | 把任務交給 lead 並執行到結束;加 `--agent` 則只交給某個成員單獨做。 [詳細](docs/commands.zh-TW.md#run) |
|
|
33
33
|
| 執行 | `resume [run-id] [--answer] [-p name]` | 接續被中斷或失敗的 run;`--answer` 會先回答它等待中的問題。 [詳細](docs/commands.zh-TW.md#resume) |
|
|
34
|
-
| 執行 | `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` / `clear --lock --force` | 刪除 run 與其任務記憶、worktree;或移除當機 run 留下的專案鎖。 [詳細](docs/commands.zh-TW.md#clear) |
|
|
34
|
+
| 執行 | `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` / `clear --lock [run-id] --force` | 刪除 run 與其任務記憶、worktree;或移除當機 run 留下的專案鎖。 [詳細](docs/commands.zh-TW.md#clear) |
|
|
35
35
|
| 執行 | `call <agent> ["task"] [--task-file f] [--dir d]` | 單獨呼叫一個 global agent(不需專案),印出它的回答。 [詳細](docs/commands.zh-TW.md#call) |
|
|
36
36
|
| 執行 | `tidy [-p name] [--agent a] [--layer project\|global] [--dry-run]` | 讓 agent 整理自己的記憶(只能手動觸發;封存而非刪除)。 [詳細](docs/commands.zh-TW.md#tidy) |
|
|
37
37
|
| 執行 | `tidy --undo <時間戳> -p name --agent a` | 還原一次整理:把封存的檔案搬回。 [詳細](docs/commands.zh-TW.md#tidy) |
|
|
@@ -89,7 +89,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
89
89
|
├── project.yaml # 團隊、repo 路徑、覆寫設定
|
|
90
90
|
├── agents/<agent>/{AGENT.md?, memory/} # 專案層人設(選用)+ 專案記憶
|
|
91
91
|
├── shared/common/COMMON.md
|
|
92
|
-
├──
|
|
92
|
+
├── locks/<run-id>.json # 該 run 執行期間持有(同時最多 dispatcher.max_concurrent_runs 個)
|
|
93
93
|
├── task-memory/<run-id>/<agent>/ # 任務記憶:每次 run 獨立,不與其他任務共用
|
|
94
94
|
└── runs/<run-id>/{task.md, log.jsonl, state.json, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
|
|
95
95
|
```
|
|
@@ -100,7 +100,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
100
100
|
# project.yaml
|
|
101
101
|
dir: ~/code/web-app
|
|
102
102
|
team: { lead: lead }
|
|
103
|
-
dispatcher: { max_rounds: 30, max_parallel: 1, wake_timeout_sec: 600, retry: 1, strict: false, workspace_mode: auto, log_max_bytes: 8388608 }
|
|
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
104
|
agents:
|
|
105
105
|
lead: { resume: true, can_message: all, can_edit_agent_md: true }
|
|
106
106
|
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
@@ -110,7 +110,7 @@ agents:
|
|
|
110
110
|
|
|
111
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
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
|
|
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
114
|
|
|
115
115
|
## 一次執行如何運作
|
|
116
116
|
|
|
@@ -123,6 +123,31 @@ Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可
|
|
|
123
123
|
|
|
124
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
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
|
+
### 開 PR
|
|
143
|
+
|
|
144
|
+
當你的 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` 在沙箱外以你自己的憑證執行。
|
|
145
|
+
|
|
146
|
+
- 它需要 lead 的 branch,所以只有在 run 有自己的 worktree 時(`dispatcher.max_concurrent_runs` 為 2 或以上)才有作用。否則這個要求會被回報並略過。
|
|
147
|
+
- 無法照辦的要求(run 沒有完成、沒有變更、沒有 `gh`、沒有 `origin`、push 或 `gh` 失敗)絕不會改變 run 的結果。run 會說明沒有開的原因並保留 branch;push 或 `gh pr create` 失敗時,還會印出可手動完成的指令。
|
|
148
|
+
- 開 PR 會把 lead 的 branch 與報告發布到 `origin`。因此,提出要求(`open_pr: true`)的 agent 能把它在沙箱內讀得到的任何東西送上遠端,所以只有在可以接受這件事時,才在 task 裡要求開 PR。push 與 `gh pr create` 從你的主 repo 執行,絕不在 agent 的 worktree 裡執行,因為 agent 能寫入那個目錄。
|
|
149
|
+
- agent-lyceum 從不合併,也不等待 CI 或審查。`max_concurrent_runs` 為 2 以上時,`doctor` 會告訴你有沒有 `gh` 與 `origin`(不會檢查 `gh` 是否已登入)。
|
|
150
|
+
|
|
126
151
|
## 網路存取(`allow_web`)
|
|
127
152
|
|
|
128
153
|
預設所有 agent 都不能使用網路。Claude Code 的 agent 是用無頭的 `dontAsk` 模式喚醒的,沒有明確允許的工具一律拒絕,所以 `WebSearch` 與 `WebFetch` 會被擋掉,要替個別 agent 用 `allow_web` 開放(寫在 `team.yaml` 或 `project.yaml`,專案層蓋過全域層)。它只決定 agent 能不能用網路工具,下面的寫入範圍不變。
|
package/dist/ask-answer.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { applyDefaults, askReplyPath, checkAnswers, describeCheck, isComplete, readAskReply, writeAskReply } from "./ask-reply.js";
|
|
2
|
+
import { applyDefaults, askReplyPath, checkAnswers, describeCheck, isComplete, readAskReply, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
|
|
3
3
|
/** Read the ask-reply file of a waiting run. With `assumeDefaults`, unanswered questions that have a suggestion take it (and the file is saved). */
|
|
4
4
|
export function prepareAnswers(runDir, opts) {
|
|
5
5
|
const file = askReplyPath(runDir);
|
|
6
6
|
let reply = readAskReply(runDir);
|
|
7
7
|
if (!reply)
|
|
8
8
|
return { complete: false, problems: ["ask-reply.md does not exist"], path: file };
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const resolved = resolveOptionLetters(reply);
|
|
10
|
+
if (JSON.stringify(resolved) !== JSON.stringify(reply) || opts.assumeDefaults) {
|
|
11
|
+
reply = opts.assumeDefaults ? applyDefaults(resolved) : resolved;
|
|
11
12
|
writeAskReply(runDir, reply);
|
|
12
13
|
}
|
|
13
14
|
const check = checkAnswers(reply);
|
package/dist/ask-reply.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface AnswerCheck {
|
|
|
55
55
|
}[];
|
|
56
56
|
}
|
|
57
57
|
export declare const isComplete: (c: AnswerCheck) => boolean;
|
|
58
|
+
/** An answer that is just a letter ("b", "B:", "b)") becomes the one option that starts with that letter and a ":", "." or ")". No match, or more than one, leaves it as typed. */
|
|
59
|
+
export declare function resolveOptionLetters(r: AskReply): AskReply;
|
|
58
60
|
/** Only questions whose answer was not sent yet count. */
|
|
59
61
|
export declare function checkAnswers(r: AskReply): AnswerCheck;
|
|
60
62
|
export declare function describeCheck(c: AnswerCheck): string[];
|
package/dist/ask-reply.js
CHANGED
|
@@ -123,6 +123,19 @@ function problem(q) {
|
|
|
123
123
|
return `must be one of: ${q.options.join(", ")} — or start with "other:" and write your own`;
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
|
+
/** An answer that is just a letter ("b", "B:", "b)") becomes the one option that starts with that letter and a ":", "." or ")". No match, or more than one, leaves it as typed. */
|
|
127
|
+
export function resolveOptionLetters(r) {
|
|
128
|
+
return {
|
|
129
|
+
...r,
|
|
130
|
+
questions: r.questions.map((q) => {
|
|
131
|
+
const v = q.answer?.value.trim().match(/^([A-Za-z])[:.)]?$/)?.[1]?.toLowerCase();
|
|
132
|
+
if (q.delivered || !v || !q.options?.length)
|
|
133
|
+
return q;
|
|
134
|
+
const hits = q.options.filter((o) => o.toLowerCase().match(/^([a-z])[:.)]/)?.[1] === v);
|
|
135
|
+
return hits.length === 1 && q.answer ? { ...q, answer: { ...q.answer, value: hits[0] } } : q;
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
126
139
|
/** Only questions whose answer was not sent yet count. */
|
|
127
140
|
export function checkAnswers(r) {
|
|
128
141
|
const c = { missing: [], invalid: [] };
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ import { restoreMemory, tidyMemory, tidyStamp } from "./memory-tidy.js";
|
|
|
9
9
|
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
|
-
import { acquireProjectLock, forceUnlock,
|
|
12
|
+
import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
|
|
13
13
|
import { loadRunState, newRunId, outcomeOf } from "./run-store.js";
|
|
14
14
|
import { exitCodeForOutcome } from "./schema.js";
|
|
15
15
|
import { absPath, assertName, projectPaths, resolveHome } from "./paths.js";
|
|
@@ -116,10 +116,10 @@ async function preflight(pr) {
|
|
|
116
116
|
if (r.errors.length)
|
|
117
117
|
fail(`${r.errors.map((e) => `ERROR ${e}`).join("\n")}\nFix the above (see \`agent-lyceum doctor\`).`);
|
|
118
118
|
}
|
|
119
|
-
/** Take the
|
|
120
|
-
function takeLock(pr, runId) {
|
|
119
|
+
/** 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). */
|
|
120
|
+
function takeLock(pr, runId, opts = {}) {
|
|
121
121
|
try {
|
|
122
|
-
return acquireProjectLock(pr.paths.root, runId);
|
|
122
|
+
return acquireProjectLock(pr.paths.root, runId, { maxConcurrent: opts.ignoreLimit ? Number.MAX_SAFE_INTEGER : pr.dispatcher.max_concurrent_runs, exclusive: opts.exclusive });
|
|
123
123
|
}
|
|
124
124
|
catch (e) {
|
|
125
125
|
return fail(e.message);
|
|
@@ -156,6 +156,17 @@ function printRun(summary, runDir) {
|
|
|
156
156
|
console.log(`Note: ${summary.outcomeNote}`);
|
|
157
157
|
if (summary.verification)
|
|
158
158
|
console.log(`Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
|
|
159
|
+
if (summary.resultBranch?.files) {
|
|
160
|
+
console.log(`\nThe work is on branch ${summary.resultBranch.branch} (${summary.resultBranch.files} file(s)); your working tree was not touched.\nBring it in with: git merge ${summary.resultBranch.branch}${summary.resultBranch.patch ? `\n(or apply the patch: git apply ${summary.resultBranch.patch})` : ""}`);
|
|
161
|
+
}
|
|
162
|
+
if (summary.pr) {
|
|
163
|
+
if (summary.pr.status === "opened")
|
|
164
|
+
console.log(`\nPull request opened: ${summary.pr.url}`);
|
|
165
|
+
else if (summary.pr.status === "skipped")
|
|
166
|
+
console.log(`\nA pull request was asked for but not opened: ${summary.pr.reason}`);
|
|
167
|
+
else
|
|
168
|
+
console.log(`\nThe pull request could not be opened (${summary.pr.step}: ${summary.pr.reason}).${summary.pr.manual.length ? ` To finish by hand:\n${summary.pr.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
|
|
169
|
+
}
|
|
159
170
|
if (summary.doneMessage) {
|
|
160
171
|
console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
|
|
161
172
|
console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
|
|
@@ -330,22 +341,51 @@ program
|
|
|
330
341
|
}
|
|
331
342
|
});
|
|
332
343
|
/** Color when writing to a terminal, unless NO_COLOR is set; FORCE_COLOR overrides. */
|
|
333
|
-
/** `clear --lock`: show who holds
|
|
334
|
-
function unlockProject(pr, force) {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
344
|
+
/** `clear --lock [run-id]`: show who holds a run lock; remove it only with --force. With several locks, name the run. */
|
|
345
|
+
function unlockProject(pr, runId, force) {
|
|
346
|
+
const root = pr.paths.root;
|
|
347
|
+
const locks = listRunLocks(root);
|
|
348
|
+
const unreadable = listUnreadableRunLocks(root);
|
|
349
|
+
if (runId && unreadable.includes(runId)) {
|
|
350
|
+
console.log(`The lock file of run ${runId} exists but cannot be read.`);
|
|
351
|
+
if (!force)
|
|
352
|
+
fail("Not removed. Check that no run is active, then repeat with --force.");
|
|
353
|
+
forceUnlock(root, runId);
|
|
354
|
+
console.log(`Lock removed. Resume the run with: agent-lyceum resume ${runId}`);
|
|
338
355
|
return;
|
|
339
356
|
}
|
|
340
|
-
if (
|
|
341
|
-
|
|
357
|
+
if (!runId && unreadable.length && locks.length + unreadable.length > 1) {
|
|
358
|
+
const list = [...locks.map((l) => ` ${l.run_id} pid ${l.pid}${lockHolderAlive(l) ? " (alive)" : " (gone)"}`), ...unreadable.map((id) => ` ${id} (lock file cannot be read)`)].join("\n");
|
|
359
|
+
fail(`Several runs hold a lock:\n${list}\nName the one to unlock: agent-lyceum clear --lock <run-id> --force`);
|
|
360
|
+
}
|
|
361
|
+
if (!runId && unreadable.length && !locks.length) {
|
|
362
|
+
fail(`The lock file of run ${unreadable[0]} cannot be read. Name it to remove it: agent-lyceum clear --lock ${unreadable[0]} --force`);
|
|
363
|
+
}
|
|
364
|
+
if (!runId && locks.length > 1) {
|
|
365
|
+
const list = locks.map((l) => ` ${l.run_id} pid ${l.pid}${lockHolderAlive(l) ? " (alive)" : " (gone)"}`).join("\n");
|
|
366
|
+
fail(`Several runs hold a lock:\n${list}\nName the one to unlock: agent-lyceum clear --lock <run-id> --force`);
|
|
342
367
|
}
|
|
343
|
-
|
|
368
|
+
const target = runId ?? locks[0]?.run_id;
|
|
369
|
+
if (!target) {
|
|
370
|
+
if (!fs.existsSync(path.join(root, LOCK_FILE))) {
|
|
371
|
+
console.log(`Project ${pr.name} has no run lock.`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
344
374
|
console.log("The lock file exists but cannot be read.");
|
|
375
|
+
if (!force)
|
|
376
|
+
fail("Not removed. Check that no run is active, then repeat with --force.");
|
|
377
|
+
forceUnlock(root);
|
|
378
|
+
console.log("Lock removed.");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const info = inspectRunLock(root, target);
|
|
382
|
+
if (!info)
|
|
383
|
+
fail(`Run "${target}" holds no readable lock. List the locks with: agent-lyceum doctor`);
|
|
384
|
+
console.log(`Lock held for run ${info.run_id} by pid ${info.pid} on ${info.hostname}, last heartbeat ${info.heartbeat_at}${lockHolderAlive(info) ? " (that process is still alive)" : " (that process is gone)"}.`);
|
|
345
385
|
if (!force)
|
|
346
386
|
fail("Not removed. Check that no run is active, then repeat with --force.");
|
|
347
|
-
forceUnlock(
|
|
348
|
-
console.log(
|
|
387
|
+
forceUnlock(root, target);
|
|
388
|
+
console.log(`Lock removed. Resume the run with: agent-lyceum resume ${target}`);
|
|
349
389
|
}
|
|
350
390
|
program
|
|
351
391
|
.command("clear [run-id]")
|
|
@@ -353,20 +393,20 @@ program
|
|
|
353
393
|
.description("Delete a task (run) by id: its run directory (state, log, result, mailboxes), its task memory and its agent worktrees, branches and snapshots.\n\n" +
|
|
354
394
|
"Long-term (global and project) memory is never touched.\n" +
|
|
355
395
|
"Refuses while the run is running, or while a worktree holds work that is not in the repo.\n\n" +
|
|
356
|
-
"With --lock (
|
|
357
|
-
" - removes the run lock a crashed run left behind\n" +
|
|
396
|
+
"With --lock it does something else (the run id is optional):\n" +
|
|
397
|
+
" - removes the run lock a crashed run left behind (name the run when several hold a lock)\n" +
|
|
358
398
|
" - needs --force to actually remove it")
|
|
359
399
|
.option("-p, --project <name>")
|
|
360
400
|
.option("--dry-run", "only list what would be deleted and what would be kept")
|
|
361
|
-
.option("--lock", "remove the
|
|
401
|
+
.option("--lock", "remove the run lock left behind by a crashed run (give its run id when several runs hold a lock; needs --force)")
|
|
362
402
|
.option("--force", "with --lock: remove the lock even though its owner cannot be confirmed dead")
|
|
363
403
|
.option("--keep-worktrees", "keep the agents' worktrees and branches (and their snapshots), and still clear the run and its task memory")
|
|
364
404
|
.action((runId, opts) => {
|
|
365
405
|
try {
|
|
366
406
|
if (opts.lock) {
|
|
367
407
|
if (runId)
|
|
368
|
-
|
|
369
|
-
return unlockProject(loadProject(opts.project), !!opts.force);
|
|
408
|
+
assertName("run", runId);
|
|
409
|
+
return unlockProject(loadProject(opts.project), runId, !!opts.force);
|
|
370
410
|
}
|
|
371
411
|
if (opts.force)
|
|
372
412
|
fail("--force only goes with --lock.");
|
|
@@ -394,7 +434,7 @@ program
|
|
|
394
434
|
refuse(plan);
|
|
395
435
|
return;
|
|
396
436
|
}
|
|
397
|
-
const lease = takeLock(pr, runId);
|
|
437
|
+
const lease = takeLock(pr, runId, { ignoreLimit: true });
|
|
398
438
|
try {
|
|
399
439
|
const plan = planRunCleanup(pr, runId, { keepWorktrees: opts.keepWorktrees });
|
|
400
440
|
if (plan.refusals.length)
|
|
@@ -537,7 +577,7 @@ program
|
|
|
537
577
|
if (!opts.agent)
|
|
538
578
|
fail("--undo needs --agent <name>: an archive belongs to one agent's memory.");
|
|
539
579
|
const target = loadProject(opts.project);
|
|
540
|
-
const undoLease = takeLock(target, `restore-${tidyStamp()}
|
|
580
|
+
const undoLease = takeLock(target, `restore-${tidyStamp()}`, { exclusive: true });
|
|
541
581
|
try {
|
|
542
582
|
const r = restoreMemory({ project: target, agent: opts.agent, layer, stamp: opts.undo });
|
|
543
583
|
for (const f of r.restored)
|
|
@@ -565,7 +605,7 @@ program
|
|
|
565
605
|
if (!res.ok)
|
|
566
606
|
fail("Configuration is invalid; fix the errors above (see `agent-lyceum doctor`).");
|
|
567
607
|
await preflight(pr);
|
|
568
|
-
const lease = takeLock(pr, `tidy-${tidyStamp()}
|
|
608
|
+
const lease = takeLock(pr, `tidy-${tidyStamp()}`, { exclusive: true });
|
|
569
609
|
const cancel = cancelOnSignals();
|
|
570
610
|
let results;
|
|
571
611
|
try {
|
package/dist/config.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
72
72
|
dispatcher: {
|
|
73
73
|
max_rounds?: number | undefined;
|
|
74
74
|
max_parallel?: number | undefined;
|
|
75
|
+
max_concurrent_runs?: number | undefined;
|
|
75
76
|
wake_timeout_sec?: number | undefined;
|
|
76
77
|
retry?: number | undefined;
|
|
77
78
|
strict?: boolean | undefined;
|
package/dist/doctor.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { findOnPath } from "./fs-util.js";
|
|
4
5
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
5
|
-
import {
|
|
6
|
+
import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
|
|
6
7
|
import { runInvocation } from "./process-runner.js";
|
|
7
8
|
import { validateProject } from "./validate.js";
|
|
8
|
-
import { dirtyPaths, isGitRepo, resolveWorkspaceMode } from "./worktree.js";
|
|
9
|
+
import { dirtyPaths, git, isGitRepo, resolveWorkspaceMode } from "./worktree.js";
|
|
9
10
|
const BINARY = { "claude-code": "claude", codex: "codex" };
|
|
10
11
|
/** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
|
|
11
12
|
async function query(cmd, args, opts) {
|
|
@@ -118,21 +119,46 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
118
119
|
if (fs.existsSync(project.dir) && isGitRepo(project.dir)) {
|
|
119
120
|
if (resolveWorkspaceMode(project.dispatcher) === "worktree") {
|
|
120
121
|
const dirty = dirtyPaths(project.dir);
|
|
121
|
-
if (dirty.length)
|
|
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) {
|
|
122
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
|
+
}
|
|
123
128
|
else
|
|
124
129
|
checks.push({ level: "ok", subject: "git", message: "Repo is clean; parallel runs can start." });
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
if (project.dispatcher.max_concurrent_runs > 1 && fs.existsSync(project.dir) && isGitRepo(project.dir)) {
|
|
133
|
+
const missing = [];
|
|
134
|
+
if (!findOnPath("gh", opts.env?.PATH ?? process.env.PATH))
|
|
135
|
+
missing.push("`gh` is not on PATH");
|
|
136
|
+
let remotes = "";
|
|
137
|
+
try {
|
|
138
|
+
remotes = git(project.dir, ["remote"]);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* no remotes readable */
|
|
142
|
+
}
|
|
143
|
+
if (!remotes.split(/\s+/).includes("origin"))
|
|
144
|
+
missing.push("the repo has no `origin` remote");
|
|
145
|
+
checks.push(missing.length
|
|
146
|
+
? { level: "info", subject: "pr", message: `A run cannot open a pull request (a lead asks for one with \`open_pr: true\`): ${missing.join("; ")}.` }
|
|
147
|
+
: { level: "ok", subject: "pr", message: "`gh` and `origin` found: a run can open a pull request when the lead asks for one." });
|
|
148
|
+
}
|
|
149
|
+
for (const lock of listRunLocks(project.paths.root)) {
|
|
129
150
|
const alive = lockHolderAlive(lock);
|
|
130
151
|
checks.push({
|
|
131
152
|
level: "warn",
|
|
132
153
|
subject: "lock",
|
|
133
|
-
message: alive
|
|
154
|
+
message: alive
|
|
155
|
+
? `A run is active: ${lock.run_id} (pid ${lock.pid}).`
|
|
156
|
+
: `A stale run lock is left by ${lock.run_id} (pid ${lock.pid} is gone); \`agent-lyceum clear --lock ${lock.run_id} --force\` clears it.`,
|
|
134
157
|
});
|
|
135
158
|
}
|
|
159
|
+
for (const id of listUnreadableRunLocks(project.paths.root)) {
|
|
160
|
+
checks.push({ level: "warn", subject: "lock", message: `The lock file of run ${id} cannot be read; if no run is active, \`agent-lyceum clear --lock ${id} --force\` clears it.` });
|
|
161
|
+
}
|
|
136
162
|
for (const a of tidyAdvice(project))
|
|
137
163
|
checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
|
|
138
164
|
const runtimes = await probeProjectRuntimes(project, opts);
|
package/dist/guard.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export declare class ProtectedGuard {
|
|
|
18
18
|
private project;
|
|
19
19
|
private baseline;
|
|
20
20
|
constructor(project: ResolvedProject);
|
|
21
|
+
/**
|
|
22
|
+
* Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
|
|
23
|
+
* judged: edits made in between (you, or another run of the project) are not the batch's doing.
|
|
24
|
+
*/
|
|
25
|
+
rebase(): void;
|
|
21
26
|
static protectedFiles(project: ResolvedProject): string[];
|
|
22
27
|
/** Copy the current protected files into `dir` for the audit trail. */
|
|
23
28
|
saveSnapshot(dir: string): void;
|
package/dist/guard.js
CHANGED
|
@@ -15,7 +15,14 @@ export class ProtectedGuard {
|
|
|
15
15
|
baseline = new Map();
|
|
16
16
|
constructor(project) {
|
|
17
17
|
this.project = project;
|
|
18
|
-
|
|
18
|
+
this.rebase();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
|
|
22
|
+
* judged: edits made in between (you, or another run of the project) are not the batch's doing.
|
|
23
|
+
*/
|
|
24
|
+
rebase() {
|
|
25
|
+
for (const f of ProtectedGuard.protectedFiles(this.project))
|
|
19
26
|
this.baseline.set(f, read(f));
|
|
20
27
|
}
|
|
21
28
|
static protectedFiles(project) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Runs `git` or `gh` in `cwd`. Injected so tests never touch a network or a real remote. */
|
|
2
|
+
export type CommandRunner = (cmd: "git" | "gh", args: string[], cwd: string) => {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
out: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The real thing: runs outside any agent sandbox, with the user's own git and gh credentials.
|
|
8
|
+
* Prompts are off, so a missing credential fails at once instead of hanging until the timeout.
|
|
9
|
+
*/
|
|
10
|
+
export declare const realRunner: CommandRunner;
|
|
11
|
+
/** The one line that says who opened the pull request. It names no agent and no role. */
|
|
12
|
+
export declare const PR_FOOTER = "\uD83E\uDD16 Opened by agent-lyceum";
|
|
13
|
+
/** First non-empty line of the task summary, cut to 72 characters. */
|
|
14
|
+
export declare function prTitle(taskSummary: string): string;
|
|
15
|
+
/** The lead's `done` report as written, then one footer line. */
|
|
16
|
+
export declare function prBody(doneBody: string): string;
|
|
17
|
+
/** The repo's default branch: `origin/HEAD`, else what `gh` says, else `main`. */
|
|
18
|
+
export declare function defaultBase(cwd: string, run: CommandRunner): string;
|
|
19
|
+
export type OpenPrResult = {
|
|
20
|
+
status: "opened";
|
|
21
|
+
url: string;
|
|
22
|
+
} | {
|
|
23
|
+
status: "failed";
|
|
24
|
+
step: "push" | "create" | "write";
|
|
25
|
+
reason: string;
|
|
26
|
+
manual: string[];
|
|
27
|
+
};
|
|
28
|
+
/** What happened to a requested pull request; `skipped` means it was asked for but the run could not support it. */
|
|
29
|
+
export type PrOutcome = OpenPrResult | {
|
|
30
|
+
status: "skipped";
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
export interface OpenPrInput {
|
|
34
|
+
/**
|
|
35
|
+
* The main repo: never a directory an agent can write. An agent can rewrite its worktree's git pointers and so plant hooks or
|
|
36
|
+
* config that git would run here, outside the sandbox. The branch is in the shared refs, so pushing it from the main repo works.
|
|
37
|
+
*/
|
|
38
|
+
cwd: string;
|
|
39
|
+
branch: string;
|
|
40
|
+
title: string;
|
|
41
|
+
body: string;
|
|
42
|
+
/** Where the description is written for `gh --body-file` (and for finishing by hand). */
|
|
43
|
+
bodyFile: string;
|
|
44
|
+
run: CommandRunner;
|
|
45
|
+
}
|
|
46
|
+
/** Push the branch and open a pull request against the default branch. Never throws; says how to finish by hand when a step fails. */
|
|
47
|
+
export declare function openPullRequest(i: OpenPrInput): OpenPrResult;
|
package/dist/open-pr.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { atomicWrite } from "./fs-util.js";
|
|
4
|
+
/**
|
|
5
|
+
* The real thing: runs outside any agent sandbox, with the user's own git and gh credentials.
|
|
6
|
+
* Prompts are off, so a missing credential fails at once instead of hanging until the timeout.
|
|
7
|
+
*/
|
|
8
|
+
export const realRunner = (cmd, args, cwd) => {
|
|
9
|
+
try {
|
|
10
|
+
const out = execFileSync(cmd, args, {
|
|
11
|
+
cwd,
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
14
|
+
timeout: 120_000,
|
|
15
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1" },
|
|
16
|
+
});
|
|
17
|
+
return { ok: true, out: out.trim() };
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
const err = e;
|
|
21
|
+
return { ok: false, out: String(err.stderr ?? err.message).trim() };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** The one line that says who opened the pull request. It names no agent and no role. */
|
|
25
|
+
export const PR_FOOTER = "🤖 Opened by agent-lyceum";
|
|
26
|
+
const TITLE_MAX = 72;
|
|
27
|
+
const BODY_MAX = 60_000; // GitHub rejects bodies over 65536 characters
|
|
28
|
+
/** First non-empty line of the task summary, cut to 72 characters. */
|
|
29
|
+
export function prTitle(taskSummary) {
|
|
30
|
+
const first = taskSummary.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "agent-lyceum task";
|
|
31
|
+
return first.length > TITLE_MAX ? `${first.slice(0, TITLE_MAX - 1)}…` : first;
|
|
32
|
+
}
|
|
33
|
+
/** The lead's `done` report as written, then one footer line. */
|
|
34
|
+
export function prBody(doneBody) {
|
|
35
|
+
return `${doneBody.trim().slice(0, BODY_MAX)}\n\n${PR_FOOTER}\n`;
|
|
36
|
+
}
|
|
37
|
+
/** The repo's default branch: `origin/HEAD`, else what `gh` says, else `main`. */
|
|
38
|
+
export function defaultBase(cwd, run) {
|
|
39
|
+
const sym = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd);
|
|
40
|
+
const m = sym.ok ? /^origin\/(.+)$/.exec(sym.out) : null;
|
|
41
|
+
if (m?.[1])
|
|
42
|
+
return m[1];
|
|
43
|
+
const gh = run("gh", ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], cwd);
|
|
44
|
+
return gh.ok && gh.out ? gh.out : "main";
|
|
45
|
+
}
|
|
46
|
+
const firstLine = (s) => s.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "no output";
|
|
47
|
+
/** POSIX single-quote: safe to paste into a shell whatever the string holds. */
|
|
48
|
+
const sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
49
|
+
/** Push the branch and open a pull request against the default branch. Never throws; says how to finish by hand when a step fails. */
|
|
50
|
+
export function openPullRequest(i) {
|
|
51
|
+
const base = defaultBase(i.cwd, i.run);
|
|
52
|
+
try {
|
|
53
|
+
atomicWrite(i.bodyFile, i.body);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return { status: "failed", step: "write", reason: firstLine(e.message ?? String(e)), manual: [] };
|
|
57
|
+
}
|
|
58
|
+
const createArgs = ["pr", "create", "--head", i.branch, "--base", base, "--title", i.title, "--body-file", i.bodyFile];
|
|
59
|
+
const manualPush = `git -C ${sh(i.cwd)} push -u origin ${sh(i.branch)}`;
|
|
60
|
+
const manualCreate = `gh pr create --head ${sh(i.branch)} --base ${sh(base)} --title ${sh(i.title)} --body-file ${sh(path.resolve(i.bodyFile))}`;
|
|
61
|
+
const push = i.run("git", ["push", "-u", "origin", i.branch], i.cwd);
|
|
62
|
+
if (!push.ok)
|
|
63
|
+
return { status: "failed", step: "push", reason: firstLine(push.out), manual: [manualPush, manualCreate] };
|
|
64
|
+
const created = i.run("gh", createArgs, i.cwd);
|
|
65
|
+
if (!created.ok)
|
|
66
|
+
return { status: "failed", step: "create", reason: firstLine(created.out), manual: [manualCreate] };
|
|
67
|
+
const url = created.out.split(/\s+/).find((w) => /^https?:\/\//.test(w));
|
|
68
|
+
if (!url)
|
|
69
|
+
return { status: "failed", step: "create", reason: `gh printed no URL: ${firstLine(created.out)}`, manual: [manualCreate] };
|
|
70
|
+
return { status: "opened", url };
|
|
71
|
+
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface WritePolicy {
|
|
|
26
26
|
export declare function commonFile(project: ResolvedProject): string;
|
|
27
27
|
export declare function outboxDir(project: ResolvedProject, agent: string): string;
|
|
28
28
|
export declare function inboxDir(project: ResolvedProject, agent: string): string;
|
|
29
|
+
/** The repo's own CLAUDE.md/AGENTS.md: in the lead's worktree when it has one (concurrent runs), else in the repo. */
|
|
29
30
|
export declare function repoInstructionFiles(project: ResolvedProject): string[];
|
|
30
31
|
export declare function memoryDirs(a: ResolvedAgent): string[];
|
|
31
32
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
package/dist/policy.js
CHANGED
|
@@ -11,8 +11,10 @@ export function outboxDir(project, agent) {
|
|
|
11
11
|
export function inboxDir(project, agent) {
|
|
12
12
|
return path.join(project.paths.inboxRoot, agent);
|
|
13
13
|
}
|
|
14
|
+
/** The repo's own CLAUDE.md/AGENTS.md: in the lead's worktree when it has one (concurrent runs), else in the repo. */
|
|
14
15
|
export function repoInstructionFiles(project) {
|
|
15
|
-
|
|
16
|
+
const dir = repoDirFor(project, project.lead);
|
|
17
|
+
return [path.join(dir, "CLAUDE.md"), path.join(dir, "AGENTS.md")];
|
|
16
18
|
}
|
|
17
19
|
export function memoryDirs(a) {
|
|
18
20
|
return [a.memory.global, a.memory.project, a.memory.task].filter((x) => !!x);
|
|
@@ -130,7 +132,7 @@ export function enforcementFor(project, agent) {
|
|
|
130
132
|
agent: agent.name,
|
|
131
133
|
runtime: agent.runtime ?? "?",
|
|
132
134
|
...e,
|
|
133
|
-
writablePaths: [...pol.allowDirs, ...pol.allowFiles.filter((f) => !f.startsWith(project.dir + path.sep))],
|
|
135
|
+
writablePaths: [...pol.allowDirs, ...pol.allowFiles.filter((f) => !f.startsWith(project.dir + path.sep) && !f.startsWith(repoDirFor(project, project.lead) + path.sep))],
|
|
134
136
|
notes,
|
|
135
137
|
};
|
|
136
138
|
}
|