pi-claude-supervisor 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.cn.md +62 -0
- package/README.md +104 -0
- package/docs/architecture.md +45 -0
- package/package.json +1 -1
- package/src/acceptance.ts +19 -9
- package/src/config.ts +27 -1
- package/src/decision-session-store.ts +15 -0
- package/src/decision-worker.ts +16 -3
- package/src/hooks/relay.ts +1 -4
- package/src/index.ts +14 -5
- package/src/notifications.ts +5 -2
- package/src/policy.ts +509 -62
- package/src/supervisor.ts +506 -25
- package/src/types.ts +20 -0
- package/src/verifier.ts +203 -5
- package/src/worker/environment.ts +12 -2
- package/src/worker/tmux-adapter.ts +63 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.9.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.1...v0.9.0) (2026-09-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **supervisor:** publish a verified candidate under a narrow, one-shot remote grant ([5e117cc](https://github.com/btnalit/pi-claude-supervisor/commit/5e117cc5695492d03684270ea25cac224f1fe41e))
|
|
11
|
+
|
|
5
12
|
## [0.8.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.0...v0.8.1) (2026-09-20)
|
|
6
13
|
|
|
7
14
|
|
package/README.cn.md
CHANGED
|
@@ -208,6 +208,66 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
208
208
|
不必等待完整的命令或模型超时。
|
|
209
209
|
- 每个任务只持有一个 cwd 租约;并发任务需要各自独立的 worktree。
|
|
210
210
|
|
|
211
|
+
## 发布已验证的候选
|
|
212
|
+
|
|
213
|
+
默认情况下任务止于**已验证的本地候选**:验收与独立 Reviewer 通过,而 Worker 全程
|
|
214
|
+
没有任何远程权限。设 `REMOTE_AUTHORITY=push`(或 `--remote push`)会在该判定之后
|
|
215
|
+
加一个**发布阶段**:Supervisor 记下已验证的 commit,发放一次性的窄授权,并让
|
|
216
|
+
Worker 推自己的分支;`pr` 还允许它开 PR。**push 由 Worker 自己执行**——Supervisor
|
|
217
|
+
从不代劳——之后 Supervisor 以只读方式核实(`git ls-remote`,`pr` 还查 `gh pr list`)
|
|
218
|
+
才把任务标记完成,候选通知里带上 PR 链接。核实不到则把候选标为 blocked,本地候选
|
|
219
|
+
依然可交付。
|
|
220
|
+
|
|
221
|
+
这个授权刻意严苛,而且两条命令都按字面匹配——没被审过的选项一律拒绝,而不是默认
|
|
222
|
+
无害。它只认 `git -C '<任务目录>' -c core.hooksPath=/dev/null -c push.followTags=false push <remote> <已验证 commit>:refs/heads/<branch>`,
|
|
223
|
+
不带其他任何选项。refspec 写的是**已验证的 commit** 而不是分支:git 只会推送这一个对象,
|
|
224
|
+
Worker 在发布轮里再提交的内容会留在本地("Everything up-to-date"),搭不上这次授权。
|
|
225
|
+
**`-C` 是必需的、必须是绝对路径、且必须与任务目录逐字节相同**——不做规范化、不做 realpath——因为 Claude 的 Bash 工具
|
|
226
|
+
会在多次调用之间保留工作目录,而 `cd` 属于普通本地操作,没有 `-C` 的话授权可能被花在
|
|
227
|
+
任何别的克隆上;任何更宽松的比较都出现过 Supervisor 与 git 解析不一致的写法(相对路径 `.` 按 Supervisor 的目录解析、`/proc/self/cwd`,以及 `<cwd>/link/..`——Node 自己的 realpath 会按字面折叠而内核会跟随 symlink)。指令写的就是精确目录,不需要接受任何别的写法。
|
|
228
|
+
这条命令上**钉死了 hooks 路径**,所以 Worker 通过任何途径(`git init --template=`、
|
|
229
|
+
解压归档、`chmod`)装进去的 `pre-push` hook 都不会在授权的 push 里以 Worker 的凭据执行;也钉死了
|
|
230
|
+
`push.followTags=false`,所以通过策略看不见的任何文件设的 `followTags=true` 都不能让这一次 push
|
|
231
|
+
顺带推上授权没点名的 tag(tag 正是发布自动化的触发点)。两者都是 ref/hook 选择而非传输层,不会
|
|
232
|
+
覆盖任何合理的仓库级设置。
|
|
233
|
+
`pr` 下另加 `gh pr create --repo <钉住的 remote URL> --head <候选分支> …`(只允许
|
|
234
|
+
title / body / base / draft / assignee / label):PR 只会开在授权 remote 对应的仓库里——
|
|
235
|
+
不带 `--repo` 的话,gh 会从 remotes 里自己挑一个 base 仓库(fork 上是 `upstream`),
|
|
236
|
+
那不是授权点名的仓库,核实也不会去查它。仓库取 remote URL 背后的 `host/owner/repo`
|
|
237
|
+
(SSH config 里的 host 别名会像 gh 那样经 `ssh -G` 翻译);URL 不是仓库的 remote(本地路径、
|
|
238
|
+
翻译不了的别名)不会拿到 `pr` 授权。授权提供的每个词都做了 shell 引用,分支叫 `feat/$ticket`
|
|
239
|
+
也能原样通过策略。
|
|
240
|
+
|
|
241
|
+
授权只会发给"就是已验证工作树"的那个 commit:工作树必须干净(含未跟踪文件——新文件也可能
|
|
242
|
+
是被验证行为的一部分),且 HEAD 自 Reviewer 评审的证据被读取以来没有移动过。仓库的 Git 目录必须是自己的 `.git` 或 linked worktree 的 `.git/worktrees/<name>`(不能是 `--separate-git-dir` 指针)。工作树不干净
|
|
243
|
+
会先花一轮修复让 Worker 把属于候选的内容提交掉;只有修复轮用尽或 HEAD 移动过,任务才以本地
|
|
244
|
+
候选结束并在通知里写明 `not published:` 原因,而不是发授权。核实时 remote 连不上,发布只是
|
|
245
|
+
"未确认"(候选仍可交付),绝不会被说成"没推上去"。
|
|
246
|
+
|
|
247
|
+
有没有授权都拒绝:任何 push 选项(`-u`、`--force`、`--force-with-lease`、`--delete`、
|
|
248
|
+
`--mirror`、`--all`、`--tags`、`--no-verify`、`--push-option`、`--receive-pack` 等)、
|
|
249
|
+
除这两个钉死项(且顺序固定)以外的任何 `-c`、以分支或 `HEAD` 作为 refspec 来源、裸 `git push`、
|
|
250
|
+
别的 remote、分支或 commit、保护分支、被 shell 包装(含 heredoc 管进 shell)、带动态参数、
|
|
251
|
+
第二条语句、不带 `-C` 的 `git push`、`-C` 与任务目录不逐字节相同(别的目录、相对路径、`/proc/self/cwd`、其中的 symlink 或 `..`)、不带
|
|
252
|
+
`--repo <钉住的 URL>` 或不带 `--head <候选分支>` 的 `gh pr create`(被别的选项当作值吞掉的
|
|
253
|
+
`--head` 不算)、`gh pr create --body-file/-F/--template`(会把任意本地文件内容发到 PR 上)、
|
|
254
|
+
`--web`、别的 `--repo`、`gh pr merge`、`gh api`、`gh release`、`npm publish`。改动仓库
|
|
255
|
+
remote(`git remote set-url|add|rename|…`,藏在 git 自己的 `--git-dir`/`--work-tree` 选项
|
|
256
|
+
或 `remote` 自己的 `-v` 后面也一样)一律拒绝;改动 push 去向或 push 期间会执行什么也一律
|
|
257
|
+
拒绝——`git config` 写 `remote.*`、`url.*.insteadOf`、`push.*`、`credential.*`、`http.*`、
|
|
258
|
+
`include.path`/`includeIf.*`、`init.*`、`core.sshCommand`、`core.hooksPath`,`git config --edit`、
|
|
259
|
+
`git init --template=…`、`git init|clone --separate-git-dir=…`,以及任何提到 `.git/config` 或 `.git/hooks` 的语句(除非它显然只读:
|
|
260
|
+
`cat`、`grep`、`ls` 等)——否则授权认的 remote 会被偷换,连 Supervisor 的核实也会被骗过;
|
|
261
|
+
核实同时钉住 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 会被记录下来,永不授权。这是一层
|
|
262
|
+
作用于命令文本的策略:Worker 自己写一个脚本再运行,策略看不见——如
|
|
263
|
+
[autonomy-target.md](docs/autonomy-target.md) 对所有文本级规则所说,绝对隔离是 host 边界的事。
|
|
264
|
+
|
|
265
|
+
授权是**一次性**的:发布轮一结束就收回(不等下一个决策),并且同时绑定 remote 的 URL
|
|
266
|
+
而不只是名字。Worker 若在该轮里改动了工作树(未提交的修改同样算改动),授权立即作废并重新完整验收;因为授权写的是
|
|
267
|
+
commit,通知能说清已验证的 commit 是否在工作树变动之前就已经推上去了。验证之前
|
|
268
|
+
被拒的 push 会说明授权稍后会来,而不是让 Worker 去猜;没能确认发布的任务会在候选
|
|
269
|
+
通知里写明原因,而不是只报一句 "ready"。
|
|
270
|
+
|
|
211
271
|
## 任务 spec
|
|
212
272
|
|
|
213
273
|
`--spec file.json` 接受如下格式:
|
|
@@ -263,6 +323,8 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
263
323
|
| `REQUIRE_LOCAL_COMMIT` | `true` | 完成前要求在候选所在分支上有本地 commit |
|
|
264
324
|
| `MAX_DECISION_RETRIES` | `2`(0–10) | Decision Worker 调用超时或失败(429/529、网络、鉴权)时的重试次数 |
|
|
265
325
|
| `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
|
|
326
|
+
| `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`;验收通过后开启发布阶段。`--remote` 可按任务覆盖 |
|
|
327
|
+
| `REMOTE_NAME` | `origin` | 发布授权唯一允许的 remote 名 |
|
|
266
328
|
| `WORKER_MAX_BUDGET_USD` | 未设置 | 作为 `--max-budget-usd` 传入的硬上限;交互式 tmux 下不可用 |
|
|
267
329
|
| `WORKER_MODEL` | 未设置(Claude 自身默认值) | Claude Worker 的 `--model` |
|
|
268
330
|
| `WORKER_AUTOCOMPACT_TOKENS` | 自动模式默认 `200000` | 每轮上下文上限;`0` 保留 Claude 自身默认值 |
|
package/README.md
CHANGED
|
@@ -255,6 +255,108 @@ Supervisor being able to see it, or when you don't need to attach.
|
|
|
255
255
|
- Only one cwd lease is held per task; concurrent tasks need separate
|
|
256
256
|
worktrees.
|
|
257
257
|
|
|
258
|
+
## Publishing a verified candidate
|
|
259
|
+
|
|
260
|
+
By default a task ends at a **verified local candidate**: acceptance and the
|
|
261
|
+
independent Reviewer pass, and the Worker never had remote authority at any
|
|
262
|
+
point. `REMOTE_AUTHORITY=push` (or `--remote push`) adds a **publish phase**
|
|
263
|
+
after that verdict: the Supervisor records the verified commit, grants a narrow
|
|
264
|
+
one-shot authority, and asks the Worker to push its own branch. `pr` also lets
|
|
265
|
+
it open a pull request. The Worker performs the push — the Supervisor never
|
|
266
|
+
does — and the Supervisor then confirms it read-only (`git ls-remote`, and
|
|
267
|
+
`gh pr list` for `pr`) before completing the task; the candidate notice carries
|
|
268
|
+
the pull request URL. A publish that cannot be confirmed blocks the candidate,
|
|
269
|
+
which stays deliverable locally.
|
|
270
|
+
|
|
271
|
+
The grant is deliberately unforgiving, and both commands are matched literally —
|
|
272
|
+
an option nobody reviewed is refused rather than assumed harmless. It admits
|
|
273
|
+
exactly `git -C '<task directory>' -c core.hooksPath=/dev/null -c push.followTags=false push <remote> <verified commit>:refs/heads/<branch>`,
|
|
274
|
+
with no other option. The refspec names the **verified commit**, not the branch:
|
|
275
|
+
git pushes exactly that object, so a commit the Worker makes during the publish
|
|
276
|
+
turn stays local ("Everything up-to-date") instead of riding the grant. `-C` is
|
|
277
|
+
**required, absolute and byte for byte the task directory** — no normalization,
|
|
278
|
+
no realpath — because Claude's Bash tool keeps its working directory between
|
|
279
|
+
calls and `cd` is ordinary local work, so without it the grant could be spent
|
|
280
|
+
in any other clone, and every looser comparison had a spelling the Supervisor
|
|
281
|
+
resolved one way and git another (`.` against the Supervisor's cwd,
|
|
282
|
+
`/proc/self/cwd`, and `<cwd>/link/..`, which Node's own realpath collapses
|
|
283
|
+
lexically while the kernel follows the link). The instruction spells the exact
|
|
284
|
+
directory, so no other spelling is needed. The hooks path is **pinned** on that one command so no
|
|
285
|
+
`pre-push` hook a Worker could have installed (by any door: `git init
|
|
286
|
+
--template=`, an archive, a `chmod`) runs inside the granted push with the
|
|
287
|
+
Worker's credentials, and `push.followTags=false` is pinned so a
|
|
288
|
+
`followTags=true` set through any file the policy never sees cannot make the
|
|
289
|
+
one push also plant a tag the grant never named (a tag is what release
|
|
290
|
+
automation keys on). Both are ref and hook selection, not transport, so they
|
|
291
|
+
override no legitimate per-repository setting. For `pr` a `gh pr create --repo <pinned remote URL>
|
|
292
|
+
--head <candidate branch> …` limited to title, body, base, draft, assignee and
|
|
293
|
+
label: the pull request opens in the granted remote's repository, full stop —
|
|
294
|
+
without `--repo`, gh picks a base repository from the remotes (`upstream` on a
|
|
295
|
+
fork) that the grant never named and the confirmation never reads. The
|
|
296
|
+
repository is the `host/owner/repo` behind the remote's URL (an SSH-config host
|
|
297
|
+
alias is translated through `ssh -G`, as gh does); a remote whose URL is not
|
|
298
|
+
one — a local path, an alias with no translation — gets no `pr` grant. Every
|
|
299
|
+
word the grant supplies is shell-quoted, so a branch named `feat/$ticket` still
|
|
300
|
+
round-trips through the policy.
|
|
301
|
+
|
|
302
|
+
The grant is only issued for a commit that *is* the verified tree: the working
|
|
303
|
+
tree must be clean (untracked files included — a new file may be part of the
|
|
304
|
+
verified behavior), and HEAD must not have moved since the evidence the Reviewer
|
|
305
|
+
judged was read. The repository's Git directory must be its own `.git` or a linked worktree's
|
|
306
|
+
`.git/worktrees/<name>` (not a `--separate-git-dir` pointer). A dirty tree first costs a repair round asking the Worker to
|
|
307
|
+
commit what belongs to the candidate; only when none is left, or when HEAD
|
|
308
|
+
moved, does the task end at the local candidate with a `not published:` reason
|
|
309
|
+
instead of a grant. A remote that cannot be reached at confirmation time leaves
|
|
310
|
+
the publish *unconfirmed* (the candidate stays deliverable), never "refuted".
|
|
311
|
+
|
|
312
|
+
Refused with or without a grant: every push option (`-u`, `--force`,
|
|
313
|
+
`--force-with-lease`, `--delete`, `--mirror`, `--all`, `--tags`, `--no-verify`,
|
|
314
|
+
`--push-option`, `--receive-pack`, …), any `-c` but the two pins (in that order), a
|
|
315
|
+
branch or `HEAD` as the refspec source, a bare `git push`, another remote,
|
|
316
|
+
branch or commit, a protected branch, a shell wrapper (`sh -c`, and a heredoc
|
|
317
|
+
piped into a shell), a dynamic word, a second statement, `git push` without
|
|
318
|
+
`-C`, a `-C` that is not the task directory byte for byte (another directory, a
|
|
319
|
+
relative path, `/proc/self/cwd`, a symlink or `..` inside it),
|
|
320
|
+
`gh pr create` without `--repo <pinned URL>` or without `--head <candidate
|
|
321
|
+
branch>` (a `--head` swallowed as another option's value does not count),
|
|
322
|
+
`gh pr create --body-file/-F/--template` (which would post the contents of an
|
|
323
|
+
arbitrary local file), `--web`, another `--repo`, `gh pr merge`, `gh api`,
|
|
324
|
+
`gh release` and `npm publish`. Changing the repository's remotes (`git remote
|
|
325
|
+
set-url|add|rename|…`, behind git's own `--git-dir`/`--work-tree` options or
|
|
326
|
+
`remote`'s own `-v` too) is denied outright, and so is reconfiguring where a
|
|
327
|
+
push goes or what runs during it — `git config` writes to `remote.*`,
|
|
328
|
+
`url.*.insteadOf`, `push.*`, `credential.*`, `http.*`, `include.path`/
|
|
329
|
+
`includeIf.*`, `init.*`, `core.sshCommand` or `core.hooksPath`, `git config
|
|
330
|
+
--edit`, `git init --template=…`, `git init|clone --separate-git-dir=…`, and any statement that names `.git/config` or
|
|
331
|
+
`.git/hooks` unless it plainly only reads (`cat`, `grep`, `ls`, …) — so the
|
|
332
|
+
granted remote cannot be repointed underneath the confirmation, which pins both
|
|
333
|
+
the fetch and the push URL and scrubs `GIT_DIR`/`GIT_CONFIG_*` from its own
|
|
334
|
+
environment. Behind all of that sits one rule the text guards do not need:
|
|
335
|
+
the remote's resolved fetch and push URLs — **every** one of them
|
|
336
|
+
(`git remote get-url --all` / `--push --all`; git pushes to each `pushurl`,
|
|
337
|
+
not only the first it prints), rewrites applied — are recorded when the task
|
|
338
|
+
**starts**, before the Worker runs a command, and required unchanged both
|
|
339
|
+
when the grant is issued *and* at the moment the granted push is authorized.
|
|
340
|
+
So a `pushInsteadOf`, `pushurl` or extra destination added during the task by
|
|
341
|
+
*any* means (`~/.gitconfig`, a script, an include the policy never saw), even
|
|
342
|
+
as the first command of the publish turn, refuses the push and revokes the
|
|
343
|
+
grant, while an operator's pre-existing rewrite, already in the baseline, is
|
|
344
|
+
not. A recovered task keeps its recorded baseline and never takes a new one;
|
|
345
|
+
a remote that could not be resolved at the first start is recorded as such and
|
|
346
|
+
never granted. This is a policy over the command text: a script the Worker writes
|
|
347
|
+
and runs is outside what it can see, as [autonomy-target.md](docs/autonomy-target.md)
|
|
348
|
+
says of every text-level rule; absolute isolation is the host boundary's job.
|
|
349
|
+
|
|
350
|
+
The grant is **one-shot**: it is revoked the moment the publish turn completes,
|
|
351
|
+
not when the next decision arrives, and it is pinned to the remote's URL as well
|
|
352
|
+
as its name. A Worker that changes the tree during that turn voids it and is
|
|
353
|
+
re-verified in full — an edit left uncommitted counts as a change, exactly like a new
|
|
354
|
+
commit; because the grant named the commit, the notice can say whether the
|
|
355
|
+
verified commit landed before the tree moved on. Before verification a refused push says the grant is coming
|
|
356
|
+
rather than leaving the Worker to guess, and a task that ends without a
|
|
357
|
+
confirmed publish says so in its candidate notice instead of reporting a bare
|
|
358
|
+
"ready".
|
|
359
|
+
|
|
258
360
|
## Task specs
|
|
259
361
|
|
|
260
362
|
`--spec file.json` accepts:
|
|
@@ -311,6 +413,8 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
|
311
413
|
| `REQUIRE_LOCAL_COMMIT` | `true` | Require a local commit on the candidate's branch before completion |
|
|
312
414
|
| `MAX_DECISION_RETRIES` | `2` (0–10) | Retries of a Decision Worker call that times out or fails (429/529, network, auth) |
|
|
313
415
|
| `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
|
|
416
|
+
| `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`; grants the publish phase after verification passes. `--remote` overrides it per task |
|
|
417
|
+
| `REMOTE_NAME` | `origin` | The single remote a publish grant may name |
|
|
314
418
|
| `WORKER_MAX_BUDGET_USD` | unset | Hard cap passed as `--max-budget-usd`; unavailable to interactive tmux |
|
|
315
419
|
| `WORKER_MODEL` | unset (Claude's own default) | `--model` for the Claude Worker |
|
|
316
420
|
| `WORKER_AUTOCOMPACT_TOKENS` | `200000` in automatic mode | Per-turn context bound; `0` keeps Claude's own default |
|
package/docs/architecture.md
CHANGED
|
@@ -440,6 +440,51 @@ derives from this task's cwd, which rejects a subagent transcript and any path
|
|
|
440
440
|
naming another project. A root is honored before it exists (Claude creates the
|
|
441
441
|
memory directory on first write) and through a symlinked ancestor.
|
|
442
442
|
|
|
443
|
+
When a task is granted remote authority (`autonomy.remoteAuthority`, default
|
|
444
|
+
`none`), verification does not end it. `#requestPublish` first checks that
|
|
445
|
+
HEAD *is* the verified tree — the evidence the Reviewer judged shows a clean
|
|
446
|
+
working tree and carries the same `head` — then issues a `RemoteGrant` naming
|
|
447
|
+
that commit, the candidate's own branch, the remote, the task directory and the
|
|
448
|
+
remote's repository (`host/owner/repo` from its fetch URL, an SSH alias
|
|
449
|
+
translated through `ssh -G`), and asks the Worker to publish: the Worker runs
|
|
450
|
+
the push and any `gh pr create`, the Supervisor never does. A dirty tree costs a
|
|
451
|
+
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
|
|
452
|
+
HEAD (fail-closed); the grant is armed before the instruction is sent and
|
|
453
|
+
revoked only if the send failed before delivery (the turn counter tells). The instruction is built
|
|
454
|
+
by `publishCommand`/`pullRequestCommand` in `policy.ts`, beside the parser that
|
|
455
|
+
admits it, and a test round-trips one through the other. Under every permission
|
|
456
|
+
authority the granted command is answered by the policy (`PolicyResult.granted`)
|
|
457
|
+
rather than escalated to the Decision Worker. The returning turn skips
|
|
458
|
+
acceptance and the Reviewer when HEAD is unchanged — they already passed on that
|
|
459
|
+
tree — and `#settlePublish` confirms the result read-only (`#confirmPublish`:
|
|
460
|
+
both pinned remote URLs unchanged, `git ls-remote` carrying the verified commit,
|
|
461
|
+
plus `gh pr list` for `pr`) before completing, or blocks the candidate when it
|
|
462
|
+
cannot; that candidate keeps `deliverable: true`, since it passed and is intact
|
|
463
|
+
on its branch, and an unreachable remote is reported as *unconfirmed*
|
|
464
|
+
(`RemoteBranchLookup` tells `absent` from `unreachable`), never as a missing
|
|
465
|
+
commit or a repointed remote. A tree that changed during the publish turn — an uncommitted edit included — voids
|
|
466
|
+
the grant and is re-verified in full, with the same confirmation deciding whether the notice
|
|
467
|
+
says the verified commit landed first. The grant is cleared on every terminal
|
|
468
|
+
path, so it never outlives the turn it was issued for, and
|
|
469
|
+
`permittedRemoteCommand` admits a single literal shape —
|
|
470
|
+
`git -C '<task dir>' -c core.hooksPath=/dev/null -c push.followTags=false push <remote> <commit>:refs/heads/<branch>`
|
|
471
|
+
with no other option, and `gh pr create --repo <pinned URL> --head <branch> …`
|
|
472
|
+
— so no force, delete, mirror, tags, push-options, other `-c`, branch or `HEAD`
|
|
473
|
+
source, other remote, branch or repository, relative `-C`, shell wrapper,
|
|
474
|
+
dynamic word or second statement; the pinned hooks path keeps any installed
|
|
475
|
+
`pre-push` out of the granted command and the pinned `push.followTags=false` keeps any tag out of it. `git config` writes to
|
|
476
|
+
transport-affecting keys (including `include.*` and `init.*`), `git config
|
|
477
|
+
--edit`, `git init --template`, and any statement naming `.git/config` or
|
|
478
|
+
`.git/hooks` in any spelling (`namesGitMetadata` normalizes the path and matches a glob segment by segment, so only a
|
|
479
|
+
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`
|
|
480
|
+
mutations; git's own `--git-dir`/`--work-tree` options and `remote`'s own `-v`
|
|
481
|
+
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
|
|
482
|
+
hint keys on `PolicyResult.boundary`, not on the reason text, and promises a publish turn
|
|
483
|
+
only where `#requestPublish` will start one. For an adopted tmux session the memory write root is
|
|
484
|
+
located under the *adopted process's* configuration directory, read from
|
|
485
|
+
`/proc/<pid>/environ` at adoption, so a Claude started with another
|
|
486
|
+
`CLAUDE_CONFIG_DIR` keeps its memory.
|
|
487
|
+
|
|
443
488
|
A record left behind by the outright
|
|
444
489
|
stop (`recoverable_failure`, so `active/interrupted`) is not a dead end either:
|
|
445
490
|
`recover --extend <duration>` re-persists a deadline measured from now
|
package/package.json
CHANGED
package/src/acceptance.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { isPlainRemoteName } from "./policy.ts";
|
|
1
2
|
import type { AcceptanceCheck, TaskSpec } from "./types.ts";
|
|
2
3
|
|
|
3
4
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
4
5
|
const DEFAULT_MAX_REPAIR_ROUNDS = 3;
|
|
5
6
|
|
|
6
7
|
/** Normalize legacy plain-text tasks into the structured acceptance model. */
|
|
7
|
-
export function normalizeTaskSpec(value: unknown, fallbackGoal: string): TaskSpec {
|
|
8
|
+
export function normalizeTaskSpec(value: unknown, fallbackGoal: string, autonomyDefaults?: Partial<TaskSpec["autonomy"]>): TaskSpec {
|
|
8
9
|
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
|
|
9
10
|
throw new Error("task spec must be a JSON object");
|
|
10
11
|
}
|
|
@@ -18,7 +19,7 @@ export function normalizeTaskSpec(value: unknown, fallbackGoal: string): TaskSpe
|
|
|
18
19
|
forbidden: stringList(source.forbidden, "forbidden"),
|
|
19
20
|
acceptance: normalizeChecks(source.acceptance),
|
|
20
21
|
maxRepairRounds: normalizeRepairRounds(source.maxRepairRounds),
|
|
21
|
-
autonomy: normalizeAutonomy(source.autonomy),
|
|
22
|
+
autonomy: normalizeAutonomy(source.autonomy, autonomyDefaults),
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -84,23 +85,32 @@ function normalizeRepairRounds(value: unknown): number {
|
|
|
84
85
|
return value;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function normalizeAutonomy(value: unknown): TaskSpec["autonomy"] {
|
|
88
|
-
|
|
88
|
+
function normalizeAutonomy(value: unknown, defaults?: Partial<TaskSpec["autonomy"]>): TaskSpec["autonomy"] {
|
|
89
|
+
// A spec file that omits a key — or the whole block — must not silently
|
|
90
|
+
// override the operator's environment defaults with hardcoded ones;
|
|
91
|
+
// `defaults` carries them in and every key falls back to it.
|
|
92
|
+
if (value === undefined) return normalizeAutonomy({}, defaults);
|
|
89
93
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("task spec autonomy must be an object");
|
|
90
94
|
const source = value as Record<string, unknown>;
|
|
91
95
|
if (source.unattended !== undefined && typeof source.unattended !== "boolean") throw new Error("task spec autonomy.unattended must be boolean");
|
|
92
96
|
if (source.requireLocalCommit !== undefined && typeof source.requireLocalCommit !== "boolean") throw new Error("task spec autonomy.requireLocalCommit must be boolean");
|
|
93
|
-
const retries = source.maxDecisionRetries ?? 2;
|
|
97
|
+
const retries = source.maxDecisionRetries ?? defaults?.maxDecisionRetries ?? 2;
|
|
94
98
|
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";
|
|
99
|
+
const authority = source.permissionAuthority ?? defaults?.permissionAuthority ?? "hybrid";
|
|
96
100
|
if (authority !== "policy" && authority !== "hybrid" && authority !== "decision-worker") throw new Error("task spec autonomy.permissionAuthority must be policy, hybrid or decision-worker");
|
|
97
|
-
const
|
|
101
|
+
const remoteAuthority = source.remoteAuthority ?? defaults?.remoteAuthority ?? "none";
|
|
102
|
+
if (remoteAuthority !== "none" && remoteAuthority !== "push" && remoteAuthority !== "pr") throw new Error("task spec autonomy.remoteAuthority must be none, push or pr");
|
|
103
|
+
const remoteName = source.remoteName ?? defaults?.remoteName ?? "origin";
|
|
104
|
+
if (!isPlainRemoteName(remoteName)) throw new Error("task spec autonomy.remoteName must be a plain remote name");
|
|
105
|
+
const maxWorkerCostUsd = source.maxWorkerCostUsd ?? defaults?.maxWorkerCostUsd;
|
|
98
106
|
if (maxWorkerCostUsd !== undefined && (typeof maxWorkerCostUsd !== "number" || !Number.isFinite(maxWorkerCostUsd) || maxWorkerCostUsd <= 0)) throw new Error("task spec autonomy.maxWorkerCostUsd must be a positive number");
|
|
99
107
|
return {
|
|
100
|
-
unattended: source.unattended !== false,
|
|
101
|
-
requireLocalCommit: source.requireLocalCommit !== false,
|
|
108
|
+
unattended: (source.unattended ?? defaults?.unattended) !== false,
|
|
109
|
+
requireLocalCommit: (source.requireLocalCommit ?? defaults?.requireLocalCommit) !== false,
|
|
102
110
|
maxDecisionRetries: retries,
|
|
103
111
|
permissionAuthority: authority,
|
|
112
|
+
remoteAuthority,
|
|
113
|
+
remoteName,
|
|
104
114
|
...(maxWorkerCostUsd !== undefined ? { maxWorkerCostUsd } : {}),
|
|
105
115
|
};
|
|
106
116
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { chmodSync, existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { isPlainRemoteName } from "./policy.ts";
|
|
4
5
|
import { redactSensitive } from "./redaction.ts";
|
|
5
|
-
import type { PermissionAuthority } from "./types.ts";
|
|
6
|
+
import type { PermissionAuthority, RemoteAuthority } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
const allowed = new Set([
|
|
8
9
|
"PI_CLAUDE_SUPERVISOR_MODE",
|
|
@@ -43,6 +44,8 @@ const allowed = new Set([
|
|
|
43
44
|
"PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS",
|
|
44
45
|
"PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS",
|
|
45
46
|
"PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS",
|
|
47
|
+
"PI_CLAUDE_SUPERVISOR_REMOTE_AUTHORITY",
|
|
48
|
+
"PI_CLAUDE_SUPERVISOR_REMOTE_NAME",
|
|
46
49
|
]);
|
|
47
50
|
|
|
48
51
|
export interface AutonomyDefaults {
|
|
@@ -50,6 +53,8 @@ export interface AutonomyDefaults {
|
|
|
50
53
|
requireLocalCommit: boolean;
|
|
51
54
|
maxDecisionRetries: number;
|
|
52
55
|
permissionAuthority: PermissionAuthority;
|
|
56
|
+
remoteAuthority: RemoteAuthority;
|
|
57
|
+
remoteName: string;
|
|
53
58
|
maxWorkerCostUsd?: number;
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -59,6 +64,8 @@ export function autonomyDefaults(env: NodeJS.ProcessEnv = process.env): Autonomy
|
|
|
59
64
|
requireLocalCommit: readBoolean(env.PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT, true),
|
|
60
65
|
maxDecisionRetries: readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES, 2, 0, 10),
|
|
61
66
|
permissionAuthority: readPermissionAuthority(env.PI_CLAUDE_SUPERVISOR_PERMISSION_AUTHORITY),
|
|
67
|
+
remoteAuthority: readRemoteAuthority(env.PI_CLAUDE_SUPERVISOR_REMOTE_AUTHORITY),
|
|
68
|
+
remoteName: readRemoteName(env.PI_CLAUDE_SUPERVISOR_REMOTE_NAME),
|
|
62
69
|
...(readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) !== undefined ? { maxWorkerCostUsd: readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) } : {}),
|
|
63
70
|
};
|
|
64
71
|
}
|
|
@@ -231,6 +238,25 @@ function readTrimmedString(value: string | undefined): string | undefined {
|
|
|
231
238
|
return trimmed ? trimmed : undefined;
|
|
232
239
|
}
|
|
233
240
|
|
|
241
|
+
/** Remote authority never defaults on: an unset or unrecognised value keeps the Worker local. */
|
|
242
|
+
function readRemoteAuthority(value: string | undefined): RemoteAuthority {
|
|
243
|
+
const normalized = value?.trim().toLowerCase();
|
|
244
|
+
if (normalized === undefined || normalized === "" || normalized === "none") return "none";
|
|
245
|
+
if (normalized === "push" || normalized === "pr") return normalized;
|
|
246
|
+
// A typo must not silently switch the publish phase off: a task would then
|
|
247
|
+
// end at a bare "candidate is ready" with no shortfall to explain it.
|
|
248
|
+
throw new Error(`PI_CLAUDE_SUPERVISOR_REMOTE_AUTHORITY must be none, push or pr: ${value?.trim()}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readRemoteName(value: string | undefined): string {
|
|
252
|
+
const trimmed = value?.trim();
|
|
253
|
+
if (trimmed === undefined || trimmed === "") return "origin";
|
|
254
|
+
// A typo must not silently redirect where a publish goes; the spec validator
|
|
255
|
+
// throws for the same input, so this fails the same way instead of guessing.
|
|
256
|
+
if (!isPlainRemoteName(trimmed)) throw new Error(`PI_CLAUDE_SUPERVISOR_REMOTE_NAME must be a plain remote name: ${trimmed}`);
|
|
257
|
+
return trimmed;
|
|
258
|
+
}
|
|
259
|
+
|
|
234
260
|
function readPermissionAuthority(value: string | undefined): PermissionAuthority {
|
|
235
261
|
const normalized = value?.trim().toLowerCase();
|
|
236
262
|
return normalized === "policy" || normalized === "decision-worker" ? normalized : "hybrid";
|
|
@@ -5,6 +5,17 @@ import { redactSensitive } from "./redaction.ts";
|
|
|
5
5
|
import { normalizeTaskSpec } from "./acceptance.ts";
|
|
6
6
|
import type { TaskSpec } from "./types.ts";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Two lists of URL strings and nothing else; a persisted baseline is data the
|
|
10
|
+
* grant trusts, so its shape is checked. Empty lists are valid: they record
|
|
11
|
+
* that the remote had no URL when the task started, which refuses a grant.
|
|
12
|
+
*/
|
|
13
|
+
function isRemoteBaseline(value: unknown): value is { fetch: string[]; push: string[] } {
|
|
14
|
+
const urls = (list: unknown): list is string[] => Array.isArray(list) && list.length <= 32 && list.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= 4_096);
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
16
|
+
&& urls((value as { fetch?: unknown }).fetch) && urls((value as { push?: unknown }).push);
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
export type DecisionRecoveryState = "ready" | "starting" | "registered" | "recovered_idle" | "interrupted";
|
|
9
20
|
|
|
10
21
|
export interface DecisionRecoveryWorker {
|
|
@@ -29,6 +40,8 @@ export interface DecisionSessionRecord {
|
|
|
29
40
|
startedAt: string;
|
|
30
41
|
baseCommit?: string;
|
|
31
42
|
baseBranch?: string;
|
|
43
|
+
/** The granted remote's resolved URLs when the task first started; the publish grant requires the same two. */
|
|
44
|
+
remoteBaseline?: { fetch: string[]; push: string[] };
|
|
32
45
|
/** Real executable identity pinned by automatic startup and recovery. */
|
|
33
46
|
resolvedExecutable?: string;
|
|
34
47
|
turn: number;
|
|
@@ -408,6 +421,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
|
|
|
408
421
|
|| (value.workerCostUsd !== undefined && (typeof value.workerCostUsd !== "number" || !Number.isFinite(value.workerCostUsd) || value.workerCostUsd < 0))
|
|
409
422
|
|| (value.baseCommit !== undefined && (typeof value.baseCommit !== "string" || !/^[0-9a-f]{40,64}$/iu.test(value.baseCommit)))
|
|
410
423
|
|| (value.baseBranch !== undefined && (typeof value.baseBranch !== "string" || !/^[A-Za-z0-9._/-]+$/u.test(value.baseBranch)))
|
|
424
|
+
|| (value.remoteBaseline !== undefined && !isRemoteBaseline(value.remoteBaseline))
|
|
411
425
|
|| (value.resolvedExecutable !== undefined && (typeof value.resolvedExecutable !== "string" || !isAbsolute(value.resolvedExecutable) || value.resolvedExecutable.length > 4_096))
|
|
412
426
|
|| (value.startedAt !== undefined && (typeof value.startedAt !== "string" || !Number.isFinite(Date.parse(value.startedAt))))
|
|
413
427
|
|| (value.recoveryWorker !== undefined && !isRecoveryWorker(value.recoveryWorker))) {
|
|
@@ -439,6 +453,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
|
|
|
439
453
|
startedAt: value.startedAt ?? value.updatedAt,
|
|
440
454
|
...(typeof value.baseCommit === "string" ? { baseCommit: value.baseCommit } : {}),
|
|
441
455
|
...(typeof value.baseBranch === "string" ? { baseBranch: value.baseBranch } : {}),
|
|
456
|
+
...(isRemoteBaseline(value.remoteBaseline) ? { remoteBaseline: { fetch: [...value.remoteBaseline.fetch], push: [...value.remoteBaseline.push] } } : {}),
|
|
442
457
|
...(typeof value.resolvedExecutable === "string" ? { resolvedExecutable: value.resolvedExecutable } : {}),
|
|
443
458
|
turn: value.turn ?? 0,
|
|
444
459
|
repairRound: value.repairRound ?? 0,
|
package/src/decision-worker.ts
CHANGED
|
@@ -294,12 +294,13 @@ Maximum automatic turns: ${context.maxTurns}
|
|
|
294
294
|
Current repair round: ${context.repairRound ?? 0}
|
|
295
295
|
${deadlineInstructions(context.deadline)}Task specification: ${boundedJson(context.spec ?? { goal: context.task })}
|
|
296
296
|
|
|
297
|
-
Return exactly one JSON object and no markdown:
|
|
297
|
+
${remoteAuthorityInstructions(context.spec)}Return exactly one JSON object and no markdown:
|
|
298
298
|
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|wait|noop",...}
|
|
299
299
|
For continue/redirect/answer include message and reason. For permission actions include
|
|
300
300
|
requestId and toolUseId. Retry may include a corrective message. Never choose allow_permission
|
|
301
|
-
for a command that crosses the remote push or main/integration merge boundary
|
|
302
|
-
policy will deny
|
|
301
|
+
for a command that crosses the remote push or main/integration merge boundary unless the task's
|
|
302
|
+
publish phase has granted it; the deterministic policy is the authority either way and will deny
|
|
303
|
+
anything outside the grant.
|
|
303
304
|
For AskUserQuestion, choose deny_permission when the question can be converted into ordinary
|
|
304
305
|
Claude text, then use answer on the resulting turn. For product ambiguity or an architecture
|
|
305
306
|
choice, inspect the repository and task evidence, select the best task-compatible option, state
|
|
@@ -321,6 +322,18 @@ interrupt it, and the Supervisor asks you again if the Worker has not resumed wi
|
|
|
321
322
|
timeout. A completed turn or a permission request always requires a concrete action.${deadlinePolicy(context.deadline)}`;
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
/**
|
|
326
|
+
* The publish phase exists only when the task was given remote authority. The
|
|
327
|
+
* Decision Worker has to know it is coming, or it reads the Supervisor's own
|
|
328
|
+
* publish instruction as an unexplained extra turn.
|
|
329
|
+
*/
|
|
330
|
+
function remoteAuthorityInstructions(spec: TaskSpec | undefined): string {
|
|
331
|
+
const authority = spec?.autonomy.remoteAuthority ?? "none";
|
|
332
|
+
if (authority === "none") return "";
|
|
333
|
+
const what = authority === "pr" ? "push the candidate branch and open a pull request" : "push the candidate branch";
|
|
334
|
+
return `Publish phase: this task may ${what} once its acceptance checks and the independent Reviewer have passed. The Supervisor issues that grant itself and asks the Worker to publish; you do not need to request it. When the Worker reports back from that turn, choose verify — the Supervisor confirms the remote rather than re-running the checks. Remote authority never extends to a merge, a force-push, a tag or a release.\n\n`;
|
|
335
|
+
}
|
|
336
|
+
|
|
324
337
|
function deadlineInstructions(deadline: DecisionDeadlineContext | undefined): string {
|
|
325
338
|
if (!deadline) return "";
|
|
326
339
|
const closeOut = deadline.graceMs > 0
|
package/src/hooks/relay.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BLOCKING_HOOK_EVENTS, HOOK_TIMEOUT_SECONDS } from "./types.ts";
|
|
2
2
|
import { nodeScriptCommand } from "../worker/runtime.ts";
|
|
3
|
+
import { shellQuote } from "../policy.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Plain JavaScript (no TypeScript syntax), executed by Claude Code as a hook
|
|
@@ -185,7 +186,3 @@ export function hookRelayCommand(relayPath: string): string {
|
|
|
185
186
|
return `${shellQuote(nodeScriptCommand())} ${shellQuote(relayPath)}`;
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
function shellQuote(value: string): string {
|
|
189
|
-
if (/^[A-Za-z0-9_.\/-]+$/u.test(value)) return value;
|
|
190
|
-
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
191
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { chmod, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs
|
|
|
7
7
|
import { EventLog } from "./events.ts";
|
|
8
8
|
import { redactSensitive } from "./redaction.ts";
|
|
9
9
|
import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
|
|
10
|
-
import { automaticWorkerEnvironment } from "./worker/environment.ts";
|
|
10
|
+
import { automaticWorkerEnvironment, claudeConfigDir } from "./worker/environment.ts";
|
|
11
11
|
import { TmuxWorkerAdapter, attachCommand, sweepDeadTmuxSockets } from "./worker/tmux-adapter.ts";
|
|
12
12
|
import { Supervisor, extendedDeadlineMs, type DecisionSessionClosedInfo, type HumanInterventionNotice, type SupervisorProgress, type SupervisorTokenUsage } from "./supervisor.ts";
|
|
13
13
|
import { evaluateCommand } from "./policy.ts";
|
|
@@ -29,7 +29,7 @@ import type { TaskSpec, WorkerHandle } from "./types.ts";
|
|
|
29
29
|
const RELAY_HOOK_MARKER = "/hooks/relay.js";
|
|
30
30
|
|
|
31
31
|
function claudeUserSettingsPath(): string {
|
|
32
|
-
return join(
|
|
32
|
+
return join(claudeConfigDir(), "settings.json");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** `src/hooks/install.ts` does not export its relay-script writer; this mirrors it for an owned launch's static relay path. */
|
|
@@ -371,11 +371,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
371
371
|
if (operation === "start" || operation === "adopt-tmux") {
|
|
372
372
|
const specPath = takeOption(rest, "--spec");
|
|
373
373
|
const deadlineOption = takeOption(rest, "--deadline");
|
|
374
|
+
const remoteOption = takeOption(rest, "--remote");
|
|
375
|
+
if (remoteOption !== undefined && !["none", "push", "pr"].includes(remoteOption)) throw new Error(`--remote expects none, push or pr: ${remoteOption}`);
|
|
374
376
|
const taskDeadlineMs = deadlineOption === undefined ? deadlineMs() : parseTaskDeadline(deadlineOption);
|
|
375
377
|
const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
|
|
376
378
|
const task = rest.join(" ").trim();
|
|
377
379
|
const fileSpec = specPath ? await readTaskSpecFile(specPath, ctx.cwd) : undefined;
|
|
378
380
|
const spec = fileSpec ?? { autonomy: autonomyDefaults() };
|
|
381
|
+
// An explicit --remote overrides both the spec file and the env default.
|
|
382
|
+
if (remoteOption) spec.autonomy = { ...spec.autonomy, remoteAuthority: remoteOption as "none" | "push" | "pr" };
|
|
379
383
|
const goal = fileSpec?.goal ?? task;
|
|
380
384
|
// Adopted sessions are explicit manual compatibility controls; they
|
|
381
385
|
// never enter the automatic Reviewer/decision loop, even when the
|
|
@@ -384,7 +388,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
384
388
|
// own events through hooks exactly like an owned one.
|
|
385
389
|
const taskAutomation = (operation !== "adopt-tmux" || tmuxMode() === "interactive") && automation && spec.autonomy.unattended;
|
|
386
390
|
const interactive = taskAutomation && adapter.capabilities().transport === "tmux" && tmuxMode() === "interactive";
|
|
387
|
-
if (!goal) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux [--spec <file>] [--deadline <duration>] <tmux-session> <task>" : "Usage: /supervise start [--spec <file>] [--deadline <duration>] <task>");
|
|
391
|
+
if (!goal) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux [--spec <file>] [--deadline <duration>] [--remote none|push|pr] <tmux-session> <task>" : "Usage: /supervise start [--spec <file>] [--deadline <duration>] [--remote none|push|pr] <task>");
|
|
388
392
|
if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
|
|
389
393
|
if (operation === "adopt-tmux" && interactive && !await userHooksInstalled(claudeUserSettingsPath())) {
|
|
390
394
|
await hookServerReady?.catch(() => {});
|
|
@@ -530,6 +534,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
530
534
|
startedAt: info.startedAt,
|
|
531
535
|
...(info.baseCommit ? { baseCommit: info.baseCommit } : {}),
|
|
532
536
|
...(info.baseBranch ? { baseBranch: info.baseBranch } : {}),
|
|
537
|
+
...(info.remoteBaseline ? { remoteBaseline: info.remoteBaseline } : {}),
|
|
533
538
|
...(info.resolvedExecutable ? { resolvedExecutable: info.resolvedExecutable } : {}),
|
|
534
539
|
turn: info.turn,
|
|
535
540
|
repairRound: info.repairRound,
|
|
@@ -808,6 +813,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
808
813
|
startedAt: record.startedAt,
|
|
809
814
|
baseCommit: record.baseCommit,
|
|
810
815
|
baseBranch: record.baseBranch,
|
|
816
|
+
remoteBaseline: record.remoteBaseline,
|
|
811
817
|
initialTurn: record.turn,
|
|
812
818
|
initialRepairRound: record.repairRound,
|
|
813
819
|
initialFindingSignature: record.lastFindingSignature,
|
|
@@ -832,6 +838,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
832
838
|
startedAt: info.startedAt,
|
|
833
839
|
...(info.baseCommit ? { baseCommit: info.baseCommit } : {}),
|
|
834
840
|
...(info.baseBranch ? { baseBranch: info.baseBranch } : {}),
|
|
841
|
+
...(info.remoteBaseline ? { remoteBaseline: info.remoteBaseline } : {}),
|
|
835
842
|
...(info.resolvedExecutable ? { resolvedExecutable: info.resolvedExecutable } : {}),
|
|
836
843
|
turn: info.turn,
|
|
837
844
|
repairRound: info.repairRound,
|
|
@@ -1010,7 +1017,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
1010
1017
|
} else if (operation === "resume-auto") {
|
|
1011
1018
|
await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
|
|
1012
1019
|
} else {
|
|
1013
|
-
throw new Error("Usage: /supervise start [--spec <file>] [--deadline <duration>]|adopt-tmux [--spec <file>] [--deadline <duration>]|recover [--takeover] [--extend <duration>]|discard <task-id>|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities|install-hooks|uninstall-hooks");
|
|
1020
|
+
throw new Error("Usage: /supervise start [--spec <file>] [--deadline <duration>] [--remote none|push|pr]|adopt-tmux [--spec <file>] [--deadline <duration>] [--remote none|push|pr]|recover [--takeover] [--extend <duration>]|discard <task-id>|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities|install-hooks|uninstall-hooks");
|
|
1014
1021
|
}
|
|
1015
1022
|
}
|
|
1016
1023
|
if (hookInstallNotice) { notify(ctx, hookInstallNotice); hookInstallNotice = undefined; }
|
|
@@ -1184,7 +1191,9 @@ function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
|
|
|
1184
1191
|
async function readTaskSpecFile(path: string, cwd: string): Promise<TaskSpec> {
|
|
1185
1192
|
const file = resolve(cwd, path.replace(/^['"]|['"]$/gu, ""));
|
|
1186
1193
|
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
|
|
1187
|
-
|
|
1194
|
+
// A spec that omits an autonomy key inherits the operator's environment
|
|
1195
|
+
// default for it, the way a plain-text task does.
|
|
1196
|
+
return normalizeTaskSpec(value, "", autonomyDefaults());
|
|
1188
1197
|
}
|
|
1189
1198
|
|
|
1190
1199
|
function parseCommand(value: string): string[] {
|
package/src/notifications.ts
CHANGED
|
@@ -99,7 +99,7 @@ function toGeneric(notice: HumanInterventionNotice | CandidateNotice): Record<st
|
|
|
99
99
|
question: sanitize(notice.question),
|
|
100
100
|
permission: notice.permission ? sanitize(notice.permission) : undefined,
|
|
101
101
|
...(notice.attach ? { attach: sanitize(notice.attach) } : {}),
|
|
102
|
-
...(candidate ? { status: notice.status, deliverable: notice.deliverable, ...(notice.usage ? { usage: usageSummary(notice.usage) } : {}) } : { actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"] }),
|
|
102
|
+
...(candidate ? { status: notice.status, deliverable: notice.deliverable, ...(notice.prUrl ? { prUrl: sanitize(notice.prUrl) } : {}), ...(notice.usage ? { usage: usageSummary(notice.usage) } : {}) } : { actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"] }),
|
|
103
103
|
note: candidate
|
|
104
104
|
? "This is an optional candidate notification. It does not grant remote push or main/integration merge permission."
|
|
105
105
|
: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
|
|
@@ -111,6 +111,9 @@ function toWeCom(notice: HumanInterventionNotice | CandidateNotice): Record<stri
|
|
|
111
111
|
const permission = notice.permission ? `\n工具: ${safeText(notice.permission.toolName)}\n请求 ID: ${safeText(notice.permission.requestId)}` : "";
|
|
112
112
|
const question = notice.question ? `\n问题: ${safeText(notice.question)}` : "";
|
|
113
113
|
const attach = notice.attach ? `\n接入: ${safeText(notice.attach)}` : "";
|
|
114
|
+
// Not Markdown-escaped: `escapeMarkdown` would turn an `_` in the org or
|
|
115
|
+
// repository name into `\_` and break the link. The URL is already sanitized.
|
|
116
|
+
const pullRequest = "status" in notice && notice.prUrl ? `\nPR: ${safeText(notice.prUrl)}` : "";
|
|
114
117
|
const title = candidate ? "Claude Supervisor 候选状态" : "Claude Supervisor 需要人工介入";
|
|
115
118
|
const usage = candidate && notice.usage ? `\n> Worker 费用: $${notice.usage.workerCostUsd.toFixed(2)} (${notice.usage.workerTurns} turns)\n> Pi tokens: ${usageSummary(notice.usage).piTokens}` : "";
|
|
116
119
|
const suffix = candidate
|
|
@@ -119,7 +122,7 @@ function toWeCom(notice: HumanInterventionNotice | CandidateNotice): Record<stri
|
|
|
119
122
|
return {
|
|
120
123
|
msgtype: "markdown",
|
|
121
124
|
markdown: {
|
|
122
|
-
content: `### ${title}\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}${escapeMarkdown(attach)}${suffix}`,
|
|
125
|
+
content: `### ${title}\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}${escapeMarkdown(attach)}${pullRequest}${suffix}`,
|
|
123
126
|
},
|
|
124
127
|
};
|
|
125
128
|
}
|