agent-lyceum 0.26.0 → 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.
package/dist/paths.js CHANGED
@@ -44,10 +44,13 @@ export function projectPaths(home, project) {
44
44
  taskMemory: path.join(root, "task-memory"),
45
45
  };
46
46
  }
47
+ /** True when a relative path leaves its base: it is `..`, starts with a `..` segment, or is absolute. A name like `..x` is not an escape. */
48
+ export function escapesParent(rel, sep = path.sep) {
49
+ return rel === ".." || rel.startsWith(`..${sep}`) || path.isAbsolute(rel);
50
+ }
47
51
  /** True when `child` is `parent` or inside it. */
48
52
  export function isInside(parent, child) {
49
- const rel = path.relative(parent, child);
50
- return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
53
+ return !escapesParent(path.relative(parent, child));
51
54
  }
52
55
  /**
53
56
  * `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
package/dist/prompt.d.ts CHANGED
@@ -1,8 +1,17 @@
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
+ /** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
5
+ export declare const WAKE_ECONOMY_SECTION: string[];
4
6
  /** 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
7
  export declare const PARALLEL_SECTION: (n: number) => string;
8
+ /** The lead's system prompt ends with this when a member can run a task on a cheaper model (spec 24.10); `members` = [name, model of low, mid, high]. No trailing newline. */
9
+ export declare const TIER_SECTION: (members: {
10
+ name: string;
11
+ low: string;
12
+ mid: string;
13
+ high: string;
14
+ }[]) => string;
6
15
  export declare const COMMON_LIMIT: number;
7
16
  export declare const MEMORY_INDEX_LIMIT: number;
8
17
  export declare const HANDOFF_LIMIT: number;
package/dist/prompt.js CHANGED
@@ -3,6 +3,7 @@ import { cutUtf8, readFileCapped } from "./fs-util.js";
3
3
  import path from "node:path";
4
4
  import { REQUIRED_SECTIONS } from "./format.js";
5
5
  import { senderLabel, slotName } from "./instances.js";
6
+ import { modelForTier } from "./tiers.js";
6
7
  import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
7
8
  import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
