pi-claude-supervisor 0.9.0 → 0.9.2

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
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.9.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.1...v0.9.2) (2026-09-24)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **supervisor:** finish stop and startup races; add gated live Decision spike ([#67](https://github.com/btnalit/pi-claude-supervisor/issues/67)) ([648b269](https://github.com/btnalit/pi-claude-supervisor/commit/648b269f185fbeb5ab39fe22ce855cc8d4342747))
11
+ * **supervisor:** stability fixes found by end-to-end runs with a real Decision Worker ([#65](https://github.com/btnalit/pi-claude-supervisor/issues/65)) ([cd0b8ef](https://github.com/btnalit/pi-claude-supervisor/commit/cd0b8efdd0898833036b14acab5ca7b29ca70d04))
12
+
13
+ ## [0.9.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.0...v0.9.1) (2026-09-24)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * long-task stability findings from an independent 0.9.0 review ([#62](https://github.com/btnalit/pi-claude-supervisor/issues/62)) ([35c6789](https://github.com/btnalit/pi-claude-supervisor/commit/35c678962c357bc188eb2ed82141e4046ae793aa))
19
+ * **review:** pin Reviewer finding key order so quoted text cannot swap a P0's description ([#64](https://github.com/btnalit/pi-claude-supervisor/issues/64)) ([6df3fd2](https://github.com/btnalit/pi-claude-supervisor/commit/6df3fd2b75a9555eb6dc9112b1c19f5e35bfa3cd))
20
+
5
21
  ## [0.9.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.1...v0.9.0) (2026-09-20)
6
22
 
7
23
 
package/README.cn.md CHANGED
@@ -202,8 +202,11 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
202
202
  时间。只有收尾窗口也耗尽,Worker 才会被硬停(`worker_watchdog_timeout`);对
203
203
  接管的交互式会话来说这个硬停只是 release:Claude 继续运行,但不再受监督。
204
204
  收尾窗口只属于自动模式任务;手动任务仍在到期时停止,`DEADLINE_GRACE_MS=0`
205
- 让自动任务也恢复这一行为。20 分钟无输出 watchdog(`NO_OUTPUT_TIMEOUT_MS`)
206
- 随时会停止沉默的 Worker。
205
+ 让自动任务也恢复这一行为。20 分钟无输出 watchdog(`NO_OUTPUT_TIMEOUT_MS`,
206
+ 从 Worker 最后一次输出或 Supervisor 最后一次发给它的消息起算)会停止在一轮
207
+ 中途沉默的 Worker;自动模式下只是空闲了这么久的 Worker(在等永远没回来的
208
+ 后台工作)则改为直接验收(`worker_idle_timeout`),人工接管中的 Worker 不会
209
+ 因沉默超时。
207
210
  - 验收命令、证据收集和 Reviewer 共用一个 abort signal,因此 stop 或 shutdown
208
211
  不必等待完整的命令或模型超时。
209
212
  - 每个任务只持有一个 cwd 租约;并发任务需要各自独立的 worktree。
@@ -285,7 +288,7 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
285
288
  "autonomy": {
286
289
  "unattended": true,
287
290
  "requireLocalCommit": true,
288
- "maxDecisionRetries": 2,
291
+ "maxDecisionRetries": 4,
289
292
  "permissionAuthority": "hybrid",
290
293
  "maxWorkerCostUsd": 20
291
294
  }
@@ -298,7 +301,9 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
298
301
  ## 配置参考
299
302
 
300
303
  环境变量(或 `~/.config/pi-claude-supervisor/env`),均以 `PI_CLAUDE_SUPERVISOR_`
301
- 为前缀;完整模板见 `.env.example`。
304
+ 为前缀;完整模板见 `.env.example`。env 文件每行是 `KEY=value`,可以带 `export `
305
+ 前缀和行尾 ` # 注释`。数值、时长或布尔类配置超出范围或无法解析时会沿用默认值,
306
+ 并以 `pi-claude-supervisor: ignoring …` 警告报告一次。
302
307
 
303
308
  | 变量 | 默认值 | 含义 |
304
309
  | --- | --- | --- |
@@ -321,7 +326,7 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
321
326
  | `HUMAN_WEBHOOK_SECRET` | 未设置 | HMAC 签名密钥;以 `x-pi-supervisor-signature` header 发送 |
322
327
  | `UNATTENDED` | `true` | 任务无需同步人工回调即可运行 |
323
328
  | `REQUIRE_LOCAL_COMMIT` | `true` | 完成前要求在候选所在分支上有本地 commit |
324
- | `MAX_DECISION_RETRIES` | `2`(0–10) | Decision Worker 调用超时或失败(429/529、网络、鉴权)时的重试次数 |
329
+ | `MAX_DECISION_RETRIES` | `4`(0–10) | Decision Worker 调用超时或失败(429/529、网络、鉴权)时的重试次数;两次尝试之间依次等待 15s、45s、60s |
325
330
  | `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
326
331
  | `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`;验收通过后开启发布阶段。`--remote` 可按任务覆盖 |
327
332
  | `REMOTE_NAME` | `origin` | 发布授权唯一允许的 remote 名 |
@@ -336,8 +341,8 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
336
341
  | `DECISION_SESSION_RETENTION_DAYS` | `30` | 启动时清理早于此天数的已关闭 Decision Worker session 记录;`0` 表示永久保留 |
337
342
  | `EVIDENCE_MAX_BYTES` | `1048576`(1 MiB) | 每个任务收集的最大仓库证据字节数 |
338
343
  | `EVIDENCE_MAX_UNTRACKED_FILES` | `512` | 每个任务作为证据收集的最大未跟踪文件数 |
339
- | `REVIEW_TIMEOUT_MS` | `600000`(10 分钟) | 每轮独立 Reviewer 的总预算,含一次针对 provider 错误的重试 |
340
- | `DEADLINE_MS` | `4h` | 每个任务的累计总时限(`8h`、`90m`、`2h30m` 或毫秒;5 分钟到 7 天);`0`(或 `0m`)关闭;`--deadline` 可按任务覆盖 |
344
+ | `REVIEW_TIMEOUT_MS` | `10m`(30s–1h) | 每轮独立 Reviewer 的总预算;预算未用完时,provider 错误会用新会话重试 |
345
+ | `DEADLINE_MS` | `4h` | 每个任务从启动起算的墙钟总时限,Pi 停机期间也计入,因此恢复时用 `recover --extend` 重新给预算(`8h`、`90m`、`2h30m` 或毫秒;5 分钟到 7 天);`0`(或 `0m`)关闭;`--deadline` 可按任务覆盖 |
341
346
  | `DEADLINE_GRACE_MS` | `30m` | 自动任务到期后的收尾窗口:空闲的 Worker 会被验收而不是停止;`0` 恢复到期立即停止 |
342
347
  | `DEADLINE_WARNING_MS` | `15m` | 到期前多久提醒并重新询问 Decision Worker;`0` 关闭提醒 |
343
348
  | `NO_OUTPUT_TIMEOUT_MS` | `20m` | Worker 多久没有输出就停止;`0` 关闭该检查 |
@@ -358,7 +363,10 @@ Worker,它不会静默恢复或重复执行任务。只有在租约证明旧 Wor
358
363
  会拒绝它;`recover --takeover --extend <duration> <task-id>` 从现在起再给这么
359
364
  多预算(恢复后的 Supervisor 会把新时限持久化),`--extend 0` 则立即进入收尾:
360
365
  新 Worker 的第一个 watchdog tick 就会对仓库现状做验收和 review,修复轮会告诉
361
- 它还剩多少时间。确定不再恢复的记录用 `/supervise discard <task-id>` 丢弃
366
+ 它还剩多少时间。带 `--extend` 时,恢复的任务会直接交回自动化:非零的延长会
367
+ 给新 Worker 发送原任务的续做指令(先让它查看已有的工作),`--extend 0` 则无需
368
+ 指令。不带 `--extend` 的普通 `recover` 仍让 Worker 在人工接管下空闲——先发送
369
+ 续做指令,再执行 `resume-auto`。确定不再恢复的记录用 `/supervise discard <task-id>` 丢弃
362
370
  (会话文件保留到保留期清理为止)。
363
371
 
364
372
  每个任务在 `CWD_LEASE_DIR` 下持有一个 cwd 租约;并发任务需要各自独立的
package/README.md CHANGED
@@ -247,8 +247,11 @@ Supervisor being able to see it, or when you don't need to attach.
247
247
  is a release: Claude keeps running, unsupervised. The close-out belongs to
248
248
  automatic tasks; a manual task is stopped at the deadline as before, and
249
249
  `DEADLINE_GRACE_MS=0` restores that for automatic ones too. A 20-minute
250
- no-output watchdog (`NO_OUTPUT_TIMEOUT_MS`) still stops a silent Worker at
251
- any time.
250
+ no-output watchdog (`NO_OUTPUT_TIMEOUT_MS`, counted from the Worker's last
251
+ output or the Supervisor's last message to it) stops a Worker that falls
252
+ silent mid-turn; an automatic Worker that is merely idle that long (waiting on
253
+ background work that never came back) is verified instead
254
+ (`worker_idle_timeout`), and a Worker under human takeover is never timed out.
252
255
  - Acceptance checks, evidence collection, and the Reviewer share an abort
253
256
  signal, so a stop or shutdown does not wait for a full command or model
254
257
  timeout.
@@ -374,7 +377,7 @@ confirmed publish says so in its candidate notice instead of reporting a bare
374
377
  "autonomy": {
375
378
  "unattended": true,
376
379
  "requireLocalCommit": true,
377
- "maxDecisionRetries": 2,
380
+ "maxDecisionRetries": 4,
378
381
  "permissionAuthority": "hybrid",
379
382
  "maxWorkerCostUsd": 20
380
383
  }
@@ -388,7 +391,11 @@ check (120s timeout) and the env autonomy defaults below.
388
391
  ## Configuration reference
389
392
 
390
393
  Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
391
- `PI_CLAUDE_SUPERVISOR_`; see `.env.example` for a template.
394
+ `PI_CLAUDE_SUPERVISOR_`; see `.env.example` for a template. The env file takes
395
+ `KEY=value` lines, optionally prefixed with `export ` and followed by a
396
+ ` # comment`. A numeric, duration or boolean value that is out of range or
397
+ unparsable keeps the default and is reported once as a
398
+ `pi-claude-supervisor: ignoring …` warning.
392
399
 
393
400
  | Variable | Default | Meaning |
394
401
  | --- | --- | --- |
@@ -411,7 +418,7 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
411
418
  | `HUMAN_WEBHOOK_SECRET` | unset | HMAC signing secret; sent as the `x-pi-supervisor-signature` header |
412
419
  | `UNATTENDED` | `true` | Task runs without a synchronous human callback |
413
420
  | `REQUIRE_LOCAL_COMMIT` | `true` | Require a local commit on the candidate's branch before completion |
414
- | `MAX_DECISION_RETRIES` | `2` (0–10) | Retries of a Decision Worker call that times out or fails (429/529, network, auth) |
421
+ | `MAX_DECISION_RETRIES` | `4` (0–10) | Retries of a Decision Worker call that times out or fails (429/529, network, auth); waits 15s, 45s, then 60s between attempts |
415
422
  | `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
416
423
  | `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`; grants the publish phase after verification passes. `--remote` overrides it per task |
417
424
  | `REMOTE_NAME` | `origin` | The single remote a publish grant may name |
@@ -426,8 +433,8 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
426
433
  | `DECISION_SESSION_RETENTION_DAYS` | `30` | Prunes closed Decision Worker session records older than this; `0` keeps forever |
427
434
  | `EVIDENCE_MAX_BYTES` | `1048576` (1 MiB) | Maximum repository evidence bytes collected per task |
428
435
  | `EVIDENCE_MAX_UNTRACKED_FILES` | `512` | Maximum untracked files collected as evidence per task |
429
- | `REVIEW_TIMEOUT_MS` | `600000` (10 min) | Total independent Reviewer budget per round, including one retry on a provider error |
430
- | `DEADLINE_MS` | `4h` | Cumulative wall-clock budget per task (`8h`, `90m`, `2h30m` or ms; 5m–7d); `0` (or `0m`) disables it; `--deadline` overrides it per task |
436
+ | `REVIEW_TIMEOUT_MS` | `10m` (30s–1h) | Total independent Reviewer budget per round; a provider error is retried with a fresh session while budget remains |
437
+ | `DEADLINE_MS` | `4h` | Wall-clock budget per task, measured from its start — time Pi was down counts too, so `recover --extend` grants a fresh budget (`8h`, `90m`, `2h30m` or ms; 5m–7d); `0` (or `0m`) disables it; `--deadline` overrides it per task |
431
438
  | `DEADLINE_GRACE_MS` | `30m` | Close-out window after the deadline for automatic tasks: an idle Worker is verified instead of stopped; `0` restores the immediate stop |
432
439
  | `DEADLINE_WARNING_MS` | `15m` | How long before the deadline the Decision Worker is warned and re-asked; `0` disables the warning |
433
440
  | `NO_OUTPUT_TIMEOUT_MS` | `20m` | Stop a Worker that has produced no output for this long; `0` disables the check |
@@ -452,7 +459,11 @@ A task that stopped at its wall-clock deadline is listed with `deadline=expired
452
459
  <task-id>` grants that much budget from now (the recovered Supervisor persists
453
460
  the new deadline), and `--extend 0` opens the close-out at once, so the fresh
454
461
  Worker's first watchdog tick verifies and reviews the repository as it stands
455
- and any repair round tells it how long it has. A record nobody will recover is
462
+ and any repair round tells it how long it has. With `--extend` the recovered
463
+ task goes straight back to automation: a real extension sends the fresh Worker a
464
+ continuation of the original task (telling it to inspect the earlier work first),
465
+ and `--extend 0` needs none. A plain `recover` still leaves the Worker idle under
466
+ takeover — send it a continuation, then `resume-auto`. A record nobody will recover is
456
467
  dropped with `/supervise discard <task-id>` (its session file is kept until
457
468
  retention pruning).
458
469
 
@@ -143,7 +143,8 @@ start); credentials are not copied into a file, and credential-shaped command
143
143
  arguments are still rejected.
144
144
  `load-buffer`, bracketed `paste-buffer` and `send-keys Enter` provide the input
145
145
  boundary without interpolating a task into a shell command. C0/C1 terminal
146
- control bytes are rejected; CRLF is normalized to a newline. Automatic agents,
146
+ control bytes are neutralized (escape sequences removed, a lone CR becomes a newline,
147
+ other C0/C1 bytes become spaces); CRLF is normalized to a newline. Automatic agents,
147
148
  background tasks, plugins, MCP servers and nested Claude processes stay in the
148
149
  same cgroup and are cleaned with the Worker; they are intentionally not rejected
149
150
  or polled as a nested-process policy failure. The lexical Bash/file-tool policy
@@ -399,8 +400,11 @@ For Claude JSONL, the adapter tracks `activeRequests`, `lastInputAt` and
399
400
  `lastOutputAt`. A `result` record closes an active request; malformed output does
400
401
  not. JSONL sends are rejected while a request is active, and a valid terminal
401
402
  result moves the session to `waiting`; only then may the next turn be sent. A paused
402
- Worker does not consume its no-output budget; resume establishes a fresh no-output
403
- baseline while the cumulative wall-clock deadline remains active.
403
+ Worker does not consume its no-output budget; resume, and every message the
404
+ Supervisor sends, establishes a fresh no-output baseline while the cumulative
405
+ wall-clock deadline remains active. An idle automatic Worker that reaches the
406
+ no-output timeout is verified (`worker_idle_timeout`) rather than stopped, and a
407
+ Worker under human takeover is exempt.
404
408
 
405
409
  The wall-clock deadline is a budget, not a kill switch. The watchdog drives it
406
410
  through three phases, each recorded once per task: `worker_deadline_approaching`
@@ -513,10 +517,17 @@ parked or failed without granting remote/main authority. Model/API failures are
513
517
  detected from the Pi `stopReason` (a provider error resolves the prompt normally
514
518
  rather than throwing); if the Decision Worker
515
519
  API/model call fails, the system records `decision_worker_failed`, applies the
516
- bounded retry/park policy and preserves the candidate evidence. An abort is never
520
+ bounded retry/park policy and preserves the candidate evidence. The startup
521
+ instructions prompt retries provider errors on the same backoff and budget, so a
522
+ provider overload at start does not fail the task before its first turn. An abort is never
517
523
  retried, and a `noop` reply on a completed turn or a permission request parks the
518
524
  candidate rather than being treated as a resolved decision, while a `noop` on a
519
- clean Worker exit proceeds to verification. Optional alert
525
+ clean Worker exit proceeds to verification. A `stop` on a completed turn of an
526
+ unattended task without remote authority stops the Worker (never keeping it open)
527
+ and then verifies its finished work instead of discarding it (`decision_overridden`);
528
+ no repair round may follow, so a failure blocks the candidate. A `stop` on a
529
+ pending permission, on a turn the Worker has already resumed, or on a task with
530
+ remote authority stays a plain stop. Optional alert
520
531
  delivery remains independent from event-log persistence, but notification is not
521
532
  the control boundary.
522
533
 
@@ -587,19 +598,40 @@ When automatic supervision is enabled, a successful check set is passed to a
587
598
  fresh read-only Reviewer session. The Reviewer receives the task specification, repository status/diff evidence,
588
599
  check results and bounded Worker completion evidence, but not the Decision Worker
589
600
  conversation or control channel. It can inspect only `read`, `grep`, `find` and `ls`, and must return
590
- `pass`, `revise` or `human` with bounded structured findings. Invalid Reviewer
601
+ `pass`, `revise` or `human` with bounded structured findings. Its whole reply
602
+ must be that one JSON object (an optional ```json fence aside), carrying a
603
+ random `reviewId` that appears only in its own prompt, with no key repeated and
604
+ nothing beyond the schema (a string `summary`, and `findings` as flat objects of
605
+ the finding fields with scalar values). Each finding opens with `severity`
606
+ then a non-empty `message` (an `id` may lead) and closes with `evidence` if it
607
+ has one — the only field the prompt allows repository quotes in. Repository
608
+ text it quotes or copies therefore cannot stand in for the answer, change its
609
+ verdict, or drop a finding it wrote, and a quote in `evidence` that closes a
610
+ finding early can only add findings after it — never change the severity,
611
+ message, fix or location the Reviewer already wrote. A quote the Reviewer puts
612
+ in any other field against the prompt can still reach the rest of that
613
+ finding. Added findings cannot unblock a candidate (any P0/P1 blocks, even
614
+ under `pass`), and repair instructions list findings most severe first so
615
+ added lesser ones cannot crowd out a blocking one. A reply that breaks any of
616
+ these earns one corrective re-prompt. Invalid Reviewer
591
617
  output, incomplete evidence or a Reviewer API failure must prevent a candidate
592
618
  from crossing the remote/main boundary; the local system may retry, repair or
593
- park it without requiring a human to be online. The Reviewer retries a provider
594
- error once within a total review budget
619
+ park it without requiring a human to be online. The Reviewer retries provider
620
+ errors with a fresh session within a total review budget
595
621
  (`PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS`, default 10 minutes). Truncated
596
622
  (oversize) evidence requests a bounded repair before parking, while incomplete
597
623
  evidence still parks.
598
624
 
599
625
  A `revise` result produces an audited repair round and sends a bounded corrective
600
626
  instruction to a still-live `repairableSession` Worker. Checks and review then run again.
601
- The repair budget defaults to three rounds; repeated findings and P0/P1 findings
602
- stop automation and park a non-publishable candidate. A Worker that has already exited cannot be silently recreated
627
+ The Decision Worker sees the last result tagged with the Worker turn it judged, and chooses
628
+ when to verify again; if it keeps steering instead, the Supervisor verifies on its own once
629
+ the Worker has taken three turns since that failure (`decision_overridden`), so a Decision
630
+ Worker reasoning from the stale failure cannot hold a fixed Worker in a loop until the deadline.
631
+ The repair budget defaults to three rounds. P0/P1 findings block a `pass` but are repair
632
+ inputs like any other concrete finding (a `pass` carrying one is treated as `revise`); a
633
+ `human` verdict, repeated findings or an exhausted budget stop automation and park a
634
+ non-publishable candidate. A Worker that has already exited cannot be silently recreated
603
635
  for repair; it remains failed/recoverable rather than replaying the original task. If a repair
604
636
  or candidate branch cannot continue, a single idempotent terminalizer records
605
637
  `verification_failed`, closes the Decision Worker and reports cleanup evidence; it never performs
@@ -106,7 +106,7 @@ stop or park safely and retain evidence; it must not silently grant remote or ma
106
106
  Automatic mode implements the local loop: policy decisions allow ordinary local development,
107
107
  `AskUserQuestion` is converted to a denied interactive permission, the Decision Worker can
108
108
  continue/redirect/answer/repair, acceptance and independent Review run without a human callback,
109
- and unresolved situations become `blocked` candidates. The default task autonomy is unattended, requires a local commit, and permits two bounded
109
+ and unresolved situations become `blocked` candidates. The default task autonomy is unattended, requires a local commit, and permits four bounded
110
110
  Decision Worker request retries. Automatic startup rejects non-Git/detached/bare/protected
111
111
  repository states, malformed baselines, startup-HEAD races, the unstructured
112
112
  process-pipe transport, Bash-preauthorizing Claude arguments/settings and non-Claude or untrusted
package/docs/testing.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Testing
2
2
 
3
- > Autonomy target: local editing, testing, repair and local commits run without a human being online. Invalid output, unavailable evidence, duplicate findings, P0/P1 findings and exhausted budgets become parked/non-publishable candidates rather than synchronous human gates. Remote push and main/integration merge remain independent-boundary tests. See [autonomy-target.md](autonomy-target.md).
3
+ > Autonomy target: local editing, testing, repair and local commits run without a human being online. Invalid output (after one corrective re-prompt), unavailable evidence, duplicate findings, a `human` Reviewer verdict and exhausted budgets become parked/non-publishable candidates rather than synchronous human gates. Remote push and main/integration merge remain independent-boundary tests. See [autonomy-target.md](autonomy-target.md).
4
4
 
5
5
  ## Local checks
6
6
 
@@ -113,6 +113,43 @@ PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH="$HOME/.local/share/mise/installs/claude/l
113
113
  PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux
114
114
  ```
115
115
 
116
+ The Decision spike runs a real Pi Decision Worker and Reviewer, on any Pi
117
+ model, against a scripted Worker that edits a temporary git repository. It
118
+ needs no Claude Code, cgroup or tmux, so it runs on hosts that cannot run the
119
+ real-Claude spikes. It is gated and excluded from normal CI:
120
+
121
+ ```bash
122
+ # Default model: google/gemini-3.5-flash-lite
123
+ PI_CLAUDE_SUPERVISOR_REAL_DECISION=1 npm run spike:decision
124
+ # Optional: another Pi model, a subset of scenarios, a per-scenario deadline,
125
+ # and keeping the temp repositories
126
+ SPIKE_DECISION_MODEL=google/gemini-3.1-flash-lite \
127
+ SPIKE_DECISION_SCENARIOS=review,stuck SPIKE_TIMEOUT_MS=600000 SPIKE_KEEP=1 \
128
+ PI_CLAUDE_SUPERVISOR_REAL_DECISION=1 npm run spike:decision
129
+ ```
130
+
131
+ Credentials come only from Pi's own sources (for example `GEMINI_API_KEY` in
132
+ the environment, or `~/.pi/agent/auth.json`); the script never reads, prints or
133
+ stores a key, and redacts what it prints. The scenarios cover:
134
+ - `review`: an incomplete first turn is caught and repaired. Two model
135
+ behaviors fail it without being regressions: a Reviewer that passes the
136
+ incomplete turn, and a Decision Worker that answers the "task is complete"
137
+ turn with `stop`, which ends the task blocked with no repair;
138
+ - `question`: a mid-task question is answered from the spec without a human;
139
+ - `stuck`: a Worker that only claims success ends `blocked` within its repair
140
+ budget.
141
+
142
+ The scripted Worker writes the full implementation only after a Supervisor
143
+ message that mentions the RangeError (or min > max). A Decision Worker answer
144
+ that never names it leaves the work undone, and the scenario fails.
145
+
146
+ Each prints a redacted summary: state, decisions, overrides, Reviewer verdicts
147
+ and answer-format failures. The script exits non-zero when a scenario misses
148
+ its expected outcome. Weak and rate-limited models are useful here, because
149
+ they exercise the deterministic guards that the prompt alone does not
150
+ guarantee. A daily quota error at startup or mid-task is expected to fail
151
+ closed (park), and is not a regression.
152
+
116
153
  The tmux spike is gated, authenticated, and excluded from normal CI. It uses
117
154
  plan mode with a fixed `opus` model, records only protocol metadata, and
118
155
  verifies three real Claude turns, exact screen-result markers, pause/resume,
@@ -146,7 +183,7 @@ Pi Decision Worker. Setting `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux` selects the
146
183
  Supervisor-owned live bridge, which carries the same structured records through
147
184
  private framing on the PTY rather than an independent JSONL sidecar. Task autonomy
148
185
  defaults to unattended local work, a required
149
- local commit on the task branch (any branch, anchored to the baseline commit) and two bounded Decision Worker retries. Configure
186
+ local commit on the task branch (any branch, anchored to the baseline commit) and four bounded Decision Worker retries. Configure
150
187
  `PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT=0` or task `autonomy.requireLocalCommit`
151
188
  only to disable the local-commit deliverability check; automatic mode still requires a Git
152
189
  baseline (any branch, including `main`; the candidate must descend from it). The tmux bridge
@@ -240,7 +277,7 @@ Deterministic tests must cover:
240
277
  - multiple required/optional checks with bounded output, timeout and exit-code evidence;
241
278
  - independent read-only Reviewer pass/revise/human results;
242
279
  - invalid Reviewer JSON and Reviewer API failure becoming a parked/non-publishable candidate without requiring a live callback;
243
- - repair rounds, repeated finding detection, P0/P1 parking and repair-budget exhaustion;
280
+ - repair rounds (P0/P1 findings are repaired, never passed), repeated finding detection and repair-budget exhaustion;
244
281
  - non-persistent JSONL verification failure without duplicate terminal transitions;
245
282
  - repairable-but-not-persistent JSONL multi-turn repair;
246
283
  - stop and Pi shutdown from `verifying`, including Decision Worker closure and cwd lease release;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -61,6 +61,7 @@
61
61
  "spike:permissions": "node scripts/spike-claude-permissions.mjs",
62
62
  "spike:signals": "node scripts/spike-claude-signals.mjs",
63
63
  "spike:automation": "node scripts/spike-claude-automation.mjs",
64
+ "spike:decision": "node scripts/spike-decision.mjs",
64
65
  "check": "npm run typecheck && npm test && npm run check:package && npm run check:docs && npm run check:automation",
65
66
  "build": "node scripts/build-package.mjs"
66
67
  },
package/src/acceptance.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_MAX_DECISION_RETRIES } from "./config.ts";
1
2
  import { isPlainRemoteName } from "./policy.ts";
2
3
  import type { AcceptanceCheck, TaskSpec } from "./types.ts";
3
4
 
@@ -94,7 +95,7 @@ function normalizeAutonomy(value: unknown, defaults?: Partial<TaskSpec["autonomy
94
95
  const source = value as Record<string, unknown>;
95
96
  if (source.unattended !== undefined && typeof source.unattended !== "boolean") throw new Error("task spec autonomy.unattended must be boolean");
96
97
  if (source.requireLocalCommit !== undefined && typeof source.requireLocalCommit !== "boolean") throw new Error("task spec autonomy.requireLocalCommit must be boolean");
97
- const retries = source.maxDecisionRetries ?? defaults?.maxDecisionRetries ?? 2;
98
+ const retries = source.maxDecisionRetries ?? defaults?.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
98
99
  if (typeof retries !== "number" || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error("task spec autonomy.maxDecisionRetries must be between 0 and 10");
99
100
  const authority = source.permissionAuthority ?? defaults?.permissionAuthority ?? "hybrid";
100
101
  if (authority !== "policy" && authority !== "hybrid" && authority !== "decision-worker") throw new Error("task spec autonomy.permissionAuthority must be policy, hybrid or decision-worker");
package/src/config.ts CHANGED
@@ -60,22 +60,22 @@ export interface AutonomyDefaults {
60
60
 
61
61
  export function autonomyDefaults(env: NodeJS.ProcessEnv = process.env): AutonomyDefaults {
62
62
  return {
63
- unattended: readBoolean(env.PI_CLAUDE_SUPERVISOR_UNATTENDED, true),
64
- requireLocalCommit: readBoolean(env.PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT, true),
65
- maxDecisionRetries: readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES, 2, 0, 10),
63
+ unattended: readBoolean(env.PI_CLAUDE_SUPERVISOR_UNATTENDED, true, "PI_CLAUDE_SUPERVISOR_UNATTENDED"),
64
+ requireLocalCommit: readBoolean(env.PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT, true, "PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT"),
65
+ maxDecisionRetries: readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES, DEFAULT_MAX_DECISION_RETRIES, 0, 10, "PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES"),
66
66
  permissionAuthority: readPermissionAuthority(env.PI_CLAUDE_SUPERVISOR_PERMISSION_AUTHORITY),
67
67
  remoteAuthority: readRemoteAuthority(env.PI_CLAUDE_SUPERVISOR_REMOTE_AUTHORITY),
68
68
  remoteName: readRemoteName(env.PI_CLAUDE_SUPERVISOR_REMOTE_NAME),
69
- ...(readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) !== undefined ? { maxWorkerCostUsd: readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) } : {}),
69
+ ...(readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD, "PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD") !== undefined ? { maxWorkerCostUsd: readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD, "PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD") } : {}),
70
70
  };
71
71
  }
72
72
 
73
73
  export function reviewTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
74
- return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS, 600_000, 30_000, 3_600_000);
74
+ return readBoundedDuration(env.PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS, 600_000, 30_000, 3_600_000, "PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS");
75
75
  }
76
76
 
77
77
  export function eventLogMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
78
- return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES, 64 * 1024 * 1024, 1024 * 1024, 1024 * 1024 * 1024);
78
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES, 64 * 1024 * 1024, 1024 * 1024, 1024 * 1024 * 1024, "PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES");
79
79
  }
80
80
 
81
81
  export function workerModel(env: NodeJS.ProcessEnv = process.env): string | undefined {
@@ -84,7 +84,7 @@ export function workerModel(env: NodeJS.ProcessEnv = process.env): string | unde
84
84
 
85
85
  /** The 200_000 default applies only in automatic mode; "0" is an explicit opt-out that omits --autocompact. */
86
86
  export function workerAutocompactTokens(env: NodeJS.ProcessEnv = process.env): number {
87
- return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS, 200_000, 100_000, 1_000_000);
87
+ return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS, 200_000, 100_000, 1_000_000, "PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS");
88
88
  }
89
89
 
90
90
  export function workerMcpConfigPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
@@ -101,25 +101,27 @@ export function reviewerModel(env: NodeJS.ProcessEnv = process.env): string | un
101
101
 
102
102
  /** "0" is an explicit opt-out that disables Decision Worker session compaction. */
103
103
  export function decisionCompactionTokens(env: NodeJS.ProcessEnv = process.env): number {
104
- return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS, 60_000, 10_000, 500_000);
104
+ return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS, 60_000, 10_000, 500_000, "PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS");
105
105
  }
106
106
 
107
107
  export function progressHeartbeatMs(env: NodeJS.ProcessEnv = process.env): number {
108
- return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS, 60_000, 5_000, 3_600_000);
108
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS, 60_000, 5_000, 3_600_000, "PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS");
109
109
  }
110
110
 
111
111
  export function decisionSessionRetentionDays(env: NodeJS.ProcessEnv = process.env): number {
112
- return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS, 30, 0, 3650);
112
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS, 30, 0, 3650, "PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS");
113
113
  }
114
114
 
115
115
  export function evidenceMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
116
- return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES, 1024 * 1024, 64 * 1024, 64 * 1024 * 1024);
116
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES, 1024 * 1024, 64 * 1024, 64 * 1024 * 1024, "PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES");
117
117
  }
118
118
 
119
119
  export function evidenceMaxUntrackedFiles(env: NodeJS.ProcessEnv = process.env): number {
120
- return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES, 512, 16, 10_000);
120
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES, 512, 16, 10_000, "PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES");
121
121
  }
122
122
 
123
+ /** Retries of a failed Decision Worker request before the task is parked. */
124
+ export const DEFAULT_MAX_DECISION_RETRIES = 4;
123
125
  export const DEFAULT_DEADLINE_MS = 4 * 60 * 60_000;
124
126
  export const DEFAULT_DEADLINE_GRACE_MS = 30 * 60_000;
125
127
  export const DEFAULT_DEADLINE_WARNING_MS = 15 * 60_000;
@@ -131,7 +133,7 @@ export const DEFAULT_NO_OUTPUT_TIMEOUT_MS = 20 * 60_000;
131
133
  * accepted as well as plain milliseconds.
132
134
  */
133
135
  export function deadlineMs(env: NodeJS.ProcessEnv = process.env): number {
134
- return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_MS, DEFAULT_DEADLINE_MS, 5 * 60_000, 7 * 24 * 60 * 60_000);
136
+ return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_MS, DEFAULT_DEADLINE_MS, 5 * 60_000, 7 * 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_MS");
135
137
  }
136
138
 
137
139
  /**
@@ -140,17 +142,17 @@ export function deadlineMs(env: NodeJS.ProcessEnv = process.env): number {
140
142
  * restores the immediate stop at the deadline. Up to 24 hours.
141
143
  */
142
144
  export function deadlineGraceMs(env: NodeJS.ProcessEnv = process.env): number {
143
- return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS, DEFAULT_DEADLINE_GRACE_MS, 60_000, 24 * 60 * 60_000);
145
+ return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS, DEFAULT_DEADLINE_GRACE_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS");
144
146
  }
145
147
 
146
148
  /** How long before the deadline the Decision Worker is warned (up to 24 hours); "0" disables the warning. */
147
149
  export function deadlineWarningMs(env: NodeJS.ProcessEnv = process.env): number {
148
- return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS, DEFAULT_DEADLINE_WARNING_MS, 60_000, 24 * 60 * 60_000);
150
+ return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS, DEFAULT_DEADLINE_WARNING_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS");
149
151
  }
150
152
 
151
153
  /** Stop a Worker that has produced no output for this long (1 minute to 24 hours); "0" disables the check. */
152
154
  export function noOutputTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
153
- return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS, DEFAULT_NO_OUTPUT_TIMEOUT_MS, 60_000, 24 * 60 * 60_000);
155
+ return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS, DEFAULT_NO_OUTPUT_TIMEOUT_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS");
154
156
  }
155
157
 
156
158
  const DURATION_UNITS_MS: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 60 * 60_000, d: 24 * 60 * 60_000 };
@@ -193,10 +195,12 @@ export function loadSupervisorEnvironment(): string | undefined {
193
195
  // Best effort: the file is local configuration, never a repository asset.
194
196
  try { chmodSync(path, 0o600); } catch { /* read-only filesystems may reject chmod */ }
195
197
  for (const line of contents.split(/\r?\n/u)) {
196
- const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u);
198
+ // Shell-style: an optional `export ` prefix, and a trailing `# comment`
199
+ // after an unquoted value or a closing quote. Keeping the comment made
200
+ // `MODE="auto" # enable` a value no reader accepts.
201
+ const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*?)\s*$/u);
197
202
  if (!match || !allowed.has(match[1]) || process.env[match[1]] !== undefined) continue;
198
- const value = match[2].replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, (_, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted);
199
- process.env[match[1]] = value;
203
+ process.env[match[1]] = envFileValue(match[2]);
200
204
  }
