Files

98 KiB
Raw Permalink Blame History

工单移交(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-<N>.tgzhandoff.json 清单 + claude/ + codex/)作为 Gitea 工单附件传输;共享 state JSON 新增两个字符串字段 handoffAttachmentId / handoffFrom 标记「待接管」;接管方用 git worktree add -B <branch> <path> origin/<branch> 重建 worktree,再把会话文件装到对应 ~/.claude/projects/<encoded cwd>/ 目录(先删本机所有同 sid 副本)。纯 fs / 纯决策逻辑拆成小模块配 vitest;副作用编排放 panel/handlers/handoffFlow.ts

Tech Stack: TypeScript、VS Code Extension API、Node 22 全局 fetch/FormData/Blobtar CLI、React 19 + Tailwind 4webview)、Vitest、Gospx 的 schema 副本)。

Spec: docs/superpowers/specs/issue-handoff/spec.md

Global Constraints

  • 所有命令在 /home/cruldra/Sources/superwork/vscode 下执行(pnpm test / pnpm lint / pnpm typecheckGo 用 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.tswebview-ui/src/lib/messages.tssrc/gitea/types.tswebview-ui/src/types.ts 是手工镜像,改一处必改另一处。
  • state JSON 清空字段用 '' 墓碑;issue/patch 清空字段用 nullundefined 会被 postMessage 丢掉)。
  • 移交只支持 Gitea 工单(issue.source !== 'youtrack')。
  • 仅 POSIX:打包/解包直接 execFile('tar', …)
  • 会话 id 与 ~/.claude/projects 目录编码:encodeCwdForProjectsDir(abs) = abs.replace(/[/.]/g, '-')projectsDirFor(abs) = ~/.claude/projects/<encoded>(已有,src/cc/sessionWatcher.ts)。

文件结构

文件 动作 职责
schemas/state-json.schema.json 新增 handoffAttachmentId / handoffFrom
cli/cmd/spx/main.go knownStateFields 加两项;make sync-schema 同步 schema 副本
src/gitea/stateJson.tssrc/youtrack/stateComment.ts KNOWN_STATE_FIELDS 加两项
src/gitea/types.tswebview-ui/src/types.ts IssuehandoffAttachmentId?handoffFrom?assignees?
src/gitea/issueLoader.ts 解析并透出上述字段
src/panel/messages.tswebview-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 导出 runGitfindLiveWorktreeForBranch;新增 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 / unpackHandofftar
src/gitea/api.ts assignees 列表、附件 list/upload/get/download/delete
src/panel/handlers/handoff.ts(新) 纯决策与 payloadcanStartHandoffcanAcceptHandoffcheckWorktreePushed、四个 payload 函数
src/panel/handlers/handoffFlow.ts(新) handleHandoffUsers / handleHandoffStart / handleHandoffAccept
src/panel/KanbanPanel.ts 三条消息分发;issues/updateme
src/panel/handlers/issues.ts 完成列 jsonl 拷贝改为 installClaudeSession 搬迁
webview-ui/src/components/HandoffModal.tsx(新) 选人弹窗
webview-ui/src/hooks/useIssues.tsApp.tsxBottomTabs.tsxIssueDetailPanel.tsxIssueCard.tsx 按钮、角标、状态、消息
README.mdpackage.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?: stringIssue.handoffFrom?: stringIssue.assignees?: string[]issues/update 消息多 me?: stringissue/patch 支持 handoffAttachmentId?: string | nullhandoffFrom?: string | nullassignees?: string[]sessionId?: string | nulltestSessionId?: string | null

  • Step 1: schema 加字段

schemas/state-json.schema.jsonproperties 末尾(autoReview 之后)追加:

    "handoffAttachmentId": {
      "type": "string",
      "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。",
      "minLength": 1
    },
    "handoffFrom": {
      "type": "string",
      "description": "发起移交的 Gitea login。",
      "minLength": 1
    }
  • Step 2: Go 侧同步

cli/cmd/spx/main.goknownStateFields 加:

	"handoffAttachmentId":   {},
	"handoffFrom":           {},

运行:cd cli && make sync-schema && make test Expected: ok .../internal/stateschema_test 通过,副本一致)

  • Step 3: TS 两处 KNOWN_STATE_FIELDS

