agent-lyceum 0.5.0 → 0.7.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/cli.js CHANGED
@@ -3,7 +3,9 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline/promises";
5
5
  import { Command } from "commander";
6
- import { ConfigError, findProjectForCwd, flattenResolved, listProjects, resolveProject, resolveProjectWithSources } from "./config.js";
6
+ import { ConfigError, findProjectForCwd, flattenResolved, listProjects, loadGlobal, resolveProject, resolveProjectWithSources } from "./config.js";
7
+ import { openInEditor, prepareAnswers } from "./ask-answer.js";
8
+ import { restoreMemory, tidyMemory, tidyStamp } from "./memory-tidy.js";
7
9
  import { RESULT_FILE, runTeam } from "./dispatcher.js";
8
10
  import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
9
11
  import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
@@ -14,8 +16,10 @@ import { absPath, assertName, projectPaths, resolveHome } from "./paths.js";
14
16
  import { addProject, initHome, removeProject } from "./scaffold.js";
15
17
  import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, runIsAlive } from "./status.js";
16
18
  import { prepareTask, readTaskFile } from "./task.js";
19
+ import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
17
20
  import { formatEnforcement, validateProject } from "./validate.js";
18
21
  import { must } from "./assert.js";
22
+ import { onlyAgent, soloProject } from "./solo.js";
19
23
  const program = new Command();
20
24
  program
21
25
  .name("agent-lyceum")
@@ -177,7 +181,7 @@ function cancelOnSignals() {
177
181
  },
178
182
  };
179
183
  }
