From ef648cc89e8a0c690033af266a9d6237fa8bf396 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 14:56:58 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20docs(vscode):=20=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E7=A7=BB=E4=BA=A4=EF=BC=88handoff=EF=BC=89=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- docs/superpowers/specs/issue-handoff/spec.md | 143 +++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/issue-handoff/spec.md diff --git a/docs/superpowers/specs/issue-handoff/spec.md b/docs/superpowers/specs/issue-handoff/spec.md new file mode 100644 index 0000000..93ebfc9 --- /dev/null +++ b/docs/superpowers/specs/issue-handoff/spec.md @@ -0,0 +1,143 @@ +# 工单移交(handoff)设计 + +## 目标 + +同事在 A 机器上开发/测试完一个工单,把它整体交给 B 机器上的我继续(合并、解冲突、测试、续改)。B 机器上要能: + +- 重建实施 worktree(分支从 origin 拉) +- `claude --resume` 同事的头脑风暴 / 实施 / 测试会话,`codex resume` 审查会话 +- 之后所有既有流程(拖到完成、冲突解决、测试会话、恢复实施会话)照常工作 + +通道选 **Gitea 工单附件**:会话文件打成 tgz 挂在工单上,移交状态记在共享 state JSON 里。不引入 Tailscale / ssh 之类点对点传输。 + +## 事实前提 + +- Claude Code ≥ 2.1.223:`claude --resume ` 在任意目录可用,会搜本机全部 `~/.claude/projects/*/`;jsonl 内部旧 `cwd` 不存在也能续。**同一 sid 出现在两个 project 目录 → 报 "No conversation found"**。 +- 一个 claude 会话 = `/.jsonl` + 可选目录 `//`(`subagents/`、`tool-results/`)。两者必须一起搬。 +- codex 会话 = `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`,全局目录,与 worktree 无关。 +- Gitea 1.27:附件 `max_size=100MB`,允许 `.tgz`;`POST/GET/DELETE /repos/{owner}/{repo}/issues/{index}/assets[/{id}]`。 +- 共享 state JSON 由 `vscode/schemas/state-json.schema.json` 约束(`additionalProperties: false`),spx 写入前校验;本机字段走 workspaceState(`issues/localState.ts`)。 +- webhook `issues.assigned` 事件已会让受影响机器重载该工单。 +- 仅 Gitea 工单支持移交;YouTrack 工单不显示相关按钮。 + +## 术语 + +- **发送方**:当前持有 worktree / 会话的机器(同事)。 +- **接管方**:目标 assignee 的机器(我)。 +- **会话包**:`spx-handoff-issue-.tgz`。 + +## 会话包格式 + +``` +handoff.json 清单 +claude/.jsonl 每个 claude 会话 +claude//** 该会话的子目录(存在才有) +codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) +``` + +`handoff.json`: + +```json +{ + "version": 1, + "issue": 123, + "from": "chw", + "createdAt": "2026-08-26T08:00:00Z", + "branch": "feature/foo", + "sessions": { + "sessionId": "…", "implementSessionId": "…", "testSessionId": "…", "reviewSessionId": "…" + }, + "profiles": { + "profilePath": "/Users/chw/.claude/profiles/x.json", + "brainstormProfilePath": "…", "testProfilePath": "…" + }, + "claude": [ { "id": "", "kind": "brainstorm" | "implement" | "test" } ], + "codex": [ { "id": "", "relPath": "2026/08/26/rollout-….jsonl" } ] +} +``` + +`sessions` / `profiles` 只放存在的键;`claude` / `codex` 只列实际打进包里的文件。profile 路径原样带发送方绝对路径,接管方沿用现有 `resolveProfilePath` 按文件名重映射。 + +## 共享 state JSON 新字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `handoffAttachmentId` | string(minLength 1) | 会话包在 Gitea 的附件 id;存在即「待接管」 | +| `handoffFrom` | string(minLength 1) | 发送方 login | + +两个字段均为共享字段(不进 `LOCAL_STATE_FIELDS`),清除沿用 `''` 墓碑约定。四处字段清单同步:`schemas/state-json.schema.json`、`gitea/stateJson.ts` 与 `youtrack/stateComment.ts` 的 `KNOWN_STATE_FIELDS`、`cli/cmd/spx/main.go` 的 `knownStateFields`;`cli` 走 `make install` 重装(schema 由 Makefile `sync-schema` 拷贝)。 + +`Issue` 类型(`gitea/types.ts` 与 `webview-ui/src/types.ts` 镜像)新增 `handoffAttachmentId?`、`handoffFrom?`、`assignees: string[]`。`issueLoader.ts` 解析并透出这三个字段。`issues/update` 消息新增 `me?: string`(当前 Gitea login,token 无效时缺省)。 + +## 发送方:移交 + +入口:工单 tab 头部「移交」按钮。显示条件:`source !== 'youtrack'` 且 `column !== 'done'` 且无 `handoffAttachmentId`。 + +点击弹 webview 内 `HandoffModal`:请求 `handoff/users`,扩展用 `GET /repos/{owner}/{repo}/assignees` 返回候选(排除我),单选后提交 `handoff/start { issueNumber, to }`。无需再弹原生确认框。 + +扩展端 `handleHandoffStart(panel, issueNumber, to)` 顺序: + +1. 前置:workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机字段(四个 sid、三个 profile、`worktreePath`)与共享 `branch`。 +2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin ` 后 `HEAD` 必须等于 `origin/`。否则 toast「有未提交/未推送改动」并终止,什么都不改。 +3. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。 +4. 在 scratch 临时目录收集会话包:每个 sid 在 `~/.claude/projects/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`;`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。 +5. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。 +6. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。 +7. `updateIssueAssignees(… [to])`。 +8. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机字段全部写 `''` 墓碑。发送方磁盘上的会话文件保留。 +9. `issue/patch { handoffAttachmentId, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast。 + +回滚:5 失败 → toast 终止(终端已关,无其他改动);6 失败 → 删附件;7 失败 → 清回 6 的两个字段、删附件。全程 spinner toast,结束 `toast/dismiss`。 + +## 接管方:接管 + +入口:工单 tab 头部「接管」按钮 + 看板卡片「待接管」角标。显示条件:`handoffAttachmentId` 存在且 `assignees` 含 `me`。 + +扩展端 `handleHandoffAccept(panel, issueNumber)` 顺序: + +1. 前置同上。读共享 state 取 `handoffAttachmentId`、`branch`、`pr`。 +2. `GET …/assets/{id}` 取 `browser_download_url`,带 `Authorization: token` 下载到 scratch 临时目录,`tar -xzf`,解析并校验 `handoff.json`(`version === 1`、`issue === issueNumber`)。 +3. **重建 worktree**(`manifest.branch ?? state.branch` 存在时):`ensureWorktreeFromRemote({ workspaceRoot, worktreePath, branch })`——`git fetch origin `;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;`git worktree add -B origin/`。模板路径用设置 `worktreeDirectory`,slug = `branch` 去掉 `feature/` 前缀。之后跑 `post-create` 钩子。分支不存在于远端 → toast 终止(附件保留,可重试)。 +4. **安装会话**:`brainstorm` 会话装到 `projectsDirFor(workspaceRoot)`,`implement` / `test` 装到 `projectsDirFor(worktreeAbs)`(无 worktree 时也装到 workspaceRoot)。安装前先删本机所有 `~/.claude/projects/*/.jsonl` 与 `/`(避免重复 sid),再拷 jsonl + 目录。codex 文件按 `relPath` 拷到 `~/.codex/sessions/` 下。 +5. 写本机字段:清单里的四个 sid、三个 profile 路径、`worktreePath`。 +6. `mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' })`;`DELETE …/assets/{id}`(失败只 warn)。 +7. `issue/patch`(sid、worktreePath、worktreeExists、两个 handoff 字段置 null)+ 成功 toast。 + +幂等:3、4 可重复执行;6 之前失败附件仍在,按钮仍显示,用户可重试。 + +## 附带修正 + +完成列流程里「把 jsonl 拷回主仓库」(`panel/handlers/issues.ts` ~L388)改为整包**搬迁**:jsonl + `/` 一起移动到 `projectsDirFor(workspaceRoot)`,并删除源文件——否则新版 claude 因重复 sid 拒绝 resume。与接管方第 4 步共用同一个安装函数。 + +## 模块划分 + +| 模块 | 职责 | 测试 | +|---|---|---| +| `cc/sessionBundle.ts` | 纯 fs:`findClaudeSessionFiles(sid, projectsRoot)`、`installClaudeSession(sid, srcDir, dstProjectsDir, projectsRoot)`(先删本机所有副本再拷)、`codexRolloutPathFor(sid, codexRoot)`、`installCodexSession(relPath, srcFile, codexRoot)` | 临时目录 vitest | +| `cc/handoffManifest.ts` | 清单类型、`buildHandoffManifest(...)`、`parseHandoffManifest(json)` | 纯函数 vitest | +| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile)` / `unpackHandoff(file, dstDir)`,`execFile('tar')` | 真 tar 往返 vitest | +| `git/worktreePath.ts` | 从 `panel/handlers/sessions.ts` 抽出 `resolveWorktreeDir`、`deriveSlug`,新增 `slugFromBranch` | 纯函数 vitest | +| `git/worktree.ts` | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | `test/git/` mock child_process | +| `gitea/api.ts` | `listRepoAssignees`、`listIssueAttachments`、`uploadIssueAttachment`(FormData + Blob)、`getIssueAttachment`、`downloadAttachment`(arrayBuffer,不吞错)、`deleteIssueAttachment` | — | +| `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)`、`canAcceptHandoff(issue, me)`、`handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定 | vitest | +| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers` | — | +| webview | `HandoffModal.tsx`、`IssueDetailPanel` 两个按钮、`IssueCard` 角标、`useIssues` 三个 postMessage、`me` 状态 | — | + +消息(`src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts` 镜像): + +- webview → 扩展:`handoff/users { issueNumber }`、`handoff/start { issueNumber, to }`、`handoff/accept { issueNumber }` +- 扩展 → webview:`handoff/users-result { issueNumber, users: string[] }`;`issues/update` 增 `me`;`issue/patch` 增 `handoffAttachmentId`、`handoffFrom`、四个 sid(可 null) + +## 验证 + +- `pnpm test` / `pnpm lint` / `pnpm typecheck`;`cd cli && make test`。 +- 实施第一步先用 curl 验证 `Authorization: token` 能下载 `browser_download_url`(Gitea 私有仓库附件走 web 路由);不通则改走 `GET /attachments/{uuid}` + 等价路径,写进 `downloadAttachment`。 +- 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。 + +## 不做 + +- 撤销移交、移交历史。 +- 发送方保留 worktree。 +- YouTrack 工单移交。 +- 清理本机存量的重复 sid。