src/gitea/stateJson.ts:22src/youtrack/stateComment.ts:23 的数组末尾各加 'handoffAttachmentId', 'handoffFrom'

  • Step 4: Issue 类型(两份镜像)

src/gitea/types.tsIssueautoReview?: boolean 之后加:

  /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
  assignees?: string[]
  /** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
  handoffAttachmentId?: string
  /** 发起移交的 Gitea login。 */
  handoffFrom?: string

webview-ui/src/types.tsIssue 加同样三个字段。

  • Step 5: issueLoader 解析

src/gitea/issueLoader.ts parseColumnFromComments

  • 返回类型加 handoffAttachmentId?: stringhandoffFrom?: string
  • obj 断言类型加 handoffAttachmentId?: unknownhandoffFrom?: unknown
  • const autoReview = … 后加:
      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 之前加:

    ...(handoffAttachmentId ? { handoffAttachmentId } : {}),
    ...(handoffFrom ? { handoffFrom } : {}),
    assignees: (issue.assignees ?? []).map(a => a.login),
  • Step 6: 消息类型(两份镜像)

src/panel/messages.ts:43 改为:

    | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string }

:45issue/patch patch 对象:sessionId?: stringsessionId?: string | nulltestSessionId?: stringtestSessionId?: string | null,并在末尾追加 , assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null

webview-ui/src/lib/messages.ts 同两行做同样修改。

  • Step 7: KanbanPanel 发 me

src/panel/KanbanPanel.ts loadAndPushthis.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: 全部通过。

git add schemas cli src webview-ui
git commit -m "✨ feat(vscode): state JSON 新增 handoffAttachmentId/handoffFromIssue 透出 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

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

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>/…),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/<slug>`;接管方只有分支名,从这里反推 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 中私有的 deriveSlugresolveWorktreeDir: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

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<string>(改 export
    • export async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined>(改 export
    • export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string>
  • Step 1: 写集成测试(真 git,仿 test/git/worktreeEnsure.test.ts

test/git/worktreeFromRemote.test.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 }

/** originbare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */
async function setup(): Promise<Fixture> {
  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: FAILensureWorktreeFromRemote 不存在)

  • Step 3: 实现

src/git/worktree.ts:把 function runGitasync function findLiveWorktreeForBranch 改为 export。在 ensureWorktree 之后新增:

/**
 * 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。
 * `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。
 */
export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string> {
  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
}

removeWorktreeDirrm -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

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:

    export interface ClaudeSessionFiles { projectsDir: string, jsonl: string, dir?: string }
    export function claudeProjectsRoot(): string                         // ~/.claude/projects
    export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles[]>
    export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles | undefined>  // 多份取 jsonl mtime 最新
    export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise<void>
    export async function installClaudeSession(opts: { sid: string, srcDir: string, dstProjectsDir: string, projectsRoot: string }): Promise<void>
    export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise<string | undefined>
    export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise<void>
    

    installClaudeSession 语义:把 srcDir/<sid>.jsonl+ srcDir/<sid>/)拷到 dstProjectsDir,然后删除 projectsRoot 下所有 projectsDir !== dstProjectsDir 的副本。srcDir 本身在 projectsRoot 下时(完成列搬迁)也成立。

  • Step 1: 写测试

src/cc/sessionBundle.test.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

