refactor: create 从 SDD 阶段降级为工具命令,移除阶段配置和提示词

This commit is contained in:
2026-06-10 14:51:29 +08:00
parent 1f6e49e336
commit 8573d2abc8
13 changed files with 190 additions and 80 deletions

View File

@@ -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 — 归档已完成的变更

View File

@@ -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<string, string> = {
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 归档已完成的变更
`;
}

View File

@@ -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 <change-name>", "创建变更").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

View File

@@ -30,10 +30,11 @@ const COMMANDS: Record<string, CommandHelpDef> = {
create: {
name: "create",
alias: "create <变更>",
description: "创建:创建变更目录",
description: "创建变更目录plan 阶段的辅助命令)",
usageTemplate: "create <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "在 .rune/changes/ 下创建以变更名称命名的目录,并生成创建阶段提示词。",
detail:
"在 .rune/changes/ 下创建以变更名称命名的目录。这不是独立的 SDD 阶段,而是为 plan 阶段准备变更工作区的工具命令。使用 plan 前必须先 create 创建变更目录。",
exampleArgs: ["create add-user-auth", "create fix-memory-leak"],
},
plan: {

View File

@@ -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 - 归档阶段:确认完成并归档变更

View File

@@ -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,

View File

@@ -91,7 +91,7 @@ export function validateConfig(config: RuneConfig): void {
function mergeConfig(userConfig: Partial<RuneConfig>): 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]) {

View File

@@ -232,14 +232,6 @@ rune status
除非……有同步组件?
\`\`\``,
},
create: {
prompt: `请根据讨论内容,拟定一个简短、有意义的变更名称,然后执行 create 命令创建变更目录。
要求:
- 变更名称应简洁明了,能概括变更的核心内容
- 仅支持中文、英文和短横线(-
- 创建成功后,引导用户使用 /rune-plan <变更名> <文档名> 进入规划阶段`,
},
plan: {
documents: [

View File

@@ -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";

108
tests/cli/create.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");