98 KiB
工单移交(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>.tgz(handoff.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/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/<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.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 之后)追加:
"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 加:
"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 之后加:
/** 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 = …后加:
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 }
: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: 全部通过。
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): stringexport function slugFromBranch(branch: string): stringexport 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 中私有的 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
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 }
/** origin(bare)← 同事的 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: FAIL(ensureWorktreeFromRemote 不存在)
- Step 3: 实现
src/git/worktree.ts:把 function runGit 与 async 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
}
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
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)。installHandoffBundle:brainstorm →workspaceProjectsDir;implement/test →worktreeProjectsDir ?? workspaceProjectsDir;codex →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
}
/** 把本机会话文件收进 stagingDir(claude/、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 API:assignees 列表 + 附件
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' import;FormData/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-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<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,不入库)
在本仓库自己的 Gitea(gitea.ailoveworld.cn,git 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.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 2resolveWorktreeDir/slugFromBranch/DEFAULT_WORKTREE_TEMPLATE;Task 6stageHandoffBundle/installHandoffBundle/packHandoff/unpackHandoff;Task 5handoffAttachmentName/parseHandoffManifest/HANDOFF_MANIFEST_FILE;Task 4claudeProjectsRoot;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<void>export async function handleHandoffStart(panel, issueNumber, to): Promise<void>export async function handleHandoffAccept(panel, issueNumber): Promise<void>
- 消息:
-
Step 1: 消息类型(两份镜像)
src/panel/messages.ts ExtensionToWebview 在 issue/pr-diff-summary-done 之后加:
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'handoff/done', issueNumber: number }
WebviewToExtension 在 column/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/show 的 spinner 为 undefined 时 postMessage 会丢键,无碍。
- Step 3: KanbanPanel 分发
src/panel/KanbanPanel.ts handleMessage 里 column/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
installClaudeSession、claudeProjectsRoot。 -
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),
})
}
}
}
import:import { 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 消息。
-
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 状态区加:
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 switch:issues/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 为准(label 或 name),按实际写。
- Step 3: IssueDetailPanel 按钮
Props 加:
/** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */
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?: string、onStartHandoff、onAcceptHandoff、isHandoffRunning(类型同上);渲染 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.tsx 在 locked 图标之后(同级)加:
{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: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 <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.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 <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、本机状态均不变)。