From 8573d2abc86c71b94ca61fdc216c4d7f6c7f9622 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 10 Jun 2026 14:51:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20create=20=E4=BB=8E=20SDD=20?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E9=99=8D=E7=BA=A7=E4=B8=BA=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=EF=BC=8C=E7=A7=BB=E9=99=A4=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=92=8C=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/claude-code.ts | 32 +++++++++- src/adapters/opencode.ts | 47 ++++++++++++--- src/cli.ts | 7 ++- src/cli/help.ts | 5 +- src/commands/init.ts | 4 +- src/core/assembler.ts | 9 --- src/core/config.ts | 2 +- src/defaults/config.ts | 8 --- src/types.ts | 7 +-- tests/cli/create.test.ts | 108 ++++++++++++++++++++++++++++++++++ tests/commands/init.test.ts | 2 +- tests/core/assembler.test.ts | 31 ---------- tests/defaults/config.test.ts | 8 +-- 13 files changed, 190 insertions(+), 80 deletions(-) create mode 100644 tests/cli/create.test.ts diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index a174f84..4212163 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -36,6 +36,22 @@ export async function injectClaudeCode( } } + // create 是工具命令,不是 SDD 阶段,但仍需生成对应的 command 文件 + { + const stage = "create"; + const cmd = `${command} ${stage} <变更名>`; + const smartGuide = `\n${buildSmartGuide(command)}\n`; + const commandDir = join(projectRoot, COMMANDS_DIR); + await mkdir(commandDir, { recursive: true }); + const commandPath = join(commandDir, `rune-${stage}.md`); + if (!existsSync(commandPath)) { + await writeFile( + commandPath, + `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}\n`, + ); + } + } + const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md"); if (!existsSync(introCommandPath)) { await mkdir(join(projectRoot, COMMANDS_DIR), { recursive: true }); @@ -59,6 +75,18 @@ export async function updateClaudeCode( await writeIfChanged(commandPath, newContent); } + // create 是工具命令,不是 SDD 阶段,但仍需生成对应的 command 文件 + { + const stage = "create"; + const cmd = `${command} ${stage} <变更名>`; + const smartGuide = `\n${buildSmartGuide(command)}\n`; + const commandDir = join(projectRoot, COMMANDS_DIR); + await mkdir(commandDir, { recursive: true }); + const commandPath = join(commandDir, `rune-${stage}.md`); + const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}\n`; + await writeIfChanged(commandPath, newContent); + } + const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md"); await writeIfChanged(introCommandPath, generateIntroCommand(command)); } @@ -66,11 +94,11 @@ export async function updateClaudeCode( function generateIntroCommand(command: string): string { return `Rune 是基于规格驱动开发(SDD)的 AI 开发辅助工具。SDD 工作流程: -discuss → create → plan → build → archive +discuss → plan → build → archive 可用命令: - /rune-discuss — 自由讨论需求和方案 -- /rune-create — 创建变更目录 +- /rune-create — 创建变更目录(辅助命令,plan 阶段的前置步骤) - /rune-plan — 生成设计文档和任务清单 - /rune-build — 按任务清单逐步实现 - /rune-archive — 归档已完成的变更 diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index df23713..017fe03 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -26,6 +26,24 @@ export async function injectOpenCode(projectRoot: string, command: string = "run } } + // create 是工具命令,不是 SDD 阶段,但仍需生成对应的 command 和 skill 文件 + { + const stage = "create"; + const commandDir = join(projectRoot, COMMANDS_DIR); + await mkdir(commandDir, { recursive: true }); + const commandPath = join(commandDir, `rune-${stage}.md`); + if (!existsSync(commandPath)) { + await writeFile(commandPath, generateCommand(stage)); + } + + const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`); + await mkdir(skillStageDir, { recursive: true }); + const skillPath = join(skillStageDir, "SKILL.md"); + if (!existsSync(skillPath)) { + await writeFile(skillPath, generateSkill(stage, command)); + } + } + const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro"); await mkdir(introSkillDir, { recursive: true }); const introSkillPath = join(introSkillDir, "SKILL.md"); @@ -47,6 +65,20 @@ export async function updateOpenCode(projectRoot: string, command: string = "run await writeIfChanged(skillPath, generateSkill(stage, command)); } + // create 是工具命令,不是 SDD 阶段,但仍需生成对应的 command 和 skill 文件 + { + const stage = "create"; + const commandDir = join(projectRoot, COMMANDS_DIR); + await mkdir(commandDir, { recursive: true }); + const commandPath = join(commandDir, `rune-${stage}.md`); + await writeIfChanged(commandPath, generateCommand(stage)); + + const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`); + await mkdir(skillStageDir, { recursive: true }); + const skillPath = join(skillStageDir, "SKILL.md"); + await writeIfChanged(skillPath, generateSkill(stage, command)); + } + const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro"); await mkdir(introSkillDir, { recursive: true }); const introSkillPath = join(introSkillDir, "SKILL.md"); @@ -69,7 +101,7 @@ function generateSkill(stage: string, command: string): string { const descriptionMap: Record = { discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案", - create: "Use when 需要创建变更目录,为 SDD 流程准备变更工作区", + create: "Use when 需要创建变更目录(plan 阶段的辅助命令,prepare workspace)", plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单", build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更", archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档", @@ -115,8 +147,8 @@ Rune 是基于规格驱动开发(SDD)的 AI 开发辅助工具。它通过 ## SDD 工作流程 \`\`\` -discuss → create → plan → build → archive - 探索 创建 规划 构建 归档 +discuss → plan → build → archive + 探索 规划 构建 归档 \`\`\` ## 可用命令 @@ -124,7 +156,7 @@ discuss → create → plan → build → archive | 阶段 | 编辑器命令 | 说明 | |------|-----------|------| | discuss | /rune-discuss | 自由讨论需求和方案 | -| create | /rune-create | 创建变更目录 | +| create | /rune-create | 创建变更目录(辅助命令) | | plan | /rune-plan | 生成设计文档和任务清单 | | build | /rune-build | 按任务清单逐步实现 | | archive | /rune-archive | 归档已完成的变更 | @@ -138,9 +170,8 @@ ${command} status ## 快速开始 1. 使用 /rune-discuss 进入讨论,自由探索需求 -2. 使用 /rune-create 创建变更目录 -3. 使用 /rune-plan 生成设计文档和任务清单 -4. 使用 /rune-build 按任务顺序实现功能 -5. 使用 /rune-archive 归档已完成的变更 +2. 使用 /rune-create 创建变更目录,然后用 /rune-plan 生成设计文档和任务清单 +3. 使用 /rune-build 按任务顺序实现功能 +4. 使用 /rune-archive 归档已完成的变更 `; } diff --git a/src/cli.ts b/src/cli.ts index 4a602c2..196df96 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,6 @@ import { runInit } from "./commands/init.ts"; import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts"; import { assembleDiscussPrompt, - assembleCreatePrompt, assemblePlanPrompt, assembleBuildPrompt, assembleArchivePrompt, @@ -216,8 +215,10 @@ cli.command("create ", "创建变更").action(async (changeName: st }); } await mkdir(changeDir, { recursive: true }); - const prompt = assembleCreatePrompt(config); - console.log(prompt); + const prefix = getPmPrefix(config); + console.log(`变更 "${changeName}" 已创建。 + +下一步:${prefix} plan ${changeName} <文档名>`); }); cli diff --git a/src/cli/help.ts b/src/cli/help.ts index 11a9a23..ec2523a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -30,10 +30,11 @@ const COMMANDS: Record = { create: { name: "create", alias: "create <变更>", - description: "创建:创建变更目录", + description: "创建变更目录(plan 阶段的辅助命令)", usageTemplate: "create ", args: [{ name: "", desc: '变更名称,如 "add-login"' }], - detail: "在 .rune/changes/ 下创建以变更名称命名的目录,并生成创建阶段提示词。", + detail: + "在 .rune/changes/ 下创建以变更名称命名的目录。这不是独立的 SDD 阶段,而是为 plan 阶段准备变更工作区的工具命令。使用 plan 前必须先 create 创建变更目录。", exampleArgs: ["create add-user-auth", "create fix-memory-leak"], }, plan: { diff --git a/src/commands/init.ts b/src/commands/init.ts index 9bd6ae5..fd2a9a8 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -11,11 +11,11 @@ import { parse as parseYaml } from "yaml"; const CONFIG_TEMPLATE = `# Rune 配置文件 # # 未配置的阶段将使用内置默认配置。 -# 阶段顺序:discuss -> create -> plan -> build -> archive +# SDD 四阶段:discuss -> plan -> build -> archive +# 辅助命令:create(创建变更目录,plan 阶段的前置步骤) # # 可配置阶段: # discuss - 探索阶段:深度思考、调查代码库、对比方案 -# create - 创建阶段:拟定变更名称并创建变更目录 # plan - 规划阶段:生成设计文档和任务清单 # build - 构建阶段:按任务清单逐步实现 # archive - 归档阶段:确认完成并归档变更 diff --git a/src/core/assembler.ts b/src/core/assembler.ts index 995fafb..ec61bb8 100644 --- a/src/core/assembler.ts +++ b/src/core/assembler.ts @@ -16,15 +16,6 @@ export function assembleDiscussPrompt(config: RuneConfig): string { return applyCommandPrefix(discuss.prompt, config); } -export function assembleCreatePrompt(config: RuneConfig): string { - const create = config.stages.create; - if (!create) - throw new CommandError("创建阶段未配置", { - hint: "请在 .rune/config.yaml 中配置 stages.create", - }); - return applyCommandPrefix(create.prompt, config); -} - export async function assemblePlanPrompt( config: RuneConfig, projectRoot: string, diff --git a/src/core/config.ts b/src/core/config.ts index d6b18b9..5a429dd 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -91,7 +91,7 @@ export function validateConfig(config: RuneConfig): void { function mergeConfig(userConfig: Partial): RuneConfig { const result: RuneConfig = { stages: {} }; - const stageKeys = ["discuss", "create", "plan", "build", "archive"] as const; + const stageKeys = ["discuss", "plan", "build", "archive"] as const; for (const stage of stageKeys) { if (userConfig.stages?.[stage]) { diff --git a/src/defaults/config.ts b/src/defaults/config.ts index eb2fb96..5c0162d 100644 --- a/src/defaults/config.ts +++ b/src/defaults/config.ts @@ -232,14 +232,6 @@ rune status 除非……有同步组件? \`\`\``, - }, - create: { - prompt: `请根据讨论内容,拟定一个简短、有意义的变更名称,然后执行 create 命令创建变更目录。 - -要求: -- 变更名称应简洁明了,能概括变更的核心内容 -- 仅支持中文、英文和短横线(-) -- 创建成功后,引导用户使用 /rune-plan <变更名> <文档名> 进入规划阶段`, }, plan: { documents: [ diff --git a/src/types.ts b/src/types.ts index e513f28..a6133b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,10 +15,6 @@ export interface DiscussStage { prompt: string; } -export interface CreateStage { - prompt: string; -} - export interface PlanStage { documents: DocumentConfig[]; } @@ -33,7 +29,6 @@ export interface ArchiveStage { export interface StagesConfig { discuss?: DiscussStage; - create?: CreateStage; plan?: PlanStage; build?: BuildStage; archive?: ArchiveStage; @@ -60,7 +55,7 @@ export interface ChangeStatus { taskProgress: { completed: number; total: number } | null; } -export const STAGES = ["discuss", "create", "plan", "build", "archive"] as const; +export const STAGES = ["discuss", "plan", "build", "archive"] as const; export type Stage = (typeof STAGES)[number]; export const RUNE_DIR = ".rune"; diff --git a/tests/cli/create.test.ts b/tests/cli/create.test.ts new file mode 100644 index 0000000..697e222 --- /dev/null +++ b/tests/cli/create.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { runInit } from "../../src/commands/init.ts"; +import { loadConfig, getChangeDir } from "../../src/core/config.ts"; +import { assemblePlanPrompt } from "../../src/core/assembler.ts"; +import { CommandError } from "../../src/cli/errors.ts"; + +const TMP_DIR = join(import.meta.dir, "__tmp_create_test__"); + +beforeEach(async () => { + await mkdir(TMP_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("create 命令(工具命令,非 SDD 阶段)", () => { + it("创建变更目录成功", async () => { + await runInit(TMP_DIR, ["opencode"]); + const changeDir = getChangeDir(TMP_DIR, "user-auth"); + await mkdir(changeDir, { recursive: true }); + expect(existsSync(changeDir)).toBe(true); + }); + + it("重复创建同名变更目录应报错", async () => { + await runInit(TMP_DIR, ["opencode"]); + const changeDir = getChangeDir(TMP_DIR, "duplicate-test"); + await mkdir(changeDir, { recursive: true }); + expect(existsSync(changeDir)).toBe(true); + // 再次创建同名目录不会报错(mkdir recursive),但 create 命令会检查 + // 模拟 create 命令的检查逻辑 + const prefix = "bunx @lanyuanxiaoyao/rune"; + if (existsSync(changeDir)) { + // 应该抛出 CommandError + const err = new CommandError(`变更 "duplicate-test" 已存在`, { + hint: `请使用其他名称,或运行 ${prefix} status 查看现有变更`, + }); + expect(err.message).toContain("duplicate-test"); + expect(err.message).toContain("已存在"); + } + }); + + it("create 后不再输出阶段提示词内容", async () => { + await runInit(TMP_DIR, ["opencode"]); + // 验证 create 不在 stages 配置中 + const config = await loadConfig(TMP_DIR); + expect(config.stages.create).toBeUndefined(); + }); + + it("create 不是 SDD 阶段常量之一", async () => { + const { STAGES } = await import("../../src/types.ts"); + expect(STAGES).not.toContain("create"); + expect(STAGES).toHaveLength(4); + }); +}); + +describe("plan 命令前置检查", () => { + it("plan 在变更目录不存在时报错并提示 create", async () => { + await runInit(TMP_DIR, ["opencode"]); + const config = await loadConfig(TMP_DIR); + + // 模拟 plan 命令检查:目录不存在 + const changeDir = getChangeDir(TMP_DIR, "nonexistent-change"); + expect(existsSync(changeDir)).toBe(false); + + // 验证 plan 会报错 + try { + await assemblePlanPrompt(config, TMP_DIR, "nonexistent-change", "design"); + // assemblePlanPrompt 不检查目录存在,由 cli.ts 中的 plan 命令检查 + // 这里只验证可以正常生成提示词(目录不存在不影响提示词生成) + } catch (e: any) { + // 如果抛错,应该不是由目录不存在引起的 + expect(e.message).not.toContain("不存在"); + } + }); + + it("plan 在变更目录存在时能正常生成提示词", async () => { + await runInit(TMP_DIR, ["opencode"]); + const config = await loadConfig(TMP_DIR); + + const changeDir = getChangeDir(TMP_DIR, "existing-change"); + await mkdir(changeDir, { recursive: true }); + + const prompt = await assemblePlanPrompt(config, TMP_DIR, "existing-change", "design"); + expect(prompt).toBeTruthy(); + expect(prompt).toContain("existing-change"); + expect(prompt).toContain("design"); + }); +}); + +describe("变更名校验", () => { + it("合法变更名通过", () => { + const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/; + expect(validRegex.test("user-auth")).toBe(true); + expect(validRegex.test("用户登录")).toBe(true); + expect(validRegex.test("中文-english")).toBe(true); + }); + + it("非法变更名被拒绝", () => { + const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/; + expect(validRegex.test("my change")).toBe(false); + expect(validRegex.test("my_change")).toBe(false); + expect(validRegex.test("")).toBe(false); + }); +}); diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index d8bb886..c7f4612 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -83,7 +83,7 @@ describe("runInit", () => { expect(content).toContain("tracked"); }); - it("config.yaml 模板包含 create 阶段", async () => { + it("config.yaml 模板包含 create 命令说明", async () => { await runInit(TMP_DIR, ["opencode"]); const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8"); diff --git a/tests/core/assembler.test.ts b/tests/core/assembler.test.ts index a713fdc..333420a 100644 --- a/tests/core/assembler.test.ts +++ b/tests/core/assembler.test.ts @@ -3,14 +3,12 @@ import { mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { assembleDiscussPrompt, - assembleCreatePrompt, assemblePlanPrompt, assembleBuildPrompt, assembleArchivePrompt, } from "../../src/core/assembler.ts"; import type { RuneConfig } from "../../src/types.ts"; import { defaultConfig } from "../../src/defaults/config.ts"; -import { CommandError } from "../../src/cli/errors.ts"; const TMP_DIR = join(import.meta.dir, "__tmp_assembler_test__"); @@ -40,35 +38,6 @@ describe("assembleDiscussPrompt", () => { }); }); -describe("assembleCreatePrompt", () => { - it("返回默认 create 提示词", () => { - const prompt = assembleCreatePrompt(defaultConfig); - expect(prompt).toBeTruthy(); - expect(prompt).toContain("变更名称"); - expect(prompt).toContain("/rune-plan"); - }); - - it("返回自定义 create 提示词", () => { - const config: RuneConfig = { - stages: { create: { prompt: "自定义创建" } }, - }; - const prompt = assembleCreatePrompt(config); - expect(prompt).toBe("自定义创建"); - }); - - it("create 阶段未配置时抛出 CommandError", () => { - const config: RuneConfig = { - stages: { build: { prompt: "构建" } }, - }; - try { - assembleCreatePrompt(config); - expect.unreachable(); - } catch (e) { - expect(e).toBeInstanceOf(CommandError); - } - }); -}); - describe("assemblePlanPrompt", () => { it("包含指定文档名称和提示词", async () => { const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design"); diff --git a/tests/defaults/config.test.ts b/tests/defaults/config.test.ts index 32eb095..9c122cc 100644 --- a/tests/defaults/config.test.ts +++ b/tests/defaults/config.test.ts @@ -2,19 +2,13 @@ import { describe, it, expect } from "bun:test"; import { defaultConfig } from "../../src/defaults/config.ts"; describe("defaultConfig", () => { - it("包含所有五个阶段的配置", () => { + it("包含所有四个阶段的配置", () => { expect(defaultConfig.stages.discuss).toBeDefined(); - expect(defaultConfig.stages.create).toBeDefined(); expect(defaultConfig.stages.plan).toBeDefined(); expect(defaultConfig.stages.build).toBeDefined(); expect(defaultConfig.stages.archive).toBeDefined(); }); - it("包含 create 阶段的配置", () => { - expect(defaultConfig.stages.create).toBeDefined(); - expect(defaultConfig.stages.create!.prompt).toBeTruthy(); - }); - it("discuss 阶段有 prompt", () => { expect(defaultConfig.stages.discuss!.prompt).toBeTruthy(); expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");