agent-lyceum 0.30.0 → 0.31.1

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
@@ -9,7 +9,7 @@ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one ov
9
9
  ## Why
10
10
 
11
11
  - **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
12
- - **Agents can't rewrite the rules.** An agent can only write its own memory and outbox. It can't touch other agents' context or its own persona, and this is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
12
+ - **Agents can't rewrite the rules.** An agent can write its own memory, its outbox, and its project agent directory (`agents/<name>/`, including a project persona it creates there). It can't touch other agents' context, `COMMON.md`, the project records, or a global persona. This is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
13
13
  - **Mix runtimes.** Claude Code and Codex agents work in the same team.
14
14
  - **Backup models.** If a model is rate-limited, out of quota or unreachable, a run can switch to a backup model or runtime instead of stopping. See [Configuration](docs/configuration.md#fallback-models).
15
15
  - **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
package/README.zh-TW.md CHANGED
@@ -9,7 +9,7 @@
9
9
  ## 為什麼用它
10
10
 
11
11
  - **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
12
- - **agent 改不了規則。** agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設;能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
12
+ - **agent 改不了規則。** agent 可以寫自己的記憶、寄件匣(outbox),以及自己的專案 agent 目錄(`agents/<name>/`,含它在那裡建立的專案人設)。它不能改其他 agent 的 context、`COMMON.md`、專案紀錄,或全域人設。能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
13
13
  - **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
14
14
  - **備用模型。** 模型被限流、額度用完或連不上時,run 可以改用備用的模型或 runtime 繼續,而不是直接停下。見[設定](docs/configuration.zh-TW.md#備援模型)。
15
15
  - **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
3
+ import { projectCommonDir, projectCommonDirAllowed, repoDirFor, scratchCwd, writePolicy } from "../policy.js";
4
4
  export function codexWritableRoots(input) {
5
5
  const pol = input.policy ?? writePolicy(input.project, input.agent);
6
6
  const work = input.policy ? scratchCwd(input.workDir) : repoDirFor(input.project, input.agent.name);
7
- const roots = [...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === work || d.startsWith(work + path.sep)));
7
+ const common = projectCommonDir(input.project);
8
+ const withholdCommon = !input.policy && !projectCommonDirAllowed(input.project, input.agent);
9
+ const fileRoots = pol.allowFiles.map((f) => path.dirname(f)).filter((d) => !withholdCommon || (d !== common && !d.startsWith(common + path.sep)));
10
+ const roots = [...pol.allowDirs, ...fileRoots].filter((d) => !(d === work || d.startsWith(work + path.sep)));
8
11
  return [...new Set(roots)];
9
12
  }
10
13
  function sessionIdOf(line) {
package/dist/cli.js CHANGED
@@ -30,6 +30,7 @@ import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
30
30
  import { onlyAgent, soloProject } from "./solo.js";
31
31
  import { DEMO_PROJECT, DEMO_TASK, defaultDemoDir, detectRuntime, prepareDemo } from "./demo.js";
32
32
  import { claudeQuota, codexQuota } from "./quota.js";
33
+ import { summarizeProgress } from "./summarize.js";
33
34
  const program = new Command();
34
35
  program
35
36
  .name("agent-lyceum")
@@ -808,6 +809,7 @@ program
808
809
  allTasks: true,
809
810
  }),
810
811
  quota: () => Promise.all([claudeQuota(), codexQuota()]),
812
+ summarize: ({ runtime, prompt }) => summarizeProgress(runtime, prompt),
811
813
  },
812
814
  });
813
815
  console.log(page.url);
