agent-lyceum 0.4.14 → 0.6.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.
Files changed (47) hide show
  1. package/README.md +21 -21
  2. package/README.zh-TW.md +21 -21
  3. package/dist/adapters/claude.js +2 -0
  4. package/dist/ask-answer.d.ts +10 -0
  5. package/dist/ask-answer.js +21 -0
  6. package/dist/ask-reply.d.ts +65 -0
  7. package/dist/ask-reply.js +165 -0
  8. package/dist/assert.d.ts +2 -0
  9. package/dist/assert.js +6 -0
  10. package/dist/cli.js +202 -16
  11. package/dist/config.d.ts +21 -16
  12. package/dist/config.js +19 -7
  13. package/dist/dispatcher.d.ts +3 -34
  14. package/dist/dispatcher.js +4 -464
  15. package/dist/doctor.js +3 -0
  16. package/dist/format.js +7 -5
  17. package/dist/fs-util.d.ts +2 -0
  18. package/dist/fs-util.js +15 -0
  19. package/dist/guard.d.ts +8 -0
  20. package/dist/guard.js +24 -3
  21. package/dist/mailbox.d.ts +11 -1
  22. package/dist/mailbox.js +21 -8
  23. package/dist/memory-tidy.d.ts +115 -0
  24. package/dist/memory-tidy.js +351 -0
  25. package/dist/policy.js +3 -3
  26. package/dist/process-runner.js +1 -2
  27. package/dist/project-lock.d.ts +10 -1
  28. package/dist/project-lock.js +39 -4
  29. package/dist/prompt.d.ts +20 -0
  30. package/dist/prompt.js +76 -3
  31. package/dist/run-cleanup.js +4 -3
  32. package/dist/run-log.d.ts +5 -0
  33. package/dist/run-log.js +18 -0
  34. package/dist/run-outcome.d.ts +22 -0
  35. package/dist/run-outcome.js +28 -0
  36. package/dist/run-session.d.ts +92 -0
  37. package/dist/run-session.js +602 -0
  38. package/dist/run-store.d.ts +3 -1
  39. package/dist/run-store.js +7 -6
  40. package/dist/scaffold.js +1 -0
  41. package/dist/schema.d.ts +72 -239
  42. package/dist/schema.js +8 -3
  43. package/dist/status.d.ts +9 -1
  44. package/dist/status.js +51 -15
  45. package/dist/validate.js +12 -8
  46. package/dist/worktree.js +7 -6
  47. package/package.json +14 -8
package/dist/mailbox.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type Step } from "./format.js";
3
3
  import { atomicWrite } from "./fs-util.js";
4
4
  import { RouteJournal } from "./message-store.js";
5
5
  export { atomicWrite };
6
- export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done"];
6
+ export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done", "ask"];
7
7
  export type MessageType = (typeof MESSAGE_TYPES)[number];
8
8
  export interface MessageMeta {
9
9
  id: string;
@@ -54,6 +54,8 @@ export declare function deliverOnce(project: ResolvedProject, journal: RouteJour
54
54
  fresh: boolean;
55
55
  };
56
56
  export declare function readMessage(file: string): Message;
57
+ /** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
58
+ export declare function unreadFiles(project: ResolvedProject, agent: string): string[];
57
59
  export declare function listUnread(project: ResolvedProject, agent: string): Message[];
58
60
  export declare function markRead(files: string[]): void;
59
61
  export interface RouteResult {
@@ -86,6 +88,14 @@ export interface RouteResult {
86
88
  file: string;
87
89
  meta: Record<string, unknown>;
88
90
  };
91
+ /** Questions for the user. Like `done`, the file stays in the outbox until the caller has stored the questions. */
92
+ asks: {
93
+ from: string;
94
+ subject: string;
95
+ body: string;
96
+ file: string;
97
+ source: string;
98
+ }[];
89
99
  /** Latest `## Steps` checklist the lead sent, if any. */
90
100
  steps?: Step[];
91
101
  }
package/dist/mailbox.js CHANGED
@@ -6,8 +6,9 @@ import { formatWarning, missingSections, parseSteps } from "./format.js";
6
6
  import { atomicWrite } from "./fs-util.js";
