agent-lyceum 0.25.1 → 0.27.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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/README.zh-TW.md +6 -2
  3. package/dist/call.js +2 -0
  4. package/dist/config.d.ts +29 -1
  5. package/dist/config.js +34 -3
  6. package/dist/doctor-instances.d.ts +4 -0
  7. package/dist/doctor-instances.js +63 -0
  8. package/dist/doctor.d.ts +2 -0
  9. package/dist/doctor.js +17 -1
  10. package/dist/duplicate-notices.d.ts +9 -0
  11. package/dist/duplicate-notices.js +34 -0
  12. package/dist/fallback.d.ts +4 -2
  13. package/dist/fallback.js +11 -4
  14. package/dist/guard.d.ts +17 -1
  15. package/dist/guard.js +37 -6
  16. package/dist/instance-containment.d.ts +15 -0
  17. package/dist/instance-containment.js +36 -0
  18. package/dist/instances.d.ts +183 -0
  19. package/dist/instances.js +268 -0
  20. package/dist/mailbox.d.ts +46 -2
  21. package/dist/mailbox.js +121 -36
  22. package/dist/message-store.d.ts +47 -1
  23. package/dist/message-store.js +75 -7
  24. package/dist/paths.d.ts +7 -0
  25. package/dist/paths.js +27 -2
  26. package/dist/policy.d.ts +1 -0
  27. package/dist/policy.js +2 -2
  28. package/dist/prompt.d.ts +11 -0
  29. package/dist/prompt.js +66 -10
  30. package/dist/run-session.d.ts +35 -2
  31. package/dist/run-session.js +337 -20
  32. package/dist/run-store.d.ts +17 -3
  33. package/dist/run-store.js +17 -7
  34. package/dist/scaffold.d.ts +3 -0
  35. package/dist/scaffold.js +10 -1
  36. package/dist/schema.d.ts +19 -0
  37. package/dist/schema.js +12 -0
  38. package/dist/status.d.ts +7 -0
  39. package/dist/status.js +74 -15
  40. package/dist/tiers.d.ts +27 -0
  41. package/dist/tiers.js +49 -0
  42. package/dist/validate.js +28 -1
  43. package/dist/worktree.d.ts +6 -0
  44. package/dist/worktree.js +16 -2
  45. package/package.json +1 -1
