agent-lyceum 0.22.0 → 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
@@ -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",
@@ -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,6 +17,11 @@ 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;
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." });
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 { mayEditProtected, repoInstructionFiles, sharedContextFiles, 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,6 +27,7 @@ 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();
@@ -86,8 +90,126 @@ export class ProtectedGuard {
86
90
  violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
87
91
  }
88
92
  }
93
+ this.checkRecords(running, violations);
89
94
  return violations;
90
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;
91
213
  }
92
214
  function read(file) {
93
215
  try {
package/dist/mailbox.js CHANGED
@@ -7,6 +7,7 @@ import { atomicWrite } from "./fs-util.js";
7
7
  import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
8
8
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
9
9
  import { inboxDir, outboxDir, sharedDir } from "./policy.js";
10
+ import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
10
11
  import { must } from "./assert.js";
11
12
  export { atomicWrite };
12
13
  export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
@@ -259,6 +260,11 @@ export function routeOutboxes(project) {
259
260
  reject(`only the lead ("${project.lead}") may send a "done" message.`);
260
261
  continue;
261
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
+ }
262
268
  res.done = { from: sender.name, subject, body: parsed.body, file, meta: d };
263
269
  const ds = parseSteps(parsed.body);
264
270
  if (ds.length)
@@ -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;
@@ -5,8 +5,9 @@ import path from "node:path";
5
5
  import { realInvoker } from "./adapters/index.js";
6
6
  import { atomicWrite } from "./fs-util.js";
7
7
  import { ensureProjectDirs } from "./mailbox.js";
8
- import { memoryDirs } from "./policy.js";
8
+ import { commonFile, memoryDirs, recordsDir, repoDirFor, writePolicy } from "./policy.js";
9
9
  import { buildTidyPrompts, MEMORY_INDEX_LIMIT } from "./prompt.js";
10
+ import { activeRecordsBytes, effectiveActive, listRecords, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
10
11
  import { git, isGitRepo } from "./worktree.js";
11
12
  export const ARCHIVE_DIR = ".archive";
12
13
  export const TIDY_STATE_FILE = ".tidy-state.json";
@@ -76,6 +77,10 @@ export function fingerprint(dir) {
76
77
  out[f.rel] = crypto.createHash("sha256").update(fs.readFileSync(path.join(dir, f.rel))).digest("hex");
77
78
  return out;
78
79
  }
80
+ /** The content hashes of every earlier tidy's archive in `dir` (not this tidy's own folder), which no tidy may touch. */
81
+ function earlierArchives(dir, stamp) {
82
+ return Object.fromEntries(Object.entries(fingerprint(dir)).filter(([rel]) => rel.startsWith(`${ARCHIVE_DIR}/`) && !rel.startsWith(`${ARCHIVE_DIR}/${stamp}/`)));
83
+ }
79
84
  const slot = (into, i) => path.join(into, String(i));
80
85
  /** Copy each directory aside (a directory that does not exist is remembered as absent). */
81
86
  export function snapshotDirs(dirs, into) {
@@ -199,34 +204,97 @@ function describeTarget(t, project, say) {
199
204
  for (const m of findMissingPaths(t.dir, t.layer === "project" ? project.dir : undefined))
200
205
  say(` missing path: ${m.path} (in ${m.file})`);
201
206
  }
202
- async function tidyOne(o, t, stamp) {
207
+ /** The live files of the project records (the archive is not listed). */
208
+ const liveRecordFiles = (project) => (isPlainDir(recordsDir(project)) ? listMemoryFiles(recordsDir(project)).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`)) : []);
209
+ function isPlainDir(p) {
210
+ try {
211
+ return fs.lstatSync(p).isDirectory();
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ function describeRecords(project, say) {
218
+ const live = liveRecordFiles(project);
219
+ const bytes = live.reduce((n, f) => n + f.bytes, 0);
220
+ const active = RECORD_KINDS.map((k) => `${k} ${effectiveActive(listRecords(project, k)).length}`).join(", ");
221
+ say(`records ${recordsDir(project)}: ${live.length} file(s), ${kb(bytes)}; active: ${active}`);
222
+ }
223
+ /** The lead's wake-up also tidies the project records, in a default project-layer tidy or with `--agent <lead>`. */
224
+ const recordsInScope = (o) => o.layer === "project" && (!o.agent || o.agent === o.project.lead);
225
+ function recordsContext(project, archiveDir, live) {
226
+ const dir = recordsDir(project);
227
+ const entries = [];
228
+ const known = new Set();
229
+ for (const kind of RECORD_KINDS)
230
+ for (const r of listRecords(project, kind)) {
231
+ const rel = `${kind}/${path.basename(r.file)}`;
232
+ known.add(rel);
233
+ entries.push({ kind, id: r.id, rel, status: r.status, date: r.date, bytes: live.find((f) => f.rel === rel)?.bytes ?? 0, title: r.title, invalid: r.invalid });
234
+ }
235
+ return { dir, archiveDir, entries, otherFiles: live.filter((f) => !known.has(f.rel)) };
236
+ }
237
+ const hasReport = (archiveDir) => {
238
+ const f = path.join(archiveDir, TIDY_REPORT_FILE);
239
+ return fs.existsSync(f) && fs.readFileSync(f, "utf8").trim() !== "";
240
+ };
241
+ /** Tidy one agent's memory directory (`dir`, if it has files) and, for the lead, the project records, in one wake-up. */
242
+ async function tidyOne(o, t, stamp, withRecords) {
203
243
  const { project } = o;
204
244
  const say = o.say ?? ((s) => console.log(s));
205
- const base = { agent: t.agent, layer: t.layer, dir: t.dir };
245
+ const base = { agent: t.agent, layer: t.layer, dir: t.dir ?? recordsDir(project) };
206
246
  const agent = project.agents[t.agent];
207
- const live = listMemoryFiles(t.dir).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`));
208
- if (live.length === 0)
247
+ const live = t.dir ? listMemoryFiles(t.dir).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`)) : [];
248
+ const recLive = withRecords ? liveRecordFiles(project) : [];
249
+ const memPart = live.length > 0;
250
+ const recPart = recLive.length > 0;
251
+ if (!memPart && !recPart)
209
252
  return { ...base, status: "skipped", problems: ["no memory files: nothing to tidy"] };
210
- const dirs = memoryDirs(agent);
211
- const others = dirs.filter((d) => d !== t.dir);
212
- const before = listMemoryFiles(t.dir).map((f) => f.rel);
253
+ const recDir = recordsDir(project);
254
+ const dirs = [...memoryDirs(agent), ...(recPart ? [recDir] : [])];
255
+ const others = dirs.filter((d) => d !== t.dir && d !== recDir);
256
+ const before = t.dir && memPart ? listMemoryFiles(t.dir).map((f) => f.rel) : [];
257
+ const recBefore = recPart ? listMemoryFiles(recDir).map((f) => f.rel) : [];
258
+ const watched = [commonFile(project)].filter((f) => fs.existsSync(f));
259
+ const watchedBefore = watched.map((f) => fs.readFileSync(f, "utf8"));
213
260
  const othersBefore = others.map((d) => fingerprint(d));
214
- const archiveDir = path.join(t.dir, ARCHIVE_DIR, stamp);
261
+ const archivesBefore = { mem: t.dir && memPart ? earlierArchives(t.dir, stamp) : {}, rec: recPart ? earlierArchives(recDir, stamp) : {} };
262
+ const archiveDir = t.dir ? path.join(t.dir, ARCHIVE_DIR, stamp) : undefined;
263
+ const recArchive = path.join(recDir, ARCHIVE_DIR, stamp);
215
264
  const workDir = tidyWorkDir(project, stamp, t.agent);
216
265
  const snapshot = path.join(workDir, "snapshot");
217
- const state = readTidyState(t.dir);
218
- const changes = t.layer === "project" ? projectChanges(project.dir, state?.head) : undefined;
219
- const missing = findMissingPaths(t.dir, t.layer === "project" ? project.dir : undefined);
220
- const index = fs.existsSync(path.join(t.dir, "MEMORY.md")) ? fs.readFileSync(path.join(t.dir, "MEMORY.md"), "utf8") : "";
221
- const prompts = buildTidyPrompts(agent, { layer: t.layer, dir: t.dir, archiveDir, files: live, index, changes, missing });
266
+ const state = t.dir ? readTidyState(t.dir) : undefined;
267
+ const changes = memPart && t.layer === "project" ? projectChanges(project.dir, state?.head) : undefined;
268
+ const missing = memPart && t.dir ? findMissingPaths(t.dir, t.layer === "project" ? project.dir : undefined) : [];
269
+ const index = t.dir && fs.existsSync(path.join(t.dir, "MEMORY.md")) ? fs.readFileSync(path.join(t.dir, "MEMORY.md"), "utf8") : "";
270
+ const prompts = buildTidyPrompts(agent, {
271
+ layer: t.layer,
272
+ ...(memPart ? { dir: t.dir, archiveDir } : {}),
273
+ files: live,
274
+ index,
275
+ changes,
276
+ missing,
277
+ ...(recPart ? { records: recordsContext(project, recArchive, recLive) } : {}),
278
+ });
222
279
  ensureProjectDirs(project);
223
280
  snapshotDirs(dirs, snapshot);
224
281
  const fail = (problems) => {
225
282
  restoreDirs(dirs, snapshot);
283
+ for (const [i, f] of watched.entries())
284
+ atomicWrite(f, watchedBefore[i]);
226
285
  fs.rmSync(snapshot, { recursive: true, force: true });
227
- return { ...base, status: "failed", problems };
286
+ return { ...base, status: "failed", ...(recPart ? { recordsDir: recDir } : {}), problems };
287
+ };
288
+ // The runtime may write only the directories being tidied (and read what it could read before): not the repo, outbox or shared files.
289
+ const normal = writePolicy(project, agent);
290
+ const allowDirs = [...(memPart && t.dir ? [t.dir] : []), ...(recPart ? [recDir] : [])];
291
+ const policy = {
292
+ allowDirs,
293
+ allowFiles: [],
294
+ readDirs: [...new Set([repoDirFor(project, t.agent), ...normal.readDirs, ...normal.allowDirs.filter((d) => !allowDirs.includes(d)), ...normal.allowFiles.map((f) => path.dirname(f))])],
295
+ deny: normal.deny,
228
296
  };
229
- say(`tidying ${t.layer} memory of ${t.agent} (${live.length} file(s)) …`);
297
+ say(`tidying ${t.layer} memory of ${t.agent} (${live.length} file(s)${recPart ? `, ${recLive.length} project record file(s)` : ""}) …`);
230
298
  const invoke = o.invoker ?? realInvoker;
231
299
  const result = await invoke({
232
300
  project,
@@ -237,36 +305,76 @@ async function tidyOne(o, t, stamp) {
237
305
  timeoutSec: project.dispatcher.wake_timeout_sec,
238
306
  signal: o.signal,
239
307
  logDir: path.join(workDir, "log"),
308
+ policy,
240
309
  }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message }));
241
310
  if (!result.ok)
242
311
  return fail([`the wake-up failed: ${result.error ?? "unknown error"}`]);
243
- const problems = [];
244
- others.forEach((d, i) => {
245
- if (JSON.stringify(fingerprint(d)) !== JSON.stringify(othersBefore[i]))
246
- problems.push(`the agent changed ${d}, which is not the ${t.layer} memory being tidied`);
247
- });
248
- problems.push(...verifyConservation(before, t.dir, stamp));
249
- const reportFile = path.join(archiveDir, TIDY_REPORT_FILE);
250
- if (!fs.existsSync(reportFile) || fs.readFileSync(reportFile, "utf8").trim() === "")
251
- problems.push(`${ARCHIVE_DIR}/${stamp}/${TIDY_REPORT_FILE} is missing or empty`);
312
+ // Anything that throws from here on (a report path that is a directory, a state file that cannot be written) is a failed tidy that is rolled back.
313
+ let problems = [];
314
+ try {
315
+ others.forEach((d, i) => {
316
+ if (JSON.stringify(fingerprint(d)) !== JSON.stringify(othersBefore[i]))
317
+ problems.push(`the agent changed ${d}, which is not the ${t.layer} memory being tidied`);
318
+ });
319
+ watched.forEach((f, i) => {
320
+ if (!fs.existsSync(f) || fs.readFileSync(f, "utf8") !== watchedBefore[i])
321
+ problems.push(`the agent changed ${f}, which a tidy may not touch`);
322
+ });
323
+ if (memPart && t.dir && archiveDir) {
324
+ if (JSON.stringify(earlierArchives(t.dir, stamp)) !== JSON.stringify(archivesBefore.mem))
325
+ problems.push(`the agent changed an earlier archive under ${path.join(t.dir, ARCHIVE_DIR)}`);
326
+ problems.push(...verifyConservation(before, t.dir, stamp));
327
+ if (!hasReport(archiveDir))
328
+ problems.push(`${ARCHIVE_DIR}/${stamp}/${TIDY_REPORT_FILE} is missing or empty`);
329
+ }
330
+ if (recPart) {
331
+ if (JSON.stringify(earlierArchives(recDir, stamp)) !== JSON.stringify(archivesBefore.rec))
332
+ problems.push(`project records: the agent changed an earlier archive under ${path.join(recDir, ARCHIVE_DIR)}`);
333
+ problems.push(...verifyConservation(recBefore, recDir, stamp).map((p) => `project records: ${p}`));
334
+ if (!hasReport(recArchive))
335
+ problems.push(`project records: ${ARCHIVE_DIR}/${stamp}/${TIDY_REPORT_FILE} is missing or empty`);
336
+ }
337
+ if (!problems.length && memPart && t.dir)
338
+ writeTidyState(t.dir, { ...(changes?.head ? { head: changes.head } : {}), at: (o.now?.() ?? new Date()).toISOString(), archive: stamp });
339
+ }
340
+ catch (e) {
341
+ problems = [...problems, `the tidy could not be checked or finished: ${e.message}`];
342
+ }
252
343
  if (problems.length)
253
344
  return fail(problems);
254
345
  fs.rmSync(snapshot, { recursive: true, force: true });
255
- writeTidyState(t.dir, { ...(changes?.head ? { head: changes.head } : {}), at: (o.now?.() ?? new Date()).toISOString(), archive: stamp });
256
- return { ...base, status: "tidied", archive: stamp, problems: [] };
346
+ return { ...base, status: "tidied", archive: stamp, ...(recPart ? { recordsDir: recDir } : {}), problems: [] };
257
347
  }
258
348
  /** Tidy the memory of the chosen agents, one after another. Only ever called by a person (`agent-lyceum tidy`). */
259
349
  export async function tidyMemory(o) {
260
350
  const say = o.say ?? ((s) => console.log(s));
261
- const targets = targetsFor(o.project, { layer: o.layer, agent: o.agent });
351
+ const withRecords = recordsInScope(o) && liveRecordFiles(o.project).length > 0;
352
+ const lead = o.project.lead;
353
+ let targets;
354
+ try {
355
+ targets = targetsFor(o.project, { layer: o.layer, agent: o.agent });
356
+ }
357
+ catch (e) {
358
+ // `--agent <lead>` with no project memory directory is fine when there are records to tidy.
359
+ if (!(withRecords && o.agent === lead && o.project.agents[lead]))
360
+ throw e;
361
+ targets = [];
362
+ }
262
363
  const results = [];
263
- for (const t of targets) {
364
+ const stamp = tidyStamp(o.now?.()); // one per tidy, for every agent
365
+ const jobs = targets.map((t) => ({ ...t }));
366
+ if (withRecords && !jobs.some((j) => j.agent === lead))
367
+ jobs.push({ agent: lead, layer: o.layer, dir: undefined });
368
+ for (const j of jobs) {
264
369
  if (o.dryRun) {
265
- describeTarget(t, o.project, say);
266
- results.push({ agent: t.agent, layer: t.layer, dir: t.dir, status: "dry-run", problems: [] });
370
+ if (j.dir)
371
+ describeTarget(j, o.project, say);
372
+ if (withRecords && j.agent === lead)
373
+ describeRecords(o.project, say);
374
+ results.push({ agent: j.agent, layer: j.layer, dir: j.dir ?? recordsDir(o.project), status: "dry-run", problems: [] });
267
375
  continue;
268
376
  }
269
- const r = await tidyOne(o, t, tidyStamp(o.now?.()));
377
+ const r = await tidyOne(o, j, stamp, withRecords && j.agent === lead);
270
378
  results.push(r);
271
379
  if (o.signal?.aborted)
272
380
  break;
@@ -289,20 +397,13 @@ function pruneEmpty(dir, stop) {
289
397
  fs.rmdirSync(d);
290
398
  }
291
399
  }
292
- /** Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten. */
293
- export function restoreMemory(o) {
294
- if (!STAMP_RE.test(o.stamp))
295
- throw new Error(`"${o.stamp}" is not an archive stamp (expected something like 20261008T031500Z).`);
296
- const [t] = targetsFor(o.project, { layer: o.layer, agent: o.agent });
297
- const archive = path.join(t.dir, ARCHIVE_DIR, o.stamp);
298
- if (!fs.existsSync(archive))
299
- throw new Error(`No archive ${o.stamp} for ${o.agent} (${o.layer}). Archives: ${listArchives(t.dir).join(", ") || "(none)"}.`);
400
+ /** Move the files of `archive` back under `dir`. Nothing that exists now is overwritten. */
401
+ function moveBack(dir, archive) {
300
402
  const restored = [];
301
403
  const skipped = [];
302
- const inArchive = listMemoryFiles(archive).filter((f) => f.rel !== TIDY_REPORT_FILE);
303
- for (const f of inArchive) {
404
+ for (const f of listMemoryFiles(archive).filter((f) => f.rel !== TIDY_REPORT_FILE)) {
304
405
  const from = path.join(archive, f.rel);
305
- const to = path.join(t.dir, f.rel);
406
+ const to = path.join(dir, f.rel);
306
407
  if (fs.existsSync(to)) {
307
408
  skipped.push({ rel: f.rel, reason: "a file with this name exists now; it was not overwritten" });
308
409
  continue;
@@ -312,13 +413,43 @@ export function restoreMemory(o) {
312
413
  pruneEmpty(path.dirname(from), archive);
313
414
  restored.push(f.rel);
314
415
  }
315
- const indexFile = path.join(t.dir, "MEMORY.md");
316
- const index = fs.existsSync(indexFile) ? fs.readFileSync(indexFile, "utf8") : "";
317
- const add = restored.filter((rel) => rel.endsWith(".md") && !index.includes(rel)).map((rel) => `- [${titleOf(path.join(t.dir, rel), rel)}](${rel}) — restored from tidy ${o.stamp}`);
318
- if (add.length)
319
- atomicWrite(indexFile, `${index.replace(/\n*$/, index ? "\n" : "")}${add.join("\n")}\n`);
320
416
  return { restored, skipped };
321
417
  }
418
+ /**
419
+ * Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten.
420
+ * 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.
421
+ */
422
+ export function restoreMemory(o) {
423
+ if (!STAMP_RE.test(o.stamp))
424
+ throw new Error(`"${o.stamp}" is not an archive stamp (expected something like 20261008T031500Z).`);
425
+ const isLead = o.layer === "project" && o.agent === o.project.lead;
426
+ const recDir = recordsDir(o.project);
427
+ const recArchive = path.join(recDir, ARCHIVE_DIR, o.stamp);
428
+ const recHas = isLead && isPlainDir(recDir) && fs.existsSync(recArchive);
429
+ let dir;
430
+ try {
431
+ dir = targetsFor(o.project, { layer: o.layer, agent: o.agent })[0].dir;
432
+ }
433
+ catch (e) {
434
+ if (!(isLead && o.project.agents[o.agent]))
435
+ throw e;
436
+ }
437
+ const archive = dir ? path.join(dir, ARCHIVE_DIR, o.stamp) : undefined;
438
+ const memHas = !!archive && fs.existsSync(archive);
439
+ if (!memHas && !recHas) {
440
+ const recs = isLead ? `${dir ? "; project records archives: " : " Project records archives: "}${listArchives(recDir).join(", ") || "(none)"}` : "";
441
+ throw new Error(`No archive ${o.stamp} for ${o.agent} (${o.layer}).${dir ? ` Archives: ${listArchives(dir).join(", ") || "(none)"}` : ""}${recs}.`);
442
+ }
443
+ const mem = dir && memHas ? moveBack(dir, archive) : { restored: [], skipped: [] };
444
+ if (dir && memHas) {
445
+ const indexFile = path.join(dir, "MEMORY.md");
446
+ const index = fs.existsSync(indexFile) ? fs.readFileSync(indexFile, "utf8") : "";
447
+ const add = mem.restored.filter((rel) => rel.endsWith(".md") && !index.includes(rel)).map((rel) => `- [${titleOf(path.join(dir, rel), rel)}](${rel}) — restored from tidy ${o.stamp}`);
448
+ if (add.length)
449
+ atomicWrite(indexFile, `${index.replace(/\n*$/, index ? "\n" : "")}${add.join("\n")}\n`);
450
+ }
451
+ return { ...mem, ...(recHas ? { records: moveBack(recDir, recArchive) } : {}) };
452
+ }
322
453
  /** The stamps of the archives in a memory directory, oldest first. */
323
454
  export function listArchives(dir) {
324
455
  try {
@@ -347,5 +478,10 @@ export function tidyAdvice(project) {
347
478
  if (Number.isFinite(n) && n >= ADVICE_COMMITS)
348
479
  out.push({ agent: t.agent, reason: `${n} commits since the last tidy` });
349
480
  }
481
+ for (const kind of RECORD_KINDS) {
482
+ const bytes = activeRecordsBytes(project, kind);
483
+ if (bytes > RECORDS_BUDGET)
484
+ out.push({ agent: project.lead, reason: `active ${kind} records are ${bytes} bytes, more than the ${RECORDS_BUDGET} an agent is shown (\`done\` is refused while over)` });
485
+ }
350
486
  return out;
351
487
  }
package/dist/policy.d.ts CHANGED
@@ -23,11 +23,9 @@ export interface WritePolicy {
23
23
  deny: string[];
24
24
  }
25
25
  export declare function commonFile(project: ResolvedProject): string;
26
- /** Durable decisions and lessons of the project, kept next to COMMON.md: the lead writes them, everyone else reads them. */
27
- export declare function decisionsFile(project: ResolvedProject): string;
28
- export declare function lessonsFile(project: ResolvedProject): string;
29
- /** Where the lead moves entries that no longer fit under the prompt cap; read on demand, never injected. */
30
- export declare function archiveFile(file: string): string;
26
+ /** Durable decisions and lessons of the project, one file per entry: the lead writes them, everyone else reads them. `.archive/` is tidy's output, never shown in prompts. */
27
+ export declare function recordsDir(project: ResolvedProject): string;
28
+ export declare function recordsKindDir(project: ResolvedProject, kind: "decisions" | "lessons"): string;
31
29
  /** The lead-maintained files shared by every run of the project. */
32
30
  export declare function sharedContextFiles(project: ResolvedProject): string[];
33
31
  export declare function outboxDir(project: ResolvedProject, agent: string): string;
@@ -41,6 +39,8 @@ export declare const SHARED_DIR = "_shared";
41
39
  export declare function sharedDir(project: ResolvedProject): string | undefined;
42
40
  /** The index of the handoff notes: the one file shown in prompts, and the one the guard watches. */
43
41
  export declare function sharedIndex(project: ResolvedProject): string | undefined;
42
+ /** The working directory of a wake-up that runs under a policy override: an empty dir, so nothing of the repo is writable by default. */
43
+ export declare const scratchCwd: (workDir: string) => string;
44
44
  /** The repo directory this agent works in: its own worktree, or the main repo. */
45
45
  export declare function repoDirFor(project: ResolvedProject, agent: string): string;
46
46
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
package/dist/policy.js CHANGED
@@ -4,21 +4,16 @@ import { findOnPath } from "./fs-util.js";
4
4
  export function commonFile(project) {
5
5
  return path.join(project.paths.shared, "common", "COMMON.md");
6
6
  }
7
- /** Durable decisions and lessons of the project, kept next to COMMON.md: the lead writes them, everyone else reads them. */
8
- export function decisionsFile(project) {
9
- return path.join(project.paths.shared, "common", "DECISIONS.md");
7
+ /** Durable decisions and lessons of the project, one file per entry: the lead writes them, everyone else reads them. `.archive/` is tidy's output, never shown in prompts. */
8
+ export function recordsDir(project) {
9
+ return path.join(project.paths.shared, "common", "records");
10
10
  }
11
- export function lessonsFile(project) {
12
- return path.join(project.paths.shared, "common", "LESSONS.md");
13
- }
14
- /** Where the lead moves entries that no longer fit under the prompt cap; read on demand, never injected. */
15
- export function archiveFile(file) {
16
- return file.replace(/\.md$/, "_ARCHIVE.md");
11
+ export function recordsKindDir(project, kind) {
12
+ return path.join(recordsDir(project), kind);
17
13
  }
18
14
  /** The lead-maintained files shared by every run of the project. */
19
15
  export function sharedContextFiles(project) {
20
- const [d, l] = [decisionsFile(project), lessonsFile(project)];
21
- return [commonFile(project), d, l, archiveFile(d), archiveFile(l)];
16
+ return [commonFile(project)];
22
17
  }
23
18
  export function outboxDir(project, agent) {
24
19
  return path.join(project.paths.outboxRoot, agent);
@@ -74,6 +69,8 @@ function runDenyPaths(project) {
74
69
  ...[...MAIL_ENTRIES, ...entries(mail)].filter((e) => e !== "outbox").map((e) => path.join(mail, e)),
75
70
  ]);
76
71
  }
72
+ /** The working directory of a wake-up that runs under a policy override: an empty dir, so nothing of the repo is writable by default. */
73
+ export const scratchCwd = (workDir) => path.join(workDir, "cwd");
77
74
  /** The repo directory this agent works in: its own worktree, or the main repo. */
78
75
  export function repoDirFor(project, agent) {
79
76
  return project.workspaces?.[agent]?.dir ?? project.dir;
@@ -90,6 +87,7 @@ export function writePolicy(project, agent) {
90
87
  ...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]),
91
88
  ...(ws ? [ws.gitDir] : []),
92
89
  ...(shared && mayWriteShared ? [shared] : []),
90
+ ...(isLead ? [recordsDir(project)] : []),
93
91
  ];
94
92
  const allowFiles = [];
95
93
  if (isLead)
@@ -122,6 +120,8 @@ export function writePolicy(project, agent) {
122
120
  addDeny(f);
123
121
  if (shared && !mayWriteShared)
124
122
  deny.push(shared);
123
+ if (!isLead)
124
+ deny.push(recordsDir(project));
125
125
  deny.push(...runDenyPaths(project), project.paths.config, path.join(project.home, "team.yaml"));
126
126
  // In a worktree: other agents' checkouts are off limits, and so are this checkout's own copies of the protected files.
127
127
  for (const [name, other] of Object.entries(project.workspaces ?? {}))
@@ -136,7 +136,7 @@ export function writePolicy(project, agent) {
136
136
  deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
137
137
  return {
138
138
  allowDirs,
139
- readDirs: shared && !mayWriteShared ? [shared] : [],
139
+ readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)])],
140
140
  allowFiles,
