Compare commits

...

57 Commits

Author SHA1 Message Date
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
49 changed files with 3536 additions and 331 deletions

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
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

@@ -37,14 +37,47 @@ tests/ # 测试目录(镜像 src 结构)
## 开发命令
```bash
bun test # 运行全部测试
bun test tests/core/ # 运行指定目录测试
bun test # 运行单元/集成测试(排除 agent e2e 测试)
bun run test:e2e # 运行 agent 端到端测试Tier 1 + 2< 5s
bun run test:e2e:llm # 运行 LLM-as-Judge 测试Tier 3需设置环境变量
bun test tests/core/ # 运行指定目录测试
bun run release # 发布新版本交互式递增版本号、测试门禁、git commit+tag、npm publish
bun src/cli.ts init opencode # 测试 init 命令
bun src/cli.ts help # 查看全局帮助
bun src/cli.ts help init # 查看 init 命令帮助
bun src/cli.ts version # 查看版本号
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 # 查看版本号
```
### Tier 3 LLM-as-Judge 环境变量
```bash
export RUNE_E2E_LLM_API_KEY="your-api-key" # 必填
export RUNE_E2E_LLM_PROVIDER="openai" # 可选,默认 openai
export RUNE_E2E_LLM_MODEL="gpt-4o-mini" # 可选,默认 gpt-4o-mini
export RUNE_E2E_LLM_BASE_URL="https://..." # 可选,自定义 endpoint
bun run test:e2e:llm
```
### 代码质量
项目使用 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 交互架构
### 子命令
@@ -63,20 +96,67 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
- `src/cli/output.ts` — 格式化输出(`错误:``用法:``提示:` 三段式)
- `src/cli/help.ts` — 帮助文本生成
## 设计决策
### 阶段与配置
- **四阶段固定**discuss → plan → build → archive不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents
- **配置文件名**`.rune/config.yaml`,不是 `rune.yml`
- **模板变量**:仅支持 `{{change-name}}`,不需要其他变量(信息已在上下文中)
### 各阶段行为
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导
- **plan**命令只输出提示词不写入文件AI 负责根据提示词生成文档内容并写入。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
- **archive**:归档前命令行校验 task 完成状态,未完成时在提示词中注入警告并引导 AI 询问用户
### 变更名校验
变更名仅支持中文、英文、短横线(`-`),不支持空格、下划线等其他符号。
### update 命令
`rune update <tool>` 对比已注入的命令/skill 文件内容与内置版本,不一致则覆盖,不存在则新建。用于升级 Rune 后更新编辑器配置。
### 其他决策
- 无跨变更依赖,变更之间完全独立
- 无并发锁,同一变更可被多个 agent 同时操作
- 无需变更废弃命令,手动删除目录即可
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
## 测试策略
### Level 1 — 纯单元/集成测试(当前
### 单元/集成测试(`bun test`
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。覆盖 `src/core/``src/cli/``src/adapters/``tests/integration/`
### Level 2 — 提示词快照测试(后续增强
### Agent 端到端测试(`bun run test:e2e`
对每个阶段捕获提示词输出,与预期快照对比。
位于 `tests/agent/`,灰度盒测试互补现有白盒测试,三层架构:
### Level 3 — mock-agent 端到端(后续增强)
| Tier | 说明 | 触发 |
| -------------- | ---------------------------- | ------------------------------ |
| 1 命令级 mock | 每命令预设行为CI 快速门禁 | `bun run test:e2e` |
| 2 场景级 mock | 行为重写,覆盖边界和错误恢复 | `bun run test:e2e` |
| 3 LLM-as-Judge | 调用 LLM API 验证提示词质量 | `bun run test:e2e:llm`(手动) |
编排完整闭环rune 输出 → mock-agent 处理 → rune 继续下一阶段。
运行策略:
### Level 4 — 真实 AI 工具集成CI 可选)
- `bun test`pre-commit 用Tier 1 + 2 **不参与**,仅跑单元/集成
- `bun run test:e2e`Tier 1 + 2< 5s
- `bun run test:e2e:llm`Tier 3手动触发`RUNE_E2E_LLM_API_KEY`
调用 LLM API 验证输出格式可被解析。
## 发布流程
`bun run release` 交互式发布新版本到 npm
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`
发布前确保已通过 `npm login` 登录 npm且 npm 账号有 `@lanyuanxiaoyao` scope 的发布权限。

View File

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

144
bun.lock
View File

@@ -10,6 +10,10 @@
},
"devDependencies": {
"@types/bun": "latest",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0",
},
"peerDependencies": {
"typescript": "^5",
@@ -17,18 +21,158 @@
},
},
"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/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=="],
"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=="],
"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=="],
"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=="],
"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,4 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"superpowers@git+https://github.com/obra/superpowers.git"
]
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"]
}

View File

@@ -1,20 +1,47 @@
{
"name": "rune",
"version": "0.1.0",
"module": "src/cli.ts",
"type": "module",
"name": "@lanyuanxiaoyao/rune",
"version": "0.1.2",
"bin": {
"rune": "./src/cli.ts"
},
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
"files": [
"src",
"README.md"
],
"type": "module",
"module": "src/cli.ts",
"scripts": {
"prepare": "husky",
"test": "bun test --path-ignore-patterns 'tests/agent/**'",
"test:e2e": "bun test tests/agent/ --path-ignore-patterns 'e2e-llm-judge*'",
"test:e2e:llm": "bun test tests/agent/e2e-llm-judge.test.ts --timeout 120000",
"lint": "oxlint",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"check": "oxlint && oxfmt --check .",
"release": "bun run scripts/release.ts"
},
"dependencies": {
"cac": "^7.0.0",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/bun": "latest",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0"
},
"peerDependencies": {
"typescript": "^5"
},
"lint-staged": {
"*.{ts,js,mjs,cjs}": [
"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", "--path-ignore-patterns", "tests/agent/**"], {
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,6 +2,7 @@ import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { STAGES } from "../types.ts";
import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".claude/commands";
@@ -13,12 +14,8 @@ export async function injectClaudeCode(projectRoot: string): Promise<void> {
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) {
const cmd = hasChangeName
? `rune ${stage} <变更名>`
: `rune ${stage}`;
const nameHint = hasChangeName
? "\n如果用户没有指定变更名称请向用户确认。"
: "";
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
const nameHint = hasChangeName ? "\n如果用户没有指定变更名称请向用户确认。" : "";
await writeFile(
commandPath,
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
@@ -29,9 +26,23 @@ export async function injectClaudeCode(projectRoot: string): Promise<void> {
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusPath = join(commandDir, "rune-status.md");
if (!existsSync(statusPath)) {
await writeFile(
statusPath,
`执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`,
);
await writeFile(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
}
}
export async function updateClaudeCode(projectRoot: string): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
const nameHint = hasChangeName ? "\n如果用户没有指定变更名称请向用户确认。" : "";
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`;
await writeIfChanged(commandPath, newContent);
}
const statusPath = join(projectRoot, COMMANDS_DIR, "rune-status.md");
await writeIfChanged(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
}

View File

