✨ feat(vscode): 七份提示词统一走 Handlebars,头脑风暴三档合一
- 模板引擎换成 Handlebars(noEscape,提示词是纯文本不能被转义),单花括号写法彻底退场
- brainstorm-continue / brainstorm-continue-prototype 两份并进 brainstorm.md,
用 {{#if issueNumber}} 分新建 / 续聊,续聊里用 {{#if prototypeFile}} 嵌原型段落,
设置面板对应的两个 textarea 一并删掉
- 模板上下文 = 工单 state JSON 全量字段 + issueNumber / userRequest / nonce /
planFile / prNumber / prRef,以后模板想用新字段不用改扩展代码
- 各会话入口共用一条 state 读取路径,读失败只记 warn 按空状态继续,不拦开会话
- 升级后首次启动把存量自定义提示词迁到双花括号并合并成一份,改写前把
settings.json 备份成 settings.json.bak-<时间戳>,迁移动作全写日志
- 装 handlebars 时给 vsce 传递依赖的老 semver 加 trustPolicyExclude 豁免,
否则 no-downgrade 策略卡住整个装包
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
This commit is contained in:
1 parent
efeac8c978
commit
7bac5c8b03
28 files changed
+853
-424
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPromptContext, renderPromptTemplate } from './promptTemplate'
|
||||
|
||||
const brainstormTemplate = readFileSync(
|
||||
path.resolve(__dirname, '../../prompts/brainstorm.md'),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
describe('buildPromptContext', () => {
|
||||
it('把 state 全量摊平进上下文', () => {
|
||||
const ctx = buildPromptContext({ branch: 'feature/x', pr: '12' }, { issueNumber: '7' })
|
||||
expect(ctx).toEqual({ branch: 'feature/x', pr: '12', issueNumber: '7' })
|
||||
})
|
||||
|
||||
it('额外变量里的 undefined 不覆盖 state 已有的值', () => {
|
||||
const ctx = buildPromptContext({ planFile: '.claude/plans/a.md' }, { planFile: undefined })
|
||||
expect(ctx.planFile).toBe('.claude/plans/a.md')
|
||||
})
|
||||
|
||||
it('state 缺失时退化成只有额外变量', () => {
|
||||
expect(buildPromptContext(undefined, { nonce: 'abc' })).toEqual({ nonce: 'abc' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPromptTemplate', () => {
|
||||
it('不做 HTML 转义,引号和 & 原样进提示词', () => {
|
||||
expect(renderPromptTemplate('说 "{{x}}" & 走', { x: 'a<b' })).toBe('说 "a<b" & 走')
|
||||
})
|
||||
|
||||
it('模板语法错误时抛错', () => {
|
||||
expect(() => renderPromptTemplate('{{#if x}}没闭合', {})).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('默认头脑风暴模板', () => {
|
||||
it('有工单号时走续聊分支,没有时走新建工单分支', () => {
|
||||
const cont = renderPromptTemplate(brainstormTemplate, { issueNumber: '42' })
|
||||
expect(cont).toContain('/ecc:grilling 讨论下 42 号工单')
|
||||
expect(cont).not.toContain('opencli spx issue create')
|
||||
|
||||
const fresh = renderPromptTemplate(brainstormTemplate, { userRequest: '加个按钮', nonce: 'n1' })
|
||||
expect(fresh).toContain('加个按钮')
|
||||
expect(fresh).toContain('<!-- spx:nonce=n1 -->')
|
||||
expect(fresh).not.toContain('/ecc:grilling 讨论下')
|
||||
})
|
||||
|
||||
it('工单挂了原型插件才出现原型段落', () => {
|
||||
const withProto = renderPromptTemplate(brainstormTemplate, {
|
||||
issueNumber: '42',
|
||||
prototypeFile: 'proto/manifest.json',
|
||||
prototypeSessionId: 'sid-1',
|
||||
})
|
||||
expect(withProto).toContain('工单已有原型插件 proto/manifest.json')
|
||||
expect(withProto).toContain('原型会话 id:sid-1')
|
||||
|
||||
const withoutProto = renderPromptTemplate(brainstormTemplate, { issueNumber: '42' })
|
||||
expect(withoutProto).not.toContain('原型插件')
|
||||
})
|
||||
|
||||
it('没有原型会话 id 时不留下半句「原型会话 id:」', () => {
|
||||
const out = renderPromptTemplate(brainstormTemplate, {
|
||||
issueNumber: '42',
|
||||
prototypeFile: 'proto/manifest.json',
|
||||
})
|
||||
expect(out).toContain('原型会话终端名:issue-42-原型')
|
||||
expect(out).not.toContain('原型会话 id')
|
||||
})
|
||||
|
||||
it('渲染结果里不残留未替换的占位符', () => {
|
||||
const out = renderPromptTemplate(brainstormTemplate, {
|
||||
issueNumber: '42',
|
||||
prototypeFile: 'proto/manifest.json',
|
||||
prototypeSessionId: 'sid-1',
|
||||
})
|
||||
expect(out).not.toMatch(/\{\{/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 提示词模板的渲染内核:七份模板统一走 Handlebars。
|
||||
*
|
||||
* `noEscape` 是必须的——提示词是打进终端的纯文本,默认的 HTML 转义会把
|
||||
* 引号、`&`、`<` 变成实体,cc 收到的就是一堆 `"`。
|
||||
*
|
||||
* 这个模块刻意不碰 vscode / 文件系统,方便单测直接跑。
|
||||
*/
|
||||
|
||||
import Handlebars from 'handlebars'
|
||||
|
||||
export type PromptContext = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* 模板上下文 = 工单 state JSON 全量字段 + 调用方补的那几个变量。
|
||||
*
|
||||
* `extra` 里值为 undefined 的键直接丢掉:否则 `{ planFile: undefined }` 会把
|
||||
* state 里真有的 planFile 盖成空。
|
||||
*/
|
||||
export function buildPromptContext(state: PromptContext | undefined, extra: PromptContext): PromptContext {
|
||||
const out: PromptContext = { ...(state ?? {}) }
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v !== undefined)
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 渲染模板;模板有语法错误时抛错,由调用方决定怎么兜。 */
|
||||
export function renderPromptTemplate(tpl: string, context: PromptContext): string {
|
||||
return Handlebars.compile(tpl, { noEscape: true })(context)
|
||||
}
|
||||
+61
-81
@@ -1,106 +1,86 @@
|
||||
/**
|
||||
* Prompt templates for the two claude entry points (brainstorm and
|
||||
* implement-plan). Defaults live in `src/settings/store.ts` (alongside the
|
||||
* other tunables); these helpers just read the persisted setting and run
|
||||
* placeholder substitution.
|
||||
* 五个会话入口(头脑风暴 / 实施 / 审查 / 测试 / 原型)的首个提示词。
|
||||
* 模板正文在 `prompts/*.md`,用户改过的副本落在设置里,这里只负责
|
||||
* 「读设置 + 灌上下文 + 渲染」。
|
||||
*
|
||||
* Substitution is a plain global string replace of the placeholder text
|
||||
* (`{userRequest}` / `{planFile}`) — no template engine. If the user removes
|
||||
* the placeholder, substitution is a no-op and that's their bug.
|
||||
* 上下文一律是「工单 state JSON 全量字段 + 调用方补的几个变量」,所以模板里
|
||||
* 想用 `{{branch}}`、`{{specFile}}` 这类字段不必改扩展代码。
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from 'vscode'
|
||||
import type { PromptContext } from './promptTemplate'
|
||||
import { logger } from '../logging/logger'
|
||||
import { getSettings } from '../settings/store'
|
||||
import { buildPromptContext, renderPromptTemplate } from './promptTemplate'
|
||||
|
||||
export function getBrainstormPrompt(
|
||||
ctx: ExtensionContext,
|
||||
vars: { userRequest: string, nonce: string, imagePaths?: string[] },
|
||||
): string {
|
||||
const tpl = getSettings(ctx).brainstormPrompt
|
||||
let out = tpl
|
||||
.split('{userRequest}')
|
||||
.join(vars.userRequest)
|
||||
.split('{nonce}')
|
||||
.join(vars.nonce)
|
||||
if (vars.imagePaths && vars.imagePaths.length > 0) {
|
||||
const lines = vars.imagePaths.map(p => `- ${p}`).join('\n')
|
||||
out += `\n\n参考图片(请用 Read 工具查看):\n${lines}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getBrainstormContinuePrompt(
|
||||
ctx: ExtensionContext,
|
||||
vars: { issueNumber: number, issueRef?: string, prototypeFile?: string, prototypeSessionId?: string },
|
||||
): string {
|
||||
const tpl = getSettings(ctx).brainstormContinuePrompt
|
||||
// For youtrack cards the board number is synthetic; substitute the readable
|
||||
// id (e.g. LXF-12) so the cc agent references the real issue (readable via
|
||||
// `opencli yt issues show <id>`), not a non-existent gitea number.
|
||||
const out = tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
|
||||
return fillPrototypeRef(ctx, out, vars.prototypeFile, {
|
||||
issueNumber: vars.issueRef ?? String(vars.issueNumber),
|
||||
prototypeSessionId: vars.prototypeSessionId,
|
||||
})
|
||||
/** 调用方补进模板上下文的变量;state 之外的都放这儿。 */
|
||||
export interface PromptVars {
|
||||
/** `readIssueState` 的结果,读失败时给 undefined(模板里条件块自然走 else)。 */
|
||||
state?: PromptContext
|
||||
/** 工单号;youtrack 卡片传可读 id(LXF-12),别传看板的合成号。 */
|
||||
issueNumber?: string
|
||||
userRequest?: string
|
||||
nonce?: string
|
||||
planFile?: string
|
||||
prNumber?: string
|
||||
prRef?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `{prototypeRef}` 在模板里独占一段:工单挂了原型插件就填进设置里的「原型段落」
|
||||
* 模板,没挂就连同前后空行一起抹掉——否则模板里会留下一个空洞段落。
|
||||
*
|
||||
* 段落模板里凡是某个占位符取值为空的整行直接删掉(如没有原型会话 id 时那条
|
||||
* 「原型会话 id:」),免得留下半句没有值的说明。
|
||||
* 模板语法错误不该把会话卡死:记 error 日志后原样把模板正文当提示词发出去,
|
||||
* 用户在终端里一眼能看到没被替换的 `{{...}}`,知道是自己模板写坏了。
|
||||
*/
|
||||
function fillPrototypeRef(
|
||||
ctx: ExtensionContext,
|
||||
tpl: string,
|
||||
prototypeFile: string | undefined,
|
||||
vars: { issueNumber: string, prototypeSessionId: string | undefined },
|
||||
): string {
|
||||
if (!prototypeFile)
|
||||
return tpl.replace(/\n*\{prototypeRef\}\n*/g, '\n\n').trimEnd()
|
||||
|
||||
const values: Record<string, string> = {
|
||||
prototypeFile,
|
||||
issueNumber: vars.issueNumber,
|
||||
prototypeSessionId: vars.prototypeSessionId ?? '',
|
||||
function render(name: string, tpl: string, vars: PromptVars): string {
|
||||
const { state, ...extra } = vars
|
||||
try {
|
||||
return renderPromptTemplate(tpl, buildPromptContext(state, extra))
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'error',
|
||||
source: 'prompts',
|
||||
message: `${name}提示词模板有语法错误,已原样发出未渲染的模板`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return tpl
|
||||
}
|
||||
const para = getSettings(ctx).prototypeRefPrompt.split('\n').filter(line => !Object.entries(values).some(([k, v]) => v === '' && line.includes(`{${k}}`))).join('\n').replace(/\{(prototypeFile|issueNumber|prototypeSessionId)\}/g, (_m, k: string) => values[k]).trim()
|
||||
|
||||
return tpl.split('{prototypeRef}').join(para)
|
||||
}
|
||||
|
||||
export function getImplementPlanPrompt(ctx: ExtensionContext, vars: { planFile: string, issueNumber: number, issueRef?: string }): string {
|
||||
const tpl = getSettings(ctx).implementPlanPrompt
|
||||
return tpl
|
||||
.split('{planFile}')
|
||||
.join(vars.planFile)
|
||||
.split('{issueNumber}')
|
||||
.join(vars.issueRef ?? String(vars.issueNumber))
|
||||
/**
|
||||
* 头脑风暴提示词。模板用 `{{#if issueNumber}}` 分叉:有工单号 = 续聊已有工单,
|
||||
* 没有 = 新建工单(用 userRequest / nonce)。
|
||||
*
|
||||
* 粘贴的参考图片不进模板——它是会话级附加信息,不是模板变量。
|
||||
*/
|
||||
export function renderBrainstormPrompt(
|
||||
ctx: ExtensionContext,
|
||||
vars: PromptVars & { imagePaths?: string[] },
|
||||
): string {
|
||||
const { imagePaths, ...rest } = vars
|
||||
const out = render('头脑风暴', getSettings(ctx).brainstormPrompt, rest)
|
||||
if (!imagePaths || imagePaths.length === 0)
|
||||
return out
|
||||
const lines = imagePaths.map(p => `- ${p}`).join('\n')
|
||||
return `${out}\n\n参考图片(请用 Read 工具查看):\n${lines}`
|
||||
}
|
||||
|
||||
export function renderImplementPlanPrompt(ctx: ExtensionContext, vars: PromptVars): string {
|
||||
return render('实施', getSettings(ctx).implementPlanPrompt, vars)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试会话首个提示词。`prRef` 是给验收技能的定位线索:主 worktree 上是
|
||||
* 已合并的 PR,worktree 里就是那条分支。
|
||||
*/
|
||||
export function getTestPrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string, prRef: string }): string {
|
||||
const tpl = getSettings(ctx).testPrompt
|
||||
return tpl
|
||||
.split('{issueNumber}')
|
||||
.join(vars.issueRef ?? String(vars.issueNumber))
|
||||
.split('{prRef}')
|
||||
.join(vars.prRef)
|
||||
export function renderTestPrompt(ctx: ExtensionContext, vars: PromptVars): string {
|
||||
return render('测试', getSettings(ctx).testPrompt, vars)
|
||||
}
|
||||
|
||||
/**
|
||||
* 原型会话首个提示词。只认工单号——原型是照工单正文画界面,与 PR / 分支无关。
|
||||
*/
|
||||
export function getPrototypePrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string }): string {
|
||||
const tpl = getSettings(ctx).prototypePrompt
|
||||
return tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
|
||||
/** 原型会话首个提示词。只认工单号——原型照工单正文画界面,与 PR / 分支无关。 */
|
||||
export function renderPrototypePrompt(ctx: ExtensionContext, vars: PromptVars): string {
|
||||
return render('原型', getSettings(ctx).prototypePrompt, vars)
|
||||
}
|
||||
|
||||
export function getReviewPrompt(ctx: ExtensionContext, vars: { prNumber: string }): string {
|
||||
const tpl = getSettings(ctx).reviewPrompt
|
||||
return tpl.split('{prNumber}').join(vars.prNumber)
|
||||
export function renderReviewPrompt(ctx: ExtensionContext, vars: PromptVars): string {
|
||||
return render('审查', getSettings(ctx).reviewPrompt, vars)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { initIdentity } from './auth/identity'
|
||||
import { setProfilesDirOverride } from './cc/profiles'
|
||||
import { logger } from './logging/logger'
|
||||
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
||||
import { getSettings, persistSeededProfiles } from './settings/store'
|
||||
import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store'
|
||||
import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler'
|
||||
import { webhookCoordinator } from './webhook/coordinator'
|
||||
|
||||
@@ -33,6 +33,9 @@ class EmptyTreeProvider implements TreeDataProvider<never> {
|
||||
export function activate(context: ExtensionContext): void {
|
||||
// Profiles 目录 override 在 panel 未打开时也要生效(list / launch 共用)。
|
||||
setProfilesDirOverride(getSettings(context).profilesDirectory || undefined)
|
||||
// 存量提示词先迁到 Handlebars,再让 persistSeededProfiles 在迁移结果上补 seed。
|
||||
for (const note of migratePromptTemplates(context))
|
||||
logger.add({ level: note.level, source: 'settings', message: note.message })
|
||||
void persistSeededProfiles(context)
|
||||
// Start the webhook server immediately so PR callbacks are received even
|
||||
// when the Kanban panel is closed. Disposed automatically via subscriptions.
|
||||
|
||||
@@ -1007,8 +1007,6 @@ export class KanbanWebviewPanel {
|
||||
webhookPort: s.webhookPort,
|
||||
webhookSecret: s.webhookSecret,
|
||||
brainstormPrompt: s.brainstormPrompt,
|
||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||
prototypeRefPrompt: s.prototypeRefPrompt,
|
||||
implementPlanPrompt: s.implementPlanPrompt,
|
||||
autoReview: s.autoReview,
|
||||
reviewPrompt: s.reviewPrompt,
|
||||
@@ -1099,8 +1097,6 @@ export class KanbanWebviewPanel {
|
||||
webhookPort: s.webhookPort,
|
||||
webhookSecret: s.webhookSecret,
|
||||
brainstormPrompt: s.brainstormPrompt,
|
||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||
prototypeRefPrompt: s.prototypeRefPrompt,
|
||||
implementPlanPrompt: s.implementPlanPrompt,
|
||||
autoReview: s.autoReview,
|
||||
reviewPrompt: s.reviewPrompt,
|
||||
@@ -1147,6 +1143,16 @@ export class KanbanWebviewPanel {
|
||||
return { source: entry?.source ?? 'gitea', number: issueNumber, externalId: entry?.externalId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示词里用的工单号:youtrack 卡片的看板号是合成的,必须换成可读 id
|
||||
* (LXF-12),否则 cc 照着一个不存在的号去查工单。
|
||||
*/
|
||||
// internal: handler 模块访问
|
||||
issueRefLabel(issueNumber: number): string {
|
||||
const ref = this.refFor(issueNumber)
|
||||
return ref.externalId ?? String(ref.number)
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下一张增量上板的卡的归属。webhook 单卡 append 不走批量加载,漏了这一步
|
||||
* 归属表里就没有它,写门禁查不到会当成我的工单放行。
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getToken } from '../../auth/secrets'
|
||||
import { buildCcCommand } from '../../cc/ccCommand'
|
||||
import { resolveCommitProfileForRun } from '../../cc/commitProfile'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { getBrainstormPrompt } from '../../cc/prompts'
|
||||
import { renderBrainstormPrompt } from '../../cc/prompts'
|
||||
import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'
|
||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||
import { spawnClaude } from '../../cc/spawnClaude'
|
||||
@@ -1414,7 +1414,8 @@ export async function handleIssueCreate(
|
||||
}
|
||||
|
||||
const color = pickRandomIssueColor()
|
||||
const prompt = getBrainstormPrompt(panel.context, {
|
||||
// 工单还不存在,模板上下文里没有 issueNumber / state —— 模板据此走新建工单分支。
|
||||
const prompt = renderBrainstormPrompt(panel.context, {
|
||||
userRequest: trimmed,
|
||||
nonce,
|
||||
imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
|
||||
|
||||
@@ -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, getPrototypePrompt, getReviewPrompt, getTestPrompt } from '../../cc/prompts'
|
||||
import { renderBrainstormPrompt, renderImplementPlanPrompt, renderPrototypePrompt, renderReviewPrompt, renderTestPrompt } from '../../cc/prompts'
|
||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||
import { detectRepo } from '../../git/remote'
|
||||
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
|
||||
@@ -29,6 +29,34 @@ import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import { planResumeCwd, resumeTerminalName } from './resumeTarget'
|
||||
import { submitToSessionTerminal } from './terminals'
|
||||
|
||||
/**
|
||||
* 会话入口统一的 state 读取:读失败只记 warn,按空状态继续——提示词模板里的
|
||||
* 条件块会自然走 else 分支,没必要为一次网络抖动拦住开会话。
|
||||
*/
|
||||
async function readStateForPrompt(
|
||||
panel: KanbanWebviewPanel,
|
||||
issueNumber: number,
|
||||
source: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await panel.readIssueState(issueNumber)
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source,
|
||||
message: `读取 #${issueNumber} state JSON 失败,按空状态继续`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** state 里「空串 = 未设置」,取值时统一收敛成 undefined。 */
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, sessionKind: ResumeKind, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
|
||||
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
|
||||
@@ -838,7 +866,13 @@ export async function handleImplement(
|
||||
// Build the prompt. The extension already created the worktree, so cc
|
||||
// just implements the plan. `$pr_no` is left as-is for cc to fill in
|
||||
// via tool calls.
|
||||
const prompt = getImplementPlanPrompt(panel.context, { planFile, issueNumber, issueRef: panel.refFor(issueNumber).externalId })
|
||||
// 上面刚写进 state 的 branch / worktreePath 也带进模板上下文,省一次回读。
|
||||
const state = await readStateForPrompt(panel, issueNumber, 'implement')
|
||||
const prompt = renderImplementPlanPrompt(panel.context, {
|
||||
state: { ...state, branch, worktreePath },
|
||||
issueNumber: panel.issueRefLabel(issueNumber),
|
||||
planFile,
|
||||
})
|
||||
if (prompt.includes('\'')) {
|
||||
void window.showErrorMessage('实施失败:prompt 含单引号,拒绝执行')
|
||||
return
|
||||
@@ -944,38 +978,15 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
|
||||
return
|
||||
}
|
||||
|
||||
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
|
||||
let brainstormProfilePath: string | undefined
|
||||
let sessionId: string | undefined
|
||||
// 工单已有原型插件时,续聊提示词里让 cc 先读它,别把已经画定的界面又讨论一遍;
|
||||
// 会话 id 一并带上,讨论里要改原型时直接 SendMessage 给原型会话。
|
||||
let prototypeFile: string | undefined
|
||||
let prototypeSessionId: string | undefined
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0)
|
||||
brainstormProfilePath = stateObj.brainstormProfilePath
|
||||
if (stateObj && typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0)
|
||||
sessionId = stateObj.sessionId
|
||||
if (stateObj && typeof stateObj.prototypeFile === 'string' && stateObj.prototypeFile.length > 0)
|
||||
prototypeFile = stateObj.prototypeFile
|
||||
if (stateObj && typeof stateObj.prototypeSessionId === 'string' && stateObj.prototypeSessionId.length > 0)
|
||||
prototypeSessionId = stateObj.prototypeSessionId
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'brainstorm',
|
||||
message: `读取 #${issueNumber} state JSON 失败,使用默认 profile`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
// state 读一次就够:profile / 会话 id 和提示词上下文(原型插件、会话 id 等)
|
||||
// 都从这份里取。读不到就按空状态走,提示词里的条件块自然走 else 分支。
|
||||
const state = await readStateForPrompt(panel, issueNumber, 'brainstorm')
|
||||
const brainstormProfilePath = nonEmptyString(state.brainstormProfilePath)
|
||||
const sessionId = nonEmptyString(state.sessionId)
|
||||
|
||||
const prompt = getBrainstormContinuePrompt(panel.context, {
|
||||
issueNumber,
|
||||
issueRef: panel.refFor(issueNumber).externalId,
|
||||
prototypeFile,
|
||||
prototypeSessionId,
|
||||
const prompt = renderBrainstormPrompt(panel.context, {
|
||||
state,
|
||||
issueNumber: panel.issueRefLabel(issueNumber),
|
||||
})
|
||||
const terminalName = `issue-${issueNumber}-规划`
|
||||
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
||||
@@ -1142,31 +1153,11 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
// 从 state JSON 读 pr / testProfile / worktree。pr 仅在主 worktree(已合并)
|
||||
// 测试时必需——那条路径要靠 tea 了解合并进来的改动;worktree 内测试代码
|
||||
// 本就在工作目录,不依赖 PR。
|
||||
let pr: string | undefined
|
||||
let testProfilePath: string | undefined
|
||||
let worktreePathRel: string | undefined
|
||||
let branch: string | undefined
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (stateObj) {
|
||||
if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0)
|
||||
pr = stateObj.pr
|
||||
if (typeof stateObj.testProfilePath === 'string' && stateObj.testProfilePath.length > 0)
|
||||
testProfilePath = stateObj.testProfilePath
|
||||
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),
|
||||
})
|
||||
}
|
||||
const state = await readStateForPrompt(panel, issueNumber, 'panel')
|
||||
const pr = nonEmptyString(state.pr)
|
||||
const testProfilePath = nonEmptyString(state.testProfilePath)
|
||||
const worktreePathRel = nonEmptyString(state.worktreePath)
|
||||
const branch = nonEmptyString(state.branch)
|
||||
|
||||
// worktree 还在(PR 未合并/未清理)就进 worktree 测,已清理则回退主 worktree(main)。
|
||||
const effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
|
||||
@@ -1191,8 +1182,9 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
// worktree 里:代码就在当前工作目录,不用 tea 拉取,直接测;
|
||||
// 主 worktree:PR 已合并,先用 tea 熟悉改动再测。验收目标直接写进提示词,
|
||||
// 省得验收技能反问要测哪个。
|
||||
const prompt = getTestPrompt(panel.context, {
|
||||
issueNumber,
|
||||
const prompt = renderTestPrompt(panel.context, {
|
||||
state,
|
||||
issueNumber: panel.issueRefLabel(issueNumber),
|
||||
prRef: inWorktree
|
||||
? `worktree 分支 ${branch || effectiveCwd}`
|
||||
: `PR #${pr}`,
|
||||
@@ -1306,23 +1298,9 @@ export async function handleStartReviewSession(panel: KanbanWebviewPanel, issueN
|
||||
return
|
||||
}
|
||||
|
||||
let pr: string | undefined
|
||||
let worktreePath = ''
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0)
|
||||
pr = stateObj.pr.replace(/\(已合并\)\s*$/, '')
|
||||
if (typeof stateObj.worktreePath === 'string')
|
||||
worktreePath = stateObj.worktreePath
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'review',
|
||||
message: `读取 #${issueNumber} state JSON 失败,无法启动审查会话`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
const state = await readStateForPrompt(panel, issueNumber, 'review')
|
||||
const pr = nonEmptyString(state.pr)?.replace(/\(已合并\)\s*$/, '')
|
||||
const worktreePath = typeof state.worktreePath === 'string' ? state.worktreePath : ''
|
||||
|
||||
if (!pr) {
|
||||
void window.showWarningMessage('该工单还没有 PR,无法启动审查会话')
|
||||
@@ -1332,7 +1310,11 @@ export async function handleStartReviewSession(panel: KanbanWebviewPanel, issueN
|
||||
const ok = await triggerAutoReviewTab(panel, {
|
||||
issueNumber,
|
||||
prNumber: pr,
|
||||
prompt: getReviewPrompt(panel.context, { prNumber: pr }),
|
||||
prompt: renderReviewPrompt(panel.context, {
|
||||
state,
|
||||
issueNumber: panel.issueRefLabel(issueNumber),
|
||||
prNumber: pr,
|
||||
}),
|
||||
worktreePath,
|
||||
workspaceRoot,
|
||||
})
|
||||
@@ -1509,28 +1491,10 @@ export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, iss
|
||||
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),
|
||||
})
|
||||
}
|
||||
const state = await readStateForPrompt(panel, issueNumber, 'panel')
|
||||
const prototypeProfilePath = nonEmptyString(state.prototypeProfilePath)
|
||||
const worktreePathRel = nonEmptyString(state.worktreePath)
|
||||
const branch = nonEmptyString(state.branch)
|
||||
|
||||
// worktree 还在就进 worktree 画,已清理则退回主 worktree。
|
||||
const effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
|
||||
@@ -1544,7 +1508,10 @@ export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, iss
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = getPrototypePrompt(panel.context, { issueNumber })
|
||||
const prompt = renderPrototypePrompt(panel.context, {
|
||||
state,
|
||||
issueNumber: panel.issueRefLabel(issueNumber),
|
||||
})
|
||||
if (prompt.includes('\'')) {
|
||||
void window.showErrorMessage('启动原型失败:prompt 含单引号,拒绝执行')
|
||||
return
|
||||
|
||||
@@ -23,8 +23,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
prototypeRefPrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -114,8 +112,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
webhookPort: payload.webhookPort,
|
||||
webhookSecret: payload.webhookSecret,
|
||||
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
|
||||
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
|
||||
prototypeRefPrompt: payload.prototypeRefPrompt || prev.prototypeRefPrompt,
|
||||
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
|
||||
autoReview: payload.autoReview,
|
||||
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
|
||||
@@ -150,8 +146,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
webhookPort: payload.webhookPort,
|
||||
webhookSecret: payload.webhookSecret.trim(),
|
||||
brainstormPrompt: payload.brainstormPrompt,
|
||||
brainstormContinuePrompt: payload.brainstormContinuePrompt,
|
||||
prototypeRefPrompt: payload.prototypeRefPrompt,
|
||||
implementPlanPrompt: payload.implementPlanPrompt,
|
||||
autoReview: payload.autoReview,
|
||||
reviewPrompt: payload.reviewPrompt,
|
||||
@@ -263,8 +257,6 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
||||
webhookPort: s.webhookPort,
|
||||
webhookSecret: s.webhookSecret,
|
||||
brainstormPrompt: s.brainstormPrompt,
|
||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||
prototypeRefPrompt: s.prototypeRefPrompt,
|
||||
implementPlanPrompt: s.implementPlanPrompt,
|
||||
autoReview: s.autoReview,
|
||||
reviewPrompt: s.reviewPrompt,
|
||||
|
||||
@@ -72,8 +72,6 @@ export type ExtensionToWebview
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
prototypeRefPrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -158,8 +156,6 @@ export type WebviewToExtension
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
prototypeRefPrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
composeBrainstormTemplate,
|
||||
planPromptMigration,
|
||||
PROMPT_TEMPLATE_VERSION,
|
||||
splitIfBlock,
|
||||
upgradePlaceholders,
|
||||
} from './promptMigration'
|
||||
|
||||
const defaultBrainstorm = readFileSync(
|
||||
path.resolve(__dirname, '../../prompts/brainstorm.md'),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
describe('upgradePlaceholders', () => {
|
||||
it('已知占位符改写成双花括号', () => {
|
||||
expect(upgradePlaceholders('实施 @{planFile} 的 #{issueNumber}')).toBe('实施 @{{planFile}} 的 #{{issueNumber}}')
|
||||
})
|
||||
|
||||
it('已经是双花括号的不再加层', () => {
|
||||
expect(upgradePlaceholders('{{issueNumber}}')).toBe('{{issueNumber}}')
|
||||
})
|
||||
|
||||
it('名单外的花括号内容原样保留', () => {
|
||||
expect(upgradePlaceholders('{foo} {planfile} {"a":1}')).toBe('{foo} {planfile} {"a":1}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeBrainstormTemplate', () => {
|
||||
it('两份正文拼成条件分支,原型段落嵌进 prototypeRef 的位置', () => {
|
||||
const out = composeBrainstormTemplate({
|
||||
newIssue: '新建 {{userRequest}}',
|
||||
cont: '续聊 {{issueNumber}}\n\n{{prototypeRef}}\n\n收尾',
|
||||
proto: '原型 {{prototypeFile}}\n- id:{{prototypeSessionId}}',
|
||||
})
|
||||
expect(out).toBe([
|
||||
'{{#if issueNumber}}',
|
||||
'续聊 {{issueNumber}}',
|
||||
'',
|
||||
'{{#if prototypeFile}}',
|
||||
'原型 {{prototypeFile}}',
|
||||
'{{#if prototypeSessionId}}',
|
||||
'- id:{{prototypeSessionId}}',
|
||||
'{{/if}}',
|
||||
'{{/if}}',
|
||||
'',
|
||||
'收尾',
|
||||
'{{else}}',
|
||||
'新建 {{userRequest}}',
|
||||
'{{/if}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('续聊正文里没有 prototypeRef 时原型段落接在末尾', () => {
|
||||
const out = composeBrainstormTemplate({ newIssue: '新建', cont: '续聊', proto: '原型 {{prototypeFile}}' })
|
||||
expect(out).toContain('续聊\n\n{{#if prototypeFile}}\n原型 {{prototypeFile}}\n{{/if}}')
|
||||
})
|
||||
|
||||
it('没有原型段落时不留空的条件块', () => {
|
||||
const out = composeBrainstormTemplate({ newIssue: '新建', cont: '续聊\n\n{{prototypeRef}}', proto: '' })
|
||||
expect(out).not.toContain('prototypeFile')
|
||||
expect(out).not.toContain('prototypeRef')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitIfBlock', () => {
|
||||
it('只认第一层的 else,内层条件块不当分界', () => {
|
||||
const branches = splitIfBlock(defaultBrainstorm, 'issueNumber')
|
||||
expect(branches!.then).toContain('/ecc:grilling')
|
||||
expect(branches!.then).toContain('{{#if prototypeFile}}')
|
||||
expect(branches!.else).toContain('opencli spx issue create')
|
||||
expect(branches!.else).not.toContain('{{else}}')
|
||||
})
|
||||
|
||||
it('没有这个条件块时返回 null', () => {
|
||||
expect(splitIfBlock('普通模板', 'issueNumber')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('planPromptMigration', () => {
|
||||
const legacy = {
|
||||
brainstormPrompt: '建工单 {userRequest} nonce={nonce}',
|
||||
brainstormContinuePrompt: '讨论 {issueNumber}\n\n{prototypeRef}\n\n收尾',
|
||||
prototypeRefPrompt: '原型 {prototypeFile}\n- id:{prototypeSessionId}',
|
||||
implementPlanPrompt: '实施 @{planFile} #{issueNumber}',
|
||||
testPrompt: '验收 {issueNumber}({prRef})',
|
||||
reviewPrompt: '审查 #{prNumber}',
|
||||
prototypePrompt: '画原型 {issueNumber}',
|
||||
webhookPort: 17421,
|
||||
}
|
||||
|
||||
it('五份存量提示词全部换成双花括号,并合并成一份头脑风暴模板', () => {
|
||||
const { blob, notes } = planPromptMigration(legacy, defaultBrainstorm)
|
||||
expect(blob).not.toBeNull()
|
||||
expect(blob!.implementPlanPrompt).toBe('实施 @{{planFile}} #{{issueNumber}}')
|
||||
expect(blob!.testPrompt).toBe('验收 {{issueNumber}}({{prRef}})')
|
||||
expect(blob!.reviewPrompt).toBe('审查 #{{prNumber}}')
|
||||
expect(blob!.prototypePrompt).toBe('画原型 {{issueNumber}}')
|
||||
expect(blob!.brainstormPrompt).toContain('{{#if issueNumber}}')
|
||||
expect(blob!.brainstormPrompt).toContain('{{#if prototypeFile}}')
|
||||
expect(blob!.brainstormPrompt).toContain('{{#if prototypeSessionId}}')
|
||||
expect(blob!.brainstormPrompt).toContain('{{else}}')
|
||||
expect(blob!.brainstormPrompt).toContain('建工单 {{userRequest}} nonce={{nonce}}')
|
||||
expect(notes.some(n => n.level === 'warn')).toBe(false)
|
||||
})
|
||||
|
||||
it('被删掉的两个字段迁移后不再留在 settings 里,其他设置不动', () => {
|
||||
const { blob } = planPromptMigration(legacy, defaultBrainstorm)
|
||||
expect(blob!.brainstormContinuePrompt).toBeUndefined()
|
||||
expect(blob!.prototypeRefPrompt).toBeUndefined()
|
||||
expect(blob!.webhookPort).toBe(17421)
|
||||
expect(blob!.promptTemplateVersion).toBe(PROMPT_TEMPLATE_VERSION)
|
||||
})
|
||||
|
||||
it('迁过一次就不再重复迁', () => {
|
||||
const { blob } = planPromptMigration(legacy, defaultBrainstorm)
|
||||
expect(planPromptMigration(blob!, defaultBrainstorm)).toEqual({ blob: null, notes: [] })
|
||||
})
|
||||
|
||||
it('没存过任何提示词时什么都不做', () => {
|
||||
expect(planPromptMigration({ webhookPort: 17421 }, defaultBrainstorm)).toEqual({ blob: null, notes: [] })
|
||||
})
|
||||
|
||||
it('只改过新建工单提示词时,续聊分支用内置默认补齐', () => {
|
||||
const { blob } = planPromptMigration(
|
||||
{ brainstormPrompt: '建工单 {userRequest}', brainstormContinuePrompt: '' },
|
||||
defaultBrainstorm,
|
||||
)
|
||||
const merged = String(blob!.brainstormPrompt)
|
||||
expect(merged).toContain('建工单 {{userRequest}}')
|
||||
expect(merged).toContain('/ecc:grilling 讨论下 {{issueNumber}} 号工单')
|
||||
// 默认续聊分支自带原型段落,别再插第二份
|
||||
expect(merged.match(/\{\{#if prototypeFile\}\}/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('只改过续聊提示词时,新建分支和原型段落用内置默认补齐', () => {
|
||||
const { blob } = planPromptMigration(
|
||||
{ brainstormContinuePrompt: '讨论 {issueNumber}\n\n{prototypeRef}' },
|
||||
defaultBrainstorm,
|
||||
)
|
||||
const merged = String(blob!.brainstormPrompt)
|
||||
expect(merged).toContain('讨论 {{issueNumber}}')
|
||||
expect(merged).toContain('opencli spx issue create')
|
||||
expect(merged).toContain('工单已有原型插件 {{prototypeFile}}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 存量自定义提示词的一次性迁移:单花括号 → Handlebars 双花括号,
|
||||
* 并把「续聊」「续聊·原型段落」两份模板并进头脑风暴模板的条件分支里。
|
||||
*
|
||||
* 纯函数,不碰文件系统 / vscode——落盘与日志由 `store.ts` 的
|
||||
* `migratePromptTemplates` 负责。
|
||||
*/
|
||||
|
||||
/** 设置里的模板引擎版本;低于它就跑一次迁移。 */
|
||||
export const PROMPT_TEMPLATE_VERSION = 2
|
||||
|
||||
/**
|
||||
* 只重写这些名字的占位符。正文里其他花括号内容(比如 shell 的 `${x}`、
|
||||
* 示例 JSON)保持原样,免得迁移把用户写的东西改坏。
|
||||
*/
|
||||
const KNOWN_PLACEHOLDERS = [
|
||||
'userRequest',
|
||||
'nonce',
|
||||
'issueNumber',
|
||||
'planFile',
|
||||
'prNumber',
|
||||
'prRef',
|
||||
'prototypeFile',
|
||||
'prototypeSessionId',
|
||||
'prototypeRef',
|
||||
] as const
|
||||
|
||||
/** 迁移后仍然存在的模板字段。 */
|
||||
const PROMPT_FIELDS = [
|
||||
'brainstormPrompt',
|
||||
'implementPlanPrompt',
|
||||
'reviewPrompt',
|
||||
'testPrompt',
|
||||
'prototypePrompt',
|
||||
] as const
|
||||
|
||||
/** 已经并进 brainstormPrompt、不再单独存在的字段。 */
|
||||
const MERGED_FIELDS = ['brainstormContinuePrompt', 'prototypeRefPrompt'] as const
|
||||
|
||||
export interface MigrationNote {
|
||||
level: 'info' | 'warn'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface PromptMigrationPlan {
|
||||
/** 要写回的完整 blob;null = 没什么可迁的,不写盘。 */
|
||||
blob: Record<string, unknown> | null
|
||||
notes: MigrationNote[]
|
||||
}
|
||||
|
||||
/** `{foo}` → `{{foo}}`,只认已知占位符;已经是 `{{foo}}` 的不动。 */
|
||||
export function upgradePlaceholders(text: string): string {
|
||||
const names = KNOWN_PLACEHOLDERS.join('|')
|
||||
return text.replace(new RegExp(`(?<!\\{)\\{(${names})\\}(?!\\})`, 'g'), '{{$1}}')
|
||||
}
|
||||
|
||||
/**
|
||||
* 把含 `{{name}}` 的整行包进 `{{#if name}}`。
|
||||
*
|
||||
* 旧引擎对原型段落的做法是「占位符取值为空就删掉整行」,只有原型会话 id
|
||||
* 会真的为空;迁移后用条件块把这条语义原样搬过来。
|
||||
*/
|
||||
export function guardLinesWith(text: string, name: string): string {
|
||||
const token = `{{${name}}}`
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line.includes(token) ? `{{#if ${name}}}\n${line}\n{{/if}}` : line)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板里切出 `{{#if cond}} … {{else}} … {{/if}}` 这一整块的两个分支。
|
||||
*
|
||||
* 迁移时要拿内置默认模板的「续聊」「新建工单」分支去补用户没存过的那一半,
|
||||
* 所以得能把合并后的默认模板再拆开。按 `{{#if}}` / `{{/if}}` 记嵌套层数,
|
||||
* 只认第一层的 `{{else}}`,嵌在里面的原型条件块不会被误当成分界。
|
||||
*/
|
||||
export function splitIfBlock(tpl: string, cond: string): { then: string, else: string } | null {
|
||||
const lines = tpl.split('\n')
|
||||
const start = lines.findIndex(line => line.trim() === `{{#if ${cond}}}`)
|
||||
if (start < 0)
|
||||
return null
|
||||
let depth = 1
|
||||
let elseAt = -1
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (depth === 1 && line.trim() === '{{else}}')
|
||||
elseAt = i
|
||||
depth += (line.match(/\{\{#(?:if|unless)\s/g) ?? []).length
|
||||
depth -= (line.match(/\{\{\/(?:if|unless)\}\}/g) ?? []).length
|
||||
if (depth === 0) {
|
||||
return {
|
||||
then: lines.slice(start + 1, elseAt < 0 ? i : elseAt).join('\n').trim(),
|
||||
else: elseAt < 0 ? '' : lines.slice(elseAt + 1, i).join('\n').trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 三份存量模板拼成一份:有工单号走续聊分支,没有走新建工单分支;
|
||||
* 原型段落嵌在续聊分支里原来 `{{prototypeRef}}` 的位置(模板里没这个
|
||||
* 占位符就接在末尾)。
|
||||
*/
|
||||
export function composeBrainstormTemplate(parts: { newIssue: string, cont: string, proto: string }): string {
|
||||
const proto = parts.proto.trim()
|
||||
const block = proto ? `{{#if prototypeFile}}\n${guardLinesWith(proto, 'prototypeSessionId')}\n{{/if}}` : ''
|
||||
let cont = parts.cont
|
||||
if (cont.includes('{{prototypeRef}}'))
|
||||
cont = cont.split('{{prototypeRef}}').join(block)
|
||||
else if (block)
|
||||
cont = `${cont.trimEnd()}\n\n${block}`
|
||||
return `{{#if issueNumber}}\n${cont.trim()}\n{{else}}\n${parts.newIssue.trim()}\n{{/if}}\n`
|
||||
}
|
||||
|
||||
function storedText(stored: Record<string, unknown>, key: string): string {
|
||||
const v = stored[key]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出迁移后的 settings blob。`defaultBrainstorm` 是内置的合并版模板,用来补
|
||||
* 用户没存过的那一半分支——比如只改过「新建工单」提示词的人,续聊分支直接用
|
||||
* 内置默认,不然合并出来的模板会有一路是空的。
|
||||
*/
|
||||
export function planPromptMigration(
|
||||
stored: Record<string, unknown>,
|
||||
defaultBrainstorm: string,
|
||||
): PromptMigrationPlan {
|
||||
const version = stored.promptTemplateVersion
|
||||
if (typeof version === 'number' && version >= PROMPT_TEMPLATE_VERSION)
|
||||
return { blob: null, notes: [] }
|
||||
|
||||
const next: Record<string, unknown> = { ...stored }
|
||||
const notes: MigrationNote[] = []
|
||||
let changed = false
|
||||
|
||||
for (const field of [...PROMPT_FIELDS, ...MERGED_FIELDS]) {
|
||||
const before = storedText(stored, field)
|
||||
if (!before)
|
||||
continue
|
||||
const after = upgradePlaceholders(before)
|
||||
if (after !== before) {
|
||||
next[field] = after
|
||||
changed = true
|
||||
notes.push({ level: 'info', message: `${field}:占位符已改写成 {{双花括号}}` })
|
||||
}
|
||||
}
|
||||
|
||||
const storedNewIssue = storedText(next, 'brainstormPrompt')
|
||||
const storedCont = storedText(next, 'brainstormContinuePrompt')
|
||||
const storedProto = storedText(next, 'prototypeRefPrompt')
|
||||
if (storedNewIssue || storedCont || storedProto) {
|
||||
const branches = splitIfBlock(defaultBrainstorm, 'issueNumber')
|
||||
if (!branches) {
|
||||
notes.push({
|
||||
level: 'warn',
|
||||
message: '内置头脑风暴模板拆不出条件分支,存量的续聊 / 原型段落没能并进来,原文见备份文件',
|
||||
})
|
||||
}
|
||||
else {
|
||||
// 用户没存过续聊时,默认续聊分支里本来就带着原型段落,别再插一遍。
|
||||
const proto = storedProto || (storedCont ? splitIfBlock(branches.then, 'prototypeFile')?.then ?? '' : '')
|
||||
next.brainstormPrompt = composeBrainstormTemplate({
|
||||
newIssue: storedNewIssue || branches.else,
|
||||
cont: storedCont || branches.then,
|
||||
proto,
|
||||
})
|
||||
changed = true
|
||||
notes.push({
|
||||
level: 'info',
|
||||
message: `头脑风暴提示词已合并成一份条件模板(新建=${storedNewIssue ? '你的' : '内置'} / 续聊=${storedCont ? '你的' : '内置'} / 原型段落=${storedProto ? '你的' : '内置'})`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of MERGED_FIELDS) {
|
||||
if (field in next) {
|
||||
delete next[field]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
return { blob: null, notes: [] }
|
||||
|
||||
next.promptTemplateVersion = PROMPT_TEMPLATE_VERSION
|
||||
return { blob: next, notes }
|
||||
}
|
||||
@@ -12,11 +12,13 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from 'vscode'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import type { MigrationNote } from './promptMigration'
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { seedCommitProfilePath } from '../cc/commitProfile'
|
||||
import { listClaudeProfiles } from '../cc/profiles'
|
||||
import { normalizeReleasePrefix } from '../git/release'
|
||||
import { planPromptMigration } from './promptMigration'
|
||||
|
||||
/**
|
||||
* Fallback prompt strings used when the on-disk markdown can't be read.
|
||||
@@ -24,27 +26,27 @@ import { normalizeReleasePrefix } from '../git/release'
|
||||
* users can tweak them without rebuilding. These short fallbacks just keep
|
||||
* the extension usable if the file is missing/corrupt.
|
||||
*/
|
||||
const FALLBACK_BRAINSTORM_PROMPT = `我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。`
|
||||
const FALLBACK_BRAINSTORM_PROMPT = `{{#if issueNumber}}
|
||||
/ecc:grilling 讨论下 {{issueNumber}} 号工单。
|
||||
{{#if prototypeFile}}
|
||||
工单已有原型插件 {{prototypeFile}},先读它;原型要改就 ListAgents 找到 issue-{{issueNumber}}-原型 会话{{#if prototypeSessionId}}(id {{prototypeSessionId}}){{/if}}并 SendMessage 给它,不要派子代理、不要自己动插件代码。
|
||||
{{/if}}
|
||||
达成共识后用 ecc:spec 写 .claude/specs/<stem>.md,我确认后用 ecc:plan 出 .claude/plans/<stem>.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。
|
||||
{{else}}
|
||||
我现在有这样一个需求 {{userRequest}},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 <!-- spx:nonce={{nonce}} -->。创建完工单立即停下汇报,不要擅自实施。
|
||||
{{/if}}`
|
||||
|
||||
const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumber} 号工单。
|
||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{{planFile}} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{{issueNumber}}"。严禁合并 PR。`
|
||||
|
||||
{prototypeRef}
|
||||
const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {{issueNumber}} 号工单({{prRef}})。验收目标以工单正文为准,不要再问验收哪个。`
|
||||
|
||||
达成共识后用 ecc:spec 写 .claude/specs/<stem>.md,我确认后用 ecc:plan 出 .claude/plans/<stem>.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。`
|
||||
const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{{prNumber}} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 <!-- spx:review=1 -->。`
|
||||
|
||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
|
||||
const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {{issueNumber}} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||
|
||||
const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。验收目标以工单正文为准,不要再问验收哪个。`
|
||||
画完用 \`opencli spx issue marker --issue {{issueNumber}} --type prototype --value <manifest.json 的仓库相对路径>\` 把原型插件挂到工单上,再汇报。`
|
||||
|
||||
const FALLBACK_PROTOTYPE_REF_PROMPT = `工单已有原型插件 {prototypeFile},先读它。原型要改就 ListAgents 找到 issue-{issueNumber}-原型 会话(id {prototypeSessionId})并 SendMessage 给它,不要派子代理、不要自己动插件代码。`
|
||||
|
||||
const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{prNumber} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 <!-- spx:review=1 -->。`
|
||||
|
||||
const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||
|
||||
画完用 \`opencli spx issue marker --issue {issueNumber} --type prototype --value <manifest.json 的仓库相对路径>\` 把原型插件挂到工单上,再汇报。`
|
||||
|
||||
type PromptName = 'brainstorm' | 'brainstorm-continue' | 'brainstorm-continue-prototype' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
||||
type PromptName = 'brainstorm' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
||||
|
||||
/**
|
||||
* Read a default prompt template from `${extensionPath}/prompts/${name}.md`.
|
||||
@@ -63,8 +65,6 @@ export function readDefaultPrompt(extensionPath: string, name: PromptName): stri
|
||||
console.warn(`[spx] 读 ${filePath} 失败,使用内联 fallback:`, err)
|
||||
switch (name) {
|
||||
case 'brainstorm': return FALLBACK_BRAINSTORM_PROMPT
|
||||
case 'brainstorm-continue': return FALLBACK_BRAINSTORM_CONTINUE_PROMPT
|
||||
case 'brainstorm-continue-prototype': return FALLBACK_PROTOTYPE_REF_PROMPT
|
||||
case 'implement-plan': return FALLBACK_IMPLEMENT_PLAN_PROMPT
|
||||
case 'review': return FALLBACK_REVIEW_PROMPT
|
||||
case 'test': return FALLBACK_TEST_PROMPT
|
||||
@@ -85,32 +85,20 @@ export interface Settings {
|
||||
*/
|
||||
webhookSecret: string
|
||||
/**
|
||||
* Prompt template for the brainstorming flow (issue creation + ongoing
|
||||
* session conventions for spec/plan body annotations). `{userRequest}`
|
||||
* and `{nonce}` placeholders.
|
||||
* 头脑风暴模板(Handlebars)。`{{#if issueNumber}}` 走续聊已有工单,
|
||||
* `{{else}}` 走新建工单(用 `{{userRequest}}` / `{{nonce}}`);续聊分支里
|
||||
* `{{#if prototypeFile}}` 嵌原型段落。
|
||||
*/
|
||||
brainstormPrompt: string
|
||||
/**
|
||||
* Prompt template for continuing brainstorm on an already-existing issue
|
||||
* (when the user later wants to discuss / write spec/plan in the panel).
|
||||
* 占位符 `{issueNumber}` / `{prototypeRef}`(后者由扩展按工单有无原型插件填,
|
||||
* 无则连同所在段落一起删掉)。
|
||||
*/
|
||||
brainstormContinuePrompt: string
|
||||
/**
|
||||
* 续聊提示词里 `{prototypeRef}` 那段的正文;只在工单挂了原型插件时填进去。
|
||||
* 占位符 {prototypeFile} / {issueNumber} / {prototypeSessionId}。
|
||||
*/
|
||||
prototypeRefPrompt: string
|
||||
/** Prompt template for the implement-plan flow. `{planFile}` placeholder. */
|
||||
/** 实施模板;常用 `{{planFile}}` / `{{issueNumber}}`。 */
|
||||
implementPlanPrompt: string
|
||||
/** Whether to automatically run `codex exec review` when a PR opens. */
|
||||
autoReview: boolean
|
||||
/** Prompt template for the auto-review flow. `{prNumber}` placeholder. */
|
||||
/** 审查模板;常用 `{{prNumber}}`。 */
|
||||
reviewPrompt: string
|
||||
/** 测试会话首个提示词模板;占位符 {issueNumber} / {prRef}。 */
|
||||
/** 测试会话首个提示词模板;常用 `{{issueNumber}}` / `{{prRef}}`。 */
|
||||
testPrompt: string
|
||||
/** 原型会话首个提示词模板;占位符 {issueNumber}。 */
|
||||
/** 原型会话首个提示词模板;常用 `{{issueNumber}}`。 */
|
||||
prototypePrompt: string
|
||||
/** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */
|
||||
devBranch: string
|
||||
@@ -289,8 +277,6 @@ function defaults(ctx: ExtensionContext): Settings {
|
||||
webhookPort: DEFAULT_WEBHOOK_PORT,
|
||||
webhookSecret: '',
|
||||
brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
||||
brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'),
|
||||
prototypeRefPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue-prototype'),
|
||||
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
|
||||
autoReview: true,
|
||||
reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'),
|
||||
@@ -340,12 +326,6 @@ export function getSettings(ctx: ExtensionContext): Settings {
|
||||
const brainstormPrompt = typeof stored.brainstormPrompt === 'string' && stored.brainstormPrompt.length > 0
|
||||
? stored.brainstormPrompt
|
||||
: base.brainstormPrompt
|
||||
const brainstormContinuePrompt = typeof stored.brainstormContinuePrompt === 'string' && stored.brainstormContinuePrompt.length > 0
|
||||
? stored.brainstormContinuePrompt
|
||||
: base.brainstormContinuePrompt
|
||||
const prototypeRefPrompt = typeof stored.prototypeRefPrompt === 'string' && stored.prototypeRefPrompt.length > 0
|
||||
? stored.prototypeRefPrompt
|
||||
: base.prototypeRefPrompt
|
||||
const implementPlanPrompt = typeof stored.implementPlanPrompt === 'string' && stored.implementPlanPrompt.length > 0
|
||||
? stored.implementPlanPrompt
|
||||
: base.implementPlanPrompt
|
||||
@@ -463,8 +443,6 @@ export function getSettings(ctx: ExtensionContext): Settings {
|
||||
webhookPort,
|
||||
webhookSecret,
|
||||
brainstormPrompt,
|
||||
brainstormContinuePrompt,
|
||||
prototypeRefPrompt,
|
||||
implementPlanPrompt,
|
||||
autoReview,
|
||||
reviewPrompt,
|
||||
@@ -527,6 +505,40 @@ export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings
|
||||
writeStoredSettings(ctx, merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性把存量自定义提示词迁到 Handlebars:单花括号改写成双花括号,续聊 /
|
||||
* 原型段落并进头脑风暴模板。写盘前先把 settings.json 备份一份。
|
||||
*
|
||||
* 返回日志条目而不是自己写 logger——logger 依赖 vscode 运行时,store 要能在
|
||||
* 单测里直接 import。
|
||||
*/
|
||||
export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] {
|
||||
const { blob, notes } = planPromptMigration(
|
||||
readStoredSettings(ctx) as Record<string, unknown>,
|
||||
readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
||||
)
|
||||
if (!blob)
|
||||
return []
|
||||
const out: MigrationNote[] = []
|
||||
const file = settingsFilePath(ctx)
|
||||
if (existsSync(file)) {
|
||||
const backup = `${file}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
|
||||
try {
|
||||
copyFileSync(file, backup)
|
||||
out.push({ level: 'info', message: `提示词迁移前备份设置:${backup}` })
|
||||
}
|
||||
catch (err) {
|
||||
return [{
|
||||
level: 'warn',
|
||||
message: `settings.json 备份失败,本次不迁移提示词:${err instanceof Error ? err.message : String(err)}`,
|
||||
}]
|
||||
}
|
||||
}
|
||||
writeStoredSettings(ctx, blob as Partial<Settings>)
|
||||
out.push(...notes, { level: 'info', message: '提示词模板已迁到 Handlebars' })
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时:把旧 memento 迁到文件;commit / 改动说明 profile 缺 key 且能 seed 则写盘。
|
||||
* 空串视为用户主动清空,不覆盖。
|
||||
|
||||
@@ -17,7 +17,7 @@ import { promises as fsp } from 'node:fs'
|
||||
import { env, Uri, window, workspace } from 'vscode'
|
||||
import { loginForToken } from '../auth/identity'
|
||||
import { getToken } from '../auth/secrets'
|
||||
import { getReviewPrompt } from '../cc/prompts'
|
||||
import { renderReviewPrompt } from '../cc/prompts'
|
||||
import { isMailboxIssue } from '../cc/sessionHandoff'
|
||||
import { detectRepo } from '../git/remote'
|
||||
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
|
||||
@@ -1623,36 +1623,33 @@ class WebhookCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// state 读一次,既当审查提示词的模板上下文,又给 worktree 兜底。读失败按空
|
||||
// 状态继续——审查照常开,triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
|
||||
let state: Record<string, unknown> = {}
|
||||
try {
|
||||
state = await readStateJsonComment({
|
||||
host: ctx.host,
|
||||
token: ctx.token,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
issueNumber,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: `读取 state JSON 失败(triggerReview)#${issueNumber}`,
|
||||
details: message,
|
||||
})
|
||||
}
|
||||
|
||||
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
|
||||
// 不回落);本地无记录时读共享评论兜底(存量数据)。缺失不硬失败——
|
||||
// triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
|
||||
let worktreePath = ''
|
||||
// 不回落);本地无记录时才用共享评论里的值(存量数据)。
|
||||
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
|
||||
if (local.worktreePath !== undefined) {
|
||||
worktreePath = local.worktreePath
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const state = await readStateJsonComment({
|
||||
host: ctx.host,
|
||||
token: ctx.token,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
issueNumber,
|
||||
})
|
||||
if (typeof state.worktreePath === 'string')
|
||||
worktreePath = state.worktreePath
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: `读取 state JSON 失败(triggerReview)#${issueNumber}`,
|
||||
details: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
const worktreePath = local.worktreePath
|
||||
?? (typeof state.worktreePath === 'string' ? state.worktreePath : '')
|
||||
|
||||
logger.add({
|
||||
level: 'info',
|
||||
@@ -1660,7 +1657,7 @@ class WebhookCoordinator {
|
||||
message: `开始审查 #${issueNumber} worktreePath=${worktreePath || '(none)'}`,
|
||||
})
|
||||
|
||||
const prompt = getReviewPrompt(this.ctx, { prNumber })
|
||||
const prompt = renderReviewPrompt(this.ctx, { state, issueNumber: String(issueNumber), prNumber })
|
||||
|
||||
const ok = await this.activePanel.triggerAutoReviewTab({
|
||||
issueNumber,
|
||||
|
||||
Reference in new issue
Block a user