agents-pod 0.3.4 → 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/CHANGELOG.md CHANGED
@@ -4,7 +4,133 @@ All notable changes to agents-pod. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.3.6] — 2026-11-05
8
+
9
+ One pod woke 41 times in five hours, and the store could not say why. Every
10
+ layer held one fact and named none of them: nothing recorded *who may wake this
11
+ pod* (`more` re-armed it, the tick obeyed, and a hand-written `crontab` line
12
+ raced both), nothing recorded *when* a wake-up happened or *what* woke it, and a
13
+ standing goal whose last word could not be `done` had no honest way to say
14
+ "this window is satisfied" — so it lied `more`, which means "now". Six issues,
15
+ four ADRs: the owner is a field (#33, ADR 0030), the derivation is told the
16
+ owner (#34, ADR 0031), every wake-up says when and who (#35, ADR 0032), a
17
+ satisfied window has a word (#36, ADR 0011 amendment), the incident is a fixture
18
+ (#37), and a hybrid ask decomposes on two axes (#32, ADR 0033).
19
+
20
+ ### Added
21
+
22
+ - **A pod has one admission owner, decided at spawn and recorded** (issue #33,
23
+ ADR 0030). `admission: intake | external | self` — `intake` is the batch a
24
+ Listener owes it, `external` is an outside clock or a human calling
25
+ `agents-pod wake`, `self` is the tick, legal only for a goal that reaches
26
+ `done`. `spawn --admission <owner>` states it and `--external` is the
27
+ shorthand; the default is `self`, which is what the tick used to assume for
28
+ every pod. A Listener binding *is* an intake declaration, so a bound pod reads
29
+ as `intake` without a second flag, and a pod recorded `external` refuses to be
30
+ bound — two owners is zero owners when both fire. `status` prints the owner;
31
+ `doctor` names the two shapes that cost money (a tick-owned pod whose goal
32
+ names a clock, a tick-owned pod whose goal is open-ended) and their mirror (an
33
+ `intake` pod no Listener is bound to, which can never be given work). Records
34
+ written before this release keep their old behaviour and are named, never
35
+ rewritten.
36
+ - **A goal that names a clock is refused at the surface** (issue #33, ADR 0030).
37
+ `spawn` — and so `spawn_pod` — exits 2 when the goal carries a time rule
38
+ (`09:00`, `每天`, `每 30 分钟`, `every 30 minutes`, `daily`) and no outside
39
+ owner is declared; the refusal names `schedule_task` and the three owners. With
40
+ `--admission external` or `intake` the clock is context and the goal is
41
+ accepted; with `self` it is refused, because that is precisely the incident.
42
+ - **Every wake-up says when, and who admitted it** (issue #35, ADR 0032).
43
+ `WakeUpEntry` gains `at` and `trigger: "spawn" | "tick" | "wake"`, and the
44
+ launcher writes `admitted` and `active` in one registry-locked write *before*
45
+ it launches, so a crash leaves an adjudicable `lost` instead of an invisible
46
+ launch. `status` prints the rhythm — `cadence: 3 in 24h, median interval 2m,
47
+ last trigger tick` — and a record written before provenance says `not
48
+ recorded` instead of inventing a timestamp. `doctor` prints a rate line for a
49
+ pod above 6 wake-ups an hour and names the shape it found; `cron.log` records
50
+ each launch as `wake <id> tick (admission self)`. This is the sentence the
51
+ incident needed in round 1 and reconstructed from 41 rounds of its own logs.
52
+ - **A daily ceiling on wake-ups, with a stop that a launcher cannot re-open**
53
+ (issue #35, ADR 0032). `policy.wakeBudgetPerDay` (default 24, `spawn
54
+ --wake-budget <n>`) counts the stamped wake-ups in the last 24 h; above it,
55
+ reconcile parks the pod with `parkedBy: "budget"` and — same park, one
56
+ notification (ADR 0021) — `status` says *over the daily budget* rather than
57
+ *consecutive failures: 0*. An ordinary `wake` refuses (exit 2, naming the
58
+ count, the budget and both ways forward); one more window costs a deliberate
59
+ `wake --over-budget`, because a ceiling any launcher can re-open is not a
60
+ ceiling.
61
+ - **A satisfied window has a word: `quiet`** (issue #36, ADR 0009 and ADR 0011
62
+ amendments). The handoff's first line takes a third declaration — `status:
63
+ quiet` → outcome `quiet` — for a standing goal whose goal forbids `done`: this
64
+ window is satisfied and nothing is scheduled. It is a clean exit (the streak
65
+ resets, the batch it was admitted for is paid) that sets
66
+ `policy.awaiting = true` and writes no clock; the wake phase skips an awaiting
67
+ pod before it even asks who owns it, and `status` prints `state: idle
68
+ (awaiting)`. A new intake batch, a human `wake`, or the removal of the last
69
+ Listener lifts it — a satisfied pod nobody can feed is reported by `doctor`,
70
+ never fixed.
71
+ - **A hybrid ask decomposes on two axes, and the clock's unit hands over**
72
+ (issue #32, ADR 0033). 「每天早上 9:00 汇总 CI,直到我说停」 carries both signals;
73
+ a tie-break applied to a two-signal sentence is a coin flip, and this one
74
+ landed on 41 wake-ups. The prompt side now reads `trigger` (`time` ·
75
+ `message` · `role alive`) and `work` (`stateless per firing` · `needs memory
76
+ across firings`) and produces exactly three shapes — `schedule_task` alone, a
77
+ schedule that **hands over** to a pod admitted `intake` or `external`, or a
78
+ `spawn_pod` with no clock anywhere in the goal — stated as one line before the
79
+ first tool call. The handover's durable form is the Inbox Item and its minimal
80
+ form is `agents-pod wake`; neither surface may reimplement the other, so
81
+ `spawn_pod`'s guidelines and the scheduler skill each name the other. The
82
+ incident's own ask is now a fixture: a `hybrid` class in
83
+ `scripts/first-turn-check.sh` (with `--replay`, so the judgement is
84
+ model-independent) fails a clock inside a `spawn_pod` goal, a missing
85
+ `schedule_task`, and a schedule with no handover named
86
+ (`tests/fixtures/first-turn/`, six cases, `tests/first-turn-check.test.ts`).
87
+
88
+ ### Changed
89
+
90
+ - **An outcome never schedules a wake-up its owner would not have made** (issue
91
+ #34, ADR 0031). One function, `applyOutcome(policy, owner, wake, now)`, is now
92
+ the only writer of `nextWakeAt`, and it is told the owner resolved once per pod
93
+ per pass. `more` re-arms a `self` pod on the next pass, as before; for an
94
+ `intake` or `external` pod it writes nothing, because the next wake-up is its
95
+ owner's decision and a `nextWakeAt` there was a second, invisible owner. The
96
+ backoff follows the same rule (three failures still park any owner — a stop is
97
+ about money, not scheduling); `cancelled`, `quiet` and any outcome a later
98
+ version adds are recorded and ignored rather than counting as failures. The
99
+ two phases still read the Listener bindings at their own moment: sharing one
100
+ read let a `listener add` that landed mid-pass wake the just-bound pod at the
101
+ end of that same pass.
102
+ - **`spawn_pod` takes the owner as a parameter** (ADR 0030, ADR 0033), so a
103
+ model decomposing a standing ask names who wakes the pod while it spawns,
104
+ instead of leaving the tick to guess.
105
+
106
+ ### Fixed
107
+
108
+ - **A flag that arrives with no value is a usage error, not a word in the goal**
109
+ (issue #35). `spawn fix it --wake-budget` used to compose a goal containing the
110
+ literal flag and run on the default budget — a guardrail whose typo silently
111
+ disables it is not a guardrail. `parseFlags` now reports every value-taking
112
+ flag that arrived dangling.
113
+ - **The package no longer ships compiled Python.** `examples/feishu/__pycache__/*.pyc`
114
+ was in the published tarball and is now ignored.
115
+
116
+ Known gap, owned by #43: `spawn` still launches one wake-up immediately, so a
117
+ hybrid pod spawned at 15:00 does its first round at 15:00 and its next at 09:00.
118
+ One run is not a storm and the ceiling bounds what follows, so the guideline says
119
+ it out loud rather than claiming the clock owned the first one.
120
+
121
+ ## [0.3.5] — 2026-11-04
122
+
123
+ ### Fixed
124
+
125
+ - **A reload no longer leaves the extension inert** (issue #28). `/reload`,
126
+ `/new`, `/resume` and `/fork` replace the whole extension runtime: Pi
127
+ re-imports the module in the same process and rebuilds every command and tool
128
+ registry from scratch, while `globalThis` — and with it the `Symbol.for` guard
129
+ that keeps a second installed copy inert — survives. The reloaded copy saw a
130
+ claimed process, returned early, and `/pod`, `spawn_pod` and `pod_handoff`
131
+ vanished until Pi restarted. The guard is now released on `session_shutdown`,
132
+ so it claims a runtime instead of a process; a second copy in one runtime is
133
+ still kept out (ADR 0010).
8
134
 
9
135
  ## [0.3.4] — 2026-11-04
10
136
 
package/README.md CHANGED
@@ -37,7 +37,11 @@ Sibling of [agents-cron](https://pi.dev/packages/agents-cron): **cron owns when
37
37
  work starts; pod owns keeping work alive.** Use agents-cron for recurring short
38
38
  tasks ("check CI every 30 minutes"). Use agents-pod for standing work
39
39
  ("refactor the auth layer — this may take days", "keep watching this incident",
40
- "serve this inbox — wake an agent per dropped task").
40
+ "serve this inbox — wake an agent per dropped task"). An ask that carries both
41
+ (「每天早上 9:00 汇总 CI,直到我说停」) is neither alone: cron owns the clock and
42
+ **hands the work over** — one Inbox Item when a per-firing input has to be
43
+ queued, or `agents-pod wake` when nothing does — and the pod's goal carries no
44
+ time rule ([ADR 0033](docs/adr/0033-a-hybrid-ask-decomposes-on-two-axes.md)).
41
45
 
42
46
  ## Why a separate plugin
43
47
 
@@ -140,12 +144,15 @@ package until both plugins demonstrably need one.
140
144
  agents-pod doctor [--probe] [--json] # what is missing, and the command that fixes it
141
145
  agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
142
146
  [--after <podId>] # wait for the upstream pod to reach done
147
+ [--admission intake|external|self | --external] # who may wake it (ADR 0030)
148
+ [--wake-budget <n>] # daily ceiling, default 24 (ADR 0032)
143
149
  agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
144
150
  agents-pod project move <old> <new> [--apply] [--move-artifacts] # the tree was renamed: relocate records (ADR 0028)
145
151
  agents-pod list [--json]
146
152
  agents-pod status <podId>
147
153
  agents-pod logs <podId> [--follow]
148
- agents-pod wake <podId> [--agent <id>] [--warm] # fresh by default; --warm resumes a lost wake-up's session
154
+ agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]
155
+ # fresh by default; --warm resumes a lost wake-up's session
149
156
  agents-pod cancel <podId>
150
157
  agents-pod reconcile # the cron entry point; idempotent
151
158
  agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
@@ -165,8 +172,12 @@ agents-pod deliver rm <name>
165
172
  agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force] # copy the package's adapter, record its provenance (ADR 0029)
166
173
  ```
167
174
 
168
- `spawn` claims a pod id, writes the pod record, wakes the first agent, and
169
- returns immediately with the id. With `--after`, the first wake waits for the
175
+ `spawn` claims a pod id, writes the pod record (including who admits its
176
+ wake-ups — `--admission`, ADR 0030), wakes the first agent, and returns
177
+ immediately with the id. A goal that names a clock (`09:00`, `每天`, `每 30
178
+ 分钟`) is refused with exit 2 unless an outside owner is declared: starting work
179
+ at a time is a schedule's job, and a pod carrying a clock is the incident
180
+ ([`tests/reconcile.test.ts`](tests/reconcile.test.ts) replays it). With `--after`, the first wake waits for the
170
181
  upstream pod's `done`; a cancelled upstream never releases it. When the
171
182
  project's cap is full, the new pod likewise waits idle with its document store
172
183
  ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
@@ -198,22 +209,34 @@ tool is a guarantee on top of that contract, never a replacement for it.
198
209
  | --- | --- | --- |
199
210
  | `spawning` | record created, first wake-up not yet confirmed | `spawn` |
200
211
  | `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
201
- | `idle` | goal open, nothing live; documents current | wrapper / reconcile |
212
+ | `idle` | goal open, nothing live; documents current — `idle (awaiting)` when the last window declared itself satisfied | wrapper / reconcile |
202
213
  | `done` | goal reached; terminal | wrapper |
203
214
  | `cancelled` | user cancel completed; terminal | wrapper |
204
215
 
205
- A pod never exits — its wake-ups do. Wake-up outcomes: `more` (the goal
206
- needs another wake-up), `done` (goal reached), `failed`, `lost` (crash,
207
- reboot, orphan), `cancelled`. Outcomes are adjudicated in three layers —
216
+ A pod never exits — its wake-ups do. Wake-up outcomes — declared in the
217
+ handoff's first line, in this order of preference: `more` (`status: rotating`,
218
+ the goal needs another wake-up now), `quiet` (`status: quiet`, this window is
219
+ satisfied and nothing is scheduled: wake me when work arrives), `done`
220
+ (`status: done`, goal reached) — plus `failed`, `lost` (crash, reboot, orphan)
221
+ and `cancelled`. `quiet` is what a standing goal says instead of lying `more` to
222
+ stay alive (ADR 0011 amendment, #36). Outcomes are adjudicated in three layers —
208
223
  the agent's HANDOFF.md declaration over the wrapper's event observation
209
224
  over OS exit facts — and a clean exit with no fresh handoff is `failed`
210
225
  ([ADR 0009](docs/adr/0009-wake-up-outcome-classification.md)). The wake
211
226
  policy turns outcomes into next actions
212
- ([ADR 0011](docs/adr/0011-minimal-wake-policy.md)): `more` re-wakes
213
- immediately; `failed` and `lost` back off exponentially (1m ×2, cap 15m,
214
- one streak, reset on `more`); three consecutive failures park the pod —
215
- still `idle`, auto-wake suspended — until a human wakes it. Reconcile
216
- policy, per record:
227
+ ([ADR 0011](docs/adr/0011-minimal-wake-policy.md), and [ADR 0031](docs/adr/0031-outcomes-do-not-schedule.md)
228
+ for the part that depends on the owner): a clean `more` re-wakes a pod **the
229
+ tick owns** immediately and schedules nothing for a pod somebody else wakes;
230
+ `failed` and `lost` back off exponentially (1m ×2, cap 15m, one streak, reset on
231
+ `more`) — again only for the tick's own pod, since a backoff would admit a pod
232
+ without its owner; three consecutive failures park the pod — still `idle`,
233
+ auto-wake suspended — until a human wakes it, whatever its owner. A **daily
234
+ ceiling** (`policy.wakeBudgetPerDay`, default 24, `spawn --wake-budget`) parks it
235
+ the same way — the same single notification — when the last 24 h hold more
236
+ wake-ups than that, and that stop is not lifted by an ordinary `wake` (only by a
237
+ deliberate `wake --over-budget`), because a ceiling any launcher can re-open is
238
+ not a ceiling ([ADR 0032](docs/adr/0032-provenance-and-a-daily-ceiling.md)).
239
+ Reconcile policy, per record:
217
240
 
218
241
  - active + wrapper alive + heartbeat fresh → no-op;
219
242
  - active + nothing alive (crash, reboot — boot-time mismatch means lost,
@@ -221,13 +244,20 @@ policy, per record:
221
244
  (death notifications in P1); the wake policy's backoff applies;
222
245
  - agent alive but wrapper dead (orphan) → kill the recorded process group,
223
246
  wake-up `lost`, pod → idle;
224
- - idle + goal open + not parked + wake eligible (past backoff) → wake the
225
- pod's default agent — which agent, which model, after how much backoff,
226
- is pod-record policy, written by reconcile from outcomes
247
+ - `admission: self` + idle + goal open + not parked + wake eligible (past
248
+ backoff — the only owner for which an outcome writes one) → wake the pod's default agent — which agent, which model, after how
249
+ much backoff, is pod-record policy, written by reconcile from outcomes
227
250
  ([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
228
- - idle + **bound by a listener** + nothing owed → no-op. An intake-driven pod
229
- is woken on the batch it owes (`policy.intakePending`), never on the tick, so
230
- a standing router with an empty inbox costs nothing
251
+ - **awaiting** (the last word was `quiet`) → no-op until work arrives: a new
252
+ intake batch, a human `wake`, or the removal of the Listener the claim was made
253
+ to ([ADR 0011 amendment](docs/adr/0011-minimal-wake-policy.md)). `status`
254
+ prints `idle (awaiting)`, and `doctor` notes an awaiting pod with no Listener,
255
+ because nothing can ever deliver work to it;
256
+ - `admission: external` → no-op, always: an outside clock or a human calls
257
+ `wake`, and the tick never launches it ([ADR 0030](docs/adr/0030-wake-admission-owners.md));
258
+ - `admission: intake` + nothing owed → no-op. An intake-driven pod is woken on
259
+ the batch it owes (`policy.intakePending`), never on the tick, so a standing
260
+ router with an empty inbox costs nothing
231
261
  ([ADR 0026 amendment](docs/adr/0026-affinity-root-message-and-delivery-address.md));
232
262
  - idle + parked (consecutive-failure stop) → no-op until a human `wake`
233
263
  clears it;
@@ -262,6 +292,42 @@ the copied body (ADRs 0014, 0026). Between batches the pod is not merely idle
262
292
  but **asleep**: the tick alone does not wake a bound pod, which is what makes
263
293
  "serve this inbox" a shape that can stand for days (ADR 0026 amendment).
264
294
 
295
+ ### Who admits a wake-up
296
+
297
+ A pod record carries one owner ([ADR 0030](docs/adr/0030-wake-admission-owners.md)),
298
+ decided at spawn, printed by `status`, and read by the wake phase before it
299
+ launches anything. There are three shapes, and picking one is the operator's
300
+ decision — not a policy the tick guesses:
301
+
302
+ | `admission` | who wakes it | the shape |
303
+ | --- | --- | --- |
304
+ | `self` | the tick, after every `more` | a goal that reaches `done`: a refactor, an audit, a migration. Default when nobody says. |
305
+ | `intake` | the batch it owes — `listener add --pod` | a standing inbox server or router: idle between batches, and an empty inbox costs nothing |
306
+ | `external` | `agents-pod wake` — an outside clock or a human | a `crontab` line, a webhook, a CI trigger: the clock lives outside core |
307
+
308
+ `--external` is shorthand for `--admission external`. A Listener binding is
309
+ itself an intake declaration, so `spawn` → `listener add --inbox <dir> --pod
310
+ <id>` needs no flag; a pod recorded `external` refuses to be bound, because two
311
+ owners is zero owners when both fire. `doctor` names the two shapes that cost
312
+ money: a pod the tick owns whose goal names a clock, and a pod the tick owns
313
+ whose goal is open-ended (`常驻`, `监听`, `我说停才停`) — the record is legal,
314
+ the intent is not, and only the operator knows which owner it should have had.
315
+
316
+ A goal that names a clock is a misroute, not a pod: the schedule owns the time
317
+ and hands the work over. The canonical example is replayed in
318
+ [`tests/reconcile.test.ts`](tests/reconcile.test.ts) and
319
+ [`tests/fixtures/first-turn/`](tests/fixtures/first-turn/), where the incident's
320
+ own ask is the fixture.
321
+
322
+ ### Reading a rate
323
+
324
+ Every wake-up records `at` and `trigger` (`spawn` / `tick` / `wake`, ADR 0032), so
325
+ the question the incident could not answer — *why did this pod wake 41 times?* —
326
+ is one command per layer: `status <id>` prints `cadence: 41 in 24h, median
327
+ interval 2m, last trigger tick`; `doctor` prints a rate line for any pod above
328
+ 6 wake-ups an hour; `cron.log` records each launch as `wake <id> tick (admission
329
+ self)`. The daily ceiling parks the pod before a rhythm becomes a storm.
330
+
265
331
  Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
266
332
  after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
267
333
  with exit 0 and steals stale ones with an audit line; correctness lives in
@@ -287,6 +353,8 @@ a Source command, `source add`, `listener add`, `deliver add`, the cron line,
287
353
  ```text
288
354
  store ✓ 1 pods, 0 active
289
355
  pass ✓ last pass 41s ago
356
+ wake ? pod-c1329c: 41 wake-ups in the last hour, median interval 2m — above the 6/h ceiling
357
+ its goal names a clock ("9:00") and the tick admits it — that is the #31 shape
290
358
  intake ✗ source feishu: the last drain (2s ago) failed — exit 1
291
359
  run: agents-pod doctor --probe
292
360
  listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,102 @@
1
+ import type { Admission, PodRecord } from "./registry.ts";
2
+
3
+ /**
4
+ * Who admits a Pod's wake-up (ADR 0030). ADR 0011's single wake rule —
5
+ * idle + goal open + not parked + due — is right for a bounded goal and an
6
+ * unbounded money pit for a standing one: the tick cannot tell "nobody feeds me"
7
+ * from "a crontab line feeds me at 09:00", so it assumed the second and woke a
8
+ * router 41 times in five hours. The missing fact was ownership, not a policy
9
+ * knob, so it is recorded as a fact on the Pod Record and read from there.
10
+ *
11
+ * Three owners, exactly one per Pod:
12
+ * intake — a Listener-bound Pod; the batch it owes (`policy.intakePending`)
13
+ * is its only admission. An empty inbox costs nothing.
14
+ * external — an outside clock or a human calls `agents-pod wake`; the tick
15
+ * never launches it.
16
+ * self — the tick admits it. Legal for a goal that reaches `done`, never
17
+ * for a standing one (`more` re-arms it on every pass).
18
+ */
19
+ export const ADMISSIONS = ["intake", "external", "self"] as const;
20
+
21
+ export function isAdmission(value: string): value is Admission {
22
+ return (ADMISSIONS as readonly string[]).includes(value);
23
+ }
24
+
25
+ /**
26
+ * Who admits this record. A Listener binding is itself an intake declaration —
27
+ * binding a Pod says "this is fed from an inbox", and it is the act the operator
28
+ * performs, not a state we ask them to restate. An unbound Pod is owned by the
29
+ * owner recorded at spawn, or by the tick when nobody recorded one (a record
30
+ * written before ADR 0030). The binding therefore never rewrites a record: it
31
+ * outranks it, and `listener add --pod` refuses a Pod recorded as `external`
32
+ * rather than quietly contradicting it.
33
+ */
34
+ export function admissionOf(
35
+ pod: Pick<PodRecord, "id" | "admission">,
36
+ intakeBound: ReadonlySet<string>,
37
+ ): Admission {
38
+ if (intakeBound.has(pod.id)) return "intake";
39
+ return pod.admission ?? "self";
40
+ }
41
+
42
+ /**
43
+ * The phrase that makes a goal a time rule, or `undefined` (issue #37: "a goal
44
+ * that names a clock is a misroute, not a pod"). Deliberately narrow — every
45
+ * match refuses a spawn on the creation side, so a false positive costs an
46
+ * operator a rejected command. `每天` / `每小时` / `每 N 分钟` / `HH:MM` are the
47
+ * shapes the incident was written in; `crontab` is NOT here, because "fix the
48
+ * crontab line" is a legitimate goal about a clock, not a goal with one.
49
+ */
50
+ const CLOCKS: ReadonlyArray<RegExp> = [
51
+ /\d{1,2}[::]\d{2}/, // 09:00, 9:30
52
+ /(?:每天|每日|每晚|每早|每周|每月|每年|每小时)/,
53
+ /每\s*\d+\s*(?:分钟|分|小时|钟头|天|日|周|星期|月|秒)/, // 每 30 分钟
54
+ /(?:早上|上午|中午|下午|晚上|凌晨|深夜)\s*\d{1,2}\s*点/,
55
+ /\d{1,2}\s*点(?:半|整|钟|\d{2})?/, // 9点, 9点30
56
+ /到点/,
57
+ /\bevery\s+(?:\d+\s*)?(?:minute|min|hour|hr|day|week|month|morning|night)/i,
58
+ /\b(?:daily|hourly|weekly|monthly|nightly)\b/i,
59
+ /\bat\s+\d{1,2}:\d{2}\b/i, // at 9:30 — a bare "at 3" is not a time ("look at 3 tests")
60
+ /\d{1,2}\s*(?:am|pm)\b/i,
61
+ ];
62
+
63
+ export function clockPhrase(goal: string): string | undefined {
64
+ for (const pattern of CLOCKS) {
65
+ const found = pattern.exec(goal);
66
+ if (found !== null) return found[0].trim();
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * Standing language: a goal that describes a role rather than a finish line
73
+ * (`常驻`, `监听`, `我说停才停`). Only `doctor` and the spawn warning read it, and
74
+ * only to name the shape — a Pod on `self` with an open-ended goal is woken
75
+ * after every `more`, with nothing new to work on.
76
+ */
77
+ const OPEN_ENDED: ReadonlyArray<RegExp> = [
78
+ /(?:常驻|长期|永久|无限期|永远|一直|监听|盯着|盯住|轮询|说停|直到.{0,6}停)/,
79
+ /\b(?:forever|indefinitely|standing)\b/i,
80
+ /\buntil\s+\w+\s+(?:says?|asks?)\s+(?:stop|enough)/i,
81
+ /\bkeep\s+(?:it\s+)?(?:running|watching|going)\b/i,
82
+ ];
83
+
84
+ export function openEnded(goal: string): boolean {
85
+ return OPEN_ENDED.some((pattern) => pattern.test(goal));
86
+ }
87
+
88
+ /**
89
+ * The refusal `spawn` prints when a goal carries a clock and nobody has said who
90
+ * admits the wake-ups: the schedule already owns the time, and the incident's
91
+ * pod was born exactly here (ADR 0030, issue #37).
92
+ */
93
+ export function clockRefusal(phrase: string): string[] {
94
+ return [
95
+ `a goal that names a clock is a misroute, not a pod: "${phrase}"`,
96
+ "starting work at a time is a schedule's job: call schedule_task (agents-cron) for the clock, and let the schedule hand the work to a pod",
97
+ "if this pod really is admitted from elsewhere, say so (exactly one owner, ADR 0030):",
98
+ " --admission external an outside clock or a human calls `agents-pod wake`",
99
+ " --admission intake a listener feeds it: listener add --inbox <dir> --pod <id>",
100
+ " --admission self the tick admits it — for a bounded goal that reaches done, never for a standing one",
101
+ ];
102
+ }
package/src/cadence.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { WakeUpEntry } from "./registry.ts";
2
+
3
+ /**
4
+ * Cadence: what the wake-up record says about *when* a Pod woke, read the same
5
+ * way by `status` (one Pod), `doctor` (the fleet's rate) and the daily ceiling
6
+ * (ADR 0032). One module because the three must agree on what counts, and
7
+ * because "41 wake-ups in 5 h" was the whole incident and nothing in the store
8
+ * could say it in one command (issue #35).
9
+ *
10
+ * A record written before ADR 0032 carries no `at`: those wake-ups are counted
11
+ * (they are history) but never timed, and every reader says so rather than
12
+ * inventing a timestamp.
13
+ */
14
+
15
+ export const HOUR_MS = 3_600_000;
16
+ export const DAY_MS = 24 * HOUR_MS;
17
+
18
+ /** A wake-up entry that knows when it happened — the only kind a rate counts. */
19
+ export interface StampedWakeUp extends WakeUpEntry {
20
+ at: number;
21
+ }
22
+
23
+ export function stamped(wakeUps: readonly WakeUpEntry[]): StampedWakeUp[] {
24
+ return wakeUps.filter(
25
+ (wake): wake is StampedWakeUp => typeof wake.at === "number" && Number.isFinite(wake.at),
26
+ );
27
+ }
28
+
29
+ /**
30
+ * The stamped wake-ups inside a window ending now (half-open: `(now - ms, now]`).
31
+ * Every outcome counts, `lost` included: a wake-up whose wrapper died still
32
+ * spent an attempt, and a flapping machine that is called busy is exactly the
33
+ * report this is meant to prevent.
34
+ */
35
+ export function inWindow(
36
+ wakeUps: readonly WakeUpEntry[],
37
+ now: number,
38
+ windowMs: number,
39
+ ): StampedWakeUp[] {
40
+ const from = now - windowMs;
41
+ return stamped(wakeUps).filter((wake) => wake.at > from && wake.at <= now);
42
+ }
43
+
44
+ /**
45
+ * The median interval between consecutive stamped wake-ups, in ms — the one
46
+ * number that separates "it works when asked" from "it is re-arming itself".
47
+ * `undefined` when fewer than two wake-ups are timed.
48
+ */
49
+ export function medianIntervalMs(wakeUps: readonly WakeUpEntry[]): number | undefined {
50
+ const times = stamped(wakeUps)
51
+ .map((wake) => wake.at)
52
+ .sort((left, right) => left - right);
53
+ if (times.length < 2) return undefined;
54
+ const gaps: number[] = [];
55
+ for (let index = 1; index < times.length; index += 1) gaps.push(times[index]! - times[index - 1]!);
56
+ gaps.sort((left, right) => left - right);
57
+ const middle = Math.floor(gaps.length / 2);
58
+ return gaps.length % 2 === 1 ? gaps[middle]! : Math.round((gaps[middle - 1]! + gaps[middle]!) / 2);
59
+ }
60
+
61
+ /** Ages and intervals are read at a glance, never as an epoch (ADR 0027's habit). */
62
+ export function duration(ms: number): string {
63
+ if (ms < 1_000) return `${Math.max(0, Math.round(ms))}ms`;
64
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
65
+ if (ms < HOUR_MS) return `${Math.round(ms / 60_000)}m`;
66
+ return `${Math.round((ms / HOUR_MS) * 10) / 10}h`;
67
+ }