agent-lyceum 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface AgentEnforcement {
6
6
  memory: Level;
7
7
  agentMd: Level;
8
8
  otherContext: Level;
9
+ shared: Level;
9
10
  repoInstructions: Level;
10
11
  owns: Level;
11
12
  /** Absolute paths outside the repo this agent may write. */
@@ -15,6 +16,8 @@ export interface AgentEnforcement {
15
16
  export interface WritePolicy {
16
17
  /** Directories the agent may write to (besides the repo). */
17
18
  allowDirs: string[];
19
+ /** Directories the agent may read besides the repo and its writable ones, but must not write (the run's handoff notes, for everyone but the lead). */
20
+ readDirs: string[];
18
21
  /** Individual files the agent may write (outside allowDirs). */
19
22
  allowFiles: string[];
20
23
  /** Files/dirs that must stay unwritable (checked even inside the repo). */
@@ -29,6 +32,12 @@ export declare function inboxDir(project: ResolvedProject, agent: string): strin
29
32
  /** The repo's own CLAUDE.md/AGENTS.md: in the lead's worktree when it has one (concurrent runs), else in the repo. */
30
33
  export declare function repoInstructionFiles(project: ResolvedProject): string[];
31
34
  export declare function memoryDirs(a: ResolvedAgent): string[];
35
+ /** Folder name of a run's handoff notes inside `task-memory/<run-id>/`. Agent names start with a letter or digit, so it cannot be an agent's own folder. */
36
+ export declare const SHARED_DIR = "_shared";
37
+ /** The run's handoff folder: read by every agent, written only by the lead. Undefined outside a run and for `call`. */
38
+ export declare function sharedDir(project: ResolvedProject): string | undefined;
39
+ /** The index of the handoff notes: the one file shown in prompts, and the one the guard watches. */
40
+ export declare function sharedIndex(project: ResolvedProject): string | undefined;
32
41
  /** The repo directory this agent works in: its own worktree, or the main repo. */
33
42
  export declare function repoDirFor(project: ResolvedProject, agent: string): string;
34
43
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
package/dist/policy.js CHANGED
@@ -19,6 +19,19 @@ export function repoInstructionFiles(project) {
19
19
  export function memoryDirs(a) {
20
20
  return [a.memory.global, a.memory.project, a.memory.task].filter((x) => !!x);
21
21
  }
22
+ /** Folder name of a run's handoff notes inside `task-memory/<run-id>/`. Agent names start with a letter or digit, so it cannot be an agent's own folder. */
23
+ export const SHARED_DIR = "_shared";
24
+ /** The run's handoff folder: read by every agent, written only by the lead. Undefined outside a run and for `call`. */
25
+ export function sharedDir(project) {
26
+ if (!project.run || project.solo === "call")
27
+ return undefined;
28
+ return path.join(project.paths.taskMemory, project.run.id, SHARED_DIR);
29
+ }
30
+ /** The index of the handoff notes: the one file shown in prompts, and the one the guard watches. */
31
+ export function sharedIndex(project) {
32
+ const dir = sharedDir(project);
33
+ return dir ? path.join(dir, "MEMORY.md") : undefined;
34
+ }
22
35
  const RUN_ENTRIES = ["state.json", "log.jsonl", "log.1.jsonl", "result.md", "task.md", "snapshots", "violations", "agents", "mail"];
23
36
  const MAIL_ENTRIES = ["inbox", "claims", "attempts", "journal.jsonl"];
24
37
  function entries(dir) {
@@ -54,10 +67,20 @@ export function writePolicy(project, agent) {
54
67
  const isLead = agent.name === project.lead && !project.solo;
55
68
  const ws = project.workspaces?.[agent.name];
56
69
  // A `call` has no mailboxes at all; a solo member of a run keeps its outbox to send `done`.
57
- const allowDirs = [...memoryDirs(agent), ...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]), ...(ws ? [ws.gitDir] : [])];
70
+ const shared = sharedDir(project);
71
+ const sharedIdx = sharedIndex(project);
72
+ const mayWriteShared = !!shared && agent.name === project.lead;
73
+ const allowDirs = [
74
+ ...memoryDirs(agent),
75
+ ...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]),
76
+ ...(ws ? [ws.gitDir] : []),
77
+ ...(shared && mayWriteShared ? [shared] : []),
78
+ ];
58
79
  const allowFiles = [];
59
80
  if (isLead)
60
81
  allowFiles.push(commonFile(project));
82
+ if (mayWriteShared && sharedIdx)
83
+ allowFiles.push(sharedIdx);
61
84
  if (agent.canEditAgentMd) {
62
85
  for (const a of Object.values(project.agents))
63
86
  allowFiles.push(a.agentMd);
@@ -81,6 +104,8 @@ export function writePolicy(project, agent) {
81
104
  addDeny(commonFile(project));
82
105
  for (const f of repoInstructionFiles(project))
83
106
  addDeny(f);
107
+ if (shared && !mayWriteShared)
108
+ deny.push(shared);
84
109
  deny.push(...runDenyPaths(project), project.paths.config, path.join(project.home, "team.yaml"));
85
110
  // In a worktree: other agents' checkouts are off limits, and so are this checkout's own copies of the protected files.
86
111
  for (const [name, other] of Object.entries(project.workspaces ?? {}))
@@ -95,6 +120,7 @@ export function writePolicy(project, agent) {
95
120
  deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
96
121
  return {
97
122
  allowDirs,
123
+ readDirs: shared && !mayWriteShared ? [shared] : [],
98
124
  allowFiles,
99
125
  deny: [...new Set(deny)],
100
126
  owns: agent.owns,
@@ -118,17 +144,22 @@ export function enforcementFor(project, agent) {
118
144
  const os = osSandboxAvailable();
119
145
  const notes = [];
120
146
  const parallel = project.dispatcher.max_parallel > 1;
147
+ const isLeadAgent = agent.name === project.lead;
121
148
  let e;
122
149
  if (agent.runtime === "claude-code") {
123
150
  const lvl = os ? "os" : "tool-rules";
124
- e = { memory: lvl, agentMd: lvl, otherContext: lvl, repoInstructions: lvl, owns: parallel ? "tool-rules" : "n/a" };
151
+ e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl, owns: parallel ? "tool-rules" : "n/a" };
125
152
  if (!os)
126
153
  notes.push("OS sandbox unavailable (Windows, or Linux without bubblewrap): Bash can bypass Edit rules.");
154
+ if (!isLeadAgent && !os)
155
+ notes.push("Without the OS sandbox Bash can write _shared; only the handoff index (_shared/MEMORY.md) is checked after each wake-up.");
127
156
  }
128
157
  else {
129
158
  const lvl = os ? "os" : "prompt-only";
130
- e = { memory: lvl, agentMd: lvl, otherContext: lvl, repoInstructions: "post-hoc", owns: parallel ? "prompt-only" : "n/a" };
159
+ e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc", owns: parallel ? "prompt-only" : "n/a" };
131
160
  notes.push("Codex cannot forbid single files inside the repo; CLAUDE.md/AGENTS.md changes are detected and reverted after each wake-up.");
161
+ if (!isLeadAgent && !os)
162
+ notes.push("Without the OS sandbox only the handoff index (_shared/MEMORY.md) is checked after each wake-up; other files in _shared are not protected.");
132
163
  if (agent.canEditAgentMd)
133
164
  notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
134
165
  }
package/dist/prompt.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { Message } from "./mailbox.js";
3
3
  import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
4
4
  export declare const COMMON_LIMIT: number;
5
5
  export declare const MEMORY_INDEX_LIMIT: number;
6
+ export declare const HANDOFF_LIMIT: number;
6
7
  export declare const LEAD_BATCH_LIMIT = 5;
7
8
  export declare function readPersona(agent: ResolvedAgent): string;
8
9
  /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
@@ -13,7 +14,7 @@ export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string):
13
14
  export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
14
15
  /** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
15
16
  export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
16
- export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number): string;
17
+ export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number, handoff?: boolean): string;
17
18
  export interface TidyContext {
18
19
  layer: Layer;
19
20
  /** The memory directory being tidied. */
package/dist/prompt.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import { readFileCapped } from "./fs-util.js";
2
3
  import path from "node:path";
3
4
  import { REQUIRED_SECTIONS } from "./format.js";
4
- import { commonFile, memoryDirs, outboxDir, repoDirFor } from "./policy.js";
5
+ import { commonFile, memoryDirs, outboxDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
5
6
  function memoryLabel(agent, dir) {
6
7
  if (dir === agent.memory.task)
7
8
  return "task (this task only, isolated)";
@@ -9,16 +10,10 @@ function memoryLabel(agent, dir) {
9
10
  }
10
11
  export const COMMON_LIMIT = 8 * 1024;
11
12
  export const MEMORY_INDEX_LIMIT = 4 * 1024;
13
+ export const HANDOFF_LIMIT = COMMON_LIMIT;
12
14
  export const LEAD_BATCH_LIMIT = 5;
13
15
  const BATCHABLE = new Set(["reply", "failure"]);
14
- function readCapped(file, limit) {
15
- if (!fs.existsSync(file))
16
- return undefined;
17
- const buf = fs.readFileSync(file);
18
- if (buf.length <= limit)
19
- return { text: buf.toString("utf8"), truncated: false };
20
- return { text: buf.subarray(0, limit).toString("utf8"), truncated: true };
21
- }
16
+ const readCapped = readFileCapped;
22
17
  /** A PR only needs to say agent-lyceum opened it; no agent or role names. */
23
18
  const PR_ATTRIBUTION_RULE = "- If you open a pull request, end its description with a line saying it was opened by agent-lyceum (e.g. `🤖 Opened by agent-lyceum`). Do not name any agent or role in it.";
24
19
  /** A tool call that never returns (a server or daemon started in the foreground) burns the whole wake-up timeout and the finished work is thrown away with it. */
@@ -28,6 +23,14 @@ const OPEN_PR_RULE = "Add the frontmatter line `open_pr: true` to `done` only wh
28
23
  export function readPersona(agent) {
29
24
  return fs.readFileSync(agent.agentMd, "utf8").trim();
30
25
  }
26
+ /** What the lead (or a solo agent) is told about the handoff notes: they are what a reopened task starts from. */
27
+ function handoffRule(dir) {
28
+ return [
29
+ "",
30
+ "## Handoff for follow-ups",
31
+ `This task may be reopened later with a follow-up request, and the agents woken then start with fresh sessions. Before you send \`done\`, write what they need to carry on into \`${dir}\`: keep a \`MEMORY.md\` index there (one line per entry, pointing at a file) and record the decisions made and why, the interfaces agreed, the current state, what is left and what to do next, and the pitfalls met. Your teammates can read this folder but cannot write it; if a report from a teammate holds something worth keeping, add it yourself. Only the index is shown in prompts, and only the first ${HANDOFF_LIMIT / 1024} KB of it, so keep it short. These notes are about this task only; lessons that outlive it belong in your project or global memory.`,
32
+ ];
33
+ }
31
34
  /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
32
35
  export function buildSoloSystemPrompt(project, agent) {
33
36
  const isCall = project.solo === "call";
@@ -38,6 +41,9 @@ export function buildSoloSystemPrompt(project, agent) {
38
41
  if (!isCall) {
39
42
  lines.push("## Finishing", `When the job is finished, create a Markdown file (any name ending in \`.md\`) directly inside \`${outboxDir(project, agent.name)}\` with this exact shape:`, "", "```", "---", "type: done", "subject: <short subject>", "outcome: completed|partial|blocked|failed", "---", "", "## Result", "## Files", "## Verification", "## Not done", "```", "", "That ends the run. `## Result` is the actual deliverable or conclusion (not just \"done\"), `## Files` the paths you created or changed, `## Verification` what you ran or checked and what it showed, `## Not done` anything skipped or unverified (or `None`). Only `completed` means success and it needs all four headings; if work remains or is blocked say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A summary in your reply text is never delivered: without a `done` mail the run ends as idle with no report. " + OPEN_PR_RULE);
40
43
  }
44
+ const shared = sharedDir(project);
45
+ if (!isCall && shared)
46
+ lines.push(...handoffRule(shared));
41
47
  lines.push("", "## Memory");
42
48
  const mems = memoryDirs(agent);
43
49
  if (mems.length === 0)
@@ -100,7 +106,7 @@ export function buildSystemPrompt(project, agent) {
100
106
  "</ask>",
101
107
  "```",
102
108
  "",
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.",
109
+ "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
110
  ]
105
111
  : []), ...(isLead
106
112
  ? [
@@ -111,7 +117,7 @@ export function buildSystemPrompt(project, agent) {
111
117
  "Your final `done` mail must also include the full `## Steps` checklist. Tick only steps that were actually done and verified; leave undone ones",
112
118
  "(e.g. build, app start, commit) unticked and say so in the mail instead of reporting them as finished.",
113
119
  ]
114
- : []), "", "## Memory");
120
+ : []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
115
121
  const mems = memoryDirs(agent);
116
122
  if (mems.length === 0)
117
123
  lines.push("You have no long-term memory directory.");
@@ -143,7 +149,7 @@ export function pickMessages(project, agent, queue) {
143
149
  }
144
150
  return run;
145
151
  }
146
- export function buildUserPrompt(project, agent, unread, handleCount = 1) {
152
+ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff = false) {
147
153
  const parts = [];
148
154
  const common = readCapped(commonFile(project), COMMON_LIMIT);
149
155
  parts.push("# Shared team context (COMMON.md)");
@@ -155,6 +161,14 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1) {
155
161
  parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
156
162
  }
157
163
  }
164
+ const handoffFile = handoff ? sharedIndex(project) : undefined;
165
+ const notes = handoffFile ? readCapped(handoffFile, HANDOFF_LIMIT) : undefined;
166
+ if (handoffFile && notes?.text.trim()) {
167
+ parts.push("", `# Handoff notes from earlier segments of this task (${handoffFile})`, notes.text.trim());
168
+ if (notes.truncated)
169
+ parts.push(`\n[handoff index truncated at ${HANDOFF_LIMIT} bytes — the lead should condense it.]`);
170
+ parts.push("The other files in that folder are there to read on demand.");
171
+ }
158
172
  for (const dir of memoryDirs(agent)) {
159
173
  const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
160
174
  const label = memoryLabel(agent, dir).split(" ")[0];
@@ -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. */
@@ -51,6 +59,8 @@ export interface RunOptions {
51
59
  export declare class RunSession {
52
60
  private readonly opts;
53
61
  private readonly resume;
62
+ /** The handoff index as this segment started; the lead is noted when it is the same at `done`. */
63
+ private readonly sharedAtStart;
54
64
  private readonly invoke;
55
65
  private readonly say;
56
66
  private readonly cfg;
@@ -9,11 +9,11 @@ import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, c
9
9
  import { isInside } from "./paths.js";
10
10
  import { createRunLog } from "./run-log.js";
11
11
  import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
12
- import { outboxDir, ownsDirs } from "./policy.js";
12
+ import { outboxDir, ownsDirs, sharedIndex } from "./policy.js";
13
13
  import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
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";
14
+ import { atomicWrite, readFileCapped } from "./fs-util.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";
@@ -24,6 +24,14 @@ export const RESULT_FILE = "result.md";
24
24
  export const MAX_DONE_REJECTIONS = 2;
25
25
  /** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
26
26
  export const MAX_ASK_REJECTIONS = 2;
27
+ /** How much of the handoff index is compared; the prompts show far less. */
28
+ const INDEX_COMPARE_LIMIT = 1024 * 1024;
29
+ /** The text of a file, "" when it is missing, undefined when there is no such file for this run (no handoff folder). */
30
+ function readIndex(file) {
31
+ if (!file)
32
+ return undefined;
33
+ return readFileCapped(file, INDEX_COMPARE_LIMIT)?.text ?? "";
34
+ }
27
35
  /** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
28
36
  function retryPrompt(prompt, error) {
29
37
  return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
@@ -31,6 +39,8 @@ function retryPrompt(prompt, error) {
31
39
  export class RunSession {
32
40
  opts;
33
41
  resume;
42
+ /** The handoff index as this segment started; the lead is noted when it is the same at `done`. */
43
+ sharedAtStart;
34
44
  invoke;
35
45
  say;
36
46
  cfg;
@@ -67,6 +77,8 @@ export class RunSession {
67
77
  const { task, resume, runDir } = opts;
68
78
  if (resume?.end_reason === "waiting")
69
79
  RunSession.assertAnswered(runDir, resume.run_id);
80
+ if (opts.followup && resume?.end_reason !== "done")
81
+ throw new Error("followup needs a finished (done) run");
70
82
  if (!task && !resume)
71
83
  throw new Error("runTeam needs a task or a run to resume");
72
84
  const invoke = opts.invoker ?? realInvoker;
@@ -86,10 +98,19 @@ export class RunSession {
86
98
  assertWorktreeRunnable(project, { fresh: !resume, concurrent });
87
99
  // Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
88
100
  let leadSpace;
101
+ let recut = false;
89
102
  if (concurrent && resume) {
90
103
  leadSpace = existingAgentWorkspace(project, runId, project.lead);
91
104
  if (!leadSpace)
92
105
  throw new Error(`Run ${runId} has no worktree for ${project.lead} (was it cleared?), so it cannot be resumed.`);
106
+ if (opts.followup) {
107
+ // A reopened run whose branch you already merged starts on a fresh branch from HEAD (never when work would be lost).
108
+ const r = recutLeadWorkspace(project, runId, project.lead, leadSpace, resume.result_branch?.head);
109
+ leadSpace = r.ws;
110
+ recut = r.recut;
111
+ if (r.recut)
112
+ say(` ${project.lead}'s branch was already merged: a fresh branch ${leadSpace.branch} was cut from HEAD ${leadSpace.base.slice(0, 8)}`);
113
+ }
93
114
  }
94
115
  ensureProjectDirs(project);
95
116
  fs.mkdirSync(runDir, { recursive: true });
@@ -103,7 +124,7 @@ export class RunSession {
103
124
  }
104
125
  let state;
105
126
  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] };
127
+ state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
107
128
  // A wake that was running when the process died never finished; keep it in the history as interrupted.
108
129
  for (const [agent, a] of Object.entries(resume.active ?? {})) {
109
130
  state.wakes.push({
@@ -116,11 +137,24 @@ export class RunSession {
116
137
  error: "interrupted",
117
138
  });
118
139
  }
140
+ if (opts.followup) {
141
+ state.reopened = [...(resume.reopened ?? []), { at: new Date().toISOString() }];
142
+ state.round_floor = resume.rounds;
143
+ state.max_rounds = roundLimit(state, cfg.max_rounds);
144
+ state.result_branch = undefined;
145
+ state.done_rejections = 0;
146
+ if (recut && resume.pr_url) {
147
+ // The recorded pull request is the merged branch's: it must not be mistaken for the new segment's.
148
+ 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.`];
149
+ state.pr_url = undefined;
150
+ }
151
+ }
119
152
  }
120
153
  else {
121
154
  const t = taskMessageBody(task);
122
155
  state = newRunState({
123
156
  run_id: runId,
157
+ name: opts.name,
124
158
  project: project.name,
125
159
  task_source: task.source,
126
160
  task_path: task.sourcePath,
@@ -143,6 +177,26 @@ export class RunSession {
143
177
  const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
144
178
  if (resume) {
145
179
  log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
180
+ if (opts.followup) {
181
+ const n = state.reopened?.length ?? 1;
182
+ fs.mkdirSync(path.join(runDir, "followups"), { recursive: true });
183
+ fs.writeFileSync(path.join(runDir, "followups", `${n}.md`), `${opts.followup.text.trimEnd()}\n`);
184
+ // A run finished before done-<n>.md existed has only result.md: keep that report before the next done overwrites it.
185
+ const prev = path.join(runDir, `done-${n}.md`);
186
+ const legacy = path.join(runDir, RESULT_FILE);
187
+ if (!fs.existsSync(prev) && fs.existsSync(legacy))
188
+ fs.copyFileSync(legacy, prev);
189
+ const handoffIdx = sharedIndex(project);
190
+ const handoffLine = handoffIdx && fs.existsSync(handoffIdx) ? ` The handoff notes written for follow-ups are in \`${handoffIdx}\`.` : "";
191
+ deliver(project, {
192
+ from: "user",
193
+ to: project.lead,
194
+ type: "task",
195
+ subject: opts.followup.text.split("\n").find((l) => l.trim())?.slice(0, 80) ?? "Follow-up",
196
+ 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")}\`.${handoffLine}\n`,
197
+ });
198
+ log("reopen", { n });
199
+ }
146
200
  }
147
201
  else {
148
202
  const t = taskMessageBody(task);
@@ -165,6 +219,8 @@ export class RunSession {
165
219
  this.log = log;
166
220
  this.guard = guard;
167
221
  this.state = state;
222
+ // Only a segment that starts here has a baseline: a fresh run or a follow-up reopen. A plain resume continues one begun by an earlier process.
223
+ this.sharedAtStart = !resume || opts.followup ? readIndex(sharedIndex(project)) : undefined;
168
224
  }
169
225
  /** A waiting run only continues once every unanswered question has a valid answer. */
170
226
  static assertAnswered(runDir, runId) {
@@ -241,19 +297,19 @@ export class RunSession {
241
297
  workDir,
242
298
  workspace: spaces[agent.name],
243
299
  systemPrompt: buildSystemPrompt(aproject, agent),
244
- userPrompt: buildUserPrompt(project, agent, queue, unread.length),
300
+ userPrompt: buildUserPrompt(project, agent, queue, unread.length, (state.reopened?.length ?? 0) > 0),
245
301
  timeoutSec: cfg.wake_timeout_sec,
246
302
  };
247
303
  let result;
248
304
  for (let attempt = 0; attempt <= cfg.retry; attempt++) {
249
- if (state.rounds >= cfg.max_rounds) {
305
+ if (state.rounds >= state.max_rounds) {
250
306
  result = { ok: false, text: "", exitCode: null, timedOut: false, error: "round limit reached" };
251
307
  break;
252
308
  }
253
309
  const round = ++state.rounds;
254
310
  const started = Date.now();
255
311
  const att = beginAttempt(runDir, claim.id);
256
- say(`[round ${state.rounds}/${cfg.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
312
+ say(`[round ${state.rounds}/${state.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
257
313
  const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
258
314
  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
315
  state.active[agent.name] = { round, since: new Date(started).toISOString(), handling };
@@ -466,13 +522,22 @@ export class RunSession {
466
522
  log("rejected", r);
467
523
  }
468
524
  if (route.done) {
525
+ const handoffIdx = sharedIndex(project);
526
+ const nowShared = handoffIdx && this.sharedAtStart !== undefined ? readIndex(handoffIdx) : undefined;
527
+ if (nowShared !== undefined && nowShared === this.sharedAtStart) {
528
+ this.note(nowShared.trim()
529
+ ? `shared-not-updated: the lead did not change the handoff notes (${handoffIdx}) in this segment; a follow-up will see them as they are.`
530
+ : `shared-not-updated: the lead did not write handoff notes (${handoffIdx}) in this segment, so a follow-up will start without handoff notes.`);
531
+ }
469
532
  this.doneMessage = { subject: route.done.subject, body: route.done.body };
470
533
  this.openPrRequested = route.done.meta.open_pr === true;
471
534
  const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? []);
472
535
  this.doneOutcome = resolved.outcome;
473
536
  this.doneNote = resolved.note;
474
537
  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`);
538
+ const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`;
539
+ atomicWrite(path.join(runDir, RESULT_FILE), report);
540
+ atomicWrite(path.join(runDir, `done-${(state.reopened?.length ?? 0) + 1}.md`), report);
476
541
  this.pendingDoneFile = route.done.file;
477
542
  log("done", { subject: route.done.subject });
478
543
  this.endReason = "done";
@@ -604,6 +669,7 @@ export class RunSession {
604
669
  return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
605
670
  if (!rb.files)
606
671
  return skip(`${rb.branch} has no file changes.`);
672
+ const existingUrl = state.pr_url;
607
673
  const result = openPullRequest({
608
674
  // Never the lead's worktree: the agent can write it (and its git pointers), and git would run what it planted there.
609
675
  cwd: toplevel(project.dir),
@@ -612,11 +678,14 @@ export class RunSession {
612
678
  body: prBody(this.doneMessage?.body ?? ""),
613
679
  bodyFile: path.join(runDir, "pr-body.md"),
614
680
  run: this.prRunner,
681
+ existingUrl,
615
682
  });
616
683
  log("pull-request", { ...result });
617
684
  if (result.status === "opened") {
685
+ say(` pull request ${result.updated ? "updated" : "opened"}: ${result.url}`);
686
+ if (existingUrl && !result.updated && !state.notes?.some((n) => n.includes(existingUrl)))
687
+ this.note(`The earlier pull request ${existingUrl} was no longer open; a new one was opened for the follow-up work.`);
618
688
  state.pr_url = result.url;
619
- say(` pull request opened: ${result.url}`);
620
689
  }
621
690
  else {
622
691
  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 +724,7 @@ export class RunSession {
655
724
  async run() {
656
725
  if (this.resume)
657
726
  this.recover();
658
- const { say, cfg, runId, runDir, project, wsMode, log, state, opts } = this;
727
+ const { say, runId, runDir, project, wsMode, log, state, opts } = this;
659
728
  // When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
660
729
  let leadWasLast = false;
661
730
  let nudged = false;
@@ -682,7 +751,7 @@ export class RunSession {
682
751
  this.endReason = "idle";
683
752
  break;
684
753
  }
685
- if (state.rounds >= cfg.max_rounds) {
754
+ if (state.rounds >= state.max_rounds) {
686
755
  this.endReason = "max_rounds";
687
756
  break;
688
757
  }
@@ -744,6 +813,6 @@ export class RunSession {
744
813
  this.saveState();
745
814
  if (this.pendingDoneFile)
746
815
  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 };
816
+ 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
817
  }
749
818
  }
@@ -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";