/**
 * claude / codex 会话文件的搬运原语。
 *
 * claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选 `<projectsDir>/<sid>/`
 * 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<boolean> {
  try {
    await fsp.stat(p)
    return true
  }
  catch {
    return false
  }
}

async function sessionFilesIn(projectsDir: string, sid: string): Promise<ClaudeSessionFiles | undefined> {
  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<ClaudeSessionFiles[]> {
  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<ClaudeSessionFiles | undefined> {
  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<void> {
  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<void> {
  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<void> {
  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<string | undefined> {
  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<void> {
  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

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:

    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

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

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<string> = new Set<ClaudeSessionKind>(['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<string, unknown>
  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<string, unknown>
  const profilesRaw = (raw.profiles ?? {}) as Record<string, unknown>
  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<string, unknown>
    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<string, unknown>
    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

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:

    // handoffArchive.ts
    export async function packHandoff(stagingDir: string, outFile: string): Promise<void>
    export async function unpackHandoff(archive: string, dstDir: string): Promise<void>
    // 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<HandoffManifest>
    export interface HandoffInstallTargets extends HandoffRoots { workspaceProjectsDir: string, worktreeProjectsDir?: string }
    export async function installHandoffBundle(extractedDir: string, manifest: HandoffManifest, targets: HandoffInstallTargets): Promise<void>
    

    stageHandoffBundle:在 stagingDir 下生成 claude/codex/handoff.json,返回清单(找不到的会话不进 claude/codex 列表,但 sessions 仍原样记录 id)。 installHandoffBundlebrainstorm → workspaceProjectsDirimplement/test → worktreeProjectsDir ?? workspaceProjectsDircodex → codexRoot/<relPath>

  • Step 1: 写测试(stage → pack → unpack → install 全链路)

src/cc/handoffBundle.test.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
import { execFile } from 'node:child_process'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'

function tar(args: string[]): Promise<void> {
  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<void> {
  await fsp.mkdir(path.dirname(outFile), { recursive: true })
  await tar(['-czf', outFile, '-C', stagingDir, '.'])
}

export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
  await fsp.mkdir(dstDir, { recursive: true })
  await tar(['-xzf', archive, '-C', dstDir])
}
  • Step 4: 实现 handoffBundle.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
}

/** 把本机会话文件收进 stagingDirclaude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */
export async function stageHandoffBundle(
  src: HandoffSource,
  stagingDir: string,
  roots: HandoffRoots,
  now: Date = new Date(),
): Promise<HandoffManifest> {
  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<void> {
  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

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 APIassignees 列表 + 附件

Files:

  • Modify: src/gitea/api.ts(末尾追加)

Interfaces:

  • Produces:

    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<GiteaUser[]>
    export async function listIssueAttachments(opts: { host, token, owner, repo, index: number }): Promise<GiteaAttachment[]>
    export async function uploadIssueAttachment(opts: { host, token, owner, repo, index: number, name: string, data: Buffer }): Promise<GiteaAttachment>
    export async function getIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise<GiteaAttachment>
    export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer>
    export async function deleteIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise<void>
    
  • Step 1: 实现

src/gitea/api.ts 末尾追加(Buffer'node:buffer' importFormData/Blob 用 Node 22 全局):

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<GiteaUser[]> {
  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<GiteaAttachment[]> {
  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<GiteaAttachment> {
  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-Typemultipart 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<GiteaAttachment> {
  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/<uuid>),Gitea 的 web
 * 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
 * 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。
 */
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer> {
  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<void> {
  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,不入库)

在本仓库自己的 Giteagitea.ailoveworld.cngit remote -v 取 owner/repo)上挑一个已关闭的测试工单 N,token 从 ~/.config/tea/config.yml 读(若 token 走 keyring,取 GITEA_TOKEN 环境变量):

T=<token>; O=<owner>; R=<repo>; N=<issue>
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" <browser_download_url>
# 期望 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/<id>"

Expected: 200 text/plain…。若返回 HTML / 302 登录页,改 downloadAttachment 为请求 https://<host>/attachments/<uuid> 并加 ?token= 方案前先把结果报告给用户再定。

  • Step 3: typecheck + 提交

Run: pnpm typecheck && pnpm lint Expected: PASS

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:

    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<string, unknown>
    export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown>
    export function handoffAcceptedStateExtra(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string }): Record<string, unknown>
    export function handoffAcceptedUiPatch(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string, worktreeExists: boolean }): Record<string, unknown>
    
  • Step 1: 写测试

src/panel/handlers/handoff.test.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

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<string, unknown> {
  const extra: Record<string, unknown> = { 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<string, unknown> {
  return {
    handoffAttachmentId: String(attachmentId),
    handoffFrom: from,
    assignees: [to],
    sessionId: null,
    implementSessionId: null,
    reviewSessionId: null,
    testSessionId: null,
    worktreePath: null,
    worktreeExists: false,
  }
}

function definedEntries(o: Record<string, string | undefined>): Record<string, string> {
  const out: Record<string, string> = {}
  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<string, unknown> {
  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<string, unknown> {
  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

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.tswebview-ui/src/lib/messages.ts
  • Create: src/panel/handlers/handoffFlow.ts
  • Modify: src/panel/KanbanPanel.ts:335+

Interfaces:

  • Consumes: Task 3 ensureWorktreeFromRemoteTask 2 resolveWorktreeDir / slugFromBranch / DEFAULT_WORKTREE_TEMPLATETask 6 stageHandoffBundle / installHandoffBundle / packHandoff / unpackHandoffTask 5 handoffAttachmentName / parseHandoffManifest / HANDOFF_MANIFEST_FILETask 4 claudeProjectsRootTask 7 APITask 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<void>
    • export async function handleHandoffStart(panel, issueNumber, to): Promise<void>
    • export async function handleHandoffAccept(panel, issueNumber): Promise<void>
  • Step 1: 消息类型(两份镜像)

src/panel/messages.ts ExtensionToWebviewissue/pr-diff-summary-done 之后加:

    | { type: 'handoff/users-result', issueNumber: number, users: string[] }
    | { type: 'handoff/done', issueNumber: number }

WebviewToExtensioncolumn/change 之后加:

    | { 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

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<RepoCtx | undefined> {
  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<string> {
  return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
}

export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
  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<void> {
  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<string, unknown>
  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<void>(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<void> {
  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<string, unknown>
  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/showspinnerundefined 时 postMessage 会丢键,无碍。

  • Step 3: KanbanPanel 分发

src/panel/KanbanPanel.ts handleMessagecolumn/change 分支之后加:

    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

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 installClaudeSessionclaudeProjectsRoot

  • Step 1: 替换实现

把从 // Before removing the worktree, copy the impl- and test-session jsonl into 到该 if 块结束(含 catch 的 warn)整段替换为:

  // 删 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),
        })
      }
    }
  }

importimport { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'。若 fs/path 因此在该文件无其它引用,保留(文件其它地方仍在用)。

  • Step 2: typecheck + lint + 提交

Run: pnpm typecheck && pnpm lint && pnpm test Expected: PASS

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 消息。

  • ProducesuseIssues 返回对象新增):me: string | undefinedhandoffUsers: { issueNumber: number, users: string[] } | nullrequestHandoffUsers(issueNumber)startHandoff(issueNumber, to)acceptHandoff(issueNumber)isHandoffRunning(issueNumber)

  • Step 1: useIssues

useIssues.ts 状态区加:

  const [me, setMe] = useState<string | undefined>(undefined)
  const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
  const handoffRunningRef = useRef<Set<number>>(new Set())
  const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())

回调:

  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 switchissues/update 分支加 setMe(msg.me);新增:

        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

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<string>('')

  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 (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
      <div
        role="dialog"
        aria-modal="true"
        className="w-[360px] rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] p-4 text-sm shadow-lg"
      >
        <div className="mb-3 flex items-center">
          <h2 className="flex-1 font-medium">
            移交 #
            {issueNumber}
          </h2>
          <button type="button" onClick={onCancel} aria-label="关闭" className="rounded p-1 hover:bg-white/10">
            <X className="size-4" />
          </button>
        </div>
        <p className="mb-3 text-xs opacity-70">
          会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push
        </p>
        {users === null
          ? (
              <div className="flex items-center gap-2 text-xs opacity-70">
                <Loader2 className="size-3.5 animate-spin" />
                读取可指派用户…
              </div>
            )
          : users.length === 0
            ? <div className="text-xs text-red-400">没有可指派的其他用户</div>
            : (
                <SelectMenu
                  value={to}
                  options={users.map(u => ({ value: u, label: u }))}
                  onChange={setTo}
                />
              )}
        <div className="mt-4 flex justify-end gap-2">
          <button type="button" onClick={onCancel} className="rounded border border-[var(--vscode-panel-border)] px-3 py-1 text-xs hover:bg-white/10">
            取消
          </button>
          <button
            type="button"
            disabled={!to}
            onClick={() => onSubmit(issueNumber, to)}
            className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] disabled:opacity-50"
          >
            移交
          </button>
        </div>
      </div>
    </div>
  )
}

SelectMenu 的 props 为 { value: string, options: SelectMenuOption[], onChange: (value: string) => void }webview-ui/src/components/ui/select-menu.tsx:13-16);SelectMenuOption 的第二个字段名以该文件 :7-11 为准(labelname),按实际写。

  • Step 3: IssueDetailPanel 按钮

Props 加:

  /** 当前 Gitea loginundefined = 身份未知(接管按钮不显示)。 */
  me?: string
  onStartHandoff: (issueNumber: number) => void
  onAcceptHandoff: (issueNumber: number) => void
  isHandoffRunning: (issueNumber: number) => boolean

解构处加三个回调与 me。头部 issue.source !== 'youtrack' 的 fragment 内、「重置为待办」按钮之前加:

            {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
              <button
                type="button"
                disabled={isHandoffRunning(issue.number)}
                onClick={() => onAcceptHandoff(issue.number)}
                title={`接管(${issue.handoffFrom ?? '?'} 移交)`}
                aria-label="接管"
                className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
              >
                {isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
                接管
              </button>
            )}
            {!issue.handoffAttachmentId && issue.column !== 'done' && (
              <button
                type="button"
                disabled={isHandoffRunning(issue.number)}
                onClick={() => onStartHandoff(issue.number)}
                title="移交给同事"
                aria-label="移交"
                className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
              >
                {isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
              </button>
            )}
            {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
              <span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
                待接管
              </span>
            )}

lucide-react 追加 import ArrowRightLeft, Download, Loader2(已 import 的不重复)。

  • Step 4: BottomTabs 透传

Props 接口加 me?: stringonStartHandoffonAcceptHandoffisHandoffRunning(类型同上);渲染 IssueDetailPanel 处透传四个。

  • Step 5: App.tsx 接线 + 挂 modal

useIssues() 解构出 me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning。加状态 const [handoffIssue, setHandoffIssue] = useState<number | null>(null)

BottomTabs 加:

                me={me}
                onStartHandoff={(n) => { setHandoffIssue(n); requestHandoffUsers(n) }}
                onAcceptHandoff={acceptHandoff}
                isHandoffRunning={isHandoffRunning}

<NewIssueModal …/> 旁挂:

      <HandoffModal
        open={handoffIssue !== null}
        issueNumber={handoffIssue}
        users={handoffUsers && handoffUsers.issueNumber === handoffIssue ? handoffUsers.users : null}
        onCancel={() => setHandoffIssue(null)}
        onSubmit={(n, to) => { startHandoff(n, to); setHandoffIssue(null) }}
      />
  • Step 6: IssueCard 角标

IssueCard.tsxlocked 图标之后(同级)加:

        {issue.handoffAttachmentId
          ? (
              <span
                className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
                title={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
              >
                待接管
              </span>
            )
          : null}
  • Step 7: typecheck + lint + 提交

Run: pnpm typecheck && pnpm lint Expected: PASS

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:50.2.950.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 <branch> … origin/<branch>`)、把会话文件装到本机 `~/.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:

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: 提交
git add README.md package.json
git commit -m "🔖 chore(vscode): bump 0.2.96README 补移交/接管"

Task 13: 端到端验证(手动)

Files: 无代码改动;问题回到对应 Task 修。

  • Step 1: 双窗口准备

同一台机器开两个 VS Code 窗口指向同一仓库的两个 clone(或用 dse / m1max 其中一台作为接管方),分别配置两个不同 Gitea 账号的 tokengit config user.email 与 Gitea 邮箱一致)。两边都装 0.2.96 与新 spx。

  • Step 2: 移交

在窗口 A:挑一个进行中的工单(有 worktree、实施会话),确保 git status 干净且已 push。工单 tab 点「移交」→ 选 B 账号 → 确认。 Expectedspinner toast → 成功 toastA 的工单 tab 会话 id / 工作树行清空;Gitea 工单附件里出现 spx-handoff-issue-N.tgzstate 评论含 handoffAttachmentId / handoffFromassignee 变为 BA 本机 worktree 目录消失、git branch 无该 feature 分支、git ls-remote origin <branch> 仍在。

  • Step 3: 接管

在窗口 B:看板卡片出现「待接管」角标(webhook 指派事件触发刷新;否则点刷新)。工单 tab 点「接管」。 Expected:成功 toast 含 worktree 路径;工作树行可双击打开;会话 id 行双击能 claude --resume 起同事的实施会话且历史完整;ls ~/.claude/projects/*/<sid>.jsonl 只有一份;Gitea 附件已删除;state 评论两个 handoff 字段为空。

  • Step 4: 后续流程

在窗口 B 把工单拖到「完成」:PR 合并(或制造冲突验证 issue-N-冲突解决 会话能在重建的 worktree 里启动)。 Expected:与本机实施的工单行为一致。

  • Step 5: 失败路径

在 A 上对一个有未提交改动的 worktree 点「移交」。 Expected:toast「工作区有未提交改动…」,什么都没改(附件、assignee、本机状态均不变)。