Compare commits

..

109 Commits

Author SHA1 Message Date
c4f83a3753 feat: 将 task 从 plan 文档提升为独立 SDD 阶段 2026-06-10 22:38:19 +08:00
289a7c6633 chore: 添加 es-toolkit 依赖 2026-06-10 21:06:30 +08:00
2552412f77 fix: 错误系统重构、消除静默吞错、update 修复、文档同步 2026-06-10 21:04:34 +08:00
4d206f39cc test: 调整 archive 流程测试,新增 finish 命令测试 2026-06-10 17:31:35 +08:00
a75652c595 docs: 更新 archive 帮助文本,新增 finish 命令帮助 2026-06-10 17:28:25 +08:00
448b336c7f feat: 拆分 archive 为纯提示词命令,新增 finish 执行目录归档 2026-06-10 17:23:51 +08:00
81d27ea1e8 feat: 更新 archive 默认提示词,去掉目录移动描述,增加 finish 引导 2026-06-10 17:20:32 +08:00
0892ef885c fix: 归档提示词矛盾且缺少操作指引,rename 冲突报错不明确 2026-06-10 16:43:53 +08:00
ca1738785a fix: TaskFormatError 未被识别导致显示发生未预期错误 2026-06-10 15:47:20 +08:00
7d3ca2a150 refactor: 移除模板变量 {{change-name}} 替换功能,模板改为纯静态文本 2026-06-10 15:36:49 +08:00
ec090ea971 docs: 更新文档反映 create 命令定位变更 2026-06-10 15:28:04 +08:00
932ee45f94 test: 更新 adapter 测试适配 create 移除 2026-06-10 15:26:37 +08:00
a4c3a245c3 feat: 更新 intro skill/command,移除 create 独立命令 2026-06-10 15:23:26 +08:00
277e78812c feat: plan/discuss skill/command 中嵌入 create 引导 2026-06-10 15:21:46 +08:00
4d30604bf0 refactor: 移除 OpenCode adapter 中 create 独立生成块 2026-06-10 15:19:36 +08:00
72bf4ac71b refactor: 移除 Claude Code adapter 中 create 独立生成块 2026-06-10 15:19:22 +08:00
8573d2abc8 refactor: create 从 SDD 阶段降级为工具命令,移除阶段配置和提示词 2026-06-10 14:51:29 +08:00
1f6e49e336 feat: opencode/claude-code 统一 command 格式、智能识别引导、新增 intro/create、移除 status 2026-06-10 13:16:02 +08:00
49f523146f feat: help 新增 create 命令帮助,更新阶段顺序 2026-06-10 13:11:01 +08:00
cb537b4f2a docs: config.yaml 模板移除 {{change-name}},增加 metadata 说明和 create 阶段 2026-06-10 13:08:47 +08:00
ed4da9b6a0 feat: 新增 create 命令,移除 fallbackNote,plan 不再自动 mkdir 2026-06-10 13:06:30 +08:00
daec0612c4 feat: 新增 assembleCreatePrompt,plan/build/archive 不再内嵌文件内容 2026-06-10 13:02:16 +08:00
b9ea668383 feat: types 和 defaultConfig 增加 create 阶段 2026-06-10 12:55:56 +08:00
a926200613 chore: release v0.1.3 2026-06-10 09:37:39 +08:00
8d6f9dbad6 chore: 添加 docs/superpowers 提交防护和 git 强制操作权限限制 2026-06-10 09:35:10 +08:00
03fabd8ab9 chore: 移除 docs/superpowers 目录的 git 跟踪 2026-06-10 09:29:53 +08:00
e3067d017e chore: 移除所有 e2e 测试 2026-06-10 09:27:03 +08:00
641d23b7c8 feat: archive 阶段根据 tracked 分支处理 2026-06-10 09:16:57 +08:00
3789d0a7b3 feat: status 命令根据 tracked 决定是否扫描 task.md 2026-06-10 09:10:17 +08:00
0c89b3ebb2 feat: build 阶段根据 tracked 分支处理 2026-06-10 09:03:38 +08:00
8e00e2cdf1 feat: 新增 validateTaskFormat 校验函数 2026-06-10 08:56:44 +08:00
7d5af32ce5 feat: metadata.tracked 类型、validateConfig 校验与 mergeConfig 深合并 2026-06-10 08:52:18 +08:00
af58f786af docs: 新增 tracked 任务跟踪模式实现计划 2026-06-10 00:15:37 +08:00
93bfa4373a docs: 新增 tracked 任务跟踪模式设计文档 2026-06-10 00:11:05 +08:00
7e260291f0 fix: 修复 CRLF 换行文件下 parseTasks 解析失败导致任务进度误判 2026-06-09 22:47:39 +08:00
983a21acb6 docs: 更新安装说明和命令示例使用完整包名 2026-06-09 20:41:43 +08:00
78caec6449 feat: 提示词拼装使用动态命令前缀 2026-06-09 20:37:10 +08:00
2feea7a74f feat: CLI 展示文本使用动态命令前缀 2026-06-09 20:29:42 +08:00
ce00751585 feat: 帮助文本支持动态命令前缀 2026-06-09 20:22:44 +08:00
589eaa120e feat: init 命令检测包管理器并写入 metadata.command 2026-06-09 20:18:04 +08:00
a5c8263412 feat: 适配器支持动态命令前缀 2026-06-09 20:13:40 +08:00
909c29db25 feat: mergeConfig 保留 metadata 字段 2026-06-09 20:08:10 +08:00
c9e2ff1c42 feat: 增加 metadata 数据模型和包管理器检测核心函数 2026-06-09 20:04:23 +08:00
a99ebb81a3 chore: release v0.1.2 2026-06-09 18:36:42 +08:00
92c1fed5d5 chore: release v0.1.1 2026-06-09 18:36:02 +08:00
750480e30c fix: 工作区检查移到流程最开始,避免写版本号后才报错 2026-06-09 18:34:49 +08:00
075bdcdd54 fix: 测试通过后再写回版本号,避免测试失败时污染 package.json 2026-06-09 18:32:47 +08:00
682bdda3e5 refactor: 测试文件改为导入源码函数,添加 import.meta.main 保护 2026-06-09 18:23:52 +08:00
7ce344801f docs: 添加 release 发布流程说明到开发文档 2026-06-09 18:18:17 +08:00
9a2fde2cfa fix: 精确匹配 package.json 状态行,使用 annotated tag 2026-06-09 18:17:26 +08:00
307bdfc922 feat: 实现 git commit + tag 和 npm publish 步骤 2026-06-09 18:14:53 +08:00
1b69e454d7 feat: 实现测试门禁步骤 2026-06-09 18:09:52 +08:00
cecd7ab925 fix: 添加 package.json version 字段校验 2026-06-09 18:01:24 +08:00
da7770f76a feat: 实现版本递增与交互选择流程 2026-06-09 17:56:55 +08:00
1871d0b665 test: 修正小数测试命名为四段格式抛出异常 2026-06-09 17:50:31 +08:00
9cc0a96f68 test: 版本递增逻辑单元测试 2026-06-09 17:47:40 +08:00
58a771ebca feat: 添加 release 脚本骨架和版本递增逻辑 2026-06-09 17:39:59 +08:00
2efcff5742 docs: 添加 files 字段限定发布内容 2026-06-09 17:38:36 +08:00
088254ab0f chore: 更新包名为 @lanyuanxiaoyao/rune,移除 private,添加 release 脚本 2026-06-09 17:30:58 +08:00
59632b5312 refactor: tier 文件重命名为 agent-mock/agent-scenario/agent-llm 2026-06-09 16:52:47 +08:00
faefefda39 chore: e2e 测试与单元测试分离,通过脚本区分 2026-06-09 16:23:32 +08:00
073b9c1e47 feat: 第三期 — Tier 3 LLM-as-Judge 集成 2026-06-09 16:11:48 +08:00
bb7d5e740c feat: 第二期 — Tier 2 场景级 mock + 错误/流程/依赖测试 2026-06-09 15:52:01 +08:00
8739a404f6 docs: 更新 init 配置模板中的 discuss 阶段描述 2026-06-09 15:39:00 +08:00
662c66c08e test: 增强 discuss 默认提示词测试覆盖 2026-06-09 15:34:53 +08:00
6346398962 test: 更新 discuss 测试以匹配新提示词 2026-06-09 15:33:13 +08:00
77cd056492 fix: 修正护栏描述中的双重否定 2026-06-09 15:32:24 +08:00
4a253bbb72 feat: 升级 discuss 阶段提示词,引入探索模式立场 2026-06-09 15:26:39 +08:00
56f39d5f0a test: build 阶段端到端测试(5 个用例) 2026-06-09 15:17:23 +08:00
6214eedf4d test: plan 阶段端到端测试(8 个用例) 2026-06-09 15:17:09 +08:00
0d90e6b2a3 test: archive 阶段端到端测试(3 个用例) 2026-06-09 15:17:05 +08:00
ac16bfa383 feat: 实现 Tier 1 命令级 mock agent 2026-06-09 15:13:32 +08:00
2d5b40379f feat: 创建可复用断言工具集 2026-06-09 15:12:12 +08:00
4e736998c7 feat: 创建测试夹具工具函数 2026-06-09 15:11:50 +08:00
9b52b46d3e feat: 定义 AgentRunner 接口 2026-06-09 15:11:43 +08:00
5a7b8f1dcc fix: 修复 oxlint 报告的代码质量问题 2026-06-09 14:33:15 +08:00
6ebfe24921 docs: 补充 lint/format 开发文档 2026-06-09 14:30:27 +08:00
34974714a2 chore: 配置 pre-commit hook 运行 lint-staged 2026-06-09 14:28:54 +08:00
7ad5411e45 chore: 添加 lint/format/check scripts 和 lint-staged 配置 2026-06-09 14:26:42 +08:00
b82f1caf0b chore: 添加 .oxfmtrc.json 并格式化全部代码 2026-06-09 14:22:33 +08:00
ebd5bb4051 chore: 添加 .oxlintrc.json 配置 2026-06-09 14:19:58 +08:00
107cd4f711 chore: 安装 oxlint、oxfmt、husky、lint-staged 2026-06-09 14:13:42 +08:00
bfa0f29dd5 refactor: 修复代码审查发现的问题
- Bug修复: formatChangeStatus 使用实际配置而非 defaultConfig
- 统一 assembler 中所有错误抛出为 CommandError
- 提取 writeIfChanged 到 adapters/utils.ts,消除 claude-code/opencode 重复代码
- 导出 SUPPORTED_TOOLS,cli.ts update 命令复用同一工具注册表
- 提取 mapError/mapCacError 函数,支持单元测试
- 补充 claude-code 适配器测试(10 个用例)
- 补充 validateChangeName、formatChangeStatus、suggestNextStep、mapError 单元测试(18 个用例)
- 共新增 3 个测试文件,测试从 96 增至 133,全部通过
2026-06-09 12:57:28 +08:00
7b258f4d90 docs: 更新 README 和 DEVELOPMENT,补充设计决策说明 2026-06-09 12:40:35 +08:00
60493e4e47 fix: 合并重复的 node:fs/promises 导入 2026-06-09 12:39:47 +08:00
f257ccbe4a feat: 新增 rune update 命令用于更新编辑器配置 2026-06-09 12:39:10 +08:00
c45f6e1d45 feat: archive 阶段校验 task 完成状态,未完成时注入警告提示词 2026-06-09 12:36:19 +08:00
5705e59285 feat: plan skill 引导 AI 先通过 rune status 获取文档列表 2026-06-09 12:34:26 +08:00
da826e2029 feat: 变更名限制为中文、英文和短横线 2026-06-09 12:32:33 +08:00
0869014c3f fix: build 命令在 plan 未完成时给出友好提示而非未预期错误 2026-06-09 11:42:27 +08:00
27f19d8bdf docs: 更新 plan/status 命令文档,增加 depend 配置说明 2026-06-09 11:03:31 +08:00
5d5d5cdc92 test: 适配集成测试到新 plan/status API 签名,验证文档依赖状态 2026-06-09 11:00:09 +08:00
4ef172ff2f feat: status 命令支持可选 change-name 参数,展示详细状态与下一步建议 2026-06-09 10:56:55 +08:00
5799ab6978 feat: plan 命令新增 document-name 参数,校验依赖是否满足 2026-06-09 10:54:03 +08:00
160ec576e1 feat: 更新 plan 和 status 命令的帮助文本 2026-06-09 10:52:24 +08:00
636ca48b4c feat: 默认配置 task 文档增加 depend: [design],init 模板增加 depend 示例 2026-06-09 10:52:04 +08:00
ee01bd87ab feat: assemblePlanPrompt 改为按单文档组装,增加依赖说明 2026-06-09 10:49:02 +08:00
1c7a8b3322 feat: scanChanges 扩展返回 DocumentStatus、planCompleted、buildUnlocked 2026-06-09 10:43:25 +08:00
0d2b117680 feat: 新增 validateConfig 校验 depend 引用、自依赖和循环依赖 2026-06-09 10:40:07 +08:00
566a9d7255 feat: 扩展 DocumentConfig 增加 depend 字段,新增 DocumentStatus 类型,扩展 ChangeStatus 2026-06-09 10:36:43 +08:00
03b0c60fb6 fix: CLI缺少参数友好提示、config.yaml注释模板、skill目录结构规范 2026-06-08 23:06:31 +08:00
c924e17253 docs: 新增禁止提交 superpowers 目录的规则 2026-06-08 22:45:48 +08:00
92dbffbec0 fix: 修复 getRuneDir 测试的跨平台路径断言 2026-06-08 22:45:00 +08:00
11819a270b docs: 更新文档,反映 CLI 交互行为变更 2026-06-08 22:41:35 +08:00
c4511ca825 refactor: CLI 入口重构,统一错误处理和 help 输出 2026-06-08 22:40:22 +08:00
c63912dc0d refactor: init 命令使用 CommandError 2026-06-08 22:34:47 +08:00
1fbec93d55 feat: CLI help 格式化输出 2026-06-08 22:32:41 +08:00
50456188a0 feat: CLI 错误输出格式化 2026-06-08 22:31:43 +08:00
dab63975f5 feat: CLI 错误类层次 2026-06-08 22:30:03 +08:00
44 changed files with 3929 additions and 436 deletions

5
.husky/pre-commit Normal file
View File

@@ -0,0 +1,5 @@
if git diff --cached --name-only | grep -q "^docs/superpowers/"; then
echo "错误: 禁止提交 docs/superpowers/ 目录下的文件" >&2
exit 1
fi
bun lint-staged

9
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf"
}

15
.oxlintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"suspicious": "error"
},
"rules": {
"eslint/no-var": "error",
"eslint/prefer-const": ["error", { "destructuring": "all" }],
"eslint/eqeqeq": ["error", "always"],
"eslint/no-throw-literal": "error",
"eslint/no-debugger": "error",
"eslint/no-template-curly-in-string": "error"
}
}

View File

@@ -22,3 +22,4 @@
- 单个文件或目录只分配给一个 subagent不重复分配subagent 输出文件路径、行号和问题摘要,不输出大段源码 - 单个文件或目录只分配给一个 subagent不重复分配subagent 输出文件路径、行号和问题摘要,不输出大段源码
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题 - 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
- 优先使用提问工具对用户确认 - 优先使用提问工具对用户确认
- 禁止提交docs/superpowers目录和目录下的设计文档不需要留存

View File