@@ -1,3 +1,6 @@
1
+ /** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
2
+ export declare const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
3
+ export declare const GLOBAL_TEAM_YAML = "# Global agent library. Projects pick agents from here by name and may override any field.\n# Relative paths here are resolved against this folder. \"~\" is allowed.\nagents:\n lead:\n runtime: claude-code # claude-code | codex\n agent_md: agents/lead/AGENT.md\n memory: { global: agents/lead/memory }\n # model: opus # most capable model: keep it for the lead\n fe-member:\n runtime: claude-code\n agent_md: agents/fe-member/AGENT.md\n memory: { global: agents/fe-member/memory }\n # model: sonnet # cheaper model for routine work\n qa-member:\n runtime: claude-code\n agent_md: agents/qa-member/AGENT.md\n memory: { global: agents/qa-member/memory }\n # model: sonnet # cheaper model for routine work\n # Model hints (comments only):\n # - a read-only search/explorer role suits \"model: haiku\", the cheapest tier (the alias resolves per provider; on the Anthropic API it is claude-haiku-5-5).\n # - a reviewer can run on the other runtime (\"runtime: codex\"); leave the codex model unset to use the CLI default.\n";
1
4
  export interface InitResult {
2
5
  home: string;
3
6
  created: string[];
package/dist/scaffold.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { assertName, homePaths, projectPaths } from "./paths.js";
4
+ /** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
5
+ export const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
4
6
  const PERSONAS = {
5
7
  lead: `# Lead
6
8
 
@@ -11,6 +13,7 @@ You coordinate the team. You receive the user's task, break it into concrete pie
11
13
  - Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
12
14
  - When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
13
15
  - Teammates cannot message each other by default; route everything through yourself.
16
+ - ${LEAD_PARALLEL_HINT}
14
17
  - When the whole job is verified and finished, send a \`done\` message summarising what was delivered.
15
18
  `,
16
19
  "fe-member": `# Frontend member
@@ -30,21 +33,27 @@ You verify work: run and write tests, reproduce bugs, and review changes against
30
33
  - Besides the standard reply headings, add a \`## Verdict\` section containing \`pass\` or \`fail\`, and put your prioritised list of issues under Open items.
31
34
  `,
32
35
  };
33
- const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
36
+ export const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
34
37
  # Relative paths here are resolved against this folder. "~" is allowed.
35
38
  agents:
36
39
  lead:
37
40
  runtime: claude-code # claude-code | codex
38
41
  agent_md: agents/lead/AGENT.md
39
42
  memory: { global: agents/lead/memory }
43
+ # model: opus # most capable model: keep it for the lead
40
44
  fe-member:
41
45
  runtime: claude-code
42
46
  agent_md: agents/fe-member/AGENT.md
43
47
  memory: { global: agents/fe-member/memory }
48
+ # model: sonnet # cheaper model for routine work
44
49
  qa-member:
45
50
  runtime: claude-code
46
51
  agent_md: agents/qa-member/AGENT.md
47
52
  memory: { global: agents/qa-member/memory }
53
+ # model: sonnet # cheaper model for routine work
54
+ # Model hints (comments only):
55
+ # - a read-only search/explorer role suits "model: haiku", the cheapest tier (the alias resolves per provider; on the Anthropic API it is claude-haiku-5-5).
56
+ # - a reviewer can run on the other runtime ("runtime: codex"); leave the codex model unset to use the CLI default.
48
57
  `;
49
58
  export function initHome(home) {
50
59
  const hp = homePaths(home);
package/dist/schema.d.ts CHANGED
@@ -108,11 +108,23 @@ export declare const DispatcherPartial: z.ZodObject<{
108
108
  }>>;
109
109
  }, z.core.$strict>>>;
110
110
  fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
111
+ max_instances: z.ZodOptional<z.ZodNumber>;
111
112
  }, z.core.$strict>;
112
113
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
113
114
  export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
114
115
  export declare const REMOVED_AGENT_KEYS: readonly ["owns"];
116
+ /** One runtime's model ladder, lowest first; `[]` = no ladder for that runtime (overrides team.yaml). */
117
+ export declare const ModelTiersPartial: z.ZodObject<{
118
+ "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
119
+ codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
120
+ }, z.core.$strict>;
121
+ export declare const TIERS: readonly ["low", "mid", "high"];
122
+ export type Tier = (typeof TIERS)[number];
115
123
  export declare const GlobalConfig: z.ZodObject<{
124
+ model_tiers: z.ZodOptional<z.ZodObject<{
125
+ "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
126
+ codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
127
+ }, z.core.$strict>>;
116
128
  agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
117
129
  runtime: z.ZodOptional<z.ZodEnum<{
118
130
  "claude-code": "claude-code";
@@ -164,6 +176,10 @@ export declare const ProjectConfig: z.ZodObject<{
164
176
  team: z.ZodObject<{
165
177
  lead: z.ZodString;
166
178
  }, z.core.$strict>;
179
+ model_tiers: z.ZodOptional<z.ZodObject<{
180
+ "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
181
+ codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
182
+ }, z.core.$strict>>;
167
183
  dispatcher: z.ZodDefault<z.ZodObject<{
168
184
  max_rounds: z.ZodOptional<z.ZodNumber>;
169
185
  max_parallel: z.ZodOptional<z.ZodUnknown>;
@@ -189,6 +205,7 @@ export declare const ProjectConfig: z.ZodObject<{
189
205
  }>>;
190
206
  }, z.core.$strict>>>;
191
207
  fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
208
+ max_instances: z.ZodOptional<z.ZodNumber>;
192
209
  }, z.core.$strict>>;
193
210
  agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
194
211
  runtime: z.ZodOptional<z.ZodEnum<{
@@ -245,6 +262,8 @@ export interface DispatcherSettings {
245
262
  log_max_bytes: number;
246
263
  /** How long a primary that hit rate_limit/quota is skipped within a run, unless the CLI says when to come back. */
247
264
  fallback_cooldown_sec: number;
265
+ /** Most instances of one role at a time. Not in `DISPATCHER_DEFAULTS`, so `doctor --resolved` lists it only when written; the default lives in `instances.ts`. */
266
+ max_instances?: number;
248
267
  }
249
268
  export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
250
269
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
package/dist/schema.js CHANGED
@@ -65,13 +65,24 @@ export const DispatcherPartial = z
65
65
  log_max_bytes: z.number().int().min(0).optional(),
66
66
  fallback: z.array(FallbackEntryPartial).optional(),
67
67
  fallback_cooldown_sec: z.number().int().min(0).optional(),
68
+ /** Most instances of one role at a time (spec 23); 1 turns parallel tasks off. Unset = `DEFAULT_MAX_INSTANCES`. */
69
+ max_instances: z.number().int().min(1).max(8).optional(),
68
70
  })
69
71
  .strict();
70
72
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
71
73
  export const REMOVED_DISPATCHER_KEYS = ["max_parallel", "max_concurrent_runs", "workspace_mode"];
72
74
  export const REMOVED_AGENT_KEYS = ["owns"];
75
+ /** One runtime's model ladder, lowest first; `[]` = no ladder for that runtime (overrides team.yaml). */
76
+ export const ModelTiersPartial = z
77
+ .object({
78
+ "claude-code": z.array(z.string().trim().min(1)).optional(),
79
+ codex: z.array(z.string().trim().min(1)).optional(),
80
+ })
81
+ .strict();
82
+ export const TIERS = ["low", "mid", "high"];
73
83
  export const GlobalConfig = z
74
84
  .object({
85
+ model_tiers: ModelTiersPartial.optional(),
75
86
  agents: z.record(z.string(), AgentPartial).default({}),
76
87
  })
77
88
  .strict();
@@ -79,6 +90,7 @@ export const ProjectConfig = z
79
90
  .object({
80
91
  dir: z.string(),
81
92
  team: z.object({ lead: z.string() }).strict(),
93
+ model_tiers: ModelTiersPartial.optional(),
82
94
  dispatcher: DispatcherPartial.default({}),
83
95
  agents: z.record(z.string(), AgentPartial).default({}),
84
96
  })
package/dist/status.d.ts CHANGED
@@ -48,6 +48,8 @@ export declare function cacheText(read: number | undefined, write: number | unde
48
48
  export declare function progressBar(done: number, total: number, width?: number, c?: Paint): string;
49
49
  export interface QueueItem {
50
50
  agent: string;
51
+ /** Mail waiting for a free instance of the role in `agent` (spec 23.7): not woken yet, placed by the dispatcher. Only for roles with instances. */
52
+ held?: true;
51
53
  from: string;
52
54
  type: string;
53
55
  subject: string;
@@ -105,6 +107,8 @@ export interface RunReport {
105
107
  ok: boolean;
106
108
  error?: string;
107
109
  }>;
110
+ /** Only when the run has same-role instances: each role's open instances, the role itself first. */
111
+ instances?: Record<string, string[]>;
108
112
  /** Present while the run waits for the user: the file to fill in and how many questions are still open. */
109
113
  ask?: {
110
114
  path: string;
@@ -116,6 +120,9 @@ export interface AgentReport {
116
120
  name: string;
117
121
  runtime?: string;
118
122
  lead: boolean;
123
+ /** Only for the rows of a role that has instances (the role itself is instance 1): the role and the instance number. */
124
+ role?: string;
125
+ instance?: number;
119
126
  unread: number;
120
127
  /** Who this agent may send mail to (`all` expanded, itself left out). */
121
128
  can_message: string[];
package/dist/status.js CHANGED
@@ -5,7 +5,9 @@ import { RESULT_FILE } from "./dispatcher.js";
5
5
  import { bindRunProject, loadRunState, outcomeOf, roundsLabel, runLabel } from "./run-store.js";
6
6
  import { candidatesFor } from "./fallback.js";
7
7
  import { briefOf, digestOf } from "./format.js";
8
- import { listUnread } from "./mailbox.js";
8
+ import { PENDING_DIR, listUnread, readMessageNoFollow } from "./mailbox.js";
9
+ import { parseSlotName } from "./instances.js";
10
+ import { inboxDir } from "./policy.js";
9
11
  import { tidyAdvice } from "./memory-tidy.js";
10
12
  import { inspectRunLock, lockHolderAlive } from "./project-lock.js";
11
13
  /** The run to show: the newest one still running (a resumed run keeps its old id, so newer runs may sit above it), else the newest run. */
@@ -154,7 +156,7 @@ function stepsLine(steps, c, finished = false) {
154
156
  return `目前:${clip(cur.text, 60)}`;
155
157
  }
156
158
  /** Order the dispatcher will wake agents: the lead first, otherwise the oldest mail. */
157
- function queueOf(project) {
159
+ function queueOf(project, instances) {
158
160
  const rows = Object.values(project.agents)
159
161
  .map((a) => ({ agent: a.name, msg: listUnread(project, a.name)[0] }))
160
162
  .flatMap((r) => (r.msg ? [{ agent: r.agent, msg: r.msg }] : []));
@@ -163,7 +165,7 @@ function queueOf(project) {
163
165
  return x.agent === project.lead ? -1 : 1;
164
166
  return path.basename(x.msg.file).localeCompare(path.basename(y.msg.file));
165
167
  });
166
- return rows.map((r) => ({
168
+ const items = rows.map((r) => ({
167
169
  agent: r.agent,
168
170
  from: r.msg.meta.from,
169
171
  type: r.msg.meta.type,
@@ -171,14 +173,44 @@ function queueOf(project) {
171
173
  brief: briefOf(r.msg.body),
172
174
  digest: digestOf(r.msg.meta.type, r.msg.body),
173
175
  }));
176
+ return [...items, ...heldQueue(project, instances)];
174
177
  }
178
+ /** Mail the assigner still holds for roles with instances, oldest first per role. Unreadable files are skipped. */
179
+ function heldQueue(project, instances) {
180
+ const out = [];
181
+ for (const role of Object.keys(instances?.roles ?? {}).sort()) {
182
+ const dir = path.join(inboxDir(project, role), PENDING_DIR);
183
+ let files = [];
184
+ try {
185
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).sort();
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ for (const f of files) {
191
+ try {
192
+ const m = readMessageNoFollow(path.join(dir, f));
193
+ out.push({ agent: role, held: true, from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) });
194
+ }
195
+ catch {
196
+ /* a half-written file: the next poll sees it */
197
+ }
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+ /** How an agent is named on the text pages: an instance of a role shows as `role #k`. */
203
+ const shownName = (name, slots) => {
204
+ const i = parseSlotName(name);
205
+ return i && slots?.[i.role]?.[i.k - 1] === name ? `${i.role} #${i.k}` : name;
206
+ };
175
207
  /** One run as `status` sees it. Without `project`, liveness falls back to the recorded pid and there is no queue. */
176
208
  export function buildRunReport(run, project, opts = {}) {
177
209
  const s = run.state;
178
210
  const detail = opts.detail ?? true;
179
211
  const live = runIsAlive(s, project) && !s.end_reason;
180
212
  const o = outcomeOf(s);
181
- const bound = project ? bindRunProject(project, run.dir, s.mail_layout) : undefined;
213
+ const bound = project ? bindRunProject(project, run.dir, s.mail_layout, s.instances) : undefined;
182
214
  const result = path.join(run.dir, RESULT_FILE);
183
215
  const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
184
216
  const cur = s.steps?.find((x) => !x.done);
@@ -221,7 +253,7 @@ export function buildRunReport(run, project, opts = {}) {
221
253
  current_step: cur?.text ?? null,
222
254
  active: s.active,
223
255
  // mail still waiting is what `resume` will pick up, so it is shown for every run that did not finish with a done
224
- queue: detail && bound && s.end_reason !== "done" ? queueOf(bound) : [],
256
+ queue: detail && bound && s.end_reason !== "done" ? queueOf(bound, s.instances) : [],
225
257
  wakes: s.wakes,
226
258
  notes: s.notes ?? [],
227
259
  blocked_integrations: s.blocked_integrations ?? [],
@@ -230,6 +262,7 @@ export function buildRunReport(run, project, opts = {}) {
230
262
  mail_layout: s.mail_layout,
231
263
  workspace_mode: s.workspace_mode,
232
264
  last_wake: s.last_wake,
265
+ ...(s.instances ? { instances: s.instances.roles } : {}),
233
266
  ...(ask ? { ask } : {}),
234
267
  };
235
268
  }
@@ -277,7 +310,7 @@ export function buildStatusReport(base, runId, now = Date.now()) {
277
310
  else
278
311
  run = currentRun(base);
279
312
  // Mail is per run: count the unread mail of the run being shown.
280
- const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
313
+ const project = run ? bindRunProject(base, run.dir, run.state.mail_layout, run.state.instances) : base;
281
314
  const report = run ? buildRunReport(run, base) : undefined;
282
315
  let advice = [];
283
316
  try {
@@ -286,13 +319,21 @@ export function buildStatusReport(base, runId, now = Date.now()) {
286
319
  catch {
287
320
  /* a hint only: never let it break status */
288
321
  }
289
- return {
322
+ const wakes = run?.state.wakes ?? [];
323
+ const below = new Set();
324
+ for (const a of Object.values(project.agents)) {
325
+ const last = wakes.filter((w) => w.agent === a.name).at(-1);
326
+ if (last?.tier && !last.fallback_from && last.runtime === a.runtime && !candidatesFor(a).some((c) => c.primary && c.key === `${last.runtime}/${(last.model ?? "").trim().toLowerCase()}`))
327
+ below.add(a.name);
328
+ }
329
+ const out = {
290
330
  schema_version: 1,
291
331
  project: projectInfo(base),
292
332
  agents: Object.values(project.agents).map((a) => ({
293
333
  name: a.name,
294
334
  runtime: a.runtime,
295
335
  lead: a.name === project.lead,
336
+ ...(a.instance ? { role: a.instance.role, instance: a.instance.k } : {}),
296
337
  unread: listUnread(project, a.name).length,
297
338
  can_message: (a.canMessage === "all" ? Object.keys(project.agents) : a.canMessage).filter((n) => n !== a.name),
298
339
  last_wake: run?.state.last_wake[a.name],
@@ -301,6 +342,8 @@ export function buildStatusReport(base, runId, now = Date.now()) {
301
342
  run: report,
302
343
  ...(advice.length ? { memory_advice: advice } : {}),
303
344
  };
345
+ belowCeiling.set(out, below);
346
+ return out;
304
347
  }
305
348
  /** Every run of the project, newest first. */
306
349
  export function buildTaskListReport(project) {
@@ -309,10 +352,10 @@ export function buildTaskListReport(project) {
309
352
  // ---- text pages ----
310
353
  /** Agents in wake order, newest last; the ones working now are bracketed. */
311
354
  function flowLine(r, c) {
312
- const names = (r.wakes ?? []).map((w) => w.agent);
355
+ const names = (r.wakes ?? []).map((w) => shownName(w.agent, r.instances));
313
356
  const shown = names.slice(-7);
314
357
  const parts = shown.map((n) => c.cyan(n));
315
- const activeNames = r.live ? Object.keys(r.active ?? {}) : [];
358
+ const activeNames = r.live ? Object.keys(r.active ?? {}).map((n) => shownName(n, r.instances)) : [];
316
359
  const out = (names.length > shown.length ? "… → " : "") + parts.join(" → ");
317
360
  if (!activeNames.length)
318
361
  return out || "(尚無)";
@@ -320,7 +363,7 @@ function flowLine(r, c) {
320
363
  }
321
364
  /** The state label of a report: an outcome that was only inferred (old runs) is not shown as a checked result. */
322
365
  const reportLabel = (r) => runStateLabel({ end_reason: r.end_reason, outcome: r.outcome_verified ? r.outcome : undefined }, r.live);
323
- const mailText = (m) => `來自 ${m.from} 的 ${m.type}:「${clip(m.subject, 60)}」`;
366
+ const mailText = (m, slots) => `來自 ${shownName(m.from, slots)} 的 ${m.type}:「${clip(m.subject, 60)}」${m.held ? "(保留中,等待空閒實例)" : ""}`;
324
367
  const taskText = (s) => s.task_summary ?? (s.task_source === "file" ? `檔案 ${s.task_path}` : "文字");
325
368
  /** The readable top block: what is happening, what is next, how far along the task is. */
326
369
  function summaryBlock(run, now, c) {
@@ -338,7 +381,7 @@ function summaryBlock(run, now, c) {
338
381
  const active = Object.entries(s.active ?? {});
339
382
  if (active.length) {
340
383
  for (const [i, [agent, a]] of active.entries()) {
341
- lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(agent)} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`);
384
+ lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(shownName(agent, s.instances))} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`);
342
385
  }
343
386
  }
344
387
  else
@@ -346,7 +389,7 @@ function summaryBlock(run, now, c) {
346
389
  const queue = s.queue;
347
390
  if (queue.length) {
348
391
  for (const [i, q] of queue.slice(0, 3).entries())
349
- lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q)}`);
392
+ lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q, s.instances)}`);
350
393
  if (queue.length > 3)
351
394
  lines.push(c.dim(` … 還有 ${queue.length - 3} 筆`));
352
395
  }
@@ -387,10 +430,10 @@ function describeRun(s, now, c, limit = Infinity) {
387
430
  if (wakes.length > limit)
388
431
  lines.push(c.dim(` … 省略較早的 ${wakes.length - limit} 筆(不帶 --monitor 可看完整紀錄)`));
389
432
  for (const w of limit < wakes.length ? wakes.slice(-limit) : wakes) {
390
- lines.push(` #${w.round} ${c.cyan(w.agent)} ${okText(c, w.ok)} (${Math.round(w.duration_ms / 1000)}s, ${tokens(w.output_tokens)} tok) — ${handlingText(w.handling)}`, ` → ${sentText(w.sent)}`);
433
+ lines.push(` #${w.round} ${c.cyan(shownName(w.agent, s.instances))} ${okText(c, w.ok)} (${Math.round(w.duration_ms / 1000)}s, ${tokens(w.output_tokens)} tok) — ${handlingText(w.handling)}`, ` → ${sentText(w.sent)}`);
391
434
  }
392
435
  for (const [agent, a] of Object.entries(s.active ?? {})) {
393
- lines.push(` #${a.round} ${c.cyan(agent)} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
436
+ lines.push(` #${a.round} ${c.cyan(shownName(agent, s.instances))} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
394
437
  }
395
438
  return lines;
396
439
  }
@@ -479,6 +522,19 @@ function fallbackLines(agents, c) {
479
522
  }
480
523
  return out;
481
524
  }
525
+ /** Agents whose latest wake ran on a tiered model below the ceiling; kept off the report so `status --json` is unchanged. */
526
+ const belowCeiling = new WeakMap();
527
+ /** An agent whose latest wake ran on a model below its ceiling (spec 24.9): one line each, in agent order. */
528
+ function tierLines(report, c) {
529
+ const out = [];
530
+ const wakes = report.run?.wakes ?? [];
531
+ for (const a of report.agents) {
532
+ const last = wakes.filter((w) => w.agent === a.name).at(-1);
533
+ if (last?.tier && last.runtime && belowCeiling.get(report)?.has(a.name))
534
+ out.push(` ${c.cyan(a.name)} ${c.yellow(`tier ${last.tier}`)}(${modelName(`${last.runtime}/${last.model ?? ""}`)})`);
535
+ }
536
+ return out;
537
+ }
482
538
  export function formatStatusReport(report, now = Date.now(), color = false) {
483
539
  const c = paint(color);
484
540
  const run = report.run;
@@ -486,7 +542,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
486
542
  const rows = [header];
487
543
  for (const a of report.agents) {
488
544
  const lw = a.last_wake;
489
- rows.push([a.name + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
545
+ rows.push([(a.instance && a.instance >= 2 && a.role ? `${a.role} #${a.instance}` : a.name) + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
490
546
  }
491
547
  const w = header.map((_, i) => Math.max(...rows.map((r) => cols(r[i] ?? ""))));
492
548
  // Pad on the plain text first so ANSI codes never skew the column widths.
@@ -509,6 +565,9 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
509
565
  const fb = fallbackLines(report.agents, c);
510
566
  if (fb.length)
511
567
  lines.push("", ...fb);
568
+ const tl = tierLines(report, c);
569
+ if (tl.length)
570
+ lines.push(...(fb.length ? [] : [""]), ...tl);
512
571
  if (run)
513
572
  lines.push("", `輸出 tokens:${tokens(run.output_tokens)}${cacheText(run.cache_read_tokens, run.cache_write_tokens) ? ` ${cacheText(run.cache_read_tokens, run.cache_write_tokens)}` : ""}`);
514
573
  if (report.memory_advice?.length)
@@ -0,0 +1,27 @@
1
+ import { type Tier } from "./schema.js";
2
+ export declare function isTier(v: unknown): v is Tier;
3
+ /** Index of `model` on `ladder` (trim + lowercase, like the fallback keys), or -1. */
4
+ export declare function ladderIndex(ladder: readonly string[], model: string | undefined): number;
5
+ /**
6
+ * The tiers an agent can run on: the ladder from its lowest entry up to and including `model` (the ceiling).
7
+ * `undefined` when there is nothing below the ceiling (no ladder, no model, model not on it, or it is the lowest entry).
8
+ */
9
+ export declare function availableTiers(ladder: readonly string[] | undefined, model: string | undefined): string[] | undefined;
10
+ /** `low` = lowest, `high` = ceiling, `mid` = `tiers[floor(n/2)]` (the higher middle when n is even). */
11
+ export declare function modelForTier(tiers: readonly string[], tier: Tier): string;
12
+ export type TierFlag = "absent" | "invalid" | Tier | {
13
+ ignored: "not-lead" | "not-a-task" | "no-tiers";
14
+ };
15
+ /** Judge the `tier` frontmatter of one outgoing mail, in the order of spec 24.5.2. Not wired anywhere yet. */
16
+ export declare function tierFlag(i: {
17
+ value: unknown;
18
+ senderIsLead: boolean;
19
+ type: string;
20
+ recipientTiers?: string[];
21
+ }): TierFlag;
22
+ /** A mail without a valid tier counts as `high` (the ceiling). */
23
+ export declare function effectiveTier(meta: {
24
+ tier?: unknown;
25
+ }): Tier;
26
+ /** The tier a wake runs at: the highest of the batch when every mail has a valid one, else `undefined` (the ceiling). */
27
+ export declare function wakeTier(tiers: readonly unknown[]): Tier | undefined;
package/dist/tiers.js ADDED
@@ -0,0 +1,49 @@
1
+ import { TIERS } from "./schema.js";
2
+ const norm = (m) => (m ?? "").trim().toLowerCase();
3
+ export function isTier(v) {
4
+ return typeof v === "string" && TIERS.includes(v);
5
+ }
6
+ /** Index of `model` on `ladder` (trim + lowercase, like the fallback keys), or -1. */
7
+ export function ladderIndex(ladder, model) {
8
+ const m = norm(model);
9
+ return m ? ladder.findIndex((e) => norm(e) === m) : -1;
10
+ }
11
+ /**
12
+ * The tiers an agent can run on: the ladder from its lowest entry up to and including `model` (the ceiling).
13
+ * `undefined` when there is nothing below the ceiling (no ladder, no model, model not on it, or it is the lowest entry).
14
+ */
15
+ export function availableTiers(ladder, model) {
16
+ if (!ladder?.length)
17
+ return undefined;
18
+ const i = ladderIndex(ladder, model);
19
+ return i >= 1 ? ladder.slice(0, i + 1) : undefined;
20
+ }
21
+ /** `low` = lowest, `high` = ceiling, `mid` = `tiers[floor(n/2)]` (the higher middle when n is even). */
22
+ export function modelForTier(tiers, tier) {
23
+ const n = tiers.length;
24
+ return tiers[tier === "low" ? 0 : tier === "high" ? n - 1 : Math.floor(n / 2)];
25
+ }
26
+ /** Judge the `tier` frontmatter of one outgoing mail, in the order of spec 24.5.2. Not wired anywhere yet. */
27
+ export function tierFlag(i) {
28
+ if (i.value === undefined || i.value === null || i.value === "")
29
+ return "absent";
30
+ if (!i.senderIsLead)
31
+ return { ignored: "not-lead" };
32
+ if (!isTier(i.value))
33
+ return "invalid";
34
+ if (i.type !== "task")
35
+ return { ignored: "not-a-task" };
36
+ if (!i.recipientTiers || i.recipientTiers.length < 2)
37
+ return { ignored: "no-tiers" };
38
+ return i.value;
39
+ }
40
+ /** A mail without a valid tier counts as `high` (the ceiling). */
41
+ export function effectiveTier(meta) {
42
+ return isTier(meta.tier) ? meta.tier : "high";
43
+ }
44
+ /** The tier a wake runs at: the highest of the batch when every mail has a valid one, else `undefined` (the ceiling). */
45
+ export function wakeTier(tiers) {
46
+ if (tiers.length === 0 || !tiers.every(isTier))
47
+ return undefined;
48
+ return TIERS[Math.max(...tiers.map((t) => TIERS.indexOf(t)))];
49
+ }
package/dist/validate.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import { enforcementFor, memoryDirs } from "./policy.js";
3
3
  import { isInside } from "./paths.js";
4
- import { inferRuntime, RUNTIME_EFFORTS } from "./schema.js";
4
+ import { inferRuntime, RUNTIME_EFFORTS, RUNTIMES } from "./schema.js";
5
+ import { ladderIndex } from "./tiers.js";
5
6
  import { must } from "./assert.js";
6
7
  export function validateProject(project) {
7
8
  const issues = [];
@@ -15,7 +16,30 @@ export function validateProject(project) {
15
16
  if (!fs.existsSync(project.dir) || !fs.statSync(project.dir).isDirectory()) {
16
17
  err(`Project dir does not exist: ${project.dir}`);
17
18
  }
19
+ for (const rt of RUNTIMES) {
20
+ const ladder = project.modelTiers[rt];
21
+ if (!ladder)
22
+ continue;
23
+ const seen = new Set();
24
+ ladder.forEach((m, i) => {
25
+ const other = inferRuntime(m);
26
+ if (other !== rt)
27
+ err(`model_tiers.${rt}[${i}]: "${m}" is not a ${rt} model (recognised as ${other ?? "nothing"}). List only ${rt} models, lowest first.`);
28
+ const k = m.trim().toLowerCase();
29
+ if (seen.has(k))
30
+ err(`model_tiers.${rt}: "${m}" is listed twice.`);
31
+ seen.add(k);
32
+ });
33
+ }
18
34
  for (const a of Object.values(project.agents)) {
35
+ const ladder = a.runtime ? project.modelTiers[a.runtime] : undefined;
36
+ if (a.runtime && ladder) {
37
+ if (!a.model)
38
+ warn(`Agent "${a.name}" has no model, so model_tiers.${a.runtime} does not apply to it: it always runs on its runtime's default model.`);
39
+ else if (ladderIndex(ladder, a.model) < 0) {
40
+ err(`Agent "${a.name}": model "${a.model}" is not on model_tiers.${a.runtime} (${ladder.join(", ")}). Add it to the ladder, or set model_tiers.${a.runtime}: [] in project.yaml to turn the ladder off for this project.`);
41
+ }
42
+ }
19
43
  if (!a.runtime)
20
44
  err(`Agent "${a.name}" has no runtime (set runtime, or a recognizable model such as opus/sonnet/haiku/claude-* or gpt-*/codex-*).`);
21
45
  const modelRuntime = inferRuntime(a.model);
@@ -39,6 +63,9 @@ export function validateProject(project) {
39
63
  }
40
64
  }
41
65
  }
66
+ if (Object.keys(project.modelTiers).length > 0 && !Object.values(project.agents).some((a) => a.name !== project.lead && a.tiers)) {
67
+ warn("model_tiers is set, but no member can run below its model (each member's model is the lowest entry of its ladder): tiers have no effect.");
68
+ }
42
69
  // Memory directories must not overlap between agents (or equal each other).
43
70
  const mem = [];
44
71
  for (const a of Object.values(project.agents))
@@ -81,6 +81,7 @@ export declare function snapshotHead(project: ResolvedProject, runId: string): s
81
81
  * `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
82
82
  */
83
83
  export declare function snapshotBase(project: ResolvedProject, runId: string, n: number, from?: AgentWorkspace): string;
84
+ export declare const workspaceRoot: (project: ResolvedProject, runId: string, agent: string) => string;
84
85
  /**
85
86
  * The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
86
87
  * An existing checkout is reused: untouched, when it already is on this snapshot (resume) or holds unfinished
@@ -144,6 +145,11 @@ export declare function integrateAgentChanges(project: ResolvedProject, changes:
144
145
  * Throws when the metadata predates recording the git dir and the git dir cannot be told for certain.
145
146
  */
146
147
  export declare function existingAgentWorkspace(project: ResolvedProject, runId: string, agent: string): AgentWorkspace | undefined;
148
+ /**
149
+ * The git dir recorded in an agent's worktree metadata, read-only (never completes or rewrites legacy metadata). undefined when there
150
+ * is no readable metadata or it predates recording the git dir.
151
+ */
152
+ export declare function recordedGitDir(project: ResolvedProject, runId: string, agent: string): string | undefined;
147
153
  export interface WorkspaceWork {
148
154
  agent: string;
149
155
  root: string;
package/dist/worktree.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { atomicWrite } from "./fs-util.js";
7
+ import { escapesParent } from "./paths.js";
7
8
  const IDENTITY = {
8
9
  GIT_AUTHOR_NAME: "agent-lyceum",
9
10
  GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
@@ -290,7 +291,7 @@ export function snapshotBase(project, runId, n, from) {
290
291
  fs.rmSync(index, { force: true });
291
292
  }
292
293
  }
293
- const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
294
+ export const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
294
295
  /** The user's repo's git common dir, as a real absolute path. Asked of the user's repo, never of a worktree. */
295
296
  const commonDirOf = (top) => fs.realpathSync(git(top, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
296
297
  /** The admin dirs under `<commonDir>/worktrees/` whose `gitdir` file names `<root>/.git` (the file may hold a relative path). */
@@ -474,7 +475,7 @@ export function collectAgentChanges(workspace) {
474
475
  const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(p), target));
475
476
  if (path.posix.isAbsolute(target))
476
477
  violations.push(`${f.path}: symlink to an absolute path (${target})`);
477
- else if (resolved.startsWith(".."))
478
+ else if (escapesParent(resolved, "/"))
478
479
  violations.push(`${f.path}: symlink leaves the project directory (${target})`);
479
480
  }
480
481
  }
@@ -594,6 +595,19 @@ export function existingAgentWorkspace(project, runId, agent) {
594
595
  }
595
596
  return fs.existsSync(root) ? describeWorkspace(project, root, agent, meta) : undefined;
596
597
  }
598
+ /**
599
+ * The git dir recorded in an agent's worktree metadata, read-only (never completes or rewrites legacy metadata). undefined when there
600
+ * is no readable metadata or it predates recording the git dir.
601
+ */
602
+ export function recordedGitDir(project, runId, agent) {
603
+ try {
604
+ const meta = JSON.parse(fs.readFileSync(`${workspaceRoot(project, runId, agent)}.json`, "utf8"));
605
+ return typeof meta.gitDir === "string" && meta.gitDir ? meta.gitDir : undefined;
606
+ }
607
+ catch {
608
+ return undefined;
609
+ }
610
+ }
597
611
  /** What is in an agent's worktree that exists nowhere else: uncommitted changes, and commits whose files are not in the main repo. */
598
612
  export function inspectWorkspace(project, runId, agent, target) {
599
613
  const root = workspaceRoot(project, runId, agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",