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 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 |
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
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
- if (value === undefined) return { unattended: true, requireLocalCommit: true, maxDecisionRetries: 2, permissionAuthority: "hybrid" };
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 maxWorkerCostUsd = source.maxWorkerCostUsd;
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,
@@ -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; the deterministic
302
- policy will deny it.
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
@@ -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(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "settings.json");
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
- return normalizeTaskSpec(value, "");
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[] {
@@ -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
  }