141
141
  deny: [...new Set(deny)],
142
142
  };
package/dist/prompt.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-ti
4
4
  export declare const COMMON_LIMIT: number;
5
5
  export declare const MEMORY_INDEX_LIMIT: number;
6
6
  export declare const HANDOFF_LIMIT: number;
7
- /** Cap of each of DECISIONS.md and LESSONS.md in a prompt. */
7
+ /** Cap of each kind of project record (decisions, lessons) in a prompt. */
8
8
  export declare const CONTEXT_LIMIT: number;
9
9
  export declare const LEAD_BATCH_LIMIT = 5;
10
10
  export declare function readPersona(agent: ResolvedAgent): string;
@@ -17,12 +17,31 @@ export declare function buildSystemPrompt(project: ResolvedProject, agent: Resol
17
17
  /** 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. */
18
18
  export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
19
19
  export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number, handoff?: boolean): string;
20
+ export interface TidyRecordsContext {
21
+ /** The project records folder (`<shared>/common/records`). */
22
+ dir: string;
23
+ /** Absolute path of this tidy's archive folder for the records (the agent creates it). */
24
+ archiveDir: string;
25
+ /** One line per entry: kind, id, status, date, size and title (the archive is not listed). */
26
+ entries: {
27
+ kind: string;
28
+ id: string;
29
+ rel: string;
30
+ status: string;
31
+ date: string;
32
+ bytes: number;
33
+ title: string;
34
+ invalid?: string;
35
+ }[];
36
+ /** Live files that are not entries (kept as they are). */
37
+ otherFiles: MemoryFile[];
38
+ }
20
39
  export interface TidyContext {
21
40
  layer: Layer;
22
- /** The memory directory being tidied. */
23
- dir: string;
41
+ /** The memory directory being tidied; absent when only the project records are tidied (the lead has no memory directory). */
42
+ dir?: string;
24
43
  /** Absolute path of this tidy's archive folder (the agent creates it). */
25
- archiveDir: string;
44
+ archiveDir?: string;
26
45
  /** Live files only (the archive is not listed). */
27
46
  files: MemoryFile[];
28
47
  /** MEMORY.md text ("" when there is none). */
@@ -30,6 +49,8 @@ export interface TidyContext {
30
49
  /** What changed in the project since the last tidy (project layer only). */
31
50
  changes?: ProjectChanges;
32
51
  missing: MissingPath[];
52
+ /** Set only for the lead's project-layer tidy: the project records are tidied in the same wake-up. */
53
+ records?: TidyRecordsContext;
33
54
  }
34
55
  /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
35
56
  export declare function buildTidyPrompts(agent: ResolvedAgent, ctx: TidyContext): {
package/dist/prompt.js CHANGED
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import { readFileCapped } from "./fs-util.js";
3
3
  import path from "node:path";
4
4
  import { REQUIRED_SECTIONS } from "./format.js";
5
- import { archiveFile, commonFile, decisionsFile, lessonsFile, memoryDirs, outboxDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
5
+ import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
6
+ import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
6
7
  function memoryLabel(agent, dir) {
7
8
  if (dir === agent.memory.task)
8
9
  return "task (this task only, isolated)";
@@ -11,8 +12,8 @@ function memoryLabel(agent, dir) {
11
12
  export const COMMON_LIMIT = 8 * 1024;
12
13
  export const MEMORY_INDEX_LIMIT = 4 * 1024;
13
14
  export const HANDOFF_LIMIT = COMMON_LIMIT;
14
- /** Cap of each of DECISIONS.md and LESSONS.md in a prompt. */
15
- export const CONTEXT_LIMIT = 6 * 1024;
15
+ /** Cap of each kind of project record (decisions, lessons) in a prompt. */
16
+ export const CONTEXT_LIMIT = RECORDS_BUDGET;
16
17
  export const LEAD_BATCH_LIMIT = 5;
17
18
  const BATCHABLE = new Set(["reply", "failure"]);
18
19
  const readCapped = readFileCapped;
@@ -134,7 +135,7 @@ export function buildSystemPrompt(project, agent) {
134
135
  lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
135
136
  (isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`, ...(isLead
136
137
  ? [
137
- `- You also keep two durable records for every future run of this project: \`${decisionsFile(project)}\` (what was decided and why; a new decision replaces the one it contradicts) and \`${lessonsFile(project)}\` (heuristics and pitfalls that stay true). Update them before you send \`done\`. Each is shown in prompts only up to ${CONTEXT_LIMIT / 1024} KB: move the oldest entries into \`${archiveFile(decisionsFile(project))}\` / \`${archiveFile(lessonsFile(project))}\` (read on demand) to keep them under it.`,
138
+ `- 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\`.`,
138
139
  ]
139
140
  : []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
140
141
  return lines.join("\n");
@@ -168,16 +169,13 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
168
169
  parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
169
170
  }
170
171
  }
171
- for (const [title, file] of [
172
- ["Project decisions (DECISIONS.md)", decisionsFile(project)],
173
- ["Project lessons (LESSONS.md)", lessonsFile(project)],
172
+ for (const [title, kind] of [
173
+ ["Project decisions", "decisions"],
174
+ ["Project lessons", "lessons"],
174
175
  ]) {
175
- const rec = readCapped(file, CONTEXT_LIMIT);
176
- if (!rec?.text.trim())
177
- continue; // nothing recorded yet: no heading, no tokens
178
- parts.push("", `# ${title}`, rec.text.trim());
179
- if (rec.truncated)
180
- parts.push(`\n[${path.basename(file)} was truncated at ${CONTEXT_LIMIT} bytes — the lead should move the oldest entries to ${path.basename(archiveFile(file))}.]`);
176
+ const view = buildRecordsView(project, kind, CONTEXT_LIMIT);
177
+ if (view)
178
+ parts.push("", `# ${title}`, view); // nothing recorded yet: no heading, no tokens
181
179
  }
182
180
  const handoffFile = handoff ? sharedIndex(project) : undefined;
183
181
  const notes = handoffFile ? readCapped(handoffFile, HANDOFF_LIMIT) : undefined;
@@ -219,6 +217,8 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
219
217
  }
220
218
  /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
221
219
  export function buildTidyPrompts(agent, ctx) {
220
+ const { dir, archiveDir, records } = ctx;
221
+ const touch = [dir ? `your ${ctx.layer} memory (\`${dir}\`)` : "", records ? `the project records (\`${records.dir}\`)` : ""].filter(Boolean).join(" and ");
222
222
  const systemPrompt = [
223
223
  readPersona(agent),
224
224
  "",
@@ -226,35 +226,46 @@ export function buildTidyPrompts(agent, ctx) {
226
226
  "",
227
227
  "# Memory tidy (injected by agent-lyceum)",
228
228
  "",
229
- `You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy your own ${ctx.layer} memory so it stays small, accurate and useful.`,
229
+ `You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy ${records ? touch : `your own ${ctx.layer} memory`} so it stays small, accurate and useful.`,
230
230
  "",
231
231
  "## What you may touch",
232
- `Only files inside \`${ctx.dir}\`. Everything else is read-only. Do not touch your other memory directories, \`${ctx.dir}/.tidy-state.json\` or the archive folders of earlier tidies.`,
233
- "",
234
- "## What to do",
235
- "- Merge entries that say the same thing; keep one file with the combined, current content.",
236
- "- Rewrite entries that are stale (see the project changes and the missing paths below); correct them if you can tell what is true now, otherwise keep them and say so in the report.",
237
- "- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
238
- "- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
239
- "",
240
- "## Never delete",
241
- `To retire a file, move it into the archive folder, keeping its relative path (\`${ctx.dir}/x/y.md\` goes to \`${ctx.archiveDir}/x/y.md\`). Never delete a file. \`MEMORY.md\` itself is never archived. A file whose content you merged into another one is archived too.`,
242
- "",
243
- "## Before you finish",
244
- `Write \`${ctx.archiveDir}/tidy-report.md\` — even when you changed nothing — with these headings: \`## Merged\` (what was combined into what), \`## Archived\` (each archived file and why), \`## Unsure\` (things you kept but doubt). Write \`None\` under a heading with nothing to say. The tidy is rolled back if the report is missing or any file went missing.`,
232
+ `Only files inside ${[dir ? `\`${dir}\`` : "", records ? `\`${records.dir}\`` : ""].filter(Boolean).join(" and ")}. Everything else is read-only. Do not touch your other memory directories, \`${dir ? `${dir}/` : ""}.tidy-state.json\` or the archive folders of earlier tidies.`,
233
+ ...(dir && archiveDir
234
+ ? [
235
+ "",
236
+ records ? "## What to do with your memory" : "## What to do",
237
+ "- Merge entries that say the same thing; keep one file with the combined, current content.",
238
+ "- Rewrite entries that are stale (see the project changes and the missing paths below); correct them if you can tell what is true now, otherwise keep them and say so in the report.",
239
+ "- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
240
+ "- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
241
+ "",
242
+ "## Never delete",
243
+ `To retire a file, move it into the archive folder, keeping its relative path (\`${dir}/x/y.md\` goes to \`${archiveDir}/x/y.md\`). Never delete a file. \`MEMORY.md\` itself is never archived. A file whose content you merged into another one is archived too.`,
244
+ "",
245
+ records ? "## Before you finish (memory)" : "## Before you finish",
246
+ `Write \`${archiveDir}/tidy-report.md\` — even when you changed nothing — with these headings: \`## Merged\` (what was combined into what), \`## Archived\` (each archived file and why), \`## Unsure\` (things you kept but doubt). Write \`None\` under a heading with nothing to say. The tidy is rolled back if the report is missing or any file went missing.`,
247
+ ]
248
+ : []),
249
+ ...(records
250
+ ? [
251
+ "",
252
+ "## What to do with the project records",
253
+ `The records are the project's durable decisions and lessons, one file per entry (\`${records.dir}/decisions/<id>.md\`, \`${records.dir}/lessons/<id>.md\`). Every entry has frontmatter with \`status\` (\`active\` or \`superseded\`), \`date\` (YYYY-MM-DD) and \`title\` (one line), optionally \`run\` and \`supersedes\` (a list of ids).`,
254
+ "- Merge entries that say the same thing into one entry (write the merged content, then archive the entries that were merged).",
255
+ "- When a newer entry replaces an older one, list the old id under `supersedes` in the newer entry, or set the old entry to `status: superseded`.",
256
+ "- Archive entries that are obsolete.",
257
+ "- Keep the frontmatter valid: `status`, `date` and `title` are required; the id is the file name without `.md` and uses only lowercase letters, digits and `-`.",
258
+ "- **When unsure, keep it.** Never delete a file.",
259
+ `To archive a record, move it into \`${records.archiveDir}\`, keeping its relative path (\`${records.dir}/lessons/x.md\` goes to \`${records.archiveDir}/lessons/x.md\`).`,
260
+ `Write \`${records.archiveDir}/tidy-report.md\` — even when you changed nothing — with the headings \`## Merged\`, \`## Archived\`, \`## Unsure\` (\`None\` when empty). The tidy of both folders is rolled back if the report is missing or any file went missing.`,
261
+ ]
262
+ : []),
245
263
  ].join("\n");
246
- const parts = [
247
- `Archive directory: ${ctx.archiveDir}`,
248
- `Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
249
- `Memory directory: ${ctx.dir}`,
250
- "",
251
- "# Files",
252
- ...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
253
- "",
254
- `# MEMORY.md (${ctx.dir}/MEMORY.md)`,
255
- ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
256
- ];
257
- if (ctx.layer === "project") {
264
+ const parts = [];
265
+ if (dir && archiveDir) {
266
+ parts.push(`Archive directory: ${archiveDir}`, `Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`, `Memory directory: ${dir}`, "", "# Files", ...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]), "", `# MEMORY.md (${dir}/MEMORY.md)`, ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)");
267
+ }
268
+ if (ctx.layer === "project" && dir) {
258
269
  const c = ctx.changes;
259
270
  parts.push("", "# Changes in the project since the last tidy");
260
271
  if (!c)
@@ -268,7 +279,14 @@ export function buildTidyPrompts(agent, ctx) {
268
279
  parts.push("", "Files changed:", ...c.stat);
269
280
  }
270
281
  }
271
- parts.push("", "# Paths named in the memory that no longer exist");
272
- parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
282
+ if (dir) {
283
+ parts.push("", "# Paths named in the memory that no longer exist");
284
+ parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
285
+ }
286
+ if (records) {
287
+ parts.push(...(parts.length ? [""] : []), "# Project records", `Records archive directory: ${records.archiveDir}`, `Records directory: ${records.dir}`, "", "Entries (kind/id status date size title):", ...(records.entries.length
288
+ ? records.entries.map((e) => `${e.rel} ${e.status} ${e.date || "no date"} ${e.bytes} B ${e.title}${e.invalid ? ` [INVALID: ${e.invalid}]` : ""}`)
289
+ : ["(none)"]), ...(records.otherFiles.length ? ["", "Other files (leave them alone):", ...records.otherFiles.map((f) => `${f.rel} ${f.bytes} B`)] : []));
290
+ }
273
291
  return { systemPrompt, userPrompt: parts.join("\n") };
274
292
  }
@@ -0,0 +1,30 @@
1
+ import type { ResolvedProject } from "./config.js";
2
+ export type RecordKind = "decisions" | "lessons";
3
+ export declare const RECORD_KINDS: readonly RecordKind[];
4
+ /** Cap of one kind's effective-active entries in a prompt (and, for `done`, the most they may add up to). */
5
+ export declare const RECORDS_BUDGET: number;
6
+ export interface ProjectRecord {
7
+ kind: RecordKind;
8
+ id: string;
9
+ file: string;
10
+ status: "active" | "superseded";
11
+ /** `YYYY-MM-DD`; empty when the frontmatter is invalid. */
12
+ date: string;
13
+ run?: string;
14
+ supersedes: string[];
15
+ title: string;
16
+ body: string;
17
+ /** Why the entry could not be read as a record; set entries still count as active so nothing silently disappears. */
18
+ invalid?: string;
19
+ }
20
+ /** Every `*.md` entry of one kind, sorted by id. Entries that cannot be parsed are listed with `invalid` set; a missing folder is an empty list. */
21
+ export declare function listRecords(project: ResolvedProject, kind: RecordKind): ProjectRecord[];
22
+ /** Entries shown in prompts: `status: active` and not named in another entry's `supersedes`. */
23
+ export declare function effectiveActive(records: ProjectRecord[]): ProjectRecord[];
24
+ /** Bytes the effective-active entries of a kind take in a prompt (each rendered whole, as `buildRecordsView` does). */
25
+ export declare function activeRecordsBytes(project: ResolvedProject, kind: RecordKind): number;
26
+ /**
27
+ * The text of one kind for a prompt: effective-active entries, newest first, whole entries only until the budget is
28
+ * used (never cut inside one); the rest are one line each, to read on demand. Undefined when there is nothing to show.
29
+ */
30
+ export declare function buildRecordsView(project: ResolvedProject, kind: RecordKind, budgetBytes?: number): string | undefined;
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { readRegularFileNoFollow } from "./fs-util.js";
5
+ import { recordsDir, recordsKindDir } from "./policy.js";
6
+ export const RECORD_KINDS = ["decisions", "lessons"];
7
+ /** Cap of one kind's effective-active entries in a prompt (and, for `done`, the most they may add up to). */
8
+ export const RECORDS_BUDGET = 6 * 1024;
9
+ const ID_RE = /^[a-z0-9-]+$/;
10
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
11
+ function parseEntry(kind, file) {
12
+ const id = path.basename(file, ".md");
13
+ const fallback = { kind, id, file, status: "active", date: "", supersedes: [], title: path.basename(file), body: "" };
14
+ // Never open a symlink (it could point into .archive/ or outside the tree) or a FIFO/device (it blocks the reader).
15
+ const buf = readRegularFileNoFollow(file);
16
+ if (!buf)
17
+ return { ...fallback, invalid: "not a readable regular file (symlinks and special files are never read)" };
18
+ const text = buf.toString("utf8");
19
+ const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/.exec(text);
20
+ if (!m)
21
+ return { ...fallback, body: text.trim(), invalid: "missing frontmatter" };
22
+ const body = (m[2] ?? "").trim();
23
+ let fm;
24
+ try {
25
+ fm = parseYaml(m[1]);
26
+ }
27
+ catch (e) {
28
+ return { ...fallback, body, invalid: `frontmatter is not valid YAML: ${e.message.split("\n")[0]}` };
29
+ }
30
+ if (!fm || typeof fm !== "object" || Array.isArray(fm))
31
+ return { ...fallback, body, invalid: "frontmatter is not a mapping" };
32
+ const f = fm;
33
+ const problems = [];
34
+ if (!ID_RE.test(id))
35
+ problems.push("file name must be lowercase letters, digits and -");
36
+ if (f.status !== "active" && f.status !== "superseded")
37
+ problems.push("status must be active or superseded");
38
+ // YAML parses an unquoted 2026-01-02 as a Date.
39
+ const date = f.date instanceof Date ? f.date.toISOString().slice(0, 10) : f.date;
40
+ if (typeof date !== "string" || !DATE_RE.test(date))
41
+ problems.push("date must be YYYY-MM-DD");
42
+ if (typeof f.title !== "string" || !f.title.trim() || /[\r\n]/.test(f.title.trim()))
43
+ problems.push("title must be one non-empty line");
44
+ let supersedes = [];
45
+ if (f.supersedes !== undefined) {
46
+ if (Array.isArray(f.supersedes) && f.supersedes.every((x) => typeof x === "string"))
47
+ supersedes = f.supersedes;
48
+ else
49
+ problems.push("supersedes must be a list of ids");
50
+ }
51
+ if (f.run !== undefined && typeof f.run !== "string")
52
+ problems.push("run must be a string");
53
+ if (problems.length)
54
+ return { ...fallback, body, invalid: problems.join("; ") };
55
+ return {
56
+ kind,
57
+ id,
58
+ file,
59
+ status: f.status,
60
+ date: date,
61
+ run: f.run,
62
+ supersedes,
63
+ title: f.title.trim(),
64
+ body,
65
+ };
66
+ }
67
+ /** Every `*.md` entry of one kind, sorted by id. Entries that cannot be parsed are listed with `invalid` set; a missing folder is an empty list. */
68
+ export function listRecords(project, kind) {
69
+ const dir = recordsKindDir(project, kind);
70
+ let names;
71
+ try {
72
+ // A symlinked records/ or kind folder could point at archived or outside entries: list nothing, never descend.
73
+ for (const d of [recordsDir(project), dir])
74
+ if (!fs.lstatSync(d).isDirectory())
75
+ return [];
76
+ names = fs.readdirSync(dir);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ return names
82
+ .filter((n) => n.endsWith(".md"))
83
+ .sort()
84
+ .map((n) => parseEntry(kind, path.join(dir, n)));
85
+ }
86
+ /** Entries shown in prompts: `status: active` and not named in another entry's `supersedes`. */
87
+ export function effectiveActive(records) {
88
+ const replaced = new Set();
89
+ for (const r of records)
90
+ for (const id of r.supersedes)
91
+ if (id !== r.id)
92
+ replaced.add(id);
93
+ return records.filter((r) => r.status === "active" && !replaced.has(r.id));
94
+ }
95
+ function newestFirst(records) {
96
+ return [...records].sort((a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id));
97
+ }
98
+ function renderEntry(r) {
99
+ return `## ${r.title} [${r.id}]${r.body ? `\n${r.body}` : ""}`;
100
+ }
101
+ /** Bytes the effective-active entries of a kind take in a prompt (each rendered whole, as `buildRecordsView` does). */
102
+ export function activeRecordsBytes(project, kind) {
103
+ return Buffer.byteLength(effectiveActive(listRecords(project, kind)).map(renderEntry).join("\n\n"));
104
+ }
105
+ /**
106
+ * The text of one kind for a prompt: effective-active entries, newest first, whole entries only until the budget is
107
+ * used (never cut inside one); the rest are one line each, to read on demand. Undefined when there is nothing to show.
108
+ */
109
+ export function buildRecordsView(project, kind, budgetBytes = RECORDS_BUDGET) {
110
+ const entries = newestFirst(effectiveActive(listRecords(project, kind)));
111
+ if (entries.length === 0)
112
+ return undefined;
113
+ const shown = [];
114
+ let used = 0;
115
+ let i = 0;
116
+ for (; i < entries.length; i++) {
117
+ const text = renderEntry(entries[i]);
118
+ const size = Buffer.byteLength(text) + (shown.length ? 2 : 0);
119
+ if (used + size > budgetBytes)
120
+ break;
121
+ shown.push(text);
122
+ used += size;
123
+ }
124
+ const rest = entries.slice(i);
125
+ const parts = [...shown];
126
+ if (rest.length) {
127
+ parts.push(`${shown.length ? "More" : "These"} active entries did not fit in ${budgetBytes} bytes; read the file on demand:`, rest.map((r) => `- [${r.id}] ${r.title} (read: ${r.file})`).join("\n"));
128
+ }
129
+ return parts.join("\n\n");
130
+ }
package/dist/scaffold.js CHANGED
@@ -7,7 +7,7 @@ const PERSONAS = {
7
7
  You coordinate the team. You receive the user's task, break it into concrete pieces, and delegate each piece to the right teammate by mail.
8
8
 
9
9
  - Keep \`COMMON.md\` (shared team context: goal, conventions) accurate and short.
10
- - Before you send \`done\`, record what was decided and why in \`DECISIONS.md\` and what the project learned in \`LESSONS.md\` (next to \`COMMON.md\`); later runs start from them.
10
+ - Before you send \`done\`, record what was decided and why as decision entries, and what the project learned as lesson entries, in the records folders named in your rules (one file per entry, with frontmatter); mark outdated entries \`superseded\`. Later runs start from the effective-active ones (about 6 KB per kind).
11
11
  - Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
12
12
  - When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
13
13
  - Teammates cannot message each other by default; route everything through yourself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",