pi-claude-supervisor 0.9.2 → 0.10.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.
@@ -0,0 +1,437 @@
1
+ # 后续优化路线图(0.9.2 之后)
2
+
3
+ > 状态:实施中(已完成项以 ✅ 标注,其余仍为提案)。本文区分 **现状(Current Reality)**、**目标(Target)** 与 **迁移路径(Migration)**;
4
+ > 除 ✅ 项外,文中新模块、新事件、新字段均为提案,尚不存在于代码中;§2 现状描述的是路线图起草时(0.9.2)的代码。
5
+ > 与既有文档的关系:[`engineering-plan.md`](engineering-plan.md) 描述整体设计,
6
+ > [`automation-hardening-plan.md`](automation-hardening-plan.md) 记录自动化加固;本文只覆盖 0.9.2 之后的
7
+ > **收敛与稳定性** 工作,不改变它们确立的边界与目标。
8
+
9
+ ## 1. 背景与目标
10
+
11
+ 0.9.0 → 0.9.2 的独立审查与真实模型端到端测试(`npm run spike:decision`)暴露出一个共同根因:
12
+ **核心编排器 `Supervisor` 的子生命周期状态分散在大量字段中,未被显式建模。**
13
+ 这一轮修复的缺陷几乎全部来自这些字段之间的交互(“引入”指缺陷所在版本,均已在 0.9.2 修复):
14
+
15
+ | 引入版本 | 缺陷 | 本质 |
16
+ |---|---|---|
17
+ | 0.9.1 | 修复后 Decision 死循环不 verify | 验证结果无时效语义(快照被当作现状) |
18
+ | 0.9.2 开发中 | 过期 stop 被吞掉;stop→verify 期间 Worker 存活 | 停止意图与验证生命周期交叉 |
19
+ | 0.9.2 开发中 | 操作员 stop 与 Decision stop 竞态 → blocked | 停止意图没有唯一所有者 |
20
+ | 0.9.2 开发中 | 新增 key 脱敏规则导致会话注册失败 | 脱敏函数被兼用作路径/分支校验(语义漂移) |
21
+
22
+ **维护者指示(优先级依据)**:要的是 **稳定、可靠的无人值守自动化**,主力模式是 **interactive tmux**
23
+ (自动化驱动真实 Claude Code TUI + hooks)。过度保守的安全限制、过度防御与过度工程,只要妨碍这一目标就应放宽或简化;
24
+ 安全只保留 §5.2 的“底线清单”。
25
+
26
+ **目标**(按优先级):
27
+
28
+ 1. **interactive tmux 模式在无人值守下不卡死、不误判、不无故 park**(§5.1,阶段 T)。
29
+ 2. **去掉过度防御造成的误拒、误 park 与人工依赖**,同时补齐真正的底线(§5.2,阶段 D)。
30
+ 3. 让质量 **可度量**,并用 **确定性机制** 兜住 LLM 决策的失控模式(阶段 0、1)。
31
+ 4. 把终止、验证两个子生命周期显式建模,从结构上消除竞态缺陷(阶段 2),恢复路径与正常路径语义一致(阶段 3)。
32
+
33
+ **非目标**:不新增传输方式、不做多 Worker 并行、不引入新基础设施(数据库、图存储、服务拆分)。
34
+ 在阶段 T、D 与阶段 2 核心完成前冻结功能扩展;structured bridge 等非主力传输冻结(只修缺陷,不扩展)。
35
+
36
+ ## 2. 现状(Current Reality)
37
+
38
+ - `src/supervisor.ts`:3132 行、73 个方法(不含构造函数与 11 个 getter)、74 个私有字段(209–307 行);
39
+ `src/supervisor.test.ts` 4262 行。已有 `src/automation-replay.test.ts`(`ReplayEventLog`)可重放事件序列。
40
+ - 主状态机 `src/state.ts` 覆盖任务主状态:`idle → starting → running/waiting/paused → verifying →
41
+ completed/blocked/failed/stopped`(另有 `verifying → running`(修复)、`stopped → blocked`、终态 `→ idle`)。
42
+ “parked” 不是独立状态,而是 `blocked` + `#candidateParked`。
43
+ - 以下 **子生命周期没有状态机**,由字段组合隐式表达:
44
+
45
+ | 子生命周期 | 现有字段(节选) |
46
+ |---|---|
47
+ | 验证 / 修复 | `#lastVerification` `#lastVerificationTurn` `#verificationAbortController` `#repairRound` `#lastFindingSignature` `#repairSendInProgress` |
48
+ | 终止 | `#stopRequested` `#stopCloseReason` `#stopVerification` `#preemptiveStop` |
49
+ | 释放 / 交接(非终止) | `#releasing` `#released`(交互任务完成后保留会话时也会置位);`#terminalNoticeSent` 仅防重复通知 |
50
+ | 截止 / 收尾 | `#deadlineNotices` `#deadlineMs` `#deadlineGraceMs`(收尾与终止 **相互独立**,见 §3.2) |
51
+ | 发布 | `#publishState` `#publishTarget` `#publishRemote` `#remoteGrant` `#verifiedHead` `#prUrl` `#publishShortfall` |
52
+ | 人工闸门 | `#humanRequired` `#humanGate` `#candidateParked` |
53
+ | 待决决策 | `#pendingDecisionKey` `#pendingDecisionSince` `#waitTimer` `#lastTurnCompleted` |
54
+ | 启动 | `#startAbortController` `#startToken` `#startStopReason` `#startAbortError` `#startAbortCompletion` |
55
+
56
+ - **Decision 的现有裁决顺序**(`#applyDecision`,由代码顺序隐式决定):
57
+ 已释放/终态 → 人工闸门(记 `decision_deferred` 并丢弃)→ 事件去重 → 按动作分支;
58
+ 分支内:`stop` 先经 `#verifyStop`(其中过期 stop 退化为普通 stop);`continue/redirect/answer/retry`
59
+ 依次经 `#decisionIsStale` → `#verifyOnTurnBudget` → `#verifyStaleFailure`;
60
+ `wait` 另有收尾期 `wait → verify` 覆盖与 deadline 预警重放。**staleness 语义随动作不同**
61
+ (过期 stop 仍要停止;`wait` 与权限回应不检查过期)。
62
+ - **无验证的 continue 循环** 目前受三道约束:`maxTurns`(默认 100)、deadline(默认 4h,收尾时强制验证空闲 Worker)、
63
+ 可选的 `autonomy.maxWorkerCostUsd`。缺口是:**最多 100 轮或 4 小时的花费** 内可以一次验证都没有。
64
+ - `redactSensitive` 同时承担 **脱敏** 与 **校验**:`decision-session-store.ts` 的 `assertNoCredentialPath`、
65
+ `assertResolvedExecutable`,`tmux-adapter.ts` 的 `isSafeAbsolutePath`,均以“脱敏前后是否相等”判定合法性;
66
+ 另外 `redactRepositoryEvidence` 会脱敏分支名,而脱敏后的值被用于 `#trackBranchChange` 与 detached HEAD 判断。
67
+ - 恢复记录 `DecisionSessionRecord` 持久化了 `turn`、`repairRound`、`lastFindingSignature`、`workerCostUsd` 等,
68
+ **未持久化** 最近验证结果与轮次、guard 计数;进度持久化逻辑在 `index.ts` 中重复两处(启动与恢复路径)。
69
+ 恢复后的 Worker 是 **新进程**。
70
+ - 事件日志约 55 种事件类型;日志按 64 MB 轮转并保留多份。**没有单一的“任务终态”事件**,
71
+ 终态需由 `verification_passed` / `candidate_parked` / `verification_failed` / `worker_stopped` / 启动失败等推导;
72
+ `decision_overridden` 只有自由文本 `reason`,没有结构化的 guard 标识。
73
+ - 已有重放测试:`src/automation-replay.test.ts` 中 `ReplayAdapter` 负责重放,`ReplayEventLog` 负责记录事件。
74
+ - 真实链路(Claude TUI / hooks / cgroup v2 / 限流 / 长 idle)无自动化覆盖。
75
+ 部分单测依赖 `process.cwd()` 为干净 git 仓库(有未跟踪文件时约 5 个 supervisor 测试失败);
76
+ 在世界可写 `/tmp` 下 `environment.test.ts` 误报(`assertSecureExecutablePath` 逐级检查父目录,`/tmp` 的 1777 权限必然不通过)。
77
+
78
+ ## 3. 领域模型(Domain Model)
79
+
80
+ ### 3.1 概念
81
+
82
+ | 概念 | 类别 | 说明 |
83
+ |---|---|---|
84
+ | Task | 实体 | 一次受监督的实现任务;主状态机的承载者 |
85
+ | Worker Turn | 事件 | Worker 的一轮输出(`turn_completed`),带序号 |
86
+ | Decision | 提议 | Decision Worker(LLM)提出的动作;**不是权威** |
87
+ | Guard | 策略 | 对 Decision 的确定性裁决;**权威**,可带副作用(如先停 Worker 再验证) |
88
+ | Verification | 观察 | 某一 Worker 轮次时刻的验收 + 仓库证据 + 审查结果;**有时效(atTurn)** |
89
+ | Repair Round | 状态转换 | 由失败验证触发的纠正轮;受预算与收尾剩余时间约束 |
90
+ | Stop Intent | 意图 | 谁要求结束任务、以何种方式;**同一时刻只有一个所有者** |
91
+ | Close-out | 时间约束 | deadline 收尾窗口;**独立于 Stop Intent**,只约束“还来得及做什么” |
92
+ | Candidate | 产物 | 通过或被搁置(blocked/parked)的候选结果 |
93
+ | Progress Fingerprint | 派生 | 某轮次的仓库状态摘要(提案,见阶段 1) |
94
+
95
+ ### 3.2 现有行为(重构必须保持)与目标不变量(需显式行为变更)
96
+
97
+ **A. 现有行为**——阶段 2 的提取 PR 必须逐条保持;这里如实描述代码,包括不理想之处:
98
+
99
+ 1. **Decision verify-first 停止让位于操作员停止**:`#verifyStop` 与 `#finalizeVerification` 检查操作员停止,
100
+ 已存在操作员停止时以其为准。其余路径(Decision 普通 stop、`park` / `ask_human` / `noop` → park)**不检查**
101
+ 操作员停止(见 B1)。重复 `stop()`:验证进行中时,最后一次的原因与关闭原因生效(`#finalizeVerification`
102
+ 读取被覆盖的字段);其他状态下第一次生效,后续调用在任务已 `stopped` 时直接返回。
103
+ 2. **收尾与停止正交**:deadline 收尾不是停止;收尾期间允许修复轮(剩余 ≥ `MIN_CLOSE_OUT_REPAIR_MS`),
104
+ Decision 停止照常经 `#verifyStop`。deadline + grace 到期由 watchdog **停止** Worker(`worker_watchdog_timeout`)。
105
+ 3. **修复前置条件**(`#requestRepair`):自动化模式 ∧ 人工闸门关闭 ∧ 无 verify-first 停止 ∧ 修复预算未耗尽 ∧
106
+ 有存活 handle 且 Worker 仍在运行 ∧ 状态为 running/waiting/verifying ∧ 可就地修复 ∧ 收尾剩余时间足够。
107
+ 触发来源:验收失败、截断证据、本地 commit 缺失、Reviewer revise,**以及通过验证后发布预检发现未提交改动**。
108
+ 4. **验证时效**:基于验证结果的 Decision 判断以 `atTurn` 标注其时效;例外——发布轮返回后,若 HEAD 与工作树未变,
109
+ 复用发布前的通过验证(`#settlePublish`),不重新验证。
110
+ 5. **发布**:`remoteAuthority = none` 时验证通过即 `completed`。否则 `#requestPublish` **按以下顺序** 检查:
111
+ 手动模式 → takeover/停止请求 → 传输能力 → 收尾窗口 → 受保护/未知分支 → HEAD 不可读 → 证据缺失 →
112
+ **工作树有未提交/未跟踪改动(→ 修复轮;无修复预算 → shortfall + `completed`)** → 证据 HEAD 缺失或 HEAD 已变 →
113
+ Git 目录非本地 → Worker 忙 → 远端 URL/基线变化 → `pr` 模式缺仓库标识 → 状态非 verifying。
114
+ 除未提交改动外,任一不满足 **不阻塞**:记 `publish_skipped` shortfall 后以 `completed` 结束(存在停止请求则为 `stopped`)。
115
+ 发布 **只尝试一次**:发布轮返回后由 `#settlePublish` 得出 `completed`、publish-only `blocked`,或 HEAD/树变化 →
116
+ `publish_abandoned`(`#publishState` 置为 settled)→ 重新验证,且重新验证后不再发起发布,以带放弃说明的 `completed` 结束。
117
+ 6. **人工闸门**:闸门打开时 Decision 被记录为 `decision_deferred` 并 **丢弃**;`resumeAutomation` 只重新询问最后一个
118
+ `turn_completed`(见 B2)。闸门检查位于已释放/终态检查之后。
119
+ 7. **无输出**:自动化下空闲 Worker 的无输出超时触发 **验证**(`worker_idle_timeout`);轮次中途静默才 **停止**。
120
+ 8. **成本预算**(仅自动化模式):超出 `maxWorkerCostUsd`(或 Claude `error_max_budget_usd`)**立即 park**,不验证。
121
+
122
+ **B. 目标不变量**——当前不成立,须以 **独立的行为变更 PR** 实现(建议作为阶段 0.3,见 §5):
123
+
124
+ 1. **操作员停止唯一优先**:任何 Decision 终止类动作(普通 stop、park、ask_human、noop→park)在操作员停止
125
+ 已请求时都让位;任务以 `stopped` / `human_stop` 结束。(复审已用临时测试复现:Decision `park` 与操作员 stop
126
+ 竞态时任务以 `blocked` 结束;Decision 普通 stop 竞态时关闭原因为 `recoverable_failure` 而非 `human_stop`。)
127
+ 2. **延迟而不丢失**:人工闸门期间的权限回应在闸门关闭后重放,或权限请求被重新交给 Decision Worker。
128
+ 3. **终态无挂起资源**:进入终态后清除计时器、中止控制器与 `#pendingDecisionKey`(当前 stop/park/finalize 不清除后者)。
129
+ 4. **裁决可审计**:每次覆盖带结构化 guard 标识(阶段 2.3 引入;当前 stale 丢弃记 `decision_ignored`,
130
+ 权限覆盖记 `permission_decision`,`decision_overridden` 仅有文本 `reason`)。
131
+ 5. **释放后不再驱动或停止 Worker**:验证进行中 `release()` 时,Supervisor 仍会尝试修复轮(`#requestRepair` 不检查
132
+ `#releasing`)并在收尾时对已释放的 handle 调用 `adapter.stop`。对真实 tmux 适配器:`send()` 在已释放记录上抛错,
133
+ 修复未送达但已记 `repair_requested`;随后 `stop` 再次 release,面板仍在运行,owned handle 的清理确认失败 →
134
+ 任务以 **`failed`**(`verification_failed`,恢复关闭原因 `recoverable_failure`)结束——验收与审查阶段皆然
135
+ (复审以 tmux 形态的假适配器复现);adopted handle 则以 parked 结束。**直接影响 interactive tmux 模式。**
136
+
137
+ ### 3.3 子生命周期(目标形态,覆盖现有真实路径)
138
+
139
+ ```text
140
+ Verification(每轮):
141
+ acceptance ──fail──▶ repair? ─┬─ yes ▶ repair_requested ▶ (Worker turn) ▶ 下一轮
142
+ │ └─ no ▶ blocked(预算耗尽 / 收尾不足 / 禁止修复)
143
+ ▼ pass
144
+ repository evidence ── truncated ▶ repair?(同上);incomplete / baseline rewritten / detached ▶ parked
145
+ ▼ ok
146
+ local commit check ── missing ▶ repair? / blocked
147
+ ▼ ok
148
+ review ── pass ▶ publish? ─┬─ remoteAuthority=none ▶ completed
149
+ │ ├─ 按 §3.2 A5 顺序检查:未提交/未跟踪改动 ▶ repair?(无预算 ▶ shortfall ▶ completed)
150
+ │ ├─ 其他前置条件不满足 ▶ publish_skipped(shortfall)▶ completed(有停止请求 ▶ stopped)
151
+ │ └─ requested ▶ (publish turn) ▶ HEAD/树未变 ▶ #settlePublish ▶ completed | publish-only blocked
152
+ │ ├ HEAD/树不可读 ▶ publish-only blocked
153
+ │ └ 已变化 ▶ abandoned ▶ 重新验证 ▶ 不再发布 ▶ completed(附放弃说明)
154
+ ├─ revise ▶ repair?(同上);重复发现 ▶ human ▶ parked
155
+ └─ human / Reviewer 失败 ▶ parked
156
+ 任一阶段:操作员停止 ▶ cancelled(stopped);证据采集失败 ▶ parked
157
+ 验证中 release:owned tmux handle ▶ 尝试修复/停止已释放的 handle ▶ failed;adopted handle ▶ parked(缺陷,见 §3.2 B5)
158
+ 验证中 takeover:在 #exclusive 队列中等待验证结束后才生效
159
+ (注:所有 blocked 的验证结果都会置 #candidateParked,“blocked”与“parked”在状态上相同)
160
+ StopIntent(终止意图,现状):
161
+ none → decision_stop{verifyFirst | plain} | operator_stop{human | abort_start | recoverable_failure}
162
+ 另:watchdog(deadline+grace)与无输出停止直接调用 #stopInternal,不设置 #stopRequested
163
+ (自动化下仅轮次中途静默才停止;非自动化下空闲 Worker 也会被停止)
164
+ decision_stop{verifyFirst} → operator_stop(操作员取代,现状只在此路径成立)
165
+ operator_stop → operator_stop:验证进行中时最后一次生效;其他状态下第一次生效(见 §3.2 A1)
166
+ Park(终止但非停止意图):成本预算、Decision park/ask_human/noop —— running/waiting/paused/starting 下经
167
+ #stopInternal(…, "blocked") 结束;verifying 下(含验证 blocked)经 #finalizeVerification 结束并记 candidate_parked
168
+ Release(非终止):断开 Supervisor;交互 Worker 移出 cgroup 交还操作员,任务状态与恢复记录不变
169
+ (验证进行中 release 的影响见上方 Verification 注释)
170
+ Budget:turns 耗尽 → 验证;deadline 收尾 → 验证空闲 Worker;deadline+grace → watchdog 停止;cost → 立即 park
171
+ ```
172
+
173
+ ## 4. 目标架构(Target)
174
+
175
+ 保持 **模块化单体**,不拆服务。按风险收益排序,只有前三项是必做:
176
+
177
+ ```text
178
+ src/supervisor/
179
+ stop-intent.ts StopIntent:唯一所有者、取代规则(必做)
180
+ verification.ts VerificationCycle:§3.3 的阶段、修复前置条件、atTurn(必做)
181
+ decision-guards.ts Decision 裁决管线(必做)
182
+ progress.ts 进度指纹与无进展检测(阶段 1,新)
183
+ publish.ts PublishCycle(可选)
184
+ watchdog.ts / usage.ts 计时与成本(可选)
185
+ src/validation.ts 路径、分支、可执行文件、凭据形状的显式校验(从 redaction 拆出)
186
+ ```
187
+
188
+ **Decision 裁决管线**(保持现有顺序与语义,而不是重排):
189
+
190
+ ```ts
191
+ type GuardVerdict =
192
+ | { kind: "pass" }
193
+ | { kind: "defer"; reason: string } // 人工闸门
194
+ | { kind: "drop"; reason: string } // 例如过期的 continue
195
+ | { kind: "override"; action: DecisionAction; reason: string }
196
+ | { kind: "handled"; reason: string }; // guard 已执行带副作用的流程(如 stop→停 Worker→验证)
197
+ interface DecisionGuard {
198
+ id: string; // 写入事件的结构化 guard 字段
199
+ actions: ReadonlyArray<DecisionAction["action"]>; // 按动作适用,staleness 语义随动作不同
200
+ decide(action, event, ctx): Promise<GuardVerdict>;
201
+ }
202
+ // 顺序(现状;标 * 者为提案新增):
203
+ // 已释放 → 终态 → 人工闸门(defer,现状为丢弃,见 §3.2 B2)→ 去重(wait 不去重)→ 记录 decision_made → 按动作:
204
+ // allow/deny_permission: 无法回应权限 → park(先检查);策略 deny 覆盖 Decision 的 allow;
205
+ // AskUserQuestion 的 deny_permission 以 reason 携带答案
206
+ // continue/redirect/answer 且待决 AskUserQuestion: 以权限 deny 携带答案(先于过期检查)
207
+ // continue/redirect/answer/retry: stale-event(drop) → turn-budget → *no-progress → stale-failure
208
+ // verify: stale-event(drop, decision_ignored) → 验证
209
+ // stop: verify-stop(handled);以下回落为普通 stop:非 turn_completed 事件、状态非 waiting、非自动化、
210
+ // 人工闸门打开、有远端授权、决策已过期
211
+ // park/ask_human/noop: → park(noop 在 clean exit 上 → 验证)
212
+ // wait: deadline 预警重放(先检查)或收尾期 wait→verify(二者互斥),否则重设等待计时器
213
+ ```
214
+
215
+ ## 5. 分阶段实施
216
+
217
+ 阶段 T、D 是当前最高优先级(直接对应维护者指示);阶段 0–3 是支撑它们的度量、兜底与结构工作。
218
+
219
+ ### 5.1 阶段 T:interactive tmux 无人值守可靠性(最高优先级)
220
+
221
+ 来源:只读可靠性审计(代码 + Claude Code 2.1.281 二进制 + 本地 tmux 实验)。标记:**[code]** 代码证实;
222
+ **[exp]** 本地 tmux 实验证实;**[bin]** 由 Claude 二进制强烈暗示;**[hyp]** 需真实 Claude 运行验证。
223
+ **测试缺口**:目前没有任何真实 Claude spike 走 interactive 适配器的 hooks 路径(`spike-claude-tmux-interactive.mjs`
224
+ 直接驱动 tmux,`spike-claude-tmux.mjs` 用 plan 模式),这是这些问题未被发现的原因。
225
+
226
+ | # | 问题 | 后果 | 修复(倾向简单、确定性) | 等级 |
227
+ |---|---|---|---|---|
228
+ | T1 ✅ | hook 路由按 `event.cwd` 查找 socket,Claude 在 Bash 中 `cd` 子目录后 cwd 随之改变 [code][bin] | 此后所有 hook 静默 no-op:Stop 丢失、PreToolUse 策略跳过、权限对话框等人 → 20 分钟/4 小时后失败 | Worker 环境设 `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1`;relay 按 `CLAUDE_PROJECT_DIR` 路由(回退:`event.cwd`;不沿父目录查找,否则 Worker 在子目录启动的嵌套 Claude 会被路由到本任务),请求携带路由键;shell cwd 偏离任务目录的 Bash 请求以 `cd <cwd> && <命令>` 呈现(不再被判为 routine,由 Decision 按真实目录判断;遗留:策略本身不跟踪 `cd`,偏离目录时的已授权 publish 会被拒直到 `cd` 回任务目录) | 高 |
229
+ | T2 ✅ | 发送无送达确认:paste → Enter 之间不检查 `pane_in_mode`,不等待对应 UserPromptSubmit [code][exp] | copy-mode 吞掉 Enter,文本滞留输入框,`activeRequests` 卡 1 → 无输出 watchdog 失败 | 发送前 `#{pane_in_mode}`=1 则 `send-keys -X cancel`;Enter 后 5–10 s 等匹配的 UserPromptSubmit,未到则截屏:**仅当** 输入框仍显示所粘贴文本且无任何对话框时重发 Enter(≤2 次,盲发 Enter 会选中对话框默认项、绕过策略)。**已实现**:重发后仍滞留 → 不可重试错误并 park(重发会重复消息);输入框已空但无 hook 确认 → 视为已送达并记日志,hook 通道失效交由无输出 watchdog 收口,不与 D1 叠加重试 | 高 |
230
+ | T3 ✅ | `#send` 在屏幕非“干净就绪”时直接抛错,经 `#decisionFailure` 标为“Decision Worker API failed”并 park、杀死 Worker [code] | 横幅、残留输入、prompt suggestion 幽灵文本 [hyp] 都会导致无故 park | 适配器内轮询就绪 30 s 后再拒绝,并标记为可重试(`WorkerInputError`);Supervisor 对 Decision 发送的可重试失败按 15 s 起、翻倍至 60 s、最多 5 次重新应用同一决策(新的 Worker 事件即取消),耗尽后以“Worker input failed”park,不再冒充 Decision 失败;owned `--settings` 关闭 `promptSuggestionEnabled` | 高 |
231
+ | T4 ✅ | 与待发消息不精确匹配的 UserPromptSubmit 一律视为人工输入 → `humanRequired`,且 `worker_prompt` 来源不发 webhook,无输出 watchdog 暂停 [code] | 自动化 **无限期静默暂停** 直到 deadline 失败 | 适配器发送未确认的 30 s 窗口内的 UserPromptSubmit 视为自身消息;待发消息按时间过期而非在 Stop 时清空;无人值守时对 takeover 发告警;为 `worker_prompt` 引起的闸门单独记来源(现与显式 `takeover()` 共用 `#humanGate="other"`),仅该来源在可选/无人值守配置下“N 分钟无新人工输入且 Worker 空闲则自动恢复”——**永不** 清除显式 takeover 或 recover 后的接管。**已实现**:发送确认进行中到达的不匹配 UserPromptSubmit 视为自身消息(确认结束后仅精确匹配);`worker_prompt` 单独成为闸门来源;`HUMAN_IDLE_RESUME_MS`(默认 30 分钟)自人工最后输入与其轮次结束起 Worker 空闲满期则自动恢复(`automation_auto_resumed`);轮次停在半路(对话框)整期无输出则告警一次,显式 takeover 与 recover 永不自动恢复。**未做**:待发消息按时间过期、无人值守 takeover 告警 | 高 |
232
+ | T5 | Pi 正常关闭会 release owned 会话(移出 cgroup、停 guardian、退订 hooks、释放租约);`recover` 总是启动 **新的** Claude [code] | 旧 Claude 在无监督下继续跑(hooks no-op),新 Claude 丢失上下文,二者同写一个工作树 | 最简:关闭时停止未完成任务的 owned Worker;更好:保留租约与 tmux 身份,`recover` 重新 adopt 存活的 pane(已有 `tmuxExpectedIdentity` 能力),不存在时才新起。须保持 `ownership=owned`(adopted 会话不允许 `killProcessGroup`)、重新挂回隔离、按“可能正处于轮次中”处理;并与 D12 协调——保留的租约 + 死掉的所有者 + 存活的 tmux 不能被当作孤儿回收 | 高 |
233
+ | T6 | `activeRequests` 只由发送或 UserPromptSubmit 置 1;忽略 `stop_hook_active` 与 Stop 的 `background_tasks`/`session_crons` [code][bin] | Stop-hook 续跑或后台代理运行时被当作空闲:发送被拒 → park,或边改文件边验证 | 主线程(无 `agent_id`)的 PreToolUse/PermissionRequest 在空闲时把 `activeRequests` 置回 1;Stop 的后台任务非空时确定性 `wait`(有界:常驻开发服务器永不结束,依赖 §3.2 A7 的空闲 watchdog 收口) | 中高 |
234
+ | T7 | StopFailure 的结构化 `error` 枚举(`rate_limit`/`overloaded`/`billing_error`/…)与 `error_details` 未被利用(`describeHookError` 只把类型/状态/消息作为文本交给 Decision);重试由 Decision 决定且立即重发 [code][bin] | 限流/配额期间重试风暴 → 修复预算或 Decision 失败 → park | 限流/过载/服务错误由 Supervisor 按退避或 `error_details` 中的重置时间 **自动续跑**(不消耗 Decision 与修复预算);认证/计费错误立即 park 并告警;识别“continuing automatically”(等待)与“press enter to continue”(发 Enter) | 中高 |
235
+ | T8 | interactive 模式下 `#monitor` 不看屏幕;`permission_prompt` 通知只记日志;AskUserQuestion 的 PreToolUse 超时返回 `{}` [code] | 对话框只能靠 20 分钟 watchdog(失败)发现;转圈卡住的轮次要等 4 小时 | AskUserQuestion 超时改为 deny(“无人可答,写明假设后继续”);无待决请求时收到 `permission_prompt`/`elicitation_dialog` 通知 → 立即告警并 park;以“活跃轮次内 X 分钟无 hook 活动”作为卡死信号(X 须大于最长单次 Bash 工具运行时间) | 中 |
236
+ | T9 | hook 服务端单行 1 MB 上限,超限直接断开 [code] | 大文件 Write/Edit 权限请求或超长 Stop 失败 → 回退到等人的对话框 | relay 截断超长字段(保留长度与哈希),提高上限;**被截断的 Bash 命令一律拒绝**(不能对截断文本做策略判断) | 中 |
237
+ | T10 | hook socket 位于 `$XDG_RUNTIME_DIR`(无 linger 时随最后会话退出被删);自动模式要求可写的委派 cgroup(ssh `session-N.scope` 为 root 所有)[code][hyp] | 所有 hook 静默 no-op;从 ssh 启动即失败 | socket 移到 `/tmp/pi-claude-supervisor-<uid>` 并定期自检重建;与 D7 一并解决 cgroup;文档写明 `loginctl enable-linger` 与 `systemd-run --user --scope -p Delegate=yes` | 中 |
238
+ | T11 | 验证中 release 以 `failed` 结束(§3.2 B5);park 时 `#stopInternal` 关闭 owned tmux 会话 | 早上接手时 Claude 上下文已丢失 | 与 0.3 一并修 B5;interactive 模式下 park 改为 **交还会话(hand-back)**,保留 Claude 上下文供人接手 | 中 |
239
+
240
+ **其余(小项)**:信任对话框按 500 ms 间隔循环处理(2.1.281 有输入保护 [bin]);其他启动对话框失败时附屏幕尾部;
241
+ 权限决策时长封顶在 hook 预算内并丢弃过期排队事件;验证持有 `#exclusive` 期间 pre-phase 策略回答移到锁外;
242
+ 以 `/`、`!`、`#` 开头的消息加安全前缀 [hyp];重新 adopt 时关闭我方遗留的 `pipe-pane`;Worker 设 `DISABLE_AUTOUPDATER=1` [bin]
243
+ (或恢复时接受同一安装根);`#detachCgroup` 前先 `cgroup.freeze`;SessionStart 后按 `session_id` 绑定。
244
+
245
+ **验收**:新增 **gated 真实 Claude interactive spike(走适配器 + hooks)**,覆盖:`cd` 后调用工具、>4 KB 消息、
246
+ copy-mode、Stop-hook block、后台任务、AskUserQuestion、限流模拟;在维护者的 cgroup v2 主机上作为发版清单必跑项。
247
+
248
+ ### 5.2 阶段 D:去除过度防御,补齐底线
249
+
250
+ 来源:只读防御机制审计(代码阅读 + 对 `evaluatePermission`/`parseReview` 的临时探针)。按“可靠性收益 / 风险”排序:
251
+
252
+ | # | 机制 | 对无人值守的伤害 | 建议 | 风险 |
253
+ |---|---|---|---|---|
254
+ | D1 ✅ | Decision 失败即 park:约 3 分钟重试后 park;应用动作时抛出的任何错误也被当作 API 失败 | 429/529 常持续更久;发送失败被误标为 API 失败 | 无人值守下对 **可重试的** provider 错误(限流、过载、5xx、超时)按上限退避持续重试直到 deadline;认证、计费、模型不存在等配置错误立即 park 并告警;权限请求回退到策略答案;动作应用错误单独分类(配合 T3)。**已实现**:配置错误立即 park;已完成轮次(未被后续事件取代)上 **明确列出的** 瞬时错误(参照 pi-ai 可重试清单)退避(≤60 s)持续重试并记 `decision_retry`,由空闲 watchdog 兜底验证、close-out 不再等待重试中的决策;其余错误(prompt 过长、会话损坏、自身超时)、权限请求与退出事件仍受 `maxDecisionRetries` 约束;被后续轮次取代的迟到决策、以及取代时仍在失败重试的旧轮次均记 `decision_ignored` 丢弃(不 park);动作错误记 `decision_action_failed`。**未做**:权限请求回退到策略答案——须在删除底线上线后再做 | 低 |
255
+ | D2 | Reviewer 回复解析:前后散文、额外键、字段顺序、嵌套值、缺 reviewId 均判格式失败;二次失败 → human → park;Provider 失败 4 次即 park | 真实模型的正常输出被判失败 | 保留 reviewId 与重复键检查;取回复中第一个带匹配 reviewId 的对象;忽略未知键;**取消字段顺序与扁平值规则**;格式失败换新会话重试;Provider 耗尽后稍后重验而非 park | 低–中 |
256
+ | D3 ✅ | 未跟踪的二进制、符号链接、硬链接、不可读文件使证据“不完整” → **无修复直接 park**(Reviewer 也拒绝) | Worker 加一张 PNG / fixture DB / symlink 就 park | 这类文件记为“omitted”,证据仍视为完整;git 读取失败重试一次;截断仍走修复。**已实现**:二进制(不论大小,记大小)、符号链接(记目标、不跟随)、特殊文件记名,证据完整;硬链接仍视为不完整(第二个链接可向 Reviewer 隐藏普通文本)。**未做**:git 读取失败重试 | 低 |
257
+ | D4 | 远端边界正则对整条命令匹配:误拒 `git commit -m "fix: push handler"`、`git stash push`、`git merge --abort`、`git log --grep=merge`、`grep -rn shutdown src/` 等;同一拒绝表也用于验收命令 | 常规开发命令被拒,Worker 反复绕路 | 按语句、在 git 子命令位置匹配(已有 `gitSubcommandIndex`);只拒 push/send-pack/受保护 ref 的 update-ref,merge 仅在当前或目标为受保护分支时拒;shutdown/reboot 锚定到命令位置 | 低–中 |
258
+ | D5 | 含动态参数(`$VAR`、`$(…)`)的命令一律拒绝:`git show $SHA`、`npx vitest run $TEST_FILE` 等 | 频繁无谓拒绝 | 仅在 push/remote/config/改写分支语句中,或会被 shell/eval/xargs 执行时拒绝 | 低–中 |
259
+ | D6 | 可信 Claude 可执行文件要求每级父目录都无组写/全局写;恢复时钉死旧 realpath | umask 002 的发行版直接启动失败;Claude 自动更新后恢复失败 | 只拒全局可写或他人所有的路径;恢复时重新解析并记 `worker_executable_changed` 事件 | 低 |
260
+ | D7 | 自动模式强制 cgroup v2(`required`),父 cgroup 须可写,探针 1 s 超时 | ssh 会话、容器、高负载机器启动失败 | **(原阶段 5,采纳方案 B)** 复用 `PI_CLAUDE_SUPERVISOR_CGROUP_MODE`,默认 `auto`:cgroup 不可用时回退到按进程树/会话清理(已有 `process-tree.ts`,不只依赖 `kill-server`),每个任务标注“降级隔离”;`required` 可选。**依赖**:`canTakeoverLease` 要求已验证的 cgroup 边界(`cwd-lease.ts`),回退主机上崩溃后无法 `recover --takeover`——须先完成 D12 的租约改造 | 低–中 |
261
+ | D8 | 基线被改写(amend/squash 起始提交)即 park;detached HEAD 即 park | rebase 冲突中途或整理提交就 park | 基线改写:记事件并继续(`git diff <base>` 仍可用;须同时修改 `automaticRepositoryBoundary` 中同样的拒绝);detached HEAD:先发修复轮(完成/中止 rebase、切回分支)再 park | 低 |
262
+ | D9 | `hybrid` 授权下的常规命令集以 npm 为中心;Write/Edit 到 `/tmp` 被拒;cwd 内经符号链接目录或硬链接的路径被误报为“Git metadata” | python/cargo/go/make/pnpm 每次都要 Decision 裁决(又一个 park 来源) | interactive 无人值守默认 `policy` 授权(配合下方删除底线);允许 `os.tmpdir()`;realpath 仍在根内的符号链接放行;去掉 `nlink>1` 规则,保留 `.git` realpath 检查 | 中 |
263
+ | D10 | 重复相同发现 → human → park;Decision 回复中 `confidence` 越界或字段超 8 KB 使整个动作无效;noop 经再提示后 park | 可修复的情形被提前 park | 交给修复预算决定;clamp/忽略 `confidence`、截断长字段;`turn_completed` 上的 noop 映射为 verify | 低 |
264
+ | D11 | 运行时目录的 lstat “非真实目录”检查(dotfiles 管理器常把 `~/.local/state` 做成符号链接);adopt 身份检查要求 pane cwd 逐字节一致且把 Claude argv 过拒绝表 | 启动/接管无故失败 | 先解析路径再检查;argv 不过拒绝表(配合 D4) | 低 |
265
+ | D12 | cwd 租约(1288 行):崩溃的 Pi 留下的租约在 tmux/cgroup 仍存活时需人工 `recover --takeover` | 无人值守下无法自动接续 | 自动回收可证明为孤儿的 owned tmux 服务器;接管事务机制简化为“锁 + 所有者 pid/启动时间 + Worker 存活检查” | 中 |
266
+
267
+ **底线清单(必须保留或新增)**:
268
+ - ✅ **新增:删除/移动底线**(**必须先于 D9、D5 上线**)。**已实现**(`deleteFloorViolation`,接在 `evaluatePermission` 的 Bash 分支,任何授权模式与人工 approve 都无法越过):
269
+ 与下方原规则的差异——字面 glob 与绑定到字面值的变量按其路径判断而非一律拒绝(`rm -rf dist/*` 是日常清理),但隐藏项 glob(`.*`)
270
+ 与无 name/path 过滤的 `find .`(会连带 `.git`)拒绝;字面 `cd`/`pushd` 会被跟踪而非一律拒绝,无法解析的 `cd` 之后的相对目标拒绝;
271
+ 包含任务目录的祖先目录总是拒绝(即使任务位于 `/tmp` 之下);整个写入根/临时根或其顶层 glob 拒绝;`git clean` 的 `-C`/`--work-tree`
272
+ 按删除目标判断;`rsync --delete`、`git worktree remove --force` 也纳入。独立评审后改为 **fail-closed 读法**:语句中任何位置的删除词都算(任意包装器、函数/case 体自动覆盖),
273
+ 只有文本命令、解释器与自带 `rm` 子命令的工具(`git rm`/`npm rm`/`docker rm`)终止扫描;引号脚本、`$(…)`/反引号/`<(…)`、管道喂给 shell 的文本与非引号 heredoc
274
+ 作为脚本递归判断,超过 6 层嵌套直接拒绝;brace 展开逐个判断,通配后跟 `..` 拒绝;`find` 的 `!`/`-not`/`-o` 或能匹配 `.git` 的过滤不算过滤;
275
+ `$(mktemp …)`、`for f in *.tmp`、`$HOME`/`$TMPDIR`/`$PWD` 按实际路径判断,`.git/*.lock` 放行(减少误拒)。
276
+ **残余风险**(本项不覆盖,cgroup 只限资源不限路径,需要时由运维用容器/OS 沙箱收窄文件系统视图):解释器或脚本文件执行的删除;覆盖写(`cp`、`ln -sf`、`install`、`truncate`、`tar -C`、`>`);
277
+ 非受保护分支上的 `git reset --hard`/`git stash clear`;任务位于 `/tmp` 时同在临时目录下的兄弟任务。原规则:现状:Bash 中只拦截 `/`、`/*`、`-- /`;`rm -rf ~/other-project`、
278
+ `rm -rf ~`、`rm -rf /home`、`rm -rf /usr`、`find ~ -delete`、`rm -rf "$HOME"`、`rm -rf $(pwd)/../x`、`cd /tmp && rm -rf *`、
279
+ `rm -rf .git` 均被放行(现有 `hybrid` 授权下这些不是常规命令,由 Decision LLM 把关;放宽为 `policy` 后只剩底线)。规则:
280
+ 对 `rm`/`mv`/`find -delete`/`git clean`/`shred` 等删除或移动类语句——
281
+ 1. 目标为动态(变量、命令替换、glob)时拒绝;同一命令中该语句前有 `cd`/`pushd`/`-C` 时拒绝;
282
+ 2. 字面目标解析后位于 cwd、写入根与临时目录之外时拒绝(`namesPathOutsideCwd` 会把 `"$HOME"`/`$(...)` 当作 cwd 内路径,不能单独依赖);
283
+ 3. 删除或移动 `.git`,以及 `git gc --prune`、`git reflog expire` 拒绝(`remoteAuthority` 默认 none,本地提交是唯一副本);
284
+ 4. D5 的动态参数放宽 **不适用于** 删除/移动目标。
285
+ - 保留:拒绝 push、send-pack/receive-pack、PR/release/API 变更、合并进受保护分支(按 D4 收窄匹配方式);拒绝修改
286
+ git remote 与传输配置(`remote`、`url`、`push`、`credential`、`core.hooksPath`);拒绝写 `.git` 元数据(现状仅覆盖 Write/Edit;Bash 侧由上条补齐);拒绝改写/删除受保护分支;
287
+ interactive 模式保留 PreToolUse 否决;开启推送授权时保留字面授权与远端 URL 基线;杀进程/tmux 服务器前的 pid + 启动时间身份核验;
288
+ 每个工作树一个租约(核心形态);日志/prompt/webhook 的脱敏(与校验分离);deadline、轮次与修复预算。
289
+
290
+ **冻结或移除候选**:`#humanGate = "permission"` 死代码;structured bridge(审计估计约占 `tmux-adapter.ts` 一半,未精确统计)、process-pipe/manual 传输、
291
+ 仅 headless 的设置检查——若 interactive 为主力,冻结或移到独立文件;发布授权机制已默认关闭,保持现状。
292
+
293
+ ### 阶段 0:先度量(2 个 PR)
294
+
295
+ **0.1 质量报告命令**(无运行时行为变更):`npm run report -- <events.jsonl…>`,支持读取轮转日志,统计:
296
+ - 任务终态分布(由多类事件推导,推导规则写进代码与文档)与 park/block 原因直方图;
297
+ - 修复轮次分布、`decision_overridden` 次数(阶段 2 之前按 `reason` 文本归类)、`decision_worker_failed` 次数;
298
+ - 首个候选耗时、Worker 成本、Decision/Reviewer token、Reviewer 格式失败率。
299
+
300
+ **验收**:对 spike 产出的事件日志,数字与人工核对一致。
301
+
302
+ **0.2 拆分校验与脱敏**(**有意的行为变更**:合法路径的判定改为显式规则):新增 `src/validation.ts`
303
+ (`assertSafeAbsolutePath`、`assertSafeBranchName`、`assertSafeExecutablePath`、`looksLikeCredential`),
304
+ 替换 `assertNoCredentialPath`、`assertResolvedExecutable`、`isSafeAbsolutePath` 中的“脱敏前后相等”判定;
305
+ 分支跟踪、detached HEAD 判断与发布授权的分支/受保护分支检查改用原始分支名,仅在输出时脱敏。
306
+ **验收**:现有测试全绿;新增“脱敏规则变化不影响路径/分支合法性”的测试;列出被新规则放行或拒绝的差异样例。
307
+
308
+ **0.3 已知行为缺口修复**(显式行为变更,单独 PR):实现 §3.2 B1(操作员停止唯一优先)、B3(终态清除待决决策)
309
+ 与 B5(`#releasing` 时跳过修复与 `adapter.stop`,以 released/parked 结束),各带竞态回归测试;B2(延迟而不丢失)需先确认权限请求重放的交互,单独评估。
310
+
311
+ ### 阶段 1:通用无进展兜底(1 个 PR,先观察后覆盖)
312
+
313
+ **问题**:见 §2——最多 100 轮或 4 小时的花费内可以一次验证都没有。
314
+
315
+ **指纹**(只看仓库,不看回复文本——LLM 回复几乎不会逐字重复,tmux 屏幕尾部含计时器/动画):
316
+ `{ head, trackedDiffHash, untrackedHash }`。
317
+ - untracked 文件纳入(`git ls-files --others --exclude-standard` + 内容摘要);
318
+ - 复用 `collectRepositoryEvidence` 的大小与超时上限;计算在 `#exclusive` 之外完成或设超时,超时视为“未知”而非“无变化”。
319
+
320
+ **规则**:自上次验证(或任务开始)以来,连续 `N`(默认 3,可配置)个完成轮次指纹不变,且 Decision 选择
321
+ continue/redirect/answer/retry → 覆盖为 verify;Worker 自身 API 错误轮与 `wait` 不计入。
322
+ 与 `#verifyStaleFailure` 的关系:后者处理“验证失败后”的情形,本规则处理“从未验证/验证后无变化”,二者合并进同一管线。
323
+
324
+ **上线方式**:第一版只记录 `no_progress_detected`(观察模式),用阶段 0 报告评估误报率后再启用覆盖。
325
+ **注意**:无变更的仓库被强制验证时,`requireLocalCommit=false` 的任务可能只剩 Reviewer 把关;
326
+ 覆盖版本需保证“无任何改动”不会被判为 completed(例如要求验收之外存在差异或由 Reviewer 明确给出理由)。
327
+ **验收**:单测;`spike:decision` 新增 `idle-chatter` 场景(Worker 只回复、不改代码、不宣称完成)→ 有界结束。
328
+
329
+ ### 阶段 2:终止与验证显式化(核心 3 个 PR + 可选 2 个)
330
+
331
+ 采用 **绞杀者模式**,每个 PR 只搬一个子生命周期,**不混入行为变更**:
332
+
333
+ | PR | 内容 | 必做 |
334
+ |---|---|---|
335
+ | 2.0 | **行为基线**:可控时钟(`node:test` 的 `mock.timers` 或注入 clock)+ 在 `automation-replay.test.ts` 的 `ReplayEventLog` 基础上录制 12–15 个场景的 **归一化事件序列**(事件类型 + 关键字段;剔除 `at` 与 `data` 内的时间戳如 `checkedAt`/`startedAt`、临时路径、耗时、成本) | ✅ |
336
+ | 2.1 | 抽出 `StopIntent`(保持 §3.2 A1、A2、A7),替换 `#stopRequested` `#stopVerification` `#stopCloseReason` `#preemptiveStop`,纳入 watchdog / 无输出 / abortStart 来源 | ✅ |
337
+ | 2.2 | 抽出 `VerificationCycle`(保持 §3.2 A3、A4、A5,§3.3 全部路径) | ✅ |
338
+ | 2.3 | 抽出 Decision 裁决管线,保持现有顺序与语义(含 §3.2 A6);事件增加结构化 guard 字段(B4) | ✅ |
339
+ | 2.4 | 抽出 `PublishCycle`(保持 §3.2 A5) | 可选 |
340
+ | 2.5 | 抽出 watchdog / usage(保持 §3.2 A7、A8);进一步缩减 `supervisor.ts` | 可选 |
341
+
342
+ **场景清单**(2.0):正常完成;验收失败→修复→通过;Reviewer revise→修复;截断证据→修复;本地 commit 缺失→修复;
343
+ 修复预算耗尽;重复发现→human;Decision stop→verify 通过/失败;可发布任务上的 Decision stop(普通 stop);
344
+ 操作员 stop 与 Decision stop 竞态;deadline 收尾内修复;Worker 无输出 idle;权限请求(允许/拒绝/AskUserQuestion);
345
+ 发布成功/中止后重新验证;启动中止。
346
+
347
+ **每个 PR 的验收**:归一化事件序列一致;全量单测通过;`spike:decision` 在两个模型上通过;独立 review 通过。
348
+ **完成标准**:`StopIntent` 与 `VerificationCycle` 有转换表与表驱动测试;§3.2 A 的每条现有行为有特征测试,
349
+ B 的每条目标不变量在对应的行为变更 PR 中有违反即失败的测试。
350
+
351
+ ### 阶段 3:恢复路径补齐(1–2 个 PR)
352
+
353
+ - 恢复后的 Worker 是新进程,因此 **不恢复进行中的验证**,而是恢复“它的结论”:
354
+ `DecisionSessionRecord` 升级 schema(`version` +1,旧记录按保守默认迁移),持久化
355
+ `lastVerification{ok, atTurn, findingSignature}` 与 guard 计数(含进度指纹基线)。
356
+ - 恢复后的任务先处于 `takeover()` 空闲状态,直到 resume-auto 或 `--extend` 才恢复自动化(`index.ts`);
357
+ 自动化恢复时:若最近验证失败,stale-failure / no-progress guard 从持久化计数继续;若中断时处于 Decision
358
+ verify-first 停止,恢复自动化后的第一个动作是一次验证而不是发送 Worker 轮次。
359
+ - 哪些终止会关闭恢复记录:清理已确认的 `completed` 与 `human_stop`。`blocked`、`failed`、关机停止
360
+ (`preserveDecisionSession` → `recoverable_failure`)、watchdog / 无输出停止、Decision 普通 stop、启动期间的停止或失败、
361
+ 清理未确认的停止都会标记为可恢复中断;`release()` 本身不改动记录(但验证中 release 会以 failed 结束,见 B5)。正是这些任务需要完整的恢复状态。
362
+ - 同时消除 `index.ts` 中两处重复的进度持久化逻辑(启动与恢复路径共用一个函数)。
363
+ - 测试:修复中途 recover、stop→verify 中途 recover;`spike:decision` 新增 `recover` 场景。
364
+
365
+ ### 阶段 4:验证环境与测试卫生(可与阶段 2 并行)
366
+
367
+ - **发版清单**:每次发版前在 cgroup v2 主机执行 `spike:decision`(至少两个模型)+ `spike:automation` + `spike:tmux`,
368
+ 结果记录到 `docs/stability-matrix-*.md`。
369
+ - **测试卫生**:依赖 `process.cwd()` 的测试改为各自创建临时 git 仓库;`environment.test.ts` 的可信可执行文件夹具
370
+ 放到可信父目录下(如 `$HOME` 下的 0700 目录),不能放在 `/tmp` 下。
371
+ - **Reviewer 解析**:按 D2 简化,不再追加防御层;以 `architecture.md` 记录简化后的威胁模型与已接受残余风险。
372
+ - **上游契约测试**:为 Claude Code `stream-json` / hooks 负载保存版本化夹具,版本升级时先跑契约测试。
373
+
374
+ ### 阶段 5:部署门槛(已决策:采纳方案 B,并入阶段 D 的 D7)
375
+
376
+ automatic 模式强制 cgroup v2,容器、macOS、部分 NAS/WSL 无法使用:
377
+
378
+ | 方案 | 说明 | 风险 |
379
+ |---|---|---|
380
+ | A. 维持现状 | 只在 cgroup v2 主机运行 | 可用范围窄 |
381
+ | B. 降级模式(**已采纳,见 D7**) | 复用现有 `PI_CLAUDE_SUPERVISOR_CGROUP_MODE`,自动模式默认 `auto`:cgroup 不可用时回退到进程组/进程树清理,每个任务都在事件与 `status` 标注“降级隔离”;`required` 可选 | setsid/daemon 化的后台进程可能残留 |
382
+ | C. 容器化隔离 | 每任务一个容器 | 复杂度与依赖显著上升,不建议现在做 |
383
+
384
+ ## 6. 本路线图之外、但影响长任务的已知风险
385
+
386
+ 以下议题中,tmux 屏幕/hooks 可靠性与策略拒绝已分别并入阶段 T 与阶段 D;其余作为后续独立议题跟踪:
387
+
388
+ - `tmux-adapter.ts`(2665 行):就绪判断依赖屏幕抓取(`isReadyScreen` / `readyStreak`),轮次检测依赖 hooks;
389
+ Claude TUI 变化时最脆弱。
390
+ - Decision Worker 自身在长任务中的上下文增长与压缩质量。
391
+ - `policy.ts`(1476 行)的权限拒绝是无人值守卡住的常见来源;阶段 0 报告应单列其统计。
392
+ - `index.ts`(1305 行)中启动与恢复路径的重复逻辑。
393
+
394
+ ## 7. 可观测性与审计
395
+
396
+ - `#humanGate` 的 `"permission"` 取值当前从未被赋值,可在阶段 2 清理。
397
+ - 新事件/字段(提案):`no_progress_detected`;`decision_overridden.guard`;`stop_intent_changed`;
398
+ `verification_stage_changed`;一个推导性的任务终态事件(如 `task_finished{outcome, reason}`)便于统计。
399
+ - 阶段 0 的报告命令作为唯一质量看板入口;不引入外部监控组件。
400
+
401
+ ## 8. 风险与对策
402
+
403
+ | 风险 | 对策 |
404
+ |---|---|
405
+ | 重构引入行为回归 | 可控时钟 + 归一化事件序列 + 每 PR 只搬一个生命周期 + 独立 review |
406
+ | 规格与现状不符导致“重构即改行为” | §3.2 区分“现有行为(保持)”与“目标不变量(显式变更)”;前者以代码为准 |
407
+ | 范围膨胀 | 阶段 2 只有 2.0–2.3 必做;可选项在指标证明必要时再做 |
408
+ | 放宽防御后出现破坏性操作 | 删除底线先于 D9 上线;§5.2 底线清单逐项保留并有测试 |
409
+ | [hyp] 项判断有误 | 先建 interactive 真实 spike,用实测确认后再改;未证实的项不作为行为变更依据 |
410
+ | LLM 行为随模型变化 | 以 guard 兜底而非 prompt;spike 多模型回归 |
411
+ | 无进展检测误伤合法的连续排查 | 先观察模式;阈值可配置;只看仓库指纹 |
412
+ | schema 迁移破坏旧恢复记录 | 版本化 + 保守默认 + 迁移测试 |
413
+
414
+ ## 9. 成功标准
415
+
416
+ - interactive 真实 spike(走适配器 + hooks)在发版清单中通过,覆盖 §5.1 验收列出的场景。
417
+ - 阶段 0 报告中,“发送失败 / 误判人工接管 / 证据不完整 / 策略误拒”导致的 park 归零或有明确解释;删除底线测试覆盖 §5.2 列出的全部示例。
418
+
419
+ - `StopIntent` 与 `VerificationCycle` 独立成模块,有转换表与表驱动测试;§3.2 A 有特征测试,B 已实现并有违反即失败的测试。
420
+ - 报告命令可给出无人值守完成率、park 原因分布、覆盖次数;阶段 1 启用覆盖后,“无验证运行到 deadline/配额耗尽”归零。
421
+ - recover 后所有确定性兜底仍然生效(阶段 3 测试)。
422
+ - 连续两个版本发版清单全部通过,且无因生命周期交互导致的回归。
423
+
424
+ ## 10. 建议顺序与当前最小版本
425
+
426
+ ```text
427
+ 第一波(速赢,S 级,确定性):T1 T2 T3 T4、D1 D3、0.3(B1 B3 B5)+ T11 hand-back、删除底线、0.1 报告、interactive 真实 spike
428
+ 第二波:T5 T6 T7 T8、D12(租约简化,先于 D7 与 T5 的重新 adopt)、D2 D4 D5 D6 D7 D8 D9 D10、0.2 校验拆分
429
+ 第三波:阶段1(无进展,先观察)→ 阶段2 核心(2.0→2.1→2.2→2.3)→ 阶段3(恢复补齐);T9 T10、D11、阶段4 并行
430
+ ```
431
+
432
+ **依赖与顺序理由**:第一波每项都小且独立,直接消除“静默卡死 / 无故 park / 杀掉会话”;删除底线必须先于 D9 放宽上线;
433
+ interactive 真实 spike 先建好,用于验证第一、二波中标 [hyp] 的项;阶段 2 重构放在行为修正之后,
434
+ 避免一边改行为一边搬代码。
435
+
436
+ **最小下一步**:T1(hook 路由,一行环境变量 + relay 路由键)与 T2/T3(发送确认与就绪轮询)——
437
+ 它们是 interactive tmux 下“静默失去监督”和“无故 park”的最大来源,改动小、可单测,且能用新的 interactive spike 实证。
package/docs/testing.md CHANGED
@@ -19,7 +19,7 @@ the published TypeScript source directly and there is no second runtime bundle.
19
19
 