@@ -13,6 +13,10 @@
``` ```
src/ src/
├── cli.ts # CLI 入口 ├── cli.ts # CLI 入口
├── cli/
│ ├── errors.ts # CLI 错误定义CliError 层级)
│ ├── output.ts # 格式化输出(错误/用法/提示)
│ └── help.ts # 帮助文本生成
├── types.ts # 类型定义 ├── types.ts # 类型定义
├── commands/ ├── commands/
│ └── init.ts # init 命令 │ └── init.ts # init 命令
@@ -20,12 +24,16 @@ src/
│ ├── config.ts # 配置加载 │ ├── config.ts # 配置加载
│ ├── scanner.ts # 状态扫描 │ ├── scanner.ts # 状态扫描
│ ├── assembler.ts # 提示词拼装 │ ├── assembler.ts # 提示词拼装
── task-parser.ts # 任务解析 ── task-parser.ts # 任务解析
│ └── pm.ts # 包管理器检测与命令前缀
├── adapters/ ├── adapters/
│ ├── opencode.ts # OpenCode 适配器 │ ├── opencode.ts # OpenCode 适配器
── claude-code.ts # Claude Code 适配器(占位) ── claude-code.ts # Claude Code 适配器
└── defaults/ │ └── utils.ts # 适配器工具函数
└── config.ts # 内置默认配置 ├── defaults/
│ └── config.ts # 内置默认配置
scripts/
└── release.ts # 发布脚本
tests/ # 测试目录(镜像 src 结构) tests/ # 测试目录(镜像 src 结构)
``` ```
@@ -33,25 +41,102 @@ tests/ # 测试目录(镜像 src 结构)
## 开发命令 ## 开发命令
```bash ```bash
bun test # 运行全部测试 bun test # 运行单元/集成测试
bun test tests/core/ # 运行指定目录测试 bun test tests/core/ # 运行指定目录测试
bun run release # 发布新版本交互式递增版本号、测试门禁、git commit+tag、npm publish
bun src/cli.ts init opencode # 测试 init 命令 bun src/cli.ts init opencode # 测试 init 命令
bun src/cli.ts plan <变更名> <文档名> # 测试 plan 命令
bun src/cli.ts status [变更名] # 测试 status 命令
bun src/cli.ts help # 查看全局帮助
bun src/cli.ts help init # 查看 init 命令帮助
bun src/cli.ts version # 查看版本号
``` ```
### 代码质量
项目使用 oxlint 进行静态分析oxfmt 进行代码格式化,提交时通过 husky + lint-staged 自动检查。
```bash
bun lint # 静态分析所有文件
bun format # 格式化所有文件(写回)
bun format:check # 检查格式不写回CI 用)
bun check # 一键 lint + 格式检查
```
**配置文件:**
- `.oxlintrc.json` — oxlint 规则配置correctness + suspicious 全开style 选开)
- `.oxfmtrc.json` — oxfmt 格式设置双引号、分号、尾逗号、2 空格缩进、100 行宽、LF 换行)
**pre-commit hook** 提交时自动对 staged 文件运行 lint 和格式化lint 失败会阻止提交。
## CLI 交互架构
### 子命令
CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version` 标志:
- `rune help` — 显示全局帮助(可用命令列表)
- `rune help <command>` — 显示指定命令的详细用法
- `rune version` — 显示版本号
### 错误处理
错误消息采用结构化格式,相关代码位于:
- `src/cli/errors.ts``CliError` 错误层级(未知命令、缺少参数等)
- `src/cli/output.ts` — 格式化输出(`错误:``用法:``提示:` 三段式)
- `src/cli/help.ts` — 帮助文本生成
## 设计决策
### 阶段与配置
- **五阶段固定**discuss → plan → task → build → archive不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents
- **配置文件名**`.rune/config.yaml`,不是 `rune.yml`
- **文档模板**:纯静态文本模板,不进行变量替换
### 各阶段行为
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导。讨论结束时引导用户运行 `rune create` 创建变更目录
- **plan**命令只输出提示词不写入文件AI 负责根据提示词生成文档内容并写入。新变更时引导用户先运行 `rune create` 创建目录。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
- **task**:根据规划文档内容拆分为 checkbox 任务清单;格式固定,不可自定义模板;规划文档必须全部完成才能进入
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
- **archive**:输出归档提示词(含未完成任务的警告),引导 AI 汇总变更并确认。`finish` 命令执行实际的目录移动
**create**CLI 辅助命令(非独立阶段),在 `.rune/changes/` 下创建变更目录。adapter 不为 create 生成独立的 skill/command 文件,使用引导嵌入在 discuss 和 plan 的 skill/command 内容中。
### 变更名校验
变更名仅支持中文、英文、短横线(`-`),不支持空格、下划线等其他符号。
### update 命令
`rune update <tool>` 对比已注入的命令/skill 文件内容与内置版本,不一致则覆盖,不存在则新建。用于升级 Rune 后更新编辑器配置。
### 其他决策
- 无跨变更依赖,变更之间完全独立
- 无并发锁,同一变更可被多个 agent 同时操作
- 无需变更废弃命令,手动删除目录即可
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
- archive 与 finish 分离archive 只输出提示词finish 执行实际的目录移动。分离原因是提示词阶段需要 AI 参与确认,而文件操作是确定性的
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
## 测试策略 ## 测试策略
### Level 1 — 纯单元/集成测试(当前 ### 单元/集成测试(`bun test`
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。 在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。覆盖 `src/core/``src/cli/``src/adapters/``tests/integration/`
### Level 2 — 提示词快照测试(后续增强) ## 发布流程
对每个阶段捕获提示词输出,与预期快照对比。 `bun run release` 交互式发布新版本到 npm
### Level 3 — mock-agent 端到端(后续增强) 1. **版本递增**:选择 major/minor/patch确认后写回 package.json
2. **测试门禁**:执行 `bun test`,失败则终止
3. **Git 操作**:确认后执行 `git add` + `commit` + `tag`(仅本地,不推送)
4. **npm 发布**`bun publish --dry-run` 预览,确认后 `bun publish --access public`
编排完整闭环rune 输出 → mock-agent 处理 → rune 继续下一阶段。 发布前确保已通过 `npm login` 登录 npm且 npm 账号有 `@lanyuanxiaoyao` scope 的发布权限。
### Level 4 — 真实 AI 工具集成CI 可选)
调用 LLM API 验证输出格式可被解析。

View File

@@ -5,37 +5,108 @@
## 安装 ## 安装
```bash ```bash
bunx rune init opencode bunx @lanyuanxiaoyao/rune init opencode
``` ```
如果没有安装 bun可使用 `pnpx @lanyuanxiaoyao/rune``npx @lanyuanxiaoyao/rune` 替代。
## 使用 ## 使用
### 初始化 ### 初始化
```bash ```bash
bunx rune init opencode bunx @lanyuanxiaoyao/rune init opencode # OpenCode 编辑器
bunx @lanyuanxiaoyao/rune init claude-code # Claude Code 编辑器
``` ```
会在项目中创建: 会在项目中创建:
- `.rune/` 目录(配置、变更文档、归档) - `.rune/` 目录(配置、变更文档、归档)
- `.opencode/commands/``.opencode/skills/`(注入的 AI 工具配置 - 编辑器对应的 command 和 skill 文件(如 `.opencode/commands/``.opencode/skills/`
### 更新编辑器配置
当 Rune 版本升级后,需要更新已注入的命令和 skill 文件:
```bash
bunx @lanyuanxiaoyao/rune update opencode # 更新 OpenCode 的命令和 skill
bunx @lanyuanxiaoyao/rune update claude-code # 更新 Claude Code 的命令
```
更新策略:对比文件内容,不一致则用内置版本覆盖;不存在则新建。
### SDD 流程 ### SDD 流程
1. `/rune-discuss` — 自由讨论需求 SDD 工作流包含固定的五个阶段,不可自定义增删:
2. `/rune-plan <变更名>` — 生成设计文档和任务列表
3. `/rune-build <变更名>` — 按任务顺序编码实现 1. **讨论阶段**`/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
4. `/rune-archive <变更名>` — 归档并清理 2. **规划阶段**`/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)一个文档。文档间支持 `depend` 字段声明前置依赖依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
3. **任务拆解阶段**`/rune-task <变更名>`:根据规划阶段生成的文档内容,拆分为 checkbox 格式的任务清单task.md。规划阶段的所有文档必须已完成。格式固定为 checkbox 列表(`- [ ] 待完成``- [x] 已完成`),不可自定义模板。
4. **构建阶段**`/rune-build <变更名>`:按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成。
5. **归档阶段**`/rune-archive <变更名>`:输出归档阶段提示词,引导 AI 汇总变更内容并确认归档。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认。确认后执行 `rune finish <变更名>` 将变更目录移动到 `archive/`
> **辅助命令**`rune create <变更名>` 用于在 `.rune/changes/` 下创建变更目录。它不是 SDD 阶段,而是在 discuss 结束后或 plan 开始前通过 CLI 运行的辅助命令。discuss 和 plan 的编辑器命令中已内嵌 create 的使用引导。
### 状态查看 ### 状态查看
```bash ```bash
rune status bunx @lanyuanxiaoyao/rune status # 查看所有变更(含各阶段文档完成状态、下一步建议)
bunx @lanyuanxiaoyao/rune status <变更名> # 查看指定变更的详细状态
``` ```
规划阶段应引导 AI 先通过 `bunx @lanyuanxiaoyao/rune status` 获取当前有哪些文档需要编写。
### 命令参考
```bash
bunx @lanyuanxiaoyao/rune help # 显示全局帮助
bunx @lanyuanxiaoyao/rune help <command> # 显示指定命令的详细帮助
bunx @lanyuanxiaoyao/rune version # 显示版本号
```
| 命令 | 说明 |
| -------------------------------------------------- | ---------------------------------------------- |
| `bunx @lanyuanxiaoyao/rune init <tool>` | 初始化项目,注入编辑器配置 |
| `bunx @lanyuanxiaoyao/rune update <tool>` | 更新编辑器的命令和 skill 文件 |
| `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 |
| `bunx @lanyuanxiaoyao/rune create <变更名>` | 创建变更目录discuss 和 plan 之间的辅助命令) |
| `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
| `bunx @lanyuanxiaoyao/rune task <变更名>` | 输出任务拆解阶段提示词 |
| `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 |
| `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词 |
| `bunx @lanyuanxiaoyao/rune finish <变更名>` | 归档变更(将变更目录移动到 archive/ |
| `bunx @lanyuanxiaoyao/rune status [变更名]` | 显示变更状态和下一步建议 |
### 自定义配置 ### 自定义配置
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。 编辑 `.rune/config.yaml` 自定义各阶段的提示词和文档模板。配置合并采用阶段级别全量覆盖策略:自定义某个阶段时需完整重写该阶段的配置,未配置的阶段使用内置默认配置。
规划阶段的文档支持 `depend` 字段声明前置依赖:
```yaml
stages:
plan:
documents:
- name: design
prompt: 生成设计文档,包含背景、目标、方案、接口和注意事项
- name: api
prompt: 生成 API 设计文档
depend: [design]
```
计划阶段文档模板为纯静态文本,直接输出不作变量替换。
## 设计决策
- **五阶段固定**discuss → plan → task → build → archive 不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并
- **讨论结果不持久化**:完全依赖 AI 会话上下文传递
- **plan 不写文件**plan 命令只输出提示词,由 AI 负责写入文档
- **变更名限制**:仅支持中文、英文、短横线(`-`
- **同一变更名同天多次归档**:依靠日期+变更名去重,不做冲突处理
- **无跨变更依赖**:变更之间完全独立
- **无并发锁**:同一变更可被多个 AI agent 同时操作
- **无变更废弃命令**:用户手动删除 `.rune/changes/<变更名>/` 目录即可
## 开发 ## 开发

147
bun.lock
View File

@@ -6,10 +6,15 @@
"name": "rune", "name": "rune",
"dependencies": { "dependencies": {
"cac": "^7.0.0", "cac": "^7.0.0",
"es-toolkit": "^1.47.0",
"yaml": "^2.7.0", "yaml": "^2.7.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^5",
@@ -17,18 +22,160 @@
}, },
}, },
"packages": { "packages": {
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.69.0", "", { "os": "android", "cpu": "arm" }, "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.69.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.69.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.69.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.69.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.69.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.69.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.69.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.69.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.69.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-toolkit": ["es-toolkit@1.47.0", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"oxfmt": ["oxfmt@0.54.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.54.0", "@oxfmt/binding-android-arm64": "0.54.0", "@oxfmt/binding-darwin-arm64": "0.54.0", "@oxfmt/binding-darwin-x64": "0.54.0", "@oxfmt/binding-freebsd-x64": "0.54.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.54.0", "@oxfmt/binding-linux-arm-musleabihf": "0.54.0", "@oxfmt/binding-linux-arm64-gnu": "0.54.0", "@oxfmt/binding-linux-arm64-musl": "0.54.0", "@oxfmt/binding-linux-ppc64-gnu": "0.54.0", "@oxfmt/binding-linux-riscv64-gnu": "0.54.0", "@oxfmt/binding-linux-riscv64-musl": "0.54.0", "@oxfmt/binding-linux-s390x-gnu": "0.54.0", "@oxfmt/binding-linux-x64-gnu": "0.54.0", "@oxfmt/binding-linux-x64-musl": "0.54.0", "@oxfmt/binding-openharmony-arm64": "0.54.0", "@oxfmt/binding-win32-arm64-msvc": "0.54.0", "@oxfmt/binding-win32-ia32-msvc": "0.54.0", "@oxfmt/binding-win32-x64-msvc": "0.54.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ=="],
"oxlint": ["oxlint@1.69.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.69.0", "@oxlint/binding-android-arm64": "1.69.0", "@oxlint/binding-darwin-arm64": "1.69.0", "@oxlint/binding-darwin-x64": "1.69.0", "@oxlint/binding-freebsd-x64": "1.69.0", "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", "@oxlint/binding-linux-arm-musleabihf": "1.69.0", "@oxlint/binding-linux-arm64-gnu": "1.69.0", "@oxlint/binding-linux-arm64-musl": "1.69.0", "@oxlint/binding-linux-ppc64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-musl": "1.69.0", "@oxlint/binding-linux-s390x-gnu": "1.69.0", "@oxlint/binding-linux-x64-gnu": "1.69.0", "@oxlint/binding-linux-x64-musl": "1.69.0", "@oxlint/binding-openharmony-arm64": "1.69.0", "@oxlint/binding-win32-arm64-msvc": "1.69.0", "@oxlint/binding-win32-ia32-msvc": "1.69.0", "@oxlint/binding-win32-x64-msvc": "1.69.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
} }
} }

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,6 +1,17 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"plugin": [ "plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
"superpowers@git+https://github.com/obra/superpowers.git" "permission": {
] "bash": {
"git add -f *": "deny",
"git add --force *": "deny",
"git push * --force *": "deny",
"git push * -f *": "deny",
"git push --force-with-lease *": "deny",
"git reset --hard *": "deny",
"git clean -f *": "deny",
"git checkout -- *": "deny",
"git restore --staged *": "deny"
}
}
} }

View File

@@ -1,20 +1,46 @@
{ {
"name": "rune", "name": "@lanyuanxiaoyao/rune",
"version": "0.1.0", "version": "0.1.3",
"module": "src/cli.ts",
"type": "module",
"bin": { "bin": {
"rune": "./src/cli.ts" "rune": "./src/cli.ts"
}, },
"private": true, "files": [
"src",
"README.md"
],
"type": "module",
"module": "src/cli.ts",
"scripts": {
"prepare": "husky",
"test": "bun test",
"lint": "oxlint",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"check": "oxlint && oxfmt --check .",
"release": "bun run scripts/release.ts"
},
"dependencies": {
"cac": "^7.0.0",
"es-toolkit": "^1.47.0",
"yaml": "^2.7.0"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
}, },
"dependencies": { "lint-staged": {
"cac": "^7.0.0", "*.{ts,js,mjs,cjs}": [
"yaml": "^2.7.0" "oxlint",
"oxfmt"
],
"*.{json,md,yaml,yml}": [
"oxfmt"
]
} }
} }

211
scripts/release.ts Normal file
View File

@@ -0,0 +1,211 @@
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { join } from "node:path";
export interface Semver {
major: number;
minor: number;
patch: number;
}
export type BumpType = "major" | "minor" | "patch";
export function parseSemver(version: string): Semver {
const parts = version.split(".");
if (parts.length !== 3) {
throw new Error(`无效的版本号格式: ${version}`);
}
const [major, minor, patch] = parts.map((p) => {
const n = Number(p);
if (Number.isNaN(n) || !Number.isInteger(n) || n < 0) {
throw new Error(`无效的版本号格式: ${version}`);
}
return n;
});
return { major: major!, minor: minor!, patch: patch! };
}
export function bumpVersion(current: string, type: BumpType): string {
const semver = parseSemver(current);
switch (type) {
case "major":
return `${semver.major + 1}.0.0`;
case "minor":
return `${semver.major}.${semver.minor + 1}.0`;
case "patch":
return `${semver.major}.${semver.minor}.${semver.patch + 1}`;
}
}
async function ask(query: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(query, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function selectBumpType(): Promise<BumpType> {
console.log("选择版本递增类型:");
console.log(" 1) major - 不兼容的 API 变更");
console.log(" 2) minor - 向下兼容的功能新增");
console.log(" 3) patch - 向下兼容的问题修正");
while (true) {
const answer = await ask("请输入 1/2/3 [3]: ");
const choice = answer || "3";
if (choice === "1") return "major";
if (choice === "2") return "minor";
if (choice === "3") return "patch";
console.log("无效选择,请输入 1、2 或 3");
}
}
async function stepBumpVersion(): Promise<string> {
const pkgPath = join(import.meta.dir, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
throw new Error("package.json 中缺少有效的 version 字段");
}
const currentVersion = pkg.version;
const bumpType = await selectBumpType();
const newVersion = bumpVersion(currentVersion, bumpType);
const answer = await ask(`确认版本号 ${currentVersion}${newVersion}? [y/N]: `);
if (answer.toLowerCase() !== "y") {
console.log("已取消");
process.exit(0);
}
return newVersion;
}
async function runTests(): Promise<void> {
console.log("\n运行测试...");
const proc = Bun.spawn(["bun", "test"], {
stdio: ["inherit", "inherit", "inherit"],
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`测试失败 (exit code: ${exitCode}),已跳过 git 和发布步骤`);
}
}
async function checkWorkingTree(): Promise<void> {
const proc = Bun.spawn(["git", "status", "--porcelain"], {
stdio: ["inherit", "pipe", "inherit"],
});
const output = await new Response(proc.stdout).text();
const lines = output.trim().split("\n").filter(Boolean);
if (lines.length > 0) {
throw new Error("工作区有未提交变更,请先提交或清理后再运行 release");
}
}
async function stepGitCommitTag(version: string): Promise<void> {
console.log("\n准备提交:");
console.log(` git add package.json`);
console.log(` git commit -m "chore: release v${version}"`);
console.log(` git tag v${version}`);
const answer = await ask("确认执行以上 git 操作? [y/N]: ");
if (answer.toLowerCase() !== "y") {
console.log("已取消 git 操作");
process.exit(0);
}
// git add package.json
const addProc = Bun.spawn(["git", "add", "package.json"], {
stdio: ["inherit", "inherit", "inherit"],
});
const addExit = await addProc.exited;
if (addExit !== 0) {
throw new Error("git add 失败");
}
// git commit
const commitProc = Bun.spawn(["git", "commit", "-m", `chore: release v${version}`], {
stdio: ["inherit", "inherit", "inherit"],
});
const commitExit = await commitProc.exited;
if (commitExit !== 0) {
throw new Error("git commit 失败");
}
// git tag
const tagProc = Bun.spawn(
["git", "tag", "-a", `v${version}`, "-m", `chore: release v${version}`],
{
stdio: ["inherit", "inherit", "inherit"],
},
);
const tagExit = await tagProc.exited;
if (tagExit !== 0) {
throw new Error("git tag 失败");
}
console.log(`git commit 和 tag v${version} 已完成`);
}
async function stepNpmPublish(): Promise<void> {
console.log("\nnpm 发布预览:");
const dryRunProc = Bun.spawn(["bun", "publish", "--dry-run"], {
stdio: ["inherit", "inherit", "inherit"],
});
const dryRunExit = await dryRunProc.exited;
if (dryRunExit !== 0) {
throw new Error("npm publish --dry-run 失败");
}
const answer = await ask("确认发布到 npm? [y/N]: ");
if (answer.toLowerCase() !== "y") {
console.log("已取消发布");
process.exit(0);
}
const proc = Bun.spawn(["bun", "publish", "--access", "public"], {
stdio: ["inherit", "inherit", "inherit"],
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`npm publish 失败 (exit code: ${exitCode}),请检查 npm 登录状态 (npm whoami)`);
}
console.log("npm 发布成功");
}
function writeVersion(version: string): void {
const pkgPath = join(import.meta.dir, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
pkg.version = version;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
async function main(): Promise<void> {
await checkWorkingTree();
const newVersion = await stepBumpVersion();
console.log(`目标版本: ${newVersion}`);
await runTests();
console.log("[1/3] 测试通过");
writeVersion(newVersion);
console.log(`[2/3] 版本号已更新: ${newVersion}`);
await stepGitCommitTag(newVersion);
console.log(`[3/3] git commit 和 tag v${newVersion} 完成`);
await stepNpmPublish();
console.log("npm 发布完成");
}
if (import.meta.main) {
main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
}

View File

@@ -2,36 +2,98 @@ import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { STAGES } from "../types.ts"; import { STAGES } from "../types.ts";
import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".claude/commands"; const COMMANDS_DIR = ".claude/commands";
export async function injectClaudeCode(projectRoot: string): Promise<void> { const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
function buildSmartGuide(command: string): string {
return `如果用户没有指定变更名称,请按以下步骤智能识别:
1. 运行 \`${command} status\` 查看当前所有变更
2. 如果只有一个变更,直接使用该变更名
3. 如果有多个变更,根据上下文推断最可能的变更
4. 如果无法确定,向用户确认`;
}
export async function injectClaudeCode(
projectRoot: string,
command: string = "rune",
): Promise<void> {
for (const stage of STAGES) { for (const stage of STAGES) {
const hasChangeName = stage !== "discuss"; const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
const commandDir = join(projectRoot, COMMANDS_DIR); const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true }); await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`); const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) { if (!existsSync(commandPath)) {
const cmd = hasChangeName let content = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}`;
? `rune ${stage} <变更名>` if (stage === "plan") {
: `rune ${stage}`; content += `\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
const nameHint = hasChangeName }
? "\n如果用户没有指定变更名称请向用户确认。" if (stage === "task") {
: ""; content += `\n任务拆解前请确认规划文档已全部完成运行 \`${command} status <变更名>\` 检查。`;
await writeFile( }
commandPath, if (stage === "discuss") {
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`, content += `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
); }
await writeFile(commandPath, content + "\n");
} }
} }
const commandDir = join(projectRoot, COMMANDS_DIR); const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
const statusPath = join(commandDir, "rune-status.md"); if (!existsSync(introCommandPath)) {
if (!existsSync(statusPath)) { await mkdir(join(projectRoot, COMMANDS_DIR), { recursive: true });
await writeFile( await writeFile(introCommandPath, generateIntroCommand(command));
statusPath,
`执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`,
);
} }
} }
export async function updateClaudeCode(
projectRoot: string,
command: string = "rune",
): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
let newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}`;
if (stage === "plan") {
newContent += `\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
}
if (stage === "task") {
newContent += `\n任务拆解前请确认规划文档已全部完成运行 \`${command} status <变更名>\` 检查。`;
}
if (stage === "discuss") {
newContent += `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
}
await writeIfChanged(commandPath, newContent + "\n");
}
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
await writeIfChanged(introCommandPath, generateIntroCommand(command));
}
function generateIntroCommand(command: string): string {
return `Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。SDD 工作流程:
discuss → plan → task → build → archive
可用命令:
- /rune-discuss — 自由讨论需求和方案
- /rune-plan — 生成设计文档(新变更需先运行 \`${command} create <变更名>\` 创建目录)
- /rune-task — 根据设计文档生成任务清单
- /rune-build — 按任务清单逐步实现
- /rune-archive — 归档已完成的变更
查看当前状态:
\`\`\`bash
${command} status
\`\`\`
`;
}

View File

@@ -2,61 +2,100 @@ import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { STAGES } from "../types.ts"; import { STAGES } from "../types.ts";
import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".opencode/commands"; const COMMANDS_DIR = ".opencode/commands";
const SKILLS_DIR = ".opencode/skills"; const SKILLS_DIR = ".opencode/skills";
export async function injectOpenCode(projectRoot: string): Promise<void> { const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
for (const stage of STAGES) {
const commandDir = join(projectRoot, COMMANDS_DIR); const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true }); await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`); const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) { if (!existsSync(commandPath)) {
await writeFile(commandPath, generateCommand(stage, hasChangeName)); await writeFile(commandPath, generateCommand(stage));
} }
const skillDir = join(projectRoot, SKILLS_DIR); const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
await mkdir(skillDir, { recursive: true }); await mkdir(skillStageDir, { recursive: true });
const skillPath = join(skillDir, `rune-${stage}.md`); const skillPath = join(skillStageDir, "SKILL.md");
if (!existsSync(skillPath)) { if (!existsSync(skillPath)) {
await writeFile(skillPath, generateSkill(stage, hasChangeName)); await writeFile(skillPath, generateSkill(stage, command));
} }
} }
const commandDir = join(projectRoot, COMMANDS_DIR); const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
const statusCommandPath = join(commandDir, "rune-status.md"); await mkdir(introSkillDir, { recursive: true });
if (!existsSync(statusCommandPath)) { const introSkillPath = join(introSkillDir, "SKILL.md");
await writeFile(statusCommandPath, generateStatusCommand()); if (!existsSync(introSkillPath)) {
} await writeFile(introSkillPath, generateIntroSkill(command));
const skillDir = join(projectRoot, SKILLS_DIR);
const statusSkillPath = join(skillDir, "rune-status.md");
if (!existsSync(statusSkillPath)) {
await writeFile(statusSkillPath, generateStatusSkill());
} }
} }
function generateCommand(stage: string, hasChangeName: boolean): string { export async function updateOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
if (hasChangeName) { for (const stage of STAGES) {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。 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");
await writeIfChanged(introSkillPath, generateIntroSkill(command));
}
function generateCommand(stage: string): string {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。 return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
`; `;
} }
function generateSkill(stage: string, hasChangeName: boolean): string { function generateSkill(stage: string, command: string): string {
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`; const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const nameHint = hasChangeName const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
? `将 <变更名> 替换为实际的变更名称。\n`
: ""; let extraGuide = "";
if (stage === "plan") {
extraGuide = `\n规划阶段应先运行 \`${command} status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n\n如果变更目录尚不存在新变更请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。\n`;
}
if (stage === "task") {
extraGuide = `\n任务拆解阶段应先运行 \`${command} status <变更名>\` 确认规划文档已全部完成,再生成任务清单。\n`;
}
if (stage === "discuss") {
extraGuide = `\n讨论结束后如果确定了变更方向请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。\n`;
}
const descriptionMap: Record<string, string> = {
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
task: "Use when 需要进入 SDD 任务拆解阶段,根据设计文档生成任务清单",
build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更",
archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档",
};
let smartGuide = "";
if (hasChangeName) {
smartGuide = `如果用户没有指定变更名称,请按以下步骤智能识别:
1. 运行 \`${command} status\` 查看当前所有变更
2. 如果只有一个变更,直接使用该变更名
3. 如果有多个变更,根据上下文推断最可能的变更
4. 如果无法确定,向用户确认
`;
}
return `--- return `---
description: Rune SDD ${stage} 阶段 name: rune-${stage}
description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶段`}
--- ---
# ${stage} 阶段 # ${stage} 阶段
@@ -67,28 +106,49 @@ description: Rune SDD ${stage} 阶段
${cmd} ${cmd}
\`\`\` \`\`\`
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。 ${smartGuide}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
`; `;
} }
function generateStatusCommand(): string { function generateIntroSkill(command: string): string {
return `请调用 rune-status skill 查看当前所有变更状态。
`;
}
function generateStatusSkill(): string {
return `--- return `---
description: 查看所有 Rune 变更状态 name: rune-intro
description: Use when 用户首次接触 Rune需要了解 SDD 工作流程和使用方式
--- ---
# 状态查看 # Rune 简介
执行以下命令: Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。它通过结构化的阶段流程,帮助 AI 编辑器和开发者高效协作。
\`\`\`bash ## SDD 工作流程
rune status
\`\`\`
discuss → plan → task → build → archive
探索 规划 任务 构建 归档
\`\`\` \`\`\`
将命令输出展示给用户。 ## 可用命令
| 阶段 | 编辑器命令 | 说明 |
|------|-----------|------|
| discuss | /rune-discuss | 自由讨论需求和方案 |
| plan | /rune-plan | 生成设计文档和任务清单 |
| task | /rune-task | 根据设计文档生成任务清单 |
| build | /rune-build | 按任务清单逐步实现 |
| archive | /rune-archive | 归档已完成的变更 |
查看当前状态:
\`\`\`bash
${command} status
\`\`\`
## 快速开始
1. 使用 /rune-discuss 进入讨论,自由探索需求
2. 讨论结束后,运行 \`${command} create <变更名>\` 创建变更目录,然后用 /rune-plan 生成设计文档
3. 使用 /rune-task 根据设计文档生成任务清单
4. 使用 /rune-build 按任务顺序实现功能
5. 使用 /rune-archive 归档已完成的变更
`; `;
} }

13
src/adapters/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
export async function writeIfChanged(filePath: string, newContent: string): Promise<void> {
try {
const existing = await readFile(filePath, "utf-8");
if (existing === newContent) {
return;
}
} catch {
// 文件不存在,创建
}
await writeFile(filePath, newContent);
}

View File

@@ -2,102 +2,409 @@
import { cac } from "cac"; import { cac } from "cac";
import { join } from "node:path"; import { join } from "node:path";
import { mkdir, rename } from "node:fs/promises"; import { mkdir, rename } from "node:fs/promises";
import { runInit } from "./commands/init.ts"; import { readFileSync, existsSync } from "node:fs";
import { runInit, ensureMetadataCommand } from "./commands/init.ts";
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts"; import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
import { import {
assembleDiscussPrompt, assembleDiscussPrompt,
assemblePlanPrompt, assemblePlanPrompt,
assembleTaskPrompt,
assembleBuildPrompt, assembleBuildPrompt,
assembleArchivePrompt, assembleArchivePrompt,
} from "./core/assembler.ts"; } from "./core/assembler.ts";
import { scanChanges } from "./core/scanner.ts"; import { scanChanges } from "./core/scanner.ts";
import { UsageError, ConfigError, CommandError, InternalError, CliError } from "./cli/errors.ts";
import { printError } from "./cli/output.ts";
import { showGlobalHelp, showCommandHelp } from "./cli/help.ts";
import { getPmPrefix, DEFAULT_PREFIX, detectCommandPrefix } from "./core/pm.ts";
import type { ChangeStatus, RuneConfig } from "./types.ts";
function requireProjectRoot(): string {
const root = findProjectRoot();
if (!root) {
const prefix = getPmPrefix();
throw new ConfigError("当前项目未初始化", { hint: `请先运行 ${prefix} init` });
}
return root;
}
export function validateChangeName(name: string): void {
if (!/^[\u4e00-\u9fa5a-zA-Z-]+$/.test(name)) {
throw new CommandError(`变更名 "${name}" 包含不支持的字符`, {
hint: "变更名仅支持中文、英文和短横线(-",
});
}
}
export function formatChangeStatus(change: ChangeStatus, config?: RuneConfig): string {
const lines: string[] = [];
lines.push(`变更:${change.name}`);
lines.push(" 规划阶段:");
const planDocs = config?.stages.plan?.documents;
for (const doc of change.documents) {
if (doc.completed) {
lines.push(` ${doc.name}.md ✓ 已完成`);
} else {
const docConfig = planDocs?.find((d) => d.name === doc.name);
const depInfo =
!doc.dependMet && docConfig?.depend?.length
? `(依赖 ${docConfig.depend.map((d) => `${d}.md`).join("、")}`
: "";
lines.push(` ${doc.name}.md ○ 待完成${depInfo}`);
}
}
const completedCount = change.documents.filter((d) => d.completed).length;
lines.push(` 规划进度:${completedCount}/${change.documents.length} 文档已完成`);
if (change.buildUnlocked) {
lines.push(" 构建阶段:已解锁");
} else {
lines.push(" 构建阶段:未解锁(需完成规划)");
}
if (change.taskProgress) {
lines.push(` 任务进度:${change.taskProgress.completed}/${change.taskProgress.total} 已完成`);
}
lines.push("");
lines.push(` 建议下一步:${suggestNextStep(change, config)}`);
return lines.join("\n");
}
export function suggestNextStep(change: ChangeStatus, config?: RuneConfig): string {
const prefix = getPmPrefix(config);
if (!change.planCompleted) {
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
if (nextDoc) {
return `${prefix} plan ${change.name} ${nextDoc.name}`;
}
return `完成前置依赖后再规划文档`;
}
if (!change.taskProgress) {
return `${prefix} task ${change.name}`;
}
if (change.taskProgress.completed < change.taskProgress.total) {
return `${prefix} build ${change.name}`;
}
return `${prefix} archive ${change.name}`;
}
const cli = cac("rune"); const cli = cac("rune");
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action( cli.command("", "").action(async () => {
async (tools: string[]) => { const root = findProjectRoot();
if (!tools || tools.length === 0) { let prefix = getPmPrefix();
console.error("请指定至少一个工具rune init opencode"); if (root) {
process.exit(1); try {
const config = await loadConfig(root);
prefix = getPmPrefix(config);
} catch {}
}
console.log(showGlobalHelp(prefix));
});
cli.command("help [command]", "显示帮助信息").action(async (command?: string) => {
const root = findProjectRoot();
let prefix = getPmPrefix();
if (root) {
try {
const config = await loadConfig(root);
prefix = getPmPrefix(config);
} catch {}
}
if (command) {
const output = showCommandHelp(command, prefix);
if (!output) {
throw new UsageError(`未知命令: ${command}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
} }
await runInit(process.cwd(), tools); console.log(output);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`); } else {
}, console.log(showGlobalHelp(prefix));
); }
});
cli.command("version", "显示版本号").action(() => {
try {
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../package.json"), "utf-8"));
console.log(`rune v${pkg.version}`);
} catch {
console.log("rune (未知版本)");
}
});
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
const prefix = getPmPrefix();
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: `${prefix} init <工具...>`,
hint: `如:${prefix} init opencode`,
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
});
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
const prefix = getPmPrefix();
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: `${prefix} update <工具...>`,
hint: `如:${prefix} update opencode`,
});
}
const root = requireProjectRoot();
const { updateOpenCode } = await import("./adapters/opencode.ts");
const { updateClaudeCode } = await import("./adapters/claude-code.ts");
const { SUPPORTED_TOOLS } = await import("./commands/init.ts");
const updaters: Record<string, (root: string, command?: string) => Promise<void>> = {
opencode: updateOpenCode,
"claude-code": updateClaudeCode,
};
for (const tool of tools) {
if (!SUPPORTED_TOOLS[tool]) {
throw new CommandError(`不支持的工具: ${tool}`, {
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
});
}
}
let config: RuneConfig | undefined;
try {
config = await loadConfig(root);
} catch {}
const command = getPmPrefix(config);
if (!config?.metadata?.command) {
const detected = await detectCommandPrefix();
if (detected) {
const { join: joinPath } = await import("node:path");
const configPath = joinPath(root, ".rune", "config.yaml");
try {
await ensureMetadataCommand(configPath, detected);
} catch {}
}
}
for (const tool of tools) {
await updaters[tool](root, command === DEFAULT_PREFIX ? undefined : command);
}
console.log(`工具配置已更新:${tools.join(", ")}`);
});
cli.command("discuss", "讨论阶段").action(async () => { cli.command("discuss", "讨论阶段").action(async () => {
const root = findProjectRoot(); const root = requireProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
}
const config = await loadConfig(root); const config = await loadConfig(root);
const prompt = assembleDiscussPrompt(config); const prompt = assembleDiscussPrompt(config);
console.log(prompt); console.log(prompt);
}); });
cli cli.command("create <change-name>", "创建变更").action(async (changeName: string) => {
.command("plan <change-name>", "规划阶段") validateChangeName(changeName);
.action(async (changeName: string) => { const root = requireProjectRoot();
const root = findProjectRoot(); const config = await loadConfig(root);
if (!root) { const changeDir = getChangeDir(root, changeName);
console.error("未找到 .rune 目录,请先运行 rune init"); if (existsSync(changeDir)) {
process.exit(1); throw new CommandError(`变更 "${changeName}" 已存在`, {
} hint: `请使用其他名称,或运行 ${getPmPrefix(config)} status 查看现有变更`,
await mkdir(getChangeDir(root, changeName), { recursive: true }); });
const config = await loadConfig(root);
const prompt = await assemblePlanPrompt(config, root, changeName);
console.log(prompt);
});
cli
.command("build <change-name>", "构建阶段")
.action(async (changeName: string) => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
}
const config = await loadConfig(root);
const prompt = await assembleBuildPrompt(config, root, changeName);
console.log(prompt);
});
cli
.command("archive <change-name>", "归档阶段")
.action(async (changeName: string) => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
}
const config = await loadConfig(root);
const prompt = assembleArchivePrompt(config, changeName);
const today = new Date().toISOString().slice(0, 10);
const src = getChangeDir(root, changeName);
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
await rename(src, dest);
console.log(prompt);
});
cli.command("status", "查看变更状态").action(async () => {
const root = findProjectRoot();
if (!root) {
console.error("未找到 .rune 目录,请先运行 rune init");
process.exit(1);
} }
const changes = await scanChanges(root); await mkdir(changeDir, { recursive: true });
if (changes.length === 0) { const prefix = getPmPrefix(config);
console.log("当前无进行中的变更"); console.log(`变更 "${changeName}" 已创建。
return;
下一步:${prefix} plan ${changeName} <文档名>`);
});
cli
.command("plan <change-name> <document-name>", "规划阶段")
.action(async (changeName: string, documentName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const config = await loadConfig(root);
const planDocs = config.stages.plan?.documents;
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
throw new CommandError(`文档"${documentName}"不在配置的规划阶段文档列表中`, {
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
});
}
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix(config);
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const doc = planDocs.find((d) => d.name === documentName)!;
if (doc.depend && doc.depend.length > 0) {
const missing = doc.depend.filter((dep) => !existsSync(join(changeDir, `${dep}.md`)));
if (missing.length > 0) {
throw new CommandError(
`文档"${documentName}"的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
{
hint: `请先完成依赖文档:${getPmPrefix(config)} plan ${changeName} ${missing[0]}`,
},
);
}
}
const prompt = await assemblePlanPrompt(config, root, changeName, documentName);
console.log(prompt);
});
cli.command("task <change-name>", "任务拆解阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
} }
for (const change of changes) { const config = await loadConfig(root);
const progress = change.taskProgress const prompt = await assembleTaskPrompt(config, root, changeName);
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)` console.log(prompt);
: ""; });
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleBuildPrompt(config, root, changeName);
console.log(prompt);
});
cli.command("archive <change-name>", "归档阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleArchivePrompt(config, root, changeName);
console.log(prompt);
});
cli.command("finish <change-name>", "归档变更").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const today = new Date().toISOString().slice(0, 10);
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
if (existsSync(dest)) {
throw new CommandError(`归档目标 "${today}-${changeName}" 已存在`, {
hint: `同一天同一变更名只能归档一次。如需重新归档,请先删除 .rune/archive/${today}-${changeName} 目录`,
});
}
await rename(changeDir, dest);
console.log(`变更 "${changeName}" 已归档到 .rune/archive/${today}-${changeName}`);
});
cli.command("status [change-name]", "查看变更状态").action(async (changeName?: string) => {
const root = requireProjectRoot();
const config = await loadConfig(root);
const prefix = getPmPrefix(config);
const changes = await scanChanges(root, config);
if (changeName) {
const change = changes.find((c) => c.name === changeName);
if (!change) {
throw new CommandError(`变更 "${changeName}" 不存在`, {
hint: `运行 ${prefix} status 查看所有变更`,
});
}
console.log(formatChangeStatus(change, config));
} else {
if (changes.length === 0) {
console.log("当前无进行中的变更。");
return;
}
for (const change of changes) {
console.log(formatChangeStatus(change, config));
console.log("---\n");
}
} }
}); });
cli.help(); export function mapError(e: unknown): CliError {
cli.parse(); if (e instanceof CliError) {
return e;
}
if (e instanceof Error) {
const err = mapCacError(e);
if (err) return err;
}
return new InternalError(e);
}
function mapCacError(e: Error): CliError | null {
const prefix = getPmPrefix();
if (e.message.includes("Unknown option")) {
const match = e.message.match(/Unknown option `([^`]+)`/);
const flag = match ? match[1] : "未知选项";
return new UsageError(`未知选项: ${flag}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("Unknown command")) {
const match = e.message.match(/Unknown command `([^`]+)`/);
const cmd = match ? match[1] : "未知命令";
return new UsageError(`未知命令: ${cmd}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("Unused args")) {
const match = e.message.match(/Unused args: (.+)/);
const args = match ? match[1] : "未知参数";
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("missing required args")) {
const match = e.message.match(/command `(\w+)/);
const cmd = match ? match[1] : "未知命令";
return new UsageError(`命令"${cmd}"缺少必填参数`, {
usage: `${prefix} ${cmd} <change-name>`,
hint: `运行 ${prefix} help ${cmd} 查看用法`,
});
}
return null;
}
function handleError(e: unknown): never {
printError(mapError(e));
}
process.on("unhandledRejection", (e) => {
handleError(e);
});
try {
cli.parse();
} catch (e) {
handleError(e);
}

34
src/cli/errors.ts Normal file
View File

@@ -0,0 +1,34 @@
export class CliError extends Error {
readonly hint?: string;
readonly usage?: string;
constructor(message: string, opts?: { hint?: string; usage?: string }) {
super(message);
this.name = this.constructor.name;
this.hint = opts?.hint;
this.usage = opts?.usage;
}
}
export class UsageError extends CliError {}
export class ConfigError extends CliError {}
export class CommandError extends CliError {}
export class InternalError extends CliError {
readonly cause?: Error;
constructor(originalError?: unknown) {
const message =
originalError instanceof Error
? `发生了未预期的错误:${originalError.message}`
: "发生了未预期的错误";
const hint =
originalError instanceof Error
? `错误类型:${originalError.constructor.name}\n调用栈${originalError.stack ?? "无"}`
: undefined;
super(message, { hint });
this.cause = originalError instanceof Error ? originalError : undefined;
}
}

173
src/cli/help.ts Normal file
View File

@@ -0,0 +1,173 @@
interface CommandHelpDef {
name: string;
alias: string;
description: string;
usageTemplate: string;
args: { name: string; desc: string }[];
detail: string;
exampleArgs: string[];
}
const COMMANDS: Record<string, CommandHelpDef> = {
init: {
name: "init",
alias: "init <工具...>",
description: "初始化 Rune 并注入工具配置",
usageTemplate: "init <工具...>",
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
exampleArgs: ["init opencode", "init opencode claude-code"],
},
discuss: {
name: "discuss",
alias: "discuss",
description: "讨论:生成讨论阶段提示词",
usageTemplate: "discuss",
args: [],
detail: "生成 SDD 流程中讨论阶段的提示词,输出到标准输出。需要先运行 init 初始化项目。",
exampleArgs: ["discuss"],
},
create: {
name: "create",
alias: "create <变更>",
description: "创建变更目录plan 阶段的辅助命令)",
usageTemplate: "create <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail:
"在 .rune/changes/ 下创建以变更名称命名的目录。这不是独立的 SDD 阶段,而是为 plan 阶段准备变更工作区的工具命令。使用 plan 前必须先 create 创建变更目录。",
exampleArgs: ["create add-user-auth", "create fix-memory-leak"],
},
plan: {
name: "plan",
alias: "plan <变更> <文档>",
description: "规划:生成指定文档的规划提示词",
usageTemplate: "plan <change-name> <document-name>",
args: [
{ name: "<change-name>", desc: '变更名称,如 "add-login"' },
{ name: "<document-name>", desc: '文档名称,如 "design"' },
],
detail:
"生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。",
exampleArgs: ["plan add-user-auth design", "plan add-user-auth design"],
},
task: {
name: "task",
alias: "task <变更>",
description: "任务拆解:根据规划文档生成任务清单",
usageTemplate: "task <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail:
"根据规划阶段的文档内容,生成 checkbox 格式的任务清单task.md。规划阶段的所有文档必须已完成。",
exampleArgs: ["task add-user-auth", "task fix-memory-leak"],
},
build: {
name: "build",
alias: "build <名称>",
description: "构建:生成构建阶段提示词",
usageTemplate: "build <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail:
"按 task.md 中的任务顺序逐步实现。需先完成任务拆解阶段task。可多次执行直到全部完成。",
exampleArgs: ["build add-user-auth", "build fix-memory-leak"],
},
archive: {
name: "archive",
alias: "archive <名称>",
description: "归档:生成归档阶段提示词",
usageTemplate: "archive <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail:
"生成归档阶段的提示词。变更目录需已存在(通过 create 创建)。此命令不移动文件,仅输出提示词。",
exampleArgs: ["archive add-user-auth", "archive fix-memory-leak"],
},
finish: {
name: "finish",
alias: "finish <名称>",
description: "归档变更:将变更目录移动到 archive",
usageTemplate: "finish <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail:
"将变更目录从 .rune/changes/ 移动到 .rune/archive/<日期>-<变更名>。这不是独立的 SDD 阶段,而是 archive 阶段中实际执行磁盘归档的工具命令。",
exampleArgs: ["finish add-user-auth", "finish fix-memory-leak"],
},
update: {
name: "update",
alias: "update <工具...>",
description: "更新:更新已注入的编辑器配置",
usageTemplate: "update <工具...>",
args: [{ name: "<工具...>", desc: "要更新的 AI 工具,如 opencode、claude-code" }],
detail:
"对比已注入的命令和 skill 文件,与内置版本不一致时覆盖,不存在时新建。用于升级 Rune 后同步编辑器配置。",
exampleArgs: ["update opencode", "update opencode claude-code"],
},
status: {
name: "status",
alias: "status [变更]",
description: "查看:展示变更状态与下一步建议",
usageTemplate: "status [change-name]",
args: [{ name: "[change-name]", desc: "可选,指定查看的变更名称" }],
detail: "展示各文档完成状态、依赖满足情况、规划进度和下一步建议。不传参数则显示所有变更。",
exampleArgs: ["status", "status add-user-auth"],
},
};
export function showGlobalHelp(prefix: string = "rune"): string {
const lines: string[] = [
"Rune — 基于规格驱动开发SDD的 AI 开发辅助工具",
"",
"用法:",
` ${prefix} <命令> [参数]`,
"",
"命令:",
];
const entries = Object.values(COMMANDS);
const maxAliasLen = Math.max(...entries.map((c) => c.alias.length));
for (const cmd of entries) {
lines.push(` ${cmd.alias.padEnd(maxAliasLen)} ${cmd.description}`);
}
lines.push(` ${"help".padEnd(maxAliasLen)} 显示帮助信息`);
lines.push(` ${"version".padEnd(maxAliasLen)} 显示版本号`);
lines.push("");
lines.push("示例:");
lines.push(` ${prefix} init opencode 初始化并注入 OpenCode 配置`);
lines.push(` ${prefix} update opencode 更新 OpenCode 配置`);
lines.push(` ${prefix} plan add-login design 规划 "add-login" 的设计文档`);
lines.push(` ${prefix} status 查看当前变更状态`);
return lines.join("\n");
}
export function showCommandHelp(name: string, prefix: string = "rune"): string | null {
const cmd = COMMANDS[name];
if (!cmd) return null;
const lines: string[] = [
`${prefix} ${cmd.name}${cmd.description}`,
"",
"用法:",
` ${prefix} ${cmd.usageTemplate}`,
];
if (cmd.args.length > 0) {
lines.push("");
lines.push("参数:");
for (const arg of cmd.args) {
lines.push(` ${arg.name} ${arg.desc}`);
}
}
lines.push("");
lines.push("描述:");
lines.push(` ${cmd.detail}`);
lines.push("");
lines.push("示例:");
for (const ex of cmd.exampleArgs) {
lines.push(` ${prefix} ${ex}`);
}
return lines.join("\n");
}

20
src/cli/output.ts Normal file
View File

@@ -0,0 +1,20 @@
import { CliError } from "./errors.ts";
export function formatError(error: CliError): string {
const parts: string[] = [`错误: ${error.message}`];
if (error.usage) {
parts.push(`用法: ${error.usage}`);
}
if (error.hint) {
parts.push(`提示: ${error.hint}`);
}
return parts.join("\n\n");
}
export function printError(error: CliError): never {
process.stderr.write(formatError(error) + "\n");
process.exit(1);
}

View File

@@ -1,22 +1,74 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts"; import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
import { injectOpenCode } from "../adapters/opencode.ts"; import { injectOpenCode } from "../adapters/opencode.ts";
import { injectClaudeCode } from "../adapters/claude-code.ts"; import { injectClaudeCode } from "../adapters/claude-code.ts";
import { CommandError } from "../cli/errors.ts";
import { detectCommandPrefix, getPmPrefix } from "../core/pm.ts";
import { parse as parseYaml } from "yaml";
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = { const CONFIG_TEMPLATE = `# Rune 配置文件
#
# 未配置的阶段将使用内置默认配置。
# SDD 五阶段discuss -> plan -> task -> build -> archive
# 辅助命令create创建变更目录plan 阶段的前置步骤)
#
# 可配置阶段:
# discuss - 探索阶段:深度思考、调查代码库、对比方案
# plan - 规划阶段:生成设计文档
# task - 任务拆解阶段:根据设计文档生成 checkbox 任务清单
# build - 构建阶段:按任务清单逐步实现
# archive - 归档阶段:确认完成并归档变更
#
# 示例 - 自定义讨论阶段提示词:
# stages:
# discuss:
# prompt: |
# 进入探索模式。深度思考,自由发散。跟随对话走向。
#
# 示例 - 自定义规划阶段的文档模板:
# stages:
# plan:
# documents:
# - name: design
# prompt: 生成设计文档
# template: |
# # 设计文档
#
# metadata 说明:
# command - Rune CLI 执行命令(如 bunx @lanyuanxiaoyao/runeinit 时自动检测
`;
export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) => Promise<void>> = {
opencode: injectOpenCode, opencode: injectOpenCode,
"claude-code": injectClaudeCode, "claude-code": injectClaudeCode,
}; };
export async function runInit( export async function ensureMetadataCommand(configPath: string, command: string): Promise<void> {
projectRoot: string, const content = await readFile(configPath, "utf-8");
tools: string[], const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
): Promise<void> { if (parsed?.metadata?.command) return;
if (parsed?.metadata) {
const lines = content.split("\n");
const metaIdx = lines.findIndex((l) => l.trim() === "metadata:");
if (metaIdx >= 0) {
lines.splice(metaIdx + 1, 0, ` command: "${command}"`);
await writeFile(configPath, lines.join("\n"), "utf-8");
return;
}
}
await appendFile(configPath, `\nmetadata:\n command: "${command}"\n`);
}
export async function runInit(projectRoot: string, tools: string[]): Promise<void> {
for (const tool of tools) { for (const tool of tools) {
if (!SUPPORTED_TOOLS[tool]) { if (!SUPPORTED_TOOLS[tool]) {
throw new Error(`不支持的工具: ${tool}`); throw new CommandError(`不支持的工具: ${tool}`, {
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
});
} }
} }
@@ -27,10 +79,16 @@ export async function runInit(
const configPath = join(runeDir, CONFIG_FILE); const configPath = join(runeDir, CONFIG_FILE);
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
await writeFile(configPath, "stages: {}\n", "utf-8"); await writeFile(configPath, CONFIG_TEMPLATE, "utf-8");
} }
const command = await detectCommandPrefix();
if (command) {
await ensureMetadataCommand(configPath, command);
}
const prefix = command ?? getPmPrefix();
for (const tool of tools) { for (const tool of tools) {
await SUPPORTED_TOOLS[tool](projectRoot); await SUPPORTED_TOOLS[tool](projectRoot, prefix === "rune" ? undefined : prefix);
} }
} }