180
- function reportRun(summary, runDir) {
184
+ function printRun(summary, runDir) {
181
185
  console.log(`\nRun ${summary.runId} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
182
186
  if (summary.outcomeNote)
183
187
  console.log(`Note: ${summary.outcomeNote}`);
@@ -187,13 +191,73 @@ function reportRun(summary, runDir) {
187
191
  console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
188
192
  console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
189
193
  }
194
+ }
195
+ function reportRun(summary, runDir) {
196
+ printRun(summary, runDir);
190
197
  process.exit(exitCodeForOutcome(summary.outcome));
191
198
  }
199
+ /** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
200
+ async function continueRun(pr, dir, runId) {
201
+ const lease = takeLock(pr, runId);
202
+ const cancel = cancelOnSignals();
203
+ try {
204
+ console.log(`Project ${pr.name} — repo ${pr.dir}`);
205
+ const state = loadRunState(dir);
206
+ console.log(`Resuming run ${state.run_id} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${pr.dispatcher.max_rounds}: ${state.task_summary}`);
207
+ // Read the state again under the lock: another process may have changed it while we were checking.
208
+ return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal });
209
+ }
210
+ finally {
211
+ cancel.dispose();
212
+ lease.release();
213
+ }
214
+ }
215
+ const interactive = () => !!process.stdin.isTTY && !!process.stdout.isTTY;
216
+ function printWaiting(pr, runId, file, problems) {
217
+ console.log(`\nRun ${runId} is waiting for your answers.\n File: ${file}`);
218
+ for (const p of problems)
219
+ console.log(` - ${p}`);
220
+ console.log(`Answer with: agent-lyceum answer ${runId} -p ${pr.name} (or edit the file, then: agent-lyceum resume ${runId} -p ${pr.name})`);
221
+ }
222
+ /**
223
+ * Keep going while the run stops to ask and the questions can be settled right now: by --assume-defaults, or (in a terminal)
224
+ * by opening the editor. Otherwise print where to answer and exit 3.
225
+ */
226
+ async function driveRun(pr, runDir, first, assumeDefaults) {
227
+ let summary = first;
228
+ while (summary.outcome === "waiting") {
229
+ let prep = prepareAnswers(runDir, { assumeDefaults });
230
+ while (!prep.complete && interactive()) {
231
+ console.log(`\nRun ${summary.runId} needs your answers (${prep.path}).`);
232
+ openInEditor(prep.path);
233
+ prep = prepareAnswers(runDir, {});
234
+ if (prep.complete)
235
+ break;
236
+ console.log("Some answers are missing or invalid:");
237
+ for (const p of prep.problems)
238
+ console.log(` - ${p}`);
239
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
240
+ const again = (await rl.question("Edit again? [Y/n] ")).trim().toLowerCase();
241
+ rl.close();
242
+ if (again === "n" || again === "no")
243
+ break;
244
+ }
245
+ if (!prep.complete) {
246
+ printRun(summary, runDir);
247
+ printWaiting(pr, summary.runId, prep.path, prep.problems);
248
+ process.exit(3);
249
+ }
250
+ summary = await continueRun(pr, runDir, summary.runId);
251
+ }
252
+ return reportRun(summary, runDir);
253
+ }
192
254
  program
193
255
  .command("run [task]")
194
- .description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>.')
256
+ .description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>. With --agent, give it to that one member alone.')
195
257
  .option("-p, --project <name>")
196
258
  .option("--task-file <path>", "read the task from a file")
259
+ .option("--agent <name>", "run only this member of the project, alone: it cannot mail teammates or ask you, and its own `done` ends the run")
260
+ .option("--assume-defaults", "when the run stops to ask, use the suggested answer for every question that has one")
197
261
  .action(async (task, opts) => {
198
262
  try {
199
263
  const pr = loadProject(opts.project);
@@ -202,7 +266,16 @@ program
202
266
  console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
203
267
  if (!res.ok)
204
268
  fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
205
- await preflight(pr);
269
+ let checked = pr;
270
+ if (opts.agent) {
271
+ assertName("agent", opts.agent);
272
+ if (opts.assumeDefaults)
273
+ console.error("warn --assume-defaults is ignored with --agent: a solo agent cannot ask questions.");
274
+ // Fails with the member list (and a hint for a global-only agent) before anything is written.
275
+ const sp = soloProject(pr, opts.agent, { globalAgents: Object.keys(loadGlobal(home()).agents) });
276
+ checked = onlyAgent(sp, opts.agent);
277
+ }
278
+ await preflight(checked);
206
279
  const runId = newRunId();
207
280
  const runDir = path.join(pr.paths.runs, runId);
208
281
  const lease = takeLock(pr, runId);
@@ -210,15 +283,15 @@ program
210
283
  const cancel = cancelOnSignals();
211
284
  try {
212
285
  const prepared = prepareTask({ text: task, file: opts.taskFile, cwd: process.cwd(), runDir });
213
- console.log(`Project ${pr.name} — repo ${pr.dir}`);
286
+ console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
214
287
  console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
215
- summary = await runTeam({ project: pr, task: prepared, runDir, signal: cancel.signal });
288
+ summary = await runTeam({ project: pr, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
216
289
  }
217
290
  finally {
218
291
  cancel.dispose();
219
292
  lease.release();
220
293
  }
221
- reportRun(summary, runDir);
294
+ await driveRun(pr, runDir, summary, !!opts.assumeDefaults);
222
295
  }
223
296
  catch (e) {
224
297
  fail(e instanceof ConfigError ? e.message : e.message);
@@ -228,6 +301,7 @@ program
228
301
  .command("resume [run-id]")
229
302
  .description("Continue an interrupted (or failed) run: same run dir, sessions and round count; the task is not re-sent. Without an id, picks the newest run that is not done or running; with an id, continues that run.")
230
303
  .option("-p, --project <name>")
304
+ .option("--assume-defaults", "if the run waits for answers, use the suggested answer for every unanswered question that has one")
231
305
  .action(async (runId, opts) => {
232
306
  try {
233
307
  const pr = loadProject(opts.project);
@@ -236,7 +310,6 @@ program
236
310
  console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
237
311
  if (!res.ok)
238
312
  fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
239
- await preflight(pr);
240
313
  let found;
241
314
  if (runId) {
242
315
  assertName("run", runId);
@@ -251,6 +324,8 @@ program
251
324
  fail("No unfinished run to resume. Start one with: agent-lyceum run \"<task>\"");
252
325
  }
253
326
  const { dir, state } = found;
327
+ // A solo run needs only its own agent's runtime, as when it started.
328
+ await preflight(state.solo_agent ? onlyAgent(soloProject(pr, state.solo_agent), state.solo_agent) : pr);
254
329
  if (!state.end_reason && runIsAlive(state, pr))
255
330
  fail(`Run ${state.run_id} is still running (pid ${state.pid}).`);
256
331
  if (state.end_reason === "done") {
@@ -263,20 +338,51 @@ program
263
338
  }
264
339
  if (state.rounds >= pr.dispatcher.max_rounds)
265
340
  fail(`Run ${state.run_id} used ${state.rounds}/${pr.dispatcher.max_rounds} rounds; raise dispatcher.max_rounds in project.yaml first.`);
266
- const lease = takeLock(pr, state.run_id);
267
- let summary;
268
- const cancel = cancelOnSignals();
269
- try {
270
- console.log(`Project ${pr.name} — repo ${pr.dir}`);
271
- console.log(`Resuming run ${state.run_id} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${pr.dispatcher.max_rounds}: ${state.task_summary}`);
272
- // Read the state again under the lock: another process may have changed it while we were checking.
273
- summary = await runTeam({ project: pr, resume: loadRunState(dir), runDir: dir, signal: cancel.signal });
341
+ if (state.end_reason === "waiting") {
342
+ const prep = prepareAnswers(dir, { assumeDefaults: !!opts.assumeDefaults });
343
+ if (!prep.complete) {
344
+ printWaiting(pr, state.run_id, prep.path, prep.problems);
345
+ process.exit(3);
346
+ }
274
347
  }
275
- finally {
276
- cancel.dispose();
277
- lease.release();
348
+ const summary = await continueRun(pr, dir, state.run_id);
349
+ await driveRun(pr, dir, summary, !!opts.assumeDefaults);
350
+ }
351
+ catch (e) {
352
+ fail(e instanceof ConfigError ? e.message : e.message);
353
+ }
354
+ });
355
+ program
356
+ .command("answer <run-id>")
357
+ .description("Answer the questions a waiting run asked: opens ask-reply.md in $EDITOR, checks the answers, then continues the run. With --no-edit it only checks a file you already edited.")
358
+ .option("-p, --project <name>")
359
+ .option("--no-edit", "do not open the editor; check the file as it is")
360
+ .option("--assume-defaults", "use the suggested answer for every unanswered question that has one")
361
+ .action(async (runId, opts) => {
362
+ try {
363
+ assertName("run", runId);
364
+ const pr = loadProject(opts.project);
365
+ const dir = path.join(pr.paths.runs, runId);
366
+ if (!fs.existsSync(path.join(dir, "state.json")))
367
+ fail(`Run "${runId}" not found in ${pr.paths.runs}.`);
368
+ const state = loadRunState(dir);
369
+ if (state.end_reason !== "waiting")
370
+ fail(`Run ${runId} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
371
+ if (opts.edit)
372
+ openInEditor(prepareAnswers(dir, {}).path);
373
+ const prep = prepareAnswers(dir, { assumeDefaults: !!opts.assumeDefaults });
374
+ if (!prep.complete) {
375
+ printWaiting(pr, runId, prep.path, prep.problems);
376
+ process.exit(3);
278
377
  }
279
- reportRun(summary, dir);
378
+ const res = validateProject(pr);
379
+ for (const i of res.issues)
380
+ console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
381
+ if (!res.ok)
382
+ fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
383
+ await preflight(pr);
384
+ const summary = await continueRun(pr, dir, runId);
385
+ await driveRun(pr, dir, summary, !!opts.assumeDefaults);
280
386
  }
281
387
  catch (e) {
282
388
  fail(e instanceof ConfigError ? e.message : e.message);
@@ -407,6 +513,129 @@ program
407
513
  fail(e.message);
408
514
  }
409
515
  });
