✨ feat(vscode): 新增第五种工单会话「原型会话」
照测试会话那条链路补一份 prototype:
- 消息与路由:session/start-prototype、session/resume-prototype、
issue/update-prototype-profile-path,都进只读门禁
- 会话:handleStartPrototypeSession / handleResumePrototypeSession,
终端名 issue-N-原型;cwd 有 worktree 用 worktree、没有退主仓库根
(原 resolveTestSessionCwd 改名 resolveWorktreeOrRootCwd 两种会话共用)
- 不要求有 PR,任何阶段都能起;前置工单锁仍然拦
- 提示词:新增 prompts/prototype.md,设置里可改,占位符 {issueNumber}
- profile:工单级 prototypeProfilePath > 设置里的原型默认 profile >
内置默认,与其它会话互不回退
- 状态字段 prototypeSessionId / prototypeProfilePath / prototypeTabOpen
贯通 gitea、youtrack、本机 state 与移交包
- 详情面板加「原型会话id」「原型配置文件」两行,设置面板加对应两项
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
This commit is contained in:
1 parent
3fff796a61
commit
b62e58d739
27 files changed
+760
-50
No files matched your search
@@ -13,7 +13,7 @@ import { buildCcCommand } from '../../cc/ccCommand'
|
||||
import { buildCodexCommandString } from '../../cc/codexCommand'
|
||||
import { watchForNewCodexSession } from '../../cc/codexSessionWatcher'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { getBrainstormContinuePrompt, getImplementPlanPrompt, getReviewPrompt, getTestPrompt } from '../../cc/prompts'
|
||||
import { getBrainstormContinuePrompt, getImplementPlanPrompt, getPrototypePrompt, getReviewPrompt, getTestPrompt } from '../../cc/prompts'
|
||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||
import { detectRepo } from '../../git/remote'
|
||||
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||
@@ -1156,7 +1156,7 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
}
|
||||
|
||||
// worktree 还在(PR 未合并/未清理)就进 worktree 测,已清理则回退主 worktree(main)。
|
||||
const effectiveCwd = await resolveTestSessionCwd(workspaceRoot, worktreePathRel)
|
||||
const effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
|
||||
const inWorktree = effectiveCwd !== workspaceRoot
|
||||
|
||||
// 主 worktree(已合并)测试要靠 tea 了解合并进来的改动,故必须有 PR;
|
||||
@@ -1368,7 +1368,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
||||
branchForHook = stateObj.branch
|
||||
if (typeof stateObj?.testProfilePath === 'string' && stateObj.testProfilePath.length > 0)
|
||||
testProfilePath = stateObj.testProfilePath
|
||||
effectiveCwd = await resolveTestSessionCwd(workspaceRoot, worktreePathRel)
|
||||
effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
@@ -1439,6 +1439,297 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个「原型」cc 会话:详情面板点原型会话行的 ▶,开一个 claude tab 让 cc
|
||||
* 照工单正文画界面原型。
|
||||
*
|
||||
* 与测试会话的差异:原型只吃工单正文,不依赖 PR,任何阶段都能起;其余(cwd
|
||||
* 回退、profile 解析、pre-create 钩子、session id 回写)与测试会话同构。
|
||||
*
|
||||
* in-flight 锁 key `${issueNumber}:prototype`,防 createTerminal 期间重复点击。
|
||||
*/
|
||||
export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
const lockKey = `${issueNumber}:prototype`
|
||||
if (panel.resumeInFlight.has(lockKey)) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `start-prototype #${issueNumber} 已在进行中,忽略重入`,
|
||||
})
|
||||
return
|
||||
}
|
||||
panel.resumeInFlight.add(lockKey)
|
||||
|
||||
try {
|
||||
const terminalName = `issue-${issueNumber}-原型`
|
||||
const existing = panel.findExistingTerminal(terminalName)
|
||||
if (existing) {
|
||||
panel.trackSessionTerminal(existing, issueNumber, 'prototype')
|
||||
existing.show(false)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `复用已有原型终端 #${issueNumber},跳过 cc 启动`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot) {
|
||||
void window.showErrorMessage('请先打开一个工作区文件夹')
|
||||
return
|
||||
}
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
if (!remote) {
|
||||
void window.showErrorMessage('当前工作区没有 Gitea 远程仓库')
|
||||
return
|
||||
}
|
||||
const token = await getToken(panel.context, remote.host)
|
||||
if (!token) {
|
||||
void window.showErrorMessage('请先完成 Gitea 配置')
|
||||
return
|
||||
}
|
||||
|
||||
const lockCheck = await panel.resolveLockedReason(issueNumber)
|
||||
if (lockCheck.locked) {
|
||||
void window.showWarningMessage(`等待前置工单 #${lockCheck.prerequisiteNumber} 完成`)
|
||||
return
|
||||
}
|
||||
|
||||
let prototypeProfilePath: string | undefined
|
||||
let worktreePathRel: string | undefined
|
||||
let branch: string | undefined
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (stateObj) {
|
||||
if (typeof stateObj.prototypeProfilePath === 'string' && stateObj.prototypeProfilePath.length > 0)
|
||||
prototypeProfilePath = stateObj.prototypeProfilePath
|
||||
if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0)
|
||||
worktreePathRel = stateObj.worktreePath
|
||||
if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0)
|
||||
branch = stateObj.branch
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'panel',
|
||||
message: `读取 #${issueNumber} state JSON 失败,无法启动原型会话`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
// worktree 还在就进 worktree 画,已清理则退回主 worktree。
|
||||
const effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
|
||||
|
||||
// 原型会话只用 prototypeProfilePath;空 = DEFAULT,不回退其它会话 profile。
|
||||
const effectiveProfilePath = resolvePrototypeProfilePath(panel, prototypeProfilePath)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`启动原型失败:prototypeProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = getPrototypePrompt(panel.context, { issueNumber })
|
||||
if (prompt.includes('\'')) {
|
||||
void window.showErrorMessage('启动原型失败:prompt 含单引号,拒绝执行')
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the projects dir exists so the watcher can't miss the create
|
||||
// event. cwd 决定 claude session jsonl 落在哪个 projects 子目录下。
|
||||
const projDir = projectsDirFor(effectiveCwd)
|
||||
try {
|
||||
await fsp.mkdir(projDir, { recursive: true })
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
|
||||
}
|
||||
|
||||
// Kick off the watcher before spawning the terminal so we don't race.
|
||||
const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
|
||||
|
||||
// 原型 cc tab 创建前钩子,与实施/测试会话共用同一钩子(impl-tab-pre-create)。
|
||||
// 顶部「复用已有原型终端」的 fast path 已早 return,复用不会执行到这里。
|
||||
const settings = getSettings(panel.context)
|
||||
await panel.dispatchWorktreeHook('impl-tab-pre-create', {
|
||||
workspaceRoot,
|
||||
worktreePath: effectiveCwd,
|
||||
branch: branch ?? '',
|
||||
issueNumber,
|
||||
mainBranch: settings.devBranch || 'main',
|
||||
customScriptPath: settings.implTabPreCreateScript,
|
||||
})
|
||||
|
||||
const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
|
||||
const terminal = window.createTerminal({
|
||||
name: terminalName,
|
||||
cwd: effectiveCwd,
|
||||
location: panel.resolveTerminalLocation(false),
|
||||
iconPath: iconUri,
|
||||
color: themeColor,
|
||||
})
|
||||
panel.trackSessionTerminal(terminal, issueNumber, 'prototype')
|
||||
terminal.show(false)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'terminal',
|
||||
message: `已创建终端 "${terminal.name}"`,
|
||||
})
|
||||
|
||||
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt })
|
||||
terminal.sendText(cmd)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `已发送原型 prompt 到终端 #${issueNumber}`,
|
||||
})
|
||||
|
||||
// Fire-and-forget: when the session jsonl materializes, persist the id as
|
||||
// the issue's `prototypeSessionId` so the X / resume button toggles.
|
||||
watchPromise.then(async (sid) => {
|
||||
if (!sid) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'panel',
|
||||
message: '原型会话监听超时 (120s)',
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `已捕获原型会话 ${sid}`,
|
||||
})
|
||||
try {
|
||||
await panel.mergeIssueState(issueNumber, { prototypeSessionId: sid })
|
||||
panel.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber,
|
||||
patch: { prototypeSessionId: sid },
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[superpowers] failed to persist prototypeSessionId:', err)
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[superpowers] prototype session watch failed:', err)
|
||||
})
|
||||
|
||||
void window.showInformationMessage(`已启动 #${issueNumber} 原型会话`)
|
||||
}
|
||||
finally {
|
||||
panel.resumeInFlight.delete(lockKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复一个已存在的原型 cc 会话:详情面板点原型会话行(prototypeTabOpen=false 时)
|
||||
* 触发。终端名 `issue-${N}-原型`、命令 `claude ... --resume ${sessionId}`。
|
||||
*
|
||||
* cwd 与 start 对称:worktree 还在就进 worktree(claude 的会话 jsonl 按 cwd 分目录,
|
||||
* 走错目录就找不到这条会话),已清理才退回主 worktree。
|
||||
* in-flight 锁 key `${issueNumber}:prototype`。
|
||||
*/
|
||||
export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, sessionId: string, issueNumber: number, _relCwd?: string): Promise<void> {
|
||||
const lockKey = `${issueNumber}:prototype`
|
||||
if (panel.resumeInFlight.has(lockKey)) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `resume #${issueNumber}/prototype 已在进行中,忽略重入`,
|
||||
})
|
||||
return
|
||||
}
|
||||
panel.resumeInFlight.add(lockKey)
|
||||
|
||||
try {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
let effectiveCwd = workspaceRoot
|
||||
// 顺带读出 branch 给 impl-tab-pre-create 钩子用;读不到就空字符串。
|
||||
let branchForHook = ''
|
||||
let prototypeProfilePath: string | undefined
|
||||
if (workspaceRoot) {
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
const token = remote ? await getToken(panel.context, remote.host) : undefined
|
||||
if (remote && token) {
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
const worktreePathRel = typeof stateObj?.worktreePath === 'string' ? stateObj.worktreePath : undefined
|
||||
if (stateObj && typeof stateObj.branch === 'string')
|
||||
branchForHook = stateObj.branch
|
||||
if (typeof stateObj?.prototypeProfilePath === 'string' && stateObj.prototypeProfilePath.length > 0)
|
||||
prototypeProfilePath = stateObj.prototypeProfilePath
|
||||
effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'panel',
|
||||
message: `读取 #${issueNumber} state JSON 失败,原型会话回退主 worktree 恢复`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --settings 决定 provider/model/鉴权,resume 也必须与 start 一致解析 profile。
|
||||
const effectiveProfilePath = resolvePrototypeProfilePath(panel, prototypeProfilePath)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`resume 失败:prototypeProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const terminalName = `issue-${issueNumber}-原型`
|
||||
const existingByName = panel.findExistingTerminal(terminalName)
|
||||
if (existingByName) {
|
||||
panel.trackSessionTerminal(existingByName, issueNumber, 'prototype')
|
||||
existingByName.show(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向,
|
||||
// 与实施 / 测试 resume 一致。
|
||||
if (effectiveCwd !== workspaceRoot && workspaceRoot) {
|
||||
const settings = getSettings(panel.context)
|
||||
await panel.dispatchWorktreeHook('impl-tab-pre-create', {
|
||||
workspaceRoot,
|
||||
worktreePath: effectiveCwd!,
|
||||
branch: branchForHook,
|
||||
issueNumber,
|
||||
mainBranch: settings.devBranch || 'main',
|
||||
customScriptPath: settings.implTabPreCreateScript,
|
||||
})
|
||||
}
|
||||
|
||||
const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
|
||||
const terminal = window.createTerminal({
|
||||
name: terminalName,
|
||||
cwd: effectiveCwd,
|
||||
location: panel.resolveTerminalLocation(false),
|
||||
iconPath: iconUri,
|
||||
color: themeColor,
|
||||
})
|
||||
panel.trackSessionTerminal(terminal, issueNumber, 'prototype')
|
||||
terminal.show(false)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'terminal',
|
||||
message: `已创建原型会话终端 #${issueNumber} cwd=${effectiveCwd}`,
|
||||
})
|
||||
|
||||
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, resumeSessionId: sessionId })
|
||||
terminal.sendText(cmd)
|
||||
}
|
||||
finally {
|
||||
panel.resumeInFlight.delete(lockKey)
|
||||
panel.postMessage({ type: 'session/tab-settled', sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当 done 列的 PR merge 因冲突失败时调用:在主 workspace 检出 feature 分支
|
||||
* 并 merge dev 让冲突落地到 working tree,然后启一个临时 cc 会话让 cc 解决
|
||||
@@ -1696,13 +1987,25 @@ export function resolveTestProfilePath(panel: KanbanWebviewPanel, testProfilePat
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试会话的工作目录:worktree 还在就用 worktree,已清理则回退主 worktree。
|
||||
* 原型会话 profile:工单级 `prototypeProfilePath` > 设置里的「原型默认配置文件」
|
||||
* > DEFAULT。与其它三种会话互不回退。
|
||||
*/
|
||||
export function resolvePrototypeProfilePath(panel: KanbanWebviewPanel, prototypeProfilePath: string | undefined): string {
|
||||
return resolveProfilePath(
|
||||
prototypeProfilePath?.trim()
|
||||
|| getSettings(panel.context).defaultPrototypeProfilePath.trim()
|
||||
|| undefined,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 / 原型会话的工作目录:worktree 还在就用 worktree,已清理则回退主 worktree。
|
||||
*
|
||||
* @param workspaceRoot 主 worktree 绝对路径
|
||||
* @param worktreePathRel state JSON 里的 worktree 相对路径(可空)
|
||||
* @returns 实际用作终端 cwd 的绝对路径
|
||||
*/
|
||||
async function resolveTestSessionCwd(workspaceRoot: string, worktreePathRel?: string): Promise<string> {
|
||||
async function resolveWorktreeOrRootCwd(workspaceRoot: string, worktreePathRel?: string): Promise<string> {
|
||||
if (worktreePathRel) {
|
||||
const abs = resolveWorktreePath(worktreePathRel, workspaceRoot)
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user