agent-lyceum 0.25.0 → 0.26.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.
@@ -15,6 +15,11 @@ export const mailDir = (runDir) => path.join(runDir, "mail");
15
15
  export class RouteJournal {
16
16
  file;
17
17
  entries = new Map();
18
+ byMsg = new Map();
19
+ byFileName = new Map();
20
+ assigns = new Map();
21
+ firstHeldSeq = new Map();
22
+ seqNext = 0;
18
23
  constructor(runDir) {
19
24
  this.file = path.join(mailDir(runDir), "journal.jsonl");
20
25
  let text = "";
@@ -30,27 +35,65 @@ export class RouteJournal {
30
35
  try {
31
36
  const j = JSON.parse(line);
32
37
  const key = `${j.source}\u0000${j.to}`;
33
- if (j.t === "intent")
34
- this.entries.set(key, { source: j.source, to: j.to, msg_id: j.msg_id, file: j.file, created: j.created, delivered: false });
38
+ if (j.t === "intent") {
39
+ this.index({
40
+ source: j.source,
41
+ to: j.to,
42
+ msg_id: j.msg_id,
43
+ file: j.file,
44
+ created: j.created,
45
+ delivered: false,
46
+ ...(j.held === true ? { held: true } : {}),
47
+ ...(j.parallel === true ? { parallel: true } : {}),
48
+ ...(typeof j.from === "string" ? { from: j.from } : {}),
49
+ ...(typeof j.about === "string" ? { about: j.about } : {}),
50
+ });
51
+ }
35
52
  else if (j.t === "delivered") {
36
53
  const e = this.entries.get(key);
37
54
  if (e)
38
55
  e.delivered = true;
39
56
  }
57
+ else if (j.t === "assign" && typeof j.msg_id === "string" && typeof j.slot === "string") {
58
+ this.assigns.set(j.msg_id, { slot: j.slot, file: String(j.file), role: String(j.role) });
59
+ }
40
60
  }
41
61
  catch {
42
62
  /* a line cut short by a crash: ignore it */
43
63
  }
44
64
  }
45
65
  }
66
+ /** Enter an intent in every index; `seq` is its position among the records read or appended. */
67
+ index(e) {
68
+ e.seq = this.seqNext++;
69
+ this.entries.set(`${e.source}\u0000${e.to}`, e);
70
+ this.byMsg.set(e.msg_id, e);
71
+ this.byFileName.set(e.file, e);
72
+ if (e.held && !this.firstHeldSeq.has(e.to))
73
+ this.firstHeldSeq.set(e.to, e.seq);
74
+ return e;
75
+ }
46
76
  get(source, to) {
47
77
  return this.entries.get(`${source}\u0000${to}`);
48
78
  }
49
79
  append(obj) {
50
80
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
51
- const fd = fs.openSync(this.file, "a");
81
+ const fd = fs.openSync(this.file, "a+");
52
82
  try {
53
- fs.writeSync(fd, JSON.stringify(obj) + "\n");
83
+ let text = `${JSON.stringify(obj)}\n`;
84
+ // A crash or a short write can leave the last line without its newline; appending straight after it would glue the two into one
85
+ // unreadable line. The real tail is read on every append (one byte), so no cached check can go stale, even with another writer.
86
+ const size = fs.fstatSync(fd).size;
87
+ if (size > 0) {
88
+ const last = Buffer.alloc(1);
89
+ fs.readSync(fd, last, 0, 1, size - 1);
90
+ if (last[0] !== 0x0a)
91
+ text = `\n${text}`;
92
+ }
93
+ const bytes = Buffer.from(text);
94
+ const written = fs.writeSync(fd, bytes);
95
+ if (written < bytes.length)
96
+ throw new Error(`Short write to ${this.file}: ${written} of ${bytes.length} bytes (disk full?). Resume the run after freeing space.`);
54
97
  fs.fsyncSync(fd);
55
98
  }
56
99
  finally {
@@ -59,9 +102,34 @@ export class RouteJournal {
59
102
  }
60
103
  intent(e) {
61
104
  this.append({ t: "intent", ...e });
62
- const entry = { ...e, delivered: false };
63
- this.entries.set(`${e.source}\u0000${e.to}`, entry);
64
- return entry;
105
+ return this.index({ ...e, delivered: false });
106
+ }
107
+ /** The assigner's decision for a held mail, made durable before the file moves (spec 23.7.2). */
108
+ assign(a) {
109
+ this.append({ t: "assign", ...a });
110
+ this.assigns.set(a.msg_id, { slot: a.slot, file: a.file, role: a.role });
111
+ }
112
+ assigned(msgId) {
113
+ return this.assigns.get(msgId);
114
+ }
115
+ byFile(file) {
116
+ return this.byFileName.get(file);
117
+ }
118
+ /** Position of the first held intent addressed to `name`; undefined when there is none. */
119
+ firstHeld(name) {
120
+ return this.firstHeldSeq.get(name);
121
+ }
122
+ ownership(msgId) {
123
+ const intent = this.byMsg.get(msgId);
124
+ const assign = this.assigns.get(msgId)?.slot;
125
+ if (!intent && assign === undefined)
126
+ return undefined;
127
+ return {
128
+ ...(assign !== undefined ? { assign } : {}),
129
+ ...(intent?.from ? { from: intent.from } : {}),
130
+ ...(intent?.about ? { about: intent.about } : {}),
131
+ ...(intent ? { intent: { seq: intent.seq, to: intent.to, file: intent.file, source: intent.source, msg_id: intent.msg_id } } : {}),
132
+ };
65
133
  }
66
134
  delivered(source, to) {
67
135
  this.append({ t: "delivered", source, to });
package/dist/paths.d.ts CHANGED
@@ -25,3 +25,8 @@ export interface ProjectPaths {
25
25
  export declare function projectPaths(home: string, project: string): ProjectPaths;
26
26
  /** True when `child` is `parent` or inside it. */
27
27
  export declare function isInside(parent: string, child: string): boolean;
28
+ /**
29
+ * `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
30
+ * yet (a directory about to be made) still compares equal to the same place reached through a symlink.
31
+ */
32
+ export declare function canonicalPath(p: string): string;
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
@@ -46,5 +47,26 @@ export function projectPaths(home, project) {
46
47
  /** True when `child` is `parent` or inside it. */
47
48
  export function isInside(parent, child) {
48
49
  const rel = path.relative(parent, child);
49
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
50
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
51
+ }
52
+ /**
53
+ * `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
54
+ * yet (a directory about to be made) still compares equal to the same place reached through a symlink.
55
+ */
56
+ export function canonicalPath(p) {
57
+ const abs = path.resolve(p);
58
+ const rest = [];
59
+ let head = abs;
60
+ for (;;) {
61
+ try {
62
+ return path.join(fs.realpathSync.native(head), ...rest);
63
+ }
64
+ catch {
65
+ const up = path.dirname(head);
66
+ if (up === head)
67
+ return abs;
68
+ rest.unshift(path.basename(head));
69
+ head = up;
70
+ }
71
+ }
50
72
  }
package/dist/policy.d.ts CHANGED
@@ -44,6 +44,7 @@ export declare const scratchCwd: (workDir: string) => string;
44
44
  /** The repo directory this agent works in: its own worktree, or the main repo. */
45
45
  export declare function repoDirFor(project: ResolvedProject, agent: string): string;
46
46
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
47
+ export declare function osSandboxAvailable(): boolean;
47
48
  /** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
48
49
  export declare function setOsSandboxForTest(available: boolean | undefined): void;
49
50
  export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
package/dist/policy.js CHANGED
@@ -136,13 +136,13 @@ export function writePolicy(project, agent) {
136
136
  deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
137
137
  return {
138
138
  allowDirs,
139
- readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)])],
139
+ readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)]), ...Object.values(agent.sharedMemory ?? {})],
140
140
  allowFiles,
141
141
  deny: [...new Set(deny)],
142
142
  };
143
143
  }
144
144
  let sandboxProbe;
145
- function osSandboxAvailable() {
145
+ export function osSandboxAvailable() {
146
146
  if (sandboxProbe !== undefined)
147
147
  return sandboxProbe;
148
148
  if (process.platform === "darwin")
package/dist/prompt.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ResolvedAgent, ResolvedProject } from "./config.js";
2
2
  import type { Message } from "./mailbox.js";
3
3
  import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
4
+ /** The lead's system prompt gets this at its very end when same-role instances are possible in the run (spec 23.12). No trailing newline. */
5
+ export declare const PARALLEL_SECTION: (n: number) => string;
4
6
  export declare const COMMON_LIMIT: number;
5
7
  export declare const MEMORY_INDEX_LIMIT: number;
6
8
  export declare const HANDOFF_LIMIT: number;
package/dist/prompt.js CHANGED
@@ -2,13 +2,28 @@ import fs from "node:fs";
2
2
  import { cutUtf8, readFileCapped } from "./fs-util.js";
3
3
  import path from "node:path";
4
4
  import { REQUIRED_SECTIONS } from "./format.js";
5
+ import { senderLabel, slotName } from "./instances.js";
5
6
  import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
6
7
  import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
7
8
  function memoryLabel(agent, dir) {
8
9
  if (dir === agent.memory.task)
9
10
  return "task (this task only, isolated)";
10
- return dir === agent.memory.global ? "global (all projects)" : "project (all tasks of this project)";
11
+ const shared = agent.sharedMemory && Object.values(agent.sharedMemory).includes(dir);
12
+ const instance1 = agent.instance ? slotName(agent.instance.role, 1) : "";
13
+ const kind = dir === (agent.memory.global ?? agent.sharedMemory?.global) ? "global (all projects" : "project (all tasks of this project";
14
+ return shared ? `${kind}, read-only for you: ${instance1} maintains it)` : `${kind})`;
11
15
  }
16
+ /** The lead's system prompt gets this at its very end when same-role instances are possible in the run (spec 23.12). No trailing newline. */
17
+ export const PARALLEL_SECTION = (n) => [
18
+ "## Parallel tasks",
19
+ `A member can work on up to ${n} tasks at the same time, each in its own instance with its own worktree. To use this, send each independent task as its own new \`task\` (not a reply, no \`reply_to\`) with the frontmatter line \`parallel: true\`. Only your own mail can do this. Instance 1 keeps the member's name; the others are named \`<member>--2\`, \`<member>--3\`, and their mail reaches you from those names.`,
20
+ "- Mark tasks parallel only when they are independent of each other and change different files. Before you send them, list in each task's `## Scope` the files it will touch; if two lists overlap, do not mark them parallel.",
21
+ "- Never run in parallel work that changes shared components, types, routes, configuration or design tokens: send that first, without the flag, and send the dependent tasks after it is integrated. Changes to the same file are not merged: the later one is blocked and comes back to you as a failure.",
22
+ "- Revisions go back to the instance that did the work: reply to its mail (`reply_to`). An instance whose work is blocked takes no new task, also not one addressed to it by name, until that work is in the repo; to redo it, reply to the failure about it.",
23
+ "- Without `parallel: true`, mail goes to the member itself, one task at a time.",
24
+ "- When rate limits hit, fewer instances may start; waiting tasks run when an instance is free.",
25
+ "- After parallel work is back, have one member type-check and test the combined result before you report `done`.",
26
+ ].join("\n");
12
27
  export const COMMON_LIMIT = 8 * 1024;
13
28
  export const MEMORY_INDEX_LIMIT = 4 * 1024;
14
29
  export const HANDOFF_LIMIT = COMMON_LIMIT;
@@ -81,12 +96,22 @@ export function buildSystemPrompt(project, agent) {
81
96
  if (project.solo)
82
97
  return buildSoloSystemPrompt(project, agent);
83
98
  const outbox = outboxDir(project, agent.name);
84
- const mates = Object.keys(project.agents).filter((n) => n !== agent.name);
99
+ // Instances 2 and up are not listed (the lead is told their names separately), and an instance does not list its own role's other instances.
100
+ const mates = Object.keys(project.agents).filter((n) => n !== agent.name && (project.agents[n]?.instance?.k ?? 1) < 2 && (!agent.instance || project.agents[n]?.instance?.role !== agent.instance.role));
85
101
  const recipients = agent.canMessage === "all" ? mates : agent.canMessage.filter((n) => project.agents[n] && n !== agent.name);
86
102
  const isLead = agent.name === project.lead;
87
103
  const lines = [];
88
104
  lines.push(readPersona(agent), "", "---", "", "# Team protocol (injected by agent-lyceum)", "");
89
- lines.push(`You are **${agent.name}**, a member of the team "${project.name}". You work in the repository \`${repoDirFor(project, agent.name)}\`.`, ...(project.workspaces?.[agent.name]
105
+ lines.push(agent.instance && agent.instance.k >= 2
106
+ ? `You are **${agent.name}**, instance ${agent.instance.k} of **${agent.instance.role}** in the team "${project.name}". Other instances of ${agent.instance.role} may be working on other tasks at the same time, each in its own worktree. You work in the repository \`${repoDirFor(project, agent.name)}\`.`
107
+ : `You are **${agent.name}**, a member of the team "${project.name}". You work in the repository \`${repoDirFor(project, agent.name)}\`.`, ...(agent.instance?.k === 1
108
+ ? [
109
+ `Other instances of you${(() => {
110
+ const names = Object.values(project.agents).filter((a) => a.instance?.role === agent.name && a.instance.k >= 2).map((a) => a.name);
111
+ return names.length ? ` (${names.join(", ")})` : "";
112
+ })()} may work in parallel; only you write your global/project memory.`,
113
+ ]
114
+ : []), ...(project.workspaces?.[agent.name]
90
115
  ? [
91
116
  `That directory is your own isolated git worktree (branch \`${project.workspaces?.[agent.name]?.branch}\`): teammates cannot see your changes while you work, and you cannot see theirs. Do not commit, push, switch branches or touch any other checkout; the dispatcher collects your changes from this directory after you finish.`,
92
117
  ]
@@ -125,14 +150,17 @@ export function buildSystemPrompt(project, agent) {
125
150
  ]
126
151
  : []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
127
152
  const mems = memoryDirs(agent);
153
+ const sharedMems = Object.values(agent.sharedMemory ?? {});
128
154
  if (mems.length === 0)
129
155
  lines.push("You have no long-term memory directory.");
130
- for (const m of mems) {
156
+ for (const m of [...sharedMems, ...mems]) {
131
157
  const label = memoryLabel(agent, m);
132
158
  lines.push(`- ${label}: \`${m}\``);
133
159
  }
134
160
  if (mems.length) {
135
- lines.push("Keep a `MEMORY.md` index in each memory directory (one line per entry, pointing at a file). Its contents are shown in your prompt", "on every wake-up; read other memory files on demand; you can write only inside these directories.", "Put anything specific to the current task (decisions, progress, findings, branch names) in the **task** memory: it is private to this task and starts empty for every new task.", "Also record approaches you considered but rejected, with the reason, so they can be revisited or reversed later.", "Use the global/project memory only for durable lessons that stay true across tasks; never record task-specific details there.");
161
+ lines.push("Keep a `MEMORY.md` index in each memory directory (one line per entry, pointing at a file). Its contents are shown in your prompt", "on every wake-up; read other memory files on demand; you can write only inside these directories.", "Put anything specific to the current task (decisions, progress, findings, branch names) in the **task** memory: it is private to this task and starts empty for every new task.", "Also record approaches you considered but rejected, with the reason, so they can be revisited or reversed later.", ...(sharedMems.length
162
+ ? ["You cannot write the shared memory: put lessons worth keeping in your reply to the lead."]
163
+ : ["Use the global/project memory only for durable lessons that stay true across tasks; never record task-specific details there."]));
136
164
  }
137
165
  lines.push("", "## Rules");
138
166
  lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
@@ -141,7 +169,8 @@ export function buildSystemPrompt(project, agent) {
141
169
  `- You also keep two kinds of durable records for every future run of this project, one Markdown file per entry: decisions in \`${recordsKindDir(project, "decisions")}/\` (what was decided and why) and lessons in \`${recordsKindDir(project, "lessons")}/\` (heuristics and pitfalls that stay true). The file name without \`.md\` is the entry id (lowercase letters, digits, \`-\`). Each file starts with YAML frontmatter between \`---\` lines: \`status: active|superseded\`, \`date: YYYY-MM-DD\`, \`title: <one line>\`, optionally \`run: <run id>\` and \`supersedes: [<ids of entries of the same kind>]\`; the body is the decision or lesson and its reason. Prompts show only effective-active entries (\`status: active\` and not named in another entry's \`supersedes\`), newest first, up to ${CONTEXT_LIMIT / 1024} KB per kind, and list what does not fit as one line each. When an entry becomes outdated, mark it \`status: superseded\` or name it in a new entry's \`supersedes\`. Add or update entries before you send \`done\`.`,
142
170
  ]
143
171
  : []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
144
- return lines.join("\n");
172
+ const text = lines.join("\n");
173
+ return isLead && project.run?.instances ? `${text}\n\n${PARALLEL_SECTION(project.run.instances.max)}` : text;
145
174
  }
146
175
  /** Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so the agent decides once on all of it; the lead takes any such run, other agents only same-thread ones. */
147
176
  export function pickMessages(project, agent, queue) {
@@ -188,7 +217,7 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
188
217
  parts.push(`\n[handoff index truncated at ${HANDOFF_LIMIT} bytes — the lead should condense it.]`);
189
218
  parts.push("The other files in that folder are there to read on demand.");
190
219
  }
191
- for (const dir of memoryDirs(agent)) {
220
+ for (const dir of [...Object.values(agent.sharedMemory ?? {}), ...memoryDirs(agent)]) {
192
221
  const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
193
222
  const label = memoryLabel(agent, dir).split(" ")[0];
194
223
  parts.push("", `# Your ${label} memory index (${dir}/MEMORY.md)`);
@@ -216,12 +245,12 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
216
245
  const body = shown.length === raw.length
217
246
  ? m.body
218
247
  : `${shown.toString("utf8")}\n[agent-lyceum: this message was cut at ${shown.length} of ${raw.length} bytes; read the whole message in \`${m.file}\` (it moves to \`read/\` in the same folder after this wake-up)]`;
219
- parts.push(`id: ${m.meta.id}`, `from: ${m.meta.from}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", body);
248
+ parts.push(`id: ${m.meta.id}`, `from: ${senderLabel(m.meta.from, project.agents)}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", body);
220
249
  });
221
250
  if (queued.length) {
222
251
  parts.push("", "# Queued messages (not handled in this wake-up; each gets its own wake-up later, titles only)");
223
252
  for (const m of queued)
224
- parts.push(`- from ${m.meta.from} [${m.meta.type}] "${m.meta.subject}" -> ${m.file}`);
253
+ parts.push(`- from ${senderLabel(m.meta.from, project.agents)} [${m.meta.type}] "${m.meta.subject}" -> ${m.file}`);
225
254
  }
226
255
  return parts.join("\n");
227
256
  }
@@ -63,6 +63,11 @@ export interface RunOptions {
63
63
  log?: (line: string) => void;
64
64
  /** The wait between retries of a model with a fallback chain; tests pass one that does not wait. Default: a timer cut short by `signal`. */
65
65
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
66
+ /**
67
+ * Containment check for an instance (spec 23.11 D-17): returns why instance `k` of `role` must not be opened (it could write the role's
68
+ * shared memory), or undefined. Asked before activation, after an instance is created and when a run is resumed. Tests inject one.
69
+ */
70
+ instanceCheck?: (role: string, k: number) => string | undefined;
66
71
  }
67
72
  export declare class RunSession {
68
73
  private readonly opts;
@@ -74,9 +79,21 @@ export declare class RunSession {
74
79
  private readonly cfg;
75
80
  private readonly runId;
76
81
  private readonly runDir;
77
- /** The project bound to this run (own memory dirs and mailboxes). */
78
- private readonly project;
82
+ /** The project bound to this run (own memory dirs and mailboxes). Bound again when the run gets instances (spec 23.6). */
83
+ private project;
84
+ /** The project as configured (solo-narrowed), before it is bound to this run. */
85
+ private readonly base;
86
+ private readonly layout;
87
+ /** Roles that get no new instance in this process: a check after an instance was created found a conflict. */
88
+ private readonly noMoreInstances;
89
+ private readonly heldLogged;
90
+ private readonly ignoredNoted;
91
+ /** Held mail the assigner left in `pending/` this round (read by the stall check). */
92
+ private waiting;
79
93
  private readonly wsMode;
94
+ /** D-17: the paths an instance would get against the shared memory of its role (writes nothing). */
95
+ private readonly containment;
96
+ private readonly instanceCheck;
80
97
  private readonly log;
81
98
  private readonly guard;
82
99
  private readonly state;
@@ -110,6 +127,20 @@ export declare class RunSession {
110
127
  private saveState;
111
128
  private journal;
112
129
  private note;
130
+ /** Bind the project to this run again, after its slot table changed. */
131
+ private rebind;
132
+ /** What the router needs to hold and place mail for roles with several instances (spec 23.7.1). */
133
+ private routing;
134
+ /** Make a new instance durable and give it its folders; false (and the role gets no more instances in this process) when a check refuses it. */
135
+ private openInstance;
136
+ /** The one round of placing held mail, at the top of the loop (spec 23.7.2). Leaves what it could not place in `this.waiting`. */
137
+ private assignHeld;
138
+ private assignRole;
139
+ /**
140
+ * The run is about to end idle but mail is still held (spec D-16). Only a cooldown in the way: wait for it (cut short by a cancel) and
141
+ * return true to go round again. Otherwise tell the lead once per set of waiting mails; true when that mail is new (the lead has work).
142
+ */
143
+ private stallHeld;
113
144
  private pendingAgents;
114
145
  private pickBatch;
115
146
  private wake;