View File

@@ -2,49 +2,127 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { RuneConfig } from "../types.ts"; import type { RuneConfig } from "../types.ts";
import { CommandError } from "../cli/errors.ts";
import { getChangeDir } from "./config.ts"; import { getChangeDir } from "./config.ts";
import { parseTasks } from "./task-parser.ts"; import { parseTasks, validateTaskFormat, TaskFormatError } from "./task-parser.ts";
import { applyCommandPrefix, getPmPrefix } from "./pm.ts";
export function assembleDiscussPrompt(config: RuneConfig): string { export function assembleDiscussPrompt(config: RuneConfig): string {
const discuss = config.stages.discuss; const discuss = config.stages.discuss;
if (!discuss) throw new Error("discuss 阶段未配置"); if (!discuss)
return discuss.prompt; throw new CommandError("讨论阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
});
return applyCommandPrefix(discuss.prompt, config);
} }
export async function assemblePlanPrompt( export async function assemblePlanPrompt(
config: RuneConfig, config: RuneConfig,
projectRoot: string, projectRoot: string,
changeName: string, changeName: string,
documentName: string,
): Promise<string> { ): Promise<string> {
const plan = config.stages.plan; const plan = config.stages.plan;
if (!plan) throw new Error("plan 阶段未配置"); if (!plan)
throw new CommandError("规划阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.plan",
});
const doc = plan.documents.find((d) => d.name === documentName);
if (!doc) {
throw new CommandError(`文档"${documentName}"不在配置的规划阶段文档列表中`, {
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
});
}
const changeDir = getChangeDir(projectRoot, changeName); const changeDir = getChangeDir(projectRoot, changeName);
const parts: string[] = []; const parts: string[] = [];
parts.push(`# 规划阶段:${changeName}\n`); parts.push(`# 规划阶段:${changeName}`);
parts.push("请为当前变更生成以下文档:\n"); parts.push("");
parts.push(`## 文档:${doc.name}.md`);
parts.push(doc.prompt);
for (const doc of plan.documents) { const docPath = join(changeDir, `${doc.name}.md`);
parts.push(`## 文档:${doc.name}.md`); if (existsSync(docPath)) {
parts.push(doc.prompt); parts.push(`\n文档已存在请先读取 ${docPath} 查看已有内容,在此基础上修订。`);
const docPath = join(changeDir, `${doc.name}.md`);
if (existsSync(docPath)) {
const existing = await readFile(docPath, "utf-8");
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
}
if (doc.template) {
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
parts.push(`\n### 格式模板:\n${rendered}`);
}
parts.push("");
} }
parts.push(`请将文档写入目录:${changeDir}`); if (doc.template) {
return parts.join("\n"); parts.push(`\n### 格式模板:\n${doc.template}`);
}
if (doc.depend && doc.depend.length > 0) {
parts.push("\n---\n");
parts.push("## 依赖说明\n");
parts.push("本文档依赖以下前置文档:");
for (const dep of doc.depend) {
const depPath = join(changeDir, `${dep}.md`);
const depCompleted = existsSync(depPath);
const status = depCompleted ? "已完成" : "未完成";
parts.push(`- ${dep}.md${status}`);
}
parts.push("\n请先阅读已完成的前置文档确保内容一致。");
}
parts.push(`\n请将文档写入目录${changeDir}`);
return applyCommandPrefix(parts.join("\n"), config);
}
export async function assembleTaskPrompt(
config: RuneConfig,
projectRoot: string,
changeName: string,
): Promise<string> {
const task = config.stages.task;
if (!task) {
throw new CommandError("任务拆解阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.task",
});
}
const plan = config.stages.plan;
if (!plan) {
throw new CommandError("规划阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.plan",
});
}
const changeDir = getChangeDir(projectRoot, changeName);
const missingDocs: string[] = [];
for (const doc of plan.documents) {
const docPath = join(changeDir, `${doc.name}.md`);
if (!existsSync(docPath)) {
missingDocs.push(`${doc.name}.md`);
}
}
if (missingDocs.length > 0) {
throw new CommandError(`变更"${changeName}"的规划文档尚未全部完成:${missingDocs.join("、")}`, {
hint: `请先完成规划阶段rune plan ${changeName} ${missingDocs[0].replace(".md", "")}`,
});
}
const parts: string[] = [];
parts.push(`# 任务拆解阶段:${changeName}\n`);
parts.push(task.prompt);
const planDocPaths = plan.documents.map((d) => join(changeDir, `${d.name}.md`));
parts.push(`\n请先读取以下规划文档`);
for (const p of planDocPaths) {
parts.push(`- ${p}`);
}
const taskPath = join(changeDir, "task.md");
if (existsSync(taskPath)) {
parts.push(`\ntask.md 已存在,请先读取 ${taskPath} 查看已有内容,在此基础上修订。`);
}
parts.push(`\n请将任务列表写入 ${taskPath}`);
return applyCommandPrefix(parts.join("\n"), config);
} }
export async function assembleBuildPrompt( export async function assembleBuildPrompt(
@@ -53,7 +131,11 @@ export async function assembleBuildPrompt(
changeName: string, changeName: string,
): Promise<string> { ): Promise<string> {
const build = config.stages.build; const build = config.stages.build;
if (!build) throw new Error("build 阶段未配置"); if (!build) {
throw new CommandError("构建阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.build",
});
}
const changeDir = getChangeDir(projectRoot, changeName); const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md"); const taskPath = join(changeDir, "task.md");
@@ -62,7 +144,21 @@ export async function assembleBuildPrompt(
try { try {
taskContent = await readFile(taskPath, "utf-8"); taskContent = await readFile(taskPath, "utf-8");
} catch { } catch {
throw new Error(`task.md not found in ${changeDir}`); const prefix = getPmPrefix(config);
throw new CommandError(`变更"${changeName}"尚未完成任务拆解task.md 不存在`, {
hint: `请先完成任务拆解阶段:${prefix} task ${changeName}`,
});
}
try {
validateTaskFormat(taskContent);
} catch (e) {
if (e instanceof TaskFormatError) {
throw new CommandError(e.message, {
hint: `请确保 ${taskPath} 中包含格式正确的 checkbox 任务项(如 - [ ] 任务描述)`,
});
}
throw e;
} }
const tasks = parseTasks(taskContent); const tasks = parseTasks(taskContent);
@@ -75,28 +171,53 @@ export async function assembleBuildPrompt(
const parts: string[] = []; const parts: string[] = [];
parts.push(`# 构建阶段:${changeName}\n`); parts.push(`# 构建阶段:${changeName}\n`);
parts.push(build.prompt); parts.push(build.prompt);
parts.push(`\n## 任务列表\n`); parts.push(`\n请先读取 ${taskPath} 查看任务列表。`);
parts.push(taskContent); parts.push(`当前有 ${pendingTasks.length} 个待执行任务,从第一个未完成的任务开始。`);
parts.push(`\n## 待执行任务(共 ${pendingTasks.length} 项)`); parts.push(`完成后更新 ${taskPath} 中的 checkbox。`);
for (const task of pendingTasks) {
parts.push(`- [ ] ${task.text}`);
}
parts.push(
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
);
return parts.join("\n"); return applyCommandPrefix(parts.join("\n"), config);
} }
export function assembleArchivePrompt( export async function assembleArchivePrompt(
config: RuneConfig, config: RuneConfig,
projectRoot: string,
changeName: string, changeName: string,
): string { ): Promise<string> {
const archive = config.stages.archive; const archive = config.stages.archive;
if (!archive) throw new Error("archive 阶段未配置"); if (!archive)
throw new CommandError("归档阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.archive",
});
const parts: string[] = []; const parts: string[] = [];
parts.push(`# 归档阶段:${changeName}\n`); parts.push(`# 归档阶段:${changeName}\n`);
const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md");
if (existsSync(taskPath)) {
try {
const taskContent = await readFile(taskPath, "utf-8");
const tasks = parseTasks(taskContent);
const incompleteTasks = tasks.filter((t) => !t.checked);
if (incompleteTasks.length > 0) {
parts.push("## ⚠️ 警告:存在未完成的任务\n");
parts.push("以下任务尚未完成:");
for (const t of incompleteTasks) {
parts.push(`- [ ] ${t.text}`);
}
parts.push("");
parts.push("询问用户是否确认在任务未全部完成的情况下归档。");
parts.push("如用户确认,则继续执行归档操作;否则中止并返回构建阶段。");
parts.push("");
}
} catch (e) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== "ENOENT" && code !== "ENOTDIR") {
throw e;
}
}
}
parts.push(archive.prompt); parts.push(archive.prompt);
return parts.join("\n"); return applyCommandPrefix(parts.join("\n"), config);
} }

