agent-lyceum 0.5.0 → 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.
package/dist/prompt.js CHANGED
@@ -34,13 +34,31 @@ export function buildSystemPrompt(project, agent) {
34
34
  ? [
35
35
  `That directory is your own isolated git worktree (branch \`${project.workspaces?.[agent.name]?.branch}\`): teammates cannot see your changes while you work, and you cannot see theirs. Do not commit, push, switch branches or touch any other checkout; the dispatcher collects your changes from this directory after you finish.`,
36
36
  ]
37
- : []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", "type: reply # task | reply" + (isLead ? " | done" : ""), "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
37
+ : []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", `type: reply # task | reply${agent.canAskUser ? " | ask" : ""}${isLead ? " | done" : ""}`, "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
38
38
  ? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
39
39
  : `Report results to the lead by mail; the lead decides what happens next.`, isLead
40
40
  ? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
41
41
  : "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
42
42
  ? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run."
43
- : "- `done` has no required headings.", ...(isLead
43
+ : "- `done` has no required headings.", ...(agent.canAskUser
44
+ ? [
45
+ "",
46
+ "## Asking the user",
47
+ "You may ask the human user with `type: ask` (no `to` needed). The run then pauses until they answer, so ask only when the answer changes what you do next and you cannot work it out from the code, the memory or the task. Ask everything you need in ONE batch (at most 10 questions); prefer questions with `<options>` and a `<suggested>` value (with a `reason`) so the user can answer quickly. The body is exactly this:",
48
+ "",
49
+ "```",
50
+ "<ask>",
51
+ ' <question id="q1">',
52
+ " <text>Session or JWT for login?</text>",
53
+ " <options><option>session</option><option>jwt</option></options>",
54
+ ' <suggested reason="existing code already uses cookies">session</suggested>',
55
+ " </question>",
56
+ "</ask>",
57
+ "```",
58
+ "",
59
+ "The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer that starts with `other:` is free text. Do not ask what you could decide yourself, and do not ask again for something already answered.",
60
+ ]
61
+ : []), ...(isLead
44
62
  ? [
45
63
  "",
46
64
  "## Progress tracking (optional, shown in `agent-lyceum status`)",
@@ -123,3 +141,58 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1) {
123
141
  }
124
142
  return parts.join("\n");
125
143
  }
144
+ /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
145
+ export function buildTidyPrompts(agent, ctx) {
146
+ const systemPrompt = [
147
+ readPersona(agent),
148
+ "",
149
+ "---",
150
+ "",
151
+ "# Memory tidy (injected by agent-lyceum)",
152
+ "",
153
+ `You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy your own ${ctx.layer} memory so it stays small, accurate and useful.`,
154
+ "",
155
+ "## What you may touch",
156
+ `Only files inside \`${ctx.dir}\`. Everything else is read-only. Do not touch your other memory directories, \`${ctx.dir}/.tidy-state.json\` or the archive folders of earlier tidies.`,
157
+ "",
158
+ "## What to do",
159
+ "- Merge entries that say the same thing; keep one file with the combined, current content.",
160
+ "- Rewrite entries that are stale (see the project changes and the missing paths below); correct them if you can tell what is true now, otherwise keep them and say so in the report.",
161
+ "- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
162
+ "- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
163
+ "",
164
+ "## Never delete",
165
+ `To retire a file, move it into the archive folder, keeping its relative path (\`${ctx.dir}/x/y.md\` goes to \`${ctx.archiveDir}/x/y.md\`). Never delete a file. \`MEMORY.md\` itself is never archived. A file whose content you merged into another one is archived too.`,
166
+ "",
167
+ "## Before you finish",
168
+ `Write \`${ctx.archiveDir}/tidy-report.md\` — even when you changed nothing — with these headings: \`## Merged\` (what was combined into what), \`## Archived\` (each archived file and why), \`## Unsure\` (things you kept but doubt). Write \`None\` under a heading with nothing to say. The tidy is rolled back if the report is missing or any file went missing.`,
169
+ ].join("\n");
170
+ const parts = [
171
+ `Archive directory: ${ctx.archiveDir}`,
172
+ `Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
173
+ `Memory directory: ${ctx.dir}`,
174
+ "",
175
+ "# Files",
176
+ ...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
177
+ "",
178
+ `# MEMORY.md (${ctx.dir}/MEMORY.md)`,
179
+ ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
180
+ ];
181
+ if (ctx.layer === "project") {
182
+ const c = ctx.changes;
183
+ parts.push("", "# Changes in the project since the last tidy");
184
+ if (!c)
185
+ parts.push("(not available)");
186
+ else {
187
+ if (c.note)
188
+ parts.push(c.note);
189
+ if (c.log.length)
190
+ parts.push("", "Commits:", ...c.log);
191
+ if (c.stat.length)
192
+ parts.push("", "Files changed:", ...c.stat);
193
+ }
194
+ }
195
+ parts.push("", "# Paths named in the memory that no longer exist");
196
+ parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
197
+ return { systemPrompt, userPrompt: parts.join("\n") };
198
+ }
@@ -16,6 +16,8 @@ export function endOutcome(endReason, done) {
16
16
  return { outcome: done?.outcome ?? "partial", note: done?.note };
17
17
  case "lead_failed":
18
18
  return { outcome: "failed", note: "The lead's wake-up failed." };
19
+ case "waiting":
20
+ return { outcome: "waiting", note: "Waiting for your answers. Fill in ask-reply.md or run `agent-lyceum answer <run-id>`, then continue with `agent-lyceum resume`." };
19
21
  case "cancelled":
20
22
  return { outcome: "cancelled", note: "Cancelled before the lead finished. Unread mail was kept; continue with `agent-lyceum resume`." };
21
23
  case "idle":
@@ -8,6 +8,8 @@ export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic
8
8
  export declare const RESULT_FILE = "result.md";
9
9
  /** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
10
10
  export declare const MAX_DONE_REJECTIONS = 2;
11
+ /** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
12
+ export declare const MAX_ASK_REJECTIONS = 2;
11
13
  export interface RunSummary {
12
14
  runId: string;
13
15
  runDir: string;
@@ -21,6 +23,8 @@ export interface RunSummary {
21
23
  subject: string;
22
24
  body: string;
23
25
  };
26
+ /** Set when the run waits for the user: the file with the questions. */
27
+ askReplyPath?: string;
24
28
  }
25
29
  export interface RunOptions {
26
30
  project: ResolvedProject;
@@ -63,6 +67,8 @@ export declare class RunSession {
63
67
  /** The attempt that produced each batch member's output; committed together with its claim. */
64
68
  private readonly attempts;
65
69
  constructor(opts: RunOptions);
70
+ /** A waiting run only continues once every unanswered question has a valid answer. */
71
+ private static assertAnswered;
66
72
  private saveState;
67
73
  private journal;
68
74
  private note;
@@ -76,6 +82,10 @@ export declare class RunSession {
76
82
  private integrateMember;
77
83
  /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
78
84
  private settle;
85
+ /** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
86
+ private deliverAnswers;
87
+ /** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
88
+ private handleAsks;
79
89
  /** On resume: sort out what the interrupted process left behind before anything is woken. */
80
90
  private recover;
81
91
  run(): Promise<RunSummary>;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { realInvoker } from "./adapters/index.js";
4
4
  import { briefOf, doneContract, parseSteps } from "./format.js";
5
5
  import { ProtectedGuard } from "./guard.js";
6
+ import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, writeAskReply } from "./ask-reply.js";
6
7
  import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
7
8
  import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
8
9
  import { isInside } from "./paths.js";
@@ -19,6 +20,8 @@ export { newRunId };
19
20
  export const RESULT_FILE = "result.md";
20
21
  /** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
21
22
  export const MAX_DONE_REJECTIONS = 2;
23
+ /** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
24
+ export const MAX_ASK_REJECTIONS = 2;
22
25
  /** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
23
26
  function retryPrompt(prompt, error) {
24
27
  return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
@@ -53,6 +56,8 @@ export class RunSession {
53
56
  attempts = new Map();
54
57
  constructor(opts) {
55
58
  const { task, resume, runDir } = opts;
59
+ if (resume?.end_reason === "waiting")
60
+ RunSession.assertAnswered(runDir, resume.run_id);
56
61
  if (!task && !resume)
57
62
  throw new Error("runTeam needs a task or a run to resume");
58
63
  const invoke = opts.invoker ?? realInvoker;
@@ -129,6 +134,16 @@ export class RunSession {
129
134
  this.guard = guard;
130
135
  this.state = state;
131
136
  }
137
+ /** A waiting run only continues once every unanswered question has a valid answer. */
138
+ static assertAnswered(runDir, runId) {
139
+ const reply = readAskReply(runDir);
140
+ if (!reply)
141
+ return;
142
+ const check = checkAnswers(reply);
143
+ if (!isComplete(check)) {
144
+ throw new Error(`Run ${runId} is still waiting for answers in ${askReplyPath(runDir)}:\n${describeCheck(check).map((l) => ` ${l}`).join("\n")}`);
145
+ }
146
+ }
132
147
  saveState() {
133
148
  saveRunState(this.runDir, this.state);
134
149
  }
@@ -354,6 +369,7 @@ export class RunSession {
354
369
  contract = undefined;
355
370
  }
356
371
  }
372
+ const askAccepted = this.handleAsks(route);
357
373
  for (const d of route.delivered)
358
374
  log("route", d);
359
375
  const handedOff = (from, t) => {
@@ -402,6 +418,10 @@ export class RunSession {
402
418
  this.endReason = "done";
403
419
  return;
404
420
  }
421
+ if (askAccepted) {
422
+ this.endReason = "waiting";
423
+ return;
424
+ }
405
425
  const leadIdx = batch.findIndex((a) => a.name === project.lead);
406
426
  const leadResult = leadIdx >= 0 ? results[leadIdx] : undefined;
407
427
  if (leadResult && !leadResult.ok && !leadResult.cancelled) {
@@ -409,9 +429,86 @@ export class RunSession {
409
429
  return;
410
430
  }
411
431
  }
432
+ /** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
433
+ deliverAnswers() {
434
+ const { runDir, project, log } = this;
435
+ const reply = readAskReply(runDir);
436
+ if (!reply)
437
+ return;
438
+ const ids = reply.questions.filter((q) => !q.delivered).map((q) => q.id).join(",");
439
+ for (const asker of askersWithAnswers(reply)) {
440
+ deliverOnce(project, this.journal(), `ask-answer:${ids}:${asker}`, {
441
+ from: "user",
442
+ to: asker,
443
+ type: "reply",
444
+ subject: "Answers to your questions",
445
+ body: renderAnswers(reply, asker),
446
+ });
447
+ }
448
+ writeAskReply(runDir, markDelivered(reply));
449
+ log("ask-answered", { questions: ids });
450
+ }
451
+ /** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
452
+ handleAsks(route) {
453
+ const { say, runDir, project, log, state } = this;
454
+ if (route.asks.length === 0)
455
+ return false;
456
+ let reply = readAskReply(runDir);
457
+ const accepted = [];
458
+ for (const a of route.asks) {
459
+ const parsed = parseAskBody(a.body);
460
+ const added = parsed.ok ? addBatch(reply, a.from, state.rounds, parsed.questions) : parsed;
461
+ if (!added.ok) {
462
+ const n = (state.ask_rejections?.[a.from] ?? 0) + 1;
463
+ state.ask_rejections = { ...state.ask_rejections, [a.from]: n };
464
+ rejectDone(a.file);
465
+ const reason = added.error;
466
+ say(` ask from ${a.from} rejected: ${reason}`);
467
+ log("ask-rejected", { from: a.from, reason, count: n });
468
+ if (n <= MAX_ASK_REJECTIONS) {
469
+ deliverOnce(project, this.journal(), `ask-reject:${a.source}`, {
470
+ from: "dispatcher",
471
+ to: a.from,
472
+ type: "failure",
473
+ subject: "Your ask was not accepted",
474
+ body: `Your \`ask\` was not accepted: ${reason}.\n\nSend it again with this shape (at most 10 questions; \`options\` and \`suggested\` are optional):\n\n<ask>\n <question id="q1">\n <text>Question?</text>\n <options><option>a</option><option>b</option></options>\n <suggested reason="why">a</suggested>\n </question>\n</ask>\n\n(Reminder ${n} of ${MAX_ASK_REJECTIONS}.)`,
475
+ });
476
+ }
477
+ else {
478
+ deliverOnce(project, this.journal(), `ask-reject:${a.source}`, {
479
+ from: "dispatcher",
480
+ to: project.lead,
481
+ type: "failure",
482
+ subject: `${a.from}'s ask was rejected ${n} times`,
483
+ body: `${a.from} sent a malformed \`ask\` rejected ${n} times (last problem: ${reason}). It was dropped. Decide without asking the user, or send the question yourself in the correct format.`,
484
+ });
485
+ }
486
+ continue;
487
+ }
488
+ reply = added.reply;
489
+ accepted.push(a.file);
490
+ }
491
+ if (!accepted.length || !reply)
492
+ return false;
493
+ if (route.done) {
494
+ for (const f of accepted)
495
+ finishDone(f);
496
+ this.note("An ask was ignored because the lead also sent done in the same round.");
497
+ return false;
498
+ }
499
+ // Questions are stored first, the outbox file moves second: a crash in between re-routes the same ask, and addBatch skips it.
500
+ writeAskReply(runDir, reply);
501
+ for (const f of accepted)
502
+ finishDone(f);
503
+ log("ask", { questions: reply.questions.filter((q) => !q.delivered).map((q) => q.id) });
504
+ say(` waiting for your answers: ${askReplyPath(runDir)}`);
505
+ return true;
506
+ }
412
507
  /** On resume: sort out what the interrupted process left behind before anything is woken. */
413
508
  recover() {
414
509
  const { say, runId, runDir, project, wsMode, log } = this;
510
+ if (this.resume?.end_reason === "waiting")
511
+ this.deliverAnswers();
415
512
  const rec = recoverRunMail(runDir, { outboxOf: (agent) => outboxDir(project, agent) });
416
513
  for (const i of rec.interrupted) {
417
514
  this.note(`${i.attempt.agent} attempt ${i.attempt.n} was interrupted${i.parked.length ? `; ${i.parked.length} output file(s) set aside in ${path.join("mail", "attempts", i.attempt.id, "outbox")}` : ""}. ` +
@@ -500,6 +597,6 @@ export class RunSession {
500
597
  this.saveState();
501
598
  if (this.pendingDoneFile)
502
599
  finishDone(this.pendingDoneFile);
503
- return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage };
600
+ return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined };
504
601
  }
505
602
  }
@@ -3,7 +3,7 @@ import type { Step } from "./format.js";
3
3
  import type { RunOutcome } from "./schema.js";
4
4
  export declare const STATE_FILE = "state.json";
5
5
  export declare const STATE_SCHEMA_VERSION = 2;
6
- export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled";
6
+ export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled" | "waiting";
7
7
  export type MailLayout = "run" | "legacy";
8
8
  export interface WakeTopic {
9
9
  from: string;
@@ -56,6 +56,8 @@ export interface RunState {
56
56
  verification?: string;
57
57
  /** How many `done` mails were sent back because they broke the completion contract. */
58
58
  done_rejections?: number;
59
+ /** How many malformed `ask` mails each agent had sent back (the limit is MAX_ASK_REJECTIONS). */
60
+ ask_rejections?: Record<string, number>;
59
61
  /** Recovery remarks (interrupted attempts whose side effects may have happened). */
60
62
  notes?: string[];
61
63
  /** "worktree": non-lead agents work in their own git worktrees. */
package/dist/run-store.js CHANGED
@@ -23,12 +23,13 @@ const WireState = z
23
23
  ended_at: z.string().optional(),
24
24
  rounds: nonNeg.optional(),
25
25
  max_rounds: nonNeg.optional(),
26
- end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled"]).optional(),
27
- outcome: z.enum(["completed", "partial", "blocked", "failed", "cancelled"]).optional(),
26
+ end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled", "waiting"]).optional(),
27
+ outcome: z.enum(["completed", "partial", "blocked", "failed", "waiting", "cancelled"]).optional(),
28
28
  outcome_note: z.string().optional(),
29
29
  verification: z.string().optional(),
30
30
  done_rejections: nonNeg.optional(),
31
31
  notes: z.array(z.string()).optional(),
32
+ ask_rejections: z.record(z.string(), nonNeg).optional(),
32
33
  sessions: z.record(z.string(), z.string()).optional(),
33
34
  output_tokens: nonNeg.optional(),
34
35
  last_wake: z.record(z.string(), z.any()).optional(),
@@ -115,5 +116,5 @@ export function outcomeOf(s) {
115
116
  return { outcome: s.outcome, verified: true };
116
117
  if (!s.end_reason)
117
118
  return undefined;
118
- return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : "partial", verified: false };
119
+ return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
119
120
  }
package/dist/schema.d.ts CHANGED
@@ -30,6 +30,7 @@ export declare const AgentPartial: z.ZodObject<{
30
30
  resume: z.ZodOptional<z.ZodBoolean>;
31
31
  can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
32
32
  can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
33
+ can_ask_user: z.ZodOptional<z.ZodBoolean>;
33
34
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
34
35
  }, z.core.$strict>;
35
36
  export type AgentPartialT = z.infer<typeof AgentPartial>;
@@ -69,6 +70,7 @@ export declare const GlobalConfig: z.ZodObject<{
69
70
  resume: z.ZodOptional<z.ZodBoolean>;
70
71
  can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
71
72
  can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
73
+ can_ask_user: z.ZodOptional<z.ZodBoolean>;
72
74
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
73
75
  }, z.core.$strict>>>;
74
76
  }, z.core.$strict>;
@@ -112,6 +114,7 @@ export declare const ProjectConfig: z.ZodObject<{
112
114
  resume: z.ZodOptional<z.ZodBoolean>;
113
115
  can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
114
116
  can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
117
+ can_ask_user: z.ZodOptional<z.ZodBoolean>;
115
118
  owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
116
119
  }, z.core.$strict>>>;
117
120
  }, z.core.$strict>;
@@ -128,6 +131,6 @@ export interface DispatcherSettings {
128
131
  }
129
132
  export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
130
133
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
131
- export declare const RUN_OUTCOMES: readonly ["completed", "partial", "blocked", "failed", "cancelled"];
134
+ export declare const RUN_OUTCOMES: readonly ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
132
135
  export type RunOutcome = (typeof RUN_OUTCOMES)[number];
133
- export declare function exitCodeForOutcome(outcome: RunOutcome): 0 | 1 | 2 | 130;
136
+ export declare function exitCodeForOutcome(outcome: RunOutcome): 0 | 1 | 2 | 3 | 130;
package/dist/schema.js CHANGED
@@ -34,6 +34,7 @@ export const AgentPartial = z
34
34
  resume: z.boolean().optional(),
35
35
  can_message: z.union([z.literal("all"), z.array(z.string())]).optional(),
36
36
  can_edit_agent_md: z.boolean().optional(),
37
+ can_ask_user: z.boolean().optional(),
37
38
  owns: z.array(z.string()).optional(),
38
39
  })
39
40
  .strict();
@@ -71,13 +72,15 @@ export const DISPATCHER_DEFAULTS = {
71
72
  log_max_bytes: 8 * 1024 * 1024,
72
73
  };
73
74
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
74
- export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "cancelled"];
75
+ export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
75
76
  export function exitCodeForOutcome(outcome) {
76
77
  switch (outcome) {
77
78
  case "completed":
78
79
  return 0;
79
80
  case "failed":
80
81
  return 1;
82
+ case "waiting":
83
+ return 3;
81
84
  case "cancelled":
82
85
  return 130;
83
86
  default:
package/dist/status.d.ts CHANGED
@@ -44,7 +44,7 @@ export interface RunReport {
44
44
  run_id: string;
45
45
  dir: string;
46
46
  /** running: its dispatcher is alive; interrupted: it died mid-run; ended: it stopped (see end_reason). */
47
- state: "running" | "interrupted" | "ended";
47
+ state: "running" | "interrupted" | "ended" | "waiting";
48
48
  live: boolean;
49
49
  end_reason?: EndReason;
50
50
  outcome?: RunOutcome;
@@ -83,6 +83,12 @@ export interface RunReport {
83
83
  ok: boolean;
84
84
  error?: string;
85
85
  }>;
86
+ /** Present while the run waits for the user: the file to fill in and how many questions are still open. */
87
+ ask?: {
88
+ path: string;
89
+ total: number;
90
+ unanswered: number;
91
+ };
86
92
  }
87
93
  export interface AgentReport {
88
94
  name: string;
@@ -104,6 +110,8 @@ export interface StatusReport {
104
110
  };
105
111
  agents: AgentReport[];
106
112
  run?: RunReport;
113
+ /** Hints that a `memory tidy` might help. Only a suggestion; nothing runs it. */
114
+ memory_advice?: string[];
107
115
  }
108
116
  export interface TaskListReport {
109
117
  schema_version: 1;
package/dist/status.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { askReplyPath, readAskReply } from "./ask-reply.js";
3
4
  import { RESULT_FILE } from "./dispatcher.js";
4
5
  import { bindRunProject, loadRunState, outcomeOf } from "./run-store.js";
5
6
  import { listUnread } from "./mailbox.js";
7
+ import { tidyAdvice } from "./memory-tidy.js";
6
8
  import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
7
9
  export function latestRun(project) {
8
10
  const root = project.paths.runs;
@@ -76,8 +78,10 @@ function alive(pid) {
76
78
  return e.code === "EPERM";
77
79
  }
78
80
  }
79
- const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", cancelled: "已取消" };
81
+ const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", waiting: "等待回答", cancelled: "已取消" };
80
82
  export function runStateLabel(s, live = alive(s.pid)) {
83
+ if (s.end_reason === "waiting")
84
+ return "等待回答";
81
85
  if (s.end_reason) {
82
86
  const o = outcomeOf(s);
83
87
  // Runs from before outcomes existed: keep what they recorded, but never present it as a checked success.
@@ -155,10 +159,23 @@ export function buildRunReport(run, project, opts = {}) {
155
159
  const result = path.join(run.dir, RESULT_FILE);
156
160
  const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
157
161
  const cur = s.steps?.find((x) => !x.done);
162
+ let ask;
163
+ if (s.end_reason === "waiting") {
164
+ try {
165
+ const r = readAskReply(run.dir);
166
+ if (r) {
167
+ const open = r.questions.filter((q) => !q.delivered);
168
+ ask = { path: askReplyPath(run.dir), total: open.length, unanswered: open.filter((q) => !q.answer?.value.trim()).length };
169
+ }
170
+ }
171
+ catch {
172
+ ask = { path: askReplyPath(run.dir), total: 0, unanswered: 0 };
173
+ }
174
+ }
158
175
  return {
159
176
  run_id: s.run_id,
160
177
  dir: run.dir,
161
- state: s.end_reason ? "ended" : live ? "running" : "interrupted",
178
+ state: s.end_reason === "waiting" ? "waiting" : s.end_reason ? "ended" : live ? "running" : "interrupted",
162
179
  live,
163
180
  end_reason: s.end_reason,
164
181
  outcome: o?.outcome,
@@ -186,6 +203,7 @@ export function buildRunReport(run, project, opts = {}) {
186
203
  mail_layout: s.mail_layout,
187
204
  workspace_mode: s.workspace_mode,
188
205
  last_wake: s.last_wake,
206
+ ...(ask ? { ask } : {}),
189
207
  };
190
208
  }
191
209
  const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
@@ -203,6 +221,13 @@ export function buildStatusReport(base, runId) {
203
221
  // Mail is per run: count the unread mail of the run being shown.
204
222
  const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
205
223
  const report = run ? buildRunReport(run, base) : undefined;
224
+ let advice = [];
225
+ try {
226
+ advice = tidyAdvice(base).map((a) => `${a.agent}: ${a.reason}`);
227
+ }
228
+ catch {
229
+ /* a hint only: never let it break status */
230
+ }
206
231
  return {
207
232
  schema_version: 1,
208
233
  project: projectInfo(base),
@@ -214,6 +239,7 @@ export function buildStatusReport(base, runId) {
214
239
  last_wake: run?.state.last_wake[a.name],
215
240
  })),
216
241
  run: report,
242
+ ...(advice.length ? { memory_advice: advice } : {}),
217
243
  };
218
244
  }
219
245
  /** Every run of the project, newest first. */
@@ -274,6 +300,8 @@ function summaryBlock(run, now, c) {
274
300
  const lastWake = (s.wakes ?? []).at(-1);
275
301
  if (lastWake)
276
302
  lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
303
+ if (s.ask)
304
+ lines.push(`${L("等你回答:")}${s.ask.unanswered}/${s.ask.total} 題未答 → ${s.ask.path}`, c.dim(` 填完後執行 agent-lyceum answer ${s.run_id}(或 resume)`));
277
305
  lines.push(`${L("流程:")}${flowLine(s, c)}`);
278
306
  return lines;
279
307
  }
@@ -401,5 +429,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
401
429
  const lines = [`${c.bold("專案:")}${report.project.name} ${c.dim(`(repo:${report.project.dir})`)}`, "", ...summaryBlock(run, now, c), "", ...table];
402
430
  if (run)
403
431
  lines.push("", `輸出 tokens:${tokens(run.output_tokens)}`);
432
+ if (report.memory_advice?.length)
433
+ lines.push("", `記憶:${report.memory_advice.join(";")}(建議執行 agent-lyceum memory tidy -p ${report.project.name};status 只提示,不會自動整理)`);
404
434
  return lines.join("\n");
405
435
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",
@@ -58,7 +58,7 @@
58
58
  "@types/node": "^20.19.43",
59
59
  "tsx": "^4.19.0",
60
60
  "typescript": "^7.0.2",
61
- "vite": "^7.3.7",
61
+ "vite": "^8.3.3",
62
62
  "vitest": "^4.1.11"
63
63
  }
64
64
  }