agent-lyceum 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,10 +28,10 @@ Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged i
28
28
  | Project | `project add <name> --dir <repo>` | Register a project. [details](docs/commands.md#project-add) |
29
29
  | Project | `project` / `project remove <name> [--purge]` | List or unregister projects. [details](docs/commands.md#project-and-project-remove) |
30
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`). [details](docs/commands.md#status) |
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
- | 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 [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) |
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
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) |
@@ -90,7 +90,8 @@ Changes take effect on the next session start. If the pane shows `無法取得
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
- └── runs/<run-id>/{task.md, log.jsonl, state.json, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
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>/}
94
95
  ```
95
96
 
96
97
  `team.yaml` defines reusable agents; `project.yaml` picks the team and overrides fields. Objects merge field by field, arrays (`can_message`, `owns`) are replaced. Relative paths resolve against the folder of the file they are written in; `~` is allowed.
@@ -138,6 +139,18 @@ Known limits:
138
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.
139
140
  - Two runs that edit the same file are for you to reconcile when merging; agent-lyceum never merges.
140
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` and `round_floor` (all optional, so older runs still load). 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
+
141
154
  ### Opening a pull request
142
155
 
143
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.
@@ -190,6 +203,7 @@ Default rules (everything else in the repo is unrestricted):
190
203
  |---|---|
191
204
  | own memory dirs, own `outbox/` | writable |
192
205
  | any `AGENT.md`, `COMMON.md`, repo `CLAUDE.md`/`AGENTS.md` | read-only; the lead (`can_edit_agent_md`) may edit them |
206
+ | the run's handoff notes `task-memory/<run-id>/_shared/` | the lead writes; every other agent is read-only |
193
207
  | other agents' memory/inbox/outbox, `runs/`, config files | not writable |
194
208
 
195
209
  Enforcement is layered because no single mechanism is complete:
@@ -198,7 +212,7 @@ Enforcement is layered because no single mechanism is complete:
198
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.
199
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.
200
214
 
201
- `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` and other agents' context are OS-enforced.
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.
202
216
 
203
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.
204
218
 
package/README.zh-TW.md CHANGED
@@ -28,10 +28,10 @@ npx agent-lyceum status
28
28
  | 專案 | `project add <name> --dir <repo>` | 註冊專案。 [詳細](docs/commands.zh-TW.md#project-add) |
29
29
  | 專案 | `project` / `project remove <name> [--purge]` | 列出或取消註冊專案。 [詳細](docs/commands.zh-TW.md#project-and-project-remove) |
30
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`)。 [詳細](docs/commands.zh-TW.md#status) |
32
- | 執行 | `run ["task"] [--task-file f] [--agent a] [-p name]` | 把任務交給 lead 並執行到結束;加 `--agent` 則只交給某個成員單獨做。 [詳細](docs/commands.zh-TW.md#run) |
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 [run-id] --force` | 刪除 run 與其任務記憶、worktree;或移除當機 run 留下的專案鎖。 [詳細](docs/commands.zh-TW.md#clear) |
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
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) |
@@ -90,8 +90,8 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
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
- ├── task-memory/<run-id>/<agent>/ # 任務記憶:每次 run 獨立,不與其他任務共用
94
- └── runs/<run-id>/{task.md, log.jsonl, state.json, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
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
95
  ```
96
96
 
97
97
  `team.yaml` 定義可重複使用的 agent;`project.yaml` 選擇團隊並覆寫欄位。物件會逐欄位合併,陣列(`can_message`、`owns`)則整個取代。相對路徑以寫入該路徑的檔案所在資料夾為基準;允許使用 `~`。
@@ -139,6 +139,18 @@ Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可
139
139
  - `COMMON.md` 與各 `AGENT.md` 由專案的所有 run 共用。每個 run 的防護只判斷它自己的 agent 工作期間發生的修改,所以某個 run 的修改會被保留;但若兩個 run 的喚醒時間重疊,一個 run 在另一個 run 的 agent 工作期間所做的修改,可能被另一個 run 還原。
140
140
  - 兩個 run 修改同一個檔案時,要由你在合併時自行整合;agent-lyceum 從不自動合併。
141
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`(都是選填,舊的 run 照樣能讀)。未解決的 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
+
142
154
  ### 開 PR
143
155
 
144
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` 在沙箱外以你自己的憑證執行。
@@ -191,6 +203,7 @@ agents:
191
203
  |---|---|
192
204
  | 自己的記憶資料夾、自己的 `outbox/` | 可寫入 |
193
205
  | 任何 `AGENT.md`、`COMMON.md`、repo 的 `CLAUDE.md`/`AGENTS.md` | 唯讀;lead(`can_edit_agent_md`)可編輯 |
206
+ | run 的交接筆記 `task-memory/<run-id>/_shared/` | lead 可寫;其他 agent 唯讀 |
194
207
  | 其他 agent 的記憶/inbox/outbox、`runs/`、設定檔 | 不可寫入 |
195
208
 
196
209
  由於沒有單一機制是完整的,防護採分層設計:
@@ -199,7 +212,7 @@ agents:
199
212
  - **Codex**:`-s workspace-write` 搭配 `-c sandbox_workspace_write.writable_roots=[...]`(由 OS 強制)。Codex 無法禁止 repo 內的單一檔案,所以 repo 的 `CLAUDE.md`/`AGENTS.md` 只能靠**事後偵測**保護。
200
213
  - **所有 runtime**:執行前會對受保護檔案做雜湊;每次喚醒後,未經授權的變更會被還原、記錄,並通報 lead;被寫入的內容會先留存在 `runs/<run-id>/violations/`。
201
214
 
202
- `doctor` 與 `run` 會依類別(`os`、`tool-rules`、`post-hoc`、`prompt-only`)印出每個 agent 的防護等級,以及它能寫入 repo 之外的哪些路徑。設定 `dispatcher.strict: true` 時,除非記憶、`AGENT.md` 與其他 agent 的 context 都由 OS 強制保護,否則拒絕執行。
215
+ `doctor` 與 `run` 會依類別(`os`、`tool-rules`、`post-hoc`、`prompt-only`)印出每個 agent 的防護等級,以及它能寫入 repo 之外的哪些路徑。設定 `dispatcher.strict: true` 時,除非記憶、`AGENT.md`、其他 agent 的 context 與(非 lead 的 agent 的)交接筆記都由 OS 強制保護,否則拒絕執行。
203
216
 
204
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 模式下會被拒絕。
205
218
 
@@ -62,7 +62,7 @@ export function buildClaudeInvocation(input) {
62
62
  fs.writeFileSync(systemFile, input.systemPrompt);
63
63
  const pol = writePolicy(project, agent);
64
64
  const repo = repoDirFor(project, agent.name);
65
- const addDirs = [...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === repo || d.startsWith(repo + path.sep)));
65
+ const addDirs = [...pol.allowDirs, ...pol.readDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === repo || d.startsWith(repo + path.sep)));
66
66
  const args = [
67
67
  "-p",
68
68
  "--output-format",
@@ -55,7 +55,7 @@ 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. */
58
+ /** An answer that is just a letter ("b", "B:", "b)") becomes the one option that starts with that letter and a ":", "." or ")"; failing that, the letter (a = first) or number (1 = first) picks an option by position. No match, or more than one, leaves it as typed. */
59
59
  export declare function resolveOptionLetters(r: AskReply): AskReply;
60
60
  /** Only questions whose answer was not sent yet count. */
61
61
  export declare function checkAnswers(r: AskReply): AnswerCheck;
package/dist/ask-reply.js CHANGED
@@ -116,23 +116,23 @@ export function addBatch(existing, asker, round, qs) {
116
116
  }
117
117
  export const isComplete = (c) => c.missing.length === 0 && c.invalid.length === 0;
118
118
  function problem(q) {
119
- const v = q.answer?.value.trim();
120
- if (!v)
121
- return undefined;
122
- if (q.options?.length && !q.options.includes(v) && !/^other:\s*\S/.test(v))
123
- return `must be one of: ${q.options.join(", ")} — or start with "other:" and write your own`;
119
+ // Any non-empty text is a valid answer: the options are shortcuts, not a limit. A bare "other:" says nothing.
120
+ if (/^other:\s*$/.test(q.answer?.value.trim() ?? ""))
121
+ return `write your own answer after "other:", or pick one of: ${(q.options ?? []).join(", ")}`;
124
122
  return undefined;
125
123
  }
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. */
124
+ /** An answer that is just a letter ("b", "B:", "b)") becomes the one option that starts with that letter and a ":", "." or ")"; failing that, the letter (a = first) or number (1 = first) picks an option by position. No match, or more than one, leaves it as typed. */
127
125
  export function resolveOptionLetters(r) {
128
126
  return {
129
127
  ...r,
130
128
  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)
129
+ const raw = q.answer?.value.trim().match(/^([A-Za-z]|\d{1,2})[:.)]?$/)?.[1]?.toLowerCase();
130
+ if (q.delivered || !raw || !q.options?.length || !q.answer)
133
131
  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;
132
+ const hits = q.options.filter((o) => o.toLowerCase().match(/^([a-z])[:.)]/)?.[1] === raw);
133
+ const byPosition = /^\d+$/.test(raw) ? Number(raw) - 1 : raw.charCodeAt(0) - 97;
134
+ const pick = hits.length ? (hits.length === 1 ? hits[0] : undefined) : q.options[byPosition];
135
+ return pick ? { ...q, answer: { ...q.answer, value: pick } } : q;
136
136
  }),
137
137
  };
138
138
  }
package/dist/cli.js CHANGED
@@ -10,12 +10,12 @@ 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 } from "./run-store.js";
13
+ import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, runLabel, slugify, uniqueName } from "./run-store.js";
14
14
  import { exitCodeForOutcome } from "./schema.js";
15
- import { absPath, assertName, projectPaths, resolveHome } from "./paths.js";
15
+ import { absPath, assertName, NAME_RE, projectPaths, resolveHome } from "./paths.js";
16
16
  import { addProject, initHome, removeProject } from "./scaffold.js";
17
- import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, runIsAlive } from "./status.js";
18
- import { prepareTask, readTaskFile } from "./task.js";
17
+ import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, listRuns, reopenCandidates, runIsAlive } from "./status.js";
18
+ import { prepareTask, readTaskFile, taskMessageBody } from "./task.js";
19
19
  import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
20
20
  import { formatEnforcement, validateProject } from "./validate.js";
21
21
  import { must } from "./assert.js";
@@ -116,6 +116,19 @@ 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
+ /** The run a user typed: full id, name or unique prefix. Exits with the candidates when it is unknown or ambiguous. */
120
+ function findRun(pr, query) {
121
+ if (!query.trim())
122
+ return fail("Give a run id or name (see `agent-lyceum status --task-list`).");
123
+ assertName("run", query);
124
+ const r = resolveRun(listRuns(pr, Infinity), query);
125
+ if (r.ok)
126
+ return r.run;
127
+ if (r.reason === "none")
128
+ return fail(`Run "${query}" not found in ${pr.paths.runs}. List runs with: agent-lyceum status --task-list`);
129
+ const list = r.candidates.map((c) => ` ${c.state.name ?? "(no name)"} ${c.state.run_id} ${c.state.task_summary}`).join("\n");
130
+ return fail(`"${query}" matches several runs:\n${list}\nGive more of the name or id.`);
131
+ }
119
132
  /** 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
133
  function takeLock(pr, runId, opts = {}) {
121
134
  try {
@@ -151,7 +164,7 @@ function cancelOnSignals() {
151
164
  };
152
165
  }
153
166
  function printRun(summary, runDir) {
154
- console.log(`\nRun ${summary.runId} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
167
+ 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")}`);
155
168
  if (summary.outcomeNote)
156
169
  console.log(`Note: ${summary.outcomeNote}`);
157
170
  if (summary.verification)
@@ -161,7 +174,7 @@ function printRun(summary, runDir) {
161
174
  }
162
175
  if (summary.pr) {
163
176
  if (summary.pr.status === "opened")
164
- console.log(`\nPull request opened: ${summary.pr.url}`);
177
+ console.log(`\nPull request ${summary.pr.updated ? "updated" : "opened"}: ${summary.pr.url}`);
165
178
  else if (summary.pr.status === "skipped")
166
179
  console.log(`\nA pull request was asked for but not opened: ${summary.pr.reason}`);
167
180
  else
@@ -176,16 +189,20 @@ function reportRun(summary, runDir) {
176
189
  printRun(summary, runDir);
177
190
  process.exit(exitCodeForOutcome(summary.outcome));
178
191
  }
192
+ const firstLine = (t) => t.trim().split("\n")[0].trim();
179
193
  /** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
180
- async function continueRun(pr, dir, runId) {
194
+ async function continueRun(pr, dir, runId, followup) {
181
195
  const lease = takeLock(pr, runId);
182
196
  const cancel = cancelOnSignals();
183
197
  try {
184
198
  console.log(`Project ${pr.name} — repo ${pr.dir}`);
185
199
  const state = loadRunState(dir);
186
- console.log(`Resuming run ${state.run_id} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${pr.dispatcher.max_rounds}: ${state.task_summary}`);
200
+ if (followup)
201
+ console.log(`Reopening run ${runLabel(state)} (follow-up ${(state.reopened?.length ?? 0) + 1}): ${firstLine(followup.text)}`);
202
+ else
203
+ console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${roundLimit(state, pr.dispatcher.max_rounds)}: ${state.task_summary}`);
187
204
  // Read the state again under the lock: another process may have changed it while we were checking.
188
- return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal });
205
+ return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal, followup });
189
206
  }
190
207
  finally {
191
208
  cancel.dispose();
@@ -238,6 +255,7 @@ program
238
255
  .option("-p, --project <name>")
239
256
  .option("--task-file <path>", "read the task from a file")
240
257
  .option("--agent <name>", "run only this member of the project, alone: it cannot mail teammates or ask you, and its own `done` ends the run")
258
+ .option("--name <name>", "name for this run (default: made from the task)")
241
259
  .option("--assume-defaults", "when the run stops to ask, use the suggested answer for every question that has one")
242
260
  .action(async (task, opts) => {
243
261
  try {
@@ -257,6 +275,13 @@ program
257
275
  checked = onlyAgent(sp, opts.agent);
258
276
  }
259
277
  await preflight(checked);
278
+ const taken = listRuns(pr, Infinity).flatMap((r) => (r.state.name ? [r.state.name] : []));
279
+ if (opts.name !== undefined) {
280
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opts.name) || opts.name.length > 30)
281
+ fail(`Invalid run name "${opts.name}": use lowercase letters, digits and "-", at most 30 characters.`);
282
+ if (taken.includes(opts.name))
283
+ fail(`A run named "${opts.name}" already exists in project ${pr.name}.`);
284
+ }
260
285
  const runId = newRunId();
261
286
  const runDir = path.join(pr.paths.runs, runId);
262
287
  const lease = takeLock(pr, runId);
@@ -266,7 +291,8 @@ program
266
291
  const prepared = prepareTask({ text: task, file: opts.taskFile, cwd: process.cwd(), runDir });
267
292
  console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
268
293
  console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
269
- summary = await runTeam({ project: pr, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
294
+ const name = opts.name ?? uniqueName(slugify(taskMessageBody(prepared).subject), taken);
295
+ summary = await runTeam({ project: pr, name, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
270
296
  }
271
297
  finally {
272
298
  cancel.dispose();
@@ -278,15 +304,41 @@ program
278
304
  fail(e instanceof ConfigError ? e.message : e.message);
279
305
  }
280
306
  });
307
+ /** With a follow-up text and no run named: the one finished run to reopen. Several: ask in a terminal, otherwise list them. Never guesses. */
308
+ async function pickReopenRun(pr) {
309
+ const cands = reopenCandidates(pr);
310
+ if (cands.length === 0)
311
+ return fail('No run to continue. Start one with: agent-lyceum run "<task>"');
312
+ if (cands.length === 1)
313
+ return cands[0];
314
+ const label = (c) => `${runLabel(c.state)} [${c.state.end_reason ?? "interrupted"}] ${firstLine(c.state.task_summary)}`;
315
+ if (!interactive()) {
316
+ return fail(`Several runs could take this follow-up:\n${cands.map((c) => ` ${c.state.name ? `${c.state.name} ` : ""}${c.state.run_id} [${c.state.end_reason ?? "interrupted"}] ${firstLine(c.state.task_summary)}`).join("\n")}\nName one: agent-lyceum resume <name> "..."`);
317
+ }
318
+ console.log("Which run should get the follow-up?");
319
+ for (const [i, c] of cands.entries())
320
+ console.log(` ${i + 1}) ${label(c)}`);
321
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
322
+ const answer = (await rl.question("Number or name: ")).trim();
323
+ rl.close();
324
+ const n = Number(answer);
325
+ if (Number.isInteger(n) && n >= 1 && n <= cands.length)
326
+ return cands[n - 1];
327
+ const r = resolveRun(cands, answer);
328
+ if (r.ok)
329
+ return r.run;
330
+ return fail(`"${answer}" is not one of the listed runs.`);
331
+ }
281
332
  program
282
- .command("resume [run-id]")
283
- .summary("Continue an interrupted (or failed) run")
284
- .description("Continue an interrupted (or failed) run: same run dir, sessions and round count; the task is not re-sent.\n\nWhich run:\n - without an id: the newest run that is not done or running\n - with an id: that run")
333
+ .command("resume [run] [text]")
334
+ .summary("Continue an interrupted run, or reopen a finished one with a follow-up")
335
+ .description('Continue an interrupted (or failed) run: same run dir, sessions and round count; the task is not re-sent.\nOr reopen a finished run with a follow-up request: the lead gets the text as a new task and the run goes on with its memory and worktree.\n\nUsage:\n - resume the newest run that is not done or running\n - resume <run> that run (full id, name or unique prefix); a finished run only prints its result\n - resume <run> "<text>" reopen that finished run with the follow-up\n - resume "<text>" if the one argument is not a run, it is the follow-up; the run is chosen among the runs that are not running\n (the only one, or you pick one in a terminal; without a terminal the candidates are listed and it fails)\n a one-word follow-up looks like a run name: use resume <run> "<text>" or --task-file\n - --task-file <path> read the follow-up from a file instead of an argument')
285
336
  .option("-p, --project <name>")
337
+ .option("--task-file <path>", "read the follow-up text from a file (reopens a finished run)")
286
338
  .option("--answer", "the run is waiting for answers: open ask-reply.md in $EDITOR, check the answers, then continue")
287
339
  .option("--no-edit", "with --answer: do not open the editor; check the file as it is")
288
340
  .option("--assume-defaults", "if the run waits for answers, use the suggested answer for every unanswered question that has one")
289
- .action(async (runId, opts) => {
341
+ .action(async (runArg, textArg, opts) => {
290
342
  try {
291
343
  const pr = loadProject(opts.project);
292
344
  const res = validateProject(pr);
@@ -294,13 +346,30 @@ program
294
346
  console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
295
347
  if (!res.ok)
296
348
  fail("Configuration is invalid; fix the errors above (see `agent-lyceum doctor`).");
349
+ let text = textArg;
350
+ let query = runArg;
351
+ if (opts.taskFile) {
352
+ if (textArg !== undefined)
353
+ fail("Give the follow-up either as text or with --task-file, not both.");
354
+ text = readTaskFile(path.resolve(process.cwd(), opts.taskFile));
355
+ }
356
+ else if (query !== undefined && textArg === undefined) {
357
+ // One argument: a run if it names one. It is follow-up text only if it cannot be a run reference (it has whitespace or a character a run name cannot contain);
358
+ // a mistyped run name must not silently become the task. An ambiguous match is still an error.
359
+ const r = query.trim() ? resolveRun(listRuns(pr, Infinity), query) : undefined;
360
+ if (r && !r.ok && r.reason === "none" && !NAME_RE.test(query)) {
361
+ text = query;
362
+ query = undefined;
363
+ }
364
+ }
365
+ if (text !== undefined && !text.trim())
366
+ fail("The follow-up text is empty.");
297
367
  let found;
298
- if (runId) {
299
- assertName("run", runId);
300
- const dir = path.join(pr.paths.runs, runId);
301
- if (!fs.existsSync(path.join(dir, "state.json")))
302
- fail(`Run "${runId}" not found in ${pr.paths.runs}.`);
303
- found = { dir, state: loadRunState(dir) };
368
+ if (query !== undefined) {
369
+ found = findRun(pr, query);
370
+ }
371
+ else if (text !== undefined) {
372
+ found = await pickReopenRun(pr);
304
373
  }
305
374
  else {
306
375
  found = latestUnfinishedRun(pr);
@@ -311,19 +380,23 @@ program
311
380
  // A solo run needs only its own agent's runtime, as when it started.
312
381
  await preflight(state.solo_agent ? onlyAgent(soloProject(pr, state.solo_agent), state.solo_agent) : pr);
313
382
  if (!state.end_reason && runIsAlive(state, pr))
314
- fail(`Run ${state.run_id} is still running (pid ${state.pid}).`);
315
- if (state.end_reason === "done") {
383
+ fail(`Run ${runLabel(state)} is still running (pid ${state.pid}).`);
384
+ const reopen = text !== undefined;
385
+ if (reopen && state.end_reason !== "done")
386
+ fail(`Run ${runLabel(state)} is not finished (${state.end_reason ?? "interrupted"}); resume it without text first, then follow up.`);
387
+ if (!reopen && state.end_reason === "done") {
316
388
  const result = path.join(dir, RESULT_FILE);
317
389
  if (!fs.existsSync(result))
318
- fail(`Run ${state.run_id} already finished (done); nothing to resume.`);
390
+ fail(`Run ${runLabel(state)} already finished (done); nothing to resume.`);
319
391
  const o = outcomeOf(state);
320
- console.log(`Run ${state.run_id} already finished (done); nothing to resume. Outcome: ${o.outcome}${o.verified ? "" : " (not verified: this run ended before outcomes were recorded)"}. Result: ${result}\n\n${fs.readFileSync(result, "utf8")}`);
392
+ console.log(`Run ${runLabel(state)} already finished (done); nothing to resume. Outcome: ${o.outcome}${o.verified ? "" : " (not verified: this run ended before outcomes were recorded)"}. Result: ${result}\n\n${fs.readFileSync(result, "utf8")}`);
321
393
  process.exit(exitCodeForOutcome(o.outcome));
322
394
  }
323
- if (state.rounds >= pr.dispatcher.max_rounds)
324
- fail(`Run ${state.run_id} used ${state.rounds}/${pr.dispatcher.max_rounds} rounds; raise dispatcher.max_rounds in project.yaml first.`);
395
+ // 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.`);
325
398
  if (opts.answer && state.end_reason !== "waiting")
326
- fail(`Run ${state.run_id} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
399
+ fail(`Run ${runLabel(state)} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
327
400
  if (state.end_reason === "waiting") {
328
401
  if (opts.answer && opts.edit)
329
402
  openInEditor(prepareAnswers(dir, {}).path);
@@ -333,7 +406,7 @@ program
333
406
  process.exit(3);
334
407
  }
335
408
  }
336
- const summary = await continueRun(pr, dir, state.run_id);
409
+ const summary = await continueRun(pr, dir, state.run_id, reopen ? { text: text } : undefined);
337
410
  await driveRun(pr, dir, summary, !!opts.assumeDefaults);
338
411
  }
339
412
  catch (e) {
@@ -412,10 +485,12 @@ program
412
485
  fail("--force only goes with --lock.");
413
486
  if (!runId)
414
487
  fail("Give the run id to clear (see `agent-lyceum status --task-list`), or use --lock.");
415
- assertName("run", runId);
416
488
  const pr = loadProject(opts.project);
489
+ const target = findRun(pr, runId).state;
490
+ const full = target.run_id;
491
+ const label = runLabel(target);
417
492
  const showPlan = (plan) => {
418
- console.log(`Run ${plan.summary}`);
493
+ console.log(`Run ${label}${plan.summary.slice(full.length)}`);
419
494
  for (const i of plan.items)
420
495
  console.log(` ${opts.dryRun ? "would delete" : "delete "} ${i.label}`);
421
496
  for (const k of plan.keep)
@@ -427,16 +502,16 @@ program
427
502
  return fail("Nothing was deleted.");
428
503
  };
429
504
  if (opts.dryRun) {
430
- const plan = planRunCleanup(pr, runId, { keepWorktrees: opts.keepWorktrees });
505
+ const plan = planRunCleanup(pr, full, { keepWorktrees: opts.keepWorktrees });
431
506
  console.log("Dry run: nothing is deleted.");
432
507
  showPlan(plan);
433
508
  if (plan.refusals.length)
434
509
  refuse(plan);
435
510
  return;
436
511
  }
437
- const lease = takeLock(pr, runId, { ignoreLimit: true });
512
+ const lease = takeLock(pr, full, { ignoreLimit: true });
438
513
  try {
439
- const plan = planRunCleanup(pr, runId, { keepWorktrees: opts.keepWorktrees });
514
+ const plan = planRunCleanup(pr, full, { keepWorktrees: opts.keepWorktrees });
440
515
  if (plan.refusals.length)
441
516
  refuse(plan);
442
517
  showPlan(plan);
@@ -446,7 +521,7 @@ program
446
521
  console.error(`FAILED ${f.item.label}: ${f.error}`);
447
522
  fail("Cleanup stopped at the first failure; fix it and repeat the same command to finish.");
448
523
  }
449
- console.log(`Deleted run ${runId}: ${plan.summary.slice(runId.length + 2)}`);
524
+ console.log(`Deleted run ${label}: ${plan.summary.slice(full.length + 2)}`);
450
525
  }
451
526
  finally {
452
527
  lease.release();
@@ -656,9 +731,7 @@ program
656
731
  if (opts.json) {
657
732
  if (opts.monitor)
658
733
  fail("--json cannot be combined with --monitor.");
659
- if (opts.taskId)
660
- assertName("run", opts.taskId);
661
- const report = opts.taskList ? buildTaskListReport(pr) : buildStatusReport(pr, opts.taskId);
734
+ const report = opts.taskList ? buildTaskListReport(pr) : buildStatusReport(pr, opts.taskId !== undefined ? findRun(pr, opts.taskId).state.run_id : undefined);
662
735
  console.log(JSON.stringify(report, null, 2));
663
736
  return;
664
737
  }
@@ -666,12 +739,8 @@ program
666
739
  console.log(formatTaskList(pr, useColor()));
667
740
  return;
668
741
  }
669
- if (opts.taskId) {
670
- assertName("run", opts.taskId);
671
- const dir = path.join(pr.paths.runs, opts.taskId);
672
- if (!fs.existsSync(path.join(dir, "state.json")))
673
- fail(`Run "${opts.taskId}" not found in ${pr.paths.runs}. List ids with: agent-lyceum status --task-list`);
674
- console.log(formatRunDetail({ dir, state: loadRunState(dir) }, Date.now(), useColor(), pr));
742
+ if (opts.taskId !== undefined) {
743
+ console.log(formatRunDetail(findRun(pr, opts.taskId), Date.now(), useColor(), pr));
675
744
  return;
676
745
  }
677
746
  if (!opts.monitor) {
package/dist/fs-util.d.ts CHANGED
@@ -2,3 +2,11 @@
2
2
  export declare function atomicWrite(file: string, content: string): void;
3
3
  /** Is `cmd` an executable file in one of the PATH directories? (No `which`: it may be missing in slim containers.) */
4
4
  export declare function findOnPath(cmd: string, pathVar?: string): boolean;
5
+ /**
6
+ * Up to `limit` bytes of a regular file (symlinks to one are followed), and whether there was more.
7
+ * undefined for anything else (missing, a directory, a FIFO, a device) or on any error: never throws, never reads a huge file whole.
8
+ */
9
+ export declare function readFileCapped(file: string, limit: number): {
10
+ text: string;
11
+ truncated: boolean;
12
+ } | undefined;
package/dist/fs-util.js CHANGED
@@ -22,3 +22,39 @@ export function findOnPath(cmd, pathVar = process.env.PATH ?? "") {
22
22
  }
23
23
  });
24
24
  }
25
+ /**
26
+ * Up to `limit` bytes of a regular file (symlinks to one are followed), and whether there was more.
27
+ * undefined for anything else (missing, a directory, a FIFO, a device) or on any error: never throws, never reads a huge file whole.
28
+ */
29
+ export function readFileCapped(file, limit) {
30
+ let fd;
31
+ try {
32
+ if (!fs.statSync(file).isFile())
33
+ return undefined;
34
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
35
+ if (!fs.fstatSync(fd).isFile())
36
+ return undefined;
37
+ const buf = Buffer.alloc(limit + 1);
38
+ let n = 0;
39
+ while (n < buf.length) {
40
+ const r = fs.readSync(fd, buf, n, buf.length - n, n);
41
+ if (r === 0)
42
+ break;
43
+ n += r;
44
+ }
45
+ if (n <= limit)
46
+ return { text: buf.subarray(0, n).toString("utf8"), truncated: false };
47
+ return { text: buf.subarray(0, limit).toString("utf8"), truncated: true };
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ finally {
53
+ if (fd !== undefined) {
54
+ try {
55
+ fs.closeSync(fd);
56
+ }
57
+ catch { }
58
+ }
59
+ }
60
+ }
package/dist/guard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { commonFile, mayEditProtected, repoInstructionFiles } from "./policy.js";
4
+ import { commonFile, mayEditProtected, repoInstructionFiles, sharedIndex } from "./policy.js";
5
5
  export const MAX_KEEP_BYTES = 1024 * 1024;
6
6
  function safeName(file) {
7
7
  return file.replace(/[\\/:]/g, "_").replace(/^_+/, "");
@@ -32,6 +32,9 @@ export class ProtectedGuard {
32
32
  files.add(commonFile(project));
33
33
  for (const f of repoInstructionFiles(project))
34
34
  files.add(f);
35
+ const handoff = sharedIndex(project);
36
+ if (handoff)
37
+ files.add(handoff);
35
38
  return [...files];
36
39
  }
37
40
  /** Copy the current protected files into `dir` for the audit trail. */
package/dist/mailbox.js CHANGED
@@ -5,7 +5,7 @@ import YAML from "yaml";
5
5
  import { formatWarning, missingSections, parseSteps } from "./format.js";
6
6
  import { atomicWrite } from "./fs-util.js";
7
7
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
8
- import { inboxDir, outboxDir } from "./policy.js";
8
+ import { inboxDir, outboxDir, sharedDir } from "./policy.js";
9
9
  import { must } from "./assert.js";
10
10
  export { atomicWrite };
11
11
  export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
@@ -33,6 +33,9 @@ export function parseRaw(text) {
33
33
  }
34
34
  export function ensureProjectDirs(project) {
35
35
  fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
36
+ const handoff = sharedDir(project);
37
+ if (handoff)
38
+ fs.mkdirSync(handoff, { recursive: true });
36
39
  for (const name of Object.keys(project.agents)) {
37
40
  fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
38
41
  fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
package/dist/open-pr.d.ts CHANGED
@@ -16,9 +16,12 @@ export declare function prTitle(taskSummary: string): string;
16
16
  export declare function prBody(doneBody: string): string;
17
17
  /** The repo's default branch: `origin/HEAD`, else what `gh` says, else `main`. */
18
18
  export declare function defaultBase(cwd: string, run: CommandRunner): string;
19
- export type OpenPrResult = {
19
+ export type OpenPrResult =
20
+ /** `updated`: the run's earlier, still open pull request got the new commits; nothing was created. */
21
+ {
20
22
  status: "opened";
21
23
  url: string;
24
+ updated?: true;
22
25
  } | {
23
26
  status: "failed";
24
27
  step: "push" | "create" | "write";
@@ -42,6 +45,8 @@ export interface OpenPrInput {
42
45
  /** Where the description is written for `gh --body-file` (and for finishing by hand). */
43
46
  bodyFile: string;
44
47
  run: CommandRunner;
48
+ /** The run already has a pull request for this branch: while it is still open only the branch is pushed; otherwise (merged, closed) a new one is opened. */
49
+ existingUrl?: string;
45
50
  }
46
51
  /** 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
52
  export declare function openPullRequest(i: OpenPrInput): OpenPrResult;