View File

@@ -3,12 +3,12 @@ import { readFile } from "node:fs/promises";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { parse as parseYaml } from "yaml"; import { parse as parseYaml } from "yaml";
import { defaultConfig } from "../defaults/config.ts"; import { defaultConfig } from "../defaults/config.ts";
import { ConfigError } from "../cli/errors.ts";
import { getPmPrefix } from "./pm.ts";
import type { RuneConfig } from "../types.ts"; import type { RuneConfig } from "../types.ts";
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts"; import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
export function findProjectRoot( export function findProjectRoot(startDir: string = process.cwd()): string | null {
startDir: string = process.cwd(),
): string | null {
let dir = startDir; let dir = startDir;
while (true) { while (true) {
if (existsSync(join(dir, RUNE_DIR))) { if (existsSync(join(dir, RUNE_DIR))) {
@@ -22,18 +22,83 @@ export function findProjectRoot(
export async function loadConfig(projectRoot: string): Promise<RuneConfig> { export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE); const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
let merged: RuneConfig;
try { try {
const content = await readFile(configPath, "utf-8"); const content = await readFile(configPath, "utf-8");
const userConfig = parseYaml(content) as Partial<RuneConfig> | null; const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
return mergeConfig(userConfig ?? {}); merged = mergeConfig(userConfig ?? {});
} catch { } catch (e) {
return mergeConfig({}); const code = (e as NodeJS.ErrnoException)?.code;
if (code === "ENOENT" || code === "ENOTDIR") {
merged = mergeConfig({});
} else {
throw new ConfigError(`配置文件加载失败:${configPath}\n${(e as Error).message}`, {
hint: `请检查 .rune/config.yaml 的格式是否正确。常见问题:\n - YAML 缩进必须使用空格,不能用 Tab\n - 字符串包含特殊字符时需要引号包裹\n - 运行 ${getPmPrefix()} init 重新生成默认配置`,
});
}
}
validateConfig(merged);
return merged;
}
export function validateConfig(config: RuneConfig): void {
const plan = config.stages.plan;
if (!plan) return;
const docNames = new Set(plan.documents.map((d) => d.name));
for (const doc of plan.documents) {
if (!doc.depend || doc.depend.length === 0) continue;
for (const dep of doc.depend) {
if (dep === doc.name) {
throw new ConfigError(`文档"${doc.name}"不能依赖自身`, {
hint: `请从文档"${doc.name}"的 depend 列表中移除自身引用`,
});
}
if (!docNames.has(dep)) {
throw new ConfigError(`文档"${doc.name}"依赖的"${dep}"不存在于规划阶段的文档列表中`, {
hint: `请在 stages.plan.documents 中添加文档"${dep}",或从文档"${doc.name}"的 depend 列表中移除"${dep}"`,
});
}
}
}
const visited = new Set<string>();
const path: string[] = [];
function hasCycle(name: string): boolean {
if (path.includes(name)) {
path.push(name);
return true;
}
if (visited.has(name)) return false;
visited.add(name);
path.push(name);
const doc = plan!.documents.find((d) => d.name === name);
if (doc?.depend) {
for (const dep of doc.depend) {
if (hasCycle(dep)) return true;
}
}
path.pop();
return false;
}
for (const doc of plan.documents) {
path.length = 0;
if (hasCycle(doc.name)) {
throw new ConfigError(`文档间存在循环依赖:${path.join(" → ")}`, {
hint: "请检查文档的 depend 配置,移除形成环路的依赖关系",
});
}
} }
} }
function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig { function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
const result: RuneConfig = { stages: {} }; const result: RuneConfig = { stages: {} };
const stageKeys = ["discuss", "plan", "build", "archive"] as const; const stageKeys = ["discuss", "plan", "task", "build", "archive"] as const;
for (const stage of stageKeys) { for (const stage of stageKeys) {
if (userConfig.stages?.[stage]) { if (userConfig.stages?.[stage]) {
@@ -43,6 +108,11 @@ function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
} }
} }
result.metadata = {
...defaultConfig.metadata,
...userConfig.metadata,
};
return result; return result;
} }

59
src/core/pm.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { RuneConfig } from "../types.ts";
export const DEFAULT_PREFIX = "bunx @lanyuanxiaoyao/rune";
export function inferFromEnvironment(
execPath: string,
userAgent: string | undefined,
): string | null {
if (execPath.includes("bun")) return "bunx @lanyuanxiaoyao/rune";
if (userAgent?.includes("pnpm")) return "pnpx @lanyuanxiaoyao/rune";
if (userAgent?.includes("yarn")) return "yarn dlx @lanyuanxiaoyao/rune";
if (userAgent?.includes("npm")) return "npx @lanyuanxiaoyao/rune";
return null;
}
export async function checkCommandAvailable(command: string): Promise<boolean> {
const which = process.platform === "win32" ? "where" : "which";
try {
const proc = Bun.spawn([which, command], {
stdout: "ignore",
stderr: "ignore",
});
const exitCode = await proc.exited;
return exitCode === 0;
} catch {
return false;
}
}
export async function detectCommandPrefix(): Promise<string | null> {
if (await checkCommandAvailable("rune")) return "rune";
const inferred = inferFromEnvironment(process.execPath, process.env.npm_config_user_agent);
if (inferred) return inferred;
for (const pm of ["bunx", "pnpx", "npx"]) {
if (await checkCommandAvailable(pm)) return `${pm} @lanyuanxiaoyao/rune`;
}
return null;
}
export function getPmPrefix(config?: RuneConfig): string {
return config?.metadata?.command ?? DEFAULT_PREFIX;
}
export function getFallbackNote(): string {
return `如果没有安装 bun可使用 \`pnpx @lanyuanxiaoyao/rune\`\`npx @lanyuanxiaoyao/rune\` 替代`;
}
export function applyCommandPrefix(text: string, config?: RuneConfig): string {
const prefix = getPmPrefix(config);
const hasCommand = /\brune(?=\s)/.test(text);
const result = text.replace(/\brune(?=\s)/g, prefix);
if (!config?.metadata?.command && hasCommand) {
return result + "\n\n" + getFallbackNote();
}
return result;
}

View File

@@ -1,14 +1,16 @@
import { readdir, stat, readFile } from "node:fs/promises"; import { readdir, stat, readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { ChangeStatus } from "../types.ts"; import type { ChangeStatus, DocumentStatus, RuneConfig } from "../types.ts";
import { getChangesDir, getArchiveDir } from "./config.ts"; import { getChangesDir, getArchiveDir } from "./config.ts";
import { parseTasks } from "./task-parser.ts"; import { parseTasks } from "./task-parser.ts";
export async function scanChanges( export async function scanChanges(
projectRoot: string, projectRoot: string,
config?: RuneConfig,
): Promise<ChangeStatus[]> { ): Promise<ChangeStatus[]> {
const changesDir = getChangesDir(projectRoot); const changesDir = getChangesDir(projectRoot);
const results: ChangeStatus[] = []; const results: ChangeStatus[] = [];
const planDocs = config?.stages.plan?.documents;
try { try {
const entries = await readdir(changesDir); const entries = await readdir(changesDir);
@@ -17,11 +19,32 @@ export async function scanChanges(
const entryStat = await stat(entryPath); const entryStat = await stat(entryPath);
if (!entryStat.isDirectory()) continue; if (!entryStat.isDirectory()) continue;
const docs = await readdir(entryPath); const files = await readdir(entryPath);
const documents = docs.filter((d) => d.endsWith(".md")); const mdFiles = new Set(files.filter((f) => f.endsWith(".md")));
let documents: DocumentStatus[];
if (planDocs) {
documents = planDocs.map((docConfig) => {
const fileName = `${docConfig.name}.md`;
const completed = mdFiles.has(fileName);
const deps = docConfig.depend ?? [];
const dependMet = deps.every((dep) => mdFiles.has(`${dep}.md`));
return { name: docConfig.name, completed, dependMet };
});
} else {
documents = Array.from(mdFiles).map((fileName) => ({
name: fileName.replace(/\.md$/, ""),
completed: true,
dependMet: true,
}));
}
const planCompleted = planDocs ? documents.every((d) => d.completed) : false;
const buildUnlocked = planCompleted;
let taskProgress: { completed: number; total: number } | null = null; let taskProgress: { completed: number; total: number } | null = null;
const taskFile = docs.find((d) => d === "task.md"); const taskFile = files.find((d) => d === "task.md");
if (taskFile) { if (taskFile) {
const content = await readFile(join(entryPath, taskFile), "utf-8"); const content = await readFile(join(entryPath, taskFile), "utf-8");
const tasks = parseTasks(content); const tasks = parseTasks(content);
@@ -31,9 +54,19 @@ export async function scanChanges(
}; };
} }
results.push({ name: entry, documents, taskProgress }); results.push({
name: entry,
documents,
planCompleted,
buildUnlocked,
taskProgress,
});
}
} catch (e) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== "ENOENT" && code !== "ENOTDIR") {
throw e;
} }
} catch {
} }
return results; return results;

View File

@@ -1,8 +1,9 @@
import type { TaskItem } from "../types.ts"; import type { TaskItem } from "../types.ts";
import { CommandError } from "../cli/errors.ts";
export function parseTasks(content: string): TaskItem[] { export function parseTasks(content: string): TaskItem[] {
const tasks: TaskItem[] = []; const tasks: TaskItem[] = [];
const lines = content.split("\n"); const lines = content.split(/\r?\n/);
for (const line of lines) { for (const line of lines) {
const match = line.match(/^[\s]*- \[([ xX])\] (.*)$/); const match = line.match(/^[\s]*- \[([ xX])\] (.*)$/);
if (match) { if (match) {
@@ -14,3 +15,22 @@ export function parseTasks(content: string): TaskItem[] {
} }
return tasks; return tasks;
} }
export class TaskFormatError extends CommandError {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export function validateTaskFormat(content: string): void {
const tasks = parseTasks(content);
if (tasks.length === 0) {
throw new TaskFormatError("task.md 必须包含至少一个 checkbox 项");
}
for (const task of tasks) {
if (task.text.trim() === "") {
throw new TaskFormatError("task.md 中每个 checkbox 项必须有非空描述");
}
}
}

View File

@@ -3,22 +3,242 @@ import type { RuneConfig } from "../types.ts";
export const defaultConfig: RuneConfig = { export const defaultConfig: RuneConfig = {
stages: { stages: {
discuss: { discuss: {
prompt: `你是一位经验丰富的软件架构师和技术顾问 prompt: `进入探索模式。深度思考,自由发散。跟随对话走向
用户将与你讨论一个软件开发需求。
你的职责是: **重要:探索模式是用于思考的,不是用于实现的。** 你可以读文件、搜索代码、调查代码库,但绝对不写代码或实现功能。如果用户要求你实现,提醒用户先退出探索模式,使用 /rune-plan <变更名> 进入规划阶段。
1. 深入理解用户的需求背景和目标
2. 提出关键问题澄清模糊之处
3. 分析技术可行性和潜在风险
4. 提供专业的架构建议
请与用户自由讨论,不要急于输出文档。讨论充分后,建议用户使用 /plan <变更名> 进入规划阶段。`, **这是一套立场,而不是固定工作流。** 没有固定步骤,没有强制序列,没有必须输出。你是帮助用户探索的思考伙伴。
---
## 立场
- **好奇而非指令式** — 提出自然涌现的问题,不按脚本走
- **开放线索而非审问** — 同时呈现多个有趣的方向,让用户跟随共鸣的方向走。不要把用户推进单一的问题路径
- **可视化** — 感到有助于理清思路时,大量使用 ASCII 示意图
- **自适应** — 跟随有趣的线索,新信息出现时及时转向
- **耐心** — 不急于下结论,让问题的形状自然浮现
- **扎根代码** — 在相关时探索真实代码库,不要凭空理论
---
## 你可能做的事
取决于用户带来什么,你可能:
**探索问题空间**
- 提出从用户话语中自然涌现的澄清问题
- 挑战假设
- 重新框定问题
- 寻找类比
**调查代码库**
- 绘制与讨论相关的现有架构
- 找到集成点
- 识别已使用的模式
- 揭示隐藏的复杂度
**对比选项**
- 头脑风暴多种方案
- 构建对比表格
- 勾勒权衡
- 推荐路径(如果被问到)
**可视化**
\`\`\`
┌─────────────────────────────────────────┐
│ 大量使用 ASCII 示意图 │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ 状态 A │────────▶│ 状态 B │ │
│ └────────┘ └────────┘ │
│ │
│ 系统图、状态机、数据流、 │
│ 架构草图、依赖图、对比表格 │
│ │
└─────────────────────────────────────────┘
\`\`\`
**揭示风险和未知**
- 识别可能出错的地方
- 找到理解上的空白
- 建议探索性研究或调查
---
## Rune 感知
你可以使用 Rune 系统。自然地使用它,不要强行。
### 获取上下文
如果需要了解项目当前有哪些变更,可以自行执行:
\`\`\`bash
rune status
\`\`\`
这会告诉你:
- 是否有活跃的变更
- 变更的名称和状态
- 用户可能在做什么
如果存在相关变更,你可以读取变更目录下的 design.md / task.md 作为讨论参考。
### 讨论结果的处理
讨论中获得的洞察和结论**保留在会话上下文中**。不需要写入文件。
当讨论充分后,自然引导用户进入下一阶段:
- "这个想法已经足够清晰了,要开始规划吗?可以用 /rune-plan <变更名> <文档名> 进入规划阶段。"
---
## 你不必做的事
- 遵循固定脚本
- 每次都问相同的问题
- 产出特定文档
- 必须达成结论
- 在切线有价值时非得回到正题
- 保持简洁(这是思考时间)
---
## 结束探索
没有强制的结束方式。探索可能:
- **流入规划阶段**:讨论充分后建议使用 /rune-plan <变更名> <文档名>
- **获得清晰度**:用户得到所需,自行推进
- **稍后继续**"我们随时可以接着聊"
当感觉思路开始结晶时,可以给出总结:
\`\`\`
## 我们梳理清楚的内容
**问题**[已结晶的理解]
**方案**[如果有了方向]
**待澄清**[如果还有]
**下一步**(准备好时):
- 使用 /rune-plan <变更名> <文档名> 进入规划阶段
\`\`\`
但这个总结是可选的。有时候思考本身就是价值。
---
## 护栏
- **不实现** — 绝不写代码或实现功能
- **不假装理解** — 如果有不清楚的地方,深挖
- **不催促** — 探索是思考时间,不是任务时间
- **不强加结构** — 让模式自然浮现
- **不自动写文件** — 讨论结果保留在会话中,除非用户明确要求写入文件
- **要可视化** — 一张好图胜过千言万语
- **要探索代码库** — 让讨论扎根于现实
- **要质疑假设** — 包括用户的和自己的
---
## 典型场景
**用户带来模糊的想法:**
\`\`\`
用户:我在想加个实时协作功能
你:实时协作是个大课题。让我想想……
协作能力光谱
════════════════════════════════════════════
感知 协调 同步
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 在线状态│ │ 多人光标│ │ CRDT │
│ "3人在 │ │ 选择 │ │ 无冲突 │
│ 线" │ │ │ │ │
└────────┘ └────────┘ └────────┘
│ │ │
简单 中等 复杂
你心里想的是哪个方向?
\`\`\`
**用户带来具体问题:**
\`\`\`
用户:认证系统太乱了
你:[探索代码库]
┌─────────────────────────────────────────────┐
│ 当前认证流程 │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ 邮箱 │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ 权限 │
└───────────┘
我看到三个缠结。哪个让你头疼?
\`\`\`
**用户对比选项:**
\`\`\`
用户:该用 MySQL 还是 PostgreSQL
你:泛泛的回答很无聊。具体上下文是什么?
用户:一个 CLI 工具,追踪本地开发环境
你:这改变一切。
┌─────────────────────────────────────────────────┐
│ CLI 工具数据存储 │
└─────────────────────────────────────────────────┘
关键约束:
• 无守护进程
• 必须离线工作
• 单用户
MySQL PostgreSQL
部署 独立服务 ✗ 独立服务 ✗
离线 否 ✗ 否 ✗
单文件 否 ✗ 否 ✗
两个都不合适。SQLite 才是正确答案。
除非……有同步组件?
\`\`\``,
}, },
plan: { plan: {
documents: [ documents: [
{ {
name: "design", name: "design",
prompt: `根据之前的讨论内容,生成一份设计文档 prompt: `先获取当前规划状态
建议执行: rune status <变更名>
请根据之前的讨论内容和状态输出,生成一份设计文档。
要求: 要求:
- 清晰描述背景和目标 - 清晰描述背景和目标
@@ -27,7 +247,7 @@ export const defaultConfig: RuneConfig = {
- 考虑边界情况和错误处理 - 考虑边界情况和错误处理
请将文档写入指定路径。`, 请将文档写入指定路径。`,
template: `# {{change-name}} 设计文档 template: `# 设计文档
## 背景 ## 背景
@@ -40,23 +260,23 @@ export const defaultConfig: RuneConfig = {
## 注意事项 ## 注意事项
`, `,
}, },
{ ],
name: "task", },
prompt: `请根据设计文档,生成一份任务列表。 task: {
prompt: `你是一位高级软件工程师,擅长将设计文档拆解为可执行的任务列表。
请先读取变更目录下所有已有的规划文档(如 design.md理解设计内容。
然后将设计拆分为一份任务列表,写入 task.md。
要求: 要求:
- 将设计拆分为可独立执行的小任务 - 将设计拆分为可独立执行的小任务
- 每个任务应该足够具体,能直接编码实现 - 每个任务应该足够具体,能直接编码实现
- 任务之间有合理的依赖顺序 - 任务之间有合理的依赖顺序
- 使用 checkbox 格式:- [ ] 待完成,- [x] 已完成 - 使用 checkbox 格式:- [ ] 待完成,- [x] 已完成
- 格式固定为 checkbox 列表,不需要模板
请将文档写入指定路径。`, 请将任务列表写入指定路径的 task.md 文件`,
template: `# {{change-name}} 任务列表
- [ ]
`,
},
],
}, },
build: { build: {
prompt: `你是一位高级软件工程师。 prompt: `你是一位高级软件工程师。
@@ -67,17 +287,17 @@ export const defaultConfig: RuneConfig = {
- 每完成一个任务,立即更新 task.md 中对应项为 [x] - 每完成一个任务,立即更新 task.md 中对应项为 [x]
- 遵循项目现有的代码风格和约定 - 遵循项目现有的代码风格和约定
- 编写必要的测试 - 编写必要的测试
- 完成所有任务后,提示用户可以使用 /archive <变更名> 归档`, - 完成所有任务后,提示用户可以使用 /rune-archive <变更名> 归档`,
}, },
archive: { archive: {
prompt: `当前变更已进入归档阶段。 prompt: `当前变更已进入归档阶段。
确认 执行以下归档操作
1. 所有任务已标记为已完成 1. 读取变更目录下的设计文档和 task.md全面了解本轮变更内容
2. 如有未完成项,提醒用户并中止归档 2. 总结变更摘要:变更目的、涉及的文件和模块、已完成的工作
3. 如全部完成,总结本轮变更内容 3. 如有未完成任务,在摘要中注明未完成的事项
4. 向用户确认是否归档
归档后,建议用户使用 /discuss 开始下一轮需求讨论。`, 5. 用户确认后,执行 rune finish <变更名> 完成归档`,
}, },
}, },
}; };

View File

@@ -2,6 +2,13 @@ export interface DocumentConfig {
name: string; name: string;
prompt: string; prompt: string;
template?: string; template?: string;
depend?: string[];
}
export interface DocumentStatus {
name: string;
completed: boolean;
dependMet: boolean;
} }
export interface DiscussStage { export interface DiscussStage {
@@ -12,6 +19,10 @@ export interface PlanStage {
documents: DocumentConfig[]; documents: DocumentConfig[];
} }
export interface TaskStage {
prompt: string;
}
export interface BuildStage { export interface BuildStage {
prompt: string; prompt: string;
} }
@@ -23,12 +34,16 @@ export interface ArchiveStage {
export interface StagesConfig { export interface StagesConfig {
discuss?: DiscussStage; discuss?: DiscussStage;
plan?: PlanStage; plan?: PlanStage;
task?: TaskStage;
build?: BuildStage; build?: BuildStage;
archive?: ArchiveStage; archive?: ArchiveStage;
} }
export interface RuneConfig { export interface RuneConfig {
stages: StagesConfig; stages: StagesConfig;
metadata?: {
command?: string;
};
} }
export interface TaskItem { export interface TaskItem {
@@ -38,11 +53,13 @@ export interface TaskItem {
export interface ChangeStatus { export interface ChangeStatus {
name: string; name: string;
documents: string[]; documents: DocumentStatus[];
planCompleted: boolean;
buildUnlocked: boolean;
taskProgress: { completed: number; total: number } | null; taskProgress: { completed: number; total: number } | null;
} }
export const STAGES = ["discuss", "plan", "build", "archive"] as const; export const STAGES = ["discuss", "plan", "task", "build", "archive"] as const;
export type Stage = (typeof STAGES)[number]; export type Stage = (typeof STAGES)[number];
export const RUNE_DIR = ".rune"; export const RUNE_DIR = ".rune";

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { injectClaudeCode, updateClaudeCode } from "../../src/adapters/claude-code.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_claude_code_test__");
beforeEach(async () => {
await mkdir(TMP_DIR, { recursive: true });
});
afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true });
});
describe("injectClaudeCode", () => {
it("生成 discuss、plan、build、archive 的 command 文件", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
for (const stage of ["discuss", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`);
}
});
it("不生成 rune-status command", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
expect(commands).not.toContain("rune-status.md");
});
it("生成 rune-intro command", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
expect(commands).toContain("rune-intro.md");
});
it("command 文件包含 bash 命令", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toContain("rune discuss");
expect(content).toContain("```bash");
});
it("plan/build/archive command 包含变更名智能识别引导", async () => {
await injectClaudeCode(TMP_DIR);
for (const stage of ["plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".claude", "commands", `rune-${stage}.md`),
"utf-8",
);
expect(content).toContain("智能识别");
expect(content).toContain("rune status");
}
});
it("discuss command 不包含智能识别引导", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).not.toContain("智能识别");
});
it("不生成 rune-create command", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
expect(commands).not.toContain("rune-create.md");
});
it("plan command 包含 create 引导", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-plan.md"), "utf-8");
expect(content).toContain("如果变更目录尚不存在");
expect(content).toContain("rune create <变更名>");
});
it("discuss command 包含 create 引导", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toContain("讨论结束后");
expect(content).toContain("rune create <变更名>");
});
it("intro command 不包含 rune-create 作为独立命令", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-intro.md"), "utf-8");
expect(content).not.toContain("/rune-create");
expect(content).toContain("rune create <变更名>");
});
it("重复注入时不覆盖已存在的文件", async () => {
await injectClaudeCode(TMP_DIR);
const originalContent = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
await injectClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toBe(originalContent);
});
});
describe("updateClaudeCode", () => {
it("文件不存在时创建", async () => {
await updateClaudeCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-discuss.md"))).toBe(true);
});
it("文件存在且内容一致时不覆盖", async () => {
await injectClaudeCode(TMP_DIR);
const originalContent = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
await updateClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toBe(originalContent);
});
it("文件存在但内容不一致时覆盖", async () => {
await injectClaudeCode(TMP_DIR);
await writeFile(join(TMP_DIR, ".claude", "commands", "rune-discuss.md"), "旧内容");
await updateClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).not.toBe("旧内容");
expect(content).toContain("rune discuss");
});
it("更新时生成 rune-intro 命令", async () => {
await updateClaudeCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-intro.md"))).toBe(true);
});
it("不生成 rune-status 命令", async () => {
await updateClaudeCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md"))).toBe(false);
});
});
describe("injectClaudeCode with command prefix", () => {
it("使用自定义前缀生成 command 文件", async () => {
await injectClaudeCode(TMP_DIR, "bunx @lanyuanxiaoyao/rune");
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss");
});
it("不传前缀时使用默认 rune 前缀", async () => {
await injectClaudeCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toContain("rune discuss");
});
it("rune-intro command 使用自定义前缀", async () => {
await injectClaudeCode(TMP_DIR, "npx @lanyuanxiaoyao/rune");
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-intro.md"), "utf-8");
expect(content).toContain("npx @lanyuanxiaoyao/rune status");
});
});

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, rm, readFile, readdir } from "node:fs/promises"; import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { injectOpenCode } from "../../src/adapters/opencode.ts"; import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__"); const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__");
@@ -23,51 +23,125 @@ describe("injectOpenCode", () => {
for (const stage of ["discuss", "plan", "build", "archive"]) { for (const stage of ["discuss", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`); expect(commands).toContain(`rune-${stage}.md`);
expect(skills).toContain(`rune-${stage}.md`); expect(skills).toContain(`rune-${stage}`);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"))).toBe(
true,
);
} }
}); });
it("生成 rune-status command 和 skill", async () => { it("生成 rune-status command 和 skill", async () => {
await injectOpenCode(TMP_DIR); await injectOpenCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".opencode", "commands")); const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills")); const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
expect(commands).toContain("rune-status.md"); expect(commands).not.toContain("rune-status.md");
expect(skills).toContain("rune-status.md"); expect(skills).not.toContain("rune-status");
}); });
it("command 文件包含 skill 调用指令", async () => { it("生成 rune-intro skill无对应 command", async () => {
await injectOpenCode(TMP_DIR); await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
"utf-8", const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
);
expect(content).toContain("rune-discuss"); expect(commands).not.toContain("rune-intro.md");
expect(skills).toContain("rune-intro");
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
}); });
it("skill 文件包含 bash 命令", async () => { it("command 文件统一格式:只包含 skill 调用指令", async () => {
await injectOpenCode(TMP_DIR); await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"), for (const stage of ["discuss", "plan", "build", "archive"]) {
"utf-8", const content = await readFile(
); join(TMP_DIR, ".opencode", "commands", `rune-${stage}.md`),
expect(content).toContain("rune discuss"); "utf-8",
expect(content).toContain("description"); );
expect(content).toContain(`rune-${stage} skill`);
expect(content).not.toContain("变更名");
}
}); });
it("plan/build/archive skill 包含变更名称参数提示", async () => { it("plan/build/archive skill 包含变更名智能识别引导", async () => {
await injectOpenCode(TMP_DIR); await injectOpenCode(TMP_DIR);
for (const stage of ["plan", "build", "archive"]) { for (const stage of ["plan", "build", "archive"]) {
const content = await readFile( const content = await readFile(
join(TMP_DIR, ".opencode", "skills", `rune-${stage}.md`), join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
"utf-8", "utf-8",
); );
expect(content).toContain("变更名"); expect(content).toContain("智能识别");
expect(content).toContain("rune status");
} }
}); });
it("discuss skill 不包含智能识别引导", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).not.toContain("智能识别");
});
it("plan skill 包含运行 status 的引导", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-plan", "SKILL.md"),
"utf-8",
);
expect(content).toContain("rune status");
});
it("discuss skill 不包含 status 引导", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).not.toContain("规划阶段应先运行");
});
it("不生成 rune-create command 和 skill", async () => {
await injectOpenCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
expect(commands).not.toContain("rune-create.md");
expect(skills).not.toContain("rune-create");
});
it("plan skill 包含 create 引导", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-plan", "SKILL.md"),
"utf-8",
);
expect(content).toContain("如果变更目录尚不存在");
expect(content).toContain("rune create <变更名>");
});
it("discuss skill 包含 create 引导", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).toContain("讨论结束后");
expect(content).toContain("rune create <变更名>");
});
it("intro skill 不包含 rune-create 作为独立命令", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"),
"utf-8",
);
expect(content).not.toContain("/rune-create");
expect(content).toContain("rune create <变更名>");
});
it("重复注入时不覆盖已存在的文件", async () => { it("重复注入时不覆盖已存在的文件", async () => {
await injectOpenCode(TMP_DIR); await injectOpenCode(TMP_DIR);
const originalContent = await readFile( const originalContent = await readFile(
@@ -83,3 +157,97 @@ describe("injectOpenCode", () => {
expect(content).toBe(originalContent); expect(content).toBe(originalContent);
}); });
}); });
describe("updateOpenCode", () => {
it("文件不存在时创建", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
});
it("文件存在且内容一致时不覆盖", async () => {
await injectOpenCode(TMP_DIR);
const originalContent = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
await updateOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toBe(originalContent);
});
it("文件存在但内容不一致时覆盖", async () => {
await injectOpenCode(TMP_DIR);
await writeFile(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), "旧内容");
await updateOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).not.toBe("旧内容");
expect(content).toContain("rune-discuss");
});
it("更新时生成 rune-intro skill", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
});
it("不生成 rune-status", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(false);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(false);
});
});
describe("injectOpenCode with command prefix", () => {
it("使用自定义前缀生成 skill 文件", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_prefix_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir, "bunx @lanyuanxiaoyao/rune");
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("不传前缀时使用默认 rune 前缀", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_default_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir);
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).toContain("rune discuss");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("rune-intro skill 使用自定义前缀", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_intro_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir, "bunx @lanyuanxiaoyao/rune");
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-intro", "SKILL.md"),
"utf-8",
);
expect(content).toContain("bunx @lanyuanxiaoyao/rune status");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});

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(5);
});
});
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);
});
});

55
tests/cli/errors.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "bun:test";
import {
CliError,
UsageError,
ConfigError,
CommandError,
InternalError,
} from "../../src/cli/errors.ts";
describe("CliError 类层次", () => {
it("UsageError 携带 message 和可选 hint", () => {
const err = new UsageError("缺少参数", { hint: "请提供参数" });
expect(err).toBeInstanceOf(CliError);
expect(err).toBeInstanceOf(UsageError);
expect(err.message).toBe("缺少参数");
expect(err.hint).toBe("请提供参数");
expect(err.usage).toBeUndefined();
});
it("UsageError 携带 message、hint、usage", () => {
const err = new UsageError("缺少参数", {
hint: "请提供参数",
usage: "rune plan <change-name>",
});
expect(err.usage).toBe("rune plan <change-name>");
});
it("ConfigError 只带 message", () => {
const err = new ConfigError("未初始化");
expect(err).toBeInstanceOf(CliError);
expect(err).toBeInstanceOf(ConfigError);
expect(err.message).toBe("未初始化");
expect(err.hint).toBeUndefined();
});
it("CommandError 带 message 和 hint", () => {
const err = new CommandError("变更不存在", { hint: "请先运行 rune plan" });
expect(err).toBeInstanceOf(CliError);
expect(err.message).toBe("变更不存在");
expect(err.hint).toBe("请先运行 rune plan");
});
it("InternalError 用默认 message", () => {
const err = new InternalError();
expect(err).toBeInstanceOf(CliError);
expect(err.message).toBe("发生了未预期的错误");
});
it("可以用 name 区分错误类型", () => {
expect(new UsageError("a").name).toBe("UsageError");
expect(new ConfigError("b").name).toBe("ConfigError");
expect(new CommandError("c").name).toBe("CommandError");
expect(new InternalError().name).toBe("InternalError");
});
});

71
tests/cli/help.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from "bun:test";
import { showGlobalHelp, showCommandHelp } from "../../src/cli/help.ts";
describe("showGlobalHelp", () => {
it("默认前缀时包含 rune", () => {
const output = showGlobalHelp();
expect(output).toContain("rune <命令> [参数]");
expect(output).toContain("rune init opencode");
});
it("自定义前缀时使用传入前缀", () => {
const output = showGlobalHelp("bunx @lanyuanxiaoyao/rune");
expect(output).toContain("bunx @lanyuanxiaoyao/rune <命令> [参数]");
expect(output).toContain("bunx @lanyuanxiaoyao/rune init opencode");
});
it("包含所有命令行", () => {
const output = showGlobalHelp("rune");
expect(output).toContain("init <工具...>");
expect(output).toContain("discuss");
expect(output).toContain("create <变更>");
expect(output).toContain("plan <变更> <文档>");
expect(output).toContain("build <名称>");
expect(output).toContain("archive <名称>");
expect(output).toContain("finish <名称>");
expect(output).toContain("status");
expect(output).toContain("help");
expect(output).toContain("version");
});
it("以标题行开头", () => {
const output = showGlobalHelp("rune");
expect(output.startsWith("Rune")).toBe(true);
});
});
describe("showCommandHelp", () => {
it("plan 命令包含用法、参数、描述、示例", () => {
const output = showCommandHelp("plan", "bunx @lanyuanxiaoyao/rune");
expect(output).toContain("bunx @lanyuanxiaoyao/rune plan <change-name> <document-name>");
expect(output).toContain("<change-name>");
expect(output).toContain("<document-name>");
expect(output).toContain("规划阶段");
expect(output).toContain("bunx @lanyuanxiaoyao/rune plan add-user-auth");
});
it("init 命令包含工具参数说明", () => {
const output = showCommandHelp("init", "rune");
expect(output).toContain("rune init <工具...>");
expect(output).toContain("opencode");
});
it("create 命令包含变更名参数", () => {
const output = showCommandHelp("create", "rune");
expect(output).toContain("rune create <change-name>");
expect(output).toContain("变更名称");
expect(output).toContain("rune create add-user-auth");
});
it("finish 命令包含归档变更说明", () => {
const output = showCommandHelp("finish", "rune");
expect(output).toContain("rune finish <change-name>");
expect(output).toContain("归档变更");
expect(output).toContain("将变更目录");
});
it("不存在的命令返回 null", () => {
const output = showCommandHelp("nonexistent", "rune");
expect(output).toBeNull();
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from "bun:test";
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
import { mapError } from "../../src/cli.ts";
import { DEFAULT_PREFIX } from "../../src/core/pm.ts";
describe("mapError", () => {
it("CliError 原样返回", () => {
const err = new ConfigError("未初始化");
const result = mapError(err);
expect(result).toBe(err);
expect(result.message).toBe("未初始化");
});
it("Unknown option 转为 UsageError", () => {
const err = new Error("Unknown option `--badflag`");
const result = mapError(err);
expect(result).toBeInstanceOf(UsageError);
expect(result.message).toBe("未知选项: --badflag");
expect(result.hint).toBe(`运行 ${DEFAULT_PREFIX} help 查看所有命令`);
});
it("Unknown command 转为 UsageError", () => {
const err = new Error("Unknown command `foo`");
const result = mapError(err);
expect(result).toBeInstanceOf(UsageError);
expect(result.message).toBe("未知命令: foo");
});
it("Unused args 转为 UsageError", () => {
const err = new Error("Unused args: --extra");
const result = mapError(err);
expect(result).toBeInstanceOf(UsageError);
expect(result.message).toContain("未知命令");
expect(result.message).toContain("--extra");
});
it("missing required args 转为 UsageError", () => {
const err = new Error("missing required args for command `plan`");
const result = mapError(err);
expect(result).toBeInstanceOf(UsageError);
expect(result.message).toBe(`命令"plan"缺少必填参数`);
expect(result.usage).toBe(`${DEFAULT_PREFIX} plan <change-name>`);
expect(result.hint).toContain(`${DEFAULT_PREFIX} help plan`);
});
it("未知 Error 转为 InternalError", () => {
const err = new Error("something unexpected");
const result = mapError(err);
expect(result).toBeInstanceOf(InternalError);
expect(result.message).toContain("发生了未预期的错误");
expect(result.message).toContain("something unexpected");
});
it("非 Error 类型转为 InternalError", () => {
const result = mapError("字符串错误");
expect(result).toBeInstanceOf(InternalError);
});
});

42
tests/cli/output.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "bun:test";
import { formatError } from "../../src/cli/output.ts";
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
describe("formatError", () => {
it("只输出错误行(无 hint/usage", () => {
const err = new ConfigError("未初始化");
const output = formatError(err);
expect(output).toBe("错误: 未初始化");
});
it("输出错误行 + 提示行", () => {
const err = new ConfigError("未初始化", { hint: "请先运行 rune init" });
const output = formatError(err);
expect(output).toBe("错误: 未初始化\n\n提示: 请先运行 rune init");
});
it("输出错误行 + 用法行", () => {
const err = new UsageError("缺少参数", {
usage: "rune plan <change-name>",
});
const output = formatError(err);
expect(output).toBe("错误: 缺少参数\n\n用法: rune plan <change-name>");
});
it("输出完整格式(错误 + 用法 + 提示)", () => {
const err = new UsageError("缺少必填参数 <change-name>", {
usage: "rune plan <change-name>",
hint: '请指定变更名称,如 "add-login"',
});
const output = formatError(err);
expect(output).toBe(
'错误: 缺少必填参数 <change-name>\n\n用法: rune plan <change-name>\n\n提示: 请指定变更名称,如 "add-login"',
);
});
it("InternalError 输出固定消息", () => {
const err = new InternalError();
const output = formatError(err);
expect(output).toBe("错误: 发生了未预期的错误");
});
});

145
tests/cli/status.test.ts Normal file
View File

@@ -0,0 +1,145 @@
import { describe, it, expect } from "bun:test";
import { formatChangeStatus, suggestNextStep } from "../../src/cli.ts";
import type { ChangeStatus, RuneConfig } from "../../src/types.ts";
function makeStatus(overrides: Partial<ChangeStatus> = {}): ChangeStatus {
return {
name: "test-change",
documents: [],
planCompleted: false,
buildUnlocked: false,
taskProgress: null,
...overrides,
};
}
describe("formatChangeStatus", () => {
it("显示变更名", () => {
const output = formatChangeStatus(makeStatus());
expect(output).toContain("test-change");
});
it("显示已完成和待完成文档", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: false, dependMet: true },
],
});
const output = formatChangeStatus(status);
expect(output).toContain("design.md ✓ 已完成");
expect(output).toContain("task.md ○ 待完成");
});
it("显示文档依赖信息dependMet 为 false 且 config 中有依赖)", () => {
const status = makeStatus({
documents: [{ name: "task", completed: false, dependMet: false }],
});
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const output = formatChangeStatus(status, config);
expect(output).toContain("依赖 design.md");
});
it("dependMet 为 false 但无 config 时不显示文档依赖信息", () => {
const status = makeStatus({
documents: [{ name: "task", completed: false, dependMet: false }],
});
const output = formatChangeStatus(status);
expect(output).not.toContain("(依赖");
});
it("显示规划进度", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: false, dependMet: true },
],
});
const output = formatChangeStatus(status);
expect(output).toContain("1/2 文档已完成");
});
it("规划完成时显示构建已解锁", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
buildUnlocked: true,
});
const output = formatChangeStatus(status);
expect(output).toContain("已解锁");
});
it("显示任务进度", () => {
const status = makeStatus({
taskProgress: { completed: 3, total: 5 },
});
const output = formatChangeStatus(status);
expect(output).toContain("3/5 已完成");
});
it("包含下一步建议", () => {
const output = formatChangeStatus(makeStatus());
expect(output).toContain("建议下一步");
});
});
describe("suggestNextStep", () => {
it("规划未完成时返回下一个可规划文档", () => {
const status = makeStatus({
documents: [{ name: "design", completed: false, dependMet: true }],
});
expect(suggestNextStep(status)).toContain("rune plan test-change design");
});
it("规划未完成且依赖未满足时提示完成前置依赖", () => {
const status = makeStatus({
documents: [{ name: "design", completed: false, dependMet: false }],
});
expect(suggestNextStep(status)).toBe("完成前置依赖后再规划文档");
});
it("规划完成且有未完成任务时建议 build", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
taskProgress: { completed: 2, total: 5 },
});
expect(suggestNextStep(status)).toContain("rune build test-change");
});
it("任务全部完成时建议 archive", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
taskProgress: { completed: 5, total: 5 },
});
expect(suggestNextStep(status)).toContain("rune archive test-change");
});
it("规划完成但无 taskProgress 时建议 task", () => {
const status = makeStatus({
documents: [{ name: "design", completed: true, dependMet: true }],
planCompleted: true,
taskProgress: null,
});
expect(suggestNextStep(status)).toContain("rune task test-change");
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "bun:test";
import { validateChangeName } from "../../src/cli.ts";
import { CommandError } from "../../src/cli/errors.ts";
describe("validateChangeName", () => {
it("英文名通过", () => {
expect(() => validateChangeName("user-auth")).not.toThrow();
expect(() => validateChangeName("addLogin")).not.toThrow();
});
it("中文名通过", () => {
expect(() => validateChangeName("用户登录")).not.toThrow();
expect(() => validateChangeName("修复内存泄漏")).not.toThrow();
});
it("中英混合通过", () => {
expect(() => validateChangeName("用户-login")).not.toThrow();
});
it("空格不通过", () => {
expect(() => validateChangeName("my change")).toThrow(CommandError);
});
it("下划线不通过", () => {
expect(() => validateChangeName("my_change")).toThrow(CommandError);
});
it("特殊符号不通过", () => {
expect(() => validateChangeName("my-change!")).toThrow(CommandError);
expect(() => validateChangeName("my.change")).toThrow(CommandError);
expect(() => validateChangeName("my@change")).toThrow(CommandError);
});
it("空字符串不通过", () => {
expect(() => validateChangeName("")).toThrow(CommandError);
});
});

View File

@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, rm, readFile, writeFile } from "node:fs/promises"; import { mkdir, rm, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts"; import { runInit } from "../../src/commands/init.ts";
import { CommandError } from "../../src/cli/errors.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_init_test__"); const TMP_DIR = join(import.meta.dir, "__tmp_init_test__");
@@ -21,11 +22,9 @@ describe("runInit", () => {
expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true); expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true); expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
const content = await readFile( const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
join(TMP_DIR, ".rune", "config.yaml"), expect(content).toContain("# Rune 配置文件");
"utf-8", expect(content).toContain("stages:");
);
expect(content.trim()).toBe("stages: {}");
}); });
it("创建 .rune/changes 和 .rune/archive 目录", async () => { it("创建 .rune/changes 和 .rune/archive 目录", async () => {
@@ -39,27 +38,55 @@ describe("runInit", () => {
await runInit(TMP_DIR, ["opencode"]); await runInit(TMP_DIR, ["opencode"]);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true); expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"))).toBe(true); expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
}); });
it("重复 init 不覆盖 config.yaml", async () => { it("重复 init 不覆盖 config.yaml 已有内容", async () => {
await runInit(TMP_DIR, ["opencode"]); await runInit(TMP_DIR, ["opencode"]);
await writeFile( await writeFile(join(TMP_DIR, ".rune", "config.yaml"), "自定义内容");
join(TMP_DIR, ".rune", "config.yaml"),
"自定义内容",
);
await runInit(TMP_DIR, ["opencode"]); await runInit(TMP_DIR, ["opencode"]);
const content = await readFile( const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
join(TMP_DIR, ".rune", "config.yaml"), expect(content).toContain("自定义内容");
"utf-8",
);
expect(content).toBe("自定义内容");
}); });
it("不支持的工具名抛出错误", async () => { it("不支持的工具名抛出 CommandError", async () => {
await expect(runInit(TMP_DIR, ["unknown-tool"])).rejects.toThrow( try {
"不支持的工具", await runInit(TMP_DIR, ["unknown-tool"]);
); expect.unreachable("应抛出错误");
} catch (e) {
expect(e).toBeInstanceOf(CommandError);
expect((e as CommandError).message).toContain("不支持的工具: unknown-tool");
}
});
it("首次 init 时 config.yaml 包含 metadata.command", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("metadata:");
expect(content).toContain("command:");
});
it("config.yaml 模板不含 {{change-name}}", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).not.toContain("{{change-name}}");
});
it("config.yaml 模板包含 metadata 说明", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("metadata");
expect(content).toContain("command");
});
it("config.yaml 模板包含 create 命令说明", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("create");
}); });
}); });

View File

@@ -6,6 +6,7 @@ import {
assemblePlanPrompt, assemblePlanPrompt,
assembleBuildPrompt, assembleBuildPrompt,
assembleArchivePrompt, assembleArchivePrompt,
assembleTaskPrompt,
} from "../../src/core/assembler.ts"; } from "../../src/core/assembler.ts";
import type { RuneConfig } from "../../src/types.ts"; import type { RuneConfig } from "../../src/types.ts";
import { defaultConfig } from "../../src/defaults/config.ts"; import { defaultConfig } from "../../src/defaults/config.ts";
@@ -24,7 +25,9 @@ describe("assembleDiscussPrompt", () => {
it("返回默认 discuss 提示词", () => { it("返回默认 discuss 提示词", () => {
const prompt = assembleDiscussPrompt(defaultConfig); const prompt = assembleDiscussPrompt(defaultConfig);
expect(prompt).toBeTruthy(); expect(prompt).toBeTruthy();
expect(prompt).toContain("软件架构师"); expect(prompt).toContain("探索模式");
expect(prompt).toContain("立场");
expect(prompt).toContain("护栏");
}); });
it("返回自定义 discuss 提示词", () => { it("返回自定义 discuss 提示词", () => {
@@ -37,42 +40,70 @@ describe("assembleDiscussPrompt", () => {
}); });
describe("assemblePlanPrompt", () => { describe("assemblePlanPrompt", () => {
it("包含变更名称和文档指引", async () => { it("包含指定文档名称和提示词", async () => {
const prompt = await assemblePlanPrompt( const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
defaultConfig,
TMP_DIR,
"user-auth",
);
expect(prompt).toContain("user-auth"); expect(prompt).toContain("user-auth");
expect(prompt).toContain("design"); expect(prompt).toContain("design");
expect(prompt).toContain("task"); expect(prompt).not.toContain("task");
expect(prompt).toContain("格式模板");
}); });
it("包含已有文档内容(重复 plan 场景)", async () => { it("已有文档时引导 AI 读取而非内嵌内容", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true }); await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 已有设计"); await writeFile(join(changeDir, "design.md"), "# 已有设计");
const prompt = await assemblePlanPrompt( const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
defaultConfig, expect(prompt).toContain("已有内容");
TMP_DIR, expect(prompt).toContain("design.md");
"user-auth", expect(prompt).not.toContain("# 已有设计");
);
expect(prompt).toContain("已有设计");
expect(prompt).toContain("在此基础上修订");
}); });
it("替换模板中的 {{change-name}}", async () => { it("包含格式模板(纯静态文本)", async () => {
const prompt = await assemblePlanPrompt( const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
defaultConfig, expect(prompt).toContain("格式模板");
TMP_DIR, expect(prompt).toContain("# 设计文档");
"user-auth",
);
expect(prompt).toContain("user-auth 设计文档");
expect(prompt).toContain("user-auth 任务列表");
expect(prompt).not.toContain("{{change-name}}"); expect(prompt).not.toContain("{{change-name}}");
}); });
it("包含依赖说明(有依赖时)", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task");
expect(prompt).toContain("依赖说明");
expect(prompt).toContain("design.md");
});
it("无依赖时不包含依赖说明", async () => {
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).not.toContain("依赖说明");
});
it("依赖说明标注完成状态", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task");
expect(prompt).toContain("已完成");
});
it("使用自定义 plan 配置", async () => { it("使用自定义 plan 配置", async () => {
const config: RuneConfig = { const config: RuneConfig = {
stages: { stages: {
@@ -81,64 +112,240 @@ describe("assemblePlanPrompt", () => {
{ {
name: "spec", name: "spec",
prompt: "生成规格", prompt: "生成规格",
template: "# {{change-name}} 规格", template: "# 规格文档",
}, },
], ],
}, },
}, },
}; };
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature"); const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature", "spec");
expect(prompt).toContain("spec"); expect(prompt).toContain("spec");
expect(prompt).toContain("my-feature 规格"); expect(prompt).toContain("规格文档");
expect(prompt).not.toContain("design"); expect(prompt).not.toContain("design");
}); });
}); });
describe("assembleBuildPrompt", () => { describe("assembleBuildPrompt", () => {
it("包含待执行任务列表", async () => { it("引导 AI 读取 task.md 而非内嵌任务内容", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true }); await mkdir(changeDir, { recursive: true });
await writeFile( await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`);
join(changeDir, "task.md"), const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
`- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`, expect(prompt).toContain("task.md");
); expect(prompt).toContain("2");
const prompt = await assembleBuildPrompt( expect(prompt).not.toContain("任务二");
defaultConfig, expect(prompt).not.toContain("任务三");
TMP_DIR,
"user-auth",
);
expect(prompt).toContain("任务二");
expect(prompt).toContain("待执行任务");
expect(prompt).toContain("共 2 项");
}); });
it("所有任务完成时提示可归档", async () => { it("所有任务完成时提示可归档", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true }); await mkdir(changeDir, { recursive: true });
await writeFile( await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [x] 任务二`);
join(changeDir, "task.md"), const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
`- [x] 任务一\n- [x] 任务二`,
);
const prompt = await assembleBuildPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
expect(prompt).toContain("已完成"); expect(prompt).toContain("已完成");
expect(prompt).toContain("归档"); expect(prompt).toContain("归档");
}); });
it("task.md 不存在时抛出错误", async () => { it("task.md 不存在时抛出 CommandError 并附带提示", async () => {
await expect( try {
assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"), await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
).rejects.toThrow("task.md not found"); expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("尚未完成任务拆解");
expect(e.message).toContain("nonexistent");
expect(e.hint).toContain("task");
}
});
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: "构建阶段" } },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("task.md");
expect(e.message).toContain("checkbox");
expect(e.hint).toBeTruthy();
}
});
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: "构建阶段" } },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("checkbox");
expect(e.hint).toBeTruthy();
}
}); });
}); });
describe("assembleArchivePrompt", () => { describe("assembleArchivePrompt", () => {
it("返回归档提示词", () => { it("返回归档提示词", async () => {
const prompt = assembleArchivePrompt(defaultConfig, "user-auth"); const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
const prompt = await assembleArchivePrompt(defaultConfig, TMP_DIR, "user-auth");
expect(prompt).toContain("user-auth"); expect(prompt).toContain("user-auth");
expect(prompt).toContain("归档"); expect(prompt).toContain("归档");
}); });
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"), "- [ ] 未完成任务");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("未完成");
expect(prompt).toContain("未完成任务");
expect(prompt).toContain("是否确认");
});
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 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("命令前缀替换", () => {
it("assembleDiscussPrompt 替换 rune 为配置前缀", () => {
const config: RuneConfig = {
stages: { discuss: { prompt: "执行 rune status 查看状态" } },
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
};
const prompt = assembleDiscussPrompt(config);
expect(prompt).toContain("bunx @lanyuanxiaoyao/rune status");
expect(prompt).not.toContain("执行 rune ");
});
it("assembleDiscussPrompt 无配置时追加降级说明", () => {
const config: RuneConfig = {
stages: { discuss: { prompt: "执行 rune status 查看" } },
};
const prompt = assembleDiscussPrompt(config);
expect(prompt).toContain("bunx @lanyuanxiaoyao/rune status");
expect(prompt).toContain("如果没有安装 bun");
});
it("assembleBuildPrompt 错误提示使用动态前缀", async () => {
const config: RuneConfig = {
stages: { build: { prompt: "构建阶段" } },
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 task nonexistent-build");
}
});
it("不替换 /rune- 形式", () => {
const config: RuneConfig = {
stages: { discuss: { prompt: "使用 /rune-plan 进入规划,然后 rune build 构建" } },
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
};
const prompt = assembleDiscussPrompt(config);
expect(prompt).toContain("/rune-plan");
expect(prompt).toContain("bunx @lanyuanxiaoyao/rune build");
});
}); });

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises"; import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { loadConfig, findProjectRoot, getRuneDir } from "../../src/core/config.ts"; import { loadConfig, findProjectRoot, getRuneDir, validateConfig } from "../../src/core/config.ts";
import { ConfigError } from "../../src/cli/errors.ts";
import type { RuneConfig } from "../../src/types.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_config_test__"); const TMP_DIR = join(import.meta.dir, "__tmp_config_test__");
@@ -37,6 +39,7 @@ describe("loadConfig", () => {
const config = await loadConfig(TMP_DIR); const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined(); expect(config.stages.discuss).toBeDefined();
expect(config.stages.plan).toBeDefined(); expect(config.stages.plan).toBeDefined();
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined(); expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined(); expect(config.stages.archive).toBeDefined();
}); });
@@ -82,24 +85,135 @@ describe("loadConfig", () => {
const config = await loadConfig(TMP_DIR); const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined(); expect(config.stages.discuss).toBeDefined();
expect(config.stages.plan).toBeDefined(); expect(config.stages.plan).toBeDefined();
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined(); expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined(); expect(config.stages.archive).toBeDefined();
}); });
it("YAML 解析错误时返回默认配置", async () => { it("YAML 解析错误时抛出 ConfigError", async () => {
const runeDir = join(TMP_DIR, ".rune"); const runeDir = join(TMP_DIR, ".rune");
await mkdir(runeDir, { recursive: true }); await mkdir(runeDir, { recursive: true });
await writeFile( await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
join(runeDir, "config.yaml"), expect(loadConfig(TMP_DIR)).rejects.toThrow(ConfigError);
`stages: [invalid yaml {{{`,
);
const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined();
}); });
}); });
describe("getRuneDir", () => { describe("getRuneDir", () => {
it("返回 .rune 目录路径", () => { it("返回 .rune 目录路径", () => {
expect(getRuneDir("/project")).toBe("/project/.rune"); expect(getRuneDir("/project")).toBe(join("/project", ".rune"));
});
});
describe("validateConfig", () => {
it("正常配置不抛错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
expect(() => validateConfig(config)).not.toThrow();
});
it("depend 引用不存在的文档时报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "task", prompt: "生成任务", depend: ["nonexistent"] }],
},
},
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("自依赖报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计", depend: ["design"] }],
},
},
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("循环依赖报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "a", prompt: "a", depend: ["b"] },
{ name: "b", prompt: "b", depend: ["a"] },
],
},
},
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("无 plan 阶段时不报错", () => {
const config: RuneConfig = { stages: {} };
expect(() => validateConfig(config)).not.toThrow();
});
it("depend 为空数组时不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计", depend: [] }],
},
},
};
expect(() => validateConfig(config)).not.toThrow();
});
});
describe("mergeConfig 保留 metadata", () => {
it("保留用户配置中的 metadata", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_meta_test__");
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: "bunx @lanyuanxiaoyao/rune"\nstages:\n discuss:\n prompt: "自定义讨论"\n`,
);
const config = await loadConfig(tmpDir);
expect(config.metadata).toBeDefined();
expect(config.metadata!.command).toBe("bunx @lanyuanxiaoyao/rune");
expect(config.stages.discuss!.prompt).toBe("自定义讨论");
} 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 不包含 tracked", async () => {
const config = await loadConfig(TMP_DIR);
expect((config.metadata as any)?.tracked).toBeUndefined();
});
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("自定义任务提示词");
}); });
}); });

