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, } }