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/README.md +4 -0
- package/README.zh-TW.md +4 -0
- package/dist/call.js +2 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +32 -3
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +4 -2
- package/dist/fallback.js +11 -4
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/mailbox.d.ts +14 -0
- package/dist/mailbox.js +48 -21
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +5 -2
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +30 -3
- package/dist/run-session.d.ts +2 -0
- package/dist/run-session.js +57 -8
- package/dist/run-store.d.ts +4 -1
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.js +7 -1
- package/dist/schema.d.ts +15 -0
- package/dist/schema.js +10 -0
- package/dist/status.js +26 -1
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +28 -1
- package/dist/worktree.js +2 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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) {
|
package/dist/run-session.d.ts
CHANGED
|
@@ -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). */
|
package/dist/run-session.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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);
|
package/dist/run-store.d.ts
CHANGED
|
@@ -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;
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/tiers.d.ts
ADDED
|
@@ -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
|
|
478
|
+
else if (escapesParent(resolved, "/"))
|
|
478
479
|
violations.push(`${f.path}: symlink leaves the project directory (${target})`);
|
|
479
480
|
}
|
|
480
481
|
}
|