Files
web-terminal/docs/PLAN_WALKAWAY_WORKBENCH.md
Yaojia Wang 4f1d3ebc6b docs(v0.7): PRD + implementation plan for Walk-away Workbench (Band A + B)
Authored via multi-agent orchestration (PRD: Band A ∥ Band B → architect
review → integrate; Plan: frontend ∥ backend ∥ security → review → integrate).

- docs/FEATURE_WALKAWAY_WORKBENCH.md — PRD for 9 features:
  Band A (push+lock-screen approve, voice, quick-reply chips, activity
  timeline, stuck/idle alert) and Band B (read-only git diff, statusLine
  cost/context/PR gauges, UI worktree-create, plan-mode relay).
- docs/PLAN_WALKAWAY_WORKBENCH.md — 27 tasks (waves W-1..W4), disjoint Owns,
  contract resolutions, 30-item security checklist, dispatch schedule, AC map.

Planning only — no source changed.
2026-06-30 15:51:58 +02:00

537 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# v0.7 Walk-away Workbench — Implementation Plan (Phased)
> 版本: v0.7 · 来源真相: [`FEATURE_WALKAWAY_WORKBENCH.md`](./FEATURE_WALKAWAY_WORKBENCH.md) (PRD is source of truth for scope).
> 本文把 PRD 的 9 个特性 (A1A5, B1B4) 拆成**细粒度、单-owner-per-shared-file、可多 agent 并行**的任务,
> 合并了 FE / BE / Security 三份草案并**已应用评审修正 (#1#16)**。
> 约定遵循 [`PLAN.md`](./PLAN.md): 稳定任务 ID + `Owns:` disjoint 文件 + `Depends:` + 建议模型/隔离 + 依赖波次。
> 进度记录在 [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) (orchestrator 独写, subagent 只返回条目, G1)。
---
## 0. 多 Agent 并行规则 (开工前必读)
三条铁律 (同 PLAN.md §0, 此处不重复, 以 CLAUDE.md / PLAN.md 为准):
1. **文件所有权独占** — 每任务 `Owns:` 列出独占写的文件; **只有该任务可改这些文件**。所有新共享类型回 `src/types.ts` (T-types 独占冻结),模块内不本地重声明 (SP9)。
2. **只依赖接口不依赖实现** — 任务间经 `src/types.ts` 的 interface / 函数签名解耦; 接口在 W0 就绪后即可与实现方并行编码, 仅在集成/测试时汇合。
3. **LOG 由 orchestrator 独写** — subagent **不碰** `PROGRESS_LOG.md`; 把"条目模板"日志作为最终返回结果交回。遇文档未定义的歧义 → 停下返回 `[!] BLOCKED`, **绝不猜** (尤其 B2/B4 的 R0 字段)。
执行 agent: `module-builder` (TDD 实现单任务)、`module-reviewer` (只读复查/验收)。每任务**测试与实现同属一个 agent**, 不要拆分。
---
## 1. 依赖关系与并行波次 (Waves)
```
W-1 研究 spike (阻断 B2/B4 字段冻结)
R0 statusLine schema + --permission-mode 值集 + ExitPlanMode 投递 + decision-JSON 能否带 mode
W0 协调点 (串行, 阻塞下游)
T-types (冻结所有新共享类型 + 5 个契约决议见 §3) → T-config · T-spawn-env (可并行起草, 类型就绪即编码)
W1 叶子模块 (全部并行, 文件 disjoint —— 并行度最高)
后端新文件: N-push · N-timeline · N-diff-be · N-statusline-be(待R0) · N-worktree · N-statusline-script(待R0) · T-hook-intake
前端新文件: N-push-ui · N-voice(+keybar.ts) · N-quickreply · N-timeline-ui · N-diff-ui · T-sw(+sw-push.js) · T-preview-grid
W2 后端汇聚 (单 owner, T-manager → T-server-wire 串行; T-hooks-installer 并行)
T-manager (依赖 N-push/N-timeline) → T-server-wire (依赖全部 BE 任务 + T-manager) · T-hooks-installer
W3 前端汇聚 (单 owner, T-termsession → T-tabs; T-projects-ui 并行)
T-termsession (B2/B4 捕获) → T-tabs (依赖全部 N-*-ui + T-preview-grid + T-termsession) · T-projects-ui
W4 验证 (报告-only, 不跨 lane 改文件)
V-integration (真起 server + 临时 repo) · V-security (守卫/token/realpath 抽检) · V-fe-pwa (HTTPS push / 跨浏览器)
```
**关键并行事实**: W1 的 13 个任务零文件交叉, 全程可并行 (受 ~35 agent/批的甜区约束, 见 §6)。前端 N-*-ui 与后端 N-* 在 W1 全程并行; 仅在 W3 T-tabs/T-projects-ui 汇合时才需要 BE 端点就绪。
**显式依赖边** (PRD §7.5): `A5 → A1`(notifyService); `B4-FR7 → A1`(背景通知); `B2/B4 → R0`(schema); `T-server-wire → {N-push, N-diff-be, N-statusline-be, N-worktree, T-hook-intake, T-manager}`; `T-tabs → {T-server-wire 契约, T-termsession, T-preview-grid, 全部 N-*-ui}`; `所有 → T-types`
---
## 2. 协调点 (Shared Files — 触碰顺序)
每个共享文件**只有一个 owner 任务** (C3 修正)。下表是触碰顺序; 任何其它任务**只读 import**, 不得编辑。
| 共享文件 | 唯一 owner | 波次 | 合并的特性改动 |
|----------|-----------|------|----------------|
| `src/types.ts` | **T-types** | W0 | 全部新共享类型 + §3 的 5 个契约决议。**必须最先冻结**, 否则下游编译失败 |
| `src/config.ts` | **T-config** | W0 | 21 个新 env 解析 + L5 `--max-time`>`permTimeoutMs` 校验 |
| `src/session/session.ts` | **T-spawn-env** | W0 | B2 `WEBTERM_STATUSLINE_URL` + A1 `WEBTERM_NTFY_*` 注入; session 字段初始化; A5 stuck 重新武装 (见 §3.5 协调) |
| `src/http/hook.ts` | **T-hook-intake** | W1 | A4 扩展摄入 (`HookEventFull`: 保留 `at`/`tool_input`/`eventClass`); B4 `gate='plan'` 识别 |
| `src/session/manager.ts` | **T-manager** | W2 | A4 timeline append · B2 telemetry 存+广播+晚加入补发(M3) · A5 `sweepStuck()`(H4) · B4 gate · manager↔notifyService 注入(M5) |
| `src/server.ts` | **T-server-wire** | W2 | A1 `/push/*`+`/hook/decision`+改 hold 门(C1)+能力token · A4 `/…/events` · A5 reaper sweep · B1 `/projects/diff` · B2 `/hook/status` · B3 `/projects/worktree` · B4 扩 `/hook/permission` gate + approve mode + auto 拒绝 · `GET /config/ui`(#4) |
| `scripts/setup-hooks.mjs` | **T-hooks-installer** | W2 | A4 补 `PostToolUse`(H1) · A1 ntfy/Pushover 桥(env token, H3) · B2 statusLine 段(R0) · L5 `--max-time` 取自 config |
| `public/preview-grid.ts` | **T-preview-grid** | W1 | (#6) `renderTelemetryGauge` · `renderStatusBadge` · `statusText('stuck')`; **冻结导出签名供 W1 importer 安全使用** |
| `public/terminal-session.ts` | **T-termsession** | W3 | B2 telemetry 捕获+`onTelemetry` · B4 `pendingGate` 捕获 + `approve(mode?)` · `case 'telemetry'` + exhaustiveness |
| `public/tabs.ts` | **T-tabs** | W3 | B2 仪表 · A5 stuck 徽标 · B4 三按钮审批+mode选择+cmd · A1/A2/A3/A4 面板挂载 · **SP10 单一 `refreshTab` owner**(M4) |
| `public/projects.ts` | **T-projects-ui** | W3 | B1 diff 入口 · B3 New-worktree 表单 · A4 timeline 区 |
| `public/keybar.ts` | **N-voice** (#7) | W1 | A2 `mountKeybar(onSend, opts?)``onVoiceTrigger?`; **仅 N-voice 编辑**, 其它任务只 consume `mountKeybar` |
| `public/sw.js` + `public/sw-push.js` | **T-sw** | W1 | A1 `push`/`notificationclick` wiring (sw.js) + 纯可测 helpers (sw-push.js, #11) |
> **不在任何 Owns: 的共享文件**: `docs/PROGRESS_LOG.md` (orchestrator 独写)。
---
## 3. 契约决议 (Contract Resolutions) — T-types 必须先冻结
评审发现 FE↔BE 草案有 5 处类型契约冲突 (会编译失败或运行期静默错)。**这些必须在 W1 派发前于 T-types 一次性定死**, 否则前后端各写各的:
### 3.1 `TimelineEvent` — 用**派生语义 class** (评审 #1)
BE 草案用 `eventClass`(原始 hook 名), FE 用 `class`(语义集) —— 字段名与语义都冲突。**决议**: 服务端 `src/session/timeline.ts``deriveClass()` 把原始 hook 名映射为语义类, FE 直接消费, 不自造。
```ts
export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user';
export interface TimelineEvent {
at: number; // 服务端 Date.now()
class: TimelineClass; // 服务端派生的语义类 (FE icon/着色用)
toolName?: string; // sanitize 后的 tool_name (≤200, 去控制字符)
label: string; // 服务端派生人话 ("ran Bash", "edited 3 files")
}
```
### 3.2 `DiffLine.kind` — 一种拼写 + diff 解析只在后端 (评审 #2)
BE 用 `'+'|'-'|...`, FE 测试用 `'added'|'removed'|...` —— 冲突。**决议**: 用语义词拼写; **解析全在 `src/http/diff.ts` (N-diff-be)**, `public/diff.ts` (N-diff-ui) **render-only**, 删除 FE 的 `parseUnifiedDiff`/`parseNumstat`/`mergeDiffStats` (DRY)。
```ts
export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta';
export interface DiffLine { kind: DiffLineKind; text: string }
export interface DiffHunk { header: string; lines: DiffLine[] }
export type FileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'binary' | 'untracked';
export interface DiffFile { oldPath: string; newPath: string; status: FileStatus; added: number; removed: number; binary: boolean; hunks: DiffHunk[] }
export interface DiffResult { files: DiffFile[]; staged: boolean; truncated: boolean }
```
### 3.3 Push payload — 一种 shape, FE 读 `cls` (评审 #3)
BE 发 `{cls,...}`, FE sw.js 读 `payload.class`/`payload.detail` —— 字段名不符且缺 `detail`。**决议** (PRD A1.4 契约):
```ts
// push-service 发 / sw-push.js 读 (同一 shape):
interface PushPayload { sessionId: string; toolName?: string; detail?: string; token?: string; cls: NotifyClass }
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
```
### 3.4 `ClaudeStatus` 加 `'stuck'` + 服务端广播 (评审 #5)
A5 徽标 keys off `claudeStatus==='stuck'` (AC-A5.5), 但 BE 草案只 `notify('stuck')`, 既不置 status 也不广播。**决议**:
- T-types: `ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck'`
- T-manager `sweepStuck()`: 置 `session.claudeStatus='stuck'` **并** `broadcast({type:'status',status:'stuck'})` (不止 notify)。
- **重新武装** (T-spawn-env, §3.5): 新输出到达时若 status 仍是 `'stuck'` → 复位 `'working'` 并广播, 否则徽标永久卡住。
### 3.5 协议 + Session/Config 字段 + 跨任务协调
```ts
// 协议 (T-types + T-server protocol via T-types):
ClientMessage |= { type: 'approve'; mode?: PermissionMode } // B4
ServerMessage |= { type: 'telemetry'; telemetry: StatusTelemetry } // B2
// status 消息加 gate:
{ type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean; gate?: 'tool' | 'plan' }
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto'; // B4 (auto 待 R0 确认映射 bypassPermissions)
export interface StatusTelemetry { // B2 — 字段 BLOCKED on R0, 见 §W-1
contextUsedPct?: number; costUsd?: number; linesAdded?: number; linesRemoved?: number;
model?: string; effort?: string; pr?: { number: number; url: string; reviewState?: string };
rate?: { fiveHourPct?: number; sevenDayPct?: number }; at: number;
}
export interface PushSubscriptionRecord { endpoint: string; keys: { p256dh: string; auth: string }; createdAt: number }
export interface NotifyService { isEnabled(): boolean; notify(session: Session, cls: NotifyClass, token?: string): Promise<void> }
export interface CreateWorktreeResult { ok: boolean; path?: string; branch?: string; status?: number; error?: string }
// Session 加可变 runtime 字段 (同 buffer/lastOutputAt 的"不可变 meta 上挂可变 handle"例外):
interface Session { /* ... */ timeline: readonly TimelineEvent[]; stuckNotified: boolean; telemetry: StatusTelemetry | null }
// SessionManager 加方法:
handleStatusLine(id: string, telemetry: StatusTelemetry): void; // B2
sweepStuck(now: number): void; // A5 (用注入的 NotifyService)
handleHookEvent(sessionId, status, detail?, pending?, gate?, eventClass?, toolName?): void; // A4 扩展
// createSessionManager(cfg, notifyService?) — DI, 避免 manager 直 import push-service (循环依赖)
```
**单一遥测真相源 (评审 #15)**: 遥测只存 `TerminalSession.telemetry` (getter); `TabEntry` **不**重复存; `refreshTab``entry.session.telemetry`
**`/hook/decision` 响应 (评审 #14)**: 统一 `204` (SW 不读 body); 契约表对齐 204。
---
## 4. 任务清单 (Tasks)
> 状态图例: `[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。勾选与记录由 orchestrator 写入 PROGRESS_LOG.md。
> 模型路由: 轻活 Haiku、攻坚 Opus、其余 Sonnet。`isolation: worktree` 仅用于真正并发编辑的 builder。
### 4.0 任务总表 (Summary)
| ID | 标题 | 特性 | Owns (独占写) | Depends | 模型 | 隔离 | 学科 |
|----|------|------|---------------|---------|------|------|------|
| **R0** | 研究 spike (statusLine/permission-mode schema) | B2,B4 | (无源码; 产出 `docs/R0_FINDINGS.md`) | — | sonnet | — | research |
| **T-types** | 冻结新共享类型 + §3 契约 | 全部 | `src/types.ts` | R0(B2/B4 字段) | sonnet | — | BE |
| **T-config** | 21 新 env 解析 + L5 校验 | 全部 | `src/config.ts`, `test/config.test.ts`(扩) | T-types | sonnet | worktree | BE |
| **T-spawn-env** | spawn env 注入 + session 字段 + stuck 重新武装 | A1,A5,B2 | `src/session/session.ts`, `test/session.test.ts`(扩) | T-types | sonnet | — | BE |
| **N-push** | push-service + subscription-store | A1 | `src/push/push-service.ts`, `src/push/subscription-store.ts`, `test/push/*.test.ts` | T-types | **opus** | worktree | BE/sec |
| **N-timeline** | 有界事件环 + 标签/类派生 + sanitize | A4 | `src/session/timeline.ts`, `test/session/timeline.test.ts` | T-types | sonnet | worktree | BE |
| **N-diff-be** | `parseUnifiedDiff`/`parseNumstat`/`getDiff` | B1 | `src/http/diff.ts`, `test/http/diff.test.ts` | T-types | **opus** | worktree | BE/sec |
| **N-statusline-be** | `parseStatusLine` (tolerant, never throw) | B2 | `src/http/statusline.ts`, `test/http/statusline.test.ts` | T-types, **R0** | sonnet | worktree | BE |
| **N-worktree** | `validateBranchName`/`computeWorktreeDir`/`createWorktree` | B3 | `src/http/worktrees.ts`(扩), `test/http/worktrees-create.test.ts` | T-types | **opus** | worktree | BE/sec |
| **N-statusline-script** | `scripts/statusline.mjs` | B2 | `scripts/statusline.mjs` | **R0** | haiku | worktree | BE |
| **T-hook-intake** | `parseHookEvent``HookEventFull` (at/tool_input/eventClass/gate) | A4,B4 | `src/http/hook.ts`, `test/hook.test.ts`(扩) | T-types | sonnet | worktree | BE |
| **T-preview-grid** | 共享仪表/徽标组件 (SP10) | A4,A5,B2 | `public/preview-grid.ts`, `test/telemetry-gauge.test.ts` | T-types | sonnet | worktree | FE |
| **N-push-ui** | 订阅流 + 🔔 toggle + insecure-context 检测 | A1 | `public/push.ts`, `test/push.test.ts` | T-types | sonnet | worktree | FE/sec |
| **N-voice** | Web Speech 包装 + keybar 🎤 注入 | A2 | `public/voice.ts`, `public/keybar.ts`, `test/voice.test.ts` | T-types | sonnet | worktree | FE |
| **N-quickreply** | 内置 chips + 调色板 CRUD | A3 | `public/quick-reply.ts`, `test/quick-reply.test.ts` | T-types | sonnet | worktree | FE |
| **N-timeline-ui** | 时间线面板渲染 + 轮询 | A4 | `public/timeline.ts`, `test/timeline.test.ts` | T-types | sonnet | worktree | FE |
| **N-diff-ui** | diff 查看器 (render-only) | B1 | `public/diff.ts`, `test/diff.test.ts` | T-types | sonnet | worktree | FE/sec |
| **T-sw** | SW push/notificationclick + 纯 helpers | A1 | `public/sw.js`, `public/sw-push.js`, `test/sw-push.test.ts` | T-types | sonnet | worktree | FE/sec |
| **T-manager** | timeline/telemetry/sweepStuck/notifyService DI | A4,A5,B2 | `src/session/manager.ts`, `test/manager.test.ts`(扩) | T-types, N-push, N-timeline | **opus** | — | BE |
| **T-server-wire** | 全部新路由 + C1 hold 门 + token + 限流 + auto 拒绝 | 全部 | `src/server.ts`, `test/integration/*.test.ts` | N-push,N-diff-be,N-statusline-be,N-worktree,T-hook-intake,T-manager | **opus** | — | BE/sec |
| **T-hooks-installer** | `PostToolUse`+ntfy 桥+statusLine 段+`--max-time` | A1,A4,B2 | `scripts/setup-hooks.mjs`, `test/setup-hooks.test.ts`(若有) | T-config, R0 | sonnet | — | BE/sec |
| **T-termsession** | telemetry/gate 捕获 + `approve(mode?)` | B2,B4 | `public/terminal-session.ts`, `test/terminal-session.test.ts`(扩) | T-types | sonnet | — | FE |
| **T-tabs** | 仪表+stuck 徽标+3按钮审批+mode+面板挂载 | A1-A5,B2,B4 | `public/tabs.ts`, `test/tabs.test.ts`(扩) | T-preview-grid, T-termsession, N-push-ui, N-voice, N-quickreply, N-timeline-ui | **opus** | worktree | FE |
| **T-projects-ui** | diff 入口 + worktree 表单 + timeline 区 | A4,B1,B3 | `public/projects.ts`, `test/worktree-form.test.ts` | N-diff-ui, N-timeline-ui, T-server-wire 契约 | sonnet | worktree | FE/sec |
| **V-integration** | 端到端集成 (真起 server, 临时 repo) | 全部 | (报告-only) | W2,W3 | sonnet(reviewer) | — | QA |
| **V-security** | 守卫/token/realpath/XSS 抽检 | 全部 | (报告-only) | W2,W3 | sonnet(reviewer) | — | sec |
| **V-fe-pwa** | HTTPS push 注册 + 跨浏览器降级 | A1,A2 | (报告-only) | W3 | sonnet(reviewer) | — | QA |
**总计: 27 个任务** (1 研究 + 3 协调 + 14 W1 叶子 + 3 W2 后端 + 3 W3 前端 + 3 W4 验证)。
---
### 4.1 W-1 · 研究 spike (阻断)
#### R0 · statusLine / permission-mode schema 确认 `[ ]`
- **Wave**: W-1 · **Feature**: B2, B4 · **Owns**: 无源码 (产出 `docs/R0_FINDINGS.md`) · **Depends**: 无 · **Model**: sonnet · **Isolation**: —
- **Why blocking**: B2 `StatusTelemetry` 字段名 + B4 `permDecision` 能否携带 mode, **不得猜测** (H5)。先于 T-types 对 B2/B4 字段的冻结。
- **Steps**:
- [ ] 核实真实 Claude Code statusLine stdin JSON schema (字段名/类型: `context_window.used_percentage`? `cost.total_cost_usd`? `rate`(5h/7d)? `pr`?) — 用 Context7/官方文档/本机 `~/.claude` 实测。
- [ ] 核实 `--permission-mode` 接受值集 (`default`/`acceptEdits`/`plan`/`bypassPermissions`)。
- [ ] 核实 `ExitPlanMode` 是否经 `/hook/permission` 到达 (tool_name 形态)。
- [ ] 核实 `permDecision` 的 decision JSON 能否写回"更新后的 permission mode" (当前 `src/server.ts:61` 只发 behavior; **无证据**能带 mode — 若不能, 记录替代方案: plan 批准后改走 `claude` 输入/重启 with mode)。
- **Accept (AC-B2.6/AC-B4.6)**: 四项均有文档证据或明确"无法"记录; T-types/N-statusline-be/N-statusline-script/B4 路径据此实现; 未确认项 → 相关任务 `[!] BLOCKED`
---
### 4.2 W0 · 协调点 (串行, 阻塞下游)
#### T-types · 冻结新共享类型 + §3 契约 `[ ]`
- **Wave**: W0 · **Feature**: 全部 · **Owns**: `src/types.ts` · **Depends**: R0(仅 B2/B4 字段) · **Model**: sonnet · **Isolation**: —
- **Steps**:
- [ ] 应用 §3.1§3.5 全部决议: `TimelineEvent`(语义 `class`)、`DiffLine.kind`(语义词)、`PushPayload`/`NotifyClass`/`PushSubscriptionRecord`/`NotifyService``ClaudeStatus += 'stuck'``StatusTelemetry`(R0)、`PermissionMode``CreateWorktreeResult``DiffFile`/`DiffHunk`/`DiffResult`
- [ ] 协议扩展: `ClientMessage.approve.mode?``ServerMessage |= telemetry``status.gate?`
- [ ] `Session``timeline`/`stuckNotified`/`telemetry`; `SessionManager``handleStatusLine`/`sweepStuck`/扩 `handleHookEvent`; `createSessionManager(cfg, notifyService?)`
- [ ] `LiveSessionInfo` 可选带 `telemetry?` (缩略图墙)。
- [ ] B2/B4 字段若 R0 未完成 → 标 `// TODO(R0)` 并对该子集返回 BLOCKED, 不冻结。
- **Accept**: `tsc --noEmit` 通过; 无实现纯类型; §3 五项契约可被 FE/BE 双侧 import。
#### T-config · 21 新 env 解析 + L5 校验 `[ ]`
- **Wave**: W0 · **Feature**: 全部 · **Owns**: `src/config.ts`, `test/config.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps** (用既有 `parseNonNegativeInt`/`parseBool` 帮手; 评审 #16 确认 **21 个**新解析):
- [ ] A1: `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`(秘密)/`VAPID_SUBJECT`/`PUSH_STORE_PATH`/`PUSH_MAX_SUBS`(50)/`NOTIFY_DONE`(1)/`NOTIFY_DND`(0)/`DECISION_TOKEN_TTL_MS`(=permTimeoutMs)。
- [ ] A4: `TIMELINE_MAX`(200)/`TIMELINE_ENABLED`(1)。 A5: `STUCK_TTL`(600s×1000)/`STUCK_ALERT`(1)。
- [ ] B1: `DIFF_TIMEOUT_MS`(2000)/`DIFF_MAX_BYTES`(2MB)/`DIFF_MAX_FILES`(300)。 B2: `STATUSLINE_TTL_MS`(30000)。
- [ ] B3: `WORKTREE_ENABLED`(1)/`WORKTREE_ROOT`(undef→`<repo>-worktrees`)/`WORKTREE_TIMEOUT_MS`(10000)。
- [ ] B4: `DEFAULT_PERMISSION_MODE`(白名单 4 值, 默认 default)/`ALLOW_AUTO_MODE`(0)。
- [ ] **L5 校验**: `permTimeoutMs > 0`; 记录 `--max-time` 须 > permTimeoutMs (实际注入在 T-hooks-installer)。秘密缺失只记 "configured"/"missing", 不入日志。
- [ ] 返回对象 `Object.freeze`
- **Security**: SEC-C5 (VAPID 私钥不入日志); SEC-L4 (保留 `isHttpOrigin` 拒非 http(s) scheme); SEC-M4 (L5)。
- **Accept (AC-A1.6)**: 默认值/覆盖/非法值抛错; VAPID 未设 → push 标记禁用; `vitest run config` 全绿。
#### T-spawn-env · spawn env + session 字段 + stuck 重新武装 `[ ]`
- **Wave**: W0 · **Feature**: A1, A5, B2 · **Owns**: `src/session/session.ts`, `test/session.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: —
- **Steps**:
- [ ] spawn env 加 `WEBTERM_STATUSLINE_URL: http://127.0.0.1:${cfg.port}/hook/status` (B2); `WEBTERM_NTFY_*``process.env` 透传 (不在此设 token, H3)。
- [ ] session 初始化: `timeline: Object.freeze([])``stuckNotified: false``telemetry: null`
- [ ] `pty.onData`: 既有 `lastOutputAt=Date.now()` 旁加 `session.stuckNotified=false`; **§3.4 重新武装**: 若 `session.claudeStatus==='stuck'` → 置 `'working'` 并 broadcast `{type:'status',status:'working'}` (复用 session.ts 既有 broadcast util)。
- **协调注记**: claudeStatus 通常由 T-manager 改; 此处 onData 的复位是 A5 重新武装的唯一可靠点 (同 lastOutputAt 已在此处)。T-manager owner 知悉, 不重复实现。
- **Accept (AC-A5.2)**: 输出恢复后 stuck flag/status 复位; spawn env 含 statusline URL; `vitest run session` 全绿。
---
### 4.3 W1 · 叶子模块 (全部并行)
#### N-push · push-service + subscription-store `[ ]`
- **Wave**: W1 · **Feature**: A1 · **Owns**: `src/push/push-service.ts`, `src/push/subscription-store.ts`, `test/push/push-service.test.ts`, `test/push/subscription-store.test.ts` · **Depends**: T-types · **Model**: opus · **Isolation**: worktree
- **Steps**:
- [ ] `subscription-store`: `loadSubscriptionStore(path, maxSubs)`; `list()`(返回 frozen)/`add`(FIFO 封顶)/`remove`/`prune(dead[])`/`persist()`(`mode:0o600`)。缺文件→空; 坏 JSON→空 (best-effort, 校验 endpoint+keys)。
- [ ] `push-service` (实现 `NotifyService`): `isEnabled()`=两 VAPID key 均在; `notify(session, cls, token?)`: DND/`notifyDone` 短路; payload=§3.3 `PushPayload` (无 raw 输出/秘密); `tag=sessionId` (替换不堆叠, M6/A1-FR7); `requireInteraction` 仅 needs-input; 404/410→`prune`+`persist`; 其它错误记日志续发。
- **Security**: SEC-C5 (私钥不入日志); SEC-M1 (`PUSH_MAX_SUBS` 封顶); SEC-M2 (600 权限, 绝不经 GET); SEC-L1 (`web-push` 锁版本, `npm audit`); payload 最小化。
- **Accept**: `isEnabled` 真值表; needs-input/done/stuck 分支; 404/410 剪除; DND no-op; mock `web-push`; ≥80%。
#### N-timeline · 有界事件环 + 标签/类派生 `[ ]`
- **Wave**: W1 · **Feature**: A4 · **Owns**: `src/session/timeline.ts`, `test/session/timeline.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps** (纯函数, 零 DOM):
- [ ] `sanitizeField(s, max=200)`: 去 `\x00-\x1f`, 截断。
- [ ] `deriveClass(eventName)``TimelineClass` (§3.1: PreToolUse/PostToolUse→`tool`, PermissionRequest/Notification(permission_prompt)→`waiting`, Stop/SessionEnd→`done`, UserPromptSubmit→`user`)。
- [ ] `deriveLabel(eventName, toolName?)` → 人话 ("ran Bash"/"edited <tool>"/"waiting for approval"/"done")。
- [ ] `makeTimelineEvent(eventName, toolName, at)``TimelineEvent | null` (whitelist eventName, 否则 null)。
- [ ] `appendEvent(events, ev, maxLen)`: 返回**新数组**, 超 `maxLen` 淘汰最旧 (NEVER 改入参)。
- **Security**: SEC-H6 (sanitize 工具名/路径); SEC-M8 (环上限 append 时强制)。
- **Accept (AC-A4.4, AC-A4.5)**: 每类一例; sanitize/截断; `appendEvent` 不可变 + 淘汰 (append `MAX+10` → 长度 ≤ MAX); ≥80%。
#### N-diff-be · diff 解析 + `getDiff` `[ ]`
- **Wave**: W1 · **Feature**: B1 · **Owns**: `src/http/diff.ts`, `test/http/diff.test.ts` · **Depends**: T-types · **Model**: opus · **Isolation**: worktree
- **Steps**:
- [ ] `parseNumstat(out)``Map<path,{added,removed}>` (含 binary `-\t-`、rename)。
- [ ] `parseUnifiedDiff(patch, numstat)``DiffFile[]` (单/多 hunk、rename、new/`/dev/null`、delete、binary、空→[]; never throw; 坏行→context)。
- [ ] `getDiff(repoPath, {staged, cfg})`: `execFileAsync('git', ['diff','--no-color', staged?'--staged':[], '--'], {cwd,timeout:diffTimeoutMs,maxBuffer:diffMaxBytes})` + `--numstat` + `status --porcelain`(untracked, L3); `truncated = files>diffMaxFiles || patch.length>=diffMaxBytes`
- **Security**: SEC-H7 (路径三连校验在路由层, 此处 `--` 终结选项); SEC-M9 (maxBuffer/maxFiles DoS); SEC-H4 (diff 内容作为纯 text 字段返回, 不渲染)。FR-B1.9 `?base` **明确 P2 延后** (评审 #13; 需 `git rev-parse --verify` 白名单)。
- **Accept (AC-B1.1, AC-B1.5)**: numstat/unified 各分支; +/- 与 `git diff --numstat` 一致; `<script>` 内容原样入 `DiffLine.text`; ≥80% + 临时 repo 集成。
#### N-statusline-be · `parseStatusLine` `[ ]`
- **Wave**: W1 · **Feature**: B2 · **Owns**: `src/http/statusline.ts`, `test/http/statusline.test.ts` · **Depends**: T-types, **R0** · **Model**: sonnet · **Isolation**: worktree
- **Steps**: `parseStatusLine(body: unknown): StatusTelemetry | null``unknown`+逐字段收窄 (仿 `parseHookEvent`); 非对象→null; 缺/脏字段→undefined; 数值 `Number.isFinite` 校验; 恒设 `at:Date.now()`; never throw。
- **Security**: SEC-M7 (`unknown`+narrowing, 字符串长度上限)。 **若 R0 未确认字段名 → `[!] BLOCKED`** (H5)。
- **Accept (AC-B2.6)**: 满/缺/脏/空; never throw; ≥80%。
#### N-worktree · 分支校验 + worktree 创建 `[ ]`
- **Wave**: W1 · **Feature**: B3 · **Owns**: `src/http/worktrees.ts`(扩, 与 `listWorktrees` 同文件), `test/http/worktrees-create.test.ts` · **Depends**: T-types · **Model**: opus · **Isolation**: worktree
- **Steps**:
- [ ] `validateBranchName(b)`: 拒 空/`>250`/前导 `-`/`..`/尾 `.lock`/控制字符/空白/`~^:?*[\`/`@{`/首尾或连续 `/`
- [ ] `sanitizeBranchForDir(b)`: `/``-`, 去首尾 `-`, 不安全 FS 字符→`-`
- [ ] `computeWorktreeDir(repoPath, sanitized, root?)`: base=`root ?? <dirname>/<basename>-worktrees`; **M2: `fs.realpath(base)` + `realpath(candidate)` 后 `startsWith(realBase+sep)`** (防 symlink 绕过); 越界 throw。
- [ ] `createWorktree(repoPath, branch, {base?, worktreeRoot?, timeoutMs})`: validate → repoPath 三连校验 → computeDir → `execFileAsync('git',['worktree','add','-b',branch,'--',dir, base].filter)` ; 分类错 (branch exists→409/path exists→409/其它→500), `error` 安全文案不带 raw stderr。
- **Security (本特性最重)**: SEC-H2 (分支注入/前导 `-`); SEC-H3/M2 (realpath 包含); SEC-M10 (不泄 git stderr); `execFile` 无 shell + `--`
- **Accept (AC-B3.4, AC-B3.6)**: validate 每拒绝模式; symlink root 越界被拒; ≥80% + 端点集成 (真 repo)。
#### N-statusline-script · `scripts/statusline.mjs` `[ ]`
- **Wave**: W1 · **Feature**: B2 · **Owns**: `scripts/statusline.mjs` · **Depends**: **R0** · **Model**: haiku · **Isolation**: worktree
- **Steps**: 读 stdin statusLine JSON → `POST $WEBTERM_STATUSLINE_URL -H "X-Webterm-Session: $WEBTERM_SESSION" --data-binary @-`; echo 一行精简 (cost·ctx%·model) 给 Claude 状态行; web-terminal 外 (`WEBTERM_STATUSLINE_URL` 未设) → no-op。 **R0 未定 schema → BLOCKED**
- **Accept**: 本机 POST 到 `/hook/status` 成功; 非 web-terminal 环境静默。
#### T-hook-intake · `parseHookEvent` → `HookEventFull` `[ ]`
- **Wave**: W1 · **Feature**: A4, B4 · **Owns**: `src/http/hook.ts`, `test/hook.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps**: 新增 `HookEventFull {sessionId, status, detail?, at, eventClass, toolInput?, gate?}`; `parseHookEvent` 改返回该型 (status/detail 逻辑不变); 加 `at=Date.now()``eventClass`(原始 hook 名 whitelist)、`toolInput`(原样 unknown)、`gate='plan'` when `eventClass==='PermissionRequest' && tool==='ExitPlanMode'` (R0 待核)。
- **Security**: SEC-M7 (`unknown`+narrowing, never throw)。
- **Accept**: 既有 status 测试不回归; 新字段 (at/eventClass/gate) 有测试; ≥80%。
#### T-preview-grid · 共享仪表/徽标组件 (SP10) `[ ]`
- **Wave**: W1 · **Feature**: A4, A5, B2 · **Owns**: `public/preview-grid.ts`, `test/telemetry-gauge.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Why W1 (评审 #6, #8)**: 单一 owner; W1 即冻结导出签名, 让 W3 的 T-tabs/T-projects-ui 安全 import; **测试与实现同任务同波次** (不跨波)。
- **Steps**: 加 `renderTelemetryGauge(container, telemetry, staleTtlMs)` (上下文条+$成本+model chip+PR 徽标, `>80%` 变色, `>TTL` 置灰); `renderStatusBadge(container, status)` (含 `'stuck'`=⚠); 扩 `statusText()` 支持 `'stuck'`。**全程 `el()`/`textContent`, 零 `innerHTML`**; PR URL 仅用户点击复制/打开。
- **Security**: SEC-H5 (telemetry 字段 textContent); SEC-L5 (PR URL `new URL().protocol==='https:'` 校验后才入 href)。
- **Accept**: gauge 渲染 (缺字段宽容/陈旧置灰/`<script>`作文本/PR URL scheme 校验); ≥80%。
#### N-push-ui · 订阅流 + 🔔 toggle `[ ]`
- **Wave**: W1 · **Feature**: A1 · **Owns**: `public/push.ts`, `test/push.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps**: `checkPushSupport()` (unsupported/insecure-context/vapid-missing/permission-denied/available/subscribed); `fetchVapidKey()` (503→null); `subscribePush`/`unsubscribePush` (POST/DELETE `/push/subscribe`); `mountPushToggle(container, opts)` (insecure→灰铃+提示 HTTPS/Tailscale; vapid-missing→隐藏)。`localStorage['web-terminal:push-muted']` 仅影响**应用内** (评审 #12: A1-FR9 记为 in-app-only; 全局 DND 走 `NOTIFY_DND`)。
- **Security**: SEC-H8 (`isSecureContext` 检测); SEC-C4 路由侧 (本任务只发请求)。
- **Accept (AC-A1.1, AC-A1.5)**: 各 support 状态; insecure 隐藏不崩; mock SW/Notification/fetch; ≥80%。 (notificationclick 测试归 T-sw, 评审 #11)。
#### N-voice · Web Speech + keybar 🎤 `[ ]`
- **Wave**: W1 · **Feature**: A2 · **Owns**: `public/voice.ts`, `public/keybar.ts`, `test/voice.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps**: `isSpeechSupported()`; `createVoiceInput(onTranscript, opts?)``VoiceInput | null` (start/stop/dispose/isActive; interim 回调; `autoSend`→补 `\r`)。`keybar.ts`: `mountKeybar(onSend, opts?)``onVoiceTrigger?`, supported 时 append 🎤 (touchstart/touchend 推杆 + `preventDefault` 防软键盘)。**纯前端, 零服务端, 无音频抵达后端**。
- **Security**: SEC-L2 (Chrome→Google 音频披露文案; AC-A2.5 验无音频抵达服务端)。
- **Accept (AC-A2.x)**: 不支持隐藏不崩; interim/final; autoSend 补/不补 `\r`; dispose 后无回调; ≥80%。
#### N-quickreply · chips + 调色板 `[ ]`
- **Wave**: W1 · **Feature**: A3 · **Owns**: `public/quick-reply.ts`, `test/quick-reply.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps**: `BUILT_IN_CHIPS` (yes/continue/1/2/3/Esc); 不可变 CRUD (`addChip`/`removeChip`/`reorderChip`/`updateChip` 返回新数组); `loadPalette`/`savePalette` (localStorage, 坏数据不抛); `mountQuickReply(container,{onSend})` (点 chip→`onSend(text+(appendEnter?'\r':''))`; `+` 开内联编辑器; **标签 `textContent`**)。
- **Security**: SEC-L3 (snippet 标签 textContent, 防注入)。
- **Accept (AC-A3.x)**: CRUD 不可变; round-trip 持久; 坏数据不抛; appendEnter 行为; `<script>` 作文本; ≥80%。
#### N-timeline-ui · 时间线面板 `[ ]`
- **Wave**: W1 · **Feature**: A4 · **Owns**: `public/timeline.ts`, `test/timeline.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps**: `normalizeTimelineEvent(raw)` (安全收窄→null); `timelineIcon(cls)` (按 §3.1 `TimelineClass`); `renderTimelineEvent(ev)` ("HH:MM · icon · label", `textContent`, 复用 `relTime`); `fetchTimeline(id)` (err→[]); `mountTimeline(container,id,opts?)` (newest-first, 可见时轮询 `refreshMs`, `maxEvents` 上限, empty 态)。
- **Security**: SEC-H6 (label textContent)。
- **Accept (AC-A4.3)**: normalize 防御; icon 映射; textContent; 轮询起停; dispose 清 interval; ≥80%。
#### N-diff-ui · diff 查看器 (render-only) `[ ]`
- **Wave**: W1 · **Feature**: B1 · **Owns**: `public/diff.ts`, `test/diff.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps (评审 #2: render-only, 无解析)**: `fetchDiff(repoPath, staged)` (GET `/projects/diff`); `normalizeDiffResult(raw)` (→null if invalid); `renderDiffFile(file, opts)`/`renderDiff(result)` (按文件分组, +/- 徽标, 折叠, truncated 警告, 空态; **全程 `textContent`/`el()`**); `mountDiffViewer(container, repoPath, {onClose})` (working|staged 切换)。
- **Security (关键)**: SEC-H4 (diff 内容**全程 textContent, 零 innerHTML**)。
- **Accept (AC-B1.3, AC-B1.4)**: `<script>`/`&`/ANSI 作文本; truncated 警告; 空态; staged 切换; ≥80%。
#### T-sw · SW push/notificationclick + 纯 helpers `[ ]`
- **Wave**: W1 · **Feature**: A1 · **Owns**: `public/sw.js`, `public/sw-push.js`, `test/sw-push.test.ts` · **Depends**: T-types · **Model**: sonnet · **Isolation**: worktree
- **Steps (评审 #11: 抽纯 helper)**:
- [ ] `sw-push.js` (纯, 无 SW 全局): `buildPushNotification(payload)``{title, options}` (needs-input 带 Allow/Deny + `requireInteraction`, `tag=sessionId`); `resolveNotificationClick(notification, action)``{kind:'decision', body}` | `{kind:'focus', url}` (Allow/Deny→`/hook/decision`{sessionId,decision,token}; 正文→`/?session=<id>`)。
- [ ] `sw.js`: `importScripts('./sw-push.js')`; `addEventListener('push'/'notificationclick')` 调上面 helper; fetch bypass 扩 `/push/*` (除既有 `/term`/`/hook`)。
- **Security**: SEC-C1 (decision 带 token, 远程→Origin+token 在路由层校验); `credentials:'same-origin'`
- **Accept (AC-A1.2, AC-A1.3)**: `buildPushNotification` 各 class; `resolveNotificationClick` allow/deny/default 路由 (jsdom mock); ≥80% (live addEventListener 验证归 W4)。
---
### 4.4 W2 · 后端汇聚 (单 owner)
#### T-manager · timeline/telemetry/sweepStuck/notifyService `[ ]`
- **Wave**: W2 · **Feature**: A4, A5, B2 · **Owns**: `src/session/manager.ts`, `test/manager.test.ts`(扩) · **Depends**: T-types, N-push(NotifyService), N-timeline(appendEvent) · **Model**: opus · **Isolation**: —
- **Steps**:
- [ ] `createSessionManager(cfg, notifyService?)` DI (避免直 import push-service)。
- [ ] `handleStatusLine(id, telemetry)`: 存 `session.telemetry` + `broadcast({type:'telemetry',telemetry})` (B2)。
- [ ] `handleHookEvent(...)` 扩: `cfg.timelineEnabled && eventClass``session.timeline = appendEvent(...)`; broadcast status 带 `gate` (A4/B4)。
- [ ] `sweepStuck(now)` (A5/H4): 跳过 exited/idle/已 notified; `now-lastOutputAt > stuckTtlMs``claudeStatus='stuck'` + **broadcast status='stuck'** (§3.4 评审 #5) + `stuckNotified=true` + `notifyService?.notify(session,'stuck')`; `stuckAlert=0||stuckTtlMs=0` 禁用。**覆盖 attached 与 detached** 存活会话。
- [ ] `handleAttach` Case 2 (M3): 晚加入设备补发 `telemetry`(若有) + 当前 status (pending 部分留 T-server-wire)。
- [ ] `list()``telemetry?` (LiveSessionInfo)。
- **Security**: SEC-M3 (每 stuck 回合一告警, 重新武装在 T-spawn-env)。
- **Accept (AC-A5.1, AC-A5.3, AC-B2.3)**: sweepStuck 覆盖 attached、一回合一次; 晚加入补发遥测; ≥80%。
#### T-server-wire · 全部新路由 + C1 + token + 限流 `[ ]`
- **Wave**: W2 · **Feature**: 全部 · **Owns**: `src/server.ts`, `test/integration/push.test.ts`, `test/integration/timeline-events.test.ts`, `test/integration/worktree.test.ts` · **Depends**: N-push, N-diff-be, N-statusline-be, N-worktree, T-hook-intake, T-manager · **Model**: opus · **Isolation**: —
- **Steps**:
- [ ] 初始化: `loadSubscriptionStore``createPushService``createSessionManager(cfg, pushService)`; `pendingApprovals` 条目扩 `{res,timer,token,expiresAt}`
- [ ] **C1 hold 门修正 (`src/server.ts:288`, SEC-C2)**: `shouldHold = clients.size>0 || (pushService.isEnabled() && subStore.list().length>0)`; 仅 hold 路径铸 `crypto.randomUUID()` token + `void pushService.notify(session,'needs-input',token)`
- [ ] 新路由: `GET /push/vapid-key`(公钥, 503 if disabled); `POST/DELETE /push/subscribe`(**`requireAllowedOrigin`** SEC-C4 + 限流); `POST /hook/decision`(**远程**: `requireAllowedOrigin` SEC-C1 + 限流 + token 校验 {session+resolve/超时失效} → `resolvePending(permDecision(...))` → 返回 **204**); `GET /live-sessions/:id/events`(只读, `timelineEnabled` else []); `GET /projects/diff`(只读 + 路径三连校验 SEC-H7 → `getDiff`); `POST /hook/status`(**`isLoopback`** SEC-H1 → `parseStatusLine``handleStatusLine`); `POST /projects/worktree`(**`requireAllowedOrigin`** SEC-C3 + `worktreeEnabled``createWorktree`); `GET /config/ui`(只读, 返回 `{allowAutoMode}`, 评审 #4)。
- [ ] **限流 (评审 #9, SEC-H9)**: 每-IP 滑窗 — `/hook/decision` **≤10/min**、`/push/subscribe` **≤5/min** (内存 Map, 无外部包)。
- [ ] `/hook` 路由: 传 `eventClass`+`toolInput``handleHookEvent`; Stop/SessionEnd → `pushService.notify(session,'done')`
- [ ] `/hook/permission`: `gate='plan'` 透传; B4 approve 携带 mode。
- [ ] **WS approve mode (评审 #10, SEC-M5)**: 处理 `{type:'approve', mode}``permDecision(behavior, mode)`; **`ALLOW_AUTO_MODE=0` 时拒绝 `mode:'auto'` 降级 default** (即使越过 FE 隐藏到达)。
- [ ] reaper (`src/server.ts:317`): 同间隔加 `manager.sweepStuck(Date.now())` (H4, 无新 timer)。
- **Security**: SEC-C1/C2/C3/C4/H1/H7/H9/M1。
- **Accept (AC-A1.2, AC-A1.8, AC-B2.5, AC-B3.2)**: C1 零客户端但有订阅→hold; decision 坏/陈旧 token/外来 Origin→403; loopback 校验; 限流 429; auto 拒绝; 集成全绿。
#### T-hooks-installer · 安装器扩展 `[ ]`
- **Wave**: W2 · **Feature**: A1, A4, B2 · **Owns**: `scripts/setup-hooks.mjs`, `test/setup-hooks.test.ts`(若有) · **Depends**: T-config, R0 · **Model**: sonnet · **Isolation**: —
- **Steps**: H1 `FF_EVENTS``PostToolUse`; L5 `--max-time = ceil(PERM_TIMEOUT_MS/1000)+缓冲` (取自 env, 不写裸字面量); H3 ntfy/Pushover 桥 (仅 `WEBTERM_NTFY_URL`+`TOPIC` 存在时追加 curl, **token 走 `$WEBTERM_NTFY_TOKEN` env, 不入 settings.json/argv**, priority high/low); B2 statusLine 段 (R0 schema, 幂等 MARKER, `--remove` 干净)。
- **Security**: SEC-C6 (token 不入 settings.json/argv); SEC-M4 (`--max-time`>permTimeoutMs)。
- **Accept (AC-A1.7, AC-A4.5, AC-B2.1)**: `FF_EVENTS` 含 PostToolUse; ntfy 命令正确 priority 且 **grep settings.json 无 token 字面量**; statusLine 幂等/`--remove`
---
### 4.5 W3 · 前端汇聚 (单 owner)
#### T-termsession · telemetry/gate 捕获 + `approve(mode?)` `[ ]`
- **Wave**: W3 · **Feature**: B2, B4 · **Owns**: `public/terminal-session.ts`, `test/terminal-session.test.ts`(扩) · **Depends**: T-types · **Model**: sonnet · **Isolation**: —
- **Steps**: 加 `onTelemetry?` 回调 + `telemetry`/`pendingGate` getter (**单一遥测真相源**, 评审 #15); `handle()``case 'telemetry'` (+ exhaustiveness/assertNever); `status` case 记 `pendingGate = msg.gate ?? null`; `approve(mode?)``sendMsg({type:'approve', ...(mode?{mode}:{})})`
- **Accept (AC-B2.6)**: telemetry 捕获+回调; gate 记录; approve(mode) 序列化; ≥80% + `telemetry` 往返。
#### T-tabs · 仪表+徽标+审批+面板挂载 (最大 FE 任务) `[ ]`
- **Wave**: W3 · **Feature**: A1A5, B2, B4 · **Owns**: `public/tabs.ts`, `test/tabs.test.ts`(扩) · **Depends**: T-preview-grid, T-termsession, N-push-ui, N-voice, N-quickreply, N-timeline-ui · **Model**: opus · **Isolation**: worktree
- **Steps**:
- [ ] **SP10 单一 `refreshTab` owner (M4)**: stuck 徽标 (`claudeStatus==='stuck'`→⚠) + telemetry gauge (`renderTelemetryGauge`, 读 `entry.session.telemetry`)。
- [ ] B4 `updateApprovalBar` (`tabs.ts:193`): plan gate (`pendingGate==='plan'`)→**3 按钮** (Approve+Auto→`approve('acceptEdits')` / Approve+Review→`approve('default')` / Keep Planning→`reject()`); tool gate 仍 2 按钮 (无回归)。
- [ ] B4 `openProject(...,mode?)`: cmd 升级 `claude --permission-mode <mode>` (非 default 时); `loadDefaultMode()` (localStorage, `auto` 仅当 `allowAutoMode`); `loadUiConfig()` fetch `GET /config/ui`
- [ ] A1 `mountPushToggle` (构造器一次, 铃在 tabBar); A2 `setupVoice` (mic→`handleVoiceTrigger``createVoiceInput``sendToActive`, interim overlay); A3 `mountQuickReply` (`#quickreply` 行在 `#keybar` 上); A4 timeline 面板 toggle (`mountTimeline`, 懒挂、关 tab dispose)。
- [ ] `TabEntry``timelineHandle` (**不**加 telemetry, 评审 #15)。
- **Security**: SEC-M5 (auto 二次确认+警示, `allowAutoMode` 门); SEC-M6 (tag 去重已在 SW)。
- **Accept (AC-A5.5, AC-B2.2, AC-B4.2, AC-B4.3, AC-B4.4)**: stuck 徽标; tab 仪表; plan 3 按钮/tool 2 按钮无回归; auto 隐藏 when 0; ≥80%。
#### T-projects-ui · diff 入口 + worktree 表单 + timeline 区 `[ ]`
- **Wave**: W3 · **Feature**: A4, B1, B3 · **Owns**: `public/projects.ts`, `test/worktree-form.test.ts` · **Depends**: N-diff-ui, N-timeline-ui, T-server-wire 契约 · **Model**: sonnet · **Isolation**: worktree
- **Steps**: B1 `renderProjectDetail` 加 "View Diff" → `mountDiffViewer` 内联面板 (会话行用 cwd); B3 `renderNewWorktreeForm` (`validateBranchNameClient` 客户端预校验 + `createWorktree` POST + 成功 `hooks.onOpenProject(dir,label,'claude\r')`; **错误 `textContent`**); A4 "Activity" 区 (running session 各 `mountTimeline`, 重渲/onBack dispose)。
- **Security**: SEC-H4 (diff textContent); SEC-L3/H6 (错误/标签 textContent); B3 写端点 Origin 在路由层。
- **Accept (AC-B1.3, AC-B3.1)**: 表单校验; diff 内联; timeline 区; server 错误作 textContent; ≥80%。
---
### 4.6 W4 · 验证 (报告-only, 不跨 lane 改文件)
> 同 PLAN.md §5 G4: reviewer 只产 findings (标 severity + owning task); 修复派回 owner builder。
#### V-integration · 端到端集成 `[ ]`
- **Owns**: 无 (报告) · **Depends**: W2, W3 · **Model**: sonnet (reviewer)
- **Steps**: 真起 server + 临时 git repo — C1 hold 门 (零客户端有订阅→hold); timeline 实时 hook 轮询; diff 真 repo; worktree 创建并开 tab; plan-gate 3 按钮解决; stuck 越 TTL; statusline.mjs→遥测仪表; `TIMELINE_ENABLED=0`/`WORKTREE_ENABLED=0`/`STUCK_TTL=0` 禁用路径。
- **Accept**: PRD §8 全部 AC 对照运行通过。
#### V-security · 安全抽检 `[ ]`
- **Owns**: 无 (报告) · **Depends**: W2, W3 · **Model**: sonnet (security-reviewer)
- **Steps**: `/hook/decision` 坏 Origin/陈旧 token/loopback IP → 403; `/push/subscribe`/`/projects/worktree` 缺 Origin → 403; 限流 429; symlink 越界 worktree → 拒; diff/telemetry/timeline `<script>` 作文本; **grep settings.json + 进程 argv 无 ntfy/VAPID token 字面量** (AC-A1.7); VAPID 私钥不入日志; `web-push` `npm audit`
- **Accept (合并安全章节 §5)**: 安全表逐行核; 无 CRITICAL/HIGH 未决。
#### V-fe-pwa · HTTPS push + 跨浏览器 `[ ]`
- **Owns**: 无 (报告) · **Depends**: W3 · **Model**: sonnet (reviewer)
- **Steps**: HTTPS/Tailscale 上下文 push 订阅成功 + 锁屏 Allow/Deny 解决; 不安全上下文隐藏 🔔; Safari/iOS Web Speech 不支持→mic 隐藏; SW push 需加主屏→降级 ntfy 桥。
- **Accept (AC-A1.5, AC-A2.3, C2)**: 安全上下文订阅成功; 不安全/不支持优雅降级。
---
## 5. 安全检查清单 (折叠到任务)
完整缓解见 PRD §5 合并安全章节; 下表把每条挂到 owner 任务 (severity 同评审约定: CRITICAL=阻断合并):
| 安全项 | Severity | Owner 任务 |
|--------|----------|-----------|
| SEC-C1 `/hook/decision` Origin+能力token | CRITICAL | T-server-wire (+ T-sw 发 token) |
| SEC-C2 C1 hold 门修正 (零客户端有订阅→hold) | CRITICAL | T-server-wire |
| SEC-C3 `/projects/worktree` `requireAllowedOrigin` | CRITICAL | T-server-wire |
| SEC-C4 `/push/subscribe` (POST/DELETE) Origin | CRITICAL | T-server-wire |
| SEC-C5 VAPID 私钥不日志/不经 GET/缺失降级 | CRITICAL | T-config, N-push |
| SEC-C6 ntfy token 仅 env, 不入 settings.json/argv | CRITICAL | T-hooks-installer |
| SEC-H1 `/hook/status` loopback-only | HIGH | T-server-wire |
| SEC-H2 `validateBranchName` 前导`-`/注入 | HIGH | N-worktree |
| SEC-H3/M2 realpath 两端包含校验 | HIGH | N-worktree |
| SEC-H4 diff 内容 textContent | HIGH | N-diff-ui, T-projects-ui |
| SEC-H5 telemetry 字段 textContent + PR URL scheme | HIGH | T-preview-grid |
| SEC-H6 timeline label sanitize + textContent | HIGH | N-timeline, N-timeline-ui |
| SEC-H7 `/projects/diff` 路径三连 + `--` | HIGH | N-diff-be, T-server-wire |
| SEC-H8 push 需安全上下文检测 | HIGH | N-push-ui |
| SEC-H9 限流 (decision≤10/min, subscribe≤5/min) | HIGH | T-server-wire |
| SEC-M1 `PUSH_MAX_SUBS` 封顶 | MEDIUM | N-push |
| SEC-M2 订阅库 600 / 不经 GET | MEDIUM | N-push |
| SEC-M3 stuck 每回合一告警 + 重新武装 | MEDIUM | T-manager, T-spawn-env |
| SEC-M4 `--max-time`>permTimeoutMs | MEDIUM | T-config, T-hooks-installer |
| SEC-M5 `ALLOW_AUTO_MODE=0` 服务端拒 auto | MEDIUM | T-server-wire (+ T-tabs 隐藏, T-types 白名单) |
| SEC-M6 tag 去重 | MEDIUM | N-push, T-sw |
| SEC-M7 `unknown`+narrowing (statusline/hook) | MEDIUM | N-statusline-be, T-hook-intake |
| SEC-M8 timeline 环上限 | MEDIUM | N-timeline |
| SEC-M9 diff DoS (maxBuffer/maxFiles) | MEDIUM | N-diff-be |
| SEC-M10 不泄 git stderr | MEDIUM | N-worktree |
| SEC-L1 `web-push` 锁版本/audit | LOW | N-push, V-security |
| SEC-L2 音频隐私披露 | LOW | N-voice |
| SEC-L3 snippet 标签 textContent | LOW | N-quickreply |
| SEC-L4 `ALLOWED_ORIGINS` scheme 校验保留 | LOW | T-config |
| SEC-L5 PR URL https scheme 校验 | LOW | T-preview-grid |
---
## 6. 建议的多 Agent 分派排程 (Dispatch Schedule)
> 单批并行控制在 ~35 个 agent (官方甜区)。worktree 隔离前提: 先 commit W0 产物再派 W1 隔离 builder。
| 批次 | 波次 | 并行任务 (≤5/批) | 说明 |
|------|------|------------------|------|
| 1 | W-1 | **R0** | 串行研究 spike, 阻断 B2/B4 字段 |
| 2 | W0 | **T-types** | 串行冻结契约 (§3 五项决议), 全员依赖 |
| 3 | W0 | **T-config, T-spawn-env** | 2 并行 (类型就绪) |
| 4a | W1 | **N-push, N-diff-be, N-worktree** | 后端高风险 (opus×2+opus) |
| 4b | W1 | **N-timeline, N-statusline-be, N-statusline-script, T-hook-intake** | 后端其余 (statusline 待 R0) |
| 5a | W1 | **T-preview-grid, N-diff-ui, N-timeline-ui** | 前端 (preview-grid 先, 供 W3) |
| 5b | W1 | **N-push-ui, N-voice, N-quickreply, T-sw** | 前端其余 |
| 6 | W2 | **T-manager** → 然后 **T-server-wire**; **T-hooks-installer** 并行 | manager 先于 server-wire (串行); installer 独立文件并行 |
| 7 | W3 | **T-termsession** → 然后 **T-tabs**; **T-projects-ui** 并行 | termsession 先 (tabs 依赖 onTelemetry); tabs/projects 文件 disjoint 可并行 |
| 8 | W4 | **V-integration, V-security, V-fe-pwa** | 3 reviewer 并行, 报告-only |
> 批 4a/4b 与 5a/5b 共 13 个 W1 任务全 disjoint, agent 充足可并发更多; 不足则按批跑。
---
## 7. 验收映射 (AC → 任务)
> 全局门槛: 纯核心 (diff/statusLine/timeline 解析、branch/dir 校验、push payload、label/类派生) 单测 ≥80%; 端点经集成测试 (真起 server); A1 push 在模拟 TLS/安全上下文注册成功。
| 特性 | 关键 AC (PRD §8) | 主验收任务 |
|------|------------------|-----------|
| **A1** | C1 零 tab 锁屏解决(AC-A1.2)、不安全上下文隐藏铃(AC-A1.5)、VAPID 未设 503(AC-A1.6)、token 不入 settings.json(AC-A1.7)、坏/陈旧 token 403(AC-A1.8) | N-push, N-push-ui, T-sw, T-server-wire, T-hooks-installer, V-integration, V-security, V-fe-pwa |
| **A2** | 按住说话打进终端、软键盘不弹、不支持隐藏、无音频抵达(AC-A2.5) | N-voice, T-tabs, V-fe-pwa |
| **A3** | 内置+持久调色板同路径发送、标签惰性文本(AC-A3.4) | N-quickreply, T-tabs |
| **A4** | 人话时间线、`/…/events` 受界、`PostToolUse`(AC-A4.5)、sanitize(AC-A4.4)、`TIMELINE_ENABLED=0` 空 | N-timeline, N-timeline-ui, T-hook-intake, T-manager, T-hooks-installer, T-projects-ui/T-tabs |
| **A5** | 静默一次告警、恢复重新武装(AC-A5.2)、覆盖 attached 无新 timer(AC-A5.3)、stuck 徽标(AC-A5.5)、0 禁用 | T-manager, T-spawn-env, T-server-wire, T-tabs, T-preview-grid |
| **B1** | 结构化 diff+numstat 一致、非 git→404、超大 truncated、`<script>` 作文本(AC-B1.4)、解析≥80%(AC-B1.5)、untracked(L3) | N-diff-be, N-diff-ui, T-server-wire, T-projects-ui |
| **B2** | tab 仪表、晚加入补发(M3, AC-B2.3)、缺字段宽容/陈旧置灰、非本机 403(AC-B2.5)、`telemetry` 往返、R0 确认 | N-statusline-be, N-statusline-script, T-manager, T-server-wire, T-termsession, T-preview-grid, T-tabs |
| **B3** | 建出并开 agent、缺 Origin 403、非法分支拒、realpath 拒 symlink(AC-B3.4)、并行不互踩、纯+集成 | N-worktree, T-server-wire, T-projects-ui, V-security |
| **B4** | plan 启动、3 按钮 approve+auto 切 acceptEdits(AC-B4.2)、tool 2 按钮无回归、`ALLOW_AUTO_MODE=0` 禁 auto、非法 mode 拒、R0 确认(AC-B4.6) | T-types, T-termsession, T-tabs, T-server-wire, R0 |
| **跨特性** | 共享文件单 owner(C3)、env 命名(L4)、`--max-time`>permTimeoutMs(L5)、共享仪表无 DOM 争用(M4) | §2 协调点, T-config, T-hooks-installer, T-preview-grid |
---
## 8. 评审修正落点速查 (Review Fixes → 本计划)
| # | 修正 | 落点 |
|---|------|------|
| 1 | `TimelineEvent` 用派生语义 `class` | §3.1, T-types, N-timeline `deriveClass` |
| 2 | `DiffLine.kind` 一种拼写 + diff 只在后端解析 | §3.2, N-diff-be(解析), N-diff-ui(render-only) |
| 3 | Push payload 一种 shape, FE 读 `cls` | §3.3, N-push, T-sw |
| 4 | 加 `GET /config/ui` | T-server-wire, T-tabs `loadUiConfig` |
| 5 | `'stuck'` 入 union + 服务端广播 + 重新武装 | §3.4, T-types, T-manager, T-spawn-env |
| 6 | `preview-grid.ts` 单 owner | §2, **T-preview-grid** (新增 W1 任务) |
| 7 | `keybar.ts` 归属明确 | §2, **N-voice** owns keybar.ts (非 T-sw) |
| 8 | gauge 测试与实现同任务同波次 | T-preview-grid (`test/telemetry-gauge.test.ts` 同 W1) |
| 9 | 限流收紧 (decision≤10/min, subscribe≤5/min) | T-server-wire |
| 10 | 服务端 `ALLOW_AUTO_MODE` 拒 auto | T-server-wire |
| 11 | SW 抽纯 helper 可测 | T-sw (`sw-push.js` + `test/sw-push.test.ts`) |
| 12 | A1-FR9 mute 记为 in-app-only | N-push-ui (注记), 全局 DND 走 `NOTIFY_DND` |
| 13 | FR-B1.9 `?base` 明确延后 P2 | N-diff-be (注记) |
| 14 | `/hook/decision` 响应 204 | §3.5, T-server-wire |
| 15 | 遥测单一真相源 (TerminalSession) | §3.5, T-termsession, T-tabs (TabEntry 不重复) |
| 16 | config 计数确认 21 | T-config |
---
## 9. 一句话总结
> v0.7 拆成 **27 个单-owner-per-shared-file 任务**, 4 波 (协调→叶子并行→后端汇聚→前端汇聚) + 1 阻断研究 (R0) + 3 报告-only 验证; 5 处 FE↔BE 类型契约在 §3 于 T-types 一次定死; 安全 30 项折叠到 owner; **服务端仍不解析一个终端字节** (新增仅 git 子进程、带外 JSON、出站推送签名)。