diff --git a/vscode/cli/cmd/spx/main.go b/vscode/cli/cmd/spx/main.go index 00fbf11..a26dff5 100644 --- a/vscode/cli/cmd/spx/main.go +++ b/vscode/cli/cmd/spx/main.go @@ -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": {}, diff --git a/vscode/cli/internal/marker/marker_test.go b/vscode/cli/internal/marker/marker_test.go index a977eee..62c7571 100644 --- a/vscode/cli/internal/marker/marker_test.go +++ b/vscode/cli/internal/marker/marker_test.go @@ -29,16 +29,38 @@ func TestUpsertMarkerReplacesWhenPresent(t *testing.T) { } func TestUpsertMarkerLeavesOtherTypesAlone(t *testing.T) { - body := "\n" + body := "\n\n" got := UpsertMarker(body, "spec", "new.md") if !strings.Contains(got, "") { t.Fatalf("plan marker should be preserved: %q", got) } + if !strings.Contains(got, "") { + t.Fatalf("prototype marker should be preserved: %q", got) + } if !strings.Contains(got, "") { 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" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestUpsertMarkerPrototypeReplacesWhenPresent(t *testing.T) { + body := "intro\n\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, "") { + t.Fatalf("new marker missing: %q", got) + } +} + func TestUpsertMarkerEmptyBody(t *testing.T) { got := UpsertMarker("", "plan", "p.md") want := "" diff --git a/vscode/cli/internal/state/schema.json b/vscode/cli/internal/state/schema.json index 2682403..c06b28b 100644 --- a/vscode/cli/internal/state/schema.json +++ b/vscode/cli/internal/state/schema.json @@ -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/ 下。", diff --git a/vscode/cli/internal/state/schema_test.go b/vscode/cli/internal/state/schema_test.go index 380cf68..f2e2094 100644 --- a/vscode/cli/internal/state/schema_test.go +++ b/vscode/cli/internal/state/schema_test.go @@ -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) diff --git a/vscode/schemas/state-json.schema.json b/vscode/schemas/state-json.schema.json index 2682403..c06b28b 100644 --- a/vscode/schemas/state-json.schema.json +++ b/vscode/schemas/state-json.schema.json @@ -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/ 下。",