20
20
  ## Test layers
21
21
 
22
- - `policy.test.ts`: deterministic local allow and hard-boundary deny behavior; legacy approval cannot override denial.
22
+ - `policy.test.ts`: deterministic local allow and hard-boundary deny behavior; legacy approval cannot override denial; the delete floor's allow/deny tables against a temp repo whose shared temp root is injected, so `..` is truly outside.
23
23
  - `state.test.ts`: legal and illegal lifecycle transitions.
24
24
  - `events.test.ts`: ordered JSONL persistence, sequence recovery and credential-shaped redaction.
25
25
  - `decision-session-store.test.ts`: atomic Decision Worker task mapping, permissions, restart discovery and corrupt-record isolation.
@@ -38,7 +38,8 @@ the published TypeScript source directly and there is no second runtime bundle.
38
38
  session.
39
39
  - `worker/environment.test.ts`: manual environment inheritance remains minimal, while
40
40
  automatic mode merges explicit overrides onto the complete inherited environment,
41
- removes only `CLAUDECODE` so nested Claude can run, adds a safe default permission
41
+ removes only `CLAUDECODE` so nested Claude can run, defaults
42
+ `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1` unless the caller set it, adds a safe default permission
42
43
  mode when omitted, resolves settings from effective `HOME`/`CLAUDE_CONFIG_DIR`, and