516
+ program
517
+ .command("call <agent> [task]")
518
+ .description("Call one agent of the global library on its own, outside any project: it works in --dir (default: the current directory) and its final answer is printed. It cannot mail anyone or ask you.")
519
+ .option("--task-file <path>", "read the task from a file")
520
+ .option("--dir <path>", "the directory the agent works in (default: the current directory)")
521
+ .action(async (agentName, task, opts) => {
522
+ try {
523
+ if (task !== undefined && opts.taskFile)
524
+ fail("Give the task either as text or with --task-file, not both.");
525
+ const text = opts.taskFile ? readTaskFile(absPath(opts.taskFile, process.cwd())) : task;
526
+ if (!text?.trim())
527
+ fail("Give the task as text or with --task-file.");
528
+ const h = home();
529
+ const dir = absPath(opts.dir ?? ".", process.cwd());
530
+ const agent = resolveCallAgent(h, agentName);
531
+ await preflight(callProject(h, agent, dir, path.join(callsDir(h), "preflight")));
532
+ const cancel = cancelOnSignals();
533
+ let r;
534
+ try {
535
+ r = await callAgent({ home: h, agent: agentName, task: text, dir, signal: cancel.signal });
536
+ }
537
+ finally {
538
+ cancel.dispose();
539
+ }
540
+ console.error(`Call ${r.callId}: ${r.callDir}`);
541
+ if (r.cancelled)
542
+ fail("Cancelled.", 130);
543
+ if (!r.ok)
544
+ fail(`Call failed: ${r.error}\nLog: ${path.join(r.callDir, "log")}`);
545
+ console.log(r.text.trimEnd());
546
+ }
547
+ catch (e) {
548
+ fail(e instanceof ConfigError ? e.message : e.message);
549
+ }
550
+ });
551
+ const memoryCmd = program.command("memory").description("Look after the agents' long-term memory");
552
+ function layerOpt(v) {
553
+ if (v === undefined || v === "project")
554
+ return "project";
555
+ if (v === "global")
556
+ return "global";
557
+ return fail(`--layer must be "project" or "global" (task memory is not tidied: \`clear\` deletes it).`);
558
+ }
559
+ memoryCmd
560
+ .command("tidy")
561
+ .description("Have agents tidy their own memory: merge duplicates, fix stale entries, archive what is obsolete (never deletes). Manual only; nothing triggers it automatically.")
562
+ .option("-p, --project <name>")
563
+ .option("--agent <name>", "tidy only this agent (default: every agent)")
564
+ .option("--layer <layer>", "project (default) or global; global memory is shared by every project, so it is never tidied unless you ask")
565
+ .option("--dry-run", "only show the memory sizes and what changed in the project; wake nobody, change nothing")
566
+ .action(async (opts) => {
567
+ try {
568
+ const layer = layerOpt(opts.layer);
569
+ const pr = loadProject(opts.project);
570
+ if (opts.dryRun) {
571
+ const dry = await tidyMemory({ project: pr, layer, agent: opts.agent, dryRun: true });
572
+ if (dry.length === 0)
573
+ console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or use --layer global)." : "Set `memory.global` for an agent."}`);
574
+ return;
575
+ }
576
+ const res = validateProject(pr);
577
+ for (const i of res.issues)
578
+ console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
579
+ if (!res.ok)
580
+ fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
581
+ await preflight(pr);
582
+ const lease = takeLock(pr, `tidy-${tidyStamp()}`);
583
+ const cancel = cancelOnSignals();
584
+ let results;
585
+ try {
586
+ results = await tidyMemory({ project: pr, layer, agent: opts.agent, signal: cancel.signal });
587
+ }
588
+ finally {
589
+ cancel.dispose();
590
+ lease.release();
591
+ }
592
+ if (results.length === 0)
593
+ console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or tidy global memory with --layer global)." : "Set `memory.global` for an agent."}`);
594
+ for (const r of results) {
595
+ if (r.status === "tidied")
596
+ console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")} — undo with: agent-lyceum memory restore ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
597
+ else if (r.status === "skipped")
598
+ console.log(` ${r.agent} (${r.layer}): skipped — ${r.problems.join("; ")}`);
599
+ else if (r.status === "failed") {
600
+ console.error(` ${r.agent} (${r.layer}): FAILED, memory restored to how it was`);
601
+ for (const p of r.problems)
602
+ console.error(` - ${p}`);
603
+ }
604
+ }
605
+ process.exit(results.some((r) => r.status === "failed") ? 1 : 0);
606
+ }
607
+ catch (e) {
608
+ fail(e instanceof ConfigError ? e.message : e.message);
609
+ }
610
+ });
611
+ memoryCmd
612
+ .command("restore <stamp>")
613
+ .description("Move the files of one tidy's archive back (nothing that exists now is overwritten) and re-add them to MEMORY.md")
614
+ .option("-p, --project <name>")
615
+ .requiredOption("--agent <name>")
616
+ .option("--layer <layer>", "project (default) or global")
617
+ .action((stamp, opts) => {
618
+ try {
619
+ const layer = layerOpt(opts.layer);
620
+ const pr = loadProject(opts.project);
621
+ const lease = takeLock(pr, `restore-${tidyStamp()}`);
622
+ try {
623
+ const r = restoreMemory({ project: pr, agent: opts.agent, layer, stamp });
624
+ for (const f of r.restored)
625
+ console.log(` restored ${f}`);
626
+ for (const s of r.skipped)
627
+ console.log(` skipped ${s.rel}: ${s.reason}`);
628
+ if (!r.restored.length && !r.skipped.length)
629
+ console.log(`Archive ${stamp} holds no files to restore.`);
630
+ }
631
+ finally {
632
+ lease.release();
633
+ }
634
+ }
635
+ catch (e) {
636
+ fail(e.message);
637
+ }
638
+ });
410
639
  function useColor() {
411
640
  if (process.env.NO_COLOR)
412
641
  return false;
package/dist/config.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface ResolvedAgent {
18
18
  resume: boolean;
19
19
  canMessage: "all" | string[];
20
20
  canEditAgentMd: boolean;
21
+ /** May send `type: ask` mail to the user (the run then waits for the answer). */
22
+ canAskUser: boolean;
21
23
  owns: string[];
22
24
  /** Where each field's value came from (for `validate` output). */
23
25
  sources: Record<string, Source>;
@@ -39,6 +41,8 @@ export interface ResolvedProject {
39
41
  };
40
42
  /** Agents working in their own git worktree instead of `dir` (set by the dispatcher for the wake-ups in progress). */
41
43
  workspaces?: Record<string, AgentWorkspace>;
44
+ /** Set for a single agent working alone: "run" = inside a run of this project (`run --agent`), "call" = outside any project (`call`). */
45
+ solo?: "run" | "call";
42
46
  }
43
47
  export declare function loadGlobal(home: string): {
44
48
  agents: Record<string, {
@@ -53,6 +57,7 @@ export declare function loadGlobal(home: string): {
53
57
  resume?: boolean | undefined;
54
58
  can_message?: "all" | string[] | undefined;
55
59
  can_edit_agent_md?: boolean | undefined;
60
+ can_ask_user?: boolean | undefined;
56
61
  owns?: string[] | undefined;
57
62
  }>;
58
63
  };
@@ -82,10 +87,13 @@ export declare function loadProjectRaw(home: string, name: string): {
82
87
  resume?: boolean | undefined;
83
88
  can_message?: "all" | string[] | undefined;
84
89
  can_edit_agent_md?: boolean | undefined;
90
+ can_ask_user?: boolean | undefined;
85
91
  owns?: string[] | undefined;
86
92
  }>;
87
93
  };
88
94
  export declare function resolveProject(home: string, name: string): ResolvedProject;
95
+ /** The merge `resolveProject` does, from configs already read: `call` uses it with a made-up project that lists one global agent. */
96
+ export declare function resolveProjectFrom(home: string, name: string, g: ReturnType<typeof loadGlobal>, p: ReturnType<typeof loadProjectRaw>): ResolvedProject;
89
97
  /** Registered projects: name -> repo dir (best effort; skips broken configs). */
90
98
  export declare function listProjects(home: string): {
91
99
  name: string;
package/dist/config.js CHANGED
@@ -51,8 +51,10 @@ function resolveLayer(p, base) {
51
51
  return out;
52
52
  }
53
53
  export function resolveProject(home, name) {
54
- const g = loadGlobal(home);
55
- const p = loadProjectRaw(home, name);
54
+ return resolveProjectFrom(home, name, loadGlobal(home), loadProjectRaw(home, name));
55
+ }
56
+ /** The merge `resolveProject` does, from configs already read: `call` uses it with a made-up project that lists one global agent. */
57
+ export function resolveProjectFrom(home, name, g, p) {
56
58
  const paths = projectPaths(home, name);
57
59
  const hp = homePaths(home);
58
60
  const lead = p.team.lead;
@@ -130,11 +132,13 @@ export function resolveProject(home, name) {
130
132
  const resume = pick("resume");
131
133
  const canMessage = pick("can_message");
132
134
  const canEdit = pick("can_edit_agent_md");
135
+ const canAsk = pick("can_ask_user");
133
136
  const owns = pick("owns");
134
137
  for (const [k, v] of [
135
138
  ["resume", resume],
136
139
  ["can_message", canMessage],
137
140
  ["can_edit_agent_md", canEdit],
141
+ ["can_ask_user", canAsk],
138
142
  ["owns", owns],
139
143
  ]) {
140
144
  if (v === undefined)
@@ -150,6 +154,7 @@ export function resolveProject(home, name) {
150
154
  resume: resume ?? isLead,
151
155
  canMessage: canMessage ?? (isLead ? "all" : [lead]),
152
156
  canEditAgentMd: canEdit ?? isLead,
157
+ canAskUser: canAsk ?? isLead,
153
158
  owns: owns ?? [],
154
159
  sources,
155
160
  };
@@ -198,7 +203,7 @@ export function findProjectForCwd(home, cwd) {
198
203
  }
199
204
  return best?.name;
200
205
  }
201
- const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "owns"];
206
+ const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "owns"];
202
207
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
203
208
  export function resolveProjectWithSources(home, name) {
204
209
  const project = resolveProject(home, name);
@@ -255,6 +260,7 @@ export function flattenResolved(project) {
255
260
  v[`${p}.resume`] = a.resume;
256
261
  v[`${p}.can_message`] = a.canMessage;
257
262
  v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
263
+ v[`${p}.can_ask_user`] = a.canAskUser;
258
264
  v[`${p}.owns`] = a.owns;
259
265
  }
260
266
  return v;
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { tidyAdvice } from "./memory-tidy.js";
4
5
  import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
5
6
  import { runInvocation } from "./process-runner.js";
6
7
  import { validateProject } from "./validate.js";
@@ -132,6 +133,8 @@ export async function diagnoseProject(project, opts = {}) {
132
133
  message: alive ? `A run is active: ${lock.run_id} (pid ${lock.pid}).` : `A stale run lock is left by ${lock.run_id} (pid ${lock.pid} is gone); \`agent-lyceum unlock --force\` clears it.`,
133
134
  });
134
135
  }
136
+ for (const a of tidyAdvice(project))
137
+ checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum memory tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
135
138
  const runtimes = await probeProjectRuntimes(project, opts);
136
139
  checks.push(...judgeRuntimes(project, runtimes));
137
140
  checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
package/dist/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;
@@ -88,6 +88,14 @@ export interface RouteResult {
88
88
  file: string;
89
89
  meta: Record<string, unknown>;
90
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
+ }[];
91
99
  /** Latest `## Steps` checklist the lead sent, if any. */
92
100
  steps?: Step[];
93
101
  }