8
9
  function memoryLabel(agent, dir) {
@@ -13,6 +14,15 @@ function memoryLabel(agent, dir) {
13
14
  const kind = dir === (agent.memory.global ?? agent.sharedMemory?.global) ? "global (all projects" : "project (all tasks of this project";
14
15
  return shared ? `${kind}, read-only for you: ${instance1} maintains it)` : `${kind})`;
15
16
  }
17
+ /** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
18
+ export const WAKE_ECONOMY_SECTION = [
19
+ "## Keeping wake-ups few",
20
+ "Every wake-up re-reads your whole context (prompt, memory, mail), so the number of wake-ups, yours and your teammates', is the main cost of a run.",
21
+ "- Send all work that is independent of each other in the same wake-up (for example the implementation and the matching docs), each task naming its reviewer, instead of one task per wake-up.",
22
+ "- When a reply arrives while other work you sent is still out and the reply needs nothing from you yet, send no mail: note it in your task memory and end the wake-up. Decide the next step once, when the rest is back; replies waiting together are handed to you in one wake-up.",
23
+ "- Skip the QA layer (the verification role and its reviewer) for a small fix: a change of a few lines that has a reproduction test which failed before and passes after, and an approving code review. Send QA for large changes, changes across modules, and changes to user-visible behaviour.",
24
+ "- Use the most capable (most expensive) model only at three moments: before the plan is final, when the same error shows up a second time, and before you send `done`. In other rounds prefer teammates on cheaper models; read-only search work suits the cheapest one.",
25
+ ];
16
26
  /** 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
27
  export const PARALLEL_SECTION = (n) => [
18
28
  "## Parallel tasks",
@@ -24,6 +34,13 @@ export const PARALLEL_SECTION = (n) => [
24
34
  "- When rate limits hit, fewer instances may start; waiting tasks run when an instance is free.",
25
35
  "- After parallel work is back, have one member type-check and test the combined result before you report `done`.",
26
36
  ].join("\n");
37
+ /** The lead's system prompt ends with this when a member can run a task on a cheaper model (spec 24.10); `members` = [name, model of low, mid, high]. No trailing newline. */
38
+ export const TIER_SECTION = (members) => [
39
+ "## Task tiers",
40
+ "Some members can run a task on a cheaper model than their own. Add the frontmatter line `tier: low`, `tier: mid` or `tier: high` to a `task` you send them; without it they use their own model. Pick by difficulty: `low` for mechanical work with an exact spec (renames, a one-line fix, running a given command), `mid` for routine changes in one module, `high` (or no tier) for design, debugging, reviews and anything unclear. A tier lasts for that one task: replies and later mail on the thread run on the member's own model, so put `tier` again on each follow-up task. Each model keeps its own session, so a member that resumes sessions does not see what it did on another model; say in the task what it needs. If a tiered task comes back poor, send it again with a higher tier.",
41
+ "Members and the models each tier uses:",
42
+ ...members.map((m) => `- ${m.name}: low ${m.low}, mid ${m.mid}, high ${m.high}`),
43
+ ].join("\n");
27
44
  export const COMMON_LIMIT = 8 * 1024;
28
45
  export const MEMORY_INDEX_LIMIT = 4 * 1024;
29
46
  export const HANDOFF_LIMIT = COMMON_LIMIT;
@@ -148,7 +165,7 @@ export function buildSystemPrompt(project, agent) {
148
165
  "Your final `done` mail must also include the full `## Steps` checklist. Tick only steps that were actually done and verified; leave undone ones",
149
166
  "(e.g. build, app start, commit) unticked and say so in the mail instead of reporting them as finished.",
150
167
  ]
151
- : []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
168
+ : []), ...(isLead ? ["", ...WAKE_ECONOMY_SECTION] : []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
152
169
  const mems = memoryDirs(agent);
153
170
  const sharedMems = Object.values(agent.sharedMemory ?? {});
154
171
  if (mems.length === 0)
@@ -169,8 +186,18 @@ export function buildSystemPrompt(project, agent) {
169
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\`.`,
170
187
  ]
171
188
  : []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
172
- const text = lines.join("\n");
173
- return isLead && project.run?.instances ? `${text}\n\n${PARALLEL_SECTION(project.run.instances.max)}` : text;
189
+ let text = lines.join("\n");
190
+ if (isLead && project.run?.instances)
191
+ text = `${text}\n\n${PARALLEL_SECTION(project.run.instances.max)}`;
192
+ if (isLead) {
193
+ const members = Object.values(project.agents)
194
+ .filter((a) => a.name !== project.lead && (!a.instance || a.instance.k === 1) && (a.tiers?.length ?? 0) >= 2)
195
+ .sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))
196
+ .map((a) => ({ name: a.name, low: modelForTier(a.tiers, "low"), mid: modelForTier(a.tiers, "mid"), high: modelForTier(a.tiers, "high") }));
197
+ if (members.length)
198
+ text = `${text}\n\n${TIER_SECTION(members)}`;
199
+ }
200
+ return text;
174
201
  }
