agent-lyceum 0.21.2 → 0.23.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.
@@ -1,7 +1,12 @@
1
+ import { type WritePolicy } from "../policy.js";
1
2
  import type { ResolvedAgent, ResolvedProject } from "../config.js";
2
3
  import type { ErrorKind } from "../schema.js";
3
4
  import type { Invocation, WakeInput } from "./types.js";
4
- export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent): Record<string, unknown>;
5
+ /** `override`: a policy that replaces the agent's own; `cwd` is then the (scratch) directory the runtime starts in, in place of the repo. */
6
+ export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent, override?: {
7
+ policy: WritePolicy;
8
+ cwd: string;
9
+ }): Record<string, unknown>;
5
10
  export declare function buildClaudeInvocation(input: WakeInput): Invocation;
6
11
  /**
7
12
  * The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
@@ -19,6 +24,8 @@ interface ClaudeResult {
19
24
  session_id?: string;
20
25
  usage?: {
21
26
  output_tokens?: number;
27
+ cache_read_input_tokens?: number;
28
+ cache_creation_input_tokens?: number;
22
29
  };
23
30
  is_error?: boolean;
24
31
  }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { repoDirFor, writePolicy } from "../policy.js";
3
+ import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
4
4
  /** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
5
5
  const rule = (tool, p) => `${tool}(/${p})`;
6
6
  function denyRules(p) {
@@ -17,10 +17,11 @@ function denyRules(p) {
17
17
  out.push(rule("Edit", `${p}/**`));
18
18
  return out;
19
19
  }
20
- export function buildClaudeSettings(project, agent) {
21
- const pol = writePolicy(project, agent);
22
- const ws = project.workspaces?.[agent.name];
23
- const repo = repoDirFor(project, agent.name);
20
+ /** `override`: a policy that replaces the agent's own; `cwd` is then the (scratch) directory the runtime starts in, in place of the repo. */
21
+ export function buildClaudeSettings(project, agent, override) {
22
+ const pol = override?.policy ?? writePolicy(project, agent);
23
+ const ws = override ? undefined : project.workspaces?.[agent.name];
24
+ const repo = override?.cwd ?? repoDirFor(project, agent.name);
24
25
  const allow = ["Bash", "Read", "Glob", "Grep"];
25
26
  // Web tools are opt-in: `search` is the lighter one (snippets only), `fetch` also pulls whole pages into the agent's context.
26
27
  if (agent.allowWeb === "search" || agent.allowWeb === "fetch")
@@ -41,7 +42,7 @@ export function buildClaudeSettings(project, agent) {
41
42
  enabled: true,
42
43
  allowUnsandboxedCommands: false,
43
44
  filesystem: {
44
- allowWrite: [ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
45
+ allowWrite: [override?.cwd ?? ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
45
46
  denyWrite: pol.deny,
46
47
  },
47
48
  },
@@ -52,10 +53,13 @@ export function buildClaudeInvocation(input) {
52
53
  fs.mkdirSync(workDir, { recursive: true });
53
54
  const settingsFile = path.join(workDir, "claude-settings.json");
54
55
  const systemFile = path.join(workDir, "system-prompt.md");
55
- fs.writeFileSync(settingsFile, JSON.stringify(buildClaudeSettings(project, agent), null, 2));
56
+ const repo = input.policy ? scratchCwd(workDir) : repoDirFor(project, agent.name);
57
+ const scope = input.policy ? { policy: input.policy, cwd: repo } : undefined;
58
+ if (scope)
59
+ fs.mkdirSync(repo, { recursive: true });
60
+ fs.writeFileSync(settingsFile, JSON.stringify(buildClaudeSettings(project, agent, scope), null, 2));
56
61
  fs.writeFileSync(systemFile, input.systemPrompt);
57
- const pol = writePolicy(project, agent);
58
- const repo = repoDirFor(project, agent.name);
62
+ const pol = input.policy ?? writePolicy(project, agent);
59
63
  const addDirs = [...pol.allowDirs, ...pol.readDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === repo || d.startsWith(repo + path.sep)));
60
64
  const args = [
61
65
  "-p",
@@ -133,6 +137,8 @@ function interpretClaude(j, stdout, stderr, code) {
133
137
  text: String(j.result ?? ""),
134
138
  sessionId: j.session_id,
135
139
  outputTokens: j.usage?.output_tokens,
140
+ cacheReadTokens: j.usage?.cache_read_input_tokens,
141
+ cacheWriteTokens: j.usage?.cache_creation_input_tokens,
136
142
  error: ok ? undefined : String(j.result ?? stderr).slice(0, 500),
137
143
  kind: ok ? undefined : classifyClaudeError({ code, stdout, stderr, result: j.is_error && j.result !== undefined ? String(j.result) : undefined }),
138
144
  };
@@ -1,9 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { repoDirFor, writePolicy } from "../policy.js";
3
+ import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
4
4
  export function codexWritableRoots(input) {
5
- const pol = writePolicy(input.project, input.agent);
6
- const work = repoDirFor(input.project, input.agent.name);
5
+ const pol = input.policy ?? writePolicy(input.project, input.agent);
6
+ const work = input.policy ? scratchCwd(input.workDir) : repoDirFor(input.project, input.agent.name);
7
7
  const roots = [...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === work || d.startsWith(work + path.sep)));
8
8
  return [...new Set(roots)];
9
9
  }
@@ -52,8 +52,10 @@ export function codexOutputTokens(jsonl) {
52
52
  const CODEX_WEB_SEARCH = { none: "disabled", search: "cached", fetch: "live" };
53
53
  export function buildCodexInvocation(input) {
54
54
  const { project, agent, workDir } = input;
55
- const repo = repoDirFor(project, agent.name);
55
+ const repo = input.policy ? scratchCwd(workDir) : repoDirFor(project, agent.name);
56
56
  fs.mkdirSync(workDir, { recursive: true });
57
+ if (input.policy)
58
+ fs.mkdirSync(repo, { recursive: true });
57
59
  const lastFile = path.join(workDir, "codex-last-message.txt");
58
60
  const roots = codexWritableRoots(input);
59
61
  const common = [
@@ -1,5 +1,6 @@
1
1
  import type { ErrorKind } from "../schema.js";
2
2
  import type { ResolvedAgent, ResolvedProject } from "../config.js";
3
+ import type { WritePolicy } from "../policy.js";
3
4
  import type { AgentWorkspace } from "../worktree.js";
4
5
  export interface WakeInput {
5
6
  project: ResolvedProject;
@@ -16,12 +17,20 @@ export interface WakeInput {
16
17
  signal?: AbortSignal;
17
18
  /** Where the full stdout/stderr of this invocation are written. */
18
19
  logDir?: string;
20
+ /**
21
+ * Replaces the agent's normal write policy (memory tidy: only the dirs being tidied). The repo is then not writable
22
+ * either: the runtime starts in an empty scratch dir inside `workDir` and may only read the repo.
23
+ */
24
+ policy?: WritePolicy;
19
25
  }
20
26
  export interface WakeResult {
21
27
  ok: boolean;
22
28
  text: string;
23
29
  sessionId?: string;
24
30
  outputTokens?: number;
31
+ /** Prompt-cache usage (claude only): input tokens read from / written to the cache. */
32
+ cacheReadTokens?: number;
33
+ cacheWriteTokens?: number;
25
34
  exitCode: number | null;
26
35
  timedOut: boolean;
27
36
  error?: string;
@@ -35,6 +44,8 @@ export type Parsed = {
35
44
  text: string;
36
45
  sessionId?: string;
37
46
  outputTokens?: number;
47
+ cacheReadTokens?: number;
48
+ cacheWriteTokens?: number;
38
49
  error?: string;
39
50
  /** Set on failure only; `unknown` when no rule recognizes the output. */
40
51
  kind?: ErrorKind;
package/dist/cli.js CHANGED
@@ -693,7 +693,11 @@ program
693
693
  console.log(` restored ${f}`);
694
694
  for (const s of r.skipped)
695
695
  console.log(` skipped ${s.rel}: ${s.reason}`);
696
- if (!r.restored.length && !r.skipped.length)
696
+ for (const f of r.records?.restored ?? [])
697
+ console.log(` restored records/${f}`);
698
+ for (const k of r.records?.skipped ?? [])
699
+ console.log(` skipped records/${k.rel}: ${k.reason}`);
700
+ if (!r.restored.length && !r.skipped.length && !r.records?.restored.length && !r.records?.skipped.length)
697
701
  console.log(`Archive ${opts.undo} holds no files to restore.`);
698
702
  }
699
703
  finally {
@@ -728,11 +732,11 @@ program
728
732
  console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or tidy global memory with --layer global)." : "Set `memory.global` for an agent."}`);
729
733
  for (const r of results) {
730
734
  if (r.status === "tidied")
731
- console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")} — undo with: agent-lyceum tidy --undo ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
735
+ console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")}${r.recordsDir && r.recordsDir !== r.dir ? `, records: ${path.join(r.recordsDir, ".archive", r.archive ?? "")}` : ""} — undo with: agent-lyceum tidy --undo ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
732
736
  else if (r.status === "skipped")
733
737
  console.log(` ${r.agent} (${r.layer}): skipped — ${r.problems.join("; ")}`);
734
738
  else if (r.status === "failed") {
735
- console.error(` ${r.agent} (${r.layer}): FAILED, memory restored to how it was`);
739
+ console.error(` ${r.agent} (${r.layer}): FAILED, ${r.recordsDir === r.dir ? "project records" : r.recordsDir ? "memory and project records" : "memory"} restored to how it was`);
736
740
  for (const p of r.problems)
737
741
  console.error(` - ${p}`);
738
742
  }
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { findOnPath } from "./fs-util.js";
5
5
  import { tidyAdvice } from "./memory-tidy.js";
6
+ import { listRecords, RECORD_KINDS } from "./records.js";
6
7
  import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
7
8
  import { runInvocation } from "./process-runner.js";
8
9
  import { validateProject } from "./validate.js";
@@ -169,6 +170,17 @@ export async function diagnoseProject(project, opts = {}) {
169
170
  }
170
171
  for (const a of tidyAdvice(project))
171
172
  checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
173
+ for (const kind of RECORD_KINDS) {
174
+ const recs = listRecords(project, kind);
175
+ const ids = new Set(recs.map((r) => r.id));
176
+ for (const r of recs) {
177
+ if (r.invalid)
178
+ checks.push({ level: "warn", subject: "records", message: `${r.file}: ${r.invalid}` });
179
+ for (const s of r.supersedes)
180
+ if (!ids.has(s))
181
+ checks.push({ level: "warn", subject: "records", message: `${r.file}: supersedes "${s}", but no ${kind} entry has that id.` });
182
+ }
183
+ }
172
184
  const runtimes = await probeProjectRuntimes(project, opts, true);
173
185
  checks.push(...judgeRuntimes(project, runtimes));
174
186
  checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
@@ -0,0 +1,6 @@
1
+ /** How many times one sender's mail (same type and subject) is sent back for missing sections before it is delivered with a warning instead. */
2
+ export declare const MAX_FORMAT_REJECTIONS = 2;
3
+ export declare function formatRejectionKey(from: string, type: string, subject: string): string;
4
+ export declare function formatRejections(runDir: string, key: string): number;
5
+ /** Record one more rejection for `key` and return the new count. */
6
+ export declare function countFormatRejection(runDir: string, key: string): number;
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { atomicWrite } from "./fs-util.js";
4
+ /** How many times one sender's mail (same type and subject) is sent back for missing sections before it is delivered with a warning instead. */
5
+ export const MAX_FORMAT_REJECTIONS = 2;
6
+ const FILE = "format-rejections.json";
7
+ export function formatRejectionKey(from, type, subject) {
8
+ return `${from}|${type}|${subject.trim().toLowerCase()}`;
9
+ }
10
+ function load(runDir) {
11
+ try {
12
+ const data = JSON.parse(fs.readFileSync(path.join(runDir, FILE), "utf8"));
13
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ export function formatRejections(runDir, key) {
20
+ return load(runDir)[key] ?? 0;
21
+ }
22
+ /** Record one more rejection for `key` and return the new count. */
23
+ export function countFormatRejection(runDir, key) {
24
+ const all = load(runDir);
25
+ const n = (all[key] ?? 0) + 1;
26
+ all[key] = n;
27
+ atomicWrite(path.join(runDir, FILE), `${JSON.stringify(all, null, 2)}\n`);
28
+ return n;
29
+ }
package/dist/fs-util.d.ts CHANGED
@@ -10,3 +10,8 @@ export declare function readFileCapped(file: string, limit: number): {
10
10
  text: string;
11
11
  truncated: boolean;
12
12
  } | undefined;
13
+ /**
14
+ * The bytes of a regular file, opened without following a symlink and without blocking (a FIFO opens at once and fails the
15
+ * fstat check). undefined for anything else or on any error: never throws, never hangs.
16
+ */
17
+ export declare function readRegularFileNoFollow(file: string): Buffer | undefined;
package/dist/fs-util.js CHANGED
@@ -58,3 +58,27 @@ export function readFileCapped(file, limit) {
58
58
  }
59
59
  }
60
60
  }
61
+ /**
62
+ * The bytes of a regular file, opened without following a symlink and without blocking (a FIFO opens at once and fails the
63
+ * fstat check). undefined for anything else or on any error: never throws, never hangs.
64
+ */
65
+ export function readRegularFileNoFollow(file) {
66
+ let fd;
67
+ try {
68
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
69
+ if (!fs.fstatSync(fd).isFile())
70
+ return undefined;
71
+ return fs.readFileSync(fd);
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ finally {
77
+ if (fd !== undefined) {
78
+ try {
79
+ fs.closeSync(fd);
80
+ }
81
+ catch { }
82
+ }
83
+ }
84
+ }
package/dist/guard.d.ts CHANGED
@@ -17,6 +17,8 @@ export declare const MAX_KEEP_BYTES: number;
17
17
  export declare class ProtectedGuard {
18
18
  private project;
19
19
  private baseline;
20
+ /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
21
+ private records;
20
22
  constructor(project: ResolvedProject);
21
23
  /**
22
24
  * Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
@@ -30,4 +32,10 @@ export declare class ProtectedGuard {
30
32
  private keep;
31
33
  /** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
32
34
  check(running: ResolvedAgent[]): Violation[];
35
+ private checkRecords;
36
+ /**
37
+ * Make every directory from the records root down to `dir` a real directory. A symlink or file standing in for one is an
38
+ * unauthorized change: unlink it (never follow it), recreate the directory and report it. False when `dir` is not safe to write into.
39
+ */
40
+ private realDirs;
33
41
  }
package/dist/guard.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { commonFile, mayEditProtected, repoInstructionFiles, sharedIndex } from "./policy.js";
4
+ import { readRegularFileNoFollow } from "./fs-util.js";
5
+ import { mayEditProtected, recordsDir, repoInstructionFiles, sharedContextFiles, sharedIndex } from "./policy.js";
5
6
  export const MAX_KEEP_BYTES = 1024 * 1024;
6
7
  function safeName(file) {
7
8
  return file.replace(/[\\/:]/g, "_").replace(/^_+/, "");
@@ -13,6 +14,8 @@ function safeName(file) {
13
14
  export class ProtectedGuard {
14
15
  project;
15
16
  baseline = new Map();
17
+ /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
18
+ records = new Map();
16
19
  constructor(project) {
17
20
  this.project = project;
18
21
  this.rebase();
@@ -24,12 +27,14 @@ export class ProtectedGuard {
24
27
  rebase() {
25
28
  for (const f of ProtectedGuard.protectedFiles(this.project))
26
29
  this.baseline.set(f, read(f));
30
+ this.records = readTree(recordsDir(this.project));
27
31
  }
28
32
  static protectedFiles(project) {
29
33
  const files = new Set();
30
34
  for (const a of Object.values(project.agents))
31
35
  files.add(a.agentMd);
32
- files.add(commonFile(project));
36
+ for (const f of sharedContextFiles(project))
37
+ files.add(f);
33
38
  for (const f of repoInstructionFiles(project))
34
39
  files.add(f);
35
40
  const handoff = sharedIndex(project);
@@ -85,8 +90,126 @@ export class ProtectedGuard {
85
90
  violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
86
91
  }
87
92
  }
93
+ this.checkRecords(running, violations);
88
94
  return violations;
89
95
  }
96
+ checkRecords(running, violations) {
97
+ const root = recordsDir(this.project);
98
+ // Never write through a symlinked parent of the records tree: skip its protection for this batch.
99
+ if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
100
+ return;
101
+ const now = readTree(root);
102
+ const lead = running.some((a) => a.name === this.project.lead);
103
+ const suspects = running.map((a) => a.name);
104
+ const fixedDirs = new Set();
105
+ for (const file of new Set([...this.records.keys(), ...now.keys()])) {
106
+ const before = this.records.get(file) ?? null;
107
+ const cur = now.get(file) ?? null;
108
+ if (same(before, cur))
109
+ continue;
110
+ if (lead)
111
+ continue;
112
+ const kept = this.keep(file, cur);
113
+ if (before === null) {
114
+ if (!insideRoot(root, path.dirname(file)))
115
+ continue;
116
+ fs.rmSync(file, { force: true });
117
+ violations.push({ file, action: "removed", suspects, ...kept });
118
+ }
119
+ else {
120
+ if (!this.realDirs(root, path.dirname(file), fixedDirs, suspects, violations))
121
+ continue;
122
+ fs.rmSync(file, { recursive: true, force: true });
123
+ fs.writeFileSync(file, before);
124
+ violations.push({ file, action: "restored", suspects, ...kept });
125
+ }
126
+ }
127
+ if (lead)
128
+ this.records = now;
129
+ }
130
+ /**
131
+ * Make every directory from the records root down to `dir` a real directory. A symlink or file standing in for one is an
132
+ * unauthorized change: unlink it (never follow it), recreate the directory and report it. False when `dir` is not safe to write into.
133
+ */
134
+ realDirs(root, dir, done, suspects, violations) {
135
+ const rel = path.relative(root, dir);
136
+ if (rel.startsWith("..") || path.isAbsolute(rel))
137
+ return false;
138
+ let cur = root;
139
+ for (const part of ["", ...(rel ? rel.split(path.sep) : [])]) {
140
+ cur = part ? path.join(cur, part) : cur;
141
+ let st;
142
+ try {
143
+ st = fs.lstatSync(cur);
144
+ }
145
+ catch { }
146
+ if (st?.isDirectory())
147
+ continue;
148
+ if (st) {
149
+ fs.rmSync(cur, { force: true });
150
+ if (!done.has(cur))
151
+ violations.push({ file: cur, action: "removed", suspects, ...this.keep(cur, null) });
152
+ done.add(cur);
153
+ }
154
+ fs.mkdirSync(cur, { recursive: true });
155
+ }
156
+ return insideRoot(root, dir);
157
+ }
158
+ }
159
+ function isLink(p) {
160
+ try {
161
+ return fs.lstatSync(p).isSymbolicLink();
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ function insideRoot(root, dir) {
168
+ try {
169
+ const r = path.relative(fs.realpathSync(root), fs.realpathSync(dir));
170
+ return !r.startsWith("..") && !path.isAbsolute(r);
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ /** Regular files under `dir`, recursively; symlinks and special files are not followed or read. */
177
+ function readTree(dir) {
178
+ const out = new Map();
179
+ try {
180
+ if (!fs.lstatSync(dir).isDirectory())
181
+ return out; // a symlinked root is never walked
182
+ }
183
+ catch {
184
+ return out;
185
+ }
186
+ const walk = (d) => {
187
+ let names;
188
+ try {
189
+ names = fs.readdirSync(d);
190
+ }
191
+ catch {
192
+ return;
193
+ }
194
+ for (const n of names) {
195
+ const f = path.join(d, n);
196
+ try {
197
+ const st = fs.lstatSync(f);
198
+ if (st.isDirectory())
199
+ walk(f);
200
+ else if (st.isFile()) {
201
+ const buf = readRegularFileNoFollow(f);
202
+ if (buf)
203
+ out.set(f, buf);
204
+ }
205
+ }
206
+ catch {
207
+ // vanished while scanning
208
+ }
209
+ }
210
+ };
211
+ walk(dir);
212
+ return out;
90
213
  }
91
214
  function read(file) {
92
215
  try {
package/dist/mailbox.js CHANGED
@@ -4,8 +4,10 @@ import path from "node:path";
4
4
  import YAML from "yaml";
5
5
  import { formatWarning, missingSections, parseSteps } from "./format.js";
6
6
  import { atomicWrite } from "./fs-util.js";
7
+ import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
7
8
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
8
9
  import { inboxDir, outboxDir, sharedDir } from "./policy.js";
10
+ import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
9
11
  import { must } from "./assert.js";
10
12
  export { atomicWrite };
11
13
  export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
@@ -258,6 +260,11 @@ export function routeOutboxes(project) {
258
260
  reject(`only the lead ("${project.lead}") may send a "done" message.`);
259
261
  continue;
260
262
  }
263
+ const over = RECORD_KINDS.map((k) => ({ k, n: activeRecordsBytes(project, k) })).filter((x) => x.n > RECORDS_BUDGET);
264
+ if (over.length) {
265
+ reject(`${over.map((x) => `records/${x.k} hold ${x.n} bytes of active entries`).join(" and ")}; the budget is ${RECORDS_BUDGET} bytes (6 KB) per kind. Merge entries, or mark obsolete ones "status: superseded" (or name them in a newer entry's "supersedes"), then send "done" again.`);
266
+ continue;
267
+ }
261
268
  res.done = { from: sender.name, subject, body: parsed.body, file, meta: d };
262
269
  const ds = parseSteps(parsed.body);
263
270
  if (ds.length)
@@ -279,6 +286,15 @@ export function routeOutboxes(project) {
279
286
  reject(why + ".");
280
287
  continue;
281
288
  }
289
+ const missing = missingSections(type, parsed.body);
290
+ if (missing.length && project.run?.layout === "run") {
291
+ const key = formatRejectionKey(sender.name, type, subject);
292
+ if (formatRejections(project.run.dir, key) < MAX_FORMAT_REJECTIONS) {
293
+ const n = countFormatRejection(project.run.dir, key);
294
+ reject(`this ${type} is missing required section(s): ${missing.map((s) => `\`## ${s}\``).join(", ")}. Add them (write \`None\` where a section is empty) and send it again. (Attempt ${n} of ${MAX_FORMAT_REJECTIONS}; after that it is delivered with a warning.)`);
295
+ continue;
296
+ }
297
+ }
282
298
  const replyTo = d.reply_to ? String(d.reply_to) : undefined;
283
299
  const thread = d.thread ? String(d.thread) : undefined;
284
300
  // Mail whose delivery was already planned (a replay after a crash) is not a repeat of itself.
@@ -308,7 +324,6 @@ export function routeOutboxes(project) {
308
324
  });
309
325
  res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
310
326
  }
311
- const missing = missingSections(type, parsed.body);
312
327
  const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
313
328
  if (sender.name === project.lead) {
314
329
  const st = parseSteps(parsed.body);
@@ -84,6 +84,8 @@ export interface TidyResult {
84
84
  status: "tidied" | "skipped" | "failed" | "dry-run";
85
85
  /** The archive stamp of a finished tidy. */
86
86
  archive?: string;
87
+ /** Set when the project records were tidied too: their folder (its archive is `<recordsDir>/.archive/<archive>`). */
88
+ recordsDir?: string;
87
89
  problems: string[];
88
90
  }
89
91
  /** Scratch space of one tidy (snapshot and the invocation's logs); outside every memory dir and every run. */
@@ -96,8 +98,19 @@ export interface RestoreResult {
96
98
  rel: string;
97
99
  reason: string;
98
100
  }[];
101
+ /** The same for the project records archive of that stamp, when the lead's undo found one. */
102
+ records?: {
103
+ restored: string[];
104
+ skipped: {
105
+ rel: string;
106
+ reason: string;
107
+ }[];
108
+ };
99
109
  }
100
- /** Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten. */
110
+ /**
111
+ * Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten.
112
+ * For the lead on the project layer this covers the project records archive of the same stamp too; either archive may be the only one.
113
+ */
101
114
  export declare function restoreMemory(o: {
102
115
  project: ResolvedProject;
103
116
  agent: string;