diff --git a/.serena/project.yml b/.serena/project.yml index ff68061..46cec7d 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -33,6 +33,7 @@ project_name: "superwork" # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: - go +- typescript # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings diff --git a/vscode/package.json b/vscode/package.json index 56fc496..808788c 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.55", + "version": "0.2.60", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/cc/codexCommand.ts b/vscode/src/cc/codexCommand.ts index 8c2af4b..ac607d6 100644 --- a/vscode/src/cc/codexCommand.ts +++ b/vscode/src/cc/codexCommand.ts @@ -1,9 +1,8 @@ /** * Single source of truth for assembling `codex` invocations. * - * The review flow shells codex three different ways — headless - * `codex exec review` (spawned as an arg array), `codex resume ` and the - * interactive `codex ''` TUI (both sent to a terminal). Building the + * The review flow shells codex two ways — `codex resume ` and the + * interactive `codex ''` TUI — both sent to a terminal. Building the * flags in one place keeps `--dangerously-bypass-approvals-and-sandbox` and the * optional model / reasoning-effort `-c` overrides from drifting apart. * @@ -12,18 +11,16 @@ */ export interface CodexCommandOpts { - /** 'exec-review' = `codex exec review ...`;'resume' = `codex resume `;'interactive' = `codex ''` */ - mode: 'exec-review' | 'resume' | 'interactive' + /** 'resume' = `codex resume `;'interactive' = `codex ''` */ + mode: 'resume' | 'interactive' /** resume 模式必填:codex thread id */ sessionId?: string - /** interactive / exec-review 模式的 prompt(positional)。调用方保证 interactive 模式下不含单引号。 */ + /** interactive 模式的 prompt(positional)。调用方保证不含单引号。 */ prompt?: string /** 空 = 不传 `-c model=`,用 codex 默认 */ model?: string /** 空 = 不传 `-c model_reasoning_effort=`,用 codex 默认。合法值 minimal/low/medium/high/xhigh */ reasoningEffort?: string - /** exec-review 模式:追加 --json */ - json?: boolean } const BYPASS_FLAG = '--dangerously-bypass-approvals-and-sandbox' @@ -43,39 +40,11 @@ function configOverrides(opts: CodexCommandOpts): string[] { return parts } -/** - * Shared assembly for both output shapes. `quotePrompt` wraps the positional - * prompt in single quotes for the shell-string form; the spawn-array form - * passes it raw (no shell, so no quoting). - */ -function buildParts(opts: CodexCommandOpts, quotePrompt: boolean): string[] { - const overrides = configOverrides(opts) - const prompt = opts.prompt - ? (quotePrompt ? `'${opts.prompt}'` : opts.prompt) - : undefined - switch (opts.mode) { - case 'exec-review': - return [ - 'exec', - 'review', - ...overrides, - BYPASS_FLAG, - ...(opts.json ? ['--json'] : []), - ...(prompt !== undefined ? [prompt] : []), - ] - case 'resume': - return ['resume', ...overrides, BYPASS_FLAG, ...(opts.sessionId ? [opts.sessionId] : [])] - case 'interactive': - return [...overrides, BYPASS_FLAG, ...(prompt !== undefined ? [prompt] : [])] - } -} - -/** Argument array (without the leading `codex`) for `spawn('codex', args)`. */ -export function buildCodexArgs(opts: CodexCommandOpts): string[] { - return buildParts(opts, false) -} - /** Full shell command string (with `codex` prefix) for `terminal.sendText`. */ export function buildCodexCommandString(opts: CodexCommandOpts): string { - return ['codex', ...buildParts(opts, true)].join(' ') + const overrides = configOverrides(opts) + const parts = opts.mode === 'resume' + ? ['resume', ...overrides, BYPASS_FLAG, ...(opts.sessionId ? [opts.sessionId] : [])] + : [...overrides, BYPASS_FLAG, ...(opts.prompt ? [`'${opts.prompt}'`] : [])] + return ['codex', ...parts].join(' ') } diff --git a/vscode/src/cc/reviewFlow.ts b/vscode/src/cc/reviewFlow.ts deleted file mode 100644 index 9be162e..0000000 --- a/vscode/src/cc/reviewFlow.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Spawns `codex exec review --json` headlessly and waits for it to finish. - * - * Output handling: codex itself is responsible for posting the review back - * as a PR comment (instructed via the review prompt). This wrapper only - * scans codex's NDJSON stdout for the very first `thread.started` event so - * the caller can persist the `thread_id` (review session id) for manual - * resume; everything after that is ignored. Failures are logged but never - * thrown — the webhook coordinator treats review runs as fire-and-forget. - */ - -import { spawn } from 'node:child_process' -import { logger } from '../logging/logger' -import { buildCodexArgs } from './codexCommand' - -export interface RunReviewOpts { - workspaceRoot: string - /** Already-substituted prompt — placeholders must be resolved by the caller. */ - prompt: string - /** 空 = 不传 `-c model=`,用 codex config.toml 默认。 */ - model?: string - /** 空 = 不传 `-c model_reasoning_effort=`,用 codex 默认。 */ - reasoningEffort?: string - /** Spawn timeout in milliseconds. Defaults to 5 minutes. */ - timeoutMs?: number - /** - * Called once with the codex `thread_id` parsed from the first - * `thread.started` NDJSON event. Errors thrown inside this callback are - * caught and logged — they never propagate out of `runReview`. - */ - onThreadId?: (id: string) => void | Promise -} - -const DEFAULT_TIMEOUT_MS = 300_000 - -/** - * Run `codex exec review --json` and wait for it to exit. Errors are - * logged as warnings; this function never throws. - */ -export async function runReview(opts: RunReviewOpts): Promise { - const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS - const args = buildCodexArgs({ - mode: 'exec-review', - prompt: opts.prompt, - model: opts.model, - reasoningEffort: opts.reasoningEffort, - json: true, - }) - - await new Promise((resolve) => { - let child: ReturnType - try { - child = spawn('codex', args, { - cwd: opts.workspaceRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }) - } - catch (err) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'codex exec review 启动失败', - details: err instanceof Error ? err.message : String(err), - }) - resolve() - return - } - - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - try { - child.kill('SIGTERM') - } - catch { - // ignore - } - }, timeoutMs) - - // NDJSON parsing: accumulate stdout, split on newline, parse each line. - // Stop parsing after the first `thread.started` event — let the rest - // of stdout drain to /dev/null so codex can keep running. - let buf = '' - let threadIdHandled = false - let stderrTail = '' - - let diagLogged = 0 - const handleLine = (line: string): void => { - if (threadIdHandled || !line.trim()) - return - // Diagnostic: log the first 3 lines so we can inspect codex's actual - // event shape if thread id detection misses. - if (diagLogged < 3) { - logger.add({ - level: 'info', - source: 'webhook', - message: `codex stdout[${diagLogged}]`, - details: line.slice(0, 500), - }) - diagLogged += 1 - } - let parsed: unknown - try { - parsed = JSON.parse(line) - } - catch { - return - } - if (!parsed || typeof parsed !== 'object') - return - const obj = parsed as { type?: unknown, thread_id?: unknown, session_id?: unknown, id?: unknown } - // Try multiple field names: thread_id (current), session_id (older - // codex), id (fallback when the event is a "session created" shape). - // Accept any event so long as it carries one of these as a string. - const candidate - = (typeof obj.thread_id === 'string' && obj.thread_id.length > 0 && obj.thread_id) - || (typeof obj.session_id === 'string' && obj.session_id.length > 0 && obj.session_id) - || (typeof obj.id === 'string' && obj.id.length > 0 && /^[0-9a-f-]{16,}$/i.test(obj.id) && obj.id) - || '' - if (candidate) { - threadIdHandled = true - const id = candidate - if (opts.onThreadId) { - try { - const ret = opts.onThreadId(id) - if (ret && typeof (ret as Promise).then === 'function') { - ;(ret as Promise).catch((err) => { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'onThreadId 回调异常', - details: err instanceof Error ? err.message : String(err), - }) - }) - } - } - catch (err) { - logger.add({ - level: 'warn', - source: 'webhook', - message: 'onThreadId 回调抛错', - details: err instanceof Error ? err.message : String(err), - }) - } - } - } - } - - child.stdout?.setEncoding('utf-8') - child.stdout?.on('data', (chunk: string) => { - if (threadIdHandled) - return - buf += chunk - let idx = buf.indexOf('\n') - while (idx >= 0) { - const line = buf.slice(0, idx) - buf = buf.slice(idx + 1) - handleLine(line) - if (threadIdHandled) { - buf = '' - break - } - idx = buf.indexOf('\n') - } - }) - - child.stderr?.setEncoding('utf-8') - child.stderr?.on('data', (chunk: string) => { - // Keep only the tail so we can include it in the failure message. - stderrTail = (stderrTail + chunk).slice(-2000) - }) - - child.on('error', (err) => { - clearTimeout(timer) - logger.add({ - level: 'warn', - source: 'webhook', - message: 'codex exec review 进程错误', - details: err instanceof Error ? err.message : String(err), - }) - resolve() - }) - - child.on('close', (code, signal) => { - clearTimeout(timer) - if (timedOut) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `codex exec review 超时 (${timeoutMs}ms)`, - details: stderrTail.slice(-500), - }) - } - else if ((code !== null && code !== 0) || (signal && !timedOut)) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `codex exec review 退出码异常 code=${code} signal=${signal ?? ''}`, - details: stderrTail.slice(-500), - }) - } - resolve() - }) - }) -} diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index cf2e16a..6476378 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -73,6 +73,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { column: IssueColumn | null sessionId?: string profilePath?: string + brainstormProfilePath?: string testProfilePath?: string specFile?: string planFile?: string @@ -116,6 +117,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { column: unknown sessionId?: unknown profilePath?: unknown + brainstormProfilePath?: unknown testProfilePath?: unknown specFile?: unknown planFile?: unknown @@ -139,6 +141,9 @@ function parseColumnFromComments(comments: GiteaComment[]): { const profilePath = typeof obj.profilePath === 'string' && obj.profilePath.length > 0 ? obj.profilePath : undefined + const brainstormProfilePath = typeof obj.brainstormProfilePath === 'string' && obj.brainstormProfilePath.length > 0 + ? obj.brainstormProfilePath + : undefined const testProfilePath = typeof obj.testProfilePath === 'string' && obj.testProfilePath.length > 0 ? obj.testProfilePath : undefined @@ -180,6 +185,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { column: obj.column, sessionId, profilePath, + brainstormProfilePath, testProfilePath, specFile, planFile, @@ -305,6 +311,7 @@ async function buildIssue(opts: { column: fromComment, sessionId, profilePath, + brainstormProfilePath, testProfilePath, specFile, planFile, @@ -369,6 +376,7 @@ async function buildIssue(opts: { column, ...(sessionId ? { sessionId } : {}), ...(profilePath ? { profilePath } : {}), + ...(brainstormProfilePath ? { brainstormProfilePath } : {}), ...(testProfilePath ? { testProfilePath } : {}), ...(specFile ? { specFile } : {}), ...(planFile ? { planFile } : {}), diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index 786870e..25895bf 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api' * 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前 * 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。 */ -const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const +const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const /** * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。 diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index dab1164..f377ba5 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -29,11 +29,11 @@ export interface Issue { /** Optional Claude Code session id stored alongside the column marker in * the issue's state-JSON comment. Used to resume the conversation. */ sessionId?: string - /** Absolute path to the Claude settings profile used at creation; passed - * as --settings on resume. */ + /** 实施会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */ profilePath?: string - /** 测试会话专用的 Claude 配置文件,独立于实施会话的 `profilePath`; - * 留空时测试会话回退到 `profilePath`,再回退默认。 */ + /** 头脑风暴会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */ + brainstormProfilePath?: string + /** 测试会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */ testProfilePath?: string /** Optional workspace-relative path to the spec file for this issue, as * surfaced from the Claude session transcript. Lives under diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index cbb7566..c2eb4c8 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -99,13 +99,13 @@ export class KanbanWebviewPanel { * terminal is shown; the session watcher fills in `sessionId` once cc * starts writing its jsonl. The webhook coordinator drains entries via * `takePendingIssueCreation` when the corresponding `issues opened` - * payload arrives, then merges the column / sessionId / profilePath / + * payload arrives, then merges the column / sessionId / brainstormProfilePath / * color into the state-JSON comment and cleans up the inbox tmpdir. */ // internal: handler 模块访问 readonly pendingIssueCreations = new Map, - profilePath?: string, + brainstormProfilePath?: string, ): Promise { const trimmed = userRequest.trim() if (!trimmed) { @@ -1141,13 +1141,13 @@ export async function handleIssueCreate( } const effectiveProfilePath - = profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH + = brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath : DEFAULT_PROFILE_PATH if (effectiveProfilePath.includes('\'')) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', - message: `创建失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, + message: `创建失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, dismissOnTimer: 8000, }) try { @@ -1185,8 +1185,12 @@ export async function handleIssueCreate( message: `已创建终端 "${terminal.name}"`, }) + // 新建工单会话是头脑风暴;只把用户选中的 brainstorm profile 写入 pending, + // 空串不落盘(webhook 侧也会跳过空值),实施/测试 profile 各自独立配置。 + const storedBrainstormProfile + = brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath.trim() : undefined panel.pendingIssueCreations.set(nonce, { - profilePath: effectiveProfilePath, + brainstormProfilePath: storedBrainstormProfile, color, workspaceRoot, inboxDir, @@ -1572,10 +1576,10 @@ export async function handleUpdateAutoReview(panel: KanbanWebviewPanel, issueNum /** * Persist a per-issue `profilePath` override into the issue's state JSON - * comment. The implement / implement-resume / conflict-resolution cc sessions - * launch with this profile (see resolveImplementProfilePath); empty falls back - * to DEFAULT_PROFILE_PATH. Mirrors handleUpdateAutoReview: optimistic update on - * the webview, rolled back via `issue/patch` if the persist fails. + * comment. Implement / implement-resume sessions use this profile + * (see resolveImplementProfilePath); empty = DEFAULT_PROFILE_PATH. + * Mirrors handleUpdateAutoReview: optimistic update on the webview, rolled + * back via `issue/patch` if the persist fails. */ export async function handleUpdateProfilePath(panel: KanbanWebviewPanel, issueNumber: number, profilePath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath @@ -1661,12 +1665,94 @@ export async function handleUpdateProfilePath(panel: KanbanWebviewPanel, issueNu } } +/** + * Persist a per-issue `brainstormProfilePath` override into the issue's state + * JSON. Brainstorm start/resume use this profile (see resolveBrainstormProfilePath); + * empty = DEFAULT_PROFILE_PATH. 与实施/测试 profile 互不回退。 + */ +export async function handleUpdateBrainstormProfilePath(panel: KanbanWebviewPanel, issueNumber: number, brainstormProfilePath: string): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '请先打开一个工作区文件夹', + dismissOnTimer: 5000, + }) + return + } + + const remote = await detectRepo(workspaceRoot) + if (!remote) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '当前工作区没有 Gitea 远程仓库', + dismissOnTimer: 5000, + }) + return + } + + const token = await getToken(panel.context, remote.host) + if (!token) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: '请先完成 Gitea 配置', + dismissOnTimer: 5000, + }) + return + } + + let previousValue: string | undefined + try { + const existingState = await panel.readIssueState(issueNumber) + previousValue = typeof existingState.brainstormProfilePath === 'string' + ? existingState.brainstormProfilePath + : undefined + } + catch { + previousValue = undefined + } + + try { + await panel.mergeIssueState(issueNumber, { brainstormProfilePath }) + logger.add({ + level: 'info', + source: 'panel', + message: `工单 #${issueNumber} brainstormProfilePath=${brainstormProfilePath} 已持久化`, + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.add({ + level: 'error', + source: 'panel', + message: `持久化 brainstormProfilePath 失败 (issue #${issueNumber})`, + details: message, + }) + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `保存工单 #${issueNumber} 头脑风暴配置文件失败: ${message}`, + dismissOnTimer: 6000, + }) + panel.postMessage({ + type: 'issue/patch', + issueNumber, + patch: { brainstormProfilePath: previousValue }, + }) + } +} + /** * Persist a per-issue `testProfilePath` override into the issue's state JSON - * comment. The manually-started test cc session launches with this profile - * (see resolveTestProfilePath); empty falls back to the implement `profilePath`, - * then DEFAULT_PROFILE_PATH. Mirrors handleUpdateProfilePath: optimistic update - * on the webview, rolled back via `issue/patch` if the persist fails. + * comment. Test start/resume use this profile (see resolveTestProfilePath); + * empty = DEFAULT_PROFILE_PATH,不回退实施 profile。 */ export async function handleUpdateTestProfilePath(panel: KanbanWebviewPanel, issueNumber: number, testProfilePath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index d0f572e..92956b5 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -68,12 +68,11 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: existing.show(false) return } - // 实施 resume 走工单级 profilePath > 默认 的优先级(同 handleImplement); - // 头脑风暴 resume 保持现状,只看工单级 + 默认。 + // 实施 / 头脑风暴各自独立:调用方传入对应字段,空 = DEFAULT。 const effectiveProfilePath = sessionKind === 'implement' ? panel.resolveImplementProfilePath(profilePath) - : (profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH) + : resolveBrainstormProfilePath(panel, profilePath) // Reject paths containing single quotes — we shell-quote with single // quotes below, and embedded quotes would break out of the wrap. In // practice profile paths live under `/home//...` so this is a @@ -977,14 +976,12 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is return } - // Pull profilePath from the issue's state JSON so the new cc session - // reuses whatever profile the user set when the issue was first created. - // Tolerated: missing/unparseable comment falls back to DEFAULT_PROFILE_PATH. - let profilePath: string | undefined + // 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。 + let brainstormProfilePath: string | undefined try { const stateObj = await panel.readIssueState(issueNumber) - if (stateObj && typeof stateObj.profilePath === 'string' && stateObj.profilePath.length > 0) - profilePath = stateObj.profilePath + if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0) + brainstormProfilePath = stateObj.brainstormProfilePath } catch (err) { logger.add({ @@ -995,11 +992,10 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is }) } - const effectiveProfilePath - = profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH + const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath) if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( - `启动规划失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, + `启动规划失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) return } @@ -1089,7 +1085,7 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is * 与 handleStartBrainstormSession 的差异: * - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。 * - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码, - * profile 走 resolveTestProfilePath(testProfilePath > profilePath > 默认)。 + * profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。 * - 捕获到的 session id 写进 state JSON 的 `testSessionId`。 * * in-flight 锁 key `${issueNumber}:test`,防 createTerminal 期间用户重复点击。 @@ -1136,11 +1132,10 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum return } - // 从 state JSON 读 pr / profile / worktree。pr 仅在主 worktree(已合并) + // 从 state JSON 读 pr / testProfile / worktree。pr 仅在主 worktree(已合并) // 测试时必需——那条路径要靠 tea 了解合并进来的改动;worktree 内测试代码 // 本就在工作目录,不依赖 PR。 let pr: string | undefined - let profilePath: string | undefined let testProfilePath: string | undefined let worktreePathRel: string | undefined let branch: string | undefined @@ -1149,8 +1144,6 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum if (stateObj) { if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0) pr = stateObj.pr - if (typeof stateObj.profilePath === 'string' && stateObj.profilePath.length > 0) - profilePath = stateObj.profilePath if (typeof stateObj.testProfilePath === 'string' && stateObj.testProfilePath.length > 0) testProfilePath = stateObj.testProfilePath if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0) @@ -1179,11 +1172,11 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum return } - // 测试会话优先用专用 testProfilePath,未设置时回退实施 profile,再回退默认。 - const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath, profilePath) + // 测试会话只用 testProfilePath;空 = DEFAULT,不回退实施 profile。 + const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath) if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( - `启动测试失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, + `启动测试失败:testProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) return } @@ -1289,7 +1282,7 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum * `issue-${N}-测试`、命令 `claude ... --resume ${sessionId}`。 * * cwd 优先用 worktree(relCwd 存在且在磁盘上),否则退回 workspaceRoot。 - * profile 与 start 对称:resolveTestProfilePath(testProfilePath > profilePath > 默认)。 + * profile 与 start 对称:resolveTestProfilePath(testProfilePath > 默认)。 * in-flight 锁 key `${issueNumber}:test`。 */ export async function handleResumeTestSession(panel: KanbanWebviewPanel, sessionId: string, issueNumber: number, _relCwd?: string): Promise { @@ -1312,7 +1305,6 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session let effectiveCwd = workspaceRoot // 顺带读出 branch 给 impl-tab-pre-create 钩子用;读不到就空字符串。 let branchForHook = '' - let profilePath: string | undefined let testProfilePath: string | undefined if (workspaceRoot) { const remote = await detectRepo(workspaceRoot) @@ -1323,8 +1315,6 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session const worktreePathRel = typeof stateObj?.worktreePath === 'string' ? stateObj.worktreePath : undefined if (stateObj && typeof stateObj.branch === 'string') branchForHook = stateObj.branch - if (typeof stateObj?.profilePath === 'string' && stateObj.profilePath.length > 0) - profilePath = stateObj.profilePath if (typeof stateObj?.testProfilePath === 'string' && stateObj.testProfilePath.length > 0) testProfilePath = stateObj.testProfilePath effectiveCwd = await resolveTestSessionCwd(workspaceRoot, worktreePathRel) @@ -1341,10 +1331,10 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session } // --settings 决定 provider/model/鉴权,resume 也必须与 start 一致解析 profile。 - const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath, profilePath) + const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath) if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( - `resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, + `resume 失败:testProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) return } @@ -1611,15 +1601,12 @@ export async function startConflictResolution(panel: KanbanWebviewPanel, opts: { * Pick the profile.json that implement-class cc sessions * (handleImplement / handleResumeSession when sessionKind === 'implement') * should launch with. Priority: - * 1. The per-issue `profilePath` recorded in state JSON (preserves the - * brainstorm-time choice, overridable from the issue detail panel). - * 2. The hard-coded `DEFAULT_PROFILE_PATH` fallback. + * 1. 工单级 `profilePath`(详情面板「实施配置文件」) + * 2. DEFAULT_PROFILE_PATH * - * Conflict-resolution sessions use the global - * `settings.conflictResolutionProfilePath` instead of this helper. - * Brainstorm sessions deliberately don't go through this helper — they - * keep the legacy `profilePath || DEFAULT_PROFILE_PATH` so creators can - * still pick a profile per issue at brainstorm time. + * 冲突解决用全局 `settings.conflictResolutionProfilePath`。 + * 头脑风暴用 resolveBrainstormProfilePath;测试用 resolveTestProfilePath。 + * 三者互不回退。 */ export function resolveImplementProfilePath(_panel: KanbanWebviewPanel, issueLevelProfilePath: string | undefined): string { const issueLevel = issueLevelProfilePath?.trim() @@ -1629,12 +1616,19 @@ export function resolveImplementProfilePath(_panel: KanbanWebviewPanel, issueLev } /** - * 测试会话的 profile 解析:专用 `testProfilePath` 优先,未单独设置时回退到 - * 实施会话的 `profilePath`,再回退默认。让测试会话既能独立锁 profile, - * 又默认与实施会话一致。 + * 头脑风暴会话 profile:工单级 `brainstormProfilePath` > DEFAULT。 + * 不回退实施 profile。 */ -export function resolveTestProfilePath(_panel: KanbanWebviewPanel, testProfilePath: string | undefined, implementProfilePath: string | undefined): string { - return testProfilePath?.trim() || implementProfilePath?.trim() || DEFAULT_PROFILE_PATH +export function resolveBrainstormProfilePath(_panel: KanbanWebviewPanel, brainstormProfilePath: string | undefined): string { + return brainstormProfilePath?.trim() || DEFAULT_PROFILE_PATH +} + +/** + * 测试会话 profile:工单级 `testProfilePath` > DEFAULT。 + * 不回退实施 profile。 + */ +export function resolveTestProfilePath(_panel: KanbanWebviewPanel, testProfilePath: string | undefined): string { + return testProfilePath?.trim() || DEFAULT_PROFILE_PATH } /** diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 656c611..3be4a32 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -346,7 +346,7 @@ export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: nu */ export function takePendingIssueCreation(panel: KanbanWebviewPanel, nonce: string): { sessionId?: string - profilePath?: string + brainstormProfilePath?: string color: string workspaceRoot: string inboxDir: string diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 587c3ff..a76ef34 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -42,7 +42,7 @@ export type ExtensionToWebview = | { type: 'issues/loading' } | { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, testProfilePath?: string } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/append', issue: Issue, select?: boolean } | { type: 'issue/select-by-number', issueNumber: number } @@ -141,7 +141,7 @@ export type WebviewToExtension | { type: 'settings/edit-request' } | { type: 'youtrack/list-projects', baseUrl: string, token: string } | { type: 'youtrack/import' } - | { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, profilePath?: string } + | { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string } | { type: 'toast/open-url', url: string } | { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number } | { type: 'session/focus', issueNumber: number } @@ -161,6 +161,7 @@ export type WebviewToExtension | { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number } | { type: 'issue/update-auto-review', issueNumber: number, value: boolean } | { type: 'issue/update-profile-path', issueNumber: number, profilePath: string } + | { type: 'issue/update-brainstorm-profile-path', issueNumber: number, brainstormProfilePath: string } | { type: 'issue/update-test-profile-path', issueNumber: number, testProfilePath: string } | { type: 'logs/fetch' } | { type: 'logs/clear' } diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index a748f9c..c086a63 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -426,7 +426,7 @@ class WebhookCoordinator { * Handles a freshly opened gitea issue. Two paths: * - The issue body carries `` and matches a * pending creation tracked by the panel → merge column / sessionId / - * profilePath / color into the state-JSON comment, append the card + * brainstormProfilePath / color into the state-JSON comment, append the card * incrementally, then clean up the inbox tmpdir. * - No nonce / no match (external creation, e.g. manual `tea issues * create`) → just append the card so the kanban stays in sync; do @@ -490,8 +490,8 @@ class WebhookCoordinator { } if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0) extra.sessionId = pending.sessionId - if (typeof pending.profilePath === 'string' && pending.profilePath.length > 0) - extra.profilePath = pending.profilePath + if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0) + extra.brainstormProfilePath = pending.brainstormProfilePath try { await mergeStateJsonComment({ diff --git a/vscode/src/youtrack/issueLoader.ts b/vscode/src/youtrack/issueLoader.ts index e83a919..5c6d545 100644 --- a/vscode/src/youtrack/issueLoader.ts +++ b/vscode/src/youtrack/issueLoader.ts @@ -69,6 +69,7 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue { reviewSessionId: str(state.reviewSessionId), testSessionId: str(state.testSessionId), profilePath: str(state.profilePath), + brainstormProfilePath: str(state.brainstormProfilePath), testProfilePath: str(state.testProfilePath), specFile: str(state.specFile), planFile: str(state.planFile), diff --git a/vscode/src/youtrack/stateComment.ts b/vscode/src/youtrack/stateComment.ts index 3272c58..dce3eb7 100644 --- a/vscode/src/youtrack/stateComment.ts +++ b/vscode/src/youtrack/stateComment.ts @@ -20,7 +20,7 @@ import { addComment, listComments } from './api' export const STATE_MARKER = '' /** Same field set as the Gitea state blob — kept independent to avoid coupling. */ -const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const +const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const /** * Parse a comment body into a state object, or null if it isn't a state diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 3df477f..0410a5f 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -55,6 +55,7 @@ export function App() { clearDependency, updateIssueAutoReview, updateIssueProfilePath, + updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, clearLogs, @@ -90,9 +91,9 @@ export function App() { // effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。 const lastProgrammaticSelectRef = useRef(null) - function handleSubmitNewIssue(userRequest: string, images: PastedImage[], profilePath?: string): void { + function handleSubmitNewIssue(userRequest: string, images: PastedImage[], brainstormProfilePath?: string): void { const payload = images.map(({ mediaType, base64 }) => ({ mediaType, base64 })) - createIssue(userRequest, payload.length > 0 ? payload : undefined, profilePath) + createIssue(userRequest, payload.length > 0 ? payload : undefined, brainstormProfilePath) setShowNewIssueModal(false) } @@ -195,7 +196,11 @@ export function App() { e.preventDefault() const isImpl = !!selectedIssue?.implementSessionId const cwd = isImpl ? selectedIssue?.worktreePath : undefined - resumeSession(sid, selectedIssue?.profilePath, cwd, selectedIssue?.number) + // 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。 + const profileForResume = isImpl + ? selectedIssue?.profilePath + : selectedIssue?.brainstormProfilePath + resumeSession(sid, profileForResume, cwd, selectedIssue?.number) } return } @@ -355,6 +360,7 @@ export function App() { onStartBrainstormSession={startBrainstormSession} onUpdateAutoReview={updateIssueAutoReview} onUpdateProfilePath={updateIssueProfilePath} + onUpdateBrainstormProfilePath={updateIssueBrainstormProfilePath} onUpdateTestProfilePath={updateIssueTestProfilePath} onOpenLogs={() => setShowLogs(true)} profileData={profileData} @@ -424,7 +430,6 @@ export function App() { onCancel={() => setShowNewIssueModal(false)} onSubmit={handleSubmitNewIssue} profiles={profiles} - defaultProfileName="offical" /> void onUpdateAutoReview: (issueNumber: number, value: boolean) => void onUpdateProfilePath: (issueNumber: number, profilePath: string) => void + onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void onOpenLogs: () => void @@ -120,6 +121,7 @@ export function BottomTabs(props: BottomTabsProps) { onStartBrainstormSession={props.onStartBrainstormSession} onUpdateAutoReview={props.onUpdateAutoReview} onUpdateProfilePath={props.onUpdateProfilePath} + onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath} onUpdateTestProfilePath={props.onUpdateTestProfilePath} profiles={props.profiles} onOpenLogs={props.onOpenLogs} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index be68f41..e271381 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -61,6 +61,8 @@ interface IssueDetailPanelProps { profiles: ClaudeProfile[] /** Persist a per-issue `profilePath` override into the state JSON. */ onUpdateProfilePath: (issueNumber: number, profilePath: string) => void + /** Persist a per-issue `brainstormProfilePath` override into the state JSON. */ + onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void /** Persist a per-issue `testProfilePath` override into the state JSON. */ onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void /** Open the in-webview log modal. */ @@ -99,6 +101,7 @@ export function IssueDetailPanel({ onUpdateAutoReview, profiles, onUpdateProfilePath, + onUpdateBrainstormProfilePath, onUpdateTestProfilePath, onOpenLogs, }: IssueDetailPanelProps) { @@ -195,7 +198,7 @@ export function IssueDetailPanel({ return if (!throttleBySessionId(v)) return - onResumeSession(v, issue?.profilePath, undefined, issue?.number) + onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number) }, secondaryActionIcon, secondaryActionTitle, @@ -321,40 +324,46 @@ export function IssueDetailPanel({ description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色', }, { - key: 'profilePath', - label: '实施配置文件', + key: 'brainstormProfilePath', + label: '头脑风暴配置文件', type: 'select', options: (() => { // 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何 // option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发 // onChange、永远存不进去;占位项让未设置态有真实匹配项。 const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] - const current = issue?.profilePath - // 自定义路径兜底:state JSON 里存的 profile 不在已知列表时, - // 额外追加一项以免下拉显示空白、丢失当前选择。 + const current = issue?.brainstormProfilePath if (current && !opts.some(o => o.value === current)) opts.push({ label: `自定义:${current}`, value: current }) return opts })(), - description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认(offical),选具体 profile 则覆盖(持久化 state JSON)', + description: '头脑风暴会话使用的 Claude 配置文件;选「(默认)」用内置默认,与实施/测试互不回退', + }, + { + key: 'profilePath', + label: '实施配置文件', + type: 'select', + options: (() => { + const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] + const current = issue?.profilePath + if (current && !opts.some(o => o.value === current)) + opts.push({ label: `自定义:${current}`, value: current }) + return opts + })(), + description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/测试互不回退', }, { key: 'testProfilePath', label: '测试配置文件', type: 'select', options: (() => { - // 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何 - // option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发 - // onChange、永远存不进去;占位项让未设置态有真实匹配项。 const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] const current = issue?.testProfilePath - // 自定义路径兜底:state JSON 里存的 profile 不在已知列表时, - // 额外追加一项以免下拉显示空白、丢失当前选择。 if (current && !opts.some(o => o.value === current)) opts.push({ label: `自定义:${current}`, value: current }) return opts })(), - description: '测试会话使用的 Claude 配置文件;留空时回退实施配置文件', + description: '测试会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/实施互不回退', }, { key: 'specFile', @@ -476,6 +485,7 @@ export function IssueDetailPanel({ testSessionId: issue.testSessionId ?? null, autoReview: issue.autoReview ?? globalAutoReview, color: issue.color ?? null, + brainstormProfilePath: issue.brainstormProfilePath ?? null, profilePath: issue.profilePath ?? null, testProfilePath: issue.testProfilePath ?? null, specFile: issue.specFile ?? null, @@ -582,6 +592,8 @@ export function IssueDetailPanel({ onChange={(key, value) => { if (key === 'autoReview' && issue && typeof value === 'boolean') onUpdateAutoReview(issue.number, value) + if (key === 'brainstormProfilePath' && issue && typeof value === 'string') + onUpdateBrainstormProfilePath(issue.number, value) if (key === 'profilePath' && issue && typeof value === 'string') onUpdateProfilePath(issue.number, value) if (key === 'testProfilePath' && issue && typeof value === 'string') diff --git a/vscode/webview-ui/src/components/NewIssueModal.tsx b/vscode/webview-ui/src/components/NewIssueModal.tsx index 34a4379..9ff9d2d 100644 --- a/vscode/webview-ui/src/components/NewIssueModal.tsx +++ b/vscode/webview-ui/src/components/NewIssueModal.tsx @@ -33,7 +33,7 @@ export interface ClaudeProfile { interface Props { open: boolean onCancel: () => void - onSubmit: (userRequest: string, images: PastedImage[], profilePath?: string) => void + onSubmit: (userRequest: string, images: PastedImage[], brainstormProfilePath?: string) => void profiles: ClaudeProfile[] defaultProfileName?: string } @@ -55,13 +55,10 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi setSelectedProfile(null) return } - // Pick default profile when the modal opens. Prefer the one matching - // `defaultProfileName`, otherwise fall back to the first alphabetically. - if (profiles.length > 0) { - const match = defaultProfileName - ? profiles.find(p => p.name === defaultProfileName) - : undefined - setSelectedProfile(match ? match.name : profiles[0].name) + // 默认不预选 profile(空 = 系统默认);仅当显式传入 defaultProfileName 时预选。 + if (profiles.length > 0 && defaultProfileName) { + const match = profiles.find(p => p.name === defaultProfileName) + setSelectedProfile(match ? match.name : null) } else { setSelectedProfile(null) @@ -208,7 +205,7 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi function handleSubmit(): void { if (!canSubmit) return - const profilePath = selectedProfile + const brainstormProfilePath = selectedProfile ? profiles.find(p => p.name === selectedProfile)?.path : undefined const pending = [...pastedTexts] @@ -224,7 +221,7 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi onSubmit( finalRequest, images.map(({ mediaType, base64, previewDataUrl }) => ({ mediaType, base64, previewDataUrl })), - profilePath, + brainstormProfilePath, ) } @@ -248,8 +245,19 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi {profiles.length > 0 && (
- 配置文件 + 头脑风暴配置文件
+ {profiles.map(p => (