201
205
  return path;
202
206
  } catch (error) {
@@ -205,32 +209,72 @@ export function loadSupervisorEnvironment(): string | undefined {
205
209
  }
206
210
  }
207
211
 
208
- function readBoolean(value: string | undefined, fallback: boolean): boolean {
212
+ const reportedSettings = new Set<string>();
213
+
214
+ /**
215
+ * An unusable value keeps the default, but never silently: `DEADLINE_MS=10d`
216
+ * quietly running a 4h task is exactly the surprise an unattended run cannot
217
+ * afford. Each distinct rejected value is reported once per process.
218
+ */
219
+ function rejectSetting(name: string | undefined, value: string, reason: string, fallback: unknown): void {
220
+ // An empty assignment (`KEY=`) is how an env file leaves a key unset.
221
+ if (!name || value.trim() === "") return;
222
+ const key = `${name}=${value}`;
223
+ if (reportedSettings.has(key)) return;
224
+ reportedSettings.add(key);
225
+ console.warn(`pi-claude-supervisor: ignoring ${name}=${JSON.stringify(String(redactSensitive(value)))} (${reason}); using ${String(fallback)}`);
226
+ }
227
+
228
+ /** The value of one env-file assignment: quoted text verbatim, or an unquoted word before any ` #` comment. */
229
+ export function envFileValue(raw: string): string {
230
+ // `KEY= # note` is an empty value with a comment, as in a shell; only a
231
+ // `#` right after the `=` (`KEY=#abc`) belongs to the value.
232
+ if (/^\s+#/u.test(raw)) return "";
233
+ raw = raw.trimStart();
234
+ const quoted = raw.match(/^(?:"([^"]*)"|'([^']*)')(?:\s+#.*)?$/u);
235
+ if (quoted) return quoted[1] ?? quoted[2] ?? "";
236
+ // A comment needs whitespace before its `#`, as in a shell: `KEY=#abc`
237
+ // is the value `#abc` (a webhook secret may well start with one).
238
+ return raw.replace(/\s+#.*$/u, "").trim();
239
+ }
240
+
241
+ function readBoolean(value: string | undefined, fallback: boolean, name?: string): boolean {
209
242
  if (value === undefined) return fallback;
210
243
  if (/^(?:1|true|yes|on)$/iu.test(value.trim())) return true;
211
244
  if (/^(?:0|false|no|off)$/iu.test(value.trim())) return false;
245
+ rejectSetting(name, value, "expected 1/0, true/false, yes/no or on/off", fallback);
212
246
  return fallback;
213
247
  }
214
248
 
215
- function readBoundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number): number {
249
+ function readBoundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
216
250
  if (value === undefined) return fallback;
217
251
  const parsed = Number(value);
218
- return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
252
+ if (Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum) return parsed;
253
+ rejectSetting(name, value, `expected an integer from ${minimum} to ${maximum}`, fallback);
254
+ return fallback;
219
255
  }
220
256
 
221
257
  /** Like readBoundedInteger, but the literal "0" is always honored as an opt-out below the normal minimum. */
222
- function readBoundedIntegerWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number): number {
258
+ function readBoundedIntegerWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
223
259
  if (value !== undefined && value.trim() === "0") return 0;
224
- return readBoundedInteger(value, fallback, minimum, maximum);
260
+ return readBoundedInteger(value, fallback, minimum, maximum, name);
225
261
  }
226
262
 
227
- /** Like readBoundedIntegerWithZeroOptOut, but also accepts a duration suffix (`8h`, `90m`); out-of-range values keep the fallback. */
228
- function readBoundedDurationWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number): number {
263
+ /** A duration (`20m`, `1h30m`, or plain milliseconds) within bounds; anything else keeps the fallback. */
264
+ function readBoundedDuration(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
229
265
  if (value === undefined) return fallback;
230
266
  const parsed = parseDurationMs(value);
267
+ if (parsed !== undefined && parsed >= minimum && parsed <= maximum) return parsed;
268
+ rejectSetting(name, value, `expected a duration from ${formatDurationMs(minimum)} to ${formatDurationMs(maximum)}`, formatDurationMs(fallback));
269
+ return fallback;
270
+ }
271
+
272
+ /** Like readBoundedIntegerWithZeroOptOut, but also accepts a duration suffix (`8h`, `90m`); out-of-range values keep the fallback. */
273
+ function readBoundedDurationWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
274
+ if (value === undefined) return fallback;
231
275
  // "0", "0m", "0h": any zero duration is the opt-out, not a below-minimum typo.
232
- if (parsed === 0) return 0;
233
- return parsed !== undefined && parsed >= minimum && parsed <= maximum ? parsed : fallback;
276
+ if (parseDurationMs(value) === 0) return 0;
277
+ return readBoundedDuration(value, fallback, minimum, maximum, name);
234
278
  }
