feat: 将 task 从 plan 文档提升为独立 SDD 阶段

This commit is contained in:
2026-06-10 22:38:19 +08:00
parent 289a7c6633
commit c4f83a3753
20 changed files with 318 additions and 353 deletions

View File

@@ -53,7 +53,7 @@ describe("create 命令(工具命令,非 SDD 阶段)", () => {
it("create 不是 SDD 阶段常量之一", async () => {
const { STAGES } = await import("../../src/types.ts");
expect(STAGES).not.toContain("create");
expect(STAGES).toHaveLength(4);
expect(STAGES).toHaveLength(5);
});
});

View File

@@ -134,15 +134,12 @@ describe("suggestNextStep", () => {
expect(suggestNextStep(status)).toContain("rune archive test-change");
});
it("规划完成但无 taskProgress 时建议 build", () => {
it("规划完成但无 taskProgress 时建议 task", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
documents: [{ name: "design", completed: true, dependMet: true }],
planCompleted: true,
taskProgress: null,
});
expect(suggestNextStep(status)).toContain("rune build test-change");
expect(suggestNextStep(status)).toContain("rune task test-change");
});
});

View File

@@ -80,7 +80,7 @@ describe("runInit", () => {
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("metadata");
expect(content).toContain("tracked");
expect(content).toContain("command");
});
it("config.yaml 模板包含 create 命令说明", async () => {

View File

@@ -6,6 +6,7 @@ import {
assemblePlanPrompt,
assembleBuildPrompt,
assembleArchivePrompt,
assembleTaskPrompt,
} from "../../src/core/assembler.ts";
import type { RuneConfig } from "../../src/types.ts";
import { defaultConfig } from "../../src/defaults/config.ts";
@@ -150,28 +151,18 @@ describe("assembleBuildPrompt", () => {
await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("尚未完成规划");
expect(e.message).toContain("尚未完成任务拆解");
expect(e.message).toContain("nonexistent");
expect(e.hint).toContain("plan nonexistent");
expect(e.hint).toContain("task");
}
});
it("tracked=false 时只输出通用提示词", async () => {
const config: RuneConfig = {
stages: { build: { prompt: "按规划文档逐步实现功能" } },
metadata: { tracked: false },
};
const prompt = await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect(prompt).toBe("按规划文档逐步实现功能");
});
it("tracked=true 且 task.md 格式不合法时抛错", async () => {
it("task.md 格式不合法时抛错", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "# 标题\n无 checkbox");
const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } },
metadata: { tracked: true },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
@@ -183,13 +174,12 @@ describe("assembleBuildPrompt", () => {
}
});
it("tracked=true 且 task.md 有空 checkbox 文本时抛错", async () => {
it("task.md 有空 checkbox 文本时抛错", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] \n- [x] 有内容");
const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } },
metadata: { tracked: true },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
@@ -210,44 +200,110 @@ describe("assembleArchivePrompt", () => {
expect(prompt).toContain("归档");
});
it("tracked=false 时不读取 task.md只输出通用提示词", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "确认归档" } },
metadata: { tracked: false },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("确认归档");
expect(prompt).not.toContain("未完成");
});
it("tracked=true 时内嵌未完成任务列表并引导用户确认", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
it("未完成任务时注入警告并引导用户确认", async () => {
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
metadata: { tracked: true },
};
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("未完成");
expect(prompt).toContain("未完成任务");
expect(prompt).toContain("是否确认");
});
it("tracked=true 且所有任务完成时不注入警告", async () => {
it("所有任务完成时不注入警告", async () => {
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
};
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
metadata: { tracked: true },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).not.toContain("未完成");
});
it("task.md 不存在时不注入警告", async () => {
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "no-task-change");
expect(prompt).toContain("归档阶段");
expect(prompt).not.toContain("警告");
});
});
describe("assembleTaskPrompt", () => {
it("任务拆解阶段提示词包含变更名和文档路径", async () => {
const config: RuneConfig = {
stages: {
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
task: { prompt: "拆解任务" },
},
};
const changeDir = join(TMP_DIR, ".rune", "changes", "feature-x");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
const prompt = await assembleTaskPrompt(config, TMP_DIR, "feature-x");
expect(prompt).toContain("feature-x");
expect(prompt).toContain("design.md");
});
it("task.md 已存在时提示修订", async () => {
const config: RuneConfig = {
stages: {
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
task: { prompt: "拆解任务" },
},
};
const changeDir = join(TMP_DIR, ".rune", "changes", "revise-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
await writeFile(join(changeDir, "task.md"), "- [ ] 已有任务");
const prompt = await assembleTaskPrompt(config, TMP_DIR, "revise-task");
expect(prompt).toContain("已有内容");
});
it("task 阶段未配置时抛错", async () => {
const config: RuneConfig = {
stages: { plan: { documents: [{ name: "design", prompt: "生成设计" }] } },
};
try {
await assembleTaskPrompt(config, TMP_DIR, "test");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("任务拆解阶段未配置");
}
});
it("plan 阶段未配置时抛错", async () => {
const config: RuneConfig = {
stages: { task: { prompt: "拆解任务" } },
};
try {
await assembleTaskPrompt(config, TMP_DIR, "test");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("规划阶段未配置");
}
});
it("plan 文档未完成时抛错", async () => {
const config: RuneConfig = {
stages: {
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
task: { prompt: "拆解任务" },
},
};
try {
await assembleTaskPrompt(config, TMP_DIR, "incomplete-plan");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("规划文档");
expect(e.message).toContain("尚未全部完成");
}
});
});
describe("命令前缀替换", () => {
@@ -273,13 +329,13 @@ describe("命令前缀替换", () => {
it("assembleBuildPrompt 错误提示使用动态前缀", async () => {
const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } },
metadata: { command: "pnpx @lanyuanxiaoyao/rune", tracked: true },
metadata: { command: "pnpx @lanyuanxiaoyao/rune" },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "nonexistent-build");
expect.unreachable();
} catch (e: any) {
expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune plan nonexistent-build");
expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune task nonexistent-build");
}
});

View File

@@ -39,6 +39,7 @@ describe("loadConfig", () => {
const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined();
expect(config.stages.plan).toBeDefined();
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined();
});
@@ -65,9 +66,7 @@ describe("loadConfig", () => {
await mkdir(runeDir, { recursive: true });
await writeFile(
join(runeDir, "config.yaml"),
`metadata:
tracked: false
stages:
`stages:
plan:
documents:
- name: spec
@@ -86,6 +85,7 @@ stages:
const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined();
expect(config.stages.plan).toBeDefined();
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined();
});
@@ -170,56 +170,6 @@ describe("validateConfig", () => {
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked=true 时 plan.documents 必须包含 task 文档", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: true },
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("tracked=true 且 plan.documents 包含 task 时不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked=false 时 plan.documents 不包含 task 也不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: false },
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked 未配置时等同于 false不强制要求 task 文档", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
};
expect(() => validateConfig(config)).not.toThrow();
});
});
describe("mergeConfig 保留 metadata", () => {
@@ -242,52 +192,28 @@ describe("mergeConfig 保留 metadata", () => {
}
});
it("无 metadata 时保留默认 metadatatracked: true", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_nometa_test__");
await mkdir(tmpDir, { recursive: true });
try {
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile(configPath, `stages:\n discuss:\n prompt: "测试"\n`);
const config = await loadConfig(tmpDir);
expect(config.metadata?.tracked).toBe(true);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
it("默认配置包含 task 阶段", async () => {
const config = await loadConfig(TMP_DIR);
expect(config.stages.task).toBeDefined();
expect(config.stages.task!.prompt).toBeTruthy();
});
it("用户 metadata 与默认 metadata 深合并", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_deep_merge__");
await mkdir(tmpDir, { recursive: true });
try {
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile(
configPath,
`metadata:\n command: "rune"\nstages:\n discuss:\n prompt: "测试"\n`,
);
const config = await loadConfig(tmpDir);
expect(config.metadata?.command).toBe("rune");
expect(config.metadata?.tracked).toBe(true);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
it("默认 metadata 不包含 tracked", async () => {
const config = await loadConfig(TMP_DIR);
expect((config.metadata as any)?.tracked).toBeUndefined();
});
it("用户 metadata.tracked 显式覆盖默认", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_tracked_override__");
await mkdir(tmpDir, { recursive: true });
try {
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile(
configPath,
`metadata:\n tracked: false\nstages:\n discuss:\n prompt: "测试"\n`,
);
const config = await loadConfig(tmpDir);
expect(config.metadata?.tracked).toBe(false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
it("用户配置 task 阶段覆盖默认", async () => {
const runeDir = join(TMP_DIR, ".rune");
await mkdir(runeDir, { recursive: true });
await writeFile(
join(runeDir, "config.yaml"),
`stages:
task:
prompt: 自定义任务提示词
`,
);
const config = await loadConfig(TMP_DIR);
expect(config.stages.task!.prompt).toBe("自定义任务提示词");
});
});

View File

@@ -21,31 +21,14 @@ describe("scanChanges", () => {
expect(changes).toEqual([]);
});
it("扫描到变更及其文档", async () => {
it("有 task.md 时无条件计算 taskProgress", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
const changes = await scanChanges(TMP_DIR, config);
const changes = await scanChanges(TMP_DIR);
expect(changes).toHaveLength(1);
expect(changes[0].name).toBe("user-auth");
const docNames = changes[0].documents.map((d) => `${d.name}.md`);
expect(docNames).toContain("design.md");
expect(docNames).toContain("task.md");
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
});
@@ -156,59 +139,6 @@ describe("scanChanges", () => {
expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false);
});
it("tracked=false 时不扫描 task.mdtaskProgress 为 null", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change"), { recursive: true });
await writeFile(join(changesDir, "test-change", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change", "task.md"), "- [x] 完成\n- [ ] 未完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: false },
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toBeNull();
});
it("tracked=true 时扫描 task.mdtaskProgress 有值", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change2"), { recursive: true });
await writeFile(join(changesDir, "test-change2", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change2", "task.md"), "- [x] 完成\n- [ ] 未完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toEqual({ completed: 1, total: 2 });
});
it("tracked 未配置undefined时不扫描 task.md", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change3"), { recursive: true });
await writeFile(join(changesDir, "test-change3", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change3", "task.md"), "- [x] 完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toBeNull();
});
});
describe("scanArchives", () => {

View File

@@ -2,9 +2,10 @@ 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.plan).toBeDefined();
expect(defaultConfig.stages.task).toBeDefined();
expect(defaultConfig.stages.build).toBeDefined();
expect(defaultConfig.stages.archive).toBeDefined();
});
@@ -42,25 +43,11 @@ describe("defaultConfig", () => {
expect(prompt).toContain("task.md");
});
it("plan 阶段包含 design 和 task 两个文档配置", () => {
it("plan 阶段包含 design 文档配置", () => {
const docs = defaultConfig.stages.plan!.documents;
expect(docs).toHaveLength(2);
expect(docs).toHaveLength(1);
expect(docs[0].name).toBe("design");
expect(docs[1].name).toBe("task");
for (const doc of docs) {
expect(doc.prompt).toBeTruthy();
}
});
it("plan 的 task 文档配置存在", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.prompt).toBeTruthy();
});
it("task 文档依赖 design", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.depend).toEqual(["design"]);
expect(docs[0].prompt).toBeTruthy();
});
it("design 文档有 template", () => {
@@ -69,10 +56,9 @@ describe("defaultConfig", () => {
expect(designDoc!.template).toContain("设计文档");
});
it("task 文档有 template", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.template).toBeTruthy();
expect(taskDoc!.template).toContain("- [ ]");
it("task 阶段有 prompt", () => {
expect(defaultConfig.stages.task).toBeDefined();
expect(defaultConfig.stages.task!.prompt).toBeTruthy();
});
it("build 阶段有 prompt", () => {

View File

@@ -7,6 +7,7 @@ import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.t
import {
assembleDiscussPrompt,
assemblePlanPrompt,
assembleTaskPrompt,
assembleBuildPrompt,
assembleArchivePrompt,
} from "../../src/core/assembler.ts";
@@ -23,7 +24,7 @@ afterEach(async () => {
});
describe("完整 SDD 流程", () => {
it("init → discuss → plan → build → archive 完整流程", async () => {
it("init → discuss → plan → task → build → archive 完整流程", async () => {
await runInit(TMP_DIR, ["opencode"]);
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
@@ -39,6 +40,12 @@ describe("完整 SDD 流程", () => {
const changeDir = getChangeDir(TMP_DIR, changeName);
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能");
const taskPrompt = await assembleTaskPrompt(config, TMP_DIR, changeName);
expect(taskPrompt).toContain("user-auth");
expect(taskPrompt).toContain("task.md");
expect(taskPrompt).toContain("design.md");
await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试");
const changes = await scanChanges(TMP_DIR, config);
@@ -97,9 +104,7 @@ describe("完整 SDD 流程", () => {
await writeFile(
join(TMP_DIR, ".rune", "config.yaml"),
`metadata:
tracked: false
stages:
`stages:
discuss:
prompt: 自定义讨论
plan:
@@ -121,6 +126,7 @@ stages:
expect(planPrompt).toContain("规格文档");
expect(planPrompt).not.toContain("design");
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined();
});
@@ -139,11 +145,6 @@ stages:
const designDoc = changes[0].documents.find((d) => d.name === "design");
expect(designDoc).toBeDefined();
expect(designDoc!.completed).toBe(true);
const taskDoc = changes[0].documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.completed).toBe(false);
expect(taskDoc!.dependMet).toBe(true);
});
});