@@ -39,6 +39,35 @@ export async function injectOpenCode(projectRoot: string): Promise<void> {
}
}
export async function updateOpenCode(projectRoot: string): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
const newCommand = generateCommand(stage, hasChangeName);
await writeIfChanged(commandPath, newCommand);
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
await mkdir(skillStageDir, { recursive: true });
const skillPath = join(skillStageDir, "SKILL.md");
const newSkill = generateSkill(stage, hasChangeName);
await writeIfChanged(skillPath, newSkill);
}
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusCommandPath = join(commandDir, "rune-status.md");
await writeIfChanged(statusCommandPath, generateStatusCommand());
const statusSkillDir = join(projectRoot, SKILLS_DIR, "rune-status");
await mkdir(statusSkillDir, { recursive: true });
const statusSkillPath = join(statusSkillDir, "SKILL.md");
await writeIfChanged(statusSkillPath, generateStatusSkill());
}
import { writeIfChanged } from "./utils.ts";
function generateCommand(stage: string, hasChangeName: boolean): string {
if (hasChangeName) {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
@@ -52,9 +81,13 @@ function generateCommand(stage: string, hasChangeName: boolean): string {
function generateSkill(stage: string, hasChangeName: boolean): string {
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
const nameHint = hasChangeName
? `将 <变更名> 替换为实际的变更名称。\n`
: "";
const nameHint = hasChangeName ? `将 <变更名> 替换为实际的变更名称。\n` : "";
let extraGuide = "";
if (stage === "plan") {
extraGuide = `\n规划阶段应先运行 \`rune status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n`;
}
const descriptionMap: Record<string, string> = {
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
@@ -75,7 +108,7 @@ description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶
${cmd}
\`\`\`
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。
${nameHint}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
`;
}

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

@@ -15,6 +15,7 @@ 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 type { ChangeStatus, RuneConfig } from "./types.ts";
function requireProjectRoot(): string {
const root = findProjectRoot();
@@ -24,6 +25,72 @@ function requireProjectRoot(): string {
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)}`);
return lines.join("\n");
}
export function suggestNextStep(change: ChangeStatus): string {
if (!change.planCompleted) {
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
if (nextDoc) {
return `rune plan ${change.name} ${nextDoc.name}`;
}
return `完成前置依赖后再规划文档`;
}
if (change.taskProgress && change.taskProgress.completed < change.taskProgress.total) {
return `rune build ${change.name}`;
}
if (change.taskProgress && change.taskProgress.completed === change.taskProgress.total) {
return `rune archive ${change.name}`;
}
return `rune build ${change.name}`;
}
const cli = cac("rune");
cli.command("", "").action(() => {
@@ -49,18 +116,44 @@ cli.command("version", "显示版本号").action(() => {
console.log(`rune v${pkg.version}`);
});
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
async (tools: string[]) => {
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: "rune init <工具...>",
hint: "如rune init opencode",
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: "rune init <工具...>",
hint: "如:rune init opencode",
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
});
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: "rune update <工具...>",
hint: "如rune 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) => 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(", ")}`,
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
},
);
}
for (const tool of tools) {
await updaters[tool](root);
}
console.log(`工具配置已更新:${tools.join(", ")}`);
});
cli.command("discuss", "讨论阶段").action(async () => {
const root = requireProjectRoot();
@@ -69,96 +162,142 @@ cli.command("discuss", "讨论阶段").action(async () => {
console.log(prompt);
});
cli.command("plan <change-name>", "规划阶段").action(
async (changeName: string) => {
cli
.command("plan <change-name> <document-name>", "规划阶段")
.action(async (changeName: string, documentName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
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 = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 rune plan ${changeName} 创建变更`,
const planDocs = config.stages.plan?.documents;
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
});
}
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 = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 rune plan ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = assembleArchivePrompt(config, changeName);
const today = new Date().toISOString().slice(0, 10);
const src = changeDir;
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
await rename(src, dest);
console.log(prompt);
},
);
await mkdir(changeDir, { recursive: true });
cli.command("status", "查看变更状态").action(async () => {
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: `请先完成依赖文档rune plan ${changeName} ${missing[0]}`,
},
);
}
}
const prompt = await assemblePlanPrompt(config, root, changeName, documentName);
console.log(prompt);
});
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changes = await scanChanges(root);
if (changes.length === 0) {
console.log("当前无进行中的变更。");
return;
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 rune plan ${changeName} 创建变更`,
});
}
for (const change of changes) {
const progress = change.taskProgress
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)`
: "";
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
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)) {
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 rune plan ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleArchivePrompt(config, root, changeName);
const today = new Date().toISOString().slice(0, 10);
const src = changeDir;
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
await rename(src, dest);
console.log(prompt);
});
cli.command("status [change-name]", "查看变更状态").action(async (changeName?: string) => {
const root = requireProjectRoot();
const config = await loadConfig(root);
const changes = await scanChanges(root, config);
if (changeName) {
const change = changes.find((c) => c.name === changeName);
if (!change) {
throw new CommandError(`变更 "${changeName}" 不存在`, {
hint: "运行 rune 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");
}
}
});
function handleError(e: unknown): never {
export function mapError(e: unknown): CliError {
if (e instanceof CliError) {
printError(e);
} else if (e instanceof Error && e.message.includes("Unknown option")) {
return e;
}
if (e instanceof Error) {
const err = mapCacError(e);
if (err) return err;
}
return new InternalError();
}
function mapCacError(e: Error): CliError | null {
if (e.message.includes("Unknown option")) {
const match = e.message.match(/Unknown option `([^`]+)`/);
const flag = match ? match[1] : "未知选项";
printError(new UsageError(`未知选项: ${flag}`, {
return new UsageError(`未知选项: ${flag}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unknown command")) {
});
}
if (e.message.includes("Unknown command")) {
const match = e.message.match(/Unknown command `([^`]+)`/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`未知命令: ${cmd}`, {
return new UsageError(`未知命令: ${cmd}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unused args")) {
});
}
if (e.message.includes("Unused args")) {
const match = e.message.match(/Unused args: (.+)/);
const args = match ? match[1] : "未知参数";
printError(new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("missing required args")) {
});
}
if (e.message.includes("missing required args")) {
const match = e.message.match(/command `(\w+)/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`命令 '${cmd}' 缺少必填参数`, {
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
usage: `rune ${cmd} <change-name>`,
hint: `运行 rune help ${cmd} 查看用法`,
}));
} else {
printError(new InternalError());
});
}
return null;
}
function handleError(e: unknown): never {
printError(mapError(e));
}
process.on("unhandledRejection", (e) => {

View File

@@ -2,10 +2,7 @@ export class CliError extends Error {
readonly hint?: string;
readonly usage?: string;
constructor(
message: string,
opts?: { hint?: string; usage?: string },
) {
constructor(message: string, opts?: { hint?: string; usage?: string }) {
super(message);
this.name = this.constructor.name;
this.hint = opts?.hint;

View File

@@ -16,10 +16,7 @@ const COMMANDS: Record<string, CommandHelpDef> = {
usage: "rune init <工具...>",
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
examples: [
"rune init opencode",
"rune init opencode claude-code",
],
examples: ["rune init opencode", "rune init opencode claude-code"],
},
discuss: {
name: "discuss",
@@ -32,48 +29,53 @@ const COMMANDS: Record<string, CommandHelpDef> = {
},
plan: {
name: "plan",
alias: "plan <名称>",
description: "规划:生成规划阶段提示词",
usage: "rune plan <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
detail: "生成规划阶段的提示词。变更目录将创建在 .rune/changes/<change-name>/ 下。",
examples: [
"rune plan add-user-auth",
"rune plan fix-memory-leak",
alias: "plan <变更> <文档>",
description: "规划:生成指定文档的规划提示词",
usage: "rune plan <change-name> <document-name>",
args: [
{ name: "<change-name>", desc: '变更名称,如 "add-login"' },
{ name: "<document-name>", desc: '文档名称,如 "design"、"task"' },
],
detail:
"生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。",
examples: ["rune plan add-user-auth design", "rune plan add-user-auth task"],
},
build: {
name: "build",
alias: "build <名称>",
description: "构建:生成构建阶段提示词",
usage: "rune build <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "生成构建阶段的提示词。变更目录需已存在(通过 rune plan 创建)。",
examples: [
"rune build add-user-auth",
"rune build fix-memory-leak",
],
examples: ["rune build add-user-auth", "rune build fix-memory-leak"],
},
archive: {
name: "archive",
alias: "archive <名称>",
description: "归档:归档变更并生成提示词",
usage: "rune archive <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "将变更目录从 .rune/changes/ 移动到 .rune/archive/,并生成归档阶段提示词。",
examples: [
"rune archive add-user-auth",
"rune archive fix-memory-leak",
],
examples: ["rune archive add-user-auth", "rune archive fix-memory-leak"],
},
update: {
name: "update",
alias: "update <工具...>",
description: "更新:更新已注入的编辑器配置",
usage: "rune update <工具...>",
args: [{ name: "<工具...>", desc: "要更新的 AI 工具,如 opencode、claude-code" }],
detail:
"对比已注入的命令和 skill 文件,与内置版本不一致时覆盖,不存在时新建。用于升级 Rune 后同步编辑器配置。",
examples: ["rune update opencode", "rune update opencode claude-code"],
},
status: {
name: "status",
alias: "status",
description: "查看:列出当前进行中的变更",
usage: "rune status",
args: [],
detail: "扫描 .rune/changes/ 目录,列出所有进行中的变更及其任务进度。",
examples: ["rune status"],
alias: "status [变更]",
description: "查看:展示变更状态与下一步建议",
usage: "rune status [change-name]",
args: [{ name: "[change-name]", desc: "可选,指定查看的变更名称" }],
detail: "展示各文档完成状态、依赖满足情况、规划进度和下一步建议。不传参数则显示所有变更。",
examples: ["rune status", "rune status add-user-auth"],
},
};
@@ -99,7 +101,8 @@ export function showGlobalHelp(): string {
lines.push("");
lines.push("示例:");
lines.push(" rune init opencode 初始化并注入 OpenCode 配置");
lines.push(" rune plan add-login 开始规划 \"add-login\" 变更");
lines.push(" rune update opencode 更新 OpenCode 配置");
lines.push(' rune plan add-login design 规划 "add-login" 的设计文档');
lines.push(" rune status 查看当前变更状态");
return lines.join("\n");
@@ -109,12 +112,7 @@ export function showCommandHelp(name: string): string | null {
const cmd = COMMANDS[name];
if (!cmd) return null;
const lines: string[] = [
`rune ${cmd.name}${cmd.description}`,
"",
"用法:",
` ${cmd.usage}`,
];
const lines: string[] = [`rune ${cmd.name}${cmd.description}`, "", "用法:", ` ${cmd.usage}`];
if (cmd.args.length > 0) {
lines.push("");

View File

@@ -12,7 +12,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# 阶段顺序discuss -> plan -> build -> archive
#
# 可配置阶段:
# discuss - 讨论阶段:自由讨论需求和架构
# discuss - 探索阶段:深度思考、调查代码库、对比方案
# plan - 规划阶段:生成设计文档和任务清单
# build - 构建阶段:按任务清单逐步实现
# archive - 归档阶段:确认完成并归档变更
@@ -21,7 +21,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# stages:
# discuss:
# prompt: |
# 你是一位资深软件架构师...
# 进入探索模式。深度思考,自由发散。跟随对话走向。
#
# 示例 - 自定义规划阶段的文档模板:
# stages:
@@ -33,19 +33,17 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# # {{change-name}} 设计文档
# - name: task
# prompt: 生成任务清单
# depend: [design]
# template: |
# # {{change-name}} 任务清单
`;
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
export const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
opencode: injectOpenCode,
"claude-code": injectClaudeCode,
};
export async function runInit(
projectRoot: string,
tools: string[],
): Promise<void> {
export async function runInit(projectRoot: string, tools: string[]): Promise<void> {
for (const tool of tools) {
if (!SUPPORTED_TOOLS[tool]) {
throw new CommandError(`不支持的工具: ${tool}`, {

View File

@@ -2,12 +2,16 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { RuneConfig } from "../types.ts";
import { CommandError } from "../cli/errors.ts";
import { getChangeDir } from "./config.ts";
import { parseTasks } from "./task-parser.ts";
export function assembleDiscussPrompt(config: RuneConfig): string {
const discuss = config.stages.discuss;
if (!discuss) throw new Error("discuss 阶段未配置");
if (!discuss)
throw new CommandError("讨论阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
});
return discuss.prompt;
}
@@ -15,35 +19,56 @@ export async function assemblePlanPrompt(
config: RuneConfig,
projectRoot: string,
changeName: string,
documentName: string,
): Promise<string> {
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}" 不在配置的 plan.documents 中`, {
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
});
}
const changeDir = getChangeDir(projectRoot, changeName);
const parts: string[] = [];
parts.push(`# 规划阶段:${changeName}\n`);
parts.push("请为当前变更生成以下文档:\n");
parts.push(`# 规划阶段:${changeName}`);
parts.push("");
parts.push(`## 文档:${doc.name}.md`);
parts.push(doc.prompt);
for (const doc of plan.documents) {
parts.push(`## 文档:${doc.name}.md`);
parts.push(doc.prompt);
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("");
const docPath = join(changeDir, `${doc.name}.md`);
if (existsSync(docPath)) {
const existing = await readFile(docPath, "utf-8");
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
}
parts.push(`请将文档写入目录:${changeDir}`);
if (doc.template) {
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
parts.push(`\n### 格式模板:\n${rendered}`);
}
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 parts.join("\n");
}
@@ -53,7 +78,11 @@ export async function assembleBuildPrompt(
changeName: string,
): Promise<string> {
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 taskPath = join(changeDir, "task.md");
@@ -62,7 +91,9 @@ export async function assembleBuildPrompt(
try {
taskContent = await readFile(taskPath, "utf-8");
} catch {
throw new Error(`task.md not found in ${changeDir}`);
throw new CommandError(`变更 "${changeName}" 尚未完成规划task.md 不存在`, {
hint: `请先完成规划阶段rune plan ${changeName} 生成所有规划文档`,
});
}
const tasks = parseTasks(taskContent);
@@ -81,22 +112,47 @@ export async function assembleBuildPrompt(
for (const task of pendingTasks) {
parts.push(`- [ ] ${task.text}`);
}
parts.push(
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
);
parts.push(`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`);
return parts.join("\n");
}
export function assembleArchivePrompt(
export async function assembleArchivePrompt(
config: RuneConfig,
projectRoot: string,
changeName: string,
): string {
): Promise<string> {
const archive = config.stages.archive;
if (!archive) throw new Error("archive 阶段未配置");
if (!archive)
throw new CommandError("归档阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.archive",
});
const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md");
const parts: string[] = [];
parts.push(`# 归档阶段:${changeName}\n`);
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(`以下 ${incompleteTasks.length} 个任务尚未完成:`);
for (const t of incompleteTasks) {
parts.push(`- [ ] ${t.text}`);
}
parts.push("");
parts.push("请询问用户是否确认在任务未全部完成的情况下归档。");
parts.push("如用户确认,则继续归档;否则中止并返回构建阶段。");
parts.push("");
}
} catch {
// task.md 不存在时不追加警告
}
parts.push(archive.prompt);
return parts.join("\n");
}

View File

@@ -3,12 +3,11 @@ import { readFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import { parse as parseYaml } from "yaml";
import { defaultConfig } from "../defaults/config.ts";
import { ConfigError } from "../cli/errors.ts";
import type { RuneConfig } from "../types.ts";
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
export function findProjectRoot(
startDir: string = process.cwd(),
): string | null {
export function findProjectRoot(startDir: string = process.cwd()): string | null {
let dir = startDir;
while (true) {
if (existsSync(join(dir, RUNE_DIR))) {
@@ -22,12 +21,64 @@ export function findProjectRoot(
export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
let merged: RuneConfig;
try {
const content = await readFile(configPath, "utf-8");
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
return mergeConfig(userConfig ?? {});
merged = mergeConfig(userConfig ?? {});
} catch {
return mergeConfig({});
merged = mergeConfig({});
}
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}" 不能依赖自身`);
}
if (!docNames.has(dep)) {
throw new ConfigError(`文档 "${doc.name}" 依赖 "${dep}" 不存在于 plan.documents 中`);
}
}
}
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(" → ")}`);
}
}
}

View File

@@ -1,14 +1,16 @@
import { readdir, stat, readFile } from "node:fs/promises";
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 { parseTasks } from "./task-parser.ts";
export async function scanChanges(
projectRoot: string,
config?: RuneConfig,
): Promise<ChangeStatus[]> {
const changesDir = getChangesDir(projectRoot);
const results: ChangeStatus[] = [];
const planDocs = config?.stages.plan?.documents;
try {
const entries = await readdir(changesDir);
@@ -17,11 +19,32 @@ export async function scanChanges(
const entryStat = await stat(entryPath);
if (!entryStat.isDirectory()) continue;
const docs = await readdir(entryPath);
const documents = docs.filter((d) => d.endsWith(".md"));
const files = await readdir(entryPath);
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;
const taskFile = docs.find((d) => d === "task.md");
const taskFile = files.find((d) => d === "task.md");
if (taskFile) {
const content = await readFile(join(entryPath, taskFile), "utf-8");
const tasks = parseTasks(content);
@@ -31,10 +54,15 @@ export async function scanChanges(
};
}
results.push({ name: entry, documents, taskProgress });
results.push({
name: entry,
documents,
planCompleted,
buildUnlocked,
taskProgress,
});
}
} catch {
}
} catch {}
return results;
}

View File

@@ -3,22 +3,242 @@ import type { RuneConfig } from "../types.ts";
export const defaultConfig: RuneConfig = {
stages: {
discuss: {
prompt: `你是一位经验丰富的软件架构师和技术顾问
用户将与你讨论一个软件开发需求。
prompt: `进入探索模式。深度思考,自由发散。跟随对话走向
你的职责是:
1. 深入理解用户的需求背景和目标
2. 提出关键问题澄清模糊之处
3. 分析技术可行性和潜在风险
4. 提供专业的架构建议
**重要:探索模式是用于思考的,不是用于实现的。** 你可以读文件、搜索代码、调查代码库,但绝对不写代码或实现功能。如果用户要求你实现,提醒用户先退出探索模式,使用 /rune-plan <变更名> 进入规划阶段。
请与用户自由讨论,不要急于输出文档。讨论充分后,建议用户使用 /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: {
documents: [
{
name: "design",
prompt: `根据之前的讨论内容,生成一份设计文档
prompt: `先获取当前规划状态
建议执行: rune status <变更名>
请根据之前的讨论内容和状态输出,生成一份设计文档。
要求:
- 清晰描述背景和目标
@@ -42,7 +262,12 @@ export const defaultConfig: RuneConfig = {
},
{
name: "task",
prompt: `请根据设计文档,生成一份任务列表。
depend: ["design"],
prompt: `请先获取当前规划状态。
建议执行: rune status <变更名>
请根据设计文档,生成一份任务列表。
要求:
- 将设计拆分为可独立执行的小任务

View File

@@ -2,6 +2,13 @@ export interface DocumentConfig {
name: string;
prompt: string;
template?: string;
depend?: string[];
}
export interface DocumentStatus {
name: string;
completed: boolean;
dependMet: boolean;
}
export interface DiscussStage {
@@ -38,7 +45,9 @@ export interface TaskItem {
export interface ChangeStatus {
name: string;
documents: string[];
documents: DocumentStatus[];
planCompleted: boolean;
buildUnlocked: boolean;
taskProgress: { completed: number; total: number } | null;
}

View File

@@ -0,0 +1,120 @@
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).toContain("rune-status.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("如果用户没有指定变更名称");
}
});
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("重复注入时不覆盖已存在的文件", 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("更新 status 命令", async () => {
await updateClaudeCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md"))).toBe(true);
});
});

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
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 { injectOpenCode } from "../../src/adapters/opencode.ts";
import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__");
@@ -24,9 +24,9 @@ describe("injectOpenCode", () => {
for (const stage of ["discuss", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`);
expect(skills).toContain(`rune-${stage}`);
expect(
existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md")),
).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"))).toBe(
true,
);
}
});
@@ -38,9 +38,7 @@ describe("injectOpenCode", () => {
expect(commands).toContain("rune-status.md");
expect(skills).toContain("rune-status");
expect(
existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md")),
).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(true);
});
it("command 文件包含 skill 调用指令", async () => {
@@ -75,6 +73,24 @@ describe("injectOpenCode", () => {
}
});
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("rune status");
});
it("重复注入时不覆盖已存在的文件", async () => {
await injectOpenCode(TMP_DIR);
const originalContent = await readFile(
@@ -90,3 +106,45 @@ describe("injectOpenCode", () => {
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("更新 status 命令和 skill", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(true);
});
});

268
tests/agent/agent-llm.ts Normal file
View File

@@ -0,0 +1,268 @@
import type { RuneConfig } from "../../src/types.ts";
import type { AgentRunner, AgentResult } from "./runner.ts";
import {
assemblePlanPrompt,
assembleBuildPrompt,
assembleArchivePrompt,
} from "../../src/core/assembler.ts";
export interface LLMAction {
type: "write_file" | "check_task" | "done";
path?: string;
content?: string;
taskIndex?: number;
}
export interface LLMPlan {
actions: LLMAction[];
}
export function isLLMAvailable(): boolean {
return !!process.env.RUNE_E2E_LLM_API_KEY;
}
export function getLLMEnv() {
return {
provider: process.env.RUNE_E2E_LLM_PROVIDER || "openai",
model: process.env.RUNE_E2E_LLM_MODEL || "gpt-4o-mini",
apiKey: process.env.RUNE_E2E_LLM_API_KEY || "",
baseUrl: process.env.RUNE_E2E_LLM_BASE_URL || "https://api.openai.com/v1",
};
}
async function callLLM(prompt: string): Promise<LLMPlan> {
const { provider, model, apiKey, baseUrl } = getLLMEnv();
if (provider === "openai" || provider === "openrouter") {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{
role: "system",
content: `你是一个自动化构建工具,负责根据提示词生成精确的文件操作计划。
请严格按以下 JSON 格式输出行动计划(不要包含其他内容):
{
"actions": [
{ "type": "write_file", "path": "相对路径", "content": "文件内容" },
{ "type": "check_task", "taskIndex": 0 }
]
}
可用的 action 类型:
- write_file: 写入文件path 和 content 必填
- check_task: 标记任务为已完成taskIndex 是任务列表中从 0 开始的索引
- done: 表示所有操作已完成
根据提示词要求,生成完整的操作计划。不要跳过任何步骤。`,
},
{
role: "user",
content: prompt,
},
],
temperature: undefined,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM API error ${response.status}: ${body}`);
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content || "";
return parseLLMResponse(text);
}
if (provider === "anthropic") {
const response = await fetch(`https://api.anthropic.com/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
max_tokens: 4096,
system: `你是一个自动化构建工具,负责根据提示词生成精确的文件操作计划。
请严格按以下 JSON 格式输出行动计划(不要包含其他内容):
{
"actions": [
{ "type": "write_file", "path": "相对路径", "content": "文件内容" },
{ "type": "check_task", "taskIndex": 0 }
]
}
可用的 action 类型:
- write_file: 写入文件path 和 content 必填
- check_task: 标记任务为已完成taskIndex 是任务列表中从 0 开始的索引
- done: 表示所有操作已完成
根据提示词要求,生成完整的操作计划。不要跳过任何步骤。`,
messages: [{ role: "user", content: prompt }],
temperature: undefined,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM API error ${response.status}: ${body}`);
}
const data = await response.json();
const text = data.content?.[0]?.text || "";
return parseLLMResponse(text);
}
throw new Error(`不支持的 LLM provider: ${provider}`);
}
function parseLLMResponse(text: string): LLMPlan {
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`LLM 输出中未找到 JSON: ${text.slice(0, 200)}`);
}
try {
const plan: LLMPlan = JSON.parse(jsonMatch[0]);
if (!plan.actions || !Array.isArray(plan.actions)) {
throw new Error("LLM 输出缺少 actions 数组");
}
return plan;
} catch (e) {
throw new Error(`LLM 输出 JSON 解析失败: ${text.slice(0, 200)}`, { cause: e });
}
}
import { mkdir, writeFile, readFile, rename } from "node:fs/promises";
import { join } from "node:path";
import { getChangeDir, getArchiveDir } from "../../src/core/config.ts";
import { parseTasks } from "../../src/core/task-parser.ts";
async function executeActions(projectDir: string, plan: LLMPlan): Promise<string[]> {
const files: string[] = [];
for (const action of plan.actions) {
switch (action.type) {
case "write_file": {
if (!action.path || action.content === undefined) {
throw new Error("write_file action 缺少 path 或 content");
}
const fullPath = join(projectDir, action.path);
await mkdir(join(fullPath, ".."), { recursive: true });
await writeFile(fullPath, action.content);
files.push(action.path);
break;
}
case "check_task": {
if (action.taskIndex === undefined) {
throw new Error("check_task action 缺少 taskIndex");
}
break;
}
case "done":
break;
}
}
return files;
}
function extractTaskCheckActions(plan: LLMPlan): number[] {
return plan.actions
.filter((a) => a.type === "check_task" && a.taskIndex !== undefined)
.map((a) => a.taskIndex!);
}
export class LLMJudgeRunner implements AgentRunner {
readonly tier = 3;
async runPlan(
projectDir: string,
changeName: string,
docName: string,
config: RuneConfig,
): Promise<AgentResult> {
if (!isLLMAvailable()) {
throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置");
}
const prompt = await assemblePlanPrompt(config, projectDir, changeName, docName);
const plan = await callLLM(prompt);
return {
files: [],
rawPlan: plan,
};
}
async runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult> {
if (!isLLMAvailable()) {
throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置");
}
const changeDir = getChangeDir(projectDir, changeName);
const prompt = await assembleBuildPrompt(config, projectDir, changeName);
const plan = await callLLM(prompt);
const files = await executeActions(projectDir, plan);
const taskIndices = extractTaskCheckActions(plan);
if (taskIndices.length > 0) {
const taskPath = join(changeDir, "task.md");
let taskContent = await readFile(taskPath, "utf-8");
const tasks = parseTasks(taskContent);
for (const index of taskIndices) {
if (index < tasks.length) {
const task = tasks[index];
const oldLine = `- [ ] ${task.text}`;
const newLine = `- [x] ${task.text}`;
taskContent = taskContent.replace(oldLine, newLine);
}
}
await writeFile(taskPath, taskContent);
files.push("task.md");
}
return {
files: [...new Set(files)],
rawPlan: plan,
};
}
async runArchive(
projectDir: string,
changeName: string,
config: RuneConfig,
): Promise<AgentResult> {
if (!isLLMAvailable()) {
throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置");
}
const prompt = await assembleArchivePrompt(config, projectDir, changeName);
const changeDir = getChangeDir(projectDir, changeName);
const plan = await callLLM(prompt);
const today = new Date().toISOString().slice(0, 10);
const archiveDir = getArchiveDir(projectDir);
await mkdir(archiveDir, { recursive: true });
const dest = join(archiveDir, `${today}-${changeName}`);
await rename(changeDir, dest);
return {
files: [],
rawPlan: plan,
};
}
}

119
tests/agent/agent-mock.ts Normal file
View File

@@ -0,0 +1,119 @@
import { mkdir, writeFile, readFile, rename } from "node:fs/promises";
import { join } from "node:path";
import type { RuneConfig, DocumentConfig } from "../../src/types.ts";
import type { AgentRunner, AgentResult } from "./runner.ts";
import { getChangeDir, getArchiveDir } from "../../src/core/config.ts";
import { parseTasks } from "../../src/core/task-parser.ts";
export class CommandLevelRunner implements AgentRunner {
readonly tier = 1;
async runPlan(
projectDir: string,
changeName: string,
docName: string,
config: RuneConfig,
): Promise<AgentResult> {
const changeDir = getChangeDir(projectDir, changeName);
await mkdir(changeDir, { recursive: true });
const planStage = config.stages.plan;
if (!planStage) {
throw new Error("plan 阶段未配置");
}
const docConfig = planStage.documents.find((d) => d.name === docName);
if (!docConfig) {
throw new Error(`文档 "${docName}" 未在 plan.documents 中配置`);
}
const content = this.renderDocument(docConfig, changeName);
const filePath = join(changeDir, `${docName}.md`);
await writeFile(filePath, content);
return { files: [`${docName}.md`] };
}
async runBuild(
projectDir: string,
changeName: string,
_config: RuneConfig,
): Promise<AgentResult> {
const changeDir = getChangeDir(projectDir, changeName);
const taskPath = join(changeDir, "task.md");
let taskContent: string;
try {
taskContent = await readFile(taskPath, "utf-8");
} catch {
throw new Error(`变更 "${changeName}" 的 task.md 不存在,请先完成规划`);
}
const tasks = parseTasks(taskContent);
const pending = tasks.filter((t) => !t.checked);
if (pending.length === 0) {
return { files: [] };
}
const files: string[] = [];
for (const task of pending) {
const oldLine = `- [ ] ${task.text}`;
const newLine = `- [x] ${task.text}`;
taskContent = taskContent.replace(oldLine, newLine);
const implFile = `${task.text
.replace(/[^a-zA-Z\u4e00-\u9fa5]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase()}.ts`;
await writeFile(join(changeDir, implFile), `// ${task.text}\n`);
files.push(implFile);
}
await writeFile(taskPath, taskContent);
files.push("task.md");
return { files };
}
async runArchive(
projectDir: string,
changeName: string,
_config: RuneConfig,
): Promise<AgentResult> {
const changeDir = getChangeDir(projectDir, changeName);
const taskPath = join(changeDir, "task.md");
try {
const taskContent = await readFile(taskPath, "utf-8");
const tasks = parseTasks(taskContent);
const pending = tasks.filter((t) => !t.checked);
if (pending.length > 0) {
throw new Error(`变更 "${changeName}" 存在 ${pending.length} 个未完成任务,无法归档`);
}
} catch (e) {
if (e instanceof Error && e.message.includes("未完成任务")) {
throw e;
}
}
const today = new Date().toISOString().slice(0, 10);
const archiveDir = getArchiveDir(projectDir);
await mkdir(archiveDir, { recursive: true });
const dest = join(archiveDir, `${today}-${changeName}`);
await rename(changeDir, dest);
return { files: [] };
}
private renderDocument(doc: DocumentConfig, changeName: string): string {
if (doc.template) {
return doc.template.replace(/\{\{change-name\}\}/g, changeName) + "\n";
}
return `# ${doc.name}\n\n${doc.prompt}\n`;
}
}
export function createRunner(): AgentRunner {
return new CommandLevelRunner();
}

View File

@@ -0,0 +1,69 @@
import type { RuneConfig } from "../../src/types.ts";
import type { AgentRunner, AgentResult } from "./runner.ts";
import { CommandLevelRunner } from "./agent-mock.ts";
export type PlanOverride = (
projectDir: string,
changeName: string,
docName: string,
config: RuneConfig,
) => Promise<AgentResult>;
export type BuildOverride = (
projectDir: string,
changeName: string,
config: RuneConfig,
) => Promise<AgentResult>;
export type ArchiveOverride = (
projectDir: string,
changeName: string,
config: RuneConfig,
) => Promise<AgentResult>;
export interface ScenarioOverrides {
plan?: PlanOverride;
build?: BuildOverride;
archive?: ArchiveOverride;
}
export class ScenarioRunner implements AgentRunner {
readonly tier = 2;
private defaults: CommandLevelRunner;
private overrides: ScenarioOverrides;
constructor(defaults: CommandLevelRunner, overrides: ScenarioOverrides = {}) {
this.defaults = defaults;
this.overrides = overrides;
}
async runPlan(
projectDir: string,
changeName: string,
docName: string,
config: RuneConfig,
): Promise<AgentResult> {
if (this.overrides.plan) {
return this.overrides.plan(projectDir, changeName, docName, config);
}
return this.defaults.runPlan(projectDir, changeName, docName, config);
}
async runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult> {
if (this.overrides.build) {
return this.overrides.build(projectDir, changeName, config);
}
return this.defaults.runBuild(projectDir, changeName, config);
}
async runArchive(
projectDir: string,
changeName: string,
config: RuneConfig,
): Promise<AgentResult> {
if (this.overrides.archive) {
return this.overrides.archive(projectDir, changeName, config);
}
return this.defaults.runArchive(projectDir, changeName, config);
}
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createRunner } from "./agent-mock.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
writeDoc,
changeFileExists,
} from "./fixtures.ts";
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
import { getChangeDir } from "../../src/core/config.ts";
describe("e2e: archive 阶段", () => {
let runner: ReturnType<typeof createRunner>;
beforeEach(async () => {
await setupTempDir();
runner = createRunner();
});
afterEach(async () => {
await cleanupTempDir();
});
it("全部任务完成时变更移至 archive/", async () => {
const config = await createFreshProject();
await writeDoc("ready", "task", "- [x] 任务\n");
await runner.runArchive(getTempDir(), "ready", config);
expect(changeFileExists("ready", "task.md")).toBe(false);
const archives = await scanArchives(getTempDir());
expect(archives.length).toBeGreaterThanOrEqual(1);
const changes = await scanChanges(getTempDir(), config);
expect(changes.find((c) => c.name === "ready")).toBeUndefined();
});
it("任务未完成时阻止归档", async () => {
const config = await createFreshProject();
await writeDoc("pending", "task", "- [ ] 未完成\n");
await expect(runner.runArchive(getTempDir(), "pending", config)).rejects.toThrow("未完成任务");
expect(changeFileExists("pending", "task.md")).toBe(true);
});
it("无 task.md 时允许归档", async () => {
const config = await createFreshProject();
const changeDir = getChangeDir(getTempDir(), "no-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计\n");
await runner.runArchive(getTempDir(), "no-task", config);
expect(changeFileExists("no-task", "design.md")).toBe(false);
const archives = await scanArchives(getTempDir());
expect(archives.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createRunner } from "./agent-mock.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
writeDoc,
} from "./fixtures.ts";
import { scanChanges } from "../../src/core/scanner.ts";
import { getChangeDir } from "../../src/core/config.ts";
describe("e2e: build 阶段", () => {
let runner: ReturnType<typeof createRunner>;
beforeEach(async () => {
await setupTempDir();
runner = createRunner();
});
afterEach(async () => {
await cleanupTempDir();
});
it("单任务执行,勾选并产出文件", async () => {
const config = await createFreshProject();
await writeDoc("auth", "task", "- [ ] 实现登录 API\n");
const result = await runner.runBuild(getTempDir(), "auth", config);
expect(result.files).toContain("task.md");
expect(result.files.length).toBeGreaterThanOrEqual(2);
const tasks = await scanChanges(getTempDir(), config);
expect(tasks[0].taskProgress).toEqual({ completed: 1, total: 1 });
});
it("多任务按顺序逐个勾选", async () => {
const config = await createFreshProject();
await writeDoc("multi-task", "task", `- [ ] 任务A\n- [ ] 任务B\n- [ ] 任务C\n`);
const result = await runner.runBuild(getTempDir(), "multi-task", config);
expect(result.files).toHaveLength(4);
expect(result.files).toContain("task.md");
const taskContent = readFileSync(
join(getChangeDir(getTempDir(), "multi-task"), "task.md"),
"utf-8",
);
expect(taskContent).toContain("- [x] 任务A");
expect(taskContent).toContain("- [x] 任务B");
expect(taskContent).toContain("- [x] 任务C");
const tasks = await scanChanges(getTempDir(), config);
expect(tasks[0].taskProgress).toEqual({ completed: 3, total: 3 });
});
it("空 task 清单时 taskProgress 提示无任务", async () => {
const config = await createFreshProject();
await writeDoc("empty", "task", "\n");
const changes = await scanChanges(getTempDir(), config);
const emptyChange = changes.find((c) => c.name === "empty");
expect(emptyChange).toBeDefined();
if (emptyChange) {
expect(emptyChange.taskProgress).toEqual({ completed: 0, total: 0 });
}
});
it("plan 未完成时 build 不可用", async () => {
const config = await createFreshProject();
await expect(runner.runBuild(getTempDir(), "no-task", config)).rejects.toThrow(
"task.md 不存在",
);
});
it("任务全部完成后状态为已完成", async () => {
const config = await createFreshProject();
await writeDoc("done", "task", "- [x] 已完成任务\n");
const result = await runner.runBuild(getTempDir(), "done", config);
expect(result.files).toEqual([]);
const tasks = await scanChanges(getTempDir(), config);
const doneChange = tasks.find((c) => c.name === "done");
expect(doneChange).toBeDefined();
if (doneChange) {
expect(doneChange.taskProgress).toEqual({ completed: 1, total: 1 });
}
});
});

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { createRunner } from "./agent-mock.ts";
import { ScenarioRunner, type BuildOverride } from "./agent-scenario.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
writeDoc,
} from "./fixtures.ts";
import { scanChanges } from "../../src/core/scanner.ts";
import { validateConfig } from "../../src/core/config.ts";
import { ConfigError } from "../../src/cli/errors.ts";
import type { RuneConfig } from "../../src/types.ts";
describe("e2e: 文档依赖", () => {
let runner: ReturnType<typeof createRunner>;
beforeEach(async () => {
await setupTempDir();
runner = createRunner();
});
afterEach(async () => {
await cleanupTempDir();
});
it("依赖文档按顺序创建A → B → C 链式依赖)", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "a", prompt: "文档 A" },
{ name: "b", prompt: "文档 B", depend: ["a"] },
{ name: "c", prompt: "文档 C", depend: ["b"] },
],
},
},
};
await createFreshProject();
await runner.runPlan(getTempDir(), "chain", "a", config);
let changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(1);
const chain = changes[0]!;
const docsA = chain.documents;
const aDoc = docsA.find((d) => d.name === "a")!;
const bDoc = docsA.find((d) => d.name === "b")!;
const cDoc = docsA.find((d) => d.name === "c")!;
expect(aDoc.completed).toBe(true);
expect(aDoc.dependMet).toBe(true);
expect(bDoc.completed).toBe(false);
// a.md 已存在,所以 b 的依赖已满足
expect(bDoc.dependMet).toBe(true);
expect(cDoc.completed).toBe(false);
// c 依赖 bb.md 不存在,所以 dependMet=false
expect(cDoc.dependMet).toBe(false);
expect(chain.planCompleted).toBe(false);
await runner.runPlan(getTempDir(), "chain", "b", config);
changes = await scanChanges(getTempDir(), config);
const chain2 = changes[0]!;
const docsB = chain2.documents;
const bDoc2 = docsB.find((d) => d.name === "b")!;
const cDoc2 = docsB.find((d) => d.name === "c")!;
expect(bDoc2.completed).toBe(true);
expect(bDoc2.dependMet).toBe(true);
expect(cDoc2.completed).toBe(false);
// b.md 已存在c 的依赖现在满足
expect(cDoc2.dependMet).toBe(true);
expect(chain2.planCompleted).toBe(false);
await runner.runPlan(getTempDir(), "chain", "c", config);
changes = await scanChanges(getTempDir(), config);
const chain3 = changes[0]!;
const docsC = chain3.documents;
expect(docsC.every((d) => d.completed)).toBe(true);
expect(docsC.every((d) => d.dependMet)).toBe(true);
expect(chain3.planCompleted).toBe(true);
expect(chain3.buildUnlocked).toBe(true);
});
it("引用不存在文档的依赖被校验拒绝", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "设计", depend: ["ghost"] }],
},
},
};
try {
validateConfig(config);
// 不应该走到这里
expect(true).toBe(false);
} catch (e) {
expect(e).toBeInstanceOf(ConfigError);
expect((e as ConfigError).message).toContain("ghost");
}
});
it("依赖链断开时 planCompleted 仍为 false", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "设计" },
{ name: "task", prompt: "任务", depend: ["design"] },
],
},
},
};
await createFreshProject();
await writeDoc("broken", "task", "# 任务\n");
const changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(1);
const broken = changes[0]!;
const taskDoc = broken.documents.find((d) => d.name === "task")!;
const designDoc = broken.documents.find((d) => d.name === "design")!;
expect(taskDoc.completed).toBe(true);
expect(taskDoc.dependMet).toBe(false);
expect(designDoc.completed).toBe(false);
expect(broken.planCompleted).toBe(false);
expect(broken.buildUnlocked).toBe(false);
});
it("依赖满足后才允许 build", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "设计" },
{ name: "task", prompt: "任务", depend: ["design"] },
],
},
},
};
await createFreshProject();
const baseRunner = createRunner();
const buildWithDependCheck: BuildOverride = async (projectDir, changeName, cfg) => {
const changes = await scanChanges(projectDir, cfg);
const change = changes.find((c) => c.name === changeName);
if (!change) throw new Error(`变更 "${changeName}" 不存在`);
if (!change.planCompleted) {
throw new Error(`变更 "${changeName}" 的 plan 阶段未完成`);
}
return baseRunner.runBuild(projectDir, changeName, cfg);
};
const scenarioRunner = new ScenarioRunner(baseRunner, { build: buildWithDependCheck });
await writeDoc("build-dep", "task", "# 任务\n- [ ] do something\n");
// 依赖未满足时 build 应抛出错误
await expect(scenarioRunner.runBuild(getTempDir(), "build-dep", config)).rejects.toThrow(
"plan 阶段未完成",
);
await runner.runPlan(getTempDir(), "build-dep", "design", config);
const changesAfter = await scanChanges(getTempDir(), config);
expect(changesAfter[0]!.planCompleted).toBe(true);
// 依赖满足后 build 应正常执行
await expect(scenarioRunner.runBuild(getTempDir(), "build-dep", config)).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { CommandLevelRunner } from "./agent-mock.ts";
import { ScenarioRunner } from "./agent-scenario.ts";
import type { PlanOverride } from "./agent-scenario.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
writeDoc,
changeFileExists,
} from "./fixtures.ts";
import { assertNoFile } from "./validators.ts";
import { scanChanges } from "../../src/core/scanner.ts";
import { loadConfig } from "../../src/core/config.ts";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { readFileSync } from "node:fs";
const brokenPlan: PlanOverride = async (_projectDir, changeName, docName, _cfg) => {
const wrongDir = join(getTempDir(), "wrong-dir");
await mkdir(wrongDir, { recursive: true });
await writeFile(join(wrongDir, `${docName}.md`), "some content\n");
return {
files: [],
missed: [`${docName}.md`],
};
};
const emptyPlan: PlanOverride = async (_projectDir, _changeName, _docName, _cfg) => {
return { files: [] };
};
describe("e2e: 错误场景", () => {
beforeEach(async () => {
await setupTempDir();
});
afterEach(async () => {
await cleanupTempDir();
});
it("agent 文件写错路径", async () => {
const config = await createFreshProject();
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: brokenPlan });
const result = await errorRunner.runPlan(getTempDir(), "wrong-path", "design", config);
expect(result.files).toHaveLength(0);
expect(result.missed).toEqual(["design.md"]);
assertNoFile(getTempDir(), ".rune/changes/wrong-path/design.md");
});
it("agent 跳过依赖文档", async () => {
const config = await createFreshProject();
const skipDepsPlan: PlanOverride = async (_projectDir, changeName, docName, _cfg) => {
const standardRunner = new CommandLevelRunner();
if (docName === "task") {
return standardRunner.runPlan(getTempDir(), changeName, "task", config);
}
if (docName === "design") {
return standardRunner.runPlan(getTempDir(), changeName, "design", config);
}
return { files: [] };
};
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: skipDepsPlan });
await errorRunner.runPlan(getTempDir(), "skip-deps", "task", config);
expect(changeFileExists("skip-deps", "task.md")).toBe(true);
let changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(1);
const taskDoc = changes[0]!.documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.completed).toBe(true);
expect(taskDoc!.dependMet).toBe(false);
const designDoc = changes[0]!.documents.find((d) => d.name === "design");
expect(designDoc).toBeDefined();
expect(designDoc!.completed).toBe(false);
await errorRunner.runPlan(getTempDir(), "skip-deps", "design", config);
expect(changeFileExists("skip-deps", "design.md")).toBe(true);
changes = await scanChanges(getTempDir(), config);
const taskDoc2 = changes[0]!.documents.find((d) => d.name === "task");
expect(taskDoc2!.dependMet).toBe(true);
});
it("agent 创建空文件", async () => {
const config = await createFreshProject();
await writeDoc("empty-file", "design", "");
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: emptyPlan });
const result = await errorRunner.runPlan(getTempDir(), "empty-file", "design", config);
expect(changeFileExists("empty-file", "design.md")).toBe(true);
const filePath = join(getTempDir(), ".rune/changes/empty-file/design.md");
const content = readFileSync(filePath, "utf-8");
expect(content).toBe("");
expect(result.files).toHaveLength(0);
});
it("config.yaml 语法错误", async () => {
const runeDir = join(getTempDir(), ".rune");
await mkdir(runeDir, { recursive: true });
const invalidYaml = "stages\n plan documents\n";
await writeFile(join(runeDir, "config.yaml"), invalidYaml, "utf-8");
const loadedConfig = await loadConfig(getTempDir());
expect(loadedConfig).toBeDefined();
expect(loadedConfig.stages.plan).toBeDefined();
expect(loadedConfig.stages.plan!.documents).toBeDefined();
});
});

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { createRunner } from "./agent-mock.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
changeFileExists,
} from "./fixtures.ts";
import { assertAllTasksDone } from "./validators.ts";
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
import { assembleDiscussPrompt } from "../../src/core/assembler.ts";
import { validateChangeName } from "../../src/cli.ts";
describe("e2e: 全流程", () => {
let runner: ReturnType<typeof createRunner>;
beforeEach(async () => {
await setupTempDir();
runner = createRunner();
});
afterEach(async () => {
await cleanupTempDir();
});
it("完整四阶段流程discuss → plan → build → archive", async () => {
const config = await createFreshProject();
const discussPrompt = assembleDiscussPrompt(config);
expect(typeof discussPrompt).toBe("string");
expect(discussPrompt.length).toBeGreaterThan(0);
await runner.runPlan(getTempDir(), "full-flow", "design", config);
await runner.runPlan(getTempDir(), "full-flow", "task", config);
let changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(1);
expect(changes[0]!.name).toBe("full-flow");
expect(changes[0]!.planCompleted).toBe(true);
expect(changes[0]!.buildUnlocked).toBe(true);
const buildResult = await runner.runBuild(getTempDir(), "full-flow", config);
expect(buildResult.files.length).toBeGreaterThan(0);
changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(1);
assertAllTasksDone(changes[0]!);
await runner.runArchive(getTempDir(), "full-flow", config);
expect(changeFileExists("full-flow", "task.md")).toBe(false);
const archives = await scanArchives(getTempDir());
expect(archives.length).toBeGreaterThanOrEqual(1);
changes = await scanChanges(getTempDir(), config);
expect(changes.find((c) => c.name === "full-flow")).toBeUndefined();
});
it("多变更并行互不干扰", async () => {
const config = await createFreshProject();
await runner.runPlan(getTempDir(), "变更A", "design", config);
await runner.runPlan(getTempDir(), "变更A", "task", config);
await runner.runPlan(getTempDir(), "变更B", "design", config);
await runner.runPlan(getTempDir(), "变更B", "task", config);
let changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(2);
const changeA = changes.find((c) => c.name === "变更A");
const changeB = changes.find((c) => c.name === "变更B");
expect(changeA).toBeDefined();
expect(changeB).toBeDefined();
expect(changeA!.planCompleted).toBe(true);
expect(changeB!.planCompleted).toBe(true);
await runner.runBuild(getTempDir(), "变更A", config);
await runner.runBuild(getTempDir(), "变更B", config);
changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(2);
assertAllTasksDone(changes.find((c) => c.name === "变更A")!);
assertAllTasksDone(changes.find((c) => c.name === "变更B")!);
expect(changes[0]!.taskProgress?.completed).toBe(changes[0]!.taskProgress?.total);
expect(changes[1]!.taskProgress?.completed).toBe(changes[1]!.taskProgress?.total);
});
describe("变更名非法字符拒绝", () => {
it("空字符串抛出错误", () => {
expect(() => validateChangeName("")).toThrow();
});
it("包含 / 抛出错误", () => {
expect(() => validateChangeName("变更/A")).toThrow();
});
it("包含 . 抛出错误", () => {
expect(() => validateChangeName("变更.A")).toThrow();
});
it("合法中文名称不抛出错误", () => {
expect(() => validateChangeName("变更名")).not.toThrow();
});
it("合法英文名称不抛出错误", () => {
expect(() => validateChangeName("my-change")).not.toThrow();
});
it("合法短横线名称不抛出错误", () => {
expect(() => validateChangeName("abc-def-xyz")).not.toThrow();
});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { LLMJudgeRunner, isLLMAvailable } from "./agent-llm.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
writeDoc,
} from "./fixtures.ts";
const tier3Available = isLLMAvailable();
if (!tier3Available) {
console.log("RUNE_E2E_LLM_ 环境变量未配置Tier 3 测试已跳过");
}
describe("e2e: Tier 3", () => {
const runner = new LLMJudgeRunner();
const testFn = tier3Available ? it : it.skip;
beforeEach(async () => {
await setupTempDir();
});
afterEach(async () => {
await cleanupTempDir();
});
testFn(
"plan: 单文档输出有效行动计划",
async () => {
const config = await createFreshProject();
const result = await runner.runPlan(getTempDir(), "user-auth", "design", config);
expect(result.rawPlan).toBeDefined();
expect(result.rawPlan).toHaveProperty("actions");
const plan = result.rawPlan as { actions: unknown[] };
expect(plan.actions.length).toBeGreaterThan(0);
},
120_000,
);
testFn(
"build: 单任务输出有效行动计划",
async () => {
const config = await createFreshProject();
await writeDoc("auth", "task", "- [ ] 实现登录 API\n");
const result = await runner.runBuild(getTempDir(), "auth", config);
expect(result.rawPlan).toBeDefined();
expect(result.rawPlan).toHaveProperty("actions");
},
120_000,
);
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { createRunner } from "./agent-mock.ts";
import {
setupTempDir,
cleanupTempDir,
getTempDir,
createFreshProject,
changeFileExists,
} from "./fixtures.ts";
import { assertDocCreated, assertDocContains, assertConfigInvalid } from "./validators.ts";
import { scanChanges } from "../../src/core/scanner.ts";
import type { RuneConfig } from "../../src/types.ts";
describe("e2e: plan 阶段", () => {
let runner: ReturnType<typeof createRunner>;
beforeEach(async () => {
await setupTempDir();
runner = createRunner();
});
afterEach(async () => {
await cleanupTempDir();
});
it("单文档按模板生成在正确路径", async () => {
const config = await createFreshProject();
const result = await runner.runPlan(getTempDir(), "user-auth", "design", config);
expect(result.files).toContain("design.md");
assertDocCreated(getTempDir(), "user-auth", "design");
assertDocContains(getTempDir(), "user-auth", "design", "user-auth");
});
it("多文档无依赖时均生成", async () => {
const config = await createFreshProject();
await runner.runPlan(getTempDir(), "my-change", "design", config);
await runner.runPlan(getTempDir(), "my-change", "task", config);
expect(changeFileExists("my-change", "design.md")).toBe(true);
expect(changeFileExists("my-change", "task.md")).toBe(true);
const changes = await scanChanges(getTempDir(), config);
expect(changes).toHaveLength(1);
expect(changes[0]!.planCompleted).toBe(true);
expect(changes[0]!.buildUnlocked).toBe(true);
});
it("模板变量 {{change-name}} 被替换", async () => {
const config = await createFreshProject();
await runner.runPlan(getTempDir(), "用户-login", "design", config);
assertDocContains(getTempDir(), "用户-login", "design", "用户-login 设计文档");
});
it("使用自定义 plan 配置(单文档 spec", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "spec", prompt: "生成规格", template: "# {{change-name}} 规格\n" }],
},
},
};
await createFreshProject(["opencode"]);
const result = await runner.runPlan(getTempDir(), "my-feature", "spec", config);
expect(result.files).toContain("spec.md");
assertDocContains(getTempDir(), "my-feature", "spec", "my-feature 规格");
});
it("多文档有依赖时 planCompleted 全完成后才为 true", async () => {
const config = await createFreshProject();
await runner.runPlan(getTempDir(), "dep-test", "design", config);
let changes = await scanChanges(getTempDir(), config);
expect(changes[0]!.planCompleted).toBe(false);
await runner.runPlan(getTempDir(), "dep-test", "task", config);
changes = await scanChanges(getTempDir(), config);
expect(changes[0]!.planCompleted).toBe(true);
expect(changes[0]!.buildUnlocked).toBe(true);
});
it("已有变更再次 plan 另一个文档(重复 plan", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计", template: "# 设计 #\n" },
{ name: "task", prompt: "生成任务", template: "# 任务 #\n" },
],
},
},
};
await runner.runPlan(getTempDir(), "multi", "design", config);
assertDocCreated(getTempDir(), "multi", "design");
const result = await runner.runPlan(getTempDir(), "multi", "task", config);
expect(result.files).toContain("task.md");
const changes = await scanChanges(getTempDir(), config);
expect(changes[0]!.planCompleted).toBe(true);
});
it("循环依赖配置被校验拒绝", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "a", prompt: "a", depend: ["b"] },
{ name: "b", prompt: "b", depend: ["a"] },
],
},
},
};
assertConfigInvalid(config);
});
it("自依赖配置被校验拒绝", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "设计", depend: ["design"] }],
},
},
};
assertConfigInvalid(config);
});
});

