agent-lyceum 0.13.1 → 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 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) |
@@ -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.
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) |
@@ -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` 在沙箱外以你自己的憑證執行。
@@ -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/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/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 that starts with `other:` is free text. Do not ask what you could decide yourself, and do not ask again for something already answered.",
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
  ? [
@@ -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. */
@@ -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 >= cfg.max_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}/${cfg.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
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
- atomicWrite(path.join(runDir, RESULT_FILE), `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`);
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, cfg, runId, runDir, project, wsMode, log, state, opts } = this;
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 >= cfg.max_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
  }
@@ -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.run_id)} [${stateColor(c, reportLabel(s))}] 第 ${s.rounds}/${s.max_rounds} 輪`,
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.run_id)} [${stateColor(c, reportLabel(s))}] 輪次 ${s.rounds}/${s.max_rounds} 輸出 tokens ${tokens(s.output_tokens)}`,
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 header = ["ID", "狀態", "輪次", "目前步驟", "任務"];
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();
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",