agent-lyceum 0.13.0 → 0.14.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 +17 -4
- package/README.zh-TW.md +17 -4
- package/dist/ask-reply.d.ts +1 -1
- package/dist/ask-reply.js +10 -10
- package/dist/cli.js +112 -43
- package/dist/open-pr.d.ts +6 -1
- package/dist/open-pr.js +10 -0
- package/dist/policy.js +4 -0
- package/dist/prompt.js +1 -1
- package/dist/run-session.d.ts +8 -0
- package/dist/run-session.js +58 -10
- package/dist/run-store.d.ts +31 -0
- package/dist/run-store.js +38 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +12 -5
- package/dist/worktree.d.ts +10 -0
- package/dist/worktree.js +25 -0
- package/package.json +1 -1
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-
|
|
34
|
-
| Run | `clear <run
|
|
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) |
|
|
@@ -138,6 +138,17 @@ Known limits:
|
|
|
138
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
139
|
- Two runs that edit the same file are for you to reconcile when merging; agent-lyceum never merges.
|
|
140
140
|
|
|
141
|
+
### Continuing a finished run
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
- **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`.
|
|
148
|
+
- **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.
|
|
149
|
+
- **Round limit:** `max_rounds` counts from the reopen: `rounds` keeps accumulating, and the limit is the rounds at reopen plus `max_rounds`.
|
|
150
|
+
- **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.
|
|
151
|
+
|
|
141
152
|
### Opening a pull request
|
|
142
153
|
|
|
143
154
|
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.
|
|
@@ -200,4 +211,6 @@ Enforcement is layered because no single mechanism is complete:
|
|
|
200
211
|
|
|
201
212
|
`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.
|
|
202
213
|
|
|
214
|
+
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.
|
|
215
|
+
|
|
203
216
|
Known gaps: Claude Code's built-in Edit/Write tools are not sandboxed (covered by the `Edit` rules); Codex MCP tools and hooks run outside its sandbox; `--dangerously-bypass-approvals-and-sandbox` / `danger-full-access` disable everything. With `can_edit_agent_md`, a Codex lead's writable roots widen to whole agent directories.
|
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
|
|
32
|
-
| 執行 | `run ["task"] [--task-file f] [--agent a] [-p name]` | 把任務交給 lead
|
|
33
|
-
| 執行 | `resume [run-
|
|
34
|
-
| 執行 | `clear <run
|
|
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) |
|
|
@@ -139,6 +139,17 @@ 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
|
+
|
|
142
153
|
### 開 PR
|
|
143
154
|
|
|
144
155
|
當你的 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` 在沙箱外以你自己的憑證執行。
|
|
@@ -201,4 +212,6 @@ agents:
|
|
|
201
212
|
|
|
202
213
|
`doctor` 與 `run` 會依類別(`os`、`tool-rules`、`post-hoc`、`prompt-only`)印出每個 agent 的防護等級,以及它能寫入 repo 之外的哪些路徑。設定 `dispatcher.strict: true` 時,除非記憶、`AGENT.md` 與其他 agent 的 context 都由 OS 強制保護,否則拒絕執行。
|
|
203
214
|
|
|
215
|
+
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 模式下會被拒絕。
|
|
216
|
+
|
|
204
217
|
已知缺口:Claude Code 內建的 Edit/Write 工具不在沙箱內(由 `Edit` 規則涵蓋);Codex 的 MCP 工具與 hooks 在其沙箱之外執行;`--dangerously-bypass-approvals-and-sandbox` / `danger-full-access` 會停用所有防護。啟用 `can_edit_agent_md` 時,Codex lead 的可寫根目錄會擴大到整個 agent 目錄。
|
package/dist/ask-reply.d.ts
CHANGED
|
@@ -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
|
-
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
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
|
|
132
|
-
if (q.delivered || !
|
|
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] ===
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
283
|
-
.summary("Continue an interrupted
|
|
284
|
-
.description(
|
|
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 (
|
|
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 (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
315
|
-
|
|
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
|
|
390
|
+
fail(`Run ${runLabel(state)} already finished (done); nothing to resume.`);
|
|
319
391
|
const o = outcomeOf(state);
|
|
320
|
-
console.log(`Run ${state
|
|
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
|
-
|
|
324
|
-
|
|
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
|
|
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,
|
|
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,
|
|
512
|
+
const lease = takeLock(pr, full, { ignoreLimit: true });
|
|
438
513
|
try {
|
|
439
|
-
const plan = planRunCleanup(pr,
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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/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;
|
package/dist/open-pr.js
CHANGED
|
@@ -48,6 +48,16 @@ const firstLine = (s) => s.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "no o
|
|
|
48
48
|
const sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
49
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
50
|
export function openPullRequest(i) {
|
|
51
|
+
if (i.existingUrl) {
|
|
52
|
+
// A squash merge leaves the branch looking unmerged, so ask GitHub. When the lookup itself fails, assume the PR is still open.
|
|
53
|
+
const view = i.run("gh", ["pr", "view", i.existingUrl, "--json", "state", "-q", ".state"], i.cwd);
|
|
54
|
+
if (!view.ok || view.out.trim() === "OPEN") {
|
|
55
|
+
const push = i.run("git", ["push", "-u", "origin", i.branch], i.cwd);
|
|
56
|
+
if (push.ok)
|
|
57
|
+
return { status: "opened", url: i.existingUrl, updated: true };
|
|
58
|
+
return { status: "failed", step: "push", reason: firstLine(push.out), manual: [`git -C ${sh(i.cwd)} push -u origin ${sh(i.branch)}`] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
51
61
|
const base = defaultBase(i.cwd, i.run);
|
|
52
62
|
try {
|
|
53
63
|
atomicWrite(i.bodyFile, i.body);
|
package/dist/policy.js
CHANGED
|
@@ -89,6 +89,10 @@ export function writePolicy(project, agent) {
|
|
|
89
89
|
if (ws)
|
|
90
90
|
for (const f of ["CLAUDE.md", "AGENTS.md"])
|
|
91
91
|
addDeny(path.join(ws.dir, f));
|
|
92
|
+
// Defence in depth: the dispatcher pins git's dir, common dir and work tree and never reads these, but a runtime that can
|
|
93
|
+
// forbid single files should not let the agent rewrite them either. Codex cannot; it relies on the pinning alone.
|
|
94
|
+
if (ws)
|
|
95
|
+
deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
|
|
92
96
|
return {
|
|
93
97
|
allowDirs,
|
|
94
98
|
allowFiles,
|
package/dist/prompt.js
CHANGED
|
@@ -100,7 +100,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
100
100
|
"</ask>",
|
|
101
101
|
"```",
|
|
102
102
|
"",
|
|
103
|
-
"The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer
|
|
103
|
+
"The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer may be one of your options or any free text of their own (it may start with `other:`). Do not ask what you could decide yourself, and do not ask again for something already answered.",
|
|
104
104
|
]
|
|
105
105
|
: []), ...(isLead
|
|
106
106
|
? [
|
package/dist/run-session.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export declare const MAX_DONE_REJECTIONS = 2;
|
|
|
14
14
|
export declare const MAX_ASK_REJECTIONS = 2;
|
|
15
15
|
export interface RunSummary {
|
|
16
16
|
runId: string;
|
|
17
|
+
/** The run's name; absent on runs from older versions. */
|
|
18
|
+
name?: string;
|
|
17
19
|
runDir: string;
|
|
18
20
|
rounds: number;
|
|
19
21
|
endReason: EndReason;
|
|
@@ -34,12 +36,18 @@ export interface RunSummary {
|
|
|
34
36
|
}
|
|
35
37
|
export interface RunOptions {
|
|
36
38
|
project: ResolvedProject;
|
|
39
|
+
/** Name for a new run: already decided and not used by another run of the project. */
|
|
40
|
+
name?: string;
|
|
37
41
|
/** Run only this member of the project, alone (`run --agent`). A resumed run uses the one recorded in its state. */
|
|
38
42
|
soloAgent?: string;
|
|
39
43
|
/** The task for a new run; omit when resuming. */
|
|
40
44
|
task?: PreparedTask;
|
|
41
45
|
/** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
|
|
42
46
|
resume?: RunState;
|
|
47
|
+
/** Reopen a done run: this text becomes a new task for the lead. Only with `resume` of a run that ended `done`. */
|
|
48
|
+
followup?: {
|
|
49
|
+
text: string;
|
|
50
|
+
};
|
|
43
51
|
runDir: string;
|
|
44
52
|
invoker?: Invoker;
|
|
45
53
|
/** Runs git and gh for the pull request. Tests pass a fake; the default is the real thing. */
|
package/dist/run-session.js
CHANGED
|
@@ -12,8 +12,8 @@ import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
|
|
|
12
12
|
import { outboxDir, ownsDirs } from "./policy.js";
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
14
|
import { atomicWrite } from "./fs-util.js";
|
|
15
|
-
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
|
-
import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
|
|
15
|
+
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
|
+
import { bindRunProject, newRunId, newRunState, roundLimit, saveRunState } from "./run-store.js";
|
|
17
17
|
import { soloProject } from "./solo.js";
|
|
18
18
|
import { taskMessageBody } from "./task.js";
|
|
19
19
|
import { must } from "./assert.js";
|
|
@@ -67,6 +67,8 @@ export class RunSession {
|
|
|
67
67
|
const { task, resume, runDir } = opts;
|
|
68
68
|
if (resume?.end_reason === "waiting")
|
|
69
69
|
RunSession.assertAnswered(runDir, resume.run_id);
|
|
70
|
+
if (opts.followup && resume?.end_reason !== "done")
|
|
71
|
+
throw new Error("followup needs a finished (done) run");
|
|
70
72
|
if (!task && !resume)
|
|
71
73
|
throw new Error("runTeam needs a task or a run to resume");
|
|
72
74
|
const invoke = opts.invoker ?? realInvoker;
|
|
@@ -86,10 +88,19 @@ export class RunSession {
|
|
|
86
88
|
assertWorktreeRunnable(project, { fresh: !resume, concurrent });
|
|
87
89
|
// Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
|
|
88
90
|
let leadSpace;
|
|
91
|
+
let recut = false;
|
|
89
92
|
if (concurrent && resume) {
|
|
90
93
|
leadSpace = existingAgentWorkspace(project, runId, project.lead);
|
|
91
94
|
if (!leadSpace)
|
|
92
95
|
throw new Error(`Run ${runId} has no worktree for ${project.lead} (was it cleared?), so it cannot be resumed.`);
|
|
96
|
+
if (opts.followup) {
|
|
97
|
+
// A reopened run whose branch you already merged starts on a fresh branch from HEAD (never when work would be lost).
|
|
98
|
+
const r = recutLeadWorkspace(project, runId, project.lead, leadSpace, resume.result_branch?.head);
|
|
99
|
+
leadSpace = r.ws;
|
|
100
|
+
recut = r.recut;
|
|
101
|
+
if (r.recut)
|
|
102
|
+
say(` ${project.lead}'s branch was already merged: a fresh branch ${leadSpace.branch} was cut from HEAD ${leadSpace.base.slice(0, 8)}`);
|
|
103
|
+
}
|
|
93
104
|
}
|
|
94
105
|
ensureProjectDirs(project);
|
|
95
106
|
fs.mkdirSync(runDir, { recursive: true });
|
|
@@ -103,7 +114,7 @@ export class RunSession {
|
|
|
103
114
|
}
|
|
104
115
|
let state;
|
|
105
116
|
if (resume) {
|
|
106
|
-
state = { ...resume, max_rounds: cfg.max_rounds, pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
117
|
+
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
107
118
|
// A wake that was running when the process died never finished; keep it in the history as interrupted.
|
|
108
119
|
for (const [agent, a] of Object.entries(resume.active ?? {})) {
|
|
109
120
|
state.wakes.push({
|
|
@@ -116,11 +127,24 @@ export class RunSession {
|
|
|
116
127
|
error: "interrupted",
|
|
117
128
|
});
|
|
118
129
|
}
|
|
130
|
+
if (opts.followup) {
|
|
131
|
+
state.reopened = [...(resume.reopened ?? []), { at: new Date().toISOString() }];
|
|
132
|
+
state.round_floor = resume.rounds;
|
|
133
|
+
state.max_rounds = roundLimit(state, cfg.max_rounds);
|
|
134
|
+
state.result_branch = undefined;
|
|
135
|
+
state.done_rejections = 0;
|
|
136
|
+
if (recut && resume.pr_url) {
|
|
137
|
+
// The recorded pull request is the merged branch's: it must not be mistaken for the new segment's.
|
|
138
|
+
state.notes = [...(resume.notes ?? []), `The earlier pull request ${resume.pr_url} belongs to the merged branch; a new one is opened for the follow-up work.`];
|
|
139
|
+
state.pr_url = undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
119
142
|
}
|
|
120
143
|
else {
|
|
121
144
|
const t = taskMessageBody(task);
|
|
122
145
|
state = newRunState({
|
|
123
146
|
run_id: runId,
|
|
147
|
+
name: opts.name,
|
|
124
148
|
project: project.name,
|
|
125
149
|
task_source: task.source,
|
|
126
150
|
task_path: task.sourcePath,
|
|
@@ -143,6 +167,24 @@ export class RunSession {
|
|
|
143
167
|
const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
|
|
144
168
|
if (resume) {
|
|
145
169
|
log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
|
|
170
|
+
if (opts.followup) {
|
|
171
|
+
const n = state.reopened?.length ?? 1;
|
|
172
|
+
fs.mkdirSync(path.join(runDir, "followups"), { recursive: true });
|
|
173
|
+
fs.writeFileSync(path.join(runDir, "followups", `${n}.md`), `${opts.followup.text.trimEnd()}\n`);
|
|
174
|
+
// A run finished before done-<n>.md existed has only result.md: keep that report before the next done overwrites it.
|
|
175
|
+
const prev = path.join(runDir, `done-${n}.md`);
|
|
176
|
+
const legacy = path.join(runDir, RESULT_FILE);
|
|
177
|
+
if (!fs.existsSync(prev) && fs.existsSync(legacy))
|
|
178
|
+
fs.copyFileSync(legacy, prev);
|
|
179
|
+
deliver(project, {
|
|
180
|
+
from: "user",
|
|
181
|
+
to: project.lead,
|
|
182
|
+
type: "task",
|
|
183
|
+
subject: opts.followup.text.split("\n").find((l) => l.trim())?.slice(0, 80) ?? "Follow-up",
|
|
184
|
+
body: `## Goal\n${opts.followup.text.trimEnd()}\n\n## Acceptance criteria\nAs stated in the goal; the earlier result still holds unless the goal changes it.\n\n## Scope\nThis run's earlier work and files.\n\n## Upstream\nThe previous final report is in \`${path.join(runDir, `done-${n}.md`)}\`; the original task is in \`${path.join(runDir, "task.md")}\`.\n`,
|
|
185
|
+
});
|
|
186
|
+
log("reopen", { n });
|
|
187
|
+
}
|
|
146
188
|
}
|
|
147
189
|
else {
|
|
148
190
|
const t = taskMessageBody(task);
|
|
@@ -246,14 +288,14 @@ export class RunSession {
|
|
|
246
288
|
};
|
|
247
289
|
let result;
|
|
248
290
|
for (let attempt = 0; attempt <= cfg.retry; attempt++) {
|
|
249
|
-
if (state.rounds >=
|
|
291
|
+
if (state.rounds >= state.max_rounds) {
|
|
250
292
|
result = { ok: false, text: "", exitCode: null, timedOut: false, error: "round limit reached" };
|
|
251
293
|
break;
|
|
252
294
|
}
|
|
253
295
|
const round = ++state.rounds;
|
|
254
296
|
const started = Date.now();
|
|
255
297
|
const att = beginAttempt(runDir, claim.id);
|
|
256
|
-
say(`[round ${state.rounds}/${
|
|
298
|
+
say(`[round ${state.rounds}/${state.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
|
|
257
299
|
const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
|
|
258
300
|
const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
|
|
259
301
|
state.active[agent.name] = { round, since: new Date(started).toISOString(), handling };
|
|
@@ -472,7 +514,9 @@ export class RunSession {
|
|
|
472
514
|
this.doneOutcome = resolved.outcome;
|
|
473
515
|
this.doneNote = resolved.note;
|
|
474
516
|
this.doneVerification = resolved.verification;
|
|
475
|
-
|
|
517
|
+
const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`;
|
|
518
|
+
atomicWrite(path.join(runDir, RESULT_FILE), report);
|
|
519
|
+
atomicWrite(path.join(runDir, `done-${(state.reopened?.length ?? 0) + 1}.md`), report);
|
|
476
520
|
this.pendingDoneFile = route.done.file;
|
|
477
521
|
log("done", { subject: route.done.subject });
|
|
478
522
|
this.endReason = "done";
|
|
@@ -604,6 +648,7 @@ export class RunSession {
|
|
|
604
648
|
return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
|
|
605
649
|
if (!rb.files)
|
|
606
650
|
return skip(`${rb.branch} has no file changes.`);
|
|
651
|
+
const existingUrl = state.pr_url;
|
|
607
652
|
const result = openPullRequest({
|
|
608
653
|
// Never the lead's worktree: the agent can write it (and its git pointers), and git would run what it planted there.
|
|
609
654
|
cwd: toplevel(project.dir),
|
|
@@ -612,11 +657,14 @@ export class RunSession {
|
|
|
612
657
|
body: prBody(this.doneMessage?.body ?? ""),
|
|
613
658
|
bodyFile: path.join(runDir, "pr-body.md"),
|
|
614
659
|
run: this.prRunner,
|
|
660
|
+
existingUrl,
|
|
615
661
|
});
|
|
616
662
|
log("pull-request", { ...result });
|
|
617
663
|
if (result.status === "opened") {
|
|
664
|
+
say(` pull request ${result.updated ? "updated" : "opened"}: ${result.url}`);
|
|
665
|
+
if (existingUrl && !result.updated && !state.notes?.some((n) => n.includes(existingUrl)))
|
|
666
|
+
this.note(`The earlier pull request ${existingUrl} was no longer open; a new one was opened for the follow-up work.`);
|
|
618
667
|
state.pr_url = result.url;
|
|
619
|
-
say(` pull request opened: ${result.url}`);
|
|
620
668
|
}
|
|
621
669
|
else {
|
|
622
670
|
this.note(`The pull request could not be opened (${result.step}: ${result.reason}). The branch ${rb.branch} is kept.${result.manual.length ? ` To finish by hand:\n${result.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
|
|
@@ -655,7 +703,7 @@ export class RunSession {
|
|
|
655
703
|
async run() {
|
|
656
704
|
if (this.resume)
|
|
657
705
|
this.recover();
|
|
658
|
-
const { say,
|
|
706
|
+
const { say, runId, runDir, project, wsMode, log, state, opts } = this;
|
|
659
707
|
// When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
|
|
660
708
|
let leadWasLast = false;
|
|
661
709
|
let nudged = false;
|
|
@@ -682,7 +730,7 @@ export class RunSession {
|
|
|
682
730
|
this.endReason = "idle";
|
|
683
731
|
break;
|
|
684
732
|
}
|
|
685
|
-
if (state.rounds >=
|
|
733
|
+
if (state.rounds >= state.max_rounds) {
|
|
686
734
|
this.endReason = "max_rounds";
|
|
687
735
|
break;
|
|
688
736
|
}
|
|
@@ -744,6 +792,6 @@ export class RunSession {
|
|
|
744
792
|
this.saveState();
|
|
745
793
|
if (this.pendingDoneFile)
|
|
746
794
|
finishDone(this.pendingDoneFile);
|
|
747
|
-
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
795
|
+
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
748
796
|
}
|
|
749
797
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -99,6 +99,14 @@ export interface RunState {
|
|
|
99
99
|
wakes: WakeRecord[];
|
|
100
100
|
/** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps`. */
|
|
101
101
|
steps?: Step[];
|
|
102
|
+
/** Readable name; lets `resume`, `clear` and `status` find the run without its id. Absent on runs from older versions. */
|
|
103
|
+
name?: string;
|
|
104
|
+
/** One entry per time a finished run was reopened with a follow-up. */
|
|
105
|
+
reopened?: {
|
|
106
|
+
at: string;
|
|
107
|
+
}[];
|
|
108
|
+
/** `rounds` at the last reopen; the round limit counts from here. */
|
|
109
|
+
round_floor?: number;
|
|
102
110
|
}
|
|
103
111
|
/** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
|
|
104
112
|
export declare function newRunId(d?: Date): string;
|
|
@@ -122,3 +130,26 @@ export declare function outcomeOf(s: Pick<RunState, "outcome" | "end_reason">):
|
|
|
122
130
|
outcome: RunOutcome;
|
|
123
131
|
verified: boolean;
|
|
124
132
|
} | undefined;
|
|
133
|
+
export declare function slugify(text: string): string;
|
|
134
|
+
/** `base`, or `base-2`, `base-3` … the first that is not in `taken`. */
|
|
135
|
+
export declare function uniqueName(base: string, taken: string[]): string;
|
|
136
|
+
/** How a run is named in output: `name (run_id)`, or just the id for a run without a name. */
|
|
137
|
+
export declare const runLabel: (r: {
|
|
138
|
+
name?: string;
|
|
139
|
+
run_id: string;
|
|
140
|
+
}) => string;
|
|
141
|
+
export type RunRef = {
|
|
142
|
+
dir: string;
|
|
143
|
+
state: RunState;
|
|
144
|
+
};
|
|
145
|
+
export type Resolved = {
|
|
146
|
+
ok: true;
|
|
147
|
+
run: RunRef;
|
|
148
|
+
} | {
|
|
149
|
+
ok: false;
|
|
150
|
+
reason: "none" | "ambiguous";
|
|
151
|
+
candidates: RunRef[];
|
|
152
|
+
};
|
|
153
|
+
/** Find a run by full id, exact name, or a prefix of either that matches exactly one run. An exact id or name always wins. */
|
|
154
|
+
export declare function resolveRun(runs: RunRef[], query: string): Resolved;
|
|
155
|
+
export declare const roundLimit: (state: Pick<RunState, "round_floor">, maxRounds: number) => number;
|
package/dist/run-store.js
CHANGED
|
@@ -39,6 +39,9 @@ const WireState = z
|
|
|
39
39
|
wakes: z.array(z.any()).optional(),
|
|
40
40
|
solo_agent: z.string().optional(),
|
|
41
41
|
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
42
|
+
name: z.string().optional(),
|
|
43
|
+
reopened: z.array(z.object({ at: z.string() })).optional(),
|
|
44
|
+
round_floor: nonNeg.optional(),
|
|
42
45
|
})
|
|
43
46
|
.passthrough();
|
|
44
47
|
/** A fresh v2 state; every collection present. */
|
|
@@ -119,3 +122,38 @@ export function outcomeOf(s) {
|
|
|
119
122
|
return undefined;
|
|
120
123
|
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
121
124
|
}
|
|
125
|
+
const NAME_MAX = 30;
|
|
126
|
+
export function slugify(text) {
|
|
127
|
+
const s = text
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
130
|
+
.replace(/^-+|-+$/g, "")
|
|
131
|
+
.slice(0, NAME_MAX)
|
|
132
|
+
.replace(/-+$/g, "");
|
|
133
|
+
return s || "task";
|
|
134
|
+
}
|
|
135
|
+
/** `base`, or `base-2`, `base-3` … the first that is not in `taken`. */
|
|
136
|
+
export function uniqueName(base, taken) {
|
|
137
|
+
if (!taken.includes(base))
|
|
138
|
+
return base;
|
|
139
|
+
for (let n = 2;; n++) {
|
|
140
|
+
const suffix = `-${n}`;
|
|
141
|
+
// The result stays within the 30 characters of a slug: trim the base, then any "-" the trim leaves at its end.
|
|
142
|
+
const candidate = `${base.slice(0, Math.max(1, NAME_MAX - suffix.length)).replace(/-+$/g, "")}${suffix}`;
|
|
143
|
+
if (!taken.includes(candidate))
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** How a run is named in output: `name (run_id)`, or just the id for a run without a name. */
|
|
148
|
+
export const runLabel = (r) => (r.name ? `${r.name} (${r.run_id})` : r.run_id);
|
|
149
|
+
/** Find a run by full id, exact name, or a prefix of either that matches exactly one run. An exact id or name always wins. */
|
|
150
|
+
export function resolveRun(runs, query) {
|
|
151
|
+
const exact = runs.filter((r) => r.state.run_id === query || r.state.name === query);
|
|
152
|
+
if (exact.length === 1)
|
|
153
|
+
return { ok: true, run: exact[0] };
|
|
154
|
+
const pool = exact.length ? exact : runs.filter((r) => r.state.run_id.startsWith(query) || (r.state.name ?? "").startsWith(query));
|
|
155
|
+
if (pool.length === 1)
|
|
156
|
+
return { ok: true, run: pool[0] };
|
|
157
|
+
return { ok: false, reason: pool.length ? "ambiguous" : "none", candidates: pool };
|
|
158
|
+
}
|
|
159
|
+
export const roundLimit = (state, maxRounds) => (state.round_floor ?? 0) + maxRounds;
|
package/dist/status.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export declare function latestUnfinishedRun(project: ResolvedProject): {
|
|
|
11
11
|
dir: string;
|
|
12
12
|
state: RunState;
|
|
13
13
|
} | undefined;
|
|
14
|
+
/** Runs a follow-up text can reopen: the finished ones (end_reason "done"). Newest first. */
|
|
15
|
+
export declare function reopenCandidates(project: ResolvedProject): {
|
|
16
|
+
dir: string;
|
|
17
|
+
state: RunState;
|
|
18
|
+
}[];
|
|
14
19
|
export declare function listRuns(project: ResolvedProject, limit: number): {
|
|
15
20
|
dir: string;
|
|
16
21
|
state: RunState;
|
|
@@ -44,6 +49,8 @@ export interface QueueItem {
|
|
|
44
49
|
}
|
|
45
50
|
export interface RunReport {
|
|
46
51
|
run_id: string;
|
|
52
|
+
/** Absent on runs from older versions. */
|
|
53
|
+
name?: string;
|
|
47
54
|
dir: string;
|
|
48
55
|
/** running: its dispatcher is alive; interrupted: it died mid-run; ended: it stopped (see end_reason). */
|
|
49
56
|
state: "running" | "interrupted" | "ended" | "waiting";
|
package/dist/status.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { askReplyPath, readAskReply } from "./ask-reply.js";
|
|
4
4
|
import { RESULT_FILE } from "./dispatcher.js";
|
|
5
|
-
import { bindRunProject, loadRunState, outcomeOf } from "./run-store.js";
|
|
5
|
+
import { bindRunProject, loadRunState, outcomeOf, runLabel } from "./run-store.js";
|
|
6
6
|
import { briefOf, digestOf } from "./format.js";
|
|
7
7
|
import { listUnread } from "./mailbox.js";
|
|
8
8
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
@@ -33,6 +33,10 @@ export function latestRun(project) {
|
|
|
33
33
|
export function latestUnfinishedRun(project) {
|
|
34
34
|
return listRuns(project, Infinity).find((r) => r.state.end_reason !== "done" && (r.state.end_reason || !runIsAlive(r.state, project)));
|
|
35
35
|
}
|
|
36
|
+
/** Runs a follow-up text can reopen: the finished ones (end_reason "done"). Newest first. */
|
|
37
|
+
export function reopenCandidates(project) {
|
|
38
|
+
return listRuns(project, Infinity).filter((r) => r.state.end_reason === "done");
|
|
39
|
+
}
|
|
36
40
|
export function listRuns(project, limit) {
|
|
37
41
|
const root = project.paths.runs;
|
|
38
42
|
if (!fs.existsSync(root))
|
|
@@ -182,6 +186,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
182
186
|
}
|
|
183
187
|
return {
|
|
184
188
|
run_id: s.run_id,
|
|
189
|
+
name: s.name,
|
|
185
190
|
dir: run.dir,
|
|
186
191
|
state: s.end_reason === "waiting" ? "waiting" : s.end_reason ? "ended" : live ? "running" : "interrupted",
|
|
187
192
|
live,
|
|
@@ -279,7 +284,7 @@ function summaryBlock(run, now, c) {
|
|
|
279
284
|
const s = run;
|
|
280
285
|
const live = s.live;
|
|
281
286
|
const lines = [
|
|
282
|
-
`${c.bold("執行 " + s
|
|
287
|
+
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 第 ${s.rounds}/${s.max_rounds} 輪`,
|
|
283
288
|
`${L("任務:")}${taskText(s)}`,
|
|
284
289
|
];
|
|
285
290
|
const steps = stepsLine(s.steps, c, s.end_reason === "done");
|
|
@@ -317,7 +322,7 @@ function summaryBlock(run, now, c) {
|
|
|
317
322
|
/** What a run is doing / did, one line per wake. */
|
|
318
323
|
function describeRun(s, now, c, limit = Infinity) {
|
|
319
324
|
const lines = [
|
|
320
|
-
`${c.bold("執行 " + s
|
|
325
|
+
`${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${s.rounds}/${s.max_rounds} 輸出 tokens ${tokens(s.output_tokens)}`,
|
|
321
326
|
` 任務:${taskText(s)}`,
|
|
322
327
|
];
|
|
323
328
|
const steps = stepsLine(s.steps, c);
|
|
@@ -383,10 +388,12 @@ export function formatTaskListReport(report, color = false) {
|
|
|
383
388
|
const head = `${c.bold("專案:")}${report.project.name} ${c.dim(`(共 ${runs.length} 個任務)`)}`;
|
|
384
389
|
if (!runs.length)
|
|
385
390
|
return `${head}\n\n無執行紀錄。`;
|
|
386
|
-
const
|
|
391
|
+
const named = runs.some((s) => s.name);
|
|
392
|
+
const header = [...(named ? ["名稱"] : []), "ID", "狀態", "輪次", "目前步驟", "任務"];
|
|
387
393
|
const rows = [header];
|
|
388
394
|
for (const s of runs) {
|
|
389
395
|
rows.push([
|
|
396
|
+
...(named ? [s.name ?? ""] : []),
|
|
390
397
|
s.run_id,
|
|
391
398
|
reportLabel(s),
|
|
392
399
|
`${s.rounds}/${s.max_rounds}`,
|
|
@@ -398,7 +405,7 @@ export function formatTaskListReport(report, color = false) {
|
|
|
398
405
|
const line = (r, ri) => r
|
|
399
406
|
.map((t, i) => {
|
|
400
407
|
const p = i === r.length - 1 ? t : padCols(t, w[i] ?? 0);
|
|
401
|
-
return ri === 0 ? c.bold(p) : i === 1 ? stateColor(c, t) + " ".repeat(Math.max(0, (w[i] ?? 0) - cols(t))) : p;
|
|
408
|
+
return ri === 0 ? c.bold(p) : i === (named ? 2 : 1) ? stateColor(c, t) + " ".repeat(Math.max(0, (w[i] ?? 0) - cols(t))) : p;
|
|
402
409
|
})
|
|
403
410
|
.join(" ")
|
|
404
411
|
.trimEnd();
|
package/dist/worktree.d.ts
CHANGED
|
@@ -162,6 +162,16 @@ export interface ResultBranch {
|
|
|
162
162
|
}
|
|
163
163
|
/** Commit the lead's checkout on its branch (once, at the end of a run) and write what changed as a patch. Never touches your repo's working tree. */
|
|
164
164
|
export declare function finishLeadBranch(ws: AgentWorkspace, message: string, patchFile: string): ResultBranch;
|
|
165
|
+
/**
|
|
166
|
+
* A reopened run whose lead branch was already merged into your HEAD starts its next segment on a fresh branch cut from HEAD,
|
|
167
|
+
* so the merged work is not offered again. Only when nothing would be lost: the branch's last committed head (`resultHead`) is
|
|
168
|
+
* in HEAD, the worktree is still at that head, and it holds no uncommitted work and nothing whose state is unknown.
|
|
169
|
+
* Otherwise the worktree and branch are returned unchanged. Your working tree, index and HEAD are never touched.
|
|
170
|
+
*/
|
|
171
|
+
export declare function recutLeadWorkspace(project: ResolvedProject, runId: string, agent: string, ws: AgentWorkspace, resultHead: string | undefined): {
|
|
172
|
+
ws: AgentWorkspace;
|
|
173
|
+
recut: boolean;
|
|
174
|
+
};
|
|
165
175
|
/** Names of the agents that have a worktree for this run. */
|
|
166
176
|
export declare function listWorkspaceAgents(project: ResolvedProject, runId: string): string[];
|
|
167
177
|
/** The snapshot refs (`refs/agent-lyceum/<run>/...`) of a run. */
|
package/dist/worktree.js
CHANGED
|
@@ -686,6 +686,31 @@ export function finishLeadBranch(ws, message, patchFile) {
|
|
|
686
686
|
fs.writeFileSync(patchFile, agentGit(ws, ["diff", "--binary", "--full-index", ws.base, head]) + "\n");
|
|
687
687
|
return { branch: ws.branch, head, base: ws.base, files: files.length, patch: files.length ? patchFile : undefined };
|
|
688
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* A reopened run whose lead branch was already merged into your HEAD starts its next segment on a fresh branch cut from HEAD,
|
|
691
|
+
* so the merged work is not offered again. Only when nothing would be lost: the branch's last committed head (`resultHead`) is
|
|
692
|
+
* in HEAD, the worktree is still at that head, and it holds no uncommitted work and nothing whose state is unknown.
|
|
693
|
+
* Otherwise the worktree and branch are returned unchanged. Your working tree, index and HEAD are never touched.
|
|
694
|
+
*/
|
|
695
|
+
export function recutLeadWorkspace(project, runId, agent, ws, resultHead) {
|
|
696
|
+
const keep = { ws, recut: false };
|
|
697
|
+
if (!resultHead)
|
|
698
|
+
return keep;
|
|
699
|
+
if (tryGit(toplevel(project.dir), ["merge-base", "--is-ancestor", resultHead, "HEAD"]) === undefined)
|
|
700
|
+
return keep;
|
|
701
|
+
const work = inspectWorkspace(project, runId, agent, ws);
|
|
702
|
+
if (!work || work.unknown || work.dirty.length)
|
|
703
|
+
return keep;
|
|
704
|
+
try {
|
|
705
|
+
if (agentGit(ws, ["rev-parse", "HEAD"]) !== resultHead)
|
|
706
|
+
return keep;
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return keep;
|
|
710
|
+
}
|
|
711
|
+
removeAgentWorkspace(project, runId, agent);
|
|
712
|
+
return { ws: prepareAgentWorkspace(project, runId, agent, snapshotHead(project, runId)), recut: true };
|
|
713
|
+
}
|
|
689
714
|
/** Names of the agents that have a worktree for this run. */
|
|
690
715
|
export function listWorkspaceAgents(project, runId) {
|
|
691
716
|
const dir = path.join(project.paths.root, "worktrees", runId);
|