agents-pod 0.3.5 → 0.3.6

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/src/doctor.ts CHANGED
@@ -14,7 +14,9 @@ import {
14
14
  reconcileLockPath,
15
15
  storeRoot,
16
16
  } from "./paths.ts";
17
- import { readPods, type PodRecord } from "./registry.ts";
17
+ import { admissionOf, clockPhrase, openEnded } from "./admission.ts";
18
+ import { HOUR_MS, duration, inWindow, medianIntervalMs } from "./cadence.ts";
19
+ import { readPods, type Admission, type PodRecord } from "./registry.ts";
18
20
  import { readSources, type SourceRecord } from "./sources.ts";
19
21
 
20
22
  /**
@@ -63,6 +65,8 @@ const PROBE_OUTPUT_CAP = 8 * 1024;
63
65
  const PROBE_LINES = 6;
64
66
  /** A pass lock older than this is what the next pass steals (ADR 0018). */
65
67
  const LOCK_STALE_MS = 5 * 60_000;
68
+ /** A Pod above this many wake-ups in an hour is worth a line (ADR 0032). */
69
+ const WAKE_RATE_CEILING_PER_HOUR = 6;
66
70
 
67
71
  export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport> {
68
72
  const now = options.now ?? Date.now();
@@ -77,13 +81,24 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
77
81
  const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
78
82
  const lastPass = await readLastPass();
79
83
 
84
+ // The first run is where a defaulted owner is most likely (nothing else is
85
+ // configured yet), so admission is reported before the recipe can swallow it.
86
+ const sections: DoctorSection[] = [];
87
+ if (registry.error === undefined && registry.pods.length > 0) {
88
+ sections.push({ title: "wake", lines: wakeLines(registry.pods, listeners, now) });
89
+ }
90
+
80
91
  const firstRun =
81
92
  sources.length === 0 && deliverer === undefined && notify.channels.length === 0;
82
93
  if (firstRun) {
83
- return { store: root, sections: [], recipe: recipeFor(sources, listeners, deliverer, notify, lastPass), healthy: false };
94
+ return {
95
+ store: root,
96
+ sections,
97
+ recipe: recipeFor(sources, listeners, deliverer, notify, lastPass),
98
+ healthy: false,
99
+ };
84
100
  }
85
101
 
86
- const sections: DoctorSection[] = [];
87
102
  const storeLines: DoctorLine[] = [];
88
103
  if (registry.error !== undefined) {
89
104
  storeLines.push({ mark: "bad", text: `pods.json is unreadable: ${registry.error}` });
@@ -204,6 +219,120 @@ async function passLines(
204
219
  return [{ mark: "ok", text: `last pass ${age(now - lastPass)} ago` }];
205
220
  }
206
221
 
222
+ /**
223
+ * Admission, and the two shapes that cost money (ADR 0030, issue #31): a Pod on
224
+ * the tick whose goal names a clock is re-armed after every `more`, and a Pod on
225
+ * the tick whose goal is open-ended is woken with nothing new to work on. Both
226
+ * are legal records and broken intent, so they are named here rather than
227
+ * rewritten: the fix is an owner, and only the operator knows which one.
228
+ */
229
+ function wakeLines(pods: PodRecord[], listeners: ListenerRecord[], now: number): DoctorLine[] {
230
+ const bound = new Set(
231
+ listeners.flatMap((listener) =>
232
+ listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
233
+ ),
234
+ );
235
+ const counts: Record<Admission, number> = { intake: 0, external: 0, self: 0 };
236
+ const findings: DoctorLine[] = [];
237
+ for (const pod of pods) {
238
+ if (pod.state === "done" || pod.state === "cancelled") continue;
239
+ const owner = admissionOf(pod, bound);
240
+ counts[owner] += 1;
241
+ // The mirror of `external` (ADR 0030): a Pod owned by intake with no
242
+ // Listener bound to it can never be given work — nothing writes the debt.
243
+ if (owner === "intake" && !bound.has(pod.id)) {
244
+ findings.push({
245
+ mark: "bad",
246
+ text: `pod ${pod.id}: admission intake but no listener is bound to it — nothing will ever give it work`,
247
+ detail: [`next: agents-pod listener add --inbox <dir> --pod ${pod.id}`],
248
+ });
249
+ continue;
250
+ }
251
+ if (owner !== "self") continue;
252
+ // A Pod that declared itself satisfied and scheduled nothing is not a Pod
253
+ // the tick loops on (ADR 0011 amendment, #36): `awaiting` is the honest
254
+ // shape of a standing goal, and naming it as a problem would teach exactly
255
+ // the wrong lesson. But a satisfied Pod with nothing that can deliver work
256
+ // has stopped for good — "wake me when work arrives" with no arrival path
257
+ // is the one silence this report exists to break.
258
+ if (pod.policy?.awaiting) {
259
+ if (!bound.has(pod.id)) {
260
+ findings.push({
261
+ mark: "note",
262
+ text: `pod ${pod.id}: awaiting — its last window was satisfied and nothing can deliver work to it`,
263
+ detail: [
264
+ ` bind an inbox: agents-pod listener add --inbox <dir> --pod ${pod.id}`,
265
+ ` or wake it by hand: agents-pod wake ${pod.id}`,
266
+ ],
267
+ });
268
+ }
269
+ continue;
270
+ }
271
+ const clock = clockPhrase(pod.goal);
272
+ if (clock !== undefined) {
273
+ findings.push({
274
+ mark: "bad",
275
+ text: `pod ${pod.id}: its goal names a clock ("${clock}") and the tick admits it — every handoff re-arms it at the speed of the passes`,
276
+ detail: [
277
+ `next: spawn the work again with --admission external (an outside clock calls \`agents-pod wake\`)`,
278
+ " or move the clock to schedule_task and let the schedule hand the work over",
279
+ ],
280
+ });
281
+ continue;
282
+ }
283
+ if (openEnded(pod.goal)) {
284
+ findings.push({
285
+ mark: "bad",
286
+ text: `pod ${pod.id}: admission self with an open-ended goal — the tick admits it after every handoff, with nothing new to work on`,
287
+ detail: [
288
+ ` if a listener feeds it: agents-pod listener add --inbox <dir> --pod ${pod.id}`,
289
+ " if an outside clock wakes it: a Pod spawned with --admission external (ADR 0030)",
290
+ ],
291
+ });
292
+ }
293
+ }
294
+ // The rate line (ADR 0032): a Pod the tick re-arms shows up as a rhythm, not
295
+ // as a single failure, and the storm was 41 wake-ups in five hours with no
296
+ // line anywhere that said so.
297
+ for (const pod of pods) {
298
+ if (pod.state === "done" || pod.state === "cancelled") continue;
299
+ const hour = inWindow(pod.wakeUps, now, HOUR_MS).length;
300
+ if (hour <= WAKE_RATE_CEILING_PER_HOUR) continue;
301
+ const median = medianIntervalMs(pod.wakeUps);
302
+ const rhythm = median === undefined ? "" : `, median interval ${duration(median)}`;
303
+ const clock = clockPhrase(pod.goal);
304
+ const owner = admissionOf(pod, bound);
305
+ findings.push({
306
+ mark: "note",
307
+ text: `pod ${pod.id}: ${hour} wake-ups in the last hour${rhythm} — above the ${WAKE_RATE_CEILING_PER_HOUR}/h ceiling`,
308
+ detail:
309
+ clock === undefined
310
+ ? [
311
+ ` the goal names no clock: check who admits it (agents-pod status ${pod.id})`,
312
+ " and its daily budget (agents-pod spawn --wake-budget, ADR 0032)",
313
+ ]
314
+ : owner === "self"
315
+ ? [
316
+ ` its goal names a clock ("${clock}") and the tick admits it — that is the #31 shape`,
317
+ " next: agents-pod spawn … --admission external, or move the clock to schedule_task",
318
+ ]
319
+ : [
320
+ ` its goal names a clock ("${clock}") and ${owner} admits it — the rhythm is that clock's`,
321
+ ` next: check the schedule that calls agents-pod wake ${pod.id}`,
322
+ ],
323
+ });
324
+ }
325
+
326
+ const census = `${counts.intake} intake, ${counts.external} external, ${counts.self} self`;
327
+ return [
328
+ {
329
+ mark: findings.length > 0 ? "note" : "ok",
330
+ text: `admission by owner: ${census}`,
331
+ },
332
+ ...findings,
333
+ ];
334
+ }
335
+
207
336
  async function intakeLines(
208
337
  sources: SourceRecord[],
209
338
  listeners: ListenerRecord[],
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * The Handoff document, rendered once and written once (ADR 0008, 0010).
3
3
  *
4
- * The only machine surface is the first line — `status: rotating` or
5
- * `status: done` — because outcome classification greps it and the wrapper's
6
- * `freshDeclaration()` refuses anything else. Everything after it is free
4
+ * The only machine surface is the first line — `status: rotating`, `status:
5
+ * quiet`, or `status: done` — because outcome classification greps it and the
6
+ * wrapper's `freshDeclaration()` refuses anything else. Everything after it is free
7
7
  * markdown for the successor, so the sections are the ones CONTEXT.md names:
8
8
  * goal restated, decisions and reasons, done, next, pointers.
9
9
  */
10
- export type HandoffStatus = "rotating" | "done";
10
+ /**
11
+ * `rotating` — the goal is open and the next wake-up is the work; `quiet` — the
12
+ * window is satisfied, nothing is scheduled, wake me when work arrives (#36);
13
+ * `done` — the goal is reached and the Pod ends.
14
+ */
15
+ export type HandoffStatus = "rotating" | "quiet" | "done";
11
16
 
12
17
  export interface HandoffInput {
13
18
  status: HandoffStatus;
@@ -44,12 +49,18 @@ export function renderHandoff(input: HandoffInput, now: Date = new Date()): stri
44
49
  lines.push(...section("## Decisions and reasons", input.decisions));
45
50
  lines.push(...section("## Done", input.done));
46
51
  // Next is the one section a successor cannot live without, so it is always
47
- // present — an empty one says so instead of disappearing.
52
+ // present — an empty one says so instead of disappearing, and the two legal
53
+ // "nothing to do" answers say why they are empty, because the difference
54
+ // between them is the difference between an ended goal and a waiting one.
48
55
  lines.push(
49
56
  ...section(
50
57
  "## Next",
51
58
  input.next,
52
- input.status === "done" ? "(nothing — the goal is reached)" : "(unstated)",
59
+ input.status === "done"
60
+ ? "(nothing — the goal is reached)"
61
+ : input.status === "quiet"
62
+ ? "(nothing scheduled — the next window starts when work arrives)"
63
+ : "(unstated)",
53
64
  ),
54
65
  );
55
66
  lines.push(...section("## Pointers", input.pointers));
@@ -93,17 +93,26 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
93
93
  * pod, and the guardrails that make fan-out safe — the per-project cap and the
94
94
  * `--after` dependency — are the same ones the CLI enforces, because it is the
95
95
  * CLI doing the enforcing.
96
+ *
97
+ * The model-facing text is also where a hybrid ask is decomposed (ADR 0033):
98
+ * trigger × work give three shapes, the middle one is a schedule that hands over
99
+ * to a pod, and the rewrite is stated in one line — naming both units and the
100
+ * admission owner — before the first tool call, because the tie-break it
101
+ * replaces is what put a clock inside a pod's goal (issue #31).
96
102
  */
97
103
  export function registerManagementTools(pi: ExtensionAPI): void {
98
104
  pi.registerTool({
99
105
  name: "spawn_pod",
100
106
  label: "Spawn Pod",
101
107
  description:
102
- "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK: it never runs at 9:00 or every 30 minutes, it wakes when it has work. Returns the pod id.",
108
+ "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK: a time rule belongs to schedule_task (agents-cron), so an ask that has both a clock and memory across firings is a schedule that hands the work over to a pod, never a pod that reads the clock. Returns the pod id.",
103
109
  promptSnippet: "Spawn a standing pod for work that outlives this session",
104
110
  promptGuidelines: [
105
- "Decide by the signal. A TIME rule — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我 — is a schedule: use schedule_task / agents-cron, which owns when work starts. A MESSAGE or a ROLE that must stay alive — 常驻, 一直跑, 监听, 收消息, 路由, 服务某个 inbox, 直到我说停, one job spanning days — is a pod: call spawn_pod as your FIRST tool call, without surveying the repo or the plugin first.",
106
- "Never put a clock in a pod's goal. If the ask needs 09:00, the schedule owns the time and a pod owns the work it hands over; a pod is woken by intake, never by the clock.",
111
+ "Read two axes before you call anything, because one ask can carry both signals, and a trigger alone does not decide — `time` without memory is a schedule (agents-cron / `schedule_task`), `message` or `role alive` is a pod (`spawn_pod`), and only the work axis separates the hybrid. trigger: `time` — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我, 09:00; `message` — 收消息, 服务某个 inbox, 有活就干; `role alive` — 常驻, 监听, 路由, 直到我说停, or one job that spans days. work: `stateless` — one firing produces one answer and keeps nothing; `memory` — it needs what an earlier firing learned (跨天对比, 累计, 记得上次, 一份持续更新的报告).",
112
+ "Three shapes fall out of those two axes, and there is no fourth. (1) time + stateless → `schedule_task` alone: agents-cron owns the clock and the work, and a pod here is the incident. (2) time + memory → `schedule_task` owns the clock and hands the work over, `spawn_pod` owns it: bind the pod to the inbox the task appends to (`agents-pod listener add --inbox <file> --pod <podId>`) and give it `admission: intake`, or — when no batch has to be queued — let the task call `agents-pod wake <podId>` with `admission: external`. Its spawn wake-up runs once immediately; every firing after that belongs to the handover. Never a pod alone, and never `self`. (3) message / role alive → one pod via `spawn_pod`, `intake` when a listener or an inbox feeds it and `self` only when the goal reaches done, with no clock anywhere in its goal — call it as your FIRST tool call, without surveying the repo or the plugin first.",
113
+ "Say the rewrite in one line before the first tool call, naming every unit the ask actually has — and, whenever a pod is involved, its admission owner: 「这句我拆成:09:00 由 schedule_task 写 ci-daily.jsonl → pod X 被 intake 唤醒;pod X 负责汇总与跨天对比」. A schedule-only ask names no pod. If your line comes out as a pod plus a hand-written `crontab wake`, as a clock inside the pod's goal, or as a pod on `self`, that is the illegal combination (#31): say so and fix the shape instead of proceeding.",
114
+ "Never put a clock in a pod's goal: the phrases the guard recognises — 09:00, 每天, 每 30 分钟, 9 点, every 30 minutes, daily, 到点 — are refused at spawn when the pod's owner is the tick, and a pod re-armed on every pass is the storm. The clock belongs to the schedule; a pod is woken by its admission owner — a listener's batch, an outside wake, or the tick — never by a time rule written inside the goal.",
115
+ "Say who admits the pod's wake-ups (`admission`), because the tick is only one of three owners and it is the wrong one for a standing pod: `intake` when a listener or inbox feeds it (the router/watcher/inbox-server shape), `external` when an outside clock or a human wakes it with `agents-pod wake`, `self` only for a bounded goal the tick may re-wake until done. Left unstated it defaults to `self`, which wakes a standing pod every tick — the incident #31 exists to prevent.",
107
116
  "A pod is the right shape for a role, not only for a big job: a router that judges incoming messages, a watcher, an inbox server.",
108
117
  "A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
109
118
  "spawn_pod returns a pod id; report that id back to the user so they can run /pod status on it. The pod owns the work from there — do not start doing its work in this session.",
@@ -122,6 +131,12 @@ export function registerManagementTools(pi: ExtensionAPI): void {
122
131
  cwd: Type.Optional(
123
132
  Type.String({ description: "Working directory for wake-ups, relative to this project." }),
124
133
  ),
134
+ admission: Type.Optional(
135
+ Type.Union([Type.Literal("intake"), Type.Literal("external"), Type.Literal("self")], {
136
+ description:
137
+ "Who admits this pod's wake-ups (ADR 0030): `intake` — a listener feeds it (the standing/inbox shape); `external` — an outside clock or a human calls `agents-pod wake`; `self` — the tick, legal only for a bounded goal that reaches done. Default is `self`; a standing pod left there is woken on every tick.",
138
+ }),
139
+ ),
125
140
  }),
126
141
  async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
127
142
  const argv = ["spawn", params.goal, "--project", ctx.cwd];
@@ -129,6 +144,7 @@ export function registerManagementTools(pi: ExtensionAPI): void {
129
144
  if (params.model !== undefined) argv.push("--model", params.model);
130
145
  if (params.after !== undefined) argv.push("--after", params.after);
131
146
  if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
147
+ if (params.admission !== undefined) argv.push("--admission", params.admission);
132
148
 
133
149
  const outcome = await runCli(argv, ctx.cwd);
134
150
  const podId = outcome.lines.join("\n").trim();
@@ -10,9 +10,9 @@ import { renderHandoff, type HandoffStatus } from "./handoff.ts";
10
10
  export const DOCS_DIR_ENV = "AGENTS_POD_DOCS_DIR";
11
11
 
12
12
  const handoffSchema = Type.Object({
13
- status: StringEnum(["rotating", "done"] as const, {
13
+ status: StringEnum(["rotating", "quiet", "done"] as const, {
14
14
  description:
15
- "rotating: the goal is open, a successor continues. done: the goal is reached and the pod ends.",
15
+ "rotating: the goal is open and the next wake-up is the work. quiet: this window is satisfied, nothing is scheduled — wake me when work arrives (the honest ending for a standing goal). done: the goal is reached and the pod ends.",
16
16
  }),
17
17
  goal: Type.Optional(Type.String({ description: "The goal as this wake-up understood it." })),
18
18
  decisions: Type.Optional(
@@ -47,7 +47,7 @@ export function registerSelf(pi: ExtensionAPI): void {
47
47
  promptSnippet: "Write the pod handoff and end this wake-up",
48
48
  promptGuidelines: [
49
49
  "Call pod_handoff as the last action of a wake-up, after the work is recorded in the documents.",
50
- "Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff.",
50
+ "Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff; quiet means this window is satisfied and nothing is scheduled — the honest ending for a standing goal, which must not say rotating to stay alive.",
51
51
  "An exit without a fresh pod_handoff is classified as failed — pod_handoff is the clean exit, not a summary you may skip.",
52
52
  ],
53
53
  parameters: handoffSchema,
@@ -59,9 +59,12 @@ export function registerSelf(pi: ExtensionAPI): void {
59
59
  throw new Error(`pod_handoff requires ${DOCS_DIR_ENV}: this session is not a pod wake-up`);
60
60
  }
61
61
  const file = join(docsDir, "HANDOFF.md");
62
- // The schema admits exactly these two words; narrowing here keeps the
62
+ // The schema admits exactly these three words; narrowing here keeps the
63
63
  // hard line legal even if a provider reorders the enum.
64
- const status: HandoffStatus = params.status === "done" ? "done" : "rotating";
64
+ const status: HandoffStatus =
65
+ params.status === "done" || params.status === "quiet"
66
+ ? (params.status as HandoffStatus)
67
+ : "rotating";
65
68
  const body = renderHandoff({ ...params, status });
66
69
  // The agent may be editing HANDOFF.md in the same turn: one queue per file.
67
70
  await withFileMutationQueue(file, () => writeFile(file, body));
package/src/registry.ts CHANGED
@@ -10,12 +10,39 @@ import { podsFile, registryLockPath } from "./paths.ts";
10
10
  */
11
11
  export type PodState = "spawning" | "active" | "idle" | "done" | "cancelled";
12
12
 
13
- export type WakeUpOutcome = "more" | "done" | "failed" | "lost" | "cancelled";
13
+ /**
14
+ * `more` — the goal needs another wake-up now; `quiet` — this window is
15
+ * satisfied and nothing is scheduled (ADR 0011 amendment, #36); `done` — the
16
+ * goal is reached; the rest are failures of the window, not of the Pod.
17
+ */
18
+ export type WakeUpOutcome = "more" | "quiet" | "done" | "failed" | "lost" | "cancelled";
19
+
20
+ /**
21
+ * Who may wake this Pod (ADR 0030): `intake` — the batch a Listener owes;
22
+ * `external` — an outside clock or a human (`wake`); `self` — the tick. Absent
23
+ * on a record written before ADR 0030, where the listener binding was the only
24
+ * thing that could say it (src/admission.ts derives that answer, never writes it).
25
+ */
26
+ export type Admission = "intake" | "external" | "self";
27
+
28
+ /**
29
+ * What admitted a wake-up (ADR 0032). Three launchers existed and were
30
+ * indistinguishable after the fact; a storm of 41 wake-ups could not be
31
+ * attributed to any of them from the record.
32
+ */
33
+ export type WakeTrigger = "spawn" | "tick" | "wake";
34
+
35
+ /** The default daily ceiling (ADR 0032): the storm ran at 41 in five hours. */
36
+ export const DEFAULT_WAKE_BUDGET_PER_DAY = 24;
14
37
 
15
38
  export interface WakeUpEntry {
16
39
  n: number;
17
40
  outcome: WakeUpOutcome;
18
41
  detail?: string;
42
+ /** When the outcome was recorded, epoch ms. Absent on records written before ADR 0032. */
43
+ at?: number;
44
+ /** What admitted this wake-up; absent on records written before ADR 0032. */
45
+ trigger?: WakeTrigger;
19
46
  }
20
47
 
21
48
  export interface PodRecord {
@@ -26,6 +53,15 @@ export interface PodRecord {
26
53
  project: string;
27
54
  /** The upstream Pod whose done state admits this Pod's first wake-up (ADR 0022). */
28
55
  after?: string;
56
+ /** Decided at spawn, one of three (ADR 0030); the tick reads it before it launches. */
57
+ admission?: Admission;
58
+ /**
59
+ * What admitted the wake-up that is live now (ADR 0032). Written by whichever
60
+ * launcher ran under the registry lock, read when the outcome is recorded, so
61
+ * every entry can say who woke it — including the ones reconcile records on a
62
+ * wrapper's behalf (`lost`, wedged, progressless).
63
+ */
64
+ admitted?: WakeTrigger;
29
65
  wakeUps: WakeUpEntry[];
30
66
  policy?: WakePolicy;
31
67
  /** When a pass first observed a wedged wrapper (ADR 0017's two-pass rule). */
@@ -49,6 +85,13 @@ export interface WakePolicy {
49
85
  failureStreak: number;
50
86
  nextWakeAt?: number;
51
87
  parked?: boolean;
88
+ /**
89
+ * Which stop parked it (ADR 0032): three different facts that read
90
+ * identically as `parked`. Only `failures` is lifted by an ordinary `wake`;
91
+ * a `budget` stop needs `wake --over-budget`, and an `agent` stop needs the
92
+ * definition back. Absent on records written before ADR 0032.
93
+ */
94
+ parkedBy?: "failures" | "budget" | "agent";
52
95
  /** How many wake-up outcomes the policy has already taken into account. */
53
96
  appliedOutcomes: number;
54
97
  /**
@@ -59,10 +102,23 @@ export interface WakePolicy {
59
102
  * wake — the tick does not (ADR 0011 stays the rule for unbound pods).
60
103
  */
61
104
  intakePending?: number;
105
+ /**
106
+ * The window declared itself satisfied with nothing scheduled (`quiet`). The
107
+ * tick stops admitting it; a new intake batch or a human `wake` lifts it
108
+ * (ADR 0011 amendment, #36). Redundant for a Pod nobody else wakes, and
109
+ * visible in `status` where a standing goal used to be forced into `more`.
110
+ */
111
+ awaiting?: boolean;
62
112
  /** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
63
113
  agent?: string;
64
114
  /** The pod's model pin: it beats the definition's model (ADR 0012). */
65
115
  model?: string;
116
+ /**
117
+ * The daily ceiling on wake-ups (ADR 0032), from `spawn --wake-budget`;
118
+ * `DEFAULT_WAKE_BUDGET_PER_DAY` when unset. Exceeding it parks the Pod, which
119
+ * is the one notification path that already exists (ADR 0021).
120
+ */
121
+ wakeBudgetPerDay?: number;
66
122
  }
67
123
 
68
124
  export async function readPods(): Promise<PodRecord[]> {
package/src/wrapper.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  withRegistryLock,
29
29
  writePods,
30
30
  type PodRecord,
31
+ type WakeUpEntry,
31
32
  type WakeUpOutcome,
32
33
  } from "./registry.ts";
33
34
  import { snapshotDocs } from "./workspace.ts";
@@ -70,7 +71,7 @@ export function composePrompt(goal: string, docsDir: string, roleInstructions =
70
71
  `1. ${join(docsDir, "HANDOFF.md")} — your predecessor's handoff; start where it ended.`,
71
72
  `2. ${join(docsDir, "TASKS.md")} — the plan and its checkboxes.`,
72
73
  `3. ${join(docsDir, "DECISIONS.md")} — the why behind the plan.`,
73
- "When this window's work is done, end with pod_handoff (status: rotating or done) so your successor starts clean. Write-then-exit is the protocol: an exit without a fresh handoff counts as a failure.",
74
+ "When this window's work is done, end with pod_handoff so your successor starts clean: status rotating (the goal is open and the next wake-up continues), quiet (this window is satisfied, nothing is scheduled — wake me when work arrives), or done (the goal is reached and the pod ends). Write-then-exit is the protocol: an exit without a fresh handoff counts as a failure.",
74
75
  ].join("\n");
75
76
  const goalBlock = `Goal: ${goal}`;
76
77
  const sections = [preamble, roleInstructions.trim(), goalBlock].filter(
@@ -170,14 +171,19 @@ export interface Adjudication {
170
171
  async function freshDeclaration(
171
172
  docsDir: string,
172
173
  wakeStart: number,
173
- ): Promise<"more" | "done" | undefined> {
174
+ ): Promise<"more" | "quiet" | "done" | undefined> {
174
175
  const handoff = join(docsDir, "HANDOFF.md");
175
176
  const info = await stat(handoff).catch(() => undefined);
176
177
  if (!info || info.mtimeMs <= wakeStart) return undefined;
177
178
  const firstLine = (await readFile(handoff, "utf8")).split("\n", 1)[0]!.trim();
178
- const declared = /^status:\s*(rotating|done)\s*$/.exec(firstLine);
179
+ // Three words, and no fourth (ADR 0009 amendment, #36): `rotating` — another
180
+ // wake-up is the work; `quiet` — this window is satisfied and nothing is
181
+ // scheduled; `done` — terminal. Anything else is not a declaration.
182
+ const declared = /^status:\s*(rotating|quiet|done)\s*$/.exec(firstLine);
179
183
  if (!declared) return undefined;
180
- return declared[1] === "done" ? "done" : "more";
184
+ const word = declared[1];
185
+ if (word === "done") return "done";
186
+ return word === "quiet" ? "quiet" : "more";
181
187
  }
182
188
 
183
189
  /**
@@ -265,7 +271,13 @@ async function recordUnresolved(
265
271
  what: string,
266
272
  ): Promise<boolean> {
267
273
  return updatePodRecord(podId, podRoot, n, (record) => {
268
- record.wakeUps.push({ n, outcome: "failed", detail: what });
274
+ record.wakeUps.push({
275
+ n,
276
+ outcome: "failed",
277
+ detail: what,
278
+ at: Date.now(),
279
+ ...(record.admitted === undefined ? {} : { trigger: record.admitted }),
280
+ });
269
281
  record.state = "idle";
270
282
  });
271
283
  }
@@ -493,11 +505,13 @@ export async function runWrapper(argv: string[]): Promise<number> {
493
505
  // a wedged kill. A human cancel has no intent and records exactly as
494
506
  // before.
495
507
  if (record.killIntent?.n !== n) {
496
- record.wakeUps.push(
497
- killedAfterGrace
498
- ? { n, outcome: "cancelled", detail: "killed after grace" }
499
- : { n, outcome: "cancelled" },
500
- );
508
+ record.wakeUps.push({
509
+ n,
510
+ outcome: "cancelled",
511
+ ...(killedAfterGrace ? { detail: "killed after grace" } : {}),
512
+ at: Date.now(),
513
+ ...(record.admitted === undefined ? {} : { trigger: record.admitted }),
514
+ });
501
515
  record.state = "cancelled";
502
516
  }
503
517
  });
@@ -518,7 +532,17 @@ export async function runWrapper(argv: string[]): Promise<number> {
518
532
  // (ADR 0025). Failures are logged inside the flush and never change outcome.
519
533
  await flushOutbox(podId, pod.project, n);
520
534
  const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
521
- record.wakeUps.push(detail === undefined ? { n, outcome } : { n, outcome, detail });
535
+ // Provenance (ADR 0032): when, and what admitted the window. `admitted` is
536
+ // written by the launcher under the registry lock, so a wake-up the tick
537
+ // started and one a human started stop looking identical after the fact.
538
+ const entry: WakeUpEntry = {
539
+ n,
540
+ outcome,
541
+ ...(detail === undefined ? {} : { detail }),
542
+ at: Date.now(),
543
+ ...(record.admitted === undefined ? {} : { trigger: record.admitted }),
544
+ };
545
+ record.wakeUps.push(entry);
522
546
  record.state = outcome === "done" ? "done" : "idle";
523
547
  });
524
548
  if (!recorded) return 1;