From 7bac5c8b03920d294ae5f43ff8a78288622b6afd Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 15 Sep 2026 00:44:28 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=B8=83=E4=BB=BD?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E7=BB=9F=E4=B8=80=E8=B5=B0=20Handle?= =?UTF-8?q?bars=EF=BC=8C=E5=A4=B4=E8=84=91=E9=A3=8E=E6=9A=B4=E4=B8=89?= =?UTF-8?q?=E6=A1=A3=E5=90=88=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板引擎换成 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 --- vscode/README.md | 15 +- vscode/package.json | 3 +- vscode/pnpm-lock.yaml | 70 ++++++- vscode/pnpm-workspace.yaml | 5 + .../prompts/brainstorm-continue-prototype.md | 8 - vscode/prompts/brainstorm-continue.md | 32 --- vscode/prompts/brainstorm.md | 50 ++++- vscode/prompts/implement-plan.md | 4 +- vscode/prompts/prototype.md | 4 +- vscode/prompts/review.md | 8 +- vscode/prompts/test.md | 2 +- vscode/src/cc/promptTemplate.test.ts | 79 ++++++++ vscode/src/cc/promptTemplate.ts | 32 +++ vscode/src/cc/prompts.ts | 142 ++++++------- vscode/src/extension.ts | 5 +- vscode/src/panel/KanbanPanel.ts | 14 +- vscode/src/panel/handlers/issues.ts | 5 +- vscode/src/panel/handlers/sessions.ts | 169 +++++++--------- vscode/src/panel/handlers/settings.ts | 8 - vscode/src/panel/messages.ts | 4 - vscode/src/settings/promptMigration.test.ts | 149 ++++++++++++++ vscode/src/settings/promptMigration.ts | 190 ++++++++++++++++++ vscode/src/settings/store.ts | 106 +++++----- vscode/src/webhook/coordinator.ts | 57 +++--- vscode/webview-ui/src/App.tsx | 2 - .../src/components/SettingsModal.tsx | 102 +++------- vscode/webview-ui/src/hooks/useIssues.ts | 8 - vscode/webview-ui/src/lib/messages.ts | 4 - 28 files changed, 853 insertions(+), 424 deletions(-) delete mode 100644 vscode/prompts/brainstorm-continue-prototype.md delete mode 100644 vscode/prompts/brainstorm-continue.md create mode 100644 vscode/src/cc/promptTemplate.test.ts create mode 100644 vscode/src/cc/promptTemplate.ts create mode 100644 vscode/src/settings/promptMigration.test.ts create mode 100644 vscode/src/settings/promptMigration.ts diff --git a/vscode/README.md b/vscode/README.md index 6f3f0f5..dd568a0 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -113,14 +113,17 @@ cd cli && make install # 编译到 bin/spx 并安装到 ~/.local/bin/ profile 是 Claude 的 settings JSON 文件。目录按 `$SUPERPOWERS_PROFILES_DIR` → 设置 `profilesDirectory` → `~/Sources/cruldra-profile/claude-config/profiles` / `~/.claude/profile-settings` / `~/.claude/profiles` 顺序探测;三种会话 profile 留空时各自回落默认 profile(`offical.json` / `official.json`),互不回退。跨机器的绝对路径按文件名重新映射。 -prompts 在 `prompts/*.md`,随 VSIX 一起安装,改完即生效,也可在设置 → 提示词里覆盖: +prompts 在 `prompts/*.md`,随 VSIX 一起安装,改完即生效,也可在设置 → 提示词里覆盖。模板走 Handlebars:变量写 `{{name}}`,分支写 `{{#if name}}…{{else}}…{{/if}}`。上下文是工单 state JSON 的全部字段(`planFile` `specFile` `prototypeFile` `branch` `pr` `worktreePath` `prototypeSessionId` …)外加下表这几个: -| 文件 | 占位符 | +| 文件 | 额外变量 | |---|---| -| `brainstorm.md` | `{userRequest}` `{nonce}` | -| `brainstorm-continue.md` | `{issueNumber}` | -| `implement-plan.md` | `{planFile}` `{issueNumber}` | -| `review.md` | `{prNumber}` | +| `brainstorm.md` | `issueNumber`(有 = 续聊已有工单,无 = 新建工单)、`userRequest`、`nonce` | +| `implement-plan.md` | `issueNumber`、`planFile` | +| `review.md` | `issueNumber`、`prNumber` | +| `test.md` | `issueNumber`、`prRef` | +| `prototype.md` | `issueNumber` | + +`brainstorm.md` 一份管三件事:`{{#if issueNumber}}` 走续聊(其中 `{{#if prototypeFile}}` 嵌原型段落),`{{else}}` 走新建工单。老版本里 `{issueNumber}` 这种单花括号写法不再支持;升级后首次启动会自动把存量自定义提示词改写成双花括号,并把「续聊」「续聊·原型段落」两份并进 `brainstormPrompt`,改写前会把 `settings.json` 备份成 `settings.json.bak-<时间戳>`。 ## 设置 diff --git a/vscode/package.json b/vscode/package.json index c44c398..a861fab 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -108,7 +108,8 @@ "ext:publish:vsce": "npm_config_proxy= npm_config_https_proxy= vsce publish --skip-duplicate" }, "dependencies": { - "diff": "^9.0.0" + "diff": "^9.0.0", + "handlebars": "^4.7.9" }, "devDependencies": { "@antfu/eslint-config": "^6.7.3", diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index acfd30e..c9245a2 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: diff: specifier: ^9.0.0 version: 9.0.0 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 devDependencies: '@antfu/eslint-config': specifier: ^6.7.3 @@ -827,24 +830,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@node-rs/crc32-linux-arm64-musl@1.10.6': resolution: {integrity: sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@node-rs/crc32-linux-x64-gnu@1.10.6': resolution: {integrity: sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@node-rs/crc32-linux-x64-musl@1.10.6': resolution: {integrity: sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@node-rs/crc32-wasm32-wasi@1.10.6': resolution: {integrity: sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==} @@ -926,66 +933,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -1117,24 +1137,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -2026,6 +2050,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2241,6 +2266,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2487,24 +2517,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2794,6 +2828,9 @@ packages: resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} engines: {node: '>=18'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-abi@3.51.0: resolution: {integrity: sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==} engines: {node: '>=10'} @@ -3167,6 +3204,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -3361,6 +3402,11 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + underscore@1.13.6: resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} @@ -3513,6 +3559,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5807,6 +5856,15 @@ snapshots: graphemer@1.4.0: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-property-descriptors@1.0.1: @@ -6478,8 +6536,7 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimist@1.2.8: - optional: true + minimist@1.2.8: {} minipass@7.1.2: {} @@ -6506,6 +6563,8 @@ snapshots: natural-orderby@5.0.0: {} + neo-async@2.6.2: {} + node-abi@3.51.0: dependencies: semver: 7.7.3 @@ -6924,6 +6983,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -7118,6 +7179,9 @@ snapshots: ufo@1.6.1: {} + uglify-js@3.19.3: + optional: true + underscore@1.13.6: {} undici-types@7.16.0: {} @@ -7248,6 +7312,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/vscode/pnpm-workspace.yaml b/vscode/pnpm-workspace.yaml index 9c17f24..5a72a26 100644 --- a/vscode/pnpm-workspace.yaml +++ b/vscode/pnpm-workspace.yaml @@ -2,6 +2,11 @@ shellEmulator: true trustPolicy: no-downgrade +# vsce 的传递依赖 parse-semver 钉死 semver@5.x,这一支停更在 provenance 普及之前, +# 永远补不上签名;不豁免的话 no-downgrade 会卡住整个装包。 +trustPolicyExclude: + - semver@5.7.2 || 6.3.1 + packages: - webview-ui diff --git a/vscode/prompts/brainstorm-continue-prototype.md b/vscode/prompts/brainstorm-continue-prototype.md deleted file mode 100644 index 9e07291..0000000 --- a/vscode/prompts/brainstorm-continue-prototype.md +++ /dev/null @@ -1,8 +0,0 @@ -工单已有原型插件 {prototypeFile},先读它了解已经定下来的界面和交互,在此基础上只对齐还不明确的地方,不要从零重画。 - -**原型要改时只能交给本工单已经开着的原型会话**:先 ListAgents 找到它,再 SendMessage 说清要改哪块、为什么改、改完怎么算对,然后等它回报。 - -- 原型会话终端名:issue-{issueNumber}-原型 -- 原型会话 id:{prototypeSessionId}(它 transcript 的文件名就是这个 id) - -严禁用下面这些方式代替:Agent 工具派子代理 / fork 分身 / 后台代理,自己动插件代码,翻 git log 猜会话。ListAgents 里没有这个原型会话就**停下告诉我**去工单面板的「原型会话id」行点 ▶ 起一个,不要自作主张换别的做法。 diff --git a/vscode/prompts/brainstorm-continue.md b/vscode/prompts/brainstorm-continue.md deleted file mode 100644 index 6961d78..0000000 --- a/vscode/prompts/brainstorm-continue.md +++ /dev/null @@ -1,32 +0,0 @@ -/ecc:grilling 讨论下 {issueNumber} 号工单 - -用 tea 命令找工单,用 `ecc:grilling` 技能讨论细节,用表单形式和我交互,每次只问一个问题(成组的问题也拆成一次一问)。 - -{prototypeRef} - -## 产物与顺序 - -1. 需求讨论到共识后,用 Skill 工具调 `ecc:spec` 写设计文档到 `.claude/specs/.md`,让我确认。 -2. 设计文档我确认后,用 Skill 工具调 `ecc:plan`,参数是设计文档路径,产出 `.claude/plans/.md`;计划出来后停下等我确认。 -3. `` 取工单标题的英文短横线名,设计文档与计划共用同一个 stem;文件名里不要带 `spec` / `plan` 字样,目录已经表达了。工单已有 marker 就沿用其中的 stem。 - -## marker 维护(本会话有效) - -设计文档 / 计划文件**一律在当前主 worktree(main 分支)创建并提交**(`ecc:spec` 默认不提交,这里要显式提交),每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 设计文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker: - -``` -opencli spx issue marker --issue <工单号> --type spec --value .claude/specs/.md -opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/.md -``` - -## 严禁擅自继续 - -如果你创建/更新了设计文档或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。 - -特别地: - -- 不要创建分支 -- 不要切换分支,包括 `git checkout` / `git switch` -- 除设计文档 / 计划文件外不要修改任何文件 -- 不要创建 PR -- 不要调用 gitea 其他写操作(除上面的 marker 同步) diff --git a/vscode/prompts/brainstorm.md b/vscode/prompts/brainstorm.md index cf93a0e..3921672 100644 --- a/vscode/prompts/brainstorm.md +++ b/vscode/prompts/brainstorm.md @@ -1,4 +1,49 @@ -接下来我要实现下面这样的功能: {userRequest} +{{#if issueNumber}} +/ecc:grilling 讨论下 {{issueNumber}} 号工单 + +用 tea 命令找工单,用 `ecc:grilling` 技能讨论细节,用表单形式和我交互,每次只问一个问题(成组的问题也拆成一次一问)。 + +{{#if prototypeFile}} +工单已有原型插件 {{prototypeFile}},先读它了解已经定下来的界面和交互,在此基础上只对齐还不明确的地方,不要从零重画。 + +**原型要改时只能交给本工单已经开着的原型会话**:先 ListAgents 找到它,再 SendMessage 说清要改哪块、为什么改、改完怎么算对,然后等它回报。 + +- 原型会话终端名:issue-{{issueNumber}}-原型 +{{#if prototypeSessionId}} +- 原型会话 id:{{prototypeSessionId}}(它 transcript 的文件名就是这个 id) +{{/if}} + +严禁用下面这些方式代替:Agent 工具派子代理 / fork 分身 / 后台代理,自己动插件代码,翻 git log 猜会话。ListAgents 里没有这个原型会话就**停下告诉我**去工单面板的「原型会话id」行点 ▶ 起一个,不要自作主张换别的做法。 + +{{/if}} +## 产物与顺序 + +1. 需求讨论到共识后,用 Skill 工具调 `ecc:spec` 写设计文档到 `.claude/specs/.md`,让我确认。 +2. 设计文档我确认后,用 Skill 工具调 `ecc:plan`,参数是设计文档路径,产出 `.claude/plans/.md`;计划出来后停下等我确认。 +3. `` 取工单标题的英文短横线名,设计文档与计划共用同一个 stem;文件名里不要带 `spec` / `plan` 字样,目录已经表达了。工单已有 marker 就沿用其中的 stem。 + +## marker 维护(本会话有效) + +设计文档 / 计划文件**一律在当前主 worktree(main 分支)创建并提交**(`ecc:spec` 默认不提交,这里要显式提交),每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 设计文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker: + +``` +opencli spx issue marker --issue <工单号> --type spec --value .claude/specs/.md +opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/.md +``` + +## 严禁擅自继续 + +如果你创建/更新了设计文档或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。 + +特别地: + +- 不要创建分支 +- 不要切换分支,包括 `git checkout` / `git switch` +- 除设计文档 / 计划文件外不要修改任何文件 +- 不要创建 PR +- 不要调用 gitea 其他写操作(除上面的 marker 同步) +{{else}} +接下来我要实现下面这样的功能: {{userRequest}} 你的任务:用 spx CLI 创建一个 Gitea 工单。spx 用法参考 `using-spx-cli` skill。 @@ -12,7 +57,7 @@ 无论哪种情况,body **末尾必须**包含这一行(且仅此一行;不要预先写其他 `` marker): ``` - + ``` ## 创建 @@ -52,3 +97,4 @@ spx 自动找到对应行替换或追加,保留所有其他 marker。**不要 - 不要创建分支 - 不要切换分支 - 除设计文档 / 计划文件外不要改动或提交任何文件 +{{/if}} diff --git a/vscode/prompts/implement-plan.md b/vscode/prompts/implement-plan.md index 225d89b..9b04708 100644 --- a/vscode/prompts/implement-plan.md +++ b/vscode/prompts/implement-plan.md @@ -1,5 +1,5 @@ -/ecc:tdd @{planFile} +/ecc:tdd @{{planFile}} -按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先自审并修掉严重问题:把 `git diff origin/main...HEAD` 交给 `ecc:code-reviewer` 子代理(`/ecc:code-review` 的本地模式只看未提交改动,提交过就看不到了)。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。 +按计划实施 {{issueNumber}} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先自审并修掉严重问题:把 `git diff origin/main...HEAD` 交给 `ecc:code-reviewer` 子代理(`/ecc:code-review` 的本地模式只看未提交改动,提交过就看不到了)。发起 PR 时务必在 PR body 中包含 "Closes #{{issueNumber}}"。 **严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。 diff --git a/vscode/prompts/prototype.md b/vscode/prompts/prototype.md index f08c1ab..94a11af 100644 --- a/vscode/prompts/prototype.md +++ b/vscode/prompts/prototype.md @@ -1,3 +1,3 @@ -/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。 +/ecc:figma-plugin-prototyping 给 {{issueNumber}} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。 -画完用 `opencli spx issue marker --issue {issueNumber} --type prototype --value ` 把原型插件挂到工单上,再汇报。 +画完用 `opencli spx issue marker --issue {{issueNumber}} --type prototype --value ` 把原型插件挂到工单上,再汇报。 diff --git a/vscode/prompts/review.md b/vscode/prompts/review.md index f1dd0bc..3918e74 100644 --- a/vscode/prompts/review.md +++ b/vscode/prompts/review.md @@ -1,6 +1,6 @@ -审查这个仓库的 PR #{prNumber}。 +审查这个仓库的 PR #{{prNumber}}。 -用 `tea pulls {prNumber}` 看 PR 元信息(标题/描述/分支)。看代码差异用 git(当前目录就是 PR 分支的 worktree):先 `git fetch origin main`,再 `git diff origin/main...HEAD`(概览可加 --stat)。注意 tea 没有 `diff` 子命令,不要尝试 `tea pulls diff`。 +用 `tea pulls {{prNumber}}` 看 PR 元信息(标题/描述/分支)。看代码差异用 git(当前目录就是 PR 分支的 worktree):先 `git fetch origin main`,再 `git diff origin/main...HEAD`(概览可加 --stat)。注意 tea 没有 `diff` 子命令,不要尝试 `tea pulls diff`。 ## 审查方式 @@ -14,10 +14,10 @@ ECC 的 `/ecc:review-pr` 和 `/ecc:code-review` 的 PR 模式都走 `gh`,对 G ## 提交审查意见 -把审查意见(markdown 格式)写到 `/tmp/review-{prNumber}.md`,调 spx: +把审查意见(markdown 格式)写到 `/tmp/review-{{prNumber}}.md`,调 spx: ``` -opencli spx pr review-comment --pr {prNumber} --body-file /tmp/review-{prNumber}.md +opencli spx pr review-comment --pr {{prNumber}} --body-file /tmp/review-{{prNumber}}.md ``` 审查结束以后,删除所有审查过程中创建的临时 worktree(`git worktree remove --force <路径>`),你当前所在的 worktree 除外。 diff --git a/vscode/prompts/test.md b/vscode/prompts/test.md index f5770d2..6ee73aa 100644 --- a/vscode/prompts/test.md +++ b/vscode/prompts/test.md @@ -1,3 +1,3 @@ -/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。 +/pr-acceptance-testing 验收 {{issueNumber}} 号工单({{prRef}})。 验收目标以工单正文为准,不要再问验收哪个。测出问题就停下报告,不要顺手改代码。 diff --git a/vscode/src/cc/promptTemplate.test.ts b/vscode/src/cc/promptTemplate.test.ts new file mode 100644 index 0000000..63fbe3b --- /dev/null +++ b/vscode/src/cc/promptTemplate.test.ts @@ -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 { + 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('') + 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(/\{\{/) + }) +}) diff --git a/vscode/src/cc/promptTemplate.ts b/vscode/src/cc/promptTemplate.ts new file mode 100644 index 0000000..e935e16 --- /dev/null +++ b/vscode/src/cc/promptTemplate.ts @@ -0,0 +1,32 @@ +/** + * 提示词模板的渲染内核:七份模板统一走 Handlebars。 + * + * `noEscape` 是必须的——提示词是打进终端的纯文本,默认的 HTML 转义会把 + * 引号、`&`、`<` 变成实体,cc 收到的就是一堆 `"`。 + * + * 这个模块刻意不碰 vscode / 文件系统,方便单测直接跑。 + */ + +import Handlebars from 'handlebars' + +export type PromptContext = Record + +/** + * 模板上下文 = 工单 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) +} diff --git a/vscode/src/cc/prompts.ts b/vscode/src/cc/prompts.ts index c91eb0f..0af36e8 100644 --- a/vscode/src/cc/prompts.ts +++ b/vscode/src/cc/prompts.ts @@ -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 `), 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 = { - 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) } diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 478cafb..1cde7fa 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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 { 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. diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 21e3574..2d4e519 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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 不走批量加载,漏了这一步 * 归属表里就没有它,写门禁查不到会当成我的工单放行。 diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 9a3a722..61e7d4f 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -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, diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 11057cc..50f07fe 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -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> { + 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 { // 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 diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index 06b3d52..c1321cb 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -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, diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index ea65bd9..e794e06 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 diff --git a/vscode/src/settings/promptMigration.test.ts b/vscode/src/settings/promptMigration.test.ts new file mode 100644 index 0000000..9a6bae8 --- /dev/null +++ b/vscode/src/settings/promptMigration.test.ts @@ -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}}') + }) +}) diff --git a/vscode/src/settings/promptMigration.ts b/vscode/src/settings/promptMigration.ts new file mode 100644 index 0000000..9142ccb --- /dev/null +++ b/vscode/src/settings/promptMigration.ts @@ -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 | null + notes: MigrationNote[] +} + +/** `{foo}` → `{{foo}}`,只认已知占位符;已经是 `{{foo}}` 的不动。 */ +export function upgradePlaceholders(text: string): string { + const names = KNOWN_PLACEHOLDERS.join('|') + return text.replace(new RegExp(`(? 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, key: string): string { + const v = stored[key] + return typeof v === 'string' ? v : '' +} + +/** + * 算出迁移后的 settings blob。`defaultBrainstorm` 是内置的合并版模板,用来补 + * 用户没存过的那一半分支——比如只改过「新建工单」提示词的人,续聊分支直接用 + * 内置默认,不然合并出来的模板会有一路是空的。 + */ +export function planPromptMigration( + stored: Record, + defaultBrainstorm: string, +): PromptMigrationPlan { + const version = stored.promptTemplateVersion + if (typeof version === 'number' && version >= PROMPT_TEMPLATE_VERSION) + return { blob: null, notes: [] } + + const next: Record = { ...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 } +} diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index 3096db4..2e17381 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -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 末尾必须包含 。创建完工单立即停下汇报,不要擅自实施。` +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/.md,我确认后用 ecc:plan 出 .claude/plans/.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。 +{{else}} +我现在有这样一个需求 {{userRequest}},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 。创建完工单立即停下汇报,不要擅自实施。 +{{/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/.md,我确认后用 ecc:plan 出 .claude/plans/.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 第一行写 。` -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 \` 把原型插件挂到工单上,再汇报。` -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 第一行写 。` - -const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。 - -画完用 \`opencli spx issue marker --issue {issueNumber} --type prototype --value \` 把原型插件挂到工单上,再汇报。` - -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, + 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) + out.push(...notes, { level: 'info', message: '提示词模板已迁到 Handlebars' }) + return out +} + /** * 启动时:把旧 memento 迁到文件;commit / 改动说明 profile 缺 key 且能 seed 则写盘。 * 空串视为用户主动清空,不覆盖。 diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 4ae6961..b78cdb1 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -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 = {} + 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, diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 0e91937..2272bde 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -542,8 +542,6 @@ export function App() { initialWebhookPort={settings?.webhookPort ?? 17421} initialWebhookSecret={settings?.webhookSecret ?? ''} initialBrainstormPrompt={settings?.brainstormPrompt ?? ''} - initialBrainstormContinuePrompt={settings?.brainstormContinuePrompt ?? ''} - initialPrototypeRefPrompt={settings?.prototypeRefPrompt ?? ''} initialImplementPlanPrompt={settings?.implementPlanPrompt ?? ''} initialAutoReview={settings?.autoReview ?? true} initialReviewPrompt={settings?.reviewPrompt ?? ''} diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 6c58c02..b8c3b88 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -21,8 +21,6 @@ interface SubmitValues { webhookPort: number webhookSecret: string brainstormPrompt: string - brainstormContinuePrompt: string - prototypeRefPrompt: string implementPlanPrompt: string autoReview: boolean reviewPrompt: string @@ -78,8 +76,6 @@ export interface SettingsModalProps { initialWebhookPort: number initialWebhookSecret: string initialBrainstormPrompt: string - initialBrainstormContinuePrompt: string - initialPrototypeRefPrompt: string initialImplementPlanPrompt: string initialAutoReview: boolean initialReviewPrompt: string @@ -171,8 +167,6 @@ export function SettingsModal({ initialWebhookPort, initialWebhookSecret, initialBrainstormPrompt, - initialBrainstormContinuePrompt, - initialPrototypeRefPrompt, initialImplementPlanPrompt, initialAutoReview, initialReviewPrompt, @@ -216,8 +210,6 @@ export function SettingsModal({ const [webhookPort, setWebhookPort] = useState(String(initialWebhookPort)) const [webhookSecret, setWebhookSecret] = useState(initialWebhookSecret) const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt) - const [brainstormContinuePrompt, setBrainstormContinuePrompt] = useState(initialBrainstormContinuePrompt) - const [prototypeRefPrompt, setPrototypeRefPrompt] = useState(initialPrototypeRefPrompt) const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt) const [autoReview, setAutoReview] = useState(initialAutoReview) const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt) @@ -263,8 +255,6 @@ export function SettingsModal({ setWebhookPort(String(initialWebhookPort)) setWebhookSecret(initialWebhookSecret) setBrainstormPrompt(initialBrainstormPrompt) - setBrainstormContinuePrompt(initialBrainstormContinuePrompt) - setPrototypeRefPrompt(initialPrototypeRefPrompt) setImplementPlanPrompt(initialImplementPlanPrompt) setAutoReview(initialAutoReview) setReviewPrompt(initialReviewPrompt) @@ -358,8 +348,6 @@ export function SettingsModal({ webhookPort: portNum, webhookSecret: webhookSecret.trim(), brainstormPrompt, - brainstormContinuePrompt, - prototypeRefPrompt, implementPlanPrompt, autoReview, reviewPrompt, @@ -979,64 +967,34 @@ export function SettingsModal({ label="头脑风暴提示词" hint={( <> - 可用占位符: - {'{userRequest}'} + Handlebars 模板。 + {'{{#if issueNumber}}'} + 分支 = 对已有工单续聊( + {'{{issueNumber}}'} + ,里面再用 + {'{{#if prototypeFile}}'} + 嵌原型段落、 + {'{{#if prototypeSessionId}}'} + 控制原型会话 id 那行); + {'{{else}}'} + 分支 = 新建工单( + {'{{userRequest}}'} 、 - {'{nonce}'} - 。同时承担:建工单初始指令 + 会话内 spec/plan 路径写回 issue body 的持续约定。 + {'{{nonce}}'} + )。工单 state JSON 的字段( + {'{{planFile}}'} + 、 + {'{{branch}}'} + 等)都能直接用。 )} >