7
7
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
8
8
  import { inboxDir, outboxDir } from "./policy.js";
9
+ import { must } from "./assert.js";
9
10
  export { atomicWrite };
10
- export const MESSAGE_TYPES = ["task", "reply", "failure", "done"];
11
+ export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
11
12
  export function newId() {
12
13
  return crypto.randomBytes(3).toString("hex");
13
14
  }
@@ -25,17 +26,17 @@ export function parseRaw(text) {
25
26
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
26
27
  if (!m)
27
28
  throw new Error("missing frontmatter (--- ... ---)");
28
- const data = YAML.parse(m[1]);
29
+ const data = YAML.parse(m[1] ?? "");
29
30
  if (!data || typeof data !== "object" || Array.isArray(data))
30
31
  throw new Error("frontmatter is not a mapping");
31
- return { data: data, body: m[2].trim() };
32
+ return { data: data, body: (m[2] ?? "").trim() };
32
33
  }
33
34
  export function ensureProjectDirs(project) {
34
35
  fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
35
36
  for (const name of Object.keys(project.agents)) {
36
37
  fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
37
38
  fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
38
- const a = project.agents[name];
39
+ const a = must(project.agents[name], `agent ${name}`);
39
40
  for (const m of [a.memory.global, a.memory.project, a.memory.task])
40
41
  if (m)
41
42
  fs.mkdirSync(m, { recursive: true });
@@ -109,15 +110,19 @@ export function readMessage(file) {
109
110
  const meta = data;
110
111
  return { meta, body, file };
111
112
  }
112
- export function listUnread(project, agent) {
113
+ /** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
114
+ export function unreadFiles(project, agent) {
113
115
  const dir = inboxDir(project, agent);
114
116
  if (!fs.existsSync(dir))
115
117
  return [];
116
- const files = fs
118
+ return fs
117
119
  .readdirSync(dir, { withFileTypes: true })
118
120
  .filter((e) => e.isFile() && e.name.endsWith(".md") && !e.name.startsWith("."))
119
121
  .map((e) => path.join(dir, e.name))
120
122
  .sort();
123
+ }
124
+ export function listUnread(project, agent) {
125
+ const files = unreadFiles(project, agent);
121
126
  const out = [];
122
127
  for (const f of files) {
123
128
  try {
@@ -160,7 +165,7 @@ export function rejectDone(file) {
160
165
  }
161
166
  /** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
162
167
  export function routeOutboxes(project) {
163
- const res = { delivered: [], rejected: [], warnings: [] };
168
+ const res = { delivered: [], rejected: [], warnings: [], asks: [] };
164
169
  const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
165
170
  for (const sender of Object.values(project.agents)) {
166
171
  const dir = outboxDir(project, sender.name);
@@ -202,10 +207,18 @@ export function routeOutboxes(project) {
202
207
  }
203
208
  const type = (d.type ?? "reply");
204
209
  if (!MESSAGE_TYPES.includes(type) || type === "failure") {
205
- reject(`unknown or not allowed message type "${String(d.type)}" (use task, reply or done).`);
210
+ reject(`unknown or not allowed message type "${String(d.type)}" (use task, reply, ask or done).`);
206
211
  continue;
207
212
  }
208
213
  const subject = String(d.subject ?? "(no subject)");
214
+ if (type === "ask") {
215
+ if (!sender.canAskUser) {
216
+ reject(`you may not ask the user directly (can_ask_user is off for "${sender.name}"). Put the question in a reply to the lead ("${project.lead}"), who can ask.`);
217
+ continue;
218
+ }
219
+ res.asks.push({ from: sender.name, subject, body: parsed.body, file, source });
220
+ continue;
221
+ }
209
222
  if (type === "done") {
210
223
  if (sender.name !== project.lead) {
211
224
  reject(`only the lead ("${project.lead}") may send a "done" message.`);
@@ -0,0 +1,115 @@
1
+ import { type Invoker } from "./adapters/index.js";
2
+ import type { ResolvedProject } from "./config.js";
3
+ export type Layer = "project" | "global";
4
+ export declare const ARCHIVE_DIR = ".archive";
5
+ export declare const TIDY_STATE_FILE = ".tidy-state.json";
6
+ export declare const TIDY_REPORT_FILE = "tidy-report.md";
7
+ export declare const STAMP_RE: RegExp;
8
+ /** `20261008T031500Z`: UTC, sortable, safe in a file name. */
9
+ export declare const tidyStamp: (d?: Date) => string;
10
+ export interface MemoryTarget {
11
+ agent: string;
12
+ layer: Layer;
13
+ dir: string;
14
+ }
15
+ /** The memory directories to tidy. Without `agent`, every agent that has a directory for the layer; with it, that agent or an error. */
16
+ export declare function targetsFor(project: ResolvedProject, opts: {
17
+ layer: Layer;
18
+ agent?: string;
19
+ }): MemoryTarget[];
20
+ export interface MemoryFile {
21
+ rel: string;
22
+ bytes: number;
23
+ mtime: string;
24
+ }
25
+ /** Every file under `dir` (the archive included, the state file not), `/`-separated and sorted. */
26
+ export declare function listMemoryFiles(dir: string): MemoryFile[];
27
+ export interface MemoryStats {
28
+ files: number;
29
+ bytes: number;
30
+ indexBytes: number;
31
+ oldest?: string;
32
+ newest?: string;
33
+ }
34
+ /** Size and age of the live files (the archive is not counted). */
35
+ export declare function memoryStats(dir: string): MemoryStats;
36
+ /** Content hash of every file (the archive included), to tell whether a directory changed. */
37
+ export declare function fingerprint(dir: string): Record<string, string>;
38
+ /** Copy each directory aside (a directory that does not exist is remembered as absent). */
39
+ export declare function snapshotDirs(dirs: string[], into: string): void;
40
+ /** Put each directory back as `snapshotDirs` saw it, removing whatever was added since. */
41
+ export declare function restoreDirs(dirs: string[], from: string): void;
42
+ /**
43
+ * Nothing may disappear: every file that existed before is still in place, or archived under the same relative
44
+ * path in this tidy's archive folder. Earlier archives must stay as they were; MEMORY.md is never archived.
45
+ */
46
+ export declare function verifyConservation(before: string[], dir: string, stamp: string): string[];
47
+ export interface TidyState {
48
+ /** HEAD of the project repo when the last tidy finished. */
49
+ head?: string;
50
+ at: string;
51
+ /** The archive folder of that tidy. */
52
+ archive?: string;
53
+ }
54
+ export declare function readTidyState(dir: string): TidyState | undefined;
55
+ export declare function writeTidyState(dir: string, st: TidyState): void;
56
+ export interface ProjectChanges {
57
+ head?: string;
58
+ since?: string;
59
+ log: string[];
60
+ stat: string[];
61
+ note?: string;
62
+ }
63
+ export declare function projectChanges(repoDir: string, since?: string): ProjectChanges;
64
+ export interface MissingPath {
65
+ file: string;
66
+ path: string;
67
+ }
68
+ /** Backticked paths in the live Markdown files of `dir` that do not exist. Relative ones are looked up in `repoDir` (skipped without it). */
69
+ export declare function findMissingPaths(dir: string, repoDir: string | undefined): MissingPath[];
70
+ export interface TidyOptions {
71
+ project: ResolvedProject;
72
+ layer: Layer;
73
+ agent?: string;
74
+ dryRun?: boolean;
75
+ invoker?: Invoker;
76
+ signal?: AbortSignal;
77
+ say?: (line: string) => void;
78
+ now?: () => Date;
79
+ }
80
+ export interface TidyResult {
81
+ agent: string;
82
+ layer: Layer;
83
+ dir: string;
84
+ status: "tidied" | "skipped" | "failed" | "dry-run";
85
+ /** The archive stamp of a finished tidy. */
86
+ archive?: string;
87
+ problems: string[];
88
+ }
89
+ /** Scratch space of one tidy (snapshot and the invocation's logs); outside every memory dir and every run. */
90
+ export declare const tidyWorkDir: (project: ResolvedProject, stamp: string, agent: string) => string;
91
+ /** Tidy the memory of the chosen agents, one after another. Only ever called by a person (`agent-lyceum memory tidy`). */
92
+ export declare function tidyMemory(o: TidyOptions): Promise<TidyResult[]>;
93
+ export interface RestoreResult {
94
+ restored: string[];
95
+ skipped: {
96
+ rel: string;
97
+ reason: string;
98
+ }[];
99
+ }
100
+ /** Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten. */
101
+ export declare function restoreMemory(o: {
102
+ project: ResolvedProject;
103
+ agent: string;
104
+ layer: Layer;
105
+ stamp: string;
106
+ }): RestoreResult;
107
+ /** The stamps of the archives in a memory directory, oldest first. */
108
+ export declare function listArchives(dir: string): string[];
109
+ export declare const ADVICE_COMMITS = 50;
110
+ export interface TidyAdvice {
111
+ agent: string;
112
+ reason: string;
113
+ }
114
+ /** Hints only: project memory whose index is larger than what an agent is shown, or whose baseline is far behind. Never acts. */
115
+ export declare function tidyAdvice(project: ResolvedProject): TidyAdvice[];
@@ -0,0 +1,351 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { realInvoker } from "./adapters/index.js";
6
+ import { atomicWrite } from "./fs-util.js";
7
+ import { ensureProjectDirs } from "./mailbox.js";
8
+ import { memoryDirs } from "./policy.js";
9
+ import { buildTidyPrompts, MEMORY_INDEX_LIMIT } from "./prompt.js";
10
+ import { git, isGitRepo } from "./worktree.js";
11
+ export const ARCHIVE_DIR = ".archive";
12
+ export const TIDY_STATE_FILE = ".tidy-state.json";
13
+ export const TIDY_REPORT_FILE = "tidy-report.md";
14
+ export const STAMP_RE = /^\d{8}T\d{6}Z$/;
15
+ /** `20261008T031500Z`: UTC, sortable, safe in a file name. */
16
+ export const tidyStamp = (d = new Date()) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
17
+ /** The memory directories to tidy. Without `agent`, every agent that has a directory for the layer; with it, that agent or an error. */
18
+ export function targetsFor(project, opts) {
19
+ const pick = (name) => {
20
+ const dir = project.agents[name]?.memory[opts.layer];
21
+ return dir ? { agent: name, layer: opts.layer, dir } : undefined;
22
+ };
23
+ if (opts.agent) {
24
+ if (!project.agents[opts.agent])
25
+ throw new Error(`No agent "${opts.agent}" in project ${project.name}. Agents: ${Object.keys(project.agents).join(", ")}.`);
26
+ const t = pick(opts.agent);
27
+ if (!t)
28
+ throw new Error(`Agent "${opts.agent}" has no ${opts.layer} memory directory.`);
29
+ return [t];
30
+ }
31
+ return Object.keys(project.agents).flatMap((n) => pick(n) ?? []);
32
+ }
33
+ function walk(dir, base, out) {
34
+ let entries;
35
+ try {
36
+ entries = fs.readdirSync(dir, { withFileTypes: true });
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ for (const e of entries) {
42
+ const full = path.join(dir, e.name);
43
+ if (e.isDirectory())
44
+ walk(full, base, out);
45
+ else if (e.isFile()) {
46
+ const rel = path.relative(base, full).split(path.sep).join("/");
47
+ if (rel === TIDY_STATE_FILE)
48
+ continue;
49
+ const st = fs.statSync(full);
50
+ out.push({ rel, bytes: st.size, mtime: st.mtime.toISOString() });
51
+ }
52
+ }
53
+ }
54
+ /** Every file under `dir` (the archive included, the state file not), `/`-separated and sorted. */
55
+ export function listMemoryFiles(dir) {
56
+ const out = [];
57
+ walk(dir, dir, out);
58
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
59
+ }
60
+ /** Size and age of the live files (the archive is not counted). */
61
+ export function memoryStats(dir) {
62
+ const live = listMemoryFiles(dir).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`));
63
+ const times = live.map((f) => f.mtime).sort();
64
+ return {
65
+ files: live.length,
66
+ bytes: live.reduce((n, f) => n + f.bytes, 0),
67
+ indexBytes: live.find((f) => f.rel === "MEMORY.md")?.bytes ?? 0,
68
+ oldest: times[0],
69
+ newest: times.at(-1),
70
+ };
71
+ }
72
+ /** Content hash of every file (the archive included), to tell whether a directory changed. */
73
+ export function fingerprint(dir) {
74
+ const out = {};
75
+ for (const f of listMemoryFiles(dir))
76
+ out[f.rel] = crypto.createHash("sha256").update(fs.readFileSync(path.join(dir, f.rel))).digest("hex");
77
+ return out;
78
+ }
79
+ const slot = (into, i) => path.join(into, String(i));
80
+ /** Copy each directory aside (a directory that does not exist is remembered as absent). */
81
+ export function snapshotDirs(dirs, into) {
82
+ fs.rmSync(into, { recursive: true, force: true });
83
+ fs.mkdirSync(into, { recursive: true });
84
+ dirs.forEach((d, i) => {
85
+ if (fs.existsSync(d))
86
+ fs.cpSync(d, slot(into, i), { recursive: true });
87
+ });
88
+ }
89
+ /** Put each directory back as `snapshotDirs` saw it, removing whatever was added since. */
90
+ export function restoreDirs(dirs, from) {
91
+ dirs.forEach((d, i) => {
92
+ fs.rmSync(d, { recursive: true, force: true });
93
+ if (fs.existsSync(slot(from, i)))
94
+ fs.cpSync(slot(from, i), d, { recursive: true });
95
+ });
96
+ }
97
+ /**
98
+ * Nothing may disappear: every file that existed before is still in place, or archived under the same relative
99
+ * path in this tidy's archive folder. Earlier archives must stay as they were; MEMORY.md is never archived.
100
+ */
101
+ export function verifyConservation(before, dir, stamp) {
102
+ const problems = [];
103
+ for (const rel of before) {
104
+ const here = fs.existsSync(path.join(dir, rel));
105
+ if (here)
106
+ continue;
107
+ const inArchive = !rel.startsWith(`${ARCHIVE_DIR}/`) && rel !== "MEMORY.md" && fs.existsSync(path.join(dir, ARCHIVE_DIR, stamp, rel));
108
+ if (!inArchive)
109
+ problems.push(rel === "MEMORY.md" ? `MEMORY.md is missing (the index must stay in place)` : `${rel} is gone: it is neither in place nor under ${ARCHIVE_DIR}/${stamp}/`);
110
+ }
111
+ return problems;
112
+ }
113
+ export function readTidyState(dir) {
114
+ try {
115
+ const j = JSON.parse(fs.readFileSync(path.join(dir, TIDY_STATE_FILE), "utf8"));
116
+ return j && typeof j === "object" && typeof j.at === "string" ? j : undefined;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ export function writeTidyState(dir, st) {
123
+ atomicWrite(path.join(dir, TIDY_STATE_FILE), JSON.stringify(st, null, 2));
124
+ }
125
+ const LOG_LINES = 50;
126
+ const STAT_LINES = 40;
127
+ function tryGit(cwd, args) {
128
+ try {
129
+ return git(cwd, args);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ const lines = (s) => (s ? s.split("\n").filter(Boolean) : []);
136
+ export function projectChanges(repoDir, since) {
137
+ if (!fs.existsSync(repoDir) || !isGitRepo(repoDir))
138
+ return { log: [], stat: [], note: "the project directory is not a git repository" };
139
+ const head = tryGit(repoDir, ["rev-parse", "HEAD"]);
140
+ if (!head)
141
+ return { log: [], stat: [], note: "the repository has no commits yet" };
142
+ if (!since)
143
+ return { head, log: [], stat: [], note: "first tidy: there is no earlier baseline, only the path check applies" };
144
+ if (since === head)
145
+ return { head, since, log: [], stat: [], note: "no commits since the last tidy" };
146
+ if (tryGit(repoDir, ["cat-file", "-t", `${since}^{commit}`]) !== "commit")
147
+ return { head, since, log: [], stat: [], note: "the baseline commit of the last tidy no longer exists in this repository (history was rewritten?)" };
148
+ const log = lines(tryGit(repoDir, ["log", "--oneline", "--no-decorate", `${since}..${head}`]));
149
+ const stat = lines(tryGit(repoDir, ["diff", "--stat", since, head]));
150
+ return {
151
+ head,
152
+ since,
153
+ log: log.length > LOG_LINES ? [...log.slice(0, LOG_LINES), `… and ${log.length - LOG_LINES} more commits`] : log,
154
+ stat: stat.length > STAT_LINES ? [...stat.slice(0, STAT_LINES - 1), stat.at(-1) ?? ""] : stat,
155
+ };
156
+ }
157
+ const PATH_TOKEN = /`([^`\n]+)`/g;
158
+ function looksLikePath(t) {
159
+ if (!t.includes("/") || /\s/.test(t) || t.includes("://"))
160
+ return false;
161
+ if (/^-|[*?{}<>$|;&=]/.test(t))
162
+ return false;
163
+ return true;
164
+ }
165
+ /** Backticked paths in the live Markdown files of `dir` that do not exist. Relative ones are looked up in `repoDir` (skipped without it). */
166
+ export function findMissingPaths(dir, repoDir) {
167
+ const seen = new Set();
168
+ const out = [];
169
+ for (const f of listMemoryFiles(dir)) {
170
+ if (f.rel.startsWith(`${ARCHIVE_DIR}/`) || !f.rel.endsWith(".md"))
171
+ continue;
172
+ const text = fs.readFileSync(path.join(dir, f.rel), "utf8");
173
+ for (const m of text.matchAll(PATH_TOKEN)) {
174
+ const tok = (m[1] ?? "").trim();
175
+ if (!looksLikePath(tok))
176
+ continue;
177
+ const abs = tok.startsWith("~/") ? path.join(os.homedir(), tok.slice(2)) : path.isAbsolute(tok) ? tok : repoDir ? path.join(repoDir, tok) : undefined;
178
+ if (!abs || fs.existsSync(abs))
179
+ continue;
180
+ const key = `${f.rel}\0${tok}`;
181
+ if (seen.has(key))
182
+ continue;
183
+ seen.add(key);
184
+ out.push({ file: f.rel, path: tok });
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+ /** Scratch space of one tidy (snapshot and the invocation's logs); outside every memory dir and every run. */
190
+ export const tidyWorkDir = (project, stamp, agent) => path.join(project.paths.root, "tidy-work", stamp, agent);
191
+ const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KiB`);
192
+ function describeTarget(t, project, say) {
193
+ const s = memoryStats(t.dir);
194
+ say(`${t.agent} (${t.layer}): ${s.files} file(s), ${kb(s.bytes)}, MEMORY.md ${kb(s.indexBytes)}${s.oldest ? `, modified ${s.oldest.slice(0, 10)} … ${s.newest?.slice(0, 10)}` : ""}`);
195
+ if (t.layer === "project") {
196
+ const c = projectChanges(project.dir, readTidyState(t.dir)?.head);
197
+ say(` project changes: ${c.note ?? `${c.log.length} commit(s) since ${c.since?.slice(0, 8)}`}`);
198
+ }
199
+ for (const m of findMissingPaths(t.dir, t.layer === "project" ? project.dir : undefined))
200
+ say(` missing path: ${m.path} (in ${m.file})`);
201
+ }
202
+ async function tidyOne(o, t, stamp) {
203
+ const { project } = o;
204
+ const say = o.say ?? ((s) => console.log(s));
205
+ const base = { agent: t.agent, layer: t.layer, dir: t.dir };
206
+ const agent = project.agents[t.agent];
207
+ const live = listMemoryFiles(t.dir).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`));
208
+ if (live.length === 0)
209
+ 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);
213
+ const othersBefore = others.map((d) => fingerprint(d));
214
+ const archiveDir = path.join(t.dir, ARCHIVE_DIR, stamp);
215
+ const workDir = tidyWorkDir(project, stamp, t.agent);
216
+ 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 });
222
+ ensureProjectDirs(project);
223
+ snapshotDirs(dirs, snapshot);
224
+ const fail = (problems) => {
225
+ restoreDirs(dirs, snapshot);
226
+ fs.rmSync(snapshot, { recursive: true, force: true });
227
+ return { ...base, status: "failed", problems };
228
+ };
229
+ say(`tidying ${t.layer} memory of ${t.agent} (${live.length} file(s)) …`);
230
+ const invoke = o.invoker ?? realInvoker;
231
+ const result = await invoke({
232
+ project,
233
+ agent,
234
+ workDir,
235
+ systemPrompt: prompts.systemPrompt,
236
+ userPrompt: prompts.userPrompt,
237
+ timeoutSec: project.dispatcher.wake_timeout_sec,
238
+ signal: o.signal,
239
+ logDir: path.join(workDir, "log"),
240
+ }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message }));
241
+ if (!result.ok)
242
+ 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`);
252
+ if (problems.length)
253
+ return fail(problems);
254
+ 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: [] };
257
+ }
258
+ /** Tidy the memory of the chosen agents, one after another. Only ever called by a person (`agent-lyceum memory tidy`). */
259
+ export async function tidyMemory(o) {
260
+ const say = o.say ?? ((s) => console.log(s));
261
+ const targets = targetsFor(o.project, { layer: o.layer, agent: o.agent });
262
+ const results = [];
263
+ for (const t of targets) {
264
+ 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: [] });
267
+ continue;
268
+ }
269
+ const r = await tidyOne(o, t, tidyStamp(o.now?.()));
270
+ results.push(r);
271
+ if (o.signal?.aborted)
272
+ break;
273
+ }
274
+ return results;
275
+ }
276
+ function titleOf(file, rel) {
277
+ const text = fs.readFileSync(file, "utf8");
278
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
279
+ const name = fm ? /^name:\s*(.+)$/m.exec(fm[1] ?? "")?.[1]?.trim() : undefined;
280
+ if (name)
281
+ return name;
282
+ const h = /^#\s+(.+)$/m.exec(text)?.[1]?.trim();
283
+ return h || path.basename(rel, path.extname(rel));
284
+ }
285
+ function pruneEmpty(dir, stop) {
286
+ for (let d = dir; d !== stop && d.startsWith(stop + path.sep); d = path.dirname(d)) {
287
+ if (fs.readdirSync(d).length)
288
+ break;
289
+ fs.rmdirSync(d);
290
+ }
291
+ }
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)"}.`);
300
+ const restored = [];
301
+ const skipped = [];
302
+ const inArchive = listMemoryFiles(archive).filter((f) => f.rel !== TIDY_REPORT_FILE);
303
+ for (const f of inArchive) {
304
+ const from = path.join(archive, f.rel);
305
+ const to = path.join(t.dir, f.rel);
306
+ if (fs.existsSync(to)) {
307
+ skipped.push({ rel: f.rel, reason: "a file with this name exists now; it was not overwritten" });
308
+ continue;
309
+ }
310
+ fs.mkdirSync(path.dirname(to), { recursive: true });
311
+ fs.renameSync(from, to);
312
+ pruneEmpty(path.dirname(from), archive);
313
+ restored.push(f.rel);
314
+ }
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
+ return { restored, skipped };
321
+ }
322
+ /** The stamps of the archives in a memory directory, oldest first. */
323
+ export function listArchives(dir) {
324
+ try {
325
+ return fs.readdirSync(path.join(dir, ARCHIVE_DIR)).filter((n) => STAMP_RE.test(n)).sort();
326
+ }
327
+ catch {
328
+ return [];
329
+ }
330
+ }
331
+ export const ADVICE_COMMITS = 50;
332
+ /** Hints only: project memory whose index is larger than what an agent is shown, or whose baseline is far behind. Never acts. */
333
+ export function tidyAdvice(project) {
334
+ const out = [];
335
+ for (const t of targetsFor(project, { layer: "project" })) {
336
+ if (!fs.existsSync(t.dir))
337
+ continue;
338
+ const s = memoryStats(t.dir);
339
+ if (s.indexBytes > MEMORY_INDEX_LIMIT) {
340
+ out.push({ agent: t.agent, reason: `MEMORY.md is ${s.indexBytes} bytes, more than the ${MEMORY_INDEX_LIMIT} an agent is shown` });
341
+ continue;
342
+ }
343
+ const since = readTidyState(t.dir)?.head;
344
+ if (!since || !fs.existsSync(project.dir) || !isGitRepo(project.dir))
345
+ continue;
346
+ const n = Number(tryGit(project.dir, ["rev-list", "--count", `${since}..HEAD`]));
347
+ if (Number.isFinite(n) && n >= ADVICE_COMMITS)
348
+ out.push({ agent: t.agent, reason: `${n} commits since the last tidy` });
349
+ }
350
+ return out;
351
+ }
package/dist/policy.js CHANGED
@@ -1,6 +1,6 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
3
+ import { findOnPath } from "./fs-util.js";
4
4
  import { globStaticPrefix } from "./paths.js";
5
5
  export function commonFile(project) {
6
6
  return path.join(project.paths.shared, "common", "COMMON.md");
@@ -17,7 +17,7 @@ export function repoInstructionFiles(project) {
17
17
  export function memoryDirs(a) {
18
18
  return [a.memory.global, a.memory.project, a.memory.task].filter((x) => !!x);
19
19
  }
20
- const RUN_ENTRIES = ["state.json", "log.jsonl", "result.md", "task.md", "snapshots", "agents", "mail"];
20
+ const RUN_ENTRIES = ["state.json", "log.jsonl", "log.1.jsonl", "result.md", "task.md", "snapshots", "violations", "agents", "mail"];
21
21
  const MAIL_ENTRIES = ["inbox", "claims", "attempts", "journal.jsonl"];
22
22
  function entries(dir) {
23
23
  try {
@@ -101,7 +101,7 @@ function osSandboxAvailable() {
101
101
  if (process.platform === "darwin")
102
102
  sandboxProbe = true;
103
103
  else if (process.platform === "linux")
104
- sandboxProbe = spawnSync("which", ["bwrap"]).status === 0;
104
+ sandboxProbe = findOnPath("bwrap");
105
105
  else
106
106
  sandboxProbe = false;
107
107
  return sandboxProbe;
@@ -125,8 +125,7 @@ export function runInvocation(inv, opts) {
125
125
  return;
126
126
  let text = partial + chunk.toString("utf8");
127
127
  partial = "";
128
- let nl;
129
- while ((nl = text.indexOf("\n")) >= 0) {
128
+ for (let nl = text.indexOf("\n"); nl >= 0; nl = text.indexOf("\n")) {
130
129
  const line = text.slice(0, nl).replace(/\r$/, "");
131
130
  text = text.slice(nl + 1);
132
131
  if (skipping)
@@ -3,7 +3,7 @@ export interface LockInfo {
3
3
  token: string;
4
4
  hostname: string;
5
5
  pid: number;
6
- /** `ps` start time of `pid`, to tell the holder from a different process that reused the pid. */
6
+ /** Start time of `pid` (see `pidStartFrom`), to tell the holder from a different process that reused the pid. */
7
7
  pid_started?: string;
8
8
  run_id: string;
9
9
  acquired_at: string;
@@ -18,6 +18,15 @@ export interface ProjectLease {
18
18
  export interface LockOptions {
19
19
  heartbeatMs?: number;
20
20
  }
21
+ /** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
22
+ export declare function parseProcStat(text: string): string | undefined;
23
+ export interface PidStartIo {
24
+ platform: NodeJS.Platform;
25
+ readFile(p: string): string | undefined;
26
+ ps(pid: number): string | undefined;
27
+ }
28
+ /** A start time that identifies one process: `proc:<ticks>` on Linux, the `ps` text elsewhere or when /proc cannot be read. */
29
+ export declare function pidStartFrom(pid: number, io: PidStartIo): string | undefined;
21
30
  /** The lock of a project (`root` = the project's home directory), if one is held and readable. */
22
31
  export declare function inspectProjectLock(root: string): LockInfo | undefined;
23
32
  /**