175
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. */
176
203
  export function pickMessages(project, agent, queue) {
@@ -127,6 +127,8 @@ export declare class RunSession {
127
127
  private saveState;
128
128
  private journal;
129
129
  private note;
130
+ /** Spec 24.5.2: log every tier that was dropped; tell the sender once per reason (`no-tiers` is log-only). */
131
+ private noteTierIgnored;
130
132
  /** Bind the project to this run again, after its slot table changed. */
131
133
  private rebind;
132
134
  /** What the router needs to hold and place mail for roles with several instances (spec 23.7.1). */
@@ -4,6 +4,7 @@ import { realInvoker } from "./adapters/index.js";
4
4
  import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, planAttempts, runWithFallback } from "./fallback.js";
5
5
  import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
6
6
  import { ProtectedGuard } from "./guard.js";
7
+ import { listRunLocks, lockHolderAlive } from "./project-lock.js";
7
8
  import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
8
9
  import { deliver, deliverOnce, ensureAgentDirs, ensureProjectDirs, finishDone, listUnread, PENDING_DIR, readMessageNoFollow, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
9
10
  import { instanceContainment } from "./instance-containment.js";
@@ -20,6 +21,7 @@ import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRun
20
21
  import { soloProject } from "./solo.js";
21
22
  import { taskMessageBody } from "./task.js";
22
23
  import { must } from "./assert.js";
24
+ import { modelForTier, wakeTier } from "./tiers.js";
23
25
  import { openPullRequest, prBody, prTitle, realRunner } from "./open-pr.js";
24
26
  export { newRunId };
25
27
  export const RESULT_FILE = "result.md";
@@ -37,6 +39,8 @@ function readIndex(file) {
37
39
  }
38
40
  /** The `state.sessions` key of the session an agent runs in on candidate `c`: a backup has a session of its own. */
39
41
  const sessionKeyOf = (agent, c) => (c.primary ? agent.name : `${agent.name}@${c.key}`);
42
+ /** Why `key` is cooling down, for a message. */
43
+ const cooldownKind = (cd, key) => cd.toJSON()[key]?.kind ?? "unknown";
40
44
  /** `runtime/model`, or the runtime alone for its default model. */
41
45
  const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
42
46
  /** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
@@ -241,7 +245,8 @@ export class RunSession {
241
245
  }
242
246
  const saveState = () => saveRunState(runDir, state);
243
247
  // Concurrent runs: the repo's instruction files the run can touch are the lead worktree's copies; your own tree is never watched (or restored).
244
- const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
248
+ const otherLiveRuns = () => listRunLocks(project.paths.root).filter((l) => l.run_id !== runId && lockHolderAlive(l)).map((l) => l.run_id);
249
+ const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project, otherLiveRuns);
245
250
  if (resume) {
246
251
  log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
247
252
  if (opts.followup) {
@@ -320,6 +325,21 @@ export class RunSession {
320
325
  log("note", { text });
321
326
  say(` ${text}`);
322
327
  }
328
+ /** Spec 24.5.2: log every tier that was dropped; tell the sender once per reason (`no-tiers` is log-only). */
329
+ noteTierIgnored(route) {
330
+ for (const t of route.tierIgnored) {
331
+ this.log("tier_ignored", { from: t.from, id: t.id, to: t.to, reason: t.reason });
332
+ if (t.reason === "no-tiers")
333
+ continue;
334
+ const key = `tier\u0000${t.from}\u0000${t.reason}`;
335
+ if (this.ignoredNoted.has(key))
336
+ continue;
337
+ this.ignoredNoted.add(key);
338
+ this.note(t.reason === "not-lead"
339
+ ? `${t.from}'s mail ${t.id} has \`tier\`, but only the lead (${this.project.lead}) can choose a member's tier: it was delivered without one.`
340
+ : `${t.from}'s mail ${t.id} ("${t.subject}") has \`tier\` but is a ${t.type}: tiers apply to tasks only, so it was delivered without one.`);
341
+ }
342
+ }
323
343
  /** Bind the project to this run again, after its slot table changed. */
324
344
  rebind() {
325
345
  this.project = withCapability(bindRunProject(this.base, this.runDir, this.layout, this.state.instances), this.wsMode, this.instanceCheck, this.state.instances);
@@ -555,11 +575,21 @@ export class RunSession {
555
575
  timeoutSec: cfg.wake_timeout_sec,
556
576
  };
557
577
  // Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
558
- const all = candidatesFor(agent);
578
+ // A lead's `tier` (spec 24.5.3): only for an agent with a ladder, and only a valid value counts.
579
+ const tier = agent.tiers && agent.tiers.length >= 2 ? wakeTier(unread.map((m) => m.meta.tier)) : undefined;
580
+ const all = candidatesFor(agent, tier && agent.tiers ? modelForTier(agent.tiers, tier) : undefined);
559
581
  const chain = all.length > 1;
560
- const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
582
+ const candidates = chain ? planAttempts(all, cooldowns, Date.now()) : all;
583
+ const ceiling = must(all.find((c) => c.primary), "the agent's own model");
584
+ const tieredFirst = all[0]?.tiered ? all[0] : undefined;
561
585
  // The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
562
- let replaced = candidates[0]?.primary ? undefined : all[0];
586
+ // A tier's cheaper model is not a fallback (spec 24.7.3): only chain entries have one.
587
+ let replaced = candidates[0]?.primary || candidates[0]?.tiered ? undefined : ceiling;
588
+ if (tieredFirst && !candidates[0]?.tiered) {
589
+ const left = Math.ceil(cooldowns.active(tieredFirst.key, Date.now()) / 1000);
590
+ say(` ${agent.name} tier ${tier} model ${modelLabel(tieredFirst)} is cooling down (${cooldownKind(cooldowns, tieredFirst.key)}, ${left}s left): starting on ${modelLabel(candidates[0])}`);
591
+ log("tier_step_up", { agent: agent.name, tier, from: modelLabel(tieredFirst), to: modelLabel(candidates[0]), reason: "cooldown" });
592
+ }
563
593
  let calls = 0;
564
594
  let stoppedByCancel = false;
565
595
  const outcome = await runWithFallback({
@@ -580,8 +610,19 @@ export class RunSession {
580
610
  return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
581
611
  },
582
612
  onSwitch: (from, to, kind) => {
583
- replaced = from;
613
+ if (!from.tiered)
614
+ replaced = from;
584
615
  this.syncCooldowns();
616
+ if (from.tiered) {
617
+ log("tier_step_up", { agent: agent.name, tier, from: modelLabel(from), to: modelLabel(to), reason: kind ?? "unknown" });
618
+ if (opts.signal?.aborted)
619
+ return;
620
+ say(` ${agent.name} stepping up from tier ${tier} (${modelLabel(from)}) to ${modelLabel(to)} (${kind ?? "unknown error"})`);
621
+ const resumesTo = agent.resume && !!state.sessions[sessionKeyOf(agent, to)];
622
+ this.note(`${agent.name}'s tier ${tier} model ${modelLabel(from)} failed (${kind ?? "unknown error"}); it is woken again on ${modelLabel(to)}, which ${resumesTo ? "resumes its own earlier session" : "starts a fresh session"}, and redoes this wake-up. ` +
623
+ `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
624
+ return;
625
+ }
585
626
  if (opts.signal?.aborted)
586
627
  return;
587
628
  say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
@@ -603,7 +644,7 @@ export class RunSession {
603
644
  const redo = this.redoContext(agent.name, unread.map((m) => m.meta.id), att.id, attempt);
604
645
  const userPrompt = redoUserPrompt(base.userPrompt, { attempt, error: ctx.previous?.error, prior: redo.prior, drafts: redo.drafts, taskMemory: agent.memory.task });
605
646
  const retryN = chain ? ctx.candidateAttempt : attempt;
606
- say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
647
+ say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime}${c.tiered ? `, tier ${tier}` : ""})${retryN ? ` — retry ${retryN}` : ""}`);
607
648
  const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
608
649
  const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
609
650
  // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
@@ -632,7 +673,7 @@ export class RunSession {
632
673
  state.cache_write_tokens += result.cacheWriteTokens ?? 0;
633
674
  delete state.active[agent.name];
634
675
  // With a chain only: which model ran, what it replaced, why it failed.
635
- const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
676
+ const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || c.tiered ? undefined : modelLabel(replaced ?? ceiling), kind: result.kind } : {};
636
677
  state.wakes.push({
637
678
  round,
638
679
  agent: agent.name,
@@ -645,8 +686,9 @@ export class RunSession {
645
686
  handling,
646
687
  error: result.error,
647
688
  ...which,
689
+ ...(tier ? { tier } : {}),
648
690
  });
649
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
691
+ state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}), ...(tier ? { tier } : {}) };
650
692
  log("wake", {
651
693
  agent: agent.name,
652
694
  runtime: c.runtime,
@@ -659,6 +701,7 @@ export class RunSession {
659
701
  error: result.error,
660
702
  ...(redo.prior.length ? { redo_of: redo.prior.map((a) => a.id) } : {}),
661
703
  ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
704
+ ...(tier ? { tier } : {}),
662
705
  });
663
706
  this.saveState();
664
707
  if (result.ok) {
@@ -820,6 +863,11 @@ export class RunSession {
820
863
  (v.saved ? `\n\nWhat was written is kept at \`${v.saved}\` (sha256 ${v.sha256}${v.truncated ? ", truncated to 1 MiB" : ""}). If it is worth keeping, have an agent that may edit this file redo it.` : ""),
821
864
  });
822
865
  }
866
+ // Another run's lead edited a cross-run shared file: kept, not a violation of this batch, nobody is woken for it.
867
+ for (const u of guard.takeUnattributed()) {
868
+ say(` shared file changed by another live run (${u.otherRuns.join(", ")}); kept: ${u.file}`);
869
+ log("protected-change-unattributed", { ...u });
870
+ }
823
871
  // Crash seam: every wake of the batch is finished and marked output_ready; nothing of it is integrated or routed yet.
824
872
  fault("wake:ready");
825
873
  batch.forEach((a, i) => {
@@ -851,6 +899,7 @@ export class RunSession {
851
899
  contract = undefined;
852
900
  }
853
901
  }
902
+ this.noteTierIgnored(route);
854
903
  const askAccepted = this.handleAsks(route);
855
904
  for (const d of route.delivered)
856
905
  log("route", d);
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import type { DigestSection, Step } from "./format.js";
3
- import { type ErrorKind, type Runtime, type RunOutcome } from "./schema.js";
3
+ import { type ErrorKind, type Runtime, type RunOutcome, type Tier } from "./schema.js";
4
4
  import type { ResultBranch } from "./worktree.js";
5
5
  export declare const STATE_FILE = "state.json";
6
6
  /** The newest state format this version reads. */
@@ -51,6 +51,8 @@ export interface WakeRecord {
51
51
  fallback_from?: string;
52
52
  /** With a fallback chain only: why the call failed. */
53
53
  kind?: ErrorKind;
54
+ /** The tier this wake ran at (spec 24): set only when every mail it handled carried a valid one. */
55
+ tier?: Tier;
54
56
  }
55
57
  /** A model given up on during a wake with a fallback chain, for `failure.tried`. */
56
58
  export interface TriedModel {
@@ -123,6 +125,7 @@ export interface RunState {
123
125
  error?: string;
124
126
  runtime?: Runtime;
125
127
  model?: string;
128
+ tier?: Tier;
126
129
  }>;
127
130
  /** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
128
131
  pid: number;
@@ -1,5 +1,6 @@
1
1
  /** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
2
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";
3
4
  export interface InitResult {
4
5
  home: string;
5
6
  created: string[];
package/dist/scaffold.js CHANGED
@@ -33,21 +33,27 @@ You verify work: run and write tests, reproduce bugs, and review changes against
33
33
  - Besides the standard reply headings, add a \`## Verdict\` section containing \`pass\` or \`fail\`, and put your prioritised list of issues under Open items.
34
34
  `,
35
35
  };
36
- 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.
37
37
  # Relative paths here are resolved against this folder. "~" is allowed.
38
38
  agents:
39
39
  lead:
40
40
  runtime: claude-code # claude-code | codex
41
41
  agent_md: agents/lead/AGENT.md
42
42
  memory: { global: agents/lead/memory }
43
+ # model: opus # most capable model: keep it for the lead
43
44
  fe-member:
44
45
  runtime: claude-code
45
46
  agent_md: agents/fe-member/AGENT.md
46
47
  memory: { global: agents/fe-member/memory }
48
+ # model: sonnet # cheaper model for routine work
47
49
  qa-member:
48
50
  runtime: claude-code
49
51
  agent_md: agents/qa-member/AGENT.md
50
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.
51
57
  `;
52
58
  export function initHome(home) {
53
59
  const hp = homePaths(home);
package/dist/schema.d.ts CHANGED
@@ -113,7 +113,18 @@ export declare const DispatcherPartial: z.ZodObject<{
113
113
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
114
114
  export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
115
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];
116
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>>;
117
128
  agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
118
129
  runtime: z.ZodOptional<z.ZodEnum<{
119
130
  "claude-code": "claude-code";
@@ -165,6 +176,10 @@ export declare const ProjectConfig: z.ZodObject<{
165
176
  team: z.ZodObject<{
166
177
  lead: z.ZodString;
167
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>>;
168
183
  dispatcher: z.ZodDefault<z.ZodObject<{
169
184
  max_rounds: z.ZodOptional<z.ZodNumber>;
170
185
  max_parallel: z.ZodOptional<z.ZodUnknown>;
package/dist/schema.js CHANGED
@@ -72,8 +72,17 @@ export const DispatcherPartial = z
72
72
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
73
73
  export const REMOVED_DISPATCHER_KEYS = ["max_parallel", "max_concurrent_runs", "workspace_mode"];
74
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"];
75
83
  export const GlobalConfig = z
76
84
  .object({
85
+ model_tiers: ModelTiersPartial.optional(),
77
86
  agents: z.record(z.string(), AgentPartial).default({}),
78
87
  })
79
88
  .strict();
@@ -81,6 +90,7 @@ export const ProjectConfig = z
81
90
  .object({
82
91
  dir: z.string(),
83
92
  team: z.object({ lead: z.string() }).strict(),
93
+ model_tiers: ModelTiersPartial.optional(),
84
94
  dispatcher: DispatcherPartial.default({}),
85
95
  agents: z.record(z.string(), AgentPartial).default({}),
86
96
  })
package/dist/status.js CHANGED
@@ -319,7 +319,14 @@ export function buildStatusReport(base, runId, now = Date.now()) {
319
319
  catch {
320
320
  /* a hint only: never let it break status */
321
321
  }
322
- 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 = {
323
330
  schema_version: 1,
324
331
  project: projectInfo(base),
325
332
  agents: Object.values(project.agents).map((a) => ({
@@ -335,6 +342,8 @@ export function buildStatusReport(base, runId, now = Date.now()) {
335
342
  run: report,
336
343
  ...(advice.length ? { memory_advice: advice } : {}),
337
344
  };
345
+ belowCeiling.set(out, below);
346
+ return out;
338
347
  }
339
348
  /** Every run of the project, newest first. */
340
349
  export function buildTaskListReport(project) {
@@ -513,6 +522,19 @@ function fallbackLines(agents, c) {
513
522
  }
514
523
  return out;
515
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
+ }
516
538
  export function formatStatusReport(report, now = Date.now(), color = false) {
517
539
  const c = paint(color);
518
540
  const run = report.run;
@@ -543,6 +565,9 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
543
565
  const fb = fallbackLines(report.agents, c);
544
566
  if (fb.length)
545
567
  lines.push("", ...fb);
568
+ const tl = tierLines(report, c);
569
+ if (tl.length)
570
+ lines.push(...(fb.length ? [] : [""]), ...tl);
546
571
  if (run)
547
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)}` : ""}`);
548
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))
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",
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.26.0",
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",