pi-claude-supervisor 0.8.1 → 0.9.1
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 +15 -0
- package/README.cn.md +78 -8
- package/README.md +123 -8
- package/docs/architecture.md +74 -8
- package/docs/autonomy-target.md +1 -1
- package/docs/testing.md +3 -3
- package/package.json +1 -1
- package/src/acceptance.ts +20 -9
- package/src/config.ts +105 -33
- package/src/cwd-lease.ts +34 -1
- package/src/decision-session-store.ts +20 -14
- package/src/decision-worker.ts +51 -9
- package/src/events.ts +5 -11
- package/src/hooks/install.ts +2 -11
- package/src/hooks/relay.ts +1 -4
- package/src/hooks/server.ts +2 -10
- package/src/hooks/settings.ts +2 -11
- package/src/hooks/types.ts +17 -9
- package/src/index.ts +97 -9
- package/src/json-extract.ts +41 -2
- package/src/lock-owner.ts +55 -0
- package/src/notifications.ts +5 -2
- package/src/policy.ts +509 -62
- package/src/reviewer.ts +228 -53
- package/src/supervisor.ts +639 -39
- package/src/types.ts +20 -0
- package/src/verifier.ts +203 -5
- package/src/worker/environment.ts +12 -2
- package/src/worker/process-adapter.ts +40 -5
- package/src/worker/tmux-adapter.ts +168 -40
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.9.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.0...v0.9.1) (2026-09-24)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* 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))
|
|
11
|
+
* **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))
|
|
12
|
+
|
|
13
|
+
## [0.9.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.1...v0.9.0) (2026-09-20)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **supervisor:** publish a verified candidate under a narrow, one-shot remote grant ([5e117cc](https://github.com/btnalit/pi-claude-supervisor/commit/5e117cc5695492d03684270ea25cac224f1fe41e))
|
|
19
|
+
|
|
5
20
|
## [0.8.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.0...v0.8.1) (2026-09-20)
|
|
6
21
|
|
|
7
22
|
|
package/README.cn.md
CHANGED
|
@@ -202,12 +202,75 @@ 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
|
-
|
|
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。
|
|
210
213
|
|
|
214
|
+
## 发布已验证的候选
|
|
215
|
+
|
|
216
|
+
默认情况下任务止于**已验证的本地候选**:验收与独立 Reviewer 通过,而 Worker 全程
|
|
217
|
+
没有任何远程权限。设 `REMOTE_AUTHORITY=push`(或 `--remote push`)会在该判定之后
|
|
218
|
+
加一个**发布阶段**:Supervisor 记下已验证的 commit,发放一次性的窄授权,并让
|
|
219
|
+
Worker 推自己的分支;`pr` 还允许它开 PR。**push 由 Worker 自己执行**——Supervisor
|
|
220
|
+
从不代劳——之后 Supervisor 以只读方式核实(`git ls-remote`,`pr` 还查 `gh pr list`)
|
|
221
|
+
才把任务标记完成,候选通知里带上 PR 链接。核实不到则把候选标为 blocked,本地候选
|
|
222
|
+
依然可交付。
|
|
223
|
+
|
|
224
|
+
这个授权刻意严苛,而且两条命令都按字面匹配——没被审过的选项一律拒绝,而不是默认
|
|
225
|
+
无害。它只认 `git -C '<任务目录>' -c core.hooksPath=/dev/null -c push.followTags=false push <remote> <已验证 commit>:refs/heads/<branch>`,
|
|
226
|
+
不带其他任何选项。refspec 写的是**已验证的 commit** 而不是分支:git 只会推送这一个对象,
|
|
227
|
+
Worker 在发布轮里再提交的内容会留在本地("Everything up-to-date"),搭不上这次授权。
|
|
228
|
+
**`-C` 是必需的、必须是绝对路径、且必须与任务目录逐字节相同**——不做规范化、不做 realpath——因为 Claude 的 Bash 工具
|
|
229
|
+
会在多次调用之间保留工作目录,而 `cd` 属于普通本地操作,没有 `-C` 的话授权可能被花在
|
|
230
|
+
任何别的克隆上;任何更宽松的比较都出现过 Supervisor 与 git 解析不一致的写法(相对路径 `.` 按 Supervisor 的目录解析、`/proc/self/cwd`,以及 `<cwd>/link/..`——Node 自己的 realpath 会按字面折叠而内核会跟随 symlink)。指令写的就是精确目录,不需要接受任何别的写法。
|
|
231
|
+
这条命令上**钉死了 hooks 路径**,所以 Worker 通过任何途径(`git init --template=`、
|
|
232
|
+
解压归档、`chmod`)装进去的 `pre-push` hook 都不会在授权的 push 里以 Worker 的凭据执行;也钉死了
|
|
233
|
+
`push.followTags=false`,所以通过策略看不见的任何文件设的 `followTags=true` 都不能让这一次 push
|
|
234
|
+
顺带推上授权没点名的 tag(tag 正是发布自动化的触发点)。两者都是 ref/hook 选择而非传输层,不会
|
|
235
|
+
覆盖任何合理的仓库级设置。
|
|
236
|
+
`pr` 下另加 `gh pr create --repo <钉住的 remote URL> --head <候选分支> …`(只允许
|
|
237
|
+
title / body / base / draft / assignee / label):PR 只会开在授权 remote 对应的仓库里——
|
|
238
|
+
不带 `--repo` 的话,gh 会从 remotes 里自己挑一个 base 仓库(fork 上是 `upstream`),
|
|
239
|
+
那不是授权点名的仓库,核实也不会去查它。仓库取 remote URL 背后的 `host/owner/repo`
|
|
240
|
+
(SSH config 里的 host 别名会像 gh 那样经 `ssh -G` 翻译);URL 不是仓库的 remote(本地路径、
|
|
241
|
+
翻译不了的别名)不会拿到 `pr` 授权。授权提供的每个词都做了 shell 引用,分支叫 `feat/$ticket`
|
|
242
|
+
也能原样通过策略。
|
|
243
|
+
|
|
244
|
+
授权只会发给"就是已验证工作树"的那个 commit:工作树必须干净(含未跟踪文件——新文件也可能
|
|
245
|
+
是被验证行为的一部分),且 HEAD 自 Reviewer 评审的证据被读取以来没有移动过。仓库的 Git 目录必须是自己的 `.git` 或 linked worktree 的 `.git/worktrees/<name>`(不能是 `--separate-git-dir` 指针)。工作树不干净
|
|
246
|
+
会先花一轮修复让 Worker 把属于候选的内容提交掉;只有修复轮用尽或 HEAD 移动过,任务才以本地
|
|
247
|
+
候选结束并在通知里写明 `not published:` 原因,而不是发授权。核实时 remote 连不上,发布只是
|
|
248
|
+
"未确认"(候选仍可交付),绝不会被说成"没推上去"。
|
|
249
|
+
|
|
250
|
+
有没有授权都拒绝:任何 push 选项(`-u`、`--force`、`--force-with-lease`、`--delete`、
|
|
251
|
+
`--mirror`、`--all`、`--tags`、`--no-verify`、`--push-option`、`--receive-pack` 等)、
|
|
252
|
+
除这两个钉死项(且顺序固定)以外的任何 `-c`、以分支或 `HEAD` 作为 refspec 来源、裸 `git push`、
|
|
253
|
+
别的 remote、分支或 commit、保护分支、被 shell 包装(含 heredoc 管进 shell)、带动态参数、
|
|
254
|
+
第二条语句、不带 `-C` 的 `git push`、`-C` 与任务目录不逐字节相同(别的目录、相对路径、`/proc/self/cwd`、其中的 symlink 或 `..`)、不带
|
|
255
|
+
`--repo <钉住的 URL>` 或不带 `--head <候选分支>` 的 `gh pr create`(被别的选项当作值吞掉的
|
|
256
|
+
`--head` 不算)、`gh pr create --body-file/-F/--template`(会把任意本地文件内容发到 PR 上)、
|
|
257
|
+
`--web`、别的 `--repo`、`gh pr merge`、`gh api`、`gh release`、`npm publish`。改动仓库
|
|
258
|
+
remote(`git remote set-url|add|rename|…`,藏在 git 自己的 `--git-dir`/`--work-tree` 选项
|
|
259
|
+
或 `remote` 自己的 `-v` 后面也一样)一律拒绝;改动 push 去向或 push 期间会执行什么也一律
|
|
260
|
+
拒绝——`git config` 写 `remote.*`、`url.*.insteadOf`、`push.*`、`credential.*`、`http.*`、
|
|
261
|
+
`include.path`/`includeIf.*`、`init.*`、`core.sshCommand`、`core.hooksPath`,`git config --edit`、
|
|
262
|
+
`git init --template=…`、`git init|clone --separate-git-dir=…`,以及任何提到 `.git/config` 或 `.git/hooks` 的语句(除非它显然只读:
|
|
263
|
+
`cat`、`grep`、`ls` 等)——否则授权认的 remote 会被偷换,连 Supervisor 的核实也会被骗过;
|
|
264
|
+
核实同时钉住 fetch 和 push 两个 URL,并从自己的环境里剔除 `GIT_DIR`/`GIT_CONFIG_*`。这些文本规则之下还有一条不依赖它们的规则:remote 解析后的**全部** fetch/push URL(`git remote get-url --all` / `--push --all`;git 会推到每一个 `pushurl`,而不只是它打印的第一个;已应用改写)在任务**开始**、Worker 还没跑任何命令时记录,并在发放授权时和授权的 push 被批准的那一刻都要求原样不变。任务期间用任何手段(`~/.gitconfig`、脚本、策略看不见的 include)、哪怕在发布轮的第一条命令里加进来的 `pushInsteadOf`、`pushurl` 或额外目的地,都会让 push 被拒、授权作废;操作者原本就有的改写已在基线里,不受影响。恢复的任务沿用记录的基线、绝不重新采集;首次启动时解析不到的 remote 会被记录下来,永不授权。这是一层
|
|
265
|
+
作用于命令文本的策略:Worker 自己写一个脚本再运行,策略看不见——如
|
|
266
|
+
[autonomy-target.md](docs/autonomy-target.md) 对所有文本级规则所说,绝对隔离是 host 边界的事。
|
|
267
|
+
|
|
268
|
+
授权是**一次性**的:发布轮一结束就收回(不等下一个决策),并且同时绑定 remote 的 URL
|
|
269
|
+
而不只是名字。Worker 若在该轮里改动了工作树(未提交的修改同样算改动),授权立即作废并重新完整验收;因为授权写的是
|
|
270
|
+
commit,通知能说清已验证的 commit 是否在工作树变动之前就已经推上去了。验证之前
|
|
271
|
+
被拒的 push 会说明授权稍后会来,而不是让 Worker 去猜;没能确认发布的任务会在候选
|
|
272
|
+
通知里写明原因,而不是只报一句 "ready"。
|
|
273
|
+
|
|
211
274
|
## 任务 spec
|
|
212
275
|
|
|
213
276
|
`--spec file.json` 接受如下格式:
|
|
@@ -225,7 +288,7 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
225
288
|
"autonomy": {
|
|
226
289
|
"unattended": true,
|
|
227
290
|
"requireLocalCommit": true,
|
|
228
|
-
"maxDecisionRetries":
|
|
291
|
+
"maxDecisionRetries": 4,
|
|
229
292
|
"permissionAuthority": "hybrid",
|
|
230
293
|
"maxWorkerCostUsd": 20
|
|
231
294
|
}
|
|
@@ -238,7 +301,9 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
238
301
|
## 配置参考
|
|
239
302
|
|
|
240
303
|
环境变量(或 `~/.config/pi-claude-supervisor/env`),均以 `PI_CLAUDE_SUPERVISOR_`
|
|
241
|
-
为前缀;完整模板见 `.env.example`。
|
|
304
|
+
为前缀;完整模板见 `.env.example`。env 文件每行是 `KEY=value`,可以带 `export `
|
|
305
|
+
前缀和行尾 ` # 注释`。数值、时长或布尔类配置超出范围或无法解析时会沿用默认值,
|
|
306
|
+
并以 `pi-claude-supervisor: ignoring …` 警告报告一次。
|
|
242
307
|
|
|
243
308
|
| 变量 | 默认值 | 含义 |
|
|
244
309
|
| --- | --- | --- |
|
|
@@ -261,8 +326,10 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
261
326
|
| `HUMAN_WEBHOOK_SECRET` | 未设置 | HMAC 签名密钥;以 `x-pi-supervisor-signature` header 发送 |
|
|
262
327
|
| `UNATTENDED` | `true` | 任务无需同步人工回调即可运行 |
|
|
263
328
|
| `REQUIRE_LOCAL_COMMIT` | `true` | 完成前要求在候选所在分支上有本地 commit |
|
|
264
|
-
| `MAX_DECISION_RETRIES` | `
|
|
329
|
+
| `MAX_DECISION_RETRIES` | `4`(0–10) | Decision Worker 调用超时或失败(429/529、网络、鉴权)时的重试次数;两次尝试之间依次等待 15s、45s、60s |
|
|
265
330
|
| `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
|
|
331
|
+
| `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`;验收通过后开启发布阶段。`--remote` 可按任务覆盖 |
|
|
332
|
+
| `REMOTE_NAME` | `origin` | 发布授权唯一允许的 remote 名 |
|
|
266
333
|
| `WORKER_MAX_BUDGET_USD` | 未设置 | 作为 `--max-budget-usd` 传入的硬上限;交互式 tmux 下不可用 |
|
|
267
334
|
| `WORKER_MODEL` | 未设置(Claude 自身默认值) | Claude Worker 的 `--model` |
|
|
268
335
|
| `WORKER_AUTOCOMPACT_TOKENS` | 自动模式默认 `200000` | 每轮上下文上限;`0` 保留 Claude 自身默认值 |
|
|
@@ -274,8 +341,8 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
274
341
|
| `DECISION_SESSION_RETENTION_DAYS` | `30` | 启动时清理早于此天数的已关闭 Decision Worker session 记录;`0` 表示永久保留 |
|
|
275
342
|
| `EVIDENCE_MAX_BYTES` | `1048576`(1 MiB) | 每个任务收集的最大仓库证据字节数 |
|
|
276
343
|
| `EVIDENCE_MAX_UNTRACKED_FILES` | `512` | 每个任务作为证据收集的最大未跟踪文件数 |
|
|
277
|
-
| `REVIEW_TIMEOUT_MS` | `
|
|
278
|
-
| `DEADLINE_MS` | `4h` |
|
|
344
|
+
| `REVIEW_TIMEOUT_MS` | `10m`(30s–1h) | 每轮独立 Reviewer 的总预算;预算未用完时,provider 错误会用新会话重试 |
|
|
345
|
+
| `DEADLINE_MS` | `4h` | 每个任务从启动起算的墙钟总时限,Pi 停机期间也计入,因此恢复时用 `recover --extend` 重新给预算(`8h`、`90m`、`2h30m` 或毫秒;5 分钟到 7 天);`0`(或 `0m`)关闭;`--deadline` 可按任务覆盖 |
|
|
279
346
|
| `DEADLINE_GRACE_MS` | `30m` | 自动任务到期后的收尾窗口:空闲的 Worker 会被验收而不是停止;`0` 恢复到期立即停止 |
|
|
280
347
|
| `DEADLINE_WARNING_MS` | `15m` | 到期前多久提醒并重新询问 Decision Worker;`0` 关闭提醒 |
|
|
281
348
|
| `NO_OUTPUT_TIMEOUT_MS` | `20m` | Worker 多久没有输出就停止;`0` 关闭该检查 |
|
|
@@ -296,7 +363,10 @@ Worker,它不会静默恢复或重复执行任务。只有在租约证明旧 Wor
|
|
|
296
363
|
会拒绝它;`recover --takeover --extend <duration> <task-id>` 从现在起再给这么
|
|
297
364
|
多预算(恢复后的 Supervisor 会把新时限持久化),`--extend 0` 则立即进入收尾:
|
|
298
365
|
新 Worker 的第一个 watchdog tick 就会对仓库现状做验收和 review,修复轮会告诉
|
|
299
|
-
|
|
366
|
+
它还剩多少时间。带 `--extend` 时,恢复的任务会直接交回自动化:非零的延长会
|
|
367
|
+
给新 Worker 发送原任务的续做指令(先让它查看已有的工作),`--extend 0` 则无需
|
|
368
|
+
指令。不带 `--extend` 的普通 `recover` 仍让 Worker 在人工接管下空闲——先发送
|
|
369
|
+
续做指令,再执行 `resume-auto`。确定不再恢复的记录用 `/supervise discard <task-id>` 丢弃
|
|
300
370
|
(会话文件保留到保留期清理为止)。
|
|
301
371
|
|
|
302
372
|
每个任务在 `CWD_LEASE_DIR` 下持有一个 cwd 租约;并发任务需要各自独立的
|
package/README.md
CHANGED
|
@@ -247,14 +247,119 @@ 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
|
|
251
|
-
|
|
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.
|
|
255
258
|
- Only one cwd lease is held per task; concurrent tasks need separate
|
|
256
259
|
worktrees.
|
|
257
260
|
|
|
261
|
+
## Publishing a verified candidate
|
|
262
|
+
|
|
263
|
+
By default a task ends at a **verified local candidate**: acceptance and the
|
|
264
|
+
independent Reviewer pass, and the Worker never had remote authority at any
|
|
265
|
+
point. `REMOTE_AUTHORITY=push` (or `--remote push`) adds a **publish phase**
|
|
266
|
+
after that verdict: the Supervisor records the verified commit, grants a narrow
|
|
267
|
+
one-shot authority, and asks the Worker to push its own branch. `pr` also lets
|
|
268
|
+
it open a pull request. The Worker performs the push — the Supervisor never
|
|
269
|
+
does — and the Supervisor then confirms it read-only (`git ls-remote`, and
|
|
270
|
+
`gh pr list` for `pr`) before completing the task; the candidate notice carries
|
|
271
|
+
the pull request URL. A publish that cannot be confirmed blocks the candidate,
|
|
272
|
+
which stays deliverable locally.
|
|
273
|
+
|
|
274
|
+
The grant is deliberately unforgiving, and both commands are matched literally —
|
|
275
|
+
an option nobody reviewed is refused rather than assumed harmless. It admits
|
|
276
|
+
exactly `git -C '<task directory>' -c core.hooksPath=/dev/null -c push.followTags=false push <remote> <verified commit>:refs/heads/<branch>`,
|
|
277
|
+
with no other option. The refspec names the **verified commit**, not the branch:
|
|
278
|
+
git pushes exactly that object, so a commit the Worker makes during the publish
|
|
279
|
+
turn stays local ("Everything up-to-date") instead of riding the grant. `-C` is
|
|
280
|
+
**required, absolute and byte for byte the task directory** — no normalization,
|
|
281
|
+
no realpath — because Claude's Bash tool keeps its working directory between
|
|
282
|
+
calls and `cd` is ordinary local work, so without it the grant could be spent
|
|
283
|
+
in any other clone, and every looser comparison had a spelling the Supervisor
|
|
284
|
+
resolved one way and git another (`.` against the Supervisor's cwd,
|
|
285
|
+
`/proc/self/cwd`, and `<cwd>/link/..`, which Node's own realpath collapses
|
|
286
|
+
lexically while the kernel follows the link). The instruction spells the exact
|
|
287
|
+
directory, so no other spelling is needed. The hooks path is **pinned** on that one command so no
|
|
288
|
+
`pre-push` hook a Worker could have installed (by any door: `git init
|
|
289
|
+
--template=`, an archive, a `chmod`) runs inside the granted push with the
|
|
290
|
+
Worker's credentials, and `push.followTags=false` is pinned so a
|
|
291
|
+
`followTags=true` set through any file the policy never sees cannot make the
|
|
292
|
+
one push also plant a tag the grant never named (a tag is what release
|
|
293
|
+
automation keys on). Both are ref and hook selection, not transport, so they
|
|
294
|
+
override no legitimate per-repository setting. For `pr` a `gh pr create --repo <pinned remote URL>
|
|
295
|
+
--head <candidate branch> …` limited to title, body, base, draft, assignee and
|
|
296
|
+
label: the pull request opens in the granted remote's repository, full stop —
|
|
297
|
+
without `--repo`, gh picks a base repository from the remotes (`upstream` on a
|
|
298
|
+
fork) that the grant never named and the confirmation never reads. The
|
|
299
|
+
repository is the `host/owner/repo` behind the remote's URL (an SSH-config host
|
|
300
|
+
alias is translated through `ssh -G`, as gh does); a remote whose URL is not
|
|
301
|
+
one — a local path, an alias with no translation — gets no `pr` grant. Every
|
|
302
|
+
word the grant supplies is shell-quoted, so a branch named `feat/$ticket` still
|
|
303
|
+
round-trips through the policy.
|
|
304
|
+
|
|
305
|
+
The grant is only issued for a commit that *is* the verified tree: the working
|
|
306
|
+
tree must be clean (untracked files included — a new file may be part of the
|
|
307
|
+
verified behavior), and HEAD must not have moved since the evidence the Reviewer
|
|
308
|
+
judged was read. The repository's Git directory must be its own `.git` or a linked worktree's
|
|
309
|
+
`.git/worktrees/<name>` (not a `--separate-git-dir` pointer). A dirty tree first costs a repair round asking the Worker to
|
|
310
|
+
commit what belongs to the candidate; only when none is left, or when HEAD
|
|
311
|
+
moved, does the task end at the local candidate with a `not published:` reason
|
|
312
|
+
instead of a grant. A remote that cannot be reached at confirmation time leaves
|
|
313
|
+
the publish *unconfirmed* (the candidate stays deliverable), never "refuted".
|
|
314
|
+
|
|
315
|
+
Refused with or without a grant: every push option (`-u`, `--force`,
|
|
316
|
+
`--force-with-lease`, `--delete`, `--mirror`, `--all`, `--tags`, `--no-verify`,
|
|
317
|
+
`--push-option`, `--receive-pack`, …), any `-c` but the two pins (in that order), a
|
|
318
|
+
branch or `HEAD` as the refspec source, a bare `git push`, another remote,
|
|
319
|
+
branch or commit, a protected branch, a shell wrapper (`sh -c`, and a heredoc
|
|
320
|
+
piped into a shell), a dynamic word, a second statement, `git push` without
|
|
321
|
+
`-C`, a `-C` that is not the task directory byte for byte (another directory, a
|
|
322
|
+
relative path, `/proc/self/cwd`, a symlink or `..` inside it),
|
|
323
|
+
`gh pr create` without `--repo <pinned URL>` or without `--head <candidate
|
|
324
|
+
branch>` (a `--head` swallowed as another option's value does not count),
|
|
325
|
+
`gh pr create --body-file/-F/--template` (which would post the contents of an
|
|
326
|
+
arbitrary local file), `--web`, another `--repo`, `gh pr merge`, `gh api`,
|
|
327
|
+
`gh release` and `npm publish`. Changing the repository's remotes (`git remote
|
|
328
|
+
set-url|add|rename|…`, behind git's own `--git-dir`/`--work-tree` options or
|
|
329
|
+
`remote`'s own `-v` too) is denied outright, and so is reconfiguring where a
|
|
330
|
+
push goes or what runs during it — `git config` writes to `remote.*`,
|
|
331
|
+
`url.*.insteadOf`, `push.*`, `credential.*`, `http.*`, `include.path`/
|
|
332
|
+
`includeIf.*`, `init.*`, `core.sshCommand` or `core.hooksPath`, `git config
|
|
333
|
+
--edit`, `git init --template=…`, `git init|clone --separate-git-dir=…`, and any statement that names `.git/config` or
|
|
334
|
+
`.git/hooks` unless it plainly only reads (`cat`, `grep`, `ls`, …) — so the
|
|
335
|
+
granted remote cannot be repointed underneath the confirmation, which pins both
|
|
336
|
+
the fetch and the push URL and scrubs `GIT_DIR`/`GIT_CONFIG_*` from its own
|
|
337
|
+
environment. Behind all of that sits one rule the text guards do not need:
|
|
338
|
+
the remote's resolved fetch and push URLs — **every** one of them
|
|
339
|
+
(`git remote get-url --all` / `--push --all`; git pushes to each `pushurl`,
|
|
340
|
+
not only the first it prints), rewrites applied — are recorded when the task
|
|
341
|
+
**starts**, before the Worker runs a command, and required unchanged both
|
|
342
|
+
when the grant is issued *and* at the moment the granted push is authorized.
|
|
343
|
+
So a `pushInsteadOf`, `pushurl` or extra destination added during the task by
|
|
344
|
+
*any* means (`~/.gitconfig`, a script, an include the policy never saw), even
|
|
345
|
+
as the first command of the publish turn, refuses the push and revokes the
|
|
346
|
+
grant, while an operator's pre-existing rewrite, already in the baseline, is
|
|
347
|
+
not. A recovered task keeps its recorded baseline and never takes a new one;
|
|
348
|
+
a remote that could not be resolved at the first start is recorded as such and
|
|
349
|
+
never granted. This is a policy over the command text: a script the Worker writes
|
|
350
|
+
and runs is outside what it can see, as [autonomy-target.md](docs/autonomy-target.md)
|
|
351
|
+
says of every text-level rule; absolute isolation is the host boundary's job.
|
|
352
|
+
|
|
353
|
+
The grant is **one-shot**: it is revoked the moment the publish turn completes,
|
|
354
|
+
not when the next decision arrives, and it is pinned to the remote's URL as well
|
|
355
|
+
as its name. A Worker that changes the tree during that turn voids it and is
|
|
356
|
+
re-verified in full — an edit left uncommitted counts as a change, exactly like a new
|
|
357
|
+
commit; because the grant named the commit, the notice can say whether the
|
|
358
|
+
verified commit landed before the tree moved on. Before verification a refused push says the grant is coming
|
|
359
|
+
rather than leaving the Worker to guess, and a task that ends without a
|
|
360
|
+
confirmed publish says so in its candidate notice instead of reporting a bare
|
|
361
|
+
"ready".
|
|
362
|
+
|
|
258
363
|
## Task specs
|
|
259
364
|
|
|
260
365
|
`--spec file.json` accepts:
|
|
@@ -272,7 +377,7 @@ Supervisor being able to see it, or when you don't need to attach.
|
|
|
272
377
|
"autonomy": {
|
|
273
378
|
"unattended": true,
|
|
274
379
|
"requireLocalCommit": true,
|
|
275
|
-
"maxDecisionRetries":
|
|
380
|
+
"maxDecisionRetries": 4,
|
|
276
381
|
"permissionAuthority": "hybrid",
|
|
277
382
|
"maxWorkerCostUsd": 20
|
|
278
383
|
}
|
|
@@ -286,7 +391,11 @@ check (120s timeout) and the env autonomy defaults below.
|
|
|
286
391
|
## Configuration reference
|
|
287
392
|
|
|
288
393
|
Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
289
|
-
`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.
|
|
290
399
|
|
|
291
400
|
| Variable | Default | Meaning |
|
|
292
401
|
| --- | --- | --- |
|
|
@@ -309,8 +418,10 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
|
309
418
|
| `HUMAN_WEBHOOK_SECRET` | unset | HMAC signing secret; sent as the `x-pi-supervisor-signature` header |
|
|
310
419
|
| `UNATTENDED` | `true` | Task runs without a synchronous human callback |
|
|
311
420
|
| `REQUIRE_LOCAL_COMMIT` | `true` | Require a local commit on the candidate's branch before completion |
|
|
312
|
-
| `MAX_DECISION_RETRIES` | `
|
|
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 |
|
|
313
422
|
| `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
|
|
423
|
+
| `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`; grants the publish phase after verification passes. `--remote` overrides it per task |
|
|
424
|
+
| `REMOTE_NAME` | `origin` | The single remote a publish grant may name |
|
|
314
425
|
| `WORKER_MAX_BUDGET_USD` | unset | Hard cap passed as `--max-budget-usd`; unavailable to interactive tmux |
|
|
315
426
|
| `WORKER_MODEL` | unset (Claude's own default) | `--model` for the Claude Worker |
|
|
316
427
|
| `WORKER_AUTOCOMPACT_TOKENS` | `200000` in automatic mode | Per-turn context bound; `0` keeps Claude's own default |
|
|
@@ -322,8 +433,8 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
|
322
433
|
| `DECISION_SESSION_RETENTION_DAYS` | `30` | Prunes closed Decision Worker session records older than this; `0` keeps forever |
|
|
323
434
|
| `EVIDENCE_MAX_BYTES` | `1048576` (1 MiB) | Maximum repository evidence bytes collected per task |
|
|
324
435
|
| `EVIDENCE_MAX_UNTRACKED_FILES` | `512` | Maximum untracked files collected as evidence per task |
|
|
325
|
-
| `REVIEW_TIMEOUT_MS` | `
|
|
326
|
-
| `DEADLINE_MS` | `4h` |
|
|
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 |
|
|
327
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 |
|
|
328
439
|
| `DEADLINE_WARNING_MS` | `15m` | How long before the deadline the Decision Worker is warned and re-asked; `0` disables the warning |
|
|
329
440
|
| `NO_OUTPUT_TIMEOUT_MS` | `20m` | Stop a Worker that has produced no output for this long; `0` disables the check |
|
|
@@ -348,7 +459,11 @@ A task that stopped at its wall-clock deadline is listed with `deadline=expired
|
|
|
348
459
|
<task-id>` grants that much budget from now (the recovered Supervisor persists
|
|
349
460
|
the new deadline), and `--extend 0` opens the close-out at once, so the fresh
|
|
350
461
|
Worker's first watchdog tick verifies and reviews the repository as it stands
|
|
351
|
-
and any repair round tells it how long it has.
|
|
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
|
|
352
467
|
dropped with `/supervise discard <task-id>` (its session file is kept until
|
|
353
468
|
retention pruning).
|
|
354
469
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|
403
|
-
|
|
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`
|
|
@@ -440,6 +444,51 @@ derives from this task's cwd, which rejects a subagent transcript and any path
|
|
|
440
444
|
naming another project. A root is honored before it exists (Claude creates the
|
|
441
445
|
memory directory on first write) and through a symlinked ancestor.
|
|
442
446
|
|
|
447
|
+
When a task is granted remote authority (`autonomy.remoteAuthority`, default
|
|
448
|
+
`none`), verification does not end it. `#requestPublish` first checks that
|
|
449
|
+
HEAD *is* the verified tree — the evidence the Reviewer judged shows a clean
|
|
450
|
+
working tree and carries the same `head` — then issues a `RemoteGrant` naming
|
|
451
|
+
that commit, the candidate's own branch, the remote, the task directory and the
|
|
452
|
+
remote's repository (`host/owner/repo` from its fetch URL, an SSH alias
|
|
453
|
+
translated through `ssh -G`), and asks the Worker to publish: the Worker runs
|
|
454
|
+
the push and any `gh pr create`, the Supervisor never does. A dirty tree costs a
|
|
455
|
+
repair round first, the remote's resolved URL lists (`get-url --all`, both sides) must equal the baseline recorded at task start (`TaskContext.remoteBaseline`, persisted with the decision session, restored on recovery, never re-taken) — checked again by `#grantedRemoteChanged` at the moment a granted command is authorized, in both the PreToolUse and prompt-phase paths — the Git directory must be the task's own `.git` or a linked worktree's, and the reviewed evidence must carry a
|
|
456
|
+
HEAD (fail-closed); the grant is armed before the instruction is sent and
|
|
457
|
+
revoked only if the send failed before delivery (the turn counter tells). The instruction is built
|
|
458
|
+
by `publishCommand`/`pullRequestCommand` in `policy.ts`, beside the parser that
|
|
459
|
+
admits it, and a test round-trips one through the other. Under every permission
|
|
460
|
+
authority the granted command is answered by the policy (`PolicyResult.granted`)
|
|
461
|
+
rather than escalated to the Decision Worker. The returning turn skips
|
|
462
|
+
acceptance and the Reviewer when HEAD is unchanged — they already passed on that
|
|
463
|
+
tree — and `#settlePublish` confirms the result read-only (`#confirmPublish`:
|
|
464
|
+
both pinned remote URLs unchanged, `git ls-remote` carrying the verified commit,
|
|
465
|
+
plus `gh pr list` for `pr`) before completing, or blocks the candidate when it
|
|
466
|
+
cannot; that candidate keeps `deliverable: true`, since it passed and is intact
|
|
467
|
+
on its branch, and an unreachable remote is reported as *unconfirmed*
|
|
468
|
+
(`RemoteBranchLookup` tells `absent` from `unreachable`), never as a missing
|
|
469
|
+
commit or a repointed remote. A tree that changed during the publish turn — an uncommitted edit included — voids
|
|
470
|
+
the grant and is re-verified in full, with the same confirmation deciding whether the notice
|
|
471
|
+
says the verified commit landed first. The grant is cleared on every terminal
|
|
472
|
+
path, so it never outlives the turn it was issued for, and
|
|
473
|
+
`permittedRemoteCommand` admits a single literal shape —
|
|
474
|
+
`git -C '<task dir>' -c core.hooksPath=/dev/null -c push.followTags=false push <remote> <commit>:refs/heads/<branch>`
|
|
475
|
+
with no other option, and `gh pr create --repo <pinned URL> --head <branch> …`
|
|
476
|
+
— so no force, delete, mirror, tags, push-options, other `-c`, branch or `HEAD`
|
|
477
|
+
source, other remote, branch or repository, relative `-C`, shell wrapper,
|
|
478
|
+
dynamic word or second statement; the pinned hooks path keeps any installed
|
|
479
|
+
`pre-push` out of the granted command and the pinned `push.followTags=false` keeps any tag out of it. `git config` writes to
|
|
480
|
+
transport-affecting keys (including `include.*` and `init.*`), `git config
|
|
481
|
+
--edit`, `git init --template`, and any statement naming `.git/config` or
|
|
482
|
+
`.git/hooks` in any spelling (`namesGitMetadata` normalizes the path and matches a glob segment by segment, so only a
|
|
483
|
+
segment that could expand to `.git` counts — a project's own `src/hooks/` is ordinary work) that does not plainly only read are refused alongside `git remote`
|
|
484
|
+
mutations; git's own `--git-dir`/`--work-tree` options and `remote`'s own `-v`
|
|
485
|
+
cannot hide either, nor can `-C /proc/self/cwd` or `-C <cwd>/link/..` (the directory must be the granted one byte for byte; every realpath comparison elsewhere uses the native implementation, since Node's JavaScript `realpathSync` collapses `link/..` lexically) or `--separate-git-dir`. The publish
|
|
486
|
+
hint keys on `PolicyResult.boundary`, not on the reason text, and promises a publish turn
|
|
487
|
+
only where `#requestPublish` will start one. For an adopted tmux session the memory write root is
|
|
488
|
+
located under the *adopted process's* configuration directory, read from
|
|
489
|
+
`/proc/<pid>/environ` at adoption, so a Claude started with another
|
|
490
|
+
`CLAUDE_CONFIG_DIR` keeps its memory.
|
|
491
|
+
|
|
443
492
|
A record left behind by the outright
|
|
444
493
|
stop (`recoverable_failure`, so `active/interrupted`) is not a dead end either:
|
|
445
494
|
`recover --extend <duration>` re-persists a deadline measured from now
|
|
@@ -542,19 +591,36 @@ When automatic supervision is enabled, a successful check set is passed to a
|
|
|
542
591
|
fresh read-only Reviewer session. The Reviewer receives the task specification, repository status/diff evidence,
|
|
543
592
|
check results and bounded Worker completion evidence, but not the Decision Worker
|
|
544
593
|
conversation or control channel. It can inspect only `read`, `grep`, `find` and `ls`, and must return
|
|
545
|
-
`pass`, `revise` or `human` with bounded structured findings.
|
|
594
|
+
`pass`, `revise` or `human` with bounded structured findings. Its whole reply
|
|
595
|
+
must be that one JSON object (an optional ```json fence aside), carrying a
|
|
596
|
+
random `reviewId` that appears only in its own prompt, with no key repeated and
|
|
597
|
+
nothing beyond the schema (a string `summary`, and `findings` as flat objects of
|
|
598
|
+
the finding fields with scalar values). Each finding opens with `severity`
|
|
599
|
+
then a non-empty `message` (an `id` may lead) and closes with `evidence` if it
|
|
600
|
+
has one — the only field the prompt allows repository quotes in. Repository
|
|
601
|
+
text it quotes or copies therefore cannot stand in for the answer, change its
|
|
602
|
+
verdict, or drop a finding it wrote, and a quote in `evidence` that closes a
|
|
603
|
+
finding early can only add findings after it — never change the severity,
|
|
604
|
+
message, fix or location the Reviewer already wrote. A quote the Reviewer puts
|
|
605
|
+
in any other field against the prompt can still reach the rest of that
|
|
606
|
+
finding. Added findings cannot unblock a candidate (any P0/P1 blocks, even
|
|
607
|
+
under `pass`), and repair instructions list findings most severe first so
|
|
608
|
+
added lesser ones cannot crowd out a blocking one. A reply that breaks any of
|
|
609
|
+
these earns one corrective re-prompt. Invalid Reviewer
|
|
546
610
|
output, incomplete evidence or a Reviewer API failure must prevent a candidate
|
|
547
611
|
from crossing the remote/main boundary; the local system may retry, repair or
|
|
548
|
-
park it without requiring a human to be online. The Reviewer retries
|
|
549
|
-
|
|
612
|
+
park it without requiring a human to be online. The Reviewer retries provider
|
|
613
|
+
errors with a fresh session within a total review budget
|
|
550
614
|
(`PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS`, default 10 minutes). Truncated
|
|
551
615
|
(oversize) evidence requests a bounded repair before parking, while incomplete
|
|
552
616
|
evidence still parks.
|
|
553
617
|
|
|
554
618
|
A `revise` result produces an audited repair round and sends a bounded corrective
|
|
555
619
|
instruction to a still-live `repairableSession` Worker. Checks and review then run again.
|
|
556
|
-
The repair budget defaults to three rounds
|
|
557
|
-
|
|
620
|
+
The repair budget defaults to three rounds. P0/P1 findings block a `pass` but are repair
|
|
621
|
+
inputs like any other concrete finding (a `pass` carrying one is treated as `revise`); a
|
|
622
|
+
`human` verdict, repeated findings or an exhausted budget stop automation and park a
|
|
623
|
+
non-publishable candidate. A Worker that has already exited cannot be silently recreated
|
|
558
624
|
for repair; it remains failed/recoverable rather than replaying the original task. If a repair
|
|
559
625
|
or candidate branch cannot continue, a single idempotent terminalizer records
|
|
560
626
|
`verification_failed`, closes the Decision Worker and reports cleanup evidence; it never performs
|
package/docs/autonomy-target.md
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
|
@@ -146,7 +146,7 @@ Pi Decision Worker. Setting `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux` selects the
|
|
|
146
146
|
Supervisor-owned live bridge, which carries the same structured records through
|
|
147
147
|
private framing on the PTY rather than an independent JSONL sidecar. Task autonomy
|
|
148
148
|
defaults to unattended local work, a required
|
|
149
|
-
local commit on the task branch (any branch, anchored to the baseline commit) and
|
|
149
|
+
local commit on the task branch (any branch, anchored to the baseline commit) and four bounded Decision Worker retries. Configure
|
|
150
150
|
`PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT=0` or task `autonomy.requireLocalCommit`
|
|
151
151
|
only to disable the local-commit deliverability check; automatic mode still requires a Git
|
|
152
152
|
baseline (any branch, including `main`; the candidate must descend from it). The tmux bridge
|
|
@@ -240,7 +240,7 @@ Deterministic tests must cover:
|
|
|
240
240
|
- multiple required/optional checks with bounded output, timeout and exit-code evidence;
|
|
241
241
|
- independent read-only Reviewer pass/revise/human results;
|
|
242
242
|
- invalid Reviewer JSON and Reviewer API failure becoming a parked/non-publishable candidate without requiring a live callback;
|
|
243
|
-
- repair rounds, repeated finding detection
|
|
243
|
+
- repair rounds (P0/P1 findings are repaired, never passed), repeated finding detection and repair-budget exhaustion;
|
|
244
244
|
- non-persistent JSONL verification failure without duplicate terminal transitions;
|
|
245
245
|
- repairable-but-not-persistent JSONL multi-turn repair;
|
|
246
246
|
- stop and Pi shutdown from `verifying`, including Decision Worker closure and cwd lease release;
|
package/package.json
CHANGED
package/src/acceptance.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { DEFAULT_MAX_DECISION_RETRIES } from "./config.ts";
|
|
2
|
+
import { isPlainRemoteName } from "./policy.ts";
|
|
1
3
|
import type { AcceptanceCheck, TaskSpec } from "./types.ts";
|
|
2
4
|
|
|
3
5
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
4
6
|
const DEFAULT_MAX_REPAIR_ROUNDS = 3;
|
|
5
7
|
|
|
6
8
|
/** Normalize legacy plain-text tasks into the structured acceptance model. */
|
|
7
|
-
export function normalizeTaskSpec(value: unknown, fallbackGoal: string): TaskSpec {
|
|
9
|
+
export function normalizeTaskSpec(value: unknown, fallbackGoal: string, autonomyDefaults?: Partial<TaskSpec["autonomy"]>): TaskSpec {
|
|
8
10
|
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
|
|
9
11
|
throw new Error("task spec must be a JSON object");
|
|
10
12
|
}
|
|
@@ -18,7 +20,7 @@ export function normalizeTaskSpec(value: unknown, fallbackGoal: string): TaskSpe
|
|
|
18
20
|
forbidden: stringList(source.forbidden, "forbidden"),
|
|
19
21
|
acceptance: normalizeChecks(source.acceptance),
|
|
20
22
|
maxRepairRounds: normalizeRepairRounds(source.maxRepairRounds),
|
|
21
|
-
autonomy: normalizeAutonomy(source.autonomy),
|
|
23
|
+
autonomy: normalizeAutonomy(source.autonomy, autonomyDefaults),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -84,23 +86,32 @@ function normalizeRepairRounds(value: unknown): number {
|
|
|
84
86
|
return value;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
function normalizeAutonomy(value: unknown): TaskSpec["autonomy"] {
|
|
88
|
-
|
|
89
|
+
function normalizeAutonomy(value: unknown, defaults?: Partial<TaskSpec["autonomy"]>): TaskSpec["autonomy"] {
|
|
90
|
+
// A spec file that omits a key — or the whole block — must not silently
|
|
91
|
+
// override the operator's environment defaults with hardcoded ones;
|
|
92
|
+
// `defaults` carries them in and every key falls back to it.
|
|
93
|
+
if (value === undefined) return normalizeAutonomy({}, defaults);
|
|
89
94
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("task spec autonomy must be an object");
|
|
90
95
|
const source = value as Record<string, unknown>;
|
|
91
96
|
if (source.unattended !== undefined && typeof source.unattended !== "boolean") throw new Error("task spec autonomy.unattended must be boolean");
|
|
92
97
|
if (source.requireLocalCommit !== undefined && typeof source.requireLocalCommit !== "boolean") throw new Error("task spec autonomy.requireLocalCommit must be boolean");
|
|
93
|
-
const retries = source.maxDecisionRetries ??
|
|
98
|
+
const retries = source.maxDecisionRetries ?? defaults?.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
|
|
94
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");
|
|
95
|
-
const authority = source.permissionAuthority ?? "hybrid";
|
|
100
|
+
const authority = source.permissionAuthority ?? defaults?.permissionAuthority ?? "hybrid";
|
|
96
101
|
if (authority !== "policy" && authority !== "hybrid" && authority !== "decision-worker") throw new Error("task spec autonomy.permissionAuthority must be policy, hybrid or decision-worker");
|
|
97
|
-
const
|
|
102
|
+
const remoteAuthority = source.remoteAuthority ?? defaults?.remoteAuthority ?? "none";
|
|
103
|
+
if (remoteAuthority !== "none" && remoteAuthority !== "push" && remoteAuthority !== "pr") throw new Error("task spec autonomy.remoteAuthority must be none, push or pr");
|
|
104
|
+
const remoteName = source.remoteName ?? defaults?.remoteName ?? "origin";
|
|
105
|
+
if (!isPlainRemoteName(remoteName)) throw new Error("task spec autonomy.remoteName must be a plain remote name");
|
|
106
|
+
const maxWorkerCostUsd = source.maxWorkerCostUsd ?? defaults?.maxWorkerCostUsd;
|
|
98
107
|
if (maxWorkerCostUsd !== undefined && (typeof maxWorkerCostUsd !== "number" || !Number.isFinite(maxWorkerCostUsd) || maxWorkerCostUsd <= 0)) throw new Error("task spec autonomy.maxWorkerCostUsd must be a positive number");
|
|
99
108
|
return {
|
|
100
|
-
unattended: source.unattended !== false,
|
|
101
|
-
requireLocalCommit: source.requireLocalCommit !== false,
|
|
109
|
+
unattended: (source.unattended ?? defaults?.unattended) !== false,
|
|
110
|
+
requireLocalCommit: (source.requireLocalCommit ?? defaults?.requireLocalCommit) !== false,
|
|
102
111
|
maxDecisionRetries: retries,
|
|
103
112
|
permissionAuthority: authority,
|
|
113
|
+
remoteAuthority,
|
|
114
|
+
remoteName,
|
|
104
115
|
...(maxWorkerCostUsd !== undefined ? { maxWorkerCostUsd } : {}),
|
|
105
116
|
};
|
|
106
117
|
}
|