diff --git a/docs/superpowers/plans/issue-handoff/plan.md b/docs/superpowers/plans/issue-handoff/plan.md new file mode 100644 index 0000000..c929f99 --- /dev/null +++ b/docs/superpowers/plans/issue-handoff/plan.md @@ -0,0 +1,2514 @@ +# 工单移交(handoff)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 同事在 A 机把工单(worktree + claude/codex 会话)打包挂到 Gitea 工单附件并重新指派,我在 B 机一键接管:重建 worktree、装回会话文件、写本机字段,之后 resume / 合并 / 解冲突照常。 + +**Architecture:** 会话包 `spx-handoff-issue-.tgz`(`handoff.json` 清单 + `claude/` + `codex/`)作为 Gitea 工单附件传输;共享 state JSON 新增两个字符串字段 `handoffAttachmentId` / `handoffFrom` 标记「待接管」;接管方用 `git worktree add -B origin/` 重建 worktree,再把会话文件装到对应 `~/.claude/projects//` 目录(先删本机所有同 sid 副本)。纯 fs / 纯决策逻辑拆成小模块配 vitest;副作用编排放 `panel/handlers/handoffFlow.ts`。 + +**Tech Stack:** TypeScript、VS Code Extension API、Node 22 全局 `fetch`/`FormData`/`Blob`、`tar` CLI、React 19 + Tailwind 4(webview)、Vitest、Go(spx 的 schema 副本)。 + +**Spec:** `docs/superpowers/specs/issue-handoff/spec.md` + +## Global Constraints + +- 所有命令在 `/home/cruldra/Sources/superwork/vscode` 下执行(`pnpm test` / `pnpm lint` / `pnpm typecheck`;Go 用 `cd cli && make test`)。 +- 代码文件用 Serena MCP 工具读写(`mcp__serena__find_symbol` / `replace_symbol_body` / `insert_after_symbol` / `replace_content`);非代码文件(md / json)才用 Read/Edit。 +- 提交信息格式:emoji + `type(scope)` + 中文描述(参考 `git log`,例:`✨ feat(vscode): …`、`♻️ refactor(vscode): …`、`🐛 fix(vscode): …`)。提交信息末尾加一行 `Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph`。 +- 注释只讲「为什么 / 坑」,不复述代码,不留历史痕迹(不写「原来是…现在改成…」)。 +- `src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts`、`src/gitea/types.ts` 与 `webview-ui/src/types.ts` 是手工镜像,改一处必改另一处。 +- state JSON 清空字段用 `''` 墓碑;`issue/patch` 清空字段用 `null`(`undefined` 会被 postMessage 丢掉)。 +- 移交只支持 Gitea 工单(`issue.source !== 'youtrack'`)。 +- 仅 POSIX:打包/解包直接 `execFile('tar', …)`。 +- 会话 id 与 `~/.claude/projects` 目录编码:`encodeCwdForProjectsDir(abs) = abs.replace(/[/.]/g, '-')`,`projectsDirFor(abs) = ~/.claude/projects/`(已有,`src/cc/sessionWatcher.ts`)。 + +--- + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `schemas/state-json.schema.json` | 改 | 新增 `handoffAttachmentId` / `handoffFrom` | +| `cli/cmd/spx/main.go` | 改 | `knownStateFields` 加两项;`make sync-schema` 同步 schema 副本 | +| `src/gitea/stateJson.ts`、`src/youtrack/stateComment.ts` | 改 | `KNOWN_STATE_FIELDS` 加两项 | +| `src/gitea/types.ts`、`webview-ui/src/types.ts` | 改 | `Issue` 加 `handoffAttachmentId?`、`handoffFrom?`、`assignees?` | +| `src/gitea/issueLoader.ts` | 改 | 解析并透出上述字段 | +| `src/panel/messages.ts`、`webview-ui/src/lib/messages.ts` | 改 | 新消息 + `issue/patch` 字段 + `issues/update.me` | +| `src/git/worktreePath.ts`(新) | 新 | `deriveSlug` / `slugFromBranch` / `resolveWorktreeDir` / `DEFAULT_WORKTREE_TEMPLATE`(从 `sessions.ts` 抽出) | +| `src/git/worktree.ts` | 改 | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | +| `src/cc/sessionBundle.ts`(新) | 新 | claude/codex 会话文件查找、安装(去重)、codex rollout 定位 | +| `src/cc/handoffManifest.ts`(新) | 新 | 清单类型、附件名、`parseHandoffManifest` | +| `src/cc/handoffBundle.ts`(新) | 新 | `stageHandoffBundle` / `installHandoffBundle`(组合 sessionBundle + manifest) | +| `src/cc/handoffArchive.ts`(新) | 新 | `packHandoff` / `unpackHandoff`(tar) | +| `src/gitea/api.ts` | 改 | assignees 列表、附件 list/upload/get/download/delete | +| `src/panel/handlers/handoff.ts`(新) | 新 | 纯决策与 payload:`canStartHandoff`、`canAcceptHandoff`、`checkWorktreePushed`、四个 payload 函数 | +| `src/panel/handlers/handoffFlow.ts`(新) | 新 | `handleHandoffUsers` / `handleHandoffStart` / `handleHandoffAccept` | +| `src/panel/KanbanPanel.ts` | 改 | 三条消息分发;`issues/update` 带 `me` | +| `src/panel/handlers/issues.ts` | 改 | 完成列 jsonl 拷贝改为 `installClaudeSession` 搬迁 | +| `webview-ui/src/components/HandoffModal.tsx`(新) | 新 | 选人弹窗 | +| `webview-ui/src/hooks/useIssues.ts`、`App.tsx`、`BottomTabs.tsx`、`IssueDetailPanel.tsx`、`IssueCard.tsx` | 改 | 按钮、角标、状态、消息 | +| `README.md`、`package.json` | 改 | 文档、版本号 | + +--- + +### Task 1: state JSON 字段 + Issue 类型 + loader + +**Files:** +- Modify: `schemas/state-json.schema.json` +- Modify: `cli/cmd/spx/main.go:391-411` +- Modify: `src/gitea/stateJson.ts:22` +- Modify: `src/youtrack/stateComment.ts:23` +- Modify: `src/gitea/types.ts:10-99` +- Modify: `webview-ui/src/types.ts:3+` +- Modify: `src/gitea/issueLoader.ts:70-203, 293-397` +- Modify: `src/panel/messages.ts:43,45` +- Modify: `webview-ui/src/lib/messages.ts`(同两行) + +**Interfaces:** +- Produces: `Issue.handoffAttachmentId?: string`、`Issue.handoffFrom?: string`、`Issue.assignees?: string[]`;`issues/update` 消息多 `me?: string`;`issue/patch` 支持 `handoffAttachmentId?: string | null`、`handoffFrom?: string | null`、`assignees?: string[]`、`sessionId?: string | null`、`testSessionId?: string | null`。 + +- [ ] **Step 1: schema 加字段** + +在 `schemas/state-json.schema.json` 的 `properties` 末尾(`autoReview` 之后)追加: + +```json + "handoffAttachmentId": { + "type": "string", + "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。", + "minLength": 1 + }, + "handoffFrom": { + "type": "string", + "description": "发起移交的 Gitea login。", + "minLength": 1 + } +``` + +- [ ] **Step 2: Go 侧同步** + +`cli/cmd/spx/main.go` 的 `knownStateFields` 加: + +```go + "handoffAttachmentId": {}, + "handoffFrom": {}, +``` + +运行:`cd cli && make sync-schema && make test` +Expected: `ok .../internal/state`(schema_test 通过,副本一致) + +- [ ] **Step 3: TS 两处 KNOWN_STATE_FIELDS** + +`src/gitea/stateJson.ts:22` 与 `src/youtrack/stateComment.ts:23` 的数组末尾各加 `'handoffAttachmentId', 'handoffFrom'`。 + +- [ ] **Step 4: Issue 类型(两份镜像)** + +`src/gitea/types.ts` 的 `Issue` 在 `autoReview?: boolean` 之后加: + +```ts + /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */ + assignees?: string[] + /** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */ + handoffAttachmentId?: string + /** 发起移交的 Gitea login。 */ + handoffFrom?: string +``` + +`webview-ui/src/types.ts` 的 `Issue` 加同样三个字段。 + +- [ ] **Step 5: issueLoader 解析** + +`src/gitea/issueLoader.ts` `parseColumnFromComments`: +- 返回类型加 `handoffAttachmentId?: string`、`handoffFrom?: string` +- `obj` 断言类型加 `handoffAttachmentId?: unknown`、`handoffFrom?: unknown` +- 在 `const autoReview = …` 后加: + +```ts + const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0 + ? obj.handoffAttachmentId + : undefined + const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0 + ? obj.handoffFrom + : undefined +``` +- 返回对象加 `handoffAttachmentId, handoffFrom`。 + +`buildIssue`:解构处加 `handoffAttachmentId, handoffFrom`;返回对象 `htmlUrl` 之前加: + +```ts + ...(handoffAttachmentId ? { handoffAttachmentId } : {}), + ...(handoffFrom ? { handoffFrom } : {}), + assignees: (issue.assignees ?? []).map(a => a.login), +``` + +- [ ] **Step 6: 消息类型(两份镜像)** + +`src/panel/messages.ts:43` 改为: + +```ts + | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string } +``` + +`:45` 的 `issue/patch` patch 对象:`sessionId?: string` → `sessionId?: string | null`,`testSessionId?: string` → `testSessionId?: string | null`,并在末尾追加 `, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null`。 + +`webview-ui/src/lib/messages.ts` 同两行做同样修改。 + +- [ ] **Step 7: KanbanPanel 发 `me`** + +`src/panel/KanbanPanel.ts` `loadAndPush` 里 `this.postMessage({ type: 'issues/update', … })` 之前加 `const me = await loginForToken(host, token)`(import `loginForToken` from `'../auth/identity'`),消息里加 `me`。 + +- [ ] **Step 8: 校验并提交** + +Run: `pnpm typecheck && pnpm lint && pnpm test` +Expected: 全部通过。 + +```bash +git add schemas cli src webview-ui +git commit -m "✨ feat(vscode): state JSON 新增 handoffAttachmentId/handoffFrom,Issue 透出 assignees" +``` + +--- + +### Task 2: `git/worktreePath.ts`(抽出 slug / 模板解析) + +**Files:** +- Create: `src/git/worktreePath.ts` +- Create: `src/git/worktreePath.test.ts` +- Modify: `src/panel/handlers/sessions.ts:636-680`(删除私有 `deriveSlug` / `resolveWorktreeDir`,改 import)、`:768-770` + +**Interfaces:** +- Produces: + - `export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name'` + - `export function deriveSlug(planFile: string): string` + - `export function slugFromBranch(branch: string): string` + - `export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string` + +- [ ] **Step 1: 写测试** + +`src/git/worktreePath.test.ts`: + +```ts +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' +import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir, slugFromBranch } from './worktreePath' + +describe('deriveSlug', () => { + it('取 plans/ 后一段', () => { + expect(deriveSlug('docs/superpowers/plans/foo-bar/plan.md')).toBe('foo-bar') + }) + it('没有 plans/specs 锚点时退回父目录名', () => { + expect(deriveSlug('docs/x/baz/plan.md')).toBe('baz') + }) +}) + +describe('slugFromBranch', () => { + it('去掉 feature/ 前缀', () => { + expect(slugFromBranch('feature/foo-bar')).toBe('foo-bar') + }) + it('无前缀原样返回', () => { + expect(slugFromBranch('hotfix-1')).toBe('hotfix-1') + }) +}) + +describe('resolveWorktreeDir', () => { + it('展开三个占位符与 ~', () => { + const out = resolveWorktreeDir(DEFAULT_WORKTREE_TEMPLATE, '/w/proj', 'slug') + expect(out).toBe(path.join(os.homedir(), 'Sources', 'worktree', 'proj', 'slug')) + }) + it('$project_root 展开为工作区绝对路径', () => { + expect(resolveWorktreeDir('$project_root/.wt/$feature_name', '/w/proj', 's')).toBe('/w/proj/.wt/s') + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm test src/git/worktreePath.test.ts` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 实现** + +`src/git/worktreePath.ts`: + +```ts +import { createHash } from 'node:crypto' +import * as os from 'node:os' +import * as path from 'node:path' + +export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name' + +/** + * spec/plan 以目录形式存放(docs/superpowers/{specs,plans}//…),slug 取 + * `plans`/`specs` 后一段。兜底父目录名,再兜底路径 hash,保证目录名不为空。 + */ +export function deriveSlug(planFile: string): string { + const segments = planFile.split('/').filter(Boolean) + const anchor = segments.findIndex(s => s === 'plans' || s === 'specs') + if (anchor >= 0 && segments[anchor + 1]) + return segments[anchor + 1] + const parent = path.basename(path.dirname(planFile)) + if (parent && parent !== '.' && parent !== '/') + return parent + return createHash('sha256').update(planFile).digest('hex').slice(0, 8) +} + +/** 实施分支固定形如 `feature/`;接管方只有分支名,从这里反推 worktree 目录名。 */ +export function slugFromBranch(branch: string): string { + return branch.startsWith('feature/') ? branch.slice('feature/'.length) : branch +} + +/** + * 把 worktreeDirectory 模板展开成绝对路径。`~` 在这里展开:git/execFile 不经 shell, + * 不会自己做 tilde 展开。 + */ +export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string { + let p = template + .split('$project_name') + .join(path.basename(workspaceRoot)) + .split('$project_root') + .join(workspaceRoot) + .split('$feature_name') + .join(slug) + if (p.startsWith('~')) + p = path.join(os.homedir(), p.slice(1)) + return path.resolve(p) +} +``` + +- [ ] **Step 4: sessions.ts 改用新模块** + +删除 `src/panel/handlers/sessions.ts` 中私有的 `deriveSlug`、`resolveWorktreeDir`(`:636-680`),加 `import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'`;`:768-770` 的 `|| '~/Sources/worktree/$project_name/$feature_name'` 改为 `|| DEFAULT_WORKTREE_TEMPLATE`。若 `createHash`/`os` 在 sessions.ts 里因此无引用,删掉对应 import。 + +- [ ] **Step 5: 跑测试 + 提交** + +Run: `pnpm test src/git/worktreePath.test.ts && pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/git/worktreePath.ts src/git/worktreePath.test.ts src/panel/handlers/sessions.ts +git commit -m "♻️ refactor(vscode): 抽出 worktreePath 模块(slug / 目录模板解析)" +``` + +--- + +### Task 3: `ensureWorktreeFromRemote` + +**Files:** +- Modify: `src/git/worktree.ts:37, 97, 110` +- Create: `test/git/worktreeFromRemote.test.ts` + +**Interfaces:** +- Produces: + - `export function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise`(改 export) + - `export async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise`(改 export) + - `export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise` + +- [ ] **Step 1: 写集成测试(真 git,仿 `test/git/worktreeEnsure.test.ts`)** + +`test/git/worktreeFromRemote.test.ts`: + +```ts +import { execFileSync } from 'node:child_process' +import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ensureWorktreeFromRemote } from '../../src/git/worktree' + +const gitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + GIT_AUTHOR_NAME: 'remote-test', + GIT_AUTHOR_EMAIL: 'remote@test.local', + GIT_COMMITTER_NAME: 'remote-test', + GIT_COMMITTER_EMAIL: 'remote@test.local', +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() +} + +interface Fixture { root: string, origin: string, repo: string, templatePath: string } + +/** origin(bare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */ +async function setup(): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-remote-')) + const origin = path.join(root, 'origin.git') + git(root, ['init', '--bare', '-b', 'main', origin]) + const peer = path.join(root, 'peer') + git(root, ['clone', origin, peer]) + writeFileSync(path.join(peer, 'README.md'), 'hello\n') + git(peer, ['add', 'README.md']) + git(peer, ['commit', '-m', 'init']) + git(peer, ['push', '-u', 'origin', 'main']) + git(peer, ['checkout', '-b', 'feature/x']) + writeFileSync(path.join(peer, 'x.txt'), 'x\n') + git(peer, ['add', 'x.txt']) + git(peer, ['commit', '-m', 'feat x']) + git(peer, ['push', '-u', 'origin', 'feature/x']) + const repo = path.join(root, 'repo') + git(root, ['clone', origin, repo]) + const templatePath = path.join(root, 'wt', 'x') + return { root, origin, repo, templatePath } +} + +let fx: Fixture | undefined +afterEach(() => { + if (fx) + rmSync(fx.root, { recursive: true, force: true }) + fx = undefined +}) + +describe('ensureWorktreeFromRemote', () => { + it('从 origin 分支建 worktree,本地分支跟踪远端', async () => { + fx = await setup() + const out = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' }) + expect(out).toBe(fx.templatePath) + expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true) + expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') + expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', '@{u}'])).toBe('origin/feature/x') + }) + + it('该分支已有 live worktree 时直接复用', async () => { + fx = await setup() + const first = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' }) + const other = path.join(fx.root, 'wt', 'other') + const second = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: other, branch: 'feature/x' }) + expect(second).toBe(first) + expect(existsSync(other)).toBe(false) + }) + + it('模板路径被非 worktree 目录占着 → 删掉重建', async () => { + fx = await setup() + await fsp.mkdir(fx.templatePath, { recursive: true }) + writeFileSync(path.join(fx.templatePath, 'junk'), '') + await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' }) + expect(existsSync(path.join(fx.templatePath, 'junk'))).toBe(false) + expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true) + }) + + it('远端没有该分支 → 抛错', async () => { + fx = await setup() + await expect( + ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/none' }), + ).rejects.toThrow(/feature\/none/) + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm test test/git/worktreeFromRemote.test.ts` +Expected: FAIL(`ensureWorktreeFromRemote` 不存在) + +- [ ] **Step 3: 实现** + +`src/git/worktree.ts`:把 `function runGit` 与 `async function findLiveWorktreeForBranch` 改为 `export`。在 `ensureWorktree` 之后新增: + +```ts +/** + * 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。 + * `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。 + */ +export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise { + const { workspaceRoot, branch } = opts + const worktreePath = path.resolve(opts.worktreePath) + + await runGit(workspaceRoot, ['fetch', 'origin', branch], `拉取远端分支 ${branch}`) + + // ① 已经有 live worktree → 复用,不动模板路径 + const live = await findLiveWorktreeForBranch(workspaceRoot, branch) + if (live) + return live + + // ② 模板路径被垃圾目录占着 → kill + 删,否则 add 撞路径 + if (await pathExists(worktreePath)) { + killProcessesUsingWorktree(worktreePath) + await removeWorktreeDir(workspaceRoot, worktreePath) + } + + await fsp.mkdir(path.dirname(worktreePath), { recursive: true }) + await runGit( + workspaceRoot, + ['worktree', 'add', '-B', branch, worktreePath, `origin/${branch}`], + 'git worktree add', + ) + return worktreePath +} +``` + +`removeWorktreeDir` 是 `rm -rf` + `git worktree prune`,对非 worktree 的垃圾目录同样适用;它拒绝少于 3 段的浅路径,测试里的模板路径在 `/tmp/sw-remote-*/wt/x` 下满足要求。 + +- [ ] **Step 4: 跑测试 + 提交** + +Run: `pnpm test test/git/worktreeFromRemote.test.ts && pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/git/worktree.ts test/git/worktreeFromRemote.test.ts +git commit -m "✨ feat(vscode): ensureWorktreeFromRemote 从远端分支重建 worktree" +``` + +--- + +### Task 4: `cc/sessionBundle.ts`(会话文件查找 / 安装 / codex 定位) + +**Files:** +- Create: `src/cc/sessionBundle.ts` +- Create: `src/cc/sessionBundle.test.ts` + +**Interfaces:** +- Produces: + ```ts + export interface ClaudeSessionFiles { projectsDir: string, jsonl: string, dir?: string } + export function claudeProjectsRoot(): string // ~/.claude/projects + export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise + export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise // 多份取 jsonl mtime 最新 + export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise + export async function installClaudeSession(opts: { sid: string, srcDir: string, dstProjectsDir: string, projectsRoot: string }): Promise + export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise + export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise + ``` + `installClaudeSession` 语义:把 `srcDir/.jsonl`(+ `srcDir//`)拷到 `dstProjectsDir`,然后删除 `projectsRoot` 下所有 `projectsDir !== dstProjectsDir` 的副本。srcDir 本身在 projectsRoot 下时(完成列搬迁)也成立。 + +- [ ] **Step 1: 写测试** + +`src/cc/sessionBundle.test.ts`: + +```ts +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + codexRolloutRelPathFor, + findClaudeSessionFiles, + installClaudeSession, + installCodexSession, + listClaudeSessionCopies, +} from './sessionBundle' + +const tempDirs: string[] = [] +afterEach(() => { + while (tempDirs.length > 0) { + const d = tempDirs.pop() + if (d) + rmSync(d, { recursive: true, force: true }) + } +}) +function tempDir(prefix: string): string { + const d = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(d) + return d +} + +const SID = '11111111-2222-4333-8444-555555555555' + +function writeSession(projectsRoot: string, encoded: string, sid: string, body: string, withDir = false): string { + const dir = path.join(projectsRoot, encoded) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, `${sid}.jsonl`), body) + if (withDir) { + mkdirSync(path.join(dir, sid, 'subagents'), { recursive: true }) + writeFileSync(path.join(dir, sid, 'subagents', 'agent-a.jsonl'), '{}\n') + } + return dir +} + +describe('listClaudeSessionCopies / findClaudeSessionFiles', () => { + it('列出所有 project 目录下的同 sid 副本,含子目录', async () => { + const root = tempDir('cc-projects-') + const a = writeSession(root, '-home-a-proj', SID, 'a\n', true) + const b = writeSession(root, '-home-a-wt', SID, 'b\n') + const copies = await listClaudeSessionCopies(SID, root) + expect(copies.map(c => c.projectsDir).sort()).toEqual([a, b].sort()) + expect(copies.find(c => c.projectsDir === a)?.dir).toBe(path.join(a, SID)) + expect(copies.find(c => c.projectsDir === b)?.dir).toBeUndefined() + }) + + it('多份副本取 jsonl mtime 最新的', async () => { + const root = tempDir('cc-projects-') + const old = writeSession(root, '-old', SID, 'old\n') + const fresh = writeSession(root, '-fresh', SID, 'fresh\n') + utimesSync(path.join(old, `${SID}.jsonl`), new Date(0), new Date(0)) + const found = await findClaudeSessionFiles(SID, root) + expect(found?.projectsDir).toBe(fresh) + }) + + it('projectsRoot 不存在 → 空 / undefined', async () => { + expect(await listClaudeSessionCopies(SID, '/nonexistent/x')).toEqual([]) + expect(await findClaudeSessionFiles(SID, '/nonexistent/x')).toBeUndefined() + }) +}) + +describe('installClaudeSession', () => { + it('从外部目录装入目标 project 目录,并清掉本机其它副本', async () => { + const root = tempDir('cc-projects-') + const stale = writeSession(root, '-stale', SID, 'stale\n', true) + const src = tempDir('cc-src-') + mkdirSync(path.join(src, SID, 'tool-results'), { recursive: true }) + writeFileSync(path.join(src, `${SID}.jsonl`), 'new\n') + writeFileSync(path.join(src, SID, 'tool-results', 'r.txt'), 'r') + const dst = path.join(root, '-home-me-wt') + await installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: dst, projectsRoot: root }) + expect(readFileSync(path.join(dst, `${SID}.jsonl`), 'utf8')).toBe('new\n') + expect(existsSync(path.join(dst, SID, 'tool-results', 'r.txt'))).toBe(true) + expect(existsSync(path.join(stale, `${SID}.jsonl`))).toBe(false) + expect(existsSync(path.join(stale, SID))).toBe(false) + }) + + it('源目录就在 projectsRoot 下(搬迁):目标保留,源删除', async () => { + const root = tempDir('cc-projects-') + const from = writeSession(root, '-wt', SID, 'x\n', true) + const to = path.join(root, '-proj') + await installClaudeSession({ sid: SID, srcDir: from, dstProjectsDir: to, projectsRoot: root }) + expect(readFileSync(path.join(to, `${SID}.jsonl`), 'utf8')).toBe('x\n') + expect(existsSync(path.join(to, SID, 'subagents', 'agent-a.jsonl'))).toBe(true) + expect(existsSync(path.join(from, `${SID}.jsonl`))).toBe(false) + }) + + it('源就是目标 → 幂等不报错', async () => { + const root = tempDir('cc-projects-') + const dir = writeSession(root, '-proj', SID, 'x\n') + await installClaudeSession({ sid: SID, srcDir: dir, dstProjectsDir: dir, projectsRoot: root }) + expect(readFileSync(path.join(dir, `${SID}.jsonl`), 'utf8')).toBe('x\n') + }) + + it('源缺 jsonl → 抛错', async () => { + const root = tempDir('cc-projects-') + const src = tempDir('cc-src-') + await expect(installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: path.join(root, '-x'), projectsRoot: root })).rejects.toThrow(SID) + }) +}) + +describe('codex', () => { + const UUID = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' + it('按文件名尾部 uuid 定位 rollout,返回相对路径', async () => { + const codexRoot = tempDir('codex-') + const day = path.join(codexRoot, '2026', '08', '26') + mkdirSync(day, { recursive: true }) + writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${UUID}.jsonl`), '{}\n') + expect(await codexRolloutRelPathFor(UUID.toUpperCase(), codexRoot)).toBe(path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`)) + expect(await codexRolloutRelPathFor('00000000-0000-4000-8000-000000000000', codexRoot)).toBeUndefined() + }) + + it('installCodexSession 按 relPath 落到 codexRoot 下并覆盖', async () => { + const codexRoot = tempDir('codex-') + const src = tempDir('codex-src-') + const rel = path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`) + writeFileSync(path.join(src, 'r.jsonl'), 'new\n') + await installCodexSession(rel, path.join(src, 'r.jsonl'), codexRoot) + expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n') + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm test src/cc/sessionBundle.test.ts` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 实现** + +`src/cc/sessionBundle.ts`: + +```ts +/** + * claude / codex 会话文件的搬运原语。 + * + * claude 会话 = `/.jsonl` + 可选 `//` + * (subagents、tool-results),两者必须一起搬。新版 claude 在任意目录 + * `--resume` 时会搜全部 project 目录,同一 sid 出现两份就拒绝 resume—— + * 所以「安装」总是先拷再删本机其它副本。 + */ + +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { ROLLOUT_UUID_REGEX } from './codexSessionWatcher' + +export interface ClaudeSessionFiles { + projectsDir: string + jsonl: string + dir?: string +} + +export function claudeProjectsRoot(): string { + return path.join(os.homedir(), '.claude', 'projects') +} + +async function exists(p: string): Promise { + try { + await fsp.stat(p) + return true + } + catch { + return false + } +} + +async function sessionFilesIn(projectsDir: string, sid: string): Promise { + const jsonl = path.join(projectsDir, `${sid}.jsonl`) + if (!(await exists(jsonl))) + return undefined + const dir = path.join(projectsDir, sid) + return (await exists(dir)) ? { projectsDir, jsonl, dir } : { projectsDir, jsonl } +} + +export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise { + let entries: string[] + try { + entries = await fsp.readdir(projectsRoot) + } + catch { + return [] + } + const out: ClaudeSessionFiles[] = [] + for (const name of entries) { + const found = await sessionFilesIn(path.join(projectsRoot, name), sid) + if (found) + out.push(found) + } + return out +} + +export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise { + const copies = await listClaudeSessionCopies(sid, projectsRoot) + if (copies.length === 0) + return undefined + const stamped = await Promise.all(copies.map(async c => ({ c, mtime: (await fsp.stat(c.jsonl)).mtimeMs }))) + stamped.sort((a, b) => b.mtime - a.mtime) + return stamped[0].c +} + +export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise { + await fsp.mkdir(dstDir, { recursive: true }) + await fsp.copyFile(files.jsonl, path.join(dstDir, path.basename(files.jsonl))) + if (files.dir) + await fsp.cp(files.dir, path.join(dstDir, path.basename(files.dir)), { recursive: true, force: true }) +} + +async function removeClaudeSessionFiles(files: ClaudeSessionFiles): Promise { + await fsp.rm(files.jsonl, { force: true }) + if (files.dir) + await fsp.rm(files.dir, { recursive: true, force: true }) +} + +export async function installClaudeSession(opts: { + sid: string + srcDir: string + dstProjectsDir: string + projectsRoot: string +}): Promise { + const src = await sessionFilesIn(path.resolve(opts.srcDir), opts.sid) + if (!src) + throw new Error(`源目录缺少会话文件 ${opts.sid}.jsonl:${opts.srcDir}`) + const dst = path.resolve(opts.dstProjectsDir) + if (src.projectsDir !== dst) + await copyClaudeSessionFiles(src, dst) + for (const copy of await listClaudeSessionCopies(opts.sid, opts.projectsRoot)) { + if (path.resolve(copy.projectsDir) !== dst) + await removeClaudeSessionFiles(copy) + } +} + +/** codex rollout 全局存放,按文件名尾部 uuid 定位;返回相对 codexRoot 的路径。 */ +export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise { + const want = sid.toLowerCase() + let entries: string[] + try { + entries = await fsp.readdir(codexRoot, { recursive: true }) + } + catch { + return undefined + } + for (const entry of entries) { + const m = path.basename(entry).match(ROLLOUT_UUID_REGEX) + if (m && m[1].toLowerCase() === want) + return entry + } + return undefined +} + +export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise { + const dst = path.join(codexRoot, relPath) + await fsp.mkdir(path.dirname(dst), { recursive: true }) + await fsp.copyFile(srcFile, dst) +} +``` + +- [ ] **Step 4: 跑测试 + 提交** + +Run: `pnpm test src/cc/sessionBundle.test.ts && pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/cc/sessionBundle.ts src/cc/sessionBundle.test.ts +git commit -m "✨ feat(vscode): sessionBundle 会话文件查找/安装/去重原语" +``` + +--- + +### Task 5: `cc/handoffManifest.ts` + +**Files:** +- Create: `src/cc/handoffManifest.ts` +- Create: `src/cc/handoffManifest.test.ts` + +**Interfaces:** +- Produces: + ```ts + export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test' + export const CLAUDE_SESSION_FIELDS: ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }> + export interface HandoffSessions { sessionId?: string, implementSessionId?: string, testSessionId?: string, reviewSessionId?: string } + export interface HandoffProfiles { profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } + export interface HandoffManifest { + version: 1, issue: number, from: string, createdAt: string, branch?: string, + sessions: HandoffSessions, profiles: HandoffProfiles, + claude: Array<{ id: string, kind: ClaudeSessionKind }>, + codex: Array<{ id: string, relPath: string }> + } + export const HANDOFF_MANIFEST_FILE = 'handoff.json' + export function handoffAttachmentName(issueNumber: number): string // `spx-handoff-issue-${n}.tgz` + export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest // 不合法抛 Error(中文) + ``` + +- [ ] **Step 1: 写测试** + +`src/cc/handoffManifest.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest' + +const good = { + version: 1, + issue: 42, + from: 'chw', + createdAt: '2026-08-26T00:00:00Z', + branch: 'feature/x', + sessions: { implementSessionId: 'a' }, + profiles: {}, + claude: [{ id: 'a', kind: 'implement' }], + codex: [], +} + +describe('handoffAttachmentName', () => { + it('固定命名', () => { + expect(handoffAttachmentName(42)).toBe('spx-handoff-issue-42.tgz') + }) +}) + +describe('parseHandoffManifest', () => { + it('合法清单原样返回', () => { + expect(parseHandoffManifest(JSON.stringify(good), 42)).toEqual(good) + }) + it('issue 不匹配 → 抛错', () => { + expect(() => parseHandoffManifest(JSON.stringify(good), 43)).toThrow(/42/) + }) + it('version 不是 1 → 抛错', () => { + expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/) + }) + it('claude 条目 kind 非法 → 抛错', () => { + expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: 'a', kind: 'x' }] }), 42)).toThrow(/claude/) + }) + it('不是 JSON → 抛错', () => { + expect(() => parseHandoffManifest('nope', 42)).toThrow() + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm test src/cc/handoffManifest.test.ts` +Expected: FAIL + +- [ ] **Step 3: 实现** + +`src/cc/handoffManifest.ts`: + +```ts +export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test' + +export const CLAUDE_SESSION_FIELDS = [ + { field: 'sessionId', kind: 'brainstorm' }, + { field: 'implementSessionId', kind: 'implement' }, + { field: 'testSessionId', kind: 'test' }, +] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }> + +export interface HandoffSessions { + sessionId?: string + implementSessionId?: string + testSessionId?: string + reviewSessionId?: string +} + +export interface HandoffProfiles { + profilePath?: string + brainstormProfilePath?: string + testProfilePath?: string +} + +export interface HandoffManifest { + version: 1 + issue: number + from: string + createdAt: string + branch?: string + sessions: HandoffSessions + profiles: HandoffProfiles + claude: Array<{ id: string, kind: ClaudeSessionKind }> + codex: Array<{ id: string, relPath: string }> +} + +export const HANDOFF_MANIFEST_FILE = 'handoff.json' + +export function handoffAttachmentName(issueNumber: number): string { + return `spx-handoff-issue-${issueNumber}.tgz` +} + +const KINDS: ReadonlySet = new Set(['brainstorm', 'implement', 'test']) + +function optString(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest { + const raw = JSON.parse(json) as Record + if (!raw || typeof raw !== 'object') + throw new Error('handoff.json 不是对象') + if (raw.version !== 1) + throw new Error(`handoff.json version 不支持:${String(raw.version)}`) + if (raw.issue !== expectedIssue) + throw new Error(`handoff.json 属于工单 #${String(raw.issue)},不是 #${expectedIssue}`) + const from = optString(raw.from) + const createdAt = optString(raw.createdAt) + if (!from || !createdAt) + throw new Error('handoff.json 缺少 from / createdAt') + const sessionsRaw = (raw.sessions ?? {}) as Record + const profilesRaw = (raw.profiles ?? {}) as Record + const claudeRaw = Array.isArray(raw.claude) ? raw.claude : [] + const codexRaw = Array.isArray(raw.codex) ? raw.codex : [] + const claude = claudeRaw.map((e) => { + const o = e as Record + const id = optString(o.id) + const kind = optString(o.kind) + if (!id || !kind || !KINDS.has(kind)) + throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`) + return { id, kind: kind as ClaudeSessionKind } + }) + const codex = codexRaw.map((e) => { + const o = e as Record + const id = optString(o.id) + const relPath = optString(o.relPath) + if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..')) + throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`) + return { id, relPath } + }) + const sessions: HandoffSessions = {} + for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'reviewSessionId'] as const) { + const v = optString(sessionsRaw[k]) + if (v) + sessions[k] = v + } + const profiles: HandoffProfiles = {} + for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath'] as const) { + const v = optString(profilesRaw[k]) + if (v) + profiles[k] = v + } + return { + version: 1, + issue: expectedIssue, + from, + createdAt, + ...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}), + sessions, + profiles, + claude, + codex, + } +} +``` + +- [ ] **Step 4: 跑测试 + 提交** + +Run: `pnpm test src/cc/handoffManifest.test.ts && pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/cc/handoffManifest.ts src/cc/handoffManifest.test.ts +git commit -m "✨ feat(vscode): handoff 清单类型与校验" +``` + +--- + +### Task 6: `cc/handoffArchive.ts` + `cc/handoffBundle.ts` + +**Files:** +- Create: `src/cc/handoffArchive.ts` +- Create: `src/cc/handoffBundle.ts` +- Create: `src/cc/handoffBundle.test.ts` + +**Interfaces:** +- Consumes: Task 4 全部、Task 5 类型。 +- Produces: + ```ts + // handoffArchive.ts + export async function packHandoff(stagingDir: string, outFile: string): Promise + export async function unpackHandoff(archive: string, dstDir: string): Promise + // handoffBundle.ts + export interface HandoffSource { issueNumber: number, from: string, branch?: string, sessions: HandoffSessions, profiles: HandoffProfiles } + export interface HandoffRoots { projectsRoot: string, codexRoot: string } + export async function stageHandoffBundle(src: HandoffSource, stagingDir: string, roots: HandoffRoots, now?: Date): Promise + export interface HandoffInstallTargets extends HandoffRoots { workspaceProjectsDir: string, worktreeProjectsDir?: string } + export async function installHandoffBundle(extractedDir: string, manifest: HandoffManifest, targets: HandoffInstallTargets): Promise + ``` + `stageHandoffBundle`:在 `stagingDir` 下生成 `claude/`、`codex/`、`handoff.json`,返回清单(找不到的会话不进 `claude`/`codex` 列表,但 `sessions` 仍原样记录 id)。 + `installHandoffBundle`:brainstorm → `workspaceProjectsDir`;implement/test → `worktreeProjectsDir ?? workspaceProjectsDir`;codex → `codexRoot/`。 + +- [ ] **Step 1: 写测试(stage → pack → unpack → install 全链路)** + +`src/cc/handoffBundle.test.ts`: + +```ts +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { packHandoff, unpackHandoff } from './handoffArchive' +import { installHandoffBundle, stageHandoffBundle } from './handoffBundle' +import { HANDOFF_MANIFEST_FILE, parseHandoffManifest } from './handoffManifest' + +const tempDirs: string[] = [] +afterEach(() => { + while (tempDirs.length > 0) { + const d = tempDirs.pop() + if (d) + rmSync(d, { recursive: true, force: true }) + } +}) +function tempDir(prefix: string): string { + const d = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(d) + return d +} + +const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001' +const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002' +const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' + +function senderRoots(): { projectsRoot: string, codexRoot: string } { + const projectsRoot = tempDir('sender-projects-') + const codexRoot = tempDir('sender-codex-') + const proj = path.join(projectsRoot, '-Users-chw-proj') + const wt = path.join(projectsRoot, '-Users-chw-wt-x') + mkdirSync(proj, { recursive: true }) + mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true }) + writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n') + writeFileSync(path.join(wt, `${IMPL}.jsonl`), 'impl\n') + writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n') + const day = path.join(codexRoot, '2026', '08', '26') + mkdirSync(day, { recursive: true }) + writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n') + return { projectsRoot, codexRoot } +} + +describe('stageHandoffBundle', () => { + it('收集存在的会话,缺失的不进列表', async () => { + const roots = senderRoots() + const staging = tempDir('staging-') + const manifest = await stageHandoffBundle({ + issueNumber: 7, + from: 'chw', + branch: 'feature/x', + sessions: { sessionId: BRAIN, implementSessionId: IMPL, testSessionId: 'missing-0000-4000-8000-000000000000', reviewSessionId: REVIEW }, + profiles: { profilePath: '/Users/chw/p.json' }, + }, staging, roots, new Date('2026-08-26T00:00:00Z')) + expect(manifest.claude).toEqual([{ id: BRAIN, kind: 'brainstorm' }, { id: IMPL, kind: 'implement' }]) + expect(manifest.codex).toEqual([{ id: REVIEW, relPath: path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`) }]) + expect(manifest.sessions.testSessionId).toBe('missing-0000-4000-8000-000000000000') + expect(manifest.createdAt).toBe('2026-08-26T00:00:00.000Z') + expect(existsSync(path.join(staging, 'claude', `${IMPL}.jsonl`))).toBe(true) + expect(existsSync(path.join(staging, 'claude', IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true) + expect(existsSync(path.join(staging, 'codex', manifest.codex[0].relPath))).toBe(true) + expect(parseHandoffManifest(readFileSync(path.join(staging, HANDOFF_MANIFEST_FILE), 'utf8'), 7)).toEqual(manifest) + }) +}) + +describe('pack → unpack → install', () => { + it('接管方装到对应 project 目录与 codex 目录', async () => { + const roots = senderRoots() + const staging = tempDir('staging-') + const manifest = await stageHandoffBundle({ + issueNumber: 7, + from: 'chw', + branch: 'feature/x', + sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW }, + profiles: {}, + }, staging, roots) + const archive = path.join(tempDir('archive-'), 'spx-handoff-issue-7.tgz') + await packHandoff(staging, archive) + expect(existsSync(archive)).toBe(true) + + const extracted = tempDir('extracted-') + await unpackHandoff(archive, extracted) + const parsed = parseHandoffManifest(readFileSync(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), 7) + expect(parsed).toEqual(manifest) + + const projectsRoot = tempDir('receiver-projects-') + const codexRoot = tempDir('receiver-codex-') + const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj') + const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x') + // 本机残留的同 sid 副本必须被清掉 + mkdirSync(path.join(projectsRoot, '-stale'), { recursive: true }) + writeFileSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`), 'stale\n') + await installHandoffBundle(extracted, parsed, { projectsRoot, codexRoot, workspaceProjectsDir, worktreeProjectsDir }) + expect(readFileSync(path.join(workspaceProjectsDir, `${BRAIN}.jsonl`), 'utf8')).toBe('brain\n') + expect(readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')).toBe('impl\n') + expect(existsSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true) + expect(existsSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`))).toBe(false) + expect(readFileSync(path.join(codexRoot, parsed.codex[0].relPath), 'utf8')).toBe('review\n') + }) + + it('无 worktreeProjectsDir 时实施会话也装到工作区目录', async () => { + const roots = senderRoots() + const staging = tempDir('staging-') + const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', sessions: { implementSessionId: IMPL }, profiles: {} }, staging, roots) + const projectsRoot = tempDir('receiver-projects-') + const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj') + await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir }) + expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true) + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm test src/cc/handoffBundle.test.ts` +Expected: FAIL + +- [ ] **Step 3: 实现 handoffArchive.ts** + +```ts +import { execFile } from 'node:child_process' +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' + +function tar(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => { + if (err) { + reject(new Error(`tar ${args[0]} 失败: ${(stderr ?? '').toString().trim() || err.message}`)) + return + } + resolve() + }) + }) +} + +export async function packHandoff(stagingDir: string, outFile: string): Promise { + await fsp.mkdir(path.dirname(outFile), { recursive: true }) + await tar(['-czf', outFile, '-C', stagingDir, '.']) +} + +export async function unpackHandoff(archive: string, dstDir: string): Promise { + await fsp.mkdir(dstDir, { recursive: true }) + await tar(['-xzf', archive, '-C', dstDir]) +} +``` + +- [ ] **Step 4: 实现 handoffBundle.ts** + +```ts +import type { HandoffManifest, HandoffProfiles, HandoffSessions } from './handoffManifest' +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' +import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest' +import { + codexRolloutRelPathFor, + copyClaudeSessionFiles, + findClaudeSessionFiles, + installClaudeSession, + installCodexSession, +} from './sessionBundle' + +export interface HandoffSource { + issueNumber: number + from: string + branch?: string + sessions: HandoffSessions + profiles: HandoffProfiles +} + +export interface HandoffRoots { + projectsRoot: string + codexRoot: string +} + +/** 把本机会话文件收进 stagingDir(claude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */ +export async function stageHandoffBundle( + src: HandoffSource, + stagingDir: string, + roots: HandoffRoots, + now: Date = new Date(), +): Promise { + const claudeDir = path.join(stagingDir, 'claude') + const codexDir = path.join(stagingDir, 'codex') + await fsp.mkdir(claudeDir, { recursive: true }) + await fsp.mkdir(codexDir, { recursive: true }) + + const claude: HandoffManifest['claude'] = [] + for (const { field, kind } of CLAUDE_SESSION_FIELDS) { + const id = src.sessions[field] + if (!id) + continue + const files = await findClaudeSessionFiles(id, roots.projectsRoot) + if (!files) + continue + await copyClaudeSessionFiles(files, claudeDir) + claude.push({ id, kind }) + } + + const codex: HandoffManifest['codex'] = [] + if (src.sessions.reviewSessionId) { + const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot) + if (relPath) { + const dst = path.join(codexDir, relPath) + await fsp.mkdir(path.dirname(dst), { recursive: true }) + await fsp.copyFile(path.join(roots.codexRoot, relPath), dst) + codex.push({ id: src.sessions.reviewSessionId, relPath }) + } + } + + const manifest: HandoffManifest = { + version: 1, + issue: src.issueNumber, + from: src.from, + createdAt: now.toISOString(), + ...(src.branch ? { branch: src.branch } : {}), + sessions: src.sessions, + profiles: src.profiles, + claude, + codex, + } + await fsp.writeFile(path.join(stagingDir, HANDOFF_MANIFEST_FILE), JSON.stringify(manifest, null, 2)) + return manifest +} + +export interface HandoffInstallTargets extends HandoffRoots { + workspaceProjectsDir: string + worktreeProjectsDir?: string +} + +export async function installHandoffBundle( + extractedDir: string, + manifest: HandoffManifest, + targets: HandoffInstallTargets, +): Promise { + const claudeDir = path.join(extractedDir, 'claude') + for (const { id, kind } of manifest.claude) { + const dstProjectsDir = kind === 'brainstorm' + ? targets.workspaceProjectsDir + : (targets.worktreeProjectsDir ?? targets.workspaceProjectsDir) + await installClaudeSession({ sid: id, srcDir: claudeDir, dstProjectsDir, projectsRoot: targets.projectsRoot }) + } + for (const { relPath } of manifest.codex) + await installCodexSession(relPath, path.join(extractedDir, 'codex', relPath), targets.codexRoot) +} +``` + +- [ ] **Step 5: 跑测试 + 提交** + +Run: `pnpm test src/cc/handoffBundle.test.ts && pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/cc/handoffArchive.ts src/cc/handoffBundle.ts src/cc/handoffBundle.test.ts +git commit -m "✨ feat(vscode): handoff 会话包 stage/pack/unpack/install" +``` + +--- + +### Task 7: Gitea API:assignees 列表 + 附件 + +**Files:** +- Modify: `src/gitea/api.ts`(末尾追加) + +**Interfaces:** +- Produces: + ```ts + export interface GiteaAttachment { id: number, name: string, size: number, uuid: string, browser_download_url: string, created_at: string } + export async function listRepoAssignees(opts: { host, token, owner, repo }): Promise + export async function listIssueAttachments(opts: { host, token, owner, repo, index: number }): Promise + export async function uploadIssueAttachment(opts: { host, token, owner, repo, index: number, name: string, data: Buffer }): Promise + export async function getIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise + export async function downloadAttachment(opts: { token: string, url: string }): Promise + export async function deleteIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise + ``` + +- [ ] **Step 1: 实现** + +在 `src/gitea/api.ts` 末尾追加(`Buffer` 从 `'node:buffer'` import;`FormData`/`Blob` 用 Node 22 全局): + +```ts +export interface GiteaAttachment { + id: number + name: string + size: number + uuid: string + browser_download_url: string + created_at: string +} + +/** 可被指派的用户(仓库协作者 + owner),移交选人用。 */ +export async function listRepoAssignees(opts: { + host: string + token: string + owner: string + repo: string +}): Promise { + const res = await fetch( + `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/assignees`, + { headers: authHeaders(opts.token) }, + ) + await ensureOk(res) + return (await res.json()) as GiteaUser[] +} + +export async function listIssueAttachments(opts: { + host: string + token: string + owner: string + repo: string + index: number +}): Promise { + const res = await fetch( + `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`, + { headers: authHeaders(opts.token) }, + ) + await ensureOk(res) + return (await res.json()) as GiteaAttachment[] +} + +export async function uploadIssueAttachment(opts: { + host: string + token: string + owner: string + repo: string + index: number + name: string + data: Buffer +}): Promise { + const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`) + url.searchParams.set('name', opts.name) + const form = new FormData() + form.append('attachment', new Blob([opts.data], { type: 'application/gzip' }), opts.name) + // 不能带 Content-Type:multipart boundary 由 fetch 按 FormData 自动生成。 + const res = await fetch(url.toString(), { + method: 'POST', + headers: { Authorization: `token ${opts.token}`, Accept: 'application/json' }, + body: form, + }) + await ensureOk(res) + return (await res.json()) as GiteaAttachment +} + +export async function getIssueAttachment(opts: { + host: string + token: string + owner: string + repo: string + index: number + attachmentId: number +}): Promise { + const res = await fetch( + `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`, + { headers: authHeaders(opts.token) }, + ) + await ensureOk(res) + return (await res.json()) as GiteaAttachment +} + +/** + * `browser_download_url` 走的是 web 路由(/attachments/),Gitea 的 web + * 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML, + * 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。 + */ +export async function downloadAttachment(opts: { token: string, url: string }): Promise { + const res = await fetch(opts.url, { headers: { Authorization: `token ${opts.token}` }, redirect: 'follow' }) + await ensureOk(res) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/html')) + throw new GiteaApiError(res.status, `附件下载被重定向到页面(鉴权失败?):${opts.url}`) + return Buffer.from(await res.arrayBuffer()) +} + +export async function deleteIssueAttachment(opts: { + host: string + token: string + owner: string + repo: string + index: number + attachmentId: number +}): Promise { + const res = await fetch( + `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`, + { method: 'DELETE', headers: authHeaders(opts.token) }, + ) + await ensureOk(res) +} +``` + +- [ ] **Step 2: 用真实 Gitea 验证下载鉴权(一次性脚本,放 scratchpad,不入库)** + +在本仓库自己的 Gitea(`gitea.ailoveworld.cn`,`git remote -v` 取 owner/repo)上挑一个已关闭的测试工单 N,token 从 `~/.config/tea/config.yml` 读(若 token 走 keyring,取 `GITEA_TOKEN` 环境变量): + +```bash +T=; O=; R=; N= +echo hi > /tmp/claude-1000/-home-cruldra-Sources-superwork/62cd8318-2051-42ed-9511-9c135e5f9c93/scratchpad/t.txt +curl -s -H "Authorization: token $T" -F "attachment=@/tmp/claude-1000/-home-cruldra-Sources-superwork/62cd8318-2051-42ed-9511-9c135e5f9c93/scratchpad/t.txt" \ + "https://gitea.ailoveworld.cn/api/v1/repos/$O/$R/issues/$N/assets?name=t.txt" | tee /dev/stderr | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['id'], d['browser_download_url'])" +# 用输出的 URL: +curl -sL -o /dev/null -w '%{http_code} %{content_type}\n' -H "Authorization: token $T" +# 期望 200 且 content_type 不是 text/html;然后删掉测试附件: +curl -s -X DELETE -H "Authorization: token $T" "https://gitea.ailoveworld.cn/api/v1/repos/$O/$R/issues/$N/assets/" +``` + +Expected: `200 text/plain…`。若返回 HTML / 302 登录页,改 `downloadAttachment` 为请求 `https:///attachments/` 并加 `?token=` 方案前先把结果报告给用户再定。 + +- [ ] **Step 3: typecheck + 提交** + +Run: `pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/gitea/api.ts +git commit -m "✨ feat(vscode): Gitea API 增加 assignees 列表与工单附件读写" +``` + +--- + +### Task 8: `panel/handlers/handoff.ts`(纯决策 + payload) + +**Files:** +- Create: `src/panel/handlers/handoff.ts` +- Create: `src/panel/handlers/handoff.test.ts` + +**Interfaces:** +- Produces: + ```ts + export function canStartHandoff(issue: { source?: 'gitea' | 'youtrack', column: IssueColumn, handoffAttachmentId?: string }): boolean + export function canAcceptHandoff(issue: { source?: 'gitea' | 'youtrack', handoffAttachmentId?: string, assignees?: string[] }, me: string | undefined): boolean + export function checkWorktreePushed(input: { statusPorcelain: string, localHead: string, remoteHead: string }): { ok: true } | { ok: false, reason: string } + export function handoffStartedStateExtra(attachmentId: number, from: string): Record + export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record + export function handoffAcceptedStateExtra(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string }): Record + export function handoffAcceptedUiPatch(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string, worktreeExists: boolean }): Record + ``` + +- [ ] **Step 1: 写测试** + +`src/panel/handlers/handoff.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { + canAcceptHandoff, + canStartHandoff, + checkWorktreePushed, + handoffAcceptedStateExtra, + handoffAcceptedUiPatch, + handoffStartedStateExtra, + handoffStartedUiPatch, +} from './handoff' + +describe('canStartHandoff', () => { + it('gitea 工单、非 done、无待接管 → true', () => { + expect(canStartHandoff({ column: 'in-progress' })).toBe(true) + expect(canStartHandoff({ column: 'todo', source: 'gitea' })).toBe(true) + }) + it('done / youtrack / 已在移交中 → false', () => { + expect(canStartHandoff({ column: 'done' })).toBe(false) + expect(canStartHandoff({ column: 'review', source: 'youtrack' })).toBe(false) + expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' })).toBe(false) + }) +}) + +describe('canAcceptHandoff', () => { + it('有附件且 assignee 含我 → true', () => { + expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me', 'x'] }, 'me')).toBe(true) + }) + it('无附件 / 不是我的 / 身份未知 / youtrack → false', () => { + expect(canAcceptHandoff({ assignees: ['me'] }, 'me')).toBe(false) + expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['x'] }, 'me')).toBe(false) + expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me'] }, undefined)).toBe(false) + expect(canAcceptHandoff({ source: 'youtrack', handoffAttachmentId: '9', assignees: ['me'] }, 'me')).toBe(false) + }) +}) + +describe('checkWorktreePushed', () => { + it('干净且 HEAD 与远端一致 → ok', () => { + expect(checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'abc' })).toEqual({ ok: true }) + }) + it('有未提交改动 → 拒绝', () => { + const r = checkWorktreePushed({ statusPorcelain: ' M a.ts\n', localHead: 'abc', remoteHead: 'abc' }) + expect(r.ok).toBe(false) + if (!r.ok) + expect(r.reason).toMatch(/未提交/) + }) + it('本地领先远端 → 拒绝', () => { + const r = checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'def' }) + expect(r.ok).toBe(false) + if (!r.ok) + expect(r.reason).toMatch(/push/) + }) +}) + +describe('payloads', () => { + it('移交落盘:两个共享字段 + 本机字段全部墓碑', () => { + expect(handoffStartedStateExtra(9, 'chw')).toEqual({ + handoffAttachmentId: '9', + handoffFrom: 'chw', + sessionId: '', + implementSessionId: '', + reviewSessionId: '', + testSessionId: '', + profilePath: '', + brainstormProfilePath: '', + testProfilePath: '', + worktreePath: '', + prDiffFile: '', + }) + }) + it('移交 UI patch 用 null 清字段并更新 assignees', () => { + expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({ + handoffAttachmentId: '9', + handoffFrom: 'chw', + assignees: ['me'], + sessionId: null, + implementSessionId: null, + reviewSessionId: null, + testSessionId: null, + worktreePath: null, + worktreeExists: false, + }) + }) + it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => { + expect(handoffAcceptedStateExtra({ + sessions: { implementSessionId: 'i', reviewSessionId: 'r' }, + profiles: { profilePath: '/p.json' }, + worktreePath: '/wt', + })).toEqual({ + handoffAttachmentId: '', + handoffFrom: '', + implementSessionId: 'i', + reviewSessionId: 'r', + profilePath: '/p.json', + worktreePath: '/wt', + }) + }) + it('接管 UI patch', () => { + expect(handoffAcceptedUiPatch({ + sessions: { sessionId: 's' }, + profiles: {}, + worktreePath: '/wt', + worktreeExists: true, + })).toEqual({ + handoffAttachmentId: null, + handoffFrom: null, + sessionId: 's', + worktreePath: '/wt', + worktreeExists: true, + }) + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm test src/panel/handlers/handoff.test.ts` +Expected: FAIL + +- [ ] **Step 3: 实现** + +`src/panel/handlers/handoff.ts`: + +```ts +import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest' +import type { IssueColumn } from '../../gitea/types' +import { LOCAL_STATE_FIELDS } from '../../issues/localState' + +export function canStartHandoff(issue: { + source?: 'gitea' | 'youtrack' + column: IssueColumn + handoffAttachmentId?: string +}): boolean { + return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId +} + +export function canAcceptHandoff(issue: { + source?: 'gitea' | 'youtrack' + handoffAttachmentId?: string + assignees?: string[] +}, me: string | undefined): boolean { + return issue.source !== 'youtrack' + && !!issue.handoffAttachmentId + && me !== undefined + && (issue.assignees ?? []).includes(me) +} + +/** 移交前 worktree 必须「干净 + 全部 push」,否则接管方从远端重建会丢工作。 */ +export function checkWorktreePushed(input: { + statusPorcelain: string + localHead: string + remoteHead: string +}): { ok: true } | { ok: false, reason: string } { + if (input.statusPorcelain.trim().length > 0) + return { ok: false, reason: '工作区有未提交改动,请先提交并 push' } + if (input.localHead.trim() !== input.remoteHead.trim()) + return { ok: false, reason: '本地分支与远端不一致,请先 push' } + return { ok: true } +} + +export function handoffStartedStateExtra(attachmentId: number, from: string): Record { + const extra: Record = { handoffAttachmentId: String(attachmentId), handoffFrom: from } + for (const f of LOCAL_STATE_FIELDS) + extra[f] = '' + return extra +} + +export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record { + return { + handoffAttachmentId: String(attachmentId), + handoffFrom: from, + assignees: [to], + sessionId: null, + implementSessionId: null, + reviewSessionId: null, + testSessionId: null, + worktreePath: null, + worktreeExists: false, + } +} + +function definedEntries(o: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(o)) { + if (v) + out[k] = v + } + return out +} + +export function handoffAcceptedStateExtra(local: { + sessions: HandoffSessions + profiles: HandoffProfiles + worktreePath?: string +}): Record { + return { + handoffAttachmentId: '', + handoffFrom: '', + ...definedEntries(local.sessions), + ...definedEntries(local.profiles), + ...(local.worktreePath ? { worktreePath: local.worktreePath } : {}), + } +} + +export function handoffAcceptedUiPatch(local: { + sessions: HandoffSessions + profiles: HandoffProfiles + worktreePath?: string + worktreeExists: boolean +}): Record { + return { + handoffAttachmentId: null, + handoffFrom: null, + ...definedEntries(local.sessions), + ...definedEntries(local.profiles), + ...(local.worktreePath ? { worktreePath: local.worktreePath } : {}), + worktreeExists: local.worktreeExists, + } +} +``` + +- [ ] **Step 4: 跑测试 + 提交** + +Run: `pnpm test src/panel/handlers/handoff.test.ts && pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add src/panel/handlers/handoff.ts src/panel/handlers/handoff.test.ts +git commit -m "✨ feat(vscode): handoff 纯决策与 state/UI payload" +``` + +--- + +### Task 9: 消息类型 + `handoffFlow.ts` + KanbanPanel 分发 + +**Files:** +- Modify: `src/panel/messages.ts`、`webview-ui/src/lib/messages.ts` +- Create: `src/panel/handlers/handoffFlow.ts` +- Modify: `src/panel/KanbanPanel.ts:335+` + +**Interfaces:** +- Consumes: Task 3 `ensureWorktreeFromRemote`;Task 2 `resolveWorktreeDir` / `slugFromBranch` / `DEFAULT_WORKTREE_TEMPLATE`;Task 6 `stageHandoffBundle` / `installHandoffBundle` / `packHandoff` / `unpackHandoff`;Task 5 `handoffAttachmentName` / `parseHandoffManifest` / `HANDOFF_MANIFEST_FILE`;Task 4 `claudeProjectsRoot`;Task 7 API;Task 8 决策。 +- Produces: + - 消息:`WebviewToExtension` 加 `{ type: 'handoff/users', issueNumber: number }`、`{ type: 'handoff/start', issueNumber: number, to: string }`、`{ type: 'handoff/accept', issueNumber: number }`;`ExtensionToWebview` 加 `{ type: 'handoff/users-result', issueNumber: number, users: string[] }`、`{ type: 'handoff/done', issueNumber: number }`。 + - `export async function handleHandoffUsers(panel, issueNumber): Promise` + - `export async function handleHandoffStart(panel, issueNumber, to): Promise` + - `export async function handleHandoffAccept(panel, issueNumber): Promise` + +- [ ] **Step 1: 消息类型(两份镜像)** + +`src/panel/messages.ts` `ExtensionToWebview` 在 `issue/pr-diff-summary-done` 之后加: + +```ts + | { type: 'handoff/users-result', issueNumber: number, users: string[] } + | { type: 'handoff/done', issueNumber: number } +``` + +`WebviewToExtension` 在 `column/change` 之后加: + +```ts + | { type: 'handoff/users', issueNumber: number } + | { type: 'handoff/start', issueNumber: number, to: string } + | { type: 'handoff/accept', issueNumber: number } +``` + +`webview-ui/src/lib/messages.ts` 同样加。 + +- [ ] **Step 2: 写 `handoffFlow.ts`** + +`src/panel/handlers/handoffFlow.ts`: + +```ts +import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest' +import type { KanbanWebviewPanel } from '../KanbanPanel' +import { execFile } from 'node:child_process' +import * as fs from 'node:fs' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { window, workspace } from 'vscode' +import { loginForToken } from '../../auth/identity' +import { getToken } from '../../auth/secrets' +import { packHandoff, unpackHandoff } from '../../cc/handoffArchive' +import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle' +import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest' +import { claudeProjectsRoot } from '../../cc/sessionBundle' +import { projectsDirFor } from '../../cc/sessionWatcher' +import { detectRepo } from '../../git/remote' +import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' +import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath' +import { + deleteIssueAttachment, + downloadAttachment, + getIssueAttachment, + listIssueAttachments, + listRepoAssignees, + updateIssueAssignees, + uploadIssueAttachment, +} from '../../gitea/api' +import { logger } from '../../logging/logger' +import { defaultCodexSessionsDir } from '../../sessions/codexSessions' +import { getSettings } from '../../settings/store' +import { makeNonce } from '../KanbanPanel' +import { cleanupFeatureBranch } from './branchCleanup' +import { + checkWorktreePushed, + handoffAcceptedStateExtra, + handoffAcceptedUiPatch, + handoffStartedStateExtra, + handoffStartedUiPatch, +} from './handoff' + +interface RepoCtx { + workspaceRoot: string + host: string + owner: string + repo: string + token: string + me: string +} + +function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string { + const id = extra?.id ?? makeNonce() + panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 }) + return id +} + +function dismiss(panel: KanbanWebviewPanel, id: string): void { + panel.postMessage({ type: 'toast/dismiss', id }) +} + +async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + toast(panel, 'error', '请先打开一个工作区文件夹') + return undefined + } + const remote = await detectRepo(workspaceRoot) + if (!remote) { + toast(panel, 'error', '当前工作区没有 Gitea 远程仓库') + return undefined + } + const token = await getToken(panel.context, remote.host) + if (!token) { + toast(panel, 'error', '请先完成 Gitea 配置') + return undefined + } + const me = await loginForToken(remote.host, token) + if (!me) { + toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?') + return undefined + } + return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me } +} + +function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> { + return new Promise((resolve) => { + execFile('git', ['-C', cwd, ...args], { timeout: 30_000, encoding: 'utf8' }, (err, stdout, stderr) => { + resolve({ ok: !err, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString() }) + }) + }) +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +function scratchDir(prefix: string): Promise { + return fsp.mkdtemp(path.join(os.tmpdir(), prefix)) +} + +export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const ctx = await resolveRepoCtx(panel) + if (!ctx) + return + try { + const users = await listRepoAssignees(ctx) + panel.postMessage({ + type: 'handoff/users-result', + issueNumber, + users: users.map(u => u.login).filter(l => l !== ctx.me).sort(), + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast(panel, 'error', `读取可指派用户失败:${message}`) + panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] }) + } +} + +/** + * 发送方:校验 worktree 已 push → 关终端 → 打包上传 → 写共享字段 → 重新指派 → + * 本机清理。远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动; + * 本机清理失败只告警,移交在远端已经生效。 + */ +export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise { + const ctx = await resolveRepoCtx(panel) + if (!ctx) + return + const { workspaceRoot, host, owner, repo, token, me } = ctx + const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) + + let state: Record + try { + state = await panel.readIssueState(issueNumber) + } + catch (err) { + toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) + finish() + return + } + const branch = str(state.branch) + const worktreePath = str(state.worktreePath) + const sessions: HandoffSessions = { + ...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}), + ...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}), + ...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}), + ...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}), + } + const profiles: HandoffProfiles = { + ...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}), + ...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}), + ...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}), + } + + // ① worktree 守卫:干净 + 与远端一致 + const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined + if (worktreeAbs && fs.existsSync(worktreeAbs) && branch) { + const status = await git(worktreeAbs, ['status', '--porcelain']) + const fetched = await git(worktreeAbs, ['fetch', 'origin', branch]) + if (!status.ok || !fetched.ok) { + toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`) + finish() + return + } + const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD']) + const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`]) + const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout }) + if (!check.ok) { + toast(panel, 'error', `无法移交 #${issueNumber}:${check.reason}`) + finish() + return + } + } + + const spinner = toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 }) + + // ② 关终端,等 jsonl 刷盘 + let disposedAny = false + for (const [terminal, origin] of panel.terminalOrigin) { + if (origin.issueNumber === issueNumber) { + try { + terminal.dispose() + disposedAny = true + } + catch {} + } + } + for (const t of window.terminals) { + if (t.name === `issue-${issueNumber}-冲突解决`) { + try { + t.dispose() + disposedAny = true + } + catch {} + } + } + if (disposedAny) + await new Promise(r => setTimeout(r, 600)) + + // ③ 打包 + 上传 + let attachmentId: number + let manifest: HandoffManifest + // tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。 + const staging = await scratchDir('spx-handoff-') + const outDir = await scratchDir('spx-handoff-out-') + try { + manifest = await stageHandoffBundle( + { issueNumber, from: me, branch, sessions, profiles }, + staging, + { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() }, + ) + const name = handoffAttachmentName(issueNumber) + const archive = path.join(outDir, name) + await packHandoff(staging, archive) + for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) { + if (old.name === name) + await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id }) + } + const uploaded = await uploadIssueAttachment({ host, token, owner, repo, index: issueNumber, name, data: await fsp.readFile(archive) }) + attachmentId = uploaded.id + logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message }) + dismiss(panel, spinner) + toast(panel, 'error', `移交失败(打包/上传):${message}`) + finish() + return + } + finally { + await fsp.rm(staging, { recursive: true, force: true }) + await fsp.rm(outDir, { recursive: true, force: true }) + } + + // ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。 + try { + await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me)) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) + dismiss(panel, spinner) + toast(panel, 'error', `移交失败(写状态):${message}`) + finish() + return + } + try { + await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {}) + await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) + dismiss(panel, spinner) + toast(panel, 'error', `移交失败(指派 ${to}):${message}`) + finish() + return + } + + // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方) + if (worktreeAbs && fs.existsSync(worktreeAbs)) { + const settings = getSettings(panel.context) + try { + await panel.dispatchWorktreeHook('pre-remove', { + workspaceRoot, + worktreePath: worktreeAbs, + branch: branch ?? '', + issueNumber, + mainBranch: settings.devBranch || 'main', + customScriptPath: settings.worktreePreRemoveScript, + }) + killProcessesUsingWorktree(worktreeAbs) + await removeWorktreeDir(workspaceRoot, worktreeAbs) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message }) + toast(panel, 'error', `已移交,但本机 worktree 清理失败:${message}`) + } + } + if (branch) { + const settings = getSettings(panel.context) + await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settings.devBranch, autoBuildBranch: settings.autoBuildBranch }) + } + + dismiss(panel, spinner) + panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) }) + toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`) + finish() +} + +/** + * 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 → + * 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。 + */ +export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const ctx = await resolveRepoCtx(panel) + if (!ctx) + return + const { workspaceRoot, host, owner, repo, token } = ctx + const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) + + let state: Record + try { + state = await panel.readIssueState(issueNumber) + } + catch (err) { + toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) + finish() + return + } + const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10) + if (!Number.isFinite(attachmentId)) { + toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`) + finish() + return + } + + const spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 }) + const extracted = await scratchDir('spx-takeover-') + try { + // ① 下载 + 解包 + 校验清单 + const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) + const data = await downloadAttachment({ token, url: meta.browser_download_url }) + const archive = path.join(extracted, meta.name) + await fsp.writeFile(archive, data) + await unpackHandoff(archive, extracted) + const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber) + + // ② 重建 worktree + const branch = manifest.branch ?? str(state.branch) + let worktreeAbs: string | undefined + if (branch) { + const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE + const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch)) + worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch }) + const settings = getSettings(panel.context) + await panel.dispatchWorktreeHook('post-create', { + workspaceRoot, + worktreePath: worktreeAbs, + branch, + issueNumber, + mainBranch: settings.devBranch || 'main', + customScriptPath: settings.worktreePostCreateScript, + }) + } + + // ③ 装会话文件 + await installHandoffBundle(extracted, manifest, { + projectsRoot: claudeProjectsRoot(), + codexRoot: defaultCodexSessionsDir(), + workspaceProjectsDir: projectsDirFor(workspaceRoot), + ...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}), + }) + + // ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) + const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } + await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) + try { + await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) + } + catch (err) { + logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) }) + } + + dismiss(panel, spinner) + panel.postMessage({ + type: 'issue/patch', + issueNumber, + patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs }), + }) + toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`) + logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message }) + dismiss(panel, spinner) + toast(panel, 'error', `接管失败:${message}`) + } + finally { + await fsp.rm(extracted, { recursive: true, force: true }) + finish() + } +} +``` + +`toast/show` 的 `spinner` 为 `undefined` 时 postMessage 会丢键,无碍。 + +- [ ] **Step 3: KanbanPanel 分发** + +`src/panel/KanbanPanel.ts` `handleMessage` 里 `column/change` 分支之后加: + +```ts + if (msg.type === 'handoff/users') { + void handoffFlow.handleHandoffUsers(this, msg.issueNumber) + return + } + if (msg.type === 'handoff/start') { + void handoffFlow.handleHandoffStart(this, msg.issueNumber, msg.to) + return + } + if (msg.type === 'handoff/accept') { + void handoffFlow.handleHandoffAccept(this, msg.issueNumber) + return + } +``` + +顶部 `import * as handoffFlow from './handlers/handoffFlow'`。 + +- [ ] **Step 4: typecheck + lint + 提交** + +Run: `pnpm typecheck && pnpm lint && pnpm test` +Expected: PASS + +```bash +git add src/panel/messages.ts webview-ui/src/lib/messages.ts src/panel/handlers/handoffFlow.ts src/panel/KanbanPanel.ts +git commit -m "✨ feat(vscode): 移交/接管扩展端流程与消息分发" +``` + +--- + +### Task 10: 完成列会话归档改为整包搬迁 + +**Files:** +- Modify: `src/panel/handlers/issues.ts:383-433`(`// Before removing the worktree, copy the impl- and test-session jsonl …` 那一段) + +**Interfaces:** +- Consumes: Task 4 `installClaudeSession`、`claudeProjectsRoot`。 + +- [ ] **Step 1: 替换实现** + +把从 `// Before removing the worktree, copy the impl- and test-session jsonl into` 到该 `if` 块结束(含 `catch` 的 warn)整段替换为: + +```ts + // 删 worktree 前把实施/测试会话整包(jsonl + 子目录)搬到主仓库 projects 目录, + // 并清掉 worktree 侧副本:新版 claude 遇到重复 sid 会拒绝 resume。 + if ((implementSessionId || testSessionId) && worktreePath) { + const worktreeAbs = resolveWorktreePath(worktreePath, workspaceRoot) + const srcProjectsDir = projectsDirFor(worktreeAbs) + const dstProjectsDir = projectsDirFor(workspaceRoot) + for (const sid of [implementSessionId, testSessionId]) { + if (typeof sid !== 'string' || sid.length === 0) + continue + try { + await installClaudeSession({ sid, srcDir: srcProjectsDir, dstProjectsDir, projectsRoot: claudeProjectsRoot() }) + logger.add({ level: 'info', source: 'panel', message: `cc 会话 ${sid} 已搬到主仓库 projects 目录 (issue #${issueNumber})` }) + } + catch (err) { + logger.add({ + level: 'warn', + source: 'panel', + message: `搬迁 cc 会话 ${sid} 失败 (issue #${issueNumber}),worktree 清理仍继续`, + details: err instanceof Error ? err.message : String(err), + }) + } + } + } +``` + +import:`import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'`。若 `fs`/`path` 因此在该文件无其它引用,保留(文件其它地方仍在用)。 + +- [ ] **Step 2: typecheck + lint + 提交** + +Run: `pnpm typecheck && pnpm lint && pnpm test` +Expected: PASS + +```bash +git add src/panel/handlers/issues.ts +git commit -m "🐛 fix(vscode): 完成列归档会话改为整包搬迁并清理重复 sid" +``` + +--- + +### Task 11: webview:状态、按钮、选人弹窗、角标 + +**Files:** +- Create: `webview-ui/src/components/HandoffModal.tsx` +- Modify: `webview-ui/src/hooks/useIssues.ts` +- Modify: `webview-ui/src/App.tsx:340-380, 444-462` +- Modify: `webview-ui/src/components/BottomTabs.tsx:36-60, 120-140` +- Modify: `webview-ui/src/components/IssueDetailPanel.tsx:20-93, 574-608` +- Modify: `webview-ui/src/components/IssueCard.tsx` +- Modify: `webview-ui/src/components/KanbanBoard.tsx` / `KanbanColumn.tsx`(若 `IssueCard` 由它们渲染,无需传新 prop——角标直接读 `issue.handoffAttachmentId`) + +**Interfaces:** +- Consumes: Task 1 类型与消息、Task 9 消息。 +- Produces(useIssues 返回对象新增):`me: string | undefined`、`handoffUsers: { issueNumber: number, users: string[] } | null`、`requestHandoffUsers(issueNumber)`、`startHandoff(issueNumber, to)`、`acceptHandoff(issueNumber)`、`isHandoffRunning(issueNumber)`。 + +- [ ] **Step 1: useIssues** + +在 `useIssues.ts` 状态区加: + +```ts + const [me, setMe] = useState(undefined) + const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null) + const handoffRunningRef = useRef>(new Set()) + const [handoffRunning, setHandoffRunning] = useState>(new Set()) +``` + +回调: + +```ts + const requestHandoffUsers = useCallback((issueNumber: number): void => { + setHandoffUsers(null) + postMessage({ type: 'handoff/users', issueNumber }) + }, []) + + const markHandoffRunning = useCallback((issueNumber: number, running: boolean): void => { + if (running) + handoffRunningRef.current.add(issueNumber) + else + handoffRunningRef.current.delete(issueNumber) + setHandoffRunning(new Set(handoffRunningRef.current)) + }, []) + + const startHandoff = useCallback((issueNumber: number, to: string): void => { + if (handoffRunningRef.current.has(issueNumber)) + return + markHandoffRunning(issueNumber, true) + postMessage({ type: 'handoff/start', issueNumber, to }) + }, [markHandoffRunning]) + + const acceptHandoff = useCallback((issueNumber: number): void => { + if (handoffRunningRef.current.has(issueNumber)) + return + markHandoffRunning(issueNumber, true) + postMessage({ type: 'handoff/accept', issueNumber }) + }, [markHandoffRunning]) + + const isHandoffRunning = useCallback((issueNumber: number): boolean => handoffRunning.has(issueNumber), [handoffRunning]) +``` + +`onMessage` switch:`issues/update` 分支加 `setMe(msg.me)`;新增: + +```ts + case 'handoff/users-result': + setHandoffUsers({ issueNumber: msg.issueNumber, users: msg.users }) + break + case 'handoff/done': + markHandoffRunning(msg.issueNumber, false) + break +``` + +`useEffect` 依赖数组加 `markHandoffRunning`。返回对象加 `me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning`。 + +- [ ] **Step 2: HandoffModal** + +`webview-ui/src/components/HandoffModal.tsx`: + +```tsx +import { useEffect, useState } from 'react' +import { Loader2, X } from 'lucide-react' +import { SelectMenu } from './ui/select-menu' + +interface Props { + open: boolean + issueNumber: number | null + /** null = 候选列表还在加载 */ + users: string[] | null + onCancel: () => void + onSubmit: (issueNumber: number, to: string) => void +} + +export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: Props) { + const [to, setTo] = useState('') + + useEffect(() => { + if (!open) { + setTo('') + return + } + function handleKey(e: KeyboardEvent): void { + if (e.key === 'Escape') + onCancel() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [open, onCancel]) + + useEffect(() => { + if (users && users.length > 0 && !to) + setTo(users[0]) + }, [users, to]) + + if (!open || issueNumber === null) + return null + + return ( +
+
+
+

