agent-lyceum 0.22.0 → 0.24.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.
@@ -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
  }
@@ -158,10 +158,30 @@ export function listAttempts(runDir) {
158
158
  }
159
159
  return out;
160
160
  }
161
+ /**
162
+ * One more than the highest number any entry under `attempts/` already uses for this claim. Only names count (a damaged JSON or a
163
+ * bare folder still holds its number), because a new attempt's paths come from the name and a rename would overwrite them.
164
+ */
165
+ function nextAttemptNumber(runDir, claimId) {
166
+ const dir = path.join(mailDir(runDir), "attempts");
167
+ const re = new RegExp(`^${claimId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-a(\\d+)(\\.json)?$`);
168
+ let max = 0;
169
+ if (fs.existsSync(dir)) {
170
+ for (const name of fs.readdirSync(dir)) {
171
+ const m = re.exec(name);
172
+ const used = m ? Number(m[1]) : 0;
173
+ if (Number.isSafeInteger(used) && used > max)
174
+ max = used;
175
+ }
176
+ }
177
+ if (!Number.isSafeInteger(max + 1))
178
+ throw new Error(`attempt numbers exhausted for claim ${claimId} (highest used: ${max}) in ${dir}`);
179
+ return max + 1;
180
+ }
161
181
  /** Start the next attempt for a claim (a1, a2, ...). */
162
182
  export function beginAttempt(runDir, claimId) {
163
183
  const claim = loadClaim(runDir, claimId);
164
- const n = listAttempts(runDir).filter((a) => a.claim_id === claimId).length + 1;
184
+ const n = nextAttemptNumber(runDir, claimId);
165
185
  const rec = { id: `${claimId}-a${n}`, claim_id: claimId, agent: claim.agent, n, status: "started", started_at: new Date().toISOString() };
166
186
  saveAttempt(runDir, rec);
167
187
  return rec;
@@ -182,6 +202,7 @@ export function parkOutbox(runDir, outbox, attemptId) {
182
202
  continue;
183
203
  fs.mkdirSync(dest, { recursive: true });
184
204
  fs.renameSync(path.join(outbox, e.name), path.join(dest, e.name));
205
+ fault("park:moved");
185
206
  moved.push(e.name);
186
207
  }
187
208
  return moved;
package/dist/paths.d.ts CHANGED
@@ -16,7 +16,6 @@ export interface ProjectPaths {
16
16
  config: string;
17
17
  agentsDir: string;
18
18
  shared: string;
19
- common: string;
20
19
  inboxRoot: string;
21
20
  outboxRoot: string;
22
21
  runs: string;
package/dist/paths.js CHANGED
@@ -37,7 +37,6 @@ export function projectPaths(home, project) {
37
37
  config: path.join(root, "project.yaml"),
38
38
  agentsDir: path.join(root, "agents"),
39
39
  shared,
40
- common: path.join(shared, "COMMON.md"),
41
40
  inboxRoot: path.join(shared, "inbox"),
42
41
  outboxRoot: path.join(shared, "outbox"),
43
42
  runs: path.join(root, "runs"),
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,8 +4,11 @@ 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
+ /** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
10
+ export declare const MAIL_BODY_LIMIT: number;
11
+ export declare const MAIL_BATCH_LIMIT: number;
9
12
  export declare const LEAD_BATCH_LIMIT = 5;
10
13
  export declare function readPersona(agent: ResolvedAgent): string;
11
14
  /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
@@ -17,12 +20,31 @@ export declare function buildSystemPrompt(project: ResolvedProject, agent: Resol
17
20
  /** 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
21
  export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
19
22
  export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number, handoff?: boolean): string;
23
+ export interface TidyRecordsContext {
24
+ /** The project records folder (`<shared>/common/records`). */
25
+ dir: string;
26
+ /** Absolute path of this tidy's archive folder for the records (the agent creates it). */
27
+ archiveDir: string;
28
+ /** One line per entry: kind, id, status, date, size and title (the archive is not listed). */
29
+ entries: {
30
+ kind: string;
31
+ id: string;
32
+ rel: string;
33
+ status: string;
34
+ date: string;
35
+ bytes: number;
36
+ title: string;
37
+ invalid?: string;
38
+ }[];
39
+ /** Live files that are not entries (kept as they are). */
40
+ otherFiles: MemoryFile[];
41
+ }
20
42
  export interface TidyContext {
21
43
  layer: Layer;
22
- /** The memory directory being tidied. */
23
- dir: string;
44
+ /** The memory directory being tidied; absent when only the project records are tidied (the lead has no memory directory). */
45
+ dir?: string;
24
46
  /** Absolute path of this tidy's archive folder (the agent creates it). */
25
- archiveDir: string;
47
+ archiveDir?: string;
26
48
  /** Live files only (the archive is not listed). */
27
49
  files: MemoryFile[];
28
50
  /** MEMORY.md text ("" when there is none). */
@@ -30,6 +52,8 @@ export interface TidyContext {
30
52
  /** What changed in the project since the last tidy (project layer only). */
31
53
  changes?: ProjectChanges;
32
54
  missing: MissingPath[];
55
+ /** Set only for the lead's project-layer tidy: the project records are tidied in the same wake-up. */
56
+ records?: TidyRecordsContext;
33
57
  }
34
58
  /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
35
59
  export declare function buildTidyPrompts(agent: ResolvedAgent, ctx: TidyContext): {