112
tests/core/pm.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from "bun:test";
import {
inferFromEnvironment,
getPmPrefix,
DEFAULT_PREFIX,
getFallbackNote,
applyCommandPrefix,
} from "../../src/core/pm.ts";
import type { RuneConfig } from "../../src/types.ts";
describe("inferFromEnvironment", () => {
it("execPath 包含 bun 时返回 bunx 前缀", () => {
const result = inferFromEnvironment("/home/user/.bun/bin/bun", undefined);
expect(result).toBe("bunx @lanyuanxiaoyao/rune");
});
it("npm_config_user_agent 包含 pnpm 时返回 pnpx 前缀", () => {
const result = inferFromEnvironment("/usr/bin/node", "pnpm/9.0.0");
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
});
it("npm_config_user_agent 包含 npm 时返回 npx 前缀", () => {
const result = inferFromEnvironment("/usr/bin/node", "npm/10.0.0");
expect(result).toBe("npx @lanyuanxiaoyao/rune");
});
it("pnpm 优先于 npmuserAgent 同时包含两者时)", () => {
const result = inferFromEnvironment("/usr/bin/node", "pnpm/9.0.0 npm/10.0.0");
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
});
it("无法推断时返回 null", () => {
const result = inferFromEnvironment("/usr/bin/node", undefined);
expect(result).toBeNull();
});
});
describe("getPmPrefix", () => {
it("有配置时返回 config.metadata.command", () => {
const config: RuneConfig = {
stages: {},
metadata: { command: "rune" },
};
expect(getPmPrefix(config)).toBe("rune");
});
it("配置中无 metadata 时返回默认前缀", () => {
const config: RuneConfig = { stages: {} };
expect(getPmPrefix(config)).toBe(DEFAULT_PREFIX);
});
it("不传配置时返回默认前缀", () => {
expect(getPmPrefix()).toBe(DEFAULT_PREFIX);
expect(getPmPrefix(undefined)).toBe("bunx @lanyuanxiaoyao/rune");
});
});
describe("getFallbackNote", () => {
it("返回降级说明文本", () => {
const note = getFallbackNote();
expect(note).toContain("pnpx @lanyuanxiaoyao/rune");
expect(note).toContain("npx @lanyuanxiaoyao/rune");
});
});
describe("applyCommandPrefix", () => {
it("有配置时替换 rune 为配置前缀", () => {
const config: RuneConfig = {
stages: {},
metadata: { command: "rune" },
};
const result = applyCommandPrefix("执行 rune status 查看", config);
expect(result).toBe("执行 rune status 查看");
});
it("无配置时替换为默认前缀", () => {
const result = applyCommandPrefix("执行 rune status 查看", undefined);
expect(result).toContain("bunx @lanyuanxiaoyao/rune status");
expect(result).not.toContain("执行 rune status");
});
it("无配置且文本含命令时追加降级说明", () => {
const result = applyCommandPrefix("执行 rune status 查看", undefined);
expect(result).toContain("pnpx @lanyuanxiaoyao/rune");
expect(result).toContain("如果没有安装 bun");
});
it("有配置时不追加降级说明", () => {
const config: RuneConfig = {
stages: {},
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
};
const result = applyCommandPrefix("执行 rune status 查看", config);
expect(result).not.toContain("如果没有安装 bun");
});
it("不替换 /rune- 形式(编辑器斜杠命令)", () => {
const text = "使用 /rune-plan 进入规划阶段,然后 rune build 开始构建";
const config: RuneConfig = {
stages: {},
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
};
const result = applyCommandPrefix(text, config);
expect(result).toContain("/rune-plan");
expect(result).toContain("bunx @lanyuanxiaoyao/rune build");
});
it("文本不含 rune 命令时无配置也不追加降级说明", () => {
const result = applyCommandPrefix("纯文本无命令", undefined);
expect(result).toBe("纯文本无命令");
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises"; import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { scanChanges, scanArchives } from "../../src/core/scanner.ts"; import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
import type { RuneConfig } from "../../src/types.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__"); const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__");
@@ -20,20 +21,14 @@ describe("scanChanges", () => {
expect(changes).toEqual([]); expect(changes).toEqual([]);
}); });
it("扫描到变更及其文档", async () => { it("有 task.md 时无条件计算 taskProgress", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes"); const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true }); await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计"); await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
await writeFile( await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
join(changesDir, "user-auth", "task.md"),
`- [x] 任务一\n- [ ] 任务二`,
);
const changes = await scanChanges(TMP_DIR); const changes = await scanChanges(TMP_DIR);
expect(changes).toHaveLength(1); expect(changes).toHaveLength(1);
expect(changes[0].name).toBe("user-auth");
expect(changes[0].documents).toContain("design.md");
expect(changes[0].documents).toContain("task.md");
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 }); expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
}); });
@@ -57,6 +52,93 @@ describe("scanChanges", () => {
const changes = await scanChanges(TMP_DIR); const changes = await scanChanges(TMP_DIR);
expect(changes).toHaveLength(2); expect(changes).toHaveLength(2);
}); });
it("返回 DocumentStatus 含 completed 和 dependMet", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1);
expect(changes[0].documents).toHaveLength(2);
expect(changes[0].documents[0]).toEqual({
name: "design",
completed: true,
dependMet: true,
});
expect(changes[0].documents[1]).toEqual({
name: "task",
completed: false,
dependMet: true,
});
expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false);
});
it("depend 未满足时 dependMet 为 false", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes[0].documents[1].dependMet).toBe(false);
});
it("所有文档完成时 planCompleted 和 buildUnlocked 为 true", 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"), "- [ ] 任务");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
});
it("无 config 时使用文件扫描兼容模式", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "feature-a"), { recursive: true });
await writeFile(join(changesDir, "feature-a", "design.md"), "# 设计");
const changes = await scanChanges(TMP_DIR);
expect(changes).toHaveLength(1);
expect(changes[0].documents[0].name).toBe("design");
expect(changes[0].documents[0].completed).toBe(true);
expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false);
});
}); });
describe("scanArchives", () => { describe("scanArchives", () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { parseTasks } from "../../src/core/task-parser.ts"; import { parseTasks, validateTaskFormat, TaskFormatError } from "../../src/core/task-parser.ts";
describe("parseTasks", () => { describe("parseTasks", () => {
it("解析标准 checkbox 格式", () => { it("解析标准 checkbox 格式", () => {
@@ -52,3 +52,29 @@ describe("parseTasks", () => {
expect(tasks.length).toBe(3); expect(tasks.length).toBe(3);
}); });
}); });
describe("validateTaskFormat", () => {
it("合法 task 内容通过校验", () => {
expect(() => validateTaskFormat("- [x] 已完成\n- [ ] 未完成")).not.toThrow();
});
it("无 checkbox 项时抛错", () => {
expect(() => validateTaskFormat("# 标题\n一些描述")).toThrow(TaskFormatError);
});
it("空内容抛错", () => {
expect(() => validateTaskFormat("")).toThrow(TaskFormatError);
});
it("checkbox 文本为空时抛错", () => {
expect(() => validateTaskFormat("- [ ] \n- [x] 有内容")).toThrow(TaskFormatError);
});
it("checkbox 文本仅空格时抛错", () => {
expect(() => validateTaskFormat("- [ ] ")).toThrow(TaskFormatError);
});
it("有 checkbox 且文本非空时通过", () => {
expect(() => validateTaskFormat("- [ ] 实现功能A")).not.toThrow();
});
});

View File

@@ -2,9 +2,10 @@ import { describe, it, expect } from "bun:test";
import { defaultConfig } from "../../src/defaults/config.ts"; import { defaultConfig } from "../../src/defaults/config.ts";
describe("defaultConfig", () => { describe("defaultConfig", () => {
it("包含所有个阶段的配置", () => { it("包含所有个阶段的配置", () => {
expect(defaultConfig.stages.discuss).toBeDefined(); expect(defaultConfig.stages.discuss).toBeDefined();
expect(defaultConfig.stages.plan).toBeDefined(); expect(defaultConfig.stages.plan).toBeDefined();
expect(defaultConfig.stages.task).toBeDefined();
expect(defaultConfig.stages.build).toBeDefined(); expect(defaultConfig.stages.build).toBeDefined();
expect(defaultConfig.stages.archive).toBeDefined(); expect(defaultConfig.stages.archive).toBeDefined();
}); });
@@ -14,38 +15,50 @@ describe("defaultConfig", () => {
expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string"); expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");
}); });
it("plan 阶段包含 design 和 task 两个文档配置", () => { it("discuss 默认提示词包含关键章节", () => {
const docs = defaultConfig.stages.plan!.documents; const prompt = defaultConfig.stages.discuss!.prompt;
expect(docs).toHaveLength(2); expect(prompt).toContain("探索模式");
expect(docs[0].name).toBe("design"); expect(prompt).toContain("立场");
expect(docs[1].name).toBe("task"); expect(prompt).toContain("你可能做的事");
for (const doc of docs) { expect(prompt).toContain("Rune 感知");
expect(doc.prompt).toBeTruthy(); expect(prompt).toContain("你不必做的事");
} expect(prompt).toContain("结束探索");
expect(prompt).toContain("护栏");
expect(prompt).toContain("典型场景");
}); });
it("plan 的 task 文档配置存在", () => { it("discuss 默认提示词不包含 OpenSpec 术语", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find( const prompt = defaultConfig.stages.discuss!.prompt;
(d) => d.name === "task", expect(prompt).not.toContain("openspec");
); expect(prompt).not.toContain("/opsx:");
expect(taskDoc).toBeDefined(); expect(prompt).not.toContain("proposal.md");
expect(taskDoc!.prompt).toBeTruthy(); expect(prompt).not.toContain("specs/");
});
it("discuss 默认提示词包含正确 Rune 术语", () => {
const prompt = defaultConfig.stages.discuss!.prompt;
expect(prompt).toContain("/rune-plan");
expect(prompt).toContain("rune status");
expect(prompt).toContain("design.md");
expect(prompt).toContain("task.md");
});
it("plan 阶段包含 design 文档配置", () => {
const docs = defaultConfig.stages.plan!.documents;
expect(docs).toHaveLength(1);
expect(docs[0].name).toBe("design");
expect(docs[0].prompt).toBeTruthy();
}); });
it("design 文档有 template", () => { it("design 文档有 template", () => {
const designDoc = defaultConfig.stages.plan!.documents.find( const designDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "design");
(d) => d.name === "design",
);
expect(designDoc!.template).toBeTruthy(); expect(designDoc!.template).toBeTruthy();
expect(designDoc!.template).toContain("{{change-name}}"); expect(designDoc!.template).toContain("设计文档");
}); });
it("task 文档有 template", () => { it("task 阶段有 prompt", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find( expect(defaultConfig.stages.task).toBeDefined();
(d) => d.name === "task", expect(defaultConfig.stages.task!.prompt).toBeTruthy();
);
expect(taskDoc!.template).toBeTruthy();
expect(taskDoc!.template).toContain("- [ ]");
}); });
it("build 阶段有 prompt", () => { it("build 阶段有 prompt", () => {

View File

@@ -1,12 +1,13 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, writeFile, rm, readFile, rename } from "node:fs/promises"; import { mkdir, writeFile, rm, rename } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts"; import { runInit } from "../../src/commands/init.ts";
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts"; import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
import { import {
assembleDiscussPrompt, assembleDiscussPrompt,
assemblePlanPrompt, assemblePlanPrompt,
assembleTaskPrompt,
assembleBuildPrompt, assembleBuildPrompt,
assembleArchivePrompt, assembleArchivePrompt,
} from "../../src/core/assembler.ts"; } from "../../src/core/assembler.ts";
@@ -23,57 +24,59 @@ afterEach(async () => {
}); });
describe("完整 SDD 流程", () => { describe("完整 SDD 流程", () => {
it("init → discuss → plan → build → archive 完整流程", async () => { it("init → discuss → plan → task → build → archive 完整流程", async () => {
await runInit(TMP_DIR, ["opencode"]); await runInit(TMP_DIR, ["opencode"]);
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true); expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true); expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
const config = await loadConfig(TMP_DIR); const config = await loadConfig(TMP_DIR);
const discussPrompt = assembleDiscussPrompt(config); const discussPrompt = assembleDiscussPrompt(config);
expect(discussPrompt).toContain("软件架构师"); expect(discussPrompt).toContain("探索模式");
const changeName = "user-auth"; const changeName = "user-auth";
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true }); await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName); const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName, "design");
expect(planPrompt).toContain("user-auth"); expect(planPrompt).toContain("user-auth");
const changeDir = getChangeDir(TMP_DIR, changeName); const changeDir = getChangeDir(TMP_DIR, changeName);
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能"); 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- [ ] 编写登录测试"); await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试");
const changes = await scanChanges(TMP_DIR); const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1); expect(changes).toHaveLength(1);
expect(changes[0].name).toBe("user-auth"); expect(changes[0].name).toBe("user-auth");
expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 }); expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 });
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
expect(changes[0].documents.length).toBeGreaterThan(0);
const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName); const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName);
expect(buildPrompt).toContain("实现登录 API"); expect(buildPrompt).toContain("task.md");
expect(buildPrompt).toContain("共 2 项"); expect(buildPrompt).toContain("2");
await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试"); await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试");
const updatedChanges = await scanChanges(TMP_DIR); const updatedChanges = await scanChanges(TMP_DIR, config);
expect(updatedChanges[0].taskProgress).toEqual({ completed: 2, total: 2 }); expect(updatedChanges[0].taskProgress).toEqual({ completed: 2, total: 2 });
expect(updatedChanges[0].planCompleted).toBe(true);
expect(updatedChanges[0].buildUnlocked).toBe(true);
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName); const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
expect(buildPrompt2).toContain("已完成"); expect(buildPrompt2).toContain("已完成");
const archivePrompt = assembleArchivePrompt(config, changeName); const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
expect(archivePrompt).toContain("归档"); expect(archivePrompt).toContain("归档阶段");
const today = new Date().toISOString().slice(0, 10); expect(archivePrompt).toContain("user-auth");
const src = getChangeDir(TMP_DIR, changeName); expect(archivePrompt).toContain("finish");
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
await mkdir(join(TMP_DIR, ".rune", "archive"), { recursive: true });
await rename(src, dest);
expect(existsSync(dest)).toBe(true); const postArchiveChanges = await scanChanges(TMP_DIR, config);
expect(existsSync(src)).toBe(false); expect(postArchiveChanges).toHaveLength(1);
const archives = await scanArchives(TMP_DIR);
expect(archives).toContain(`${today}-${changeName}`);
const postArchiveChanges = await scanChanges(TMP_DIR);
expect(postArchiveChanges).toHaveLength(0);
}); });
it("多变更并行", async () => { it("多变更并行", async () => {
@@ -86,14 +89,14 @@ describe("完整 SDD 流程", () => {
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`); await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
} }
const changes = await scanChanges(TMP_DIR); const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(2); expect(changes).toHaveLength(2);
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth"); const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
expect(authPrompt).toContain("auth 任务"); expect(authPrompt).toContain("task.md");
const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment"); const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment");
expect(paymentPrompt).toContain("payment 任务"); expect(paymentPrompt).toContain("task.md");
}); });
it("自定义配置覆盖默认配置", async () => { it("自定义配置覆盖默认配置", async () => {
@@ -108,7 +111,7 @@ describe("完整 SDD 流程", () => {
documents: documents:
- name: spec - name: spec
prompt: 生成规格文档 prompt: 生成规格文档
template: "# {{change-name}} 规格" template: "# 规格文档"
`, `,
); );
@@ -118,12 +121,147 @@ describe("完整 SDD 流程", () => {
expect(discussPrompt).toBe("自定义讨论"); expect(discussPrompt).toBe("自定义讨论");
await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true }); await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true });
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test"); const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test", "spec");
expect(planPrompt).toContain("spec"); expect(planPrompt).toContain("spec");
expect(planPrompt).toContain("test 规格"); expect(planPrompt).toContain("规格文档");
expect(planPrompt).not.toContain("design"); expect(planPrompt).not.toContain("design");
expect(config.stages.task).toBeDefined();
expect(config.stages.build).toBeDefined(); expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined(); expect(config.stages.archive).toBeDefined();
}); });
it("scanChanges 返回文档依赖状态", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeDir = getChangeDir(TMP_DIR, "dep-test");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计文档");
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1);
const designDoc = changes[0].documents.find((d) => d.name === "design");
expect(designDoc).toBeDefined();
expect(designDoc!.completed).toBe(true);
});
});
describe("变更名校验", () => {
it("合法变更名(中文、英文、短横线)通过校验", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
await mkdir(getChangeDir(TMP_DIR, "用户-login"), { recursive: true });
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "design.md"), "# 设计");
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "task.md"), "- [ ] 任务");
const prompt = await assemblePlanPrompt(config, TMP_DIR, "用户-login", "design");
expect(prompt).toContain("用户-login");
});
it("非法变更名(空格、下划线、特殊符号)被拒绝", () => {
const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/;
expect(validRegex.test("my change")).toBe(false);
expect(validRegex.test("my_change")).toBe(false);
expect(validRegex.test("my-change!")).toBe(false);
expect(validRegex.test("my.change")).toBe(false);
expect(validRegex.test("")).toBe(false);
});
});
describe("archive 校验", () => {
it("task 未全部完成时注入警告提示词", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeDir = getChangeDir(TMP_DIR, "incomplete-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务\n- [ ] 未完成任务");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "incomplete-task");
expect(prompt).toContain("警告");
expect(prompt).toContain("未完成");
expect(prompt).toContain("未完成任务");
expect(prompt).toContain("是否确认");
});
it("task 全部完成时不注入警告", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeDir = getChangeDir(TMP_DIR, "complete-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "complete-task");
expect(prompt).not.toContain("警告");
expect(prompt).toContain("归档阶段");
});
it("task.md 不存在时不追加警告", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeDir = getChangeDir(TMP_DIR, "no-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "no-task");
expect(prompt).not.toContain("警告");
});
});
describe("finish 命令", () => {
it("将变更目录移动到 archive", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeName = "finish-test";
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
await writeFile(join(getChangeDir(TMP_DIR, changeName), "design.md"), "# 设计");
await writeFile(join(getChangeDir(TMP_DIR, changeName), "task.md"), "- [x] 全部完成");
const today = new Date().toISOString().slice(0, 10);
const src = getChangeDir(TMP_DIR, changeName);
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
await mkdir(getArchiveDir(TMP_DIR), { recursive: true });
await rename(src, dest);
expect(existsSync(dest)).toBe(true);
expect(existsSync(src)).toBe(false);
const archives = await scanArchives(TMP_DIR);
expect(archives).toContain(`${today}-${changeName}`);
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(0);
});
it("目标路径已存在时抛出错误", async () => {
await runInit(TMP_DIR, ["opencode"]);
const changeName = "dup-test";
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
await writeFile(join(getChangeDir(TMP_DIR, changeName), "design.md"), "# 设计");
const today = new Date().toISOString().slice(0, 10);
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
await mkdir(dest, { recursive: true });
await writeFile(join(dest, "existing.md"), "# 已有内容");
await expect(rename(getChangeDir(TMP_DIR, changeName), dest)).rejects.toThrow();
});
it("变更目录不存在时抛出错误", async () => {
await runInit(TMP_DIR, ["opencode"]);
await expect(
rename(
getChangeDir(TMP_DIR, "nonexistent"),
join(getArchiveDir(TMP_DIR), "2024-01-01-nonexistent"),
),
).rejects.toThrow();
});
}); });

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "bun:test";
import { parseSemver, bumpVersion } from "../../scripts/release";
describe("parseSemver", () => {
it("解析标准版本号 1.2.3", () => {
expect(parseSemver("1.2.3")).toEqual({ major: 1, minor: 2, patch: 3 });
});
it("解析 0.0.0", () => {
expect(parseSemver("0.0.0")).toEqual({ major: 0, minor: 0, patch: 0 });
});
it("解析大版本号 99.999.1", () => {
expect(parseSemver("99.999.1")).toEqual({ major: 99, minor: 999, patch: 1 });
});
it("非三位格式抛出异常", () => {
expect(() => parseSemver("1.2")).toThrow("无效的版本号格式");
expect(() => parseSemver("1.2.3.4")).toThrow("无效的版本号格式");
});
it("非数字格式抛出异常", () => {
expect(() => parseSemver("a.b.c")).toThrow("无效的版本号格式");
expect(() => parseSemver("1.2.3-beta")).toThrow("无效的版本号格式");
});
it("负数抛出异常", () => {
expect(() => parseSemver("-1.2.3")).toThrow("无效的版本号格式");
});
it("四段格式抛出异常", () => {
expect(() => parseSemver("1.5.3.2")).toThrow("无效的版本号格式");
});
});
describe("bumpVersion", () => {
it("major 递增0.1.0 → 1.0.0", () => {
expect(bumpVersion("0.1.0", "major")).toBe("1.0.0");
});
it("minor 递增0.1.0 → 0.2.0", () => {
expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0");
});
it("patch 递增0.1.0 → 0.1.1", () => {
expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1");
});
it("major 递增低位归零1.5.3 → 2.0.0", () => {
expect(bumpVersion("1.5.3", "major")).toBe("2.0.0");
});
it("minor 递增 patch 归零1.5.3 → 1.6.0", () => {
expect(bumpVersion("1.5.3", "minor")).toBe("1.6.0");
});
it("patch 递增不影响高位1.5.3 → 1.5.4", () => {
expect(bumpVersion("1.5.3", "patch")).toBe("1.5.4");
});
it("大数字递增正确", () => {
expect(bumpVersion("99.999.1", "minor")).toBe("99.1000.0");
});
it("无效版本号抛出异常", () => {
expect(() => bumpVersion("invalid", "minor")).toThrow("无效的版本号格式");
});
});