diff --git a/vscode/src/cc/handoffManifest.test.ts b/vscode/src/cc/handoffManifest.test.ts new file mode 100644 index 0000000..1131d7f --- /dev/null +++ b/vscode/src/cc/handoffManifest.test.ts @@ -0,0 +1,38 @@ +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() + }) +}) diff --git a/vscode/src/cc/handoffManifest.ts b/vscode/src/cc/handoffManifest.ts new file mode 100644 index 0000000..446e825 --- /dev/null +++ b/vscode/src/cc/handoffManifest.ts @@ -0,0 +1,101 @@ +export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test' + +export const CLAUDE_SESSION_FIELDS = [ + { field: 'sessionId', kind: 'brainstorm' }, + { field: 'implementSessionId', kind: 'implement' }, + { field: 'testSessionId', kind: 'test' }, +] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }> + +export interface HandoffSessions { + sessionId?: string + implementSessionId?: string + testSessionId?: string + reviewSessionId?: string +} + +export interface HandoffProfiles { + profilePath?: string + brainstormProfilePath?: string + testProfilePath?: string +} + +export interface HandoffManifest { + version: 1 + issue: number + from: string + createdAt: string + branch?: string + sessions: HandoffSessions + profiles: HandoffProfiles + claude: Array<{ id: string, kind: ClaudeSessionKind }> + codex: Array<{ id: string, relPath: string }> +} + +export const HANDOFF_MANIFEST_FILE = 'handoff.json' + +export function handoffAttachmentName(issueNumber: number): string { + return `spx-handoff-issue-${issueNumber}.tgz` +} + +const KINDS: ReadonlySet = new Set(['brainstorm', 'implement', 'test']) + +function optString(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest { + const raw = JSON.parse(json) as Record + if (!raw || typeof raw !== 'object') + throw new Error('handoff.json 不是对象') + if (raw.version !== 1) + throw new Error(`handoff.json version 不支持:${String(raw.version)}`) + if (raw.issue !== expectedIssue) + throw new Error(`handoff.json 属于工单 #${String(raw.issue)},不是 #${expectedIssue}`) + const from = optString(raw.from) + const createdAt = optString(raw.createdAt) + if (!from || !createdAt) + throw new Error('handoff.json 缺少 from / createdAt') + const sessionsRaw = (raw.sessions ?? {}) as Record + const profilesRaw = (raw.profiles ?? {}) as Record + const claudeRaw = Array.isArray(raw.claude) ? raw.claude : [] + const codexRaw = Array.isArray(raw.codex) ? raw.codex : [] + const claude = claudeRaw.map((e) => { + const o = e as Record + const id = optString(o.id) + const kind = optString(o.kind) + if (!id || !kind || !KINDS.has(kind)) + throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`) + return { id, kind: kind as ClaudeSessionKind } + }) + const codex = codexRaw.map((e) => { + const o = e as Record + const id = optString(o.id) + const relPath = optString(o.relPath) + if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..')) + throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`) + return { id, relPath } + }) + const sessions: HandoffSessions = {} + for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'reviewSessionId'] as const) { + const v = optString(sessionsRaw[k]) + if (v) + sessions[k] = v + } + const profiles: HandoffProfiles = {} + for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath'] as const) { + const v = optString(profilesRaw[k]) + if (v) + profiles[k] = v + } + return { + version: 1, + issue: expectedIssue, + from, + createdAt, + ...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}), + sessions, + profiles, + claude, + codex, + } +}