235
279
 
236
280
  function readTrimmedString(value: string | undefined): string | undefined {
@@ -262,10 +306,12 @@ function readPermissionAuthority(value: string | undefined): PermissionAuthority
262
306
  return normalized === "policy" || normalized === "decision-worker" ? normalized : "hybrid";
263
307
  }
264
308
 
265
- function readPositiveNumber(value: string | undefined): number | undefined {
309
+ function readPositiveNumber(value: string | undefined, name?: string): number | undefined {
266
310
  if (value === undefined || value.trim() === "") return undefined;
267
311
  const parsed = Number(value);
268
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
312
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
313
+ rejectSetting(name, value, "expected a positive number", "no limit");
314
+ return undefined;
269
315
  }
270
316
 
271
317
  /** How automatic tmux supervision drives Claude: the real TUI through hooks (default) or the stream-json bridge. */
@@ -275,10 +321,10 @@ export function tmuxMode(env: NodeJS.ProcessEnv = process.env): "interactive" |
275
321
 
276
322
  /** Exit the interactive Worker and its tmux session once a task completes; default keeps it open for the operator. */
277
323
  export function closeWorkerOnCompletion(env: NodeJS.ProcessEnv = process.env): boolean {
278
- return readBoolean(env.PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION, false);
324
+ return readBoolean(env.PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION, false, "PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION");
279
325
  }
280
326
 
281
327
  /** Install the user-level Claude Code hook entries automatically when the interactive tmux mode is configured. */
282
328
  export function autoInstallHooks(env: NodeJS.ProcessEnv = process.env): boolean {
283
- return readBoolean(env.PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS, true);
329
+ return readBoolean(env.PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS, true, "PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS");
284
330
  }