agent-lyceum 0.26.0 → 0.28.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/guard.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readRegularFileNoFollow } from "./fs-util.js";
5
+ import { escapesParent } from "./paths.js";
5
6
  import { mayEditProtected, recordsDir, repoInstructionFiles, sharedContextFiles, sharedIndex } from "./policy.js";
6
7
  export const MAX_KEEP_BYTES = 1024 * 1024;
7
8
  function safeName(file) {
@@ -13,18 +14,38 @@ function safeName(file) {
13
14
  */
14
15
  export class ProtectedGuard {
15
16
  project;
17
+ otherLiveRuns;
16
18
  baseline = new Map();
17
19
  /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
18
20
  records = new Map();
19
- constructor(project) {
21
+ /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
22
+ unattributed = [];
23
+ /** Other runs that were live at the last `rebase()` (or at `check()` time); computed once per batch. */
24
+ liveAtBase = [];
25
+ /** `otherLiveRuns`: ids of the project's other runs that have a live lock holder right now. */
26
+ constructor(project, otherLiveRuns = () => []) {
20
27
  this.project = project;
28
+ this.otherLiveRuns = otherLiveRuns;
21
29
  this.rebase();
22
30
  }
31
+ /** The unattributed changes since the last call (they were kept and adopted as the baseline). */
32
+ takeUnattributed() {
33
+ const out = this.unattributed;
34
+ this.unattributed = [];
35
+ return out;
36
+ }
37
+ /** Files shared by every run of the project: only these can be changed by another run's lead. */
38
+ crossRunShared(file) {
39
+ if (sharedContextFiles(this.project).includes(file))
40
+ return true;
41
+ return Object.values(this.project.agents).some((a) => a.agentMd === file);
42
+ }
23
43
  /**
24
44
  * Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
25
45
  * judged: edits made in between (you, or another run of the project) are not the batch's doing.
26
46
  */
27
47
  rebase() {
48
+ this.liveAtBase = this.otherLiveRuns();
28
49
  for (const f of ProtectedGuard.protectedFiles(this.project))
29
50
  this.baseline.set(f, read(f));
30
51
  this.records = readTree(recordsDir(this.project));
@@ -69,6 +90,7 @@ export class ProtectedGuard {
69
90
  /** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
70
91
  check(running) {
71
92
  const violations = [];
93
+ const other = [...new Set([...this.liveAtBase, ...this.otherLiveRuns()])];
72
94
  for (const [file, before] of this.baseline) {
73
95
  const now = read(file);
74
96
  if (same(before, now))
@@ -78,6 +100,11 @@ export class ProtectedGuard {
78
100
  this.baseline.set(file, now);
79
101
  continue;
80
102
  }
103
+ if (other.length && this.crossRunShared(file)) {
104
+ this.baseline.set(file, now);
105
+ this.unattributed.push({ file, agents: running.map((a) => a.name), otherRuns: other });
106
+ continue;
107
+ }
81
108
  if (before === null) {
82
109
  const kept = this.keep(file, now);
83
110
  fs.rmSync(file, { force: true });
@@ -90,10 +117,10 @@ export class ProtectedGuard {
90
117
  violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
91
118
  }
92
119
  }
93
- this.checkRecords(running, violations);
120
+ this.checkRecords(running, violations, other);
94
121
  return violations;
95
122
  }
96
- checkRecords(running, violations) {
123
+ checkRecords(running, violations, other) {
97
124
  const root = recordsDir(this.project);
98
125
  // Never write through a symlinked parent of the records tree: skip its protection for this batch.
99
126
  if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
@@ -109,6 +136,10 @@ export class ProtectedGuard {
109
136
  continue;
110
137
  if (lead)
111
138
  continue;
139
+ if (other.length) {
140
+ this.unattributed.push({ file, agents: suspects, otherRuns: other });
141
+ continue;
142
+ }
112
143
  const kept = this.keep(file, cur);
113
144
  if (before === null) {
114
145
  if (!insideRoot(root, path.dirname(file)))
@@ -124,7 +155,7 @@ export class ProtectedGuard {
124
155
  violations.push({ file, action: "restored", suspects, ...kept });
125
156
  }
126
157
  }
127
- if (lead)
158
+ if (lead || other.length)
128
159
  this.records = now;
129
160
  }
130
161
  /**
@@ -133,7 +164,7 @@ export class ProtectedGuard {
133
164
  */
134
165
  realDirs(root, dir, done, suspects, violations) {
135
166
  const rel = path.relative(root, dir);
136
- if (rel.startsWith("..") || path.isAbsolute(rel))
167
+ if (escapesParent(rel))
137
168
  return false;
138
169
  let cur = root;
139
170
  for (const part of ["", ...(rel ? rel.split(path.sep) : [])]) {
@@ -167,7 +198,7 @@ function isLink(p) {
167
198
  function insideRoot(root, dir) {
168
199
  try {
169
200
  const r = path.relative(fs.realpathSync(root), fs.realpathSync(dir));
170
- return !r.startsWith("..") && !path.isAbsolute(r);
201
+ return !escapesParent(r);
171
202
  }
172
203
  catch {
173
204
  return false;
package/dist/mailbox.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { ResolvedProject } from "./config.js";
2
2
  import { type Step } from "./format.js";
3
3
  import { atomicWrite } from "./fs-util.js";
4
4
  import { type InstanceCap } from "./instances.js";
5
+ import type { Tier } from "./schema.js";
5
6
  import { RouteJournal } from "./message-store.js";
6
7
  export { atomicWrite };
7
8
  export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done", "ask"];
@@ -17,6 +18,8 @@ export interface MessageMeta {
17
18
  created: string;
18
19
  /** The run this mail belongs to. Stamped by the dispatcher; absent on mail of legacy runs. */
19
20
  run_id?: string;
21
+ /** The lead-chosen difficulty tier, validated by the dispatcher at delivery (spec 24.5.3). Absent = the ceiling. */
22
+ tier?: Tier;
20
23
  }
21
24
  export interface Message {
22
25
  meta: MessageMeta;
@@ -40,6 +43,8 @@ export interface DeliverInput {
40
43
  body: string;
41
44
  thread?: string;
42
45
  reply_to?: string;
46
+ /** Written to the mail only when set (spec 24.5.3), so mail without it is unchanged. */
47
+ tier?: Tier;
43
48
  }
44
49
  /** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
45
50
  export declare function deliver(project: ResolvedProject, input: DeliverInput & {
@@ -112,6 +117,15 @@ export interface RouteResult {
112
117
  }[];
113
118
  /** Latest `## Steps` checklist the lead sent, if any. */
114
119
  steps?: Step[];
120
+ /** `tier` fields delivered without effect (spec 24.5.2); the caller logs `tier_ignored` and sends the notes. */
121
+ tierIgnored: {
122
+ from: string;
123
+ id: string;
124
+ to: string;
125
+ subject: string;
126
+ type: string;
127
+ reason: "not-lead" | "not-a-task" | "no-tiers";
128
+ }[];
115
129
  }
116
130
  /** Move a handled `done` file out of the outbox (to `read/`). */
117
131
  export declare function finishDone(file: string): void;
package/dist/mailbox.js CHANGED
@@ -2,10 +2,12 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import YAML from "yaml";
5
+ import { duplicateNoticeKey, shouldNotifyDuplicate } from "./duplicate-notices.js";
5
6
  import { formatWarning, missingSections, parseSteps } from "./format.js";
6
7
  import { atomicWrite, readRegularFileNoFollow } from "./fs-util.js";
7
8
  import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
8
9
  import { parallelFlag, roleOfName } from "./instances.js";
10
+ import { effectiveTier, tierFlag } from "./tiers.js";
9
11
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
10
12
  import { inboxDir, outboxDir, sharedDir } from "./policy.js";
11
13
  import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
@@ -25,14 +27,31 @@ export function serialize(meta, body) {
25
27
  const fm = YAML.stringify(meta).trimEnd();
26
28
  return `---\n${fm}\n---\n\n${body.trim()}\n`;
27
29
  }
30
+ const ID_KEYS = new Set(["id", "reply_to", "thread", "about", "run_id"]);
28
31
  export function parseRaw(text) {
29
32
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
30
33
  if (!m)
31
34
  throw new Error("missing frontmatter (--- ... ---)");
32
- const data = YAML.parse(m[1] ?? "");
35
+ const fm = m[1] ?? "";
36
+ const data = YAML.parse(fm);
33
37
  if (!data || typeof data !== "object" || Array.isArray(data))
34
38
  throw new Error("frontmatter is not a mapping");
35
- return { data: data, body: (m[2] ?? "").trim() };
39
+ const out = data;
40
+ // Ids are 8 hex characters: `79e76911` parses as Infinity and `01234567` as 1234567.
41
+ // Only an id key that came out as a number is re-read from its source text; everything else stays as YAML.parse returned it.
42
+ if (Object.entries(out).some(([k, v]) => ID_KEYS.has(k) && typeof v === "number")) {
43
+ const doc = YAML.parseDocument(fm);
44
+ if (YAML.isMap(doc.contents)) {
45
+ for (const pair of doc.contents.items) {
46
+ const key = YAML.isScalar(pair.key) ? String(pair.key.value) : "";
47
+ if (!ID_KEYS.has(key) || typeof out[key] !== "number" || !YAML.isScalar(pair.value))
48
+ continue;
49
+ if (pair.value.type === "PLAIN" && typeof pair.value.source === "string")
50
+ out[key] = pair.value.source;
51
+ }
52
+ }
53
+ }
54
+ return { data: out, body: (m[2] ?? "").trim() };
36
55
  }
37
56
  export function ensureProjectDirs(project) {
38
57
  fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
@@ -64,6 +83,7 @@ export function deliver(project, input) {
64
83
  subject: input.subject,
65
84
  created: input.created ?? new Date().toISOString(),
66
85
  ...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
86
+ ...(input.tier ? { tier: input.tier } : {}),
67
87
  };
68
88
  const file = path.join(inboxDir(project, input.to), input.fileName ?? `${stamp()}-${input.from}-${id}.md`);
69
89
  atomicWrite(file, serialize(meta, input.body));
@@ -123,6 +143,7 @@ function deliveredMeta(project, input, planned) {
123
143
  subject: input.subject,
124
144
  created: planned.created,
125
145
  ...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
146
+ ...(input.tier ? { tier: input.tier } : {}),
126
147
  };
127
148
  }
128
149
  export function readMessage(file) {
@@ -199,7 +220,7 @@ function sameText(body) {
199
220
  * An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
200
221
  * sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
201
222
  */
202
- function earlierDuplicate(project, from, to, type, replyTo, body, held = false) {
223
+ function earlierDuplicate(project, from, to, type, replyTo, body, held = false, tier = "high") {
203
224
  const text = sameText(body);
204
225
  if (text.length < 20)
205
226
  return undefined;
@@ -215,7 +236,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body, held = false)
215
236
  continue;
216
237
  try {
217
238
  const m = readMessage(path.join(d, e.name));
218
- if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text)
239
+ if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text && effectiveTier(m.meta) === tier)
219
240
  return m;
220
241
  }
221
242
  catch {
@@ -227,7 +248,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body, held = false)
227
248
  }
228
249
  /** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
229
250
  export function routeOutboxes(project, routing) {
230
- const res = { delivered: [], rejected: [], warnings: [], asks: [] };
251
+ const res = { delivered: [], rejected: [], warnings: [], asks: [], tierIgnored: [] };
231
252
  const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
232
253
  for (const sender of Object.values(project.agents)) {
233
254
  const dir = outboxDir(project, sender.name);
@@ -240,14 +261,15 @@ export function routeOutboxes(project, routing) {
240
261
  .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs || a.localeCompare(b));
241
262
  for (const file of files) {
242
263
  const source = sourceIdOf(sender.name, file);
243
- const reject = (reason) => {
244
- deliverOnce(project, journal, `reject:${source}`, {
245
- from: "dispatcher",
246
- to: sender.name,
247
- type: "failure",
248
- subject: `Message ${path.basename(file)} was rejected`,
249
- body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
250
- });
264
+ const reject = (reason, notify = true) => {
265
+ if (notify)
266
+ deliverOnce(project, journal, `reject:${source}`, {
267
+ from: "dispatcher",
268
+ to: sender.name,
269
+ type: "failure",
270
+ subject: `Message ${path.basename(file)} was rejected`,
271
+ body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
272
+ });
251
273
  const dest = path.join(dir, "rejected", path.basename(file));
252
274
  fs.mkdirSync(path.dirname(dest), { recursive: true });
253
275
  fs.renameSync(file, dest);
@@ -332,6 +354,18 @@ export function routeOutboxes(project, routing) {
332
354
  continue;
333
355
  }
334
356
  }
357
+ // `tier` is judged per recipient like `parallel`, before the duplicate check, which needs the effective tier (spec 24.5.2, 24.5.4).
358
+ const tiers = new Map();
359
+ for (const t of targets)
360
+ tiers.set(t, tierFlag({ value: d.tier, senderIsLead: sender.name === project.lead, type, recipientTiers: project.agents[t]?.tiers }));
361
+ if ([...tiers.values()].includes("invalid")) {
362
+ reject(`tier must be low, mid or high, not ${JSON.stringify(d.tier)}. Write \`tier: low\` (or mid/high) on a task to run it on a cheaper model, or leave the line out to use the member's own model.`);
363
+ continue;
364
+ }
365
+ const tierOf = (to) => {
366
+ const f = tiers.get(to);
367
+ return f === "low" || f === "mid" || f === "high" ? f : undefined;
368
+ };
335
369
  const missing = missingSections(type, parsed.body);
336
370
  if (missing.length && project.run?.layout === "run") {
337
371
  const key = formatRejectionKey(sender.name, type, subject);
@@ -348,7 +382,7 @@ export function routeOutboxes(project, routing) {
348
382
  for (const to of targets) {
349
383
  if (journal?.get(source, to))
350
384
  continue;
351
- const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)));
385
+ const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)), tierOf(to) ?? "high");
352
386
  if (earlier)
353
387
  repeats.set(to, earlier);
354
388
  }
@@ -356,18 +390,25 @@ export function routeOutboxes(project, routing) {
356
390
  .map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
357
391
  .join("; ");
358
392
  const REPEAT_HELP = "Do not send it again: if that mail has not been answered yet, wait for the reply; if you need something different, change what you ask for; if the work is finished, the lead sends `done`.";
393
+ // A failure mail wakes the sender; an agent that answers with the same text again would be woken forever, so after
394
+ // MAX_DUPLICATE_NOTICES notices for the same repeated mail the repeat is still set aside and logged, but not announced.
395
+ // Bound to a run directory (either layout); a project-only mailbox has no place to count and stays unbounded.
396
+ const notifyRepeat = !repeats.size ||
397
+ !project.run ||
398
+ shouldNotifyDuplicate(project.run.dir, duplicateNoticeKey(sender.name, [...repeats].map(([to, m]) => [to, m.meta.id])), source);
359
399
  if (repeats.size === targets.length) {
360
- reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`);
400
+ reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`, notifyRepeat);
361
401
  continue;
362
402
  }
363
403
  if (repeats.size) {
364
- deliverOnce(project, journal, `dup:${source}`, {
365
- from: "dispatcher",
366
- to: sender.name,
367
- type: "failure",
368
- subject: `Part of ${path.basename(file)} was not sent`,
369
- body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
370
- });
404
+ if (notifyRepeat)
405
+ deliverOnce(project, journal, `dup:${source}`, {
406
+ from: "dispatcher",
407
+ to: sender.name,
408
+ type: "failure",
409
+ subject: `Part of ${path.basename(file)} was not sent`,
410
+ body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
411
+ });
371
412
  res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
372
413
  }
373
414
  const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
@@ -381,13 +422,16 @@ export function routeOutboxes(project, routing) {
381
422
  continue;
382
423
  const flag = flags.get(to);
383
424
  const role = roleOf(to);
425
+ const tf = tiers.get(to);
426
+ if (typeof tf === "object" && !journal?.get(source, to))
427
+ res.tierIgnored.push({ from: sender.name, id: deterministicId(source, to), to, subject, type, reason: tf.ignored });
384
428
  if (typeof flag === "object" && !journal?.get(source, to))
385
429
  routing?.ignored(flag.ignored, { from: sender.name, id: deterministicId(source, to), to, subject });
386
430
  // The first valid parallel task makes the role's instances durable before a single file of this mail exists.
387
431
  if (routing && flag === "on" && !routing.held(role))
388
432
  routing.enable(role);
389
433
  const held = !!routing?.held(role);
390
- const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo }, {
434
+ const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo, ...(tierOf(to) ? { tier: tierOf(to) } : {}) }, {
391
435
  ...(held ? { held: true } : {}),
392
436
  ...(held && flag === "on" ? { parallel: true } : {}),
393
437
  ...(routing?.held(roleOf(sender.name)) ? { from: sender.name } : {}),
package/dist/paths.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface ProjectPaths {
23
23
  taskMemory: string;
24
24
  }
25
25
  export declare function projectPaths(home: string, project: string): ProjectPaths;
26
+ /** 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. */
27
+ export declare function escapesParent(rel: string, sep?: string): boolean;
26
28
  /** True when `child` is `parent` or inside it. */
27
29
  export declare function isInside(parent: string, child: string): boolean;
28
30
  /**
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/policy.d.ts CHANGED
@@ -50,3 +50,36 @@ export declare function setOsSandboxForTest(available: boolean | undefined): voi
50
50
  export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
51
51
  /** Can `agent` legitimately change this protected file? */
52
52
  export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
53
+ export type SandboxRelation = "equal" | "inside" | "contains";
54
+ export interface SandboxPathConflict {
55
+ key: "write" | "unix_sockets";
56
+ index: number;
57
+ path: string;
58
+ protectedPath: string;
59
+ reason: string;
60
+ relation: SandboxRelation;
61
+ }
62
+ /** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
63
+ export declare function sandboxPathConflicts(entries: {
64
+ key: "write" | "unix_sockets";
65
+ index: number;
66
+ path: string;
67
+ }[], protectedPaths: {
68
+ path: string;
69
+ reason: string;
70
+ }[]): SandboxPathConflict[];
71
+ /** What a runtime really applies of an agent's allowances (spec 25.9): the tuple a fallback must not exceed. */
72
+ export interface SandboxCaps {
73
+ network: "none" | "domains" | "all";
74
+ localBinding: boolean;
75
+ unixSockets: boolean;
76
+ }
77
+ export declare const NETWORK_RANK: {
78
+ readonly none: 0;
79
+ readonly domains: 1;
80
+ readonly all: 2;
81
+ };
82
+ /** Without an OS sandbox nothing is applied, so every runtime is equal there. */
83
+ export declare function sandboxCaps(agent: ResolvedAgent, runtime?: string | undefined): SandboxCaps;
84
+ /** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
85
+ export declare function sandboxCapExcess(primary: SandboxCaps, backup: SandboxCaps): string[];
package/dist/policy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { findOnPath } from "./fs-util.js";
4
+ import { canonicalPath } from "./paths.js";
4
5
  export function commonFile(project) {
5
6
  return path.join(project.paths.shared, "common", "COMMON.md");
6
7
  }
@@ -88,6 +89,8 @@ export function writePolicy(project, agent) {
88
89
  ...(ws ? [ws.gitDir] : []),
89
90
  ...(shared && mayWriteShared ? [shared] : []),
90
91
  ...(isLead ? [recordsDir(project)] : []),
92
+ // Spec 25: canonical, because the OS sandbox compares real paths (`/tmp` is `/private/tmp` on macOS).
93
+ ...(agent.sandbox?.write ?? []).map(canonicalPath),
91
94
  ];
92
95
  const allowFiles = [];
93
96
  if (isLead)
@@ -180,6 +183,8 @@ export function enforcementFor(project, agent) {
180
183
  if (agent.canEditAgentMd)
181
184
  notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
182
185
  }
186
+ if (agent.sandbox)
187
+ notes.push(...sandboxNotes(agent, os));
183
188
  return {
184
189
  agent: agent.name,
185
190
  runtime: agent.runtime ?? "?",
@@ -188,7 +193,82 @@ export function enforcementFor(project, agent) {
188
193
  notes,
189
194
  };
190
195
  }
196
+ /** Doctor lines for an agent's sandbox allowances (spec 25.7, 25.10): what is requested and what its runtime really applies. */
197
+ function sandboxNotes(agent, os) {
198
+ const s = agent.sandbox;
199
+ if (!s)
200
+ return [];
201
+ const claude = agent.runtime === "claude-code";
202
+ const notes = [];
203
+ if (!os) {
204
+ notes.push(claude
205
+ ? "no OS sandbox; sandbox.write changes Edit tool rules only; network/local_binding/unix_sockets have no effect."
206
+ : "no OS sandbox; enforcement is prompt-only; sandbox allowances provide no OS enforcement.");
207
+ }
208
+ if (s.network.length) {
209
+ notes.push(`network: ${s.network.join(", ")} (${claude ? "claude-code: per-domain" : "codex: all hosts"})`);
210
+ if (!claude)
211
+ notes.push(`warn: sandbox.network is all-or-nothing on Codex: this agent may reach any host, not only ${s.network.join(", ")}.`);
212
+ }
213
+ if (s.localBinding) {
214
+ notes.push(claude ? (process.platform === "darwin" ? "local_binding: on" : "local_binding: requested, no effect on this platform (macOS only)") : "local_binding: requested, not supported by codex");
215
+ if (!claude)
216
+ notes.push("warn: sandbox.local_binding has no Codex setting; whether a workspace-write sandbox lets it bind ports is unverified. sandbox.network turns on all network access, not only binding.");
217
+ }
218
+ if (s.unixSockets.length) {
219
+ notes.push(`unix_sockets: ${s.unixSockets.map(canonicalPath).join(", ")} (${claude ? "applied" : "not applied by codex"})`);
220
+ if (!claude)
221
+ notes.push("warn: sandbox.unix_sockets has no per-socket Codex control; no setting emitted.");
222
+ }
223
+ for (const [key, list] of [["write", s.write], ["unix_sockets", s.unixSockets]]) {
224
+ list.forEach((p, i) => {
225
+ if (!fs.existsSync(p))
226
+ notes.push(`info: sandbox.${key}[${i}] ${p} does not exist yet`);
227
+ });
228
+ }
229
+ return notes;
230
+ }
191
231
  /** Can `agent` legitimately change this protected file? */
192
232
  export function mayEditProtected(project, agent, file) {
193
233
  return writePolicy(project, agent).allowFiles.includes(file);
194
234
  }
235
+ const within = (parent, p) => p === parent || p.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
236
+ /** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
237
+ export function sandboxPathConflicts(entries, protectedPaths) {
238
+ const out = [];
239
+ for (const e of entries) {
240
+ for (const pp of protectedPaths) {
241
+ const relation = e.path === pp.path ? "equal" : within(pp.path, e.path) ? "inside" : within(e.path, pp.path) ? "contains" : undefined;
242
+ if (relation)
243
+ out.push({ ...e, protectedPath: pp.path, reason: pp.reason, relation });
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+ export const NETWORK_RANK = { none: 0, domains: 1, all: 2 };
249
+ /** Without an OS sandbox nothing is applied, so every runtime is equal there. */
250
+ export function sandboxCaps(agent, runtime = agent.runtime) {
251
+ const s = agent.sandbox;
252
+ const none = { network: "none", localBinding: false, unixSockets: false };
253
+ if (!s || !osSandboxAvailable())
254
+ return none;
255
+ if (runtime === "claude-code") {
256
+ return {
257
+ network: s.network.length ? "domains" : "none",
258
+ localBinding: s.localBinding && process.platform === "darwin",
259
+ unixSockets: s.unixSockets.length > 0,
260
+ };
261
+ }
262
+ return { ...none, network: s.network.length ? "all" : "none" };
263
+ }
264
+ /** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
265
+ export function sandboxCapExcess(primary, backup) {
266
+ const out = [];
267
+ if (NETWORK_RANK[backup.network] > NETWORK_RANK[primary.network])
268
+ out.push(`network: ${primary.network} -> ${backup.network}`);
269
+ if (backup.localBinding && !primary.localBinding)
270
+ out.push("local_binding: off -> on");
271
+ if (backup.unixSockets && !primary.unixSockets)
272
+ out.push("unix_sockets: off -> on");
273
+ return out;
274
+ }
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). */