+ 移交 # + {issueNumber} +

+ +
+

+ 会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。 +

+ {users === null + ? ( +
+ + 读取可指派用户… +
+ ) + : users.length === 0 + ?
没有可指派的其他用户
+ : ( + ({ value: u, label: u }))} + onChange={setTo} + /> + )} +
+ + +
+
+
+ ) +} +``` + +`SelectMenu` 的 props 为 `{ value: string, options: SelectMenuOption[], onChange: (value: string) => void }`(`webview-ui/src/components/ui/select-menu.tsx:13-16`);`SelectMenuOption` 的第二个字段名以该文件 `:7-11` 为准(`label` 或 `name`),按实际写。 + +- [ ] **Step 3: IssueDetailPanel 按钮** + +Props 加: + +```ts + /** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */ + me?: string + onStartHandoff: (issueNumber: number) => void + onAcceptHandoff: (issueNumber: number) => void + isHandoffRunning: (issueNumber: number) => boolean +``` + +解构处加三个回调与 `me`。头部 `issue.source !== 'youtrack'` 的 fragment 内、「重置为待办」按钮之前加: + +```tsx + {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && ( + + )} + {!issue.handoffAttachmentId && issue.column !== 'done' && ( + + )} + {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( + + 待接管 + + )} +``` + +从 `lucide-react` 追加 import `ArrowRightLeft, Download, Loader2`(已 import 的不重复)。 + +- [ ] **Step 4: BottomTabs 透传** + +Props 接口加 `me?: string`、`onStartHandoff`、`onAcceptHandoff`、`isHandoffRunning`(类型同上);渲染 `IssueDetailPanel` 处透传四个。 + +- [ ] **Step 5: App.tsx 接线 + 挂 modal** + +从 `useIssues()` 解构出 `me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning`。加状态 `const [handoffIssue, setHandoffIssue] = useState(null)`。 + +`BottomTabs` 加: + +```tsx + me={me} + onStartHandoff={(n) => { setHandoffIssue(n); requestHandoffUsers(n) }} + onAcceptHandoff={acceptHandoff} + isHandoffRunning={isHandoffRunning} +``` + +在 `` 旁挂: + +```tsx + setHandoffIssue(null)} + onSubmit={(n, to) => { startHandoff(n, to); setHandoffIssue(null) }} + /> +``` + +- [ ] **Step 6: IssueCard 角标** + +`IssueCard.tsx` 在 `locked` 图标之后(同级)加: + +```tsx + {issue.handoffAttachmentId + ? ( + + 待接管 + + ) + : null} +``` + +- [ ] **Step 7: typecheck + lint + 提交** + +Run: `pnpm typecheck && pnpm lint` +Expected: PASS + +```bash +git add webview-ui/src +git commit -m "✨ feat(vscode): webview 移交/接管按钮、选人弹窗与待接管角标" +``` + +--- + +### Task 12: README、版本、打包、安装、spx 重装 + +**Files:** +- Modify: `README.md`(工作流章节、state JSON 字段表、面板「工单 tab」段) +- Modify: `package.json:5`(`0.2.95` → `0.2.96`) + +- [ ] **Step 1: README** + +「工作流」列表第 6 条「回退」后加第 7 条: + +``` +7. **移交**:工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree(`git worktree add -B … origin/`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 +``` + +state JSON 字段表加两行: + +``` +| `handoffAttachmentId` / `handoffFrom` | 移交中:会话包附件 id 与发起人;接管后清空 | +``` + +「工单 tab」段落的顶部按钮列表加「移交 / 接管」。 + +「环境要求」表 `git` 行说明末尾加「、tar(移交打包)」。 + +- [ ] **Step 2: 版本号与打包** + +`package.json` 的 `"version": "0.2.95"` → `"0.2.96"`。 + +Run: `pnpm test && pnpm lint && pnpm typecheck && pnpm ext:package` +Expected: 生成 `superpowers-vscode-clurdra-0.2.96.vsix`。 + +- [ ] **Step 3: 安装本机 + 重装 spx** + +Run: +```bash +code --install-extension superpowers-vscode-clurdra-0.2.96.vsix --force --profile augment +cd cli && make install && spx --help | head -3 +``` +Expected: 扩展安装成功;`~/.local/bin/spx` 为带新 schema 的版本。 + +- [ ] **Step 4: 提交** + +```bash +git add README.md package.json +git commit -m "🔖 chore(vscode): bump 0.2.96,README 补移交/接管" +``` + +--- + +### Task 13: 端到端验证(手动) + +**Files:** 无代码改动;问题回到对应 Task 修。 + +- [ ] **Step 1: 双窗口准备** + +同一台机器开两个 VS Code 窗口指向同一仓库的两个 clone(或用 `dse` / `m1max` 其中一台作为接管方),分别配置两个不同 Gitea 账号的 token(`git config user.email` 与 Gitea 邮箱一致)。两边都装 0.2.96 与新 spx。 + +- [ ] **Step 2: 移交** + +在窗口 A:挑一个进行中的工单(有 worktree、实施会话),确保 `git status` 干净且已 push。工单 tab 点「移交」→ 选 B 账号 → 确认。 +Expected:spinner toast → 成功 toast;A 的工单 tab 会话 id / 工作树行清空;Gitea 工单附件里出现 `spx-handoff-issue-N.tgz`;state 评论含 `handoffAttachmentId` / `handoffFrom`;assignee 变为 B;A 本机 worktree 目录消失、`git branch` 无该 feature 分支、`git ls-remote origin ` 仍在。 + +- [ ] **Step 3: 接管** + +在窗口 B:看板卡片出现「待接管」角标(webhook 指派事件触发刷新;否则点刷新)。工单 tab 点「接管」。 +Expected:成功 toast 含 worktree 路径;工作树行可双击打开;会话 id 行双击能 `claude --resume` 起同事的实施会话且历史完整;`ls ~/.claude/projects/*/.jsonl` 只有一份;Gitea 附件已删除;state 评论两个 handoff 字段为空。 + +- [ ] **Step 4: 后续流程** + +在窗口 B 把工单拖到「完成」:PR 合并(或制造冲突验证 `issue-N-冲突解决` 会话能在重建的 worktree 里启动)。 +Expected:与本机实施的工单行为一致。 + +- [ ] **Step 5: 失败路径** + +在 A 上对一个有未提交改动的 worktree 点「移交」。 +Expected:toast「工作区有未提交改动…」,什么都没改(附件、assignee、本机状态均不变)。