✨ 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:
cruldra committed 2026-09-15 00:44:28 +08:00
1 parent efeac8c978
commit 7bac5c8b03
28 files changed
+853 -424

No files matched your search

+79
View File
@@ -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(/\{\{/)
})
})
+32
View File
@@ -0,0 +1,32 @@
/**
* 提示词模板的渲染内核:七份模板统一走 Handlebars。
*
* `noEscape` 是必须的——提示词是打进终端的纯文本,默认的 HTML 转义会把
* 引号、`&`、`<` 变成实体,cc 收到的就是一堆 `&quot;`。
*
* 这个模块刻意不碰 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
View File
@@ -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)
}