46
tests/agent/fixtures.ts Normal file
View File

@@ -0,0 +1,46 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile, 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 type { RuneConfig } from "../../src/types.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_agent_test__");
export function getTempDir(): string {
return TMP_DIR;
}
export async function setupTempDir(): Promise<void> {
await rm(TMP_DIR, { recursive: true, force: true });
await mkdir(TMP_DIR, { recursive: true });
}
export async function cleanupTempDir(): Promise<void> {
await rm(TMP_DIR, { recursive: true, force: true });
}
export async function createFreshProject(editors: string[] = ["opencode"]): Promise<RuneConfig> {
await runInit(TMP_DIR, editors);
return loadConfig(TMP_DIR);
}
export async function createChangeDir(changeName: string): Promise<string> {
const dir = getChangeDir(TMP_DIR, changeName);
await mkdir(dir, { recursive: true });
return dir;
}
export async function writeDoc(
changeName: string,
docName: string,
content: string,
): Promise<void> {
const dir = getChangeDir(TMP_DIR, changeName);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, `${docName}.md`), content);
}
export function changeFileExists(changeName: string, fileName: string): boolean {
return existsSync(join(getChangeDir(TMP_DIR, changeName), fileName));
}

19
tests/agent/runner.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { RuneConfig } from "../../src/types.ts";
export interface AgentResult {
files: string[];
missed?: string[];
rawPlan?: unknown;
}
export interface AgentRunner {
readonly tier: number;
runPlan(
projectDir: string,
changeName: string,
docName: string,
config: RuneConfig,
): Promise<AgentResult>;
runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult>;
runArchive(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult>;
}

64
tests/agent/validators.ts Normal file
View File

@@ -0,0 +1,64 @@
import { expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { ChangeStatus } from "../../src/types.ts";
import { validateConfig } from "../../src/core/config.ts";
import type { RuneConfig } from "../../src/types.ts";
export function assertFileExists(projectDir: string, relativePath: string): void {
expect(existsSync(join(projectDir, relativePath))).toBe(true);
}
export function assertNoFile(projectDir: string, relativePath: string): void {
expect(existsSync(join(projectDir, relativePath))).toBe(false);
}
export function assertDirExists(projectDir: string, relativePath: string): void {
expect(existsSync(join(projectDir, relativePath))).toBe(true);
}
export function assertFileContains(
projectDir: string,
relativePath: string,
expected: string,
): void {
const content = readFileSync(join(projectDir, relativePath), "utf-8");
expect(content).toContain(expected);
}
export function assertDocCreated(projectDir: string, changeName: string, docName: string): void {
assertFileExists(projectDir, `.rune/changes/${changeName}/${docName}.md`);
const content = readFileSync(
join(projectDir, `.rune/changes/${changeName}/${docName}.md`),
"utf-8",
);
expect(content.length).toBeGreaterThan(0);
}
export function assertDocContains(
projectDir: string,
changeName: string,
docName: string,
expected: string,
): void {
assertFileContains(projectDir, `.rune/changes/${changeName}/${docName}.md`, expected);
}
export function assertAllTasksDone(change: ChangeStatus): void {
expect(change.taskProgress).not.toBeNull();
if (change.taskProgress) {
expect(change.taskProgress.completed).toBe(change.taskProgress.total);
}
}
export function assertTaskProgress(change: ChangeStatus, completed: number, total: number): void {
expect(change.taskProgress).toEqual({ completed, total });
}
export function assertConfigValid(config: RuneConfig): void {
expect(() => validateConfig(config)).not.toThrow();
}
export function assertConfigInvalid(config: RuneConfig): void {
expect(() => validateConfig(config)).toThrow();
}

View File

@@ -7,7 +7,7 @@ describe("showGlobalHelp", () => {
expect(output).toContain("rune <命令> [参数]");
expect(output).toContain("init <工具...>");
expect(output).toContain("discuss");
expect(output).toContain("plan <名称>");
expect(output).toContain("plan <变更> <文档>");
expect(output).toContain("build <名称>");
expect(output).toContain("archive <名称>");
expect(output).toContain("status");
@@ -18,7 +18,7 @@ describe("showGlobalHelp", () => {
it("包含示例", () => {
const output = showGlobalHelp();
expect(output).toContain("rune init opencode");
expect(output).toContain("rune plan add-login");
expect(output).toContain("rune plan add-login design");
expect(output).toContain("rune status");
});
@@ -31,8 +31,9 @@ describe("showGlobalHelp", () => {
describe("showCommandHelp", () => {
it("plan 命令包含用法、参数、描述、示例", () => {
const output = showCommandHelp("plan");
expect(output).toContain("rune plan <change-name>");
expect(output).toContain("rune plan <change-name> <document-name>");
expect(output).toContain("<change-name>");
expect(output).toContain("<document-name>");
expect(output).toContain("规划阶段");
expect(output).toContain("rune plan add-user-auth");
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "bun:test";
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
import { mapError } from "../../src/cli.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("运行 rune 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("rune plan <change-name>");
expect(result.hint).toContain("rune help plan");
});
it("未知 Error 转为 InternalError", () => {
const err = new Error("something unexpected");
const result = mapError(err);
expect(result).toBeInstanceOf(InternalError);
expect(result.message).toBe("发生了未预期的错误");
});
it("非 Error 类型转为 InternalError", () => {
const result = mapError("字符串错误");
expect(result).toBeInstanceOf(InternalError);
});
});

View File

@@ -1,11 +1,6 @@
import { describe, it, expect } from "bun:test";
import { formatError } from "../../src/cli/output.ts";
import {
UsageError,
ConfigError,
CommandError,
InternalError,
} from "../../src/cli/errors.ts";
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
describe("formatError", () => {
it("只输出错误行(无 hint/usage", () => {
@@ -25,15 +20,13 @@ describe("formatError", () => {
usage: "rune plan <change-name>",
});
const output = formatError(err);
expect(output).toBe(
"错误: 缺少参数\n\n用法: rune plan <change-name>",
);
expect(output).toBe("错误: 缺少参数\n\n用法: rune plan <change-name>");
});
it("输出完整格式(错误 + 用法 + 提示)", () => {
const err = new UsageError("缺少必填参数 <change-name>", {
usage: "rune plan <change-name>",
hint: "请指定变更名称,如 \"add-login\"",
hint: '请指定变更名称,如 "add-login"',
});
const output = formatError(err);
expect(output).toBe(

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

@@ -0,0 +1,148 @@
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 时建议 build", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
taskProgress: null,
});
expect(suggestNextStep(status)).toContain("rune build 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

@@ -22,10 +22,7 @@ describe("runInit", () => {
expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
const content = await readFile(
join(TMP_DIR, ".rune", "config.yaml"),
"utf-8",
);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("# Rune 配置文件");
expect(content).toContain("stages:");
});
@@ -46,16 +43,10 @@ describe("runInit", () => {
it("重复 init 不覆盖 config.yaml", async () => {
await runInit(TMP_DIR, ["opencode"]);
await writeFile(
join(TMP_DIR, ".rune", "config.yaml"),
"自定义内容",
);
await writeFile(join(TMP_DIR, ".rune", "config.yaml"), "自定义内容");
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(
join(TMP_DIR, ".rune", "config.yaml"),
"utf-8",
);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toBe("自定义内容");
});

View File

@@ -24,7 +24,9 @@ describe("assembleDiscussPrompt", () => {
it("返回默认 discuss 提示词", () => {
const prompt = assembleDiscussPrompt(defaultConfig);
expect(prompt).toBeTruthy();
expect(prompt).toContain("软件架构师");
expect(prompt).toContain("探索模式");
expect(prompt).toContain("立场");
expect(prompt).toContain("护栏");
});
it("返回自定义 discuss 提示词", () => {
@@ -37,42 +39,68 @@ describe("assembleDiscussPrompt", () => {
});
describe("assemblePlanPrompt", () => {
it("包含变更名称和文档指引", async () => {
const prompt = await assemblePlanPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
it("包含指定文档名称和提示词", async () => {
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("user-auth");
expect(prompt).toContain("design");
expect(prompt).toContain("task");
expect(prompt).toContain("格式模板");
expect(prompt).not.toContain("task");
});
it("包含已有文档内容(重复 plan 场景)", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 已有设计");
const prompt = await assemblePlanPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("已有设计");
expect(prompt).toContain("在此基础上修订");
});
it("替换模板中的 {{change-name}}", async () => {
const prompt = await assemblePlanPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("user-auth 设计文档");
expect(prompt).toContain("user-auth 任务列表");
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 () => {
const config: RuneConfig = {
stages: {
@@ -87,7 +115,7 @@ describe("assemblePlanPrompt", () => {
},
},
};
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("my-feature 规格");
expect(prompt).not.toContain("design");
@@ -98,15 +126,8 @@ describe("assembleBuildPrompt", () => {
it("包含待执行任务列表", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(
join(changeDir, "task.md"),
`- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`,
);
const prompt = await assembleBuildPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`);
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
expect(prompt).toContain("任务二");
expect(prompt).toContain("待执行任务");
expect(prompt).toContain("共 2 项");
@@ -115,29 +136,29 @@ describe("assembleBuildPrompt", () => {
it("所有任务完成时提示可归档", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(
join(changeDir, "task.md"),
`- [x] 任务一\n- [x] 任务二`,
);
const prompt = await assembleBuildPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [x] 任务二`);
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
expect(prompt).toContain("已完成");
expect(prompt).toContain("归档");
});
it("task.md 不存在时抛出错误", async () => {
await expect(
assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"),
).rejects.toThrow("task.md not found");
it("task.md 不存在时抛出 CommandError 并附带提示", async () => {
try {
await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("尚未完成规划");
expect(e.message).toContain("nonexistent");
expect(e.hint).toContain("rune plan");
}
});
});
describe("assembleArchivePrompt", () => {
it("返回归档提示词", () => {
const prompt = assembleArchivePrompt(defaultConfig, "user-auth");
it("返回归档提示词", async () => {
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("归档");
});

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises";
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__");
@@ -89,10 +91,7 @@ describe("loadConfig", () => {
it("YAML 解析错误时返回默认配置", async () => {
const runeDir = join(TMP_DIR, ".rune");
await mkdir(runeDir, { recursive: true });
await writeFile(
join(runeDir, "config.yaml"),
`stages: [invalid yaml {{{`,
);
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined();
});
@@ -103,3 +102,71 @@ describe("getRuneDir", () => {
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();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
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__");
@@ -24,16 +25,16 @@ describe("scanChanges", () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
await writeFile(
join(changesDir, "user-auth", "task.md"),
`- [x] 任务一\n- [ ] 任务二`,
);
await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
const changes = await scanChanges(TMP_DIR);
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");
const docNames = changes[0].documents.map((d) => `${d.name}.md`);
expect(docNames).toContain("design.md");
expect(docNames).toContain("task.md");
expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false);
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
});
@@ -57,6 +58,93 @@ describe("scanChanges", () => {
const changes = await scanChanges(TMP_DIR);
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", () => {

View File

@@ -14,6 +14,34 @@ describe("defaultConfig", () => {
expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");
});
it("discuss 默认提示词包含关键章节", () => {
const prompt = defaultConfig.stages.discuss!.prompt;
expect(prompt).toContain("探索模式");
expect(prompt).toContain("立场");
expect(prompt).toContain("你可能做的事");
expect(prompt).toContain("Rune 感知");
expect(prompt).toContain("你不必做的事");
expect(prompt).toContain("结束探索");
expect(prompt).toContain("护栏");
expect(prompt).toContain("典型场景");
});
it("discuss 默认提示词不包含 OpenSpec 术语", () => {
const prompt = defaultConfig.stages.discuss!.prompt;
expect(prompt).not.toContain("openspec");
expect(prompt).not.toContain("/opsx:");
expect(prompt).not.toContain("proposal.md");
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 和 task 两个文档配置", () => {
const docs = defaultConfig.stages.plan!.documents;
expect(docs).toHaveLength(2);
@@ -25,25 +53,24 @@ describe("defaultConfig", () => {
});
it("plan 的 task 文档配置存在", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find(
(d) => d.name === "task",
);
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.prompt).toBeTruthy();
});
it("task 文档依赖 design", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.depend).toEqual(["design"]);
});
it("design 文档有 template", () => {
const designDoc = defaultConfig.stages.plan!.documents.find(
(d) => d.name === "design",
);
const designDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "design");
expect(designDoc!.template).toBeTruthy();
expect(designDoc!.template).toContain("{{change-name}}");
});
it("task 文档有 template", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find(
(d) => d.name === "task",
);
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.template).toBeTruthy();
expect(taskDoc!.template).toContain("- [ ]");
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
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 { runInit } from "../../src/commands/init.ts";
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
@@ -30,21 +30,24 @@ describe("完整 SDD 流程", () => {
const config = await loadConfig(TMP_DIR);
const discussPrompt = assembleDiscussPrompt(config);
expect(discussPrompt).toContain("软件架构师");
expect(discussPrompt).toContain("探索模式");
const changeName = "user-auth";
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");
const changeDir = getChangeDir(TMP_DIR, changeName);
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\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[0].name).toBe("user-auth");
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);
expect(buildPrompt).toContain("实现登录 API");
@@ -52,13 +55,15 @@ describe("完整 SDD 流程", () => {
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].planCompleted).toBe(true);
expect(updatedChanges[0].buildUnlocked).toBe(true);
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
expect(buildPrompt2).toContain("已完成");
const archivePrompt = assembleArchivePrompt(config, changeName);
const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
expect(archivePrompt).toContain("归档");
const today = new Date().toISOString().slice(0, 10);
const src = getChangeDir(TMP_DIR, changeName);
@@ -72,7 +77,7 @@ describe("完整 SDD 流程", () => {
const archives = await scanArchives(TMP_DIR);
expect(archives).toContain(`${today}-${changeName}`);
const postArchiveChanges = await scanChanges(TMP_DIR);
const postArchiveChanges = await scanChanges(TMP_DIR, config);
expect(postArchiveChanges).toHaveLength(0);
});
@@ -86,7 +91,7 @@ describe("完整 SDD 流程", () => {
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
}
const changes = await scanChanges(TMP_DIR);
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(2);
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
@@ -118,7 +123,7 @@ describe("完整 SDD 流程", () => {
expect(discussPrompt).toBe("自定义讨论");
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("test 规格");
expect(planPrompt).not.toContain("design");
@@ -126,4 +131,89 @@ describe("完整 SDD 流程", () => {
expect(config.stages.build).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);
const taskDoc = changes[0].documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.completed).toBe(false);
expect(taskDoc!.dependMet).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("是否确认");
});
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("警告");
});
});

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("无效的版本号格式");
});
});