package/dist/mailbox.js CHANGED
@@ -8,7 +8,7 @@ import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-stor
8
8
  import { inboxDir, outboxDir } from "./policy.js";
9
9
  import { must } from "./assert.js";
10
10
  export { atomicWrite };
11
- export const MESSAGE_TYPES = ["task", "reply", "failure", "done"];
11
+ export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
12
12
  export function newId() {
13
13
  return crypto.randomBytes(3).toString("hex");
14
14
  }
@@ -165,7 +165,7 @@ export function rejectDone(file) {
165
165
  }
166
166
  /** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
167
167
  export function routeOutboxes(project) {
168
- const res = { delivered: [], rejected: [], warnings: [] };
168
+ const res = { delivered: [], rejected: [], warnings: [], asks: [] };
169
169
  const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
170
170
  for (const sender of Object.values(project.agents)) {
171
171
  const dir = outboxDir(project, sender.name);
@@ -207,10 +207,18 @@ export function routeOutboxes(project) {
207
207
  }
208
208
  const type = (d.type ?? "reply");
209
209
  if (!MESSAGE_TYPES.includes(type) || type === "failure") {
210
- 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).`);
211
211
  continue;
212
212
  }
213
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
+ }
214
222
  if (type === "done") {
215
223
  if (sender.name !== project.lead) {
216
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[];