Compare commits
12
Commits
814e0e8f5a
...
b77f2b1b63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b77f2b1b63 | ||
|
|
2b02da27e7 | ||
|
|
387b323271 | ||
|
|
7bac5c8b03 | ||
|
|
efeac8c978 | ||
|
|
43a0bf6bad | ||
|
|
f9537abf97 | ||
|
|
24e590a8dd | ||
|
|
0792c9262d | ||
|
|
9a388b50ce | ||
|
|
142af57d9d | ||
|
|
def5f4e88c |
No files matched your search
+9
-6
@@ -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-<时间戳>`。
|
||||
|
||||
## 设置
|
||||
|
||||
|
||||
@@ -111,6 +111,20 @@ func buildRootCmd() *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
// markerTypeToStateField maps a `spx issue marker --type` value to the state
|
||||
// JSON field it mirrors. All three types share one incremental-update path;
|
||||
// this table is the single source of truth — adding a type only touches it
|
||||
// (plus markerTypesHint below, which is what the user sees).
|
||||
var markerTypeToStateField = map[string]string{
|
||||
"spec": "specFile",
|
||||
"plan": "planFile",
|
||||
"prototype": "prototypeFile",
|
||||
}
|
||||
|
||||
// markerTypesHint is the human-readable enumeration used in help text and the
|
||||
// "bad --type" error, kept next to the table so the two can't drift.
|
||||
const markerTypesHint = "spec、plan 或 prototype"
|
||||
|
||||
func buildIssueCmd() *cobra.Command {
|
||||
issue := &cobra.Command{
|
||||
Use: "issue",
|
||||
@@ -215,17 +229,17 @@ func buildIssueCmd() *cobra.Command {
|
||||
|
||||
markerCmd := &cobra.Command{
|
||||
Use: "marker",
|
||||
Short: "增量更新现有工单的 spec/plan marker,并同步合并 specFile/planFile 进 state JSON",
|
||||
Short: "增量更新现有工单的 spec/plan/prototype marker,并同步合并 specFile/planFile/prototypeFile 进 state JSON",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if imIssue <= 0 {
|
||||
return fmt.Errorf("--issue 必须是正整数")
|
||||
}
|
||||
if imType != "spec" && imType != "plan" {
|
||||
return fmt.Errorf("--type 必须是 spec 或 plan,得到 %q", imType)
|
||||
fieldName, ok := markerTypeToStateField[imType]
|
||||
if !ok {
|
||||
return fmt.Errorf("--type 必须是 %s,得到 %q", markerTypesHint, imType)
|
||||
}
|
||||
// Fail fast on a bad path before we touch the network — the
|
||||
// schema's specFile/planFile pattern doubles as input check.
|
||||
fieldName := map[string]string{"spec": "specFile", "plan": "planFile"}[imType]
|
||||
// schema's pattern for this field doubles as input check.
|
||||
patch := map[string]any{fieldName: imValue}
|
||||
if err := state.ValidateValue(patch); err != nil {
|
||||
return err
|
||||
@@ -263,8 +277,8 @@ func buildIssueCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)")
|
||||
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:spec 或 plan(必填)")
|
||||
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile)")
|
||||
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:"+markerTypesHint+"(必填)")
|
||||
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile/prototypeFile)")
|
||||
_ = markerCmd.MarkFlagRequired("issue")
|
||||
_ = markerCmd.MarkFlagRequired("type")
|
||||
_ = markerCmd.MarkFlagRequired("value")
|
||||
@@ -394,11 +408,14 @@ var knownStateFields = map[string]struct{}{
|
||||
"implementSessionId": {},
|
||||
"reviewSessionId": {},
|
||||
"testSessionId": {},
|
||||
"prototypeSessionId": {},
|
||||
"profilePath": {},
|
||||
"brainstormProfilePath": {},
|
||||
"testProfilePath": {},
|
||||
"prototypeProfilePath": {},
|
||||
"specFile": {},
|
||||
"planFile": {},
|
||||
"prototypeFile": {},
|
||||
"prDiffFile": {},
|
||||
"pr": {},
|
||||
"prMerged": {},
|
||||
|
||||
@@ -29,16 +29,38 @@ func TestUpsertMarkerReplacesWhenPresent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpsertMarkerLeavesOtherTypesAlone(t *testing.T) {
|
||||
body := "<!-- spx:plan=p.md -->\n<!-- spx:spec=old.md -->"
|
||||
body := "<!-- spx:plan=p.md -->\n<!-- spx:prototype=docs/prototype/x/figma-plugin/manifest.json -->\n<!-- spx:spec=old.md -->"
|
||||
got := UpsertMarker(body, "spec", "new.md")
|
||||
if !strings.Contains(got, "<!-- spx:plan=p.md -->") {
|
||||
t.Fatalf("plan marker should be preserved: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<!-- spx:prototype=docs/prototype/x/figma-plugin/manifest.json -->") {
|
||||
t.Fatalf("prototype marker should be preserved: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
|
||||
t.Fatalf("spec marker should be updated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertMarkerPrototypeAppendsWhenAbsent(t *testing.T) {
|
||||
got := UpsertMarker("hello body", "prototype", "docs/prototype/login/figma-plugin/manifest.json")
|
||||
want := "hello body\n<!-- spx:prototype=docs/prototype/login/figma-plugin/manifest.json -->"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertMarkerPrototypeReplacesWhenPresent(t *testing.T) {
|
||||
body := "intro\n<!-- spx:prototype=old/manifest.json -->\nmore"
|
||||
got := UpsertMarker(body, "prototype", "new/manifest.json")
|
||||
if strings.Contains(got, "old/manifest.json") {
|
||||
t.Fatalf("old value should be gone: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<!-- spx:prototype=new/manifest.json -->") {
|
||||
t.Fatalf("new marker missing: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertMarkerEmptyBody(t *testing.T) {
|
||||
got := UpsertMarker("", "plan", "p.md")
|
||||
want := "<!-- spx:plan=p.md -->"
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
||||
"minLength": 1
|
||||
},
|
||||
"prototypeSessionId": {
|
||||
"type": "string",
|
||||
"description": "原型阶段的 Claude Code session id;画 Figma 原型的会话,用于 resume。",
|
||||
"minLength": 1
|
||||
},
|
||||
"profilePath": {
|
||||
"type": "string",
|
||||
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
||||
@@ -46,6 +51,11 @@
|
||||
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||
"minLength": 1
|
||||
},
|
||||
"prototypeProfilePath": {
|
||||
"type": "string",
|
||||
"description": "原型会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||
"minLength": 1
|
||||
},
|
||||
"specFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
||||
@@ -58,6 +68,12 @@
|
||||
"minLength": 1,
|
||||
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
||||
},
|
||||
"prototypeFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 Figma 原型插件 manifest.json 路径,仓库相对或绝对,必须形如 `dir/.../manifest.json`。",
|
||||
"minLength": 1,
|
||||
"pattern": "^[^\\s]+/[^\\s]+\\.json$"
|
||||
},
|
||||
"prDiffFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
||||
|
||||
@@ -71,11 +71,14 @@ func TestValidateAcceptsFullState(t *testing.T) {
|
||||
"implementSessionId":"impl-1",
|
||||
"reviewSessionId":"rev-1",
|
||||
"testSessionId":"test-1",
|
||||
"prototypeSessionId":"proto-1",
|
||||
"profilePath":"/home/x/.claude/settings.json",
|
||||
"brainstormProfilePath":"/home/x/.claude/profiles/brainstorm.json",
|
||||
"testProfilePath":"/home/x/.claude/profiles/test.json",
|
||||
"prototypeProfilePath":"/home/x/.claude/profiles/prototype.json",
|
||||
"specFile":"docs/superpowers/specs/foo.md",
|
||||
"planFile":"docs/superpowers/plans/foo.md",
|
||||
"prototypeFile":"docs/prototype/login/figma-plugin/manifest.json",
|
||||
"prDiffFile":"docs/pr-diff/pr-42-issue-7.md",
|
||||
"pr":"42",
|
||||
"prMerged":true,
|
||||
@@ -91,6 +94,13 @@ func TestValidateAcceptsFullState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsBadPrototypeFile(t *testing.T) {
|
||||
err := Validate([]byte(`{"prototypeFile":"docs/prototype/login/figma-plugin/manifest.md"}`))
|
||||
if err == nil {
|
||||
t.Fatal("非 .json 路径应当被 prototypeFile pattern 拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsBrainstormProfilePathOnly(t *testing.T) {
|
||||
if err := Validate([]byte(`{"brainstormProfilePath":"/home/x/.claude/profiles/brainstorm.json"}`)); err != nil {
|
||||
t.Fatalf("仅含 brainstormProfilePath 应当通过校验,得到: %v", err)
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.138",
|
||||
"version": "0.2.143",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
@@ -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",
|
||||
|
||||
Generated
+68
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/ecc:grilling 讨论下 {issueNumber} 号工单
|
||||
|
||||
用 tea 命令找工单,用 `ecc:grilling` 技能讨论细节,用表单形式和我交互,每次只问一个问题(成组的问题也拆成一次一问)。
|
||||
|
||||
## 产物与顺序
|
||||
|
||||
1. 需求讨论到共识后,用 Skill 工具调 `ecc:spec` 写设计文档到 `.claude/specs/<stem>.md`,让我确认。
|
||||
2. 设计文档我确认后,用 Skill 工具调 `ecc:plan`,参数是设计文档路径,产出 `.claude/plans/<stem>.md`;计划出来后停下等我确认。
|
||||
3. `<stem>` 取工单标题的英文短横线名,设计文档与计划共用同一个 stem;文件名里不要带 `spec` / `plan` 字样,目录已经表达了。工单已有 marker 就沿用其中的 stem。
|
||||
|
||||
## marker 维护(本会话有效)
|
||||
|
||||
设计文档 / 计划文件**一律在当前主 worktree(main 分支)创建并提交**(`ecc:spec` 默认不提交,这里要显式提交),每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 设计文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/specs/<stem>.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<stem>.md
|
||||
```
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
如果你创建/更新了设计文档或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支,包括 `git checkout` / `git switch`
|
||||
- 除设计文档 / 计划文件外不要修改任何文件
|
||||
- 不要创建 PR
|
||||
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
|
||||
@@ -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/<stem>.md`,让我确认。
|
||||
2. 设计文档我确认后,用 Skill 工具调 `ecc:plan`,参数是设计文档路径,产出 `.claude/plans/<stem>.md`;计划出来后停下等我确认。
|
||||
3. `<stem>` 取工单标题的英文短横线名,设计文档与计划共用同一个 stem;文件名里不要带 `spec` / `plan` 字样,目录已经表达了。工单已有 marker 就沿用其中的 stem。
|
||||
|
||||
## marker 维护(本会话有效)
|
||||
|
||||
设计文档 / 计划文件**一律在当前主 worktree(main 分支)创建并提交**(`ecc:spec` 默认不提交,这里要显式提交),每次只提交这两个文件(提交信息形如 `📝 docs: #<工单号> 设计文档` / `📝 docs: #<工单号> 实施计划`),不要为此新建或切换分支。创建后追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value .claude/specs/<stem>.md
|
||||
opencli spx issue marker --issue <工单号> --type plan --value .claude/plans/<stem>.md
|
||||
```
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
如果你创建/更新了设计文档或计划文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支,包括 `git checkout` / `git switch`
|
||||
- 除设计文档 / 计划文件外不要修改任何文件
|
||||
- 不要创建 PR
|
||||
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
|
||||
{{else}}
|
||||
接下来我要实现下面这样的功能: {{userRequest}}
|
||||
|
||||
你的任务:用 spx CLI 创建一个 Gitea 工单。spx 用法参考 `using-spx-cli` skill。
|
||||
|
||||
@@ -12,7 +57,7 @@
|
||||
无论哪种情况,body **末尾必须**包含这一行(且仅此一行;不要预先写其他 `<!-- spx:* -->` marker):
|
||||
|
||||
```
|
||||
<!-- spx:nonce={nonce} -->
|
||||
<!-- spx:nonce={{nonce}} -->
|
||||
```
|
||||
|
||||
## 创建
|
||||
@@ -52,3 +97,4 @@ spx 自动找到对应行替换或追加,保留所有其他 marker。**不要
|
||||
- 不要创建分支
|
||||
- 不要切换分支
|
||||
- 除设计文档 / 计划文件外不要改动或提交任何文件
|
||||
{{/if}}
|
||||
@@ -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` 或任何合并操作。
|
||||
@@ -1 +1,3 @@
|
||||
/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||
/ecc:figma-plugin-prototyping 给 {{issueNumber}} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||
|
||||
画完用 `opencli spx issue marker --issue {{issueNumber}} --type prototype --value <manifest.json 的仓库相对路径>` 把原型插件挂到工单上,再汇报。
|
||||
@@ -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 除外。
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。
|
||||
/pr-acceptance-testing 验收 {{issueNumber}} 号工单({{prRef}})。
|
||||
|
||||
验收目标以工单正文为准,不要再问验收哪个。测出问题就停下报告,不要顺手改代码。
|
||||
@@ -31,6 +31,11 @@
|
||||
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
||||
"minLength": 1
|
||||
},
|
||||
"prototypeSessionId": {
|
||||
"type": "string",
|
||||
"description": "原型阶段的 Claude Code session id;画 Figma 原型的会话,用于 resume。",
|
||||
"minLength": 1
|
||||
},
|
||||
"profilePath": {
|
||||
"type": "string",
|
||||
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
||||
@@ -46,6 +51,11 @@
|
||||
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||
"minLength": 1
|
||||
},
|
||||
"prototypeProfilePath": {
|
||||
"type": "string",
|
||||
"description": "原型会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||
"minLength": 1
|
||||
},
|
||||
"specFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
||||
@@ -58,6 +68,12 @@
|
||||
"minLength": 1,
|
||||
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
||||
},
|
||||
"prototypeFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 Figma 原型插件 manifest.json 路径,仓库相对或绝对,必须形如 `dir/.../manifest.json`。",
|
||||
"minLength": 1,
|
||||
"pattern": "^[^\\s]+/[^\\s]+\\.json$"
|
||||
},
|
||||
"prDiffFile": {
|
||||
"type": "string",
|
||||
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+63
-50
@@ -1,73 +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}`
|
||||
/** 调用方补进模板上下文的变量;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
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板语法错误不该把会话卡死:记 error 日志后原样把模板正文当提示词发出去,
|
||||
* 用户在终端里一眼能看到没被替换的 `{{...}}`,知道是自己模板写坏了。
|
||||
*/
|
||||
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
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getBrainstormContinuePrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: 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.
|
||||
return tpl.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 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))
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 「在 Figma 中导入并打开」:把工单挂着的 Figma 插件 manifest.json 写进
|
||||
* Figma 桌面版的已导入插件清单,然后拉起 Figma。
|
||||
*
|
||||
* 为什么要自己改配置文件:Figma 没有命令行导入开发插件的入口,UI 上得
|
||||
* 「Plugins → Development → Import plugin from manifest」一路点。清单本身
|
||||
* 就是 `~/Library/Application Support/Figma/settings.json` 里的一个数组,
|
||||
* 直接往里加一条即可。
|
||||
*
|
||||
* 最大的坑:**Figma 退出时会用内存里的状态整个覆盖 settings.json**。开着
|
||||
* Figma 写进去,用户一关 Figma 就全没了。所以写之前必须先把 Figma 关掉,
|
||||
* 而且要等进程真的退出——不弹窗问用户,问了也只能答「关」。
|
||||
*
|
||||
* 只支持 macOS:路径和 `osascript` / `open -a` 都是 mac 专有。
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { env, window } from 'vscode'
|
||||
import { logger } from '../logging/logger'
|
||||
import { appendManifestEntry, asExtensionEntries } from './localExtensions'
|
||||
|
||||
/** Figma 桌面版的设置文件(含已导入开发插件清单)。 */
|
||||
function figmaSettingsPath(): string {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support', 'Figma', 'settings.json')
|
||||
}
|
||||
|
||||
/** 等 Figma 退出的上限;到点还没退就让用户自己关,绝不 kill -9。 */
|
||||
const QUIT_TIMEOUT_MS = 10_000
|
||||
const QUIT_POLL_MS = 400
|
||||
|
||||
function run(cmd: string, args: string[]): Promise<{ code: number, stdout: string, stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
|
||||
// pgrep 没匹配到进程也是非零退出,所以这里不把非零当失败,交给调用方判。
|
||||
const code = err ? Number((err as NodeJS.ErrnoException).code ?? 1) : 0
|
||||
resolve({
|
||||
code: Number.isFinite(code) ? code : 1,
|
||||
stdout: (stdout ?? '').toString(),
|
||||
stderr: (stderr ?? '').toString(),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function isFigmaRunning(): Promise<boolean> {
|
||||
const { stdout } = await run('pgrep', ['-x', 'Figma'])
|
||||
return stdout.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅退出 Figma 并轮询等它真的没了。超时返回 false(调用方报错让用户手动关),
|
||||
* 不强杀——Figma 被 SIGKILL 会丢用户没保存的画布。
|
||||
*/
|
||||
async function quitFigmaAndWait(): Promise<boolean> {
|
||||
await run('osascript', ['-e', 'quit app "Figma"'])
|
||||
const deadline = Date.now() + QUIT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (!await isFigmaRunning())
|
||||
return true
|
||||
await new Promise(resolve => setTimeout(resolve, QUIT_POLL_MS))
|
||||
}
|
||||
return !await isFigmaRunning()
|
||||
}
|
||||
|
||||
/** 读 manifest.json 的 `name` 当 Figma 里显示的插件名;没有就用目录名兜底。 */
|
||||
async function readPluginName(manifestAbs: string): Promise<string> {
|
||||
const raw = await fsp.readFile(manifestAbs, 'utf8')
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const name = parsed && typeof parsed === 'object'
|
||||
? (parsed as { name?: unknown }).name
|
||||
: undefined
|
||||
if (typeof name === 'string' && name.trim().length > 0)
|
||||
return name.trim()
|
||||
return path.basename(path.dirname(manifestAbs))
|
||||
}
|
||||
|
||||
function fail(message: string, err?: unknown): void {
|
||||
const details = err === undefined
|
||||
? undefined
|
||||
: err instanceof Error
|
||||
? (err.stack ?? err.message)
|
||||
: String(err)
|
||||
logger.add({ level: 'error', source: 'figma', message, details })
|
||||
void window.showErrorMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程。`manifestPath` 可以是仓库相对路径也可以是绝对路径。
|
||||
* 任何一步失败都出错误提示 + 记日志,不静默。
|
||||
*/
|
||||
export async function importPluginIntoFigma(opts: {
|
||||
issueNumber: number
|
||||
manifestPath: string
|
||||
workspaceRoot: string
|
||||
}): Promise<void> {
|
||||
const { issueNumber, manifestPath, workspaceRoot } = opts
|
||||
const manifestAbs = path.isAbsolute(manifestPath)
|
||||
? manifestPath
|
||||
: path.join(workspaceRoot, manifestPath)
|
||||
|
||||
if (!fs.existsSync(manifestAbs)) {
|
||||
fail(`#${issueNumber} 的原型插件不存在:${manifestAbs}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 非 mac 没有这条路径可走,把绝对路径给用户,让他自己在 Figma 里导入。
|
||||
if (process.platform !== 'darwin') {
|
||||
await env.clipboard.writeText(manifestAbs)
|
||||
void window.showWarningMessage(
|
||||
`当前系统不支持自动导入 Figma 插件,manifest 路径已复制到剪贴板,请在 Figma 里手动导入:${manifestAbs}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const settingsPath = figmaSettingsPath()
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fail(`没找到 Figma 配置文件:${settingsPath}。先装 Figma 桌面版并至少启动一次。`)
|
||||
return
|
||||
}
|
||||
|
||||
let pluginName: string
|
||||
try {
|
||||
pluginName = await readPluginName(manifestAbs)
|
||||
}
|
||||
catch (err) {
|
||||
fail(`读取 manifest.json 失败:${manifestAbs}`, err)
|
||||
return
|
||||
}
|
||||
|
||||
let settings: Record<string, unknown>
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await fsp.readFile(settingsPath, 'utf8'))
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new TypeError('settings.json 顶层不是对象')
|
||||
settings = parsed as Record<string, unknown>
|
||||
}
|
||||
catch (err) {
|
||||
fail(`读取 Figma 配置失败:${settingsPath}`, err)
|
||||
return
|
||||
}
|
||||
|
||||
const entries = asExtensionEntries(settings.localFileExtensions)
|
||||
const result = appendManifestEntry(entries, { manifestPath: manifestAbs, name: pluginName })
|
||||
|
||||
// Figma 在跑就一律先关掉,两个原因都成立:
|
||||
// 写清单时——退出时的内存快照会把这次写入盖掉;
|
||||
// 清单里已经有了——Figma 把插件代码缓存在进程里,不重启就还跑上一版
|
||||
// code.js,改完原型点这个按钮会看到旧界面。
|
||||
if (await isFigmaRunning()) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'figma',
|
||||
message: result.added
|
||||
? '写配置前先退出 Figma(退出会覆盖 settings.json)'
|
||||
: '插件已在清单中,重启 Figma 让它重新读盘上最新的插件代码',
|
||||
})
|
||||
if (!await quitFigmaAndWait()) {
|
||||
fail(`Figma 在 ${QUIT_TIMEOUT_MS / 1000} 秒内没有退出,请手动关掉 Figma 后重试。`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (result.added) {
|
||||
try {
|
||||
const backup = `${settingsPath}.bak-${Date.now()}`
|
||||
await fsp.copyFile(settingsPath, backup)
|
||||
settings.localFileExtensions = result.entries
|
||||
await fsp.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'figma',
|
||||
message: `#${issueNumber} 原型插件已写入 Figma 配置:${pluginName}`,
|
||||
details: `manifest=${manifestAbs} backup=${backup}`,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
fail(`写入 Figma 配置失败:${settingsPath}`, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'figma',
|
||||
message: `#${issueNumber} 原型插件已在 Figma 清单中,跳过写入:${result.name}`,
|
||||
details: `manifest=${manifestAbs}`,
|
||||
})
|
||||
}
|
||||
|
||||
const open = await run('open', ['-a', 'Figma'])
|
||||
if (open.code !== 0) {
|
||||
fail(`拉起 Figma 失败:${open.stderr.trim() || `exit ${open.code}`}`)
|
||||
return
|
||||
}
|
||||
|
||||
void window.showInformationMessage(
|
||||
result.added
|
||||
? `原型插件「${result.name}」已导入 Figma,在 Plugins > Development 里跑它`
|
||||
: `Figma 已重启,原型插件「${result.name}」是最新代码,在 Plugins > Development 里跑它`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendManifestEntry,
|
||||
asExtensionEntries,
|
||||
buildManifestEntry,
|
||||
findManifestEntry,
|
||||
nextExtensionId,
|
||||
} from './localExtensions'
|
||||
|
||||
/** 造一条 manifest 条目(id 与 codeFileId 成对,和 Figma 真实清单一致)。 */
|
||||
function manifest(id: number, manifestPath: string) {
|
||||
return {
|
||||
id,
|
||||
manifestPath,
|
||||
lastKnownName: `plugin-${id}`,
|
||||
lastKnownPluginId: '',
|
||||
fileMetadata: { type: 'manifest' as const, codeFileId: id + 1, uiFileIds: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/** Figma 加载插件后补的 code.js 条目,占的是 manifest 的 codeFileId。 */
|
||||
function code(id: number, manifestFileId: number, manifestPath: string) {
|
||||
return {
|
||||
id,
|
||||
manifestPath,
|
||||
fileMetadata: { type: 'code' as const, manifestFileId },
|
||||
}
|
||||
}
|
||||
|
||||
describe('nextExtensionId', () => {
|
||||
it('空清单从 1 开始', () => {
|
||||
expect(nextExtensionId([])).toBe(1)
|
||||
})
|
||||
|
||||
it('取已有条目 id 最大值 +1', () => {
|
||||
expect(nextExtensionId([manifest(1, '/a/manifest.json'), code(2, 1, '/a/code.js')])).toBe(3)
|
||||
})
|
||||
|
||||
it('codeFileId 预定的号也要算进去(code 条目还没建出来时)', () => {
|
||||
// id 只到 11,但 11 的 codeFileId 已经把 12 预定了 → 下一个必须是 13
|
||||
expect(nextExtensionId([
|
||||
manifest(9, '/a/manifest.json'),
|
||||
code(10, 9, '/a/code.js'),
|
||||
manifest(11, '/b/manifest.json'),
|
||||
])).toBe(13)
|
||||
})
|
||||
|
||||
it('顺序乱了也按最大值算', () => {
|
||||
expect(nextExtensionId([
|
||||
code(8, 7, '/b/code.js'),
|
||||
manifest(3, '/a/manifest.json'),
|
||||
manifest(7, '/b/manifest.json'),
|
||||
])).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findManifestEntry', () => {
|
||||
const entries = [manifest(1, '/a/manifest.json'), manifest(3, '/b/manifest.json')]
|
||||
|
||||
it('按 manifestPath 精确命中', () => {
|
||||
expect(findManifestEntry(entries, '/b/manifest.json')?.id).toBe(3)
|
||||
})
|
||||
|
||||
it('路径不同就不算重复(同名不同目录)', () => {
|
||||
expect(findManifestEntry(entries, '/c/manifest.json')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildManifestEntry', () => {
|
||||
it('codeFileId 紧跟 id,给 Figma 之后建 code 条目占号', () => {
|
||||
expect(buildManifestEntry({ id: 19, manifestPath: '/x/manifest.json', name: 'X Lab' })).toEqual({
|
||||
id: 19,
|
||||
manifestPath: '/x/manifest.json',
|
||||
lastKnownName: 'X Lab',
|
||||
lastKnownPluginId: '',
|
||||
fileMetadata: { type: 'manifest', codeFileId: 20, uiFileIds: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendManifestEntry', () => {
|
||||
it('新插件追加到末尾,id 接着往下发', () => {
|
||||
const entries = [manifest(1, '/a/manifest.json'), code(2, 1, '/a/code.js')]
|
||||
const got = appendManifestEntry(entries, { manifestPath: '/b/manifest.json', name: 'B Lab' })
|
||||
expect(got.added).toBe(true)
|
||||
expect(got.entries).toHaveLength(3)
|
||||
expect(got.entries[2]).toMatchObject({ id: 3, manifestPath: '/b/manifest.json', lastKnownName: 'B Lab' })
|
||||
// 原数组不被改动
|
||||
expect(entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('已导入过 → 不追加,名字用清单里记的那个', () => {
|
||||
const entries = [manifest(1, '/a/manifest.json')]
|
||||
const got = appendManifestEntry(entries, { manifestPath: '/a/manifest.json', name: '新名字' })
|
||||
expect(got.added).toBe(false)
|
||||
expect(got.entries).toBe(entries)
|
||||
expect(got.name).toBe('plugin-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('asExtensionEntries', () => {
|
||||
it('不是数组 → 空清单(Figma 没装过插件时该字段可能缺失)', () => {
|
||||
expect(asExtensionEntries(undefined)).toEqual([])
|
||||
expect(asExtensionEntries({})).toEqual([])
|
||||
})
|
||||
|
||||
it('丢掉缺 id / manifestPath 的脏条目', () => {
|
||||
expect(asExtensionEntries([
|
||||
manifest(1, '/a/manifest.json'),
|
||||
{ id: 'x', manifestPath: '/b/manifest.json' },
|
||||
{ id: 2 },
|
||||
null,
|
||||
])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Figma 桌面版「已导入的开发插件」清单的纯逻辑:算新 id、查重、造条目。
|
||||
*
|
||||
* 清单落在 `~/Library/Application Support/Figma/settings.json` 的
|
||||
* `localFileExtensions` 数组里,一条 manifest 条目长这样:
|
||||
*
|
||||
* ```json
|
||||
* { "id": 9, "manifestPath": "/abs/.../manifest.json",
|
||||
* "lastKnownName": "Xxx Design Lab", "lastKnownPluginId": "",
|
||||
* "fileMetadata": { "type": "manifest", "codeFileId": 10, "uiFileIds": [] } }
|
||||
* ```
|
||||
*
|
||||
* 坑:id 是**整个数组共享**的递增计数器,manifest 条目占一个 id,Figma 之后
|
||||
* 给它的 code.js 建条目时占 `fileMetadata.codeFileId` 那个 id。所以算下一个
|
||||
* id 时不能只看已有条目的 id,还得把被 codeFileId / manifestFileId 预定掉的
|
||||
* 号算进去,否则新插件会和 Figma 迟早要建的 code 条目撞号。
|
||||
*
|
||||
* 这里不碰文件系统,好单测;读写与关 Figma 在 importPlugin.ts。
|
||||
*/
|
||||
|
||||
export interface FigmaFileMetadata {
|
||||
type: 'manifest' | 'code' | string
|
||||
/** manifest 条目专有:预留给同插件 code.js 条目的 id。 */
|
||||
codeFileId?: number
|
||||
/** code 条目专有:反指所属 manifest 条目的 id。 */
|
||||
manifestFileId?: number
|
||||
uiFileIds?: number[]
|
||||
}
|
||||
|
||||
export interface FigmaLocalFileExtension {
|
||||
id: number
|
||||
manifestPath: string
|
||||
lastKnownName?: string
|
||||
lastKnownPluginId?: string
|
||||
fileMetadata?: FigmaFileMetadata
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 数组里混着 Figma 写的杂项,只把「有数字 id + 字符串 manifestPath」的当条目。 */
|
||||
export function asExtensionEntries(value: unknown): FigmaLocalFileExtension[] {
|
||||
if (!Array.isArray(value))
|
||||
return []
|
||||
return value.filter((e): e is FigmaLocalFileExtension =>
|
||||
!!e
|
||||
&& typeof e === 'object'
|
||||
&& typeof (e as FigmaLocalFileExtension).id === 'number'
|
||||
&& typeof (e as FigmaLocalFileExtension).manifestPath === 'string',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一个可用 id:已有条目 id 与被 codeFileId / manifestFileId 预定的 id 取最大再 +1。
|
||||
* 空清单从 1 开始(Figma 自己也是从 1 起)。
|
||||
*/
|
||||
export function nextExtensionId(entries: FigmaLocalFileExtension[]): number {
|
||||
let max = 0
|
||||
for (const e of entries) {
|
||||
for (const n of [e.id, e.fileMetadata?.codeFileId, e.fileMetadata?.manifestFileId]) {
|
||||
if (typeof n === 'number' && Number.isFinite(n) && n > max)
|
||||
max = n
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
/** 按 manifestPath 精确查重;已导入过就返回那条,用来跳过写入。 */
|
||||
export function findManifestEntry(
|
||||
entries: FigmaLocalFileExtension[],
|
||||
manifestPath: string,
|
||||
): FigmaLocalFileExtension | undefined {
|
||||
return entries.find(e => e.manifestPath === manifestPath)
|
||||
}
|
||||
|
||||
/** 造一条 manifest 条目;code 条目留给 Figma 自己建,这里只把 id 号占住。 */
|
||||
export function buildManifestEntry(opts: {
|
||||
id: number
|
||||
manifestPath: string
|
||||
name: string
|
||||
}): FigmaLocalFileExtension {
|
||||
return {
|
||||
id: opts.id,
|
||||
manifestPath: opts.manifestPath,
|
||||
lastKnownName: opts.name,
|
||||
lastKnownPluginId: '',
|
||||
fileMetadata: {
|
||||
type: 'manifest',
|
||||
codeFileId: opts.id + 1,
|
||||
uiFileIds: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
/** 追加后的完整条目数组;已存在时原样返回。 */
|
||||
entries: FigmaLocalFileExtension[]
|
||||
/** false = 已导入过,调用方跳过写文件直接拉起 Figma。 */
|
||||
added: boolean
|
||||
/** 这条插件在 Figma 里的显示名(新建用传入名,已存在用清单里记的名)。 */
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 查重 → 算 id → 追加,一次算完,写文件的那端只管落盘。 */
|
||||
export function appendManifestEntry(
|
||||
entries: FigmaLocalFileExtension[],
|
||||
opts: { manifestPath: string, name: string },
|
||||
): AppendResult {
|
||||
const existing = findManifestEntry(entries, opts.manifestPath)
|
||||
if (existing)
|
||||
return { entries, added: false, name: existing.lastKnownName || opts.name }
|
||||
const entry = buildManifestEntry({
|
||||
id: nextExtensionId(entries),
|
||||
manifestPath: opts.manifestPath,
|
||||
name: opts.name,
|
||||
})
|
||||
return { entries: [...entries, entry], added: true, name: opts.name }
|
||||
}
|
||||
@@ -45,6 +45,19 @@ export function isValidSpxFilePath(v: unknown): v is string {
|
||||
&& v !== '…'
|
||||
}
|
||||
|
||||
/**
|
||||
* prototypeFile 指向 Figma 插件的 manifest.json,规则与 isValidSpxFilePath
|
||||
* 同款,只是后缀换成 .json(可以是仓库相对路径,也可以是绝对路径)。
|
||||
*/
|
||||
export function isValidPrototypeFilePath(v: unknown): v is string {
|
||||
return typeof v === 'string'
|
||||
&& v.length > 0
|
||||
&& v.includes('/')
|
||||
&& v.endsWith('.json')
|
||||
&& v !== '...'
|
||||
&& v !== '…'
|
||||
}
|
||||
|
||||
function isValidPrDiffFilePath(v: unknown): v is string {
|
||||
return typeof v === 'string'
|
||||
&& /^docs\/pr-diff\/\S+\.md$/.test(v)
|
||||
@@ -73,6 +86,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
prototypeProfilePath?: string
|
||||
specFile?: string
|
||||
planFile?: string
|
||||
prototypeFile?: string
|
||||
prDiffFile?: string
|
||||
pr?: string
|
||||
prMerged?: boolean
|
||||
@@ -121,6 +135,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
prototypeProfilePath?: unknown
|
||||
specFile?: unknown
|
||||
planFile?: unknown
|
||||
prototypeFile?: unknown
|
||||
prDiffFile?: unknown
|
||||
pr?: unknown
|
||||
prMerged?: unknown
|
||||
@@ -155,6 +170,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
: undefined
|
||||
const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : undefined
|
||||
const planFile = isValidSpxFilePath(obj.planFile) ? obj.planFile : undefined
|
||||
const prototypeFile = isValidPrototypeFilePath(obj.prototypeFile) ? obj.prototypeFile : undefined
|
||||
const prDiffFile = isValidPrDiffFilePath(obj.prDiffFile) ? obj.prDiffFile : undefined
|
||||
const pr = typeof obj.pr === 'string' && obj.pr.length > 0
|
||||
? obj.pr
|
||||
@@ -205,6 +221,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
prototypeProfilePath,
|
||||
specFile,
|
||||
planFile,
|
||||
prototypeFile,
|
||||
prDiffFile,
|
||||
pr,
|
||||
prMerged,
|
||||
@@ -348,6 +365,7 @@ async function buildIssue(opts: {
|
||||
prototypeProfilePath,
|
||||
specFile,
|
||||
planFile,
|
||||
prototypeFile,
|
||||
prDiffFile,
|
||||
pr,
|
||||
prMerged: persistedMerged,
|
||||
@@ -412,6 +430,7 @@ async function buildIssue(opts: {
|
||||
...(prototypeProfilePath ? { prototypeProfilePath } : {}),
|
||||
...(specFile ? { specFile } : {}),
|
||||
...(planFile ? { planFile } : {}),
|
||||
...(prototypeFile ? { prototypeFile } : {}),
|
||||
...(prDiffFile ? { prDiffFile } : {}),
|
||||
...(pr ? { pr } : {}),
|
||||
...(prMerged !== undefined ? { prMerged } : {}),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
|
||||
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
|
||||
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
|
||||
*/
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
|
||||
|
||||
/**
|
||||
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface Issue {
|
||||
* `.claude/plans/<stem>.md`.
|
||||
*/
|
||||
planFile?: string
|
||||
/**
|
||||
* 原型会话产出的 Figma 插件 `manifest.json` 路径(仓库相对或绝对)。
|
||||
* cc 画完原型后用 `spx issue marker --type prototype` 挂上来。
|
||||
*/
|
||||
prototypeFile?: string
|
||||
/** Optional workspace-relative path to the generated PR diff summary. */
|
||||
prDiffFile?: string
|
||||
/** PR number associated with this issue (set after webhook fires). */
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
||||
import { deleteToken, getToken } from '../auth/secrets'
|
||||
import { importPluginIntoFigma } from '../figma/importPlugin'
|
||||
import { detectRepo } from '../git/remote'
|
||||
import {
|
||||
GiteaApiError,
|
||||
@@ -464,6 +465,15 @@ export class KanbanWebviewPanel {
|
||||
void issues.handleOpenFile(this, msg.path)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'prototype/open-in-figma') {
|
||||
const root = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!root) {
|
||||
void window.showErrorMessage('请先打开一个工作区文件夹')
|
||||
return
|
||||
}
|
||||
void importPluginIntoFigma({ issueNumber: msg.issueNumber, manifestPath: msg.path, workspaceRoot: root })
|
||||
return
|
||||
}
|
||||
if (msg.type === 'issue/implement') {
|
||||
void this.handleImplement(msg.issueNumber, msg.planFile, msg.profilePath, msg.sessionId)
|
||||
return
|
||||
@@ -997,7 +1007,6 @@ export class KanbanWebviewPanel {
|
||||
webhookPort: s.webhookPort,
|
||||
webhookSecret: s.webhookSecret,
|
||||
brainstormPrompt: s.brainstormPrompt,
|
||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||
implementPlanPrompt: s.implementPlanPrompt,
|
||||
autoReview: s.autoReview,
|
||||
reviewPrompt: s.reviewPrompt,
|
||||
@@ -1088,7 +1097,6 @@ export class KanbanWebviewPanel {
|
||||
webhookPort: s.webhookPort,
|
||||
webhookSecret: s.webhookSecret,
|
||||
brainstormPrompt: s.brainstormPrompt,
|
||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||
implementPlanPrompt: s.implementPlanPrompt,
|
||||
autoReview: s.autoReview,
|
||||
reviewPrompt: s.reviewPrompt,
|
||||
@@ -1135,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,
|
||||
@@ -2314,7 +2315,8 @@ export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string)
|
||||
})
|
||||
return
|
||||
}
|
||||
const abs = path.join(workspaceRoot, relPath)
|
||||
// 多数字段存的是仓库相对路径,但 prototypeFile 允许绝对路径(插件可能在仓库外)。
|
||||
const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath)
|
||||
const isMarkdown = abs.toLowerCase().endsWith('.md')
|
||||
try {
|
||||
if (isMarkdown) {
|
||||
|
||||
@@ -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,26 +978,16 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
|
||||
return
|
||||
}
|
||||
|
||||
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
|
||||
let brainstormProfilePath: string | undefined
|
||||
let sessionId: 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
|
||||
}
|
||||
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 })
|
||||
const prompt = renderBrainstormPrompt(panel.context, {
|
||||
state,
|
||||
issueNumber: panel.issueRefLabel(issueNumber),
|
||||
})
|
||||
const terminalName = `issue-${issueNumber}-规划`
|
||||
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
||||
const linked = panel.newIssueTerminals.get(issueNumber)
|
||||
@@ -1129,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)
|
||||
@@ -1178,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}`,
|
||||
@@ -1293,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,无法启动审查会话')
|
||||
@@ -1319,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,
|
||||
})
|
||||
@@ -1496,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)
|
||||
@@ -1531,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,7 +23,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -113,7 +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,
|
||||
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
|
||||
autoReview: payload.autoReview,
|
||||
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
|
||||
@@ -148,7 +146,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
||||
webhookPort: payload.webhookPort,
|
||||
webhookSecret: payload.webhookSecret.trim(),
|
||||
brainstormPrompt: payload.brainstormPrompt,
|
||||
brainstormContinuePrompt: payload.brainstormContinuePrompt,
|
||||
implementPlanPrompt: payload.implementPlanPrompt,
|
||||
autoReview: payload.autoReview,
|
||||
reviewPrompt: payload.reviewPrompt,
|
||||
@@ -260,7 +257,6 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
||||
webhookPort: s.webhookPort,
|
||||
webhookSecret: s.webhookSecret,
|
||||
brainstormPrompt: s.brainstormPrompt,
|
||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||
implementPlanPrompt: s.implementPlanPrompt,
|
||||
autoReview: s.autoReview,
|
||||
reviewPrompt: s.reviewPrompt,
|
||||
|
||||
@@ -52,7 +52,7 @@ export type ExtensionToWebview
|
||||
| { type: 'issues/loaded' }
|
||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||
| { type: 'session/tab-settled', sessionId: string }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
@@ -72,7 +72,6 @@ export type ExtensionToWebview
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -157,7 +156,6 @@ export type WebviewToExtension
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -203,6 +201,7 @@ export type WebviewToExtension
|
||||
| { type: 'session/start-review', issueNumber: number }
|
||||
| { type: 'session/start-test', issueNumber: number }
|
||||
| { type: 'session/start-prototype', issueNumber: number }
|
||||
| { type: 'prototype/open-in-figma', issueNumber: number, path: string }
|
||||
| { type: 'editor/open-file', path: string }
|
||||
| { type: 'profiles/list' }
|
||||
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: 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,19 +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} 号工单。达成共识后用 ecc:spec 写 .claude/specs/<stem>.md,我确认后用 ecc:plan 出 .claude/plans/<stem>.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。`
|
||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{{planFile}} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{{issueNumber}}"。严禁合并 PR。`
|
||||
|
||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
|
||||
const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {{issueNumber}} 号工单({{prRef}})。验收目标以工单正文为准,不要再问验收哪个。`
|
||||
|
||||
const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。验收目标以工单正文为准,不要再问验收哪个。`
|
||||
const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{{prNumber}} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 <!-- spx:review=1 -->。`
|
||||
|
||||
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 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||
|
||||
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' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
||||
type PromptName = 'brainstorm' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
||||
|
||||
/**
|
||||
* Read a default prompt template from `${extensionPath}/prompts/${name}.md`.
|
||||
@@ -55,7 +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 'implement-plan': return FALLBACK_IMPLEMENT_PLAN_PROMPT
|
||||
case 'review': return FALLBACK_REVIEW_PROMPT
|
||||
case 'test': return FALLBACK_TEST_PROMPT
|
||||
@@ -76,26 +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}` placeholder.
|
||||
*/
|
||||
brainstormContinuePrompt: 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
|
||||
@@ -274,7 +277,6 @@ function defaults(ctx: ExtensionContext): Settings {
|
||||
webhookPort: DEFAULT_WEBHOOK_PORT,
|
||||
webhookSecret: '',
|
||||
brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
||||
brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'),
|
||||
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
|
||||
autoReview: true,
|
||||
reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'),
|
||||
@@ -324,9 +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 implementPlanPrompt = typeof stored.implementPlanPrompt === 'string' && stored.implementPlanPrompt.length > 0
|
||||
? stored.implementPlanPrompt
|
||||
: base.implementPlanPrompt
|
||||
@@ -444,7 +443,6 @@ export function getSettings(ctx: ExtensionContext): Settings {
|
||||
webhookPort,
|
||||
webhookSecret,
|
||||
brainstormPrompt,
|
||||
brainstormContinuePrompt,
|
||||
implementPlanPrompt,
|
||||
autoReview,
|
||||
reviewPrompt,
|
||||
@@ -507,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'
|
||||
@@ -48,6 +48,38 @@ export interface PendingHook {
|
||||
feature: string
|
||||
}
|
||||
|
||||
/**
|
||||
* issue body 里的 spx marker → state JSON 字段。三种 marker 走同一套同步
|
||||
* 逻辑,这张表是唯一真源(与 cli 的 markerTypeToStateField 对应)。
|
||||
*
|
||||
* 路径必须含 `/` 且以对应后缀结尾:cc 在头脑风暴阶段会往 body 里写
|
||||
* `<!-- spx:spec=... -->` 这种占位说明文,不卡死就会被当成真路径存进去。
|
||||
*/
|
||||
const BODY_MARKERS = [
|
||||
{ type: 'spec', field: 'specFile', ext: 'md' },
|
||||
{ type: 'plan', field: 'planFile', ext: 'md' },
|
||||
{ type: 'prototype', field: 'prototypeFile', ext: 'json' },
|
||||
] as const
|
||||
|
||||
/** 本次从 body 里扫出来的 marker 值,按 state JSON 字段名索引。 */
|
||||
type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string }
|
||||
|
||||
function parseBodyMarkers(body: string): MarkerPatch {
|
||||
const out: MarkerPatch = {}
|
||||
for (const { type, field, ext } of BODY_MARKERS) {
|
||||
const re = new RegExp(String.raw`<!--\s*spx:${type}=([^\s/>]*\/[^\s>]*\.${ext})\s*-->`)
|
||||
const m = body.match(re)
|
||||
if (m)
|
||||
out[field] = m[1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 日志用的一行摘要,例如 `spec=a.md plan=<空> prototype=<空>`。 */
|
||||
function describeMarkers(patch: MarkerPatch): string {
|
||||
return BODY_MARKERS.map(({ type, field }) => `${type}=${patch[field] ?? '<空>'}`).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link PrWebhookEvent} whose `issueNumber` has been resolved (either from
|
||||
* the legacy `/webhook/:n` path or via PR-body / branch heuristics).
|
||||
@@ -779,11 +811,10 @@ class WebhookCoordinator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue `edited` payload: cc updates the issue body with `<!-- spx:spec=... -->`
|
||||
* / `<!-- spx:plan=... -->` annotation lines as it discovers spec/plan files.
|
||||
* We scan the body for those markers, diff against the state-JSON comment,
|
||||
* and (if anything changed) merge the new paths in + patch the open panel
|
||||
* so the detail view refreshes without a full kanban reload.
|
||||
* Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->`
|
||||
* / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。
|
||||
* 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对,
|
||||
* 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。
|
||||
*/
|
||||
private async handleIssueEdited(event: IssueWebhookEvent): Promise<void> {
|
||||
if (!this.ctx)
|
||||
@@ -817,17 +848,12 @@ class WebhookCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// 路径必须含 `/` 且以 `.md` 结尾,避免 cc 写的 `<!-- spx:spec=... -->` 占位被当真。
|
||||
const specMatch = event.body.match(/<!--\s*spx:spec=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
|
||||
const planMatch = event.body.match(/<!--\s*spx:plan=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
|
||||
const specFile = specMatch ? specMatch[1] : undefined
|
||||
const planFile = planMatch ? planMatch[1] : undefined
|
||||
|
||||
if (!specFile && !planFile) {
|
||||
const found = parseBodyMarkers(event.body)
|
||||
if (Object.keys(found).length === 0) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'webhook',
|
||||
message: `issue=#${event.issueNumber} edited body 无 spx:spec/spx:plan 注释,跳过`,
|
||||
message: `issue=#${event.issueNumber} edited body 无 spx marker 注释,跳过`,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -852,15 +878,19 @@ class WebhookCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
const currentSpec = typeof currentState.specFile === 'string' ? currentState.specFile : undefined
|
||||
const currentPlan = typeof currentState.planFile === 'string' ? currentState.planFile : undefined
|
||||
|
||||
// cc only adds or updates; it never deletes. Treat an absent marker
|
||||
// (undefined) as "no signal", not "clear" — that way deleting a spec
|
||||
// line by mistake won't lose the existing pointer.
|
||||
const nextSpec = specFile ?? currentSpec
|
||||
const nextPlan = planFile ?? currentPlan
|
||||
if (currentSpec === nextSpec && currentPlan === nextPlan) {
|
||||
// cc 只增不删:body 里没有某个 marker 当「没信号」,不当「清空」,
|
||||
// 免得手滑删掉一行就把已有指针丢了。
|
||||
const next: MarkerPatch = {}
|
||||
let changed = false
|
||||
for (const { field } of BODY_MARKERS) {
|
||||
const current = typeof currentState[field] === 'string' ? currentState[field] as string : undefined
|
||||
const value = found[field] ?? current
|
||||
if (value)
|
||||
next[field] = value
|
||||
if (value !== current)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) {
|
||||
// Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment
|
||||
// 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠
|
||||
// coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去,
|
||||
@@ -868,24 +898,17 @@ class WebhookCoordinator {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'webhook',
|
||||
message: `issue=#${event.issueNumber} edited spec/plan 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (spec=${nextSpec ?? '<空>'} plan=${nextPlan ?? '<空>'})`,
|
||||
message: `issue=#${event.issueNumber} edited marker 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (${describeMarkers(next)})`,
|
||||
})
|
||||
this.activePanel?.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber: event.issueNumber,
|
||||
patch: next,
|
||||
})
|
||||
if (this.activePanel) {
|
||||
const patch: { specFile?: string, planFile?: string } = {}
|
||||
if (nextSpec)
|
||||
patch.specFile = nextSpec
|
||||
if (nextPlan)
|
||||
patch.planFile = nextPlan
|
||||
this.activePanel.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber: event.issueNumber,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// spec/plan 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
|
||||
// marker 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
|
||||
// 额外认规划终端。observe 机器只把新值推给本机 webview。
|
||||
const ownership = await this.resolveOwnership(
|
||||
event.issueNumber,
|
||||
@@ -893,27 +916,14 @@ class WebhookCoordinator {
|
||||
['实施', '测试', '规划'],
|
||||
)
|
||||
if (ownership === 'observe') {
|
||||
if (this.activePanel) {
|
||||
const patch: { specFile?: string, planFile?: string } = {}
|
||||
if (specFile)
|
||||
patch.specFile = specFile
|
||||
if (planFile)
|
||||
patch.planFile = planFile
|
||||
this.activePanel.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber: event.issueNumber,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
this.activePanel?.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber: event.issueNumber,
|
||||
patch: found,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const extra: Record<string, unknown> = {}
|
||||
if (specFile)
|
||||
extra.specFile = specFile
|
||||
if (planFile)
|
||||
extra.planFile = planFile
|
||||
|
||||
try {
|
||||
await mergeStateJsonComment({
|
||||
host: remote.host,
|
||||
@@ -921,7 +931,7 @@ class WebhookCoordinator {
|
||||
repo: remote.repo,
|
||||
token,
|
||||
issueNumber: event.issueNumber,
|
||||
extra,
|
||||
extra: found,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
@@ -938,22 +948,15 @@ class WebhookCoordinator {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'webhook',
|
||||
message: `issue=#${event.issueNumber} edited spec/plan 已同步`,
|
||||
details: `spec=${specFile ?? '<未变>'} plan=${planFile ?? '<未变>'}`,
|
||||
message: `issue=#${event.issueNumber} edited marker 已同步`,
|
||||
details: describeMarkers(found),
|
||||
})
|
||||
|
||||
if (this.activePanel) {
|
||||
const patch: { specFile?: string, planFile?: string } = {}
|
||||
if (specFile)
|
||||
patch.specFile = specFile
|
||||
if (planFile)
|
||||
patch.planFile = planFile
|
||||
this.activePanel.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber: event.issueNumber,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
this.activePanel?.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber: event.issueNumber,
|
||||
patch: found,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1620,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',
|
||||
@@ -1657,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,
|
||||
|
||||
@@ -75,6 +75,7 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
|
||||
prototypeProfilePath: str(state.prototypeProfilePath),
|
||||
specFile: str(state.specFile),
|
||||
planFile: str(state.planFile),
|
||||
prototypeFile: str(state.prototypeFile),
|
||||
prDiffFile: str(state.prDiffFile),
|
||||
pr: str(state.pr),
|
||||
branch: str(state.branch),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
|
||||
export const STATE_MARKER = '<!--spx-state-->'
|
||||
|
||||
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
|
||||
|
||||
/**
|
||||
* Parse a comment body into a state object, or null if it isn't a state
|
||||
|
||||
@@ -49,6 +49,7 @@ export function App() {
|
||||
resumePrototypeSession,
|
||||
focusSession,
|
||||
openFile,
|
||||
openPrototypeInFigma,
|
||||
implement,
|
||||
generatePrDiffSummary,
|
||||
isPrDiffSummaryRunning,
|
||||
@@ -386,6 +387,7 @@ export function App() {
|
||||
onStartPrototypeSession={startPrototypeSession}
|
||||
onResumePrototypeSession={resumePrototypeSession}
|
||||
onOpenFile={openFile}
|
||||
onOpenPrototypeInFigma={openPrototypeInFigma}
|
||||
onImplement={implement}
|
||||
onOpenPr={openPr}
|
||||
onGeneratePrDiffSummary={generatePrDiffSummary}
|
||||
@@ -540,7 +542,6 @@ export function App() {
|
||||
initialWebhookPort={settings?.webhookPort ?? 17421}
|
||||
initialWebhookSecret={settings?.webhookSecret ?? ''}
|
||||
initialBrainstormPrompt={settings?.brainstormPrompt ?? ''}
|
||||
initialBrainstormContinuePrompt={settings?.brainstormContinuePrompt ?? ''}
|
||||
initialImplementPlanPrompt={settings?.implementPlanPrompt ?? ''}
|
||||
initialAutoReview={settings?.autoReview ?? true}
|
||||
initialReviewPrompt={settings?.reviewPrompt ?? ''}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface BottomTabsProps {
|
||||
onStartPrototypeSession: (issueNumber: number) => void
|
||||
onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onOpenPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||
onOpenPr: (pr: string) => void
|
||||
onGeneratePrDiffSummary: (issueNumber: number) => void
|
||||
@@ -140,6 +141,7 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
onStartPrototypeSession={props.onStartPrototypeSession}
|
||||
onResumePrototypeSession={props.onResumePrototypeSession}
|
||||
onOpenFile={props.onOpenFile}
|
||||
onOpenPrototypeInFigma={props.onOpenPrototypeInFigma}
|
||||
onImplement={props.onImplement}
|
||||
onOpenPr={props.onOpenPr}
|
||||
onGeneratePrDiffSummary={props.onGeneratePrDiffSummary}
|
||||
|
||||
@@ -69,6 +69,11 @@ interface IssueDetailPanelProps {
|
||||
onStartPrototypeSession: (issueNumber: number) => void
|
||||
/** Open a workspace-relative file in the editor. */
|
||||
onOpenFile: (path: string) => void
|
||||
/**
|
||||
* 把工单的 Figma 插件 manifest 导进本机 Figma 桌面版并拉起 Figma。
|
||||
* 「原型插件路径」行尾那个按钮。
|
||||
*/
|
||||
onOpenPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||
/** Kick off the end-to-end implementation flow for the given plan file. */
|
||||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||
/** Open the gitea PR page in the browser. */
|
||||
@@ -151,6 +156,7 @@ export function IssueDetailPanel({
|
||||
onStartTestSession,
|
||||
onStartPrototypeSession,
|
||||
onOpenFile,
|
||||
onOpenPrototypeInFigma,
|
||||
onImplement,
|
||||
onOpenPr,
|
||||
onGeneratePrDiffSummary,
|
||||
@@ -567,6 +573,24 @@ export function IssueDetailPanel({
|
||||
}]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
key: 'prototypeFile',
|
||||
label: '原型插件路径',
|
||||
type: 'file-link',
|
||||
description: '双击在编辑器打开 Figma 插件的 manifest.json;点右侧按钮把插件导入本机 Figma 并打开;cc 通过 issue body 的 <!-- spx:prototype=路径 --> 注释同步',
|
||||
onOpen: (p: string) => onOpenFile(p),
|
||||
// 没挂原型插件时整个按钮不渲染,行只显示空态。
|
||||
secondaryActions: issue?.prototypeFile
|
||||
? [{
|
||||
icon: <ExternalLink className="size-3.5" />,
|
||||
title: '在 Figma 中导入并打开',
|
||||
onClick: () => {
|
||||
if (issue?.prototypeFile)
|
||||
onOpenPrototypeInFigma(issue.number, issue.prototypeFile)
|
||||
},
|
||||
}]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
key: 'pr',
|
||||
label: '合并请求',
|
||||
@@ -641,7 +665,7 @@ export function IssueDetailPanel({
|
||||
],
|
||||
},
|
||||
],
|
||||
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onResumePrototypeSession, onStartPrototypeSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles, isSessionTabPending, onStartReviewSession],
|
||||
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onResumePrototypeSession, onStartPrototypeSession, onOpenFile, onOpenPrototypeInFigma, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles, isSessionTabPending, onStartReviewSession],
|
||||
)
|
||||
|
||||
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
|
||||
@@ -671,6 +695,7 @@ export function IssueDetailPanel({
|
||||
prototypeProfilePath: matchProfileOption(issue.prototypeProfilePath, profiles) || null,
|
||||
specFile: issue.specFile ?? null,
|
||||
planFile: issue.planFile ?? null,
|
||||
prototypeFile: issue.prototypeFile ?? null,
|
||||
prDiffFile: issue.prDiffFile ?? null,
|
||||
pr: issue.pr
|
||||
? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
|
||||
|
||||
@@ -21,7 +21,6 @@ interface SubmitValues {
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -77,7 +76,6 @@ export interface SettingsModalProps {
|
||||
initialWebhookPort: number
|
||||
initialWebhookSecret: string
|
||||
initialBrainstormPrompt: string
|
||||
initialBrainstormContinuePrompt: string
|
||||
initialImplementPlanPrompt: string
|
||||
initialAutoReview: boolean
|
||||
initialReviewPrompt: string
|
||||
@@ -169,7 +167,6 @@ export function SettingsModal({
|
||||
initialWebhookPort,
|
||||
initialWebhookSecret,
|
||||
initialBrainstormPrompt,
|
||||
initialBrainstormContinuePrompt,
|
||||
initialImplementPlanPrompt,
|
||||
initialAutoReview,
|
||||
initialReviewPrompt,
|
||||
@@ -213,7 +210,6 @@ export function SettingsModal({
|
||||
const [webhookPort, setWebhookPort] = useState<string>(String(initialWebhookPort))
|
||||
const [webhookSecret, setWebhookSecret] = useState(initialWebhookSecret)
|
||||
const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt)
|
||||
const [brainstormContinuePrompt, setBrainstormContinuePrompt] = useState(initialBrainstormContinuePrompt)
|
||||
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
|
||||
const [autoReview, setAutoReview] = useState(initialAutoReview)
|
||||
const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt)
|
||||
@@ -259,7 +255,6 @@ export function SettingsModal({
|
||||
setWebhookPort(String(initialWebhookPort))
|
||||
setWebhookSecret(initialWebhookSecret)
|
||||
setBrainstormPrompt(initialBrainstormPrompt)
|
||||
setBrainstormContinuePrompt(initialBrainstormContinuePrompt)
|
||||
setImplementPlanPrompt(initialImplementPlanPrompt)
|
||||
setAutoReview(initialAutoReview)
|
||||
setReviewPrompt(initialReviewPrompt)
|
||||
@@ -353,7 +348,6 @@ export function SettingsModal({
|
||||
webhookPort: portNum,
|
||||
webhookSecret: webhookSecret.trim(),
|
||||
brainstormPrompt,
|
||||
brainstormContinuePrompt,
|
||||
implementPlanPrompt,
|
||||
autoReview,
|
||||
reviewPrompt,
|
||||
@@ -973,39 +967,34 @@ export function SettingsModal({
|
||||
label="头脑风暴提示词"
|
||||
hint={(
|
||||
<>
|
||||
可用占位符:
|
||||
<code>{'{userRequest}'}</code>
|
||||
Handlebars 模板。
|
||||
<code>{'{{#if issueNumber}}'}</code>
|
||||
分支 = 对已有工单续聊(
|
||||
<code>{'{{issueNumber}}'}</code>
|
||||
,里面再用
|
||||
<code>{'{{#if prototypeFile}}'}</code>
|
||||
嵌原型段落、
|
||||
<code>{'{{#if prototypeSessionId}}'}</code>
|
||||
控制原型会话 id 那行);
|
||||
<code>{'{{else}}'}</code>
|
||||
分支 = 新建工单(
|
||||
<code>{'{{userRequest}}'}</code>
|
||||
、
|
||||
<code>{'{nonce}'}</code>
|
||||
。同时承担:建工单初始指令 + 会话内 spec/plan 路径写回 issue body 的持续约定。
|
||||
<code>{'{{nonce}}'}</code>
|
||||
)。工单 state JSON 的字段(
|
||||
<code>{'{{planFile}}'}</code>
|
||||
、
|
||||
<code>{'{{branch}}'}</code>
|
||||
等)都能直接用。
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
rows={12}
|
||||
rows={16}
|
||||
value={brainstormPrompt}
|
||||
onChange={e => setBrainstormPrompt(e.target.value)}
|
||||
className={`${inputClass} resize-y font-mono`}
|
||||
placeholder="头脑风暴 + 建工单 + 持续约定提示词"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="头脑风暴续聊提示词"
|
||||
hint={(
|
||||
<>
|
||||
可用占位符
|
||||
{' '}
|
||||
<code>{'{issueNumber}'}</code>
|
||||
;用于对已有工单继续讨论 / 写 spec/plan 的会话初始指令。
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={brainstormContinuePrompt}
|
||||
onChange={e => setBrainstormContinuePrompt(e.target.value)}
|
||||
className={`${inputClass} resize-y font-mono`}
|
||||
placeholder="新建工单 + 续聊 + 原型段落,一份模板按条件分叉"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1013,10 +1002,10 @@ export function SettingsModal({
|
||||
label="实施提示词"
|
||||
hint={(
|
||||
<>
|
||||
可用占位符:
|
||||
<code>{'{planFile}'}</code>
|
||||
可用变量(Handlebars):
|
||||
<code>{'{{planFile}}'}</code>
|
||||
、
|
||||
<code>{'{issueNumber}'}</code>
|
||||
<code>{'{{issueNumber}}'}</code>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -1032,8 +1021,8 @@ export function SettingsModal({
|
||||
label="审查提示词"
|
||||
hint={(
|
||||
<>
|
||||
可用占位符:
|
||||
<code>{'{prNumber}'}</code>
|
||||
可用变量(Handlebars):
|
||||
<code>{'{{prNumber}}'}</code>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -1049,10 +1038,10 @@ export function SettingsModal({
|
||||
label="测试提示词"
|
||||
hint={(
|
||||
<>
|
||||
可用占位符:
|
||||
<code>{'{issueNumber}'}</code>
|
||||
可用变量(Handlebars):
|
||||
<code>{'{{issueNumber}}'}</code>
|
||||
、
|
||||
<code>{'{prRef}'}</code>
|
||||
<code>{'{{prRef}}'}</code>
|
||||
(worktree 里是分支,主仓库里是已合并的 PR)
|
||||
</>
|
||||
)}
|
||||
@@ -1069,8 +1058,8 @@ export function SettingsModal({
|
||||
label="原型提示词"
|
||||
hint={(
|
||||
<>
|
||||
可用占位符:
|
||||
<code>{'{issueNumber}'}</code>
|
||||
可用变量(Handlebars):
|
||||
<code>{'{{issueNumber}}'}</code>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface SettingsValues {
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -81,7 +80,6 @@ export interface SettingsOverlayState {
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -200,6 +198,8 @@ export interface UseIssuesResult {
|
||||
resumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||
focusSession: (issueNumber: number) => void
|
||||
openFile: (path: string) => void
|
||||
/** 把工单的 Figma 插件导入本机 Figma 桌面版并拉起 Figma。 */
|
||||
openPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||
implement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||
generatePrDiffSummary: (issueNumber: number) => void
|
||||
isPrDiffSummaryRunning: (issueNumber: number) => boolean
|
||||
@@ -430,7 +430,6 @@ export function useIssues(): UseIssuesResult {
|
||||
webhookPort: values.webhookPort,
|
||||
webhookSecret: values.webhookSecret,
|
||||
brainstormPrompt: values.brainstormPrompt,
|
||||
brainstormContinuePrompt: values.brainstormContinuePrompt,
|
||||
implementPlanPrompt: values.implementPlanPrompt,
|
||||
autoReview: values.autoReview,
|
||||
reviewPrompt: values.reviewPrompt,
|
||||
@@ -588,6 +587,10 @@ export function useIssues(): UseIssuesResult {
|
||||
postMessage({ type: 'editor/open-file', path })
|
||||
}, [])
|
||||
|
||||
const openPrototypeInFigma = useCallback((issueNumber: number, path: string): void => {
|
||||
postMessage({ type: 'prototype/open-in-figma', issueNumber, path })
|
||||
}, [])
|
||||
|
||||
const implement = useCallback((
|
||||
issueNumber: number,
|
||||
planFile: string,
|
||||
@@ -921,7 +924,6 @@ export function useIssues(): UseIssuesResult {
|
||||
webhookPort: msg.webhookPort,
|
||||
webhookSecret: msg.webhookSecret,
|
||||
brainstormPrompt: msg.brainstormPrompt,
|
||||
brainstormContinuePrompt: msg.brainstormContinuePrompt,
|
||||
implementPlanPrompt: msg.implementPlanPrompt,
|
||||
autoReview: msg.autoReview,
|
||||
reviewPrompt: msg.reviewPrompt,
|
||||
@@ -1037,5 +1039,5 @@ export function useIssues(): UseIssuesResult {
|
||||
return cleanup
|
||||
}, [clearPrDiffSummaryRunning, markHandoffRunning])
|
||||
|
||||
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearBrainstormSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
||||
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearBrainstormSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export type ExtensionToWebview
|
||||
| { type: 'issues/loaded' }
|
||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||
| { type: 'session/tab-settled', sessionId: string }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
@@ -103,7 +103,6 @@ export type ExtensionToWebview
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -188,7 +187,6 @@ export type WebviewToExtension
|
||||
webhookPort: number
|
||||
webhookSecret: string
|
||||
brainstormPrompt: string
|
||||
brainstormContinuePrompt: string
|
||||
implementPlanPrompt: string
|
||||
autoReview: boolean
|
||||
reviewPrompt: string
|
||||
@@ -234,6 +232,7 @@ export type WebviewToExtension
|
||||
| { type: 'session/start-review', issueNumber: number }
|
||||
| { type: 'session/start-test', issueNumber: number }
|
||||
| { type: 'session/start-prototype', issueNumber: number }
|
||||
| { type: 'prototype/open-in-figma', issueNumber: number, path: string }
|
||||
| { type: 'editor/open-file', path: string }
|
||||
| { type: 'profiles/list' }
|
||||
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface Issue {
|
||||
* `.claude/plans/<stem>.md`.
|
||||
*/
|
||||
planFile?: string
|
||||
/**
|
||||
* 原型会话产出的 Figma 插件 `manifest.json` 路径(仓库相对或绝对)。
|
||||
* cc 画完原型后用 `spx issue marker --type prototype` 挂上来。
|
||||
*/
|
||||
prototypeFile?: string
|
||||
/** Optional workspace-relative path to the generated PR diff summary. */
|
||||
prDiffFile?: string
|
||||
/** PR number associated with this issue (set after webhook fires). */
|
||||
|
||||
Reference in new issue
Block a user