package/dist/guard.d.ts CHANGED
@@ -24,7 +24,7 @@ export declare class ProtectedGuard {
24
24
  private project;
25
25
  private otherLiveRuns;
26
26
  private baseline;
27
- /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
27
+ /** Regular files of the records tree (including `.archive/`); any agent in the running batch may change them. */
28
28
  private records;
29
29
  /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
30
30
  private unattributed;
package/dist/guard.js CHANGED
@@ -16,7 +16,7 @@ export class ProtectedGuard {
16
16
  project;
17
17
  otherLiveRuns;
18
18
  baseline = new Map();
19
- /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
19
+ /** Regular files of the records tree (including `.archive/`); any agent in the running batch may change them. */
20
20
  records = new Map();
21
21
  /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
22
22
  unattributed = [];
@@ -92,6 +92,16 @@ export class ProtectedGuard {
92
92
  const violations = [];
93
93
  const other = [...new Set([...this.liveAtBase, ...this.otherLiveRuns()])];
94
94
  for (const [file, before] of this.baseline) {
95
+ if (unsafeToRead(file)) {
96
+ const kept = this.keep(file, null);
97
+ fs.rmSync(file, { force: true });
98
+ if (before !== null) {
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ fs.writeFileSync(file, before);
101
+ }
102
+ violations.push({ file, action: before === null ? "removed" : "restored", suspects: running.map((a) => a.name), ...kept });
103
+ continue;
104
+ }
95
105
  const now = read(file);
96
106
  if (same(before, now))
97
107
  continue;
@@ -126,7 +136,7 @@ export class ProtectedGuard {
126
136
  if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
127
137
  return;
128
138
  const now = readTree(root);
129
- const lead = running.some((a) => a.name === this.project.lead);
139
+ const authorized = running.some((a) => a.name === this.project.lead);
130
140
  const suspects = running.map((a) => a.name);
131
141
  const fixedDirs = new Set();
132
142
  for (const file of new Set([...this.records.keys(), ...now.keys()])) {
@@ -134,7 +144,7 @@ export class ProtectedGuard {
134
144
  const cur = now.get(file) ?? null;
135
145
  if (same(before, cur))
136
146
  continue;
137
- if (lead)
147
+ if (authorized)
138
148
  continue;
139
149
  if (other.length) {
140
150
  this.unattributed.push({ file, agents: suspects, otherRuns: other });
@@ -155,7 +165,7 @@ export class ProtectedGuard {
155
165
  violations.push({ file, action: "restored", suspects, ...kept });
156
166
  }
157
167
  }
158
- if (lead || other.length)
168
+ if (authorized || other.length)
159
169
  this.records = now;
160
170
  }
161
171
  /**
@@ -242,13 +252,55 @@ function readTree(dir) {
242
252
  walk(dir);
243
253
  return out;
244
254
  }
255
+ const GUARD_READ_MAX = MAX_KEEP_BYTES * 2;
256
+ /** A FIFO, device, directory, or a symlink to one. Opening it would block or is not a file the guard can compare. */
257
+ function unsafeToRead(file) {
258
+ let fd;
259
+ try {
260
+ const listed = fs.lstatSync(file);
261
+ if (listed.isFIFO() || listed.isSocket() || listed.isCharacterDevice() || listed.isBlockDevice() || listed.isDirectory())
262
+ return true;
263
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
264
+ return !fs.fstatSync(fd).isFile();
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ finally {
270
+ if (fd !== undefined) {
271
+ try {
272
+ fs.closeSync(fd);
273
+ }
274
+ catch {
275
+ // already closed
276
+ }
277
+ }
278
+ }
279
+ }
245
280
  function read(file) {
281
+ let fd;
246
282
  try {
247
- return fs.readFileSync(file);
283
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
284
+ const st = fs.fstatSync(fd);
285
+ if (!st.isFile())
286
+ return null;
287
+ if (st.size > GUARD_READ_MAX)
288
+ return null;
289
+ return fs.readFileSync(fd);
248
290
  }
249
291
  catch {
250
292
  return null;
251
293
  }
294
+ finally {
295
+ if (fd !== undefined) {
296
+ try {
297
+ fs.closeSync(fd);
298
+ }
299
+ catch {
300
+ // already closed
301
+ }
302
+ }
303
+ }
252
304
  }
253
305
  function same(a, b) {
254
306
  if (a === null || b === null)
package/dist/policy.d.ts CHANGED
@@ -23,7 +23,7 @@ export interface WritePolicy {
23
23
  deny: string[];
24
24
  }
25
25
  export declare function commonFile(project: ResolvedProject): string;
26
- /** Durable decisions and lessons of the project, one file per entry: the lead writes them, everyone else reads them. `.archive/` is tidy's output, never shown in prompts. */
26
+ /** Durable decisions and lessons of the project, one file per entry. Only the lead may write them. `.archive/` is tidy's output, never shown in prompts. */
27
27
  export declare function recordsDir(project: ResolvedProject): string;
28
28
  export declare function recordsKindDir(project: ResolvedProject, kind: "decisions" | "lessons"): string;
29
29
  /** The lead-maintained files shared by every run of the project. */
@@ -43,6 +43,12 @@ export declare function sharedIndex(project: ResolvedProject): string | undefine
43
43
  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
+ export declare function projectAgentDir(project: ResolvedProject, agentName: string): string;
47
+ export declare function projectCommonDir(project: ResolvedProject): string;
48
+ /** True when `agents/<name>/` stays inside the project agents directory and does not overlap another agent's protected paths. */
49
+ export declare function ownAgentDirAllowed(project: ResolvedProject, agent: ResolvedAgent): boolean;
50
+ /** True when `shared/common/` does not overlap another agent's persona, memory, inbox or outbox. */
51
+ export declare function projectCommonDirAllowed(project: ResolvedProject, agent: ResolvedAgent): boolean;
46
52
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
47
53
  export declare function osSandboxAvailable(): boolean;
48
54
  /** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
package/dist/policy.js CHANGED
@@ -5,7 +5,7 @@ import { canonicalPath } from "./paths.js";
5
5
  export function commonFile(project) {
6
6
  return path.join(project.paths.shared, "common", "COMMON.md");
7
7
  }
8
- /** Durable decisions and lessons of the project, one file per entry: the lead writes them, everyone else reads them. `.archive/` is tidy's output, never shown in prompts. */
8
+ /** Durable decisions and lessons of the project, one file per entry. Only the lead may write them. `.archive/` is tidy's output, never shown in prompts. */
9
9
  export function recordsDir(project) {
10
10
  return path.join(project.paths.shared, "common", "records");
11
11
  }
@@ -76,6 +76,57 @@ export const scratchCwd = (workDir) => path.join(workDir, "cwd");
76
76
  export function repoDirFor(project, agent) {
77
77
  return project.workspaces?.[agent]?.dir ?? project.dir;
78
78
  }
79
+ export function projectAgentDir(project, agentName) {
80
+ return path.join(project.paths.agentsDir, agentName);
81
+ }
82
+ export function projectCommonDir(project) {
83
+ return path.join(project.paths.shared, "common");
84
+ }
85
+ function covers(dir, target) {
86
+ const root = canonicalPath(dir);
87
+ const file = canonicalPath(target);
88
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
89
+ return file === root || file.startsWith(prefix);
90
+ }
91
+ /** Another agent's persona, memory, inbox or outbox. A slot that shares this agent's persona file is not another persona. */
92
+ function foreignProtectedPaths(project, agent, other) {
93
+ return [
94
+ ...(other.agentMd === agent.agentMd ? [] : [other.agentMd]),
95
+ inboxDir(project, other.name),
96
+ outboxDir(project, other.name),
97
+ ...memoryDirs(other),
98
+ ];
99
+ }
100
+ /**
101
+ * True when `dir` contains a foreign protected path, or sits inside one.
102
+ * Codex has no deny list inside a writable root, so either direction closes the root.
103
+ * Compared on real paths: a symlink grant is the target the sandbox would write if it follows links.
104
+ * Whether a live Claude or Codex sandbox follows that symlink was not verified; the directory is not granted either way.
105
+ */
106
+ function overlapsForeign(project, agent, dir) {
107
+ for (const other of Object.values(project.agents)) {
108
+ if (other.name === agent.name)
109
+ continue;
110
+ if (foreignProtectedPaths(project, agent, other).some((p) => covers(dir, p) || covers(p, dir)))
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ /** True when the real path of `dir` is `parent` or a directory inside it. */
116
+ function staysInside(dir, parent) {
117
+ const real = canonicalPath(dir);
118
+ const root = canonicalPath(parent);
119
+ return real === root || real.startsWith(root + path.sep);
120
+ }
121
+ /** True when `agents/<name>/` stays inside the project agents directory and does not overlap another agent's protected paths. */
122
+ export function ownAgentDirAllowed(project, agent) {
123
+ const dir = projectAgentDir(project, agent.name);
124
+ return staysInside(dir, project.paths.agentsDir) && !overlapsForeign(project, agent, dir);
125
+ }
126
+ /** True when `shared/common/` does not overlap another agent's persona, memory, inbox or outbox. */
127
+ export function projectCommonDirAllowed(project, agent) {
128
+ return !overlapsForeign(project, agent, projectCommonDir(project));
129
+ }
79
130
  export function writePolicy(project, agent) {
80
131
  const isLead = agent.name === project.lead && !project.solo;
81
132
  const ws = project.workspaces?.[agent.name];
@@ -83,12 +134,15 @@ export function writePolicy(project, agent) {
83
134
  const shared = sharedDir(project);
84
135
  const sharedIdx = sharedIndex(project);
85
136
  const mayWriteShared = !!shared && agent.name === project.lead;
137
+ const ownDir = projectAgentDir(project, agent.name);
138
+ const grantOwnDir = ownAgentDirAllowed(project, agent);
86
139
  const allowDirs = [
87
140
  ...memoryDirs(agent),
88
141
  ...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]),
89
142
  ...(ws ? [ws.gitDir] : []),
90
143
  ...(shared && mayWriteShared ? [shared] : []),
91
144
  ...(isLead ? [recordsDir(project)] : []),
145
+ ...(grantOwnDir ? [ownDir] : []),
92
146
  // Spec 25: canonical, because the OS sandbox compares real paths (`/tmp` is `/private/tmp` on macOS).
93
147
  ...(agent.sandbox?.write ?? []).map(canonicalPath),
94
148
  ];
@@ -104,8 +158,9 @@ export function writePolicy(project, agent) {
104
158
  }
105
159
  const deny = [];
106
160
  const allowedFileSet = new Set(allowFiles);
161
+ const grantedRoots = [...(grantOwnDir ? [ownDir] : [])];
107
162
  const addDeny = (p) => {
108
- if (!allowedFileSet.has(p))
163
+ if (!allowedFileSet.has(p) && !grantedRoots.some((d) => covers(d, p)))
109
164
  deny.push(p);
110
165
  };
111
166
  for (const a of Object.values(project.agents)) {
@@ -230,7 +285,11 @@ function sandboxNotes(agent, os) {
230
285
  }
231
286
  /** Can `agent` legitimately change this protected file? */
232
287
  export function mayEditProtected(project, agent, file) {
233
- return writePolicy(project, agent).allowFiles.includes(file);
288
+ const pol = writePolicy(project, agent);
289
+ if (pol.allowFiles.includes(file))
290
+ return true;
291
+ const roots = [projectAgentDir(project, agent.name)].filter((d) => pol.allowDirs.includes(d));
292
+ return roots.some((d) => file === d || file.startsWith(d + path.sep));
234
293
  }
235
294
  const within = (parent, p) => p === parent || p.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
236
295
  /** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
package/dist/prompt.d.ts CHANGED
@@ -20,7 +20,12 @@ export declare const CONTEXT_LIMIT: number;
20
20
  /** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
21
21
  export declare const MAIL_BODY_LIMIT: number;
22
22
  export declare const MAIL_BATCH_LIMIT: number;
23
+ /** Same-thread `reply`/`failure` messages a non-lead agent takes in one wake-up. The lead uses `MAIL_BATCH_LIMIT` and `LEAD_REPLY_CAP` instead. */
23
24
  export declare const LEAD_BATCH_LIMIT = 5;
25
+ /** Most waiting `reply`/`failure` messages the lead takes in one wake-up; headers are not counted in `MAIL_BATCH_LIMIT`, so tiny replies need a count cap too. */
26
+ export declare const LEAD_REPLY_CAP = 20;
27
+ /** A project persona is injected into every wake-up. Cap it, and refuse a FIFO or other non-file, so one write cannot stall the dispatcher. */
28
+ export declare const PERSONA_LIMIT: number;
24
29
  export declare function readPersona(agent: ResolvedAgent): string;
25
30
  /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
26
31
  export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
@@ -28,7 +33,12 @@ export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: R
28
33
  export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string): string;
29
34
  /** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
30
35
  export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
31
- /** 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. */
36
+ /**
37
+ * Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` so the agent decides once.
38
+ * The lead takes every such message that is already waiting and whose body still fits in `MAIL_BATCH_LIMIT`
39
+ * (a later one that would show none of its body stays unread), at most `LEAD_REPLY_CAP`. Other agents take
40
+ * at most `LEAD_BATCH_LIMIT` on the same thread.
41
+ */
32
42
  export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
33
43
  export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number, handoff?: boolean): string;
34
44
  export interface TidyRecordsContext {
package/dist/prompt.js CHANGED
@@ -1,11 +1,10 @@
1
- import fs from "node:fs";
2
1
  import { cutUtf8, readFileCapped } from "./fs-util.js";
3
2
  import path from "node:path";
4
3
  import { REQUIRED_SECTIONS } from "./format.js";
5
4
  import { senderLabel, slotName } from "./instances.js";
6
5
  import { modelForTier } from "./tiers.js";
7
6
  import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
8
- import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
7
+ import { commonFile, memoryDirs, outboxDir, ownAgentDirAllowed, projectAgentDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
9
8
  function memoryLabel(agent, dir) {
10
9
  if (dir === agent.memory.task)
11
10
  return "task (this task only, isolated)";
@@ -49,7 +48,10 @@ export const CONTEXT_LIMIT = RECORDS_BUDGET;
49
48
  /** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
50
49
  export const MAIL_BODY_LIMIT = 16 * 1024;
51
50
  export const MAIL_BATCH_LIMIT = 64 * 1024;
51
+ /** Same-thread `reply`/`failure` messages a non-lead agent takes in one wake-up. The lead uses `MAIL_BATCH_LIMIT` and `LEAD_REPLY_CAP` instead. */
52
52
  export const LEAD_BATCH_LIMIT = 5;
53
+ /** Most waiting `reply`/`failure` messages the lead takes in one wake-up; headers are not counted in `MAIL_BATCH_LIMIT`, so tiny replies need a count cap too. */
54
+ export const LEAD_REPLY_CAP = 20;
53
55
  const BATCHABLE = new Set(["reply", "failure"]);
54
56
  const readCapped = readFileCapped;
55
57
  /** A PR only needs to say agent-lyceum opened it; no agent or role names. */
@@ -58,8 +60,14 @@ const PR_ATTRIBUTION_RULE = "- If you open a pull request, end its description w
58
60
  const NO_DAEMONS_RULE = "- Never start a server, daemon or watcher (pm2, `serve`, `dev`, `--watch`) in the foreground of a command, and never leave one running. Run it in the background, bound every such command with `timeout 60`, stop what you started before you finish, and if it cannot start (e.g. a sandbox denies binding a port) report that as unverified instead of retrying.";
59
61
  /** Only the lead (or a solo agent, the lead of a team of one) can send `done`, so only it can ask for a pull request. */
60
62
  const OPEN_PR_RULE = "Add the frontmatter line `open_pr: true` to `done` only when the user's task explicitly asks for a pull request; otherwise leave it out. It only has an effect when the run ends `completed` with changes on its own branch, and agent-lyceum (not you) then pushes the branch and opens the pull request. Keep agent and role names out of the report, because it becomes the pull request description.";
63
+ /** A project persona is injected into every wake-up. Cap it, and refuse a FIFO or other non-file, so one write cannot stall the dispatcher. */
64
+ export const PERSONA_LIMIT = 64 * 1024;
61
65
  export function readPersona(agent) {
62
- return fs.readFileSync(agent.agentMd, "utf8").trim();
66
+ const read = readFileCapped(agent.agentMd, PERSONA_LIMIT);
67
+ if (!read)
68
+ return "";
69
+ const text = read.text.trim();
70
+ return read.truncated ? `${text}\n\n[persona truncated at ${PERSONA_LIMIT} bytes]` : text;
63
71
  }
64
72
  /** What the lead (or a solo agent) is told about the handoff notes: they are what a reopened task starts from. */
65
73
  function handoffRule(dir) {
@@ -69,6 +77,32 @@ function handoffRule(dir) {
69
77
  `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.`,
70
78
  ];
71
79
  }
80
+ function extraWritableDirs(project, agent) {
81
+ if (project.solo === "call")
82
+ return [];
83
+ return ownAgentDirAllowed(project, agent) ? [projectAgentDir(project, agent.name)] : [];
84
+ }
85
+ function extraWriteClause(dirs) {
86
+ return dirs.length ? ` You may also write ${dirs.map((d) => `\`${d}/\``).join(" and ")}.` : "";
87
+ }
88
+ function writeScopeRules(project, agent) {
89
+ const isLead = agent.name === project.lead && !project.solo;
90
+ const ownDir = projectAgentDir(project, agent.name);
91
+ const mayWriteOwn = project.solo !== "call" && ownAgentDirAllowed(project, agent);
92
+ const ownPersona = mayWriteOwn && (agent.agentMd === path.join(ownDir, "AGENT.md") || agent.agentMd.startsWith(ownDir + path.sep));
93
+ const records = `decisions in \`${recordsKindDir(project, "decisions")}/\` and lessons in \`${recordsKindDir(project, "lessons")}/\`. 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\`.`;
94
+ return [
95
+ isLead
96
+ ? `- You keep the shared team context \`${commonFile(project)}\` (you maintain it; keep it short) and two kinds of durable records for every future run of this project, one Markdown file per entry: ${records} Add or update entries before you send \`done\`.`
97
+ : `- The shared team context is in \`${commonFile(project)}\` (read-only). Project records are ${records} Both are read-only.`,
98
+ ...(mayWriteOwn ? [`- You may write your project agent directory \`${ownDir}/\`. Creating \`AGENT.md\` there makes it this project's persona on the next run; the global persona file stays read-only.`] : []),
99
+ ...(ownPersona ? [`- You may edit your project persona \`${agent.agentMd}\`.`] : []),
100
+ ...(agent.canEditAgentMd
101
+ ? ["- You may edit every `AGENT.md` and the repository's `CLAUDE.md`/`AGENTS.md`; those changes are logged."]
102
+ : ["- Never edit another agent's AGENT.md, a global `AGENT.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`."]),
103
+ "- Never touch another agent's memory, inbox or outbox, or the configuration files of agent-lyceum (`project.yaml`, `team.yaml`, `runs/`).",
104
+ ];
105
+ }
72
106
  /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
73
107
  export function buildSoloSystemPrompt(project, agent) {
74
108
  const isCall = project.solo === "call";
@@ -89,9 +123,10 @@ export function buildSoloSystemPrompt(project, agent) {
89
123
  for (const m of mems)
90
124
  lines.push(`- ${memoryLabel(agent, m)}: \`${m}\``);
91
125
  if (mems.length) {
92
- 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; you can write only inside these directories.", "Use memory only for durable lessons that stay true across tasks; never record task-specific details there.");
126
+ const extra = extraWritableDirs(project, agent);
127
+ 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.${extraWriteClause(extra)}`, "Use memory only for durable lessons that stay true across tasks; never record task-specific details there.");
93
128
  }
94
- lines.push("", "## Rules", "- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`.", "- Never touch another agent's memory, inbox or outbox, or the configuration files of agent-lyceum.", PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
129
+ lines.push("", "## Rules", ...writeScopeRules(project, agent), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
95
130
  return lines.join("\n");
96
131
  }
97
132
  /** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
@@ -175,17 +210,13 @@ export function buildSystemPrompt(project, agent) {
175
210
  lines.push(`- ${label}: \`${m}\``);
176
211
  }
177
212
  if (mems.length) {
178
- 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
213
+ const extra = extraWritableDirs(project, agent);
214
+ 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.${extraWriteClause(extra)}`, "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
179
215
  ? ["You cannot write the shared memory: put lessons worth keeping in your reply to the lead."]
180
216
  : ["Use the global/project memory only for durable lessons that stay true across tasks; never record task-specific details there."]));
181
217
  }
182
218
  lines.push("", "## Rules");
183
- lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
184
- (isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`, ...(isLead
185
- ? [
186
- `- 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\`.`,
187
- ]
188
- : []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
219
+ lines.push(...writeScopeRules(project, agent), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
189
220
  let text = lines.join("\n");
190
221
  if (isLead && project.run?.instances)
191
222
  text = `${text}\n\n${PARALLEL_SECTION(project.run.instances.max)}`;
@@ -199,7 +230,18 @@ export function buildSystemPrompt(project, agent) {
199
230
  }
200
231
  return text;
201
232
  }
202
- /** 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. */
233
+ /** Bytes of `body` this wake-up would actually show, using the same caps as the prompt. */
234
+ function shownBodyBytes(body, remaining) {
235
+ if (remaining <= 0)
236
+ return 0;
237
+ return cutUtf8(Buffer.from(body, "utf8"), Math.min(MAIL_BODY_LIMIT, remaining)).length;
238
+ }
239
+ /**
240
+ * Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` so the agent decides once.
241
+ * The lead takes every such message that is already waiting and whose body still fits in `MAIL_BATCH_LIMIT`
242
+ * (a later one that would show none of its body stays unread), at most `LEAD_REPLY_CAP`. Other agents take
243
+ * at most `LEAD_BATCH_LIMIT` on the same thread.
244
+ */
203
245
  export function pickMessages(project, agent, queue) {
204
246
  const sorted = [...queue].sort((a, b) => path.basename(a.file).localeCompare(path.basename(b.file)));
205
247
  const first = sorted[0];
@@ -207,12 +249,20 @@ export function pickMessages(project, agent, queue) {
207
249
  return sorted.slice(0, 1);
208
250
  const isLead = agent.name === project.lead;
209
251
  const run = [];
252
+ let remaining = MAIL_BATCH_LIMIT;
210
253
  for (const m of sorted) {
211
- if (!BATCHABLE.has(m.meta.type) || run.length >= LEAD_BATCH_LIMIT)
254
+ if (!BATCHABLE.has(m.meta.type))
255
+ break;
256
+ if (run.length >= (isLead ? LEAD_REPLY_CAP : LEAD_BATCH_LIMIT))
212
257
  break;
213
258
  if (!isLead && m.meta.thread !== first.meta.thread)
214
259
  break;
260
+ const shown = isLead ? shownBodyBytes(m.body, remaining) : 0;
261
+ if (isLead && Buffer.byteLength(m.body, "utf8") > 0 && shown === 0)
262
+ break;
215
263
  run.push(m);
264
+ if (isLead)
265
+ remaining -= shown;
216
266
  }
217
267
  return run;
218
268
  }
@@ -0,0 +1,2 @@
1
+ /** One local CLI call. The prompt already says what to write; this only runs it. */
2
+ export declare function summarizeProgress(runtime: "claude" | "codex", prompt: string): Promise<string>;
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ const TIMEOUT_MS = 90_000;
6
+ const CAP = 200_000;
7
+ /** One local CLI call. The prompt already says what to write; this only runs it. */
8
+ export async function summarizeProgress(runtime, prompt) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lyceum-summary-"));
10
+ try {
11
+ const text = runtime === "claude" ? await claude(dir, prompt) : await codex(dir, prompt);
12
+ const trimmed = text.trim();
13
+ if (!trimmed)
14
+ throw new Error("沒有寫出摘要。");
15
+ return trimmed;
16
+ }
17
+ finally {
18
+ fs.rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ }
21
+ async function claude(dir, prompt) {
22
+ const settings = path.join(dir, "settings.json");
23
+ fs.writeFileSync(settings, JSON.stringify({
24
+ disableAllHooks: true,
25
+ permissions: { deny: ["Bash", "Edit", "Write", "NotebookEdit", "WebSearch", "WebFetch", "Agent", "Read", "Glob", "Grep"] },
26
+ }));
27
+ const result = await run("claude", ["-p", "--no-session-persistence", "--output-format", "text", "--permission-mode", "dontAsk", "--settings", settings], prompt, dir);
28
+ if (!result.stdout.trim())
29
+ throw new Error(first(result.stderr) || "claude 沒有寫出摘要。");
30
+ return result.stdout;
31
+ }
32
+ async function codex(dir, prompt) {
33
+ const last = path.join(dir, "last.txt");
34
+ const result = await run("codex", ["exec", "-C", dir, "-s", "read-only", "--skip-git-repo-check", "-c", 'approval_policy="never"', "-o", last, "-"], prompt, dir);
35
+ let text = "";
36
+ try {
37
+ text = fs.readFileSync(last, "utf8");
38
+ }
39
+ catch {
40
+ text = "";
41
+ }
42
+ if (!text.trim())
43
+ text = result.stdout;
44
+ if (!text.trim())
45
+ throw new Error(first(result.stderr) || "codex 沒有寫出摘要。");
46
+ return text;
47
+ }
48
+ function first(text) {
49
+ return text.split("\n").map((line) => line.trim()).find(Boolean) ?? "";
50
+ }
51
+ function run(cmd, args, stdin, cwd) {
52
+ return new Promise((resolve, reject) => {
53
+ const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
54
+ let stdout = "";
55
+ let stderr = "";
56
+ let settled = false;
57
+ const finish = (error) => {
58
+ if (settled)
59
+ return;
60
+ settled = true;
61
+ clearTimeout(timer);
62
+ if (error)
63
+ reject(error);
64
+ else
65
+ resolve({ stdout, stderr });
66
+ };
67
+ const timer = setTimeout(() => {
68
+ child.kill();
69
+ finish(new Error(`${cmd} 超過 ${Math.round(TIMEOUT_MS / 1000)} 秒沒有回應。`));
70
+ }, TIMEOUT_MS);
71
+ child.stdout.on("data", (chunk) => {
72
+ stdout = (stdout + String(chunk)).slice(0, CAP);
73
+ });
74
+ child.stderr.on("data", (chunk) => {
75
+ stderr = (stderr + String(chunk)).slice(0, CAP);
76
+ });
77
+ child.on("error", (error) => {
78
+ const code = error.code;
79
+ finish(code === "ENOENT" ? new Error(`找不到 ${cmd}。`) : error);
80
+ });
81
+ child.on("close", () => finish());
82
+ child.stdin.write(stdin);
83
+ child.stdin.end();
84
+ });
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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",
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:Futura, "Century Gothic", "Avenir Next", "Trebuchet MS", sans-serif;--font-serif:"Hoefler Text", "Iowan Old Style", Baskerville, "Palatino Linotype", serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-bold:700;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink:#1a2330;--color-paper:#f7f8f9;--color-wall:#e4e7ea;--color-signal:#0f6f6a;--color-working:#0f6f6a;--color-failed:#8e3434;--color-line:#c5cad1}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.top-0{top:0}.-left-8{left:calc(var(--spacing) * -8)}.left-1\/2{left:50%}.z-10{z-index:10}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-auto{margin-top:auto}.ml-3{margin-left:calc(var(--spacing) * 3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-dvh{height:100dvh}.h-full{height:100%}.min-h-0{min-height:0}.w-2\/3{width:66.6667%}.w-20{width:calc(var(--spacing) * 20)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-36{min-width:calc(var(--spacing) * 36)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[7rem_minmax\(0\,1fr\)\]{grid-template-columns:7rem minmax(0,1fr)}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-1{row-gap:var(--spacing)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:2147483647px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-failed{border-color:var(--color-failed)}.border-ink{border-color:var(--color-ink)}.border-ink\/60{border-color:#1a233099}@supports (color:color-mix(in lab, red, red)){.border-ink\/60{border-color:color-mix(in oklab, var(--color-ink) 60%, transparent)}}.border-line{border-color:var(--color-line)}.border-signal{border-color:var(--color-signal)}.border-transparent{border-color:#0000}.border-l-ink{border-left-color:var(--color-ink)}.border-l-signal{border-left-color:var(--color-signal)}.bg-ink{background-color:var(--color-ink)}.bg-ink\/30{background-color:#1a23304d}@supports (color:color-mix(in lab, red, red)){.bg-ink\/30{background-color:color-mix(in oklab, var(--color-ink) 30%, transparent)}}.bg-line{background-color:var(--color-line)}.bg-paper{background-color:var(--color-paper)}.bg-signal{background-color:var(--color-signal)}.bg-wall{background-color:var(--color-wall)}.fill-failed{fill:var(--color-failed)}.fill-ink{fill:var(--color-ink)}.fill-none{fill:none}.stroke-line{stroke:var(--color-line)}.stroke-working{stroke:var(--color-working)}.p-0{padding:0}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pt-2{padding-top:calc(var(--spacing) * 2)}.inset-safe{padding-bottom:env(safe-area-inset-bottom);padding-left:env(safe-area-inset-left)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-left{text-align:left}.font-serif{font-family:var(--font-serif)}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.text-balance{text-wrap:balance}.text-pretty{text-wrap:pretty}.wrap-break-word{overflow-wrap:break-word}.whitespace-pre-wrap{white-space:pre-wrap}.text-failed{color:var(--color-failed)}.text-ink{color:var(--color-ink)}.text-ink\/50{color:#1a233080}@supports (color:color-mix(in lab, red, red)){.text-ink\/50{color:color-mix(in oklab, var(--color-ink) 50%, transparent)}}.text-ink\/60{color:#1a233099}@supports (color:color-mix(in lab, red, red)){.text-ink\/60{color:color-mix(in oklab, var(--color-ink) 60%, transparent)}}.text-ink\/70{color:#1a2330b3}@supports (color:color-mix(in lab, red, red)){.text-ink\/70{color:color-mix(in oklab, var(--color-ink) 70%, transparent)}}.text-ink\/80{color:#1a2330cc}@supports (color:color-mix(in lab, red, red)){.text-ink\/80{color:color-mix(in oklab, var(--color-ink) 80%, transparent)}}.text-signal{color:var(--color-signal)}.text-working{color:var(--color-working)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.\!running{animation-play-state:running!important}.enter{--tw-ease:ease-out}.fade-in{--tw-enter-opacity:0}.running{animation-play-state:running}.group-data-\[open\]\:hidden:is(:where(.group)[data-open] *){display:none}.group-data-\[open\]\:inline:is(:where(.group)[data-open] *){display:inline}.first\:mt-0:first-child{margin-top:0}@media (hover:hover){.hover\:bg-paper\/60:hover{background-color:#f7f8f999}@supports (color:color-mix(in lab, red, red)){.hover\:bg-paper\/60:hover{background-color:color-mix(in oklab, var(--color-paper) 60%, transparent)}}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-ink:focus-visible{outline-color:var(--color-ink)}.disabled\:opacity-60:disabled{opacity:.6}@media (width>=40rem){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (width>=48rem){.md\:block{display:block}.md\:h-dvh{height:100dvh}.md\:min-w-0{min-width:0}.md\:grid-cols-\[15rem_minmax\(0\,1fr\)\]{grid-template-columns:15rem minmax(0,1fr)}.md\:flex-col{flex-direction:column}.md\:overflow-y-auto{overflow-y:auto}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}@media (width>=64rem){.lg\:sticky{position:sticky}.lg\:top-20{top:calc(var(--spacing) * 20)}.lg\:max-h-\[calc\(100dvh-5rem\)\]{max-height:calc(100dvh - 5rem)}.lg\:grid-cols-\[minmax\(0\,1fr\)_18rem\]{grid-template-columns:minmax(0,1fr) 18rem}.lg\:overflow-y-auto{overflow-y:auto}.lg\:p-8{padding:calc(var(--spacing) * 8)}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}body{font-family:var(--font-sans);color:var(--color-ink);background:var(--color-wall);margin:0}@media (prefers-reduced-motion:reduce){.enter{animation:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}