# 工单移交(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、本机状态均不变)。