43
44
  rejects Bash preauthorization in CLI/settings configuration.
44
45
  - `supervisor.test.ts`: the no-output watchdog stops a stalled worker, lifecycle event failures are retried, and output is restored when event persistence fails.
@@ -262,7 +263,8 @@ isolated temporary worktree. The current hardening plan and evidence paths are r
262
263
  in [`docs/automation-hardening-plan.md`](automation-hardening-plan.md). Deterministic
263
264
  coverage now includes repairable-vs-persistent capability assertions, cancellation
264
265
  of acceptance commands, stop-from-verifying precedence, paused watchdog baselining,
265
- staged/untracked evidence, and untracked symlink/hard-link rejection. The exact-head
266
+ staged/untracked evidence, untracked symlinks and binaries named without their content
267
+ while the evidence stays complete, and untracked hard links still incomplete. The exact-head
266
268
  independent read-only review was rerun. Its pathname TOCTOU finding is recorded as a
267
269
  false positive for the trusted local-development threat model: automatic workers are
268
270
  trusted development agents, and this policy is a metadata guard rather than a host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.9.2",
3
+ "version": "0.10.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/config.ts CHANGED
@@ -150,6 +150,17 @@ export function deadlineWarningMs(env: NodeJS.ProcessEnv = process.env): number
150
150
  return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS, DEFAULT_DEADLINE_WARNING_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS");
151
151
  }
152
152
 
153
+ /**
154
+ * How long a human who typed into a supervised interactive session may leave
155
+ * it idle before unattended automation resumes on its own. Only a pause caused
156
+ * by typing into the pane resumes this way, never an explicit takeover.
157
+ */
158
+ export const DEFAULT_HUMAN_IDLE_RESUME_MS = 30 * 60_000;
159
+
160
+ export function humanIdleResumeMs(env: NodeJS.ProcessEnv = process.env): number {
161
+ return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_HUMAN_IDLE_RESUME_MS, DEFAULT_HUMAN_IDLE_RESUME_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_HUMAN_IDLE_RESUME_MS");
162
+ }
163
+
153
164
  /** Stop a Worker that has produced no output for this long (1 minute to 24 hours); "0" disables the check. */
154
165
  export function noOutputTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
155
166
  return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS, DEFAULT_NO_OUTPUT_TIMEOUT_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS");