agent-lyceum 0.21.2 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.d.ts +8 -1
- package/dist/adapters/claude.js +15 -9
- package/dist/adapters/codex.js +6 -4
- package/dist/adapters/types.d.ts +11 -0
- package/dist/cli.js +7 -3
- package/dist/doctor.js +12 -0
- package/dist/format-rejections.d.ts +6 -0
- package/dist/format-rejections.js +29 -0
- package/dist/fs-util.d.ts +5 -0
- package/dist/fs-util.js +24 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +125 -2
- package/dist/mailbox.js +16 -1
- package/dist/memory-tidy.d.ts +14 -1
- package/dist/memory-tidy.js +184 -48
- package/dist/policy.d.ts +7 -0
- package/dist/policy.js +20 -3
- package/dist/prompt.d.ts +27 -4
- package/dist/prompt.js +72 -33
- package/dist/records.d.ts +30 -0
- package/dist/records.js +130 -0
- package/dist/run-session.js +4 -0
- package/dist/run-store.d.ts +6 -0
- package/dist/run-store.js +4 -0
- package/dist/scaffold.js +2 -1
- package/dist/status-watch.d.ts +5 -4
- package/dist/status-watch.js +9 -8
- package/dist/status.d.ts +7 -0
- package/dist/status.js +11 -2
- package/package.json +1 -1
package/dist/memory-tidy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
const
|
|
212
|
-
const
|
|
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
|
|
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, {
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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,
|
|
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
|
|
293
|
-
|
|
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
|
|
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(
|
|
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,6 +23,11 @@ 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, 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;
|
|
29
|
+
/** The lead-maintained files shared by every run of the project. */
|
|
30
|
+
export declare function sharedContextFiles(project: ResolvedProject): string[];
|
|
26
31
|
export declare function outboxDir(project: ResolvedProject, agent: string): string;
|
|
27
32
|
export declare function inboxDir(project: ResolvedProject, agent: string): string;
|
|
28
33
|
/** The repo's own CLAUDE.md/AGENTS.md: in the lead's worktree when it has one (concurrent runs), else in the repo. */
|
|
@@ -34,6 +39,8 @@ export declare const SHARED_DIR = "_shared";
|
|
|
34
39
|
export declare function sharedDir(project: ResolvedProject): string | undefined;
|
|
35
40
|
/** The index of the handoff notes: the one file shown in prompts, and the one the guard watches. */
|
|
36
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;
|
|
37
44
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
|
38
45
|
export declare function repoDirFor(project: ResolvedProject, agent: string): string;
|
|
39
46
|
export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
|
package/dist/policy.js
CHANGED
|
@@ -4,6 +4,17 @@ 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, 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
|
+
}
|
|
11
|
+
export function recordsKindDir(project, kind) {
|
|
12
|
+
return path.join(recordsDir(project), kind);
|
|
13
|
+
}
|
|
14
|
+
/** The lead-maintained files shared by every run of the project. */
|
|
15
|
+
export function sharedContextFiles(project) {
|
|
16
|
+
return [commonFile(project)];
|
|
17
|
+
}
|
|
7
18
|
export function outboxDir(project, agent) {
|
|
8
19
|
return path.join(project.paths.outboxRoot, agent);
|
|
9
20
|
}
|
|
@@ -58,6 +69,8 @@ function runDenyPaths(project) {
|
|
|
58
69
|
...[...MAIL_ENTRIES, ...entries(mail)].filter((e) => e !== "outbox").map((e) => path.join(mail, e)),
|
|
59
70
|
]);
|
|
60
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");
|
|
61
74
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
|
62
75
|
export function repoDirFor(project, agent) {
|
|
63
76
|
return project.workspaces?.[agent]?.dir ?? project.dir;
|
|
@@ -74,10 +87,11 @@ export function writePolicy(project, agent) {
|
|
|
74
87
|
...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]),
|
|
75
88
|
...(ws ? [ws.gitDir] : []),
|
|
76
89
|
...(shared && mayWriteShared ? [shared] : []),
|
|
90
|
+
...(isLead ? [recordsDir(project)] : []),
|
|
77
91
|
];
|
|
78
92
|
const allowFiles = [];
|
|
79
93
|
if (isLead)
|
|
80
|
-
allowFiles.push(
|
|
94
|
+
allowFiles.push(...sharedContextFiles(project));
|
|
81
95
|
if (mayWriteShared && sharedIdx)
|
|
82
96
|
allowFiles.push(sharedIdx);
|
|
83
97
|
if (agent.canEditAgentMd) {
|
|
@@ -100,11 +114,14 @@ export function writePolicy(project, agent) {
|
|
|
100
114
|
deny.push(outboxDir(project, a.name));
|
|
101
115
|
deny.push(inboxDir(project, a.name));
|
|
102
116
|
}
|
|
103
|
-
|
|
117
|
+
for (const f of sharedContextFiles(project))
|
|
118
|
+
addDeny(f);
|
|
104
119
|
for (const f of repoInstructionFiles(project))
|
|
105
120
|
addDeny(f);
|
|
106
121
|
if (shared && !mayWriteShared)
|
|
107
122
|
deny.push(shared);
|
|
123
|
+
if (!isLead)
|
|
124
|
+
deny.push(recordsDir(project));
|
|
108
125
|
deny.push(...runDenyPaths(project), project.paths.config, path.join(project.home, "team.yaml"));
|
|
109
126
|
// In a worktree: other agents' checkouts are off limits, and so are this checkout's own copies of the protected files.
|
|
110
127
|
for (const [name, other] of Object.entries(project.workspaces ?? {}))
|
|
@@ -119,7 +136,7 @@ export function writePolicy(project, agent) {
|
|
|
119
136
|
deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
|
|
120
137
|
return {
|
|
121
138
|
allowDirs,
|
|
122
|
-
readDirs: shared && !mayWriteShared ? [shared] : [],
|
|
139
|
+
readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)])],
|
|
123
140
|
allowFiles,
|
|
124
141
|
deny: [...new Set(deny)],
|
|
125
142
|
};
|
package/dist/prompt.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ 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 kind of project record (decisions, lessons) in a prompt. */
|
|
8
|
+
export declare const CONTEXT_LIMIT: number;
|
|
7
9
|
export declare const LEAD_BATCH_LIMIT = 5;
|
|
8
10
|
export declare function readPersona(agent: ResolvedAgent): string;
|
|
9
11
|
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
@@ -12,15 +14,34 @@ export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: R
|
|
|
12
14
|
export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string): string;
|
|
13
15
|
/** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
|
|
14
16
|
export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
15
|
-
/** Messages one wake-up handles: the oldest one, or
|
|
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. */
|
|
16
18
|
export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
|
|
17
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
|
+
}
|
|
18
39
|
export interface TidyContext {
|
|
19
40
|
layer: Layer;
|
|
20
|
-
/** The memory directory being tidied. */
|
|
21
|
-
dir
|
|
41
|
+
/** The memory directory being tidied; absent when only the project records are tidied (the lead has no memory directory). */
|
|
42
|
+
dir?: string;
|
|
22
43
|
/** Absolute path of this tidy's archive folder (the agent creates it). */
|
|
23
|
-
archiveDir
|
|
44
|
+
archiveDir?: string;
|
|
24
45
|
/** Live files only (the archive is not listed). */
|
|
25
46
|
files: MemoryFile[];
|
|
26
47
|
/** MEMORY.md text ("" when there is none). */
|
|
@@ -28,6 +49,8 @@ export interface TidyContext {
|
|
|
28
49
|
/** What changed in the project since the last tidy (project layer only). */
|
|
29
50
|
changes?: ProjectChanges;
|
|
30
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;
|
|
31
54
|
}
|
|
32
55
|
/** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
|
|
33
56
|
export declare function buildTidyPrompts(agent: ResolvedAgent, ctx: TidyContext): {
|