From 8a67b2312c4846e7a9db987138aea4d5929f0b66 Mon Sep 17 00:00:00 2001 From: auYeCoding Date: Thu, 12 Nov 2026 13:12:58 -0800 Subject: [PATCH] =?UTF-8?q?feat(waypoint)!:=20project-navigator=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BB=A5=E5=88=87=E7=89=87=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E4=BD=8D,=20=E9=AA=A8=E6=9E=B6=E5=85=88=E8=A1=8C=E5=B9=B6?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E6=8E=A5=E7=BA=BF=E4=B8=8E=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作单位从工单改为切片: 一个切片一份文件, 分计划, 执行结果, 验收三段, 取代工单, 回执与验收报告 - 第一个切片固定为最小骨架, 通过之前不做其它切片; 验收必须给出从入口到新代码的接线证据 - 模块组织方式 (物理目录或 Visual Studio 筛选器) 与验证方式登记为项目设置, 计划带 "模块安排", 验收带 "结构核对", 新增 check --filters - 新增只产出结论的切片, 默认为交互式, 由编排会话在对话中直接做逆向与实测 - 默认每个切片只在验收后请用户决定一次; 去掉受阻状态, 实现子代理中途交回后由原子代理继续 - 回复不再有模板与校验, 去掉 Stop hook; 命令从约 30 条减到 12 条 - 删除工单状态机, 发布前核对, 调研与阅读子代理, 术语表, 风险与变更清单, 体检, evidence, model, report, 写作规则校验与文档生成脚本 - 不读取也不迁移 1.x 的记录, init --archive 把旧的 .navigator/ 改名归档 - 重写测试, 中英文使用说明与维护文档, 更新 CHANGELOG, README, 贡献与维护指南, CI 去掉生成文档检查 历次迭代都花在流程管控上, 这次回到本意: 合理的路线, 不忘进度, 从最小骨架逐步丰满. --- .github/workflows/validate.yml | 3 - CLAUDE.md | 2 +- CONTRIBUTING.md | 6 +- MAINTAINING.en.md | 30 +- MAINTAINING.md | 30 +- README.en.md | 6 +- README.md | 6 +- package.json | 3 +- plugins/waypoint/CHANGELOG.md | 30 + plugins/waypoint/README.en.md | 12 +- plugins/waypoint/README.md | 12 +- plugins/waypoint/agents/navigator-checker.md | 25 - plugins/waypoint/agents/navigator-executor.md | 16 +- plugins/waypoint/agents/navigator-reader.md | 23 - .../waypoint/agents/navigator-researcher.md | 24 - plugins/waypoint/agents/navigator-reviewer.md | 15 +- .../docs/project-navigator-maintaining.en.md | 515 +++++---- .../docs/project-navigator-maintaining.md | 451 ++++---- plugins/waypoint/docs/project-navigator.en.md | 329 +++--- plugins/waypoint/docs/project-navigator.md | 303 +++--- plugins/waypoint/hooks/hooks.json | 14 - .../skills/project-navigator/SKILL.md | 141 ++- .../project-navigator/guide/executor.md | 175 +--- .../references/brainstorm.md | 84 -- .../references/change-milestone.md | 51 - .../project-navigator/references/change.md | 40 + .../project-navigator/references/checkup.md | 28 - .../project-navigator/references/files.md | 104 -- .../project-navigator/references/intake.md | 109 +- .../references/order-loop.md | 127 --- .../project-navigator/references/recovery.md | 48 - .../project-navigator/references/replies.md | 173 --- .../project-navigator/references/selection.md | 39 - .../references/slice-loop.md | 93 ++ .../project-navigator/references/writing.md | 65 -- .../runtime/commands/brief.mjs | 126 +-- .../runtime/commands/check.mjs | 270 ++--- .../runtime/commands/codecheck.mjs | 46 - .../runtime/commands/decision.mjs | 54 + .../runtime/commands/enter.mjs | 331 ++---- .../runtime/commands/evidence.mjs | 137 --- .../runtime/commands/handlers.mjs | 53 +- .../runtime/commands/init.mjs | 312 +++--- .../runtime/commands/model.mjs | 54 - .../runtime/commands/order.mjs | 372 ------- .../runtime/commands/plan.mjs | 132 ++- .../runtime/commands/records.mjs | 133 --- .../runtime/commands/ref.mjs | 25 +- .../runtime/commands/reply.mjs | 202 ---- .../runtime/commands/report.mjs | 254 ----- .../runtime/commands/setup.mjs | 109 ++ .../runtime/commands/slice.mjs | 388 +++++++ .../runtime/commands/stage.mjs | 127 --- .../runtime/commands/standards.mjs | 87 -- .../runtime/commands/support.mjs | 100 +- .../runtime/commands/template.mjs | 62 -- .../runtime/commands/uninstall.mjs | 37 +- .../skills/project-navigator/runtime/hook.mjs | 317 ++---- .../project-navigator/runtime/lib/briefs.mjs | 329 +++--- .../runtime/lib/code-checks.mjs | 190 ---- .../runtime/lib/decisions.mjs | 87 ++ .../runtime/lib/diagnostics.mjs | 298 ------ .../runtime/lib/file-checks.mjs | 266 ----- .../runtime/lib/file-guide.mjs | 118 --- .../project-navigator/runtime/lib/filters.mjs | 215 ++++ .../runtime/lib/freeform-sections.mjs | 14 - .../project-navigator/runtime/lib/guard.mjs | 112 +- .../runtime/lib/guidance.mjs | 417 ++++---- .../project-navigator/runtime/lib/layout.mjs | 193 ---- .../runtime/lib/markdown.mjs | 20 +- .../runtime/lib/option-answer.mjs | 68 -- .../runtime/lib/order-approval.mjs | 189 ---- .../runtime/lib/order-base.mjs | 76 -- .../project-navigator/runtime/lib/paths.mjs | 245 ++--- .../runtime/lib/project-settings.mjs | 166 +++ .../runtime/lib/receipt-record.mjs | 39 - .../runtime/lib/registry.mjs | 62 -- .../runtime/lib/reminders.mjs | 58 - .../runtime/lib/render-plan.mjs | 185 ---- .../project-navigator/runtime/lib/render.mjs | 284 ----- .../runtime/lib/reply-checks.mjs | 172 --- .../runtime/lib/reply-guide.mjs | 164 --- .../project-navigator/runtime/lib/repo.mjs | 140 ++- .../runtime/lib/review-record.mjs | 155 --- .../runtime/lib/roadmap-view.mjs | 74 ++ .../project-navigator/runtime/lib/roadmap.mjs | 420 ++++++++ .../runtime/lib/settings.mjs | 96 +- .../runtime/lib/slice-file.mjs | 565 ++++++++++ .../runtime/lib/slice-flow.mjs | 241 +++++ .../runtime/lib/source-evidence.mjs | 282 ----- .../runtime/lib/source-fetch.mjs | 195 ---- .../project-navigator/runtime/lib/spec.mjs | 148 --- .../runtime/lib/state-checks.mjs | 108 -- .../project-navigator/runtime/lib/state.mjs | 368 ++++--- .../runtime/lib/subagent-models.mjs | 163 --- .../runtime/lib/text-units.mjs | 67 -- .../runtime/lib/tracked-paths.mjs | 82 -- .../project-navigator/runtime/lib/version.mjs | 25 - .../runtime/lib/workflow-orders.mjs | 306 ------ .../runtime/lib/workflow-plan.mjs | 242 ----- .../runtime/lib/workflow-records.mjs | 194 ---- .../runtime/lib/writing-checks.mjs | 466 -------- .../runtime/lib/writing-rules.mjs | 29 - .../project-navigator/runtime/navigator.mjs | 93 +- .../skills/project-navigator/spec/slice.json | 151 +++ .../project-navigator/spec/templates.json | 772 -------------- .../standards/engineering.md | 44 +- scripts/gen-navigator-docs.mjs | 416 -------- scripts/navigator-docs/executor-guide.md | 148 --- scripts/navigator-docs/writing.md | 39 - tests/project-navigator/cli.test.mjs | 593 +++++------ tests/project-navigator/code-checks.test.mjs | 183 ---- tests/project-navigator/docs.test.mjs | 129 ++- tests/project-navigator/evidence.test.mjs | 251 ----- tests/project-navigator/file-checks.test.mjs | 283 ----- tests/project-navigator/filters.test.mjs | 141 +++ tests/project-navigator/flow.test.mjs | 991 ++++++++---------- tests/project-navigator/guard.test.mjs | 237 ----- tests/project-navigator/helpers.mjs | 135 ++- tests/project-navigator/reply-checks.test.mjs | 159 --- tests/project-navigator/roadmap.test.mjs | 236 +++++ tests/project-navigator/self-report.test.mjs | 364 ------- tests/project-navigator/settings.test.mjs | 52 +- tests/project-navigator/slice-file.test.mjs | 232 ++++ tests/project-navigator/spec.test.mjs | 183 ---- tests/project-navigator/workflow.test.mjs | 584 ----------- .../project-navigator/writing-checks.test.mjs | 196 ---- 127 files changed, 6595 insertions(+), 15210 deletions(-) delete mode 100644 plugins/waypoint/agents/navigator-checker.md delete mode 100644 plugins/waypoint/agents/navigator-reader.md delete mode 100644 plugins/waypoint/agents/navigator-researcher.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/brainstorm.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/change-milestone.md create mode 100644 plugins/waypoint/skills/project-navigator/references/change.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/checkup.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/files.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/order-loop.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/recovery.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/replies.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/selection.md create mode 100644 plugins/waypoint/skills/project-navigator/references/slice-loop.md delete mode 100644 plugins/waypoint/skills/project-navigator/references/writing.md delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/codecheck.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/decision.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/evidence.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/model.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/order.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/records.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/reply.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/report.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/setup.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/slice.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/stage.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/standards.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/commands/template.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/code-checks.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/decisions.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/diagnostics.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/file-checks.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/file-guide.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/filters.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/freeform-sections.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/layout.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/option-answer.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/order-approval.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/order-base.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/project-settings.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/receipt-record.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/registry.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/reminders.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/render-plan.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/render.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/reply-checks.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/reply-guide.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/review-record.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/roadmap-view.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/roadmap.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/slice-file.mjs create mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/slice-flow.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/source-evidence.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/source-fetch.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/state-checks.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/subagent-models.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/text-units.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/tracked-paths.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/workflow-orders.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/workflow-plan.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/workflow-records.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/writing-checks.mjs delete mode 100644 plugins/waypoint/skills/project-navigator/runtime/lib/writing-rules.mjs create mode 100644 plugins/waypoint/skills/project-navigator/spec/slice.json delete mode 100644 plugins/waypoint/skills/project-navigator/spec/templates.json delete mode 100644 scripts/gen-navigator-docs.mjs delete mode 100644 scripts/navigator-docs/executor-guide.md delete mode 100644 scripts/navigator-docs/writing.md delete mode 100644 tests/project-navigator/code-checks.test.mjs delete mode 100644 tests/project-navigator/evidence.test.mjs delete mode 100644 tests/project-navigator/file-checks.test.mjs create mode 100644 tests/project-navigator/filters.test.mjs delete mode 100644 tests/project-navigator/guard.test.mjs delete mode 100644 tests/project-navigator/reply-checks.test.mjs create mode 100644 tests/project-navigator/roadmap.test.mjs delete mode 100644 tests/project-navigator/self-report.test.mjs create mode 100644 tests/project-navigator/slice-file.test.mjs delete mode 100644 tests/project-navigator/spec.test.mjs delete mode 100644 tests/project-navigator/workflow.test.mjs delete mode 100644 tests/project-navigator/writing-checks.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 66d4ecb..7c8a795 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -39,6 +39,3 @@ jobs: - name: Run tests run: npm test - - - name: Check generated documents - run: npm run gen:docs -- --check diff --git a/CLAUDE.md b/CLAUDE.md index 30a578b..5b2da00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,5 +13,5 @@ - 自然语言文本遵守 [标点规则](plugins/waypoint/shared/punctuation.md): 英文 (ASCII) 标点, 每个标点后加一个空格. - 提交消息用 `waypoint:commit-message` 技能生成, 不附加任何署名. - 提交前运行 `npm run format`, `npm run validate` 与 `npm test`. -- `project-navigator` 的 `references/replies.md`, `references/files.md`, `references/writing.md` 与 `guide/executor.md` 是生成文件. 修改 `spec/templates.json`, `scripts/navigator-docs/` 或 `shared/punctuation.md` 后运行 `npm run gen:docs`, 不直接改生成文件. +- `project-navigator` 中每条知识只有一个来源: 切片文件的小节在 `spec/slice.json`, 下一动作在 `runtime/lib/guidance.mjs`, 委派提示词在 `runtime/lib/briefs.mjs`, 工程规范在 `standards/engineering.md`. 规则文件引用它们, 不重复写. - 运行脚本只用 Node.js 内置模块; 每个声明都写 JSDoc, 函数体内不写注释. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f84635b..13dd539 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ plugins// One directory per plugin, installed and versio docs/.md, .en.md Detailed guide for each skill, in Simplified Chinese and English README.md, README.en.md Plugin overview and skill index, in Simplified Chinese and English CHANGELOG.md Plugin changelog -scripts/ Repository tooling, including the project-navigator document generator +scripts/ Repository tooling tests/ Automated tests, run with npm test .github/ Issue forms, pull request template, CI workflows, and README assets ``` @@ -55,7 +55,7 @@ Prerequisites: Git, Node.js 22 or later, and [Claude Code](https://code.claude.c ``` 2. Make your change and [test it locally](#testing-locally). -3. Format, validate, and test. If you changed the `project-navigator` spec or the sources in `scripts/navigator-docs/`, run `npm run gen:docs` first: +3. Format, validate, and test: ```bash npm run format @@ -67,7 +67,7 @@ Prerequisites: Git, Node.js 22 or later, and [Claude Code](https://code.claude.c 5. Push the branch to your fork and open a pull request against `main`. Fill in the pull request template. 6. Keep the pull request focused on one change. If `main` moves ahead, rebase your branch onto `upstream/main` instead of merging it in. -CI runs the same format, validation, and test checks on every pull request, and checks that the generated `project-navigator` documents are in sync. A pull request can be merged only after these checks pass, and only a maintainer can merge it. +CI runs the same format, validation, and test checks on every pull request. A pull request can be merged only after these checks pass, and only a maintainer can merge it. ## Testing locally diff --git a/MAINTAINING.en.md b/MAINTAINING.en.md index 89285d8..d29a834 100644 --- a/MAINTAINING.en.md +++ b/MAINTAINING.en.md @@ -6,19 +6,19 @@ This guide is for maintainers. It lists what else needs updating when you change ## Change checklist -| What you changed | What else to update | -| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Any Chinese document | Its English counterpart (`*.en.md`), and the other way around | -| A skill's behavior (`SKILL.md` or `references/`) | The skill's Chinese and English guides, the skill summary in the plugin and root READMEs (if its purpose changed), and `Unreleased` in `CHANGELOG.md` | -| A skill's `description` | Test automatic triggering again by hand (see [Pre-release manual tests](#pre-release-manual-tests)) | -| `shared/punctuation.md` | Every skill is affected, so test them all; also run `npm run gen:docs`, because the generated documents of `project-navigator` embed the punctuation rules | -| The spec, runtime, or document sources of `project-navigator` | See the [project-navigator maintainer guide](plugins/waypoint/docs/project-navigator-maintaining.en.md#change-process) | -| A new skill | See [Adding a skill](#adding-a-skill) | -| A new plugin | See [Adding a plugin](#adding-a-plugin) | -| Anything users will notice in a plugin | Release it: bump `version` in `plugin.json`, or users never receive the change | -| The CI job name (`validate.yml`) | The required check name in the GitHub branch rules, or no pull request can be merged | -| What the banner shows, such as the skill list | Regenerate the PNG and upload the social preview again in the GitHub settings | -| The repository, user, marketplace, or plugin name | See [Hard-coded names and links](#hard-coded-names-and-links) | +| What you changed | What else to update | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Any Chinese document | Its English counterpart (`*.en.md`), and the other way around | +| A skill's behavior (`SKILL.md` or `references/`) | The skill's Chinese and English guides, the skill summary in the plugin and root READMEs (if its purpose changed), and `Unreleased` in `CHANGELOG.md` | +| A skill's `description` | Test automatic triggering again by hand (see [Pre-release manual tests](#pre-release-manual-tests)) | +| `shared/punctuation.md` | Every skill is affected, so test them all | +| The spec, runtime, or rule files of `project-navigator` | See the [project-navigator maintainer guide](plugins/waypoint/docs/project-navigator-maintaining.en.md#change-process) | +| A new skill | See [Adding a skill](#adding-a-skill) | +| A new plugin | See [Adding a plugin](#adding-a-plugin) | +| Anything users will notice in a plugin | Release it: bump `version` in `plugin.json`, or users never receive the change | +| The CI job name (`validate.yml`) | The required check name in the GitHub branch rules, or no pull request can be merged | +| What the banner shows, such as the skill list | Regenerate the PNG and upload the social preview again in the GitHub settings | +| The repository, user, marketplace, or plugin name | See [Hard-coded names and links](#hard-coded-names-and-links) | ## Documentation @@ -146,7 +146,7 @@ These settings live on GitHub, not in the repository files. Check them whenever - **After upgrading `@anthropic-ai/claude-code`**, the new validator may report new warnings. CI runs with `--strict`, so warnings fail the build. Fix the plugin files as reported instead of dropping `--strict`. - **GitHub Actions** are pinned to full commit SHAs with the version in a comment, and dependabot upgrades them. - **Node.js:** CI uses version 24. `engines` in `package.json` requires 22 or later, because the Claude Code npm package does. -- **Before committing**, run `npm run format`, `npm run validate`, and `npm test`. After changing the spec or document sources of `project-navigator`, run `npm run gen:docs` first. CI runs the same checks, and confirms with `npm run gen:docs -- --check` that the generated documents are in sync. +- **Before committing**, run `npm run format`, `npm run validate`, and `npm test`. CI runs the same checks. ## Hard-coded names and links @@ -158,7 +158,7 @@ When the repository moves or something is renamed, replace every occurrence belo | User `auYeCoding` | `owner` in `marketplace.json`, `author` in `plugin.json`, `.github/CODEOWNERS`, `LICENSE` | | Marketplace `radish-plugins` | `name` in `marketplace.json`, the install, update, and uninstall commands in the READMEs, local testing in `CONTRIBUTING.md`, the banner source | | Plugin `waypoint` | `marketplace.json`, `plugin.json`, the plugin table, commands, and version badge URL in the READMEs, examples in `CONTRIBUTING.md`, examples in the issue forms, the banner source, the subagent and skill names in the `project-navigator` guard (`runtime/lib/guard.mjs`) | -| State directory `.navigator` | `runtime/lib/paths.mjs` of `project-navigator`, the allow rules (`runtime/lib/settings.mjs`), its references and guides | +| State directory `.navigator` | `runtime/lib/paths.mjs` of `project-navigator` (including the archive directory name `.navigator-1.x`), the allow rules (`runtime/lib/settings.mjs`), its references and guides | | Command `project-navigator` | The plugin's `bin/project-navigator` and `bin/project-navigator.cmd`, `COMMAND_NAME` in `runtime/lib/paths.mjs`, the allow rules, `allowed-tools` in `SKILL.md`, and its references | | Code of Conduct contact email | `CODE_OF_CONDUCT.md` | diff --git a/MAINTAINING.md b/MAINTAINING.md index 5fab594..c5cfc4a 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -6,19 +6,19 @@ ## 改动同步速查表 -| 你改了什么 | 还需要同步的地方 | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| 任何中文文档 | 对应的英文文档 (`*.en.md`), 反之亦然 | -| 技能的行为 (`SKILL.md` 或 `references/`) | 该技能的中英文文档, 插件 README 与根 README 中的简介 (若用途变化), `CHANGELOG.md` 的 `Unreleased` | -| 技能的 `description` | 重新手动测试自动触发 (见 [发版前手动测试](#发版前手动测试)) | -| `shared/punctuation.md` | 所有技能都会受影响, 都要测试; 还要运行 `npm run gen:docs`, 因为 `project-navigator` 的生成文档内嵌了标点规则 | -| `project-navigator` 的规格, 运行脚本或文档源文件 | 见 [project-navigator 维护文档](plugins/waypoint/docs/project-navigator-maintaining.md#修改流程) | -| 新增技能 | 见 [新增技能](#新增技能) | -| 新增插件 | 见 [新增插件](#新增插件) | -| 插件的对外表现 | 发版: 提升 `plugin.json` 中的 `version`, 否则用户收不到更新 | -| CI 任务名 (`validate.yml`) | GitHub 分支规则中的必需检查名, 否则所有 PR 都无法合并 | -| 横幅图展示的内容 (如技能列表) | 重新生成 PNG, 并在 GitHub 设置中重新上传社交预览图 | -| 仓库名, 用户名, 市场名或插件名 | 见 [写死的名称与链接](#写死的名称与链接) | +| 你改了什么 | 还需要同步的地方 | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 任何中文文档 | 对应的英文文档 (`*.en.md`), 反之亦然 | +| 技能的行为 (`SKILL.md` 或 `references/`) | 该技能的中英文文档, 插件 README 与根 README 中的简介 (若用途变化), `CHANGELOG.md` 的 `Unreleased` | +| 技能的 `description` | 重新手动测试自动触发 (见 [发版前手动测试](#发版前手动测试)) | +| `shared/punctuation.md` | 所有技能都会受影响, 都要测试 | +| `project-navigator` 的规格, 运行脚本或规则文件 | 见 [project-navigator 维护文档](plugins/waypoint/docs/project-navigator-maintaining.md#修改流程) | +| 新增技能 | 见 [新增技能](#新增技能) | +| 新增插件 | 见 [新增插件](#新增插件) | +| 插件的对外表现 | 发版: 提升 `plugin.json` 中的 `version`, 否则用户收不到更新 | +| CI 任务名 (`validate.yml`) | GitHub 分支规则中的必需检查名, 否则所有 PR 都无法合并 | +| 横幅图展示的内容 (如技能列表) | 重新生成 PNG, 并在 GitHub 设置中重新上传社交预览图 | +| 仓库名, 用户名, 市场名或插件名 | 见 [写死的名称与链接](#写死的名称与链接) | ## 文档 @@ -146,7 +146,7 @@ - **升级 `@anthropic-ai/claude-code` 后**, 新版校验器可能新增警告, 而 CI 以 `--strict` 运行, 警告也会导致失败. 这时要按提示修改插件文件, 而不是去掉 `--strict`. - **GitHub Actions** 固定到完整的提交 SHA, 并在注释中写明版本, 由 dependabot 统一升级. - **Node.js:** CI 使用 24; `package.json` 的 `engines` 要求 22 及以上, 因为 Claude Code 的 npm 包要求 22 及以上. -- **提交前**运行 `npm run format`, `npm run validate` 与 `npm test`; 改了 `project-navigator` 的规格或文档源文件时, 先运行 `npm run gen:docs`. CI 执行同样的检查, 并以 `npm run gen:docs -- --check` 确认生成文档已同步. +- **提交前**运行 `npm run format`, `npm run validate` 与 `npm test`. CI 执行同样的检查. ## 写死的名称与链接 @@ -158,7 +158,7 @@ | 用户名 `auYeCoding` | `marketplace.json` 的 `owner`, `plugin.json` 的 `author`, `.github/CODEOWNERS`, `LICENSE` | | 市场名 `radish-plugins` | `marketplace.json` 的 `name`, README 中的安装, 更新与卸载命令, `CONTRIBUTING.md` 的本地测试, 横幅源文件 | | 插件名 `waypoint` | `marketplace.json`, `plugin.json`, README 的插件表, 命令与版本徽章地址, `CONTRIBUTING.md` 的示例, issue 模板的示例, 横幅源文件, `project-navigator` 守卫中的子代理与技能名 (`runtime/lib/guard.mjs`) | -| 状态目录名 `.navigator` | `project-navigator` 的 `runtime/lib/paths.mjs`, 放行规则 (`runtime/lib/settings.mjs`), 参考文件与文档 | +| 状态目录名 `.navigator` | `project-navigator` 的 `runtime/lib/paths.mjs` (含归档目录名 `.navigator-1.x`), 放行规则 (`runtime/lib/settings.mjs`), 参考文件与文档 | | 命令名 `project-navigator` | 插件的 `bin/project-navigator` 与 `bin/project-navigator.cmd`, `runtime/lib/paths.mjs` 的 `COMMAND_NAME`, 放行规则, `SKILL.md` 的 `allowed-tools` 与参考文件 | | 行为准则联系邮箱 | `CODE_OF_CONDUCT.md` | diff --git a/README.en.md b/README.en.md index a46ed0d..52732b8 100644 --- a/README.en.md +++ b/README.en.md @@ -10,9 +10,9 @@ English | [简体中文](README.md) ## Plugins -| Plugin | Skills | Docs | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| [WayPoint](plugins/waypoint) (`waypoint`) | [`commit-message`](plugins/waypoint/docs/commit-message.en.md): drafts a Simplified Chinese Conventional Commits message from the actual Git changes, then commits or pushes on request.
[`repo-init`](plugins/waypoint/docs/repo-init.en.md): initializes a Git repository with fully commented `.gitignore`, `.editorconfig`, and `.gitattributes` files.
[`project-navigator`](plugins/waypoint/docs/project-navigator.en.md): guides a large project from a single idea through framing, technology selection, a skeleton, and slice-by-slice delivery; it only plans and reviews, while background executor subagents implement. | [Plugin guide](plugins/waypoint/README.en.md) | +| Plugin | Skills | Docs | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| [WayPoint](plugins/waypoint) (`waypoint`) | [`commit-message`](plugins/waypoint/docs/commit-message.en.md): drafts a Simplified Chinese Conventional Commits message from the actual Git changes, then commits or pushes on request.
[`repo-init`](plugins/waypoint/docs/repo-init.en.md): initializes a Git repository with fully commented `.gitignore`, `.editorconfig`, and `.gitattributes` files.
[`project-navigator`](plugins/waypoint/docs/project-navigator.en.md): guides a large project from a single idea through confirmed requirements, a roadmap, a walking skeleton, and delivery one vertical slice at a time, and keeps track of progress; it only plans and reviews, while background executor subagents implement. | [Plugin guide](plugins/waypoint/README.en.md) | Select a skill name for its detailed guide. WayPoint writes commit messages, rule-file comments, and project records in Simplified Chinese. diff --git a/README.md b/README.md index 8479cbc..b0fab68 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ ## 插件列表 -| 插件 | 技能 | 文档 | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | -| [WayPoint](plugins/waypoint) (`waypoint`) | [`commit-message`](plugins/waypoint/docs/commit-message.md): 根据真实的 Git 改动生成符合 Conventional Commits 的简体中文提交消息, 并按需提交或推送.
[`repo-init`](plugins/waypoint/docs/repo-init.md): 初始化 Git 仓库, 编写逐条附中文注释的 `.gitignore`, `.editorconfig` 与 `.gitattributes`.
[`project-navigator`](plugins/waypoint/docs/project-navigator.md): 引导大型项目从一句需求走到立项, 选型, 骨架与逐个切片的交付, 只做规划与验收, 实现交给后台的执行子代理. | [插件说明](plugins/waypoint/README.md) | +| 插件 | 技能 | 文档 | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | +| [WayPoint](plugins/waypoint) (`waypoint`) | [`commit-message`](plugins/waypoint/docs/commit-message.md): 根据真实的 Git 改动生成符合 Conventional Commits 的简体中文提交消息, 并按需提交或推送.
[`repo-init`](plugins/waypoint/docs/repo-init.md): 初始化 Git 仓库, 编写逐条附中文注释的 `.gitignore`, `.editorconfig` 与 `.gitattributes`.
[`project-navigator`](plugins/waypoint/docs/project-navigator.md): 引导大型项目从一句需求走到确认需求, 路线, 最小骨架与逐个纵向切片的交付, 并记住进度; 只做规划与验收, 实现交给后台的实现子代理. | [插件说明](plugins/waypoint/README.md) | 点击技能名可查看该技能的详细介绍与使用方法. WayPoint 生成的提交消息, 规则文件注释与项目记录都使用简体中文. diff --git a/package.json b/package.json index 25bb163..ba673b2 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "validate": "node scripts/validate.mjs", - "test": "node --test \"tests/**/*.test.mjs\"", - "gen:docs": "node scripts/gen-navigator-docs.mjs" + "test": "node --test \"tests/**/*.test.mjs\"" }, "devDependencies": { "@anthropic-ai/claude-code": "^2.1.319", diff --git a/plugins/waypoint/CHANGELOG.md b/plugins/waypoint/CHANGELOG.md index bdbc26a..23e2845 100644 --- a/plugins/waypoint/CHANGELOG.md +++ b/plugins/waypoint/CHANGELOG.md @@ -6,6 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- **BREAKING** `project-navigator`: rebuilt around slices instead of work orders. The skill now does five things: confirm requirements and lay out a roadmap, keep track of progress, run each slice through plan, implementation, and independent review, hold modular-design constraints, and insist on a walking skeleton with wiring evidence. Records written by 1.x are not read and not migrated; see "Upgrading from 1.x" below. +- `project-navigator`: one slice is one file under `.navigator/slices/` with three parts: the plan (written by the orchestrator), the result (written by the executor), and the review (written from what the reviewer hands back). It replaces the work order, receipt, and review record. The script records a digest of the plan when the slice starts and refuses to submit it for review if the plan changed in between. +- `project-navigator`: by default you decide once per slice, after the review. The plan is put to you beforehand only when it lists something you must decide (`slice start --decided`). The precheck subagent is gone; the orchestrator reads the code itself before writing a plan. +- `project-navigator`: there is no blocked state. The executor hands back with "交回: 需要回答" (needs an answer) or "交回: 需要操作" (needs an action), the orchestrator relays it, and the same executor continues after your reply. Corrections of details that do not match reality and criteria that cannot be met are recorded in the result and settled at review, as in 1.5.0. +- `project-navigator`: replies are no longer templated or checked. The rule is a conclusion on the first line and the progress on the second; nothing is sent back for rewriting, and the `Stop` hook is removed. +- `project-navigator`: commands went from about 30 to 12: `init`, `status`, `ref`, `setup`, `roadmap`, `slice`, `brief`, `milestone`, `decision`, `check`, `finish`, `uninstall`. + +### Added + +- `project-navigator`: the walking skeleton is enforced. The first slice of the first milestone must change code and run from a real entry point through every planned layer; no other slice can start until it passes review, and it cannot be dropped. +- `project-navigator`: wiring evidence. The executor writes the call path from the entry point to the new code, and the reviewer traces it independently. A slice whose new code is not wired in fails review and cannot be accepted. +- `project-navigator`: module organization is a registered project setting, `directories` (one physical directory per module) or `filters` (source files flat in the project directory, modules expressed as Visual Studio virtual filters). `setup` suggests one from the files in the repository and writes the matching wording into `.claude/rules/engineering.md`. +- `project-navigator`: every code slice carries a "模块安排" (module layout) in its plan (each file's module, layer, and single responsibility, where each new constant or type is defined once, and what is reused) and a "结构核对" (structure check) in its review, six questions covering single source of truth, layering, reuse, and premature shared modules. +- `project-navigator`: `check --filters` verifies that every source file of a Visual Studio project is assigned to a filter and that files added by the current slice are not placed in physical subdirectories. Its result is included in the review brief. +- `project-navigator`: the way of verifying is a registered project setting: `auto` (commands the reviewer runs), `manual` (you build or run it yourself), or `measured` (measured in the real environment). With the last two, subagents do not build or run anything, and the criteria that need running are listed as steps for you. +- `project-navigator`: slices that only produce findings (`findings`), for reverse engineering, measurement, and research. They are interactive by default: the orchestrator session does them directly in conversation and writes each conclusion to `.navigator/findings/`, so tasks that need you at every step no longer go through a subagent. +- `project-navigator`: `.navigator/backlog.md` for ideas outside the current slice, sorted at each milestone review. + +### Removed + +- `project-navigator`: work orders, receipts, review records, and the order state machine; about 20 reply types with the reply check; the order review approval record; the checker, researcher, and reader subagents; brainstorm records, the glossary, and the risk and change lists; checkup numbering and the semantic checkup; the standards comparison step; the `evidence` command and source cache; the `model` command; skill self-diagnosis and the `report` command; writing-rule checks; reply buttons; stage numbers 0 to 6; and the document generator (`npm run gen:docs`). + +### Upgrading from 1.x + +- In a project initialized by 1.x, the skill says that it does not read the old records. With your consent it runs `project-navigator init --archive`, which renames `.navigator/` to `.navigator-1.x/` without deleting anything, and you start again from intake. Until then the plugin's hooks do nothing in that project. +- To keep the 1.x workflow, pin the plugin to 1.5.0. +- A project still on 0.x must remove its project-level hooks first, by upgrading with 1.x or by deleting them from `.claude/settings.json`. + ## [1.5.0] - 2026-11-12 ### Changed diff --git a/plugins/waypoint/README.en.md b/plugins/waypoint/README.en.md index b72c270..b47f780 100644 --- a/plugins/waypoint/README.en.md +++ b/plugins/waypoint/README.en.md @@ -14,11 +14,11 @@ WayPoint is a [Claude Code](https://code.claude.com) plugin of practical workflo ## Skills -| Skill | Command | Purpose | Docs | -| ------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `commit-message` | `/waypoint:commit-message [extra request]` | Drafts a Simplified Chinese Conventional Commits message from the actual Git changes, then commits or pushes on request. | [Guide](docs/commit-message.en.md) | -| `repo-init` | `/waypoint:repo-init` | Initializes a Git repository with fully commented `.gitignore`, `.editorconfig`, and `.gitattributes` files. | [Guide](docs/repo-init.en.md) | -| `project-navigator` | `/waypoint:project-navigator [argument]` | Guides a large project from a single idea through framing, technology selection, a skeleton, and slice-by-slice delivery; it only plans and reviews, while background executor subagents implement. | [Guide](docs/project-navigator.en.md), [maintainer guide](docs/project-navigator-maintaining.en.md) | +| Skill | Command | Purpose | Docs | +| ------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `commit-message` | `/waypoint:commit-message [extra request]` | Drafts a Simplified Chinese Conventional Commits message from the actual Git changes, then commits or pushes on request. | [Guide](docs/commit-message.en.md) | +| `repo-init` | `/waypoint:repo-init` | Initializes a Git repository with fully commented `.gitignore`, `.editorconfig`, and `.gitattributes` files. | [Guide](docs/repo-init.en.md) | +| `project-navigator` | `/waypoint:project-navigator [argument]` | Guides a large project from a single idea through confirmed requirements, a roadmap, a walking skeleton, and delivery one vertical slice at a time, and keeps track of progress; it only plans and reviews, while background executor subagents implement. | [Guide](docs/project-navigator.en.md), [maintainer guide](docs/project-navigator-maintaining.en.md) | `commit-message` and `repo-init` trigger automatically when you ask for them in plain language, and you can also call them directly with the slash commands above. `project-navigator` runs only when invoked explicitly with its slash command. @@ -31,7 +31,7 @@ Take a new project that is not under version control yet: From then on, repeat step 2 whenever you finish a change. -To drive a larger project from scratch, run `/waypoint:project-navigator ` after step 1, and move forward with the options at the end of each reply. See [Quick start](docs/project-navigator.en.md#quick-start) for a full walkthrough. +To drive a larger project from scratch, run `/waypoint:project-navigator ` after step 1, and move forward as each reply prompts. See [Quick start](docs/project-navigator.en.md#quick-start) for a full walkthrough. ## Tips diff --git a/plugins/waypoint/README.md b/plugins/waypoint/README.md index 18096d8..3ee88ec 100644 --- a/plugins/waypoint/README.md +++ b/plugins/waypoint/README.md @@ -14,11 +14,11 @@ WayPoint 是一个提供实用工作流技能的 [Claude Code](https://code.clau ## 技能列表 -| 技能 | 命令 | 用途 | 文档 | -| ------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `commit-message` | `/waypoint:commit-message [额外要求]` | 根据真实的 Git 改动生成符合 Conventional Commits 的简体中文提交消息, 并按需提交或推送. | [使用说明](docs/commit-message.md) | -| `repo-init` | `/waypoint:repo-init` | 初始化 Git 仓库, 编写逐条附中文注释的 `.gitignore`, `.editorconfig` 与 `.gitattributes`. | [使用说明](docs/repo-init.md) | -| `project-navigator` | `/waypoint:project-navigator [参数]` | 引导大型项目从一句需求走到立项, 选型, 骨架与逐个切片的交付; 只做规划与验收, 实现交给后台的执行子代理. | [使用说明](docs/project-navigator.md), [维护文档](docs/project-navigator-maintaining.md) | +| 技能 | 命令 | 用途 | 文档 | +| ------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `commit-message` | `/waypoint:commit-message [额外要求]` | 根据真实的 Git 改动生成符合 Conventional Commits 的简体中文提交消息, 并按需提交或推送. | [使用说明](docs/commit-message.md) | +| `repo-init` | `/waypoint:repo-init` | 初始化 Git 仓库, 编写逐条附中文注释的 `.gitignore`, `.editorconfig` 与 `.gitattributes`. | [使用说明](docs/repo-init.md) | +| `project-navigator` | `/waypoint:project-navigator [参数]` | 引导大型项目从一句需求走到确认需求, 路线, 最小骨架与逐个纵向切片的交付, 并记住进度; 只做规划与验收, 实现交给后台的实现子代理. | [使用说明](docs/project-navigator.md), [维护文档](docs/project-navigator-maintaining.md) | `commit-message` 与 `repo-init` 会在你用自然语言提出相应请求时自动触发, 也可以用上表中的斜杠命令直接调用. `project-navigator` 只能用斜杠命令显式调用. @@ -31,7 +31,7 @@ WayPoint 是一个提供实用工作流技能的 [Claude Code](https://code.clau 之后每次改完代码, 重复第 2 步即可. -要从零推进一个较大的项目, 在第 1 步之后调用 `/waypoint:project-navigator 你的需求`, 按每条回复末尾的选项逐步推进. 完整的操作示范见 [快速上手](docs/project-navigator.md#快速上手). +要从零推进一个较大的项目, 在第 1 步之后调用 `/waypoint:project-navigator 你的需求`, 按每条回复的提示逐步推进. 完整的操作示范见 [快速上手](docs/project-navigator.md#快速上手). ## 使用提示 diff --git a/plugins/waypoint/agents/navigator-checker.md b/plugins/waypoint/agents/navigator-checker.md deleted file mode 100644 index 1ad3501..0000000 --- a/plugins/waypoint/agents/navigator-checker.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: navigator-checker -description: project-navigator 的核对子代理, 只由编排会话按 precheck-brief 生成的提示词派出, 在工单发布之前逐条核对其中写下的事实. Pre-issue fact checker for project-navigator; dispatched only by the orchestrator session with the generated precheck brief. Do not use for anything else. -disallowedTools: Write, Edit, MultiEdit, NotebookEdit ---- - -你是 project-navigator 的核对子代理, 在全新的上下文中核对一张还没有发布的工单: 其中写下的事实是否成立, 判据能否验证. - -## 立场 - -- 只读: 不写, 不改, 不删任何文件, 也不尝试这样做. 不安装依赖, 不改动项目. -- 只报告事实与证据, 不改写工单, 不给实现方案, 不评价工单写得好不好. -- 委派提示词中的 "核对要求" 与 "交回格式" 是唯一的任务说明, 逐条完成, 不增不减. - -## 取证 - -- 每条结论都附证据: 文件路径与行号, 命令的原样输出, 或网页地址与摘录. 不凭记忆下结论. -- 只运行只读命令: 读文件, 搜索, 只读 git 命令, 以及查询数据的命令 (例如对项目数据文件的只读查询). 不运行构建, 测试与会写文件的脚本. -- 外部资源 (下载地址, 发布形式, 许可) 用网络工具查看原始页面. -- 结论只用委派提示词列出的几种. 查不到证据时写 "无法核对", 并写明缺少什么; 不凭推测写 "成立". - -## 交回 - -- 按委派提示词的 "交回格式" 输出, 用简体中文. -- 标点使用英文 (ASCII) 标点, 每个标点后加一个空格; 标点属于路径, 版本号等记号时不加. diff --git a/plugins/waypoint/agents/navigator-executor.md b/plugins/waypoint/agents/navigator-executor.md index 153a785..e9a89ba 100644 --- a/plugins/waypoint/agents/navigator-executor.md +++ b/plugins/waypoint/agents/navigator-executor.md @@ -1,18 +1,20 @@ --- name: navigator-executor -description: project-navigator 的执行子代理, 只由编排会话按 exec-brief 生成的提示词在后台派出, 按一张工单实现, 测试并写回执. Executor for project-navigator; dispatched in the background only by the orchestrator session with the generated exec brief. Do not use for anything else. +description: project-navigator 的实现子代理, 只由编排会话按 brief exec 生成的提示词在后台派出, 按一个切片的计划实现, 自测并写执行结果. Executor for project-navigator; dispatched in the background only by the orchestrator session with the generated exec brief. Do not use for anything else. --- -你是 project-navigator 的执行子代理, 在全新的上下文中按一张工单完成工作. +你是 project-navigator 的实现子代理, 在全新的上下文中按一个切片的计划完成工作. ## 立场 -- 委派提示词中的规则与交回格式是硬性要求; 细节以执行手册 (`project-navigator ref executor`) 与工单为准. -- 只做工单 "工作范围" 中要做的事. 范围之外的改动, 任务目标达不成, 需要用户决定的事, 都写进回执, 以受阻结束, 不自行变通. -- 工单中的细节 (数据格式, 路径, 具体实例, 实现途径) 与实际不符时, 取得证据后以实际为准继续做, 在回执 "事实修正" 中逐条记录, 不算受阻. 任务目标与工作范围不能借修正改动. -- 不提交, 不推送, 不运行改变编排状态的插件命令. 守卫拒绝的写入按拒绝理由调整, 不换写法重试. +- 委派提示词中的规则与交回格式是硬性要求; 细节以实现手册 (`project-navigator ref executor`) 与切片文件的 "计划" 段为准. +- 只做计划写的事. 计划的目标, 完成标准, 明确不做与模块安排不能改; 做不到, 或需要用户决定时, 按手册的 "中途交回" 结束, 不自行变通. +- 计划中的细节 (路径, 接口名, 数据格式, 具体实例) 与实际不符时, 取得证据后以实际为准继续做, 在 "修正" 表中逐条记录, 不交回. +- 新代码必须接到真实入口上, 并在 "接线证据" 中写出调用路径. +- 常量, 类型, 配置只定义一次; 按职责分层; 优先复用; 不提前抽通用模块, 不加计划没有要求的东西. +- 不提交, 不推送, 不运行改变状态的插件命令. 守卫拒绝的写入按拒绝理由调整, 不换写法重试. ## 交回 -- 先写回执文件, 再交回. 交回内容按委派提示词的 "交回格式", 用简体中文. +- 先写切片文件的 "执行结果" 段, 再交回. 交回内容按委派提示词的 "交回格式", 用简体中文. - 标点使用英文 (ASCII) 标点, 每个标点后加一个空格; 标点属于路径, 版本号等记号时不加. diff --git a/plugins/waypoint/agents/navigator-reader.md b/plugins/waypoint/agents/navigator-reader.md deleted file mode 100644 index f68bc5d..0000000 --- a/plugins/waypoint/agents/navigator-reader.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: navigator-reader -description: project-navigator 的只读阅读子代理, 只由编排会话派出, 用于已有项目盘点, 规范对照与文档体检的语义检查. Read-only reader for project-navigator (survey, standards comparison, semantic checkup); dispatched only by the orchestrator session. -tools: Read, Grep, Glob ---- - -你是 project-navigator 的阅读子代理, 在全新的上下文中阅读仓库或文档, 按委派提示词交回结构化的结果. - -## 立场 - -- 只读: 不写, 不改, 不删任何文件, 也不运行命令. -- 委派提示词给出的范围, 检查项与交回格式是唯一的任务说明, 不扩大范围. -- 只报告读到的事实; 需要判断时写明依据, 不替用户做决定. - -## 取证 - -- 每条结论都附证据: 文件路径与行号, 或原文摘录 (不超过一行). -- 读不到或找不到时写 "未找到", 并写明查过哪些位置. - -## 交回 - -- 按委派提示词的交回格式输出, 用简体中文. -- 标点使用英文 (ASCII) 标点, 每个标点后加一个空格; 标点属于路径, 版本号等记号时不加. diff --git a/plugins/waypoint/agents/navigator-researcher.md b/plugins/waypoint/agents/navigator-researcher.md deleted file mode 100644 index 0630f14..0000000 --- a/plugins/waypoint/agents/navigator-researcher.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: navigator-researcher -description: project-navigator 的问题域调研子代理, 只由编排会话在立项阶段按 research-brief 的固定框架派出. Problem-domain researcher for project-navigator's intake stage; dispatched only by the orchestrator session. Do not use for technology selection or anything else. -tools: WebSearch, WebFetch, Read, Grep, Glob ---- - -你是 project-navigator 的问题域调研子代理, 在全新的上下文中调研用户需求所在的问题域. - -## 立场 - -- 只读: 不写, 不改, 不删任何文件. -- 只调研问题域: 同类产品与替代方案, 目标使用者与典型场景, 行业做法与标准, 法规与合规约束, 常见失败原因, 关键术语. -- 不做技术选型, 不比较库, 框架或云服务; 遇到技术话题只记为 "选型问题", 留给选型阶段. - -## 取证 - -- 每条发现都附来源地址, 并标明 "事实" 或 "推断"; 推断写明依据哪条来源. -- 不凭记忆下结论: 查不到来源的说法不写成事实. -- 来源互相矛盾时, 列出双方与各自来源, 不替用户裁决. - -## 交回 - -- 按委派提示词的 "交回格式" 输出, 用简体中文. -- 标点使用英文 (ASCII) 标点, 每个标点后加一个空格; 标点属于网址, 路径等记号时不加. diff --git a/plugins/waypoint/agents/navigator-reviewer.md b/plugins/waypoint/agents/navigator-reviewer.md index a71ae33..3612651 100644 --- a/plugins/waypoint/agents/navigator-reviewer.md +++ b/plugins/waypoint/agents/navigator-reviewer.md @@ -1,26 +1,27 @@ --- name: navigator-reviewer -description: project-navigator 的验收子代理, 只由编排会话按 review-brief 生成的提示词派出, 逐条核对工单判据并交回证据. Acceptance reviewer for project-navigator; dispatched only by the orchestrator session with the generated review brief. Do not use for anything else. +description: project-navigator 的验收子代理, 只由编排会话按 brief review 生成的提示词派出, 逐条核对切片的完成标准, 接线与结构, 并交回证据. Acceptance reviewer for project-navigator; dispatched only by the orchestrator session with the generated review brief. Do not use for anything else. disallowedTools: Write, Edit, MultiEdit, NotebookEdit --- -你是 project-navigator 的验收子代理, 在全新的上下文中核对一张工单是否完成. +你是 project-navigator 的验收子代理, 在全新的上下文中核对一个切片是否完成. ## 立场 - 只读: 不写, 不改, 不删任何文件, 也不尝试这样做. -- 只报告事实与证据, 不给修改建议, 不替执行子代理辩解, 也不替编排会话下结论. +- 只报告事实与证据, 不给修改建议, 不替实现方辩解, 也不替编排会话下结论. - 委派提示词中的 "核对要求" 与 "交回格式" 是唯一的任务说明, 逐条完成, 不增不减. ## 取证 -- 每条结论都附证据: 文件路径与行号, git 命令的原样输出, 测试命令的原样输出, 或取证工具的返回内容摘录. -- 只运行这几类命令: 只读 git 命令 (例如 `git diff`, `git log`, `git show`), 委派提示词中写明的代码检查与源码证据核对命令, 以及回执 "测试命令" 中没有列入 "外部影响" 的命令. 不安装依赖, 不改动项目. +- 每条结论都附证据: 文件路径与行号, git 命令的原样输出, 验证命令的原样输出, 或取证工具的返回内容摘录. +- 只运行这几类命令: 只读 git 命令 (例如 `git diff`, `git log`, `git show`, `git status`), 委派提示词 "验证方式" 中的命令, 以及切片文件 "自测记录" 中的命令. 需要真实账号, 会产生费用或外部副作用的命令不运行. 不安装依赖, 不改动项目. +- 委派提示词写明不构建, 不运行程序时, 不构建, 不运行; 只读代码与记录, 靠运行才能确认的标准写 "未验证". - 调用 MCP 取证工具时只做查询类的只读调用, 调用前先用 ToolSearch 加载; 不发请求, 不清空或修改工具中的记录. -- 结论只用委派提示词列出的几种, 不自造 "部分验证" 之类的说法. 找不到证据时写 "未验证", 并写明缺少什么; 不凭推测写 "通过". +- 结论只用委派提示词列出的三种, 不自造 "部分通过" 之类的说法. 找不到证据时写 "未验证", 并写明缺少什么; 不凭推测写 "通过". +- 接线要自己追: 从程序的真实入口读代码, 一步一步走到新代码, 不照抄实现方写的路径. ## 交回 - 按委派提示词的 "交回格式" 输出, 用简体中文. -- 问题出在技能自身时 (插件命令输出 "技能异常" 一行, 或委派提示词与实际对不上), 在交回内容的末尾加一行 "技能问题: ...", 只写运行的命令与原始提示, 不写原因推测与处理建议. - 标点使用英文 (ASCII) 标点, 每个标点后加一个空格; 标点属于路径, 版本号等记号时不加. diff --git a/plugins/waypoint/docs/project-navigator-maintaining.en.md b/plugins/waypoint/docs/project-navigator-maintaining.en.md index 6ff542f..584b3fb 100644 --- a/plugins/waypoint/docs/project-navigator-maintaining.en.md +++ b/plugins/waypoint/docs/project-navigator-maintaining.en.md @@ -4,332 +4,309 @@ English | [简体中文](project-navigator-maintaining.md) [Back to the usage guide](project-navigator.en.md) -This document is for maintainers: design overview, design decisions, component map, troubleshooting, change workflow, tests, and measured findings. Repository-wide conventions are in [MAINTAINING.md](../../../MAINTAINING.md). +This document is for maintainers: design overview, design decisions, component map, troubleshooting, change process, tests, and measured findings. Repository-wide maintenance conventions are in [MAINTAINING.en.md](../../../MAINTAINING.en.md). ## Design overview -The skill has three layers, each with one job: +The skill is responsible for only five things. Before adding any feature, confirm that it belongs to one of them: -| Layer | Location | Job | -| ------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Rules | `SKILL.md`, `references/`, `guide/executor.md` | Written for the model: workflow, steps, which reply to use when | -| Scripts | `runtime/navigator.mjs`, `runtime/hook.mjs` | Commands change state; hooks guard a few write boundaries and reply structure | -| Spec | `spec/templates.json`, `standards/engineering.md` | Single source of truth for replies, record files, writing rules, and engineering standards | +1. Intake and roadmap: from a one-sentence requirement to a requirements file and a roadmap arranged by milestones and slices. +2. Progress: the state is recorded by scripts, the next action is computed by scripts, and any session can pick up the work. +3. Slice loop: the orchestrator session writes the plan, a subagent implements it, and another subagent reviews it independently. +4. Modularity constraints: single source of truth, layering by responsibility, modularity, no generic modules extracted ahead of time; how modules are expressed changes with the shape of the project. +5. Skeleton and wiring: the first slice is the walking skeleton, and every slice must give wiring evidence. + +The skill consists of three layers: + +| Layer | Location | Job | +| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Rules | `SKILL.md`, `references/`, `guide/executor.md` | Written for the model: workflow, steps, how to write plans and results | +| Runtime scripts | `runtime/navigator.mjs`, `runtime/hook.mjs` | Commands modify the state and compute the next action; the hook guards write boundaries | +| Spec | `spec/slice.json`, `standards/engineering.md` | The single source for the sections of a slice file and for the default engineering standards pack | Plugin-level wiring: -| Location | Contents | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `hooks/hooks.json` | Four events (PreToolUse matching write tools only, UserPromptSubmit, SessionStart, Stop) all call `runtime/hook.mjs` | -| `bin/project-navigator` | Command entry for the Bash tool: `exec node .../navigator.mjs "$@"` | -| `bin/project-navigator.cmd` | Command entry for the PowerShell tool | -| `agents/navigator-*.md` | Checker, executor, reviewer, researcher, and reader subagents | +| Location | Contents | +| --------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `hooks/hooks.json` | Three events (PreToolUse matching write tools only, UserPromptSubmit, SessionStart) all call `runtime/hook.mjs` | +| `bin/project-navigator` | Command entry in the Bash tool, `exec node .../navigator.mjs "$@"` | +| `bin/project-navigator.cmd` | Command entry in the PowerShell tool | +| `agents/navigator-*.md` | Executor and reviewer subagents | In a user's project, the skill uses these locations: -| Location | Contents | Committed | -| ------------------------------ | --------------------------------------------------------------------------------------- | --------- | -| `.navigator/` | State file, plan records, work order folders | Yes | -| `.navigator/drafts/` | JSON drafts the orchestrator passes to commands | No | -| `.claude/settings.json` | Permission rules | Yes | -| `.claude/settings.local.json` | Local copy of the permission rules, excluded via `.git/info/exclude` | No | -| `.claude/rules/engineering.md` | Project standards, loaded by every session in the project | Yes | -| `.git/navigator/` | Work order approval record (`order-approval.json`), source evidence cache (`evidence/`) | No | +| Location | Contents | Committed | +| ------------------------------ | ---------------------------------------------------------------------------- | --------- | +| `.navigator/` | State file, roadmap view, requirements, decisions, backlog, slices, findings | Yes | +| `.navigator/drafts/` | JSON drafts the orchestrator session passes to commands | No | +| `.claude/settings.json` | Allow rules | Yes | +| `.claude/settings.local.json` | Local copy of the allow rules, excluded by `.git/info/exclude` | No | +| `.claude/rules/engineering.md` | Project standards, written by `setup`, loaded automatically by every session | Yes | + +2.0.0 stores nothing under `.git/`. ## Design decisions Each entry states the decision, the reason, and what happens if it is changed. +### Trade-offs in 2.0.0 + +Almost all of the 1.x iterations went into process control (the work order state machine, reply validation, review binding, pre-issue checks), while the quality of intake, how slices are cut, and how wiring is checked were never refined after the first version. 2.0.0 reverses that ratio. + +- **Kept:** orchestration separate from implementation; independent review; engineering standards written into the project; state and next action managed by scripts; records committed, with the ignore rules checked; commits go through commit-message; the hook lives at plugin level; runtime scripts use only Node.js built-in modules; details that do not match reality are corrected to match it; individual criteria that cannot be met are listed as pending. +- **Removed:** the three files (work order, receipt, review report) and the work order state machine; about 20 reply types and the validation at the end of a reply; the digest binding and base-commit check for work order review; the pre-issue checker subagent; the problem-domain researcher subagent and the brainstorm archive; the glossary, the risk list, the change list; checkup numbering and the semantic checkup; the standards comparison workflow; the `evidence` command for technology selection and the source cache; subagent model configuration; the skill's self-check for anomalies and problem reports; writing-rule validation; buttons; stage numbers 0 to 6; the document generation step. +- **1.x records are not migrated.** The two kinds of records have different units (work orders and slices), a migration could only be approximate, and very few projects use 1.x. `init --archive` renames the old directory as a whole and deletes nothing. If changed: code that reads the old structure has to be maintained for the long term. + ### Sessions and execution -- **Orchestration and execution are separate; execution runs in a background subagent.** The orchestrator uses the Agent tool in the background (`run_in_background`) to dispatch `waypoint:navigator-executor` with the output of `exec-brief`; when the subagent finishes, the orchestrator is notified and continues on its own. 0.x had the user open an executor session: each work order meant switching sessions, pasting a launch prompt, confirming a kickoff alignment, choosing how to report, and confirming the receipt back in the orchestrator, about seven choices per order. The cost of a subagent is that it cannot ask the user questions while it works, so the kickoff alignment is gone and anything needing a user decision ends the run as blocked; the module plan goes into the receipt and the reviewer checks it. If changed: back to the multi-session round trip, or the orchestrator writes code itself and planning and implementation mix again. -- **No "open an executor session by hand" fallback.** Keeping it would keep the whole launch prompt registration, kickoff alignment, fork claiming, and orchestrator address machinery, which produced most entries of the 0.x troubleshooting table. Very large tasks are split into several orders. If changed: two execution paths, with guards and reminders duplicated for each. -- **The orchestrator is not registered; it is recognized by a marker in the transcript.** `enter` outputs "编排标记: project-navigator 编排标记 ", where the id is the first 12 hex digits of the SHA-256 of the project path (lowercased on Windows). The output is injected into the skill content, which is recorded in the session transcript, and the hook looks for the marker through `transcript_path`. The source code contains only the prefix, so a session that reads the source is not mistaken for the orchestrator; `status` does not print the marker, so a subagent running `status` does not affect the main session. A forked session copies the transcript and carries the marker, so rewinding or editing a message needs no handling. 0.x registered the identity in `.git/navigator/orchestrator.json`, with rules for taking over, being superseded, claiming forks, and re-registering. If changed: back to registration and claiming, with a chance of misjudging identity every time the session id changes. -- **An unchanged blocked order continues with the same executor.** `order set issued` records a digest of the order file at issue time (`issuedDigest`). When a blocked order's file still matches, no new review is needed, and the output tells the orchestrator to resume the original subagent with SendMessage (measured: SendMessage resumes a finished subagent with its context), or to dispatch a new one if it cannot. A changed order needs a new review. If changed: a small user action costs a new review, or the subagent starts over every time. -- **The executor subagent omits `tools` and inherits every tool of the session.** It writes code, runs builds and tests, and may need the user's MCP tools. Plugin subagents do not support the `hooks`, `mcpServers`, and `permissionMode` fields (official documentation); its write scope is limited by the plugin-level guard. If changed: listing `tools` misses the user's tools. -- **A subagent's model is set at dispatch, and the assignment lives in the state file.** Defaults: the executor follows the orchestrator; the checker, reviewer, reader, and researcher use sonnet (`lib/subagent-models.mjs`). `model set` changes it, and the executor can be set per order kind. The "next action" and the "子代理模型" (subagent models) line of `status` spell out the `model` parameter of the Agent tool. It is not written into the `model` field of the subagent definitions for three reasons: the official documentation says the dispatch parameter outranks the definition and is kept when the subagent is resumed with SendMessage, while it does not say whether a definition's `model` is kept on resume; a fixed value would affect every project; and users can change models without editing plugin files. The executor is not downgraded by default: in a real project, all 5 blocked rounds of implementation orders were the executor proving the order wrong with evidence, and a weaker model is more likely to force a result. If changed: a resumed subagent may fall back to the session model, or every project is forced onto one executor model. -- **When the user must do something by hand, the executor hands back midway without a receipt.** The first line of the handback is "执行状态: 等待操作" (status: waiting for an action); the orchestrator replies with "等待操作" (no option block) and forwards the user's answer to the same subagent with SendMessage; the order stays issued throughout. In the same project a reverse-analysis order was blocked 8 times, each time only to ask the user for one action in the program under analysis, yet each round rewrote the receipt, moved the order to blocked, replied "受阻处理" with options, and issued again. Decisions and order changes still go through blocked. The original subagent is lost when the session ends, so the guide requires writing what is already known into `artifacts/` before handing back. If changed: every step of an interactive analysis is a full blocked round trip. -- **Delegation prompts are generated by scripts and used verbatim; the guard does not compare them.** `precheck-brief`, `exec-brief`, `review-brief`, and `research-brief` are the single source of the prompts. The 0.x guard compared the review prompt character by character and occasionally refused a dispatch over whitespace. If changed: prompts scatter across rule files and drift between versions. - -### Guard and reply checks - -- **The guard looks only at the target path of write tools, with four rules.** Nobody writes the state file or the generated views directly; the checker and the reviewer write no files; under `.navigator/` the executor writes only the current order's receipt and `artifacts/`, writes no business files on record-only orders, and writes no business files when the order is not running; the orchestrator writes only under `.navigator/`. Subagents are told apart by `agent_type` in the hook input (`waypoint:navigator-executor` and so on; measured: plugin subagents report ":"). 0.x also parsed Bash command text, whitelisted the orchestrator's tools, and checked prompts, record structure, user test output, and changes to `.claude/settings.json`; most of these rules were added for cross-session scenarios and past incidents, and they caused most denials and misjudgments. If changed: each rule added back blocks some normal operation. -- **"The orchestrator writes no business files" stays.** It is not about malice but about role collapse: one "just fix it" from the user and the model writes code, and the orchestrator's context is muddled from then on. The transcript is read only when the main session writes a project file outside `.navigator/`. If changed: the orchestrator gradually turns into an executor. -- **Stop checks only reply structure: the heading is a reply type, the first line under it is the conclusion, there is exactly one option block matching the spec, code blocks are closed, and no placeholder remains.** The heading identifies the reply type and the option block identifies the user's choice (order review approval depends on it), so these two cannot be relaxed. A reply whose `optionSets` is empty in the spec ("工单执行", "工单验收", "等待结果", used while waiting for a checker, researcher, or reader subagent, and "等待操作", used while waiting for the user's action) needs no choice from the user and has no option block; adding one sends the reply back. In 1.0.0 and 1.1.0 "工单执行" had a "wait" option, and users thought they had to choose A before the wait started. Sections, keys, progress values, and writing rules are reminders in the fill-in instructions. 0.x sent replies back for writing rules and summary length, leaving two copies on screen. A reply is sent back at most once (`stop_hook_active` passes). Replies are checked while background tasks run too: up to 1.2.1 they were not, and with the reviewer in the background the model wrote an untitled note that blended into the replies around it; both kinds of waiting now have a reply type. The progress line shows the stage after the order id (`format.orderStages`; "验收报告" sets `orderStage` to "待你决定"), which tells consecutive replies apart. Recommendations go only in the "处理建议" section of "验收报告", and the fill-in instructions say to add no text outside the sections. If changed: writing rules become frequent send-backs again. -- **Option groups with two choices also become buttons; the option block stays.** The Claude desktop app has a tool that fills its "next step" box (`mcp__ccd_turn__wrap_up`, see `BUTTON_TOOL` in `lib/reply-guide.mjs`). The box takes at most 2 buttons, and a click sends the button text as the user's reply. The fill-in instructions from `reply` include a "按钮" (buttons) line; the button text is the first clause of each choice (`choiceLead`), so a click is recognized the same way as a typed answer (`isChoiceSelected`). Groups with three or more choices get no buttons, and neither do groups marked `"buttons": false` in the spec (the user must type more after choosing, or the choice leads to a commit: the app does not allow a reply button to approve a commit). Buttons never replace the option block: the box can be dismissed and is not kept in the transcript, and both the Stop check and order review approval read the option block. The tool is internal to the app with no public contract, and no hook checks that it was called; if it is renamed or removed, replies fall back to the option block alone without errors. If changed: without the option block, other environments and users who dismiss the box cannot see the choices. -- **Replies start with a one-line conclusion; the human summary block at the end is gone.** A reader with little time sees only what comes first; the summary at the end repeated the conclusion. The conclusion length is only a reminder (`conclusionMaxLength`). Progress went from four lines to one. If changed: the key information ends up at the bottom. -- **Record files are not checked at write time; `check ` self-checks after writing.** Scripts that read records (review point prefill, the receipt's execution status, the review's verdicts) read key lines and tables and give a clear reason when they cannot. `check` with files assigns no checkup number and writes no state, so the executor can use it too. If changed: writes are denied as often as in 0.x. - -### Work orders and review - -- **A work order needs two user decisions: publish it in "工单审阅" (order review), and accept and commit it in "验收报告" (review report).** The review card lists only the "审阅要点" (review points: goal, decisions needed, out of scope, sizing reason) and the order file path, prefilled by `reply approve` from the order file. Approval is still bound to the digest of the order file (the hook records the digest when the review reply ends and the choice when the user answers), so a shorter display does not weaken the binding. 0.x quoted five sections of the order verbatim; users could not read it in a few seconds, and most details were beyond their judgment anyway. If changed: the review becomes a formality, or users read every order in full. -- **A checker subagent checks each work order once before the review.** The orchestrator writes orders from the records it already has; when a concrete fact in one (a file path, an id in the data, the release form or license of an external resource) does not hold, the executor finds out halfway through. In the same project all 5 blocked rounds of implementation orders were of this kind, and the orchestrator had read almost no project files before writing those orders. `precheck-brief` generates the prompt; `waypoint:navigator-checker` checks read-only and returns the items that do not hold or cannot be checked; the orchestrator fixes the order and runs `order precheck`. Without that mark, `reply approve` and `order set issued` refuse a drafting order. The mark records only that the check ran once and is not bound to the order content: changes made from the findings, or after a block, need no second check, otherwise every edit costs another subagent run. The findings are not stored in a file of their own; they end up in the corrected order. If changed: mistakes in an order surface during execution again, one round trip per mistake. -- **A work order has two layers, hard constraints and working assumptions, and the executor may correct the second.** The hard constraints are the goal, the decisions needed, and what is out of scope under "审阅要点" (review points), plus "工作范围" (scope). Every other detail is a working assumption: the order's "工作假设" (working assumptions) section, and the data formats, paths, specific instances, and implementation routes written in the guidelines and criteria. When an assumption does not match reality, the executor gets evidence, follows reality, and records one row per correction in the receipt's "事实修正" (fact corrections) table; this is not blocked. The "修正核对" (correction check) item of the review prompt checks each row for evidence and for overstepping: a valid correction means the criteria are judged against the corrected fact, an invalid one fails them; the review report lists every correction. Up to 1.4.0 an order was treated as a contract and a mismatched detail was a specification problem: for reverse engineering and integration with external systems, details are known only after doing the work, and one order went back and forth repeatedly. The precheck only catches mistakes that can be looked up. There is no separate order kind; this applies to all orders, because ordinary projects meet mismatched details too and another kind is another judgement to make. The line against pending criteria: if another detail that matches the facts satisfies the criterion, it is a correction; if the facts make it impossible, it is pending. If changed: every detail found in exploratory work costs another review and issue, or the executor builds something that contradicts the facts in order to match the order. -- **When a few criteria cannot be met, the executor finishes the rest and lists them as pending.** The receipt's "偏差记录" (deviations) has a "待定判据" (pending criteria) item, and the status is still completed. The reviewer confirms the stated reason and marks those criteria unverified with evidence starting with "待定:" (pending); they go through the existing option set 3 of "验收报告", where the user accepts as is or issues a follow-up order. No new status or option set. In the same project, 3 blocked receipts said the rest of the work was already done and only one criterion was stuck. If the user's decision would change how the rest is done, the order is still blocked. If changed: the wording of one criterion costs the whole order another review and issue. -- **Test authorization is gone.** The executor does not run tests with external effects and lists them under "外部影响" (external effects) in the receipt's test record; the reviewer runs the code checks and the receipt's other test commands, not the external ones, and judges those from the receipt's output. MCP evidence tools are no longer registered; the reviewer makes read-only queries at the places named in the receipt's evidence record, with the constraint stated in the prompt and the subagent definition. If changed: every order with an external test costs one more user choice. -- **Unverified criteria can be accepted explicitly by the user.** The third option group of "验收报告" (only unverified, nothing failed) lets the user accept as is and commit, via `order set accepted --accept-unverified`; the unverified criteria are recorded as risks. 0.x always failed the order, so criteria that depend on external evidence were rejected over and over. Failed criteria still cannot be accepted. If changed: orders cannot converge when external conditions are missing. -- **Committing is not an order state.** Order states are drafting, issued, blocked, reviewing, accepted, rejected, and voided; after `order set accepted` the orchestrator calls commit-message as the output says, and the state file goes in with that commit. 0.x had committing and a commit settlement record to match `lastCommit`; without reconciliation, neither is needed. When old state is read, accepted and committing orders count as accepted and ended. If changed: the state file stays modified after each commit. -- **Order size follows four limits, not the number of criteria.** One executor run can finish it, one review can check it, it runs afterwards, risky parts are separate; within these, bigger is better, one slice per order by default, with the reasoning in "粒度依据" (sizing reason). 0.x capped criteria at 5, so criteria became denser; consecutive rejections only ever led to smaller slices. A rejection now first asks why. If changed: orders split ever finer, and user confirmations grow with them. -- **Orders that change code check for registered check commands at creation (`creationBlocker`), and again before issue.** Run-check and analysis orders only produce records, need no check commands, and are followed by an order of the same kind when rejected. A rejected selection is followed by a new selection order, not a fix order: fix orders require check commands, which are registered only after a selection is adopted. If changed: an order rejected before check commands are registered has no way forward. -- **Check commands exclude `.navigator/` and `.claude/`.** Some formatters also process Markdown by default and would pull the records in. If changed: the record format fails every implementation order's check criterion. -- **A selection answers only "can the library do each requirement", is reviewed in one round, and the user decides whether to adopt it.** The `evidence` command fetches the cited lines from the original repository at that version (https only, git reads no credentials). The orchestrator may recommend technologies; only key dependencies get selection orders, and minor libraries are chosen by the executor within implementation orders. If changed: every small dependency needs an order, or key dependencies go without evidence. - -### Installation and upgrade - -- **Hooks live at plugin level and exit at once in projects that are not initialized.** `hooks.json` uses the shell-free form (`"command": "node"`, `"args": ["${CLAUDE_PLUGIN_ROOT}/..."]`), measured to work with spaces and Chinese characters in paths. `hook.mjs` first checks for `.navigator/state.json` and exits without it, and also exits when the project is marked uninstalled. 0.x had `init` write hooks into the project settings and copy the scripts into `.navigator/bin/`: about 60 scripts committed per project, a manual upgrade after every plugin update, and copies that could not upgrade themselves. The cost is that in every project with the plugin enabled, each write tool call and each message starts Node once. If changed: back to copied scripts and manual upgrades. -- **Commands run through the plugin's `bin/`, written as `project-navigator `.** Official documentation: files in a plugin's `bin/` are on the Bash tool's PATH; measured: the PowerShell tool runs the `.cmd` of the same name. The name is the full `project-navigator`, to avoid clashing with tools on the user's PATH (the plugin directory comes after the user's PATH, so the user's tool would win). `bin/project-navigator` needs the executable bit in the repository. The skill's `enter` still runs `node "${CLAUDE_SKILL_DIR}/runtime/navigator.mjs"`, because the PATH of `!` commands is not documented. If changed: commands depend on a copy inside the project. -- **Reference files and the executor guide are printed by `ref `.** In the default permission mode, reading plugin files outside the project with Read needs user approval; the permission rules cover the plugin command, so its output needs no confirmation and is always the current version. If changed: either copy into the project, or a prompt on every read. -- **Permission rules go into both `settings.json` and `settings.local.json`, and broad writes are not allowed.** The rules are `Bash(project-navigator *)`, `PowerShell(project-navigator *)`, and `Edit(.navigator/**)`. Rules in the project's `settings.json` take effect only after the user accepts the workspace trust dialog and never in headless mode; the local file needs no trust. Confirmations for the executor's business file writes follow the user's permission mode: measured, a background subagent's write prompt appears in the main session (without saying it comes from the subagent), and "Always allow" switches the session to Accept edits. So `init` adds no `Edit(**)`, and the usage guide suggests Accept edits while an order runs. `SKILL.md`'s `allowed-tools` also allows node and the `project-navigator` command, only for the turn that invokes the skill, for initialization. If changed: a broad rule stops confirming every edit in the project. -- **Upgrading from 0.x is done by `init`; entering first removes the old orchestrator registration.** `init` removes the project-level hooks and old permission rules, deletes `.navigator/bin/` and `.navigator/guide/`, cleans the old files in `.git/navigator/` and `refs/navigator/snapshots`, and moves an issued order without a receipt back to drafting. Until the upgrade, the old project-level hook still runs; it identifies the orchestrator by `orchestrator.json` and blocks commands by whitelist. So when `enter` finds an old installation it deletes that file first, the old hook treats the session as an ordinary one, and `project-navigator init` is no longer blocked. If changed: running the upgrade from an old orchestrator session is denied by the old guard. -- **Everything under `.navigator/` except drafts is committed, and ignore rules are checked at entry and initialization.** `.navigator/.gitignore` re-includes directories, the file types the plugin writes, and everything under each order's `artifacts/`, then excludes `drafts/`. `init` and every entry run `git check-ignore --no-index` on the paths that must be committed (state file, glossary, a sample location of each record type, `.claude/settings.json`, and `engineering.md`), and stop with the offending rule if a parent rule ignores them. If changed: records are incomplete after switching machines or cloning. -- **Committing skill products is a step in command output, not a reply type.** When skill products are uncommitted, the output of `init`, stage changes, and `finish` gives the arguments for calling commit-message (committing only `.navigator/` and plugin-managed settings; commit-message asks about other changes); `finish` counts the state file, since no later commit would carry the completion mark. 0.x had the "阶段提交" (stage commit) reply and the `stagecommit` command, one more three-way choice per stage. If changed: every stage costs one more user choice. -- **Scripts use only Node.js 22 built-in modules.** The hooks run on user machines and cannot require installing dependencies. +- **The orchestrator session does not write business code; implementation uses background subagents.** The purpose is not to guard against malice but to guard against role collapse: one "just fix this while you're at it" from the user and the model writes the code itself, and from then on the context for planning and progress is taken over by implementation details. If changed: the orchestrator session gradually turns into an implementation session, and a long project loses its thread midway. +- **Interactive slices are done directly by the orchestrator session.** Tasks such as reverse engineering and live measurement need the user's cooperation at every step, and a subagent cannot talk to the user; 1.x added "waiting for action" for this, and an analysis work order still took several round trips. An interactive slice only produces findings, written to `.navigator/findings/`, which is already within the orchestrator session's write scope, so the guard needs no new rule and "does not write business code" is unaffected. Only `findings` slices can be interactive: writing code does not require talking to the user. If changed: back to one handback round per step. +- **No "open a separate execution session" mechanism.** 0.x did exactly this, and the machinery of identity registration, fork claiming, and orchestrator addresses was the source of most failures at the time. Now any session that has invoked the skill is an orchestrator session and the state is in files, so "continue in another session" needs no mechanism at all. If changed: back to registration and claiming. +- **The orchestrator session does not register; it is recognized by the orchestrator marker in the transcript.** The output of `enter` carries "编排标记: project-navigator 编排标记 <编号>" (orchestrator marker: project-navigator orchestrator marker ), where the id is the first 12 characters of the SHA-256 of the project path (lowercased first on Windows); the output is injected into the skill content, the skill content is recorded in the session transcript, and the hook reads `transcript_path` to find this marker. The source code contains only the prefix, so a session that reads the source code is not mistaken for one; `status` does not output the marker. A forked session copies the transcript and so naturally carries the marker. If changed: identity may be misjudged every time the session id changes. +- **The executor subagent cannot ask questions, so there is only one kind of handback.** It ends the current run, and the first line of its last message says "交回: 需要回答" (handback: needs an answer) or "交回: 需要操作" (handback: needs an action); the orchestrator session tells the user, then uses SendMessage to let the same subagent continue (measured: SendMessage can resume an ended subagent with its context). The slice status stays "实现中" (implementing) throughout. 1.x had three channels (blocked, waiting for action, pending criteria), each with its own reply type and state transitions. If changed: every extra channel adds a set of states and decisions. +- **Delegation prompts are generated by scripts, and the orchestrator session uses them unchanged.** `brief exec` and `brief review` are the only source of the prompts, and the section names in them come from the spec. If changed: the prompts scatter across rule files and gradually stop matching the structure of the slice file. +- **Subagents do not declare `tools` (executor) or only disable write tools (reviewer), and the model follows the session.** MCP tools installed by the user may be needed. The model configuration command from 1.4.0 was removed: it was one more piece of state, and the user can use Claude Code's own subagent model setting directly. If changed: listing `tools` misses the user's tools. + +### Slices and review + +- **The unit of work is the slice, one file per slice, in three parts.** The Plan is written by the orchestrator session, the Result by the executor, and the Review by the orchestrator session from what the reviewer subagent hands back. 1.x had three files, each with a fixed structure, and the workflow stopped when one could not be parsed. If changed: back to the three-file set. +- **Ownership of the parts is ensured by the digest of the Plan part.** The guard can only judge by path and cannot govern parts inside a file. `slice start` records the digest of the Plan part and `slice review` compares it, refusing to submit for review on a mismatch; when the orchestrator session changed it according to the user's answer, run `slice replan`. Before hashing, leading and trailing whitespace on each line is removed, whitespace is collapsed, and table separator rows are normalized: the digest does not change when a formatting hook realigns tables. This can only detect, not prevent; if it turns out to happen often in practice, the fallback is to split the Plan and the Result into two files. +- **The scripts read only what they need.** The completion criteria (a numbered list), whether "需要决定" (needs decision) and "补做" (rework) are "无" (none), "执行状态" (execution status), and the "标准核对" (criteria check) table (the verdict can only be one of three, and every row needs evidence). For the rest they only check "not empty, no placeholder". Structure is not validated on write. If changed: the more structure rules, the more often work gets blocked. +- **By default the user is asked to decide only once, after the review.** When "需要决定" in the plan is not "无", `slice start` refuses, and after the user answers it is run with `--decided`. 1.x had a fixed two per work order (review of the order and review of the work), and in most order reviews the user simply clicked agree. The pre-issue check was removed too: the orchestrator session can read code anyway, and reads it itself before writing the plan. If changed: every slice gets one more confirmation that carries no information. +- **Hard constraints and working assumptions are two layers.** The hard constraints are the goal, the completion criteria, the explicit exclusions, and the module layout; the rest are working assumptions, and when they do not match reality the executor gathers evidence, works to what is actually there, and records it in the "修正" (corrections) table, which the review checks. 1.4.0 and earlier treated the plan as a contract, and a mismatched detail meant one round trip. If changed: exploratory tasks take one more round for every detail discovered. +- **The skeleton is a hard rule.** The first slice of the first milestone is marked as the skeleton by the script, and its kind must be `build`; `slice plan` refuses other slices until the skeleton passes; the skeleton cannot be abandoned. If changed: back to "write every layer horizontally first, then find out at the end that they do not connect". +- **Wiring is a fixed item of the review.** The executor writes the call path from the entry point to the new code in "接线证据" (wiring evidence), and the reviewer subagent traces it again itself. When it cannot be traced, the related criteria are judged fail, and a slice with a fail can only be reworked or abandoned, so a slice without wiring cannot be followed by the next one. This is ensured jointly by the review prompt and the refusal in `slice accept`; the script itself does not judge wiring. +- **Only one slice is worked on at a time.** `current` has only one position. If changed: the base commit and the review scope are no longer well defined. +- **Rework happens in the same file.** `slice redo` changes the execution status to "补做中" (rework in progress), adds the round number to the heading of the existing Review part, and appends an empty Review part at the end of the file; when parsing, the last of the parts with the same name is used. If changed: one file per round, or the previous round's review record is lost. +- **Unverified criteria can be explicitly accepted by the user** (`slice accept --accept-unverified`). In projects whose verification mode is `manual` or `measured`, every criterion that can only be confirmed by running will be unverified, and is accepted after the user confirms it by hand. Failed criteria cannot be accepted. +- **Committing is not a state.** After `slice accept`, the orchestrator session invokes commit-message as the output instructs, and the state file is committed with that commit. + +### Modularity constraints + +- **Module organization is a registered project setting, not inferred each time.** `directories` or `filters`; during intake the script gives a suggestion (by looking for `.sln`, `.vcxproj`, `.csproj`, `.vbproj` in the repository), and the user confirms it. In the default engineering standards pack, each of the two variants occupies one marked block, and `setup` keeps only one block when writing to the project. If changed: within the same project, some slices create directories and others create filters. +- **The constraints land on two fixed items, in the plan and in the review, not just in rule text.** "模块安排" (module layout) in the plan (file, module, layer, description, single definition, reuse) is a hard constraint; the six items of "结构核对" (structure check) in the review are written in `STRUCTURE_CHECKS` in `lib/briefs.mjs`. 1.x had only one line, "规范核查" (standards check), in the review. If changed: the standards are only remembered after something goes wrong. +- **The filters check is the only deterministic modularity check.** It parses `.vcxproj` and `.vcxproj.filters` with regular expressions: every source file item must be assigned to a filter; only files added by this slice are required to lie flat (compared against the base commit), and existing subdirectories are left alone; files outside the project directory and items with wildcards or macros are not checked. No XML library is introduced: runtime scripts use only built-in modules. +- **The verification mode is also a registered project setting.** With `auto`, the reviewer subagent runs the registered commands; with `manual` and `measured`, the prompt states that it does not build or run. 1.x assumed that every project had code check commands, and a project without commands had to take a detour and register a "原因" (reason). ### State and records -- **`state.json` is the single source of state and is written only by plugin commands.** Writes compare a revision number and write a temporary file before renaming; reads migrate old structures (schema 5 drops session registration, `lastCommit`, `stageCommit`, `pendingAnomaly`, `evidenceTools`, and the order's `authorizedTests`). The guard denies changing it with write tools. If changed: concurrent writes overwrite each other, or the model edits state by hand. -- **Snapshots and reconciliation are removed.** `.navigator/` is already in Git, so rollbacks and manual edits are visible with git. 0.x snapshots, pending writes, reconciliation, and the "验收异常" (review anomaly) reply guarded against manual edits but could stall the workflow after any `git reset` or formatter hook. Now a rollback continues from the current records; when they do not match reality, the orchestrator replies with "运行受阻" (blocked run) for the user to decide. If changed: every rollback needs anomaly handling again. -- **`spec/templates.json` is the single source for replies, record files, and writing rules.** Reply checks, skeletons, checkups, and reference documents all read the spec. If changed: documents and checks drift apart. -- **Replies and record files start from a skeleton, and progress is computed by the script.** Models miscount progress or miss sections. -- **There is one way to count characters** (`lib/text-units.mjs`): a Chinese character, an English word, a number, or a path counts as one; spaces and punctuation do not. Sentence length and conclusion length share it. +- **`state.json` is the single source of the state and is written only by plugin commands.** A write compares the version number, and writes a temporary file first and then renames it. The schema version is 6; earlier schemas are not read. +- **The next action is computed only in `lib/guidance.mjs`.** `status` and the hook's position reminder share it. It needs only the state, plus three things read from the slice file: the execution status, and the row count and classification of the criteria check table. If changed: command output, the hook, and the rule files each say something different. +- **The roadmap view is generated by the script.** `.navigator/roadmap.md` is rewritten every time the state is saved, and the guard rejects direct edits. +- **Decision entries are appended by a command.** `decision add` registers the number and appends the body at the same time, so the two cannot disagree; the requirements file and the backlog are written directly by the orchestrator session. - **Chinese content is passed to commands through JSON drafts under `.navigator/drafts/`.** Windows PowerShell 5.1 garbles Chinese arguments. +- **No snapshots and no reconciliation.** The records are in Git, and rollbacks and manual edits are visible with git; when things do not agree, `check` reports the problems and the user decides. -### Skill anomalies and problem reports +### Replies -- **The scripts record only the signs they can be sure of and do not judge causes.** Four signs: a command or hook throws an internal error (not a `WorkflowError` and not a mistyped argument); the self-check in `status` finds an inconsistent state; a command named in the previous "下一动作" (next action) is refused by a workflow rule; the same next action is given 4 times in a row while the state revision does not change (not counted while waiting for the executor). Ordinary refusals by workflow rules are not recorded. If changed: every slip produces a report, or the model works around skill problems and maintainers never see them. -- **Anomalies are recorded in `.git/navigator/`, and reports are written to `.navigator/drafts/reports/`.** Neither is committed: the records are local runtime facts, and reports go to the maintainers, not into the project records. An identical unreported anomaly is recorded once, and at most 30 are kept; once written into a report they are marked as reported, so one problem is not reported twice. -- **A report states only what happened.** The script fills in the environment, a state summary, and the recorded anomalies; the model fills in the course of events in a draft (what it was doing, expected, actual, how to reproduce). No guesses about causes and no advice: whether and how to fix is for the maintainers to judge, and a model's guess easily misleads the investigation. A report contains no work order text or business code, and the project and skill directory paths are replaced with placeholders. If changed: reports leak project content or carry unfounded conclusions. -- **Orchestration pauses only when it cannot continue.** For an internal command error or an inconsistent state, or when the draft says `"blocked": true`, "问题报告" (problem report) uses option group 2 and waits for a fix; otherwise it uses group 1 and continues after recording. When the state is inconsistent, the next action in `status` becomes the report itself. -- **A failed recording never affects commands or hooks.** A failing hook still lets the action through and only adds an anomaly, and the orchestrator is reminded to report it on the user's next message; `report` runs even when the state file is corrupt. +- **Replies have no template and no validation.** The only rules are: conclusion on the first line, progress on the second, options listed at the end when a decision is needed, no pasted code. The reply types, skeleton commands, and Stop hook of 1.x were all removed: they existed to recognize the user's choice from a reply (the approval in work order review), and the workflow no longer depends on the structure of replies. The cost is that "conclusion first" relies on rules alone. If changed: back to rejections and rewrites. + +### Installation + +- **The hook lives at plugin level and ends immediately in a project that has not been initialized.** `hooks.json` uses the form that does not go through a shell (`"command": "node"`, `"args": ["${CLAUDE_PLUGIN_ROOT}/..."]`). `hook.mjs` ends at once when it cannot read a state file of the current schema, or when the project has been uninstalled; so the hook has no effect on 1.x records before they are archived either. The cost is that in every project with the plugin installed, every write-tool call and every message starts Node once. +- **Commands are invoked through the plugin's `bin/`, written as `project-navigator `.** The `enter` run when the skill loads still uses `node "${CLAUDE_SKILL_DIR}/runtime/navigator.mjs"`, because the PATH of `!` commands is not guaranteed by the documentation; `SKILL.md` sets `allowed-tools` for this. +- **Reference files and the executor guide are output by `ref `.** In the default permission mode, using Read on plugin files outside the project needs the user's approval. `ref executor` appends `shared/punctuation.md` after the guide, so the punctuation rules are maintained in that one file only. +- **The allow rules are written to both `settings.json` and `settings.local.json`, and no broad write is allowed.** The rules are `Bash(project-navigator *)`, `PowerShell(project-navigator *)`, and `Edit(.navigator/**)`. Confirmations for the executor subagent writing business files are handled according to the user's permission mode. +- **Everything in `.navigator/` except drafts is committed, and the ignore rules are checked on entry and at initialization.** `.navigator/.gitignore` re-includes the directories at every level, `.md` and `.json` files, and everything under `findings/`, and finally excludes `drafts/`. When a path in `REQUIRED_TRACKED_PATHS` is ignored by a higher-level rule, the skill stops and reports which rule it is. ## Component map Entry points and commands: -| File | Job | -| ------------------------ | ------------------------------------------------------------------------------------- | -| `runtime/navigator.mjs` | Command line entry, parses arguments and dispatches; `enter` always exits with code 0 | -| `runtime/hook.mjs` | Entry for the four hook events; exits at once in projects that are not initialized | -| `commands/handlers.mjs` | Maps command names to handlers | -| `commands/init.mjs` | Initialization and upgrade from 0.x | -| `commands/uninstall.mjs` | Removes the permission rules and marks the project uninstalled | -| `commands/enter.mjs` | `enter` and `status`: environment checks, orchestrator marker, next action | -| `commands/reply.mjs` | Reply instructions and skeletons; order review prefill, review report option checks | -| `commands/template.mjs` | Record file instructions and skeletons | -| `commands/ref.mjs` | Prints reference files, the executor guide, and the engineering standards | -| `commands/stage.mjs` | Stages, steps, skips, closing | -| `commands/plan.mjs` | Roadmap, milestone and slice status | -| `commands/order.mjs` | Work order creation and status changes, archiving old receipts | -| `commands/records.mjs` | Risks, decisions, changes | -| `commands/brief.mjs` | Precheck, executor, review, and research delegation prompts | -| `commands/model.mjs` | Show and set subagent models | -| `commands/evidence.mjs` | Fetches the source lines cited by a selection receipt's evidence table | -| `commands/check.mjs` | Checkups and self-checks | -| `commands/standards.mjs` | Writes the project standards | -| `commands/codecheck.mjs` | Registers code check commands | -| `commands/report.mjs` | Generates the skill problem report and marks anomalies as reported | -| `commands/support.mjs` | Opens the project, saves state, reads drafts, the commit step for skill products | +| File | Job | +| ------------------------ | ----------------------------------------------------------------------------------------------- | +| `runtime/navigator.mjs` | Command-line entry; parses arguments and dispatches; `enter` ends with exit code 0 in all cases | +| `runtime/hook.mjs` | Single entry for the three hook events | +| `commands/handlers.mjs` | Mapping from command names to handler functions | +| `commands/init.mjs` | Initialization; archiving records from the old version | +| `commands/uninstall.mjs` | Removes the allow rules and marks the project as uninstalled | +| `commands/enter.mjs` | `enter` and `status`: environment check, orchestrator marker, position and next action | +| `commands/ref.mjs` | Outputs reference files, the executor guide, and the engineering standards | +| `commands/setup.mjs` | Suggestion and registration of project settings; writes the project standards | +| `commands/plan.mjs` | `roadmap`, `milestone done`, `finish` | +| `commands/slice.mjs` | The seven subcommands of `slice` | +| `commands/brief.mjs` | Delegation prompts for implementation and review | +| `commands/decision.mjs` | Registers a decision and appends the entry | +| `commands/check.mjs` | Record self-check and filters check | +| `commands/support.mjs` | Opens the project, saves the state and generates the roadmap view, reads drafts, commit steps | Modules in `runtime/lib/`: -| Group | Files | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Paths and state | `paths.mjs`, `state.mjs`, `atomic-file.mjs`, `numbering.mjs`, `validation.mjs`, `workflow-error.mjs`, `tracked-paths.mjs` | -| Spec and layout | `spec.mjs`, `markdown.mjs`, `layout.mjs`, `reply-checks.mjs`, `file-checks.mjs`, `writing-checks.mjs`, `text-units.mjs`, `freeform-sections.mjs` | -| Generation | `render.mjs`, `render-plan.mjs`, `table.mjs`, `briefs.mjs`, `reminders.mjs`, `guidance.mjs`, `reply-guide.mjs`, `file-guide.mjs`, `writing-rules.mjs` | -| Guard and identity | `guard.mjs` (four write rules), `orchestrator-marker.mjs` (recognizes the orchestrator from the transcript) | -| Workflow | `workflow-orders.mjs`, `workflow-plan.mjs`, `workflow-records.mjs`, `code-checks.mjs`, `review-record.mjs`, `receipt-record.mjs`, `order-approval.mjs`, `order-base.mjs`, `option-answer.mjs`, `registry.mjs`, `subagent-models.mjs` | -| Source evidence | `source-evidence.mjs`, `source-fetch.mjs` | -| Anomaly self-checks | `diagnostics.mjs` (judging and recording anomalies, stalls), `state-checks.mjs` (state self-check) | -| Repository and environment | `repo.mjs`, `environment.mjs`, `version.mjs`, `settings.mjs` | - -Other parts of the repository: +| Group | Files | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Paths and state | `paths.mjs`, `state.mjs`, `atomic-file.mjs`, `numbering.mjs`, `validation.mjs`, `workflow-error.mjs` | +| Roadmap and slices | `roadmap.mjs` (roadmap drafts, milestones), `slice-flow.mjs` (slice state transitions), `slice-file.mjs` (slice file skeleton, reading, digest) | +| Generation | `guidance.mjs` (next action and progress), `briefs.mjs` (delegation prompts), `roadmap-view.mjs` (roadmap view), `decisions.mjs` (decision entries) | +| Project settings | `project-settings.mjs` (suggestion, validation, generation of the project standards), `filters.mjs` (filters check) | +| Guard and identity | `guard.mjs` (the four write rules), `orchestrator-marker.mjs` (recognizes the orchestrator session by the transcript) | +| Parsing | `markdown.mjs`, `table.mjs` | +| Repository and environment | `repo.mjs`, `environment.mjs`, `version.mjs`, `settings.mjs` | -- `scripts/gen-navigator-docs.mjs`: generates `references/replies.md`, `files.md`, `writing.md`, and `guide/executor.md` from the spec; handwritten parts are in `scripts/navigator-docs/`. -- `tests/project-navigator/`: automated tests. +`tests/project-navigator/` holds the automated tests. ## Troubleshooting -| Symptom | Cause | Files | How to verify | -| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Invoking the skill aborts with a permission check failure | `allowed-tools` was removed or changed | `SKILL.md` | Invoke once in the default permission mode | -| `project-navigator` is not found | The plugin is not enabled, or the session started before the plugin update; PowerShell cannot find the `.cmd` | `bin/` | Check with `claude plugin list`; restart the session | -| Plugin commands or record writes ask for permission | The local settings lack the rules, or the command does not start with `project-navigator` | `lib/settings.mjs` | Check the rules in `.claude/settings.local.json` | -| The orchestrator's write outside `.navigator/` is denied | This is rule 4; issue a work order instead | `lib/guard.mjs` | Drive `hook.mjs` through stdin with an orchestrator transcript | -| A session that is not the orchestrator cannot write business files | The project's orchestrator marker appears in its transcript, for example it once invoked the skill | `lib/orchestrator-marker.mjs` | Search the transcript for "project-navigator 编排标记" | -| The executor's business file write is denied | The order is not issued, or it is a run-check or analysis order | `lib/guard.mjs`, `lib/workflow-orders.mjs` | The "工单状态" (order status) line of `status` | -| The executor's record write is denied | Under `.navigator/` only the current order's receipt and `artifacts/` are writable | `lib/guard.mjs` | The denial reason states the writable scope | -| The executor's document write under `artifacts/` is denied, and the message is not the guard's | Claude Code itself refuses report-like file writes from subagents | `scripts/navigator-docs/executor-guide.md` | The subagent returns the full text in its final reply and the orchestrator writes it to that path | -| A reply is sent back | It lacks the heading or the conclusion line, or the option block differs from the spec | `lib/reply-checks.mjs`, `spec/templates.json` | Take the skeleton with `reply`, fill it in, and check again | -| The executor finished, but `status` says no receipt | The subagent did not write the receipt from the skeleton, or wrote it elsewhere | `commands/brief.mjs`, `lib/receipt-record.mjs` | Ask the subagent to write it with SendMessage; look in the order folder | -| The receipt exists, but the next action cannot tell blocked from done | "执行状态" (execution status) in "回执概况" (receipt overview) is missing, or the section was renamed | `lib/receipt-record.mjs` | Self-check with `check ` | -| `order set issued` is refused, mentioning the review | The user did not choose A in "工单审阅" (order review), or the order file changed after approval. Status `declined` means the user answered something else (including a message withdrawn later): a later message starting with "发布工单" (issue the order) still approves, a bare letter does not | `lib/order-approval.mjs`, `commands/order.mjs` | Check the order id, digest, and status in `.git/navigator/order-approval.json` | -| `reply approve` or `order set issued` is refused, mentioning the precheck | The drafting order never had `order precheck` run (orders drafted before 1.4.0 need it once too) | `lib/workflow-orders.mjs` | Check `prechecked` on the current order in `state.json` | -| A subagent did not use the assigned model | The orchestrator dispatched without the `model` parameter, or the user set `CLAUDE_CODE_SUBAGENT_MODEL_FORCE` | `lib/subagent-models.mjs`, `lib/guidance.mjs` | The "子代理模型" line of `status`; the parameters of the Agent call in the session | -| The executor ended without a receipt and its first line is "执行状态: 等待操作" | A midway handback, not an anomaly | `lib/briefs.mjs`, `scripts/navigator-docs/executor-guide.md` | Reply "等待操作", then resume with SendMessage after the user answers | -| Continuing a blocked order is refused | The order file changed after issue and `issuedDigest` no longer matches; a new review is needed | `commands/order.mjs` | The next action in `status` says a new review is required | -| `order set accepted` is refused | A criterion failed; unverified criteria without `--accept-unverified`; or "用户结论" (user verdict) is still "待用户确认" (awaiting the user) | `lib/review-record.mjs`, `commands/order.mjs` | Check the verdict table and manual verification section of `review.md` | -| `reply approve` or `order set issued` is refused, mentioning the base commit | "基准提交" (base commit) in the order file is not the current commit: the repository got a new commit after the order was written (for example the commit after a plugin upgrade) | `lib/order-base.mjs` | Change "基准提交" in the order file to the current commit shown in the message and review again | -| `reply review --option 1` or `--option 3` is refused | The option group does not match the verdicts | `commands/reply.mjs` | Check the verdict table of `review.md` | -| Creating or issuing an implementation or fix order is refused, mentioning code checks | No check commands are registered, or the criteria lack the check criterion verbatim | `lib/code-checks.mjs`, `commands/order.mjs` | Check `codeChecks` in `state.json`; take `template order` again | -| Entering says the project was initialized by an older version | The project settings still have 0.x project-level hooks, or `.navigator/bin/` still exists | `commands/enter.mjs`, `commands/init.mjs` | Run `project-navigator init` to upgrade | -| Before the upgrade, commands in the old orchestrator session are denied | The old project-level hook still runs; the 0.x `orchestrator.json` was not deleted | `commands/enter.mjs` | Invoke the skill again (entering deletes the old registration), or invoke it in a new session | -| `init` prints "设置结果: 未执行" (not done), or entering asks for "运行受阻" (blocked run) | The project's ignore rules ignore `.navigator/`, the configuration under `.claude/`, or files that must be committed | `commands/init.mjs`, `lib/tracked-paths.mjs` | `git check-ignore -v --no-index ` | -| `evidence` cannot fetch a version or file | The version differs from the repository's tag naming, the path is the installed package path, the repository needs an account, or the network is down | `lib/source-fetch.mjs` | Retry one entry with `evidence check` and read git's original error in the output | -| The hook still outputs after uninstalling | `init.status` in the state file is not `uninstalled` | `commands/uninstall.mjs`, `runtime/hook.mjs` | Check `init` in `state.json` | -| A user forwards a problem report | "脚本记录的异常" (recorded anomalies) gives the kind, source command, and stack; "状态" (state) gives the position and order status at the time | `lib/diagnostics.mjs`, `commands/report.mjs` | Reproduce in a temporary repository from "复现步骤" (steps to reproduce); `<技能目录>` in the stack stands for `skills/project-navigator` | -| `status` keeps showing "技能异常" (skill anomaly) | There are unreported anomalies; they are marked as reported only after `report` runs | `lib/diagnostics.mjs` | Look for entries without `reportId` in `.git/navigator/diagnostics.json` | -| A slip was recorded as a skill anomaly | The script threw an error that is not a `WorkflowError`, so it counts as internal; or the next action text names a command that gets refused | `lib/diagnostics.mjs`, `lib/guidance.mjs` | Check the anomaly's kind and message; workflow refusals always throw `WorkflowError` | - -## Change workflow - -| What changed | Also do | -| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `spec/templates.json` | `npm run gen:docs`, `npm test`; check section names and option group numbers referenced by handwritten references | -| Option group order of a reply type | Group numbers are referenced in code: `lib/guidance.mjs` (`REVIEW_OPTIONS`, `REPORT_OPTIONS`), `commands/order.mjs`, `commands/reply.mjs`, and handwritten references | -| A refusal by a workflow rule | Always throw `WorkflowError`; any other error type is recorded as an internal skill error | -| Columns or values of the evidence or verdict table | The constants in `lib/source-evidence.mjs` and `lib/review-record.mjs`; `spec.test.mjs` checks both sides match | -| The order file's "审阅要点" (review points) | The order review reply must contain all its keys; `spec.test.mjs` checks it | -| `scripts/navigator-docs/` | `npm run gen:docs` | -| `shared/punctuation.md` | `npm run gen:docs`: the executor guide and `writing.md` embed the punctuation rules | -| `runtime/` | `npm test`; after a plugin update users restart the session, no project upgrade needed | -| State file structure | Bump `STATE_SCHEMA_VERSION` and migrate old fields in `normalizeState` | -| Adding or renaming a plugin command | Register it in `commands/handlers.mjs`; update the command table in `SKILL.md` and the commands available to the executor in the guide | -| The command name `project-navigator` | `COMMAND_NAME` in `lib/paths.mjs`, the two files in `bin/`, the permission rules in `lib/settings.mjs`, documentation | -| `SKILL.md` | The length and routing table checks in `npm test`; a smoke test with `claude -p --plugin-dir` | -| Subagent names | `SUBAGENT_TYPES` in `lib/guard.mjs` and `SKILL.md` | -| The plugin name `waypoint` | `SUBAGENT_TYPES`, `COMMIT_SKILL`, the `/waypoint:` commands in the documentation | +| Symptom | Root cause | Related files | How to verify | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Invoking the skill aborts at once, reporting that a permission check failed | `allowed-tools` was deleted or rewritten | `SKILL.md` | Invoke it once in the default permission mode | +| Running `project-navigator` reports that the command is not found | The plugin is not enabled, or the session started before the plugin was updated; `.cmd` is not found in PowerShell | `bin/` | `claude plugin list` to see whether the plugin is enabled; restart the session | +| Plugin commands or record writes pop up permission prompts | The local configuration lacks the allow rules, or the command does not start with `project-navigator` | `lib/settings.mjs` | The "放行规则" (allow rules) line of `status`; run `init` again | +| On entry, it says the records were written by an old version | The `schema` of the state file is less than 6 | `commands/enter.mjs`, `commands/init.mjs` | Run `init --archive` after the user agrees | +| The orchestrator session is refused when writing files outside `.navigator/` | This is rule 4; when a change is needed, put it into a slice | `lib/guard.mjs` | Drive `hook.mjs` through standard input, with an orchestrator session's transcript | +| A session that is not the orchestrator session is refused when writing business files | This project's orchestrator marker appears in the session transcript, for example because the session once invoked the skill | `lib/orchestrator-marker.mjs` | Search the transcript for "project-navigator 编排标记" (project-navigator orchestrator marker) | +| The executor subagent is refused when writing orchestration records | Under `.navigator/` only the slice file in progress and `findings/` can be written | `lib/guard.mjs` | The refusal reason states the writable scope | +| `slice start` says the Plan part is not filled in | A section is empty or still contains "(待填写" ("(to be filled in"); the completion criteria are not a numbered list | `lib/slice-file.mjs` | `check` lists the sections that are not filled in | +| `slice start` mentions a needed decision | "需要决定" (needs decision) is not a single line "无" (none) | `commands/slice.mjs` | After asking the user and writing in the answer, run with `--decided` | +| The executor subagent has ended, but `status` still says waiting | It did not change "执行状态" (execution status) to "完成" (done), or wrote it in another section | `lib/slice-file.mjs`, `lib/guidance.mjs` | Look at the "执行结果" (Result) part of the slice file; use SendMessage to ask it to fill it in | +| `slice review` says the Plan part was changed | The content of the Plan part changed after work started: the orchestrator session changed it according to the user's answer, or the executor subagent changed it by mistake | `digestPlan` in `lib/slice-file.mjs` | `git diff` on the slice file; in the former case run `slice replan` | +| The Review part is written, but `status` still says waiting for review | The criteria check table still has placeholders, or is not a valid table | `readVerdicts` in `lib/slice-file.mjs` | There must be a separator row under the header, and three cells per row | +| `slice accept` is refused | A section of the Review part is not filled in; a verdict is not one of the three; there are fewer rows than completion criteria; there is a fail; there is an unverified without the flag | `commands/slice.mjs` | The error message states each one | +| `slice plan` is refused, mentioning the walking skeleton | The skeleton slice has not passed review yet | `lib/slice-flow.mjs` | The line in `roadmap.md` that carries "最小骨架" (walking skeleton) | +| `roadmap` is refused, mentioning a missing slice that has already started | The draft does not carry the `id` of an existing slice | `lib/roadmap.mjs` | Compare with `roadmap.md` and give the existing entries their `id` | +| The filters check misses something or reports a false positive | The project file uses wildcards, macros, or a form the regular expressions do not cover | `lib/filters.mjs` | The count of "没有检查" (not checked) items in the output; reproduce with a minimal project file | +| init outputs "设置结果: 未执行" (setup result: not run), or on entry it says records would be ignored | The project's ignore rules ignore files under `.navigator/` or `.claude/` that must be committed | `commands/init.mjs`, `lib/paths.mjs` | `git check-ignore -v --no-index ` | +| The hook still produces output after uninstalling | `init.status` in the state file is not `uninstalled` | `commands/uninstall.mjs`, `runtime/hook.mjs` | Look at `init` in `state.json` | + +## Change process + +| What changed | What else to do | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sections in `spec/slice.json` | The section `id`s are referenced by `SECTIONS` in `lib/slice-file.mjs`, and `slice-file.test.mjs` checks that the two agree; check the section names referenced in the rule files | +| Refusals by workflow rules | Always throw `WorkflowError`, with an error message written in Chinese that can be acted on as it stands | +| The next action | Change only `lib/guidance.mjs`; the rule files do not repeat "when to run which command" | +| Delegation prompts | Change only `lib/briefs.mjs`; the executor guide covers how to work, and the prompt covers the facts of this slice and the handback format | +| `standards/engineering.md` | Each of the two module organization variants sits between a pair of `` and ``; `docs.test.mjs` checks that both can be generated | +| The structure of the state file | Bump `STATE_SCHEMA_VERSION`; decide whether to migrate or archive, and update how `init` and `enter` handle the old structure | +| Adding or renaming a plugin command | Register it in `commands/handlers.mjs`; update the command quick reference in `SKILL.md` (checked by `docs.test.mjs`), and the options and file header comment of `navigator.mjs` | +| The plugin command name `project-navigator` | `COMMAND_NAME` in `lib/paths.mjs`, the two files in `bin/`, the allow rules in `lib/settings.mjs`, the documentation | +| `SKILL.md` | The length check and routing table check in `npm test`; smoke test with `claude -p --plugin-dir` | +| Subagent names | `SUBAGENT_TYPES` in `lib/guard.mjs` and `SKILL.md`; `docs.test.mjs` checks that the definition files exist | +| The plugin name `waypoint` | `SUBAGENT_TYPES`, `COMMIT_SKILL`, the `/waypoint:` commands in the documentation | +| `shared/punctuation.md` | No extra step: `ref executor` reads it at run time | ## Tests -- `npm test` runs every test in `tests/project-navigator/`: - -| File | Coverage | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `spec.test.mjs` | Titles and keys are 4 Chinese characters, ids are unique, choices in a group have equal width, the order review contains the review point keys, table constants match the spec | -| `reply-checks.test.mjs` | The filled sample of every option group of every reply passes; a missing heading, conclusion line, or option block is reported; long conclusions, writing rules, and section structure are not sent back | -| `file-checks.test.mjs` | Every record file skeleton passes; the instructions cover every key and table section and name the self-check command; entries and closing sections, the conclusion line, code blocks | -| `writing-checks.test.mjs` | One hit and one miss for every writing rule | -| `code-checks.test.mjs` | Check command registration, the fixed criterion, checks at creation and before issue, the check commands to run, skeleton prefill | -| `evidence.test.mjs` | Reading and validating the evidence table, fetching lines at a version from a local temporary repository, the single-entry self-check | -| `guard.test.mjs` | An allow and a deny case for each of the four write rules, record-only orders, orders not running, judging the orchestrator only when needed | -| `workflow.test.mjs` | Order states (withdrawing blocked orders, issue digests, no committing state), roadmap, stages, records, the orchestrator marker, option recognition, verdict classification, delegation prompts (including the precheck), next actions, subagent models | -| `settings.test.mjs` | Merging and removing permission rules, recognizing and removing 0.x project-level hooks, the wiring of the plugin's `hooks.json` and `bin/` | -| `cli.test.mjs` | Entering and initialization (marker, no hooks written, no scripts copied), resume, ignore rule checks, upgrading from 0.x (cleanup, order withdrawal, old orchestrator registration), migrating old committing orders, uninstall, `ref`, reply skeletons, self-checks | -| `flow.test.mjs` | Projects that are not initialized, recognizing the orchestrator from the transcript, reminders, reply structure checks, the order round trip to review and the commit step, changing an approved order, accepting unverified criteria, review prefill, continuing a blocked order, next actions after rejection, check commands, the stage commit step | -| `self-report.test.mjs` | Judging internal errors, deduplicating and marking anomalies, stalls, the state self-check, recording on command and hook errors, refused next actions, report content (no advice, no local paths) and whether the workflow can continue | -| `docs.test.mjs` | Generated documents match the spec, the conclusion prefix in `SKILL.md`, references named in the routing table exist, `SKILL.md` length limit | -| `markdown.test.mjs` | Markdown parsing | - -- `npm run gen:docs -- --check` checks on its own whether the generated documents are in sync. CI runs both. +`npm test` runs all tests in `tests/project-navigator/`: + +| File | Coverage | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flow.test.mjs` | Walks the slice loop in a temporary repository with the command line and the hook: skeleton, accepting unverified, rework, interactive slice, milestone review, wrap-up, the guard's four rules, position reminder, filters | +| `cli.test.mjs` | Entry and initialization, archiving of old-version records, ignore-rule check, uninstall, `ref`, project settings and generation of the project standards, preconditions of the roadmap, decisions | +| `roadmap.test.mjs` | Merging of roadmap drafts, skeleton, one slice at a time, state transitions, milestone completion and wrap-up, next action during intake | +| `slice-file.test.mjs` | Agreement between the spec and the script's references, skeleton, parsing, plan digest, sections not filled in, criteria check table, archiving on rework | +| `filters.test.mjs` | Assignment to filters, flat layout of added files, wildcards and files outside the project | +| `settings.test.mjs` | Merging and removal of allow rules, wiring of the plugin's `hooks.json` and `bin/` | +| `docs.test.mjs` | The length of `SKILL.md`, and its routing table and command quick reference agreeing with the actual files and commands; the two variants of the engineering standards; subagent definitions | +| `markdown.test.mjs` | Markdown parsing | ## Measured findings -### Basis for 1.0.0 (Claude Code 2.1.321, Windows 10, 2026-11-10) +### Design basis of 1.0.0 (Claude Code 2.1.321, Windows 10, 2026-11-10) -Measured in `claude -p --plugin-dir` with a probe plugin containing only hooks, `bin/`, and one subagent, with spaces and Chinese characters in paths: +Measured in `claude -p --plugin-dir` with a probe plugin containing only a hook, `bin/`, and one subagent, on a path containing spaces and Chinese characters. 2.0.0 still depends on these findings: -| Question | Finding | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Plugin-level hooks | Both the exec form and the shell form in `hooks.json` fire, and `${CLAUDE_PLUGIN_ROOT}` expands correctly | -| Subagent identity | A plugin subagent's PreToolUse input has `agent_id` and `agent_type: ":"` | -| Transcript | PreToolUse input has `transcript_path`; at the first tool call the transcript already contains this turn's marker; scanning a 0.5 MB transcript takes 1 to 2 ms | -| Plugin `bin/` | The Bash tool runs an extensionless script directly (Git Bash); the PowerShell tool runs the `.cmd` of the same name | -| Background subagents | Headless mode waits for a background subagent and processes its result; in the desktop app its completion notification wakes the main session | -| Resuming subagents | SendMessage resumes a finished subagent with its earlier context | -| Subagent write permission | Without rules, writes are denied in headless mode; a bare `Edit` rule does not allow Write, `Edit(**)` allows foreground and background subagent writes | -| Desktop app, default permission mode | A background subagent's write prompt appears in the main session ("Allow Claude to write x.txt?", not naming the subagent); "Always allow" switches the session to Accept edits | +| Point to verify | Finding | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plugin-level hook | Both the exec form and the shell form in `hooks.json` fire, and `${CLAUDE_PLUGIN_ROOT}` expands correctly | +| Subagent identity | The PreToolUse input of a plugin subagent carries `agent_id` and `agent_type: ":"` | +| Transcript | The PreToolUse input carries `transcript_path`; at the first tool call the transcript already contains the marker from this turn; scanning a transcript of about 0.5 MB takes 1 to 2 milliseconds | +| Plugin `bin/` | The Bash tool can run a script with no extension directly (Git Bash), and the PowerShell tool runs the `.cmd` of the same name | +| Background subagents | Headless mode waits for background subagents to end and handles the results; in the desktop app, the completion notification of a background subagent wakes the main session automatically | +| Subagent resumption | SendMessage lets an ended subagent continue with its original context | +| Subagent write permission | In headless mode, writes are refused when there is no allow rule; a bare `Edit` rule does not allow Write, and `Edit(**)` allows writes by foreground and background subagents | +| Default permission mode in the desktop app | Write confirmations of a background subagent pop up in the main session ("Allow Claude to write x.txt?", without saying it is a subagent); "Always allow" switches the session to Accept edits | -Official documentation (2026-11-10): files in a plugin's `bin/` are on the Bash tool's PATH, after the user's PATH; plugin subagents do not support `hooks`, `mcpServers`, or `permissionMode`; a skill's `allowed-tools` applies only to the turn that invokes it. +Official documentation (2026-11-10): the plugin's `bin/` is on the PATH of the Bash tool, after the user's PATH; plugin subagents do not support `hooks`, `mcpServers`, or `permissionMode`; a skill's `allowed-tools` is in effect only for the turn that invokes the skill. ### Earlier findings (Claude Code 2.1.280 to 2.1.284) -| Question | Finding | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `!` commands in a skill | Without `allowed-tools`, the whole invocation aborts in the default mode; `${CLAUDE_SESSION_ID}` matches the hook's `session_id`; they do not trigger PreToolUse | -| The skill `shell` field | In a child session without Git Bash, a skill with `shell: bash` fails | -| Stop send-back | A top-level `{"decision": "block"}` works, and the second Stop has `stop_hook_active` set to true | -| Denials in bypass mode | PreToolUse `permissionDecision: "deny"` still applies | -| Context injection | The model reads `additionalContext` returned by UserPromptSubmit; slash commands also trigger the event | -| Permission rules | Rules in the project `settings.json` need workspace trust and never apply in headless mode; `settings.local.json` needs none; an `Edit(...)` rule also covers Write | -| Session forks | `--fork-session`, rewinding, and resending after an interrupt in the desktop app all change the session id, and the forked transcript copies the original history | -| Parallel hooks | All hooks of one event run in parallel, and Claude Code waits for all of them | +| Point to verify | Finding | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `!` commands in a skill | Without `allowed-tools`, the whole invocation aborts in default mode; PreToolUse does not fire | +| The skill's `shell` field | When Git Bash cannot be found in a child session, a skill with `shell: bash` fails outright | +| Denial in bypass mode | `permissionDecision: "deny"` from PreToolUse still takes effect | +| Context injection | The model can read the `additionalContext` returned by UserPromptSubmit; slash commands also fire this event | +| Allow rules | Allow rules in the project's `settings.json` need workspace trust and do not take effect in headless mode; `settings.local.json` does not need it; an `Edit(...)` rule also covers Write | +| Session forking | `--fork-session`, and rewinding or resending after an interrupt in the desktop app, all change the session id, and the forked transcript copies the original session's history | +| Parallel hooks | All hooks for the same event run in parallel, and Claude Code waits for all of them to end before continuing | + +### Smoke test for 2.0.0 (Claude Code 2.1.319, Windows 10, 2026-11-12) + +A "command-line to-do tool" was run through in a temporary repository with `claude -p --plugin-dir` and `--continue`: initialization, intake, the roadmap, and the first slice (the walking skeleton) from plan through implementation, submission, and review, up to asking the user to decide. -Fetching source evidence (git 2.55, 2026-09-23): fetching a few lines of one file from GitHub by tag or full commit, depth 1 with on-demand blobs, takes about 2 seconds; a wrong tag or path fails at once; a missing repository fails within about 2 seconds without a credential prompt; a local remote falls back to a full fetch with the same result. +| Point to verify | Finding | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invocation with the `init` argument | Initializes and commits directly; the first wording still made the model ask, so the next action of `enter` and `SKILL.md` now say so explicitly | +| Intake | Wrote the requirements file, registered the project settings, laid out a roadmap of the skeleton plus two vertical slices, and committed as the step said | +| Plan | The "模块安排" (module layout) listed files, modules, layers, and single definitions by physical directory; with nothing to decide, the slice started | +| Executor subagent | Wrote only business files and the Result part; the wiring evidence gave the step-by-step call path from the entry point to the data layer; two corrections | +| Plan digest | The formatter hook re-aligned the table in the Plan part, the digest did not change, and `slice review` passed | +| Review | After the reviewer subagent handed back, the orchestrator session wrote the Review part and asked the user to choose between two options | +| Replies | Without validation, replies kept the conclusion on the first line and the progress on the second | +| Interrupted session (headless timeout) | The next `--continue` turn resumed from the state and went on with the review | -Without limits, one framing research ran more than 80 web calls and took about 13 minutes; the frame now caps them at 20 in total. +A caveat for smoke testing: a child process started from a session that already has this plugin installed inherits the installed version's `bin/` on its PATH, so the old commands run. Remove that directory from PATH first. -### Subagent models (official documentation, 2026-11-12, not tested) +Not verified in a real session: -Taken from the sub-agents, plugins/components, and model-config pages of the Claude Code documentation, as excerpted by a lookup subagent: +- Resuming with SendMessage after a handback, and a rework round. +- An interactive slice, and how much of the orchestrator session's context it uses. +- A Visual Studio project that expresses modules as filters. +- Projects verified as `manual` or `measured`. +- Behavior in the desktop app. -| Point | What the documentation says | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| Order of precedence | The `model` parameter at dispatch, the definition's `model`, the `CLAUDE_CODE_SUBAGENT_MODEL` variable, the session model | -| Dispatch values | Only the aliases `sonnet`, `opus`, `haiku`, `fable`; no full model id and no `inherit` | -| Resume | The dispatch parameter is kept on a SendMessage resume; whether a definition's `model` is kept is not stated | -| Forced override | With `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1` set by the user, every subagent uses the session model | -| Reasoning effort | The session's effort does not carry to a subagent on another model; definitions have an `effort` field. Not set here | +Problems met in real projects after the release are to be added to the troubleshooting table. -## Manual tests before a release +## Pre-release manual tests -In a temporary repository with `claude --plugin-dir ./plugins/waypoint`, confirm each item: +In a temporary repository, confirm each item with `claude --plugin-dir ./plugins/waypoint`: ### Initialization and sessions -1. Before initialization the reply is "初始设置" (initial setup); after A, `init` writes the state directory and permission rules and the output is committed; B changes nothing. In a repository that is not initialized, writing files is not restricted at all. -2. Before and after accepting the workspace trust dialog, `project-navigator` commands ask for no permission, and they run in both Bash and PowerShell. -3. A new session invoking the skill replies with "恢复进度" (resume progress) and continues from the records; the old session still works. -4. After rewinding a message of the orchestrator in the desktop app and sending another, the orchestrator keeps working and business file writes are still denied. -5. After context compaction, the orchestrator receives a position reminder. -6. After uninstalling, the permission rules are gone, business file writes are no longer denied, and records are kept. -7. Upgrading a project initialized by 0.11.0 with an issued order: old hooks, old permission rules, and `.navigator/bin/` are removed, the order is moved back to drafting, and records are kept; after invoking the skill in the old orchestrator session, the upgrade command runs there. - -### Framing and selection - -1. Framing a new project: requirement understanding, problem-domain research, research report; with B in the research report the skill skips brainstorming and drafts the brief; with A it brainstorms, diverging then converging. -2. Surveying an existing project, including a run-check order. -3. Standards comparison: the orchestrator gets the bundled standards with `ref standards` and passes them to the reader subagent; at most 3 conflicts per round, choosing the comment language, writing `.claude/rules/engineering.md`. -4. The evidence table of a selection receipt: the executor self-checks with `evidence check`; the reviewer runs `evidence`; a wrong version or path is judged unverified. -5. After the language selection, register the code check commands; before that, creating an implementation order is refused, and afterwards the order skeleton prefills the check criterion. -6. A run-check order rejected before check commands are registered: the next action is a new run-check order in the same slice. -7. An analysis order: the executor can write documents and scripts under `artifacts/`, and its business file writes are denied with a reason pointing to an implementation order. - -### Work order round trip - -1. After writing an order the orchestrator first sends the checker subagent (the Agent call carries `model: "sonnet"`), and the checker is refused when it tries to write a file; put a nonexistent file into a criterion on purpose, the findings point it out, and the orchestrator fixes the order and runs `order precheck`; `reply approve` without the precheck is refused. -2. Write one wrong detail into the order's "工作假设" on purpose (for example a function name that does not exist): the executor is not blocked, finishes according to the facts, and the receipt's "事实修正" has one row; "修正核对" in the review report describes the correction. Then make a correction cross "明确不做" (out of scope): the review fails the related criterion. -3. The executor meets one criterion it cannot satisfy: it finishes the rest, the receipt lists the criterion as pending, and the review report uses option set 3 and describes that criterion. -4. On an analysis order the executor needs a user action: no receipt, the handback is "执行状态: 等待操作"; the orchestrator replies "等待操作" (no option block), the same subagent continues after the user replies "操作完成", and `status` shows the order as issued throughout. -5. After `model set executor sonnet --kind fix`, the next action of a fix order says `model 参数写 "sonnet"` while an implementation order still says not to pass it; after a resume (SendMessage) the subagent still uses its dispatch model. -6. "工单审阅" (order review) shows only the review points and the order file path, matching the order file; after B the order is changed and reviewed again; after A it is issued, an executor is dispatched in the background, and the reply is "工单执行" (order running); changing the order file after approval makes the issue fail. -7. While the executor works in the background, the user can keep talking to the orchestrator; when it finishes, the orchestrator runs `status` on its own and starts the review. -8. In the default permission mode, the executor's write prompts appear in the orchestrator session; after switching to Accept edits they stop. -9. The executor is blocked (for example it needs an account): the reply is "受阻处理" (blocked handling); after the user acts, no new review is needed and the original subagent continues through SendMessage. -10. "工单执行" has no option block; replying "停止执行" (stop) during execution: the subagent stops, the order goes back to drafting, and it is reviewed again after changes. -11. The reviewer returns verdicts and evidence and is denied when it tries to write files; it does not run tests the receipt lists as external effects. -12. After a passing review, A commits and B commits and pushes; with unverified criteria the third group is used, and A accepts the order and records risks; with failures a follow-up order is issued, and for an implementation order the orchestrator first judges whether it was too large. -13. The executor's writes to records other than its order's receipt and `artifacts/` are denied; the orchestrator's business file writes are denied. - -### Changes, milestone reviews, and anomalies - -1. A requirement change, a milestone review (with an automatic checkup), a manual checkup. After A in "项目收尾" (project closing), `finish` runs and its output is committed, then the reply is "项目完成" (project complete) and `git status` is clean. -2. Choosing B in "项目完成" pushes successfully. -3. Asking for a problem report gets a "自由答复" (free answer) reply, and a body with code blocks is not sent back. -4. After rolling the records back with `git reset`, the orchestrator continues from the current records, and replies with "运行受阻" (blocked run) when they do not match reality. -5. A reply without the conclusion line is sent back once and passes the second time. +1. When not initialized, the skill asks whether to initialize; after agreement, init writes the state directory and the allow rules, and commits as the output instructs; on disagreement nothing is changed. In a repository that has not been initialized, writing files is not restricted in any way. +2. The `project-navigator` command runs in both Bash and PowerShell without a permission prompt. +3. A new session invokes the skill and continues from the records; the original session remains usable as before. +4. After context compaction, the orchestrator session receives the position reminder. +5. After uninstalling, the allow rules are removed, writing business files is no longer refused, and the records are kept. +6. Invoke the skill in a project initialized under 1.5.0: it explains that old records are not read; after agreement, `init --archive` runs, and the old directory is renamed to `.navigator-1.x/` with its contents unchanged. + +### Intake + +1. The skill restates the requirement, confirms the open questions one by one, and writes the requirements file. +2. The suggestion from `setup`: `filters` for a repository with `.vcxproj`, `directories` for the rest; after registration, `.claude/rules/engineering.md` contains only the matching variant. +3. The first slice of the roadmap is the walking skeleton; writing the first slice as `findings` is refused. + +### Slice loop + +1. The orchestrator session read the relevant code before writing the plan; "模块安排" (module layout) in the plan is written according to the registered organization (directories or filters). +2. When the plan has nothing pending, work starts right away; when it does, the user is asked first and work starts with `--decided`. +3. While the executor subagent works in the background, you can keep talking to the orchestrator session; after it ends, the orchestrator session carries on automatically. +4. The executor subagent needs an action from the user: it hands back "交回: 需要操作" (handback: needs an action), the orchestrator session relays it unchanged, the same subagent continues after the user replies, and the slice status stays "实现中" (implementing) throughout. +5. Deliberately write one wrong detail in "工作假设" (working assumptions) in the plan: the executor subagent does not hand back, finishes the work to what is actually there, and the "修正" (corrections) table has one row; "修正核对" (corrections check) in the review describes this correction. +6. The executor subagent tries to change the Plan part: `slice review` refuses to submit for review. +7. Deliberately leave the new code unconnected to the entry point: the wiring check in the review points it out, and the related criteria fail; they pass after rework. +8. A project whose verification mode is `manual`: the reviewer subagent does not build or run, criteria that can only be confirmed by running are judged unverified with the steps written out; the user accepts as is after confirming them. +9. In a Visual Studio project, add a source file that is not assigned to a filter: the filters check in the review prompt points it out. +10. Interactive slice: no executor subagent is dispatched, the orchestrator session does the work directly, and the findings are written to `.navigator/findings/`; a new session that invokes the skill can continue; the orchestrator session is still refused when writing business files. +11. The reviewer subagent is refused when it tries to write a file. +12. After the review passes, the work is committed; when the user chooses to push, it is pushed as well. +13. An idea outside the current slice comes up in discussion: it is recorded in `backlog.md` and is not done on the side. + +### Milestones, changes, and wrap-up + +1. After all slices of a milestone have ended, the milestone review starts and the slices of the next milestone are laid out. +2. Requirement change: the requirements file and the roadmap are updated and a decision is recorded; slices that have already started are kept. +3. After all milestones are complete, wrap up and commit as the output instructs; `git status` is clean; adding a milestone afterwards lets the work continue. ## Known limitations -- Writing rules rely on prompts and checkups only; replies are not sent back for them, and the script checks are approximate. -- A reply that is sent back stays on screen. -- The guard checks only the target paths of write tools: writing files from the command line or a script, installing dependencies, and running state-changing plugin commands are not checked and rely on the skill's rules. Changes made this way to the state file show up in git. -- The plugin's hooks fire in every project, starting Node.js once more for each write tool call and each message, about 70 ms on a local measurement; in projects that are not initialized the script exits before reading any state. -- Recognizing the orchestrator depends on the marker in the transcript, whose format is internal to Claude Code; when the transcript cannot be read, rule 4 does not apply (the write is allowed). -- The executor subagent belongs to the session that started it; closing that session interrupts its work, and a new session continues according to whether the receipt was written. -- A background subagent's write prompt does not name the subagent; users can infer it only from the "工单执行" (order running) reply and the task list. -- Fact corrections are kept in check by the reviewer and the user: no script can tell when corrections add up to a changed goal; that shows only in the "修正核对" item of the review report. The line between hard constraints and working assumptions is written in the rules; when the executor misjudges it, it either blocks once more than needed or oversteps and fails the review. -- The precheck records only that it ran, not whether the orchestrator changed the order accordingly; the checker examines only the facts written in the order and cannot find unstated assumptions. -- The subagent model is only a parameter written in the "next action"; no hook checks that the orchestrator passes it. -- While waiting for a user action the order status does not change, and the original executor is lost with the session; a newly dispatched one can continue only from the documents under `artifacts/`. -- Source evidence supports only public git repositories over https; `evidence` checks only the cited lines, and whether they support the "说明" (note) is still up to the reviewer. +- The guard only checks the target paths of write tools: writing files from the command line or a scripting language, installing dependencies, and running plugin commands that change state are all unchecked and rely only on the skill's rules. +- Recognizing the orchestrator session depends on the marker in the transcript, and the transcript format is internal to Claude Code; when the transcript cannot be read, rule 4 does not take effect (the write is allowed). +- Ownership of the parts can only be detected after the fact: when the executor subagent changes the Plan part, it is not pointed out until `slice review`. +- The wiring check and the structure check (except the filters check) are judged by the reviewer subagent and are not deterministic. +- No script checks whether the code was read before the plan was written. +- The reply format relies only on rules. +- An executor subagent belongs to the session that started it, and its work is interrupted when the session closes; a new subagent can only continue from "中途记录" (interim notes) and the changes already in the working tree. +- Write confirmations of a background subagent do not say that it is a subagent. +- The filters check covers only the forms the regular expressions can recognize. +- The plugin's hook fires in every project, and every write-tool call and every message starts Node.js one more time. diff --git a/plugins/waypoint/docs/project-navigator-maintaining.md b/plugins/waypoint/docs/project-navigator-maintaining.md index 81b37ad..2e2d388 100644 --- a/plugins/waypoint/docs/project-navigator-maintaining.md +++ b/plugins/waypoint/docs/project-navigator-maintaining.md @@ -8,99 +8,106 @@ ## 设计总览 -技能由三层组成, 每层只做一件事: +技能只负责五件事, 加任何功能之前先确认它属于其中之一: -| 层 | 位置 | 职责 | -| -------- | ------------------------------------------------- | -------------------------------------------- | -| 规则 | `SKILL.md`, `references/`, `guide/executor.md` | 写给模型: 流程, 步骤, 何时回复哪种类型 | -| 运行脚本 | `runtime/navigator.mjs`, `runtime/hook.mjs` | 命令修改状态, hook 守几条写入边界与回复结构 | -| 规格 | `spec/templates.json`, `standards/engineering.md` | 回复, 记录文件, 写作规则与工程规范的唯一来源 | +1. 立项与路线: 从一句需求得到需求文件与按里程, 切片排列的路线. +2. 进度: 状态由脚本记录, 下一动作由脚本计算, 任何会话都能接上. +3. 切片循环: 编排会话写计划, 子代理实现, 另一个子代理独立验收. +4. 模块化约束: 单一数据源, 按职责分层, 模块化, 不提前抽通用模块; 模块的表达方式随项目形态变化. +5. 骨架与接线: 第一个切片是最小骨架, 每个切片都要给出接线证据. + +技能由三层组成: + +| 层 | 位置 | 职责 | +| -------- | ---------------------------------------------- | ------------------------------------------- | +| 规则 | `SKILL.md`, `references/`, `guide/executor.md` | 写给模型: 流程, 步骤, 怎么写计划与结果 | +| 运行脚本 | `runtime/navigator.mjs`, `runtime/hook.mjs` | 命令修改状态并计算下一动作, hook 守写入边界 | +| 规格 | `spec/slice.json`, `standards/engineering.md` | 切片文件的小节, 与工程规范默认包的唯一来源 | 插件层面的接线: -| 位置 | 内容 | -| --------------------------- | ------------------------------------------------------------------------------------------------------ | -| `hooks/hooks.json` | 四个事件 (PreToolUse 只匹配写入类工具, UserPromptSubmit, SessionStart, Stop) 都调用 `runtime/hook.mjs` | -| `bin/project-navigator` | Bash 工具中的命令入口, `exec node .../navigator.mjs "$@"` | -| `bin/project-navigator.cmd` | PowerShell 工具中的命令入口 | -| `agents/navigator-*.md` | 核对, 执行, 验收, 调研与阅读子代理 | +| 位置 | 内容 | +| --------------------------- | ------------------------------------------------------------------------------------------------ | +| `hooks/hooks.json` | 三个事件 (PreToolUse 只匹配写入类工具, UserPromptSubmit, SessionStart) 都调用 `runtime/hook.mjs` | +| `bin/project-navigator` | Bash 工具中的命令入口, `exec node .../navigator.mjs "$@"` | +| `bin/project-navigator.cmd` | PowerShell 工具中的命令入口 | +| `agents/navigator-*.md` | 实现与验收子代理 | 在用户项目中, 技能会用到以下位置: -| 位置 | 内容 | 是否入库 | -| ------------------------------ | ---------------------------------------------------------------- | -------- | -| `.navigator/` | 状态文件, 计划记录, 工单文件夹 | 入库 | -| `.navigator/drafts/` | 编排会话传给命令的 JSON 草稿 | 不入库 | -| `.claude/settings.json` | 放行规则 | 入库 | -| `.claude/settings.local.json` | 放行规则的本机副本, 由 `.git/info/exclude` 排除 | 不入库 | -| `.claude/rules/engineering.md` | 项目规范, 项目内所有会话自动加载 | 入库 | -| `.git/navigator/` | 工单审阅记录 (`order-approval.json`), 源码证据缓存 (`evidence/`) | 不入库 | +| 位置 | 内容 | 是否入库 | +| ------------------------------ | ------------------------------------------------ | -------- | +| `.navigator/` | 状态文件, 路线视图, 需求, 决策, 待办, 切片, 结论 | 入库 | +| `.navigator/drafts/` | 编排会话传给命令的 JSON 草稿 | 不入库 | +| `.claude/settings.json` | 放行规则 | 入库 | +| `.claude/settings.local.json` | 放行规则的本机副本, 由 `.git/info/exclude` 排除 | 不入库 | +| `.claude/rules/engineering.md` | 项目规范, 由 `setup` 写入, 所有会话自动加载 | 入库 | + +2.0.0 不在 `.git/` 下保存任何东西. ## 设计决策 每条写明决定, 原因, 以及改掉会怎样. +### 2.0.0 的取舍 + +1.x 的迭代几乎全部花在流程管控上 (工单状态机, 回复校验, 审阅绑定, 发布前核对), 而立项质量, 切片怎么切, 接线怎么查从首版之后没有再打磨. 2.0.0 把这个比例倒过来. + +- **保留的:** 编排与实现分开; 独立验收; 工程规范写入项目; 状态与下一动作由脚本管; 记录入库并检查忽略规则; 提交走 commit-message; hook 放在插件级; 运行脚本只用 Node.js 内置模块; 细节与实际不符时按实际修正; 个别标准做不到时列为待定. +- **去掉的:** 工单, 回执, 验收报告三份文件与工单状态机; 约 20 种回复类型与回复结束时的校验; 工单审阅的摘要绑定与基准提交核对; 发布前核对子代理; 问题域调研子代理与头脑风暴存档; 术语表, 风险清单, 变更清单; 体检编号与语义体检; 规范对照流程; 选型的 `evidence` 命令与源码缓存; 子代理模型配置; 技能异常自检与问题报告; 写作规则校验; 按钮; 阶段编号 0 到 6; 生成文档这一步. +- **不迁移 1.x 的记录.** 两种记录的单位不同 (工单与切片), 迁移只能做成近似的, 而用 1.x 的项目很少. `init --archive` 把旧目录整个改名, 不删除. 改掉的后果: 要长期维护一份读旧结构的代码. + ### 会话与执行 -- **编排会话与执行分开, 执行用后台子代理.** 编排会话用 Agent 工具在后台 (`run_in_background`) 派 `waypoint:navigator-executor`, 提示词是 `exec-brief` 的输出; 子代理结束时编排会话收到通知, 自动接着处理. 0.x 让用户另开执行会话: 一张工单要切换会话, 粘贴启动提示词, 确认开工对齐, 选汇报方式, 回编排会话确认回执, 用户约做 7 次选择. 子代理的代价是工作期间不能向用户提问, 所以开工对齐取消, 需要用户决定的事以受阻结束; 模块划分写进回执, 由验收子代理核对. 改掉的后果: 回到多会话往返, 或让编排会话自己写代码, 规划与实现重新混在一起. -- **不保留 "手动开执行会话" 的备选.** 只要保留, 启动提示词登记, 开工对齐, 分叉认领, 编排地址这一整套机制就删不掉, 而这套机制是 0.x 排查手册中大部分条目的来源. 超大的任务拆成多张工单. 改掉的后果: 两套执行方式并存, 守卫与提醒都要分两路. -- **编排会话不做登记, 按转录中的编排标记认出.** `enter` 的输出带 "编排标记: project-navigator 编排标记 <编号>", 编号是项目路径 SHA-256 的前 12 位 (Windows 上先转小写); 输出注入技能内容, 技能内容记在会话转录中, hook 读 `transcript_path` 找这个标记. 源代码里只有前缀, 读源代码的会话不会被误认; status 不输出标记, 子代理运行 status 不影响主会话. 分叉出的会话复制了转录, 自然带着标记, 回退与编辑消息之后不需要任何处理. 0.x 把身份登记在 `.git/navigator/orchestrator.json`, 为此有接管, 被接管, 分叉认领, 重新登记一系列规则. 改掉的后果: 回到登记与认领, 每次换编号都可能误判身份. -- **受阻后工单没有改动时直接继续, 由原执行子代理接着做.** `order set issued` 记下发布时工单文件的摘要 (`issuedDigest`); 受阻工单的文件与摘要一致时不要求重新审阅, 输出要求用 SendMessage 让原子代理继续 (实测 SendMessage 能带着上下文续接已结束的子代理), 找不到时重新派出. 工单改过就要重新审阅. 改掉的后果: 用户完成一个小操作也要重新审阅一次, 或子代理每次从头再来. -- **执行子代理不写 `tools`, 继承会话的全部工具.** 它要写代码, 运行构建与测试, 用户装的 MCP 工具也可能用到. 插件子代理不支持 `hooks`, `mcpServers`, `permissionMode` 字段 (官方文档), 写入范围由插件级 hook 的守卫限制. 改掉的后果: 列出 `tools` 时漏掉用户的工具. -- **子代理的模型在派出时指定, 分配记在状态文件中.** 默认: 执行跟随编排会话, 核对, 验收, 阅读与调研用 sonnet (`lib/subagent-models.mjs`); `model set` 可以改, 执行子代理可以按工单类型单独指定. "下一动作" 与 `status` 的 "子代理模型" 一行写出 Agent 工具 `model` 参数的写法. 不写进子代理定义的 `model` 字段有三个原因: 官方文档写明派出参数优先于定义, 用 SendMessage 续接时保留, 而定义中的 `model` 续接时是否保留没有说明; 写死会影响所有项目; 用户换模型不用改插件文件. 执行默认不降: 在一个实际项目中, 实现工单的 5 轮受阻都是执行子代理拿出证据指出工单有错, 较弱的模型更可能硬凑结果. 改掉的后果: 续接时模型可能变回会话模型, 或所有项目的执行都被迫用同一个模型. -- **需要用户亲手操作时, 执行子代理中途交回, 不写回执.** 交回第一行写 "执行状态: 等待操作", 编排会话回复 "等待操作" (没有选项块), 用户回复后用 SendMessage 转给原子代理; 工单状态一直是 issued. 同一项目的逆向分析工单受阻 8 轮, 每轮都只是请用户在被分析的程序里操作一次, 却要重写回执, 转为受阻, 回复带选项的 "受阻处理", 再重新发布. 要用户做决定或改工单时仍走受阻. 会话中断后原子代理找不回, 所以手册要求交回前把已查清的内容写进 `artifacts/`. 改掉的后果: 交互式的分析任务每一步都是一轮完整的受阻往返. -- **委派提示词由脚本生成, 编排会话原样使用, 守卫不核对.** `precheck-brief`, `exec-brief`, `review-brief`, `research-brief` 是提示词的唯一来源. 0.x 守卫逐字核对验收提示词, 偶尔因空白差异拒绝派出. 改掉的后果: 提示词散落在规则文件里, 版本之间不一致. - -### 守卫与回复校验 - -- **守卫只看写入类工具的目标路径, 四条规则.** 状态文件与生成的视图谁都不能直接写; 核对与验收子代理不写文件; 执行子代理在 `.navigator/` 下只写当前工单的回执与 `artifacts/`, 只产出记录的工单不写业务文件, 工单不在执行中时不写业务文件; 编排会话只写 `.navigator/`. 子代理按 hook 输入的 `agent_type` 区分 (`waypoint:navigator-executor` 等, 实测插件子代理的值为 "<插件>:<名称>"). 0.x 还解析 Bash 命令文字, 按白名单放行编排会话的工具, 核对提示词, 记录文件结构, 用户测试输出与 `.claude/settings.json` 的改动; 这些规则多数为跨会话场景与历次事故补上, 也是拒绝与误判的主要来源. 改掉的后果: 加回去的每条规则都会在某个正常操作上误伤. -- **保留 "编排会话不写业务文件".** 目的不是防恶意, 是防角色坍塌: 用户一句 "顺手改一下", 模型就会自己写代码, 编排会话的上下文从此混乱. 只在主会话写项目内 `.navigator/` 之外的文件时才读转录判断身份. 改掉的后果: 编排会话逐渐变成执行会话. -- **Stop 只校验回复结构: 标题是回复类型, 标题下第一行是结论, 恰好一个与规格一致的选项块, 代码块都关闭, 没有占位标记.** 标题用来识别回复类型, 选项块用来识别用户的选择 (工单审阅的认可依赖它), 这两项不能放松. 规格中 `optionSets` 为空的回复 ("工单执行", "工单验收", 等待核对, 调研, 阅读子代理的 "等待结果", 与等用户操作的 "等待操作") 不需要用户选择, 不带选项块, 带了会被打回: 1.0.0 与 1.1.0 的 "工单执行" 有 "等待完成" 选项, 用户以为要先选 A 才开始等待. 节, 键名, 进展值与写作规则只在填写要求中提醒. 0.x 按写作规则与总结字数打回, 打回后屏幕上留着两遍回复. 打回最多一次 (`stop_hook_active` 时放行). 有后台任务时照常校验: 1.2.1 及之前不校验, 验收子代理在后台时模型写了一段没有标题的说明, 与前后的回复混在一起; 现在等待子代理的两种情况都有回复类型. 进展行的工单编号之后写环节 (`format.orderStages`, "验收报告" 用 `orderStage` 写 "待你决定"), 连续几条回复靠它区分. 建议只写在 "验收报告" 的 "处理建议" 一节, 填写要求写明各节之外不加文字. 改掉的后果: 写作规则重新变成频繁打回. -- **两个选项的组同时做成按钮, 选项块照常保留.** Claude 桌面应用有一个填写 "下一步框" 的工具 (`mcp__ccd_turn__wrap_up`, 见 `lib/reply-guide.mjs` 的 `BUTTON_TOOL`), 最多 2 个按钮, 点按钮等于把按钮文字作为回复发出. `reply` 的填写要求给出 "按钮" 一条, 按钮文字是选项的第一个分句 (`choiceLead`), 与打字回复走同一条识别 (`isChoiceSelected`). 三项以上的组, 以及规格中写了 `"buttons": false` 的组 (选择后还要打字补充, 或选了就会提交: 应用规定提交不能用回复按钮) 不做按钮. 选项块不能因为有按钮而去掉: 框可以被关掉, 不留在对话记录中, Stop 的校验与工单审阅的认可都读选项块. 这个工具是应用内部的, 没有公开约定, hook 也不核对是否调用; 工具改名或消失时退回只有选项块, 不会出错. 改掉的后果: 去掉选项块, 其它环境与关掉框的用户看不到选项. -- **回复以一行结论开头, 删掉末尾的人类总结块.** 读者注意力有限, 结论放在最前面才看得到; 末尾的总结与结论重复. 结论字数只作提醒 (`conclusionMaxLength`). 进展从四行压成一行. 改掉的后果: 关键信息落到回复末尾. -- **记录文件写入时不校验结构, 写完用 `check <文件>` 自查.** 读记录的脚本 (审阅要点预填, 回执的执行状态, 验收记录的判据结论) 按键值行与表格读取, 读不到时命令给出明确原因. `check` 带文件时不分配体检编号, 不写状态, 执行子代理也能用. 改掉的后果: 写入被拦下的次数回到 0.x 的水平. - -### 工单与验收 - -- **一张工单只要用户做两次决定: 工单审阅中发布, 验收报告中通过并提交.** 审阅卡片只列 "审阅要点" (任务目标, 需要决定, 明确不做, 粒度依据) 与工单文件路径, 由 `reply approve` 从工单文件预填. 认可仍按工单文件的摘要绑定 (hook 在回复工单审阅时记下摘要, 用户回答时记下选择), 展示的内容变短不影响绑定. 0.x 原样摘录工单五节, 用户三秒内读不完, 而且多数细节用户判断不了. 改掉的后果: 审阅变成走过场, 或用户每张工单都要读完整工单. -- **工单在审阅之前由核对子代理核对一次.** 编排会话凭已有的记录写工单, 其中的具体事实 (文件路径, 数据中的编号, 外部资源的发布形式与许可) 不成立时, 执行子代理做到一半才发现. 在同一个项目中, 实现工单的 5 轮受阻都属于这种情况, 而写这几张工单之前编排会话几乎没有读项目文件. `precheck-brief` 生成提示词, `waypoint:navigator-checker` 只读核对并交回不成立与无法核对的条目, 编排会话改好工单后运行 `order precheck`; 起草中的工单没有这个标记时, `reply approve` 与 `order set issued` 拒绝. 标记只记做过一次, 不绑定工单内容: 按核对结果修改, 或受阻后修改, 都不用再核对, 否则每改一次就多一轮子代理. 核对结果不另存文件, 落在改好的工单里. 改掉的后果: 工单中的错误回到执行阶段才暴露, 每个错误一轮往返. -- **工单分硬约束与工作假设两层, 执行子代理可以按实际修正后一层.** 硬约束是 "审阅要点" 的任务目标, 需要决定, 明确不做, 以及 "工作范围"; 其余细节 (工单的 "工作假设" 一节, 以及守则与判据中的数据格式, 路径, 具体实例, 实现途径) 都是工作假设. 假设与实际不符时, 执行子代理取得证据后以实际为准, 在回执 "事实修正" 表中逐行记录, 不算受阻; 验收提示词的 "修正核对" 逐行核对证据与是否越界, 成立的修正按修正后的事实判判据, 不成立的判不通过; 验收报告列出全部修正. 1.4.0 及之前把工单当合同, 细节不符就是规格问题: 逆向与对接外部系统的任务, 细节做了才知道, 同一张工单反复往返. 发布前核对只能拦住查得到的错. 不单设工单类型, 所有工单适用: 普通项目同样会遇到细节不符, 多一种类型多一处判断. 与待定判据的界线: 换一个符合事实的细节就能满足的是修正, 按事实根本做不出来的才是待定. 改掉的后果: 探索性的任务每发现一个细节就多一轮审阅与发布, 或执行子代理为了符合工单做出与事实不符的东西. -- **个别判据做不到时, 执行子代理做完其余部分, 把这几条列为待定.** 回执 "偏差记录" 有 "待定判据" 一项, 执行状态仍写完成; 验收子代理核对原因属实后把这几条判为未验证, 证据以 "待定:" 开头, 走已有的 "验收报告" 第 3 组, 由用户接受现状或发补正工单. 没有新增状态与选项组. 同一项目中有 3 轮受阻的回执写明 "其余工作已全部完成", 只卡在一条判据上. 用户的决定会改变其余部分的做法时仍然受阻. 改掉的后果: 一条判据的措辞问题让整张工单多走一轮审阅与发布. -- **测试授权去掉.** 执行子代理不运行有外部影响的测试, 写进回执 "测试记录" 的 "外部影响"; 验收子代理运行代码检查与回执中其它测试命令, 不重跑外部影响的命令, 按回执输出判断. MCP 取证工具不再登记, 验收子代理按回执 "取证记录" 做只读查询, 约束写在提示词与子代理定义中. 改掉的后果: 每张带外部测试的工单多一次用户选择. -- **未验证的判据可以由用户明确接受.** "验收报告" 第 3 组 (只有未验证, 没有不通过) 由用户选择接受现状并提交, 对应 `order set accepted --accept-unverified`, 未验证的判据登记为风险. 0.x 一律判不通过, 依赖外部取证的判据反复被拒. 不通过的判据仍然不能接受. 改掉的后果: 外部条件不足时工单无法收敛. -- **提交不是工单状态.** 工单状态为 drafting, issued, blocked, reviewing, accepted, rejected, voided; `order set accepted` 之后编排会话按输出调用 commit-message, 状态文件随这次提交入库. 0.x 有 committing 与提交结算记录, 用来对上 `lastCommit`; 去掉对账之后不再需要. 读取旧状态时, accepted 与 committing 的工单视为已通过并结束. 改掉的后果: 每次提交后状态文件留在工作区. -- **工单粒度按四条上限判断, 不按判据条数.** 执行一次能做完, 验收能看清, 做完能运行, 高风险部分单独成工单; 在此之内工单越大越好, 默认一个切片一张, "粒度依据" 写出判断. 0.x 规定判据不超过 5 条, 结果判据越写越密; 连续不通过只会拆小. 现在不通过时先判断原因. 改掉的后果: 工单越拆越细, 用户确认次数随之增加. -- **会改动代码的工单在新建时就核对检查命令已登记 (`creationBlocker`), 发布前仍再核对一次.** 运行检查与分析工单只产出记录, 不需要检查命令, 被拒后重发同类工单. 选型工单被否决时新建选型工单, 不发修复工单: 修复工单要求已登记检查命令, 而检查命令在选型采用后才登记. 改掉的后果: 检查命令登记之前被拒的工单无法继续. -- **检查命令的检查范围排除 `.navigator/` 与 `.claude/`.** 有的格式化工具默认也处理 Markdown, 会把编排记录纳入检查. 改掉的后果: 编排记录的格式让每张实现工单的检查判据都通不过. -- **选型只回答 "库能不能实现每条需求", 一轮验收完, 由用户决定是否采用.** 证据表由 `evidence` 命令从原仓库按版本取回被引用的行核对 (只接受 https 地址, git 不读凭据). 编排会话可以给出技术建议, 只有关键依赖发选型工单, 次要的库由执行子代理在实现工单中选定. 改掉的后果: 每个小依赖都要一张工单, 或关键依赖没有证据. - -### 安装与升级 - -- **hook 放在插件级, 未初始化的项目中立即结束.** `hooks.json` 用不经过 shell 的写法 (`"command": "node"`, `"args": ["${CLAUDE_PLUGIN_ROOT}/..."]`), 实测在含空格与中文的路径下正常. `hook.mjs` 第一步检查项目中有没有 `.navigator/state.json`, 没有就结束; 项目标为已卸载时也结束. 0.x 由 init 把 hook 写进项目配置, 并把运行脚本复制进 `.navigator/bin/`: 每个项目入库约 60 个脚本, 插件更新后要用户手动升级, 还出现过副本不能升级自己的问题. 代价是装了插件的所有项目中, 每次写入类工具调用与每条消息都启动一次 Node. 改掉的后果: 回到复制脚本与手动升级. -- **命令经插件 `bin/` 调用, 写成 `project-navigator <命令>`.** 官方文档: 插件 `bin/` 中的文件在 Bash 工具的 PATH 中; 实测 PowerShell 工具运行同名的 `.cmd`. 命令名用完整的 `project-navigator`, 避免与用户 PATH 中的工具重名 (插件目录排在用户 PATH 之后, 重名时用户的工具优先). `bin/project-navigator` 在仓库中要有可执行位. 技能加载时的 `enter` 仍用 `node "${CLAUDE_SKILL_DIR}/runtime/navigator.mjs"`, 因为 `!` 命令的 PATH 没有文档保证. 改掉的后果: 命令依赖项目中的副本路径. -- **参考文件与执行手册由 `ref <名称>` 输出.** 默认权限模式下, 用 Read 读取项目之外的插件文件需要用户批准; 放行规则覆盖插件命令, 输出内容不弹确认, 也总是当前版本. 改掉的后果: 要么复制进项目, 要么每读一次都弹确认. -- **放行规则同时写进 `settings.json` 与 `settings.local.json`, 不放行宽泛的写入.** 规则是 `Bash(project-navigator *)`, `PowerShell(project-navigator *)` 与 `Edit(.navigator/**)`. 项目 `settings.json` 中的规则要等用户接受工作区信任后才生效, 无头模式不生效; 本机文件不需要信任. 执行子代理写业务文件的确认按用户的权限模式处理: 实测后台子代理的写入确认弹在主会话中 (不写明是子代理), 选 "Always allow" 会把会话切到 Accept edits. 所以 init 不加 `Edit(**)`, 使用说明建议执行期间切到 Accept edits. `SKILL.md` 的 `allowed-tools` 另外放行 node 与 `project-navigator` 命令, 只在调用技能的那一轮有效, 用于初始化. 改掉的后果: 宽泛的放行规则让整个项目的编辑都不再确认. -- **从 0.x 升级由 init 完成, 进入时先删旧的编排登记.** init 移除项目级 hook 与旧放行规则, 删除 `.navigator/bin/` 与 `.navigator/guide/`, 清理 `.git/navigator/` 中的旧文件与 `refs/navigator/snapshots`, 已发布但没有回执的工单撤回为起草中. 升级之前旧的项目级 hook 仍会运行, 它按 `orchestrator.json` 判断编排会话并按白名单拦下命令; 所以 `enter` 发现旧版本安装时先删掉这个文件, 旧 hook 把会话当成其它会话, 不再拦下 `project-navigator init`. 改掉的后果: 旧编排会话中运行升级命令被旧守卫拒绝. -- **`.navigator/` 除草稿外全部入库, 进入与初始化时检查忽略规则.** `.navigator/.gitignore` 重新纳入各级目录与插件写的文件类型以及工单 `artifacts/` 下的全部文件, 最后排除 `drafts/`. init 与每次进入用 `git check-ignore --no-index` 检查必须入库的路径 (状态文件, 术语表, 每种记录文件的示例位置, `.claude/settings.json` 与 `engineering.md`); 上级规则忽略了它们时停下并报告是哪条规则. 改掉的后果: 换机器或 clone 之后记录不全. -- **技能产物的提交是命令输出中的一步, 不是回复类型.** init, 推进阶段与确认收尾的输出在有未入库的技能产物时写出调用 commit-message 的参数 (只提交 `.navigator/` 与插件管理的配置, 其它改动由 commit-message 询问); 确认收尾把状态文件算进去, 因为完成标记之后没有别的提交能带上它. 0.x 有 "阶段提交" 回复与 `stagecommit` 命令, 每个阶段多一次三选一. 改掉的后果: 每推进一个阶段多一次用户选择. -- **运行脚本只用 Node.js 22 内置模块.** hook 在用户机器上运行, 不能要求安装依赖. +- **编排会话不写业务代码, 实现用后台子代理.** 目的不是防恶意, 是防角色坍塌: 用户一句 "顺手改一下", 模型就会自己写代码, 规划与进度的上下文从此被实现细节占掉. 改掉的后果: 编排会话逐渐变成实现会话, 长项目中途丢失主线. +- **交互式切片由编排会话直接做.** 逆向, 实测这类任务每一步都要用户配合, 而子代理不能与用户对话; 1.x 为此加了 "等待操作", 一个分析工单仍要来回多轮. 交互式切片只产出结论, 写在 `.navigator/findings/`, 本来就在编排会话的写入范围内, 守卫不需要新规则, "不写业务代码" 也不受影响. 只有 `findings` 切片可以是交互式: 写代码时不需要与用户对话. 改掉的后果: 回到每一步都要一轮交回. +- **不做 "另开执行会话" 的机制.** 0.x 就是这样做的, 身份登记, 分叉认领, 编排地址这一套是当时大部分故障的来源. 现在任何调用过技能的会话都是编排会话, 状态在文件里, 所以 "换一个会话接着做" 不需要任何机制. 改掉的后果: 回到登记与认领. +- **编排会话不做登记, 按转录中的编排标记认出.** `enter` 的输出带 "编排标记: project-navigator 编排标记 <编号>", 编号是项目路径 SHA-256 的前 12 位 (Windows 上先转小写); 输出注入技能内容, 技能内容记在会话转录中, hook 读 `transcript_path` 找这个标记. 源代码里只有前缀, 读源代码的会话不会被误认; `status` 不输出标记. 分叉出的会话复制了转录, 自然带着标记. 改掉的后果: 每次换会话编号都可能误判身份. +- **实现子代理不能提问, 所以只有一种中途交回.** 它结束本次运行, 最后一条消息第一行写 "交回: 需要回答" 或 "交回: 需要操作"; 编排会话转告用户, 再用 SendMessage 让原子代理继续 (实测 SendMessage 能带着上下文续接已结束的子代理). 切片状态一直是实现中. 1.x 有受阻, 等待操作, 待定判据三条通道, 各有自己的回复类型与状态转换. 改掉的后果: 每多一条通道, 多一组状态与判断. +- **委派提示词由脚本生成, 编排会话原样使用.** `brief exec` 与 `brief review` 是提示词的唯一来源, 其中的小节名取自规格. 改掉的后果: 提示词散落在规则文件里, 与切片文件的结构逐渐对不上. +- **子代理不写 `tools` (实现) 或只禁用写入工具 (验收), 模型跟随会话.** 用户装的 MCP 工具可能用到. 1.4.0 的模型配置命令去掉了: 多一条状态, 而用户可以直接用 Claude Code 自己的子代理模型设置. 改掉的后果: 列出 `tools` 时漏掉用户的工具. + +### 切片与验收 + +- **工作单位是切片, 一个切片一份文件, 分三段.** 计划由编排会话写, 执行结果由实现方写, 验收由编排会话按验收子代理交回的内容写. 1.x 是三份文件, 各有固定结构, 读不出来流程就停. 改掉的后果: 回到三件套. +- **段的归属靠计划段的摘要保证.** 守卫只能按路径判断, 管不到文件里的段. `slice start` 记下计划段的摘要, `slice review` 比对, 不一致时拒绝送验; 是编排会话按用户的回答改的, 运行 `slice replan`. 摘要先去掉行首尾空白, 合并空白, 统一表格分隔行: 格式化 hook 重排表格时摘要不变. 这只能发现, 不能阻止; 实测中如果经常发生, 退路是把计划与执行结果分成两个文件. +- **脚本只读它需要的内容.** 完成标准 (编号列表), 需要决定与补做是否为 "无", 执行状态, 标准核对表 (结论只能取三种, 每行要有证据). 其余只检查 "不为空, 没有占位". 写入时不校验结构. 改掉的后果: 结构规则越多, 被拦下的次数越多. +- **默认只在验收之后请用户决定一次.** 计划的 "需要决定" 不是 "无" 时, `slice start` 拒绝, 用户回答后带 `--decided`. 1.x 每张工单固定两次 (审阅与验收), 而多数审阅用户只是点同意. 发布前核对也去掉了: 编排会话本来就能读代码, 写计划前自己读. 改掉的后果: 每个切片多一次没有信息量的确认. +- **硬约束与工作假设分两层.** 硬约束是目标, 完成标准, 明确不做, 模块安排; 其余是工作假设, 与实际不符时实现方取得证据后按实际做, 记进 "修正" 表, 由验收核对. 1.4.0 及之前把计划当合同, 细节不符就是一轮往返. 改掉的后果: 探索性的任务每发现一个细节就多一轮. +- **骨架是硬规则.** 第一个里程的第一个切片由脚本标为骨架, 种类必须是 `build`; 骨架通过之前 `slice plan` 拒绝其它切片; 骨架不能放弃. 改掉的后果: 回到 "先把各层横着写完, 最后发现接不上". +- **接线是验收的固定项.** 实现方在 "接线证据" 写出从入口到新代码的调用路径, 验收子代理自己再追一遍. 追不到时相关标准判不通过, 而不通过的切片只能补做或放弃, 所以没有接线的切片进不了下一个. 这条靠验收提示词与 `slice accept` 的拒绝共同保证, 脚本本身不判断接线. +- **同一时间只做一个切片.** `current` 只有一个位置. 改掉的后果: 基准提交与验收范围不再确定. +- **补做在同一份文件里进行.** `slice redo` 把执行状态改成 "补做中", 把现有的验收段标题加上轮次, 在文末追加一段空的验收段; 解析时同名的段取最后一个. 改掉的后果: 每轮一个文件, 或丢掉上一轮的验收记录. +- **未验证的标准可以由用户明确接受** (`slice accept --accept-unverified`). 验证方式为 `manual` 或 `measured` 的项目, 靠运行才能确认的标准都会是未验证, 由用户亲手确认后接受. 不通过的标准不能接受. +- **提交不是状态.** `slice accept` 之后编排会话按输出调用 commit-message, 状态文件随这次提交入库. + +### 模块化约束 + +- **模块组织方式是登记的项目设置, 不是每次推断.** `directories` 或 `filters`, 立项时由脚本给出建议 (看仓库里有没有 `.sln`, `.vcxproj`, `.csproj`, `.vbproj`), 由用户确认. 工程规范默认包中两种写法各占一个带标记的块, `setup` 写入项目时只保留一块. 改掉的后果: 同一个项目里, 有的切片建目录, 有的建筛选器. +- **约束落在计划与验收两处固定项上, 不只是规则文本.** 计划的 "模块安排" (文件, 模块, 层, 说明, 唯一定义, 复用) 是硬约束; 验收的 "结构核对" 六项写在 `lib/briefs.mjs` 的 `STRUCTURE_CHECKS` 中. 1.x 只在验收里有一行 "规范核查". 改掉的后果: 规范只在出问题之后才被想起来. +- **筛选器检查是唯一确定性的模块化检查.** 用正则解析 `.vcxproj` 与 `.vcxproj.filters`: 每个源文件项要归入筛选器; 只有本切片新增的文件要求平铺 (与基准提交比较), 已有的子目录不追究; 项目目录之外的文件与带通配符或宏的项不检查. 不引入 XML 库: 运行脚本只用内置模块. +- **验证方式也是登记的项目设置.** `auto` 时验收子代理运行登记的命令; `manual` 与 `measured` 时提示词写明不构建, 不运行. 1.x 假定每个项目都有代码检查命令, 没有命令的项目要绕路登记 "原因". ### 状态与记录 -- **`state.json` 是状态的唯一来源, 只由插件命令写入.** 写入比较版本号, 先写临时文件再改名; 读取时迁移旧结构 (结构版本 5 去掉会话登记, `lastCommit`, `stageCommit`, `pendingAnomaly`, `evidenceTools` 与工单的 `authorizedTests`). 守卫拒绝用写入类工具直接改它. 改掉的后果: 并发写入互相覆盖, 或模型手改状态. -- **删除快照与对账.** `.navigator/` 已经在 Git 中, 回退与手工改动用 git 就能看到. 0.x 的快照, 待并入写入, 对账与 "验收异常" 用来防手工改记录, 却让每次 `git reset` 或格式化 hook 都可能卡住流程. 现在回退之后按当前记录继续, 与实际情况对不上时编排会话回复 "运行受阻" 请用户决定. 改掉的后果: 回到每次回退都要处理验收异常. -- **`spec/templates.json` 是回复, 记录文件与写作规则的唯一来源.** 回复校验, 骨架生成, 体检与参考文档都从规格读取. 改掉的后果: 文档与校验逐渐不一致. -- **回复与记录文件先取骨架再填写, 进展由脚本计算.** 模型容易数错进度或漏写节. -- **"字" 只有一种算法** (`lib/text-units.mjs`): 汉字, 英文单词, 数字, 路径各算 1 字, 空格与标点不算, 句长与结论字数共用. +- **`state.json` 是状态的唯一来源, 只由插件命令写入.** 写入比较版本号, 先写临时文件再改名. 结构版本 6; 更早的结构不读取. +- **下一动作只在 `lib/guidance.mjs` 中计算.** `status` 与 hook 的位置提醒共用. 它只需要状态, 以及从切片文件读出的三件事: 执行状态, 标准核对表的行数与归类. 改掉的后果: 命令输出, hook 与规则文件各说各的. +- **路线视图由脚本生成.** `.navigator/roadmap.md` 每次保存状态时重写, 守卫拒绝直接编辑. +- **决策条目由命令追加.** `decision add` 同时登记编号与追加正文, 两边不会对不上; 需求文件与待办由编排会话直接写. - **中文内容用 `.navigator/drafts/` 下的 JSON 草稿传给命令.** Windows PowerShell 5.1 传中文参数会乱码. +- **不用快照与对账.** 记录在 Git 中, 回退与手工改动用 git 就能看到; 对不上时 `check` 给出问题, 由用户决定. -### 技能异常与问题报告 +### 回复 -- **脚本只记自己能确定的迹象, 不判断原因.** 四种迹象: 命令或 hook 抛出内部错误 (不是 `WorkflowError`, 也不是参数写错); `status` 自检发现状态不自洽; 上一次 "下一动作" 写明的命令被流程规则拒绝; 状态版本不变时同一个 "下一动作" 连续给出 4 次 (等执行子代理期间不计数). 流程规则的正常拒绝不记. 改掉的后果: 每次操作失误都生成报告, 或技能的问题被模型换写法绕开, 维护者看不到. -- **异常记在 `.git/navigator/`, 报告写在 `.navigator/drafts/reports/`.** 两处都不入库: 记录是本机运行期的事, 报告是交给维护者的, 不属于项目记录. 相同的未上报异常只记一次, 最多保留 30 条; 写进报告后标为已上报, 同一个问题不重复上报. -- **报告只写遇到的情况.** 脚本填环境, 状态摘要与异常记录, 模型在草稿中填经过 (当时在做, 预期, 实际, 复现); 不写原因推测与处理建议. 修不修, 怎么修由维护者判断, 模型的推测容易把排查带偏. 报告不含工单正文与业务代码, 项目与技能目录的路径替换成占位写法. 改掉的后果: 报告泄露项目内容, 或混入没有依据的结论. -- **只有无法继续时才暂停.** 命令内部错误与状态不自洽, 或草稿写了 `"blocked": true` 时, "问题报告" 用第 2 组选项, 等修复后再继续; 其余情况用第 1 组, 记录后继续推进. 状态不自洽时 `status` 的下一动作直接改为上报. -- **记录失败不影响命令与 hook.** hook 出错仍然放行, 只多记一条异常, 下次用户发消息时提醒编排会话上报; `report` 在状态文件损坏时也能运行. +- **回复没有模板, 也没有校验.** 规则只有: 第一行结论, 第二行进展, 需要决定时末尾列选项, 不贴代码. 1.x 的回复类型, 骨架命令与 Stop hook 都去掉了: 它们是为了从回复里识别用户的选择 (工单审阅的认可), 现在流程不再依赖回复的结构. 代价是 "结论先行" 只靠规则. 改掉的后果: 回到打回与重写. + +### 安装 + +- **hook 放在插件级, 未初始化的项目中立即结束.** `hooks.json` 用不经过 shell 的写法 (`"command": "node"`, `"args": ["${CLAUDE_PLUGIN_ROOT}/..."]`). `hook.mjs` 读不到当前结构的状态文件, 或项目已卸载时直接结束; 所以 1.x 的记录在归档之前, hook 也不生效. 代价是装了插件的所有项目中, 每次写入类工具调用与每条消息都启动一次 Node. +- **命令经插件 `bin/` 调用, 写成 `project-navigator <命令>`.** 技能加载时的 `enter` 仍用 `node "${CLAUDE_SKILL_DIR}/runtime/navigator.mjs"`, 因为 `!` 命令的 PATH 没有文档保证; 为此 `SKILL.md` 设置了 `allowed-tools`. +- **参考文件与实现手册由 `ref <名称>` 输出.** 默认权限模式下, 用 Read 读取项目之外的插件文件需要用户批准. `ref executor` 在手册之后附上 `shared/punctuation.md`, 标点规则只在那一个文件中维护. +- **放行规则同时写进 `settings.json` 与 `settings.local.json`, 不放行宽泛的写入.** 规则是 `Bash(project-navigator *)`, `PowerShell(project-navigator *)` 与 `Edit(.navigator/**)`. 实现子代理写业务文件的确认按用户的权限模式处理. +- **`.navigator/` 除草稿外全部入库, 进入与初始化时检查忽略规则.** `.navigator/.gitignore` 重新纳入各级目录, `.md`, `.json` 与 `findings/` 下的全部文件, 最后排除 `drafts/`. `REQUIRED_TRACKED_PATHS` 中的路径被上级规则忽略时停下并报告是哪条规则. ## 组件地图 @@ -109,125 +116,94 @@ | 文件 | 职责 | | ------------------------ | ------------------------------------------------------------- | | `runtime/navigator.mjs` | 命令行入口, 解析参数并分发; `enter` 任何情况下以退出码 0 结束 | -| `runtime/hook.mjs` | 四种 hook 事件的统一入口; 未初始化的项目中立即结束 | +| `runtime/hook.mjs` | 三种 hook 事件的统一入口 | | `commands/handlers.mjs` | 命令名到处理函数的映射 | -| `commands/init.mjs` | 初始化与从 0.x 升级 | +| `commands/init.mjs` | 初始化, 归档旧版本的记录 | | `commands/uninstall.mjs` | 移除放行规则, 项目标为已卸载 | -| `commands/enter.mjs` | `enter` 与 `status`: 环境检查, 编排标记, 下一动作 | -| `commands/reply.mjs` | 回复的填写要求与骨架; 工单审阅预填, 验收报告选项组核对 | -| `commands/template.mjs` | 记录文件的填写要求与骨架 | -| `commands/ref.mjs` | 输出参考文件, 执行手册与工程规范 | -| `commands/stage.mjs` | 阶段, 步骤, 跳过, 收尾 | -| `commands/plan.mjs` | 推进路线, 里程与切片状态 | -| `commands/order.mjs` | 工单新建与状态转换, 旧回执归档 | -| `commands/records.mjs` | 风险, 决策, 变更 | -| `commands/brief.mjs` | 核对, 执行, 验收与调研的委派提示词 | -| `commands/model.mjs` | 查看与登记子代理模型 | -| `commands/evidence.mjs` | 从原仓库取回选型回执证据表引用的源码行 | -| `commands/check.mjs` | 体检与自查 | -| `commands/standards.mjs` | 写入项目规范 | -| `commands/codecheck.mjs` | 登记代码检查命令 | -| `commands/report.mjs` | 生成技能问题报告, 把异常标为已上报 | -| `commands/support.mjs` | 打开项目, 保存状态, 读取草稿, 技能产物的提交步骤 | +| `commands/enter.mjs` | `enter` 与 `status`: 环境检查, 编排标记, 位置与下一动作 | +| `commands/ref.mjs` | 输出参考文件, 实现手册与工程规范 | +| `commands/setup.mjs` | 项目设置的建议与登记, 写入项目规范 | +| `commands/plan.mjs` | `roadmap`, `milestone done`, `finish` | +| `commands/slice.mjs` | `slice` 的七个子命令 | +| `commands/brief.mjs` | 实现与验收的委派提示词 | +| `commands/decision.mjs` | 登记决策并追加条目 | +| `commands/check.mjs` | 记录自查与筛选器检查 | +| `commands/support.mjs` | 打开项目, 保存状态并生成路线视图, 读取草稿, 提交步骤 | `runtime/lib/` 中的模块: -| 分组 | 文件 | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 路径与状态 | `paths.mjs`, `state.mjs`, `atomic-file.mjs`, `numbering.mjs`, `validation.mjs`, `workflow-error.mjs`, `tracked-paths.mjs` | -| 规格与版式 | `spec.mjs`, `markdown.mjs`, `layout.mjs`, `reply-checks.mjs`, `file-checks.mjs`, `writing-checks.mjs`, `text-units.mjs`, `freeform-sections.mjs` | -| 生成 | `render.mjs`, `render-plan.mjs`, `table.mjs`, `briefs.mjs`, `reminders.mjs`, `guidance.mjs`, `reply-guide.mjs`, `file-guide.mjs`, `writing-rules.mjs` | -| 守卫与身份 | `guard.mjs` (四条写入规则), `orchestrator-marker.mjs` (按转录认出编排会话) | -| 工作流 | `workflow-orders.mjs`, `workflow-plan.mjs`, `workflow-records.mjs`, `code-checks.mjs`, `review-record.mjs`, `receipt-record.mjs`, `order-approval.mjs`, `order-base.mjs`, `option-answer.mjs`, `registry.mjs`, `subagent-models.mjs` | -| 源码证据 | `source-evidence.mjs`, `source-fetch.mjs` | -| 异常自检 | `diagnostics.mjs` (异常的判断, 记录与原地打转), `state-checks.mjs` (状态自检) | -| 仓库与环境 | `repo.mjs`, `environment.mjs`, `version.mjs`, `settings.mjs` | - -仓库中的其它部分: +| 分组 | 文件 | +| ---------- | --------------------------------------------------------------------------------------------------------------------- | +| 路径与状态 | `paths.mjs`, `state.mjs`, `atomic-file.mjs`, `numbering.mjs`, `validation.mjs`, `workflow-error.mjs` | +| 路线与切片 | `roadmap.mjs` (路线草稿, 里程), `slice-flow.mjs` (切片状态转换), `slice-file.mjs` (切片文件的骨架, 读取, 摘要) | +| 生成 | `guidance.mjs` (下一动作与进展), `briefs.mjs` (委派提示词), `roadmap-view.mjs` (路线视图), `decisions.mjs` (决策条目) | +| 项目设置 | `project-settings.mjs` (建议, 校验, 项目规范的生成), `filters.mjs` (筛选器检查) | +| 守卫与身份 | `guard.mjs` (四条写入规则), `orchestrator-marker.mjs` (按转录认出编排会话) | +| 解析 | `markdown.mjs`, `table.mjs` | +| 仓库与环境 | `repo.mjs`, `environment.mjs`, `version.mjs`, `settings.mjs` | -- `scripts/gen-navigator-docs.mjs`: 从规格生成 `references/replies.md`, `files.md`, `writing.md` 与 `guide/executor.md`; 手写部分在 `scripts/navigator-docs/`. -- `tests/project-navigator/`: 自动化测试. +`tests/project-navigator/` 是自动化测试. ## 排查手册 -| 症状 | 根因 | 相关文件 | 验证方法 | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | -| 调用技能时直接中止, 提示权限检查失败 | `allowed-tools` 被删除或改写 | `SKILL.md` | 默认权限模式下调用一次 | -| 运行 `project-navigator` 提示找不到命令 | 插件没有启用, 或会话在插件更新前启动; PowerShell 中找不到 `.cmd` | `bin/` | `claude plugin list` 看插件是否启用; 重启会话 | -| 插件命令或写记录弹出权限确认 | 本机配置缺少放行规则, 或命令没有以 `project-navigator` 开头 | `lib/settings.mjs` | 检查 `.claude/settings.local.json` 中的规则 | -| 编排会话写 `.navigator/` 之外的文件被拒 | 这是规则 4; 需要改动时发工单 | `lib/guard.mjs` | 用标准输入驱动 `hook.mjs`, 带编排会话的转录 | -| 不是编排会话的会话写业务文件被拒 | 会话转录中出现了本项目的编排标记, 例如该会话曾调用过技能 | `lib/orchestrator-marker.mjs` | 在转录中查找 "project-navigator 编排标记" | -| 执行子代理写业务文件被拒 | 工单不在已发布状态, 或是运行检查与分析工单 | `lib/guard.mjs`, `lib/workflow-orders.mjs` | `status` 的 "工单状态" 一行 | -| 执行子代理写编排记录被拒 | `.navigator/` 下只能写当前工单的回执与 `artifacts/` | `lib/guard.mjs` | 拒绝理由写明可写范围 | -| 执行子代理写 `artifacts/` 下的文档被拒, 提示不是守卫的 | Claude Code 自身拒绝子代理写报告类文件 | `scripts/navigator-docs/executor-guide.md` | 子代理在最终回复中交回全文, 编排会话按路径写入 | -| 回复被打回 | 缺少一级标题, 结论行, 或选项块与规格不一致 | `lib/reply-checks.mjs`, `spec/templates.json` | `reply` 取骨架后填写再校验 | -| 执行子代理结束了, `status` 仍说回执未写入 | 子代理没有按骨架写回执, 或写到了别的路径 | `commands/brief.mjs`, `lib/receipt-record.mjs` | 用 SendMessage 请子代理补写; 查看工单文件夹 | -| 回执已写入, 下一动作分不清受阻还是完成 | "回执概况" 的 "执行状态" 没有写, 或节名被改 | `lib/receipt-record.mjs` | `check <回执路径>` 自查结构 | -| `order set issued` 被拒, 提到审阅 | 用户没有在 "工单审阅" 中选 A, 或认可之后改过工单文件. 状态为 `declined` 表示用户回答了别的内容 (包括之后撤回的消息): 此后以 "发布工单" 开头的消息仍会认可, 单独的字母不会 | `lib/order-approval.mjs`, `commands/order.mjs` | 查看 `.git/navigator/order-approval.json` 的工单编号, 摘要与状态 | -| `reply approve` 或 `order set issued` 被拒, 提到发布前核对 | 起草中的工单没有运行过 `order precheck` (1.4.0 之前起草的工单也要补一次) | `lib/workflow-orders.mjs` | 查看 `state.json` 中当前工单的 `prechecked` | -| 子代理没有用上登记的模型 | 编排会话派出时没有传 `model` 参数, 或用户设置了 `CLAUDE_CODE_SUBAGENT_MODEL_FORCE` | `lib/subagent-models.mjs`, `lib/guidance.mjs` | `status` 的 "子代理模型" 一行; 会话中 Agent 调用的参数 | -| 执行子代理结束了, 没有回执, 交回第一行是 "执行状态: 等待操作" | 这是中途交回, 不是异常 | `lib/briefs.mjs`, `scripts/navigator-docs/executor-guide.md` | 回复 "等待操作", 用户回复后用 SendMessage 续接 | -| 受阻后继续发布被拒 | 工单文件在发布后改过, `issuedDigest` 对不上, 要重新审阅 | `commands/order.mjs` | `status` 的下一动作写明 "必须重新审阅" | -| `order set accepted` 被拒 | 有判据不通过; 有未验证但没带 `--accept-unverified`; 或 "用户结论" 还是 "待用户确认" | `lib/review-record.mjs`, `commands/order.mjs` | 查看 `review.md` 的判据核对表与人工验收一节 | -| `reply approve` 或 `order set issued` 被拒, 提到基准提交 | 工单文件 "工单概况" 的 "基准提交" 不是当前提交: 写好工单之后仓库有了新提交 (例如升级插件后的提交) | `lib/order-base.mjs` | 把工单文件的 "基准提交" 改为提示中的当前提交, 重新审阅 | -| `reply review --option 1` 或 `--option 3` 被拒 | 选项组与判据结论不符 | `commands/reply.mjs` | 查看 `review.md` 的判据核对表 | -| 实现或修复工单新建或发布被拒, 提到代码检查 | 项目未登记检查命令, 或工单的验收判据不含检查判据原文 | `lib/code-checks.mjs`, `commands/order.mjs` | 查看 `state.json` 的 `codeChecks`; 重新取 `template order` | -| 进入时说项目由旧版本初始化 | 项目配置中还有 0.x 的项目级 hook, 或 `.navigator/bin/` 还在 | `commands/enter.mjs`, `commands/init.mjs` | 运行 `project-navigator init` 升级 | -| 升级之前在旧编排会话中运行命令被拒 | 旧的项目级 hook 仍在运行; 0.x 的 `orchestrator.json` 没有被删掉 | `commands/enter.mjs` | 重新调用技能 (进入时删掉旧登记), 或新开会话调用 | -| init 输出 "设置结果: 未执行", 或进入时要求回复 "运行受阻" | 项目的忽略规则忽略了 `.navigator/`, `.claude/` 下的配置, 或其中必须入库的文件 | `commands/init.mjs`, `lib/tracked-paths.mjs` | `git check-ignore -v --no-index <路径>` | -| `evidence` 报取不到版本或文件 | 版本与仓库的 tag 写法不同, 路径写成了安装后的包内路径, 仓库需要账号, 或网络不通 | `lib/source-fetch.mjs` | 用 `evidence check` 单条重试, 看输出中 git 的原始错误 | -| 卸载后 hook 仍有输出 | 状态文件的 `init.status` 不是 `uninstalled` | `commands/uninstall.mjs`, `runtime/hook.mjs` | 查看 `state.json` 的 `init` | -| 收到用户转来的问题报告 | 报告的 "脚本记录的异常" 给出种类, 来源命令与调用栈; "状态" 给出当时的位置与工单状态 | `lib/diagnostics.mjs`, `commands/report.mjs` | 按 "复现步骤" 在临时仓库中重现; 调用栈中的 `<技能目录>` 对应 `skills/project-navigator` | -| `status` 一直显示 "技能异常" | 有未上报的异常; 运行 `report` 后才标为已上报 | `lib/diagnostics.mjs` | 查看 `.git/navigator/diagnostics.json` 中没有 `reportId` 的条目 | -| 操作失误被记成了技能异常 | 脚本抛出的错误不是 `WorkflowError`, 被当成内部错误; 或 "下一动作" 的文字里写了会被拒绝的命令 | `lib/diagnostics.mjs`, `lib/guidance.mjs` | 查看异常的种类与信息; 流程规则的拒绝一律抛 `WorkflowError` | +| 症状 | 根因 | 相关文件 | 验证方法 | +| ------------------------------------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------- | +| 调用技能时直接中止, 提示权限检查失败 | `allowed-tools` 被删除或改写 | `SKILL.md` | 默认权限模式下调用一次 | +| 运行 `project-navigator` 提示找不到命令 | 插件没有启用, 或会话在插件更新前启动; PowerShell 中找不到 `.cmd` | `bin/` | `claude plugin list` 看插件是否启用; 重启会话 | +| 插件命令或写记录弹出权限确认 | 本机配置缺少放行规则, 或命令没有以 `project-navigator` 开头 | `lib/settings.mjs` | `status` 的 "放行规则" 一行; 重新运行 `init` | +| 进入时说记录由旧版本写入 | 状态文件的 `schema` 小于 6 | `commands/enter.mjs`, `commands/init.mjs` | 用户同意后运行 `init --archive` | +| 编排会话写 `.navigator/` 之外的文件被拒 | 这是规则 4; 需要改动时排进切片 | `lib/guard.mjs` | 用标准输入驱动 `hook.mjs`, 带编排会话的转录 | +| 不是编排会话的会话写业务文件被拒 | 会话转录中出现了本项目的编排标记, 例如该会话曾调用过技能 | `lib/orchestrator-marker.mjs` | 在转录中查找 "project-navigator 编排标记" | +| 实现子代理写编排记录被拒 | `.navigator/` 下只能写进行中的切片文件与 `findings/` | `lib/guard.mjs` | 拒绝理由写明可写范围 | +| `slice start` 说计划段没有填好 | 小节为空或仍有 "(待填写"; 完成标准不是编号列表 | `lib/slice-file.mjs` | `check` 列出没有填好的小节 | +| `slice start` 提到需要决定 | "需要决定" 不是单独一行 "无" | `commands/slice.mjs` | 问过用户并写入答案后带 `--decided` | +| 实现子代理结束了, `status` 仍说等待 | 它没有把 "执行状态" 改成 "完成", 或写到了别的小节 | `lib/slice-file.mjs`, `lib/guidance.mjs` | 看切片文件 "执行结果" 段; 用 SendMessage 请它补写 | +| `slice review` 说计划段被改动 | 开工后计划段的内容变了: 编排会话按用户的回答改了, 或实现子代理误改 | `lib/slice-file.mjs` 的 `digestPlan` | `git diff` 看切片文件; 前者运行 `slice replan` | +| 验收段已写, `status` 仍说等待验收 | 标准核对表还有占位, 或不是合法的表格 | `lib/slice-file.mjs` 的 `readVerdicts` | 表头下要有分隔行, 每行三格 | +| `slice accept` 被拒 | 验收段有小节没填; 结论不是三种之一; 行数少于完成标准; 有不通过; 有未验证而没带参数 | `commands/slice.mjs` | 错误信息逐条写明 | +| `slice plan` 被拒, 提到最小骨架 | 骨架切片还没有通过验收 | `lib/slice-flow.mjs` | `roadmap.md` 中带 "最小骨架" 的那一行 | +| `roadmap` 被拒, 提到缺少已经开始的切片 | 草稿没有带上已有切片的 `id` | `lib/roadmap.mjs` | 对照 `roadmap.md` 把已有条目带上 `id` | +| 筛选器检查漏报或误报 | 工程文件用了通配符, 宏, 或正则没有覆盖的写法 | `lib/filters.mjs` | 输出中的 "没有检查" 项数; 用最小的工程文件复现 | +| init 输出 "设置结果: 未执行", 或进入时说会被忽略 | 项目的忽略规则忽略了 `.navigator/` 或 `.claude/` 下必须入库的文件 | `commands/init.mjs`, `lib/paths.mjs` | `git check-ignore -v --no-index <路径>` | +| 卸载后 hook 仍有输出 | 状态文件的 `init.status` 不是 `uninstalled` | `commands/uninstall.mjs`, `runtime/hook.mjs` | 查看 `state.json` 的 `init` | ## 修改流程 -| 改了什么 | 还要做 | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| `spec/templates.json` | `npm run gen:docs`, `npm test`; 检查手写参考文件中引用的节名与选项组号 | -| 回复类型中选项组的顺序 | 代码中引用了组号: `lib/guidance.mjs` (`REVIEW_OPTIONS`, `REPORT_OPTIONS`), `commands/order.mjs`, `commands/reply.mjs`, 以及手写参考文件 | -| 流程规则的拒绝 | 一律抛 `WorkflowError`; 抛别的错误类型会被记成技能的内部错误 | -| 证据表或判据核对表的列与取值 | `lib/source-evidence.mjs` 与 `lib/review-record.mjs` 中的常量; `spec.test.mjs` 核对两边一致 | -| 工单文件的 "审阅要点" | 工单审阅回复的键要包含它的全部键, `spec.test.mjs` 核对 | -| `scripts/navigator-docs/` | `npm run gen:docs` | -| `shared/punctuation.md` | `npm run gen:docs`: 执行手册与 `writing.md` 内嵌了标点规则 | -| `runtime/` | `npm test`; 插件更新后用户重启会话即生效, 不需要升级项目 | -| 状态文件的结构 | 提升 `STATE_SCHEMA_VERSION`, 在 `normalizeState` 中迁移旧字段 | -| 新增或改名插件命令 | 在 `commands/handlers.mjs` 登记; 更新 `SKILL.md` 的命令速查与执行手册中执行子代理可用的命令 | -| 插件命令名 `project-navigator` | `lib/paths.mjs` 的 `COMMAND_NAME`, `bin/` 中的两个文件, `lib/settings.mjs` 的放行规则, 文档 | -| `SKILL.md` | `npm test` 中的篇幅检查与路由表检查; 用 `claude -p --plugin-dir` 冒烟 | -| 子代理的名称 | `lib/guard.mjs` 的 `SUBAGENT_TYPES` 与 `SKILL.md` | -| 插件名 `waypoint` | `SUBAGENT_TYPES`, `COMMIT_SKILL`, 文档中的 `/waypoint:` 命令 | +| 改了什么 | 还要做 | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `spec/slice.json` 的小节 | 小节的 `id` 被 `lib/slice-file.mjs` 的 `SECTIONS` 引用, `slice-file.test.mjs` 核对两边一致; 检查规则文件中引用的小节名 | +| 流程规则的拒绝 | 一律抛 `WorkflowError`, 错误信息写成可以照着改的中文 | +| 下一动作 | 只改 `lib/guidance.mjs`; 规则文件中不重复写 "什么时候运行哪条命令" | +| 委派提示词 | 只改 `lib/briefs.mjs`; 实现手册讲做法, 提示词讲本切片的事实与交回格式 | +| `standards/engineering.md` | 两种模块组织写法各在一对 `` 与 `` 之间; `docs.test.mjs` 核对两种都能生成 | +| 状态文件的结构 | 提升 `STATE_SCHEMA_VERSION`; 决定是迁移还是归档, 并更新 `init` 与 `enter` 对旧结构的处理 | +| 新增或改名插件命令 | 在 `commands/handlers.mjs` 登记; 更新 `SKILL.md` 的命令速查 (`docs.test.mjs` 核对), `navigator.mjs` 的选项与文件头注释 | +| 插件命令名 `project-navigator` | `lib/paths.mjs` 的 `COMMAND_NAME`, `bin/` 中的两个文件, `lib/settings.mjs` 的放行规则, 文档 | +| `SKILL.md` | `npm test` 中的篇幅检查与路由表检查; 用 `claude -p --plugin-dir` 冒烟 | +| 子代理的名称 | `lib/guard.mjs` 的 `SUBAGENT_TYPES` 与 `SKILL.md`; `docs.test.mjs` 核对定义文件存在 | +| 插件名 `waypoint` | `SUBAGENT_TYPES`, `COMMIT_SKILL`, 文档中的 `/waypoint:` 命令 | +| `shared/punctuation.md` | 不需要额外步骤: `ref executor` 运行时读取它 | ## 测试 -- `npm test` 运行 `tests/project-navigator/` 中的全部测试: - -| 文件 | 覆盖内容 | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `spec.test.mjs` | 标题与键名为 4 个汉字, 编号唯一, 同组选项宽度一致, 工单审阅包含审阅要点的键, 表格常量与规格一致 | -| `reply-checks.test.mjs` | 每种回复每组选项的填写样例合格; 缺少标题, 结论行或选项块被指出; 结论很长, 写作规则与节结构问题不打回 | -| `file-checks.test.mjs` | 每种记录文件的骨架合格, 填写要求覆盖每个键值节与表格节并写明自查命令, 条目与收尾节, 结论行, 代码块 | -| `writing-checks.test.mjs` | 每条写作规则一个命中例与一个不命中例 | -| `code-checks.test.mjs` | 检查命令登记, 固定判据, 新建与发布前核对, 要运行的检查命令, 骨架预填 | -| `evidence.test.mjs` | 证据表的读取与写法校验, 从本机临时仓库按版本取行, 单条自查命令 | -| `guard.test.mjs` | 四条写入规则各有放行例与拒绝例, 只产出记录的工单, 工单不在执行中, 只在需要时判断编排会话 | -| `workflow.test.mjs` | 工单状态 (含受阻撤回, 发布摘要, 没有提交中的状态), 路线, 阶段, 记录, 编排标记, 选项识别, 验收结论分类, 委派提示词 (含发布前核对), 下一动作, 子代理模型 | -| `settings.test.mjs` | 放行规则的合并与移除, 旧版本项目级 hook 的识别与清理, 插件 `hooks.json` 与 `bin/` 的接线 | -| `cli.test.mjs` | 进入与初始化 (编排标记, 不写 hook 不复制脚本), 恢复进度, 忽略规则检查, 从 0.x 升级 (清理与工单撤回, 旧编排登记), 旧提交中工单的迁移, 卸载, `ref`, 回复骨架, 自查 | -| `flow.test.mjs` | 未初始化的项目, 按转录认出编排会话, 提醒, 回复结构校验, 工单往返到验收与提交步骤, 认可后改工单, 接受未验证判据, 审阅预填, 受阻继续, 验收不通过的下一动作, 检查命令, 阶段提交步骤 | -| `self-report.test.mjs` | 内部错误的判断, 异常的去重与上报标记, 原地打转, 状态自检, 命令与 hook 出错时的记录, 被拒绝的下一动作, 问题报告的内容 (不含建议与本机路径) 与能否继续 | -| `docs.test.mjs` | 生成文档与规格同步, `SKILL.md` 的结论行前缀, 路由表引用的参考文件存在, `SKILL.md` 篇幅上限 | -| `markdown.test.mjs` | Markdown 解析 | - -- `npm run gen:docs -- --check` 单独检查生成文档是否同步. CI 运行以上两项. +`npm test` 运行 `tests/project-navigator/` 中的全部测试: + +| 文件 | 覆盖内容 | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `flow.test.mjs` | 在临时仓库中用命令行与 hook 走完切片循环: 骨架, 接受未验证, 补做, 交互式切片, 里程复查, 收尾, 守卫四条规则, 位置提醒, 筛选器 | +| `cli.test.mjs` | 进入与初始化, 旧版本记录的归档, 忽略规则检查, 卸载, `ref`, 项目设置与项目规范的生成, 路线的前提, 决策 | +| `roadmap.test.mjs` | 路线草稿的合并, 骨架, 同一时间一个切片, 状态转换, 里程完成与收尾, 立项阶段的下一动作 | +| `slice-file.test.mjs` | 规格与脚本引用的一致, 骨架, 解析, 计划摘要, 没有填好的小节, 标准核对表, 补做时的归档 | +| `filters.test.mjs` | 归入筛选器, 新增文件的平铺, 通配符与项目之外的文件 | +| `settings.test.mjs` | 放行规则的合并与移除, 插件 `hooks.json` 与 `bin/` 的接线 | +| `docs.test.mjs` | `SKILL.md` 的篇幅, 路由表与命令速查对得上实际的文件与命令; 工程规范的两种写法; 子代理定义 | +| `markdown.test.mjs` | Markdown 解析 | ## 实测结论 ### 1.0.0 的设计依据 (Claude Code 2.1.321, Windows 10, 2026-11-10) -用只含 hook, `bin/` 与一个子代理的探针插件在 `claude -p --plugin-dir` 中实测, 路径含空格与中文: +用只含 hook, `bin/` 与一个子代理的探针插件在 `claude -p --plugin-dir` 中实测, 路径含空格与中文. 2.0.0 仍依赖这些结论: | 待验证点 | 结论 | | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | @@ -246,30 +222,40 @@ | 待验证点 | 结论 | | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| 技能中的 `!` 命令 | 不设 `allowed-tools` 时默认模式下整个调用中止; `${CLAUDE_SESSION_ID}` 与 hook 的 `session_id` 一致; 不触发 PreToolUse | +| 技能中的 `!` 命令 | 不设 `allowed-tools` 时默认模式下整个调用中止; 不触发 PreToolUse | | 技能的 `shell` 字段 | 子会话中找不到 Git Bash 时, `shell: bash` 的技能直接报错 | -| Stop 打回 | 顶层 `{"decision": "block"}` 生效, 第二次 Stop 的 `stop_hook_active` 为 true | | bypass 模式下的拒绝 | PreToolUse 的 `permissionDecision: "deny"` 仍然生效 | | 上下文注入 | UserPromptSubmit 返回的 `additionalContext` 模型能读到; 斜杠命令也触发该事件 | | 放行规则 | 项目 `settings.json` 的放行规则需要工作区信任, 无头模式不生效; `settings.local.json` 不需要; `Edit(...)` 规则同时覆盖 Write | | 会话分叉 | `--fork-session` 与桌面应用中回退, 中断后重发都会换会话编号, 分叉出的转录复制了原会话的历史 | | 并行 hook | 同一事件的全部 hook 并行运行, Claude Code 等它们全部结束后再继续 | -源码证据的获取 (git 2.55, 2026-09-23): 按 tag 或完整提交号从 GitHub 取一个文件的若干行, 深度为 1 且文件按需下载, 约 2 秒; tag 或路径写错时直接报错; 仓库不存在时约 2 秒内失败, 不弹凭据窗口; 远端是本机路径时改为完整获取, 结果相同. +### 2.0.0 的冒烟 (Claude Code 2.1.319, Windows 10, 2026-11-12) + +在临时仓库中用 `claude -p --plugin-dir` 与 `--continue` 走了一遍 "命令行待办工具": 初始化, 立项, 排路线, 第一个切片 (最小骨架) 的计划, 实现, 送验, 验收, 直到请用户决定. + +| 待验证点 | 结论 | +| ----------------------- | ---------------------------------------------------------------------------------------------- | +| 带 `init` 参数调用 | 直接初始化并提交; 最初的规则文字让模型仍先询问, 已在 `enter` 的下一动作与 `SKILL.md` 中写明 | +| 立项 | 写入需求文件, 登记项目设置, 排出骨架加两个纵向切片的路线, 按提交步骤提交 | +| 计划 | "模块安排" 按物理目录写出文件, 模块, 层与唯一定义; 没有待定事项时直接开工 | +| 实现子代理 | 只写了业务文件与 "执行结果" 段; "接线证据" 写出从入口到数据层的逐步调用路径; "修正" 表记了两行 | +| 计划摘要 | 格式化 hook 重排了计划段的表格, 摘要不变, `slice review` 照常通过 | +| 验收 | 验收子代理交回后, 编排会话写入验收段并请用户在两个选项中决定 | +| 回复 | 没有校验时, 回复保持第一行结论, 第二行进展 | +| 会话中断 (无头模式超时) | 下一轮 `--continue` 按状态恢复, 接着验收 | -调研框架不设范围时, 一次立项调研超过 80 次网络调用, 耗时约 13 分钟; 框架现在限制为合计 20 次. +冒烟时要注意: 在已经装有本插件的会话里启动子进程, 子进程会继承 PATH 中已安装版本的 `bin/`, 实际运行的是旧命令. 先把那个目录从 PATH 中去掉. -### 子代理模型 (官方文档, 2026-11-12, 没有实测) +没有在真实会话中验证的: -以下内容摘自 Claude Code 文档的 sub-agents, plugins/components 与 model-config 三页, 由查询子代理摘录: +- 中途交回之后用 SendMessage 续接, 以及补做一轮. +- 交互式切片, 以及它对编排会话上下文的占用. +- 用筛选器分模块的 Visual Studio 项目. +- 验证方式为 `manual` 与 `measured` 的项目. +- 桌面应用中的表现. -| 待验证点 | 文档的说法 | -| -------------- | -------------------------------------------------------------------------------------------------- | -| 取值顺序 | 派出时的 `model` 参数, 子代理定义的 `model`, 环境变量 `CLAUDE_CODE_SUBAGENT_MODEL`, 编排会话的模型 | -| 派出参数的取值 | 只接受别名 `sonnet`, `opus`, `haiku`, `fable`, 不接受完整模型编号与 `inherit` | -| 续接 | 派出参数在 SendMessage 续接时保留; 定义中的 `model` 续接时是否保留, 文档没有说明 | -| 强制覆盖 | 用户设置 `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1` 时子代理一律用会话模型 | -| 推理强度 | 会话的推理强度不带到另一个模型的子代理上; 子代理定义有 `effort` 字段. 本技能目前不设置 | +发版后在真实项目中遇到的问题, 补进排查手册. ## 发版前手动测试 @@ -277,59 +263,50 @@ ### 初始化与会话 -1. 未初始化时回复 "初始设置"; 选 A 后 init 写入状态目录与放行规则, 按输出提交; 选 B 不做任何修改. 没有初始化的仓库中写文件不受任何限制. -2. 接受工作区信任对话框之前与之后, `project-navigator` 命令都不弹权限确认; Bash 与 PowerShell 中都能运行. -3. 新会话调用技能, 回复 "恢复进度" 并按记录继续; 原会话照常可用. -4. 在桌面应用中回退编排会话的一条消息后再发消息, 编排会话照常工作, 写业务文件仍被拒. -5. 上下文压缩后, 编排会话收到位置提醒. -6. 卸载后放行规则被移除, 写业务文件不再被拒, 记录保留. -7. 用 0.11.0 初始化, 有一张已发布工单的项目升级: 旧 hook, 旧放行规则与 `.navigator/bin/` 被移除, 工单撤回为起草中, 记录保留; 在原来的旧编排会话中调用技能后能运行升级命令. - -### 立项与选型 - -1. 新建项目的立项: 需求理解, 问题域调研, 调研报告; 调研报告选 B 时跳过头脑风暴直接写简报草稿; 选 A 时头脑风暴的发散与收敛. -2. 已有项目的盘点, 含运行检查工单. -3. 规范对照: 编排会话用 `ref standards` 取得默认包交给阅读子代理; 矛盾条目每轮不超过 3 条, 选定注释语言, 写入 `.claude/rules/engineering.md`. -4. 选型回执的证据表: 执行子代理用 `evidence check` 自查; 验收子代理运行 `evidence` 核对; 版本或路径写错时判为未验证. -5. 语言选型后登记代码检查命令; 登记之前新建实现工单被拒, 登记之后工单骨架预填检查判据. -6. 登记检查命令之前, 运行检查工单验收不通过: 下一动作是在同一切片新建运行检查工单. -7. 分析工单: 执行子代理能写 `artifacts/` 下的文档与脚本, 写业务文件被拒, 理由指向实现工单. - -### 工单往返 - -1. 写好工单后编排会话先派核对子代理 (Agent 调用带 `model: "sonnet"`), 核对子代理尝试写文件时被拒; 故意在判据里写一个不存在的文件, 核对结果指出来, 编排会话改好后运行 `order precheck`; 跳过核对直接 `reply approve` 被拒. -2. 工单的 "工作假设" 故意写错一个细节 (例如一个不存在的函数名): 执行子代理不受阻, 按实际做完, 回执 "事实修正" 有一行; 验收报告的 "修正核对" 写出这项修正. 再让修正越过 "明确不做": 验收判相关判据不通过. -3. 执行子代理遇到一条做不到的判据: 做完其余部分, 回执列为待定, 验收报告用第 3 组并写明这条判据的情况. -4. 分析工单中执行子代理需要用户操作: 不写回执, 交回 "执行状态: 等待操作"; 编排会话回复 "等待操作" (没有选项块), 用户回复 "操作完成" 后原子代理继续, `status` 的工单状态一直是 issued. -5. `model set executor sonnet --kind fix` 之后, 修复工单的下一动作写 `model 参数写 "sonnet"`, 实现工单仍写不传; 续接 (SendMessage) 之后子代理仍用派出时的模型. -6. "工单审阅" 只有审阅要点与工单文件路径, 与工单文件一致; 选 B 后修改再审阅; 选 A 后发布并在后台派出执行子代理, 回复 "工单执行"; 认可之后改工单文件, 发布被拒. -7. 执行子代理在后台工作时, 能继续与编排会话对话; 子代理结束后编排会话自动运行 status 并进入验收. -8. 默认权限模式下, 执行子代理的写入确认弹在编排会话中; 切到 Accept edits 后不再弹出. -9. 执行子代理受阻 (例如需要用户提供账号): 回复 "受阻处理"; 用户完成操作后不需要重新审阅, 原子代理用 SendMessage 继续. -10. "工单执行" 没有选项块; 执行期间回复 "停止执行": 子代理停止, 工单撤回为起草中, 修改后重新审阅. -11. 验收子代理交回结论与证据, 它尝试写文件时被拒绝; 回执中列为外部影响的测试它不运行. -12. 验收通过选 A 提交, 选 B 提交并推送; 有未验证判据时用第 3 组, 选 A 后工单通过并登记风险; 有不通过时补正, 实现工单先判断是否太大. -13. 执行子代理写本工单回执与 `artifacts/` 之外的编排记录被拒; 编排会话写业务文件被拒. - -### 变更, 复查与异常 - -1. 需求变更, 里程复查 (含自动体检), 手动体检. "项目收尾" 选 A 后运行 `finish`, 按输出提交, 之后回复 "项目完成", `git status` 干净. -2. "项目完成" 选 B 时编排会话推送成功. -3. 用户要求写一份问题报告, 编排会话回复 "自由答复", 正文含代码块也不被打回. -4. `git reset` 回退记录后, 编排会话按当前记录继续; 对不上时回复 "运行受阻". -5. 回复缺少结论行被打回一次, 第二次放行. +1. 未初始化时技能询问是否初始化; 同意后 init 写入状态目录与放行规则, 按输出提交; 不同意时不做任何修改. 没有初始化的仓库中写文件不受任何限制. +2. `project-navigator` 命令在 Bash 与 PowerShell 中都能运行, 不弹权限确认. +3. 新会话调用技能, 按记录继续; 原会话照常可用. +4. 上下文压缩后, 编排会话收到位置提醒. +5. 卸载后放行规则被移除, 写业务文件不再被拒, 记录保留. +6. 在 1.5.0 初始化过的项目中调用技能: 说明不读取旧记录; 同意后 `init --archive`, 旧目录改名为 `.navigator-1.x/`, 内容不变. + +### 立项 + +1. 技能复述需求, 逐个确认待定问题, 写入需求文件. +2. `setup` 的建议: 有 `.vcxproj` 的仓库建议 `filters`, 其余建议 `directories`; 登记后 `.claude/rules/engineering.md` 只含对应的一种写法. +3. 路线的第一个切片是最小骨架; 把第一个切片写成 `findings` 时被拒. + +### 切片循环 + +1. 写计划前编排会话读了相关代码; 计划的 "模块安排" 按登记的组织方式写 (目录或筛选器). +2. 计划中没有待定事项时直接开工; 有时先问用户, 带 `--decided` 开工. +3. 实现子代理在后台工作时能继续与编排会话对话; 它结束后编排会话自动接着处理. +4. 实现子代理需要用户操作: 交回 "交回: 需要操作", 编排会话原样转告, 用户回复后原子代理继续, 切片状态一直是实现中. +5. 计划的 "工作假设" 故意写错一个细节: 实现子代理不交回, 按实际做完, "修正" 表有一行; 验收的 "修正核对" 写出这项修正. +6. 实现子代理尝试改计划段: `slice review` 拒绝送验. +7. 故意不把新代码接到入口上: 验收的接线核对指出来, 相关标准不通过; 补做后通过. +8. 验证方式为 `manual` 的项目: 验收子代理不构建, 不运行, 靠运行才能确认的标准判为未验证并写出步骤; 用户确认后接受现状. +9. Visual Studio 项目中新增一个没有归入筛选器的源文件: 验收提示词的筛选器检查指出来. +10. 交互式切片: 不派实现子代理, 编排会话直接做, 结论写进 `.navigator/findings/`; 新开会话调用技能能接着做; 编排会话写业务文件仍被拒. +11. 验收子代理尝试写文件时被拒. +12. 验收通过后提交; 用户选推送时一并推送. +13. 讨论中提到当前切片之外的想法: 被记进 `backlog.md`, 没有被顺手做掉. + +### 里程, 变更与收尾 + +1. 一个里程的切片都结束后进入里程复查, 排出下一个里程的切片. +2. 需求变更: 更新需求文件与路线, 记一条决策; 已经开始的切片保留. +3. 全部里程完成后收尾, 按输出提交, `git status` 干净; 之后追加里程能继续. ## 已知限制 -- 写作规则只靠提示词与体检, 回复结束时不打回; 脚本检查是近似的. -- 打回时不合格的回复仍留在屏幕上. -- 守卫只检查写入类工具的目标路径: 用命令行或脚本语言写文件, 安装依赖, 运行改变编排状态的插件命令都不检查, 只靠技能规则. 状态文件被这样改动时, 由 git 看到. -- 插件的 hook 在所有项目中触发, 每次写入类工具调用与每条消息多启动一次 Node.js, 本机实测约 70 毫秒; 未初始化的项目中脚本在读取状态文件之前结束. +- 守卫只检查写入类工具的目标路径: 用命令行或脚本语言写文件, 安装依赖, 运行改变状态的插件命令都不检查, 只靠技能规则. - 编排会话的认定依赖转录中的标记, 转录格式是 Claude Code 的内部格式; 读不到转录时规则 4 不生效 (放行). -- 执行子代理属于启动它的会话, 会话关闭后子代理的工作中断; 新会话按回执是否写入继续. -- 后台子代理的写入确认不写明是子代理, 用户只能从 "工单执行" 回复与任务列表推断. -- 事实修正靠验收子代理与用户把关: 修正多到任务目标实际变了的情况, 脚本判断不了, 只能从验收报告的 "修正核对" 看出来. 硬约束与工作假设的界线写在规则里, 执行子代理判断错时, 要么多受阻一次, 要么越界后被验收判不通过. -- 发布前核对只记是否做过, 不核对编排会话是否按结果改了工单; 核对子代理只查工单中写出来的事实, 没写出来的隐含假设查不到. -- 子代理模型只是写在 "下一动作" 里的参数, hook 不核对编排会话是否照传. -- 等待操作期间工单状态不变, 原执行子代理随会话结束而丢失; 新派出的子代理只能靠 `artifacts/` 下的文档接着做. -- 源码证据只支持公开 git 仓库的 https 地址; `evidence` 只核对被引用的行, 这些行是否真能支持 "说明" 仍由验收子代理判断. +- 段的归属只能事后发现: 实现子代理改了计划段, 要到 `slice review` 才被指出. +- 接线与结构核对 (筛选器检查除外) 由验收子代理判断, 不是确定性的. +- 写计划前是否读了代码, 没有脚本把关. +- 回复的格式只靠规则. +- 实现子代理属于启动它的会话, 会话关闭后它的工作中断; 新的子代理只能靠 "中途记录" 与工作区中已有的改动接着做. +- 后台子代理的写入确认不写明是子代理. +- 筛选器检查只覆盖正则能认出的写法. +- 插件的 hook 在所有项目中触发, 每次写入类工具调用与每条消息多启动一次 Node.js. diff --git a/plugins/waypoint/docs/project-navigator.en.md b/plugins/waypoint/docs/project-navigator.en.md index 16e89a7..ba16255 100644 --- a/plugins/waypoint/docs/project-navigator.en.md +++ b/plugins/waypoint/docs/project-navigator.en.md @@ -4,276 +4,263 @@ English | [简体中文](project-navigator.md) [Back to WayPoint](../README.en.md) -Guides a large project from a single high-level idea through problem-domain research, framing, technology selection, and a minimal runnable skeleton, then moves it forward in vertical slices and milestones, handling changes in requirements or direction along the way. +Guides a large project starting from a single high-level requirement: it confirms the requirements, lays out a roadmap that advances by milestones and vertical slices, builds a walking skeleton that runs from the real entry point first, then fills it out one slice at a time, and records progress in the repository so that any session can pick up the work. -The session that invokes this skill only plans, records, issues work orders, and organizes reviews. It is called the orchestrator session. Writing code, building, and testing are done by executor subagents the orchestrator dispatches in the background, and reviews are done by a reviewer subagent with a fresh context. Every step is recorded in the repository, so any new session can pick up where the last one left off. +The skill targets three common problems: -The skill's replies and records are written in Simplified Chinese. +- **Starting work without a roadmap.** The skill first confirms the requirements with you, then lays out the roadmap; only the current milestone is planned in detail, and later ones are planned when you reach them. +- **Growing horizontally, and finding at the end that features and the interface were never connected.** Every slice is vertical and must be visible from the entry point or the interface when it is done; the review must give the call path from the entry point to the new code, and a slice that is not wired in cannot be followed by the next one. Ideas outside the current slice go into the backlog and are not done on the side. +- **Forgetting the progress when the session changes.** Progress is recorded by scripts in `.navigator/`, and what to do next is computed by scripts, not left to the model's memory. + +The session that invokes this skill is called the orchestrator session. It only plans, records, and organizes reviews. Code is written by executor subagents it dispatches in the background, and reviews are done by a reviewer subagent in a fresh context. ## When to use it -- Starting a project that will take weeks or longer, when you want the problem, scope, and main flow clear before writing code. -- Moving an existing project forward, with a clear goal, acceptance criteria, and review for every change. -- Handling a change in requirements mid-project, by assessing its impact and adjusting the plan. +- Starting from scratch on a project that will take weeks or longer. +- Moving an existing project forward, when you want every change to have a clear goal, completion criteria, and a review. +- Projects such as reverse engineering or live measurement, where you investigate as you build: the investigation can be planned as slices that only produce findings. -It is not meant for small tasks such as fixing a typo, patching a small bug, or writing a one-off script. Just ask Claude directly for those. +It does not fit these cases: fixing a typo, fixing a small bug, writing a one-off script. Just ask Claude directly for small tasks like these. ## Requirements - [Git](https://git-scm.com): the project must be a Git repository. If it is not a repository yet, run [`/waypoint:repo-init`](repo-init.en.md) first. - [Node.js](https://nodejs.org) 22 or later: the hooks and commands run on Node.js. - Start Claude Code in the project root. -- During selection, the public git repositories (https) of the chosen dependencies must be reachable: the review fetches source code from the original repositories to verify the evidence. ## Invocation -The skill runs only when you invoke it explicitly. Plain-language requests never trigger it: +The skill can only be invoked explicitly. Natural language never triggers it: ```text -/waypoint:project-navigator [init | check | uninstall | initial idea] +/waypoint:project-navigator [init | check | uninstall | initial requirement] ``` - No argument: show the current progress and continue as prompted. -- `init`: initialize the project, or upgrade a project initialized by an older version. -- `check`: run a checkup on the record documents. -- `uninstall`: stop using the skill in this project, keeping all records. -- Any other text: used as the initial idea, for example `/waypoint:project-navigator build a tool that collects the team's weekly reports`. +- `init`: initialize the project. +- `check`: check that the records agree with the state. +- `uninstall`: stop using the skill, keeping all records. +- Any other text: used as the initial requirement, for example `/waypoint:project-navigator build a tool that collects the team's weekly reports`. -Any session that invokes the skill continues from the recorded state; there is nothing to take over or register. After you rewind or edit a message in the desktop app, the session simply continues. +Any session that invokes the skill continues from the recorded state; there is nothing to take over or register. ## Quick start -Take "build a tool that collects the team's weekly reports" as an example, starting from a new repository that has only a README. Each reply starts with a one-line conclusion and ends with an option block, so you only need to answer with a letter. In the Claude desktop app, a reply with two choices also shows two buttons above the input box, and a click answers with that choice. To add details, write them after the letter, for example `B assumption 3 is wrong, we use Feishu`. +Take "build a tool that collects the team's weekly reports" as an example, starting from a new repository that has only a README. In every reply from the orchestrator session, the first line is the conclusion and the second line is the progress; when you need to decide something, the reply ends with options that start with a letter, and you answer with the letter. ### First invocation 1. Start Claude Code in the project root and enter `/waypoint:project-navigator build a tool that collects the team's weekly reports`. -2. The skill replies with "初始设置" (initial setup), listing the check results and what it will write. Answer `A`. -3. The skill initializes, commits the written files through [`commit-message`](commit-message.en.md), and replies with "首次接入" (first entry). Answer `A` for a new project, or `B` for a project with existing code. +2. The skill asks whether to initialize in this project. Once you agree, it creates `.navigator/`, writes the allow rules, and invokes [`commit-message`](commit-message.en.md) to commit these files. -### Framing and main flow +### Intake -1. "需求理解" (requirement understanding) lists your own words and the skill's assumptions separately. Answer `B` with the numbers of any wrong assumptions, or `A` if they are right. -2. The skill sends a research subagent to look at similar products, target users, industry practice, and constraints, then replies with "调研报告" (research report). Research needs web access and usually takes a few minutes. -3. Choose `A` to brainstorm the problem, users, and scope together, or `B` when you already know what you want or the project is small: the skill then drafts the brief from the research for you to correct. -4. The skill writes the project brief and replies with "产出确认" (output confirmation). The main flow and the roadmap are confirmed the same way. -5. When a new stage starts and records from the previous stage are not committed yet, the skill commits them through commit-message; other changes are listed for you to decide. +1. The skill restates its understanding of the requirement, points out what is vague, missing, or conflicting, and asks you the open questions one by one. It does not make assumptions on its own. +2. Once confirmed, the requirements are written to `.navigator/requirements.md`, the single source of truth for the requirements. +3. The skill asks you to confirm two project settings: how modules are organized, and how finished work is verified. See [Project settings](#project-settings) below. +4. The skill lays out the roadmap: the first slice of the first milestone is the walking skeleton, and each later slice adds one capability on top of the skeleton. After you approve it, the roadmap is committed and intake is complete. -### Selection and the first work order +### One slice -1. "规范对照" (standards comparison) lists conflicting engineering standards for you to decide one by one, then asks for the language of code comments. -2. The skill may recommend technologies from the research; key dependencies such as the language, framework, and core libraries are verified with selection work orders. -3. When a work order is written, the skill replies with "工单审阅" (work order review): the goal in one sentence, the decisions it needs from you, what is out of scope, and why the order has this size. Answer `A` if it is right, or `B` with the changes you want. The full order is at the path shown under the review points. -4. After you answer `A`, the skill dispatches an executor subagent in the background and replies with "工单执行" (order running). You can do something else; the orchestrator continues on its own when the executor finishes. -5. When the executor is blocked (for example, the specification contradicts itself, or it needs a choice between approaches), the skill replies with "受阻处理" (blocked handling) for you to decide; the executor then continues. When it only needs you to do something by hand, the skill replies with "等待操作" (waiting for your action), and you reply "操作完成" (done) afterwards. -6. When the work is done, the skill sends the reviewer subagent and replies with "验收报告" (review report). If everything passes, choose `A` to commit, `B` to commit and push, or `C` if you found a problem. For a selection order, you decide whether to adopt the result or choose another option. +1. The skill reads the relevant code first, then writes the plan for this slice: goal, completion criteria, explicit exclusions, module layout. When the plan contains something you need to decide, it asks you first; when it does not, work starts right away, and you do not need to look the plan over first. +2. The skill dispatches an executor subagent in the background. You can go do something else, or keep talking to the orchestrator session. +3. When the executor subagent needs you to answer a question or do something by hand, the skill passes the question or the steps on to you unchanged; after you reply, the same subagent continues. +4. When the work is done, the skill dispatches a reviewer subagent to check it, and reports to you the verdict on each criterion, the results of the wiring check and the structure check, and the steps you need to verify by hand. +5. You decide: pass and commit, rework, or abandon this slice. -The skeleton and every later slice follow the same work order round trip. Normally a work order needs two decisions from you: publish it at review, and accept and commit it after the review. +Normally a slice needs only one decision from you, after the review. -### Coming back later +### Every time you come back -Open a new session in the project root and enter `/waypoint:project-navigator` without an argument. The skill replies with "恢复进度" (resume progress), stating where it stopped and what comes next. +Open a new session in the project root and enter `/waypoint:project-navigator` with no argument. The skill gives the current position and the next step, and continues. -## Initialization +## Core concepts -On the first invocation, the skill checks the environment and asks whether to set up. After you choose A, it: +### Milestones and slices -1. Creates the state directory `.navigator/`: the state file, the glossary, and the plan and order directories. -2. Adds permission rules for the plugin command and for writing records to `.claude/settings.json`, keeping your existing settings. A copy of the permission rules goes into the local `.claude/settings.local.json`, which is not committed. +- **Milestone:** a stage result that means something to you. +- **Slice:** a piece of vertical functionality that one round of implementation plus one review can finish. A slice contains the interface, logic, and data it needs together; work is not cut horizontally by layer. +- **Walking skeleton:** the first slice. It enters from the real entry point (an executable, an exported function, a page), passes through every planned layer, and does the minimum in each layer, to prove that the layers connect. No other slice is worked on before the skeleton passes review; the skeleton cannot be abandoned. +- Only one slice is worked on at a time. -The hooks come from the plugin and are not written into project settings; in projects that are not initialized, the plugin's hooks do nothing. +There are two kinds of slices: -Both `.navigator/` and `.claude/settings.json` must be under version control, so the records survive switching machines or rolling back. The `.gitignore` inside `.navigator/` re-includes the record files; at initialization and on every invocation, the skill checks the record files against all of Git's ignore rules, and if a rule ignores `.navigator/` entirely or the configuration under `.claude/`, it stops and names the rule. +| Kind | Written as | What it does | Who does it | +| ---------------------- | ---------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Changes code | `build` | Implements a piece of functionality | The executor subagent in the background | +| Only produces findings | `findings` | Reverse engineering, live measurement, research: gets one thing clear and writes down the findings | By default, the orchestrator session, directly in the conversation (interactive) | -### Upgrading from 0.x +### Interactive slices -1.0.0 changes a lot, so a project initialized by 0.x needs one upgrade. After updating the plugin, invoke the skill: it reports that the project was initialized by an older version and replies with "初始设置" (initial setup). After you choose A, it: +In tasks such as reverse engineering and live measurement, every step may need you to do something in the program being debugged. A subagent cannot talk to you, so for this kind of slice no subagent is dispatched; the session you are talking to does the work directly: -- Removes the old project-level hooks and old permission rules from `.claude/settings.json` and writes the new permission rules. -- Deletes the copies under `.navigator/bin/` and `.navigator/guide/`, and removes old session registrations and snapshot records from the local `.git/navigator/`. -- Moves an issued work order without a receipt back to drafting, so it is reviewed again and then handled by an executor subagent. +- It only produces findings, written under `.navigator/findings/`, and does not change business code. +- Each finding is written to a file as soon as it is confirmed, together with the host version, the location, the verification method, and the verification time. +- When debug output fills up the context, open a new session and invoke the skill to continue: the findings are in files and the progress is in the state. +- When it is done, a reviewer subagent checks it independently as usual. -All records are kept. The upgrade is committed through commit-message. +Research that does not need your cooperation (purely reading code or documentation) can be marked as non-interactive in the roadmap and handed to the executor subagent in the background. -## Roles +### Project settings -| Role | Who | What it does | -| ----------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------- | -| Orchestrator | The session that invokes the skill | Plans, records, writes work orders, organizes reviews and checkups; writes only `.navigator/` | -| Checker subagent | Sent by the orchestrator before the order review | Checks that each fact written in the order holds and each criterion can be verified; read-only | -| Executor subagent | Dispatched by the orchestrator in the background | Writes code, tests, and the receipt for one work order; never commits or pushes | -| Reviewer subagent | Sent by the orchestrator, with a fresh context | Checks each criterion, runs the code checks and tests, returns evidence; read-only | -| Research subagent | Sent by the orchestrator during framing | Researches the problem domain: similar products, users, practice, constraints; read-only | -| You | The only decision maker | Every decision: publishing orders, handling blocks, accepting reviews, how to commit | +Two settings are registered during intake, and the plan and review of every later slice follow them: -The hooks guard only a few boundaries: the orchestrator cannot write files outside `.navigator/`; the state file and the generated views can only be changed by plugin commands; under `.navigator/` the executor can write only its order's receipt and evidence files, and on run-check and analysis orders it cannot change business files; the checker and the reviewer cannot write files. Everything else is left to the skill's rules and is not blocked. +**Module organization.** Modular design follows the same principles everywhere, but how it is expressed depends on the project: -### Which model each subagent uses +| Value | How modules are expressed | Fits | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `directories` | One physical directory per module | Python, Web, Go, and similar projects | +| `filters` | Source files lie flat in the project directory, and Visual Studio virtual filters (`.vcxproj.filters`) divide them into modules | Visual Studio projects | -The model of a subagent is set when it is dispatched. The defaults are below; `project-navigator model` shows the current assignment: +The skill gives a suggestion based on whether the repository has project files such as `.sln` or `.vcxproj`, and you confirm it. -| Subagent | Default model | Why | -| ------------------------------------- | ------------------------ | ---------------------------------------------------------------------------- | -| Executor | Follows the orchestrator | It designs and decides, and it is the one that points out a wrong work order | -| Checker, reviewer, reader, researcher | Sonnet | They gather evidence against a list and need no design judgement | +**Verification mode.** -To change it, tell the orchestrator or run the command yourself. A model is `sonnet`, `opus`, `haiku`, `fable`, or `inherit` (follow the orchestrator): +| Value | Meaning | During review | +| ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `auto` | There are check commands that can be run directly in the repository root | The reviewer subagent runs these commands | +| `manual` | You have to build or run it yourself to confirm | The subagent does not build or run; it lists the verification steps for you, and you accept after confirming them | +| `measured` | It has to be measured in the real environment | Same as above | -```bash -project-navigator model set reviewer inherit -``` +### Modularity constraints -The executor can be set per order kind, for example Sonnet for fix orders only: +When the project settings are registered, the skill writes the engineering standards to `.claude/rules/engineering.md`, which every session and subagent in the project loads automatically. The points related to modularity: -```bash -project-navigator model set executor sonnet --kind fix -``` - -Before moving implementation orders to a smaller model, look at how often recent orders were blocked or rejected: one round of rework makes the orchestrator read the receipt again, change the order, and dispatch again, which can cancel the saving. +- Constants, types, configuration, and rules are defined only once (SSOT). +- Layers are divided by responsibility, and cross-layer access goes only through interfaces. +- Reuse existing implementations first; until there are two real duplicates, do not create a generic module and do not push code down into a shared layer (YAGNI). +- The granularity of modules and files is set by clear responsibilities, not by splitting for its own sake. -### Permission prompts +These are not just rule text. Every slice that changes code has two fixed checks: -When the executor writes files in the background in the default permission mode, the write prompts appear in the orchestrator session. A work order often writes many files, so you may switch the session to "Accept edits" while an order runs to avoid repeated prompts. That is your choice. +- "模块安排" (module layout) in the plan: which module each new or changed file belongs to, which layer it is in, and which one thing it is responsible for; where the single definition of each new constant and type lives; what is reused. +- "结构核对" (structure check) in the review: the reviewer subagent answers item by item: whether new files are in the modules the plan names; whether there is only one definition; whether cross-layer access goes only through interfaces; whether an implementation that could have been reused was rewritten; whether a generic module with only one user was created; whether several existing modules were forced to change their internal implementation. -## Stages +Projects that divide modules with filters have one more check, done by a script: every source file is assigned to a filter in `.vcxproj.filters`, and no source file added by this slice was put in a physical subdirectory. The result is written directly into the review prompt, and the check can also be run at any time: -| No. | Stage | What happens | -| --- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | Survey | Existing projects only: read the repository, run checks, write a status report | -| 1 | Framing | Requirement understanding, problem-domain research, research report, brainstorming (optional), brief | -| 2 | Main flow | Write the shortest path through the core scenario, split it into milestones and slices, record risks | -| 3 | Selection | Compare engineering standards, choose the comment language; for key dependencies the executor answers per requirement whether the library can do it, with source evidence or tests | -| 4 | Skeleton | The first slice: the smallest version that runs the main flow end to end | -| 5 | Slices | Move forward slice by slice, with a review at the end of each milestone | -| 6 | Closing | Overall acceptance against the brief, statistics, and a retrospective | +```bash +project-navigator check --filters +``` -A requirement change can be inserted at any stage, and the work returns to where it was afterwards. Skipping a stage requires your explicit approval and a written reason. +## The round trip of a slice -## One work order round trip +1. **Write the plan.** After reading the code, the orchestrator session creates the slice file under `.navigator/slices/` and fills in the Plan part. The goal, completion criteria, explicit exclusions, and module layout are hard constraints; the remaining details are working assumptions. +2. **Start work.** When the plan contains nothing you need to decide, work starts right away. The script records the commit at that moment and the digest of the Plan part. +3. **Implement.** The executor subagent works to the plan, tests its own work, and writes the Result part. In it, "接线证据" (wiring evidence) gives the call path from the program entry point to the new code. +4. **Handback.** The executor subagent cannot ask you questions. When it needs an answer or an action from you, it writes what it has established so far into "中途记录" (interim notes) and ends the current run; the orchestrator session passes the question on to you unchanged, and after you reply the subagent continues. The slice status does not change during this, and work does not need to be started again. +5. **Corrections and pending criteria.** When a detail in the plan does not match reality, the executor subagent gathers evidence, works to what is actually there, and records each case in the "修正" (corrections) table, without stopping to ask you. When an individual completion criterion cannot be met as a matter of fact, it finishes the rest and lists the criterion under "待定标准" (pending criteria). You see both in one go at review time. +6. **Submit for review.** The script checks that the Plan part was not changed during implementation and that the Result part is filled in. +7. **Review.** The reviewer subagent checks item by item in a fresh context. There are only three verdicts: pass, fail, and unverified, and each comes with evidence. It traces the wiring from the entry point itself and does not copy the path the executor wrote. +8. **Your decision.** -1. **Precheck.** After writing the work order, the orchestrator sends a checker subagent to check the facts in it one by one: file paths, ids in the data, the release form and license of external resources, and whether each criterion can be verified. The orchestrator corrects what does not hold and puts trade-offs that need you under "需要决定" (decisions needed) in the review points. This step needs nothing from you. -2. **Review.** The orchestrator replies with "工单审阅" (work order review), listing only the review points and the order file path. The order can be issued only after you choose A; if the order changes after that, it is reviewed again. -3. **Execution.** The orchestrator dispatches an executor subagent in the background and replies with "工单执行" (order running). The executor checks the commit and assumptions, plans, implements, checks itself, and writes the receipt. "工单执行" needs no choice from you, so it has no option block; to stop and change the order midway, reply "停止执行" (stop). -4. **Waiting for your action.** When the order is fine and the executor only needs you to do something by hand (for example, one action in the program being debugged), it writes no receipt and hands back midway; the orchestrator replies with "等待操作" (waiting for your action) and lists the steps. Reply "操作完成" (done) with what you actually did, and the executor continues. This can repeat; the order status does not change. -5. **Fact corrections.** The details in a work order (data formats, paths, specific instances, the way to implement something) are judgements made before the work started. When the executor finds one that does not match reality, it follows reality and keeps going, recording the original text, the actual facts, the evidence, and what it did instead in the receipt; it does not stop to ask you. The goal, the scope, what is out of scope, and the decisions reserved for you are not details, and it cannot change them. The reviewer checks each correction for evidence and for overstepping; "修正核对" (correction check) in the review report lists them all, so you read them once at acceptance. -6. **Blocked.** When the executor finds the goal cannot be reached, needs to go beyond the scope, or needs a decision from you, it explains in the receipt and stops. The orchestrator replies with "受阻处理" (blocked handling); once you have handled it and the order does not need to change, the same executor continues without a new review. When only a few criteria cannot be met as written and the rest is unaffected, this is not blocked: the executor finishes the rest and lists those criteria as pending in the receipt, for you to decide at acceptance. -7. **Review of the work.** The orchestrator sends the reviewer subagent to check each criterion; the only verdicts are pass, fail, and unverified. While the reviewer works in the background, the orchestrator first replies with "工单验收" (order under review, no option block), then with "验收报告" (review report) when the result is back; its recommendation goes only in the "处理建议" (recommendation) section. In "验收报告": - - Everything passes: you may verify by hand following the "人工验收" (manual verification) steps, then choose to commit, or commit and push. - - Something fails: the orchestrator first judges why; an order that was too large is split, otherwise a fix order follows. - - Only unverified criteria remain: you decide to accept as is and commit, or to follow up; accepted unverified criteria are recorded as risks. Criteria the receipt lists as pending are handled here too; "处理建议" (recommendation) states the facts and the options for each. -8. **Commit.** After acceptance, the orchestrator calls commit-message, and the business changes and records of the round become one commit. Changes outside the receipt are listed for you to decide. +| Situation | You can choose | +| -------------------------- | -------------------------------------------------------------- | +| All pass | Pass and commit; pass, commit, and push | +| Some unverified, none fail | Accept as is and commit; rework | +| Some fail | Rework; abandon this slice (the skeleton can only be reworked) | -Work orders run strictly one after another. The orchestrator sizes each order by four limits (one executor run can finish it, one review can check it, it runs afterwards, risky parts are separate), one slice per order by default, and states the reason in the review points. +For rework, the orchestrator session writes what needs to be redone into the plan, this round's review is archived in the same file, the executor subagent continues, and the slice is then reviewed again. -Run-check and analysis orders only produce records: a run-check builds, runs, and tests with the existing commands and records the results; an analysis order covers hands-on investigation such as reverse engineering or research, producing documents and evidence scripts. Their executors can write only the receipt and the order folder's `artifacts/`; they need no check commands and can be issued before selection. A rejected one is followed by a new order of the same kind. +After a pass, the orchestrator session invokes commit-message and puts this slice's code changes and records into one commit. -Every work order that changes code carries a fixed criterion, "代码检查通过" (code checks pass). The check commands are chosen during selection, using mature checkers for the language with function length and complexity rules enabled, and exclude `.navigator/` and `.claude/`. +## Milestone review, requirement changes, and wrap-up -Tests that need real accounts, cost money, or have external side effects are not run by the executor; it lists them under "外部影响" (external effects) in the receipt, and the reviewer judges from the output in the receipt instead of rerunning them. When a criterion can only be confirmed with an MCP tool such as a packet capture tool, the executor names the tool and where to look in the receipt, and the reviewer makes read-only queries with the same tool. +- **Milestone review:** when all slices of a milestone have ended, the skill explains how far the milestone goal was met, goes through the backlog with you, and lays out the slices of the next milestone. +- **Requirement changes:** you can raise them at any time. The skill explains the impact, and after you confirm it updates the requirements file and the roadmap and records a decision: what was decided, why, and what was given up. Slices that have already started are not changed; ideas mentioned only in passing go into the backlog. +- **Wrap-up:** when all milestones are complete, the skill explains the results item by item against the requirements, and wraps up after you confirm. After wrap-up you can still add milestones and continue. -A selection answers one question: can the chosen library do each requirement listed in the order. The receipt has one row per requirement, with "能" (yes) or "不能" (no), backed by the library's source (repository, version, path, lines), or by a temporary test when the source does not show it. The reviewer runs `evidence` to fetch the source from the original repository at that version. You decide whether to adopt the result; requirements that did not check out are recorded as risks. +## Record files -## What a reply looks like +```text +.navigator/ +├── state.json Progress and numbering; modified only by plugin commands +├── roadmap.md Roadmap view, generated from the state +├── requirements.md Requirements: goal, must-haves, explicit exclusions, constraints, confirmed conclusions +├── decisions.md Decision log, append-only +├── backlog.md Backlog: ideas outside the current slice +├── slices/ +│ └── 0003-export-csv.md One file per slice: Plan, Result, Review +└── findings/ Findings and evidence scripts written by slices that only produce findings +``` -Every reply from the orchestrator has the same structure: a type heading, a one-line conclusion, a progress line, a few sections, and an option block at the end (a reply that needs no choice from you, such as "工单执行", has no option block). For example: +- `.navigator/drafts/` holds the drafts the orchestrator session uses to pass content to commands, and is not committed. Everything else is committed. +- The `.gitignore` that ships inside `.navigator/` re-includes the record files; at initialization and on every invocation of the skill, the records are checked against all of Git's ignore rules, and when a record would be ignored the skill stops and names the rule. +- The records are all in Git. After you roll back or edit the records by hand, the skill continues from the records as they are. -````markdown -# 工单审阅 +## Roles and boundaries -结论: 工单 0007 补齐 HTTP/2 指纹, 需要你决定是否在仓库内维护 fhttp 补丁. +| Role | Who | What it does | What it can write | +| -------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| Orchestrator session | The session that invokes this skill | Confirms requirements, lays out the roadmap, writes plans, organizes reviews, records progress | Only `.navigator/` | +| Executor subagent | Dispatched in the background by the orchestrator session | Writes code to one slice's plan, tests its own work, writes the Result | Business files, the slice file in progress, `findings/` | +| Reviewer subagent | Dispatched by the orchestrator session, with a fresh context | Checks the completion criteria, wiring, structure, and scope item by item, and hands back evidence | Writes no files | +| You | The only one who makes the calls | Requirements, trade-offs, review verdicts | | -阶段 5/6 (切片) · 里程 3/5 (引擎跑通主线) · 切片 4/10 (HTTP/2 指纹) · 工单 0007 (起草中) +The hook guards only four write boundaries: nobody edits the state file or the roadmap view directly; the reviewer subagent writes no files; under `.navigator/`, the executor subagent writes only the slice file in progress and the findings directory; the orchestrator session writes no files outside `.navigator/`. Everything else relies on the skill's rules and is not intercepted. The hook is provided by the plugin and is not written into the project configuration; in a project that has not been initialized it does nothing. -## 审阅要点 +The orchestrator session does not write business code so that its context stays reserved for planning and progress: when you say "just fix this while you're at it", it puts the request into the current slice's plan or the backlog. -- 任务目标: 调用方用指纹配置控制 HTTP/2 层的全部维度 -- 需要决定: 在仓库内维护 fhttp v0.7.3 的补丁, 实现伪头不索引 -- 明确不做: HTTP/1.1 与头部大小写, 留给切片 0005 -- 粒度依据: 整个切片一张工单, 改动集中在适配层与补丁 -- 工单文件: .navigator/orders/0007-http2-fingerprint/order.md +### Permission prompts -``` -[是否发布] +When the executor subagent writes files in the background, in the default permission mode the write confirmations pop up in the orchestrator session. A slice often writes many files, so during implementation you can switch the session to "Accept edits" (file edits are accepted automatically). Whether to switch is up to you. -A. 发布工单, 开始执行. -B. 需要修改, 请提需求. -``` -```` +## Upgrading from 1.x -The conclusion line alone tells you what is asked of you; the details follow for when you need them. The parentheses at the end of the progress line show where the work order stands (起草中 drafting, 执行中 running, 待你操作 awaiting your action, 受阻 blocked, 验收中 under review, 待你决定 awaiting your decision), which tells consecutive replies apart. +2.0.0 is an incompatible redesign: the unit of work changed from the work order to the slice, and the records have a completely different structure. It does not read 1.x records and does not migrate them. -## Record files +When you invoke the skill in a project initialized under 1.x after updating the plugin, the skill explains this. After you agree, it runs: -```text -.navigator/ -├── state.json progress, numbers, and status; changed only by plugin commands -├── plan/ brief, glossary, research, brainstorming, main flow, roadmap, risks, decisions, changes, reviews, checkups -└── orders/0007-export-csv/ - ├── order.md the work order - ├── receipt.md the receipt - ├── artifacts/ evidence files left by the executor, such as raw captures and reproduction scripts (only when needed) - └── review.md the review record +```bash +project-navigator init --archive ``` -- `plan/roadmap.md` and `plan/risks.md` are generated from the state by plugin commands. Do not edit them by hand. -- `.navigator/drafts/` holds drafts the orchestrator uses to pass Chinese text to commands. It is not committed. -- Your choices in work order reviews are registered in the local `.git/navigator/`, which is not committed. -- All records are in Git. If you roll back or edit the records, the skill continues from the current records; when they do not match reality, it says so and asks you which side to trust. - -## Engineering standards - -At the start of selection, the orchestrator compares three sets of standards: the general engineering standards bundled with the plugin, the project's existing standards (`CLAUDE.md`, `AGENTS.md`, `.claude/rules/`, formatter configuration), and your global standards on this machine. You decide each conflict, at most 3 per round. The result is written to `.claude/rules/engineering.md`, which every session in the project loads automatically. Code comments default to Chinese; you can choose English or another language. - -## Checkup - -Run `/waypoint:project-navigator check`, or let it run automatically at each milestone review. A checkup has two layers: - -- Script checks: whether the structure matches the templates, whether sentences and paragraphs are too long, whether lists are too long or too deep, and whether there is jargon, metaphor, translationese, or a sentence starting with a bare "它" or "这是". -- Semantic checks: a reader subagent looks for made-up abbreviations, undefined terms, unclear references, empty phrases, and so on. +The old `.navigator/` is renamed as a whole to `.navigator-1.x/`, with its contents untouched and still available to consult; then everything starts again from intake. During intake you can have the skill read the brief and the decisions in the archive, so you do not have to dictate everything from scratch. -The result is a list of problems, and you decide whether to fix them one by one or only record them. +If you do not agree, the skill ends without changing anything. To keep using the 1.x workflow, pin the plugin at 1.5.0. ## FAQ -**Why didn't "help me plan a project" trigger the skill?** +**Why did typing "help me plan a project" not trigger this skill?** -The skill runs only when invoked explicitly with `/waypoint:project-navigator`, so a whole workflow never starts by accident in an ordinary conversation. +The skill can only be invoked explicitly with `/waypoint:project-navigator`, so that an ordinary conversation does not start a whole workflow by mistake. -**Can the orchestrator write some code for me?** +**Can the orchestrator session write a piece of code for me?** -No. Writes outside `.navigator/` by the orchestrator are denied. When code needs to change, the orchestrator issues a work order and an executor subagent does it. +No. Writes by the orchestrator session to files outside `.navigator/` are rejected. When code needs to change, put it into a slice and let the executor subagent do it. -**Can I keep talking to the orchestrator while the executor works?** +**Can I keep talking to the orchestrator session while the executor subagent is working?** -Yes. The executor runs in the background, so you can ask questions or do other things; the orchestrator is notified and continues when the executor finishes. +Yes. The subagent runs in the background; when it finishes, the orchestrator session is notified and carries on. -**What happens to the executor's work if I close the orchestrator session?** +**I closed the orchestrator session. What happens to the executor subagent's work?** -The executor belongs to the session that started it. When a new session invokes the skill, a written receipt is reviewed as usual; without one, the orchestrator dispatches a new executor. +A subagent belongs to the session that started it, and its work is interrupted when the session closes. A new session that invokes the skill dispatches a new one, and the new subagent continues from the "中途记录" (interim notes) in the slice file and the changes already in the working tree. -**What if the skill itself misbehaves?** +**My project cannot be compiled or run by Claude. Can I use the skill?** -The scripts notice some problems on their own: internal errors in a command or hook, an inconsistent state, a command that the "下一动作" (next action) asked for but that was refused, and the same next action given repeatedly while the state does not change. The orchestrator also reports when it finds rules that contradict each other. It then replies with "问题报告" (problem report): the report is written to `.navigator/drafts/reports/` (not committed) and shown in full in one code block, ready to copy to the plugin's maintainers. The report states only what happened: environment, a state summary, the recorded anomalies, and the course of events. It contains no guesses about causes, no advice, no work order text or business code, and local paths are masked. If the workflow can continue, choose `A`; if it cannot (an internal command error or an inconsistent state), orchestration pauses until the problem is fixed. +Yes. During intake, register the verification mode as `manual` or `measured`. The subagents do not build or run; they only read the code to check wiring and structure. Criteria that can only be confirmed by running are listed as steps and handed to you, and after confirming them you choose to accept as is. -**Can the orchestrator write something outside the workflow?** +**I thought of a new idea, but it does not belong to the current slice?** -Yes. Just ask. The orchestrator replies with "自由答复" (free answer) and writes it in "答复内容" (answer) in the format you ask for, code blocks included. Choose `A` afterwards to return to the workflow. +Just say it. The skill records it in `.navigator/backlog.md`, and at the milestone review decides with you where to schedule it. -**Can several work orders run in parallel?** +**Can several slices run in parallel?** -No. Work orders run strictly one after another, so each order's base commit and review result are well defined. +No. Only one slice is worked on at a time, which keeps every slice's base commit and review result well defined. -**How do I stop using the skill?** +**How do I stop using it?** -Run `/waypoint:project-navigator uninstall`. The permission rules are removed, the plugin's hooks stop acting in this project, and all records in `.navigator/` are kept, so you can initialize again later. +Run `/waypoint:project-navigator uninstall`. The allow rules are removed, the plugin's hook no longer takes effect in this project, all records in `.navigator/` are kept, and you can initialize again later. ## Known limitations -- Every machine that works on the project needs Node.js 22 or later. -- The plugin's hooks fire in every project, starting Node.js once for each write tool call and each message; in projects that are not initialized the script exits at once, about 70 ms on a local measurement. -- The guard checks only the target paths of write tools, not shell commands: if the orchestrator changes code or installs dependencies from the command line, only the skill's rules stop it. -- Writing requirements such as plain Chinese rely mainly on prompts and checkups, and replies are not sent back for writing rules; the script checks are approximate. -- A reply with a broken structure is sent back for one rewrite, but the broken version stays on screen. -- The executor cannot ask you questions while it works; anything that needs your decision ends the run as blocked and is handled afterwards. -- Source evidence supports only public git repositories; a dependency without one is judged unverified, and you decide whether to accept the risk. -- MCP evidence tools see what the external tool recorded at the time. If the records are cleared before the review, or the tool is not running, the related criteria are judged unverified. +- Every machine that uses the project needs Node.js 22 or later. +- The plugin's hook fires in every project, and every write-tool call and every message starts Node.js once; in a project that has not been initialized the script ends immediately. +- The guard only checks the target paths of write tools, not the command line: when the orchestrator session changes code or installs dependencies from the command line, only the skill's rules constrain it. +- The reply format (conclusion first, one line of progress) relies only on the skill's rules and is not validated. +- A slice file is written part by part by the orchestrator session and the executor. The script uses the digest of the Plan part to discover that it was changed during implementation; it can detect this but cannot prevent it. +- No script checks whether the orchestrator session really read the code before writing the plan; errors in the plan are caught by the executor subagent's "修正" (corrections) and by the review. +- Apart from the filters check, the items of "结构核对" (structure check) are judged by the reviewer subagent and are not deterministic. +- The filters check parses project files with regular expressions and covers only the common forms; items with wildcards or MSBuild macros are not checked, only counted. +- An interactive slice uses up the orchestrator session's context; "write each finding to a file immediately" and "you can switch sessions at any time" mitigate this. +- The executor subagent cannot ask you questions while it works; it can only end the current run and have the orchestrator session relay them. diff --git a/plugins/waypoint/docs/project-navigator.md b/plugins/waypoint/docs/project-navigator.md index 979e040..3ed862b 100644 --- a/plugins/waypoint/docs/project-navigator.md +++ b/plugins/waypoint/docs/project-navigator.md @@ -4,15 +4,21 @@ [返回 WayPoint](../README.md) -引导一个大型项目从一句宏观需求开始, 经过问题域调研, 立项, 技术选型与最小可运行骨架, 再按纵向切片与里程逐步推进, 并处理中途的需求与方向变更. +引导一个大型项目从一句宏观需求开始: 确认需求, 排出按里程与纵向切片推进的路线, 先做从真实入口跑通的最小骨架, 再一个切片一个切片地丰满, 并把进度记在仓库里, 任何会话都能接着做. -调用本技能的会话只做规划, 记录, 发布工单与组织验收, 称为编排会话. 写代码, 构建与测试由编排会话在后台派出的执行子代理完成, 验收由验收子代理在全新上下文中完成. 每一步都记录在仓库里, 换一个会话也能接着推进. +本技能针对三个常见问题: + +- **没有路线就开工.** 技能先和你把需求确认清楚, 再排路线; 只把当前里程排细, 后面的到了再排. +- **横向膨胀, 做到最后功能与界面没有接上.** 每个切片都是纵向的, 做完要能从入口或界面看到; 验收时必须给出从入口到新代码的调用路径, 没接上不能进入下一个切片. 当前切片之外的想法记进待办, 不顺手做. +- **换个会话就忘了进度.** 进度由脚本记录在 `.navigator/` 中, 下一步做什么由脚本计算, 不靠模型记忆. + +调用本技能的会话称为编排会话, 只做规划, 记录与组织验收. 写代码由它在后台派出的实现子代理完成, 验收由验收子代理在全新上下文中完成. ## 适用场景 -- 从零开始一个需要几周或更久的项目, 想先把问题, 范围与主线想清楚再动手. -- 推进一个已有项目, 希望每次改动都有明确的目标, 判据与验收. -- 项目中途需求变了, 需要评估影响并调整计划. +- 从零开始一个需要几周或更久的项目. +- 推进一个已有项目, 希望每一块改动都有明确的目标, 完成标准与验收. +- 逆向, 实测这类要边查边做的项目: 查证的部分可以排成只产出结论的切片. 以下场景不适合: 改一个错字, 修一个小 bug, 写一次性脚本. 这类小任务直接对 Claude 说即可. @@ -21,7 +27,6 @@ - [Git](https://git-scm.com): 项目必须是 Git 仓库. 还不是仓库时, 先运行 [`/waypoint:repo-init`](repo-init.md). - [Node.js](https://nodejs.org) 22 或更高版本: hook 与命令都用 Node.js 运行. - 在项目根目录启动 Claude Code. -- 选型阶段要能访问所选依赖的公开 git 仓库 (https): 验收时从原仓库取回源码核对证据. ## 调用方式 @@ -32,204 +37,186 @@ ``` - 不带参数: 查看当前进度, 按提示继续. -- `init`: 初始化项目, 或升级旧版本初始化的项目. -- `check`: 对记录文档做一次体检. +- `init`: 初始化项目. +- `check`: 自查记录是否与状态对得上. - `uninstall`: 停用本技能, 保留全部记录. - 其它文字: 作为初始需求, 例如 `/waypoint:project-navigator 做一个团队周报汇总工具`. -任何会话调用本技能, 都按记录中的状态继续, 不需要接管或登记. 在桌面应用中回退或编辑消息之后, 会话照常继续. +任何会话调用本技能, 都按记录中的状态继续, 不需要接管或登记. ## 快速上手 -以 "做一个团队周报汇总工具" 为例, 从一个只有 README 的新仓库开始. 每条回复第一行是一句结论, 末尾是选项块, 你只需回复字母; 在 Claude 桌面应用中, 两个选项的回复还会在输入框上方显示两个按钮, 点一下等于回复对应的选项. 需要补充时在字母后写明, 例如 `B 第 3 条假设不对, 我们用飞书`. +以 "做一个团队周报汇总工具" 为例, 从一个只有 README 的新仓库开始. 编排会话的每条回复第一行是结论, 第二行是进展; 需要你决定时, 末尾列出以字母开头的选项, 回复字母即可. ### 第一次调用 1. 在项目根目录启动 Claude Code, 输入 `/waypoint:project-navigator 做一个团队周报汇总工具`. -2. 技能回复 "初始设置", 列出检查结果与将要写入的内容. 回复 `A`. -3. 技能初始化后调用 [`commit-message`](commit-message.md) 提交写入的文件, 然后回复 "首次接入". 新项目回复 `A`, 已有代码的项目回复 `B`. +2. 技能询问是否在本项目初始化. 同意后, 它创建 `.navigator/`, 写入放行规则, 并调用 [`commit-message`](commit-message.md) 提交这些文件. -### 立项与主线 +### 立项 -1. "需求理解" 分开列出你的原话与技能的假设. 假设不对时回复 `B` 并指出编号, 没问题回复 `A`. -2. 技能派调研子代理查同类产品, 使用人群, 行业做法与约束, 然后回复 "调研报告". 调研需要联网, 通常要几分钟. -3. 你可以选 `A` 进入头脑风暴, 一起把问题, 人群, 范围想清楚; 已经想清楚或项目不大时选 `B`, 技能直接按调研结果写简报草稿给你改. -4. 技能写好项目简报, 回复 "产出确认". 之后的主线流程与推进路线, 同样用 "产出确认" 请你确认. -5. 每进入一个新阶段, 上一阶段写下的记录还没提交时, 技能会调用 commit-message 提交; 记录之外还有改动时, commit-message 会列出来问你. +1. 技能复述它对需求的理解, 指出含糊, 缺失, 冲突的地方, 把待定问题逐个问你. 它不会自行假设. +2. 需求确认后写成 `.navigator/requirements.md`, 这是需求的唯一权威来源. +3. 技能请你确认两项项目设置: 模块怎么组织, 完成之后怎么验证. 见下方 [项目设置](#项目设置). +4. 技能排出路线: 第一个里程的第一个切片是最小骨架, 之后的切片在骨架上各加一项能力. 你认可后提交, 立项完成. -### 选型与第一张工单 +### 一个切片 -1. "规范对照" 列出互相矛盾的规范条目, 由你逐条决定, 最后选定代码注释的语言. -2. 技能可以根据调研给出技术建议; 语言, 框架, 核心库这类关键依赖, 写成选型工单核实. -3. 工单写好后, 技能回复 "工单审阅": 一句话目标, 需要你拍板的事, 明确不做的事, 以及为什么这样划分工单. 无误回复 `A`, 需要修改回复 `B` 并写明要求. 工单文件的完整内容在审阅要点下方给出的路径中. -4. 你选 `A` 后, 技能在后台派出执行子代理, 回复 "工单执行". 你可以去做别的事, 子代理做完时编排会话会自动接着处理. -5. 执行子代理受阻时 (例如规格有矛盾, 或要在几种做法中选一种), 技能回复 "受阻处理" 请你决定; 处理完后子代理接着做. 只是要你亲手做一件事时, 技能回复 "等待操作", 你做完后回复 "操作完成". -6. 做完后技能派验收子代理核对, 回复 "验收报告". 全部通过时选 `A` 提交, 选 `B` 提交并推送, 发现问题选 `C`. 选型工单则由你决定采用还是换方案. +1. 技能先读相关代码, 再写这个切片的计划: 目标, 完成标准, 明确不做, 模块安排. 计划里有要你决定的事时先问你; 没有时直接开工, 不需要你先过目. +2. 技能在后台派出实现子代理. 你可以去做别的事, 或继续和编排会话说话. +3. 实现子代理需要你回答问题或亲手做一件事时, 技能把问题或步骤原样转给你; 你回复后, 原来的子代理接着做. +4. 做完后技能派验收子代理核对, 向你报告每条标准的结论, 接线与结构核对的结果, 以及需要你亲手验证的步骤. +5. 你决定: 通过并提交, 补做, 或放弃这个切片. -骨架与之后的每个切片, 都按同样的工单往返推进. 正常情况下, 一张工单只需要你做两次决定: 审阅时发布, 验收时通过并提交. +正常情况下, 一个切片只需要你做一次决定, 在验收之后. ### 之后每次打开 -在项目根目录新开会话, 输入不带参数的 `/waypoint:project-navigator`. 技能回复 "恢复进度", 写明上次停在哪里, 下一步做什么. +在项目根目录新开会话, 输入不带参数的 `/waypoint:project-navigator`. 技能给出当前位置与下一步, 接着做. -## 初始化 +## 核心概念 -第一次调用时, 技能会检查环境并询问是否设置. 你选 A 后, 技能会: +### 里程与切片 -1. 创建状态目录 `.navigator/`: 状态文件, 术语表, 以及计划与工单的目录. -2. 在 `.claude/settings.json` 中加入插件命令与记录写入的放行规则, 保留你已有的配置. 放行规则另存一份到本机的 `.claude/settings.local.json`, 这份文件不入库. +- **里程:** 一个对你有意义的阶段成果. +- **切片:** 一次实现加一次验收能做完的一块纵向功能. 一个切片同时包含它需要的界面, 逻辑与数据, 不按层横着切. +- **最小骨架:** 第一个切片. 它从真实入口 (可执行文件, 导出函数, 页面) 进入, 经过规划中的每一层, 每层只做最少的事, 证明各层接得上. 骨架通过验收之前, 不做其它切片; 骨架不能放弃. +- 同一时间只做一个切片. -hook 由插件提供, 不写进项目配置; 没有初始化的项目中, 插件的 hook 什么也不做. +切片有两种: -`.navigator/` 与 `.claude/settings.json` 都要纳入版本控制, 换机器或回退时记录才不会丢失. `.navigator/` 自带的 `.gitignore` 会把记录文件重新纳入; 初始化与每次调用技能时, 都按 Git 的全部忽略规则检查记录文件, 如果有规则把 `.navigator/` 整个忽略, 或忽略了 `.claude/` 下的配置, 技能会停下并指出是哪条规则. +| 种类 | 写法 | 做什么 | 由谁做 | +| ---------- | ---------- | -------------------------------------- | ------------------------------------- | +| 改代码 | `build` | 实现一块功能 | 后台的实现子代理 | +| 只产出结论 | `findings` | 逆向, 实测, 调研: 查清一件事并写下结论 | 默认由编排会话在对话中直接做 (交互式) | -### 从 0.x 升级 +### 交互式切片 -1.0.0 改了很多做法, 0.x 初始化的项目需要升级一次. 更新插件后调用技能, 技能提示项目由旧版本初始化, 回复 "初始设置"; 你选 A 后, 技能会: +逆向, 实测这类任务, 每一步都可能要你在被调试的程序里操作一下. 子代理不能和你对话, 所以这类切片不派子代理, 由你正在对话的会话直接做: -- 从 `.claude/settings.json` 中移除旧的项目级 hook 与旧放行规则, 写入新的放行规则. -- 删除 `.navigator/bin/` 与 `.navigator/guide/` 下的副本, 清理本机 `.git/navigator/` 中旧的会话登记与快照记录. -- 已发布但还没有回执的工单撤回为起草中, 重新审阅后由执行子代理处理. +- 它只产出结论, 写在 `.navigator/findings/` 下, 不改业务代码. +- 每确认一条结论立即写入文件, 并写明宿主版本, 位置, 验证方法与验证时间. +- 调试输出把上下文占满时, 新开一个会话调用技能即可接着做: 结论在文件里, 进度在状态里. +- 做完后照常由验收子代理独立核对. -编排记录全部保留. 升级后的改动由 commit-message 提交. +不需要你配合的调研 (纯读代码或读文档) 可以在路线中标为非交互, 交给后台的实现子代理. -## 角色分工 +### 项目设置 -| 角色 | 由谁担任 | 做什么 | -| ---------- | ------------------------ | ------------------------------------------------------ | -| 编排会话 | 调用本技能的会话 | 规划, 记录, 写工单, 组织验收, 体检; 只写 `.navigator/` | -| 核对子代理 | 编排会话在工单审阅前派出 | 逐条核对工单中写下的事实是否成立, 判据能否验证, 只读 | -| 执行子代理 | 编排会话在后台派出 | 按一张工单写代码, 测试, 写回执; 不提交, 不推送 | -| 验收子代理 | 编排会话派出, 上下文全新 | 逐条核对判据, 运行代码检查与测试, 交回证据, 只读 | -| 调研子代理 | 编排会话在立项时派出 | 调研问题域: 同类产品, 使用人群, 行业做法, 约束, 只读 | -| 你 | 唯一拍板的人 | 所有决定: 发布工单, 处理受阻, 决定验收结果与提交方式 | +立项时登记两项, 之后每个切片的计划与验收都以它们为准: -hook 只守几条边界: 编排会话不能写 `.navigator/` 之外的文件; 状态文件与脚本生成的视图只能由插件命令修改; 执行子代理在 `.navigator/` 下只能写本工单的回执与证据文件, 运行检查与分析工单不能改业务文件; 核对与验收子代理不能写文件. 其余靠技能规则, 不拦截. +**模块组织方式.** 模块化设计是同一套原则, 但表达方式因项目而异: -### 子代理用哪个模型 +| 取值 | 模块怎么表达 | 适用 | +| ------------- | --------------------------------------------------------------------------------- | ---------------------- | +| `directories` | 一个模块一个物理目录 | Python, Web, Go 等项目 | +| `filters` | 源码文件平铺在工程目录下, 用 Visual Studio 虚拟筛选器 (`.vcxproj.filters`) 分模块 | Visual Studio 项目 | -子代理的模型在派出时指定, 默认分配如下, 用 `project-navigator model` 查看: +技能会按仓库中有没有 `.sln`, `.vcxproj` 等工程文件给出建议, 由你确认. -| 子代理 | 默认模型 | 原因 | -| ---------------------- | ------------ | ---------------------------------------------- | -| 执行 | 跟随编排会话 | 要设计与判断, 也是指出工单有错的那一方 | -| 核对, 验收, 阅读, 调研 | Sonnet | 照单取证, 不需要设计判断, 用较小的模型更省用量 | +**验证方式.** -要调整时告诉编排会话, 或自己运行命令. 模型可以写 `sonnet`, `opus`, `haiku`, `fable`, 或 `inherit` (跟随编排会话): +| 取值 | 含义 | 验收时 | +| ---------- | ---------------------------------- | ------------------------------------------------------ | +| `auto` | 有能在仓库根目录直接运行的检查命令 | 验收子代理运行这些命令 | +| `manual` | 要你亲手构建或运行才能确认 | 子代理不构建, 不运行; 把验证步骤列给你, 由你确认后接受 | +| `measured` | 要在真实环境中实测 | 同上 | -```bash -project-navigator model set reviewer inherit -``` +### 模块化约束 -执行子代理可以按工单类型单独指定, 例如只让修复工单用 Sonnet: +登记项目设置时, 技能把工程规范写入 `.claude/rules/engineering.md`, 项目内所有会话与子代理都会自动加载. 其中与模块化有关的要点: -```bash -project-navigator model set executor sonnet --kind fix -``` +- 常量, 类型, 配置, 规则只定义一次 (SSOT). +- 按职责分层, 跨层只通过接口. +- 优先复用已有实现; 没有两处真实重复之前, 不建通用模块, 不把代码下沉到公共层 (YAGNI). +- 模块与文件的粒度以职责清晰为准, 不为拆而拆. -实现类工单换用较小的模型之前, 先看几张工单的受阻与验收不通过的情况: 返工一次要编排会话重新读回执, 改工单, 再派一轮, 可能抵掉省下的用量. +这些不只是规则文本, 每个改代码的切片都有两处固定检查: -### 权限确认 +- 计划中的 "模块安排": 每个新增或改动的文件归哪个模块, 属于哪一层, 承担哪一件事; 新增的常量与类型的唯一定义放在哪里; 复用了什么. +- 验收中的 "结构核对": 验收子代理逐项回答: 新文件是否在计划写明的模块中; 是否只有一处定义; 跨层是否只通过接口; 是否重写了可以复用的实现; 是否新建了只有一处使用的通用模块; 是否迫使多个已有模块改动内部实现. -执行子代理在后台写文件时, 默认权限模式下, 写入确认会弹在编排会话中. 一张工单常常要写很多文件, 执行期间可以把会话切到 "Accept edits" (自动接受文件编辑), 免得反复确认. 是否切换由你决定. +用筛选器分模块的项目另有一项脚本检查: 每个源文件都在 `.vcxproj.filters` 中归入了筛选器, 本切片新增的源文件没有放进物理子目录. 结果直接写进验收提示词, 也可以随时运行: -## 阶段 - -| 编号 | 阶段 | 做什么 | -| ---- | ---- | -------------------------------------------------------------------------------------- | -| 0 | 盘点 | 仅已有项目: 读仓库, 运行检查, 写现状报告 | -| 1 | 立项 | 需求理解, 问题域调研, 调研报告, 头脑风暴 (可跳过), 项目简报 | -| 2 | 主线 | 写出核心场景的最短操作路径, 拆成里程与切片, 登记风险 | -| 3 | 选型 | 对照工程规范, 选定注释语言; 关键依赖由执行子代理逐条回答能否实现需求, 附源码证据或测试 | -| 4 | 骨架 | 第一个切片: 主线从头到尾跑通的最小版本 | -| 5 | 切片 | 逐个切片推进; 每个里程结束时复查 | -| 6 | 收尾 | 对照简报总体验收, 统计与复盘 | +```bash +project-navigator check --filters +``` -需求变更可以在任何阶段插入, 处理完回到原来的位置. 跳过某个阶段要由你明确认可, 并写明原因. +## 一个切片的往返 -## 一张工单的往返 +1. **写计划.** 编排会话读代码后, 在 `.navigator/slices/` 下建切片文件, 填写计划段. 目标, 完成标准, 明确不做, 模块安排是硬约束; 其余细节是工作假设. +2. **开工.** 计划中没有要你决定的事时直接开工. 脚本记下当时的提交与计划段的摘要. +3. **实现.** 实现子代理按计划做, 自测, 写执行结果段. 其中 "接线证据" 写出从程序入口到新代码的调用路径. +4. **中途交回.** 实现子代理不能向你提问. 需要你回答或操作时, 它把已查清的内容写进 "中途记录", 结束本次运行; 编排会话把问题原样转给你, 你回复后它接着做. 这个过程中切片状态不变, 不需要重新开工. +5. **修正与待定.** 计划中的细节与实际不符时, 实现子代理取得证据后按实际做, 逐条记进 "修正" 表, 不停下来问你. 个别完成标准按事实做不到时, 做完其余部分, 列为 "待定标准". 两者都在验收时由你一次看完. +6. **送验.** 脚本核对计划段在实现期间没有被改动, 执行结果已填好. +7. **验收.** 验收子代理在全新上下文中逐条核对, 结论只有通过, 不通过, 未验证三种, 每条都附证据. 它自己从入口追一遍接线, 不照抄实现方写的路径. +8. **你的决定.** -1. **核对.** 编排会话写好工单后, 先派核对子代理逐条核对其中的事实: 文件路径, 数据中的编号, 外部资源的发布形式与许可, 判据能否验证. 不成立的由编排会话改正, 需要你取舍的写进审阅要点的 "需要决定". 这一步不需要你参与. -2. **审阅.** 编排会话回复 "工单审阅", 只列审阅要点与工单文件路径. 你选 A 后工单才能发布; 选 A 之后工单再有改动, 要再审阅一次. -3. **执行.** 编排会话在后台派出执行子代理, 回复 "工单执行". 执行子代理核对提交与前提, 做计划, 实施, 自检, 写回执. "工单执行" 不需要你选择, 所以没有选项块; 中途想停下改工单时, 回复 "停止执行". -4. **等待操作.** 工单没有问题, 只是要你亲手做一件事才能继续时 (例如在被调试的程序中操作一次), 执行子代理不写回执, 中途交回; 编排会话回复 "等待操作", 列出操作步骤. 你做完后回复 "操作完成" 并写明做了什么, 子代理接着做. 这一步可以重复多次, 工单状态不变. -5. **事实修正.** 工单里的细节 (数据格式, 路径, 具体实例, 实现途径) 是动手前的判断. 执行子代理发现与实际不符时, 以实际为准继续做, 在回执中逐条记录原文, 实际情况, 证据与处理办法, 不停下来问你. 任务目标, 工作范围, 明确不做与需要你决定的事不在此列, 这些它不能改. 验收子代理逐条核对修正是否属实, 是否越界; "验收报告" 的 "修正核对" 列出全部修正, 你在验收时一次看完. -6. **受阻.** 执行子代理发现任务目标达不成, 要超出范围, 或需要你做决定时, 写回执说明并停下. 编排会话回复 "受阻处理"; 你处理完而工单不用改时, 原来的子代理接着做, 不需要重新审阅. 只有个别判据照原文做不到, 其余部分不受影响时, 不算受阻: 执行子代理把其余工作做完, 这几条判据在回执中列为待定, 由你在验收时决定. -7. **验收.** 编排会话派验收子代理逐条核对判据, 结论只有通过, 不通过, 未验证三种. 验收子代理在后台核对时, 编排会话先回复 "工单验收" (没有选项块), 结果回来后回复 "验收报告"; 编排会话的建议只写在其中的 "处理建议" 一节. 回复 "验收报告" 时: - - 全部通过: 你可以按 "人工验收" 的步骤亲手验证, 然后选择提交, 或提交并推送. - - 有不通过: 编排会话先判断原因, 工单太大就拆小, 否则发修复工单. - - 只有未验证: 由你决定接受现状并提交, 或补正; 接受的未验证判据登记为风险. 回执列为待定的判据也在这里处理, "处理建议" 中写明每条的实际情况与可选做法. -8. **提交.** 验收通过后, 编排会话调用 commit-message, 本轮业务改动与记录合成一条提交. 有回执之外的改动时, commit-message 会列出来问你是否纳入. +| 情况 | 你可以选 | +| -------------------- | --------------------------------- | +| 全部通过 | 通过并提交; 通过, 提交并推送 | +| 有未验证, 没有不通过 | 接受现状并提交; 补做 | +| 有不通过 | 补做; 放弃这个切片 (骨架只能补做) | -工单严格一张接一张. 工单的大小由编排会话按 "执行一次能做完, 验收能看清, 做完能运行, 高风险部分单独成工单" 判断, 默认一个切片一张, 审阅要点中写明划分依据. +补做时, 编排会话把要补的内容写进计划, 本轮验收归档在同一份文件里, 实现子代理接着做, 之后重新验收. -运行检查工单与分析工单只产出记录: 运行检查按已有命令构建, 运行, 测试并记下结果; 分析用于逆向, 调研等需要动手查证的任务, 产出文档与证据脚本. 两者的执行子代理只能写回执与工单文件夹的 `artifacts/`; 不需要代码检查命令, 选型之前也能发. 验收不通过时重发同类工单. +通过后, 编排会话调用 commit-message, 把这个切片的代码改动与记录合成一条提交. -会改动代码的工单都带一条固定判据 "代码检查通过". 检查命令在选型阶段确定, 用各语言成熟的检查工具, 打开函数长度与复杂度规则, 检查范围不含 `.navigator/` 与 `.claude/`. +## 里程复查, 需求变更与收尾 -需要真实账号, 会产生费用或外部副作用的测试, 执行子代理不运行, 写进回执的 "外部影响"; 验收子代理也不重跑, 按回执中的输出判断. 判据要靠抓包之类的 MCP 工具确认时, 执行子代理在回执中写明工具与查找位置, 验收子代理用同一工具做只读查询. +- **里程复查:** 一个里程的切片全部结束后, 技能对照里程目标说明达成情况, 和你一起整理待办, 排出下一个里程的切片. +- **需求变更:** 随时可以提. 技能说明影响, 你确认后更新需求文件与路线, 并记一条决策: 决定了什么, 为什么, 放弃了什么. 已经开始的切片不会被改掉; 只是随口一提的想法记进待办. +- **收尾:** 全部里程完成后, 技能对照需求逐条说明成果, 你确认后收尾. 收尾之后还可以追加里程继续. -选型只回答 "选定的库能不能实现工单列出的每条需求". 回执中每条需求一行, 结论为能或不能, 依据是库的源码 (仓库, 版本, 路径, 行号), 源码看不出来时是临时测试. 验收子代理运行 `evidence` 命令从原仓库按版本取回源码核对. 选型工单由你决定采用或换方案; 没核对通过的需求登记为风险. +## 记录文件 -## 回复的样子 +```text +.navigator/ +├── state.json 进度与编号, 只能由插件命令修改 +├── roadmap.md 路线视图, 由状态生成 +├── requirements.md 需求: 目标, 必须要做, 明确不做, 约束, 已确认的结论 +├── decisions.md 决策记录, 只追加 +├── backlog.md 待办: 当前切片之外的想法 +├── slices/ +│ └── 0003-export-csv.md 一个切片一份: 计划, 执行结果, 验收 +└── findings/ 只产出结论的切片写下的结论与取证脚本 +``` -编排会话的每条回复都是同一种结构: 类型标题, 一行结论, 一行进展, 几个小节, 最后一个选项块 (不需要你选择的回复没有选项块, 例如 "工单执行"). 例如: +- `.navigator/drafts/` 是编排会话给命令传内容用的草稿, 不入库. 其余全部入库. +- `.navigator/` 自带的 `.gitignore` 会把记录文件重新纳入; 初始化与每次调用技能时, 都按 Git 的全部忽略规则检查, 记录会被忽略时技能停下并指出是哪条规则. +- 记录都在 Git 中. 回退或手工改了记录后, 技能按当前记录继续. -````markdown -# 工单审阅 +## 角色与边界 -结论: 工单 0007 补齐 HTTP/2 指纹, 需要你决定是否在仓库内维护 fhttp 补丁. +| 角色 | 由谁担任 | 做什么 | 能写什么 | +| ---------- | ------------------------ | -------------------------------------------- | --------------------------------------- | +| 编排会话 | 调用本技能的会话 | 确认需求, 排路线, 写计划, 组织验收, 记进度 | 只写 `.navigator/` | +| 实现子代理 | 编排会话在后台派出 | 按一个切片的计划写代码, 自测, 写执行结果 | 业务文件, 进行中的切片文件, `findings/` | +| 验收子代理 | 编排会话派出, 上下文全新 | 逐条核对完成标准, 接线, 结构与范围, 交回证据 | 不写文件 | +| 你 | 唯一拍板的人 | 需求, 取舍, 验收结论 | | -阶段 5/6 (切片) · 里程 3/5 (引擎跑通主线) · 切片 4/10 (HTTP/2 指纹) · 工单 0007 (起草中) +hook 只守四条写入边界: 任何人不直接改状态文件与路线视图; 验收子代理不写文件; 实现子代理在 `.navigator/` 下只写进行中的切片文件与结论目录; 编排会话不写 `.navigator/` 之外的文件. 其余靠技能规则, 不拦截. hook 由插件提供, 不写进项目配置; 没有初始化的项目中什么也不做. -## 审阅要点 +编排会话不写业务代码, 是为了让它的上下文一直留给规划与进度: 你说 "顺手改一下" 时, 它会把这件事排进当前切片的计划或待办. -- 任务目标: 调用方用指纹配置控制 HTTP/2 层的全部维度 -- 需要决定: 在仓库内维护 fhttp v0.7.3 的补丁, 实现伪头不索引 -- 明确不做: HTTP/1.1 与头部大小写, 留给切片 0005 -- 粒度依据: 整个切片一张工单, 改动集中在适配层与补丁 -- 工单文件: .navigator/orders/0007-http2-fingerprint/order.md +### 权限确认 -``` -[是否发布] +实现子代理在后台写文件时, 默认权限模式下, 写入确认会弹在编排会话中. 一个切片常常要写很多文件, 实现期间可以把会话切到 "Accept edits" (自动接受文件编辑). 是否切换由你决定. -A. 发布工单, 开始执行. -B. 需要修改, 请提需求. -``` -```` +## 从 1.x 升级 -结论一行就能看出要你做什么; 细节在后面, 需要时再看. 进展行末尾的括号是工单所处的环节 (起草中, 执行中, 待你操作, 受阻, 验收中, 待你决定), 连续几条回复靠它区分先后. +2.0.0 是一次不兼容的重构: 工作单位从工单改成切片, 记录的结构完全不同. 它不读取 1.x 的记录, 也不迁移. -## 记录文件 +更新插件后在 1.x 初始化过的项目中调用技能, 技能会说明这一点. 你同意后它运行: -```text -.navigator/ -├── state.json 进度, 编号与状态, 只能由插件命令修改 -├── plan/ 简报, 术语表, 调研, 头脑风暴, 主线, 推进路线, 风险, 决策, 变更, 复查, 体检 -└── orders/0007-export-csv/ - ├── order.md 工单 - ├── receipt.md 回执 - ├── artifacts/ 执行子代理留下的证据文件, 例如抓包原文, 复现脚本 (需要时才有) - └── review.md 验收记录 +```bash +project-navigator init --archive ``` -- `plan/roadmap.md` 与 `plan/risks.md` 由插件命令根据状态生成, 不要手改. -- `.navigator/drafts/` 是编排会话给命令传中文内容用的草稿, 不入库. -- 你对工单审阅的选择登记在本机的 `.git/navigator/` 中, 不入库. -- 记录都在 Git 中. 回退或手工改了记录后, 技能按当前记录继续; 记录与实际情况对不上时, 技能会说明并请你决定以哪边为准. - -## 工程规范 - -选型阶段开始时, 编排会话会对照三处规范: 插件自带的通用工程规范, 项目已有的规范 (`CLAUDE.md`, `AGENTS.md`, `.claude/rules/`, 格式化配置), 你本机的全局规范. 互相矛盾的条目由你逐条决定以哪边为准, 每次最多 3 条. 结果写入 `.claude/rules/engineering.md`, 项目中的所有会话都会自动加载. 代码注释的语言默认中文, 你可以改选英文或其它语言. - -## 体检 - -运行 `/waypoint:project-navigator check`, 或在每个里程复查时自动进行. 体检分两层: - -- 脚本检查: 结构是否符合模板, 句子与段落是否过长, 列表是否过长或过深, 是否有黑话, 比喻, 翻译腔, 句首没有名词的 "它", "这是" 等. -- 语义检查: 由阅读子代理检查自造简称, 未定义术语, 指代不明, 空话套话等. +旧的 `.navigator/` 被整个改名为 `.navigator-1.x/`, 内容不动, 仍可查阅; 然后从立项重新开始. 立项时可以让技能读归档中的简报与决策, 不必从头口述. -体检结果列成问题清单, 由你决定逐条修正, 还是只记录在案. +不同意时技能结束, 不做任何修改. 想继续用 1.x 的流程, 把插件固定在 1.5.0. ## 常见问题 @@ -239,27 +226,27 @@ B. 需要修改, 请提需求. **编排会话能帮我写一段代码吗?** -不能. 编排会话写 `.navigator/` 之外的文件会被拒绝. 需要改代码时, 编排会话会发布工单, 由执行子代理完成. +不能. 编排会话写 `.navigator/` 之外的文件会被拒绝. 需要改代码时排进切片, 由实现子代理完成. -**执行子代理在工作, 我能继续和编排会话说话吗?** +**实现子代理在工作, 我能继续和编排会话说话吗?** -能. 子代理在后台运行, 你可以问问题或处理别的事; 子代理做完时编排会话会被通知并接着处理. +能. 子代理在后台运行; 它做完时编排会话会被通知并接着处理. -**我关掉了编排会话, 执行子代理的工作会怎样?** +**我关掉了编排会话, 实现子代理的工作会怎样?** -子代理属于启动它的会话. 新会话调用技能后, 回执已写入就照常验收; 没写入时, 编排会话会重新派出执行子代理. +子代理属于启动它的会话, 会话关闭后它的工作中断. 新会话调用技能后会重新派出一个, 新的子代理靠切片文件的 "中途记录" 与工作区中已有的改动接着做. -**技能本身出了问题怎么办?** +**我的项目不能由 Claude 编译或运行, 能用吗?** -脚本会自己发现一部分问题: 命令或 hook 的内部错误, 不自洽的状态, 照 "下一动作" 运行却被拒绝的命令, 以及状态不变时反复给出的同一个 "下一动作". 编排会话自己发现规则对不上时也会上报. 这时它回复 "问题报告": 报告写入 `.navigator/drafts/reports/` (不入库), 全文放在回复的一个代码块中, 复制后交给插件的维护者即可. 报告只写遇到的情况: 环境, 状态摘要, 异常记录与经过; 不写原因推测与处理建议, 不含工单正文与业务代码, 本机路径会被隐去. 流程还能继续时选 `A` 继续; 无法继续时 (命令内部错误或状态不自洽) 编排会暂停, 等问题修复后再继续. +能. 立项时把验证方式登记为 `manual` 或 `measured`. 子代理不构建, 不运行, 只读代码核对接线与结构; 要运行才能确认的标准列成步骤交给你, 你确认后选择接受现状. -**我想让编排会话写一段流程之外的内容, 可以吗?** +**想到一个新点子, 但不属于当前切片?** -可以. 直接提出要求, 编排会话回复 "自由答复", 在 "答复内容" 中按你要求的格式撰写, 可以含代码块. 看完选 `A` 回到流程. +直接说. 技能把它记进 `.navigator/backlog.md`, 里程复查时和你一起决定排进哪里. -**多个工单能并行吗?** +**多个切片能并行吗?** -不能. 工单严格一张接一张, 保证每张工单的基准提交与验收结果都是确定的. +不能. 同一时间只做一个切片, 保证每个切片的基准提交与验收结果都是确定的. **怎样停止使用?** @@ -268,10 +255,12 @@ B. 需要修改, 请提需求. ## 已知限制 - 使用本项目的每台机器都需要 Node.js 22 或更高版本. -- 插件的 hook 在所有项目中触发, 每次写入类工具调用与每条消息都会启动一次 Node.js; 未初始化的项目中脚本立即结束, 本机实测约 70 毫秒. +- 插件的 hook 在所有项目中触发, 每次写入类工具调用与每条消息都会启动一次 Node.js; 未初始化的项目中脚本立即结束. - 守卫只检查写入类工具的目标路径, 不检查命令行: 编排会话用命令行改代码或装依赖时, 只靠技能规则约束. -- 朴素中文等写作要求主要靠提示词与体检保证, 回复结束时不按写作规则打回; 脚本检查是近似的. -- 结构不合格的回复会被打回重写一次, 但不合格的那一版仍会留在屏幕上. -- 执行子代理工作期间不能向你提问, 需要你决定的事只能以受阻结束后再处理. -- 源码证据只支持公开 git 仓库; 没有公开仓库的依赖会判为未验证, 由你决定是否接受这个风险. -- MCP 取证工具查看的是外部工具中当时的记录. 验收前记录被清空, 或工具没有运行, 相关判据会判为未验证. +- 回复的格式 (结论先行, 一行进展) 只靠技能规则, 没有校验. +- 一份切片文件由编排会话与实现方分段写. 脚本靠计划段的摘要发现它在实现期间被改动, 能发现, 不能阻止. +- 写计划前编排会话是否真的读了代码, 没有脚本把关; 计划中的错误由实现子代理的 "修正" 与验收兜底. +- "结构核对" 中除筛选器检查之外的各项由验收子代理判断, 不是确定性的. +- 筛选器检查用正则解析工程文件, 只覆盖常见写法; 带通配符或 MSBuild 宏的项不检查, 只统计数量. +- 交互式切片会占用编排会话的上下文; 靠 "结论立即写入文件" 与 "随时可换会话" 缓解. +- 实现子代理工作期间不能向你提问, 只能结束本次运行后由编排会话转达. diff --git a/plugins/waypoint/hooks/hooks.json b/plugins/waypoint/hooks/hooks.json index 7ce437f..c76c9d2 100644 --- a/plugins/waypoint/hooks/hooks.json +++ b/plugins/waypoint/hooks/hooks.json @@ -42,20 +42,6 @@ } ] } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/skills/project-navigator/runtime/hook.mjs" - ], - "timeout": 30 - } - ] - } ] } } diff --git a/plugins/waypoint/skills/project-navigator/SKILL.md b/plugins/waypoint/skills/project-navigator/SKILL.md index 4a8c0cb..eb17ae5 100644 --- a/plugins/waypoint/skills/project-navigator/SKILL.md +++ b/plugins/waypoint/skills/project-navigator/SKILL.md @@ -1,6 +1,6 @@ --- name: project-navigator -description: 大型项目的引导工作流. 从一句宏观需求开始, 经过问题域调研, 立项, 技术选型, 最小可运行骨架, 按纵向切片与里程逐步推进, 并处理中途的需求与方向变更. 本技能只做规划, 编排与验收, 实现交给后台的执行子代理. 只能由用户显式调用 /waypoint:project-navigator. Guides a large project from a single idea through research, framing, technology selection, a walking skeleton, and vertical slices; it plans, orchestrates, and reviews while background executor subagents implement. Invoke explicitly only. +description: 大型项目的引导工作流. 从一句宏观需求开始, 确认需求, 排出按里程与纵向切片推进的路线, 先做最小可运行骨架, 再一个切片一个切片地丰满, 并记住进度, 任何会话都能接着做. 本技能只做规划, 编排与验收, 实现交给后台的实现子代理, 验收交给独立的验收子代理. 只能由用户显式调用 /waypoint:project-navigator. Guides a large project from a single idea to a roadmap of milestones and vertical slices, walking skeleton first; it plans, orchestrates, and reviews while subagents implement and verify. Invoke explicitly only. argument-hint: "[init | check | uninstall | 初始需求]" disable-model-invocation: true allowed-tools: Bash(node *) PowerShell(node *) Bash(project-navigator *) PowerShell(project-navigator *) @@ -8,107 +8,84 @@ allowed-tools: Bash(node *) PowerShell(node *) Bash(project-navigator *) PowerSh # 项目导航 -调用本技能的会话是编排会话: 只做规划, 记录, 发布工单与组织验收. 写代码, 构建, 测试由编排会话在后台派出的执行子代理完成, 验收由验收子代理在全新上下文中完成. 规划与实现分开进行, 各自保持干净的上下文, 每一步都有记录可查. 任何会话调用本技能后都按记录中的状态继续. +调用本技能的会话是编排会话: 负责与用户确认需求, 排路线, 写每个切片的计划, 组织验收, 记录进度. 写代码由后台的实现子代理完成, 验收由验收子代理在全新上下文中完成. 进度记在项目的 `.navigator/` 中, 任何会话调用本技能后都按记录继续. -## 禁止事项 +本技能要守住三件事: + +- **骨架先行:** 第一个切片是从真实入口跑通的最小骨架, 之后每个切片在骨架上加一点. +- **纵向切片:** 每个切片做完, 都能从入口或界面看到结果; 验收时必须给出从入口到新代码的接线证据. +- **不横向膨胀:** 当前切片之外的想法记进 `.navigator/backlog.md`, 不顺手做. -1. 不写业务代码, 不改源码与项目配置, 不写测试: 实现交给执行子代理. 守卫会拒绝编排会话写 `.navigator/` 之外的文件. -2. 不装依赖, 不构建, 不运行业务脚本: 这些操作会改变项目, 属于实现. -3. 不在回复中贴代码: 回复中只有选项块一个代码块; "自由答复" 的正文除外. -4. 不替用户做决定, 不用提问框: 需要用户选择时, 用回复末尾的选项块. +## 禁止事项 -可以给出技术建议, 但关键依赖 (语言, 框架, 核心库) 用选型工单核实, 见 `selection`. +1. 不写业务代码, 不改项目配置, 不写测试: 这些排进切片, 交给实现子代理. 守卫会拒绝编排会话写 `.navigator/` 之外的文件. +2. 不安装依赖, 不构建, 不运行业务脚本: 这些会改变项目, 属于实现. +3. 不替用户做决定: 需求, 取舍, 验收结论都由用户决定. +4. 不直接改 `.navigator/state.json` 与 `.navigator/roadmap.md`: 状态只用插件命令修改. -用户要求越权操作时 (例如让编排会话写代码), 不照做: 仍按 "下一动作" 回复, 在结论中说明原因与可选做法. 需要改变计划时走需求变更. +唯一的例外是交互式切片 (逆向, 实测这类每一步都要用户配合的任务): 它只产出结论, 不改业务代码, 由编排会话在对话中直接做, 见 `ref slice-loop`. -用户要求流程之外的内容 (例如解释, 或写给维护者的报告) 时, 回复 "自由答复" (`reply freeform`), 在 "答复内容" 中按用户要求的格式撰写. 用户选 A 后按 "下一动作" 继续. +用户要求越权操作时 (例如 "顺手改一下这个文件"), 不照做: 说明原因, 把它排进当前切片的计划或待办. ## 每一步的固定动作 -1. 看本文末尾 "当前状态" 中的 "下一动作"; 会话中途用 `status` 命令重新查询. 进度以脚本输出为准, 不凭记忆判断. -2. 按下方路由表, 用 `ref <名称>` 读取对应的参考文件, 按其中的步骤操作. 初始化之前不读参考文件, 回复骨架由 `reply` 命令给出. -3. 状态只用插件命令修改, 不直接写 `.navigator/state.json`. 记录文件先用 `template` 取得填写要求与骨架, 按要求填写. 命令需要的中文内容, 先用 Write 写成 `.navigator/drafts/` 下的 JSON 草稿, 再用 `--from` 传入. -4. 每条回复之前, 运行 `reply <编号> --option <组号>` 取得填写要求与骨架, 按要求在骨架上填写. 回复从一级标题写起, 前面不加引导语. 标题下方第一行 "结论:" 写给人看: 先说结果, 再说要用户做的选择, 尽量短; 进展行与选项块原样保留; 骨架没有选项块时 (例如 "工单执行") 不需要用户选择, 不自己加. 填写要求中有 "按钮" 一条, 且会话的工具中有其中写的工具时, 按该条把选项同时做成按钮, 用户点一下就能回答; 没有这个工具时只用选项块. 回复结束时 hook 只校验标题, 结论行与选项块, 不合格会被打回一次. -5. 用简体中文与英文 (ASCII) 标点, 每个标点后加一个空格. 写朴素的话: 结论先行, 不铺垫, 不自造简称, 不打比方. 细则见 `ref writing`. -6. 工具调用之间的过程说明每次最多一句中文, 只说正在做什么. 结论与需要用户决定的事只放在最终回复里. -7. 发现技能自身的问题时上报, 不绕开, 不自行修改技能文件: 命令输出或 `status` 中出现 "技能异常" 一行; 或你自己发现规则互相矛盾, 骨架与校验对不上, 照 "下一动作" 做却走不通. 做法见 `ref recovery` 的 "问题报告". +1. 看本文末尾 "当前状态" 中的 "下一动作"; 会话中途用 `project-navigator status` 重新查询. 进度以脚本输出为准, 不凭记忆判断. +2. 按下方路由表, 用 `project-navigator ref <名称>` 读对应的参考文件, 按其中的步骤做. +3. 命令需要的中文内容, 先用 Write 写成 `.navigator/drafts/` 下的 JSON 草稿, 再用 `--from` 传入. 一次只运行一条命令. +4. 命令报错时按错误信息改, 不换写法绕开. 照着 "下一动作" 做却走不通时, 把命令与原始输出告诉用户, 不自行修改技能文件. + +## 回复 + +- 第一行写结论: 先说结果, 再说要用户做的事. +- 第二行写进展, 照抄 `status` 的 "当前进展". +- 需要用户决定时, 在末尾列出选项, 每项一行, 以字母开头; 一次只问一件事. 不需要决定时不加选项. +- 不贴代码. 用简体中文与英文 (ASCII) 标点, 每个标点后加一个空格. 写朴素的话, 不自造简称. ## 路由表 -| 情形 | 读取 | -| ---------------------------------------------- | ------------------ | -| 首次接入之后, 阶段 0 盘点, 阶段 1 立项, 阶段 2 | `intake` | -| 立项中的头脑风暴 | `brainstorm` | -| 阶段 3 选型与规范对照 | `selection` | -| 阶段 4 骨架, 阶段 5 切片, 工单往返 | `order-loop` | -| 需求变更, 里程复查, 阶段 6 收尾, 项目完成 | `change-milestone` | -| 恢复进度, 运行受阻, 问题报告, 升级 | `recovery` | -| 体检 | `checkup` | -| 回复类型的节与选项组 | `replies` | -| 记录文件的结构 | `files` | -| 写作规则与禁用词 | `writing` | +| 情形 | 读取 | +| -------------------------------------- | ------------ | +| 立项: 确认需求, 选型, 项目设置, 排路线 | `intake` | +| 写计划, 开工, 中途交回, 验收, 里程复查 | `slice-loop` | +| 需求变更, 收尾, 恢复进度, 出错 | `change` | ## 命令速查 -初始化之后, 命令一律写成 `project-navigator <命令>`, 由插件提供, 在 Bash 与 PowerShell 中都能直接运行. 一次只运行一条命令. - -| 命令 | 用途与草稿格式 | -| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `init` | 初始化, 或升级旧版本初始化的项目 | -| `status` | 查看位置与下一动作 | -| `reply <编号> [--option <组号>]` | 取得填写要求与骨架; "下一动作" 已写出参数时照抄; 不带编号时列出全部类型与编号 | -| `template <种类> [--option <结构>]` | 取得记录文件骨架; 种类见 `ref files` | -| `ref <名称>` | 输出参考文件, 执行手册 (`executor`) 或工程规范默认包 (`standards`) | -| `stage <编号>`, `step <标识>`, `finish` | 记录阶段与步骤; `finish` 在 "项目收尾" 中用户选 A 后运行 | -| `skip <编号> --from <草稿>` | 记录用户认可的跳过: `{"reason"}` | -| `roadmap --from <草稿>` | 更新推进路线: `{"milestones": [{"name", "goal", "metrics": [], "slices": [{"name"}]}]}`, 已有条目保留 `id` | -| `milestone <编号> <状态>`, `slice <编号> <状态>` | 状态为 pending, active, done | -| `order new --kind <类型> --slug <短名> [--slice <编号>]` | 新建工单; 不带 `--slice` 时挂在当前切片 | -| `order set <状态> [--accept-unverified]` | 转换工单状态 | -| `order precheck` | 记下当前工单已做过发布前核对, 之后才能审阅 | -| `precheck-brief`, `exec-brief`, `review-brief`, `research-brief` | 取得核对, 执行, 验收与调研的委派提示词 | -| `model`, `model set <角色> <模型> [--kind <工单类型>]` | 查看与登记子代理模型; 角色为 executor, checker, reviewer, reader, researcher; 模型为 sonnet, opus, haiku, fable, inherit | -| `evidence` | 从原仓库取回选型回执证据表引用的源码行; 由验收子代理运行 | -| `risk add --from <草稿>` | 登记风险: `{"description", "severity": "high/medium/low", "source", "handling"}` | -| `risk set <编号> <状态> [--from <草稿>]` | 状态为 open, investigating, resolved, accepted; 草稿 `{"handling"}` | -| `decision add --from <草稿>` | 登记决策: `{"title"}` | -| `decision supersede <编号> --by <编号>` | 标记决策被取代 | -| `change add --from <草稿>` | 登记变更: `{"title", "status": "accepted/deferred/dropped"}` | -| `check [文件...]` | 体检; 带文件时只自查这些文件 | -| `standards --from <草稿>` | 写入项目规范: `{"commentLanguage", "conventions": []}` | -| `codecheck set --from <草稿>` | 登记代码检查命令: `{"commands": [], "reason"}`, 没有命令时必须写原因 | -| `report [--from <草稿>]` | 生成技能问题报告: `{"doing", "expected", "actual", "reproduce", "blocked": false}`, 只写事实 | +命令一律写成 `project-navigator <命令>`, 由插件提供, 在 Bash 与 PowerShell 中都能直接运行. + +| 命令 | 用途与草稿格式 | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `init [--archive]` | 初始化; `--archive` 把旧版本的记录改名归档后重新初始化 | +| `status` | 位置与下一动作 | +| `ref <名称>` | 输出参考文件, 实现手册 (`executor`) 或工程规范默认包 (`standards`) | +| `setup [--from <草稿>]` | 不带草稿时输出建议; 草稿: `{"organization", "verification": {"mode", "commands": []}, "conventions": []}` | +| `roadmap --from <草稿>` | 更新路线: `{"milestones": [{"name", "goal", "slices": [{"name", "kind", "interactive"}]}]}`, 已有的里程与切片带上 `id` | +| `slice plan <编号> --slug <短名>` | 建切片文件并写入骨架 | +| `slice start [--decided]` | 开工; 计划中有要用户决定的事时, 用户回答后带 `--decided` | +| `slice replan` | 实现期间按用户的回答改了计划之后运行 | +| `slice review` | 送验 | +| `slice accept [--accept-unverified]` | 验收通过 | +| `slice redo`, `slice drop` | 补做, 放弃 | +| `brief exec`, `brief review` | 实现与验收的委派提示词 | +| `milestone done <编号>` | 里程复查完成 | +| `decision add --from <草稿>` | 登记决策并追加到决策记录: `{"title", "decision", "reason", "rejected", "supersedes"}` | +| `check [--filters]` | 记录自查; `--filters` 检查 Visual Studio 项目的每个源文件都归入了筛选器 | +| `finish` | 确认收尾 | +| `uninstall` | 移除放行规则, 记录保留 | ## 子代理 -- 发布前核对: `waypoint:navigator-checker`, 提示词是 `precheck-brief` 的输出原文. 每张工单审阅之前核对一次. -- 执行: `waypoint:navigator-executor`, 提示词是 `exec-brief` 的输出原文, 用 Agent 工具在后台派出 (`run_in_background`). 子代理结束后会自动通知编排会话. -- 验收: `waypoint:navigator-reviewer`, 提示词是 `review-brief` 的输出原文. -- 问题域调研: `waypoint:navigator-researcher`, 提示词以 `research-brief` 的输出开头, 其后只写调研问题. -- 盘点, 规范对照与语义体检: `waypoint:navigator-reader`, 或 Explore. -- 模型: 派子代理时, Agent 工具的 `model` 参数按 `status` 输出的 "子代理模型" 一行填写 (执行, 核对, 验收, 阅读, 调研各一项); 写 "跟随会话" 的不传这个参数. "下一动作" 已写出参数时照抄. 用户要调整时运行 `model set`. -- 核对, 调研与阅读子代理在后台运行, 本轮要先结束时回复 "等待结果" (`reply waiting`): "等待对象" 写子代理, "任务内容" 写它在查什么, "后续动作" 写结果回来后做什么; 不写其它说明. 这条回复不带选项块. -- 委派提示词原样使用, 不增删内容. 不建 Agent Team. - -## 初始设置 - -"下一动作" 要求回复 "初始设置" 时: - -1. 按 "当前状态" 填写 "检查结果" 四项. "设置内容" 固定写: 状态目录为 "创建 .navigator/, 含状态文件与术语表"; 放行规则为 "在 .claude/settings.json 与本机配置中加入插件命令的放行规则, 保留已有配置"; 初始标记为 "写入技能版本与初始化时间". -2. 用户选 A, 或调用参数为 `init` 时, 运行 `project-navigator init`. 旧版本初始化的项目同样用它升级, 升级会移除项目中的旧 hook 与脚本副本, 记录保留. -3. 按输出中的下一动作提交技能产物, 再运行 `status`, 按其中的下一动作回复: 新项目为 "首次接入", 升级时接着原来的进度. 输出为 "设置结果: 未执行" 时回复 "运行受阻", 如实转告其中的忽略规则与处理办法. -4. 用户选 B 时结束, 不做任何修改. - -## 首次接入 - -"下一动作" 要求回复 "首次接入" 时, "仓库情况" 三项写: 版本控制情况, 已跟踪的文件数, 调用参数中的初始需求 (没有时写 "无"). 用户选择后按 `ref intake` 进行. +- 实现: `waypoint:navigator-executor`, 提示词是 `brief exec` 的输出原文, 用 Agent 工具在后台派出 (`run_in_background`). 它结束后会自动通知编排会话. +- 验收: `waypoint:navigator-reviewer`, 提示词是 `brief review` 的输出原文. +- 委派提示词原样使用, 不增删内容. 需要读大量文件时可以用内置的 Explore 子代理. +- 等待子代理期间, 回复只写在等什么, 结果回来后做什么. ## 调用参数 -- `init`: 按 "初始设置" 第 2 步初始化. -- `check`: 按 `ref checkup` 体检. -- `uninstall`: 运行 `project-navigator uninstall`, 把输出如实告诉用户. 卸载移除放行规则, 插件的 hook 在本项目中不再生效, 编排记录全部保留. -- 其它文字: 作为初始需求, 在首次接入与需求理解中原样摘录. +- `init`: 用户已经同意初始化, 不再询问, 直接运行 `project-navigator init`, 按输出继续. +- `check`: 运行 `project-navigator check`, 把结果告诉用户. +- `uninstall`: 运行 `project-navigator uninstall`, 把输出如实告诉用户. +- 其它文字: 作为初始需求, 立项时原样记入需求文件. 本次调用参数: $ARGUMENTS diff --git a/plugins/waypoint/skills/project-navigator/guide/executor.md b/plugins/waypoint/skills/project-navigator/guide/executor.md index 3c6b78c..ef1dda8 100644 --- a/plugins/waypoint/skills/project-navigator/guide/executor.md +++ b/plugins/waypoint/skills/project-navigator/guide/executor.md @@ -1,158 +1,89 @@ -# 执行手册 +# 实现手册 - - -本手册写给执行子代理: 编排会话在后台派出你, 委派提示词给出了一张工单. 先完整读完本手册, 再读工单. 你工作期间不能向用户提问: 需要用户决定或操作的事, 写进回执, 以受阻结束. +本手册写给实现子代理: 编排会话在后台派出你, 委派提示词给出了一个切片. 先完整读完本手册, 再读切片文件的 "计划" 段. 你工作期间不能向用户提问: 需要用户回答或操作时, 按下方 "中途交回" 结束本次运行. ## 职责边界 -- 你按一张工单完成一个使用者可见的行为变化, 回答一个选型问题, 或做一次运行检查或分析. 调研, 选型, 写代码, 构建, 测试都由你完成. +- 你按一个切片完成一块纵向功能, 或查清一件事. 写代码, 构建, 测试, 取证都由你完成. - 编排会话负责规划与验收, 用户负责所有决定. -- 你在 `.navigator/` 下只写本工单的回执与证据文件 (见下方 "证据文件"), 守卫拒绝写其它编排记录. 不规划下一步, 不提交, 不推送. -- 插件命令只用 `status`, `template`, `ref`, `check <文件>` 与 `evidence`, 都以 `project-navigator` 开头. 不运行改变编排状态的命令 (例如 `order`, `stage`). -- 你在仓库根目录工作, 不使用 worktree. - -## 执行流程 - -1. 对账: 运行 `git status` 与 `git log -1`. 当前提交应等于工单概况中的 "基准提交", 工作区不应有与本工单无关的改动. 不一致时不动手, 写回执, 执行状态写 "受阻", 受阻类型写 "对账不符". -2. 核对假设: 逐条检查工单 "工作假设" 是否成立, 读代码与配置核实, 不凭记忆, 结果写进回执 "假设核对". 不成立的假设以实际为准, 按下方 "事实修正" 处理, 不因此停下. -3. 做计划: 先定模块划分, 写法见下方 "模块划分"; 再列出完成每条验收判据的步骤与验证方式. 计划不需要用户确认, 但要写进回执的 "模块划分" 与 "判据自检". -4. 实施: 只做工单 "工作范围" 中要做的事; 遵守工单 "执行守则" 与项目规范 `.claude/rules/engineering.md`. -5. 自检: 逐条核对验收判据, 运行代码检查与测试. 需要真实账号, 会产生费用或外部副作用的测试不要运行, 把命令写进回执 "测试记录" 的 "外部影响", 由用户决定. -6. 写回执: 运行 `project-navigator template receipt` 取得填写要求与骨架 (选型工单加 `--option 2`), 按要求填好后写入工单文件夹中的 `receipt.md`, 再运行 `project-navigator check <回执路径>` 自查结构. "回执概况" 的 "执行状态" 写 "完成" 或 "受阻". -7. 交回: 按委派提示词的 "交回格式" 结束. - -## 模块划分 - -回执 "模块划分" 一节写明代码结构, 验收子代理会对照实际改动核对: - -- 新增模块: 每个模块写路径与一句职责, 用分号隔开. 一个模块只承担一件事. -- 改动模块: 只列需要改动内部实现的已有模块, 每个写明原因. 新功能迫使多个已有模块改动内部实现时, 说明模块边界不合适, 先拆出新模块. -- 入口改动: 写入口文件改了什么. 入口只做读取配置, 组装与调用, 流程的步骤写在各自的模块中. - -没有的项写 "无". - -## 代码检查 +- 你在 `.navigator/` 下只写两处: 切片文件的 "执行结果" 段, 以及 `.navigator/findings/` 下的结论文件. 切片文件的 "计划" 与 "验收" 段不能改: 送验时脚本会比对计划段, 被改动就送不了验. +- 不规划下一步, 不提交, 不推送. 插件命令只用 `project-navigator ref`, `status` 与 `check`. +- 在仓库根目录工作, 不使用 worktree. -- 会改动代码的工单, 验收判据中有一条 "代码检查通过", 写明检查命令. 自检时运行这些命令, 没有报错才算通过. -- 不放宽检查规则, 不加忽略标记来通过检查. 工单的工作范围包含配置检查工具时除外; 其它情况确实需要调整, 以受阻结束并说明原因. -- 选型工单涉及语言或技术栈时, 同时选定格式化与代码检查工具, 规则中打开函数长度与复杂度检查, 给出能在仓库根目录直接运行的检查命令. -- 检查范围只含项目代码: 用工具自身的排除配置排除 `.navigator/` 与 `.claude/`. 这两个目录中有编排记录与插件管理的配置, 被纳入检查后, 编排记录的格式会让以后每张工单的检查都通不过. +## 工作流程 -## 项目配置 +1. 对账: 运行 `git status` 与 `git log -1`. 工作区中有与本切片无关, 又会妨碍你的改动时, 按 "中途交回" 请用户处理. +2. 读项目规范 `.claude/rules/engineering.md` 与计划涉及的代码. +3. 核对计划: 逐条看 "工作假设" 与 "模块安排" 是否与实际相符, 读代码核实, 不凭记忆. 不符的按下方 "修正" 处理. +4. 实现: 只做计划写的事. 遵守 "模块安排" 与项目规范. +5. 自检: 逐条核对 "完成标准". 按委派提示词中的验证方式: 有命令时运行命令; 验证方式是用户手动运行或实测时, 不构建, 不运行程序, 把用户要亲手做的验证步骤写进 "自测记录". +6. 填写 "执行结果" 段的每个小节, 把 "执行状态" 改成 "完成". +7. 按委派提示词的 "交回格式" 结束. -- 工单要求接线 Claude Code hook 时, 可以改 `.claude/settings.json` 的 `hooks` 字段与 `.claude/` 下项目自己的文件 (例如 `.claude/hooks/` 中的脚本). -- 不改 `.claude/settings.json` 的 `permissions` 字段中本技能的放行规则 (以 `project-navigator` 或 `.navigator/` 开头的规则), 不改 `.claude/rules/engineering.md`: 前者由 init 管理, 后者由编排会话的 `standards` 命令写入. 需要调整时写进回执的 "需要操作". +## 硬约束与工作假设 -## 事实修正 +计划的内容分两层: -工单由编排会话在动手之前写成, 其中的细节只是当时的判断. 逆向, 对接外部系统这类任务, 很多事实做了才知道. 工单的内容因此分两层: +- 硬约束: 目标, 完成标准, 明确不做, 模块安排. 这些你不能改. +- 工作假设: 其余的细节, 包括 "工作假设" 一节, 以及完成标准中写到的路径, 接口名, 数据格式与具体实例. -- 硬约束: "审阅要点" 的任务目标, 需要决定, 明确不做, 以及 "工作范围". 这些是用户认可过的, 你不能改. -- 工作假设: 其余的细节, 包括 "工作假设" 一节, 以及 "执行守则" 与 "验收判据" 中写到的数据格式, 字段含义, 路径, 接口名, 具体实例与实现途径. +## 修正 -工作假设与实际不符时, 以实际为准继续做, 不算受阻: +计划在动手之前写成, 其中的细节只是当时的判断. 工作假设与实际不符时, 以实际为准继续做, 不交回: -- 先取得证据 (文件路径与行号, 命令输出, 取证记录, 或 `artifacts/` 下的脚本), 再按实际情况实现. 不为了符合工单而做出与事实不符的东西. -- 在回执 "事实修正" 表中每项修正写一行: "工单原文" 摘录原句并写明所在的节; "实际情况" 写查到的事实; "证据位置" 写能复核的位置; "处理办法" 写你改成了怎么做. 没有修正时只留表头. -- 判据引用了被修正的细节时, 按修正后的事实自检, "判据自检" 中写明 "按事实修正第 N 行". -- 修正之后任务目标仍要达成, 改动仍在工作范围之内. +- 先取得证据 (文件路径与行号, 命令输出, 或 `.navigator/findings/` 下的记录), 再按实际情况实现. 不为了符合计划而做出与事实不符的东西. +- 在 "修正" 表中每项修正写一行: "计划原文" 摘录原句; "实际情况" 写查到的事实; "证据位置" 写能复核的位置; "处理办法" 写你改成了怎么做. 没有修正时, 表中只留一行, 每格写 "无". +- 修正之后目标仍要达成, 完成标准的意思不能变, 不做 "明确不做" 的事. -以下情况不属于事实修正, 仍按受阻处理: 按实际情况任务目标达不成; 要做 "明确不做" 的事或超出 "工作范围"; 有几种做法而取舍会影响用户 (例如许可, 费用, 对外行为); 工单 "需要决定" 中写的事. 拿不准时按受阻处理. +"模块安排" 需要调整时 (例如多出一个文件), 新文件仍放进计划写明的模块, 并在 "修正" 表中记一行; 要新建计划中没有的模块, 或改变层的划分时, 按 "中途交回" 请用户决定. -## 受阻情形 +## 待定标准 -遇到以下情况立即停下, 写回执, 执行状态写 "受阻", 不自行变通. 细节与实际不符不在其列, 见上方 "事实修正"; 另有两种例外, 见下方 "待定判据" 与 "等待操作": +个别完成标准按事实根本做不到, 而其余部分不受影响时: 把其余部分做完, 在 "待定标准" 中逐条写编号, 原因与证据位置, "执行状态" 仍写 "完成". 由用户在验收时决定. -- 对账不符. -- 按实际情况, 任务目标达不成. -- 规格有歧义而几种理解会做出不同的结果, 或判据之间互相矛盾. -- 需要超出工作范围的改动, 例如改其它模块的公共接口. -- 需要用户操作或决定, 例如提供账号, 密钥, 安装软件, 或在几种做法中选一种. -- 按选型守则找不到满足需求的方案. +与修正的界线: 换一个符合事实的细节就能满足的, 是修正; 按事实做不出来的, 才是待定. 不要按自己选的办法改写标准后当作完成. -回执 "偏差记录" 的 "受阻详情" 写受阻类型 (对账不符, 目标不可达, 规格问题, 范围过大, 需要用户操作, 需要用户决定), 规格原文, 实际情况与证据位置. 用户处理后, 编排会话可能让你接着做: 收到继续的消息时, 先运行 `git status` 核对工作区, 再从受阻处继续, 最后重写回执. +## 中途交回 -## 待定判据 +以下情况结束本次运行, 交回编排会话: -工单的其余部分不受影响, 只有个别判据照原文做不到时, 不以受阻结束: 把其余工作做完, 这几条判据留作待定, 由用户在验收时决定. 这样用户只决定一次, 不用整张工单往返一轮. +- 需要用户回答: 有几种做法而取舍会影响用户; 硬约束做不到; 计划有歧义而几种理解会做出不同的结果. +- 需要用户操作: 提供账号, 连接设备, 在被调试的程序中做一次操作, 亲手构建并告诉你结果. -- 与事实修正的界线: 判据换一个符合事实的细节就能满足时 (例如点名的实例不存在, 另一个实例带有同样的特征), 属于事实修正, 照做并记录. 判据要求的东西按事实根本做不出来, 或要用户在两种说法中选一种时, 才留作待定. -- 适用情形: 判据要求的特征在实际数据中不存在; 判据要用户在两种说法中选一种, 而选哪种都不改变已经完成的工作. -- 不适用: 用户的决定会改变其余部分的做法 (例如随包带哪种格式的文件); 任务目标达不成; 要改动工作范围之外的文件. 这些情况仍按受阻处理. -- 写法: 回执 "执行状态" 写 "完成"; "偏差记录" 的 "待定判据" 逐条写判据编号, 规格原文, 实际情况与证据位置, 以及可选的处理办法 (例如 "改用实例 X 核对" 或 "以单元测试代替"). "判据自检" 中这几条写 "待定". 没有待定判据时写 "无". -- 不替用户选: 不按自己选的办法改写判据后写 "通过". +做法: -## 等待操作 +1. 把已经查清的内容与做到哪一步写进 "执行结果" 段的 "中途记录". 会话中断后, 新的实现子代理靠它接着做. +2. "执行状态" 保持不变. +3. 最后一条消息按委派提示词的 "交回格式": 第一行写 "交回: 需要回答" 或 "交回: 需要操作", 其后逐条写问题或操作步骤, 写到用户不用追问就能照做, 并写明做完后要告诉你什么. -工单没有问题, 只是要用户亲手做一件事才能继续时 (例如在被调试的程序中做一次操作, 连接一台设备, 登录一个账号), 不写回执, 中途交回: +编排会话转来用户的回复后, 从停下的地方继续. 需要再次交回时照此再做一次. -- 交回之前把断点, 监听等准备工作做好, 把已经查清的内容写进 `artifacts/` 下的文档: 会话中断后, 新的执行子代理靠这些文档接着做. -- 交回内容第一行写 "执行状态: 等待操作", 其后逐条写要用户做的操作: 写到用户不用追问就能照做, 写明做完后要告诉你什么. -- 编排会话转来用户的回复后, 从停下的地方继续. 需要再次操作时照此再交回一次. -- 全部做完后照常写回执. 回执 "偏差记录" 的 "需要操作" 写 "无", 中途请用户做过的操作写进 "实现决定". -- 要用户做决定, 或要修改工单时, 不用这种方式, 按受阻处理. +## 模块化 -## 技能问题 +写代码时遵守项目规范, 尤其是这几条: -- 问题出在技能自身时 (插件命令输出 "技能异常" 一行, 本手册与骨架或自查命令对不上, 守卫拒绝了本手册允许的写入), 不绕开, 不修改技能文件. -- 在交回内容的末尾加一行 "技能问题: ...", 只写遇到的情况: 运行的命令或操作, 原始提示原样摘录. 不写原因推测与处理建议. 上报由编排会话完成, 你不运行 `report`. -- 不影响工单时照常完成; 无法继续时以受阻结束, 受阻类型写 "需要用户操作". +- 模块怎么表达以委派提示词的 "模块组织方式" 为准. 按物理目录分模块时, 文件放进所属模块的目录. 用 Visual Studio 筛选器分模块时, 源文件一律平铺在工程目录下, 不建物理子目录; 每个新文件都要在 `.vcxproj` 中登记, 并在 `.vcxproj.filters` 中归入所属模块的筛选器. +- 常量, 类型, 配置, 规则只定义一次, 放在计划 "唯一定义" 写明的位置, 其它地方引用它. +- 按职责分层, 跨层只通过接口调用. +- 优先复用已有实现. 没有两处真实重复之前, 不建通用模块, 不把代码下沉到公共层; 不加计划没有要求的功能, 扩展点与兼容路径. -## 取证记录 +## 接线证据 -- 判据要靠 MCP 工具看到的结果来确认时 (例如抓包工具记下的请求), 在回执 "测试记录" 的 "取证记录" 写明: 工具的完整名称, 定位参数 (过滤条件或请求编号), 发生的时间段. 验收子代理按这些位置用同一工具重新查看. -- 验收结束之前不清空, 不覆盖这些记录, 例如不清空抓包列表. -- 没有用到 MCP 工具时写 "无". +改代码的切片必须接到入口上: 新代码要能从程序的真实入口走到. 在 "接线证据" 中写出这条调用路径, 每一步写 `文件:行号` 与一句说明, 从入口开始, 到本切片的新代码结束. 验收子代理会照着追一遍, 追不到就是没有接线. -## 证据文件 +最小骨架切片另有要求: 入口必须是真实入口, 路径要经过计划中的每一层. -- 工单要求留存, 或验收需要独立复核的产物 (例如抓包原文, 复现脚本, 样例输入输出), 写进本工单文件夹的 `artifacts/` 目录, 例如 `.navigator/orders/0007-export-csv/artifacts/`. 这些文件随本工单一起入库, 验收子代理按路径读取. -- 证据文件不是业务代码, 程序运行时不依赖它们. 业务需要的数据与测试夹具仍放在项目目录中. -- 运行其中的脚本时, 输出写到会话临时目录或标准输出, 不在 `artifacts/` 中生成新文件, 例如 Python 加 `-B`, 不生成 `__pycache__`. -- 写 `artifacts/` 下的文档被运行环境拒绝时 (提示不是守卫给出的, 例如要求子代理把结论作为文字交回), 不算受阻: 把该文档的路径与全文放进最终回复, 由编排会话原样写入; 回执 "改动清单" 照常列出这个文件, 注明 "由编排会话写入". 回执与脚本仍由你自己写. -- 在回执 "改动清单" 中列出每个证据文件; 判据自检引用证据文件时写出路径. 需要验收子代理运行的脚本, 写进回执 "测试记录" 的 "测试命令". +## 只产出结论的切片 -## 回执结构 +委派提示词的规则写 "本切片只产出结论" 时: -回执以 "# 工单回执" 开头, 第一节为 "## 当前进展". 之后各节: - -- 实现类工单 (`--option 1`): 回执概况 (执行状态, 基准提交, 完成时间); 假设核对; 事实修正 (表格, 列为 工单原文, 实际情况, 证据位置, 处理办法); 模块划分 (新增模块, 改动模块, 入口改动); 判据自检; 改动清单; 测试记录 (测试命令, 测试结果, 测试改动, 外部影响, 取证记录); 运行方式 (启动命令, 验证步骤); 偏差记录 (实现决定, 需要操作, 发现风险, 待定判据, 受阻详情) -- 选型类工单 (`--option 2`): 回执概况 (执行状态, 基准提交, 完成时间); 假设核对; 现有能力; 候选方案; 需求核对 (表格, 列为 需求, 结论, 依据, "结论" 只能填 能/不能); 能力核实 (表格, 列为 能力, 仓库, 版本, 路径, 行号, 说明); 选型结论; 一致检查; 改动清单; 偏差记录 (实现决定, 需要操作, 发现风险, 受阻详情) - -括号中是键名. 键值节中每个键只占一行 "- 键名: 值", 值写在同一行, 节中不加其它行, 列表, 表格或段落, 编排会话的脚本按键值行读取回执. 表格与多行内容写进没有键名的节, 完整输出放进证据文件后在值中写出路径. - -## 运行检查与分析工单 - -- 委派提示词的第 1 条规则写 "本工单只产出记录" 时 (运行检查工单与分析工单, 例如逆向与调研): 只写本工单的回执, 以及 `artifacts/` 下的文档与证据脚本. 守卫拒绝改动业务文件与项目配置. -- 回执 "模块划分" 三项都写 "无". -- 结论都要能追到证据: 文档中的每条结论写明依据的证据文件路径, 命令输出或取证记录. 证据脚本要能在仓库根目录直接运行, 运行命令写进回执 "测试记录" 的 "测试命令". -- 构建与运行程序时产生的文件 (例如构建目录) 不算改动, 但不要改动已入库的文件. 确实需要改代码时, 写进回执与 "需要操作", 由编排会话另发实现工单. - -## 选型工单 - -- 选型只回答一件事: 选定的库能不能实现工单列出的每条需求. 按项目规范 `.claude/rules/engineering.md` 中 "技术选型" 一节的守则选择候选. -- 回执 "需求核对" 表每条需求一行, 结论只写 "能" 或 "不能". "依据" 列写源码证据的位置 (例如 "能力核实第 2 行"), 或测试文件路径与运行命令; 结论为不能时写原因. 回执其它内容不需要附证据. -- 先看源码. 源码看不出来时 (例如行为, 兼容性或几个库能否配合), 写临时测试实际运行: 测试文件放在本工单的 `artifacts/` 下, 运行命令写进 "依据", 验收子代理会重新运行. 测试的依赖装在会话临时目录, 不装进项目环境. -- 只有文档或记忆作依据的结论视为未核实, 验收时判为不通过. -- 回执 "能力核实" 是源码证据表, 每行是 "需求核对" 引用的一段源码; 全部用测试核实时只留表头. 仓库, 版本与路径三列写在行内代码中, 格式化工具不会改写其中的下划线. 各列写法: - - 仓库: 公开 git 仓库的 https 地址, 例如 `https://github.com/encode/httpx`. - - 版本: 仓库中的 tag, 分支或完整提交号, 按仓库实际的写法, 例如有的仓库 tag 带 `v`. - - 路径: 仓库中的文件路径, 不是安装后包里的路径; 例如源码放在 `src/` 下的仓库要带上 `src/`. - - 行号: `N` 或 `N-M`, 只引用证明该能力的那一段; 范围过长时自查命令会提示拆成多行. - - 说明: 这几行代码证明了什么. -- 每行证据写好后运行 `project-navigator evidence check <仓库> <版本> <路径> <行号>` 自查: 命令从原仓库取回这几行, 输出的源码要能支持 "说明". 写入回执后, 再运行 `project-navigator evidence` 核对整张表. 验收子代理会用同一条命令从原仓库重新取源码核对. -- 没有公开 git 仓库的依赖拿不出源码证据, 改用临时测试核实. -- 只给出结论与理由, 由用户决定是否采用; 采用之前不在项目中安装依赖, 不写实现代码. 取证需要的源码用上面的命令查看, 或下载到会话临时目录. +- 只写 `.navigator/findings/` 下的文件与 "执行结果" 段, 不改业务代码与项目配置. 需要改代码时写进 "执行结果", 由编排会话另排切片. +- 每条结论写明: 宿主或依赖的版本, 位置 (地址, 偏移, 文件与行号), 验证方法, 验证时间. 没有验证过的只能写成 "待验证". +- 取证用的脚本也放在 `.navigator/findings/` 下, 运行命令写进 "自测记录". +- "改动清单" 列出写了哪些结论文件. ## 写作要求 -- 用简体中文, 朴素直白: 不自造简称, 不打比方, 不用没有名词的 "它", "这", "其". +- 用简体中文, 朴素直白: 不自造简称, 不打比方. - 路径, 命令与标识符原样写出, 放在行内代码中. - -## 标点与排版 - -- 使用英文 (ASCII) 标点, 禁止中文全角标点; 成对符号也用 ASCII 版, 即 `()`, `""`, `''`. -- 每个标点后跟一个空格; 标点位于行尾或文本末尾时不加. -- 标点属于数字, 代码, 标识符, URL 或路径等 "记号" 整体的一部分时不加空格. 例: `v2.1.187`, `file.txt`, `C:\path`, `50%`, `a/b`. -- 成对开合标点按英文惯例: 开符外侧留空格内侧不留, 闭符内侧不留外侧留空格. +- "执行结果" 段的小节内不加标题. diff --git a/plugins/waypoint/skills/project-navigator/references/brainstorm.md b/plugins/waypoint/skills/project-navigator/references/brainstorm.md deleted file mode 100644 index f899e8f..0000000 --- a/plugins/waypoint/skills/project-navigator/references/brainstorm.md +++ /dev/null @@ -1,84 +0,0 @@ -# 头脑风暴 - -立项的第 3 步与第 4 步. 目标: 与用户一起想清楚问题, 人群, 成品样子, 指标与边界, 为简报准备全部输入. 发散与收敛分开进行. - -## 立场 - -- 你是共创伙伴: 用户与你都出点子, 每个点子标注提出人. -- 你每轮最多提 1 个点子, 以用户的想法为主. -- 点子必须来自调研报告或用户原话, 不凭记忆提方案. -- 不讨论技术: 技术问题写进调研报告的 "选型问题", 留给阶段 3. - -## 角度清单 - -按顺序进行, 每个角度 1 至 2 轮, 然后换下一个. 调研报告与用户原话已经回答的角度直接写进结论, 不再讨论: - -1. 问题与动机 -2. 使用人群与场景 -3. 成品样子 -4. 成功指标 -5. 范围边界 -6. 约束与风险 -7. 替代方案: 对照调研报告中的同类方案 - -## 每轮结构 - -回复 "头脑风暴", 使用第 1 组选项: - -- "本轮角度": 角度序号 (例如 0003/0007), 角度名称, 所处阶段 (发散或收敛). -- "本轮问题": 2 个苏格拉底式问题, 编号 1 与 2. 尽量给出候选答案 (甲, 乙, 丙) 并标出推荐项, 让用户一眼能答. -- "我的点子": 1 个点子, 以及请用户评价的具体问题. - -一轮的问题总数不超过 3 个, 含点子评价. - -## 苏格拉底六类问题 - -前期多用前三类, 后期多用后三类. - -| 类型 | 用途 | 例句 | -| -------- | -------------- | --------------------------------------------- | -| 澄清 | 弄清说法的含义 | "月报" 是每月 1 日自动生成, 还是随时手动导出? | -| 质疑假设 | 检查默认前提 | 如果使用者不是财务人员, 这个流程还成立吗? | -| 追问依据 | 要证据与理由 | "每周导出 3 次" 这个频率来自哪次观察? | -| 换视角 | 从他人角度看 | 审核报表的主管最在意哪一项? | -| 推演后果 | 看决定的影响 | 如果第一版不做权限控制, 上线后会出现什么问题? | -| 反问问题 | 检查问题本身 | 使用者真正的困扰是导出太慢, 还是数据不准? | - -## 记录 - -- 第一次记录前运行 `template brainstorm`, 创建 `.navigator/plan/brainstorm.md`. -- 每轮结束后追加一个条目, 标题为 `## 轮次 0001: 角度名称`, 写本轮问题, 用户回答, 点子与提出人, 当轮结论. -- 同时更新 "记录概况" 的完成轮次与所处阶段. 会话中断后按这份记录恢复. - -## 何时收敛 - -满足任一条件时, 回复 "头脑风暴" 并使用第 2 组选项, 征得用户同意后才收敛, 不擅自收敛: - -- 用户主动提出. -- 连续 2 轮没有新点子, 或用户的回答明显变短. -- 角度清单已全部覆盖. - -用户选 B 时换一个角度继续发散. - -## 收敛 - -用户选 A 后运行 `step converge`, 收敛 1 至 2 轮: - -1. 回放用户提过的全部点子, 包括早期不起眼的, 问用户看到哪些主题. -2. 按必须做, 应该做, 可以做, 明确不做四档 (MoSCoW 方法) 问三件事: 哪些必须做, 哪些明确不做, 哪个假设最需要先验证. -3. 结论写进 `.navigator/plan/brainstorm.md` 末尾的 "收敛结果" 一节. - -## 结束条件 - -以下三条同时满足时结束, 运行 `step brief`, 按 `ref intake` 写简报: - -- 用户确认问题本身的表述无误. -- 必须项已经确认. -- 每个未决问题都有验证方式. - -## 不做的事 - -- 不设点子数量或讨论时长的指标. -- 不做多角色扮演. -- 不问答案已知的问题: 调研报告与用户原话已经回答的内容, 直接写进结论. -- 用户的一次确认只批准当前一步, 不延伸到后续步骤. diff --git a/plugins/waypoint/skills/project-navigator/references/change-milestone.md b/plugins/waypoint/skills/project-navigator/references/change-milestone.md deleted file mode 100644 index 0a748ae..0000000 --- a/plugins/waypoint/skills/project-navigator/references/change-milestone.md +++ /dev/null @@ -1,51 +0,0 @@ -# 需求变更, 里程复查与项目收尾 - -## 需求变更 - -用户在任何阶段提出新需求, 改变方向, 或推翻已定内容时: - -1. 暂停当前步骤, 记住 `status` 输出中的 "最后动作", 处理完变更后回到这里. -2. 分析影响, 回复 "需求变更": - - "变更编号" 写 "接受后分配". - - "影响分析" 五项: 简报, 推进路线, 决策, 切片, 当前工单. 没有影响的写 "无". -3. 用户选择后, 写草稿 `{"title": "变更标题", "status": "<状态>"}`, 运行 `change add --from <草稿>`, 在 `.navigator/plan/changes.md` 追加条目 `## 变更 0001: 标题` (编号取自命令输出): - - 选 A 接受: 状态写 accepted. 更新简报 ("简报版本" 加 1, "最近变更" 写变更编号); 需要时更新推进路线 (`roadmap --from`), 用 `decision supersede` 取代旧决策; 当前工单受影响时运行 `order set voided` 作废. - - 选 B 延后: 状态写 deferred, 条目中写明计划何时处理. - - 选 C 放弃: 状态写 dropped, 条目中写明原因. -4. 回到暂停的位置继续. - -## 里程复查 - -里程的切片全部完成后: - -1. 运行 `check` 做一次脚本体检, 结果写成一行, 放进 "偏离检查" 的 "文档体检". -2. 回复 "里程复查": - - 里程成果: 完成的切片, 运行方式 (启动命令取自最后一张回执), 版本提交 (最后一个提交的短哈希). - - 简报对照: 里程目标与覆盖指标逐条对照简报, 写达成情况. - - 偏离检查: 范围偏离, 约束偏离, 新增风险, 文档体检. - - 复盘要点: 有效做法与改进事项. -3. 在 `.navigator/plan/milestone-reviews.md` 追加条目 `## 里程 0001: 名称`. 第一次写入前运行 `template milestone-reviews`. -4. 用户选 A: 运行 `milestone <编号> done`. 还有里程时运行 `milestone <下一个> active`, 回到阶段 5; 刚复查的里程是最后一个时, 运行 `stage 6`. -5. 用户选 B: 走需求变更. - -## 项目收尾 (阶段 6) - -1. 汇总三项内容: - - 总体验收: 简报的成功指标逐条对照. - - 项目统计: 里程, 切片, 工单, 变更的数量, 从 `.navigator/state.json` 读取. - - 复盘要点: 有效做法, 改进事项, 遗留事项. -2. 在 `.navigator/plan/milestone-reviews.md` 追加条目 `## 项目收尾`. -3. 回复 "项目收尾". -4. 用户选 A: 运行 `finish`, 项目结束. 按输出调用 `waypoint:commit-message`, 收尾记录与完成标记一起入库. 之后按下一动作回复 "项目完成". -5. 用户选 B: 按需求变更流程新开里程, 更新推进路线后运行 `stage 5`. 按 `stage` 的输出提交收尾记录. - -## 项目完成 - -项目已确认收尾, 记录都已入库之后, 下一动作要求回复 "项目完成", 不再回复 "项目收尾". - -1. "完成情况" 三项用 git 命令查询: "最近提交" 写 `git log -1 --format=%h` 的结果; "工作区域" 写 `git status --short` 的结果, 没有改动时写 "干净"; "远程推送" 写 `git status -sb` 第一行中与远程的差距, 例如 "领先 2 个提交", 没有上游时写 "没有上游". -2. 用户选 A, 或提出新需求: 按需求变更流程新开里程, 更新推进路线后运行 `stage 5`. -3. 用户选 B: 运行不带强制参数的 `git push`, 再回复 "项目完成". -4. 用户选 C: 不做任何操作. - -项目完成后记录又有改动时, 下一动作会要求先提交, 再回复 "项目完成". diff --git a/plugins/waypoint/skills/project-navigator/references/change.md b/plugins/waypoint/skills/project-navigator/references/change.md new file mode 100644 index 0000000..07ae724 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/references/change.md @@ -0,0 +1,40 @@ +# 变更, 收尾与恢复 + +## 需求变更 + +用户提出新的需求, 或要改已确认的需求时: + +1. 先弄清楚变更的内容与原因, 说明它影响哪些已完成与未完成的切片. +2. 有切片正在进行时, 先把它做完或放弃 (`slice drop`), 再改路线. 只影响当前切片的小调整, 按 `ref slice-loop` 的 "中途交回" 改计划并运行 `slice replan`. +3. 用户确认后: + - 用 Edit 更新 `.navigator/requirements.md`. + - 用 `roadmap --from <草稿>` 更新路线: 还没有开始的切片可以改名, 调整或去掉; 已经开始的切片带上 `id` 原样保留. + - 用 `decision add --from <草稿>` 记一条决策: 决定了什么, 为什么, 放弃了什么; 推翻了以前的决策时写 `supersedes`. +4. 用户只是随口一提, 还没有决定要做时, 记进 `.navigator/backlog.md`, 不改路线. + +## 收尾 + +全部里程完成后: + +1. 向用户说明成果: 对照需求文件的 "必须要做" 逐条说明; 列出接受了现状的未验证标准, 被放弃的切片, 以及待办中还留着的事. +2. 用户确认后运行 `project-navigator finish`, 按输出提交. +3. 收尾之后用户还有新的工作时, 按上方 "需求变更" 更新需求, 用 `roadmap` 追加里程即可继续. + +## 恢复进度 + +- 新会话调用技能后, "当前状态" 给出位置与下一动作, 照做即可. 需要上下文时读需求文件, 路线视图与进行中的切片文件, 不要重新规划. +- 上下文压缩后, hook 会注入位置与下一动作; 拿不准时运行 `status`. +- 切片在实现中而实现子代理已经不在了 (例如上一个会话已关闭): 运行 `brief exec` 重新派出, 新的子代理靠切片文件的 "中途记录" 与工作区中已有的改动接着做. +- 切片在验收中而验收段还没有写: 运行 `brief review` 重新派出验收子代理. + +## 出错时 + +| 情况 | 做法 | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| 命令输出 "脚本错误" | 按错误信息改; 信息中写了要运行的命令时照做 | +| `slice review` 说计划段被改动 | 是你按用户的回答改的: 运行 `slice replan`; 不是: 用 `git diff` 查看, 恢复后再送验 | +| 写文件被守卫拒绝 | 按拒绝理由调整; 编排会话要改业务文件时, 排进切片 | +| 进入时说记录由旧版本写入 | 本版本不读取旧记录: 用户同意后运行 `init --archive`, 旧的 `.navigator/` 改名为 `.navigator-1.x/`, 再重新立项 | +| 进入时说记录会被 Git 忽略 | 把输出中的忽略规则告诉用户, 用户改好后重新调用技能 | +| 状态与实际对不上 (例如用户回退了提交) | 运行 `check`, 把结果与你看到的情况告诉用户, 由用户决定以哪边为准 | +| 照着 "下一动作" 做却走不通 | 把运行的命令与原始输出告诉用户, 不绕开, 不修改技能文件 | diff --git a/plugins/waypoint/skills/project-navigator/references/checkup.md b/plugins/waypoint/skills/project-navigator/references/checkup.md deleted file mode 100644 index 18ef973..0000000 --- a/plugins/waypoint/skills/project-navigator/references/checkup.md +++ /dev/null @@ -1,28 +0,0 @@ -# 体检 - -体检检查记录文件是否冗长, 是否好懂, 是否对注意力障碍与强迫倾向的读者友好. 调用参数为 `check`, 用户要求体检, 或里程复查时进行. - -## 步骤 - -1. 脚本检查: 运行 `check`. 检查计划目录, 全部工单与验收记录, 以及最近一份回执, 输出含体检编号, 问题清单与分类统计. 命令后列出文件路径时只检查这些文件, 不分配体检编号, 用于写完记录后自查. -2. 语义检查: 派 `waypoint:navigator-reader`, 列出要检查的文件, 要求按下方评分表逐条检查. 每个问题交回文件, 行号, 类别, 原文摘录与说明. 子代理没回来就要结束本轮时, 回复 "等待结果". -3. 回复 "体检报告": - - "体检范围": 文件数量, 脚本检查的问题数, 语义检查的问题数. - - "问题清单": 表格, 列为文件, 位置, 类别, 说明. 问题级别在前, 提示级别只写数量. - - "分类统计": 表格, 列为类别, 问题, 提示. -4. 在 `.navigator/plan/checkups.md` 追加条目 `## 体检 0001: 检查范围`, 编号取自脚本输出, 写体检时间, 检查范围, 问题数量与处理方式. 第一次写入前运行 `template checkups`. -5. 用户选 A: 逐条修正编排会话自己写的文档, 回执只报告不改; 修正后再运行一次 `check` 确认. 用户选 B: 只记录. - -## 语义评分表 - -| 类别 | 检查什么 | 反例 | -| ---------- | ---------------------------- | ---------------------- | -| 自造简称 | 没有定义就使用的缩写 | 用 "NS" 代指导航状态 | -| 未定义术语 | 术语表中没有, 正文也没有解释 | 直接写 "热路径" | -| 比喻 | 用比方代替事实 | "工单是一座桥" | -| 指代不明 | 读者要往前找才知道说的是谁 | "失败后它会重试" | -| 例子不完整 | 缺少输入, 操作或结果 | "像上次那样导出" | -| 复述铺垫 | 重复前文或写开场白 | "如前所述" | -| 空话套话 | 没有信息的句子 | "我们将持续优化体验" | -| 一处多焦点 | 一段讲多件事 | 一段话同时讲范围与风险 | -| 依赖记忆 | 需要读者记得早前的内容 | "按之前说的办" | diff --git a/plugins/waypoint/skills/project-navigator/references/files.md b/plugins/waypoint/skills/project-navigator/references/files.md deleted file mode 100644 index 456bbad..0000000 --- a/plugins/waypoint/skills/project-navigator/references/files.md +++ /dev/null @@ -1,104 +0,0 @@ -# 记录文件 - - - -`.navigator/` 下的记录文件按以下结构编写. 新建文件前运行 `project-navigator template <种类>` 取得填写要求与骨架, 按要求在骨架上填写, 写完后运行 `project-navigator check <文件>` 自查. 每个文件以一级标题开头, 文件中不放代码块. 键值节中每个键只占一行 "- 键名: 值", 值写在同一行, 节中不加其它行, 列表, 表格或段落; 脚本按键值行读取记录. - -## 执行工单 - -- 种类: `order` -- 路径: `.navigator/orders/*/order.md` -- 当前进展: 有 -- 固定节: 审阅要点 (任务目标, 需要决定, 明确不做, 粒度依据); 工单概况 (背景原因, 完成标志, 基准提交, 回执路径); 执行守则; 工作假设; 工作范围 (要做的事, 不做的事); 验收判据; 相关材料 - -## 工单回执 - -- 种类: `receipt` -- 路径: `.navigator/orders/*/receipt.md` -- 当前进展: 有 -- 固定节 (结构 1): 回执概况 (执行状态, 基准提交, 完成时间); 假设核对; 事实修正 (表格, 列为 工单原文, 实际情况, 证据位置, 处理办法); 模块划分 (新增模块, 改动模块, 入口改动); 判据自检; 改动清单; 测试记录 (测试命令, 测试结果, 测试改动, 外部影响, 取证记录); 运行方式 (启动命令, 验证步骤); 偏差记录 (实现决定, 需要操作, 发现风险, 待定判据, 受阻详情) -- 固定节 (结构 2): 回执概况 (执行状态, 基准提交, 完成时间); 假设核对; 现有能力; 候选方案; 需求核对 (表格, 列为 需求, 结论, 依据, "结论" 只能填 能/不能); 能力核实 (表格, 列为 能力, 仓库, 版本, 路径, 行号, 说明); 选型结论; 一致检查; 改动清单; 偏差记录 (实现决定, 需要操作, 发现风险, 受阻详情) - -## 验收记录 - -- 种类: `review` -- 路径: `.navigator/orders/*/review*.md` -- 当前进展: 有 -- 固定节: 验收结论 (总体结论, 判据通过, 回执来源, 验收轮次, 验收时间); 判据核对 (表格, 列为 判据, 结论, 证据, "结论" 只能填 通过/不通过/未验证); 专项检查 (回执一致, 接线核查, 范围核查, 测试改动, 测试运行, 修正核对, 规范核查); 人工验收 (验收步骤, 用户结论 [骨架预填: 待用户确认]) - -## 项目简报 - -- 种类: `brief` -- 路径: `.navigator/plan/brief.md` -- 当前进展: 无 -- 固定节: 基本信息 (项目名称, 简报版本, 最近变更); 问题背景 (背景说明, 核心问题); 使用人群 (使用人群, 使用场景); 成品样子; 成功指标; 范围边界 (必须包含, 明确不做); 硬性约束; 已知未知 - -## 术语定义 - -- 种类: `glossary` -- 路径: `.navigator/plan/glossary.md` -- 当前进展: 无 -- 固定节: 流程术语; 项目术语 - -## 现状报告 - -- 种类: `survey` -- 路径: `.navigator/plan/survey.md` -- 当前进展: 无 -- 固定节: 盘点概况 (盘点时间, 基准提交, 源码规模, 运行检查); 已有能力; 运行情况 (构建结果, 启动结果, 测试结果); 目标差距 - -## 调研报告 - -- 种类: `research` -- 路径: `.navigator/plan/research.md` -- 当前进展: 有 -- 固定节: 需求理解 (原话摘录, 调研方向); 同类方案; 使用人群; 行业做法; 约束风险; 关键术语; 选型问题; 结论汇总; 来源列表 - -## 头脑风暴 - -- 种类: `brainstorm` -- 路径: `.navigator/plan/brainstorm.md` -- 当前进展: 无 -- 固定节: 记录概况 (角度总数, 完成轮次, 所处阶段) -- 条目标题: 匹配 `^轮次 \d{4}: .+$`, 只追加不修改 -- 收尾节: 收敛结果 (必须要做, 明确不做, 优先验证) - -## 主线流程 - -- 种类: `main-flow` -- 路径: `.navigator/plan/main-flow.md` -- 当前进展: 无 -- 固定节: 场景清单; 主线步骤; 外部依赖 - -## 决策记录 - -- 种类: `decisions` -- 路径: `.navigator/plan/decisions.md` -- 当前进展: 无 -- 固定节: 无 -- 条目标题: 匹配 `^决策 \d{4}: .+$`, 只追加不修改 -- 条目键名: 决策状态, 批准时间, 来源工单, 决策内容, 决策理由, 可否回退, 影响范围 - -## 变更记录 - -- 种类: `changes` -- 路径: `.navigator/plan/changes.md` -- 当前进展: 无 -- 固定节: 无 -- 条目标题: 匹配 `^变更 \d{4}: .+$`, 只追加不修改 - -## 里程复查 - -- 种类: `milestone-reviews` -- 路径: `.navigator/plan/milestone-reviews.md` -- 当前进展: 无 -- 固定节: 无 -- 条目标题: 匹配 `^(里程 \d{4}: .+|项目收尾)$`, 只追加不修改 - -## 体检记录 - -- 种类: `checkups` -- 路径: `.navigator/plan/checkups.md` -- 当前进展: 无 -- 固定节: 无 -- 条目标题: 匹配 `^体检 \d{4}: .+$`, 只追加不修改 diff --git a/plugins/waypoint/skills/project-navigator/references/intake.md b/plugins/waypoint/skills/project-navigator/references/intake.md index c302d9a..d83659d 100644 --- a/plugins/waypoint/skills/project-navigator/references/intake.md +++ b/plugins/waypoint/skills/project-navigator/references/intake.md @@ -1,86 +1,59 @@ -# 盘点, 立项与主线 +# 立项 -本文件覆盖首次接入之后的阶段 0 盘点, 阶段 1 立项与阶段 2 主线. 每完成一步, 运行 `step <步骤标识>` 记下位置; 进入下一阶段时运行 `stage <编号>`, 再运行该阶段第一步的 `step`. `stage` 的输出中有提交技能产物的一步时, 照抄参数调用 `waypoint:commit-message`, 再做该阶段的第一步. +立项阶段的目标: 一份用户确认过的需求文件, 登记好的项目设置, 一条从最小骨架开始的路线. 立项全程是对话, 不派子代理, 不写代码. -## 首次接入之后 +## 确认需求 -- 用户选 A (新建项目): 写草稿 `{"reason": "新建项目, 没有已有代码"}`, 运行 `skip 0 --from <草稿>`, 再运行 `stage 1` 与 `step understanding`. -- 用户选 B (已有项目): 运行 `stage 0` 与 `step survey-read`. +1. 读用户给的初始需求. 已有项目先读一遍项目: 入口在哪里, 有哪些模块, 沿用什么约定. 文件多时用 Explore 子代理. +2. 向用户复述你的理解, 指出含糊, 缺失, 互相冲突的地方. +3. 把待定问题列出来, 与用户逐个确认, 一次只问一个, 不自行假设. 用户说 "你定" 时给出建议与理由, 由用户确认. +4. 讨论中冒出的, 不属于本项目范围的想法, 记进 `.navigator/backlog.md`. -## 阶段 0: 盘点 +## 需求文件 -| 步骤标识 | 做什么 | 回复 | -| --------------- | ------------------ | -------- | -| survey-read | 派阅读子代理读仓库 | 无 | -| survey-runcheck | 发布运行检查工单 | 工单审阅 | -| survey-report | 写现状报告 | 产出确认 | +需求确认后, 用 Write 写入 `.navigator/requirements.md`. 它是需求的唯一权威来源, 之后的路线与每个切片的计划都从它出发. 依次写这几节: -1. survey-read: 派 `waypoint:navigator-reader` (仓库很大时可以派 Explore); 子代理没回来就要结束本轮时, 回复 "等待结果". 要求交回以下内容, 每条附文件路径: - - 目录结构与模块划分, 技术栈与主要依赖, 程序入口. - - 构建, 启动与测试命令. - - 已有功能清单. - - 已有规范: `CLAUDE.md`, `AGENTS.md`, `.claude/rules/`, 格式化与检查配置. -2. survey-runcheck: 编排会话不能构建与运行程序. 新建运行检查工单 `order new --kind runcheck --slug runcheck`, 要求执行子代理按盘点得到的命令构建, 启动, 测试, 原样记录结果, 不修改任何文件 (守卫拒绝运行检查工单改动业务文件). 工单往返见 `ref order-loop`; 验收通过后只提交记录. 验收不通过时重发运行检查工单, 不发修复工单: 修复工单要求已登记检查命令, 盘点阶段还没有登记. -3. survey-report: 运行 `template survey`, 写 `.navigator/plan/survey.md`. "运行情况" 取自运行检查回执; 没有初始需求时, "目标差距" 写 "立项后补充". 回复 "产出确认". -4. 用户确认后运行 `stage 1` 与 `step understanding`. 现状报告的关键事实并入下一步的 "我的假设". +- `## 目标`: 一段话, 做出来的东西给谁用, 解决什么问题. +- `## 必须要做`: 编号列表, 每条是使用者能感知的一项能力. +- `## 明确不做`: 讨论中决定不做的事, 写明原因. +- `## 约束`: 运行环境, 技术栈, 工程形态, 不能做的操作等硬性条件. +- `## 现状`: 只在已有项目中写: 入口, 模块, 已有约定. +- `## 已确认的结论`: 每个待定问题的结论, 一条一行. -## 阶段 1: 立项 +需求文件写朴素的话, 不贴代码. 用户的原话有约束力时原样摘录. -立项不能只问用户: 先调研, 形成基本认知并结构化汇报, 再按用户的选择带用户头脑风暴, 或直接写简报草稿让用户改. 小项目, 或用户已经想清楚的项目, 不必走完头脑风暴. +## 选型 -| 步骤标识 | 做什么 | 回复 | -| ------------- | ------------ | -------- | -| understanding | 写回需求理解 | 需求理解 | -| research | 问题域调研 | 调研报告 | -| brainstorm | 发散讨论 | 头脑风暴 | -| converge | 收敛结论 | 头脑风暴 | -| brief | 写项目简报 | 产出确认 | +- 用户已经定下的技术栈直接写进 "约束", 不再讨论. +- 需要选的关键依赖 (语言, 框架, 核心库), 逐条回答 "它能不能实现每条需求", 依据写明来源: 已安装依赖的源码, 官方文档中的明确说法, 或一次实际运行. 只凭记忆的结论要说明是没有核实的. +- 需要动手才能确认的 (写一段临时测试, 读一个库的源码, 在目标程序里实测), 不在立项时做: 在路线里排一个 `findings` 切片, 放在最小骨架之后. 骨架本身依赖这个结论时, 先与用户商量用最保守的做法搭骨架. +- 重要的取舍用 `decision add` 记一条决策. -### 需求理解 (understanding) +## 项目设置 -- "原话摘录": 逐条摘录用户说过的需求原文, 不改写. -- "我的假设": 列出从原话推出的内容, 每条写明依据. 用户纠正之前, 假设不能作为后续步骤的依据. -- "调研方向": 列出准备调研的问题域话题, 3 至 5 个. 不列技术话题. -- 用户选 B: 按纠正改写, 再次回复 "需求理解". 用户选 A: 运行 `step research`. +1. 运行 `project-navigator setup`, 看脚本对模块组织方式的建议与依据. +2. 与用户确认两件事: + - 模块组织方式: `directories` 是按物理目录分模块, 适用于 Python, Web, Go 等多数项目; `filters` 是源码文件平铺在工程目录下, 用 Visual Studio 虚拟筛选器 (`.vcxproj.filters`) 分模块, 适用于 Visual Studio 项目. + - 验证方式: `auto` 是有能在仓库根目录直接运行的检查或测试命令; `manual` 是要用户亲手构建或运行才能确认; `measured` 是要在真实环境中实测. 不能由你构建或运行的项目不选 `auto`. +3. 项目有自己的约定时 (注释语言, 类的骨架写法, 命名习惯), 写进草稿的 `conventions`. +4. 写草稿, 运行 `project-navigator setup --from <草稿>`. 命令同时把工程规范写入 `.claude/rules/engineering.md`, 项目内所有会话与子代理都会自动加载它. 要调整时重新运行 `setup`. -### 问题域调研 (research) +## 路线 -1. 运行 `research-brief` 取得固定框架. 派 `waypoint:navigator-researcher`, 提示词是框架原文加上编号列出的调研问题. 守卫会核对框架没有被改动. 子代理没回来就要结束本轮时, 回复 "等待结果". -2. 运行 `template research`, 用子代理交回的结果写 `.navigator/plan/research.md`. "关键术语" 同步写进术语表 `.navigator/plan/glossary.md` 的 "项目术语". -3. 查到的技术话题只写进 "选型问题", 不讨论, 留给阶段 3. -4. 回复 "调研报告": "已知事实", "关键假设", "未知事项" 三节各列最重要的条目, 每条附依据. 只问一个问题: 哪条与你的认知不符. -5. 用户选 A: 运行 `step brainstorm`, 按 `ref brainstorm` 进行. 用户选 B (跳过讨论): 运行 `step brief`, 用调研报告与用户原话写简报草稿, 按下方 "项目简报" 进行; 用户在 "产出确认" 中提出的修改照改. 用户选 C: 修正用户指出的条目, 需要时补充调研, 再次回复. +路线分里程与切片: -### 项目简报 (brief) +- 里程: 一个对用户有意义的阶段成果, 写明目标. +- 切片: 一次实现加一次验收能做完的一块纵向功能. 做完后从入口或界面能看到它. -1. 收敛结束后 (或用户选择跳过讨论后), 运行 `template brief`, 直接写 `.navigator/plan/brief.md`, 不经过中间文档. "简报版本" 写 1, "最近变更" 写 "无". 跳过讨论时, 调研与原话没有回答的内容写进 "已知未知", 不凭空补全. -2. 自查四项: 没有占位符; 前后不矛盾; 范围与收敛结果一致; 没有两种读法的句子. -3. 运行 `check .navigator/plan/brief.md`, 修正问题级别的条目. -4. 回复 "产出确认". 用户的确认只批准本次展示的内容. -5. 确认后运行 `stage 2` 与 `step main-flow`. +排路线的规则: -## 阶段 2: 主线 +1. 第一个里程的第一个切片是最小骨架: 从真实入口 (可执行文件, 导出函数, 页面) 进入, 经过规划中的每一层, 每层只做最少的事, 能按验证方式确认它跑通了. 骨架不追求功能, 只证明各层接得上. +2. 之后的每个切片在骨架上加一项能力, 纵向切: 一个切片同时包含它需要的界面, 逻辑与数据, 不按层横着切. "先把数据层全写完" 这样的切片不合格. +3. 只把当前里程的切片排全; 之后的里程只写名称与目标, 到了再排. 提前排细的切片多半会作废. +4. 切片的种类 `kind`: `build` 是改代码 (默认); `findings` 是只产出结论, 例如逆向, 实测, 调研. +5. `findings` 切片默认是交互式 (`interactive` 为 true): 由编排会话在对话中直接做, 适合每一步都要用户配合的任务. 纯读代码或读文档, 不需要用户配合的, 写 `"interactive": false`, 交给后台的实现子代理. +6. 切片宜大不宜碎: 一次实现能做完, 验收能看清, 做完能运行, 就不再拆. 高风险的部分单独成一个切片. -| 步骤标识 | 做什么 | 回复 | -| --------- | ------------------ | -------- | -| main-flow | 写主线流程 | 产出确认 | -| roadmap | 定推进路线与风险表 | 产出确认 | +写好草稿后运行 `project-navigator roadmap --from <草稿>`. 编号由脚本分配, 之后修改路线时已有的里程与切片带上 `id`. 路线视图在 `.navigator/roadmap.md`. -### 主线流程 (main-flow) - -运行 `template main-flow`, 写 `.navigator/plan/main-flow.md`: - -- "场景清单": 从简报的使用场景列出, 标出核心场景. -- "主线步骤": 使用者完成核心场景的最短路径. 按顺序写每一步使用者做什么, 系统给出什么. -- "外部依赖": 主线涉及的外部系统, 账号与数据来源. 需要选型的条目标注 "待选型". - -回复 "产出确认". 确认后运行 `step roadmap`. - -### 推进路线 (roadmap) - -1. 把简报拆成里程: 每个里程结束时能演示一个完整的使用者成果. 第一个里程的第一个切片是骨架, 即主线从头到尾跑通的最小版本. -2. 每个切片是一段使用者可见的功能, 做完即可运行, 大小以执行子代理一张工单能做完为宜 (见 `ref order-loop` 的 "工单粒度"). 不按层拆分, 例如不设 "先做完全部数据层" 这样的切片; 也不为求细而拆, 切片越多, 用户要做的确认越多. -3. 写草稿 `.navigator/drafts/roadmap.json`, 运行 `roadmap --from .navigator/drafts/roadmap.json`, 脚本生成 `.navigator/plan/roadmap.md`. -4. 调研报告 "约束风险" 与主线中发现的风险, 用 `risk add` 逐条登记, 脚本生成 `.navigator/plan/risks.md`. -5. 回复 "产出确认", "文件路径" 写推进路线与风险清单两个文件. -6. 确认后运行 `stage 3` 与 `step standards`, 按 `ref selection` 进行. +向用户展示路线并取得认可, 然后按命令输出的提交步骤提交, 再运行 `status` 进入切片循环. diff --git a/plugins/waypoint/skills/project-navigator/references/order-loop.md b/plugins/waypoint/skills/project-navigator/references/order-loop.md deleted file mode 100644 index 980b034..0000000 --- a/plugins/waypoint/skills/project-navigator/references/order-loop.md +++ /dev/null @@ -1,127 +0,0 @@ -# 骨架, 切片与工单往返 - -## 阶段 4: 骨架 - -- 骨架是第一个里程的第一个切片: 主线从头到尾跑通的最小版本. 骨架工单同时按选型结论建立格式化, 检查与测试配置. -- 运行 `milestone <第一个里程> active` 与 `slice <第一个切片> active`, 然后按下方 "工单往返" 发布工单. -- 已有项目: 阶段 4 改为接线, 把盘点发现的断点接通. 主线已经能跑通时, 经用户认可, 写明原因后运行 `skip 4 --from <草稿>`. 项目已有检查命令时, 新建第一张实现工单之前用 `codecheck set` 登记. -- 骨架切片完成后运行 `slice <编号> done` 与 `stage 5`. - -## 阶段 5: 切片 - -1. 取推进路线中下一个未开始的切片, 运行 `slice <编号> active`. -2. 按下方 "工单粒度" 决定这个切片发几张工单; 默认一个切片一张. -3. 工单严格串行: 一张工单结束 (已通过, 不通过或已作废) 之后才能新建下一张. -4. 切片的工单全部通过后, 运行 `slice <编号> done`. -5. 里程的切片全部完成后, 按 `ref change-milestone` 做里程复查. - -## 工单粒度 - -在不超过以下四条上限的前提下, 工单越大越好: 每张工单都要经过一次审阅与一次验收, 拆得越细, 用户要做的确认越多. - -1. 执行一次能做完: 执行子代理在一个上下文中能完成, 不需要中途压缩上下文. -2. 验收能看清: 验收子代理能逐条核对全部改动. 改动过大时验收质量先下降. -3. 做完能运行: 做完之后全链路可以运行. -4. 失败代价可接受: 风险高或不确定的部分 (例如自己维护的第三方补丁) 单独成工单, 被拒时只重做这一部分. - -在工单 "审阅要点" 的 "粒度依据" 中用一句话写出判断, 例如 "整个切片一张工单: 改动集中在适配层, 预计 8 个文件". 验收不通过时先判断原因: 超出上述能力时拆小 (需要时更新推进路线), 规格不清或实现有误时发修复工单. - -## 工单往返 - -| 工单状态 | 含义 | 你的动作 | -| --------- | ---------------------- | ------------------------------------------------------------------------------------------- | -| drafting | 起草中 | 写工单文件, 做发布前核对, 回复 "工单审阅"; 用户选 A 后发布并派出执行子代理, 回复 "工单执行" | -| issued | 已发布, 执行子代理工作 | 等子代理结束; 读回执后进入受阻处理或验收 | -| blocked | 受阻 | 回复 "受阻处理"; 工单没有改动时直接继续, 改动后回复 "工单审阅" | -| reviewing | 验收中 | 派验收子代理, 写验收记录, 回复 "验收报告" | - -用户的决定只有两处: "工单审阅" 中发布工单, "验收报告" 中通过并提交 (或补正). 其余步骤由编排会话按下一动作直接进行. - -### 写工单 - -1. 运行 `order new --kind <类型> --slug <短名> [--slice <切片编号>]`. 类型: implementation 实现, selection 选型, runcheck 运行检查, analysis 分析, fix 修复. 不带 `--slice` 时工单挂在当前切片; 切片编号是 `.navigator/plan/roadmap.md` 中的四位数字, 不是进展行中的序号. -2. 运行 `template order` 取得骨架, 按下表逐项填写后用 Write 写入. -3. 发布前核对: 运行 `precheck-brief`, 把输出原样作为提示词派 `waypoint:navigator-checker`. 它逐条核对工单中写下的事实. 按结果改工单: "不成立" 的改正; "无法核对" 的自己查证, 或改写成能核对的说法; 遗漏的决定写进 "需要决定". 改好后运行 `order precheck`. 每张工单核对一次, 按结果修改之后不用再核对. -4. 运行 `reply approve`, 回复 "工单审阅". 骨架已从工单文件的 "审阅要点" 预填, 核对后原样保留. 没有做发布前核对时 `reply approve` 拒绝. 实现与修复工单要求项目已登记代码检查命令, 且验收判据含有代码检查判据; 没有登记时 `order new` 就会拒绝, 判据不全时 `reply approve` 拒绝, 按输出补救后再取骨架. -5. "工单审阅" 中用户选 A: 运行 `order set issued`, 再运行 `exec-brief`, 把输出原样作为提示词, 用 Agent 工具在后台 (`run_in_background`) 派 `waypoint:navigator-executor`, 然后运行 `reply running`, 回复 "工单执行". 发布只接受用户认可过的内容: 认可之后改过工单文件, `order set issued` 会拒绝. -6. "工单审阅" 中用户选 B: 按要求修改工单文件, 再次回复 "工单审阅". - -| 工单中的项 | 写法 | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 审阅要点 | 写给用户看, 每项一句: "任务目标" 写使用者可见的行为变化; "需要决定" 写需要用户拍板的事, 没有时写 "无"; "明确不做" 写最容易误解的范围边界; "粒度依据" 见上方 "工单粒度" | -| 背景原因 | 为什么现在做, 引用简报或决策编号 | -| 完成标志 | 使用者能观察到的结果 | -| 基准提交 | 骨架已预填当前提交的完整哈希, 原样保留; 写好工单后仓库又有新提交时, 改为新的当前提交 (`git rev-parse HEAD`), 审阅与发布都会核对 | -| 回执路径 | 同一文件夹中的 `receipt.md` | -| 执行守则 | 本工单特有的约束, 例如 "不改 `src/api/` 的公共接口"; 手册与项目规范中已有的规则不重复. 要留存抓包原文, 复现脚本等证据时, 写明放在本工单文件夹的 `artifacts/` 下 | -| 工作假设 | 你对细节的判断: 数据格式, 字段含义, 已有的接口与文件, 实现途径. 每条写明依据 (查证过的写出处, 推测的写 "推测"). 与实际不符时执行子代理以实际为准, 不算受阻 | -| 工作范围 | 要做的事与不做的事 | -| 验收判据 | 编号列出, 每条只验证一件事, 写出验证方式: 命令, 操作步骤或文件位置; 要用 MCP 工具核对的写 "取证工具: <工具名>, 按回执取证记录核对". 实现与修复工单的第一条是骨架预填的代码检查判据, 原样保留 | -| 相关材料 | 简报, 决策, 主线流程中相关条目的路径 | - -执行子代理工作期间不能向用户提问, 所以工单要写到不用追问就能动手: 判据可以验证, 需要用户决定的事在审阅时就问清. - -工单的内容分两层, 写的时候分清: - -- 硬约束: "审阅要点" 的任务目标, 需要决定, 明确不做, 以及 "工作范围". 用户认可的是这些, 执行子代理不能改; 做不到时受阻. -- 工作假设: 其余的细节. 执行子代理发现与实际不符时以实际为准, 在回执 "事实修正" 中逐条记录, 验收子代理核对每条修正的证据与是否越界. 逆向, 对接外部系统这类做了才知道细节的任务, 靠这一层避免反复往返. - -所以不能让步的要求写进硬约束, 不要埋在 "执行守则" 里; 判据写要达到的结果, 少写达到结果的途径. 途径写得越死, 修正越多. - -工单中的具体事实只写查证过的: 文件路径, 接口名, 数据中的编号或实例, 引用的记录中的结论, 外部资源的发布形式与许可. 凭记忆写下的事实不成立时, 执行子代理要做到一半才发现. 判据尽量写要满足的特征 (例如 "任选一条带附件的记录"), 只在实例本身是要求时才点名实例. 发布前核对会逐条核对这些事实, 但核对子代理只报告, 改工单仍由你完成. - -只产出记录的工单: runcheck 与 analysis 的执行子代理只能写回执与本工单的 `artifacts/`, 守卫拒绝改动业务文件; 不需要代码检查命令, 任何阶段都能发布. - -- runcheck: 按已有命令构建, 启动, 测试, 原样记录结果, 用于盘点. -- analysis: 逆向, 调研等需要动手查证的任务, 产出文档与证据脚本, 都放在 `artifacts/` 下. 判据写明要回答的问题与每条结论的证据要求, 例如 "结论附证据脚本路径, 脚本能在仓库根目录运行". -- 任务需要改动项目文件 (包括以后要复用的脚本) 时, 不用这两类, 发实现工单. - -工单涉及 `.claude/` 下的文件时 (例如接线 Claude Code hook), 执行子代理可以改 `.claude/settings.json` 的 `hooks` 字段与 `.claude/` 下项目自己的文件; 本技能的放行规则与 `.claude/rules/engineering.md` 不由工单修改. - -### 执行期间 - -- "工单执行" 中 "执行方式" 写 "后台执行子代理"; 回复之后本轮结束, 子代理结束时编排会话会收到通知. 这条回复不需要用户选择, 不带选项块. -- 执行期间用户要求停止 (例如回复 "停止执行"): 停止子代理, 运行 `order set drafting` 撤回工单, 按要求修改后回复 "工单审阅". -- 收到子代理结束的通知后运行 `status`. 回执未写入时用 SendMessage 请子代理按回执骨架补写. -- 子代理交回的第一行是 "执行状态: 等待操作" 时, 它没有写回执, 在等用户亲手做一件事: 回复 "等待操作" (`reply paused`), "等待原因" 写一句为什么要用户操作, "操作步骤" 照抄子代理写的操作. 这条回复不带选项块. 用户回复之后, 用 SendMessage 把用户的话原样转给子代理, 再回复 "工单执行". 工单状态不变, 不运行 `order set`. 找不到原子代理时运行 `exec-brief` 重新派出, 并在提示词之后附一句 "先读 artifacts/ 下已有的文档, 接着做". -- 子代理在最终回复中交回了 `artifacts/` 下某个文档的路径与全文 (运行环境拒绝它写这个文件) 时, 按该路径原样写入, 不改内容, 再继续. - -### 收回执 - -- 回执是否写入, 执行状态是什么, 以 `status` 输出的 "工单状态" 一行为准. -- 执行状态为受阻: 读回执, 运行 `order set blocked`, 回复 "受阻处理", 按受阻类型选择选项组: - -| 受阻类型 | 选项组 | -| ------------------------------------------ | ------- | -| 规格问题: 对账不符, 目标不可达, 歧义, 矛盾 | 第 1 组 | -| 范围过大 | 第 2 组 | -| 需要用户操作 | 第 3 组 | -| 需要用户决定, 或执行子代理提出问题 | 第 4 组 | - -- 用户完成所需操作, 或回答了问题而工单内容不用改: 运行 `order set issued` (内容没有改动时不需要重新审阅), 用 SendMessage 让原执行子代理继续, 说明用户做了什么或回答了什么; 找不到原子代理时运行 `exec-brief` 重新派出. 然后回复 "工单执行". -- 需要修改工单: 改好工单文件后回复 "工单审阅"; 用户选 A 后发布并重新派出执行子代理. 旧回执在发布时自动改名归档. -- 用户选 "调整范围, 进入变更" 或 "转为变更, 重新评估" 时, 按 `ref change-milestone` 走需求变更. -- 执行状态为完成: 进入验收. 回执 "偏差记录" 的 "待定判据" 不是 "无" 时照常验收, 这几条判据由用户在 "验收报告" 中决定. - -### 验收 - -1. 运行 `order set reviewing`. -2. 运行 `review-brief`, 把输出原样作为提示词派 `waypoint:navigator-reviewer`. 回执 "测试记录" 中列为 "外部影响" 的命令, 验收子代理不运行, 按回执中的输出判断. 子代理在后台运行, 本轮要先结束时回复 "工单验收": "验收方式" 写 "后台验收子代理", "核对重点" 只列执行子代理提请核对的事项, 没有时写 "无"; 不写其它说明. -3. 运行 `template review`, 用子代理交回的结果写 `review.md`. "判据核对" 表的结论照抄子代理的结论, 只能是 通过, 不通过, 未验证 之一. "用户结论" 保留骨架预填的 "待用户确认", 用户选择之后再改写. "人工验收" 写出用户可以亲手验证的步骤, 命令只取自回执; 没有时写 "无". -4. 回执 "事实修正" 表不为空时, `review.md` 与 "验收报告" 的 "修正核对" 逐条写出每项修正: 工单原来怎么写, 实际是什么, 是否成立. 用户靠这一项知道做出来的东西与审阅时的工单有哪些不同, 不能只写 "均成立". 修正成立的细节, 下一张工单直接按修正后的事实写. -5. 回复 "验收报告", 按判据结论选择选项组 (`reply review --option <组号>` 会核对). 建议只写在 "处理建议" 一节: "建议选项" 写选项字母, "主要依据" 写一句话; 选项块之前不另加说明: - - 全部通过: 第 1 组. 用户选 A 或 B: 在 `review.md` 写 "用户结论", 运行 `order set accepted`, 按输出调用 `waypoint:commit-message` 提交 (B 同时推送). 用户选 C: 把用户描述的现象写进 `review.md`, 运行 `order set rejected`, 按输出用第 2 组再回复 "验收报告". - - 有判据不通过: 运行 `order set rejected`, 按输出使用第 2 组. - - 没有不通过, 但有未验证的判据: 第 3 组. 其中有回执列为待定的判据时 (证据以 "待定:" 开头), "处理建议" 的 "主要依据" 写明每条待定判据的实际情况与可选的处理办法: 用户选 A 表示按现状接受, 选 B 表示发补正工单. 用户选 A (接受现状): 在 `review.md` 写 "用户结论", 运行 `order set accepted --accept-unverified`, 按输出提交; 未验证的判据用 `risk add` 各登记一条风险. 用户选 B: 运行 `order set rejected`, 按输出新建补正工单. - - 选型工单: 第 4 组, 见 `ref selection`. -6. 第 2 组用户选 A: 按 `order set rejected` 输出的命令新建补正工单, 判据写明要补正的问题. 实现与修复工单补正时先判断原因: 工单太大就拆小, 否则发修复工单 (`--kind fix`); 运行检查与分析工单补正时重发同类工单. - -### 提交 - -1. 提交消息由 `waypoint:commit-message` 生成并展示, 不另写. 参数照抄 `order set accepted` 输出中的写法. -2. 纳入范围之外还有改动时 (例如编辑器的工程文件), 用文字列出并询问, 按用户的回答处理, 不自行排除. -3. 切片的工单全部通过时运行 `slice <编号> done`. - -## 技能产物的提交 - -推进阶段, 初始化与项目收尾的命令输出中有 "调用 waypoint:commit-message 提交" 一步时, 照抄输出中的参数提交. 这一步只提交 `.navigator/` 与插件管理的项目配置, 其它改动由 commit-message 列出并询问. diff --git a/plugins/waypoint/skills/project-navigator/references/recovery.md b/plugins/waypoint/skills/project-navigator/references/recovery.md deleted file mode 100644 index 97cc4f1..0000000 --- a/plugins/waypoint/skills/project-navigator/references/recovery.md +++ /dev/null @@ -1,48 +0,0 @@ -# 恢复与异常 - -## 恢复进度 - -调用技能时, 已有进度的项目先回复 "恢复进度". 任何会话调用技能都按记录中的状态继续, 不需要接管或登记. - -- "上次位置": 最后动作与记录时间, 取自 "当前状态". -- "当前情况": 工单状态取自 "工单状态" 一行 (没有工单时写 "无"); 工作区域取自 "工作区域" 一行. -- "后续操作": 接着要做的一件事. - -用户选 A 后运行 `status`, 按其中的下一动作继续. 有执行子代理还在工作时, 它属于启动它的会话; 在新会话中继续时, 按 "工单状态" 判断: 回执已写入就照常处理, 没有写入且原会话已经关闭时, 运行 `exec-brief` 重新派出执行子代理. - -## 运行受阻 - -编排会话自己的操作失败时 (命令出错, 环境问题), 不换写法绕开, 回复 "运行受阻": - -- "受阻情况": 受阻操作, 受阻原因, 原始提示 (原样摘录一行), 影响范围. -- "处理办法": 用户需要做什么. - -守卫的拒绝理由就是正确做法: 编排会话写 `.navigator/` 之外的文件被拒时, 改为发布工单交给执行子代理. - -## 问题报告 - -"运行受阻" 用于环境与操作的问题; 问题出在技能自身时, 回复 "问题报告", 用户会把报告转交给技能的维护者. 两种来源: - -- 脚本发现: 命令输出或 `status` 中出现 "技能异常" 一行. 脚本会记下命令与 hook 的内部错误, 不自洽的状态, 被拒绝的 "下一动作", 以及状态不变时重复给出的 "下一动作". -- 你自己发现: 规则互相矛盾, 骨架与校验对不上, 照 "下一动作" 做却走不通, 子代理交回中写了 "技能问题". - -做法: - -1. 用 Write 把经过写成 `.navigator/drafts/` 下的 JSON 草稿: `{"doing": "当时在做什么", "expected": "预期发生什么", "actual": "实际发生了什么", "reproduce": "怎么复现"}`. 流程无法继续时加 `"blocked": true`. -2. 草稿只写遇到的情况本身: 做了什么, 看到了什么, 原始提示原样摘录. 不写原因推测, 不写处理建议, 不写修复方案; 不贴工单正文与业务代码. -3. 运行 `project-navigator report --from <草稿>`. 脚本补上环境, 状态与异常记录, 把报告写入 `.navigator/drafts/reports/`, 并输出报告全文. -4. 按输出的下一动作回复 "问题报告": "报告概况" 两项照抄输出; "报告内容" 放一个语言标记为 `text` 的代码块, 把报告全文原样放进去, 不增删内容. - -"能否继续" 由脚本判断: 命令内部错误, 状态不自洽, 或草稿写了 `"blocked": true` 时为不能, 使用第 2 组选项, 等用户处理后再继续; 其余情况使用第 1 组选项, 用户选 A 后运行 `status`, 按其中的下一动作继续. 同一个问题只上报一次. - -## 记录被回退或改动 - -记录都在 git 中. 用户用 git 回退了记录, 或手工改了记录文件时, 运行 `status`, 按当前记录继续; 状态与实际情况对不上时 (例如工单状态已回退, 代码却还在), 回复 "运行受阻", 说明对不上的地方, 请用户决定以哪边为准. - -## 状态文件损坏 - -"当前状态" 显示状态文件损坏时, 回复 "运行受阻", 请用户用 git 恢复 `.navigator/state.json` (例如 `git checkout -- .navigator/state.json`) 后重新调用技能. - -## 升级 - -"下一动作" 提示项目由旧版本初始化时, 回复 "初始设置" 第 1 组选项. 用户选 A 后运行 `project-navigator init`. 升级会移除项目配置中的旧 hook 与旧放行规则, 删除 `.navigator/bin/` 与 `.navigator/guide/` 下的副本, 清理旧的会话登记与快照; 编排记录全部保留. 已发布但还没有回执的工单撤回为起草中, 重新审阅后由执行子代理处理. 按 init 的输出提交改动. diff --git a/plugins/waypoint/skills/project-navigator/references/replies.md b/plugins/waypoint/skills/project-navigator/references/replies.md deleted file mode 100644 index 9e8c74b..0000000 --- a/plugins/waypoint/skills/project-navigator/references/replies.md +++ /dev/null @@ -1,173 +0,0 @@ -# 回复类型 - - - -每条回复都以 "# 类型" 开头, 下方第一行是 "结论: ..." 一句话结论, 其后是脚本计算的进展行, 然后是各节, 最后是一个选项块; 不需要用户选择的回复 (下方 "选项" 写 "无") 不带选项块. 恰好 2 项的选项组, 在提供 `mcp__ccd_turn__wrap_up` 工具的应用中同时做成按钮, 按钮文字是选项的第一个分句, 写法见填写要求中的 "按钮" 一条; 规格中关闭了按钮的组除外 (选择后还要打字补充, 或选了就会提交). 回复前运行 `project-navigator reply <编号> --option <组号>` 取得填写要求与骨架, 按要求在骨架上填写. 回复结束时只校验结构: 标题, 结论行与选项块. - -## 初始设置 - -- 编号: `setup` -- 各节: 检查结果 (版本控制, 运行环境, 初始标记, 放行规则); 设置内容 (状态目录, 放行规则, 初始标记) -- 选项: - - 第 1 组 [是否设置]: A. 开始设置, 写入配置. B. 暂不设置, 稍后再来. - - 第 2 组 [如何继续]: A. 已经完成, 重新检查. - -## 首次接入 - -- 编号: `entry` -- 各节: 仓库情况 (版本控制, 已有代码, 初始需求) -- 选项: - - 第 1 组 [如何开始]: A. 新建项目, 从立项开始. B. 已有项目, 从盘点开始. - -## 恢复进度 - -- 编号: `resume` -- 各节: 上次位置 (最后动作, 记录时间); 当前情况 (工单状态, 工作区域); 后续操作 (下一动作) -- 选项: - - 第 1 组 [如何继续]: A. 按照计划, 继续推进. B. 暂不继续, 稍后再来. - -## 需求理解 - -- 编号: `understanding` -- 各节: 原话摘录; 我的假设; 调研方向 -- 选项: - - 第 1 组 [是否准确]: A. 理解准确, 开始调研. B. 有所偏差, 请指出来. - -## 调研报告 - -- 编号: `research` -- 各节: 报告文件 (文件路径, 来源数量, 选型问题); 已知事实; 关键假设; 未知事项 -- 选项: - - 第 1 组 [是否相符]: A. 认知相符, 开始讨论. B. 跳过讨论, 直接简报. C. 有所出入, 指出哪条. - -## 头脑风暴 - -- 编号: `brainstorm` -- 各节: 本轮角度 (角度序号, 角度名称, 所处阶段); 本轮问题; 我的点子 (点子内容, 请你评价) -- 选项: - - 第 1 组 [如何回答]: A. 按照编号, 逐条回答. B. 本题跳过, 说明原因. - - 第 2 组 [是否收敛]: A. 开始收敛, 整理结论. B. 继续发散, 换个角度. - -## 产出确认 - -- 编号: `confirm` -- 各节: 产出文件 (文件路径, 文件内容, 条目数量); 内容要点 -- 选项: - - 第 1 组 [是否确认]: A. 确认无误, 继续推进. B. 需要修改, 请提需求. - -## 规范对照 - -- 编号: `standards` -- 各节: 对照结果 (一致条目, 单方独有, 互相矛盾); 本轮条目 -- 选项: - - 第 1 组 [如何回答]: A. 按照编号, 逐条回答. B. 全部采用, 插件默认. - - 第 2 组 [是否纳入]: A. 全部纳入, 写入规范. B. 按照编号, 逐条决定. - - 第 3 组 [注释语言]: A. 中文注释, 默认选项. B. 英文注释, 改用英文. C. 其它语言, 直接写明. - -## 工单审阅 - -- 编号: `approve` -- 各节: 审阅要点 (任务目标, 需要决定, 明确不做, 粒度依据, 工单文件) -- 选项: - - 第 1 组 [是否发布]: A. 发布工单, 开始执行. B. 需要修改, 请提需求. - -## 工单执行 - -- 编号: `running` -- 各节: 执行情况 (工单文件, 回执路径, 执行方式, 中途停止 [骨架预填: 回复 "停止执行", 工单撤回后可以修改]) -- 选项: 无, 不需要用户选择, 回复不带选项块. - -## 工单验收 - -- 编号: `reviewing` -- 各节: 验收情况 (回执路径, 验收方式); 核对重点 -- 选项: 无, 不需要用户选择, 回复不带选项块. - -## 等待结果 - -- 编号: `waiting` -- 各节: 等待情况 (等待对象, 任务内容, 后续动作) -- 选项: 无, 不需要用户选择, 回复不带选项块. - -## 等待操作 - -- 编号: `paused` -- 各节: 操作内容 (等待原因, 完成之后 [骨架预填: 回复 "操作完成", 并写明实际做了什么; 不做时回复 "停止执行"]); 操作步骤 -- 选项: 无, 不需要用户选择, 回复不带选项块. - -## 验收报告 - -- 编号: `review` -- 各节: 验收结论 (总体结论, 判据通过, 回执来源); 判据核对; 专项检查 (回执一致, 接线核查, 范围核查, 测试改动, 测试运行, 修正核对, 规范核查); 人工验收; 处理建议 (建议选项, 主要依据) -- 选项: - - 第 1 组 [验收结论]: A. 通过验收, 提交改动. B. 通过验收, 提交推送. C. 发现问题, 描述现象. - - 第 2 组 [如何处理]: A. 补正工单, 立即发布. B. 暂不修复, 说明原因. - - 第 3 组 [未验判据]: A. 接受现状, 提交改动. B. 补正工单, 立即发布. - - 第 4 组 [是否采用]: A. 采用结论, 提交记录. B. 另选方案, 重新选型. - -## 受阻处理 - -- 编号: `blocked` -- 各节: 受阻原因 (受阻类型, 规格原文, 实际情况, 证据位置, 影响判据) -- 选项: - - 第 1 组 [如何处理]: A. 修改工单, 重新发布. B. 先做调研, 再定方案. C. 调整范围, 进入变更. - - 第 2 组 [如何处理]: A. 拆分工单, 重新发布. B. 保持原样, 说明原因. - - 第 3 组 [如何处理]: A. 操作完成, 继续执行. B. 暂不操作, 说明原因. - - 第 4 组 [如何处理]: A. 按照编号, 逐条回答. B. 转为变更, 重新评估. - -## 需求变更 - -- 编号: `change` -- 各节: 变更内容 (变更编号, 变更描述, 变更原因, 提出来源); 影响分析 (简报影响, 路径影响, 决策影响, 切片影响, 工单影响) -- 选项: - - 第 1 组 [如何处理]: A. 接受变更, 更新记录. B. 延后处理, 列入后续. C. 放弃变更, 记录原因. - -## 里程复查 - -- 编号: `milestone` -- 各节: 里程成果 (完成切片, 运行方式, 版本提交); 简报对照; 偏离检查 (范围偏离, 约束偏离, 新增风险, 文档体检); 复盘要点 (有效做法, 改进事项) -- 选项: - - 第 1 组 [如何推进]: A. 继续推进, 开始下一里程. B. 调整计划, 先走变更流程. - -## 项目收尾 - -- 编号: `closing` -- 各节: 总体验收; 项目统计 (里程数量, 切片数量, 工单数量, 变更数量); 复盘要点 (有效做法, 改进事项, 遗留事项) -- 选项: - - 第 1 组 [是否收尾]: A. 确认收尾, 项目完成. B. 继续迭代, 新开里程. - -## 项目完成 - -- 编号: `complete` -- 各节: 完成情况 (最近提交, 工作区域, 远程推送) -- 选项: - - 第 1 组 [后续安排]: A. 继续迭代, 新开里程. B. 推送远程, 保持完成. C. 暂无安排, 保持完成. - -## 自由答复 - -- 编号: `freeform` -- 各节: 答复内容 (按用户要求的格式撰写, 可以放代码块与三级及以下的标题, 不能用一级与二级标题) -- 选项: - - 第 1 组 [后续安排]: A. 回到流程, 继续推进. B. 还有问题, 继续答复. - -## 体检报告 - -- 编号: `checkup` -- 各节: 体检范围 (文件数量, 脚本检查, 语义检查); 问题清单; 分类统计 -- 选项: - - 第 1 组 [如何处理]: A. 逐条修正, 更新文档. B. 暂不修正, 记录在案. - -## 运行受阻 - -- 编号: `halted` -- 各节: 受阻情况 (受阻操作, 受阻原因, 原始提示, 影响范围); 处理办法 -- 选项: - - 第 1 组 [如何继续]: A. 已经处理, 重新尝试. B. 暂停编排, 稍后再来. - -## 问题报告 - -- 编号: `report` -- 各节: 报告概况 (报告文件, 能否继续); 报告内容 (按用户要求的格式撰写, 可以放代码块与三级及以下的标题, 不能用一级与二级标题) -- 选项: - - 第 1 组 [如何继续]: A. 记录问题, 继续推进. B. 暂停编排, 等待修复. - - 第 2 组 [如何继续]: A. 已经修复, 重新尝试. B. 暂停编排, 等待修复. diff --git a/plugins/waypoint/skills/project-navigator/references/selection.md b/plugins/waypoint/skills/project-navigator/references/selection.md deleted file mode 100644 index ee9a4b8..0000000 --- a/plugins/waypoint/skills/project-navigator/references/selection.md +++ /dev/null @@ -1,39 +0,0 @@ -# 阶段 3: 选型 - -本阶段先做规范对照, 再回答调研报告中的 "选型问题". 编排会话可以凭调研结果给出建议, 但关键依赖 (语言, 框架, 核心库, 以及难以替换的外部服务) 要用选型工单核实: 执行子代理按源码证据回答 "能不能实现每条需求", 由用户决定是否采用. 次要的库不发选型工单, 由执行子代理在实现工单中按选型守则选定, 写进回执的 "实现决定". - -| 步骤标识 | 做什么 | 回复 | -| --------- | -------- | ---------------------------- | -| standards | 规范对照 | 规范对照 | -| selection | 选型工单 | 工单审阅, 工单执行, 验收报告 | - -## 规范对照 (standards) - -1. 运行 `ref standards` 取得插件默认包全文. 派 `waypoint:navigator-reader` 对比三处规范, 默认包全文放进委派提示词; 子代理没回来就要结束本轮时, 回复 "等待结果". 要求交回一致, 只有一方有, 互相矛盾三类条目, 每条附文件路径与原文摘录: - - 插件默认包: 委派提示词中的全文 - - 项目已有规范: `CLAUDE.md`, `AGENTS.md`, `.claude/rules/`, 格式化与检查配置 - - 本机全局规范: `~/.claude/CLAUDE.md`, `~/.claude/rules/` -2. 回复 "规范对照", 按顺序使用三组选项: - - 有矛盾条目时用第 1 组. "本轮条目" 每轮最多 3 条, 每条写出双方原文与推荐以哪边为准. 用户逐条回答, 或选 B 全部以插件默认包为准. - - 矛盾处理完后, 对只有一方有的条目用第 2 组, 问是否纳入. - - 最后用第 3 组问代码注释的语言, 默认中文. -3. 写草稿 `.navigator/drafts/standards.json`: 注释语言写入 `commentLanguage`, 用户选定的项目约定写入 `conventions`, 每条一句. 运行 `standards --from .navigator/drafts/standards.json`, 脚本写入 `.claude/rules/engineering.md`. -4. 用 `decision add` 登记 "项目规范", 并在 `.navigator/plan/decisions.md` 追加对应条目. -5. 运行 `step selection`. - -## 选型工单 (selection) - -1. 列出选型问题: 调研报告的 "选型问题", 以及主线流程 "外部依赖" 中标注 "待选型" 的条目. 分成两类: 关键依赖发选型工单; 次要的库写进对应实现工单的 "执行守则", 由执行子代理选定. 回答选型问题之前要先动手查证的 (例如逆向目标程序的数据格式), 先发分析工单 (`--kind analysis`), 见 `ref order-loop` 的 "只产出记录的工单". -2. 每个关键选型问题一张工单: `order new --kind selection --slug <短名>`. 可以在 "背景原因" 中写出你的建议与依据, 由执行子代理核实. 验收判据写: - - 需求清单: 来自简报与主线流程的具体需求, 有几条写几条, 每条是可以回答 "能" 或 "不能" 的一件事. 判据写明: 回执 "需求核对" 表逐条回答, 结论为能的附源码证据或测试. - - 按项目规范中的技术选型守则选择候选. - - 选定语言或技术栈的工单, 另加一条: 选定格式化与代码检查工具, 规则中打开函数长度与复杂度检查, 检查范围排除 `.navigator/` 与 `.claude/`, 给出能在仓库根目录直接运行的检查命令. - - 只要求回答需求能否实现, 不要求回执中其它说法附证据. -3. 工单往返见 `ref order-loop`. 选型回执使用第 2 种结构 (`template receipt --option 2`). -4. 验收一次完成: 验收子代理只核对需求核对表, 依据是源码的运行 `evidence` 从原仓库取回核对, 依据是测试的重新运行. 编排会话不自己取源码. -5. 写好验收记录后, 回复 "验收报告" 第 4 组选项, 列出每条需求的核对结果, 由用户决定是否采用. 有对不上的需求也照实列出, 不另发补正工单: - - 用户选 A (采用): 在 `review.md` 写 "用户结论", 运行 `order set accepted`, 按输出提交. 核对没有通过的需求, 用 `risk add` 各登记一条风险. - - 用户选 B (另选): 运行 `order set rejected`, 按用户给出的方向新建选型工单 (`--kind selection`). 不发布修复工单: 修复工单要求已登记检查命令, 而检查命令在选型采用后才登记. -6. 用户采用后, 用 `decision add` 登记选型结论, 在 `.navigator/plan/decisions.md` 追加条目, 写明决策状态, 批准时间, 来源工单, 决策内容, 决策理由, 可否回退, 影响范围. -7. 选型结论中有检查命令时, 写草稿 `{"commands": ["检查命令"]}`, 运行 `codecheck set --from <草稿>` 登记. 确实没有合适的检查工具时, 经用户同意写 `{"commands": [], "reason": "原因"}` 登记. 登记之前, 实现与修复工单不能新建. -8. 全部关键选型问题都有决策后, 运行 `stage 4`, 按 `ref order-loop` 做骨架. diff --git a/plugins/waypoint/skills/project-navigator/references/slice-loop.md b/plugins/waypoint/skills/project-navigator/references/slice-loop.md new file mode 100644 index 0000000..fd96076 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/references/slice-loop.md @@ -0,0 +1,93 @@ +# 切片循环 + +同一时间只做一个切片. 一个切片一份文件, 在 `.navigator/slices/` 下, 分三段: "计划" 由编排会话写, "执行结果" 由实现方写, "验收" 由编排会话按验收子代理交回的内容写. 每一步做完都运行 `project-navigator status`, 按 "下一动作" 继续. + +## 写计划 + +1. 先读代码: 本切片要改或要用到的文件, 入口, 相邻模块. 计划里写的路径, 接口名, 数据格式要是读到的, 不是猜的. 文件多时用 Explore 子代理. +2. 运行 `project-navigator slice plan <编号> --slug <短名>`, 命令会建好切片文件. +3. 用 Edit 填 "计划" 段的每个小节, 小节内不加标题: + - 目标: 做完后使用者能做到什么. + - 完成标准: 编号列表. 每条写成从入口或界面能看到的结果, 并写明怎么验证. "实现了某个函数" 不是合格的标准. + - 明确不做: 容易被顺手做掉, 但不属于本切片的事. + - 需要决定: 必须由用户决定的事; 没有时写 "无". + - 模块安排 (只有 `build` 切片): 见下方. + - 结论位置 (只有 `findings` 切片): 结论写进 `.navigator/findings/` 下的哪些文件. + - 工作假设: 你对细节的判断. 与实际不符时实现方以实际为准. +4. "需要决定" 不是 "无" 时, 先问用户, 把答案写在这一节, 再运行 `slice start --decided`. 没有待定事项时直接运行 `slice start`, 不需要用户先过目. + +硬约束是目标, 完成标准, 明确不做与模块安排; 其余都是工作假设. 所以拿不准的细节写进 "工作假设", 不写进完成标准. + +### 模块安排 + +模块怎么表达, 以 `status` 输出的 "项目设置" 为准: 按物理目录分模块的项目, "模块" 一列写目录; 用 Visual Studio 筛选器分模块的项目, 文件一律平铺在工程目录下, "模块" 一列写筛选器名, 不建物理子目录. + +- 表格每行一个新增或改动的文件: 文件, 所属模块, 所属的层 (表现, 业务, 数据, 或项目自己定义的层), 这个文件承担的那一件事. +- 唯一定义: 本切片新增的常量, 类型, 配置, 规则, 各自的权威定义放在哪个文件. 同一条知识只定义一次. +- 复用: 用到了哪些已有实现; 没有时写 "无". + +安排时遵守项目规范 `.claude/rules/engineering.md`: 一个文件只承担一件事; 跨层只通过接口; 优先复用已有实现; 没有两处真实重复之前, 不建通用模块, 不把代码下沉到公共层. + +## 开工 + +- 普通切片: `slice start` 之后, 用 Agent 工具在后台 (`run_in_background`) 派出 `waypoint:navigator-executor`, 提示词是 `project-navigator brief exec` 的输出原文. 然后告诉用户切片已开工, 结束本轮; 子代理结束时会自动通知. +- 交互式切片: 不派子代理, 见下方 "交互式切片". + +## 中途交回 + +实现子代理不能向用户提问. 它结束时最后一条消息的第一行说明情况: + +- "交回: 完成": 运行 `status`, 按下一动作送验. +- "交回: 需要回答" 或 "交回: 需要操作": 把它写的问题或操作步骤原样转告用户. 用户回复后, 用 SendMessage 把回复转给原来的子代理, 它会接着做; 找不到它时重新运行 `brief exec` 派一个新的, 新的子代理靠 "执行结果" 段的 "中途记录" 接着做. + +用户的回答改变了计划时 (例如换了一种做法), 先用 Edit 改计划, 运行 `slice replan`, 再让子代理继续. 切片状态一直是实现中, 不需要重新开工. + +## 计划与实际不符 + +实现方发现工作假设与实际不符时, 取得证据后按实际做, 在 "执行结果" 段的 "修正" 表中逐行记录, 不交回. 个别完成标准按事实做不到时, 做完其余部分, 列进 "待定标准". 这两种情况都在验收时由验收子代理核对, 由用户决定. 你不需要在中途处理. + +## 送验与验收 + +1. "执行状态" 为 "完成" 后运行 `project-navigator slice review`. 命令会核对计划段没有被改动, 执行结果已填好. +2. 用 Agent 工具派出 `waypoint:navigator-reviewer`, 提示词是 `project-navigator brief review` 的输出原文. +3. 验收子代理交回后, 用 Edit 把内容原样写入切片文件的 "验收" 段: 每个小节对应它交回的一节, 不改结论, 不删证据. "用户结论" 先保留占位. +4. 运行 `status`, 按下一动作向用户报告并请用户决定. 报告写: 每条标准的结论, 接线与结构核对中的问题, 成立与不成立的修正, 需要用户亲手验证的步骤. + +验收结论只有通过, 不通过, 未验证三种. 三种情况下用户的选择: + +| 情况 | 选项 | 用户选择之后 | +| -------------------- | ------------------------------------- | ---------------------------------------------------------------------- | +| 全部通过 | A 通过并提交; B 通过, 提交并推送 | 把原话写入 "用户结论", 运行 `slice accept` | +| 有未验证, 没有不通过 | A 接受现状并提交; B 补做 | A: 写入 "用户结论", 运行 `slice accept --accept-unverified`; B: 见补做 | +| 有不通过 | A 补做; B 放弃这个切片 (骨架只能补做) | A: 见补做; B: 运行 `slice drop` | + +- 需要用户亲手验证的标准 (验证方式为 `manual` 或 `measured` 的项目很常见) 会被判为未验证, 并写在 "人工验收" 中. 把步骤给用户, 用户确认结果后选 A, 在 "用户结论" 中写明用户看到了什么. +- `slice accept` 之后按输出调用 `waypoint:commit-message` 提交. 提交消息不附加任何署名. +- 补做: 把要补的内容写入计划段的 "补做" (替换掉 "无"), 运行 `slice redo`. 命令把本轮验收归档在文件中, 并在文末给出新的 "验收" 段. 然后用 SendMessage 让原来的实现子代理继续, 找不到时重新派出. +- 接线没有接上的切片不能通过: 相关标准会被判为不通过, 只能补做或放弃. 不要用 "下个切片再接" 绕过. + +## 交互式切片 + +逆向, 实测这类任务每一步都要用户配合, 子代理不能与用户对话, 所以由你在对话中直接做. 它只产出结论, 不改业务代码. + +1. `slice start` 之后, 在本会话按计划逐步做. 需要用户操作时直接说, 写到用户不用追问就能照做. +2. 每确认一条结论, 立即用 Write 或 Edit 写入 `.navigator/findings/` 下的文件, 再做下一步. 每条结论写明: 宿主或依赖的版本, 位置 (地址, 偏移, 文件与行号), 验证方法, 验证时间. 没有验证过的只能写成 "待验证". +3. 调试输出会占用上下文. 结论都在文件里, 进度在状态里, 所以随时可以让用户新开一个会话调用本技能接着做. +4. 不需要对话的重活 (例如在一份转储里批量搜索) 可以派给后台的通用子代理, 结果回到本会话. +5. 做完后填写 "执行结果" 段, 把 "执行状态" 改成 "完成", 运行 `slice review`, 照常派验收子代理. +6. 需要改业务代码时, 不在这个切片里改: 把结论写好, 在路线中排一个 `build` 切片. + +## 待办 + +当前切片之外的想法, 不论是你的还是用户的, 都用 Edit 追加到 `.navigator/backlog.md`, 每条一行: 内容, 来源, 日期. 文件不存在时用 Write 新建, 一级标题写 "待办". 不顺手做, 也不塞进当前切片的计划. + +## 里程复查 + +当前里程的切片全部结束后: + +1. 对照里程目标, 向用户说明达成了什么, 没达成什么. +2. 与用户一起整理 `.navigator/backlog.md`: 每项归入下一个里程, 继续留着, 或删除. +3. 为下一个里程排出切片, 用 `roadmap --from <草稿>` 写入 (已有的里程与切片带上 `id`). 还没有下一个里程而用户有新的目标时, 一并加上. +4. 用户确认后运行 `project-navigator milestone done <编号>`, 按输出提交. + +全部里程完成后, 见 `ref change` 的 "收尾". diff --git a/plugins/waypoint/skills/project-navigator/references/writing.md b/plugins/waypoint/skills/project-navigator/references/writing.md deleted file mode 100644 index 8d18af1..0000000 --- a/plugins/waypoint/skills/project-navigator/references/writing.md +++ /dev/null @@ -1,65 +0,0 @@ -# 写作规则 - - - -编排会话写的每一条回复与每一份记录文件都按本文件写. 这些规则是写作建议: 回复结束时 hook 只校验结构 (标题, 结论行, 选项块), 不按写作规则打回; 体检命令 `check` 同时报告 "问题" 与 "提示". - -## 结论先行 - -- 每条回复标题下方第一行是 "结论: ...": 先说结果, 再说要用户做的选择, 尽量短. 读者注意力有限, 只读这一行也要能做决定. -- 细节放在结论之后, 读者可以跳过; 选项块放在最后. - -## 朴素中文 - -- 一句话只说一件事; 不铺垫, 不复述, 不写空话套话. -- 不自造简称: 名词第一次出现时写全称; 新术语先写进术语表 `.navigator/plan/glossary.md`. -- 不打比方, 不用黑话: 直接写事实, 数字与文件路径. -- 不用没有名词的指代: 写 "工单 0007", 不写 "它"; 写 "这个切片", 不写 "这". -- 例子要完整: 写出输入, 操作与结果. -- 一处只讲一个焦点: 一节一个主题, 一段一个意思. -- 不依赖读者记忆: 需要前文信息时, 重写关键内容或给出文件路径. - -## 面向注意力障碍与强迫倾向的读者 - -- 结构固定: 标题, 结论行, 进展行与选项块的位置每次都一样, 读者不用重新寻找. -- 信息分块: 段落, 列表与嵌套都有上限, 见下方规则表. -- 对齐整齐: 编号与计数按固定位数补零, 同一组选项字数一致, 表格源码按显示宽度对齐. -- 下一步明确: 需要用户选择的回复都以选项块结尾, 选项之外不留开放问题; 不需要用户选择的回复 (例如 "工单执行") 不带选项块, 避免用户以为要先选择才会继续. - -## 规则表 - -| 类别 | 级别 | 规则 | -| -------- | ---- | -------------------------------------------------------- | -| 结构不符 | 问题 | 记录文件的标题, 节与键名不符合规格 | -| 句子过长 | 问题 | 没有标点的片段超过 40 字; 达到 20 字时给出提示 | -| 段落过长 | 问题 | 连续的普通文本超过 4 行 | -| 列表过长 | 问题 | 同一层列表超过 7 项 | -| 列表过深 | 问题 | 列表嵌套超过 2 层 | -| 加粗过多 | 提示 | 一节中加粗超过 3 处 | -| 的字过多 | 提示 | 一个片段中 "的" 超过 2 个 | -| 黑话用词 | 问题 | 使用黑话, 见禁用词 | -| 比喻用词 | 问题 | 使用比喻用词, 见禁用词 | -| 翻译腔调 | 提示 | 使用翻译腔句式, 见禁用词 | -| 指代不明 | 问题 | 句首使用没有名词的指代词, 例如 "它", "这是" | -| 表格未齐 | 提示 | 表格源码的竖线没有按显示宽度对齐 | -| 篇幅过长 | 提示 | 回复超过 60 行, 或记录文件超过 200 行 (只追加的记录不限) | - -字数的算法: 汉字, 英文单词, 数字, 路径各算 1 字, 行内代码与网址整体算 1 字, 空格与标点不算. - -## 禁用词 - -- 黑话用词: 赋能, 抓手, 闭环, 打通, 沉淀, 颗粒度, 心智, 底层逻辑, 顶层设计, 组合拳, 拉通, 倒逼, 反哺, 护城河, 飞轮, 势能, 赛道, 深耕, 丝滑, 无缝. -- 比喻用词: 就像, 好比, 犹如, 仿佛, 如同, 宛如, 比作, 打个比方. -- 翻译腔调: - - 去掉 "进行", 直接用后面的动词. - - 去掉 "作出", 直接用后面的动词. - - 去掉 "对于...来说", 直接说对象. - - 把 "在...的情况下" 改为 "如果...". - - 把 "通过...的方式" 改为 "用...". - -## 标点与排版 - -- 使用英文 (ASCII) 标点, 禁止中文全角标点; 成对符号也用 ASCII 版, 即 `()`, `""`, `''`. -- 每个标点后跟一个空格; 标点位于行尾或文本末尾时不加. -- 标点属于数字, 代码, 标识符, URL 或路径等 "记号" 整体的一部分时不加空格. 例: `v2.1.187`, `file.txt`, `C:\path`, `50%`, `a/b`. -- 成对开合标点按英文惯例: 开符外侧留空格内侧不留, 闭符内侧不留外侧留空格. diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/brief.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/brief.mjs index 2507f98..c448e68 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/brief.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/brief.mjs @@ -1,91 +1,75 @@ /** - * @file precheck-brief, exec-brief, review-brief, research-brief 命令: 输出派子代理 - * 时使用的固定提示词. + * @file brief 命令: 输出实现子代理与验收子代理的委派提示词. * - * 提示词由脚本生成, 编排会话原样使用, 不在其中加入倾向性说明. + * 用法: brief exec | brief review. 提示词只对进行中的切片生成, 编排会话原样使用. */ -import { - RESEARCH_FRAME, - buildExecutorBrief, - buildPrecheckBrief, - buildReviewBrief, -} from "../lib/briefs.mjs"; -import { orderCheckCommands } from "../lib/code-checks.mjs"; -import { readOrderText } from "../lib/order-approval.mjs"; -import { orderFilePath } from "../lib/paths.mjs"; -import { criteriaTableSpec } from "../lib/review-record.mjs"; +import { buildExecutorBrief, buildReviewBrief } from "../lib/briefs.mjs"; +import { runFilterCheck } from "../lib/filters.mjs"; +import { activeSlice } from "../lib/roadmap.mjs"; +import { loadSliceSpec } from "../lib/slice-file.mjs"; +import { SLICE_STATUSES, SLICE_STATUS_LABELS } from "../lib/state.mjs"; import { WorkflowError } from "../lib/workflow-error.mjs"; -import { - ISSUED_ORDER_STATUS, - REVIEWABLE_ORDER_STATUSES, -} from "../lib/workflow-orders.mjs"; import { openProject } from "./support.mjs"; /** - * 执行提示词命令. - * - * @param {object} options 命令参数. - * @param {"precheck-brief" | "exec-brief" | "review-brief" | "research-brief"} options.command 命令名. - * @param {string} options.cwd 会话工作目录. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 没有当前工单, 工单不在需要的状态, 或命令未知时. + * 提示词的种类. + * @type {Readonly<{exec: string, review: string}>} */ -export function runBrief({ command, cwd }) { - const context = openProject(cwd); - if (command === "research-brief") { - return RESEARCH_FRAME.split("\n"); - } - const order = context.state.order; - if (order === null) { - throw new WorkflowError("当前没有工单."); - } - switch (command) { - case "precheck-brief": - assertPrecheckable(context.projectRoot, order); - return buildPrecheckBrief({ - projectRoot: context.projectRoot, - order, - }).split("\n"); - case "exec-brief": - if (order.status !== ISSUED_ORDER_STATUS) { - throw new WorkflowError( - `工单 ${order.id} 处于 ${order.status}, 发布 (order set issued) 之后才能派执行子代理.`, - ); - } - return buildExecutorBrief({ - projectRoot: context.projectRoot, - order, - }).split("\n"); - case "review-brief": - return buildReviewBrief({ - projectRoot: context.projectRoot, - order, - checkCommands: orderCheckCommands(context.state), - criteriaTable: criteriaTableSpec(context.spec), - }).split("\n"); - default: - throw new WorkflowError(`未知命令 ${command}.`); - } -} +const BRIEF_KINDS = Object.freeze({ exec: "exec", review: "review" }); + +/** + * 每种提示词要求切片所处的状态. + * @type {Readonly>} + */ +const REQUIRED_STATUSES = Object.freeze({ + [BRIEF_KINDS.exec]: SLICE_STATUSES.running, + [BRIEF_KINDS.review]: SLICE_STATUSES.reviewing, +}); /** - * 核对当前工单可以做发布前核对: 还没有发布, 工单文件已写好. + * 执行 brief 命令. * - * @param {string} projectRoot 项目根目录. - * @param {import("../lib/state.mjs").OrderState} order 当前工单. - * @returns {void} - * @throws {WorkflowError} 工单已发布, 或工单文件还没有写入时. + * @param {import("./handlers.mjs").CommandInvocation} invocation 命令调用. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 种类未知, 或进行中的切片不在对应状态时. */ -function assertPrecheckable(projectRoot, order) { - if (!REVIEWABLE_ORDER_STATUSES.includes(order.status)) { +export function runBrief({ positionals, cwd }) { + const [kind] = positionals; + if (kind === undefined || !Object.hasOwn(REQUIRED_STATUSES, kind)) { throw new WorkflowError( - `工单 ${order.id} 处于 ${order.status}, 发布前核对只用于 ${REVIEWABLE_ORDER_STATUSES.join(", ")} 状态的工单.`, + `brief 的用法: brief <${Object.keys(REQUIRED_STATUSES).join(" | ")}>.`, ); } - if (readOrderText(projectRoot, order) === undefined) { + const { projectRoot, state } = openProject(cwd); + const slice = activeSlice(state)?.slice; + const required = REQUIRED_STATUSES[kind]; + if ( + slice === undefined || + state.current === null || + slice.status !== required + ) { + throw new WorkflowError( + `brief ${kind} 要求进行中的切片处于 "${SLICE_STATUS_LABELS[required]}"${slice === undefined ? "; 现在没有进行中的切片" : `; 切片 ${slice.id} 现在是 "${SLICE_STATUS_LABELS[slice.status]}"`}.`, + ); + } + const context = { + projectRoot, + slice, + current: state.current, + project: state.project, + spec: loadSliceSpec(), + }; + if (kind === BRIEF_KINDS.review) { + return buildReviewBrief({ + ...context, + filterLines: runFilterCheck(projectRoot, state), + }).split("\n"); + } + if (slice.interactive) { throw new WorkflowError( - `工单文件 ${orderFilePath(order.folder, "order")} 还没有写入.`, + `切片 ${slice.id} 是交互式切片, 在本会话执行, 不派实现子代理.`, ); } + return buildExecutorBrief(context).split("\n"); } diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/check.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/check.mjs index bdba87a..fd53f68 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/check.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/check.mjs @@ -1,222 +1,120 @@ /** - * @file check 命令: 体检的脚本层. 检查编排文档的结构与写作规则, 分配体检编号. + * @file check 命令: 记录自查与筛选器检查. 只读, 不改任何文件. * - * 用法: - * - check: 体检, 检查计划目录中编排会话写的文档, 全部工单与验收记录, 以及最近一份 - * 回执, 并分配体检编号 - * - check <文件...>: 只检查指定文件, 用于写完记录后自查, 不分配编号, 不写状态 - * - * 语义层 (自造简称, 未定义术语, 比喻等) 由编排会话派只读子代理完成, 不在脚本中. + * 用法: check [--filters]. + * - 不带参数: 核对状态与记录文件是否对得上, 进行中的切片文件是否读得出来. + * - --filters: 模块用筛选器表达的项目, 核对每个源文件都归入了筛选器. */ -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; -import { checkFile, findFileSpec } from "../lib/file-checks.mjs"; -import { formatNumber } from "../lib/numbering.mjs"; +import { REQUIREMENTS_FILE } from "../lib/paths.mjs"; +import { activeSlice } from "../lib/roadmap.mjs"; import { - GENERATED_FILES, - navigatorPath, - orderFilePath, - projectRelativePath, - relativePathsEqual, -} from "../lib/paths.mjs"; -import { renderTable } from "../lib/table.mjs"; + PARTS, + findUnfilledSections, + loadSliceSpec, + partSpec, + readSliceFile, + readVerdicts, +} from "../lib/slice-file.mjs"; +import { ORGANIZATIONS, SLICE_STATUSES, STAGES } from "../lib/state.mjs"; import { WorkflowError } from "../lib/workflow-error.mjs"; -import { addCheckup } from "../lib/workflow-records.mjs"; -import { checkWriting } from "../lib/writing-checks.mjs"; -import { openProject, saveState } from "./support.mjs"; - -/** - * 工单文件夹中由编排会话编写, 需要检查的文件种类. - * @type {readonly ("order" | "review")[]} - */ -const ORCHESTRATOR_ORDER_FILES = Object.freeze(["order", "review"]); +import { runFilterCheck } from "../lib/filters.mjs"; +import { openProject } from "./support.mjs"; /** - * @typedef {import("../lib/writing-checks.mjs").WritingFinding & {file: string}} FileFinding 带文件路径的问题. + * 切片处于各状态时, 应该已经填好的段. + * @type {Readonly>} */ +const FILLED_PARTS = Object.freeze({ + [SLICE_STATUSES.running]: [PARTS.plan], + [SLICE_STATUSES.reviewing]: [PARTS.plan, PARTS.result], +}); /** * 执行 check 命令. * - * @param {object} options 命令参数. - * @param {string[]} options.positionals 要检查的文件; 为空时检查默认范围. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. + * @param {import("./handlers.mjs").CommandInvocation} invocation 命令调用. * @returns {string[]} 输出各行. - * @throws {WorkflowError} 指定的文件不存在时. - */ -export function runCheck({ positionals, cwd, now }) { - const context = openProject(cwd); - const isSelfCheck = positionals.length > 0; - const files = isSelfCheck - ? resolveTargets(context.projectRoot, positionals) - : defaultTargets(context.projectRoot); - const findings = files.flatMap((file) => - checkOneFile(context.projectRoot, file, context.spec), - ); - if (isSelfCheck) { - return reportLines({ id: undefined, files, findings, spec: context.spec }); - } - const { id, state } = addCheckup(context.state); - saveState(context, state, now); - return reportLines({ id, files, findings, spec: context.spec }); -} - -/** - * 解析命令行指定的文件, 转换为项目相对路径. - * - * @param {string} projectRoot 项目根目录. - * @param {string[]} targets 命令行给出的路径. - * @returns {string[]} 项目相对路径. - * @throws {WorkflowError} 文件在项目之外或不存在时. + * @throws {WorkflowError} 带 --filters 而项目不用筛选器表达模块时. */ -function resolveTargets(projectRoot, targets) { - return targets.map((target) => { - const relative = projectRelativePath(projectRoot, target); - if ( - relative === undefined || - !existsSync(path.join(projectRoot, relative)) - ) { - throw new WorkflowError(`文件 ${target} 不在项目中或不存在.`); +export function runCheck({ values, cwd }) { + const { projectRoot, state } = openProject(cwd); + if (values.filters === true) { + if (state.project.organization !== ORGANIZATIONS.filters) { + throw new WorkflowError( + `筛选器检查只用于模块组织方式为 ${ORGANIZATIONS.filters} 的项目.`, + ); } - return relative; - }); + return runFilterCheck(projectRoot, state); + } + const problems = [ + ...recordProblems(projectRoot, state), + ...sliceProblems(projectRoot, state), + ]; + return problems.length === 0 + ? ["- 自查结果: 没有问题"] + : [ + `- 自查结果: ${problems.length} 个问题`, + ...problems.map((problem) => ` - ${problem}`), + ]; } /** - * 列出默认检查范围: 计划目录中除生成文件外的文档, 各工单的工单与验收记录, - * 以及编号最大的一份回执. + * 核对状态与记录文件是否对得上. * * @param {string} projectRoot 项目根目录. - * @returns {string[]} 项目相对路径. + * @param {import("../lib/state.mjs").NavigatorState} state 当前状态. + * @returns {string[]} 问题列表. */ -function defaultTargets(projectRoot) { - const planFiles = listFiles(navigatorPath(projectRoot, "plan")) - .filter((name) => name.endsWith(".md")) - .map((name) => - projectRelativePath( - projectRoot, - path.join(navigatorPath(projectRoot, "plan"), name), - ), - ) +function recordProblems(projectRoot, state) { + const missingSliceFiles = state.milestones + .flatMap((milestone) => milestone.slices) .filter( - (relative) => - relative !== undefined && - !GENERATED_FILES.some((generated) => - relativePathsEqual(generated, relative), - ), - ); - const isPresent = (relative) => existsSync(path.join(projectRoot, relative)); - const folders = listFiles(navigatorPath(projectRoot, "orders")).sort(); - const orderFiles = folders.flatMap((folder) => - ORCHESTRATOR_ORDER_FILES.map((kind) => orderFilePath(folder, kind)).filter( - isPresent, - ), - ); - const latestReceipt = [...folders] - .reverse() - .map((folder) => orderFilePath(folder, "receipt")) - .find(isPresent); + (slice) => + slice.file !== null && !existsSync(path.join(projectRoot, slice.file)), + ) + .map((slice) => `切片 ${slice.id} 的文件 ${slice.file} 不存在.`); return [ - ...planFiles, - ...orderFiles, - ...(latestReceipt === undefined ? [] : [latestReceipt]), + ...(state.stage !== STAGES.intake && + !existsSync(path.join(projectRoot, REQUIREMENTS_FILE)) + ? [`需求文件 ${REQUIREMENTS_FILE} 不存在.`] + : []), + ...missingSliceFiles, + ...(state.current !== null && activeSlice(state) === undefined + ? [`状态中进行中的切片 ${state.current.sliceId} 不在路线里.`] + : []), ]; } /** - * 列出目录中的条目名; 目录不存在时返回空列表. - * - * @param {string} directory 目录. - * @returns {string[]} 条目名. - */ -function listFiles(directory) { - return existsSync(directory) ? readdirSync(directory) : []; -} - -/** - * 检查一个文件的结构与写作规则. + * 核对进行中的切片文件: 按切片状态应该填好的段是否填好, 标准核对表是否合格. * * @param {string} projectRoot 项目根目录. - * @param {string} relative 项目相对路径. - * @param {import("../lib/spec.mjs").TemplateSpec} spec 模板规格. - * @returns {FileFinding[]} 问题. - */ -function checkOneFile(projectRoot, relative, spec) { - const text = readFileSync(path.join(projectRoot, relative), "utf8"); - const fileSpec = findFileSpec(spec, relative); - const structure = - fileSpec === undefined - ? [] - : checkFile({ text, fileSpec, spec }).map((message) => ({ - label: spec.writing.structure.label, - severity: spec.writing.structure.severity, - line: 1, - message, - })); - const writing = checkWriting({ - text, - spec, - maxLines: - fileSpec?.entryPattern === undefined - ? spec.writing.length.maxFileLines - : undefined, - }); - return [...structure, ...writing].map((finding) => ({ - ...finding, - file: relative, - })); -} - -/** - * 生成体检结果: 概况, 问题清单 (只列问题级别) 与分类统计. - * - * @param {object} options 结果参数. - * @param {string | undefined} options.id 体检编号; 只检查指定文件时为 undefined. - * @param {string[]} options.files 检查的文件. - * @param {FileFinding[]} options.findings 全部问题. - * @param {import("../lib/spec.mjs").TemplateSpec} options.spec 模板规格. - * @returns {string[]} 输出各行. + * @param {import("../lib/state.mjs").NavigatorState} state 当前状态. + * @returns {string[]} 问题列表. */ -function reportLines({ id, files, findings, spec }) { - const problems = findings.filter((finding) => finding.severity === "problem"); - const hints = findings.filter((finding) => finding.severity === "hint"); - const labels = Object.values(spec.writing).map((rule) => rule.label); - const statistics = labels - .map((label) => [ - label, - formatNumber(problems.filter((item) => item.label === label).length), - formatNumber(hints.filter((item) => item.label === label).length), - ]) - .filter(([, problemCount, hintCount]) => - [problemCount, hintCount].some((count) => Number(count) > 0), - ); +function sliceProblems(projectRoot, state) { + const slice = activeSlice(state)?.slice; + const spec = loadSliceSpec(); + const parsed = + slice === undefined + ? undefined + : readSliceFile(projectRoot, slice.file, spec); + if (slice === undefined || parsed === undefined) { + return []; + } return [ - ...(id === undefined ? [] : [`- 体检编号: ${id}`]), - `- 文件数量: ${formatNumber(files.length)}`, - `- 问题数量: ${formatNumber(problems.length)}`, - `- 提示数量: ${formatNumber(hints.length)}`, - "", - "问题清单:", - "", - ...(problems.length === 0 - ? ["无"] - : renderTable( - ["文件", "位置", "类别", "说明"], - problems.map((item) => [ - item.file, - `第 ${item.line} 行`, - item.label, - item.message, - ]), - )), - "", - "分类统计:", - "", - ...(statistics.length === 0 - ? ["无"] - : renderTable(["类别", "问题", "提示"], statistics)), + ...(FILLED_PARTS[slice.status] ?? []).flatMap((partId) => + findUnfilledSections({ parsed, spec, partId, kind: slice.kind }).map( + (title) => + `${slice.file}: "${partSpec(spec, partId).heading}" 段的 "${title}" 没有填好.`, + ), + ), + ...readVerdicts(parsed, spec).problems.map( + (problem) => `${slice.file}: ${problem}`, + ), ]; } diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/codecheck.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/codecheck.mjs deleted file mode 100644 index e71b3c5..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/codecheck.mjs +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @file codecheck 命令: 登记项目的代码检查命令. - * - * 用法: codecheck set --from <草稿>, 草稿格式 {"commands": ["ruff check ."], "reason": ""}. - * 没有合适的检查工具时, commands 为空, reason 写明原因. - */ - -import { describeCodeChecks, registerCodeChecks } from "../lib/code-checks.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { - openProject, - readDraftJson, - resultLines, - saveState, -} from "./support.mjs"; - -/** - * 执行 codecheck 命令. - * - * @param {object} options 命令参数. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 用法不对或草稿不合法时. - */ -export function runCodeCheck({ positionals, values, cwd, now }) { - if (positionals[0] !== "set") { - throw new WorkflowError("codecheck 的用法: codecheck set --from <草稿>."); - } - const context = openProject(cwd); - const draft = readDraftJson(context.projectRoot, values.from); - const state = saveState( - context, - registerCodeChecks(context.state, { - commands: Array.isArray(draft.commands) ? draft.commands.map(String) : [], - reason: typeof draft.reason === "string" ? draft.reason : "", - }), - now, - ); - return [ - ...resultLines(state), - `- 代码检查: ${describeCodeChecks(state.codeChecks)}`, - ]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/decision.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/decision.mjs new file mode 100644 index 0000000..4c14d67 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/decision.mjs @@ -0,0 +1,54 @@ +/** + * @file decision 命令: 登记一条决策, 并把条目追加到决策记录. + * + * 用法: decision add --from <草稿>. + */ + +import { appendFileSync, existsSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { DECISIONS_HEADER, addDecision } from "../lib/decisions.mjs"; +import { DECISIONS_FILE } from "../lib/paths.mjs"; +import { WorkflowError } from "../lib/workflow-error.mjs"; +import { openProject, readDraftJson, saveState } from "./support.mjs"; + +/** + * decision 命令唯一的子命令. + * @type {string} + */ +const ADD_SUBCOMMAND = "add"; + +/** + * 草稿的格式说明. + * @type {string} + */ +const DRAFT_FORMAT = + '{"title": "一句话标题", "decision": "决定了什么", "reason": "为什么", "rejected": "放弃了什么", "supersedes": "被取代的决策编号, 没有时不写"}'; + +/** + * 执行 decision 命令. + * + * @param {import("./handlers.mjs").CommandInvocation} invocation 命令调用. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 子命令未知或草稿不合法时. + */ +export function runDecision({ positionals, values, cwd, now }) { + if (positionals[0] !== ADD_SUBCOMMAND) { + throw new WorkflowError( + `decision 的用法: decision add --from <草稿>, 草稿格式: ${DRAFT_FORMAT}.`, + ); + } + const { projectRoot, state } = openProject(cwd); + const { state: next, entry } = addDecision( + state, + readDraftJson(projectRoot, values.from), + now, + ); + const file = path.join(projectRoot, DECISIONS_FILE); + if (!existsSync(file)) { + writeFileSync(file, DECISIONS_HEADER, "utf8"); + } + appendFileSync(file, `\n${entry}`, "utf8"); + saveState(projectRoot, next, now); + return [`- 执行结果: ${next.lastAction}, 条目已追加到 ${DECISIONS_FILE}`]; +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/enter.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/enter.mjs index 4584d54..cc96dcd 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/enter.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/enter.mjs @@ -1,82 +1,45 @@ /** - * @file enter 与 status 命令: 检查环境与初始化情况, 输出当前状态与下一动作. + * @file enter 与 status 命令: 检查环境与初始化情况, 输出当前位置与下一动作. * * enter 由技能加载时的 `!` 命令调用, 输出会原样注入技能内容, 因此必须始终以 * 退出码 0 结束. enter 的输出带本项目的编排标记: 技能内容记在会话转录中, hook - * 据此认出编排会话, 不需要登记. status 只查询, 不输出标记, 不写项目文件; - * 两者都做状态自检, status 另外记下给出的下一动作, 用来发现技能自身的问题 - * (见 lib/diagnostics.mjs), 这些记录在本机的运行期登记目录中. + * 据此认出编排会话, 不需要登记. status 只查询, 不输出标记, 不写任何文件. */ -import { existsSync, rmSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; -import { - EVENT_KINDS, - EVENT_LABELS, - REPORT_STEP, - recordEvent, - trackGuidance, - unreportedEvents, -} from "../lib/diagnostics.mjs"; import { checkEnvironment } from "../lib/environment.mjs"; -import { REPORT_OPTIONS, nextAction, replyStep } from "../lib/guidance.mjs"; -import { digestOrderFile, readApprovalStatus } from "../lib/order-approval.mjs"; +import { + command, + nextAction, + progressLine, + readFacts, +} from "../lib/guidance.mjs"; import { orchestratorMarker } from "../lib/orchestrator-marker.mjs"; import { - LEGACY_BIN_DIRECTORY, + ARCHIVE_DIRECTORY, + COMMITTED_PATHSPECS, + NAVIGATOR_DIRECTORY, NAVIGATOR_IGNORE_FILE, PROJECT_LOCAL_SETTINGS_FILE, - PROJECT_SETTINGS_FILE, - navigatorPath, - registryDirectory, + REQUIRED_TRACKED_PATHS, + STATE_FILE, } from "../lib/paths.mjs"; -import { readReceiptStatus } from "../lib/receipt-record.mjs"; -import { progressLine } from "../lib/render.mjs"; import { - countTrackedFiles, findIgnoredPaths, + listTrackedFiles, listUncommittedPaths, + summarizeIgnoredPaths, } from "../lib/repo.mjs"; -import { - NAVIGATOR_PERMISSION_RULES, - hasLegacyHooks, - readSettingsFile, -} from "../lib/settings.mjs"; -import { lastStageNumber, loadSpec } from "../lib/spec.mjs"; +import { hasNavigatorPermissions, readSettingsFile } from "../lib/settings.mjs"; import { INIT_ACTIVE, - INIT_UNINSTALLED, - readStateIfValid, + ORGANIZATION_LABELS, + VERIFICATION_LABELS, + isCurrentState, + readRawState, } from "../lib/state.mjs"; -import { - COMMITTED_PATHSPECS, - requiredTrackedPaths, - summarizeIgnoredPaths, -} from "../lib/tracked-paths.mjs"; -import { checkStateConsistency } from "../lib/state-checks.mjs"; -import { describeModels } from "../lib/subagent-models.mjs"; -import { ISSUED_ORDER_STATUS } from "../lib/workflow-orders.mjs"; -import { isProjectFinished } from "../lib/workflow-plan.mjs"; - -/** - * "初始设置" 中请用户确认初始化或升级的选项组. - * @type {number} - */ -const SETUP_OPTION = 1; - -/** - * "初始设置" 中说明环境不满足, 无法初始化的选项组. - * @type {number} - */ -const SETUP_BLOCKED_OPTION = 2; - -/** - * 1.0.0 之前登记编排会话的文件名. 升级之前旧版 hook 仍会运行, 它按这个文件 - * 判断编排会话并按白名单拦下命令; 进入时删掉, 旧 hook 就不再拦下升级命令. - * @type {string} - */ -const LEGACY_ORCHESTRATOR_FILE = "orchestrator.json"; /** * 执行 enter 或 status 命令. @@ -84,225 +47,113 @@ const LEGACY_ORCHESTRATOR_FILE = "orchestrator.json"; * @param {object} options 命令参数. * @param {string} options.cwd 会话工作目录. * @param {boolean} options.isEntry 是否为调用技能时的进入. - * @param {string} options.now 当前时间, ISO 格式. * @returns {string[]} 输出各行. */ -export function runEnter({ cwd, isEntry, now }) { - const spec = loadSpec(); +export function runEnter({ cwd, isEntry }) { const environment = checkEnvironment(cwd); const nodeLine = `- 运行环境: Node.js ${environment.nodeVersion}${environment.isNodeSupported ? "" : ", 版本过低, 需要 22 或更高版本"}`; if (environment.repositoryRoot === undefined) { return [ "- 版本控制: 当前目录不是 Git 仓库", nodeLine, - `- 下一动作: ${replyStep(spec, "初始设置", SETUP_BLOCKED_OPTION)}, 提示先运行 /waypoint:repo-init`, + "- 下一动作: 告诉用户本技能需要 Git 仓库, 请先运行 /waypoint:repo-init", ]; } const projectRoot = environment.repositoryRoot; - const markerLines = isEntry - ? [`- 编排标记: ${orchestratorMarker(projectRoot)}`] - : []; - const state = readStateIfValid(projectRoot); - if (state === undefined && existsSync(navigatorPath(projectRoot, "state"))) { - return [ - ...markerLines, - "- 初始标记: 状态文件损坏", - nodeLine, - `- 下一动作: ${replyStep(spec, "运行受阻")}, 说明状态文件不是合法的 JSON; 请用户用 git 恢复 .navigator/state.json 后重新调用技能`, - ]; - } - const isLegacy = isLegacyInstall(projectRoot); - if (isEntry && isLegacy) { - rmSync( - path.join(registryDirectory(projectRoot), LEGACY_ORCHESTRATOR_FILE), - { - force: true, - }, - ); - } + const raw = readRawState(projectRoot); const lines = [ - ...markerLines, - `- 版本控制: Git 仓库, 已跟踪 ${countTrackedFiles(projectRoot)} 个文件`, + ...(isEntry ? [`- 编排标记: ${orchestratorMarker(projectRoot)}`] : []), + `- 版本控制: Git 仓库, 已跟踪 ${listTrackedFiles(projectRoot).length} 个文件`, nodeLine, - `- 初始标记: ${describeInit(state, isLegacy)}`, - `- 放行规则: ${hasPermissionRules(projectRoot) ? "已写入" : "未写入"}`, ]; - const blocker = findBlocker({ - state, - isLegacy, - isNodeSupported: environment.isNodeSupported, - projectRoot, - spec, - }); - if (blocker !== undefined || state === undefined) { - return [...lines, `- 下一动作: ${blocker ?? ""}`]; + if (!environment.isNodeSupported) { + return [...lines, "- 下一动作: 告诉用户先安装 Node.js 22 或更高版本"]; + } + const blocker = findBlocker(projectRoot, raw); + if (blocker !== undefined) { + return [...lines, ...blocker]; } return [ ...lines, - ...describeProgress({ projectRoot, state, isEntry, spec, now }), + ...describeProgress( + projectRoot, + /** @type {import("../lib/state.mjs").NavigatorState} */ (raw), + ), ]; } /** - * 描述记录情况与下一动作. + * 找出阻止进入编排的情况: 没有初始化, 旧版本的记录, 状态文件损坏, 或必须入库的 + * 路径被忽略规则漏掉. * - * @param {object} options 参数. - * @param {string} options.projectRoot 项目根目录. - * @param {import("../lib/state.mjs").NavigatorState} options.state 当前状态. - * @param {boolean} options.isEntry 是否为调用技能时的进入. - * @param {import("../lib/spec.mjs").TemplateSpec} options.spec 模板规格. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. + * @param {string} projectRoot 项目根目录. + * @param {Record | undefined} raw 读取到的状态文件内容. + * @returns {string[] | undefined} 输出各行; 可以照常进入时为 undefined. */ -function describeProgress({ projectRoot, state, isEntry, spec, now }) { - const order = state.order; - const receiptStatus = - order === null ? undefined : readReceiptStatus(projectRoot, order); - const uncommitted = listUncommittedPaths(projectRoot, COMMITTED_PATHSPECS); - const inconsistencies = checkStateConsistency({ state, projectRoot, spec }); - for (const message of inconsistencies) { - recordEvent(projectRoot, { - kind: EVENT_KINDS.invalidState, - source: "status", - message, - stack: [], - at: now, - }); +function findBlocker(projectRoot, raw) { + const hasStateFile = existsSync(path.join(projectRoot, STATE_FILE)); + if (hasStateFile && raw === undefined) { + return [ + "- 初始标记: 状态文件损坏", + `- 下一动作: 告诉用户 ${STATE_FILE} 不是合法的 JSON, 请用 git 恢复后重新调用技能`, + ]; } - const action = - inconsistencies.length > 0 - ? `状态不自洽, 不能照常继续: ${inconsistencies.join(" ")} ${REPORT_STEP}, 使用第 ${REPORT_OPTIONS.blocking} 组选项` - : nextAction({ - state, - receiptStatus, - orderApproval: - order === null ? undefined : readApprovalStatus(projectRoot, order), - isOrderUnchanged: - order === null - ? undefined - : order.issuedDigest === digestOrderFile(projectRoot, order), - hasUncommittedRecords: - isProjectFinished(state, lastStageNumber(spec)) && - uncommitted.length > 0, - isEntry, - spec, - }); - if (!isEntry) { - trackGuidance(projectRoot, { - action, - revision: state.revision, - isWaiting: - order?.status === ISSUED_ORDER_STATUS && receiptStatus === undefined, - now, - }); + if (raw !== undefined && !isCurrentState(raw)) { + return [ + `- 初始标记: ${NAVIGATOR_DIRECTORY}/ 由旧版本 ${raw.skillVersion ?? "未知"} 写入`, + `- 下一动作: 告诉用户本版本不读取旧记录, 也不迁移; 用户同意后运行 ${command("init --archive")}, 把 ${NAVIGATOR_DIRECTORY}/ 改名为 ${ARCHIVE_DIRECTORY}/ (只改名, 不删除), 然后重新立项. 用户不同意时结束, 不做任何修改`, + ]; } - const pending = unreportedEvents(projectRoot); - return [ - "", - `- 当前进展: ${progressLine(state, spec)}`, - `- 最后动作: ${state.lastAction}`, - `- 记录时间: ${state.updatedAt}`, - ...(order === null - ? [] - : [ - `- 工单状态: ${order.id} ${order.status}, 回执${describeReceipt(receiptStatus)}`, - ]), - `- 工作区域: ${uncommitted.length === 0 ? "技能产物都已入库" : `${uncommitted.length} 个技能产物未入库`}`, - `- 子代理模型: ${describeModels(state.models)}`, - ...(pending.length === 0 - ? [] - : [ - `- 技能异常: 脚本记录到 ${pending.length} 条还没有上报的异常 (${[...new Set(pending.map((event) => EVENT_LABELS[event.kind] ?? event.kind))].join(", ")}). ${REPORT_STEP}`, - ]), - `- 下一动作: ${action}`, - ]; -} - -/** - * 描述回执的情况. - * - * @param {string | undefined} receiptStatus 回执中的执行状态; 回执不存在时为 undefined. - * @returns {string} 描述. - */ -function describeReceipt(receiptStatus) { - if (receiptStatus === undefined) { - return "未写入"; + if (raw === undefined || raw.init.status !== INIT_ACTIVE) { + return [ + `- 初始标记: ${raw === undefined ? "未找到" : "已卸载"}`, + `- 下一动作: 询问用户是否在本项目初始化 (创建 ${NAVIGATOR_DIRECTORY}/, 在项目配置中加入插件命令的放行规则, 保留已有配置); 同意后运行 ${command("init")}, 不同意时结束. 本次调用参数是 init 时, 用户已经同意, 不再询问, 直接运行`, + ]; + } + const ignored = findIgnoredPaths(projectRoot, REQUIRED_TRACKED_PATHS); + if (ignored.length > 0) { + return [ + `- 初始标记: 已初始化, 版本 ${raw.skillVersion}`, + `- 下一动作: 告诉用户编排记录或项目配置会被 Git 忽略, 无法入库: ${summarizeIgnoredPaths(ignored).join("; ")}. 用户修改这些规则后重新调用技能; 规则在 ${NAVIGATOR_IGNORE_FILE} 中时, 运行 ${command("init")} 即可恢复`, + ]; } - return receiptStatus === "" ? "已写入" : `已写入, 执行状态 ${receiptStatus}`; + return undefined; } /** - * 判断项目是否由 1.0.0 之前的版本初始化: 项目配置中还有项目级 hook, 或状态目录中 - * 还有运行脚本副本. + * 描述位置, 项目设置与下一动作. * * @param {string} projectRoot 项目根目录. - * @returns {boolean} 是旧版本的安装时返回 true. + * @param {import("../lib/state.mjs").NavigatorState} state 当前状态. + * @returns {string[]} 输出各行. */ -function isLegacyInstall(projectRoot) { - return ( - existsSync(path.join(projectRoot, LEGACY_BIN_DIRECTORY)) || - hasLegacyHooks(readSettingsFile(projectRoot, PROJECT_SETTINGS_FILE)) +function describeProgress(projectRoot, state) { + const uncommitted = listUncommittedPaths(projectRoot, COMMITTED_PATHSPECS); + const hasPermissions = hasNavigatorPermissions( + readSettingsFile(projectRoot, PROJECT_LOCAL_SETTINGS_FILE), ); + return [ + `- 初始标记: 已初始化, 版本 ${state.skillVersion}`, + `- 放行规则: ${hasPermissions ? "已写入" : `未写入, 运行 ${command("init")} 补上`}`, + "", + `- 当前进展: ${progressLine(state)}`, + `- 项目设置: ${describeSettings(state.project)}`, + `- 最后动作: ${state.lastAction} (${state.updatedAt})`, + `- 工作区域: ${uncommitted.length === 0 ? "技能产物都已入库" : `${uncommitted.length} 个技能产物未入库`}`, + `- 下一动作: ${nextAction(state, readFacts(projectRoot, state))}`, + ]; } /** - * 判断本机配置中是否已有本技能的全部放行规则. - * - * @param {string} projectRoot 项目根目录. - * @returns {boolean} 全部写入时返回 true. - */ -function hasPermissionRules(projectRoot) { - const allow = - readSettingsFile(projectRoot, PROJECT_LOCAL_SETTINGS_FILE).permissions - ?.allow ?? []; - return NAVIGATOR_PERMISSION_RULES.every((rule) => allow.includes(rule)); -} - -/** - * 描述初始化情况. - * - * @param {import("../lib/state.mjs").NavigatorState | undefined} state 状态. - * @param {boolean} isLegacy 是否为 1.0.0 之前的安装. - * @returns {string} 描述. - */ -function describeInit(state, isLegacy) { - if (state === undefined) { - return "未找到"; - } - if (state.init.status === INIT_UNINSTALLED) { - return "已卸载"; - } - return isLegacy - ? `由旧版本 ${state.skillVersion} 初始化, 需要升级` - : `已初始化, 版本 ${state.skillVersion}`; -} - -/** - * 找出阻止进入编排的问题, 并给出下一动作. 必须入库的路径被忽略规则漏掉时也要 - * 停下: 否则新写的记录会悄悄漏提交. + * 描述项目设置. * - * @param {object} options 判断参数. - * @param {import("../lib/state.mjs").NavigatorState | undefined} options.state 状态. - * @param {boolean} options.isLegacy 是否为 1.0.0 之前的安装. - * @param {boolean} options.isNodeSupported Node.js 版本是否满足要求. - * @param {string} options.projectRoot 项目根目录. - * @param {import("../lib/spec.mjs").TemplateSpec} options.spec 模板规格. - * @returns {string | undefined} 下一动作; 没有问题时为 undefined. + * @param {import("../lib/state.mjs").ProjectSettings} project 项目设置. + * @returns {string} 一行描述. */ -function findBlocker({ state, isLegacy, isNodeSupported, projectRoot, spec }) { - if (!isNodeSupported) { - return `${replyStep(spec, "初始设置", SETUP_BLOCKED_OPTION)}, 提示先安装 Node.js 22 或更高版本`; - } - const setup = `${replyStep(spec, "初始设置", SETUP_OPTION)}, 用户选 A 后运行 init`; - if (state === undefined || state.init.status !== INIT_ACTIVE) { - return setup; +function describeSettings(project) { + if (project.organization === null || project.verification === null) { + return "未登记"; } - if (isLegacy) { - return `项目由 1.0.0 之前的版本初始化, 需要升级 (移除项目中的旧 hook 与脚本副本, 记录保留): ${setup}`; - } - const ignored = findIgnoredPaths(projectRoot, requiredTrackedPaths(spec)); - if (ignored.length > 0) { - return `${replyStep(spec, "运行受阻")}, 说明编排记录或项目配置会被 Git 忽略, 无法入库: ${summarizeIgnoredPaths(ignored).join("; ")}. 用户修改这些规则后重新调用技能; 规则在 ${NAVIGATOR_IGNORE_FILE} 中时, 运行 init 即可恢复`; - } - return undefined; + const { mode, commands } = project.verification; + return `${ORGANIZATION_LABELS[project.organization]}; 验证方式为${VERIFICATION_LABELS[mode]}${commands.length === 0 ? "" : ` (${commands.join("; ")})`}`; } diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/evidence.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/evidence.mjs deleted file mode 100644 index c7bfdf5..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/evidence.mjs +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @file evidence 命令: 从原仓库取回选型回执中引用的源码行, 供验收子代理与执行子代理核对. - * - * 用法: - * - evidence: 核对当前选型工单回执 "能力核实" 表中的全部证据 - * - evidence check <仓库> <版本> <路径> <行号>: 核对一条证据, 执行子代理写回执前自查 - */ - -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; - -import { evidenceCacheDirectory, orderFilePath } from "../lib/paths.mjs"; -import { findRepositoryRoot } from "../lib/repo.mjs"; -import { - EVIDENCE_CHECK_ACTION, - formatEvidence, - readEvidenceRows, - verifyEvidenceRows, -} from "../lib/source-evidence.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { SELECTION_ORDER_KIND } from "../lib/workflow-orders.mjs"; -import { openProject } from "./support.mjs"; - -/** - * 本命令的命令名, 用于用法说明. - * @type {string} - */ -const EVIDENCE_COMMAND_NAME = "evidence"; - -/** - * 单条自查时 check 之后的参数个数: 仓库, 版本, 路径, 行号. - * @type {number} - */ -const CHECK_ARGUMENT_COUNT = 4; - -/** - * 单条自查时, 证据中 "能力" 与 "说明" 两列显示的文字. - * @type {Readonly<{capability: string, note: string}>} - */ -const SELF_CHECK_LABELS = Object.freeze({ - capability: "单条自查", - note: "无", -}); - -/** - * 执行 evidence 命令. - * - * @param {object} options 命令参数. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {string} options.cwd 会话工作目录. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 参数不合法, 没有选型工单或回执中没有证据表时. - */ -export function runEvidence({ positionals, cwd }) { - const [action, ...rest] = positionals; - if (action === undefined) { - return verifyReceipt(cwd); - } - if ( - action === EVIDENCE_CHECK_ACTION && - rest.length === CHECK_ARGUMENT_COUNT - ) { - return verifySingle(cwd, rest); - } - throw new WorkflowError( - `${EVIDENCE_COMMAND_NAME} 的用法: ${EVIDENCE_COMMAND_NAME}, 或 ${EVIDENCE_COMMAND_NAME} ${EVIDENCE_CHECK_ACTION} <仓库> <版本> <路径> <行号>.`, - ); -} - -/** - * 核对当前选型工单回执中的全部证据. - * - * @param {string} cwd 会话工作目录. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 没有选型工单, 回执不存在或没有证据表时. - */ -function verifyReceipt(cwd) { - const context = openProject(cwd); - const order = context.state.order; - if (order === null || order.kind !== SELECTION_ORDER_KIND) { - throw new WorkflowError( - `当前没有选型工单; ${EVIDENCE_COMMAND_NAME} 只核对选型工单回执中的源码证据.`, - ); - } - const receipt = path.join( - context.projectRoot, - orderFilePath(order.folder, "receipt"), - ); - if (!existsSync(receipt)) { - throw new WorkflowError(`工单 ${order.id} 还没有回执文件.`); - } - return reportLines( - readEvidenceRows(readFileSync(receipt, "utf8")), - evidenceCacheDirectory(context.projectRoot), - ); -} - -/** - * 核对命令行给出的一条证据. - * - * @param {string} cwd 会话工作目录. - * @param {string[]} values 仓库, 版本, 路径, 行号. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 不在 Git 仓库中时. - */ -function verifySingle(cwd, [repository, version, filePath, lines]) { - const projectRoot = findRepositoryRoot(cwd); - if (projectRoot === undefined) { - throw new WorkflowError("当前目录不是 Git 仓库."); - } - return reportLines( - [{ ...SELF_CHECK_LABELS, repository, version, path: filePath, lines }], - evidenceCacheDirectory(projectRoot), - ); -} - -/** - * 逐条核对证据, 输出每条的结果与汇总. - * - * @param {import("../lib/source-evidence.mjs").EvidenceRow[]} rows 各条证据. - * @param {string} cacheRoot 源码缓存目录. - * @returns {string[]} 输出各行. - */ -function reportLines(rows, cacheRoot) { - const results = verifyEvidenceRows(rows, cacheRoot).map((result, index) => ({ - row: rows[index], - ...result, - })); - const failed = results.filter((result) => result.excerpt === undefined); - return [ - ...results.flatMap((result, index) => [ - ...formatEvidence({ index: index + 1, ...result }), - "", - ]), - `- 核对结果: 共 ${rows.length} 条, 取到源码 ${rows.length - failed.length} 条, 取不到 ${failed.length} 条. 取不到源码的证据按未验证处理.`, - ]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/handlers.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/handlers.mjs index 4a12f37..50eb797 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/handlers.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/handlers.mjs @@ -4,20 +4,13 @@ import { runBrief } from "./brief.mjs"; import { runCheck } from "./check.mjs"; -import { runCodeCheck } from "./codecheck.mjs"; +import { runDecision } from "./decision.mjs"; import { runEnter } from "./enter.mjs"; -import { runEvidence } from "./evidence.mjs"; import { runInit } from "./init.mjs"; -import { runModel } from "./model.mjs"; -import { runOrder } from "./order.mjs"; import { runPlan } from "./plan.mjs"; -import { runRecords } from "./records.mjs"; import { runRef } from "./ref.mjs"; -import { runReply } from "./reply.mjs"; -import { runReport } from "./report.mjs"; -import { runStage } from "./stage.mjs"; -import { runStandards } from "./standards.mjs"; -import { runTemplate } from "./template.mjs"; +import { runSetup } from "./setup.mjs"; +import { runSlice } from "./slice.mjs"; import { runUninstall } from "./uninstall.mjs"; /** @@ -33,38 +26,28 @@ import { runUninstall } from "./uninstall.mjs"; * @typedef {(invocation: CommandInvocation) => string[]} CommandHandler 命令的处理函数, 返回输出各行. */ +/** + * 技能加载时调用的命令; 它出错时也以退出码 0 结束. + * @type {string} + */ +export const ENTER_COMMAND = "enter"; + /** * 每条命令的处理函数, 键为命令名. * @type {Readonly>} */ export const COMMAND_HANDLERS = Object.freeze({ - init: ({ cwd, now }) => runInit({ cwd, now }), - uninstall: ({ cwd, now }) => runUninstall({ cwd, now }), - enter: ({ cwd, now }) => runEnter({ cwd, isEntry: true, now }), - status: ({ cwd, now }) => runEnter({ cwd, isEntry: false, now }), - reply: ({ positionals, values, cwd }) => - runReply({ cwd, type: positionals[0], optionSet: Number(values.option) }), - template: runTemplate, + init: runInit, + uninstall: runUninstall, + [ENTER_COMMAND]: ({ cwd }) => runEnter({ cwd, isEntry: true }), + status: ({ cwd }) => runEnter({ cwd, isEntry: false }), ref: runRef, - stage: runStage, - step: runStage, - skip: runStage, - finish: runStage, + setup: runSetup, roadmap: runPlan, milestone: runPlan, - slice: runPlan, - order: runOrder, - risk: runRecords, - decision: runRecords, - change: runRecords, - "precheck-brief": runBrief, - "exec-brief": runBrief, - "review-brief": runBrief, - "research-brief": runBrief, - evidence: runEvidence, + finish: runPlan, + slice: runSlice, + brief: runBrief, + decision: runDecision, check: runCheck, - standards: runStandards, - codecheck: runCodeCheck, - model: runModel, - report: runReport, }); diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/init.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/init.mjs index 17111d1..26f47ce 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/init.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/init.mjs @@ -1,77 +1,64 @@ /** - * @file init 命令: 初始化项目, 或把 1.0.0 之前初始化的项目升级到新的结构. + * @file init 命令: 初始化项目, 或把旧版本的记录归档后重新初始化. * - * 初始化写入状态目录, 术语表与放行规则; hook 由插件提供, 不写进项目配置, 也不 - * 需要自检. 写入之前先检查必须入库的路径是否会被项目的忽略规则漏掉. + * 初始化写入状态目录与放行规则; hook 由插件提供, 不写进项目配置. 写入之前先检查 + * 必须入库的路径是否会被项目的忽略规则漏掉. * - * 升级时另外清理旧版本写进项目的内容: 项目配置中的项目级 hook 与旧放行规则, - * `.navigator/bin/` 下的脚本副本与 `.navigator/guide/`, 运行期登记目录中的会话 - * 登记等文件, 以及快照引用. 已发布但还没有回执的工单撤回为起草中, 重新审阅后 - * 由执行子代理处理. 编排记录全部保留. + * 2.0.0 不读取更早版本的记录, 也不迁移: 发现旧记录时停下, 用户同意后用 + * `init --archive` 把旧的状态目录整个改名, 不删除任何内容. */ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; import path from "node:path"; import { checkEnvironment } from "../lib/environment.mjs"; import { - GLOSSARY_FILE, - LEGACY_BIN_DIRECTORY, - LEGACY_GUIDE_DIRECTORY, - LEGACY_REGISTRY_ENTRIES, - LEGACY_SNAPSHOT_REF, + ARCHIVE_DIRECTORY, + COMMAND_NAME, + DRAFTS_DIRECTORY, + FINDINGS_DIRECTORY, NAVIGATOR_DIRECTORY, - NAVIGATOR_ENTRIES, - ORDER_ARTIFACTS_DIRECTORY, + NAVIGATOR_IGNORE_FILE, PROJECT_LOCAL_SETTINGS_FILE, PROJECT_SETTINGS_FILE, - navigatorPath, - orderFilePath, - registryDirectory, + REQUIRED_TRACKED_PATHS, + SLICES_DIRECTORY, + STATE_FILE, } from "../lib/paths.mjs"; -import { renderGlossarySeed } from "../lib/render-plan.mjs"; import { - deleteRef, ensureLocallyIgnored, findIgnoredPaths, + summarizeIgnoredPaths, } from "../lib/repo.mjs"; import { mergeNavigatorPermissions, readSettingsFile, - removeLegacyHooks, writeSettingsFile, } from "../lib/settings.mjs"; -import { loadSpec } from "../lib/spec.mjs"; import { INIT_ACTIVE, createInitialState, - readStateIfValid, - writeState, + isCurrentState, + readRawState, } from "../lib/state.mjs"; -import { - requiredTrackedPaths, - summarizeIgnoredPaths, -} from "../lib/tracked-paths.mjs"; import { runtimeVersion } from "../lib/version.mjs"; -import { recordCommitStep } from "./support.mjs"; - -/** - * 状态目录中需要预先创建的空目录. - * @type {readonly ("plan" | "orders" | "drafts")[]} - */ -const EMPTY_DIRECTORIES = Object.freeze(["plan", "orders", "drafts"]); +import { productsCommitStep, saveState } from "./support.mjs"; /** - * 发布状态; 升级时没有回执的已发布工单撤回为起草中. - * @type {string} + * 状态目录中需要预先创建的目录, 相对于项目根目录. + * @type {readonly string[]} */ -const ISSUED_STATUS = "issued"; +const EMPTY_DIRECTORIES = Object.freeze([ + SLICES_DIRECTORY, + FINDINGS_DIRECTORY, + DRAFTS_DIRECTORY, +]); /** - * 状态目录内 `.gitignore` 的内容. 先重新纳入各级目录与插件写入的文件类型, - * 避免项目中 `*.json` 之类的规则漏掉记录; 工单的证据文件类型不固定 (例如脚本, - * 抓包导出), 整个目录重新纳入. 其它文件 (例如系统与编辑器生成的文件) 仍按项目 - * 规则处理. 最后排除草稿: 草稿只是命令的输入, 不入库. 后写的规则优先. + * 状态目录内 `.gitignore` 的内容. 先重新纳入各级目录与插件写入的文件类型, 避免 + * 项目中 `*.json` 之类的规则漏掉记录; 结论目录中的文件类型不固定 (例如脚本, + * 抓包导出), 整个目录重新纳入. 最后排除草稿: 草稿只是命令的输入, 不入库. + * 后写的规则优先. * @type {string} */ const NAVIGATOR_GITIGNORE = [ @@ -79,8 +66,8 @@ const NAVIGATOR_GITIGNORE = [ "!*.md", "!*.json", "!.gitignore", - `!${NAVIGATOR_ENTRIES.orders}/*/${ORDER_ARTIFACTS_DIRECTORY}/**`, - "drafts/", + `!${path.posix.basename(FINDINGS_DIRECTORY)}/**`, + `${path.posix.basename(DRAFTS_DIRECTORY)}/`, "", ].join("\n"); @@ -90,9 +77,10 @@ const NAVIGATOR_GITIGNORE = [ * @param {object} options 命令参数. * @param {string} options.cwd 会话工作目录. * @param {string} options.now 当前时间, ISO 格式. + * @param {Record} options.values 选项值; archive 为 true 时先归档旧记录. * @returns {string[]} 输出各行. */ -export function runInit({ cwd, now }) { +export function runInit({ cwd, now, values }) { const environment = checkEnvironment(cwd); if (environment.repositoryRoot === undefined) { return [ @@ -106,186 +94,130 @@ export function runInit({ cwd, now }) { `- 运行环境: Node.js ${environment.nodeVersion} 版本过低, 需要 22 或更高版本`, ]; } - return installNavigator(environment.repositoryRoot, now); + const projectRoot = environment.repositoryRoot; + const hasStateFile = existsSync(path.join(projectRoot, STATE_FILE)); + const raw = readRawState(projectRoot); + if (hasStateFile && !isCurrentState(raw)) { + return values.archive === true + ? archiveAndInstall(projectRoot, now) + : legacyLines(raw); + } + if (values.archive === true) { + return [ + "- 设置结果: 未执行", + `- 归档: ${NAVIGATOR_DIRECTORY}/ 中没有旧版本的记录, 不需要 --archive`, + ]; + } + return installNavigator(projectRoot, now, []); } /** - * 写入状态目录, 术语表与放行规则, 并清理旧版本的内容. 先写入状态目录的 - * `.gitignore` 并检查必须入库的路径, 仍有路径被忽略时不做其它改动. + * 把旧版本的状态目录改名归档, 再初始化. * * @param {string} projectRoot 项目根目录. * @param {string} now 当前时间. * @returns {string[]} 输出各行. */ -function installNavigator(projectRoot, now) { - writeIgnoreFile(projectRoot); - const ignored = findIgnoredPaths( - projectRoot, - requiredTrackedPaths(loadSpec()), +function archiveAndInstall(projectRoot, now) { + const target = path.join(projectRoot, ARCHIVE_DIRECTORY); + if (existsSync(target)) { + return [ + "- 设置结果: 未执行", + `- 归档: ${ARCHIVE_DIRECTORY}/ 已经存在, 请用户先把它移走或改名`, + ]; + } + renameSync(path.join(projectRoot, NAVIGATOR_DIRECTORY), target); + return installNavigator(projectRoot, now, [ARCHIVE_DIRECTORY]); +} + +/** + * 写入状态目录与放行规则. 先写入状态目录的 `.gitignore` 并检查必须入库的路径, + * 仍有路径被忽略时不做其它改动. + * + * @param {string} projectRoot 项目根目录. + * @param {string} now 当前时间. + * @param {readonly string[]} archived 本次归档出的目录, 相对于项目根目录. + * @returns {string[]} 输出各行. + */ +function installNavigator(projectRoot, now, archived) { + mkdirSync(path.join(projectRoot, NAVIGATOR_DIRECTORY), { recursive: true }); + writeFileSync( + path.join(projectRoot, NAVIGATOR_IGNORE_FILE), + NAVIGATOR_GITIGNORE, + "utf8", ); + const ignored = findIgnoredPaths(projectRoot, REQUIRED_TRACKED_PATHS); if (ignored.length > 0) { - return ignoredPathLines(ignored); + return [ + "- 设置结果: 未执行, 编排记录或项目配置会被 Git 忽略, 无法入库", + ...summarizeIgnoredPaths(ignored).map( + (summary) => `- 忽略规则: ${summary}`, + ), + "- 处理办法: 修改上面的忽略规则, 让这些文件能够入库, 然后重新运行 init", + ]; + } + for (const directory of EMPTY_DIRECTORIES) { + mkdirSync(path.join(projectRoot, directory), { recursive: true }); } - const version = runtimeVersion(); - const existing = readStateIfValid(projectRoot); - createDirectories(projectRoot); - seedGlossary(projectRoot); installSettings(projectRoot); - const removed = removeLegacyContent(projectRoot); - writeState( + const version = runtimeVersion(); + const existing = readRawState(projectRoot); + saveState( projectRoot, - existing === undefined - ? createInitialState({ skillVersion: version, now }) - : migrateState(projectRoot, existing, version), + isCurrentState(existing) + ? { + ...existing, + init: { ...existing.init, status: INIT_ACTIVE }, + lastAction: "重新初始化", + } + : createInitialState({ skillVersion: version, now }), now, ); - const commit = recordCommitStep( - projectRoot, - existing === undefined ? "初始化" : "升级", - { includeState: true }, - ); + const commit = productsCommitStep(projectRoot, "初始化", { + includeState: true, + extraPathspecs: archived, + }); return [ - `- 设置结果: ${existing === undefined ? "已写入" : "已升级"}`, + "- 设置结果: 已写入", `- 状态目录: ${NAVIGATOR_DIRECTORY}/`, + ...archived.map( + (directory) => `- 归档: 旧记录已改名为 ${directory}/, 内容没有改动`, + ), `- 放行规则: 已合并进 ${PROJECT_SETTINGS_FILE}, 另存一份到本机的 ${PROJECT_LOCAL_SETTINGS_FILE}`, - ...(removed.length === 0 ? [] : [`- 清理旧版本: ${removed.join(", ")}`]), `- 技能版本: ${version}`, - `- 下一动作: ${commit === undefined ? "" : `${commit}; 然后`}运行 status, 按其中的下一动作继续`, + `- 下一动作: ${commit === undefined ? "" : `${commit}; 然后`}运行 \`${COMMAND_NAME} status\`, 按其中的下一动作继续`, ]; } /** - * 升级已有状态: 更新技能版本, 恢复为已初始化; 已发布但还没有回执的工单撤回为 - * 起草中, 重新审阅后由执行子代理处理. - * - * @param {string} projectRoot 项目根目录. - * @param {import("../lib/state.mjs").NavigatorState} state 已有状态. - * @param {string} version 技能版本. - * @returns {import("../lib/state.mjs").NavigatorState} 升级后的状态. - */ -function migrateState(projectRoot, state, version) { - const order = state.order; - const isWaitingIssued = - order !== null && - order.status === ISSUED_STATUS && - !existsSync(path.join(projectRoot, orderFilePath(order.folder, "receipt"))); - return { - ...state, - skillVersion: version, - init: { ...state.init, status: INIT_ACTIVE }, - order: isWaitingIssued ? { ...order, status: "drafting" } : order, - lastAction: isWaitingIssued - ? `升级到 ${version}, 工单 ${order.id} 撤回为起草中` - : `升级到 ${version}`, - }; -} - -/** - * 写入放行规则: 入库的项目配置与本机配置各一份, 项目配置中移除旧版本的 - * 项目级 hook, 并确保本机配置不被纳入版本控制. + * 写入放行规则: 入库的项目配置与本机配置各一份, 并确保本机配置不被纳入版本控制. * * @param {string} projectRoot 项目根目录. * @returns {void} */ function installSettings(projectRoot) { - writeSettingsFile( - projectRoot, - PROJECT_SETTINGS_FILE, - mergeNavigatorPermissions( - removeLegacyHooks(readSettingsFile(projectRoot, PROJECT_SETTINGS_FILE)), - ), - ); - ensureLocallyIgnored(projectRoot, PROJECT_LOCAL_SETTINGS_FILE); - writeSettingsFile( - projectRoot, - PROJECT_LOCAL_SETTINGS_FILE, - mergeNavigatorPermissions( - readSettingsFile(projectRoot, PROJECT_LOCAL_SETTINGS_FILE), - ), - ); -} - -/** - * 清理旧版本写进项目的内容: 脚本副本, 执行手册副本, 运行期登记中的旧文件, - * 快照引用. - * - * @param {string} projectRoot 项目根目录. - * @returns {string[]} 实际清理掉的项目, 用于输出. - */ -function removeLegacyContent(projectRoot) { - const directories = [LEGACY_BIN_DIRECTORY, LEGACY_GUIDE_DIRECTORY].filter( - (directory) => existsSync(path.join(projectRoot, directory)), - ); - for (const directory of directories) { - rmSync(path.join(projectRoot, directory), { recursive: true, force: true }); - } - const registry = registryDirectory(projectRoot); - const entries = LEGACY_REGISTRY_ENTRIES.filter((entry) => - existsSync(path.join(registry, entry)), - ); - for (const entry of entries) { - rmSync(path.join(registry, entry), { recursive: true, force: true }); - } - const isSnapshotRemoved = deleteRef(projectRoot, LEGACY_SNAPSHOT_REF); - return [ - ...directories.map((directory) => `${directory}/`), - ...(entries.length === 0 ? [] : ["旧的会话登记与快照记录"]), - ...(isSnapshotRemoved ? [LEGACY_SNAPSHOT_REF] : []), - ]; -} - -/** - * 术语表不存在时写入流程术语; 已有的术语表保留原样. - * - * @param {string} projectRoot 项目根目录. - * @returns {void} - */ -function seedGlossary(projectRoot) { - const file = path.join(projectRoot, GLOSSARY_FILE); - if (!existsSync(file)) { - writeFileSync(file, renderGlossarySeed(loadSpec()), "utf8"); + for (const file of [PROJECT_SETTINGS_FILE, PROJECT_LOCAL_SETTINGS_FILE]) { + writeSettingsFile( + projectRoot, + file, + mergeNavigatorPermissions(readSettingsFile(projectRoot, file)), + ); } + ensureLocallyIgnored(projectRoot, PROJECT_LOCAL_SETTINGS_FILE); } /** - * 生成必须入库的路径被忽略时的输出: 按忽略规则分组, 写明影响的路径数与一个例子. + * 生成发现旧版本记录时的输出. * - * @param {import("../lib/repo.mjs").IgnoredPath[]} ignored 被忽略的路径. + * @param {Record | undefined} raw 旧的状态文件内容; 文件损坏时为 undefined. * @returns {string[]} 输出各行. */ -function ignoredPathLines(ignored) { +function legacyLines(raw) { return [ - "- 设置结果: 未执行, 编排记录或项目配置会被 Git 忽略, 无法入库", - ...summarizeIgnoredPaths(ignored).map( - (summary) => `- 忽略规则: ${summary}`, - ), - "- 处理办法: 修改上面的忽略规则, 让这些文件能够入库, 然后重新运行 init", + "- 设置结果: 未执行", + raw === undefined + ? `- 旧版记录: ${STATE_FILE} 不是合法的 JSON` + : `- 旧版记录: ${NAVIGATOR_DIRECTORY}/ 由版本 ${raw.skillVersion ?? "未知"} 写入, 本版本不读取, 也不迁移`, + `- 处理办法: 用户同意后运行 \`${COMMAND_NAME} init --archive\`, 把 ${NAVIGATOR_DIRECTORY}/ 改名为 ${ARCHIVE_DIRECTORY}/ (只改名, 不删除), 然后重新立项; 状态文件损坏而不想归档时, 先用 git 恢复它`, ]; } - -/** - * 创建状态目录并写入其 `.gitignore`; 每次初始化与升级都重写. - * - * @param {string} projectRoot 项目根目录. - * @returns {void} - */ -function writeIgnoreFile(projectRoot) { - mkdirSync(navigatorPath(projectRoot), { recursive: true }); - writeFileSync( - navigatorPath(projectRoot, "ignoreFile"), - NAVIGATOR_GITIGNORE, - "utf8", - ); -} - -/** - * 创建状态目录中的空子目录. - * - * @param {string} projectRoot 项目根目录. - * @returns {void} - */ -function createDirectories(projectRoot) { - for (const entry of EMPTY_DIRECTORIES) { - mkdirSync(navigatorPath(projectRoot, entry), { recursive: true }); - } -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/model.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/model.mjs deleted file mode 100644 index b66df0a..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/model.mjs +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @file model 命令: 查看与登记各类子代理使用的模型. - * - * 用法: - * - model: 查看当前分配. - * - model set <角色> <模型> [--kind <工单类型>]: 登记; `--kind` 只用于执行子代理, - * 按工单类型单独指定. - */ - -import { - MODEL_CHOICES, - MODEL_ROLES, - assignModel, - describeModels, -} from "../lib/subagent-models.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { openProject, resultLines, saveState } from "./support.mjs"; - -/** - * 执行 model 命令. - * - * @param {object} options 命令参数. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 用法不对, 或角色, 模型, 工单类型不合法时. - */ -export function runModel({ positionals, values, cwd, now }) { - const context = openProject(cwd); - const [action, role, model] = positionals; - if (action === undefined) { - return [`- 子代理模型: ${describeModels(context.state.models)}`]; - } - if (action !== "set" || role === undefined || model === undefined) { - throw new WorkflowError( - `model 的用法: model; model set <角色> <模型> [--kind <工单类型>]. 角色: ${Object.values(MODEL_ROLES).join(", ")}; 模型: ${MODEL_CHOICES.join(", ")}.`, - ); - } - const state = saveState( - context, - assignModel(context.state, { - role, - model, - kind: typeof values.kind === "string" ? values.kind : undefined, - }), - now, - ); - return [ - ...resultLines(state), - `- 子代理模型: ${describeModels(state.models)}`, - ]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/order.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/order.mjs deleted file mode 100644 index 14acec7..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/order.mjs +++ /dev/null @@ -1,372 +0,0 @@ -/** - * @file order 命令: 新建工单, 转换工单状态. - * - * 用法: - * - order new --kind <类型> --slug <短名> [--slice <切片编号>] - * - order set <状态> [--accept-unverified] - * - order precheck: 记下当前工单已做过发布前核对 - */ - -import { existsSync, renameSync } from "node:fs"; -import path from "node:path"; - -import { creationBlocker, issueBlocker } from "../lib/code-checks.mjs"; -import { COMMIT_SKILL } from "../lib/guard.mjs"; -import { - REVIEW_OPTIONS, - REVIEW_REPLY_TYPE, - RUNNING_REPLY_TYPE, - executorDispatchAction, - precheckStep, - replyStep, -} from "../lib/guidance.mjs"; -import { formatNumber } from "../lib/numbering.mjs"; -import { - APPROVAL_REPLY_TYPE, - APPROVAL_STATUSES, - consumeApproval, - digestOrderFile, - readApprovalStatus, - readOrderText, -} from "../lib/order-approval.mjs"; -import { baseCommitBlocker } from "../lib/order-base.mjs"; -import { orderFilePath, projectRelativePath } from "../lib/paths.mjs"; -import { headCommit } from "../lib/repo.mjs"; -import { - readOrderVerdicts, - unfilledUserVerdict, -} from "../lib/review-record.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { - FIX_ORDER_KIND, - ISSUED_ORDER_STATUS, - SELECTION_ORDER_KIND, - consecutiveRejections, - createOrder, - isRecordOnlyOrderKind, - markPrechecked, - precheckBlocker, - setOrderStatus, -} from "../lib/workflow-orders.mjs"; -import { openProject, resultLines, saveState } from "./support.mjs"; - -/** - * 同一切片连续验收不通过达到这个次数时, 提醒先考虑拆小工单或调整切片. - * @type {number} - */ -const RESPLIT_THRESHOLD = 2; - -/** - * 提交范围的写法, 验收通过后的提交使用. - * @type {string} - */ -const COMMIT_SCOPE = - "纳入范围: 回执改动清单中的文件, .navigator/ 下的改动, 以及插件命令改动的 .claude/settings.json 与 .claude/rules/engineering.md; 纳入范围之外的改动用文字列出并询问"; - -/** - * 执行 order 命令. - * - * @param {object} options 命令参数. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 参数不合法时. - */ -export function runOrder({ positionals, values, cwd, now }) { - const context = openProject(cwd); - const [action, argument] = positionals; - switch (action) { - case "new": - return createNewOrder(context, values, now); - case "set": - return changeStatus(context, argument, values, now); - case "precheck": - return recordPrecheck(context, now); - default: - throw new WorkflowError("order 的用法: new, set <状态>, precheck."); - } -} - -/** - * 记下当前工单已做过发布前核对, 之后才能请用户审阅. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {string} now 当前时间. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 没有当前工单, 或工单已经发布时. - */ -function recordPrecheck(context, now) { - const state = saveState(context, markPrechecked(context.state), now); - return [ - ...resultLines(state), - `- 下一动作: ${replyStep(context.spec, APPROVAL_REPLY_TYPE)}; 用户选 A 后运行 order set issued`, - ]; -} - -/** - * 新建工单并输出工单文件的路径. 会改动代码的工单要求项目已登记检查命令. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {Record} values 选项值. - * @param {string} now 当前时间. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 仓库没有提交, 参数不合法, 或缺少检查命令时. - */ -function createNewOrder(context, values, now) { - const baseCommit = headCommit(context.projectRoot); - if (baseCommit === undefined) { - throw new WorkflowError("仓库还没有任何提交, 请先完成首次提交再发布工单."); - } - const created = createOrder(context.state, { - kind: String(values.kind ?? ""), - slug: String(values.slug ?? ""), - slice: typeof values.slice === "string" ? values.slice : undefined, - baseCommit, - }); - const blocker = creationBlocker(context.state, created.order.kind); - if (blocker !== undefined) { - throw new WorkflowError(blocker); - } - const state = saveState(context, created, now); - const orderFile = path.join( - context.projectRoot, - orderFilePath(state.order.folder, "order"), - ); - return [ - ...resultLines(state), - `- 下一动作: 运行 template order 取得骨架, 用 Write 写入 ${orderFile}, 再${precheckStep(context.spec, state.models)}, 再${replyStep(context.spec, APPROVAL_REPLY_TYPE)}; 用户选 A 后运行 order set issued`, - ]; -} - -/** - * 转换当前工单状态. 发布前核对代码检查判据与用户审阅, 发布时把旧回执改名归档; - * 通过验收前核对验收记录的判据结论; 验收不通过时提示下一动作. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {string | undefined} status 目标状态. - * @param {Record} values 选项值. - * @param {string} now 当前时间. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 缺少目标状态, 或不满足转换条件时. - */ -function changeStatus(context, status, values, now) { - if (status === undefined) { - throw new WorkflowError("order set 缺少目标状态."); - } - const order = context.state.order; - if (status === ISSUED_ORDER_STATUS) { - return issueOrder(context, now); - } - if (status === "accepted" && order?.status === "reviewing") { - assertAcceptable(context, values["accept-unverified"] === true); - } - const state = saveState(context, setOrderStatus(context.state, status), now); - const lines = resultLines(state); - if (status === "accepted" && order !== null) { - lines.push( - `- 下一动作: 按用户在 "${REVIEW_REPLY_TYPE}" 中的选择调用 ${COMMIT_SKILL}: 只提交时参数写 "提交, ${COMMIT_SCOPE}"; 提交并推送时写 "提交并推送, ${COMMIT_SCOPE}"`, - ); - } - if (status === "rejected" && order !== null) { - lines.push(rejectionAction(context.spec, state, order)); - } - return lines; -} - -/** - * 发布当前工单: 核对能否发布, 把旧回执改名归档, 记下发布时的工单内容与当前 - * 提交 (基准提交), 用掉审阅. 受阻后内容没有改动的工单直接继续, 由原执行子代理 - * 接着做, 基准提交不变. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {string} now 当前时间. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 不能发布时. - */ -function issueOrder(context, now) { - const order = context.state.order; - if (order === null) { - throw new WorkflowError("当前没有工单."); - } - const isContinuing = assertIssuable(context); - const archived = archiveReceipt(context.projectRoot, order.folder); - const state = saveState( - context, - setOrderStatus(context.state, ISSUED_ORDER_STATUS, { - issuedDigest: digestOrderFile(context.projectRoot, order), - baseCommit: isContinuing ? undefined : headCommit(context.projectRoot), - }), - now, - ); - consumeApproval(context.projectRoot); - const running = replyStep(context.spec, RUNNING_REPLY_TYPE); - const dispatch = executorDispatchAction(state.models, order); - return [ - ...resultLines(state), - ...(archived === undefined ? [] : [`- 旧回执已归档: ${archived}`]), - isContinuing - ? `- 下一动作: 用 SendMessage 让原执行子代理继续 (说明用户已完成的操作); 找不到它时${dispatch}; 然后${running}` - : `- 下一动作: ${dispatch}, 然后${running}`, - ]; -} - -/** - * 核对当前工单能否发布: 会改动代码的工单必须带代码检查判据; 起草中的工单 - * 必须做过发布前核对; 用户必须已在 - * "工单审阅" 中认可当前的工单内容, 且工单文件中的基准提交是当前提交; 或工单 - * 受阻后内容与上次发布时相同. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @returns {boolean} 是受阻后内容没有改动的继续发布时返回 true. - * @throws {WorkflowError} 不能发布时, 原因写明补救办法. - */ -function assertIssuable(context) { - const order = /** @type {import("../lib/state.mjs").OrderState} */ ( - context.state.order - ); - const blocker = issueBlocker( - context.state, - readOrderText(context.projectRoot, order), - ); - const unchecked = blocker ?? precheckBlocker(order); - if (unchecked !== undefined) { - throw new WorkflowError(unchecked); - } - const approval = readApprovalStatus(context.projectRoot, order); - if (approval === APPROVAL_STATUSES.approved) { - assertBaseCommitCurrent(context, order); - return false; - } - if ( - order.status === "blocked" && - order.issuedDigest !== undefined && - order.issuedDigest === digestOrderFile(context.projectRoot, order) - ) { - return true; - } - throw new WorkflowError( - approval === APPROVAL_STATUSES.awaiting - ? `工单 ${order.id} 正在等用户审阅: 用户在 "${APPROVAL_REPLY_TYPE}" 中选 A 之后才能发布.` - : `工单 ${order.id} 还没有经用户审阅, 或审阅之后内容有改动: 先${replyStep(context.spec, APPROVAL_REPLY_TYPE)}并结束本轮, 用户选 A 后再运行 order set issued. 只运行 reply 命令不算审阅.`, - ); -} - -/** - * 核对工单文件中的基准提交是当前提交. 审阅时已核对过一次; 审阅之后仓库又有了 - * 新提交时在这里拒绝, 改好后要重新审阅. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {import("../lib/state.mjs").OrderState} order 当前工单. - * @returns {void} - * @throws {WorkflowError} 基准提交不是当前提交时. - */ -function assertBaseCommitCurrent(context, order) { - const blocker = baseCommitBlocker({ - orderId: order.id, - orderText: readOrderText(context.projectRoot, order), - head: headCommit(context.projectRoot), - }); - if (blocker !== undefined) { - throw new WorkflowError( - `${blocker} 改好后${replyStep(context.spec, APPROVAL_REPLY_TYPE)}, 用户选 A 后再运行 order set issued.`, - ); - } -} - -/** - * 核对当前工单能否通过验收: 不能有不通过的判据; 有未验证的判据时, 要用户 - * 在 "验收报告" 中明确接受. 选型工单例外: 是否采用由用户在看过逐条核对结果 - * 之后决定. 两种工单都要求验收记录中已写下用户结论. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {boolean} isUnverifiedAccepted 用户是否已接受未验证的判据. - * @returns {void} - * @throws {WorkflowError} 不能通过验收时, 原因写明下一步. - */ -function assertAcceptable(context, isUnverifiedAccepted) { - const order = /** @type {import("../lib/state.mjs").OrderState} */ ( - context.state.order - ); - const verdicts = readOrderVerdicts(context.projectRoot, order); - if (verdicts.problem !== undefined) { - throw new WorkflowError( - `工单 ${order.id} 不能通过验收: ${verdicts.problem}`, - ); - } - if (order.kind !== SELECTION_ORDER_KIND) { - if (verdicts.failed.length > 0) { - throw new WorkflowError( - `工单 ${order.id} 不能通过验收: 判据 ${verdicts.failed.map((item) => `"${item}"`).join(", ")} 不通过. 运行 order set rejected, 按输出处理.`, - ); - } - if (verdicts.unverified.length > 0 && !isUnverifiedAccepted) { - throw new WorkflowError( - `工单 ${order.id} 有未验证的判据: ${verdicts.unverified.map((item) => `"${item}"`).join(", ")}. 用 "${REVIEW_REPLY_TYPE}" 第 ${REVIEW_OPTIONS.unverified} 组请用户决定; 用户接受时运行 order set accepted --accept-unverified.`, - ); - } - } - const unfilled = unfilledUserVerdict({ - projectRoot: context.projectRoot, - order, - spec: context.spec, - }); - if (unfilled !== undefined) { - throw new WorkflowError(unfilled); - } -} - -/** - * 把回执改名为下一个未占用的归档名, 例如 receipt-0001.md. - * - * @param {string} projectRoot 项目根目录. - * @param {string} folder 工单文件夹名. - * @returns {string | undefined} 归档后的项目相对路径; 没有回执时为 undefined. - */ -function archiveReceipt(projectRoot, folder) { - const receipt = path.join(projectRoot, orderFilePath(folder, "receipt")); - if (!existsSync(receipt)) { - return undefined; - } - const { name, ext } = path.parse(receipt); - const archivedName = (round) => - path.join(path.dirname(receipt), `${name}-${formatNumber(round)}${ext}`); - let round = 1; - while (existsSync(archivedName(round))) { - round += 1; - } - renameSync(receipt, archivedName(round)); - return projectRelativePath(projectRoot, archivedName(round)); -} - -/** - * 验收不通过后的下一动作, 按工单类型分三种: - * - 选型工单只在用户选择另选方案之后才转为不通过, 下一步按用户给出的方向新建 - * 选型工单 (检查命令在选型采用后才登记, 不能发修复工单). - * - 运行检查与分析工单只产出记录, 补正就是在同一切片重发同类工单. - * - 其余工单先判断不通过的原因: 工单太大就拆小, 规格不清或实现有误就发修复工单. - * 同一切片连续不通过时另加提醒. - * - * @param {import("../lib/spec.mjs").TemplateSpec} spec 模板规格. - * @param {import("../lib/state.mjs").NavigatorState} state 写入后的状态. - * @param {{kind: string, slice: string | null}} order 被判为不通过的工单. - * @returns {string} 下一动作行. - */ -function rejectionAction(spec, state, order) { - if (order.kind === SELECTION_ORDER_KIND) { - return `- 下一动作: 按用户在 "${REVIEW_REPLY_TYPE}" 第 ${REVIEW_OPTIONS.selection} 组选 B 时给出的方向, 运行 order new --kind ${SELECTION_ORDER_KIND} 新建选型工单`; - } - const remedyStep = replyStep(spec, REVIEW_REPLY_TYPE, REVIEW_OPTIONS.fix); - const sliceFlag = order.slice === null ? "" : ` --slice ${order.slice}`; - if (isRecordOnlyOrderKind(order.kind)) { - return `- 下一动作: ${remedyStep}; 用户选 A 后运行 order new --kind ${order.kind}${sliceFlag}, 重发同类工单 (这类工单只产出记录, 不发修复工单)`; - } - const count = - order.slice === null ? 0 : consecutiveRejections(state, order.slice); - const warning = - count >= RESPLIT_THRESHOLD - ? ` 切片 ${order.slice} 已连续 ${count} 次不通过, 优先考虑工单是否太大.` - : ""; - return `- 下一动作: ${remedyStep}; 用户选 A 后先判断不通过的原因: 工单超出一次执行或验收的能力时拆成更小的工单 (需要时更新推进路线), 规格不清或实现有误时运行 order new --kind ${FIX_ORDER_KIND}${sliceFlag}.${warning}`; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/plan.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/plan.mjs index def0da4..ed4e6ab 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/plan.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/plan.mjs @@ -1,52 +1,126 @@ /** - * @file roadmap, milestone, slice 命令: 更新推进路线, 设置里程与切片的状态. + * @file roadmap, milestone 与 finish 命令: 更新路线, 完成里程, 确认收尾. * * 用法: - * - roadmap --from <草稿>, 草稿格式见 workflow-plan 的 RoadmapDraft - * - milestone <编号> - * - slice <编号> + * - roadmap --from <草稿> + * - milestone done <编号> + * - finish */ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { REQUIREMENTS_FILE } from "../lib/paths.mjs"; +import { + applyRoadmap, + completeMilestone, + finishProject, +} from "../lib/roadmap.mjs"; +import { STAGES } from "../lib/state.mjs"; import { WorkflowError } from "../lib/workflow-error.mjs"; -import { applyRoadmap, setProgressStatus } from "../lib/workflow-plan.mjs"; import { openProject, + productsCommitStep, readDraftJson, - resultLines, saveState, } from "./support.mjs"; /** - * 执行推进路线相关命令. + * 更新路线的命令名. + * @type {string} + */ +const ROADMAP_COMMAND = "roadmap"; + +/** + * 确认收尾的命令名. + * @type {string} + */ +const FINISH_COMMAND = "finish"; + +/** + * milestone 命令唯一的子命令. + * @type {string} + */ +const MILESTONE_DONE = "done"; + +/** + * 执行 roadmap, milestone 或 finish 命令. * - * @param {object} options 命令参数. - * @param {"roadmap" | "milestone" | "slice"} options.command 命令名. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. + * @param {import("./handlers.mjs").CommandInvocation} invocation 命令调用. * @returns {string[]} 输出各行. - * @throws {WorkflowError} 参数不合法时. + * @throws {WorkflowError} 参数不合法或操作不被允许时. */ export function runPlan({ command, positionals, values, cwd, now }) { - const context = openProject(cwd); - if (command === "roadmap") { - const draft = readDraftJson(context.projectRoot, values.from); - return resultLines( - saveState(context, applyRoadmap(context.state, draft), now), - ); + const { projectRoot, state } = openProject(cwd); + if (command === ROADMAP_COMMAND) { + return updateRoadmap(projectRoot, state, values.from, now); } - const [id, status] = positionals; - if (id === undefined || status === undefined) { + if (command === FINISH_COMMAND) { + const written = saveState(projectRoot, finishProject(state), now); + return withCommit(projectRoot, written, "项目收尾", true); + } + const [subcommand, id] = positionals; + if (subcommand !== MILESTONE_DONE) { + throw new WorkflowError("milestone 的用法: milestone done <编号>."); + } + const written = saveState(projectRoot, completeMilestone(state, id), now); + return withCommit(projectRoot, written, `里程 ${id} 完成`, true); +} + +/** + * 把路线草稿合并进状态. 需求文件还没有写入时拒绝: 路线要从确认过的需求排出来. + * + * @param {string} projectRoot 项目根目录. + * @param {import("../lib/state.mjs").NavigatorState} state 当前状态. + * @param {string | undefined} draftPath 草稿路径. + * @param {string} now 当前时间. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 需求文件不存在, 或草稿不合法时. + */ +function updateRoadmap(projectRoot, state, draftPath, now) { + if (!existsSync(path.join(projectRoot, REQUIREMENTS_FILE))) { throw new WorkflowError( - `${command} 的用法: ${command} <编号> .`, + `还没有需求文件 ${REQUIREMENTS_FILE}: 先与用户确认需求并写入, 再排路线.`, ); } - return resultLines( - saveState( - context, - setProgressStatus(context.state, command, id, status), - now, - ), + const written = saveState( + projectRoot, + applyRoadmap(state, readDraftJson(projectRoot, draftPath)), + now, ); + const lines = written.milestones.flatMap((milestone) => [ + `- 里程 ${milestone.id}: ${milestone.name}`, + ...milestone.slices.map( + (slice) => + ` - 切片 ${slice.id}: ${slice.name}${slice.skeleton ? " (最小骨架)" : ""}`, + ), + ]); + const isIntakeDone = + state.stage === STAGES.intake && written.stage === STAGES.slicing; + return [ + ...lines, + ...withCommit( + projectRoot, + written, + isIntakeDone ? "立项完成" : "更新路线", + isIntakeDone, + ), + ]; +} + +/** + * 输出结果, 有未入库的技能产物时加上提交步骤. + * + * @param {string} projectRoot 项目根目录. + * @param {import("../lib/state.mjs").NavigatorState} state 写入后的状态. + * @param {string} label 这次提交的成果. + * @param {boolean} includeState 是否把状态文件的改动算进去. + * @returns {string[]} 输出各行. + */ +function withCommit(projectRoot, state, label, includeState) { + const commit = productsCommitStep(projectRoot, label, { includeState }); + return [ + `- 执行结果: ${state.lastAction}`, + ...(commit === undefined ? [] : [`- 提交步骤: ${commit}`]), + ]; } diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/records.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/records.mjs deleted file mode 100644 index 98c59b4..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/records.mjs +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @file risk, decision, change 命令: 登记风险, 决策与变更, 更新它们的状态. - * - * 用法 (中文内容一律通过草稿传入): - * - risk add --from <草稿>, 草稿 {"description", "severity", "source", "handling"} - * - risk set <编号> [--from <草稿>], 草稿 {"handling"} - * - decision add --from <草稿>, 草稿 {"title"} - * - decision supersede <编号> --by <编号> - * - change add --from <草稿>, 草稿 {"title", "status"} - */ - -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { - addChange, - addDecision, - addRisk, - setRiskStatus, - supersedeDecision, -} from "../lib/workflow-records.mjs"; -import { - openProject, - readDraftJson, - resultLines, - saveState, -} from "./support.mjs"; - -/** - * 执行记录相关命令. - * - * @param {object} options 命令参数. - * @param {"risk" | "decision" | "change"} options.command 命令名. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 参数不合法时. - */ -export function runRecords({ command, positionals, values, cwd, now }) { - const context = openProject(cwd); - const [action, id, status] = positionals; - const next = applyAction({ command, action, id, status, values, context }); - const saved = saveState(context, next, now); - return [...resultLines(saved), ...newIdLine(command, action, saved)]; -} - -/** - * 按命令与动作计算新状态. - * - * @param {object} options 动作参数. - * @param {string} options.command 命令名. - * @param {string | undefined} options.action 动作名. - * @param {string | undefined} options.id 条目编号. - * @param {string | undefined} options.status 新状态. - * @param {Record} options.values 选项值. - * @param {import("./support.mjs").ProjectContext} options.context 项目上下文. - * @returns {import("../lib/state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 命令或动作不合法时. - */ -function applyAction({ command, action, id, status, values, context }) { - const { state, projectRoot } = context; - const key = `${command} ${action ?? ""}`; - switch (key) { - case "risk add": - return addRisk(state, readDraftJson(projectRoot, values.from)); - case "risk set": { - const handling = - values.from === undefined - ? undefined - : readDraftJson(projectRoot, values.from).handling; - return setRiskStatus( - state, - requireId(id), - String(status ?? ""), - handling, - ); - } - case "decision add": - return addDecision( - state, - String(readDraftJson(projectRoot, values.from).title ?? ""), - ); - case "decision supersede": - return supersedeDecision(state, requireId(id), String(values.by ?? "")); - case "change add": { - const draft = readDraftJson(projectRoot, values.from); - return addChange( - state, - String(draft.title ?? ""), - String(draft.status ?? ""), - ); - } - default: - throw new WorkflowError( - "用法: risk add | risk set <编号> <状态> | decision add | decision supersede <编号> --by <编号> | change add.", - ); - } -} - -/** - * 新增条目时输出新编号, 方便编排会话在记录文件中使用. - * - * @param {string} command 命令名. - * @param {string | undefined} action 动作名. - * @param {import("../lib/state.mjs").NavigatorState} state 写入后的状态. - * @returns {string[]} 输出各行. - */ -function newIdLine(command, action, state) { - if (action !== "add") { - return []; - } - const list = { - risk: state.risks, - decision: state.decisions, - change: state.changes, - }[command]; - const created = list?.[list.length - 1]; - return created === undefined ? [] : [`- 新增编号: ${created.id}`]; -} - -/** - * 确认提供了条目编号. - * - * @param {string | undefined} id 编号. - * @returns {string} 编号. - * @throws {WorkflowError} 缺少编号时. - */ -function requireId(id) { - if (id === undefined) { - throw new WorkflowError("缺少条目编号."); - } - return id; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/ref.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/ref.mjs index ba2349b..cbb30c2 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/ref.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/ref.mjs @@ -1,27 +1,29 @@ /** - * @file ref 命令: 输出技能中的参考文件, 执行手册或工程规范默认包. + * @file ref 命令: 输出技能中的参考文件, 实现手册或工程规范默认包. * * 用法: ref <名称>. 参考文件在插件目录中, 默认权限模式下用 Read 读取项目之外的 * 文件需要用户批准; 用放行规则中的插件命令输出内容, 不弹确认, 也总是当前版本. + * 实现手册之后附上插件共享的标点规则, 规则只在那一个文件中维护. */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { - GUIDE_SOURCE_FILE, + GUIDE_FILE, + PUNCTUATION_FILE, REFERENCES_DIRECTORY, STANDARDS_FILE, } from "../lib/paths.mjs"; import { WorkflowError } from "../lib/workflow-error.mjs"; /** - * 参考文件之外可以输出的文件, 键为名称. - * @type {Readonly>} + * 参考文件之外可以输出的内容, 键为名称, 值为依次输出的文件. + * @type {Readonly>} */ const EXTRA_FILES = Object.freeze({ - executor: GUIDE_SOURCE_FILE, - standards: STANDARDS_FILE, + executor: [GUIDE_FILE, PUNCTUATION_FILE], + standards: [STANDARDS_FILE], }); /** @@ -46,13 +48,16 @@ export function runRef({ positionals }) { `ref 的用法: ref <名称>, 名称为 ${Object.keys(files).join(", ")} 之一.`, ); } - return readFileSync(files[name], "utf8").trimEnd().split(/\r?\n/u); + return files[name].flatMap((file, index) => [ + ...(index === 0 ? [] : [""]), + ...readFileSync(file, "utf8").trimEnd().split(/\r?\n/u), + ]); } /** - * 列出可以输出的文件: 参考文件目录中的每个 Markdown 文件, 以及执行手册与工程规范. + * 列出可以输出的内容: 参考文件目录中的每个 Markdown 文件, 以及实现手册与工程规范. * - * @returns {Record} 名称到绝对路径的映射. + * @returns {Record} 名称到文件绝对路径的映射. */ function availableFiles() { const references = existsSync(REFERENCES_DIRECTORY) @@ -60,7 +65,7 @@ function availableFiles() { .filter((file) => file.endsWith(REFERENCE_EXTENSION)) .map((file) => [ path.basename(file, REFERENCE_EXTENSION), - path.join(REFERENCES_DIRECTORY, file), + [path.join(REFERENCES_DIRECTORY, file)], ]) : []; return { ...Object.fromEntries(references), ...EXTRA_FILES }; diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/reply.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/reply.mjs deleted file mode 100644 index b1dbc8e..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/reply.mjs +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @file reply 命令: 输出某种回复的填写要求与骨架, 进展行已按状态填好. - * 不带回复类型时, 列出全部回复类型及其编号与选项组数. - * - * "工单审阅" 按当前工单文件的 "审阅要点" 预填, 工单不在可审阅的状态或还不能 - * 发布时拒绝. "验收报告" 的选项组按验收记录的判据结论核对: 全部通过才能用 - * 第 1 组, 只有未验证 (没有不通过) 才能用第 3 组, 选型工单只用第 4 组. - */ - -import { issueBlocker } from "../lib/code-checks.mjs"; -import { REVIEW_OPTIONS, REVIEW_REPLY_TYPE } from "../lib/guidance.mjs"; -import { findSection, readKeyValues } from "../lib/layout.mjs"; -import { APPROVAL_REPLY_TYPE, readOrderText } from "../lib/order-approval.mjs"; -import { baseCommitBlocker } from "../lib/order-base.mjs"; -import { orderFilePath } from "../lib/paths.mjs"; -import { renderReplySkeleton } from "../lib/render.mjs"; -import { renderReplyGuide } from "../lib/reply-guide.mjs"; -import { findRepositoryRoot, headCommit } from "../lib/repo.mjs"; -import { readOrderVerdicts } from "../lib/review-record.mjs"; -import { loadSpec, resolveReplyType } from "../lib/spec.mjs"; -import { readStateIfValid } from "../lib/state.mjs"; -import { renderTable } from "../lib/table.mjs"; -import { - REVIEWABLE_ORDER_STATUSES, - SELECTION_ORDER_KIND, - precheckBlocker, -} from "../lib/workflow-orders.mjs"; - -/** - * 工单文件中写给人看的节, 与规格 files.order 中的节标题一致. - * @type {string} - */ -const ORDER_SUMMARY_SECTION = "审阅要点"; - -/** - * "工单审阅" 中由脚本填写工单文件路径的键. - * @type {string} - */ -const ORDER_PATH_KEY = "工单文件"; - -/** - * 需要核对判据结论的工单状态: 验收中. - * @type {string} - */ -const REVIEWING_STATUS = "reviewing"; - -/** - * 执行 reply 命令. - * - * @param {object} options 命令参数. - * @param {string} options.cwd 会话工作目录. - * @param {string | undefined} options.type 回复类型; 省略时列出全部类型. - * @param {number} options.optionSet 使用第几组选项, 从 1 开始. - * @returns {string[]} 输出各行. - * @throws {Error} 回复类型或选项组不存在, 选项组与判据结论不符, 或没有可审阅的工单时. - */ -export function runReply({ cwd, type, optionSet }) { - const spec = loadSpec(); - if (type === undefined) { - return listReplyTypes(spec); - } - const resolvedType = resolveReplyType(spec, type); - if (resolvedType === undefined) { - throw new Error( - `reply: 未知的回复类型 "${type}", 不带参数运行 reply 查看全部类型`, - ); - } - const projectRoot = findRepositoryRoot(cwd); - const state = - projectRoot === undefined ? undefined : readStateIfValid(projectRoot); - const order = state?.order ?? null; - if ( - projectRoot !== undefined && - order?.status === REVIEWING_STATUS && - resolvedType === REVIEW_REPLY_TYPE - ) { - assertReviewOption(projectRoot, order, optionSet); - } - const skeleton = renderReplySkeleton({ - type: resolvedType, - optionSetIndex: optionSet - 1, - state, - spec, - values: - resolvedType === APPROVAL_REPLY_TYPE - ? approvalValues(projectRoot, state) - : {}, - }); - return [ - ...renderReplyGuide({ - type: resolvedType, - spec, - optionSetIndex: optionSet - 1, - }), - ...skeleton.trimEnd().split("\n"), - ]; -} - -/** - * 为 "工单审阅" 取出预填的值: 工单文件 "审阅要点" 中的各键, 以及工单文件路径. - * 工单要处于可审阅的状态, 起草中的工单已做过发布前核对, 工单文件已写好, 且内容 - * 已满足发布条件 (含基准提交是当前提交). - * - * @param {string | undefined} projectRoot 项目根目录. - * @param {import("../lib/state.mjs").NavigatorState | undefined} state 当前状态. - * @returns {Record} 键名到值的映射. - * @throws {Error} 没有可审阅的工单, 工单文件不存在, 或内容还不能发布时. - */ -function approvalValues(projectRoot, state) { - const order = state?.order ?? null; - if ( - projectRoot === undefined || - state === undefined || - order === null || - !REVIEWABLE_ORDER_STATUSES.includes(order.status) - ) { - throw new Error( - `reply: 当前没有待审阅的工单; 只有 ${REVIEWABLE_ORDER_STATUSES.join(", ")} 状态的工单需要审阅.`, - ); - } - const unchecked = precheckBlocker(order); - if (unchecked !== undefined) { - throw new Error(`reply: ${unchecked}`); - } - const orderText = readOrderText(projectRoot, order); - const orderPath = orderFilePath(order.folder, "order"); - if (orderText === undefined) { - throw new Error(`reply: 工单文件 ${orderPath} 还没有写入.`); - } - const blocker = - issueBlocker(state, orderText) ?? - baseCommitBlocker({ - orderId: order.id, - orderText, - head: headCommit(projectRoot), - }); - if (blocker !== undefined) { - throw new Error(`reply: 工单还不能发布, 先改好再请用户审阅: ${blocker}`); - } - const section = findSection(orderText, ORDER_SUMMARY_SECTION); - return { - ...(section === undefined ? {} : readKeyValues(section)), - [ORDER_PATH_KEY]: orderPath, - }; -} - -/** - * 核对 "验收报告" 的选项组与判据结论相符: 选型工单只用采用与否的一组; 其它工单 - * 判据全部通过才能用验收通过的一组, 只有未验证的判据才能用接受现状的一组. - * - * @param {string} projectRoot 项目根目录. - * @param {{id: string, folder: string, kind: string}} order 当前工单. - * @param {number} optionSet 选项组号. - * @returns {void} - * @throws {Error} 选项组与判据结论不符时. - */ -function assertReviewOption(projectRoot, order, optionSet) { - const isSelection = order.kind === SELECTION_ORDER_KIND; - if (isSelection !== (optionSet === REVIEW_OPTIONS.selection)) { - throw new Error( - isSelection - ? `reply: 选型工单使用 "${REVIEW_REPLY_TYPE}" 第 ${REVIEW_OPTIONS.selection} 组选项, 由用户决定是否采用选型结论.` - : `reply: 第 ${REVIEW_OPTIONS.selection} 组选项只用于选型工单.`, - ); - } - const verdicts = readOrderVerdicts(projectRoot, order); - const isPassed = - verdicts.problem === undefined && - verdicts.failed.length === 0 && - verdicts.unverified.length === 0; - const isUnverifiedOnly = - verdicts.problem === undefined && - verdicts.failed.length === 0 && - verdicts.unverified.length > 0; - if (optionSet === REVIEW_OPTIONS.passed && !isPassed) { - throw new Error( - `reply: 工单 ${order.id} 的判据没有全部通过, 不能用第 ${REVIEW_OPTIONS.passed} 组: ${verdicts.problem ?? "有判据不通过或未验证"}. 有不通过时运行 order set rejected 后用第 ${REVIEW_OPTIONS.fix} 组, 只有未验证时用第 ${REVIEW_OPTIONS.unverified} 组.`, - ); - } - if (optionSet === REVIEW_OPTIONS.unverified && !isUnverifiedOnly) { - throw new Error( - `reply: 第 ${REVIEW_OPTIONS.unverified} 组只用于没有不通过, 但有未验证判据的工单.`, - ); - } -} - -/** - * 列出全部回复类型: 类型, 编号, 选项组数. - * - * @param {import("../lib/spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 表格各行. - */ -function listReplyTypes(spec) { - return renderTable( - ["回复类型", "编号", "选项组数"], - Object.entries(spec.replies).map(([title, reply]) => [ - title, - reply.id, - String(reply.optionSets.length), - ]), - ); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/report.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/report.mjs deleted file mode 100644 index b769ce6..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/report.mjs +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @file report 命令: 把技能自身的问题写成一份问题报告, 供用户转交给维护者. - * - * 用法: report [--from <草稿>]. 草稿格式: - * {"doing": "当时在做什么", "expected": "预期发生什么", "actual": "实际发生了什么", - * "reproduce": "怎么复现", "blocked": false} - * - * 报告由两部分组成: 脚本填写的环境, 状态与异常记录; 草稿提供的经过. 报告只写 - * 遇到的情况本身, 不写原因推测与处理建议. 报告不含工单正文与业务代码, 项目路径 - * 与技能目录在报告中用占位写法代替. - * - * 状态文件损坏时也要能生成报告, 所以本命令不要求项目状态可读. - */ - -import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - BLOCKING_EVENT_KINDS, - EVENT_LABELS, - markEventsReported, - readGuidance, - unreportedEvents, -} from "../lib/diagnostics.mjs"; -import { - REPORT_OPTIONS, - REPORT_REPLY_TYPE, - replyStep, -} from "../lib/guidance.mjs"; -import { formatNumber } from "../lib/numbering.mjs"; -import { REPORTS_DIRECTORY, SKILL_ROOT } from "../lib/paths.mjs"; -import { findRepositoryRoot } from "../lib/repo.mjs"; -import { loadSpec } from "../lib/spec.mjs"; -import { readStateIfValid } from "../lib/state.mjs"; -import { runtimeVersion } from "../lib/version.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { readDraftJson } from "./support.mjs"; - -/** - * 草稿中描述经过的键, 以及它们在报告中的名称, 按顺序. - * @type {readonly (readonly [string, string])[]} - */ -const NARRATIVE_FIELDS = Object.freeze([ - ["doing", "当时在做"], - ["expected", "预期结果"], - ["actual", "实际结果"], - ["reproduce", "复现步骤"], -]); - -/** - * 报告文件名的前缀与扩展名. - * @type {Readonly<{prefix: string, extension: string}>} - */ -const REPORT_FILE = Object.freeze({ prefix: "report-", extension: ".md" }); - -/** - * 报告中没有内容的项写的值. - * @type {string} - */ -const EMPTY = "未填写"; - -/** - * 报告中代替项目根目录与技能目录的写法, 避免泄露本机路径. - * @type {Readonly<{project: string, skill: string}>} - */ -const PATH_PLACEHOLDERS = Object.freeze({ - project: "<项目>", - skill: "<技能目录>", -}); - -/** - * 执行 report 命令. - * - * @param {object} options 命令参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行: 报告文件, 能否继续, 下一动作与报告全文. - * @throws {WorkflowError} 不在 Git 仓库中, 或既没有异常记录也没有草稿时. - */ -export function runReport({ values, cwd, now }) { - const projectRoot = findRepositoryRoot(cwd); - if (projectRoot === undefined) { - throw new WorkflowError("当前目录不是 Git 仓库."); - } - const draft = - values.from === undefined ? {} : readDraftJson(projectRoot, values.from); - const events = unreportedEvents(projectRoot); - const narrative = NARRATIVE_FIELDS.map(([key, label]) => [ - label, - typeof draft[key] === "string" ? draft[key].trim() : "", - ]); - if (events.length === 0 && narrative.every(([, text]) => text === "")) { - throw new WorkflowError( - '没有可上报的内容: 脚本没有记录到异常, 草稿中也没有经过. 先把遇到的情况写成草稿 {"doing", "expected", "actual", "reproduce"}, 再用 --from 传入.', - ); - } - const id = nextReportId(projectRoot); - const content = sanitize( - renderReport({ id, projectRoot, events, narrative, now }), - projectRoot, - ); - const relativeFile = `${REPORTS_DIRECTORY}/${REPORT_FILE.prefix}${id}${REPORT_FILE.extension}`; - const file = path.join(projectRoot, relativeFile); - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, content, "utf8"); - markEventsReported(projectRoot, id); - const isBlocking = - draft.blocked === true || - events.some((event) => BLOCKING_EVENT_KINDS.includes(event.kind)); - const optionSet = isBlocking - ? REPORT_OPTIONS.blocking - : REPORT_OPTIONS.continuable; - return [ - `- 报告文件: ${relativeFile}`, - `- 能否继续: ${isBlocking ? "不能, 需要先修复技能或状态" : "能, 记录后可以继续推进"}`, - `- 下一动作: ${replyStep(loadSpec(), REPORT_REPLY_TYPE, optionSet)}; "报告内容" 中放一个语言标记为 text 的代码块, 把下面的报告全文原样放进去, 不增删内容, 不加处理建议`, - "", - "报告全文:", - "", - ...content.trimEnd().split("\n"), - ]; -} - -/** - * 生成报告全文: 环境, 状态, 脚本记录的异常, 经过. - * - * @param {object} options 参数. - * @param {string} options.id 报告编号. - * @param {string} options.projectRoot 项目根目录. - * @param {import("../lib/diagnostics.mjs").DiagnosticEvent[]} options.events 未上报的异常. - * @param {string[][]} options.narrative 经过各项: 名称与内容. - * @param {string} options.now 当前时间. - * @returns {string} 报告全文, 以换行结尾. - */ -function renderReport({ id, projectRoot, events, narrative, now }) { - return `${[ - `# project-navigator 问题报告 ${id}`, - "", - "## 环境", - "", - `- 插件版本: ${runtimeVersion()}`, - `- 运行环境: Node.js ${process.versions.node}`, - `- 操作系统: ${process.platform} ${os.release()}`, - `- 生成时间: ${now}`, - "", - "## 状态", - "", - ...stateLines(projectRoot), - "", - "## 脚本记录的异常", - "", - ...(events.length === 0 ? ["无"] : events.flatMap(eventLines)), - "", - "## 经过", - "", - ...narrative.map(([label, text]) => `- ${label}: ${text || EMPTY}`), - ].join("\n")}\n`; -} - -/** - * 生成状态摘要: 只含位置与工单的类型和状态, 不含项目内容. - * - * @param {string} projectRoot 项目根目录. - * @returns {string[]} 各行. - */ -function stateLines(projectRoot) { - const state = readStateIfValid(projectRoot); - const guidance = readGuidance(projectRoot); - if (state === undefined) { - return ["- 状态文件: 不存在, 或不是合法的 JSON"]; - } - const order = state.order; - return [ - `- 结构版本: ${state.schema}, 写入版本 ${state.skillVersion}, 状态版本 ${state.revision}`, - `- 当前位置: 阶段 ${state.stage ?? "无"}, 步骤 ${state.step}`, - `- 当前工单: ${order === null ? "无" : `${order.id}, 类型 ${order.kind}, 状态 ${order.status}, 发布轮次 ${order.round}`}`, - `- 最后动作: ${state.lastAction} (${state.updatedAt})`, - `- 上次的下一动作: ${guidance === undefined ? "无记录" : `${guidance.action} (连续给出 ${guidance.count} 次)`}`, - ]; -} - -/** - * 生成一条异常的各行. - * - * @param {import("../lib/diagnostics.mjs").DiagnosticEvent} event 异常. - * @param {number} index 序号, 从 0 开始. - * @returns {string[]} 各行. - */ -function eventLines(event, index) { - return [ - `### ${index + 1}. ${EVENT_LABELS[event.kind] ?? event.kind}`, - "", - `- 发生时间: ${event.at}`, - `- 来源: ${event.source}`, - `- 信息: ${event.message}`, - ...(event.stack.length === 0 - ? [] - : ["- 调用栈:", ...event.stack.map((line) => ` - ${line}`)]), - "", - ]; -} - -/** - * 去掉报告中的本机路径与会破坏代码块的反引号围栏. - * - * @param {string} content 报告全文. - * @param {string} projectRoot 项目根目录. - * @returns {string} 处理后的全文. - */ -function sanitize(content, projectRoot) { - const replacements = [ - [SKILL_ROOT, PATH_PLACEHOLDERS.skill], - [projectRoot, PATH_PLACEHOLDERS.project], - ].flatMap(([target, placeholder]) => [ - [target, placeholder], - [target.split(path.sep).join("/"), placeholder], - ]); - return replacements - .reduce( - (text, [target, placeholder]) => text.split(target).join(placeholder), - content, - ) - .replaceAll("```", "'''"); -} - -/** - * 取下一份报告的编号: 报告目录中已有的最大编号加一. - * - * @param {string} projectRoot 项目根目录. - * @returns {string} 四位编号. - */ -function nextReportId(projectRoot) { - const directory = path.join(projectRoot, REPORTS_DIRECTORY); - const numbers = existsSync(directory) - ? readdirSync(directory) - .filter( - (name) => - name.startsWith(REPORT_FILE.prefix) && - name.endsWith(REPORT_FILE.extension), - ) - .map((name) => - Number( - name.slice( - REPORT_FILE.prefix.length, - -REPORT_FILE.extension.length, - ), - ), - ) - .filter(Number.isInteger) - : []; - return formatNumber(Math.max(0, ...numbers) + 1); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/setup.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/setup.mjs new file mode 100644 index 0000000..aff56bc --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/setup.mjs @@ -0,0 +1,109 @@ +/** + * @file setup 命令: 登记模块组织方式, 验证方式与项目约定, 并写入项目规范. + * + * 用法: setup [--from <草稿>]. 不带草稿时只输出现有设置, 建议与草稿格式; 带草稿 + * 时登记, 并把工程规范默认包按模块组织方式写入 `.claude/rules/engineering.md`. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { writeFileAtomically } from "../lib/atomic-file.mjs"; +import { ENGINEERING_RULES_FILE, STANDARDS_FILE } from "../lib/paths.mjs"; +import { + renderEngineeringRules, + suggestOrganization, + validateSettings, +} from "../lib/project-settings.mjs"; +import { listTrackedFiles } from "../lib/repo.mjs"; +import { ORGANIZATION_LABELS, VERIFICATION_LABELS } from "../lib/state.mjs"; +import { openProject, readDraftJson, saveState } from "./support.mjs"; + +/** + * 输出建议时, 作为依据列出的工程文件的最多个数. + * @type {number} + */ +const EVIDENCE_LIMIT = 3; + +/** + * 草稿的格式说明. + * @type {string} + */ +const DRAFT_FORMAT = + '{"organization": "directories 或 filters", "verification": {"mode": "auto, manual 或 measured", "commands": ["能在仓库根目录直接运行的命令"]}, "conventions": ["项目自己的约定, 可以为空"]}'; + +/** + * 执行 setup 命令. + * + * @param {import("./handlers.mjs").CommandInvocation} invocation 命令调用. + * @returns {string[]} 输出各行. + */ +export function runSetup({ values, cwd, now }) { + const { projectRoot, state } = openProject(cwd); + if (values.from === undefined) { + return suggestionLines(projectRoot, state.project); + } + const project = validateSettings(readDraftJson(projectRoot, values.from)); + writeFileAtomically( + path.join(projectRoot, ENGINEERING_RULES_FILE), + renderEngineeringRules({ + standards: readFileSync(STANDARDS_FILE, "utf8"), + organization: /** @type {string} */ (project.organization), + conventions: project.conventions, + }), + ); + saveState( + projectRoot, + { ...state, project, lastAction: "登记项目设置" }, + now, + ); + return [ + "- 执行结果: 已登记项目设置", + ...settingsLines(project), + `- 项目规范: 已写入 ${ENGINEERING_RULES_FILE}`, + ]; +} + +/** + * 输出现有设置, 建议与草稿格式. + * + * @param {string} projectRoot 项目根目录. + * @param {import("../lib/state.mjs").ProjectSettings} project 现有的项目设置. + * @returns {string[]} 输出各行. + */ +function suggestionLines(projectRoot, project) { + const { organization, evidence } = suggestOrganization( + listTrackedFiles(projectRoot), + ); + const reason = + evidence.length === 0 + ? "没有找到 Visual Studio 的解决方案或工程文件" + : `找到 ${evidence.slice(0, EVIDENCE_LIMIT).join(", ")}${evidence.length > EVIDENCE_LIMIT ? ` 等 ${evidence.length} 个文件` : ""}`; + return [ + ...(project.organization === null + ? ["- 现有设置: 未登记"] + : settingsLines(project)), + `- 组织建议: ${organization} (${ORGANIZATION_LABELS[organization]}), 依据: ${reason}. 由用户确认`, + `- 验证方式: ${Object.entries(VERIFICATION_LABELS) + .map(([mode, label]) => `${mode} (${label})`) + .join(", ")}, 由用户确认; 不能构建或运行的项目不要选 auto`, + `- 草稿格式: ${DRAFT_FORMAT}`, + ]; +} + +/** + * 把项目设置写成输出行. + * + * @param {import("../lib/state.mjs").ProjectSettings} project 项目设置. + * @returns {string[]} 输出各行. + */ +function settingsLines(project) { + const verification = /** @type {{mode: string, commands: string[]}} */ ( + project.verification + ); + return [ + `- 模块组织: ${project.organization} (${ORGANIZATION_LABELS[/** @type {string} */ (project.organization)]})`, + `- 验证方式: ${verification.mode} (${VERIFICATION_LABELS[verification.mode]})${verification.commands.length === 0 ? "" : `, 命令: ${verification.commands.join("; ")}`}`, + `- 项目约定: ${project.conventions.length === 0 ? "无" : project.conventions.join("; ")}`, + ]; +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/slice.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/slice.mjs new file mode 100644 index 0000000..da12bac --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/slice.mjs @@ -0,0 +1,388 @@ +/** + * @file slice 命令: 一个切片从写计划到结束的各步. + * + * 用法: + * - slice plan <编号> --slug <短名> + * - slice start [--decided] + * - slice replan + * - slice review + * - slice accept [--accept-unverified] + * - slice redo + * - slice drop + */ + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { writeFileAtomically } from "../lib/atomic-file.mjs"; +import { COMMIT_SKILL, SUBAGENT_TYPES } from "../lib/guard.mjs"; +import { command } from "../lib/guidance.mjs"; +import { FINDINGS_DIRECTORY, sliceFilePath } from "../lib/paths.mjs"; +import { headCommit } from "../lib/repo.mjs"; +import { activeSlice, currentMilestone } from "../lib/roadmap.mjs"; +import { + PARTS, + SECTIONS, + VERDICT_CLASSES, + classifyVerdicts, + digestPlan, + findUnfilledSections, + isPlanSectionNone, + loadSliceSpec, + parseSliceFile, + partSpec, + prepareRework, + readCriteria, + readResultStatus, + readVerdicts, + renderSliceFile, + sectionTitle, +} from "../lib/slice-file.mjs"; +import { + acceptSlice, + dropSlice, + planSlice, + redoSlice, + refreshPlan, + reviewSlice, + startSlice, +} from "../lib/slice-flow.mjs"; +import { SLICE_KIND_LABELS } from "../lib/state.mjs"; +import { WorkflowError } from "../lib/workflow-error.mjs"; +import { openProject, saveState } from "./support.mjs"; + +/** + * 切片文件短名的格式: 小写字母, 数字与连字符. + * @type {RegExp} + */ +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** + * @typedef {object} SliceCommandContext 一次 slice 子命令的上下文. + * @property {string} projectRoot 项目根目录. + * @property {import("../lib/state.mjs").NavigatorState} state 当前状态. + * @property {import("../lib/slice-file.mjs").SliceSpec} spec 切片文件规格. + * @property {string[]} positionals 子命令之后的位置参数. + * @property {Record} values 选项值. + * @property {string} now 当前时间, ISO 格式. + */ + +/** + * 每个子命令的处理函数. + * @type {Readonly string[]>>} + */ +const SUBCOMMANDS = Object.freeze({ + plan: runSlicePlan, + start: runSliceStart, + replan: runSliceReplan, + review: runSliceReview, + accept: runSliceAccept, + redo: runSliceRedo, + drop: runSliceDrop, +}); + +/** + * 执行 slice 命令. + * + * @param {import("./handlers.mjs").CommandInvocation} invocation 命令调用. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 子命令未知或操作不被允许时. + */ +export function runSlice({ positionals, values, cwd, now }) { + const [subcommand, ...rest] = positionals; + if (subcommand === undefined || !Object.hasOwn(SUBCOMMANDS, subcommand)) { + throw new WorkflowError( + `slice 的用法: slice <${Object.keys(SUBCOMMANDS).join(" | ")}>.`, + ); + } + return SUBCOMMANDS[subcommand]({ + ...openProject(cwd), + spec: loadSliceSpec(), + positionals: rest, + values, + now, + }); +} + +/** + * slice plan: 建切片文件, 写入骨架. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 短名不合法, 文件已存在或切片不能开始时. + */ +function runSlicePlan({ projectRoot, state, spec, positionals, values, now }) { + const [id] = positionals; + if (typeof values.slug !== "string" || !SLUG_PATTERN.test(values.slug)) { + throw new WorkflowError( + "slice plan 的用法: slice plan <编号> --slug <短名>, 短名只用小写字母, 数字与连字符, 例如 export-csv.", + ); + } + const file = sliceFilePath(id ?? "", values.slug); + const next = planSlice(state, id, file); + if (existsSync(path.join(projectRoot, file))) { + throw new WorkflowError(`切片文件 ${file} 已经存在, 换一个短名.`); + } + const { slice } = /** @type {NonNullable>} */ ( + activeSlice(next) + ); + const milestone = currentMilestone(next); + writeFileAtomically( + path.join(projectRoot, file), + renderSliceFile({ + spec, + slice, + summary: [ + `里程 ${milestone?.id} ${milestone?.name}`, + `种类: ${SLICE_KIND_LABELS[slice.kind]}`, + ...(slice.skeleton ? ["最小骨架"] : []), + ...(slice.interactive ? ["交互式"] : []), + ].join("; "), + }), + ); + saveState(projectRoot, next, now); + const plan = partSpec(spec, PARTS.plan).heading; + return [ + `- 执行结果: 已建切片文件 ${file}`, + `- 填写要求: 只填 "${plan}" 段, 把每处 "${spec.placeholder}...)" 换成内容; 小节内不加标题; "${sectionTitle(spec, SECTIONS.criteria)}" 写成编号列表; 其余两段保持原样`, + `- 下一动作: 填好后运行 ${command("slice start")}`, + ]; +} + +/** + * slice start: 核对计划已填好, 记下基准提交与计划摘要, 开工. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 计划没有填好, 有待用户决定的事, 或仓库还没有提交时. + */ +function runSliceStart({ projectRoot, state, spec, values, now }) { + const { slice, parsed } = readActive(projectRoot, state, spec); + const plan = partSpec(spec, PARTS.plan).heading; + const unfilled = findUnfilledSections({ + parsed, + spec, + partId: PARTS.plan, + kind: slice.kind, + }); + if (unfilled.length > 0) { + throw new WorkflowError(`"${plan}" 段还没有填好: ${unfilled.join(", ")}.`); + } + if (readCriteria(parsed, spec).length === 0) { + throw new WorkflowError( + `"${sectionTitle(spec, SECTIONS.criteria)}" 要写成编号列表, 至少一条.`, + ); + } + if ( + !isPlanSectionNone(parsed, spec, SECTIONS.decisions) && + values.decided !== true + ) { + throw new WorkflowError( + `"${sectionTitle(spec, SECTIONS.decisions)}" 中有要用户决定的事: 先问用户, 把答案写在这一节, 再运行 slice start --decided.`, + ); + } + const baseCommit = headCommit(projectRoot); + if (baseCommit === undefined) { + throw new WorkflowError("仓库还没有任何提交, 先提交一次再开工."); + } + const next = startSlice(state, { + baseCommit, + planDigest: /** @type {string} */ (digestPlan(parsed)), + }); + saveState(projectRoot, next, now); + return [ + `- 执行结果: ${next.lastAction}, 基准提交 ${baseCommit}`, + `- 下一动作: ${dispatchStep(slice)}`, + ]; +} + +/** + * slice replan: 实现期间计划有改动时重新记下摘要. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + */ +function runSliceReplan({ projectRoot, state, spec, now }) { + const { parsed } = readActive(projectRoot, state, spec); + const next = refreshPlan(state, /** @type {string} */ (digestPlan(parsed))); + saveState(projectRoot, next, now); + return [`- 执行结果: ${next.lastAction}`]; +} + +/** + * slice review: 核对执行结果已写完, 计划段没有被改动, 送验. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 执行状态不是完成, 计划段被改动, 或执行结果没有填好时. + */ +function runSliceReview({ projectRoot, state, spec, now }) { + const next = reviewSlice(state); + const { slice, parsed } = readActive(projectRoot, state, spec); + const result = partSpec(spec, PARTS.result).heading; + if (readResultStatus(parsed, spec) !== spec.doneStatus) { + throw new WorkflowError( + `"${sectionTitle(spec, SECTIONS.status)}" 还不是 "${spec.doneStatus}": 实现没有做完, 不能送验.`, + ); + } + if (digestPlan(parsed) !== state.current?.planDigest) { + throw new WorkflowError( + `切片文件的 "${partSpec(spec, PARTS.plan).heading}" 段在开工之后被改动. 是你按用户的回答改的, 运行 slice replan 后再送验; 不是的话用 git diff 查看, 恢复后再送验.`, + ); + } + const unfilled = findUnfilledSections({ + parsed, + spec, + partId: PARTS.result, + kind: slice.kind, + }); + if (unfilled.length > 0) { + throw new WorkflowError( + `"${result}" 段还没有填好: ${unfilled.join(", ")}. 请实现方补写.`, + ); + } + saveState(projectRoot, next, now); + return [ + `- 执行结果: ${next.lastAction}`, + `- 下一动作: 用 Agent 工具派出 ${SUBAGENT_TYPES.reviewer}, 提示词是 ${command("brief review")} 的输出原文; 它交回后把内容原样写入切片文件的 "${partSpec(spec, PARTS.review).heading}" 段`, + ]; +} + +/** + * slice accept: 核对验收段, 切片结束. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 验收段没有填好, 有标准不通过, 或有未验证而没有带接受参数时. + */ +function runSliceAccept({ projectRoot, state, spec, values, now }) { + const next = acceptSlice(state); + const { slice, parsed } = readActive(projectRoot, state, spec); + const review = partSpec(spec, PARTS.review).heading; + const verdictTitle = sectionTitle(spec, SECTIONS.verdicts); + const unfilled = findUnfilledSections({ + parsed, + spec, + partId: PARTS.review, + kind: slice.kind, + }); + if (unfilled.length > 0) { + throw new WorkflowError( + `"${review}" 段还没有填好: ${unfilled.join(", ")}.`, + ); + } + const { rows, problems } = readVerdicts(parsed, spec); + if (problems.length > 0) { + throw new WorkflowError(problems.join(" ")); + } + if (rows.length < readCriteria(parsed, spec).length) { + throw new WorkflowError( + `"${verdictTitle}" 只有 ${rows.length} 行, 少于完成标准的条数: 每条标准一行.`, + ); + } + const verdictClass = classifyVerdicts(rows, spec); + if (verdictClass === VERDICT_CLASSES.fail) { + throw new WorkflowError( + `"${verdictTitle}" 中有 ${spec.verdicts.fail} 的标准, 不能通过: 补做 (slice redo) 或放弃 (slice drop).`, + ); + } + if ( + verdictClass === VERDICT_CLASSES.unverified && + values["accept-unverified"] !== true + ) { + throw new WorkflowError( + `"${verdictTitle}" 中有 ${spec.verdicts.unverified} 的标准: 用户明确接受现状时, 运行 slice accept --accept-unverified.`, + ); + } + saveState(projectRoot, next, now); + return [ + `- 执行结果: ${next.lastAction}`, + `- 提交步骤: 调用 ${COMMIT_SKILL} 提交 "切片 ${slice.id} ${slice.name}", 参数写 "提交, 纳入全部改动"; 用户选了推送时写 "提交并推送, 纳入全部改动"`, + `- 下一动作: 提交后运行 ${command("status")}`, + ]; +} + +/** + * slice redo: 归档本轮验收, 回到实现中. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + * @throws {WorkflowError} 计划的补做一节还没有写时. + */ +function runSliceRedo({ projectRoot, state, spec, now }) { + const { slice, file, text, parsed } = readActive(projectRoot, state, spec); + const rework = sectionTitle(spec, SECTIONS.rework); + if (isPlanSectionNone(parsed, spec, SECTIONS.rework)) { + throw new WorkflowError( + `先把要补的内容写入 "${partSpec(spec, PARTS.plan).heading}" 段的 "${rework}", 再运行 slice redo.`, + ); + } + const round = /** @type {{round: number}} */ (state.current).round; + const reworked = prepareRework({ text, spec, kind: slice.kind, round }); + const next = redoSlice( + state, + /** @type {string} */ (digestPlan(parseSliceFile(reworked, spec))), + ); + writeFileAtomically(file, reworked); + saveState(projectRoot, next, now); + return [ + `- 执行结果: ${next.lastAction}; 第 ${round} 轮的验收已归档在切片文件中`, + `- 下一动作: ${ + slice.interactive + ? `在本会话按 "${rework}" 继续做` + : `用 SendMessage 让原来的实现子代理按 "${rework}" 继续; 找不到它时, ${dispatchStep(slice)}` + }`, + ]; +} + +/** + * slice drop: 放弃进行中的切片. + * + * @param {SliceCommandContext} context 上下文. + * @returns {string[]} 输出各行. + */ +function runSliceDrop({ projectRoot, state, now }) { + const next = dropSlice(state); + saveState(projectRoot, next, now); + return [ + `- 执行结果: ${next.lastAction}`, + `- 下一动作: 工作区中属于这个切片的改动由用户决定保留或撤销; 然后运行 ${command("status")}`, + ]; +} + +/** + * 读取进行中的切片及其文件. + * + * @param {string} projectRoot 项目根目录. + * @param {import("../lib/state.mjs").NavigatorState} state 当前状态. + * @param {import("../lib/slice-file.mjs").SliceSpec} spec 切片文件规格. + * @returns {{slice: import("../lib/state.mjs").SliceState, file: string, text: string, parsed: import("../lib/slice-file.mjs").ParsedSlice}} 切片, 文件的绝对路径, 全文与解析结果. + * @throws {WorkflowError} 没有进行中的切片, 或切片文件不存在时. + */ +function readActive(projectRoot, state, spec) { + const slice = activeSlice(state)?.slice; + if (slice === undefined || slice.file === null) { + throw new WorkflowError( + "没有进行中的切片; 先用 slice plan 为一个切片建计划.", + ); + } + const file = path.join(projectRoot, slice.file); + if (!existsSync(file)) { + throw new WorkflowError(`切片文件 ${slice.file} 不存在, 请用 git 恢复.`); + } + const text = readFileSync(file, "utf8"); + return { slice, file, text, parsed: parseSliceFile(text, spec) }; +} + +/** + * 写出开工之后由谁来做: 交互式切片在本会话做, 其余在后台派实现子代理. + * + * @param {import("../lib/state.mjs").SliceState} slice 切片. + * @returns {string} 一步动作. + */ +function dispatchStep(slice) { + if (slice.interactive) { + return `这是交互式切片, 在本会话按计划逐步做: 需要用户操作时直接说, 每确认一条结论立即写入 ${FINDINGS_DIRECTORY}/; 做完后填写执行结果, 运行 ${command("slice review")}`; + } + return `用 Agent 工具在后台 (run_in_background) 派出 ${SUBAGENT_TYPES.executor}, 提示词是 ${command("brief exec")} 的输出原文`; +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/stage.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/stage.mjs deleted file mode 100644 index ed1347f..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/stage.mjs +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file stage, step, skip, finish 命令: 进入阶段, 设置步骤, 记录跳过的阶段, 确认收尾. - * - * 用法: stage <阶段编号>; step <步骤标识>; skip <阶段编号> --from <草稿>; finish. - * skip 的草稿格式: {"reason": "跳过原因"}. 跳过必须先得到用户认可. - * 进入新阶段时, 上一阶段有尚未入库的技能产物, 输出中附上提交这些产物的一步; - * 确认收尾写下的完成标记也要入库, 所以 finish 总把状态文件算进去. - */ - -import { lastStageNumber } from "../lib/spec.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; -import { - enterStage, - finishProject, - setStep, - skipStage, -} from "../lib/workflow-plan.mjs"; -import { - openProject, - readDraftJson, - recordCommitStep, - resultLines, - saveState, -} from "./support.mjs"; - -/** - * 确认收尾时提交的成果名. - * @type {string} - */ -const FINISH_LABEL = "项目收尾"; - -/** - * 执行阶段相关命令. - * - * @param {object} options 命令参数. - * @param {"stage" | "step" | "skip" | "finish"} options.command 命令名. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 参数不合法时. - */ -export function runStage({ command, positionals, values, cwd, now }) { - const context = openProject(cwd); - const lastStage = lastStageNumber(context.spec); - if (command === "finish") { - const saved = saveState( - context, - finishProject(context.state, lastStage), - now, - ); - return withCommitStep( - resultLines(saved), - recordCommitStep(context.projectRoot, FINISH_LABEL, { - includeState: true, - }), - ); - } - const [argument] = positionals; - if (argument === undefined) { - throw new WorkflowError(`${command} 缺少参数.`); - } - switch (command) { - case "stage": - return advanceStage(context, Number(argument), now); - case "step": - return resultLines( - saveState(context, setStep(context.state, argument), now), - ); - case "skip": { - const draft = readDraftJson(context.projectRoot, values.from); - return resultLines( - saveState( - context, - skipStage( - context.state, - Number(argument), - String(draft.reason ?? ""), - { lastStage, now }, - ), - now, - ), - ); - } - default: - throw new WorkflowError(`未知命令 ${command}.`); - } -} - -/** - * 进入新阶段; 上一阶段有尚未入库的技能产物时, 附上提交这些产物的一步. - * - * @param {import("./support.mjs").ProjectContext} context 项目上下文. - * @param {number} stage 要进入的阶段编号. - * @param {string} now 当前时间. - * @returns {string[]} 输出各行. - */ -function advanceStage(context, stage, now) { - const { state, spec, projectRoot } = context; - const saved = saveState( - context, - enterStage(state, stage, lastStageNumber(spec)), - now, - ); - const label = - state.stage === null - ? "开始阶段" - : `阶段 ${state.stage} (${spec.stages[state.stage]}) 完成`; - return withCommitStep( - resultLines(saved), - state.stage === null ? undefined : recordCommitStep(projectRoot, label), - ); -} - -/** - * 有提交步骤时, 在输出末尾加上下一动作. - * - * @param {string[]} lines 输出各行. - * @param {string | undefined} commitStep 提交步骤; 不需要提交时为 undefined. - * @returns {string[]} 输出各行. - */ -function withCommitStep(lines, commitStep) { - return commitStep === undefined - ? lines - : [...lines, `- 下一动作: ${commitStep}, 然后继续`]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/standards.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/standards.mjs deleted file mode 100644 index d7aa209..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/standards.mjs +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file standards 命令: 把规范对照的结果写入项目规范文件. - * - * 用法: standards --from <草稿>, 草稿格式 - * {"commentLanguage": "中文", "conventions": ["以项目现有的格式化配置为准.", ...]} - * - * 项目规范文件 = 插件的通用工程规范默认包 + 注释语言 + 项目约定. 项目约定来自 - * 规范对照中用户的选择, 与默认包冲突时以项目约定为准. 该文件受保护, - * 所有会话都只能通过本命令修改. - */ - -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import { ENGINEERING_RULES_FILE, STANDARDS_FILE } from "../lib/paths.mjs"; -import { requireText } from "../lib/validation.mjs"; -import { - openProject, - readDraftJson, - resultLines, - saveState, -} from "./support.mjs"; - -/** - * 执行 standards 命令. - * - * @param {object} options 命令参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {string[]} 输出各行. - * @throws {import("../lib/workflow-error.mjs").WorkflowError} 草稿不合法时. - */ -export function runStandards({ values, cwd, now }) { - const context = openProject(cwd); - const draft = readDraftJson(context.projectRoot, values.from); - const commentLanguage = requireText(draft.commentLanguage, "注释语言"); - const conventions = Array.isArray(draft.conventions) - ? draft.conventions.map((item) => String(item).trim()).filter(Boolean) - : []; - const target = path.join(context.projectRoot, ENGINEERING_RULES_FILE); - mkdirSync(path.dirname(target), { recursive: true }); - writeFileSync( - target, - composeRules( - readFileSync(STANDARDS_FILE, "utf8"), - commentLanguage, - conventions, - ), - "utf8", - ); - const state = saveState( - context, - { ...context.state, lastAction: "写入项目规范" }, - now, - ); - return [...resultLines(state), `- 规范文件: ${ENGINEERING_RULES_FILE}`]; -} - -/** - * 拼接项目规范全文. - * - * @param {string} pack 通用工程规范默认包. - * @param {string} commentLanguage 注释语言. - * @param {string[]} conventions 项目约定. - * @returns {string} 项目规范全文. - */ -function composeRules(pack, commentLanguage, conventions) { - const conventionLines = - conventions.length === 0 - ? ["无"] - : conventions.map((item, index) => `${index + 1}. ${item}`); - return [ - pack.trimEnd(), - "", - "## 注释语言", - "", - `代码注释使用${commentLanguage}.`, - "", - "## 项目约定", - "", - "本节来自规范对照中用户的选择, 与上文冲突时以本节为准.", - "", - ...conventionLines, - "", - ].join("\n"); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/support.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/support.mjs index 06bcc90..eaade9f 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/support.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/support.mjs @@ -1,32 +1,46 @@ /** - * @file 命令共用的步骤: 打开项目, 保存状态 (写入并生成视图), 读取草稿, 写出提交步骤. + * @file 命令共用的步骤: 打开项目, 保存状态 (写入并生成路线视图), 读取草稿, + * 写出提交步骤. */ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; +import { writeFileAtomically } from "../lib/atomic-file.mjs"; import { COMMIT_SKILL } from "../lib/guard.mjs"; import { + COMMAND_NAME, + COMMITTED_PATHSPECS, DRAFTS_DIRECTORY, + ENGINEERING_RULES_FILE, + NAVIGATOR_DIRECTORY, + PROJECT_SETTINGS_FILE, + ROADMAP_FILE, + STATE_FILE, isUnderDirectory, projectRelativePath, } from "../lib/paths.mjs"; -import { writePlanViews } from "../lib/render-plan.mjs"; import { findRepositoryRoot, listUncommittedPaths } from "../lib/repo.mjs"; -import { loadSpec } from "../lib/spec.mjs"; -import { INIT_ACTIVE, readState, writeState } from "../lib/state.mjs"; +import { renderRoadmap } from "../lib/roadmap-view.mjs"; import { - COMMITTED_PATHSPECS, - listUncommittedProducts, -} from "../lib/tracked-paths.mjs"; + INIT_ACTIVE, + isCurrentState, + readRawState, + writeState, +} from "../lib/state.mjs"; import { runtimeVersion } from "../lib/version.mjs"; import { WorkflowError } from "../lib/workflow-error.mjs"; +/** + * 只提交技能产物时, 交给提交技能的参数. + * @type {string} + */ +const PRODUCTS_COMMIT_ARGUMENT = `提交, 纳入范围: ${NAVIGATOR_DIRECTORY}/ 下的改动, ${PROJECT_SETTINGS_FILE} 与 ${ENGINEERING_RULES_FILE}; 纳入范围之外的改动用文字列出并询问`; + /** * @typedef {object} ProjectContext 已初始化项目的上下文. * @property {string} projectRoot 项目根目录. * @property {import("../lib/state.mjs").NavigatorState} state 当前状态. - * @property {import("../lib/spec.mjs").TemplateSpec} spec 模板规格. */ /** @@ -34,35 +48,43 @@ import { WorkflowError } from "../lib/workflow-error.mjs"; * * @param {string} cwd 会话工作目录. * @returns {ProjectContext} 项目上下文. - * @throws {WorkflowError} 不在 Git 仓库中, 或项目尚未初始化时. + * @throws {WorkflowError} 不在 Git 仓库中, 项目尚未初始化, 或记录由 1.x 写入时. */ export function openProject(cwd) { const projectRoot = findRepositoryRoot(cwd); if (projectRoot === undefined) { throw new WorkflowError("当前目录不是 Git 仓库."); } - const state = readState(projectRoot); - if (state === undefined || state.init.status !== INIT_ACTIVE) { - throw new WorkflowError("项目尚未初始化, 请先运行 init."); + const raw = readRawState(projectRoot); + if (raw !== undefined && !isCurrentState(raw)) { + throw new WorkflowError( + `${NAVIGATOR_DIRECTORY}/ 中是旧版本的记录, 本版本不读取; 运行 ${COMMAND_NAME} init --archive 归档后重新立项.`, + ); } - return { projectRoot, state, spec: loadSpec() }; + if (raw === undefined || raw.init.status !== INIT_ACTIVE) { + throw new WorkflowError(`项目尚未初始化, 请先运行 ${COMMAND_NAME} init.`); + } + return { projectRoot, state: raw }; } /** - * 保存新状态: 原子写入, 记下写入时的技能版本, 重新生成视图文件. + * 保存新状态: 原子写入, 记下写入时的技能版本, 重新生成路线视图. * - * @param {ProjectContext} context 项目上下文, 其中的 state 为修改前的状态. + * @param {string} projectRoot 项目根目录. * @param {import("../lib/state.mjs").NavigatorState} nextState 修改后的状态. * @param {string} now 当前时间, ISO 格式. * @returns {import("../lib/state.mjs").NavigatorState} 实际写入的状态. */ -export function saveState(context, nextState, now) { +export function saveState(projectRoot, nextState, now) { const written = writeState( - context.projectRoot, + projectRoot, { ...nextState, skillVersion: runtimeVersion() }, now, ); - writePlanViews(context.projectRoot, written, context.spec.format); + writeFileAtomically( + path.join(projectRoot, ROADMAP_FILE), + renderRoadmap(written), + ); return written; } @@ -70,25 +92,31 @@ export function saveState(context, nextState, now) { * 写出把技能产物提交入库的一步: 状态目录与插件管理的项目配置有未入库的改动时, * 请编排会话调用提交技能; 没有改动时不需要这一步. * - * 状态文件每条命令都会改写, 默认不算改动; 写下的状态必须入库时 (例如确认收尾) - * 把它算进去. + * 状态文件与路线视图每条命令都会改写, 默认不算改动; 写下的状态必须入库时 + * (例如确认收尾) 把它们算进去. * * @param {string} projectRoot 项目根目录. - * @param {string} label 这次提交的成果, 例如 "阶段 1 (立项) 完成". - * @param {{includeState?: boolean}} [options] 是否把状态文件的改动算进去. - * @returns {string | undefined} 下一动作中的提交步骤; 没有改动时为 undefined. + * @param {string} label 这次提交的成果, 例如 "立项完成". + * @param {{includeState?: boolean, extraPathspecs?: readonly string[]}} [options] 是否把状态文件的改动算进去, 以及额外要提交的范围. + * @returns {string | undefined} 提交步骤; 没有改动时为 undefined. */ -export function recordCommitStep( +export function productsCommitStep( projectRoot, label, - { includeState = false } = {}, + { includeState = false, extraPathspecs = [] } = {}, ) { - const changed = includeState - ? listUncommittedPaths(projectRoot, COMMITTED_PATHSPECS) - : listUncommittedProducts(projectRoot); - return changed.length === 0 - ? undefined - : `调用 ${COMMIT_SKILL} 提交 "${label}" 的技能产物, 参数写 "提交, 纳入范围: .navigator/ 下的改动, .claude/settings.json 与 .claude/rules/engineering.md; 纳入范围之外的改动用文字列出并询问"`; + const changed = listUncommittedPaths(projectRoot, [ + ...COMMITTED_PATHSPECS, + ...extraPathspecs, + ]).filter( + (file) => includeState || (file !== STATE_FILE && file !== ROADMAP_FILE), + ); + if (changed.length === 0) { + return undefined; + } + const extra = + extraPathspecs.length === 0 ? "" : `, 以及 ${extraPathspecs.join(", ")}`; + return `调用 ${COMMIT_SKILL} 提交 "${label}", 参数写 "${PRODUCTS_COMMIT_ARGUMENT}${extra}"`; } /** @@ -121,13 +149,3 @@ export function readDraftJson(projectRoot, draftPath) { ); } } - -/** - * 输出状态变化后的简短结果行. - * - * @param {import("../lib/state.mjs").NavigatorState} state 写入后的状态. - * @returns {string[]} 输出各行. - */ -export function resultLines(state) { - return [`- 执行结果: ${state.lastAction}`]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/template.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/template.mjs deleted file mode 100644 index f467c18..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/template.mjs +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @file template 命令: 输出某种记录文件的填写要求与骨架, "当前进展" 已按状态填好. - * 工单骨架预填基准提交 (当前提交); 会改动代码的工单, 验收判据第一条预填代码检查判据. - * - * 用法: template <文件种类> [--option <结构编号>], 文件种类为规格 files 中的键, - * 例如 order, receipt, review, brief. 回执有两种结构: 1 为实现类, 2 为选型类. - */ - -import { orderSkeletonPrefills } from "../lib/code-checks.mjs"; -import { renderFileGuide } from "../lib/file-guide.mjs"; -import { orderSkeletonValues } from "../lib/order-base.mjs"; -import { fileSections, renderFileSkeleton } from "../lib/render.mjs"; -import { findRepositoryRoot } from "../lib/repo.mjs"; -import { loadSpec } from "../lib/spec.mjs"; -import { readStateIfValid } from "../lib/state.mjs"; -import { WorkflowError } from "../lib/workflow-error.mjs"; - -/** - * 工单文件的种类名; 工单骨架预填基准提交, 并按当前工单预填代码检查判据. - * @type {string} - */ -const ORDER_FILE_KIND = "order"; - -/** - * 执行 template 命令. - * - * @param {object} options 命令参数. - * @param {string[]} options.positionals 命令名之后的位置参数. - * @param {Record} options.values 选项值. - * @param {string} options.cwd 会话工作目录. - * @returns {string[]} 输出各行. - * @throws {WorkflowError} 文件种类不存在时. - */ -export function runTemplate({ positionals, values, cwd }) { - const spec = loadSpec(); - const [kind] = positionals; - if (kind === undefined || !Object.hasOwn(spec.files, kind)) { - throw new WorkflowError( - `template 的用法: template <文件种类>, 文件种类为 ${Object.keys(spec.files).join(", ")} 之一.`, - ); - } - const projectRoot = findRepositoryRoot(cwd); - const state = - projectRoot === undefined ? undefined : readStateIfValid(projectRoot); - const fileSpec = spec.files[kind]; - const variantIndex = Number(values.option ?? "1") - 1; - const skeleton = renderFileSkeleton({ - fileSpec, - variantIndex, - state, - spec, - prefills: kind === ORDER_FILE_KIND ? orderSkeletonPrefills(state) : {}, - values: kind === ORDER_FILE_KIND ? orderSkeletonValues(projectRoot) : {}, - }); - return [ - ...renderFileGuide({ - fileSpec, - sections: fileSections(fileSpec, variantIndex), - }), - ...skeleton.trimEnd().split("\n"), - ]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/commands/uninstall.mjs b/plugins/waypoint/skills/project-navigator/runtime/commands/uninstall.mjs index 2eabae5..3d9e0eb 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/commands/uninstall.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/commands/uninstall.mjs @@ -6,21 +6,18 @@ */ import { + COMMAND_NAME, PROJECT_LOCAL_SETTINGS_FILE, PROJECT_SETTINGS_FILE, } from "../lib/paths.mjs"; import { findRepositoryRoot } from "../lib/repo.mjs"; import { readSettingsFile, - removeLegacyHooks, removeNavigatorPermissions, writeSettingsFile, } from "../lib/settings.mjs"; -import { - INIT_UNINSTALLED, - readStateIfValid, - writeState, -} from "../lib/state.mjs"; +import { INIT_UNINSTALLED, readState } from "../lib/state.mjs"; +import { saveState } from "./support.mjs"; /** * 执行 uninstall 命令. @@ -35,33 +32,27 @@ export function runUninstall({ cwd, now }) { if (projectRoot === undefined) { return ["- 卸载结果: 未执行, 当前目录不是 Git 仓库"]; } - writeSettingsFile( - projectRoot, - PROJECT_SETTINGS_FILE, - removeNavigatorPermissions( - removeLegacyHooks(readSettingsFile(projectRoot, PROJECT_SETTINGS_FILE)), - ), - ); - writeSettingsFile( - projectRoot, - PROJECT_LOCAL_SETTINGS_FILE, - removeNavigatorPermissions( - readSettingsFile(projectRoot, PROJECT_LOCAL_SETTINGS_FILE), - ), - ); - const state = readStateIfValid(projectRoot); + for (const file of [PROJECT_SETTINGS_FILE, PROJECT_LOCAL_SETTINGS_FILE]) { + writeSettingsFile( + projectRoot, + file, + removeNavigatorPermissions(readSettingsFile(projectRoot, file)), + ); + } + const state = readState(projectRoot); if (state !== undefined) { - writeState( + saveState( projectRoot, { ...state, init: { ...state.init, status: INIT_UNINSTALLED, uninstalledAt: now }, + lastAction: "卸载", }, now, ); } return [ "- 卸载结果: 已移除放行规则, 插件的 hook 在本项目中不再生效", - "- 编排记录: 已保留, 重新运行 init 可恢复", + `- 编排记录: 已保留, 重新运行 ${COMMAND_NAME} init 可恢复`, ]; } diff --git a/plugins/waypoint/skills/project-navigator/runtime/hook.mjs b/plugins/waypoint/skills/project-navigator/runtime/hook.mjs index dca34d8..b92b189 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/hook.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/hook.mjs @@ -2,77 +2,59 @@ * @file 插件 hook 的统一入口, 由插件的 `hooks/hooks.json` 以 `node <本文件>` * 调用, 从标准输入读取事件 JSON. * - * 插件的 hook 在所有项目中都会触发; 项目中没有 `.navigator/state.json`, 或项目 - * 已卸载时, 本脚本立即结束, 不做任何事. 已初始化的项目中: + * 插件的 hook 在所有项目中都会触发; 项目中没有当前结构的状态文件, 或项目已卸载 + * 时, 本脚本立即结束, 不做任何事. 已初始化的项目中: * * - 工具调用前: 写入类工具按守卫的四条规则放行或拒绝. - * - 用户发消息时: 编排会话注入位置与要点, 并记下用户对工单审阅的选择. - * - 会话开始时 (含恢复与上下文压缩后): 编排会话注入位置; 压缩后另提示重新查询. - * - 回复结束时: 校验编排会话回复的结构, 不合格时打回一次; 回复工单审阅后开始 - * 等待用户选择. + * - 用户发消息时与会话开始时 (含恢复与上下文压缩后): 向编排会话注入位置与 + * 下一动作. * * 编排会话不做登记, 按会话转录中的编排标记判断 (见 lib/orchestrator-marker.mjs). * 脚本出错时一律放行, 不卡住会话. 本脚本从不以退出码 2 结束: "用户发消息时" * 事件以退出码 2 结束会清掉用户输入. */ -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; -import { - EVENT_KINDS, - recordEvent, - stackLines, - unreportedEvents, -} from "./lib/diagnostics.mjs"; import { WRITE_TOOLS, decideWrite } from "./lib/guard.mjs"; import { - APPROVAL_REPLY_TYPE, - applyApprovalAnswer, - markAwaitingApproval, -} from "./lib/order-approval.mjs"; + command, + nextAction, + progressLine, + readFacts, +} from "./lib/guidance.mjs"; import { isOrchestratorTranscript, readTranscript, } from "./lib/orchestrator-marker.mjs"; -import { findWorktreeRoot, navigatorPath } from "./lib/paths.mjs"; -import { - diagnosticsReminder, - orchestratorCompactReminder, - orchestratorReminder, -} from "./lib/reminders.mjs"; -import { checkReply, locateReply } from "./lib/reply-checks.mjs"; -import { loadSpec } from "./lib/spec.mjs"; -import { INIT_UNINSTALLED, readStateIfValid } from "./lib/state.mjs"; -import { REVIEWABLE_ORDER_STATUSES } from "./lib/workflow-orders.mjs"; +import { COMMAND_NAME, findWorktreeRoot } from "./lib/paths.mjs"; +import { activeSlice } from "./lib/roadmap.mjs"; +import { INIT_ACTIVE, readState } from "./lib/state.mjs"; /** - * 回复结构不合格时, 打回原因的开头. 只让模型改列出的问题: 重写整条回复 - * 容易引入新的错误, 而重写后的回复不再校验. + * 会话开始事件中表示上下文压缩之后的来源值. * @type {string} */ -const REPLY_REJECTION_HEADER = - "回复结构不合格. 只修改下列问题, 其余内容原样保留, 然后重新输出完整回复:"; +const COMPACT_SOURCE = "compact"; /** - * 会话开始事件中表示上下文压缩之后的来源值. - * @type {string} + * 注入上下文的事件名. + * @type {readonly string[]} */ -const COMPACT_SOURCE = "compact"; +const REMINDER_EVENTS = Object.freeze(["UserPromptSubmit", "SessionStart"]); /** - * 记录 hook 错误时, 输入中没有事件名时写的来源. + * 工具调用前的事件名. * @type {string} */ -const UNKNOWN_EVENT = "未知事件"; +const PRE_TOOL_EVENT = "PreToolUse"; /** - * @typedef {object} HookEvent 一次 hook 调用的上下文. - * @property {Record} input hook 输入. - * @property {string} projectRoot 项目根目录. - * @property {import("./lib/state.mjs").NavigatorState} state 当前状态. - * @property {() => boolean} isOrchestrator 判断本会话是否为编排会话, 结果在本次调用中缓存. - * @property {string} now 当前时间, ISO 格式. + * 位置提醒之后固定附上的规则. + * @type {string} */ +const REMINDER_RULES = + "规则: 编排会话不写业务代码; 状态只用插件命令修改; 回复第一行写结论, 第二行写进展; 当前切片之外的想法记进待办, 不顺手做."; /** * 读取事件, 分发处理, 输出结果. @@ -84,55 +66,18 @@ function main() { const projectRoot = findWorktreeRoot( process.env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd(), ); - if ( - projectRoot === undefined || - !existsSync(navigatorPath(projectRoot, "state")) - ) { + if (projectRoot === undefined) { return; } try { - const state = readStateIfValid(projectRoot); - if (state === undefined || state.init.status === INIT_UNINSTALLED) { + const state = readState(projectRoot); + if (state === undefined || state.init.status !== INIT_ACTIVE) { return; } - const output = handleEvent({ - input, - projectRoot, - state, - isOrchestrator: memoize(() => - isOrchestratorTranscript( - readTranscript(input.transcript_path), - projectRoot, - ), - ), - now: new Date().toISOString(), - }); + const output = handleEvent(input, projectRoot, state); if (output !== undefined) { process.stdout.write(JSON.stringify(output)); } - } catch (error) { - recordHookError(projectRoot, input, error); - } -} - -/** - * 记下 hook 的内部错误, 供问题报告使用. hook 出错时一律放行, 所以这里只记录, - * 不输出; 记录本身出错时也不影响放行. - * - * @param {string} projectRoot 项目根目录. - * @param {Record} input hook 输入. - * @param {unknown} error 捕获到的错误. - * @returns {void} - */ -function recordHookError(projectRoot, input, error) { - try { - recordEvent(projectRoot, { - kind: EVENT_KINDS.hookError, - source: String(input.hook_event_name ?? UNKNOWN_EVENT), - message: error instanceof Error ? error.message : String(error), - stack: stackLines(error), - at: new Date().toISOString(), - }); } catch { return; } @@ -141,31 +86,49 @@ function recordHookError(projectRoot, input, error) { /** * 按事件分发. * - * @param {HookEvent} event hook 调用上下文. + * @param {Record} input hook 输入. + * @param {string} projectRoot 项目根目录. + * @param {import("./lib/state.mjs").NavigatorState} state 当前状态. * @returns {Record | undefined} 要输出的 JSON; 不输出时为 undefined. */ -function handleEvent(event) { - switch (event.input.hook_event_name) { - case "PreToolUse": - return handlePreToolUse(event); - case "UserPromptSubmit": - return handleUserPrompt(event); - case "SessionStart": - return handleSessionStart(event); - case "Stop": - return handleStop(event); - default: - return undefined; +function handleEvent(input, projectRoot, state) { + const isOrchestrator = () => + isOrchestratorTranscript( + readTranscript(input.transcript_path), + projectRoot, + ); + if (input.hook_event_name === PRE_TOOL_EVENT) { + return handlePreToolUse(input, projectRoot, state, isOrchestrator); } + if ( + !REMINDER_EVENTS.includes(input.hook_event_name) || + typeof input.agent_id === "string" || + !isOrchestrator() + ) { + return undefined; + } + return { + hookSpecificOutput: { + hookEventName: input.hook_event_name, + additionalContext: reminder( + projectRoot, + state, + input.source === COMPACT_SOURCE, + ), + }, + }; } /** * 处理工具调用前事件: 写入类工具交给守卫判定, 拒绝时输出理由. * - * @param {HookEvent} event hook 调用上下文. + * @param {Record} input hook 输入. + * @param {string} projectRoot 项目根目录. + * @param {import("./lib/state.mjs").NavigatorState} state 当前状态. + * @param {() => boolean} isOrchestrator 判断本会话是否为编排会话. * @returns {Record | undefined} 要输出的 JSON. */ -function handlePreToolUse({ input, projectRoot, state, isOrchestrator }) { +function handlePreToolUse(input, projectRoot, state, isOrchestrator) { if (!WRITE_TOOLS.includes(String(input.tool_name ?? ""))) { return undefined; } @@ -175,149 +138,39 @@ function handlePreToolUse({ input, projectRoot, state, isOrchestrator }) { isSubagent: typeof input.agent_id === "string", agentType: typeof input.agent_type === "string" ? input.agent_type : undefined, - order: state.order, + sliceFile: activeSlice(state)?.slice.file ?? null, isOrchestrator, }); return decision.decision === "deny" - ? denyOutput(decision.reason ?? "") + ? { + hookSpecificOutput: { + hookEventName: PRE_TOOL_EVENT, + permissionDecision: "deny", + permissionDecisionReason: decision.reason ?? "", + }, + } : undefined; } /** - * 处理用户发消息事件: 编排会话记下用户对工单审阅的选择, 并注入位置与要点. - * - * @param {HookEvent} event hook 调用上下文. - * @returns {Record | undefined} 要输出的 JSON. - */ -function handleUserPrompt({ input, projectRoot, state, isOrchestrator, now }) { - if (!isOrchestrator()) { - return undefined; - } - const spec = loadSpec(); - applyApprovalAnswer({ - projectRoot, - order: state.order, - prompt: String(input.prompt ?? ""), - spec, - now, - }); - return contextOutput( - "UserPromptSubmit", - [ - orchestratorReminder(state, spec), - ...diagnosticsReminder(unreportedEvents(projectRoot).length), - ].join("\n"), - ); -} - -/** - * 处理会话开始事件: 编排会话注入位置; 上下文压缩后另提示重新查询下一动作. - * - * @param {HookEvent} event hook 调用上下文. - * @returns {Record | undefined} 要输出的 JSON. - */ -function handleSessionStart({ input, state, isOrchestrator }) { - if (!isOrchestrator()) { - return undefined; - } - const spec = loadSpec(); - return contextOutput( - "SessionStart", - input.source === COMPACT_SOURCE - ? orchestratorCompactReminder(state, spec) - : orchestratorReminder(state, spec), - ); -} - -/** - * 处理回复结束事件: 校验编排会话回复的结构, 不合格时打回一次. 工单审阅合格, - * 或本轮不校验时, 开始等待用户对工单审阅的选择. - * - * `stop_hook_active` 为 true 表示本轮已被打回过, 此时不再校验, 避免循环. - * 有后台任务时照常校验: 等待子代理或用户操作的回复有自己的类型 ("工单执行", - * "工单验收", "等待结果", "等待操作"). - * - * @param {HookEvent} event hook 调用上下文. - * @returns {Record | undefined} 要输出的 JSON. - */ -function handleStop({ input, projectRoot, state, isOrchestrator, now }) { - const text = - typeof input.last_assistant_message === "string" - ? input.last_assistant_message - : ""; - if (text === "" || !isOrchestrator()) { - return undefined; - } - const problems = - input.stop_hook_active === true - ? [] - : checkReply({ text, spec: loadSpec() }); - if (problems.length > 0) { - return { - decision: "block", - reason: [ - REPLY_REJECTION_HEADER, - ...problems.map((problem, index) => `${index + 1}. ${problem}`), - ].join("\n"), - }; - } - if ( - locateReply(text).heading?.text === APPROVAL_REPLY_TYPE && - state.order !== null && - REVIEWABLE_ORDER_STATUSES.includes(state.order.status) - ) { - markAwaitingApproval(projectRoot, state.order, now); - } - return undefined; -} - -/** - * 包装一个无参数函数, 第一次调用后缓存结果. - * - * @template T - * @param {() => T} compute 计算函数. - * @returns {() => T} 带缓存的函数. - */ -function memoize(compute) { - let isComputed = false; - /** @type {T | undefined} */ - let value; - return () => { - if (!isComputed) { - value = compute(); - isComputed = true; - } - return /** @type {T} */ (value); - }; -} - -/** - * 构造注入上下文的输出. - * - * @param {string} eventName 事件名. - * @param {string} text 注入的文字. - * @returns {Record} hook 输出 JSON. - */ -function contextOutput(eventName, text) { - return { - hookSpecificOutput: { hookEventName: eventName, additionalContext: text }, - }; -} - -/** - * 构造拒绝工具调用的输出. + * 生成注入编排会话的位置提醒. * - * @param {string} reason 拒绝理由. - * @returns {Record} hook 输出 JSON. + * @param {string} projectRoot 项目根目录. + * @param {import("./lib/state.mjs").NavigatorState} state 当前状态. + * @param {boolean} isAfterCompact 是否在上下文压缩之后. + * @returns {string} 提醒全文. */ -function denyOutput(reason) { - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason, - }, - }; +function reminder(projectRoot, state, isAfterCompact) { + return [ + ...(isAfterCompact + ? [ + `[${COMMAND_NAME}] 上下文已压缩: 以下面的位置为准, 需要细节时运行 ${command("status")} 与对应的 ref.`, + ] + : []), + `[${COMMAND_NAME}] 位置: ${progressLine(state)}.`, + `下一动作: ${nextAction(state, readFacts(projectRoot, state))}`, + REMINDER_RULES, + ].join("\n"); } main(); diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/briefs.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/briefs.mjs index acfcc05..b804b71 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/briefs.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/briefs.mjs @@ -1,8 +1,8 @@ /** - * @file 由脚本生成的委派提示词: 核对子代理, 执行子代理, 验收子代理与调研子代理. + * @file 由脚本生成的委派提示词: 实现子代理与验收子代理. * * 提示词由脚本生成, 编排会话原样使用, 不在其中加入倾向性说明或删掉检查项. - * 这里是这些提示词的唯一来源. + * 这里是这些提示词的唯一来源; 小节名取自切片文件规格. */ import path from "node:path"; @@ -10,227 +10,214 @@ import path from "node:path"; import { COMMAND_NAME, ENGINEERING_RULES_FILE, - orderArtifactsPath, - orderFilePath, + FINDINGS_DIRECTORY, + NAVIGATOR_DIRECTORY, } from "./paths.mjs"; -import { VERDICTS } from "./review-record.mjs"; +import { PARTS, SECTIONS, partSpec, sectionsFor } from "./slice-file.mjs"; import { - EVIDENCE_COMMAND, - EVIDENCE_SECTION, - REQUIREMENT_SECTION, -} from "./source-evidence.mjs"; -import { - SELECTION_ORDER_KIND, - isRecordOnlyOrderKind, -} from "./workflow-orders.mjs"; - -/** - * 一次问题域调研中网络搜索与网页抓取的总次数上限. 立项调研只需形成基本认知, - * 实测不设上限时一次调研超过 80 次调用, 耗时十几分钟. - * @type {number} - */ -const RESEARCH_CALL_LIMIT = 20; + AUTO_VERIFICATION, + ORGANIZATIONS, + ORGANIZATION_LABELS, + SLICE_KINDS, + SLICE_KIND_LABELS, + VERIFICATION_LABELS, +} from "./state.mjs"; /** - * 执行子代理交回给编排会话的要点最多行数. + * 实现子代理交回给编排会话的要点最多行数. * @type {number} */ -const EXECUTOR_SUMMARY_LINES = 3; - -/** - * 执行子代理中途交回, 等用户完成操作时, 交回内容第一行的写法. - * @type {string} - */ -export const PAUSED_HANDBACK = "执行状态: 等待操作"; - -/** - * 回执 "偏差记录" 中列出待定判据的键, 与规格 files.receipt 一致. - * @type {string} - */ -export const PENDING_CRITERIA_KEY = "待定判据"; +const SUMMARY_LINES = 3; /** - * 工单中写编排会话对细节的判断的节, 与规格 files.order 一致. 这一节与实际不符时, - * 执行子代理以实际为准, 不算受阻. - * @type {string} + * 实现子代理交回内容第一行的写法. + * @type {Readonly<{done: string, question: string, action: string}>} */ -export const ASSUMPTIONS_SECTION = "工作假设"; - -/** - * 回执中逐条记录事实修正的节, 与规格 files.receipt 一致. - * @type {string} - */ -export const CORRECTIONS_SECTION = "事实修正"; +export const HANDBACKS = Object.freeze({ + done: "交回: 完成", + question: "交回: 需要回答", + action: "交回: 需要操作", +}); /** - * 问题域调研提示词的固定框架; 编排会话只在其后填写调研问题. - * @type {string} + * 结构核对逐项要回答的问题. + * @type {readonly string[]} */ -export const RESEARCH_FRAME = [ - "# 问题域调研", - "", - "你是调研子代理, 只读, 不写任何文件.", - "", - "## 调研规则", - "", - "1. 只查问题域: 同类产品与替代方案, 目标使用者与典型场景, 行业做法与标准, 法规与合规约束, 常见失败原因, 关键术语.", - '2. 不做技术选型, 不比较库或框架; 查到的技术话题只记为 "选型问题".', - '3. 每条发现附来源地址, 并标明 "事实" 或 "推断"; 推断写明依据哪条来源. 不凭记忆下结论.', - `4. 控制范围: 目标是形成基本认知, 不求穷尽. 每个调研问题找 2 至 4 个可靠来源, 能回答就停; 网络搜索与网页抓取合计不超过 ${RESEARCH_CALL_LIMIT} 次; 同一网页不重复抓取; 摘录只取支持结论的一两句.`, - "5. 用简体中文与英文 (ASCII) 标点交回, 每个标点后加一个空格.", - "", - "## 交回格式", - "", - "依次交回以下各节: 同类方案 (表格: 名称, 做法, 可借鉴之处, 来源), 使用人群, 行业做法, 约束风险, 关键术语 (表格: 术语, 定义, 来源), 选型问题, 结论汇总 (表格: 类别, 内容, 依据, 类别为已知, 假设, 未知), 来源列表.", - "", - "## 调研问题", - "", -].join("\n"); +const STRUCTURE_CHECKS = Object.freeze([ + "每个新文件是否在计划写明的模块中", + "新增的常量, 类型, 配置, 规则是否只有一处定义", + "跨层调用是否只通过接口, 依赖方向是否与项目规范一致", + "是否有可以复用却重写的实现", + "是否新建了只有一处使用的通用模块或公共层", + "新功能是否迫使多个已有模块改动内部实现", +]); /** - * 发布前核对的三种结论. - * @type {Readonly<{holds: string, fails: string, unknown: string}>} + * @typedef {object} BriefContext 生成提示词需要的信息. + * @property {string} projectRoot 项目根目录. + * @property {import("./state.mjs").SliceState} slice 切片. + * @property {import("./state.mjs").CurrentSlice} current 切片的运行信息. + * @property {import("./state.mjs").ProjectSettings} project 项目设置. + * @property {import("./slice-file.mjs").SliceSpec} spec 切片文件规格. */ -export const PRECHECK_VERDICTS = Object.freeze({ - holds: "成立", - fails: "不成立", - unknown: "无法核对", -}); /** - * 生成发布前核对的委派提示词: 工单发布之前, 由核对子代理逐条核对工单中写下的 - * 事实. 工单由编排会话凭已有记录写成, 其中的具体事实不成立时, 执行子代理要做到 - * 一半才发现, 整张工单往返一轮. + * 生成实现子代理的委派提示词. * - * @param {object} options 生成参数. - * @param {string} options.projectRoot 项目根目录. - * @param {{id: string, folder: string}} options.order 当前工单. + * @param {BriefContext} context 生成提示词需要的信息. * @returns {string} 提示词全文. */ -export function buildPrecheckBrief({ projectRoot, order }) { - const { holds, fails, unknown } = PRECHECK_VERDICTS; +export function buildExecutorBrief(context) { + const { slice, current, spec } = context; + const titles = sectionTitles(spec); + const plan = partSpec(spec, PARTS.plan).heading; + const result = partSpec(spec, PARTS.result).heading; + const isBuild = slice.kind === SLICE_KINDS.build; return [ - `# 发布前核对 ${order.id}`, + `# 实现切片 ${slice.id}: ${slice.name}`, "", - "你是核对子代理: 只读, 不写任何文件, 不改写工单, 只报告事实与证据.", + `你是实现子代理. 先运行 \`${COMMAND_NAME} ref executor\` 读完实现手册, 再读切片文件的 "${plan}" 段, 然后按手册工作.`, "", - "## 核对对象", + "## 本切片", "", - `- 工单文件: ${path.join(projectRoot, orderFilePath(order.folder, "order"))}`, + ...subjectLines(context), + ...(slice.skeleton + ? [ + "- 最小骨架: 是. 从真实入口进入, 经过计划中的每一层, 每层只做最少的事; 要能按验证方式确认它跑通了.", + ] + : []), + ...(current.round > 1 + ? [ + `- 补做: 这是第 ${current.round} 轮, 只做 "${plan}" 段 "${titles[SECTIONS.rework]}" 一节写的内容, 并更新 "${result}" 段.`, + ] + : []), "", - "## 核对要求", + "## 规则", "", - `1. 工作假设: 逐条核对工单 "${ASSUMPTIONS_SECTION}", 读代码, 配置与数据核实.`, - '2. 具体事实: 找出 "执行守则", "工作范围" 与 "验收判据" 中的每个具体事实, 逐个核对: 文件与目录路径, 函数与接口名, 命令, 数据中的编号或实例, 引用的记录中的结论, 外部资源的地址, 发布形式与许可. 判据点名某个实例时, 核对这个实例存在, 并且带有判据要求的特征.', - '3. 判据可验证: 逐条看 "验证方式" 能否照做: 命令是否存在, 需要的数据, 工具与环境是否具备.', - '4. 遗漏的决定: 列出执行中必须由用户决定, 而工单 "需要决定" 没有写的事, 例如许可条款的解释, 要改动仓库之外的文件, 几种做法的取舍.', - `5. 每条给出结论与证据 (文件路径与行号, 命令输出, 或网页地址与摘录). 结论只能是 ${holds}, ${fails}, ${unknown} 之一; ${unknown} 时写明缺少什么. ${holds} 的条目可以合并成一行, 只写条数.`, + isBuild + ? `1. 可以写业务文件, 切片文件的 "${result}" 段, 以及 ${FINDINGS_DIRECTORY}/ 下的文件. 不改切片文件的其它段与 ${NAVIGATOR_DIRECTORY}/ 下的其它文件.` + : `1. 本切片只产出结论: 只写 ${FINDINGS_DIRECTORY}/ 下的文件与切片文件的 "${result}" 段, 不改业务代码与项目配置.`, + `2. 不提交, 不推送, 不运行改变状态的插件命令 (\`${COMMAND_NAME}\` 只用 ref, status 与 check).`, + `3. 做完后填写 "${result}" 段的每个小节, 把 "${titles[SECTIONS.status]}" 改成 "${spec.doneStatus}", 然后结束.`, "", "## 交回格式", "", - `1. 核对结果: 表格, 列为 位置, 内容, 结论, 证据; 只列 ${fails} 与 ${unknown} 的条目, 最后一行写 ${holds} 的条数.`, - '2. 遗漏的决定: 逐条列出, 没有时写 "无".', + `- 做完时: 第一行写 "${HANDBACKS.done}", 其后不超过 ${SUMMARY_LINES} 行要点: 最重要的实现决定, 修正与待定标准.`, + `- 需要用户回答或亲手操作才能继续时: 先把已经查清的内容写进 "${result}" 段的 "${titles[SECTIONS.notes]}", 第一行写 "${HANDBACKS.question}" 或 "${HANDBACKS.action}", 其后逐条写问题或操作步骤, 写到用户不用追问就能照做. "${titles[SECTIONS.status]}" 保持不变.`, ].join("\n"); } /** - * 生成执行子代理的委派提示词: 手册与工单的位置, 改动范围, 交回格式. - * 细节都在执行手册与工单中. 只产出记录的工单 (运行检查与分析) 只写回执与证据文件. + * 生成验收子代理的委派提示词. * - * @param {object} options 生成参数. - * @param {string} options.projectRoot 项目根目录. - * @param {{id: string, folder: string, kind: string}} options.order 当前工单. + * @param {BriefContext & {filterLines: readonly string[]}} context 生成提示词需要的信息; filterLines 是筛选器检查的输出, 没有做时为空. * @returns {string} 提示词全文. */ -export function buildExecutorBrief({ projectRoot, order }) { - const scopeRule = isRecordOnlyOrderKind(order.kind) - ? "1. 本工单只产出记录: 只写本工单的回执与工单文件夹 artifacts/ 下的证据文件 (文档, 脚本等), 不改业务文件与 .navigator/ 下的其它文件." - : "1. 只改业务文件, 本工单的回执与工单文件夹 artifacts/ 下的证据文件, 不改 .navigator/ 下的其它文件."; +export function buildReviewBrief(context) { + const { slice, current, project, spec, filterLines } = context; + const titles = sectionTitles(spec); + const { pass, fail, unverified } = spec.verdicts; + const plan = partSpec(spec, PARTS.plan).heading; + const result = partSpec(spec, PARTS.result).heading; + const review = partSpec(spec, PARTS.review); + const isBuild = slice.kind === SLICE_KINDS.build; + const verdictColumns = /** @type {string[]} */ ( + review.sections.find((section) => section.id === SECTIONS.verdicts)?.table + ); + const requirements = [ + `${titles[SECTIONS.verdicts]}: 逐条对照 "${plan}" 段的 "${titles[SECTIONS.criteria]}"${current.round > 1 ? ` 与 "${titles[SECTIONS.rework]}"` : ""}, 每条给出结论与证据 (文件路径与行号, 或命令的原样输出). 结论只能是 ${pass}, ${fail}, ${unverified} 之一; 无法确认的写 ${unverified}, 并写明缺少什么. "${result}" 段 "${titles[SECTIONS.pending]}" 列出的标准, 核对原因是否属实: 属实时结论写 ${unverified}, 证据以 "待定: " 开头; 不属实时按实际情况下结论.`, + ...(isBuild + ? [ + `${titles[SECTIONS.wiringCheck]}: 从程序入口追到本切片的新代码, 写出调用路径经过的 文件:行号, 并与 "${result}" 段的 "${titles[SECTIONS.wiring]}" 比对; 追不到即为没有接线, 相关标准为 ${fail}.${slice.skeleton ? " 本切片是最小骨架: 另外确认入口是真实入口, 并且经过了计划中的每一层." : ""}`, + `${titles[SECTIONS.structure]}: 对照 "${plan}" 段的 "${titles[SECTIONS.modules]}" 与 ${ENGINEERING_RULES_FILE}, 逐项回答并附证据: ${STRUCTURE_CHECKS.map((check, index) => `(${index + 1}) ${check}`).join("; ")}. 任一项有问题时, 相关标准为 ${fail}.${project.organization === ORGANIZATIONS.filters ? ' 模块以筛选器为准, 按下方 "筛选器检查" 的结果判断第 (1) 项.' : ""}`, + ] + : []), + `${titles[SECTIONS.scope]}: 用 git diff ${current.baseCommit} --stat 与 git status 列出实际改动, 与 "${result}" 段的 "${titles[SECTIONS.changes]}" 比对; 列出做了 "${titles[SECTIONS.excluded]}" 的改动.${isBuild ? "" : " 本切片只产出结论, 业务代码与项目配置不应有改动."}`, + `${titles[SECTIONS.correctionCheck]}: "${result}" 段 "${titles[SECTIONS.corrections]}" 表的每一行, 核对 "实际情况" 有没有证据支持, "处理办法" 有没有改变目标, 完成标准或做了明确不做的事. 两项都没有问题的修正成立, 相关标准按修正后的事实判断; 否则相关标准为 ${fail}. 没有修正时写 "${spec.none}".`, + verificationRequirement(context, titles), + ...(isBuild + ? [] + : [ + `结论核对: ${FINDINGS_DIRECTORY}/ 下本切片写的每条结论, 是否写明宿主或依赖的版本, 位置, 验证方法与验证时间; 能重新读取或重新运行的, 读一遍或运行一遍, 与结论不符的, 相关标准为 ${fail}. 结果写进 "${titles[SECTIONS.verdicts]}" 的证据.`, + ]), + ]; + const handback = sectionsFor(review, slice.kind) + .filter((section) => section.id !== SECTIONS.conclusion) + .map((section) => + section.id === SECTIONS.verdicts + ? `${section.title} (表格, 列为 ${verdictColumns.join(", ")})` + : section.title, + ); return [ - `# 执行工单 ${order.id}`, + `# 验收切片 ${slice.id}: ${slice.name}`, "", - `你是执行子代理. 先运行 \`${COMMAND_NAME} ref executor\` 读完执行手册, 再读工单 ${path.join(projectRoot, orderFilePath(order.folder, "order"))}, 然后按手册中的执行流程工作.`, + "你是验收子代理: 只读, 不写任何文件, 不给修改建议, 只报告事实与证据.", "", - "## 规则", + "## 验收对象", + "", + ...subjectLines(context), + `- 项目规范: ${ENGINEERING_RULES_FILE}`, + ...(filterLines.length === 0 + ? [] + : ["", "## 筛选器检查", "", ...filterLines]), "", - scopeRule, - `2. 不提交, 不推送, 不运行改变编排状态的插件命令 (例如 \`${COMMAND_NAME} order\`).`, - `3. 做完或受阻时, 用 \`${COMMAND_NAME} template receipt\` 取骨架 (选型工单加 \`--option 2\`), 写回执 ${path.join(projectRoot, orderFilePath(order.folder, "receipt"))}, 然后结束.`, + "## 核对要求", + "", + ...requirements.map((requirement, index) => `${index + 1}. ${requirement}`), "", "## 交回格式", "", - `- 第一行: 执行状态 (完成或受阻) 与回执路径.`, - `- 其后不超过 ${EXECUTOR_SUMMARY_LINES} 行要点: 受阻时写受阻类型与需要的决定或操作; 完成时写最重要的实现决定与风险, 有待定判据时写明是哪几条.`, - `- 例外: 按手册 "等待操作" 一节中途交回时不写回执, 第一行写 "${PAUSED_HANDBACK}", 其后逐条写要用户做的操作.`, + `按以下小节依次交回, 每节以小节名作标题, 内容会被原样写入切片文件: ${handback.join("; ")}.`, ].join("\n"); } /** - * 生成验收提示词中的接线核查项: 选型工单与只产出记录的工单不改动程序代码, 不做本项. + * 生成两种提示词共用的切片信息. * - * @param {string} kind 工单类型. - * @returns {string} 核查项全文. + * @param {BriefContext} context 生成提示词需要的信息. + * @returns {string[]} 各行. */ -function wiringCheckItem(kind) { - if (kind === SELECTION_ORDER_KIND) { - return '3. 接线核查: 选型工单不改动程序代码, 不做本项, 交回时写 "不适用".'; - } - return isRecordOnlyOrderKind(kind) - ? '3. 接线核查: 运行检查与分析工单只产出记录, 不改动程序代码, 不做本项, 交回时写 "不适用". 改为在 "范围核查" 中列出本工单文件夹之外被改动的已入库文件.' - : "3. 接线核查: 从程序入口追到本工单的新代码, 写出调用链经过的文件路径; 追不到即为未接线."; +function subjectLines({ projectRoot, slice, current, project }) { + const verification = /** @type {{mode: string, commands: string[]}} */ ( + project.verification + ); + return [ + `- 切片文件: ${path.join(projectRoot, /** @type {string} */ (slice.file))}`, + `- 种类: ${SLICE_KIND_LABELS[slice.kind]}`, + `- 轮次: ${current.round}`, + `- 基准提交: ${current.baseCommit}`, + `- 模块组织方式: ${ORGANIZATION_LABELS[/** @type {string} */ (project.organization)]}`, + `- 验证方式: ${VERIFICATION_LABELS[verification.mode]}${verification.commands.length === 0 ? "" : `, 命令: ${verification.commands.join("; ")}`}`, + ]; } /** - * 生成验收委派提示词. 交回表格的列取自验收记录的规格, 结论只用三种固定取值. + * 生成验收提示词中关于验证运行的一条要求. * - * @param {object} options 生成参数. - * @param {string} options.projectRoot 项目根目录. - * @param {{id: string, folder: string, baseCommit: string, kind: string}} options.order 当前工单. - * @param {readonly string[]} options.checkCommands 本工单要运行的代码检查命令. - * @param {import("./spec.mjs").TableSpec} options.criteriaTable 验收记录中判据核对表的规格. - * @returns {string} 提示词全文. + * @param {BriefContext} context 生成提示词需要的信息. + * @param {Record} titles 小节编号到标题的映射. + * @returns {string} 要求全文. */ -export function buildReviewBrief({ - projectRoot, - order, - checkCommands, - criteriaTable, -}) { - const checks = checkCommands.length === 0 ? "无" : checkCommands.join("; "); - const { pass, fail, unverified } = VERDICTS; - const verdicts = Object.values(VERDICTS).join(", "); - return [ - `# 验收委派 ${order.id}`, - "", - "你是验收子代理: 只读, 不写任何文件, 不给修改建议, 只报告事实与证据.", - "", - "## 验收对象", - "", - `- 工单文件: ${path.join(projectRoot, orderFilePath(order.folder, "order"))}`, - `- 回执文件: ${path.join(projectRoot, orderFilePath(order.folder, "receipt"))}`, - `- 证据文件目录: ${path.join(projectRoot, orderArtifactsPath(order.folder))}`, - `- 基准提交: ${order.baseCommit}`, - `- 工单类型: ${order.kind}`, - `- 代码检查: ${checks}`, - "", - "## 核对要求", - "", - `1. 逐条对照工单的 "验收判据", 每条给出结论与证据 (文件路径与行号, 或命令输出). 结论只能是 ${verdicts} 之一, 不用其它说法; 无法确认的写 ${unverified}, 并写明缺少什么. 回执 "偏差记录" 的 "${PENDING_CRITERIA_KEY}" 列出的判据, 核对回执给出的原因是否属实: 属实时结论写 ${unverified}, 证据写 "待定: " 加上原因与你核对到的事实; 不属实时按实际情况下结论.`, - `2. 回执一致: 用 git diff ${order.baseCommit} --stat 与回执的 "改动清单" 比对, 列出不一致之处.`, - wiringCheckItem(order.kind), - '4. 范围核查: 列出工单 "工作范围" 之外的改动.', - "5. 测试改动: 检查是否删除, 放宽或跳过了已有测试, 是否放宽了代码检查的规则或加了忽略标记.", - '6. 测试运行: 运行 "代码检查" 中的命令, 以及回执 "测试记录" 中 "测试命令" 列出的命令, 原样记录结果; "外部影响" 中列出的命令 (需要真实账号, 会产生费用或外部副作用) 不运行, 按回执中的输出判断, 证据写明 "依据回执输出".', - `7. 取证核对: 判据要用 MCP 工具核对时, 按回执 "取证记录" 给出的工具与位置查看, 只做查询类的只读调用, 不发请求, 不清空或修改记录. 证据写工具名, 参数与返回内容摘录; 工具不可用或按位置找不到记录时, 该判据为 ${unverified}.`, - '8. 证据文件: "证据文件目录" 存在时, 其中是执行子代理留下的产物, 例如抓包原文与复现脚本. 回执或判据引用这些文件时, 按路径读取核对, 证据写文件路径与行号. 目录不存在时跳过本项.', - `9. 修正核对: 回执 "${CORRECTIONS_SECTION}" 表中的每一行, 是执行子代理发现工单写的细节与实际不符, 按实际做了. 逐行核对: "实际情况" 有没有证据支持; "处理办法" 有没有改变工单的任务目标, 超出 "工作范围", 做了 "明确不做" 的事, 或替用户做了 "需要决定" 的事. 两项都没有问题的修正成立: 判据引用了被修正的细节时, 按修正后的事实判断. 证据不支持或越界的修正不成立: 相关判据为 ${fail}. 表为空时写 "无修正".`, - `10. 规范核查: 对照 ${ENGINEERING_RULES_FILE} 检查文档注释, 函数体内注释, 模块边界 (回执 "模块划分" 是否与实际改动一致, 新功能是否迫使多个已有模块改动内部实现), 单一数据源, 魔法值.`, - `11. 选型工单另查: 核对范围只是工单列出的每条需求, 判据核对表中每条需求单独一行. 对照回执 "${REQUIREMENT_SECTION}" 表: 依据是源码的, 运行\`${EVIDENCE_COMMAND}\` 从原仓库按版本取回 "${EVIDENCE_SECTION}" 表引用的源码行, 判断是否支持该需求的结论; 依据是测试的, 运行回执写明的测试命令, 按输出判断. 依据支持结论的为 ${pass}, 不支持的为 ${fail}, 取不到源码或测试无法运行的为 ${unverified}; 需求在表中没有对应行的也为 ${unverified}. 回执其它内容不核对, 不用其它方式获取源码.`, - "", - "## 交回格式", - "", - `1. 判据核对: 表格, 列为 ${criteriaTable.columns.join(", ")}.`, - "2. 专项检查: 依次写 回执一致, 接线核查, 范围核查, 测试改动, 测试运行, 修正核对, 规范核查, 每项一行结论加证据; 修正核对逐条写每项修正是否成立.", - '3. 人工验收: 需要用户亲手运行才能确认的判据, 写成逐步操作, 命令原样取自回执; 回执中没有这样的命令时写 "无".', - ].join("\n"); +function verificationRequirement({ project, spec }, titles) { + const verification = /** @type {{mode: string, commands: string[]}} */ ( + project.verification + ); + const manual = `需要用户亲手运行或操作才能确认的标准, 结论写 ${spec.verdicts.unverified}, 并在 "${titles[SECTIONS.manual]}" 中写成逐步操作, 命令原样取自 "${titles[SECTIONS.tests]}"; 没有时写 "${spec.none}"`; + return verification.mode === AUTO_VERIFICATION + ? `验证运行: 运行 "验证方式" 中的命令与 "${titles[SECTIONS.tests]}" 中的命令, 原样记录结果; 检查是否删除, 放宽或跳过了已有测试. 需要真实账号, 会产生费用或外部副作用的命令不运行. ${manual}.` + : `${titles[SECTIONS.manual]}: 本项目不靠自动测试验证, 不构建, 不运行程序. ${manual}.`; +} + +/** + * 取全部小节的标题. + * + * @param {import("./slice-file.mjs").SliceSpec} spec 切片文件规格. + * @returns {Record} 小节编号到标题的映射. + */ +function sectionTitles(spec) { + return Object.fromEntries( + spec.parts + .flatMap((part) => part.sections) + .map((section) => [section.id, section.title]), + ); } diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/code-checks.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/code-checks.mjs deleted file mode 100644 index 7aaff49..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/code-checks.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @file 项目的代码检查命令: 登记, 生成工单中的固定判据, 发布前核对. - * - * 模块化与代码质量的机械检查交给各语言成熟的检查工具 (例如函数长度与复杂度规则), - * 插件不自己解析代码. 选型阶段确定检查命令并登记后, 每张会改动代码的工单都带一条 - * 固定判据 "代码检查通过", 验收子代理运行这些检查命令. - */ - -import { findSection } from "./layout.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 需要代码检查判据的工单类型: 会改动代码的工单. - * @type {readonly string[]} - */ -export const CODE_CHECKED_ORDER_KINDS = Object.freeze([ - "implementation", - "fix", -]); - -/** - * 工单中放判据的节, 与规格 files.order 中的节标题一致. - * @type {string} - */ -export const CRITERIA_SECTION = "验收判据"; - -/** - * @typedef {object} CodeChecks 项目登记的代码检查. - * @property {string[]} commands 检查命令, 每条都要能在仓库根目录直接运行. - * @property {string | null} reason 没有检查命令时的原因; 有命令时为 null. - */ - -/** - * 判断某种工单是否需要代码检查判据. - * - * @param {string} kind 工单类型. - * @returns {boolean} 需要时返回 true. - */ -export function requiresCodeCheck(kind) { - return CODE_CHECKED_ORDER_KINDS.includes(kind); -} - -/** - * 生成代码检查判据的原文. 工单骨架预填这一条, 发布时按原文核对. - * - * @param {readonly string[]} commands 检查命令. - * @returns {string} 判据原文. - */ -export function codeCheckCriterion(commands) { - return `代码检查通过: 运行 ${commands.map((command) => `\`${command}\``).join(", ")}, 没有报错.`; -} - -/** - * 登记代码检查命令, 覆盖此前的登记. 没有命令时必须写明原因. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {{commands: string[], reason: string}} draft 草稿: 检查命令与原因. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 没有命令也没有原因时. - */ -export function registerCodeChecks(state, { commands, reason }) { - const cleaned = [ - ...new Set(commands.map((command) => command.trim()).filter(Boolean)), - ]; - const trimmedReason = reason.trim(); - if (cleaned.length === 0 && trimmedReason === "") { - throw new WorkflowError( - '没有代码检查命令时, 草稿中必须用 "reason" 写明原因, 并经用户同意.', - ); - } - return { - ...state, - codeChecks: { - commands: cleaned, - reason: cleaned.length === 0 ? trimmedReason : null, - }, - lastAction: `登记代码检查命令 ${cleaned.length} 条`, - }; -} - -/** - * 描述已登记的代码检查, 用于命令输出. - * - * @param {CodeChecks | null} codeChecks 已登记的代码检查. - * @returns {string} 描述. - */ -export function describeCodeChecks(codeChecks) { - if (codeChecks === null) { - return "未登记"; - } - return codeChecks.commands.length === 0 - ? `无, 原因: ${codeChecks.reason}` - : codeChecks.commands.join("; "); -} - -/** - * 当前工单要运行的代码检查命令: 只有会改动代码的工单需要. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @returns {string[]} 检查命令; 不需要或未登记时为空. - */ -export function orderCheckCommands(state) { - const order = state.order; - return order !== null && requiresCodeCheck(order.kind) - ? (state.codeChecks?.commands ?? []) - : []; -} - -/** - * 工单骨架中预填的内容: 需要代码检查且已登记命令时, 验收判据第一条是检查判据. - * - * @param {import("./state.mjs").NavigatorState | undefined} state 当前状态. - * @returns {Readonly>} 节标题到预填行的映射. - */ -export function orderSkeletonPrefills(state) { - const order = state?.order ?? null; - const commands = state?.codeChecks?.commands ?? []; - if ( - order === null || - !requiresCodeCheck(order.kind) || - commands.length === 0 - ) { - return {}; - } - return { [CRITERIA_SECTION]: [`1. ${codeCheckCriterion(commands)}`] }; -} - -/** - * 找出不能新建某种工单的原因: 需要代码检查的工单, 项目必须已登记检查命令. - * 新建时就拒绝, 不等写完工单到审阅时才发现. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} kind 要新建的工单类型. - * @returns {string | undefined} 不能新建的原因; 可以新建时为 undefined. - */ -export function creationBlocker(state, kind) { - if (!requiresCodeCheck(kind) || (state.codeChecks ?? null) !== null) { - return undefined; - } - return unregisteredChecksMessage(`${kind} 工单会改动代码`); -} - -/** - * 找出工单不能发布的原因: 需要代码检查的工单, 项目必须已登记检查命令, - * 且验收判据中含有检查判据的原文. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string | undefined} orderText 工单文件内容; 文件不存在时为 undefined. - * @returns {string | undefined} 不能发布的原因; 可以发布时为 undefined. - */ -export function issueBlocker(state, orderText) { - const order = state.order; - if (order === null || !requiresCodeCheck(order.kind)) { - return undefined; - } - const codeChecks = state.codeChecks ?? null; - if (codeChecks === null) { - return unregisteredChecksMessage(`工单 ${order.id} 会改动代码`); - } - if (codeChecks.commands.length === 0) { - return undefined; - } - const criterion = codeCheckCriterion(codeChecks.commands); - return criteriaText(orderText ?? "").includes(criterion) - ? undefined - : `工单 ${order.id} 的 "${CRITERIA_SECTION}" 缺少代码检查判据, 请原样加入: ${criterion}`; -} - -/** - * 项目还没有登记检查命令时的说明与补救办法. - * - * @param {string} subject 说明开头, 指出哪张工单或哪种工单会改动代码. - * @returns {string} 说明. - */ -function unregisteredChecksMessage(subject) { - return `${subject}, 但项目还没有登记代码检查命令. 先确定检查命令 (选型阶段由执行子代理选定并配置, 含函数长度与复杂度规则), 写草稿 {"commands": ["命令"]} 后运行 codecheck set --from <草稿>; 确实没有合适的工具时, 经用户同意写 {"commands": [], "reason": "原因"} 登记.`; -} - -/** - * 取出工单文件中验收判据一节的全部文字. - * - * @param {string} orderText 工单文件内容. - * @returns {string} 该节文字; 没有该节时为空字符串. - */ -function criteriaText(orderText) { - const section = findSection(orderText, CRITERIA_SECTION); - return (section?.items ?? []) - .map((item) => (item.kind === "text" ? item.text : "")) - .join("\n"); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/decisions.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/decisions.mjs new file mode 100644 index 0000000..5b3ed26 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/decisions.mjs @@ -0,0 +1,87 @@ +/** + * @file 决策记录: 登记决策, 生成追加到 `decisions.md` 的条目. + * + * 决策的编号与取代关系记在状态文件中, 条目正文由脚本追加到决策记录, 两边不会 + * 对不上. 已有条目不改写; 被取代的决策在新条目中写明. + */ + +import { allocateNumber } from "./numbering.mjs"; +import { requireText } from "./validation.mjs"; +import { WorkflowError } from "./workflow-error.mjs"; + +/** + * 决策记录文件开头的内容. + * @type {string} + */ +export const DECISIONS_HEADER = [ + "# 决策记录", + "", + "本文件由 project-navigator 的 decision add 命令追加, 已有条目不改写.", + "", +].join("\n"); + +/** + * ISO 时间中日期部分的长度. + * @type {number} + */ +const DATE_LENGTH = 10; + +/** + * @typedef {object} DecisionDraft 决策草稿. + * @property {string} title 一句话标题. + * @property {string} decision 决定了什么. + * @property {string} reason 为什么. + * @property {string} rejected 放弃了什么. + * @property {string} [supersedes] 被这条决策取代的决策编号. + */ + +/** + * 登记一条决策. + * + * @param {import("./state.mjs").NavigatorState} state 状态. + * @param {DecisionDraft} draft 决策草稿. + * @param {string} now 当前时间, ISO 格式. + * @returns {{state: import("./state.mjs").NavigatorState, entry: string}} 新状态与追加到决策记录的条目. + * @throws {WorkflowError} 草稿缺少字段, 或被取代的决策不存在时. + */ +export function addDecision(state, draft, now) { + const title = requireText(draft?.title, "决策标题 (title)"); + const fields = [ + ["日期", now.slice(0, DATE_LENGTH)], + ["决定", requireText(draft.decision, "决定 (decision)")], + ["原因", requireText(draft.reason, "原因 (reason)")], + ["放弃", requireText(draft.rejected, "放弃 (rejected)")], + ]; + if ( + draft.supersedes !== undefined && + !state.decisions.some((decision) => decision.id === draft.supersedes) + ) { + throw new WorkflowError(`被取代的决策 ${draft.supersedes} 不存在.`); + } + const { id, next } = allocateNumber(state.next, "decision"); + const entry = [ + `## 决策 ${id}: ${title}`, + "", + ...fields.map(([key, value]) => `- ${key}: ${value}`), + ...(draft.supersedes === undefined + ? [] + : [`- 取代: 决策 ${draft.supersedes}`]), + "", + ].join("\n"); + return { + state: { + ...state, + decisions: [ + ...state.decisions.map((decision) => + decision.id === draft.supersedes + ? { ...decision, supersededBy: id } + : decision, + ), + { id, title, supersededBy: null }, + ], + next, + lastAction: `登记决策 ${id}`, + }, + entry, + }; +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/diagnostics.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/diagnostics.mjs deleted file mode 100644 index af20a67..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/diagnostics.mjs +++ /dev/null @@ -1,298 +0,0 @@ -/** - * @file 技能异常的发现与记录: 脚本自己能判断的技能问题记在运行期登记目录中, - * 供 report 命令生成问题报告. - * - * 记录的是 "技能本身可能有问题" 的迹象, 不是流程规则的正常拒绝: - * - 命令或 hook 抛出了内部错误 (类型错误, 文件系统错误等), 一定是技能的问题. - * - 状态不自洽 (例如工单已发布, 工单文件却不存在). - * - 上一次 "下一动作" 要求运行的命令被流程规则拒绝, 说明两处规则对不上. - * - 同一个下一动作连续给出多次, 状态却没有变化. - * - * 记录失败不能影响命令与 hook 本身, 调用方要自行兜住这里抛出的错误. - */ - -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; - -import { writeJsonAtomically } from "./atomic-file.mjs"; -import { - COMMAND_NAME, - DIAGNOSTICS_FILE, - GUIDANCE_FILE, - registryDirectory, -} from "./paths.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 异常的种类. - * @type {Readonly<{internalError: string, hookError: string, invalidState: string, refused: string, stalled: string}>} - */ -export const EVENT_KINDS = Object.freeze({ - internalError: "internal-error", - hookError: "hook-error", - invalidState: "invalid-state", - refused: "refused-after-guidance", - stalled: "stalled", -}); - -/** - * 各种异常在报告与输出中的中文名称. - * @type {Readonly>} - */ -export const EVENT_LABELS = Object.freeze({ - [EVENT_KINDS.internalError]: "命令内部错误", - [EVENT_KINDS.hookError]: "hook 内部错误", - [EVENT_KINDS.invalidState]: "状态不自洽", - [EVENT_KINDS.refused]: "下一动作要求的命令被拒绝", - [EVENT_KINDS.stalled]: "下一动作重复而状态没有变化", -}); - -/** - * 出现后流程无法继续的异常种类: 命令本身跑不了, 或状态已经不可信. - * @type {readonly string[]} - */ -export const BLOCKING_EVENT_KINDS = Object.freeze([ - EVENT_KINDS.internalError, - EVENT_KINDS.invalidState, -]); - -/** - * 发现技能异常后的上报做法, 命令输出, status 与提醒共用. - * @type {string} - */ -export const REPORT_STEP = `把遇到的情况写成草稿 {"doing", "expected", "actual", "reproduce"} (只写事实, 不写原因推测与处理建议), 运行 ${COMMAND_NAME} report --from <草稿>, 再按输出回复 "问题报告"`; - -/** - * 保留的异常记录条数. - * @type {number} - */ -const EVENT_LIMIT = 30; - -/** - * 同一个下一动作在状态不变时连续给出多少次算原地打转. - * @type {number} - */ -const STALL_THRESHOLD = 4; - -/** - * 等待外部结果时记下的连续次数: 等待中的重复不计数. - * @type {number} - */ -const WAITING_COUNT = 0; - -/** - * 记录中保留的调用栈行数. - * @type {number} - */ -const STACK_LINE_LIMIT = 8; - -/** - * 属于输入问题而不是技能问题的 Node.js 错误码前缀: 命令行参数写错. - * @type {string} - */ -const ARGUMENT_ERROR_PREFIX = "ERR_PARSE_ARGS"; - -/** - * @typedef {object} DiagnosticEvent 一条异常记录. - * @property {string} kind 种类, 取值见 EVENT_KINDS. - * @property {string} source 来源: 命令行 (例如 "order set issued") 或 hook 事件名. - * @property {string} message 错误信息或问题描述. - * @property {string[]} stack 调用栈的前几行; 没有时为空. - * @property {string} at 发生时间, ISO 格式. - * @property {string} [reportId] 已写入的问题报告编号; 还没有上报时省略. - */ - -/** - * @typedef {object} GuidanceRecord 最近一次给出的下一动作. - * @property {string} action 下一动作全文. - * @property {number} revision 给出时状态文件的版本号. - * @property {number} count 状态不变时连续给出同一动作的次数. - */ - -/** - * 判断一个错误是否是技能脚本的内部错误. 流程规则的拒绝 (WorkflowError 与脚本 - * 主动抛出的普通 Error) 和命令行参数写错都不算. - * - * @param {unknown} error 捕获到的错误. - * @returns {boolean} 是内部错误时返回 true. - */ -export function isInternalError(error) { - if (!(error instanceof Error) || error instanceof WorkflowError) { - return false; - } - const code = /** @type {{code?: unknown}} */ (error).code; - if (typeof code === "string") { - return !code.startsWith(ARGUMENT_ERROR_PREFIX); - } - return ( - error instanceof TypeError || - error instanceof ReferenceError || - error instanceof RangeError - ); -} - -/** - * 取出错误调用栈的前几行, 不含第一行的错误信息. - * - * @param {unknown} error 捕获到的错误. - * @returns {string[]} 调用栈各行, 已去掉首尾空白. - */ -export function stackLines(error) { - const stack = error instanceof Error ? (error.stack ?? "") : ""; - return stack - .split("\n") - .slice(1, STACK_LINE_LIMIT + 1) - .map((line) => line.trim()) - .filter((line) => line !== ""); -} - -/** - * 读取全部异常记录, 按发生的先后排列. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {DiagnosticEvent[]} 异常记录; 没有时为空. - */ -export function readEvents(worktreeRoot) { - return readJson(worktreeRoot, DIAGNOSTICS_FILE) ?? []; -} - -/** - * 读取还没有写进问题报告的异常记录. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {DiagnosticEvent[]} 未上报的异常记录. - */ -export function unreportedEvents(worktreeRoot) { - return readEvents(worktreeRoot).filter( - (event) => event.reportId === undefined, - ); -} - -/** - * 记下一条异常. 已有种类, 来源与信息都相同的未上报记录时不重复记. - * - * @param {string} worktreeRoot 工作区根目录. - * @param {Omit} event 异常. - * @returns {boolean} 本次新记下了异常时返回 true. - */ -export function recordEvent(worktreeRoot, event) { - const events = readEvents(worktreeRoot); - const isKnown = events.some( - (entry) => - entry.reportId === undefined && - entry.kind === event.kind && - entry.source === event.source && - entry.message === event.message, - ); - if (isKnown) { - return false; - } - writeJson( - worktreeRoot, - DIAGNOSTICS_FILE, - [...events, event].slice(-EVENT_LIMIT), - ); - return true; -} - -/** - * 把未上报的异常标为已写进某份问题报告. - * - * @param {string} worktreeRoot 工作区根目录. - * @param {string} reportId 问题报告编号. - * @returns {void} - */ -export function markEventsReported(worktreeRoot, reportId) { - writeJson( - worktreeRoot, - DIAGNOSTICS_FILE, - readEvents(worktreeRoot).map((event) => - event.reportId === undefined ? { ...event, reportId } : event, - ), - ); -} - -/** - * 读取最近一次给出的下一动作. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {GuidanceRecord | undefined} 记录; 还没有时为 undefined. - */ -export function readGuidance(worktreeRoot) { - return readJson(worktreeRoot, GUIDANCE_FILE); -} - -/** - * 记下这次给出的下一动作, 并发现原地打转: 状态不变时同一个动作连续给出达到 - * 阈值的那一次, 记一条异常. 正在等待 (例如等执行子代理) 时重复是正常的, 不计数. - * - * @param {string} worktreeRoot 工作区根目录. - * @param {object} options 参数. - * @param {string} options.action 下一动作全文. - * @param {number} options.revision 状态文件的版本号. - * @param {boolean} options.isWaiting 当前是否在等待外部结果. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {void} - */ -export function trackGuidance( - worktreeRoot, - { action, revision, isWaiting, now }, -) { - const previous = readGuidance(worktreeRoot); - const isRepeat = - previous !== undefined && - previous.action === action && - previous.revision === revision; - const count = isWaiting - ? WAITING_COUNT - : (isRepeat ? previous.count : WAITING_COUNT) + 1; - writeJson(worktreeRoot, GUIDANCE_FILE, { action, revision, count }); - if (count === STALL_THRESHOLD) { - recordEvent(worktreeRoot, { - kind: EVENT_KINDS.stalled, - source: "status", - message: `同一个下一动作已连续给出 ${count} 次, 状态版本 ${revision} 没有变化: ${action}`, - stack: [], - at: now, - }); - } -} - -/** - * 判断一条被拒绝的命令是不是上一次下一动作要求运行的命令. - * - * @param {GuidanceRecord | undefined} guidance 最近一次给出的下一动作. - * @param {string} commandLine 命令名加位置参数, 例如 "order set issued". - * @returns {boolean} 下一动作中写了这条命令时返回 true. - */ -export function isRecommendedCommand(guidance, commandLine) { - return ( - guidance !== undefined && - commandLine.trim() !== "" && - guidance.action.includes(commandLine) - ); -} - -/** - * 读取运行期登记目录中的 JSON 文件. - * - * @param {string} worktreeRoot 工作区根目录. - * @param {string} name 文件名. - * @returns {any} 文件内容; 不存在时为 undefined. - */ -function readJson(worktreeRoot, name) { - const file = path.join(registryDirectory(worktreeRoot), name); - return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined; -} - -/** - * 原子写入运行期登记目录中的 JSON 文件. - * - * @param {string} worktreeRoot 工作区根目录. - * @param {string} name 文件名. - * @param {unknown} value 要写入的值. - * @returns {void} - */ -function writeJson(worktreeRoot, name, value) { - writeJsonAtomically(path.join(registryDirectory(worktreeRoot), name), value); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/file-checks.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/file-checks.mjs deleted file mode 100644 index 9bc8128..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/file-checks.mjs +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @file 按模板规格校验 `.navigator/` 下记录文件的结构. - * - * 由 check 命令调用: 写记录的一方 (编排会话或执行子代理) 写完后用它自查, - * 体检也用它. 写入时不拦截, 结构问题只在自查与体检中报告. - */ - -import { checkKeyLines, checkTable, splitSections } from "./layout.mjs"; -import { parseMarkdown } from "./markdown.mjs"; -import { PLACEHOLDER } from "./render.mjs"; - -/** - * @typedef {object} FileSpec 一种记录文件的规格. - * @property {string} pattern 相对于项目根目录的路径模式, `*` 匹配一段路径中的任意字符. - * @property {string} title 一级标题. - * @property {boolean} [hasProgress] 是否以 "当前进展" 一节开头. - * @property {import("./spec.mjs").SectionSpec[]} [sections] 固定的节, 按顺序. - * @property {import("./spec.mjs").SectionSpec[][]} [variants] 多种可选的固定节序列, 满足其一即可. - * @property {string} [entryPattern] 只追加记录中, 条目标题的正则. - * @property {string[]} [entryKeys] 每个条目必须依次包含的键. - * @property {import("./spec.mjs").SectionSpec[]} [closingSections] 条目之后可选的收尾节. - */ - -/** - * 查找与路径匹配的文件规格. - * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {string} relativePath 以正斜杠分隔的项目相对路径. - * @returns {FileSpec | undefined} 规格; 不受校验的文件为 undefined. - */ -export function findFileSpec(spec, relativePath) { - return Object.values(spec.files ?? {}).find((fileSpec) => - patternToRegExp(fileSpec.pattern).test(relativePath), - ); -} - -/** - * 校验一个记录文件的全文. - * - * @param {object} options 校验参数. - * @param {string} options.text 文件全文. - * @param {FileSpec} options.fileSpec 文件规格. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @returns {string[]} 问题列表; 为空表示合格. - */ -export function checkFile({ text, fileSpec, spec }) { - const items = parseMarkdown(text); - const first = items.find( - (item) => !(item.kind === "text" && item.text.trim() === ""), - ); - if ( - first?.kind !== "heading" || - first.level !== 1 || - first.text !== fileSpec.title - ) { - return [`第一行必须是一级标题 "# ${fileSpec.title}".`]; - } - const { leading, sections } = returnTrailingBlocks( - splitSections(items.slice(items.indexOf(first) + 1)), - ); - const problems = []; - const conclusionPrefix = `${spec.format.conclusionLabel}: `; - if ( - leading.some( - (item) => - !(item.kind === "text" && item.text.startsWith(conclusionPrefix)), - ) - ) { - problems.push( - `一级标题与第一个二级标题之间只能有一行 "${conclusionPrefix}...".`, - ); - } - const progress = - fileSpec.hasProgress === true - ? checkProgress(sections, spec) - : { problems: [], rest: sections }; - problems.push(...progress.problems); - problems.push(...checkBody(progress.rest, fileSpec)); - if ( - sections.some((section) => - section.items.some((item) => item.kind === "block"), - ) - ) { - problems.push("节正文中不能出现代码块, 不能贴代码."); - } - if (text.includes(PLACEHOLDER)) { - problems.push(`文件中仍有未填写的 "${PLACEHOLDER}" 标记.`); - } - return problems; -} - -/** - * 记录文件没有结尾代码块; 切节时取出的末尾代码块放回最后一节 (没有节时放回 - * 标题之后的内容), 由节的规则判断是否允许. - * - * @param {import("./layout.mjs").SplitResult} split 切节结果. - * @returns {import("./layout.mjs").SplitResult} 调整后的切节结果. - */ -function returnTrailingBlocks(split) { - const { leading, sections, trailing } = split; - if (trailing.length === 0) { - return split; - } - if (sections.length === 0) { - return { leading: [...leading, ...trailing], sections, trailing: [] }; - } - const last = sections[sections.length - 1]; - return { - leading, - sections: [ - ...sections.slice(0, -1), - { ...last, items: [...last.items, ...trailing] }, - ], - trailing: [], - }; -} - -/** - * 校验开头的当前进展一节. - * - * @param {import("./layout.mjs").Section[]} sections 全部节. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {{problems: string[], rest: import("./layout.mjs").Section[]}} 问题列表与当前进展之后的节. - */ -function checkProgress(sections, spec) { - const first = sections[0]; - if (first?.title !== spec.format.progressTitle) { - return { - problems: [`第一节必须是 "## ${spec.format.progressTitle}".`], - rest: sections, - }; - } - return { - problems: checkKeyLines(first, spec.format.progressKeys), - rest: sections.slice(1), - }; -} - -/** - * 校验进展之后的正文各节: 固定节 (或可选序列之一), 以及只追加的条目. - * 有多种可选序列时, 优先报告标题对得上的那一种的问题. - * - * @param {import("./layout.mjs").Section[]} sections 正文各节. - * @param {FileSpec} fileSpec 文件规格. - * @returns {string[]} 问题列表. - */ -function checkBody(sections, fileSpec) { - const candidates = fileSpec.variants ?? [fileSpec.sections ?? []]; - const results = candidates.map((fixed) => - checkWithFixedSections(sections, fixed, fileSpec), - ); - const chosen = - results.find((result) => result.problems.length === 0) ?? - results.find((result) => result.isTitleMatch) ?? - results[0]; - return chosen.problems; -} - -/** - * 按一种固定节序列校验正文. - * - * @param {import("./layout.mjs").Section[]} sections 正文各节. - * @param {import("./spec.mjs").SectionSpec[]} fixed 固定节序列. - * @param {FileSpec} fileSpec 文件规格. - * @returns {{isTitleMatch: boolean, problems: string[]}} 标题是否与该序列一致, 以及问题列表. - */ -function checkWithFixedSections(sections, fixed, fileSpec) { - const head = sections.slice(0, fixed.length); - const expectedTitles = fixed.map((section) => section.title); - if ( - head.map((section) => section.title).join("|") !== expectedTitles.join("|") - ) { - return { - isTitleMatch: false, - problems: [ - `二级标题应依次为 ${expectedTitles.map((title) => `"${title}"`).join(", ") || "无固定节"}.`, - ], - }; - } - const problems = fixed.flatMap((sectionSpec, index) => - checkFixedSection(head[index], sectionSpec), - ); - problems.push(...checkEntries(sections.slice(fixed.length), fileSpec)); - return { isTitleMatch: true, problems }; -} - -/** - * 按节规格校验一个固定节的内容: 键值行或表格. - * - * @param {import("./layout.mjs").Section} section 节. - * @param {import("./spec.mjs").SectionSpec} sectionSpec 节规格. - * @returns {string[]} 问题列表. - */ -function checkFixedSection(section, sectionSpec) { - if (sectionSpec.keys !== undefined) { - return checkKeyLines(section, sectionSpec.keys); - } - if (sectionSpec.table !== undefined) { - return checkTable(section, sectionSpec.table); - } - return []; -} - -/** - * 校验固定节之后的条目与收尾节. - * - * @param {import("./layout.mjs").Section[]} rest 固定节之后的节. - * @param {FileSpec} fileSpec 文件规格. - * @returns {string[]} 问题列表. - */ -function checkEntries(rest, fileSpec) { - const closing = fileSpec.closingSections ?? []; - const closingTitles = closing.map((section) => section.title); - const entryPattern = - fileSpec.entryPattern === undefined - ? undefined - : new RegExp(fileSpec.entryPattern, "u"); - const problems = []; - let isClosing = false; - for (const section of rest) { - const closingIndex = closingTitles.indexOf(section.title); - if (closingIndex >= 0) { - isClosing = true; - const keys = closing[closingIndex].keys; - if (keys !== undefined) { - problems.push(...checkKeyLines(section, keys)); - } - } else if ( - entryPattern === undefined || - isClosing || - !entryPattern.test(section.title) - ) { - problems.push(`多出了不符合规格的二级标题 "## ${section.title}".`); - } else { - problems.push(...checkEntry(section, fileSpec)); - } - } - return problems; -} - -/** - * 校验一个条目的键值行. - * - * @param {import("./layout.mjs").Section} section 条目所在的节. - * @param {FileSpec} fileSpec 文件规格. - * @returns {string[]} 问题列表. - */ -function checkEntry(section, fileSpec) { - return fileSpec.entryKeys === undefined - ? [] - : checkKeyLines(section, fileSpec.entryKeys); -} - -/** - * 把路径模式转换为正则: `*` 匹配一段路径中的任意字符, 其余字符按原样匹配. - * - * @param {string} pattern 路径模式. - * @returns {RegExp} 正则. - */ -function patternToRegExp(pattern) { - const escaped = pattern - .split("*") - .map((part) => part.replace(/[.+?^${}()|[\]\\]/gu, "\\$&")) - .join("[^/]*"); - return new RegExp(`^${escaped}$`, "iu"); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/file-guide.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/file-guide.mjs deleted file mode 100644 index 38b9569..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/file-guide.mjs +++ /dev/null @@ -1,118 +0,0 @@ -/** - * @file template 命令在骨架之前给出的填写要求: 标题与键名的保留, 键值节与表格节的 - * 写法, 代码块, 以及写完后的自查命令. - * - * 写入时不做结构校验; 要求在填写的时刻给出, 写完后用 check 命令自查. 内容全部 - * 由文件规格生成, 与自查使用同一份规格. - */ - -import { COMMAND_NAME } from "./paths.mjs"; -import { PLACEHOLDER } from "./render.mjs"; -import { wrapGuide } from "./reply-guide.mjs"; - -/** - * 写完记录文件后自查结构的命令前缀, 其后接文件路径. - * @type {string} - */ -const SELF_CHECK_COMMAND = `${COMMAND_NAME} check`; - -/** - * 键值节的写法. 读取记录的脚本按键值行取值, 模型常在键下面接表格或列表; - * 参考文档与执行手册也引用这一句. - * @type {string} - */ -export const KEY_SECTION_RULE = - '键值节中每个键只占一行 "- 键名: 值", 值写在同一行, 节中不加其它行, 列表, 表格或段落'; - -/** - * 生成记录文件的填写要求, 以 "骨架:" 与一个空行结束, 其后紧接骨架. - * - * @param {object} options 生成参数. - * @param {import("./file-checks.mjs").FileSpec} options.fileSpec 文件规格. - * @param {readonly import("./spec.mjs").SectionSpec[]} options.sections 骨架使用的固定节序列. - * @returns {string[]} 各行. - */ -export function renderFileGuide({ fileSpec, sections }) { - return wrapGuide([ - `- 文件从下面的 "# ${fileSpec.title}" 写起. 只替换 "${PLACEHOLDER}", 标题与键名原样保留, 不增删二级标题.`, - ...keySectionLines(sections), - ...sections - .filter((section) => section.table !== undefined) - .map(tableSectionLine), - ...entryLines(fileSpec), - "- 文件中不放代码块, 不贴代码.", - `- 写完后运行 \`${SELF_CHECK_COMMAND} <文件路径>\` 自查结构.`, - ]); -} - -/** - * 键值节的写法, 并指出多行内容可以放在哪些没有键名的节. - * - * @param {readonly import("./spec.mjs").SectionSpec[]} sections 固定节序列. - * @returns {string[]} 各条要求; 没有键值节时为空. - */ -function keySectionLines(sections) { - const keyed = sections.filter((section) => section.keys !== undefined); - if (keyed.length === 0) { - return []; - } - const free = sections.filter( - (section) => section.keys === undefined && section.table === undefined, - ); - const overflow = - free.length === 0 - ? "" - : `, 表格与多行内容写进没有键名的节 (${free.map((section) => `"${section.title}"`).join(", ")})`; - return [ - `- ${KEY_SECTION_RULE}. 键值节: ${keyed.map((section) => `"${section.title}"`).join(", ")}. 内容多时在一行内用分号分隔${overflow}.`, - ]; -} - -/** - * 表格节的写法: 表头, 行数, 固定取值的列. - * - * @param {import("./spec.mjs").SectionSpec} section 表格节. - * @returns {string} 一条要求. - */ -function tableSectionLine(section) { - const table = /** @type {import("./spec.mjs").TableSpec} */ (section.table); - const choices = Object.entries(table.choices ?? {}).map( - ([column, values]) => `, "${column}" 只能填 ${values.join(", ")} 之一`, - ); - const rows = table.allowEmpty === true ? "没有内容时只留表头" : "至少一行"; - return `- "${section.title}" 只放一个表格, 表头依次为 ${table.columns.map((column) => `"${column}"`).join(", ")}, ${rows}, 每格都填写${choices.join("")}.`; -} - -/** - * 只追加的条目的写法; 骨架中没有条目. - * - * @param {import("./file-checks.mjs").FileSpec} fileSpec 文件规格. - * @returns {string[]} 各条要求; 没有条目时为空. - */ -function entryLines(fileSpec) { - if (fileSpec.entryPattern === undefined) { - return []; - } - const keys = - fileSpec.entryKeys === undefined - ? "" - : `, 依次写 ${fileSpec.entryKeys.map((key) => `"- ${key}: 值"`).join(", ")}, 每个键只占一行`; - return [ - `- 条目在固定节之后逐条追加, 标题匹配 \`${fileSpec.entryPattern}\`${keys}.`, - ...(fileSpec.closingSections ?? []).map(closingSectionLine), - ]; -} - -/** - * 收尾节的写法: 放在全部条目之后, 带键名时每个键只占一行. - * - * @param {import("./spec.mjs").SectionSpec} section 收尾节. - * @returns {string} 一条要求. - */ -function closingSectionLine(section) { - const keys = - section.keys === undefined - ? "" - : `, 依次写 ${section.keys.map((key) => `"- ${key}: 值"`).join(", ")}, 每个键只占一行`; - return `- 收尾节 "## ${section.title}" 放在全部条目之后${keys}.`; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/filters.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/filters.mjs new file mode 100644 index 0000000..e00236f --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/filters.mjs @@ -0,0 +1,215 @@ +/** + * @file 筛选器检查: Visual Studio 项目用虚拟筛选器分模块时, 确认每个源文件都归入 + * 了筛选器, 新增的源文件没有放进物理子目录. + * + * 只用正则解析 `.vcxproj` 与 `.vcxproj.filters` 的常见写法, 不引入 XML 库. + * 带通配符或宏的项不检查, 只统计数量. 项目目录之外的文件不检查. + */ + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { projectRelativePath } from "./paths.mjs"; +import { listAddedFiles, listTrackedFiles } from "./repo.mjs"; +import { activeSlice } from "./roadmap.mjs"; +import { ORGANIZATIONS, SLICE_KINDS } from "./state.mjs"; + +/** + * 工程文件的扩展名. + * @type {string} + */ +const PROJECT_FILE_EXTENSION = ".vcxproj"; + +/** + * 筛选器文件相对于工程文件多出的后缀. + * @type {string} + */ +const FILTERS_SUFFIX = ".filters"; + +/** + * 算作源文件的项类型. + * @type {readonly string[]} + */ +const SOURCE_ITEM_TYPES = Object.freeze([ + "ClCompile", + "ClInclude", + "ResourceCompile", + "MASM", +]); + +/** + * 项的开始标签: 类型与 Include 属性. + * @type {RegExp} + */ +const ITEM_PATTERN = new RegExp( + `<(${SOURCE_ITEM_TYPES.join("|")})\\s+Include="([^"]+)"`, + "gu", +); + +/** + * 筛选器文件中带内容的项: 类型, Include 属性与内容. + * @type {RegExp} + */ +const FILTERED_ITEM_PATTERN = new RegExp( + `<(${SOURCE_ITEM_TYPES.join("|")})\\s+Include="([^"]+)"\\s*>([\\s\\S]*?)`, + "gu", +); + +/** + * 项内容中的筛选器名. + * @type {RegExp} + */ +const FILTER_NAME_PATTERN = /\s*([^<]+?)\s*<\/Filter>/u; + +/** + * 不检查的 Include 写法: 通配符与 MSBuild 宏. + * @type {RegExp} + */ +const DYNAMIC_INCLUDE_PATTERN = /[*?]|\$\(|%\(/u; + +/** + * 路径中的分隔符. + * @type {RegExp} + */ +const SEPARATOR_PATTERN = /[\\/]/u; + +/** + * @typedef {object} FilterReport 一次筛选器检查的结果. + * @property {number} projectCount 检查的工程文件数. + * @property {number} itemCount 检查的源文件项数. + * @property {number} skippedCount 带通配符或宏, 没有检查的项数. + * @property {string[]} problems 发现的问题. + */ + +/** + * 检查若干工程文件. + * + * @param {object} options 参数. + * @param {string} options.projectRoot 项目根目录. + * @param {readonly string[]} options.projectFiles 工程文件的项目相对路径. + * @param {readonly string[]} options.addedFiles 本切片新增文件的项目相对路径. + * @returns {FilterReport} 检查结果. + */ +export function checkFilters({ projectRoot, projectFiles, addedFiles }) { + const added = new Set(addedFiles.map((file) => file.toLowerCase())); + const reports = projectFiles.map((projectFile) => + checkProjectFile({ projectRoot, projectFile, added }), + ); + return { + projectCount: projectFiles.length, + itemCount: reports.reduce((sum, report) => sum + report.itemCount, 0), + skippedCount: reports.reduce((sum, report) => sum + report.skippedCount, 0), + problems: reports.flatMap((report) => report.problems), + }; +} + +/** + * 把检查结果写成输出行. + * + * @param {FilterReport} report 检查结果. + * @returns {string[]} 输出各行. + */ +export function describeFilterReport(report) { + if (report.projectCount === 0) { + return [`- 筛选器检查: 没有找到 ${PROJECT_FILE_EXTENSION} 文件`]; + } + const skipped = + report.skippedCount === 0 + ? "" + : `, 另有 ${report.skippedCount} 项带通配符或宏, 没有检查`; + return [ + `- 筛选器检查: ${report.projectCount} 个工程文件, ${report.itemCount} 个源文件项${skipped}, ${report.problems.length === 0 ? "没有问题" : `${report.problems.length} 个问题`}`, + ...report.problems.map((problem) => ` - ${problem}`), + ]; +} + +/** + * 对项目运行筛选器检查并写成输出行. 只在模块用筛选器表达, 且进行中的切片不是 + * 只产出结论时检查; 新增的文件从切片开工时的提交算起. + * + * @param {string} projectRoot 项目根目录. + * @param {import("./state.mjs").NavigatorState} state 当前状态. + * @returns {string[]} 输出各行; 不需要检查时为空. + */ +export function runFilterCheck(projectRoot, state) { + const slice = activeSlice(state)?.slice; + if ( + state.project.organization !== ORGANIZATIONS.filters || + slice?.kind === SLICE_KINDS.findings + ) { + return []; + } + const baseCommit = state.current?.baseCommit ?? null; + const addedFiles = + baseCommit === null ? [] : listAddedFiles(projectRoot, baseCommit); + const projectFiles = [ + ...new Set([...listTrackedFiles(projectRoot), ...addedFiles]), + ].filter((file) => file.toLowerCase().endsWith(PROJECT_FILE_EXTENSION)); + return describeFilterReport( + checkFilters({ projectRoot, projectFiles, addedFiles }), + ); +} + +/** + * 检查一个工程文件. + * + * @param {object} options 参数. + * @param {string} options.projectRoot 项目根目录. + * @param {string} options.projectFile 工程文件的项目相对路径. + * @param {ReadonlySet} options.added 本切片新增文件的项目相对路径, 已转小写. + * @returns {{itemCount: number, skippedCount: number, problems: string[]}} 检查结果. + */ +function checkProjectFile({ projectRoot, projectFile, added }) { + const absolute = path.join(projectRoot, projectFile); + const includes = [ + ...readFileSync(absolute, "utf8").matchAll(ITEM_PATTERN), + ].map((match) => match[2]); + const filtersFile = `${absolute}${FILTERS_SUFFIX}`; + const filters = existsSync(filtersFile) + ? readFilters(readFileSync(filtersFile, "utf8")) + : new Map(); + const checked = includes.filter( + (include) => !DYNAMIC_INCLUDE_PATTERN.test(include), + ); + const inside = checked + .map((include) => ({ + include, + relative: projectRelativePath( + projectRoot, + path.resolve(path.dirname(absolute), include.replaceAll("\\", "/")), + ), + })) + .filter((item) => item.relative !== undefined); + return { + itemCount: inside.length, + skippedCount: includes.length - checked.length, + problems: inside.flatMap((item) => [ + ...(filters.has(item.include.toLowerCase()) + ? [] + : [`${projectFile}: ${item.include} 没有归入任何筛选器.`]), + ...(SEPARATOR_PATTERN.test(item.include) && + added.has(/** @type {string} */ (item.relative).toLowerCase()) + ? [ + `${projectFile}: 新增的 ${item.include} 没有平铺在工程目录下, 模块用筛选器表达, 不建物理子目录.`, + ] + : []), + ]), + }; +} + +/** + * 读取筛选器文件中每个项归入的筛选器. + * + * @param {string} text 筛选器文件全文. + * @returns {Map} Include 路径 (小写) 到筛选器名的映射; 没有筛选器的项不在其中. + */ +function readFilters(text) { + return new Map( + [...text.matchAll(FILTERED_ITEM_PATTERN)] + .map((match) => [ + match[2].toLowerCase(), + FILTER_NAME_PATTERN.exec(match[3])?.[1], + ]) + .filter((entry) => entry[1] !== undefined), + ); +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/freeform-sections.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/freeform-sections.mjs deleted file mode 100644 index 8d092da..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/freeform-sections.mjs +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @file 回复中的自由正文节: 规格中标为 `"freeform": true` 的节. - * - * 用户要求流程之外的内容 (例如解释, 或写给维护者的报告) 时, 编排会话用 "自由答复" - * 回复. 自由正文按用户要求的格式撰写, 可以放代码块与三级及以下的标题; 二级标题会被 - * 当成新的一节, 所以不能用. 结构校验仍然核对结论行与选项块. - */ - -/** - * 自由正文的写法, 填写要求与参考文档共用. - * @type {string} - */ -export const FREEFORM_RULE = - "按用户要求的格式撰写, 可以放代码块与三级及以下的标题, 不能用一级与二级标题"; diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/guard.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/guard.mjs index 256efa1..b20370f 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/guard.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/guard.mjs @@ -1,10 +1,9 @@ /** * @file 写入守卫: 只看写入类工具的目标路径, 四条规则. * - * 1. 任何会话与子代理都不能直接写状态文件与脚本生成的视图文件. - * 2. 核对与验收子代理不能写任何文件. - * 3. 执行子代理在 `.navigator/` 下只能写当前工单的回执与证据文件; 只产出记录的 - * 工单 (运行检查与分析) 不能写业务文件. + * 1. 任何会话与子代理都不能直接写状态文件与脚本生成的路线视图. + * 2. 验收子代理不能写任何文件. + * 3. 实现子代理在 `.navigator/` 下只能写进行中的切片文件与结论目录. * 4. 编排会话不能写 `.navigator/` 之外的文件. * * 命令行与其它工具不检查: 守卫只管不会误伤正常工作的几条边界, 其余靠技能规则. @@ -12,18 +11,13 @@ */ import { + FINDINGS_DIRECTORY, GENERATED_FILES, NAVIGATOR_DIRECTORY, isUnderDirectory, - orderArtifactsPath, - orderFilePath, projectRelativePath, relativePathsEqual, } from "./paths.mjs"; -import { - ISSUED_ORDER_STATUS, - isRecordOnlyOrderKind, -} from "./workflow-orders.mjs"; /** * 会写入文件的工具名. @@ -38,25 +32,13 @@ export const WRITE_TOOLS = Object.freeze([ /** * 插件自带的子代理类型. - * @type {Readonly<{reader: string, researcher: string, checker: string, reviewer: string, executor: string}>} + * @type {Readonly<{executor: string, reviewer: string}>} */ export const SUBAGENT_TYPES = Object.freeze({ - reader: "waypoint:navigator-reader", - researcher: "waypoint:navigator-researcher", - checker: "waypoint:navigator-checker", - reviewer: "waypoint:navigator-reviewer", executor: "waypoint:navigator-executor", + reviewer: "waypoint:navigator-reviewer", }); -/** - * 只读的子代理类型: 可以运行命令取证, 不能写任何文件. - * @type {readonly string[]} - */ -const READ_ONLY_SUBAGENT_TYPES = Object.freeze([ - SUBAGENT_TYPES.checker, - SUBAGENT_TYPES.reviewer, -]); - /** * 生成提交消息并提交的技能. * @type {string} @@ -68,42 +50,25 @@ export const COMMIT_SKILL = "waypoint:commit-message"; * @type {string} */ const GENERATED_REASON = - "这个文件由插件命令生成: 状态只用插件命令修改, 推进路线与风险清单随状态自动更新."; + "这个文件由插件命令生成: 状态只用插件命令修改, 路线视图随状态自动更新."; /** - * 核对与验收子代理写文件时的拒绝理由. + * 验收子代理写文件时的拒绝理由. * @type {string} */ -const READ_ONLY_REASON = - "核对与验收子代理只读, 不写任何文件; 只报告事实与证据."; +const REVIEWER_REASON = "验收子代理只读, 不写任何文件; 只报告事实与证据."; /** - * 执行子代理写本工单回执与证据文件之外的编排记录时的拒绝理由. + * 实现子代理写其它编排记录时的拒绝理由. * @type {string} */ -const EXECUTOR_RECORD_REASON = - ".navigator/ 下只能写本工单的回执, 以及本工单文件夹 artifacts/ 下的证据文件; 其它文件是编排记录, 执行中的发现写进回执."; - -/** - * 工单不在执行中时, 执行子代理改动业务文件的拒绝理由. - * @type {string} - */ -const EXECUTOR_INACTIVE_REASON = - "当前工单不在执行中 (已结束, 验收中, 受阻或被撤回修改), 不能再改动业务文件. 写进回执, 交回编排会话."; - -/** - * 只产出记录的工单的执行子代理改动业务文件时的拒绝理由. - * @type {string} - */ -const EXECUTOR_RECORD_ONLY_REASON = - "本工单是运行检查或分析工单, 只产出记录: 只能写本工单的回执, 以及本工单文件夹 artifacts/ 下的文档, 脚本等证据文件, 不能改动业务文件与项目配置. 确实需要改代码时, 写进回执, 由编排会话另发实现工单."; +const EXECUTOR_REASON = `${NAVIGATOR_DIRECTORY}/ 下只能写进行中的切片文件与 ${FINDINGS_DIRECTORY}/ 下的结论文件; 其它文件是编排记录, 需要改动时写进执行结果, 交回编排会话.`; /** * 编排会话写业务文件时的拒绝理由. * @type {string} */ -const ORCHESTRATOR_REASON = - "编排会话只写 .navigator/ 下的编排记录, 不写业务代码与项目配置; 需要改动时发布工单, 交给执行子代理."; +const ORCHESTRATOR_REASON = `编排会话只写 ${NAVIGATOR_DIRECTORY}/ 下的编排记录, 不写业务代码与项目配置; 需要改动时排进切片, 交给实现子代理.`; /** * @typedef {object} WriteRequest 一次写入的判定输入. @@ -111,7 +76,7 @@ const ORCHESTRATOR_REASON = * @property {Record} toolInput 工具参数. * @property {boolean} isSubagent 调用是否来自子代理. * @property {string | undefined} agentType 子代理类型. - * @property {import("./state.mjs").OrderState | null} order 当前工单. + * @property {string | null} sliceFile 进行中的切片文件的项目相对路径; 没有时为 null. * @property {() => boolean} isOrchestrator 判断主会话是否为编排会话; 只在需要时调用. */ @@ -139,49 +104,34 @@ export function decideWrite(request) { if (GENERATED_FILES.some((file) => relativePathsEqual(relativePath, file))) { return deny(GENERATED_REASON); } - if (READ_ONLY_SUBAGENT_TYPES.includes(request.agentType ?? "")) { - return deny(READ_ONLY_REASON); + if (request.agentType === SUBAGENT_TYPES.reviewer) { + return deny(REVIEWER_REASON); } + const isRecord = isUnderDirectory(relativePath, NAVIGATOR_DIRECTORY); if (request.agentType === SUBAGENT_TYPES.executor) { - return decideExecutorWrite(relativePath, request.order); + return !isRecord || isExecutorRecord(relativePath, request.sliceFile) + ? allow() + : deny(EXECUTOR_REASON); } - if (request.isSubagent) { + if (request.isSubagent || isRecord) { return allow(); } - if ( - !isUnderDirectory(relativePath, NAVIGATOR_DIRECTORY) && - request.isOrchestrator() - ) { - return deny(ORCHESTRATOR_REASON); - } - return allow(); + return request.isOrchestrator() ? deny(ORCHESTRATOR_REASON) : allow(); } /** - * 判定执行子代理的写入: `.navigator/` 下只能写当前工单的回执与证据文件; - * 业务文件要求工单在执行中, 且不是只产出记录的工单. + * 判断 `.navigator/` 下的路径是否是实现子代理可以写的: 进行中的切片文件, 或 + * 结论目录下的文件. * * @param {string} relativePath 目标的项目相对路径. - * @param {import("./state.mjs").OrderState | null} order 当前工单. - * @returns {GuardDecision} 判定结果. - */ -function decideExecutorWrite(relativePath, order) { - if (isUnderDirectory(relativePath, NAVIGATOR_DIRECTORY)) { - const isOwnRecord = - order !== null && - (relativePathsEqual( - relativePath, - orderFilePath(order.folder, "receipt"), - ) || - isUnderDirectory(relativePath, orderArtifactsPath(order.folder))); - return isOwnRecord ? allow() : deny(EXECUTOR_RECORD_REASON); - } - if (order === null || order.status !== ISSUED_ORDER_STATUS) { - return deny(EXECUTOR_INACTIVE_REASON); - } - return isRecordOnlyOrderKind(order.kind) - ? deny(EXECUTOR_RECORD_ONLY_REASON) - : allow(); + * @param {string | null} sliceFile 进行中的切片文件. + * @returns {boolean} 可以写时返回 true. + */ +function isExecutorRecord(relativePath, sliceFile) { + return ( + isUnderDirectory(relativePath, FINDINGS_DIRECTORY) || + (sliceFile !== null && relativePathsEqual(relativePath, sliceFile)) + ); } /** diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/guidance.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/guidance.mjs index dd1c7c4..4e68994 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/guidance.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/guidance.mjs @@ -1,290 +1,241 @@ /** - * @file 根据状态给出编排会话的下一动作. 纯函数, 由 enter 与 status 使用. + * @file 下一动作与进展: "接下来做什么" 的唯一来源. * - * 下一动作写成编排会话可以照做的一句话: 回复哪种类型, 使用第几组选项, - * 运行哪条命令. "运行 X" 指运行插件命令 `project-navigator X`. - */ - -import { PAUSED_HANDBACK, PRECHECK_VERDICTS } from "./briefs.mjs"; -import { COMMIT_SKILL, SUBAGENT_TYPES } from "./guard.mjs"; -import { APPROVAL_REPLY_TYPE, APPROVAL_STATUSES } from "./order-approval.mjs"; -import { lastStageNumber } from "./spec.mjs"; -import { MODEL_ROLES, modelArgument } from "./subagent-models.mjs"; -import { SELECTION_ORDER_KIND } from "./workflow-orders.mjs"; -import { isProjectFinished } from "./workflow-plan.mjs"; - -/** - * "验收报告" 的选项组: 判据全部通过, 有判据不通过, 只有未验证的判据, 选型工单. - * @type {Readonly<{passed: number, fix: number, unverified: number, selection: number}>} - */ -export const REVIEW_OPTIONS = Object.freeze({ - passed: 1, - fix: 2, - unverified: 3, - selection: 4, -}); - -/** - * 上报技能自身问题的回复类型. - * @type {string} - */ -export const REPORT_REPLY_TYPE = "问题报告"; - -/** - * "问题报告" 的选项组: 还能继续推进, 无法继续. - * @type {Readonly<{continuable: number, blocking: number}>} - */ -export const REPORT_OPTIONS = Object.freeze({ continuable: 1, blocking: 2 }); - -/** - * 请用户决定验收结果的回复类型. - * @type {string} - */ -export const REVIEW_REPLY_TYPE = "验收报告"; - -/** - * 执行子代理工作期间的回复类型. - * @type {string} - */ -export const RUNNING_REPLY_TYPE = "工单执行"; - -/** - * 验收子代理工作期间的回复类型. - * @type {string} - */ -export const REVIEWING_REPLY_TYPE = "工单验收"; - -/** - * 处理受阻工单的回复类型. - * @type {string} - */ -const BLOCKED_REPLY_TYPE = "受阻处理"; - -/** - * 项目已确认收尾, 记录都已入库之后的回复类型. - * @type {string} - */ -export const COMPLETE_REPLY_TYPE = "项目完成"; - -/** - * 回执中表示执行受阻的执行状态关键字. - * @type {string} - */ -export const BLOCKED_RECEIPT_KEYWORD = "受阻"; - -/** - * 等待调研, 阅读与核对子代理期间的回复类型. - * @type {string} - */ -const WAITING_REPLY_TYPE = "等待结果"; - -/** - * 执行子代理中途交回, 等用户完成操作期间的回复类型. - * @type {string} - */ -const PAUSED_REPLY_TYPE = "等待操作"; - -/** - * 派出执行子代理的动作, 写明这种工单使用的模型. - * - * @param {Record | undefined} models 状态中登记的模型分配. - * @param {{kind: string}} order 当前工单. - * @returns {string} 动作. - */ -export function executorDispatchAction(models, order) { - return `运行 exec-brief, 把输出原样作为提示词, 用 Agent 工具在后台 (run_in_background) 派 ${SUBAGENT_TYPES.executor} (${modelArgument(models, MODEL_ROLES.executor, order.kind)})`; -} - -/** - * 发布并派出执行子代理的一串动作. - * - * @param {Record | undefined} models 状态中登记的模型分配. - * @param {{kind: string}} order 当前工单. - * @returns {string} 动作. - */ -function dispatchStep(models, order) { - return `运行 order set issued, 再${executorDispatchAction(models, order)}, 然后`; -} - -/** - * 派出核对子代理并按结果改好工单的一串动作; 子代理在后台运行, 本轮要先结束时 - * 回复 "等待结果". - * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {Record | undefined} models 状态中登记的模型分配. - * @returns {string} 动作. - */ -export function precheckStep(spec, models) { - return `运行 precheck-brief, 把输出原样作为提示词派 ${SUBAGENT_TYPES.checker} (${modelArgument(models, MODEL_ROLES.checker)}); 子代理在后台运行, 本轮要先结束时${replyStep(spec, WAITING_REPLY_TYPE)}; 结果回来后改正 "${PRECHECK_VERDICTS.fails}" 的事实, "${PRECHECK_VERDICTS.unknown}" 的条目自己查证或改写成能核对的说法, 遗漏的决定写进 "需要决定", 然后运行 order precheck`; -} - -/** - * 派出验收子代理的一串动作; 子代理在后台运行, 本轮要先结束时回复 "工单验收". + * 按状态与切片文件中的事实计算, status 命令与 hook 的位置提醒共用. 这里只给出 + * 动作与要运行的命令, 做法的细节在参考文件中. + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { + BACKLOG_FILE, + COMMAND_NAME, + FINDINGS_DIRECTORY, + REQUIREMENTS_FILE, +} from "./paths.mjs"; +import { + activeSlice, + currentMilestone, + isMilestoneSettled, + isSliceSettled, + nextPendingSlice, +} from "./roadmap.mjs"; +import { + PARTS, + SECTIONS, + VERDICT_CLASSES, + classifyVerdicts, + loadSliceSpec, + partSpec, + readResultStatus, + readSliceFile, + readVerdicts, + sectionTitle, +} from "./slice-file.mjs"; +import { + SLICE_STATUSES, + SLICE_STATUS_LABELS, + STAGES, + STAGE_LABELS, +} from "./state.mjs"; + +/** + * @typedef {import("./state.mjs").NavigatorState} NavigatorState + * @typedef {import("./state.mjs").SliceState} SliceState + * @typedef {import("./slice-file.mjs").SliceSpec} SliceSpec + */ + +/** + * @typedef {object} ProgressFacts 计算下一动作需要的, 不在状态文件中的事实. + * @property {boolean} hasRequirements 需求文件是否已经写入. + * @property {string} resultStatus 进行中切片的执行状态; 没有时为空字符串. + * @property {number} verdictCount 标准核对表的行数; 还没有填写时为 0. + * @property {string[]} verdictProblems 标准核对表中的问题. + * @property {string} verdictClass 标准核对结果的归类, 见 VERDICT_CLASSES. + */ + +/** + * 读取计算下一动作需要的事实. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {Record | undefined} models 状态中登记的模型分配. - * @returns {string} 动作. - */ -function reviewDispatchStep(spec, models) { - return `运行 review-brief, 把输出原样作为提示词派 ${SUBAGENT_TYPES.reviewer} (${modelArgument(models, MODEL_ROLES.reviewer)}); 子代理在后台运行, 本轮要先结束时${replyStep(spec, REVIEWING_REPLY_TYPE)}, 不写其它说明`; + * @param {string} projectRoot 项目根目录. + * @param {NavigatorState} state 状态. + * @param {SliceSpec} [spec] 切片文件规格. + * @returns {ProgressFacts} 事实. + */ +export function readFacts(projectRoot, state, spec = loadSliceSpec()) { + const slice = activeSlice(state)?.slice; + const parsed = + slice === undefined + ? undefined + : readSliceFile(projectRoot, slice.file, spec); + const verdicts = readVerdicts(parsed, spec); + return { + hasRequirements: existsSync(path.join(projectRoot, REQUIREMENTS_FILE)), + resultStatus: readResultStatus(parsed, spec), + verdictCount: verdicts.rows.length, + verdictProblems: verdicts.problems, + verdictClass: classifyVerdicts(verdicts.rows, spec), + }; } /** - * @typedef {object} GuidanceInput 判断下一动作需要的信息. - * @property {import("./state.mjs").NavigatorState} state 当前状态. - * @property {string | undefined} receiptStatus 当前工单回执中的执行状态; 回执不存在时为 undefined. - * @property {string} [orderApproval] 当前工单的审阅状态, 取值见 order-approval.mjs 的 APPROVAL_STATUSES; 没有工单时省略. - * @property {boolean} [isOrderUnchanged] 受阻工单的内容与最近一次发布时是否相同. - * @property {boolean} [hasUncommittedRecords] 技能产物 (含状态文件) 是否有未入库的改动; 只在项目已确认收尾时需要. - * @property {boolean} isEntry 是否是调用技能时的进入 (不是会话中途的查询). - * @property {import("./spec.mjs").TemplateSpec} spec 模板规格, 用于写出回复类型的编号. - */ - -/** - * 写出 "回复某种类型" 这一步, 附上取骨架的命令, 编排会话不用再查编号. + * 描述当前位置: 阶段, 里程, 切片与环节. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {string} type 回复类型. - * @param {number} [optionSet] 使用第几组选项; 省略时不写. - * @returns {string} 例如 `回复 "验收报告", 使用第 2 组选项 (reply review --option 2)`. + * @param {NavigatorState} state 状态. + * @returns {string} 一行进展. */ -export function replyStep(spec, type, optionSet) { - const id = spec.replies[type].id; - return optionSet === undefined - ? `回复 "${type}" (reply ${id})` - : `回复 "${type}", 使用第 ${optionSet} 组选项 (reply ${id} --option ${optionSet})`; +export function progressLine(state) { + const milestone = currentMilestone(state); + if (state.stage !== STAGES.slicing || milestone === undefined) { + return `阶段 ${STAGE_LABELS[state.stage]}`; + } + const settled = milestone.slices.filter(isSliceSettled).length; + const head = `里程 ${milestone.id} ${milestone.name} (${settled}/${milestone.slices.length})`; + const slice = activeSlice(state)?.slice; + if (slice === undefined || state.current === null) { + return head; + } + return `${head} > 切片 ${slice.id} ${slice.name} [${SLICE_STATUS_LABELS[slice.status]}, 第 ${state.current.round} 轮]`; } /** - * 给出下一动作. + * 计算下一动作. * - * @param {GuidanceInput} input 判断信息. + * @param {NavigatorState} state 状态. + * @param {ProgressFacts} facts 不在状态文件中的事实. + * @param {SliceSpec} [spec] 切片文件规格. * @returns {string} 下一动作. */ -export function nextAction(input) { - const { state, spec } = input; - if (state.stage === null) { - return replyStep(spec, "首次接入"); +export function nextAction(state, facts, spec = loadSliceSpec()) { + if (state.stage === STAGES.finished) { + return `项目已确认收尾. 用户提出新的工作时按 ${command("ref change")} 处理: 更新需求, 用 roadmap 追加里程.`; } - if (isProjectFinished(state, lastStageNumber(spec))) { - return finishedAction(spec, input.hasUncommittedRecords === true); + if (state.stage === STAGES.intake) { + return intakeAction(state, facts); } - if (input.isEntry) { - return `${replyStep(spec, "恢复进度")}; 用户选 A 后运行 status, 按其中的下一动作继续`; - } - return state.order === null - ? `继续阶段 ${state.stage} 的步骤 ${state.step}` - : orderAction(spec, state.order, input); + const active = activeSlice(state)?.slice; + return active === undefined + ? betweenSlicesAction(state) + : sliceAction(active, facts, spec); } /** - * 项目已确认收尾之后的下一动作: 记录有未入库的改动时先提交, 否则回复项目完成. + * 把命令写成可以直接运行的形式. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {boolean} hasUncommittedRecords 技能产物是否有未入库的改动. - * @returns {string} 下一动作. + * @param {string} args 命令名与参数. + * @returns {string} 带反引号的完整命令. */ -function finishedAction(spec, hasUncommittedRecords) { - return hasUncommittedRecords - ? `项目已确认收尾, 但记录还有未入库的改动: 调用 ${COMMIT_SKILL}, 参数写 "提交, 纳入范围: .navigator/ 下的改动; 纳入范围之外的改动用文字列出并询问", 再${replyStep(spec, COMPLETE_REPLY_TYPE)}` - : `项目已完成, 没有待办: ${replyStep(spec, COMPLETE_REPLY_TYPE)}; 用户选 A 或提出新需求时, 按需求变更流程新开里程, 更新推进路线后运行 stage 5; 选 B 时运行 git push (不带强制参数), 再回复 "${COMPLETE_REPLY_TYPE}"; 选 C 时不做任何操作`; +export function command(args) { + return `\`${COMMAND_NAME} ${args}\``; } /** - * 当前有工单时, 按工单状态给出下一动作. + * 立项阶段的下一动作. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {import("./state.mjs").OrderState} order 当前工单. - * @param {GuidanceInput} input 判断信息. + * @param {NavigatorState} state 状态. + * @param {ProgressFacts} facts 事实. * @returns {string} 下一动作. */ -function orderAction(spec, order, input) { - const models = input.state.models; - switch (order.status) { - case "drafting": - return draftingAction(spec, order, input); - case "issued": - return issuedAction(spec, order, input); - case "blocked": - return blockedAction(spec, order, input); - case "reviewing": - return reviewingAction(spec, order, models); - default: - return `工单 ${order.id} 处于未知状态 ${order.status}`; +function intakeAction(state, facts) { + if (!facts.hasRequirements) { + return `立项: 先运行 ${command("ref intake")} 读立项步骤; 与用户逐个确认需求后写入 ${REQUIREMENTS_FILE}.`; } + if ( + state.project.organization === null || + state.project.verification === null + ) { + return `登记项目设置: 运行 ${command("setup")} 查看建议, 与用户确认模块组织方式与验证方式后, 用 ${command("setup --from <草稿>")} 登记.`; + } + return `写路线: 按 ${command("ref intake")} 的 "路线" 一节写草稿, 运行 ${command("roadmap --from <草稿>")}.`; } /** - * 起草中的工单的下一动作: 先做发布前核对, 再请用户审阅, 用户认可当前内容后 - * 发布并派出执行子代理. + * 没有进行中的切片时的下一动作. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {import("./state.mjs").OrderState} order 当前工单. - * @param {GuidanceInput} input 判断信息. + * @param {NavigatorState} state 状态. * @returns {string} 下一动作. */ -function draftingAction(spec, order, input) { - const models = input.state.models; - const dispatch = `${dispatchStep(models, order)}${replyStep(spec, RUNNING_REPLY_TYPE)}`; - const approve = `${replyStep(spec, APPROVAL_REPLY_TYPE)}; 用户选 A 后${dispatch}`; - if (order.prechecked !== true) { - return `写完工单 ${order.id} 的 order.md, ${precheckStep(spec, models)}, 再${approve}`; +function betweenSlicesAction(state) { + const milestone = currentMilestone(state); + if (milestone === undefined) { + return `全部里程已完成: 向用户说明成果, 用户确认后运行 ${command("finish")}; 还有新的工作时用 roadmap 追加里程.`; + } + const pending = nextPendingSlice(state); + if (pending !== undefined) { + return `为切片 ${pending.id} "${pending.name}" 写计划: 先读相关代码, 再运行 ${command(`slice plan ${pending.id} --slug <短名>`)}, 做法见 ${command("ref slice-loop")}.`; } - switch (input.orderApproval) { - case APPROVAL_STATUSES.approved: - return `工单 ${order.id} 已经用户审阅: ${dispatch}`; - case APPROVAL_STATUSES.awaiting: - return `等用户在 "${APPROVAL_REPLY_TYPE}" 中选择: 选 A 后${dispatch}; 用户要修改时, 改好工单文件后再${replyStep(spec, APPROVAL_REPLY_TYPE)}`; - default: - return `工单 ${order.id} 已做发布前核对: ${approve}`; + if (isMilestoneSettled(milestone)) { + return `里程复查 (${command("ref slice-loop")} 的 "里程复查"): 对照里程目标向用户说明达成情况, 整理 ${BACKLOG_FILE}, 用 roadmap 排出下一个里程的切片; 用户确认后运行 ${command(`milestone done ${milestone.id}`)}.`; } + return `为里程 ${milestone.id} "${milestone.name}" 排出切片: 写路线草稿, 运行 ${command("roadmap --from <草稿>")}.`; } /** - * 已发布工单的下一动作: 回执写入前等执行子代理, 写入后按执行状态处理. - * 执行子代理也可能不写回执, 中途交回等用户操作. + * 有进行中的切片时的下一动作. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {{id: string}} order 当前工单. - * @param {GuidanceInput} input 判断信息. + * @param {SliceState} slice 进行中的切片. + * @param {ProgressFacts} facts 事实. + * @param {SliceSpec} spec 切片文件规格. * @returns {string} 下一动作. */ -function issuedAction(spec, order, input) { - const { receiptStatus } = input; - if (receiptStatus === undefined) { - return `工单 ${order.id} 由执行子代理处理中: ${replyStep(spec, RUNNING_REPLY_TYPE)}; 子代理结束后会自动通知, 届时运行 status. 子代理交回的第一行是 "${PAUSED_HANDBACK}" 时, ${replyStep(spec, PAUSED_REPLY_TYPE)}, 用户做完后用 SendMessage 把用户的话转给它继续, 再${replyStep(spec, RUNNING_REPLY_TYPE)}. 子代理已结束, 既没有等待操作也没有写回执时, 用 SendMessage 请它按回执骨架补写`; +function sliceAction(slice, facts, spec) { + if (slice.status === SLICE_STATUSES.planned) { + return `填写切片文件 ${slice.file} 的 "${partSpec(spec, PARTS.plan).heading}" 段, 然后运行 ${command("slice start")}${slice.interactive ? "; 这是交互式切片, 开工后在本会话执行" : ", 按输出在后台派实现子代理"}.`; } - return receiptStatus.includes(BLOCKED_RECEIPT_KEYWORD) - ? `工单 ${order.id} 执行受阻: 读回执, 运行 order set blocked, 再${replyStep(spec, BLOCKED_REPLY_TYPE)}, 按受阻类型选择选项组` - : `工单 ${order.id} 执行完成: 运行 order set reviewing, 再${reviewDispatchStep(spec, input.state.models)}`; + if (slice.status === SLICE_STATUSES.running) { + return runningAction(slice, facts, spec); + } + return reviewingAction(slice, facts, spec); } /** - * 受阻工单的下一动作: 内容没有改动时直接继续, 改动后要重新审阅. + * 切片在实现中时的下一动作. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {{id: string, kind: string}} order 当前工单. - * @param {GuidanceInput} input 判断信息. + * @param {SliceState} slice 进行中的切片. + * @param {ProgressFacts} facts 事实. + * @param {SliceSpec} spec 切片文件规格. * @returns {string} 下一动作. */ -function blockedAction(spec, order, input) { - if (input.orderApproval === APPROVAL_STATUSES.approved) { - return `工单 ${order.id} 的修改已经用户审阅: ${dispatchStep(input.state.models, order)}${replyStep(spec, RUNNING_REPLY_TYPE)}`; +function runningAction(slice, facts, spec) { + const resultHeading = partSpec(spec, PARTS.result).heading; + const statusTitle = sectionTitle(spec, SECTIONS.status); + if (facts.resultStatus === spec.doneStatus) { + return `运行 ${command("slice review")}, 按输出派验收子代理.`; + } + if (slice.interactive) { + return `继续交互式切片 ${slice.id}: 在本会话按计划逐步做, 需要用户操作时直接说, 每确认一条结论立即写入 ${FINDINGS_DIRECTORY}/; 做完后填写 "${resultHeading}" 段, 把 "${statusTitle}" 改成 "${spec.doneStatus}", 再运行 ${command("slice review")}.`; } - return `工单 ${order.id} 受阻: ${replyStep(spec, BLOCKED_REPLY_TYPE)}; 用户完成所需操作而工单没有改动时, 运行 order set issued, 用 SendMessage 让原执行子代理继续 (找不到它时运行 exec-brief 重新派出), 再${replyStep(spec, RUNNING_REPLY_TYPE)}; 要修改工单时, 改好后${replyStep(spec, APPROVAL_REPLY_TYPE)}${input.isOrderUnchanged === false ? ". 工单文件已在发布后改动, 必须重新审阅" : ""}`; + return `等待实现子代理. 它以 "交回: ..." 开头交回时, 把内容原样转告用户, 用户回复后用 SendMessage 让它继续 (找不到它时运行 ${command("brief exec")} 重新派出); 它已结束而 "${statusTitle}" 还不是 "${spec.doneStatus}" 时, 请它补写 "${resultHeading}" 段.`; } /** - * 验收中的工单的下一动作: 派验收子代理, 写验收记录, 按判据结论选择选项组. + * 切片在验收中时的下一动作. * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {{id: string, kind: string}} order 当前工单. - * @param {Record | undefined} models 状态中登记的模型分配. + * @param {SliceState} slice 进行中的切片. + * @param {ProgressFacts} facts 事实. + * @param {SliceSpec} spec 切片文件规格. * @returns {string} 下一动作. */ -function reviewingAction(spec, order, models) { - const brief = `${reviewDispatchStep(spec, models)}; 结果回来后把结论写入 review.md, 再`; - return order.kind === SELECTION_ORDER_KIND - ? `选型工单 ${order.id} 验收中: ${brief}${replyStep(spec, REVIEW_REPLY_TYPE, REVIEW_OPTIONS.selection)}, 由用户决定是否采用` - : `工单 ${order.id} 验收中: ${brief}${replyStep(spec, REVIEW_REPLY_TYPE)}: 判据全部通过用第 ${REVIEW_OPTIONS.passed} 组, 有不通过用第 ${REVIEW_OPTIONS.fix} 组, 只有未验证用第 ${REVIEW_OPTIONS.unverified} 组`; +function reviewingAction(slice, facts, spec) { + const reviewHeading = partSpec(spec, PARTS.review).heading; + const conclusion = sectionTitle(spec, SECTIONS.conclusion); + const rework = sectionTitle(spec, SECTIONS.rework); + if (facts.verdictCount === 0) { + return `等待验收子代理; 它交回后把内容原样写入切片文件 ${slice.file} 的 "${reviewHeading}" 段 (找不到它时运行 ${command("brief review")} 重新派出).`; + } + if (facts.verdictProblems.length > 0) { + return `修正切片文件的 "${reviewHeading}" 段: ${facts.verdictProblems.join(" ")}`; + } + const record = `把用户的原话写入 "${conclusion}"`; + const redo = `把要补的内容写入计划的 "${rework}" 后运行 ${command("slice redo")}`; + if (facts.verdictClass === VERDICT_CLASSES.pass) { + return `验收全部通过, 请用户决定: A 通过并提交; B 通过, 提交并推送. 用户选择后${record}, 运行 ${command("slice accept")}.`; + } + if (facts.verdictClass === VERDICT_CLASSES.unverified) { + return `有标准未验证, 请用户决定: A 接受现状并提交 (${record}, 运行 ${command("slice accept --accept-unverified")}); B 补做 (${redo}).`; + } + return slice.skeleton + ? `有标准不通过, 这是最小骨架, 只能补做: 向用户说明后${redo}.` + : `有标准不通过, 请用户决定: A 补做 (${redo}); B 放弃这个切片 (运行 ${command("slice drop")}).`; } diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/layout.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/layout.mjs deleted file mode 100644 index 3244801..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/layout.mjs +++ /dev/null @@ -1,193 +0,0 @@ -/** - * @file 回复与文件共用的版式工具: 按二级标题切节, 校验键值节与表格节. - * - * 回复校验 (reply-checks), 文件校验 (file-checks) 与读取记录的模块都建立在这里的 - * 函数之上, 保证各处对 "节", "键值行" 与 "表格" 的理解完全一致. - */ - -import { parseMarkdown } from "./markdown.mjs"; -import { parseTable } from "./table.mjs"; - -/** - * 键值行的格式: "- 键名: 值". - * @type {RegExp} - */ -export const KEY_LINE_PATTERN = /^- (\S+?): (.+)$/u; - -/** - * @typedef {object} Section 一个二级节. - * @property {string} title 节标题. - * @property {import("./markdown.mjs").MarkdownItem[]} items 节内条目, 不含空行. - */ - -/** - * @typedef {object} SplitResult 切节结果. - * @property {import("./markdown.mjs").MarkdownItem[]} leading 一级标题之后, 第一个二级标题之前的条目. - * @property {Section[]} sections 各节. - * @property {import("./markdown.mjs").BlockItem[]} trailing 最后一节末尾连续的代码块. - */ - -/** - * 去掉空行后, 按二级标题切节; 末尾连续的代码块单独取出 (没有任何节时, - * 从一级标题之后的内容末尾取出). - * - * @param {import("./markdown.mjs").MarkdownItem[]} items 一级标题之后的条目. - * @returns {SplitResult} 切节结果. - */ -export function splitSections(items) { - const leading = []; - /** @type {Section[]} */ - const sections = []; - for (const item of items) { - if (item.kind === "text" && item.text.trim() === "") { - continue; - } - if (item.kind === "heading" && item.level === 2) { - sections.push({ title: item.text, items: [] }); - } else if (sections.length === 0) { - leading.push(item); - } else { - sections[sections.length - 1].items.push(item); - } - } - const container = - sections.length === 0 ? leading : sections[sections.length - 1].items; - const start = trailingBlockStart(container); - const trailing = - start < 0 - ? [] - : /** @type {import("./markdown.mjs").BlockItem[]} */ ( - container.splice(start) - ); - return { leading, sections, trailing }; -} - -/** - * 校验键值节: 只含规格中的键值行, 顺序一致. - * - * @param {Section} section 节. - * @param {readonly string[]} keys 规格中的键名. - * @returns {string[]} 问题列表. - */ -export function checkKeyLines(section, keys) { - const actualKeys = section.items.map((item) => - item.kind === "text" ? KEY_LINE_PATTERN.exec(item.text)?.[1] : undefined, - ); - const isMatch = - actualKeys.length === keys.length && - actualKeys.every((key, index) => key === keys[index]); - return isMatch - ? [] - : [ - `"## ${section.title}" 应依次包含 ${keys.map((key) => `"- ${key}: 值"`).join(", ")}, 每行一个, 不加其它内容.`, - ]; -} - -/** - * 在 Markdown 全文中找出某个二级节. - * - * @param {string} text Markdown 全文. - * @param {string} title 节标题. - * @returns {Section | undefined} 节; 没有该节时为 undefined. - */ -export function findSection(text, title) { - return splitSections(parseMarkdown(text)).sections.find( - (section) => section.title === title, - ); -} - -/** - * 读取节中的键值行, 不是键值行的条目忽略. - * - * @param {Section} section 节. - * @returns {Record} 键名到值的映射. - */ -export function readKeyValues(section) { - return Object.fromEntries( - section.items - .map((item) => - item.kind === "text" ? KEY_LINE_PATTERN.exec(item.text) : null, - ) - .filter((match) => match !== null) - .map((match) => [match[1], match[2]]), - ); -} - -/** - * 读取节中的第一个表格. - * - * @param {Section} section 节. - * @returns {import("./table.mjs").ParsedTable | undefined} 表格; 节中没有合法表格时为 undefined. - */ -export function readSectionTable(section) { - return parseTable( - section.items.map((item) => (item.kind === "text" ? item.text : "")), - ); -} - -/** - * 校验表格节: 表头与规格一致, 至少一行 (规格允许为空的表格除外), 每格都已填写, - * 固定取值的列只用允许的值. - * - * @param {Section} section 节. - * @param {import("./spec.mjs").TableSpec} table 表格规格. - * @returns {string[]} 问题列表. - */ -export function checkTable(section, table) { - const parsed = readSectionTable(section); - const title = `"## ${section.title}"`; - if ( - parsed === undefined || - parsed.headers.join("|") !== table.columns.join("|") - ) { - return [ - `${title} 应包含一个表格, 表头依次为 ${table.columns.map((column) => `"${column}"`).join(", ")}.`, - ]; - } - if (parsed.rows.length === 0 && table.allowEmpty !== true) { - return [`${title} 的表格至少要有一行内容.`]; - } - return parsed.rows.flatMap((row, index) => - checkTableRow({ row, rowNumber: index + 1, table, title }), - ); -} - -/** - * 校验表格的一行. - * - * @param {object} options 校验参数. - * @param {string[]} options.row 该行的单元格. - * @param {number} options.rowNumber 行号, 从 1 开始, 不含表头. - * @param {import("./spec.mjs").TableSpec} options.table 表格规格. - * @param {string} options.title 用于报告的节标题. - * @returns {string[]} 问题列表. - */ -function checkTableRow({ row, rowNumber, table, title }) { - if (row.length !== table.columns.length || row.some((cell) => cell === "")) { - return [ - `${title} 的表格第 ${rowNumber} 行应有 ${table.columns.length} 格, 每格都要填写.`, - ]; - } - return table.columns.flatMap((column, index) => { - const allowed = table.choices?.[column]; - return allowed === undefined || allowed.includes(row[index]) - ? [] - : [ - `${title} 的表格第 ${rowNumber} 行 "${column}" 只能是 ${allowed.join(", ")} 之一, 不能写 "${row[index]}".`, - ]; - }); -} - -/** - * 找到条目列表末尾连续代码块的起点. - * - * @param {import("./markdown.mjs").MarkdownItem[]} items 条目. - * @returns {number} 起点下标; 末尾没有代码块时为 -1. - */ -function trailingBlockStart(items) { - let start = items.length; - while (start > 0 && items[start - 1].kind === "block") { - start -= 1; - } - return start === items.length ? -1 : start; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/markdown.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/markdown.mjs index 60c4cac..e5fcac0 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/markdown.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/markdown.mjs @@ -1,8 +1,8 @@ /** * @file 把 Markdown 文本切分成标题, 围栏代码块与普通文本行的有序序列. * - * 只实现版式校验需要的 CommonMark 子集: ATX 标题与围栏代码块 (反引号或波浪号, - * 关闭围栏须使用同一字符且长度不短于开启围栏). 回复校验与文件校验共用这一个解析器. + * 只实现读取切片文件需要的 CommonMark 子集: ATX 标题与围栏代码块 (反引号或 + * 波浪号, 关闭围栏须使用同一字符且长度不短于开启围栏). 围栏中的井号行不算标题. */ /** @@ -71,22 +71,6 @@ export function parseMarkdown(source) { return items; } -/** - * 把指定行替换为空行, 行号保持不变; 换行符统一为 `\n`, 与 parseMarkdown 的行号一致. - * 用于把不参加写作规则检查的内容从全文中去掉. - * - * @param {string} source Markdown 文本. - * @param {ReadonlySet} lines 要替换的行号, 从 1 开始. - * @returns {string} 替换后的文本. - */ -export function blankLines(source, lines) { - return source - .replace(/\r\n?/gu, "\n") - .split("\n") - .map((line, index) => (lines.has(index + 1) ? "" : line)) - .join("\n"); -} - /** * 判断匹配到的开启围栏是否有效: 反引号围栏的信息串中不能再含反引号. * diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/option-answer.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/option-answer.mjs deleted file mode 100644 index ff00581..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/option-answer.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @file 识别用户对选项块的回答: 回复单独的选项字母, 或以选项文字的第一个分句开头. - * - * 开工对齐与工单审阅都要由 hook 记下用户是否选了 A; 识别的文字取自规格中的 - * 选项, 与回复中显示的选项保持一致. 支持按钮的应用中, 按钮文字就是选项的 - * 第一个分句, 点按钮与打字回复走同一条识别. - */ - -import { OPTION_LETTERS } from "./render.mjs"; - -/** - * 选项文字中分句的分隔符, 取第一个分句作为可识别的写法. - * @type {RegExp} - */ -const CLAUSE_BREAK_PATTERN = /[,.;:]/u; - -/** - * 取出选项文字的第一个分句, 例如 "发布工单, 开始执行." 得到 "发布工单". - * 用户以这个分句开头的回复算选了该项, 应用的按钮也用它作文字. - * - * @param {string} choice 选项文字. - * @returns {string} 第一个分句. - */ -export function choiceLead(choice) { - return choice.split(CLAUSE_BREAK_PATTERN)[0].trim(); -} - -/** - * 判断用户消息是否选了某组选项中的某一项. - * - * @param {string} prompt 用户消息. - * @param {import("./spec.mjs").OptionSetSpec} optionSet 选项组. - * @param {number} index 选项下标, 从 0 开始. - * @returns {boolean} 选了该项时返回 true. - */ -export function isChoiceSelected(prompt, optionSet, index) { - const letter = OPTION_LETTERS[index]; - const choice = optionSet.choices[index]; - if (letter === undefined || choice === undefined) { - return false; - } - const letterPattern = new RegExp( - `^[${letter}${letter.toLowerCase()}](?:[\\s.,:;)]|$)`, - "u", - ); - return ( - letterPattern.test(prompt.trim()) || isChoiceNamed(prompt, optionSet, index) - ); -} - -/** - * 判断用户消息是否以某一项选项文字的第一个分句开头. 选项字母在每条回复中都有, - * 只有紧跟着那条回复时才指向这一项; 选项文字本身说明了用户要做什么, 隔了几条 - * 消息之后仍然有效. - * - * @param {string} prompt 用户消息. - * @param {import("./spec.mjs").OptionSetSpec} optionSet 选项组. - * @param {number} index 选项下标, 从 0 开始. - * @returns {boolean} 以该项的第一个分句开头时返回 true. - */ -export function isChoiceNamed(prompt, optionSet, index) { - const choice = optionSet.choices[index]; - if (choice === undefined) { - return false; - } - const lead = choiceLead(choice); - return lead !== "" && prompt.trim().startsWith(lead); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/order-approval.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/order-approval.mjs deleted file mode 100644 index 4bbb73e..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/order-approval.mjs +++ /dev/null @@ -1,189 +0,0 @@ -/** - * @file 工单审阅: 发布工单之前, 用户在 "工单审阅" 中看过审阅要点并选 A. - * - * hook 在编排会话回复 "工单审阅" 时记下当时工单文件的摘要, 在用户回答时记下 - * 选择; `order set issued` 只接受摘要与当前工单文件一致的认可. 认可在发布时 - * 用掉; 工单内容改过之后重新发布要重新审阅, 受阻后内容没有改动时可以直接继续. - * 用户没有选发布时记录保留: 之后的消息写明 "发布工单" 时仍可认可同一份内容. - * 审阅记录存在运行期登记目录中, hook 不写状态文件. - */ - -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; - -import { isChoiceNamed, isChoiceSelected } from "./option-answer.mjs"; -import { orderFilePath } from "./paths.mjs"; -import { - readOrderApproval, - removeOrderApproval, - writeOrderApproval, -} from "./registry.mjs"; - -/** - * 请用户审阅工单的回复类型. - * @type {string} - */ -export const APPROVAL_REPLY_TYPE = "工单审阅"; - -/** - * "工单审阅" 中表示发布工单的选项位置: 第 1 组第 1 项. - * @type {Readonly<{optionSet: number, choice: number}>} - */ -const APPROVE_CHOICE = Object.freeze({ optionSet: 0, choice: 0 }); - -/** - * 审阅状态: 用户已认可当前内容, 正在等用户选择, 没有有效的审阅. - * @type {Readonly<{approved: string, awaiting: string, none: string}>} - */ -export const APPROVAL_STATUSES = Object.freeze({ - approved: "approved", - awaiting: "awaiting", - none: "none", -}); - -/** - * 审阅记录中的状态: 用户看过审阅, 但没有选发布. 对外等同于没有有效的审阅. - * @type {string} - */ -const DECLINED_STATUS = "declined"; - -/** - * 用户的消息还能改变结果的记录状态: 正在等用户选择, 或用户没有选发布. - * @type {readonly string[]} - */ -const ANSWERABLE_STATUSES = Object.freeze([ - APPROVAL_STATUSES.awaiting, - DECLINED_STATUS, -]); - -/** - * 计算工单文件内容的摘要; 统一换行符, 避免编辑器的换行差异让认可失效. - * - * @param {string} text 工单文件内容. - * @returns {string} 十六进制的 SHA-256 摘要. - */ -export function digestOrderText(text) { - return createHash("sha256") - .update(text.replace(/\r\n?/gu, "\n")) - .digest("hex"); -} - -/** - * 读取当前工单文件的内容. - * - * @param {string} projectRoot 项目根目录. - * @param {{folder: string}} order 当前工单. - * @returns {string | undefined} 内容; 文件不存在时为 undefined. - */ -export function readOrderText(projectRoot, order) { - const file = path.join(projectRoot, orderFilePath(order.folder, "order")); - return existsSync(file) ? readFileSync(file, "utf8") : undefined; -} - -/** - * 计算当前工单文件内容的摘要. - * - * @param {string} projectRoot 项目根目录. - * @param {{folder: string}} order 当前工单. - * @returns {string | undefined} 摘要; 文件不存在时为 undefined. - */ -export function digestOrderFile(projectRoot, order) { - const text = readOrderText(projectRoot, order); - return text === undefined ? undefined : digestOrderText(text); -} - -/** - * 判断当前工单的审阅状态: 审阅记录必须针对这张工单, 且摘要与当前文件一致. - * - * @param {string} projectRoot 项目根目录. - * @param {{id: string, folder: string}} order 当前工单. - * @returns {string} APPROVAL_STATUSES 中的一个值. - */ -export function readApprovalStatus(projectRoot, order) { - const record = readOrderApproval(projectRoot); - const text = readOrderText(projectRoot, order); - if ( - record === undefined || - text === undefined || - record.order !== order.id || - record.digest !== digestOrderText(text) - ) { - return APPROVAL_STATUSES.none; - } - return record.status === APPROVAL_STATUSES.approved || - record.status === APPROVAL_STATUSES.awaiting - ? record.status - : APPROVAL_STATUSES.none; -} - -/** - * 编排会话回复 "工单审阅" 之后, 记下当时的工单内容, 等待用户选择. - * 工单文件不存在时清除审阅记录. - * - * @param {string} projectRoot 项目根目录. - * @param {{id: string, folder: string}} order 当前工单. - * @param {string} now 当前时间, ISO 格式. - * @returns {void} - */ -export function markAwaitingApproval(projectRoot, order, now) { - const text = readOrderText(projectRoot, order); - if (text === undefined) { - removeOrderApproval(projectRoot); - return; - } - writeOrderApproval(projectRoot, { - order: order.id, - digest: digestOrderText(text), - status: APPROVAL_STATUSES.awaiting, - updatedAt: now, - }); -} - -/** - * 记录用户对 "工单审阅" 的回答. 紧跟着审阅的那条消息选 A (字母或选项文字) 时 - * 认可审阅时的内容, 其它回答记为没有选发布. 没有选发布之后, 用户的消息以 - * "发布工单" 的选项文字开头时仍然认可: 用户撤回上一条消息重新回答时, 审阅回复 - * 不会再结束一次, 只能靠这条记录认出来; 单独的字母此时可能在回答别的回复, 不算. - * 认可的始终是审阅时的内容, 工单文件改过之后要重新审阅. 没有针对当前工单的 - * 审阅记录时不做任何事. - * - * @param {object} options 参数. - * @param {string} options.projectRoot 项目根目录. - * @param {{id: string} | null} options.order 当前工单. - * @param {string} options.prompt 用户消息. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {void} - */ -export function applyApprovalAnswer({ projectRoot, order, prompt, spec, now }) { - const record = readOrderApproval(projectRoot); - if ( - record === undefined || - record.order !== order?.id || - !ANSWERABLE_STATUSES.includes(record.status) - ) { - return; - } - const optionSet = - spec.replies[APPROVAL_REPLY_TYPE].optionSets[APPROVE_CHOICE.optionSet]; - const isApproved = - record.status === APPROVAL_STATUSES.awaiting - ? isChoiceSelected(prompt, optionSet, APPROVE_CHOICE.choice) - : isChoiceNamed(prompt, optionSet, APPROVE_CHOICE.choice); - writeOrderApproval(projectRoot, { - ...record, - status: isApproved ? APPROVAL_STATUSES.approved : DECLINED_STATUS, - updatedAt: now, - }); -} - -/** - * 工单发布之后用掉审阅记录; 内容改动后再发布要重新审阅. - * - * @param {string} projectRoot 项目根目录. - * @returns {void} - */ -export function consumeApproval(projectRoot) { - removeOrderApproval(projectRoot); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/order-base.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/order-base.mjs deleted file mode 100644 index 4dd82f7..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/order-base.mjs +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @file 工单的基准提交: 骨架预填, 审阅与发布前核对. - * - * 基准提交是执行子代理开工时仓库所在的提交: 执行子代理拿它与当前提交对账, 验收 - * 子代理拿它做 git diff. 工单文件与状态各记一份, 都以发布时的当前提交为准: - * 骨架预填当前提交, 审阅与发布前核对工单文件中的值, 发布时把状态中的值更新为 - * 当前提交. 新建工单之后仓库有了新提交 (例如升级插件后的提交) 时, 两处不会不一致. - */ - -import { findSection, readKeyValues } from "./layout.mjs"; -import { headCommit } from "./repo.mjs"; - -/** - * 工单文件中放基准提交的节, 与规格 files.order 中的节标题一致. - * @type {string} - */ -const OVERVIEW_SECTION = "工单概况"; - -/** - * 基准提交的键名, 与规格 files.order 中的键名一致. - * @type {string} - */ -export const BASE_COMMIT_KEY = "基准提交"; - -/** - * 工单文件中可以写提交哈希的缩写, 缩写至少这么多位. - * @type {number} - */ -const MIN_ABBREVIATION_LENGTH = 7; - -/** - * 工单骨架中预填的键值: 基准提交写当前提交. - * - * @param {string | undefined} projectRoot 项目根目录. - * @returns {Record} 键名到值的映射; 仓库没有提交时为空. - */ -export function orderSkeletonValues(projectRoot) { - const head = projectRoot === undefined ? undefined : headCommit(projectRoot); - return head === undefined ? {} : { [BASE_COMMIT_KEY]: head }; -} - -/** - * 读取工单文件中写的基准提交, 去掉行内代码的反引号. - * - * @param {string} orderText 工单文件内容. - * @returns {string | undefined} 基准提交; 没有这个键时为 undefined. - */ -export function readOrderBaseCommit(orderText) { - const section = findSection(orderText, OVERVIEW_SECTION); - const value = - section === undefined ? undefined : readKeyValues(section)[BASE_COMMIT_KEY]; - return value?.replaceAll("`", "").trim(); -} - -/** - * 找出工单因基准提交不能审阅或发布的原因: 工单文件中的基准提交必须是当前提交. - * - * @param {object} options 核对参数. - * @param {string} options.orderId 工单编号. - * @param {string | undefined} options.orderText 工单文件内容; 文件不存在时为 undefined. - * @param {string | undefined} options.head 当前提交的完整哈希; 仓库没有提交时为 undefined. - * @returns {string | undefined} 原因, 写明补救办法; 一致时为 undefined. - */ -export function baseCommitBlocker({ orderId, orderText, head }) { - if (head === undefined) { - return "仓库还没有任何提交, 请先完成首次提交再发布工单."; - } - const written = readOrderBaseCommit(orderText ?? ""); - const isCurrent = - written !== undefined && - written.length >= MIN_ABBREVIATION_LENGTH && - head.startsWith(written); - return isCurrent - ? undefined - : `工单 ${orderId} 的 "${OVERVIEW_SECTION}" 中 "${BASE_COMMIT_KEY}" 写的是 "${written ?? "无"}", 当前提交是 ${head}: 执行子代理按当前提交对账, 验收子代理按基准提交比对改动. 把 "${BASE_COMMIT_KEY}" 改为 ${head}.`; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/paths.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/paths.mjs index f12b234..6a0a992 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/paths.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/paths.mjs @@ -3,7 +3,7 @@ * * 运行脚本只在插件目录中运行: 命令经插件 `bin/` 中的 `project-navigator` 调用, * hook 由插件的 `hooks/hooks.json` 调用. 所有相对路径都在这里定义, 其它模块不写 - * 路径字面量. 以 LEGACY 开头的常量是 1.0.0 之前写进项目的内容, 只供升级时清理. + * 路径字面量. */ import { existsSync, readFileSync, statSync } from "node:fs"; @@ -23,189 +23,115 @@ export const COMMAND_NAME = "project-navigator"; export const NAVIGATOR_DIRECTORY = ".navigator"; /** - * 状态目录中各子路径, 相对于状态目录. - * @type {Readonly>} + * 1.x 的状态目录归档后的目录名, 相对于项目根目录. + * @type {string} */ -export const NAVIGATOR_ENTRIES = Object.freeze({ - state: "state.json", - plan: "plan", - orders: "orders", - drafts: "drafts", - ignoreFile: ".gitignore", -}); +export const ARCHIVE_DIRECTORY = ".navigator-1.x"; /** * 状态文件, 相对于项目根目录. * @type {string} */ -export const STATE_FILE = `${NAVIGATOR_DIRECTORY}/${NAVIGATOR_ENTRIES.state}`; +export const STATE_FILE = `${NAVIGATOR_DIRECTORY}/state.json`; /** * 状态目录自己的 `.gitignore`, 相对于项目根目录; 由 init 写入. * @type {string} */ -export const NAVIGATOR_IGNORE_FILE = `${NAVIGATOR_DIRECTORY}/${NAVIGATOR_ENTRIES.ignoreFile}`; +export const NAVIGATOR_IGNORE_FILE = `${NAVIGATOR_DIRECTORY}/.gitignore`; /** - * 草稿目录, 相对于项目根目录; 编排会话在这里写命令的输入, 不入库. + * 路线视图, 相对于项目根目录; 由脚本从状态文件整份生成. * @type {string} */ -export const DRAFTS_DIRECTORY = `${NAVIGATOR_DIRECTORY}/${NAVIGATOR_ENTRIES.drafts}`; +export const ROADMAP_FILE = `${NAVIGATOR_DIRECTORY}/roadmap.md`; /** - * 工单文件夹所在目录, 相对于项目根目录. + * 需求文件, 相对于项目根目录; 由编排会话在立项时写入. * @type {string} */ -export const ORDERS_DIRECTORY = `${NAVIGATOR_DIRECTORY}/${NAVIGATOR_ENTRIES.orders}`; +export const REQUIREMENTS_FILE = `${NAVIGATOR_DIRECTORY}/requirements.md`; /** - * 工单文件夹中各文件的文件名. - * @type {Readonly<{order: string, receipt: string, review: string}>} + * 决策记录, 相对于项目根目录; 由 decision 命令追加条目. + * @type {string} */ -export const ORDER_FILES = Object.freeze({ - order: "order.md", - receipt: "receipt.md", - review: "review.md", -}); +export const DECISIONS_FILE = `${NAVIGATOR_DIRECTORY}/decisions.md`; /** - * 工单文件夹中存放证据文件的子目录名: 执行子代理留下的抓包原文, 复现脚本等 - * 供验收独立复核的产物. + * 待办文件, 相对于项目根目录; 记录当前切片之外的想法. * @type {string} */ -export const ORDER_ARTIFACTS_DIRECTORY = "artifacts"; +export const BACKLOG_FILE = `${NAVIGATOR_DIRECTORY}/backlog.md`; /** - * 项目规范文件, 相对于项目根目录. 项目内所有会话自动加载, 由 standards 命令写入. + * 切片文件所在目录, 相对于项目根目录. * @type {string} */ -export const ENGINEERING_RULES_FILE = ".claude/rules/engineering.md"; +export const SLICES_DIRECTORY = `${NAVIGATOR_DIRECTORY}/slices`; /** - * 本机专用的 Claude Code 配置文件, 相对于项目根目录. 不入库. + * 只产出结论的切片存放产物的目录, 相对于项目根目录. * @type {string} */ -export const PROJECT_LOCAL_SETTINGS_FILE = ".claude/settings.local.json"; +export const FINDINGS_DIRECTORY = `${NAVIGATOR_DIRECTORY}/findings`; /** - * 项目级 Claude Code 配置文件, 相对于项目根目录. + * 草稿目录, 相对于项目根目录; 编排会话在这里写命令的输入, 不入库. * @type {string} */ -export const PROJECT_SETTINGS_FILE = ".claude/settings.json"; +export const DRAFTS_DIRECTORY = `${NAVIGATOR_DIRECTORY}/drafts`; /** - * 推进路线文件, 相对于项目根目录; 由脚本从状态文件整份生成. + * 项目规范文件, 相对于项目根目录. 项目内所有会话自动加载, 由 setup 命令写入. * @type {string} */ -export const ROADMAP_FILE = ".navigator/plan/roadmap.md"; +export const ENGINEERING_RULES_FILE = ".claude/rules/engineering.md"; /** - * 风险清单文件, 相对于项目根目录; 由脚本从状态文件整份生成. + * 本机专用的 Claude Code 配置文件, 相对于项目根目录. 不入库. * @type {string} */ -export const RISKS_FILE = ".navigator/plan/risks.md"; +export const PROJECT_LOCAL_SETTINGS_FILE = ".claude/settings.local.json"; /** - * 术语表, 相对于项目根目录; init 写入流程术语, 编排会话追加项目术语. + * 项目级 Claude Code 配置文件, 相对于项目根目录. * @type {string} */ -export const GLOSSARY_FILE = ".navigator/plan/glossary.md"; +export const PROJECT_SETTINGS_FILE = ".claude/settings.json"; /** * 由脚本生成, 禁止用工具直接编辑的文件, 相对于项目根目录. * @type {readonly string[]} */ -export const GENERATED_FILES = Object.freeze([ - STATE_FILE, - ROADMAP_FILE, - RISKS_FILE, -]); - -/** - * 工单审阅记录在运行期登记目录中的文件名: 用户在 "工单审阅" 中对哪张工单的 - * 哪一版内容作出了选择. - * @type {string} - */ -export const ORDER_APPROVAL_FILE = "order-approval.json"; +export const GENERATED_FILES = Object.freeze([STATE_FILE, ROADMAP_FILE]); /** - * 技能异常记录在运行期登记目录中的文件名: 脚本自己发现的内部错误, hook 错误, - * 状态不自洽等, 供生成问题报告. - * @type {string} - */ -export const DIAGNOSTICS_FILE = "diagnostics.json"; - -/** - * 最近一次给出的下一动作在运行期登记目录中的文件名, 用来发现 "照着做却被拒" - * 与原地打转. - * @type {string} - */ -export const GUIDANCE_FILE = "guidance.json"; - -/** - * 问题报告所在目录, 相对于项目根目录. 在草稿目录之下, 不入库. - * @type {string} - */ -export const REPORTS_DIRECTORY = `${DRAFTS_DIRECTORY}/reports`; - -/** - * 运行期登记信息在 Git 目录中的子目录名. 不入库, 也不受回退影响. - * @type {string} - */ -export const RUNTIME_REGISTRY_DIRECTORY = "navigator"; - -/** - * 源码证据缓存在运行期登记目录中的子目录名: 每个第三方仓库一个裸仓库. - * 放在 Git 目录中, 不进入工作区, 检查工具与格式化工具都扫不到. - * @type {string} - */ -export const EVIDENCE_CACHE_DIRECTORY = "evidence"; - -/** - * 1.0.0 之前复制进项目的运行脚本目录, 相对于项目根目录. - * @type {string} - */ -export const LEGACY_BIN_DIRECTORY = ".navigator/bin"; - -/** - * 1.0.0 之前复制进项目的执行手册目录, 相对于项目根目录. - * @type {string} - */ -export const LEGACY_GUIDE_DIRECTORY = ".navigator/guide"; - -/** - * 1.0.0 之前的项目级 hook 引用的入口脚本, 用于从项目配置中移除这些 hook. - * @type {string} - */ -export const LEGACY_HOOK_ENTRY_ARGUMENT = - "${CLAUDE_PROJECT_DIR}/.navigator/bin/runtime/hook.mjs"; - -/** - * 1.0.0 之前的放行规则中引用的项目内命令路径. - * @type {string} - */ -export const LEGACY_COMMAND_PATH = ".navigator/bin/runtime/navigator.mjs"; - -/** - * 1.0.0 之前写在运行期登记目录中的文件与目录: 会话登记, 待并入快照的写入, - * 用户消息记录, 提交结算记录, 自检请求与心跳. + * 技能产物的范围: 状态目录与插件管理的项目配置. 草稿由状态目录的忽略规则排除. * @type {readonly string[]} */ -export const LEGACY_REGISTRY_ENTRIES = Object.freeze([ - "orchestrator.json", - "sessions", - "pending-writes", - "prompts.json", - "commit-settlement.json", - "probe.json", - "probe-request.json", +export const COMMITTED_PATHSPECS = Object.freeze([ + NAVIGATOR_DIRECTORY, + PROJECT_SETTINGS_FILE, + ENGINEERING_RULES_FILE, ]); /** - * 1.0.0 之前存放状态目录快照的隐藏引用. - * @type {string} + * 必须能入库的路径, 用于检查项目的忽略规则; 切片与结论各取一个示例位置. + * @type {readonly string[]} */ -export const LEGACY_SNAPSHOT_REF = "refs/navigator/snapshots"; +export const REQUIRED_TRACKED_PATHS = Object.freeze([ + NAVIGATOR_IGNORE_FILE, + STATE_FILE, + ROADMAP_FILE, + REQUIREMENTS_FILE, + DECISIONS_FILE, + BACKLOG_FILE, + `${SLICES_DIRECTORY}/0001-sample.md`, + `${FINDINGS_DIRECTORY}/sample.md`, + PROJECT_SETTINGS_FILE, + ENGINEERING_RULES_FILE, +]); /** * 技能目录的绝对路径, 由本文件位置 (`<技能目录>/runtime/lib/paths.mjs`) 推出. @@ -218,10 +144,10 @@ export const SKILL_ROOT = path.resolve( ); /** - * 模板规格文件的绝对路径. + * 切片文件规格的绝对路径. * @type {string} */ -export const SPEC_FILE = path.join(SKILL_ROOT, "spec", "templates.json"); +export const SLICE_SPEC_FILE = path.join(SKILL_ROOT, "spec", "slice.json"); /** * 通用工程规范默认包的绝对路径. @@ -234,54 +160,44 @@ export const STANDARDS_FILE = path.join( ); /** - * 技能目录中执行手册的绝对路径, 由生成脚本产出. + * 实现手册的绝对路径. * @type {string} */ -export const GUIDE_SOURCE_FILE = path.join(SKILL_ROOT, "guide", "executor.md"); +export const GUIDE_FILE = path.join(SKILL_ROOT, "guide", "executor.md"); /** - * 技能目录中参考文件所在目录的绝对路径. + * 参考文件所在目录的绝对路径. * @type {string} */ export const REFERENCES_DIRECTORY = path.join(SKILL_ROOT, "references"); /** - * 当前平台的文件系统是否不区分大小写. - * @type {boolean} + * 插件共享的标点规则文件的绝对路径. + * @type {string} */ -const IS_CASE_INSENSITIVE = process.platform === "win32"; +export const PUNCTUATION_FILE = path.resolve( + SKILL_ROOT, + "..", + "..", + "shared", + "punctuation.md", +); /** - * 返回状态目录中某个条目的绝对路径. - * - * @param {string} projectRoot 项目根目录的绝对路径. - * @param {keyof typeof NAVIGATOR_ENTRIES} [entry] 条目名; 省略时返回状态目录本身. - * @returns {string} 绝对路径. + * 当前平台的文件系统是否不区分大小写. + * @type {boolean} */ -export function navigatorPath(projectRoot, entry) { - const base = path.join(projectRoot, NAVIGATOR_DIRECTORY); - return entry === undefined ? base : path.join(base, NAVIGATOR_ENTRIES[entry]); -} +const IS_CASE_INSENSITIVE = process.platform === "win32"; /** - * 返回工单文件夹中某个文件的项目相对路径. + * 返回切片文件的项目相对路径. * - * @param {string} folder 工单文件夹名, 例如 "0007-export-csv". - * @param {keyof typeof ORDER_FILES} kind 文件种类. + * @param {string} id 切片编号, 例如 "0003". + * @param {string} slug 短名, 例如 "export-csv". * @returns {string} 以正斜杠分隔的项目相对路径. */ -export function orderFilePath(folder, kind) { - return `${ORDERS_DIRECTORY}/${folder}/${ORDER_FILES[kind]}`; -} - -/** - * 返回工单文件夹中证据文件目录的项目相对路径. - * - * @param {string} folder 工单文件夹名, 例如 "0007-export-csv". - * @returns {string} 以正斜杠分隔的项目相对路径, 不带结尾斜杠. - */ -export function orderArtifactsPath(folder) { - return `${ORDERS_DIRECTORY}/${folder}/${ORDER_ARTIFACTS_DIRECTORY}`; +export function sliceFilePath(id, slug) { + return `${SLICES_DIRECTORY}/${id}-${slug}.md`; } /** @@ -330,29 +246,6 @@ export function gitCommonDirectory(worktreeRoot) { return path.resolve(gitDirectory, readFileSync(commonFile, "utf8").trim()); } -/** - * 返回运行期登记目录的绝对路径. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {string} 登记目录的绝对路径. - */ -export function registryDirectory(worktreeRoot) { - return path.join( - gitCommonDirectory(worktreeRoot), - RUNTIME_REGISTRY_DIRECTORY, - ); -} - -/** - * 返回源码证据缓存目录的绝对路径. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {string} 缓存目录的绝对路径. - */ -export function evidenceCacheDirectory(worktreeRoot) { - return path.join(registryDirectory(worktreeRoot), EVIDENCE_CACHE_DIRECTORY); -} - /** * 把绝对路径转换成相对于项目根目录, 以正斜杠分隔的路径. * diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/project-settings.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/project-settings.mjs new file mode 100644 index 0000000..2769eef --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/project-settings.mjs @@ -0,0 +1,166 @@ +/** + * @file 项目设置: 模块组织方式, 验证方式与项目约定的校验, 建议与项目规范的生成. + * + * 模块组织方式决定工程规范中 "模块如何表达" 一段的写法: 默认包中两种写法各占 + * 一个带标记的块, 写入项目时只保留对应的一块. + */ + +import { + AUTO_VERIFICATION, + ORGANIZATIONS, + ORGANIZATION_LABELS, + VERIFICATION_LABELS, +} from "./state.mjs"; +import { requireLabel } from "./validation.mjs"; +import { WorkflowError } from "./workflow-error.mjs"; + +/** + * 表明项目是 Visual Studio 项目的文件扩展名. + * @type {readonly string[]} + */ +const VISUAL_STUDIO_EXTENSIONS = Object.freeze([ + ".sln", + ".vcxproj", + ".csproj", + ".vbproj", +]); + +/** + * 默认包中按模块组织方式取舍的块: 开始标记, 内容, 结束标记. + * @type {RegExp} + */ +const VARIANT_BLOCK_PATTERN = + /\n([\s\S]*?)\n?/gu; + +/** + * 写入项目规范时, 项目约定一节的标题. + * @type {string} + */ +const CONVENTIONS_HEADING = "## 项目约定"; + +/** + * 写入项目规范时, 一级标题之后的说明. + * @type {string} + */ +const RULES_NOTICE = + "本文件由 project-navigator 的 setup 命令写入, 不要直接编辑; 需要修改时在编排会话中重新运行 setup."; + +/** + * @typedef {object} SettingsDraft 项目设置草稿. + * @property {string} organization 模块组织方式. + * @property {{mode: string, commands?: string[]}} verification 验证方式. + * @property {string[]} [conventions] 项目自己的约定. + */ + +/** + * 按已跟踪的文件给出模块组织方式的建议. + * + * @param {readonly string[]} trackedFiles 已跟踪文件的项目相对路径. + * @returns {{organization: string, evidence: string[]}} 建议的组织方式, 以及作为依据的工程文件. + */ +export function suggestOrganization(trackedFiles) { + const evidence = trackedFiles.filter((file) => + VISUAL_STUDIO_EXTENSIONS.some((extension) => + file.toLowerCase().endsWith(extension), + ), + ); + return { + organization: + evidence.length > 0 ? ORGANIZATIONS.filters : ORGANIZATIONS.directories, + evidence, + }; +} + +/** + * 校验项目设置草稿. + * + * @param {SettingsDraft} draft 草稿. + * @returns {import("./state.mjs").ProjectSettings} 校验后的项目设置. + * @throws {WorkflowError} 草稿不合法时. + */ +export function validateSettings(draft) { + const organization = requireLabel( + draft?.organization, + ORGANIZATION_LABELS, + "模块组织方式 (organization)", + ); + const mode = requireLabel( + draft.verification?.mode, + VERIFICATION_LABELS, + "验证方式 (verification.mode)", + ); + const commands = requireStrings( + draft.verification.commands ?? [], + "验证命令 (verification.commands)", + ); + if (mode === AUTO_VERIFICATION && commands.length === 0) { + throw new WorkflowError( + `验证方式为 ${AUTO_VERIFICATION} 时, verification.commands 至少写一条能在仓库根目录直接运行的命令.`, + ); + } + return { + organization, + verification: { mode, commands }, + conventions: requireStrings( + draft.conventions ?? [], + "项目约定 (conventions)", + ), + }; +} + +/** + * 生成写入项目的工程规范. + * + * @param {object} options 生成参数. + * @param {string} options.standards 默认包全文. + * @param {string} options.organization 模块组织方式. + * @param {readonly string[]} options.conventions 项目自己的约定. + * @returns {string} 项目规范全文. + */ +export function renderEngineeringRules({ + standards, + organization, + conventions, +}) { + const [title, ...rest] = standards + .replace(/\r\n?/gu, "\n") + .replace(VARIANT_BLOCK_PATTERN, (_block, variant, content) => + variant === organization ? content : "", + ) + .replace(/\n{3,}/gu, "\n\n") + .trimEnd() + .split("\n"); + return [ + title, + "", + RULES_NOTICE, + ...rest, + ...(conventions.length === 0 + ? [] + : [ + "", + CONVENTIONS_HEADING, + "", + ...conventions.map((item) => `- ${item}`), + ]), + "", + ].join("\n"); +} + +/** + * 确认值是字符串数组, 并去掉每项的首尾空白. + * + * @param {unknown} value 值. + * @param {string} label 字段名, 用于错误信息. + * @returns {string[]} 字符串数组. + * @throws {WorkflowError} 值不是字符串数组, 或其中有空字符串时. + */ +function requireStrings(value, label) { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== "string" || item.trim() === "") + ) { + throw new WorkflowError(`${label}应为字符串数组, 每项不为空.`); + } + return value.map((item) => item.trim()); +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/receipt-record.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/receipt-record.mjs deleted file mode 100644 index 3304963..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/receipt-record.mjs +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @file 读取当前工单回执中的执行状态, 供下一动作判断受阻还是完成. - */ - -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; - -import { findSection, readKeyValues } from "./layout.mjs"; -import { orderFilePath } from "./paths.mjs"; - -/** - * 回执中放执行状态的节, 与规格 files.receipt 中的节标题一致. - * @type {string} - */ -const OVERVIEW_SECTION = "回执概况"; - -/** - * 回执概况中写执行状态的键. - * @type {string} - */ -const STATUS_KEY = "执行状态"; - -/** - * 读取当前工单回执中的执行状态. - * - * @param {string} projectRoot 项目根目录. - * @param {{folder: string}} order 当前工单. - * @returns {string | undefined} 执行状态; 回执不存在时为 undefined, 回执中没有这一项时为空字符串. - */ -export function readReceiptStatus(projectRoot, order) { - const file = path.join(projectRoot, orderFilePath(order.folder, "receipt")); - if (!existsSync(file)) { - return undefined; - } - const section = findSection(readFileSync(file, "utf8"), OVERVIEW_SECTION); - return section === undefined - ? "" - : (readKeyValues(section)[STATUS_KEY] ?? ""); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/registry.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/registry.mjs deleted file mode 100644 index 985b84c..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/registry.mjs +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @file 运行期登记信息的读写, 位于 Git 公共目录下的 `navigator/`. - * - * 这里只存工单审阅记录: 不入库, 也不应随回退丢失. Git 目录不受 `reset`, - * `checkout`, `clean` 影响. hook 会读写这个文件, 所以一律原子写入. - */ - -import { existsSync, readFileSync, rmSync } from "node:fs"; -import path from "node:path"; - -import { writeJsonAtomically } from "./atomic-file.mjs"; -import { ORDER_APPROVAL_FILE, registryDirectory } from "./paths.mjs"; - -/** - * @typedef {object} OrderApprovalRecord 用户对一张工单的审阅. - * @property {string} order 工单编号. - * @property {string} digest 审阅时工单文件内容的摘要. - * @property {"awaiting" | "approved" | "declined"} status 等待用户选择, 用户已选发布, 或用户没有选发布. - * @property {string} updatedAt 最后更新时间. - */ - -/** - * 读取工单审阅记录. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {OrderApprovalRecord | undefined} 审阅记录; 没有时为 undefined. - */ -export function readOrderApproval(worktreeRoot) { - const file = orderApprovalPath(worktreeRoot); - return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined; -} - -/** - * 写入工单审阅记录. - * - * @param {string} worktreeRoot 工作区根目录. - * @param {OrderApprovalRecord} record 审阅记录. - * @returns {void} - */ -export function writeOrderApproval(worktreeRoot, record) { - writeJsonAtomically(orderApprovalPath(worktreeRoot), record); -} - -/** - * 删除工单审阅记录: 已用于发布工单, 或工单文件不存在. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {void} - */ -export function removeOrderApproval(worktreeRoot) { - rmSync(orderApprovalPath(worktreeRoot), { force: true }); -} - -/** - * 返回工单审阅记录的路径. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {string} 文件路径. - */ -function orderApprovalPath(worktreeRoot) { - return path.join(registryDirectory(worktreeRoot), ORDER_APPROVAL_FILE); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/reminders.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/reminders.mjs deleted file mode 100644 index 744b89c..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/reminders.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @file hook 注入给编排会话的提醒文字: 每次收到消息时的位置与要点, 上下文压缩后 - * 重新查询下一动作. - */ - -import { REPORT_STEP } from "./diagnostics.mjs"; -import { COMMAND_NAME } from "./paths.mjs"; -import { progressLine } from "./render.mjs"; - -/** - * 编排会话每次收到消息时重申的要点. - * @type {readonly string[]} - */ -const ORCHESTRATOR_POINTS = Object.freeze([ - "不写业务代码, 实现交给执行子代理", - `回复前运行 ${COMMAND_NAME} reply <编号> 取骨架, 第一行写一句给人看的结论`, - "需要用户选择时用回复末尾的选项块", -]); - -/** - * 生成编排会话的提醒: 当前位置与要点. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string} 提醒文字. - */ -export function orchestratorReminder(state, spec) { - return [ - `[project-navigator] 你是编排会话. ${progressLine(state, spec)}.`, - `要点: ${ORCHESTRATOR_POINTS.map((point, index) => `${index + 1}. ${point}`).join("; ")}.`, - ].join("\n"); -} - -/** - * 生成有未上报的技能异常时给编排会话的提醒. - * - * @param {number} count 未上报的异常条数. - * @returns {string[]} 提醒各行; 没有异常时为空. - */ -export function diagnosticsReminder(count) { - return count > 0 - ? [`技能异常: 脚本记录到 ${count} 条还没有上报的异常. ${REPORT_STEP}.`] - : []; -} - -/** - * 生成上下文压缩后给编排会话的提醒: 在位置与要点之外, 提示重新查询下一动作. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string} 提醒文字. - */ -export function orchestratorCompactReminder(state, spec) { - return [ - orchestratorReminder(state, spec), - `上下文刚被压缩: 先运行 ${COMMAND_NAME} status 查看最后动作与下一动作, 再继续.`, - ].join("\n"); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/render-plan.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/render-plan.mjs deleted file mode 100644 index 9c381e4..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/render-plan.mjs +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @file 由状态整份生成 `plan/roadmap.md` 与 `plan/risks.md`. - * - * 这两个文件只是状态的视图, 禁止手改; 每次状态变化后由命令行脚本重新生成, - * 保证表格中的编号与状态和 `state.json` 一致. - */ - -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import { RISKS_FILE, ROADMAP_FILE } from "./paths.mjs"; -import { renderTable } from "./table.mjs"; -import { PROGRESS_STATUS_LABELS } from "./workflow-plan.mjs"; -import { - RISK_SEVERITY_LABELS, - RISK_STATUS_LABELS, -} from "./workflow-records.mjs"; - -/** - * 由状态生成的视图文件, 相对于项目根目录. 每次保存状态都重新生成或删除, - * 保存后的快照要一并更新这些文件. - * @type {readonly string[]} - */ -export const PLAN_VIEW_FILES = Object.freeze([ROADMAP_FILE, RISKS_FILE]); - -/** - * 严重程度与状态的取值说明, 写在风险清单末尾. - * @type {readonly string[]} - */ -const RISK_LEGEND = Object.freeze([ - "- 严重程度: 高 = 可能推翻主线或架构; 中 = 影响一个里程; 低 = 影响一个切片", - "- 状态取值: 待处理; 调研中; 已处理; 已接受 (你确认不处理)", -]); - -/** - * 重新生成两个视图文件; 没有内容时删除对应文件. - * - * @param {string} projectRoot 项目根目录. - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {import("./spec.mjs").FormatSpec} format 全局版式规则. - * @returns {void} - */ -export function writePlanViews(projectRoot, state, format) { - writeOrRemove( - path.join(projectRoot, ROADMAP_FILE), - state.milestones.length === 0 ? undefined : renderRoadmap(state, format), - ); - writeOrRemove( - path.join(projectRoot, RISKS_FILE), - state.risks.length === 0 ? undefined : renderRisks(state, format), - ); -} - -/** - * 生成推进路线全文. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {import("./spec.mjs").FormatSpec} format 全局版式规则. - * @returns {string} 文件全文. - */ -export function renderRoadmap(state, format) { - const sections = state.milestones.flatMap((milestone) => { - const slices = state.slices.filter( - (slice) => slice.milestone === milestone.id, - ); - return [ - "", - `## 里程 ${milestone.id}: ${milestone.name}`, - "", - `- 里程目标: ${milestone.goal}`, - `- 覆盖指标: ${milestone.metrics.length === 0 ? format.emptyValue : milestone.metrics.join(", ")}`, - `- 里程状态: ${PROGRESS_STATUS_LABELS[milestone.status]}`, - "", - ...renderTable( - ["切片", "名称", "状态", "工单"], - slices.map((slice) => [ - slice.id, - slice.name, - PROGRESS_STATUS_LABELS[slice.status], - slice.orders.length === 0 - ? format.emptyValue - : slice.orders.join(", "), - ]), - ), - ]; - }); - const done = state.slices.filter((slice) => slice.status === "done").length; - const current = - state.slice === null ? format.emptyValue : `切片 ${state.slice}`; - const summary = `共 ${state.milestones.length} 个里程 ${state.slices.length} 个切片, 已完成 ${done} 个切片, 当前在做 ${current}.`; - return joinDocument(["# 推进路线", ...sections], summary, format); -} - -/** - * 生成风险清单全文. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {import("./spec.mjs").FormatSpec} format 全局版式规则. - * @returns {string} 文件全文. - */ -export function renderRisks(state, format) { - const open = state.risks.filter((risk) => risk.status === "open").length; - const summary = `共 ${state.risks.length} 项风险, 其中 ${open} 项待处理.`; - return joinDocument( - [ - "# 风险清单", - "", - "## 风险列表", - "", - ...renderTable( - ["编号", "风险描述", "严重程度", "来源", "状态", "处理"], - state.risks.map((risk) => [ - risk.id, - risk.description, - RISK_SEVERITY_LABELS[risk.severity], - risk.source, - RISK_STATUS_LABELS[risk.status], - risk.handling, - ]), - ), - "", - "## 取值说明", - "", - ...RISK_LEGEND, - ], - summary, - format, - ); -} - -/** - * 生成术语表的初始内容: 流程术语取自规格, 项目术语留空待编排会话追加. - * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string} 文件全文. - */ -export function renderGlossarySeed(spec) { - const [processTitle, projectTitle] = spec.files.glossary.sections.map( - (section) => section.title, - ); - return joinDocument( - [ - `# ${spec.files.glossary.title}`, - "", - `## ${processTitle}`, - "", - ...renderTable(["术语", "定义"], spec.processTerms), - "", - `## ${projectTitle}`, - "", - spec.format.emptyValue, - ], - `术语表含 ${spec.processTerms.length} 个流程术语, 项目术语在立项时补充.`, - spec.format, - ); -} - -/** - * 在一级标题之后加上一行结论, 拼成以换行结尾的全文. - * - * @param {string[]} lines 正文各行, 第一行是一级标题. - * @param {string} summary 结论正文. - * @param {import("./spec.mjs").FormatSpec} format 全局版式规则. - * @returns {string} 文件全文. - */ -function joinDocument(lines, summary, format) { - const [title, ...rest] = lines; - return `${[title, "", `${format.conclusionLabel}: ${summary}`, ...rest].join("\n")}\n`; -} - -/** - * 写入文件, 内容为 undefined 时删除文件. - * - * @param {string} file 文件路径. - * @param {string | undefined} content 文件内容. - * @returns {void} - */ -function writeOrRemove(file, content) { - if (content === undefined) { - rmSync(file, { force: true }); - return; - } - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, content, "utf8"); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/render.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/render.mjs deleted file mode 100644 index eb4fd16..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/render.mjs +++ /dev/null @@ -1,284 +0,0 @@ -/** - * @file 根据状态与模板规格生成进展行, 记录文件的 "当前进展" 一节, 以及回复与 - * 记录文件的骨架. - * - * 编排会话先取得骨架再填写内容, 进展由脚本计算, 避免模型自己数 "2/5" 出错. - */ - -import { findReply, lastStageNumber } from "./spec.mjs"; -import { renderTable } from "./table.mjs"; - -/** - * 骨架中待填写的位置标记; 校验时发现它残留即判为未完成. - * @type {string} - */ -export const PLACEHOLDER = "<待填写>"; - -/** - * 围栏代码块使用的三个反引号. - * @type {string} - */ -const CODE_FENCE = "```"; - -/** - * 选项的字母前缀, 按顺序使用. - * @type {string} - */ -export const OPTION_LETTERS = "ABCDEFGH"; - -/** - * 计算进展四项的值: 阶段, 里程, 切片, 工单. - * - * @param {import("./state.mjs").NavigatorState | undefined} state 状态; 未初始化时为 undefined. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 与 `format.progressKeys` 顺序一致的值. - */ -export function progressValues(state, spec) { - const empty = spec.format.emptyValue; - if (state === undefined) { - return spec.format.progressKeys.map(() => empty); - } - return [ - formatStage(state.stage, spec), - formatPosition(state.milestones, state.milestone, empty), - formatPosition(state.slices, state.slice, empty), - state.order?.id ?? formatNoOrder(state.orders, empty), - ]; -} - -/** - * 生成回复中的一行进展, 例如 - * "阶段 5/6 (切片) · 里程 3/5 (导出) · 切片 4/10 (报表) · 工单 0007 (执行中)". - * 有当前工单时, 工单编号之后写工单所处的环节, 连续几条回复靠它区分先后. - * - * @param {import("./state.mjs").NavigatorState | undefined} state 状态. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @param {string} [orderStage] 工单所处的环节; 省略时按工单状态取. - * @returns {string} 进展行. - */ -export function progressLine(state, spec, orderStage) { - const values = progressValues(state, spec); - const order = state?.order ?? null; - const stage = - order === null - ? undefined - : (orderStage ?? spec.format.orderStages[order.status]); - const lastIndex = values.length - 1; - return spec.format.progressLabels - .map((label, index) => - index === lastIndex && stage !== undefined - ? `${label} ${values[index]} (${stage})` - : `${label} ${values[index]}`, - ) - .join(spec.format.progressSeparator); -} - -/** - * 生成记录文件的 "当前进展" 一节, 含二级标题. - * - * @param {import("./state.mjs").NavigatorState | undefined} state 状态. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 各行文本. - */ -export function renderProgressSection(state, spec) { - const values = progressValues(state, spec); - return [ - `## ${spec.format.progressTitle}`, - "", - ...spec.format.progressKeys.map( - (key, index) => `- ${key}: ${values[index]}`, - ), - ]; -} - -/** - * 生成某种回复的骨架: 一级标题, 结论行, 进展行, 各节与选项块. 规格中没有 - * 选项组的回复不需要用户选择, 骨架不带选项块. - * - * @param {object} options 生成参数. - * @param {string} options.type 回复类型. - * @param {number} [options.optionSetIndex] 使用第几组选项, 从 0 开始. - * @param {import("./state.mjs").NavigatorState | undefined} options.state 状态. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @param {Readonly>} [options.values] 键名到预填值的映射, 用于脚本已知的键值. - * @returns {string} 骨架文本, 以换行结尾. - * @throws {Error} 回复类型或选项组不存在时. - */ -export function renderReplySkeleton({ - type, - optionSetIndex = 0, - state, - spec, - values = {}, -}) { - const reply = findReply(spec, type); - if (reply === undefined) { - throw new Error(`render: 未知的回复类型 "${type}"`); - } - const hasOptions = reply.optionSets.length > 0; - const optionSet = reply.optionSets[optionSetIndex]; - if (hasOptions && optionSet === undefined) { - throw new Error(`render: "${type}" 没有第 ${optionSetIndex + 1} 组选项`); - } - const lines = [ - `# ${type}`, - "", - `${spec.format.conclusionLabel}: ${PLACEHOLDER}`, - "", - progressLine(state, spec, reply.orderStage), - ...reply.sections.flatMap((section) => [ - "", - ...renderSection(section, values), - ]), - ...(hasOptions ? ["", ...renderOptionBlock(optionSet)] : []), - ]; - return `${lines.join("\n")}\n`; -} - -/** - * 生成某种记录文件的骨架: 一级标题, 当前进展 (如有) 与固定节. - * 只追加的条目在文件创建后逐条添加, 不在骨架中. - * - * @param {object} options 生成参数. - * @param {import("./file-checks.mjs").FileSpec} options.fileSpec 文件规格. - * @param {number} [options.variantIndex] 使用第几种固定节序列, 从 0 开始. - * @param {import("./state.mjs").NavigatorState | undefined} options.state 状态. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @param {Readonly>} [options.prefills] 节标题到预填行的映射, 预填行放在占位标记之前. - * @param {Readonly>} [options.values] 键名到预填值的映射, 用于脚本已知的键值. - * @returns {string} 骨架文本, 以换行结尾. - * @throws {Error} 固定节序列不存在时. - */ -export function renderFileSkeleton({ - fileSpec, - variantIndex = 0, - state, - spec, - prefills = {}, - values = {}, -}) { - const sections = fileSections(fileSpec, variantIndex); - const lines = [ - `# ${fileSpec.title}`, - ...(fileSpec.hasProgress === true - ? ["", ...renderProgressSection(state, spec)] - : []), - ...sections.flatMap((section) => [ - "", - ...renderSection(section, values, prefills[section.title]), - ]), - ]; - return `${lines.join("\n")}\n`; -} - -/** - * 取出某种记录文件的一种固定节序列. - * - * @param {import("./file-checks.mjs").FileSpec} fileSpec 文件规格. - * @param {number} variantIndex 使用第几种固定节序列, 从 0 开始. - * @returns {import("./spec.mjs").SectionSpec[]} 固定节序列. - * @throws {Error} 固定节序列不存在时. - */ -export function fileSections(fileSpec, variantIndex) { - const sections = (fileSpec.variants ?? [fileSpec.sections ?? []])[ - variantIndex - ]; - if (sections === undefined) { - throw new Error( - `render: "${fileSpec.title}" 没有第 ${variantIndex + 1} 种结构`, - ); - } - return sections; -} - -/** - * 生成选项代码块. - * - * @param {import("./spec.mjs").OptionSetSpec} optionSet 选项组. - * @returns {string[]} 代码块各行, 含围栏. - */ -export function renderOptionBlock(optionSet) { - return [ - CODE_FENCE, - `[${optionSet.title}]`, - "", - ...optionSet.choices.map( - (choice, index) => `${OPTION_LETTERS[index]}. ${choice}`, - ), - CODE_FENCE, - ]; -} - -/** - * 生成一个节的骨架: 表格节给出表头与一行占位; 键值行依次使用调用方给的值, - * 规格中的预填值, 其余留占位标记; 没有键名的节可以在占位标记之前预填若干行. - * - * @param {import("./spec.mjs").SectionSpec} section 节规格. - * @param {Readonly>} values 键名到预填值的映射. - * @param {readonly string[]} [prefill] 预填行. - * @returns {string[]} 各行文本, 含二级标题. - */ -function renderSection(section, values, prefill = []) { - if (section.table !== undefined) { - return [ - `## ${section.title}`, - "", - ...renderTable(section.table.columns, [ - section.table.columns.map(() => PLACEHOLDER), - ]), - ]; - } - const body = - section.keys === undefined - ? [...prefill, PLACEHOLDER] - : section.keys.map( - (key) => - `- ${key}: ${values[key] ?? section.presets?.[key] ?? PLACEHOLDER}`, - ); - return [`## ${section.title}`, "", ...body]; -} - -/** - * 没有当前工单时的写法: 有已结束的工单时附上最近一张的编号与结束状态, 例如 - * 验收不通过之后的验收报告仍能看出说的是哪张工单. - * - * @param {readonly {id: string, status: string}[] | undefined} orders 已结束的工单摘要. - * @param {string} empty 字段不适用时写的值. - * @returns {string} 当前工单一项的值. - */ -function formatNoOrder(orders, empty) { - const last = orders?.at(-1); - return last === undefined - ? empty - : `${empty} (上一张 ${last.id} 已结束, ${last.status})`; -} - -/** - * 格式化阶段, 例如 "5/6 (切片)". - * - * @param {number | null} stage 阶段编号. - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string} 显示值. - */ -function formatStage(stage, spec) { - if (stage === null) { - return spec.format.emptyValue; - } - return `${stage}/${lastStageNumber(spec)} (${spec.stages[stage]})`; -} - -/** - * 格式化里程或切片的位置, 例如 "2/5 (导出报表)". - * - * @param {{id: string, name: string}[]} entries 全部条目, 按顺序. - * @param {string | null} currentId 当前条目编号. - * @param {string} empty 不适用时的值. - * @returns {string} 显示值. - */ -function formatPosition(entries, currentId, empty) { - const index = entries.findIndex((entry) => entry.id === currentId); - if (currentId === null || index < 0) { - return empty; - } - return `${index + 1}/${entries.length} (${entries[index].name})`; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/reply-checks.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/reply-checks.mjs deleted file mode 100644 index 61bac61..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/reply-checks.mjs +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @file 按模板规格校验回复的结构. - * - * 回复结束时 Stop hook 调用这里的检查, 只查机器依赖的结构: 一级标题是回复类型 - * (据此识别回复), 标题下方有结论行, 选项块与规格逐字一致 (据此识别用户的选择), - * 不需要用户选择的回复不带选项块, 代码块都已关闭, 没有残留占位标记. 节, 键名与写作规则都只是填写要求中的提醒, - * 不在这里打回. 返回的问题列表直接作为打回原因交给模型. - */ - -import { parseMarkdown } from "./markdown.mjs"; -import { PLACEHOLDER } from "./render.mjs"; -import { findReply, isFreeformSection } from "./spec.mjs"; - -/** - * 选项行的格式: "A. 选项文字". - * @type {RegExp} - */ -const OPTION_LINE_PATTERN = /^([A-H])\. (.+)$/u; - -/** - * 选项块首行的格式: "[标题]". - * @type {RegExp} - */ -const OPTION_TITLE_PATTERN = /^\[(.+)\]$/u; - -/** - * @typedef {object} LocatedReply 回复在全文中的位置. - * @property {import("./markdown.mjs").MarkdownItem[]} preface 第一个一级标题之前的非空条目. - * @property {import("./markdown.mjs").HeadingItem | undefined} heading 第一个一级标题; 没有时为 undefined. - * @property {import("./markdown.mjs").MarkdownItem[]} body 第一个一级标题之后的非空条目. - */ - -/** - * 在全文中找出回复: 回复从第一个一级标题开始. 模型常在标题前加一句过程说明, - * 这部分不算回复内容; 打回它只会让用户看到两遍同样的回复. - * - * @param {string} text 回复全文. - * @returns {LocatedReply} 回复的位置. - */ -export function locateReply(text) { - const items = parseMarkdown(text).filter( - (item) => !(item.kind === "text" && item.text.trim() === ""), - ); - const start = items.findIndex( - (item) => item.kind === "heading" && item.level === 1, - ); - if (start === -1) { - return { preface: items, heading: undefined, body: [] }; - } - return { - preface: items.slice(0, start), - heading: /** @type {import("./markdown.mjs").HeadingItem} */ (items[start]), - body: items.slice(start + 1), - }; -} - -/** - * 校验一条回复的结构, 返回发现的问题. - * - * @param {object} options 校验参数. - * @param {string} options.text 回复全文. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @returns {string[]} 问题列表; 为空表示合格. - */ -export function checkReply({ text, spec }) { - const { preface, heading, body } = locateReply(text); - if (heading === undefined) { - return ['回复必须以一级标题写明回复类型, 例如 "# 首次接入".']; - } - const reply = findReply(spec, heading.text); - if (reply === undefined) { - return [`一级标题 "${heading.text}" 不是允许的回复类型.`]; - } - const blocks = body.filter((item) => item.kind === "block"); - const optionBlocks = blocks.filter((block) => isOptionBlock(block)); - const problems = []; - if (preface.some((item) => item.kind !== "text")) { - problems.push( - `一级标题 "# ${heading.text}" 之前只能写一句过程说明, 不能有代码块或其它标题.`, - ); - } - problems.push(...checkConclusion(body[0], spec.format)); - problems.push(...checkOptionBlocks(optionBlocks, reply)); - if (!reply.sections.some(isFreeformSection)) { - const stray = blocks.filter((block) => !isOptionBlock(block)); - if (stray.length > 0) { - problems.push("回复中只能有选项块一个代码块, 不能贴代码."); - } - } - if (blocks.some((block) => !block.isClosed)) { - problems.push("有代码块缺少关闭围栏."); - } - if (text.includes(PLACEHOLDER)) { - problems.push(`回复中仍有未填写的 "${PLACEHOLDER}" 标记.`); - } - return problems; -} - -/** - * 校验一级标题之后的第一行是结论行, 且结论不为空. - * - * @param {import("./markdown.mjs").MarkdownItem | undefined} first 一级标题之后的第一个非空条目. - * @param {import("./spec.mjs").FormatSpec} format 全局版式规则. - * @returns {string[]} 问题列表. - */ -function checkConclusion(first, format) { - const prefix = `${format.conclusionLabel}: `; - const isConclusion = - first?.kind === "text" && - first.text.startsWith(prefix) && - first.text.slice(prefix.length).trim() !== ""; - return isConclusion - ? [] - : [ - `一级标题下方第一行必须是结论行 "${prefix}<一句话结论>", 写给人看, 先说结果与要用户做的选择.`, - ]; -} - -/** - * 判断代码块是否为选项块: 无语言标记, 首行为方括号标题. - * - * @param {import("./markdown.mjs").BlockItem} block 代码块. - * @returns {boolean} 是选项块时返回 true. - */ -function isOptionBlock(block) { - return ( - block.language === "" && OPTION_TITLE_PATTERN.test(block.content[0] ?? "") - ); -} - -/** - * 校验选项块的数量: 规格中有选项组的回复恰好一个, 且与某一组一致; 没有选项组的 - * 回复不需要用户选择, 不能带选项块. - * - * @param {import("./markdown.mjs").BlockItem[]} optionBlocks 回复中的选项块. - * @param {import("./spec.mjs").ReplySpec} reply 回复规格. - * @returns {string[]} 问题列表. - */ -function checkOptionBlocks(optionBlocks, reply) { - if (reply.optionSets.length === 0) { - return optionBlocks.length === 0 - ? [] - : ["这种回复不需要用户选择, 不能带选项块."]; - } - return optionBlocks.length === 1 - ? checkOptionSet(optionBlocks[0], reply) - : ["回复结尾必须恰好有一个选项块."]; -} - -/** - * 校验选项块与规格中的某一组选项完全一致. - * - * @param {import("./markdown.mjs").BlockItem} block 选项块. - * @param {import("./spec.mjs").ReplySpec} reply 回复规格. - * @returns {string[]} 问题列表. - */ -function checkOptionSet(block, reply) { - const title = OPTION_TITLE_PATTERN.exec(block.content[0])?.[1]; - const choices = block.content - .slice(1) - .filter((line) => line.trim() !== "") - .map((line) => OPTION_LINE_PATTERN.exec(line)?.[2]); - const isMatch = reply.optionSets.some( - (set) => - set.title === title && - set.choices.length === choices.length && - set.choices.every((choice, index) => choice === choices[index]), - ); - return isMatch - ? [] - : ["选项块必须与规格中的固定选项逐字一致, 请用 reply 命令生成的选项块."]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/reply-guide.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/reply-guide.mjs deleted file mode 100644 index 7af8860..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/reply-guide.mjs +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @file reply 命令在骨架之前给出的填写要求: 版式要点, 按钮与结论行的写法, 以及写作建议. - * - * 回复结束时只校验结构 (标题, 结论行, 选项块); 结论的字数与写作规则都是提醒, - * 不打回. 内容全部由规格生成, 与体检使用同一份阈值与词表. - */ - -import { FREEFORM_RULE } from "./freeform-sections.mjs"; -import { choiceLead } from "./option-answer.mjs"; -import { PLACEHOLDER } from "./render.mjs"; -import { isFreeformSection } from "./spec.mjs"; -import { UNIT_RULE } from "./text-units.mjs"; -import { describeWritingRules } from "./writing-rules.mjs"; - -/** - * 写作建议中列出的规则级别. - * @type {string} - */ -const ADVISED_SEVERITY = "problem"; - -/** - * 按词表检查的写作规则, 填写要求中直接列出词表. - * @type {readonly string[]} - */ -const WORD_LIST_RULES = Object.freeze(["jargon", "metaphor"]); - -/** - * Claude 桌面应用中填写下一步框的工具; 其它环境没有这个工具. - * @type {string} - */ -export const BUTTON_TOOL = "mcp__ccd_turn__wrap_up"; - -/** - * 下一步框的按钮数量上限; 选项恰好这么多项时才做成按钮. - * @type {number} - */ -export const BUTTON_COUNT = 2; - -/** - * 下一步框中一个按钮的文字长度上限 (字符数). - * @type {number} - */ -export const BUTTON_LABEL_MAX_LENGTH = 24; - -/** - * 只适用于记录文件的写作规则. - * @type {readonly string[]} - */ -const FILE_ONLY_RULES = Object.freeze(["structure"]); - -/** - * 生成填写要求, 以 "骨架:" 与一个空行结束, 其后紧接骨架. - * - * @param {object} options 生成参数. - * @param {string} options.type 回复类型, 即骨架的一级标题. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @param {number} [options.optionSetIndex] 使用第几组选项, 从 0 开始. - * @returns {string[]} 各行. - */ -export function renderReplyGuide({ type, spec, optionSetIndex = 0 }) { - const reply = spec.replies[type]; - const kept = - reply.optionSets.length > 0 - ? "标题, 进展行与选项块原样保留" - : "标题与进展行原样保留; 这种回复不需要用户选择, 结尾不加选项块"; - return wrapGuide([ - `- 回复从下面的 "# ${type}" 写起, 前面不加引导语. 只替换 "${PLACEHOLDER}", ${kept}.`, - "- 各节之外不加文字: 补充说明与建议写进已有的节, 没有对应的节就不写. 每项只写结论与必要的依据, 不复述工单与回执.", - ...buttonLines(reply.optionSets[optionSetIndex]), - conclusionRule(spec.format), - ...freeformLines(spec.replies[type]), - ...writingLines(spec.writing), - ]); -} - -/** - * 给填写要求的各条加上开头 "填写要求:" 与结尾 "骨架:", 其后紧接骨架. - * reply 与 template 两个命令的输出共用这一格式. - * - * @param {readonly string[]} rules 各条要求, 每条一行. - * @returns {string[]} 各行. - */ -export function wrapGuide(rules) { - return ["填写要求:", "", ...rules, "", "骨架:", ""]; -} - -/** - * 判断一组选项能否做成应用的按钮: 恰好是按钮数量上限那么多项, 且规格没有关闭. - * - * @param {import("./spec.mjs").OptionSetSpec | undefined} optionSet 选项组; 回复没有选项时为 undefined. - * @returns {boolean} 能做成按钮时返回 true. - */ -export function hasButtons(optionSet) { - return ( - optionSet !== undefined && - optionSet.buttons !== false && - optionSet.choices.length === BUTTON_COUNT - ); -} - -/** - * 按钮的写法: 应用提供下一步框时, 把两个选项同时做成按钮, 用户点一下就能回答. - * 按钮文字是选项的第一个分句, 与打字回复走同一条识别; 选项块仍然保留. - * - * @param {import("./spec.mjs").OptionSetSpec | undefined} optionSet 选项组. - * @returns {string[]} 各行; 不做按钮时为空. - */ -function buttonLines(optionSet) { - if (optionSet === undefined || !hasButtons(optionSet)) { - return []; - } - const labels = optionSet.choices - .map((choice) => `"${choiceLead(choice)}"`) - .join(", "); - return [ - `- 按钮: 会话的工具中有 \`${BUTTON_TOOL}\` 时, 在回复之前调用它, 把选项同时做成按钮: ask 用一句话问 "${optionSet.title}" 这件事, actions 依次是 label 为 ${labels} 的两项, 不带 action 字段. 选项块照常保留. 没有这个工具时不做按钮, 只用选项块.`, - ]; -} - -/** - * 结论行的写法: 用户只读这一行也能知道结果与要做的选择. - * - * @param {import("./spec.mjs").FormatSpec} format 全局版式规则. - * @returns {string} 一条要求. - */ -function conclusionRule(format) { - return `- "${format.conclusionLabel}:" 一行写给人看: 先说结果, 再说要用户做的选择, 尽量不超过 ${format.conclusionMaxLength} 字, 不铺垫, 不复述. 读者注意力有限, 只读这一行也要能做决定. ${UNIT_RULE}`; -} - -/** - * 回复中自由正文节的写法; 没有自由正文节时为空. - * - * @param {import("./spec.mjs").ReplySpec} reply 回复规格. - * @returns {string[]} 各行. - */ -function freeformLines(reply) { - return reply.sections - .filter(isFreeformSection) - .map((section) => `- "## ${section.title}" ${FREEFORM_RULE}.`); -} - -/** - * 列出写作建议与禁用词; 只作提醒, 回复结束时不按这些规则打回. - * - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {string[]} 各行. - */ -function writingLines(writing) { - const descriptions = describeWritingRules(writing); - const advised = Object.entries(writing).filter( - ([key, rule]) => - rule.severity === ADVISED_SEVERITY && !FILE_ONLY_RULES.includes(key), - ); - const conditions = advised - .filter(([key]) => !WORD_LIST_RULES.includes(key)) - .map(([key]) => descriptions[key]); - const words = advised - .filter(([key]) => WORD_LIST_RULES.includes(key)) - .flatMap(([, rule]) => rule.words ?? []); - return [ - `- 写作建议: 避免${conditions.join("; ")}.`, - `- 不用这些词: ${words.join(", ")}.`, - ]; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/repo.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/repo.mjs index 800da48..9ef05e0 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/repo.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/repo.mjs @@ -1,6 +1,5 @@ /** - * @file 调用 git: 查询本仓库信息, 以及为源码证据访问第三方公开仓库. - * 只在命令行脚本中使用, hook 不启动 git. + * @file 调用 git 查询本仓库的信息. 只在命令行脚本中使用, hook 不启动 git. */ import { spawnSync } from "node:child_process"; @@ -16,46 +15,23 @@ import { gitCommonDirectory } from "./paths.mjs"; const GIT_EXECUTABLE = "git"; /** - * 访问第三方仓库时附加的环境变量: 不在终端询问账号, 不弹出凭据管理器的窗口. - * 需要账号的仓库直接失败, 避免命令卡住. - * @type {Readonly>} - */ -const NON_INTERACTIVE_ENVIRONMENT = Object.freeze({ - GIT_TERMINAL_PROMPT: "0", - GCM_INTERACTIVE: "never", -}); - -/** - * 访问第三方仓库时放在最前面的 git 参数: 清空凭据助手, 不读取本机保存的账号. + * 让 git 原样输出非 ASCII 路径的参数. * @type {readonly string[]} */ -const NO_CREDENTIAL_ARGUMENTS = Object.freeze(["-c", "credential.helper="]); - -/** - * @typedef {object} GitOutcome 一次 git 运行的结果. - * @property {boolean} isSuccess 退出码为 0 时为 true. - * @property {Buffer} stdout 标准输出的原始字节. - * @property {string} message 失败原因: git 的错误输出, 或无法启动, 超时的说明; 成功时为空字符串. - */ +const PLAIN_PATH_ARGUMENTS = Object.freeze(["-c", "core.quotePath=false"]); /** * 在指定目录运行 git 并返回标准输出. * * @param {string} directory 运行目录. * @param {readonly string[]} args git 参数. - * @param {{env?: Record, input?: string}} [options] 附加的环境变量与标准输入. * @returns {string | undefined} 去掉首尾空白的输出; git 无法启动或退出码非 0 时为 undefined. */ -export function runGit(directory, args, options = {}) { +export function runGit(directory, args) { const result = spawnSync(GIT_EXECUTABLE, args, { cwd: directory, encoding: "utf8", windowsHide: true, - input: options.input, - env: - options.env === undefined - ? process.env - : { ...process.env, ...options.env }, }); if (result.error !== undefined || result.status !== 0) { return undefined; @@ -64,75 +40,65 @@ export function runGit(directory, args, options = {}) { } /** - * 以不读凭据, 不弹提示的方式运行 git, 保留原始输出与失败原因. - * 用于访问第三方公开仓库; 调用方用 `--git-dir` 指明仓库, 不依赖工作目录. + * 查找目录所在 Git 工作区的根目录. * - * @param {readonly string[]} args git 参数. - * @param {number} timeout 超时毫秒数. - * @returns {GitOutcome} 运行结果. + * @param {string} directory 起始目录. + * @returns {string | undefined} 工作区根目录; 不在 Git 仓库中时为 undefined. */ -export function runIsolatedGit(args, timeout) { - const result = spawnSync( - GIT_EXECUTABLE, - [...NO_CREDENTIAL_ARGUMENTS, ...args], - { - windowsHide: true, - timeout, - env: { ...process.env, ...NON_INTERACTIVE_ENVIRONMENT }, - }, - ); - if (result.error !== undefined) { - return { - isSuccess: false, - stdout: Buffer.alloc(0), - message: `git 无法完成: ${result.error.message}`, - }; - } - return { - isSuccess: result.status === 0, - stdout: result.stdout, - message: result.status === 0 ? "" : result.stderr.toString("utf8").trim(), - }; +export function findRepositoryRoot(directory) { + return runGit(directory, ["rev-parse", "--show-toplevel"]); } /** - * 删除一个引用; 引用不存在时不做任何事. + * 列出仓库中已跟踪的文件. * * @param {string} worktreeRoot 工作区根目录. - * @param {string} ref 完整引用名, 例如 "refs/navigator/snapshots". - * @returns {boolean} 引用存在并已删除时返回 true. + * @returns {string[]} 以正斜杠分隔的项目相对路径; 查询失败时为空. */ -export function deleteRef(worktreeRoot, ref) { - if ( - runGit(worktreeRoot, ["rev-parse", "-q", "--verify", ref]) === undefined - ) { - return false; - } - return runGit(worktreeRoot, ["update-ref", "-d", ref]) !== undefined; +export function listTrackedFiles(worktreeRoot) { + return splitLines( + runGit(worktreeRoot, [...PLAIN_PATH_ARGUMENTS, "ls-files"]), + ); } /** - * 查找目录所在 Git 工作区的根目录. + * 列出相对某个提交新增的文件: 已提交或已暂存的新文件, 以及未跟踪但没有被忽略 + * 的文件. * - * @param {string} directory 起始目录. - * @returns {string | undefined} 工作区根目录; 不在 Git 仓库中时为 undefined. + * @param {string} worktreeRoot 工作区根目录. + * @param {string} baseCommit 作为基准的提交. + * @returns {string[]} 以正斜杠分隔的项目相对路径. */ -export function findRepositoryRoot(directory) { - return runGit(directory, ["rev-parse", "--show-toplevel"]); +export function listAddedFiles(worktreeRoot, baseCommit) { + return [ + ...splitLines( + runGit(worktreeRoot, [ + ...PLAIN_PATH_ARGUMENTS, + "diff", + "--name-only", + "--diff-filter=A", + baseCommit, + ]), + ), + ...splitLines( + runGit(worktreeRoot, [ + ...PLAIN_PATH_ARGUMENTS, + "ls-files", + "--others", + "--exclude-standard", + ]), + ), + ]; } /** - * 统计仓库中已跟踪的文件数. + * 把 git 的多行输出拆成非空行. * - * @param {string} worktreeRoot 工作区根目录. - * @returns {number} 文件数; 查询失败时为 0. + * @param {string | undefined} output git 的输出; 命令失败时为 undefined. + * @returns {string[]} 各行. */ -export function countTrackedFiles(worktreeRoot) { - const output = runGit(worktreeRoot, ["ls-files", "-z"]); - if (output === undefined || output === "") { - return 0; - } - return output.split("\0").filter((entry) => entry !== "").length; +function splitLines(output) { + return (output ?? "").split("\n").filter((line) => line !== ""); } /** @@ -170,8 +136,7 @@ export function findIgnoredPaths(worktreeRoot, relativePaths) { const result = spawnSync( GIT_EXECUTABLE, [ - "-c", - "core.quotePath=false", + ...PLAIN_PATH_ARGUMENTS, "check-ignore", "--no-index", "--verbose", @@ -202,6 +167,19 @@ export function findIgnoredPaths(worktreeRoot, relativePaths) { })); } +/** + * 按忽略规则分组描述被忽略的路径: 每条规则写明影响的路径数与一个例子. + * + * @param {readonly IgnoredPath[]} ignored 被忽略的路径. + * @returns {string[]} 每条规则一句描述. + */ +export function summarizeIgnoredPaths(ignored) { + return [...Map.groupBy(ignored, (entry) => entry.rule)].map( + ([rule, entries]) => + `${rule}, 影响 ${entries.length} 个路径, 例如 ${entries[0].path}`, + ); +} + /** * `git status --porcelain -z` 每条记录中状态码与路径之间的字符数: 两位状态码与一个空格. * @type {number} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/review-record.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/review-record.mjs deleted file mode 100644 index 906f7b0..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/review-record.mjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @file 验收记录的判据结论: 读取 review.md 的判据核对表, 按结论分类; - * 核对等用户选择后才写的键已经写下. - * - * "有判据不通过时不能通过验收, 有未验证的判据时要用户明确接受" 由脚本执行: - * order set accepted 与 "验收报告" 的选项组都先核对这里. - */ - -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; - -import { findSection, readSectionTable } from "./layout.mjs"; -import { orderFilePath } from "./paths.mjs"; - -/** - * 验收记录中放判据结论的节, 与规格 files.review 中的节标题一致. - * @type {string} - */ -export const CRITERIA_CHECK_SECTION = "判据核对"; - -/** - * 判据核对表中写判据的列. - * @type {string} - */ -export const CRITERION_COLUMN = "判据"; - -/** - * 判据核对表中写结论的列, 取值由规格限定. - * @type {string} - */ -export const VERDICT_COLUMN = "结论"; - -/** - * 判据的三种结论, 与规格中判据核对表 "结论" 列允许的取值一致. - * @type {Readonly<{pass: string, fail: string, unverified: string}>} - */ -export const VERDICTS = Object.freeze({ - pass: "通过", - fail: "不通过", - unverified: "未验证", -}); - -/** - * 验收记录中等用户选择后才写的键. 骨架预填规格中的值, 用户选择之后改写. - * @type {string} - */ -export const USER_VERDICT_KEY = "用户结论"; - -/** - * @typedef {object} ReviewVerdicts 验收记录中判据结论的分类. - * @property {string | undefined} problem 记录缺失或没有判据核对表时的原因. - * @property {string[]} failed 结论为不通过的判据. - * @property {string[]} unverified 结论为未验证, 或写了其它说法的判据. - */ - -/** - * 从规格中取出验收记录判据核对表的规格. - * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {import("./spec.mjs").TableSpec} 表格规格. - * @throws {Error} 规格中没有该表格时. - */ -export function criteriaTableSpec(spec) { - const table = spec.files.review.sections?.find( - (section) => section.title === CRITERIA_CHECK_SECTION, - )?.table; - if (table === undefined) { - throw new Error( - `review-record: 规格中缺少 "${CRITERIA_CHECK_SECTION}" 的表格`, - ); - } - return table; -} - -/** - * 按结论给验收记录中的判据分类. - * - * @param {string | undefined} reviewText 验收记录全文; 文件不存在时为 undefined. - * @returns {ReviewVerdicts} 分类结果. - */ -export function classifyVerdicts(reviewText) { - if (reviewText === undefined) { - return { - problem: "验收记录 review.md 还没有写入.", - failed: [], - unverified: [], - }; - } - const section = findSection(reviewText, CRITERIA_CHECK_SECTION); - const table = section === undefined ? undefined : readSectionTable(section); - const criterionIndex = table?.headers.indexOf(CRITERION_COLUMN) ?? -1; - const verdictIndex = table?.headers.indexOf(VERDICT_COLUMN) ?? -1; - if (table === undefined || verdictIndex < 0 || table.rows.length === 0) { - return { - problem: `验收记录中没有 "${CRITERIA_CHECK_SECTION}" 表格.`, - failed: [], - unverified: [], - }; - } - const label = (row) => row[criterionIndex] ?? ""; - return { - problem: undefined, - failed: table.rows - .filter((row) => row[verdictIndex] === VERDICTS.fail) - .map(label), - unverified: table.rows - .filter( - (row) => - row[verdictIndex] !== VERDICTS.pass && - row[verdictIndex] !== VERDICTS.fail, - ) - .map(label), - }; -} - -/** - * 读取当前工单的验收记录, 按结论给判据分类. - * - * @param {string} projectRoot 项目根目录. - * @param {{folder: string}} order 当前工单. - * @returns {ReviewVerdicts} 分类结果. - */ -export function readOrderVerdicts(projectRoot, order) { - const file = path.join(projectRoot, orderFilePath(order.folder, "review")); - return classifyVerdicts( - existsSync(file) ? readFileSync(file, "utf8") : undefined, - ); -} - -/** - * 检查验收记录中的用户结论是否已写: 键不存在, 值为空, 或仍是骨架预填的值时 - * 视为未写. - * - * @param {object} options 参数. - * @param {string} options.projectRoot 项目根目录. - * @param {{folder: string}} options.order 当前工单. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @returns {string | undefined} 未写时的原因; 已写时为 undefined. - */ -export function unfilledUserVerdict({ projectRoot, order, spec }) { - const file = path.join(projectRoot, orderFilePath(order.folder, "review")); - if (!existsSync(file)) { - return "验收记录 review.md 还没有写入."; - } - const line = readFileSync(file, "utf8") - .split(/\r?\n/u) - .find((entry) => entry.startsWith(`- ${USER_VERDICT_KEY}:`)); - const value = line?.slice(`- ${USER_VERDICT_KEY}:`.length).trim() ?? ""; - const preset = spec.files.review.sections?.find( - (section) => section.presets?.[USER_VERDICT_KEY] !== undefined, - )?.presets?.[USER_VERDICT_KEY]; - return value === "" || value === preset - ? `review.md 的 "${USER_VERDICT_KEY}" 还是 "${value || "空"}", 先写下用户的选择.` - : undefined; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/roadmap-view.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/roadmap-view.mjs new file mode 100644 index 0000000..7fb4a49 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/roadmap-view.mjs @@ -0,0 +1,74 @@ +/** + * @file 路线视图: 把状态中的里程与切片写成给人看的 `roadmap.md`. + * + * 视图由脚本整份生成, 每次保存状态时重写, 不能直接编辑. + */ + +import { + MILESTONE_LABELS, + SLICE_KINDS, + SLICE_STATUSES, + SLICE_STATUS_LABELS, +} from "./state.mjs"; + +/** + * 视图的标题. + * @type {string} + */ +const TITLE = "# 推进路线"; + +/** + * 标题下方的说明. + * @type {string} + */ +const NOTICE = + "本文件由 project-navigator 从状态文件生成, 不要直接编辑; 用 roadmap 命令更新路线."; + +/** + * 还没有路线时的内容. + * @type {string} + */ +const EMPTY_NOTICE = "还没有路线."; + +/** + * 生成路线视图. + * + * @param {import("./state.mjs").NavigatorState} state 状态. + * @returns {string} 文件全文. + */ +export function renderRoadmap(state) { + const body = + state.milestones.length === 0 + ? [EMPTY_NOTICE, ""] + : state.milestones.flatMap((milestone) => [ + `## 里程 ${milestone.id}: ${milestone.name} (${MILESTONE_LABELS[milestone.status]})`, + "", + `目标: ${milestone.goal}`, + "", + ...(milestone.slices.length === 0 + ? ["切片到这个里程开始时再排."] + : milestone.slices.map(sliceLine)), + "", + ]); + return [TITLE, "", NOTICE, "", ...body].join("\n"); +} + +/** + * 生成一个切片的复选框行. + * + * @param {import("./state.mjs").SliceState} slice 切片. + * @returns {string} 一行. + */ +function sliceLine(slice) { + const marks = [ + ...(slice.skeleton ? ["最小骨架"] : []), + ...(slice.kind === SLICE_KINDS.findings ? ["只产出结论"] : []), + ...(slice.interactive ? ["交互式"] : []), + ...(slice.status === SLICE_STATUSES.pending || + slice.status === SLICE_STATUSES.done + ? [] + : [SLICE_STATUS_LABELS[slice.status]]), + ]; + const box = slice.status === SLICE_STATUSES.done ? "x" : " "; + return `- [${box}] ${slice.id} ${slice.name}${marks.length === 0 ? "" : ` (${marks.join(", ")})`}`; +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/roadmap.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/roadmap.mjs new file mode 100644 index 0000000..b9c1c44 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/roadmap.mjs @@ -0,0 +1,420 @@ +/** + * @file 路线: 里程与切片的查询, 路线草稿的合并, 里程完成与项目收尾. + * + * 所有函数都不修改传入的状态, 返回新状态. 不合法的操作抛出 WorkflowError. + */ + +import { allocateNumber } from "./numbering.mjs"; +import { + MILESTONE_STATUSES, + SLICE_KINDS, + SLICE_KIND_LABELS, + SLICE_STATUSES, + STAGES, +} from "./state.mjs"; +import { requireLabel, requireText } from "./validation.mjs"; +import { WorkflowError } from "./workflow-error.mjs"; + +/** + * 已经结束, 不再推进的切片状态. + * @type {readonly string[]} + */ +const SETTLED_SLICE_STATUSES = Object.freeze([ + SLICE_STATUSES.done, + SLICE_STATUSES.dropped, +]); + +/** + * @typedef {import("./state.mjs").NavigatorState} NavigatorState + * @typedef {import("./state.mjs").MilestoneState} MilestoneState + * @typedef {import("./state.mjs").SliceState} SliceState + */ + +/** + * @typedef {object} RoadmapDraft 路线草稿. + * @property {{id?: string, name: string, goal: string, slices?: {id?: string, name: string, kind?: string, interactive?: boolean}[]}[]} milestones 里程. + */ + +/** + * 判断切片是否已经结束. + * + * @param {SliceState} slice 切片. + * @returns {boolean} 已完成或已放弃时返回 true. + */ +export function isSliceSettled(slice) { + return SETTLED_SLICE_STATUSES.includes(slice.status); +} + +/** + * 取当前里程: 第一个没有完成的里程. + * + * @param {NavigatorState} state 状态. + * @returns {MilestoneState | undefined} 当前里程; 全部完成或还没有路线时为 undefined. + */ +export function currentMilestone(state) { + return state.milestones.find( + (milestone) => milestone.status !== MILESTONE_STATUSES.done, + ); +} + +/** + * 按编号查找切片. + * + * @param {NavigatorState} state 状态. + * @param {string} id 切片编号. + * @returns {{milestone: MilestoneState, slice: SliceState} | undefined} 切片及其所在里程. + */ +export function findSlice(state, id) { + for (const milestone of state.milestones) { + const slice = milestone.slices.find((entry) => entry.id === id); + if (slice !== undefined) { + return { milestone, slice }; + } + } + return undefined; +} + +/** + * 取进行中的切片. + * + * @param {NavigatorState} state 状态. + * @returns {{milestone: MilestoneState, slice: SliceState} | undefined} 进行中的切片及其所在里程. + */ +export function activeSlice(state) { + return state.current === null + ? undefined + : findSlice(state, state.current.sliceId); +} + +/** + * 取当前里程中第一个还没有开始的切片. + * + * @param {NavigatorState} state 状态. + * @returns {SliceState | undefined} 切片; 没有时为 undefined. + */ +export function nextPendingSlice(state) { + return currentMilestone(state)?.slices.find( + (slice) => slice.status === SLICE_STATUSES.pending, + ); +} + +/** + * 判断里程中的切片是否全部结束. + * + * @param {MilestoneState} milestone 里程. + * @returns {boolean} 至少有一个切片, 且全部已完成或已放弃时返回 true. + */ +export function isMilestoneSettled(milestone) { + return milestone.slices.length > 0 && milestone.slices.every(isSliceSettled); +} + +/** + * 取最小骨架切片. + * + * @param {NavigatorState} state 状态. + * @returns {SliceState | undefined} 骨架切片. + */ +export function skeletonSlice(state) { + return state.milestones + .flatMap((milestone) => milestone.slices) + .find((slice) => slice.skeleton); +} + +/** + * 替换某个切片的字段. + * + * @param {NavigatorState} state 状态. + * @param {string} id 切片编号. + * @param {Partial} patch 要替换的字段. + * @returns {MilestoneState[]} 新的里程列表. + */ +export function patchSlice(state, id, patch) { + return state.milestones.map((milestone) => ({ + ...milestone, + slices: milestone.slices.map((slice) => + slice.id === id ? { ...slice, ...patch } : slice, + ), + })); +} + +/** + * 把路线草稿合并进状态. 已有的里程与切片凭编号保留; 已经开始的切片原样保留, + * 不能删除. 第一个里程的第一个切片是最小骨架. + * + * @param {NavigatorState} state 状态. + * @param {RoadmapDraft} draft 路线草稿. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 草稿不合法时. + */ +export function applyRoadmap(state, draft) { + if (!Array.isArray(draft?.milestones) || draft.milestones.length === 0) { + throw new WorkflowError( + '路线草稿的格式: {"milestones": [{"name", "goal", "slices": [{"name", "kind", "interactive"}]}]}, 至少一个里程.', + ); + } + if ( + state.project.organization === null || + state.project.verification === null + ) { + throw new WorkflowError( + "还没有登记模块组织方式与验证方式, 请先运行 setup.", + ); + } + let counters = state.next; + const milestones = draft.milestones.map((entry) => { + const merged = mergeMilestone(state, entry, counters); + counters = merged.next; + return merged.milestone; + }); + assertNothingLost(state, milestones); + const marked = markStatuses(markSkeleton(state, milestones)); + const first = marked.find( + (milestone) => milestone.status === MILESTONE_STATUSES.active, + ); + if (first !== undefined && first.slices.length === 0) { + throw new WorkflowError( + `里程 ${first.id} 是当前里程, 要排出它的全部切片; 之后的里程可以只写名称与目标.`, + ); + } + return { + ...state, + stage: first === undefined ? state.stage : STAGES.slicing, + milestones: marked, + next: counters, + lastAction: "更新路线", + }; +} + +/** + * 把当前里程标为完成, 下一个里程成为当前里程. + * + * @param {NavigatorState} state 状态. + * @param {string | undefined} id 里程编号. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 编号不是当前里程, 或里程中还有没结束的切片时. + */ +export function completeMilestone(state, id) { + const milestone = currentMilestone(state); + if (milestone === undefined || milestone.id !== id) { + throw new WorkflowError( + milestone === undefined + ? "没有进行中的里程." + : `当前里程是 ${milestone.id}, 用法: milestone done ${milestone.id}.`, + ); + } + if (!isMilestoneSettled(milestone)) { + throw new WorkflowError( + `里程 ${milestone.id} 还有没结束的切片, 全部通过或放弃后才能完成.`, + ); + } + const milestones = markStatuses( + state.milestones.map((entry) => + entry.id === id ? { ...entry, status: MILESTONE_STATUSES.done } : entry, + ), + ); + return { + ...state, + milestones, + lastAction: `里程 ${id} 完成`, + }; +} + +/** + * 确认项目收尾. + * + * @param {NavigatorState} state 状态. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 还有没完成的里程时. + */ +export function finishProject(state) { + const milestone = currentMilestone(state); + if (state.milestones.length === 0 || milestone !== undefined) { + throw new WorkflowError( + milestone === undefined + ? "还没有路线, 不能收尾." + : `里程 ${milestone.id} 还没有完成, 不能收尾.`, + ); + } + return { ...state, stage: STAGES.finished, lastAction: "项目收尾" }; +} + +/** + * 合并草稿中的一个里程. + * + * @param {NavigatorState} state 状态. + * @param {RoadmapDraft["milestones"][number]} entry 草稿中的里程. + * @param {Record} counters 编号计数器. + * @returns {{milestone: MilestoneState, next: Record}} 合并后的里程与新计数器. + * @throws {WorkflowError} 编号不存在, 或向已完成的里程加切片时. + */ +function mergeMilestone(state, entry, counters) { + const existing = + entry.id === undefined + ? undefined + : state.milestones.find((milestone) => milestone.id === entry.id); + if (entry.id !== undefined && existing === undefined) { + throw new WorkflowError( + `里程 ${entry.id} 不存在; 新里程不写 id, 由脚本分配.`, + ); + } + const allocated = + existing === undefined ? allocateNumber(counters, "milestone") : undefined; + let next = allocated?.next ?? counters; + const slices = (entry.slices ?? []).map((draft) => { + const merged = mergeSlice(state, draft, next); + next = merged.next; + return merged.slice; + }); + const isDone = existing?.status === MILESTONE_STATUSES.done; + if (isDone && !slices.every(isSliceSettled)) { + throw new WorkflowError( + `里程 ${entry.id} 已经完成, 不能再加切片; 新的工作排进之后的里程.`, + ); + } + return { + milestone: { + id: existing?.id ?? /** @type {{id: string}} */ (allocated).id, + name: requireText(entry.name, "里程名称"), + goal: requireText(entry.goal, "里程目标"), + status: isDone ? MILESTONE_STATUSES.done : MILESTONE_STATUSES.pending, + slices, + }, + next, + }; +} + +/** + * 合并草稿中的一个切片: 已经开始的切片原样保留, 还没有开始的按草稿更新. + * + * @param {NavigatorState} state 状态. + * @param {{id?: string, name: string, kind?: string, interactive?: boolean}} draft 草稿中的切片. + * @param {Record} counters 编号计数器. + * @returns {{slice: SliceState, next: Record}} 合并后的切片与新计数器. + * @throws {WorkflowError} 编号不存在, 种类不合法, 或改代码的切片被标为交互式时. + */ +function mergeSlice(state, draft, counters) { + const existing = + draft.id === undefined ? undefined : findSlice(state, draft.id)?.slice; + if (draft.id !== undefined && existing === undefined) { + throw new WorkflowError( + `切片 ${draft.id} 不存在; 新切片不写 id, 由脚本分配.`, + ); + } + if (existing !== undefined && existing.status !== SLICE_STATUSES.pending) { + return { slice: existing, next: counters }; + } + const kind = requireLabel( + draft.kind ?? SLICE_KINDS.build, + SLICE_KIND_LABELS, + "切片种类 (kind)", + ); + const isInteractive = draft.interactive ?? kind === SLICE_KINDS.findings; + if (isInteractive && kind === SLICE_KINDS.build) { + throw new WorkflowError( + `切片 "${draft.name}": 改代码的切片不能是交互式, 需要用户亲手验证的部分放在验收之后.`, + ); + } + const allocated = + existing === undefined ? allocateNumber(counters, "slice") : undefined; + return { + slice: { + id: existing?.id ?? /** @type {{id: string}} */ (allocated).id, + name: requireText(draft.name, "切片名称"), + kind, + skeleton: false, + interactive: isInteractive, + status: SLICE_STATUSES.pending, + file: null, + }, + next: allocated?.next ?? counters, + }; +} + +/** + * 确认草稿没有丢掉已完成的里程与已经开始的切片. + * + * @param {NavigatorState} state 合并前的状态. + * @param {readonly MilestoneState[]} milestones 合并后的里程. + * @returns {void} + * @throws {WorkflowError} 有里程或切片被丢掉时. + */ +function assertNothingLost(state, milestones) { + const keptMilestones = milestones.map((milestone) => milestone.id); + const keptSlices = milestones + .flatMap((milestone) => milestone.slices) + .map((slice) => slice.id); + const lostMilestone = state.milestones.find( + (milestone) => + milestone.status === MILESTONE_STATUSES.done && + !keptMilestones.includes(milestone.id), + ); + if (lostMilestone !== undefined) { + throw new WorkflowError( + `草稿中缺少已完成的里程 ${lostMilestone.id}: 已有的里程带上 id 原样保留.`, + ); + } + const lostSlice = state.milestones + .flatMap((milestone) => milestone.slices) + .find( + (slice) => + slice.status !== SLICE_STATUSES.pending && + !keptSlices.includes(slice.id), + ); + if (lostSlice !== undefined) { + throw new WorkflowError( + `草稿中缺少已经开始的切片 ${lostSlice.id}: 已有的切片带上 id 原样保留; 不想做了用 slice drop.`, + ); + } +} + +/** + * 标出最小骨架: 骨架已经开始时保持不变, 否则取第一个里程的第一个切片. + * + * @param {NavigatorState} state 合并前的状态. + * @param {readonly MilestoneState[]} milestones 合并后的里程. + * @returns {MilestoneState[]} 标好骨架的里程. + * @throws {WorkflowError} 第一个切片不是改代码的切片时. + */ +function markSkeleton(state, milestones) { + const started = skeletonSlice(state); + const isFixed = + started !== undefined && started.status !== SLICE_STATUSES.pending; + const skeletonId = isFixed ? started.id : milestones[0].slices[0]?.id; + const marked = milestones.map((milestone) => ({ + ...milestone, + slices: milestone.slices.map((slice) => ({ + ...slice, + skeleton: slice.id === skeletonId, + })), + })); + const skeleton = marked + .flatMap((milestone) => milestone.slices) + .find((slice) => slice.skeleton); + if (skeleton !== undefined && skeleton.kind !== SLICE_KINDS.build) { + throw new WorkflowError( + `第一个里程的第一个切片 "${skeleton.name}" 是最小骨架, 种类必须是 ${SLICE_KINDS.build}: 它要从真实入口跑通. 需要先查清的事排在骨架之后, 或在立项时查清.`, + ); + } + return marked; +} + +/** + * 重新标出里程状态: 已完成的不变, 第一个没完成的为进行中, 其余为未开始. + * + * @param {readonly MilestoneState[]} milestones 里程. + * @returns {MilestoneState[]} 标好状态的里程. + */ +function markStatuses(milestones) { + const active = milestones.find( + (milestone) => milestone.status !== MILESTONE_STATUSES.done, + ); + return milestones.map((milestone) => ({ + ...milestone, + status: + milestone.status === MILESTONE_STATUSES.done + ? MILESTONE_STATUSES.done + : milestone.id === active?.id + ? MILESTONE_STATUSES.active + : MILESTONE_STATUSES.pending, + })); +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/settings.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/settings.mjs index a38068b..fb2937d 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/settings.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/settings.mjs @@ -1,5 +1,5 @@ /** - * @file 在项目配置中合并与移除本技能的放行规则, 并清理 1.0.0 之前的项目级 hook. + * @file 在项目配置中合并与移除本技能的放行规则. * * hook 由插件的 `hooks/hooks.json` 提供, 不写进项目配置. 项目配置中只放放行规则, * 同时写入 `.claude/settings.json` 与本机的 `.claude/settings.local.json`: 入库的 @@ -14,12 +14,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { - COMMAND_NAME, - LEGACY_COMMAND_PATH, - LEGACY_HOOK_ENTRY_ARGUMENT, - NAVIGATOR_DIRECTORY, -} from "./paths.mjs"; +import { COMMAND_NAME, NAVIGATOR_DIRECTORY } from "./paths.mjs"; /** * 放行规则: 插件命令 (两种 shell 各一条), 以及状态目录中的文件写入. @@ -32,15 +27,6 @@ export const NAVIGATOR_PERMISSION_RULES = Object.freeze([ `Edit(${NAVIGATOR_DIRECTORY}/**)`, ]); -/** - * 1.0.0 之前写入的放行规则, 升级与卸载时移除. - * @type {readonly string[]} - */ -const LEGACY_PERMISSION_RULES = Object.freeze([ - `Bash(node ${LEGACY_COMMAND_PATH} *)`, - `PowerShell(node ${LEGACY_COMMAND_PATH} *)`, -]); - /** * 读取项目中的某个配置文件; 文件不存在时返回空对象. * @@ -78,30 +64,30 @@ export function writeSettingsFile(projectRoot, relativeFile, settings) { } /** - * 返回合并了本技能放行规则的新配置, 同时去掉 1.0.0 之前的放行规则, - * 不修改传入对象. + * 返回合并了本技能放行规则的新配置, 不修改传入对象. * * @param {Record} settings 原配置. * @returns {Record} 新配置. */ export function mergeNavigatorPermissions(settings) { - const allow = (settings.permissions?.allow ?? []).filter( - (rule) => !LEGACY_PERMISSION_RULES.includes(rule), - ); - for (const rule of NAVIGATOR_PERMISSION_RULES) { - if (!allow.includes(rule)) { - allow.push(rule); - } - } + const existing = settings.permissions?.allow ?? []; return { ...settings, - permissions: { ...(settings.permissions ?? {}), allow }, + permissions: { + ...(settings.permissions ?? {}), + allow: [ + ...existing, + ...NAVIGATOR_PERMISSION_RULES.filter( + (rule) => !existing.includes(rule), + ), + ], + }, }; } /** - * 返回移除了本技能放行规则 (含 1.0.0 之前的规则) 的新配置, 不修改传入对象. - * 移除后为空的 `allow` 与 `permissions` 字段一并删除. + * 返回移除了本技能放行规则的新配置, 不修改传入对象. 移除后为空的 `allow` 与 + * `permissions` 字段一并删除. * * @param {Record} settings 原配置. * @returns {Record} 新配置. @@ -111,47 +97,21 @@ export function removeNavigatorPermissions(settings) { return settings; } const allow = (settings.permissions.allow ?? []).filter( - (rule) => - !NAVIGATOR_PERMISSION_RULES.includes(rule) && - !LEGACY_PERMISSION_RULES.includes(rule), + (rule) => !NAVIGATOR_PERMISSION_RULES.includes(rule), ); const permissions = replaceField(settings.permissions, "allow", allow); return replaceField(settings, "permissions", permissions); } /** - * 判断配置中是否还有 1.0.0 之前写入的项目级 hook. + * 判断配置中是否已有本技能的全部放行规则. * * @param {Record} settings 配置对象. - * @returns {boolean} 有时返回 true. - */ -export function hasLegacyHooks(settings) { - return Object.values(settings.hooks ?? {}).some((groups) => - groups.some((group) => (group.hooks ?? []).some(isLegacyHook)), - ); -} - -/** - * 返回移除了 1.0.0 之前项目级 hook 的新配置, 不修改传入对象. 移除后为空的 - * 分组, 事件与 `hooks` 字段一并删除. - * - * @param {Record} settings 原配置. - * @returns {Record} 新配置. + * @returns {boolean} 全部写入时返回 true. */ -export function removeLegacyHooks(settings) { - const kept = {}; - for (const [event, groups] of Object.entries(settings.hooks ?? {})) { - const remaining = groups - .map((group) => ({ - ...group, - hooks: (group.hooks ?? []).filter((hook) => !isLegacyHook(hook)), - })) - .filter((group) => group.hooks.length > 0); - if (remaining.length > 0) { - kept[event] = remaining; - } - } - return replaceField(settings, "hooks", kept); +export function hasNavigatorPermissions(settings) { + const allow = settings.permissions?.allow ?? []; + return NAVIGATOR_PERMISSION_RULES.every((rule) => allow.includes(rule)); } /** @@ -174,17 +134,3 @@ function replaceField(source, field, value) { } return result; } - -/** - * 判断一个 hook 命令是否是 1.0.0 之前写入的项目级 hook. - * - * @param {Record} hook hook 命令对象. - * @returns {boolean} 是时返回 true. - */ -function isLegacyHook(hook) { - return ( - hook.type === "command" && - Array.isArray(hook.args) && - hook.args[0] === LEGACY_HOOK_ENTRY_ARGUMENT - ); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/slice-file.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/slice-file.mjs new file mode 100644 index 0000000..0b1ae23 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/slice-file.mjs @@ -0,0 +1,565 @@ +/** + * @file 切片文件的骨架, 读取与摘要. + * + * 一个切片一份文件, 分计划, 执行结果, 验收三段, 段名与小节名取自规格 + * `spec/slice.json`. 脚本只读取推进流程需要的内容: 完成标准, 需要决定, 执行状态, + * 标准核对表与用户结论; 其余自由书写. 段的归属靠计划段的摘要保证: 开工时记下 + * 摘要, 送验时比对. + */ + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { parseMarkdown } from "./markdown.mjs"; +import { SLICE_SPEC_FILE } from "./paths.mjs"; +import { parseTable, renderTable } from "./table.mjs"; + +/** + * 段标题的级别. + * @type {number} + */ +const PART_LEVEL = 2; + +/** + * 小节标题的级别. + * @type {number} + */ +const SECTION_LEVEL = 3; + +/** + * 段的编号. + * @type {Readonly<{plan: string, result: string, review: string}>} + */ +export const PARTS = Object.freeze({ + plan: "plan", + result: "result", + review: "review", +}); + +/** + * 脚本与委派提示词引用的小节的编号, 与规格中的 `id` 一致. + * @type {Readonly>} + */ +export const SECTIONS = Object.freeze({ + criteria: "criteria", + excluded: "excluded", + decisions: "decisions", + modules: "modules", + rework: "rework", + status: "status", + changes: "changes", + wiring: "wiring", + corrections: "corrections", + pending: "pending", + tests: "tests", + notes: "notes", + verdicts: "verdicts", + wiringCheck: "wiringCheck", + structure: "structure", + scope: "scope", + correctionCheck: "correctionCheck", + manual: "manual", + conclusion: "conclusion", +}); + +/** + * 完成标准列表项的格式: 编号, 点, 内容. + * @type {RegExp} + */ +const NUMBERED_ITEM_PATTERN = /^\s*\d+\.\s+(.+)$/u; + +/** + * 标准核对表中各列的下标. + * @type {Readonly<{criterion: number, verdict: number, evidence: number}>} + */ +const VERDICT_COLUMNS = Object.freeze({ + criterion: 0, + verdict: 1, + evidence: 2, +}); + +/** + * 标准核对结论的三种归类. + * @type {Readonly<{pass: string, unverified: string, fail: string}>} + */ +export const VERDICT_CLASSES = Object.freeze({ + pass: "pass", + unverified: "unverified", + fail: "fail", +}); + +/** + * @typedef {object} SectionSpec 一个小节的规格. + * @property {string} id 小节编号. + * @property {string} title 小节标题. + * @property {string} [hint] 骨架中的填写提示. + * @property {string} [initial] 骨架中的初始内容, 代替占位. + * @property {string[]} [kinds] 只在这些切片种类中出现; 省略时都出现. + * @property {string[]} [table] 表格的列名. + * @property {string[]} [keys] 表格之后的键值行的键名. + */ + +/** + * @typedef {object} PartSpec 一段的规格. + * @property {string} id 段编号. + * @property {string} heading 段标题. + * @property {string} owner 负责写这一段的角色. + * @property {SectionSpec[]} sections 小节. + */ + +/** + * @typedef {object} SliceSpec 切片文件的规格. + * @property {string} titlePrefix 一级标题的前缀. + * @property {string} placeholder 占位内容的开头. + * @property {string} none 表示 "没有" 的写法. + * @property {string} doneStatus 执行状态中表示做完的写法. + * @property {string} reworkStatus 补做期间脚本写入执行状态的内容. + * @property {{pass: string, fail: string, unverified: string}} verdicts 标准核对的三种结论. + * @property {PartSpec[]} parts 各段. + */ + +/** + * @typedef {object} ParsedPart 解析出的一段. + * @property {number} start 段标题所在行的下标. + * @property {number} end 段的最后一行之后的下标. + * @property {Record} sections 各小节的内容行, 键为小节标题. + */ + +/** + * @typedef {object} ParsedSlice 解析出的切片文件. + * @property {string[]} lines 全文各行. + * @property {Record} parts 各段, 键为段编号; 文件中没有的段不在其中. + */ + +/** + * @typedef {object} VerdictRow 标准核对表的一行. + * @property {string} criterion 标准. + * @property {string} verdict 结论. + * @property {string} evidence 证据. + */ + +/** + * 读取切片文件的规格. + * + * @returns {SliceSpec} 规格. + */ +export function loadSliceSpec() { + return JSON.parse(readFileSync(SLICE_SPEC_FILE, "utf8")); +} + +/** + * 取某一段的规格. + * + * @param {SliceSpec} spec 规格. + * @param {string} partId 段编号. + * @returns {PartSpec} 段的规格. + */ +export function partSpec(spec, partId) { + return /** @type {PartSpec} */ ( + spec.parts.find((part) => part.id === partId) + ); +} + +/** + * 取某个小节的标题. + * + * @param {SliceSpec} spec 规格. + * @param {string} sectionId 小节编号. + * @returns {string} 小节标题. + */ +export function sectionTitle(spec, sectionId) { + const section = spec.parts + .flatMap((part) => part.sections) + .find((entry) => entry.id === sectionId); + return /** @type {SectionSpec} */ (section).title; +} + +/** + * 列出某一段在某种切片中出现的小节. + * + * @param {PartSpec} part 段的规格. + * @param {string} kind 切片种类. + * @returns {SectionSpec[]} 小节. + */ +export function sectionsFor(part, kind) { + return part.sections.filter( + (section) => section.kinds === undefined || section.kinds.includes(kind), + ); +} + +/** + * 生成一段的骨架. + * + * @param {SliceSpec} spec 规格. + * @param {string} partId 段编号. + * @param {string} kind 切片种类. + * @returns {string[]} 骨架各行, 以段标题开头. + */ +export function renderPart(spec, partId, kind) { + const part = partSpec(spec, partId); + return [ + `${"#".repeat(PART_LEVEL)} ${part.heading}`, + "", + ...sectionsFor(part, kind).flatMap((section) => [ + `${"#".repeat(SECTION_LEVEL)} ${section.title}`, + "", + ...sectionBody(spec, section), + "", + ]), + ]; +} + +/** + * 生成整份切片文件的骨架. + * + * @param {object} options 生成参数. + * @param {SliceSpec} options.spec 规格. + * @param {{id: string, name: string, kind: string}} options.slice 切片. + * @param {string} options.summary 标题下方的一行说明. + * @returns {string} 文件全文. + */ +export function renderSliceFile({ spec, slice, summary }) { + return [ + `# ${spec.titlePrefix} ${slice.id}: ${slice.name}`, + "", + summary, + "", + ...spec.parts.flatMap((part) => renderPart(spec, part.id, slice.kind)), + ] + .join("\n") + .replace(/\n+$/u, "\n"); +} + +/** + * 读取并解析切片文件. + * + * @param {string} projectRoot 项目根目录. + * @param {string | null} relativeFile 切片文件的项目相对路径. + * @param {SliceSpec} spec 规格. + * @returns {ParsedSlice | undefined} 解析结果; 文件不存在时为 undefined. + */ +export function readSliceFile(projectRoot, relativeFile, spec) { + if (relativeFile === null) { + return undefined; + } + const file = path.join(projectRoot, relativeFile); + return existsSync(file) + ? parseSliceFile(readFileSync(file, "utf8"), spec) + : undefined; +} + +/** + * 解析切片文件: 按段标题与小节标题切分. 同名的段取最后一个, 补做时归档的旧段 + * 标题带轮次, 不会重名. + * + * @param {string} text 文件全文. + * @param {SliceSpec} spec 规格. + * @returns {ParsedSlice} 解析结果. + */ +export function parseSliceFile(text, spec) { + const lines = text.replace(/\r\n?/gu, "\n").split("\n"); + const headings = parseMarkdown(text).filter( + (item) => item.kind === "heading", + ); + const partHeadings = headings.filter((item) => item.level === PART_LEVEL); + /** @type {Record} */ + const parts = {}; + for (const part of spec.parts) { + const heading = partHeadings.findLast((item) => item.text === part.heading); + if (heading === undefined) { + continue; + } + const following = partHeadings.find((item) => item.line > heading.line); + const end = following === undefined ? lines.length : following.line - 1; + parts[part.id] = { + start: heading.line - 1, + end, + sections: readSections(lines, headings, heading.line, end), + }; + } + return { lines, parts }; +} + +/** + * 计算计划段的摘要. 先去掉行首尾空白, 合并连续空白, 统一表格分隔行的长度, + * 格式化工具重排表格时摘要不变. + * + * @param {ParsedSlice} parsed 解析结果. + * @returns {string | undefined} 摘要; 文件中没有计划段时为 undefined. + */ +export function digestPlan(parsed) { + const part = parsed.parts[PARTS.plan]; + if (part === undefined) { + return undefined; + } + const normalized = parsed.lines + .slice(part.start, part.end) + .map((line) => line.trim().replace(/\s+/gu, " ").replace(/-{3,}/gu, "---")) + .filter((line) => line !== "") + .join("\n"); + return createHash("sha256").update(normalized).digest("hex"); +} + +/** + * 找出某一段中还没有填写的小节: 缺少小节, 小节为空, 或仍有占位内容. + * + * @param {object} options 参数. + * @param {ParsedSlice} options.parsed 解析结果. + * @param {SliceSpec} options.spec 规格. + * @param {string} options.partId 段编号. + * @param {string} options.kind 切片种类. + * @param {readonly string[]} [options.except] 不检查的小节编号. + * @returns {string[]} 没有填写的小节标题; 整段缺失时为段标题. + */ +export function findUnfilledSections({ + parsed, + spec, + partId, + kind, + except = [], +}) { + const part = partSpec(spec, partId); + const found = parsed.parts[partId]; + if (found === undefined) { + return [part.heading]; + } + return sectionsFor(part, kind) + .filter((section) => !except.includes(section.id)) + .filter((section) => { + const content = meaningfulLines(found.sections[section.title]); + return ( + content.length === 0 || + content.some((line) => line.includes(spec.placeholder)) + ); + }) + .map((section) => section.title); +} + +/** + * 读取完成标准. + * + * @param {ParsedSlice} parsed 解析结果. + * @param {SliceSpec} spec 规格. + * @returns {string[]} 各条标准的内容. + */ +export function readCriteria(parsed, spec) { + return sectionLines(parsed, spec, PARTS.plan, SECTIONS.criteria) + .map((line) => NUMBERED_ITEM_PATTERN.exec(line)) + .filter((match) => match !== null) + .map((match) => match[1].trim()); +} + +/** + * 判断计划段的某个小节是否只写了 "无". + * + * @param {ParsedSlice} parsed 解析结果. + * @param {SliceSpec} spec 规格. + * @param {string} sectionId 小节编号. + * @returns {boolean} 小节只有一行 "无" 时返回 true. + */ +export function isPlanSectionNone(parsed, spec, sectionId) { + const content = sectionLines(parsed, spec, PARTS.plan, sectionId); + return content.length === 1 && stripMark(content[0]) === spec.none; +} + +/** + * 读取执行状态. + * + * @param {ParsedSlice | undefined} parsed 解析结果. + * @param {SliceSpec} spec 规格. + * @returns {string} 执行状态的第一行; 没有写或仍是占位时为空字符串. + */ +export function readResultStatus(parsed, spec) { + if (parsed === undefined) { + return ""; + } + const [first = ""] = sectionLines( + parsed, + spec, + PARTS.result, + SECTIONS.status, + ); + return first.includes(spec.placeholder) ? "" : stripMark(first); +} + +/** + * 读取标准核对表. + * + * @param {ParsedSlice | undefined} parsed 解析结果. + * @param {SliceSpec} spec 规格. + * @returns {{rows: VerdictRow[], problems: string[]}} 表格各行与读取中发现的问题; 表格还没有填写时两者都为空. + */ +export function readVerdicts(parsed, spec) { + if (parsed === undefined) { + return { rows: [], problems: [] }; + } + const content = sectionLines(parsed, spec, PARTS.review, SECTIONS.verdicts); + const table = parseTable(content); + if ( + table === undefined || + content.some((line) => line.includes(spec.placeholder)) + ) { + return { rows: [], problems: [] }; + } + const allowed = Object.values(spec.verdicts); + const rows = table.rows.map((cells) => ({ + criterion: cells[VERDICT_COLUMNS.criterion] ?? "", + verdict: cells[VERDICT_COLUMNS.verdict] ?? "", + evidence: cells[VERDICT_COLUMNS.evidence] ?? "", + })); + const title = sectionTitle(spec, SECTIONS.verdicts); + const problems = rows.flatMap((row, index) => [ + ...(allowed.includes(row.verdict) + ? [] + : [ + `"${title}" 第 ${index + 1} 行的结论 "${row.verdict}" 只能是 ${allowed.join(", ")} 之一.`, + ]), + ...(row.evidence === "" ? [`"${title}" 第 ${index + 1} 行没有证据.`] : []), + ]); + return { rows, problems }; +} + +/** + * 给标准核对的结果归类: 有不通过的归为不通过, 否则有未验证的归为未验证. + * + * @param {readonly VerdictRow[]} rows 标准核对表各行. + * @param {SliceSpec} spec 规格. + * @returns {string} 归类, 见 VERDICT_CLASSES. + */ +export function classifyVerdicts(rows, spec) { + if (rows.some((row) => row.verdict === spec.verdicts.fail)) { + return VERDICT_CLASSES.fail; + } + return rows.some((row) => row.verdict === spec.verdicts.unverified) + ? VERDICT_CLASSES.unverified + : VERDICT_CLASSES.pass; +} + +/** + * 把执行状态改成补做中, 并把现有的验收段归档, 在文末追加一段空的验收段. + * + * @param {object} options 参数. + * @param {string} options.text 文件全文. + * @param {SliceSpec} options.spec 规格. + * @param {string} options.kind 切片种类. + * @param {number} options.round 刚结束的轮次. + * @returns {string} 新的文件全文. + */ +export function prepareRework({ text, spec, kind, round }) { + const parsed = parseSliceFile(text, spec); + const lines = [...parsed.lines]; + const review = parsed.parts[PARTS.review]; + if (review !== undefined) { + lines[review.start] = `${lines[review.start].trimEnd()} (第 ${round} 轮)`; + } + const statusTitle = sectionTitle(spec, SECTIONS.status); + const result = parsed.parts[PARTS.result]; + if (result !== undefined) { + const heading = lines.findIndex( + (line, index) => + index > result.start && + index < result.end && + line.trim() === `${"#".repeat(SECTION_LEVEL)} ${statusTitle}`, + ); + const first = lines.findIndex( + (line, index) => index > heading && line.trim() !== "", + ); + if (heading >= 0 && first >= 0 && first < result.end) { + lines[first] = spec.reworkStatus; + } + } + return [ + lines.join("\n").replace(/\n+$/u, ""), + "", + ...renderPart(spec, PARTS.review, kind), + ] + .join("\n") + .replace(/\n+$/u, "\n"); +} + +/** + * 生成一个小节在骨架中的内容. + * + * @param {SliceSpec} spec 规格. + * @param {SectionSpec} section 小节的规格. + * @returns {string[]} 内容各行. + */ +function sectionBody(spec, section) { + const blank = `${spec.placeholder})`; + if (section.initial !== undefined) { + return [section.initial]; + } + if (section.table === undefined) { + return [`${spec.placeholder}: ${section.hint})`]; + } + return [ + ...renderTable(section.table, [section.table.map(() => blank)]), + ...(section.keys === undefined + ? [] + : ["", ...section.keys.map((key) => `- ${key}: ${blank}`)]), + ]; +} + +/** + * 按小节标题切分一段的内容. + * + * @param {readonly string[]} lines 全文各行. + * @param {readonly import("./markdown.mjs").MarkdownItem[]} headings 全文的标题. + * @param {number} partLine 段标题的行号, 从 1 开始. + * @param {number} end 段的最后一行之后的下标. + * @returns {Record} 各小节的内容行. + */ +function readSections(lines, headings, partLine, end) { + const inside = headings.filter( + (item) => + item.kind === "heading" && + item.level === SECTION_LEVEL && + item.line > partLine && + item.line <= end, + ); + return Object.fromEntries( + inside.map((heading, index) => [ + /** @type {import("./markdown.mjs").HeadingItem} */ (heading).text, + lines.slice( + heading.line, + index + 1 < inside.length ? inside[index + 1].line - 1 : end, + ), + ]), + ); +} + +/** + * 取某个小节中有内容的行. + * + * @param {ParsedSlice} parsed 解析结果. + * @param {SliceSpec} spec 规格. + * @param {string} partId 段编号. + * @param {string} sectionId 小节编号. + * @returns {string[]} 去掉首尾空白的非空行; 段或小节不存在时为空. + */ +function sectionLines(parsed, spec, partId, sectionId) { + return meaningfulLines( + parsed.parts[partId]?.sections[sectionTitle(spec, sectionId)], + ); +} + +/** + * 去掉空行与行首尾空白. + * + * @param {readonly string[] | undefined} lines 内容行. + * @returns {string[]} 有内容的行. + */ +function meaningfulLines(lines) { + return (lines ?? []).map((line) => line.trim()).filter((line) => line !== ""); +} + +/** + * 去掉一行开头的列表符号与结尾的句号, 用于比较单行的取值. + * + * @param {string} line 一行内容. + * @returns {string} 取值. + */ +function stripMark(line) { + return line.replace(/^[-*]\s+/u, "").replace(/\.$/u, ""); +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/slice-flow.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/slice-flow.mjs new file mode 100644 index 0000000..a59c4d4 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/slice-flow.mjs @@ -0,0 +1,241 @@ +/** + * @file 切片的状态转换: 写计划, 开工, 更新计划, 送验, 通过, 补做, 放弃. + * + * 同一时间只有一个切片在进行, 它的运行信息记在状态的 `current` 中. 所有函数都 + * 不修改传入的状态, 返回新状态. 不合法的操作抛出 WorkflowError. + */ + +import { + activeSlice, + currentMilestone, + patchSlice, + skeletonSlice, +} from "./roadmap.mjs"; +import { SLICE_STATUSES, SLICE_STATUS_LABELS } from "./state.mjs"; +import { WorkflowError } from "./workflow-error.mjs"; + +/** + * 第一轮的轮次. + * @type {number} + */ +const FIRST_ROUND = 1; + +/** + * @typedef {import("./state.mjs").NavigatorState} NavigatorState + * @typedef {import("./state.mjs").SliceState} SliceState + */ + +/** + * 为一个还没有开始的切片建计划. + * + * @param {NavigatorState} state 状态. + * @param {string | undefined} id 切片编号. + * @param {string} file 切片文件的项目相对路径. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 已有进行中的切片, 切片不在当前里程, 或骨架还没有通过时. + */ +export function planSlice(state, id, file) { + const active = activeSlice(state); + if (active !== undefined) { + throw new WorkflowError( + `切片 ${active.slice.id} 还在进行 (${SLICE_STATUS_LABELS[active.slice.status]}), 同一时间只做一个切片; 它通过或放弃后再开始下一个.`, + ); + } + const milestone = currentMilestone(state); + const slice = milestone?.slices.find((entry) => entry.id === id); + if (slice === undefined || slice.status !== SLICE_STATUSES.pending) { + const pending = (milestone?.slices ?? []) + .filter((entry) => entry.status === SLICE_STATUSES.pending) + .map((entry) => entry.id); + throw new WorkflowError( + `slice plan 的用法: slice plan <编号> --slug <短名>, 编号是当前里程中还没有开始的切片${pending.length === 0 ? "; 当前里程没有这样的切片" : `: ${pending.join(", ")}`}.`, + ); + } + const skeleton = skeletonSlice(state); + if ( + skeleton !== undefined && + skeleton.id !== slice.id && + skeleton.status !== SLICE_STATUSES.done + ) { + throw new WorkflowError( + `最小骨架 (切片 ${skeleton.id}) 还没有通过验收, 先做它: 骨架跑通之前不做其它切片.`, + ); + } + return { + ...state, + milestones: patchSlice(state, slice.id, { + status: SLICE_STATUSES.planned, + file, + }), + current: { + sliceId: slice.id, + baseCommit: null, + planDigest: null, + round: FIRST_ROUND, + }, + lastAction: `切片 ${slice.id} 开始写计划`, + }; +} + +/** + * 开工: 记下基准提交与计划段的摘要. + * + * @param {NavigatorState} state 状态. + * @param {{baseCommit: string, planDigest: string}} facts 开工时的提交与计划摘要. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 没有处于计划中的切片时. + */ +export function startSlice(state, { baseCommit, planDigest }) { + const slice = requireActive(state, [SLICE_STATUSES.planned], "slice start"); + return move(state, slice, SLICE_STATUSES.running, { + current: { baseCommit, planDigest }, + lastAction: `切片 ${slice.id} 开工`, + }); +} + +/** + * 实现期间计划有改动时, 重新记下计划段的摘要. + * + * @param {NavigatorState} state 状态. + * @param {string} planDigest 新的计划摘要. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 没有处于实现中的切片时. + */ +export function refreshPlan(state, planDigest) { + const slice = requireActive(state, [SLICE_STATUSES.running], "slice replan"); + return move(state, slice, SLICE_STATUSES.running, { + current: { planDigest }, + lastAction: `切片 ${slice.id} 更新计划`, + }); +} + +/** + * 送验. + * + * @param {NavigatorState} state 状态. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 没有处于实现中的切片时. + */ +export function reviewSlice(state) { + const slice = requireActive(state, [SLICE_STATUSES.running], "slice review"); + return move(state, slice, SLICE_STATUSES.reviewing, { + lastAction: `切片 ${slice.id} 送验`, + }); +} + +/** + * 验收通过, 切片结束. + * + * @param {NavigatorState} state 状态. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 没有处于验收中的切片时. + */ +export function acceptSlice(state) { + const slice = requireActive( + state, + [SLICE_STATUSES.reviewing], + "slice accept", + ); + return settle(state, slice, SLICE_STATUSES.done, `切片 ${slice.id} 通过`); +} + +/** + * 补做: 回到实现中, 轮次加一, 记下补做内容写入后的计划摘要. + * + * @param {NavigatorState} state 状态. + * @param {string} planDigest 新的计划摘要. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 没有处于验收中的切片时. + */ +export function redoSlice(state, planDigest) { + const slice = requireActive(state, [SLICE_STATUSES.reviewing], "slice redo"); + const round = /** @type {{round: number}} */ (state.current).round + 1; + return move(state, slice, SLICE_STATUSES.running, { + current: { planDigest, round }, + lastAction: `切片 ${slice.id} 补做, 第 ${round} 轮`, + }); +} + +/** + * 放弃进行中的切片. 最小骨架不能放弃. + * + * @param {NavigatorState} state 状态. + * @returns {NavigatorState} 新状态. + * @throws {WorkflowError} 没有进行中的切片, 或它是最小骨架时. + */ +export function dropSlice(state) { + const slice = requireActive( + state, + [SLICE_STATUSES.planned, SLICE_STATUSES.running, SLICE_STATUSES.reviewing], + "slice drop", + ); + if (slice.skeleton) { + throw new WorkflowError( + `切片 ${slice.id} 是最小骨架, 不能放弃: 之后的切片都建立在它之上. 做不下去时补做, 或用需求变更重排路线.`, + ); + } + return settle(state, slice, SLICE_STATUSES.dropped, `切片 ${slice.id} 放弃`); +} + +/** + * 取进行中的切片, 并确认它处于允许的状态. + * + * @param {NavigatorState} state 状态. + * @param {readonly string[]} allowed 允许的状态. + * @param {string} command 命令名, 用于错误信息. + * @returns {SliceState} 进行中的切片. + * @throws {WorkflowError} 没有进行中的切片, 或状态不在允许范围内时. + */ +function requireActive(state, allowed, command) { + const slice = activeSlice(state)?.slice; + if (slice === undefined) { + throw new WorkflowError( + `没有进行中的切片, 不能运行 ${command}; 先用 slice plan 为一个切片建计划.`, + ); + } + if (!allowed.includes(slice.status)) { + throw new WorkflowError( + `切片 ${slice.id} 现在是 "${SLICE_STATUS_LABELS[slice.status]}", ${command} 要求它是 ${allowed.map((status) => `"${SLICE_STATUS_LABELS[status]}"`).join(" 或 ")}.`, + ); + } + return slice; +} + +/** + * 把进行中的切片转到新状态, 并更新运行信息. + * + * @param {NavigatorState} state 状态. + * @param {SliceState} slice 进行中的切片. + * @param {string} status 新状态. + * @param {{current?: Partial, lastAction: string}} changes 运行信息中要替换的字段与动作描述. + * @returns {NavigatorState} 新状态. + */ +function move(state, slice, status, { current = {}, lastAction }) { + return { + ...state, + milestones: patchSlice(state, slice.id, { status }), + current: { + .../** @type {import("./state.mjs").CurrentSlice} */ (state.current), + ...current, + }, + lastAction, + }; +} + +/** + * 结束进行中的切片. + * + * @param {NavigatorState} state 状态. + * @param {SliceState} slice 进行中的切片. + * @param {string} status 结束时的状态. + * @param {string} lastAction 动作描述. + * @returns {NavigatorState} 新状态. + */ +function settle(state, slice, status, lastAction) { + return { + ...state, + milestones: patchSlice(state, slice.id, { status }), + current: null, + lastAction, + }; +} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/source-evidence.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/source-evidence.mjs deleted file mode 100644 index b991646..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/source-evidence.mjs +++ /dev/null @@ -1,282 +0,0 @@ -/** - * @file 选型回执的源码证据: 读取回执中的证据表, 校验每条证据的写法, - * 从原仓库取回被引用的行, 生成核对输出. - * - * 证据由执行子代理写进回执; 验收子代理运行 evidence 命令, 从原仓库重新取源码核对, - * 不依赖执行子代理手中的副本. 取源码的细节在 source-fetch.mjs 中. - */ - -import { readSectionTable, splitSections } from "./layout.mjs"; -import { parseMarkdown } from "./markdown.mjs"; -import { COMMAND_NAME } from "./paths.mjs"; -import { fetchVersion, readSourceLines } from "./source-fetch.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 核对当前工单回执中全部证据的完整命令; 验收子代理原样运行. - * @type {string} - */ -export const EVIDENCE_COMMAND = `${COMMAND_NAME} evidence`; - -/** - * 核对单条证据的动作名, 执行子代理写回执前自查时使用. - * @type {string} - */ -export const EVIDENCE_CHECK_ACTION = "check"; - -/** - * 回执中放源码证据的节, 与规格 files.receipt 选型结构中的节标题一致. - * @type {string} - */ -export const EVIDENCE_SECTION = "能力核实"; - -/** - * 回执中逐条回答需求能否实现的节, 与规格 files.receipt 选型结构中的节标题一致. - * @type {string} - */ -export const REQUIREMENT_SECTION = "需求核对"; - -/** - * 证据表各列的列名, 与规格中该节表格的列一致, 顺序相同. - * @type {Readonly<{capability: string, repository: string, version: string, path: string, lines: string, note: string}>} - */ -export const EVIDENCE_COLUMNS = Object.freeze({ - capability: "能力", - repository: "仓库", - version: "版本", - path: "路径", - lines: "行号", - note: "说明", -}); - -/** - * 一条证据最多引用的行数; 更长的范围应拆成多条, 每条对应一段完整的逻辑. - * @type {number} - */ -export const MAX_EVIDENCE_LINES = 40; - -/** - * 仓库地址允许的协议: 只接受 https, 不接受本机路径与其它协议. - * @type {string} - */ -const REPOSITORY_PROTOCOL = "https:"; - -/** - * 版本的写法: tag, 分支或完整提交号, 不能以连字符开头. - * @type {RegExp} - */ -const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/+-]*$/u; - -/** - * 行号的写法: N 或 N-M. - * @type {RegExp} - */ -const LINE_RANGE_PATTERN = /^(\d+)(?:-(\d+))?$/u; - -/** - * 输出源码行时, 每行前面的缩进. - * @type {string} - */ -const SOURCE_LINE_INDENT = " "; - -/** - * 整格是一个行内代码时的格式. 证据表的仓库, 版本与路径写在行内代码中, - * 格式化工具不会改写其中的下划线等字符. - * @type {RegExp} - */ -const CODE_SPAN_PATTERN = /^`([^`]+)`$/u; - -/** - * Markdown 的反斜杠转义: 反斜杠加一个 ASCII 标点. - * @type {RegExp} - */ -const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/gu; - -/** - * @typedef {object} EvidenceRow 一条源码证据, 对应证据表的一行. - * @property {string} capability 证明的能力. - * @property {string} repository 仓库地址. - * @property {string} version 版本: tag, 分支或完整提交号. - * @property {string} path 仓库中的文件路径. - * @property {string} lines 行号, N 或 N-M. - * @property {string} note 这几行代码证明了什么. - */ - -/** - * 从回执全文中读出证据表. 单元格写成行内代码时取其中的文字, 否则去掉 Markdown 转义. - * - * @param {string} receiptText 回执全文. - * @returns {EvidenceRow[]} 各条证据. - * @throws {WorkflowError} 回执中没有证据表, 或表头与规格不一致时. - */ -export function readEvidenceRows(receiptText) { - const section = splitSections(parseMarkdown(receiptText)).sections.find( - (entry) => entry.title === EVIDENCE_SECTION, - ); - const table = section === undefined ? undefined : readSectionTable(section); - const keys = Object.keys(EVIDENCE_COLUMNS); - const columns = Object.values(EVIDENCE_COLUMNS); - if (table === undefined || table.headers.join("|") !== columns.join("|")) { - throw new WorkflowError( - `回执的 "${EVIDENCE_SECTION}" 节中没有证据表, 表头应依次为 ${columns.join(", ")}.`, - ); - } - return table.rows.map( - (row) => - /** @type {EvidenceRow} */ ( - Object.fromEntries( - keys.map((key, index) => [key, cellText(row[index] ?? "")]), - ) - ), - ); -} - -/** - * 取出单元格的文字: 整格是行内代码时取其中的内容, 否则去掉 Markdown 的反斜杠转义. - * - * @param {string} cell 单元格原文. - * @returns {string} 文字. - */ -function cellText(cell) { - const code = CODE_SPAN_PATTERN.exec(cell); - return code === null ? cell.replace(MARKDOWN_ESCAPE_PATTERN, "$1") : code[1]; -} - -/** - * 解析行号范围. - * - * @param {string} text 行号, N 或 N-M. - * @returns {import("./source-fetch.mjs").LineRange | undefined} 范围; 写法不合格, 起始大于结束或超过行数上限时为 undefined. - */ -export function parseLineRange(text) { - const match = LINE_RANGE_PATTERN.exec(text); - if (match === null) { - return undefined; - } - const start = Number(match[1]); - const end = match[2] === undefined ? start : Number(match[2]); - return start >= 1 && start <= end && end - start < MAX_EVIDENCE_LINES - ? { start, end } - : undefined; -} - -/** - * 找出一条证据在写法上的问题. - * - * @param {EvidenceRow} row 证据. - * @returns {string | undefined} 问题; 写法合格时为 undefined. - */ -export function evidenceProblem(row) { - if (!isHttpsAddress(row.repository)) { - return `仓库应写 https 开头的公开仓库地址, 实际为 "${row.repository}".`; - } - if (!VERSION_PATTERN.test(row.version)) { - return `版本应写仓库中的 tag, 分支或完整提交号, 实际为 "${row.version}".`; - } - if (!isRepositoryPath(row.path)) { - return `路径应写仓库中以正斜杠分隔的相对路径, 实际为 "${row.path}".`; - } - if (parseLineRange(row.lines) === undefined) { - return `行号应写 N 或 N-M, 起始不大于结束, 一条最多 ${MAX_EVIDENCE_LINES} 行, 实际为 "${row.lines}".`; - } - return undefined; -} - -/** - * 逐条核对证据: 先校验写法, 再从原仓库取回被引用的行. 同一仓库的同一版本 - * 在一次核对中只获取一次. - * - * @param {readonly EvidenceRow[]} rows 各条证据. - * @param {string} cacheRoot 源码缓存目录. - * @returns {import("./source-fetch.mjs").SourceResult[]} 与证据一一对应的结果: 取到的源码, 或写法不合格, 取不到的原因. - * @throws {WorkflowError} 无法建立本地缓存时. - */ -export function verifyEvidenceRows(rows, cacheRoot) { - /** @type {Map} */ - const versions = new Map(); - return rows.map((row) => { - const problem = evidenceProblem(row); - const range = parseLineRange(row.lines); - if (problem !== undefined || range === undefined) { - return { problem }; - } - const key = JSON.stringify([row.repository, row.version]); - if (!versions.has(key)) { - versions.set( - key, - fetchVersion({ - cacheRoot, - repository: row.repository, - version: row.version, - }), - ); - } - const { commit, problem: versionProblem } = versions.get(key) ?? {}; - return commit === undefined - ? { problem: versionProblem } - : readSourceLines({ - cacheRoot, - repository: row.repository, - commit, - filePath: row.path, - range, - }); - }); -} - -/** - * 生成一条证据的核对输出: 位置, 说明, 以及取到的源码或取不到的原因. - * - * @param {object} options 输出参数. - * @param {number} options.index 证据序号, 从 1 开始. - * @param {EvidenceRow} options.row 证据. - * @param {import("./source-fetch.mjs").SourceExcerpt} [options.excerpt] 取到的源码. - * @param {string} [options.problem] 取不到的原因. - * @returns {string[]} 输出各行. - */ -export function formatEvidence({ index, row, excerpt, problem }) { - const head = [ - `证据 ${index}: ${row.capability}`, - `- 位置: ${row.repository} ${row.version} ${row.path}:${row.lines}`, - `- 说明: ${row.note}`, - ]; - if (excerpt === undefined) { - return [...head, `- 结果: 取不到源码. ${problem ?? ""}`.trimEnd()]; - } - const width = String(excerpt.lines.at(-1)?.number ?? 0).length; - return [ - ...head, - `- 提交: ${excerpt.commit}`, - "- 源码:", - ...excerpt.lines.map( - (line) => - `${SOURCE_LINE_INDENT}${String(line.number).padStart(width)} | ${line.text}`, - ), - ]; -} - -/** - * 判断仓库地址是否为 https 地址. - * - * @param {string} value 仓库地址. - * @returns {boolean} 是 https 地址时返回 true. - */ -function isHttpsAddress(value) { - return URL.canParse(value) && new URL(value).protocol === REPOSITORY_PROTOCOL; -} - -/** - * 判断路径是否为仓库中的相对路径: 正斜杠分隔, 不以斜杠或连字符开头, 不含上级目录. - * - * @param {string} value 路径. - * @returns {boolean} 合格时返回 true. - */ -function isRepositoryPath(value) { - return ( - value !== "" && - !value.startsWith("/") && - !value.startsWith("-") && - !value.includes("\\") && - !value.split("/").includes("..") - ); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/source-fetch.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/source-fetch.mjs deleted file mode 100644 index 7cf3a40..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/source-fetch.mjs +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @file 从第三方公开仓库按版本取回某个文件的若干行. - * - * 每个仓库在缓存目录中对应一个只有远端配置的裸仓库. 每次只获取深度为 1 的 - * 提交, 文件内容按需下载, 因此只下载被引用的文件. 缓存放在 Git 目录中, - * 不进入工作区. git 以不读凭据, 不弹提示的方式运行, 需要账号的仓库直接失败. - */ - -import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import path from "node:path"; - -import { runIsolatedGit } from "./repo.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 访问远端 (取版本, 按需取文件) 的超时毫秒数. - * @type {number} - */ -const REMOTE_TIMEOUT = 120_000; - -/** - * 本地 git 操作 (建缓存仓库, 查配置, 解析提交) 的超时毫秒数. - * @type {number} - */ -const LOCAL_TIMEOUT = 30_000; - -/** - * 缓存目录名取仓库地址 SHA-256 摘要的前若干位. - * @type {number} - */ -const CACHE_NAME_LENGTH = 16; - -/** - * 缓存仓库中远端的名称. - * @type {string} - */ -const REMOTE_NAME = "origin"; - -/** - * @typedef {object} LineRange 行号范围, 两端都含. - * @property {number} start 起始行号, 从 1 开始. - * @property {number} end 结束行号. - */ - -/** - * @typedef {object} SourceExcerpt 取回的源码片段. - * @property {string} commit 版本对应的完整提交号. - * @property {{number: number, text: string}[]} lines 被引用的各行. - */ - -/** - * @typedef {object} SourceResult 取源码的结果: 取到的片段, 或取不到的原因. - * @property {SourceExcerpt} [excerpt] 取到的源码. - * @property {string} [problem] 取不到的原因. - */ - -/** - * @typedef {object} VersionResult 解析版本的结果: 对应的完整提交号, 或取不到的原因. - * @property {string} [commit] 版本对应的完整提交号. - * @property {string} [problem] 取不到的原因. - */ - -/** - * 从仓库获取某个版本, 返回对应的提交号. 本函数不限制仓库地址的协议, - * 调用方负责校验. - * - * @param {object} options 获取参数. - * @param {string} options.cacheRoot 源码缓存目录. - * @param {string} options.repository 仓库地址. - * @param {string} options.version 版本: tag, 分支或完整提交号. - * @returns {VersionResult} 提交号, 或取不到的原因. - * @throws {WorkflowError} 无法建立本地缓存时. - */ -export function fetchVersion({ cacheRoot, repository, version }) { - const gitDirectory = `--git-dir=${prepareCache(cacheRoot, repository)}`; - const fetched = runIsolatedGit( - [ - gitDirectory, - "fetch", - "--quiet", - "--depth", - "1", - "--filter=blob:none", - "--no-tags", - REMOTE_NAME, - version, - ], - REMOTE_TIMEOUT, - ); - const resolved = fetched.isSuccess - ? runIsolatedGit([gitDirectory, "rev-parse", "FETCH_HEAD"], LOCAL_TIMEOUT) - : fetched; - return resolved.isSuccess - ? { commit: resolved.stdout.toString("utf8").trim() } - : { problem: `取不到版本 ${version}: ${resolved.message}` }; -} - -/** - * 读取已获取的提交中某个文件的若干行, 文件内容按需下载. - * - * @param {object} options 读取参数. - * @param {string} options.cacheRoot 源码缓存目录. - * @param {string} options.repository 仓库地址. - * @param {string} options.commit 由 fetchVersion 得到的提交号. - * @param {string} options.filePath 仓库中的文件路径. - * @param {LineRange} options.range 被引用的行. - * @returns {SourceResult} 取到的源码, 或取不到的原因. - * @throws {WorkflowError} 无法建立本地缓存时. - */ -export function readSourceLines({ - cacheRoot, - repository, - commit, - filePath, - range, -}) { - const gitDirectory = `--git-dir=${prepareCache(cacheRoot, repository)}`; - const blob = runIsolatedGit( - [gitDirectory, "cat-file", "blob", `${commit}:${filePath}`], - REMOTE_TIMEOUT, - ); - if (!blob.isSuccess) { - return { problem: `取不到文件 ${filePath}: ${blob.message}` }; - } - const lines = splitLines(blob.stdout.toString("utf8")); - if (range.end > lines.length) { - return { - problem: `文件 ${filePath} 只有 ${lines.length} 行, 引用的行号 ${range.start}-${range.end} 超出范围.`, - }; - } - return { - excerpt: { - commit, - lines: lines - .slice(range.start - 1, range.end) - .map((text, index) => ({ number: range.start + index, text })), - }, - }; -} - -/** - * 准备某个仓库的缓存: 已有且远端地址一致时直接使用, 否则重建一个只有远端配置的裸仓库. - * - * @param {string} cacheRoot 源码缓存目录. - * @param {string} repository 仓库地址. - * @returns {string} 缓存仓库的绝对路径. - * @throws {WorkflowError} 无法建立缓存仓库时. - */ -function prepareCache(cacheRoot, repository) { - const directory = path.join( - cacheRoot, - createHash("sha256") - .update(repository) - .digest("hex") - .slice(0, CACHE_NAME_LENGTH), - ); - const gitDirectory = `--git-dir=${directory}`; - if (existsSync(directory)) { - const configured = runIsolatedGit( - [gitDirectory, "config", "--get", `remote.${REMOTE_NAME}.url`], - LOCAL_TIMEOUT, - ); - if ( - configured.isSuccess && - configured.stdout.toString("utf8").trim() === repository - ) { - return directory; - } - rmSync(directory, { recursive: true, force: true }); - } - mkdirSync(cacheRoot, { recursive: true }); - for (const args of [ - ["init", "--quiet", "--bare", directory], - [gitDirectory, "remote", "add", REMOTE_NAME, repository], - ]) { - const outcome = runIsolatedGit(args, LOCAL_TIMEOUT); - if (!outcome.isSuccess) { - rmSync(directory, { recursive: true, force: true }); - throw new WorkflowError(`无法建立源码缓存: ${outcome.message}`); - } - } - return directory; -} - -/** - * 把文件内容拆成行; 文件末尾的换行不算作多出的一行. - * - * @param {string} text 文件内容. - * @returns {string[]} 各行. - */ -function splitLines(text) { - const lines = text.replace(/\r\n?/gu, "\n").split("\n"); - return lines.at(-1) === "" ? lines.slice(0, -1) : lines; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs deleted file mode 100644 index d505130..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @file 读取模板规格 `spec/templates.json`, 并提供按类型查询的方法. - * - * 规格是回复与文件结构的唯一来源: 回复骨架的生成, Stop 时的版式校验与参考 - * 文档的生成都从这里读取, 不在其它地方重复定义标题, 键名或选项. - */ - -import { readFileSync } from "node:fs"; - -import { SPEC_FILE } from "./paths.mjs"; - -/** - * @typedef {object} TableSpec 节中表格的规格. - * @property {string[]} columns 表头各列, 按顺序. - * @property {Record} [choices] 只能取固定值的列, 键为列名, 值为允许的取值. - * @property {boolean} [allowEmpty] 是否允许只有表头, 没有内容行. - */ - -/** - * @typedef {object} SectionSpec 回复或记录文件中一个二级节的规格. - * @property {string} title 节标题, 4 个汉字. - * @property {string[]} [keys] 键值行的键名, 按顺序; 省略时节内容不固定. - * @property {Record} [presets] 骨架中预填的键值, 键为键名. 用于写骨架时还不知道, 或由固定说明代替的值; 预填值不是占位标记, 写入校验放行. - * @property {TableSpec} [table] 节中必须有的表格; 与 keys 不同时使用. - * @property {boolean} [freeform] 是否为自由正文: 按用户要求的格式撰写, 可以放代码块. - */ - -/** - * @typedef {object} OptionSetSpec 一组固定选项的规格. - * @property {string} title 选项标题, 4 个汉字, 显示在方括号中. - * @property {string[]} choices 选项文字, 按字母顺序排列, 不含字母前缀. - * @property {boolean} [buttons] 写 false 时不做成应用的按钮: 选择之后还要用户打字补充 (例如逐条回答), 或选了就会提交改动 (应用规定提交不能用回复按钮). - */ - -/** - * @typedef {object} ReplySpec 一种回复类型的规格. - * @property {string} id 英文编号, 供命令行参数使用. - * @property {string} [orderStage] 这种回复的进展行中工单所处的环节; 省略时按工单状态取 format.orderStages. - * @property {SectionSpec[]} sections 结论与进展行之后的节, 按顺序. - * @property {OptionSetSpec[]} optionSets 允许使用的选项组, 回复中必须恰好使用其中一组; 为空表示这种回复不需要用户选择, 不带选项块. - */ - -/** - * @typedef {object} FormatSpec 全局版式规则. - * @property {number} titleLength 标题与键名的汉字数. - * @property {number} keyLength 键名的汉字数. - * @property {number} numberWidth 编号与计数的位数. - * @property {string} conclusionLabel 回复标题下方结论行的前缀. - * @property {number} conclusionMaxLength 结论行建议的最大字数, 只作提醒, 不打回. - * @property {string} emptyValue 字段不适用时写的值. - * @property {string} progressTitle 记录文件中进展节的标题. - * @property {string[]} progressKeys 记录文件中进展节的键名, 按顺序. - * @property {string[]} progressLabels 回复中进展行各项的名称, 与 progressKeys 一一对应. - * @property {string} progressSeparator 回复中进展行各项之间的分隔符. - * @property {Record} orderStages 工单状态到环节名称的映射, 写在进展行的工单编号之后. - */ - -/** - * @typedef {object} WritingRule 一条写作规则. - * @property {string} label 问题类别, 4 个汉字, 用于报告. - * @property {"problem" | "hint"} severity 级别: 问题必须修改, 提示供参考. - */ - -/** - * @typedef {object} WritingSpec 写作规则, 各条规则在 WritingRule 之外另有自己的阈值或词表. - * @property {WritingRule} structure 结构不符合文件规格. - * @property {WritingRule & {maxLength: number, hintLength: number}} sentence 无标点片段的字数. - * @property {WritingRule & {maxLines: number}} paragraph 段落行数. - * @property {WritingRule & {maxItems: number}} listLength 同一层列表的项数. - * @property {WritingRule & {maxDepth: number}} listDepth 列表嵌套层数. - * @property {WritingRule & {maxPerSection: number}} bold 每节加粗次数. - * @property {WritingRule & {character: string, maxPerPhrase: number}} particle 每个片段中 "的" 的个数. - * @property {WritingRule & {words: string[]}} jargon 黑话词表. - * @property {WritingRule & {words: string[]}} metaphor 比喻词表. - * @property {WritingRule & {patterns: {pattern: string, advice: string}[]}} translationese 翻译腔句式. - * @property {WritingRule & {patterns: string[]}} pronoun 句首裸指代词. - * @property {WritingRule} table 表格源码未对齐. - * @property {WritingRule & {maxReplyLines: number, maxFileLines: number}} length 篇幅上限. - */ - -/** - * @typedef {object} TemplateSpec 模板规格全文. - * @property {number} version 规格版本. - * @property {FormatSpec} format 全局版式规则. - * @property {WritingSpec} writing 写作规则. - * @property {string[]} stages 各阶段名称, 下标为阶段编号. - * @property {[string, string][]} processTerms 流程术语及其定义, init 写入术语表. - * @property {Record} replies 各回复类型的规格, 键为一级标题. - * @property {Record} files 各记录文件的规格. - */ - -/** - * 读取模板规格. - * - * @param {string} [file] 规格文件路径; 默认为技能目录中的规格. - * @returns {TemplateSpec} 规格. - */ -export function loadSpec(file = SPEC_FILE) { - return JSON.parse(readFileSync(file, "utf8")); -} - -/** - * 查询某种回复类型的规格. - * - * @param {TemplateSpec} spec 模板规格. - * @param {string} type 回复类型, 即一级标题. - * @returns {ReplySpec | undefined} 规格; 类型不存在时为 undefined. - */ -export function findReply(spec, type) { - return Object.hasOwn(spec.replies, type) ? spec.replies[type] : undefined; -} - -/** - * 把回复类型的中文标题或英文编号解析为中文标题. 命令行参数可以使用英文编号, - * 避免部分终端传递中文参数时出现乱码. - * - * @param {TemplateSpec} spec 模板规格. - * @param {string} nameOrId 中文标题或英文编号. - * @returns {string | undefined} 中文标题; 两者都不匹配时为 undefined. - */ -export function resolveReplyType(spec, nameOrId) { - if (Object.hasOwn(spec.replies, nameOrId)) { - return nameOrId; - } - return Object.entries(spec.replies).find( - ([, reply]) => reply.id === nameOrId, - )?.[0]; -} - -/** - * 判断节是否为自由正文. - * - * @param {SectionSpec} section 节规格. - * @returns {boolean} 是自由正文时返回 true. - */ -export function isFreeformSection(section) { - return section.freeform === true; -} - -/** - * 返回最后一个阶段的编号, 用作 "当前阶段" 中的分母. - * - * @param {TemplateSpec} spec 模板规格. - * @returns {number} 最后一个阶段的编号. - */ -export function lastStageNumber(spec) { - return spec.stages.length - 1; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/state-checks.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/state-checks.mjs deleted file mode 100644 index 8535ed8..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/state-checks.mjs +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @file 状态自检: 核对状态文件与记录文件是否自洽. 命令只会写出自洽的状态, - * 所以不自洽说明技能脚本有问题, 或状态被别的途径改过; 两种情况流程都不能照常继续. - */ - -import { existsSync } from "node:fs"; -import path from "node:path"; - -import { orderFilePath } from "./paths.mjs"; -import { lastStageNumber } from "./spec.mjs"; -import { ORDER_KINDS, ORDER_TRANSITIONS } from "./workflow-orders.mjs"; - -/** - * 工单文件必须已经存在的工单状态: 发布之后的各个状态. - * @type {readonly string[]} - */ -const STATUSES_WITH_ORDER_FILE = Object.freeze([ - "issued", - "blocked", - "reviewing", -]); - -/** - * 核对状态是否自洽, 返回发现的问题. - * - * @param {object} options 参数. - * @param {import("./state.mjs").NavigatorState} options.state 当前状态. - * @param {string} options.projectRoot 项目根目录. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @returns {string[]} 问题列表; 为空表示自洽. - */ -export function checkStateConsistency({ state, projectRoot, spec }) { - return [ - ...checkPosition(state, lastStageNumber(spec)), - ...checkOrder(state, projectRoot), - ]; -} - -/** - * 核对阶段, 当前里程与当前切片. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {number} lastStage 最后一个阶段的编号. - * @returns {string[]} 问题列表. - */ -function checkPosition(state, lastStage) { - const problems = []; - const isStageValid = - state.stage === null || - (Number.isInteger(state.stage) && - state.stage >= 0 && - state.stage <= lastStage); - if (!isStageValid) { - problems.push( - `阶段编号 ${String(state.stage)} 不在 0 至 ${lastStage} 之间.`, - ); - } - if ( - state.milestone !== null && - !state.milestones.some((entry) => entry.id === state.milestone) - ) { - problems.push(`当前里程 ${state.milestone} 不在里程列表中.`); - } - if ( - state.slice !== null && - !state.slices.some((entry) => entry.id === state.slice) - ) { - problems.push(`当前切片 ${state.slice} 不在切片列表中.`); - } - return problems; -} - -/** - * 核对当前工单: 类型与状态合法, 所属切片存在, 发布之后工单文件存在. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} projectRoot 项目根目录. - * @returns {string[]} 问题列表. - */ -function checkOrder(state, projectRoot) { - const order = state.order; - if (order === null) { - return []; - } - const problems = []; - if (!ORDER_KINDS.includes(order.kind)) { - problems.push(`工单 ${order.id} 的类型 ${order.kind} 不是已知的类型.`); - } - if (!Object.hasOwn(ORDER_TRANSITIONS, order.status)) { - problems.push(`工单 ${order.id} 的状态 ${order.status} 不是进行中的状态.`); - } - if ( - order.slice !== null && - !state.slices.some((entry) => entry.id === order.slice) - ) { - problems.push(`工单 ${order.id} 所属的切片 ${order.slice} 不在切片列表中.`); - } - const orderFile = orderFilePath(order.folder, "order"); - if ( - STATUSES_WITH_ORDER_FILE.includes(order.status) && - !existsSync(path.join(projectRoot, orderFile)) - ) { - problems.push( - `工单 ${order.id} 处于 ${order.status}, 但工单文件 ${orderFile} 不存在.`, - ); - } - return problems; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs index dee8792..ca50fb6 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs @@ -1,22 +1,22 @@ /** - * @file 状态文件 `state.json` 的创建, 读取与原子写入. + * @file 状态文件 `state.json` 的结构, 创建, 读取与原子写入. * - * `state.json` 是阶段, 里程, 切片, 工单状态与各类编号的唯一来源. 写入时比较 - * 版本号, 并用 "写临时文件再改名" 的方式替换, 防止并发写入互相覆盖或写出半个文件. - * 读取时迁移旧结构: 去掉 1.0.0 之前的会话登记, 对账与提交结算字段. + * `state.json` 是阶段, 项目设置, 里程, 切片与各类编号的唯一来源. 写入时比较 + * 版本号, 并用 "写临时文件再改名" 的方式替换, 防止并发写入互相覆盖或写出半个 + * 文件. 结构版本 6 是 2.0.0 的结构; 更早的结构不迁移, 由 init 归档. */ import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { writeJsonAtomically } from "./atomic-file.mjs"; -import { navigatorPath } from "./paths.mjs"; +import { STATE_FILE } from "./paths.mjs"; /** - * 状态文件结构的版本号; 结构变化时加一, 并在读取时迁移. - * 版本 5 去掉会话登记, 对账, 阶段提交与取证工具登记, 工单不再有提交中的状态. + * 状态文件结构的版本号. * @type {number} */ -export const STATE_SCHEMA_VERSION = 5; +export const STATE_SCHEMA_VERSION = 6; /** * 初始化状态: 已初始化, 插件的 hook 在本项目中生效. @@ -31,23 +31,122 @@ export const INIT_ACTIVE = "active"; export const INIT_UNINSTALLED = "uninstalled"; /** - * 旧结构中已经不用的顶层字段, 读取时移除. - * @type {readonly string[]} + * 项目阶段的取值. + * @type {Readonly<{intake: string, slicing: string, finished: string}>} */ -const LEGACY_KEYS = Object.freeze([ - "session", - "formerSessions", - "lastCommit", - "stageCommit", - "pendingAnomaly", - "evidenceTools", -]); +export const STAGES = Object.freeze({ + intake: "intake", + slicing: "slicing", + finished: "finished", +}); /** - * 旧结构中表示验收通过之后正在或等待提交的工单状态; 读取时视为已通过并结束. - * @type {readonly string[]} + * 项目阶段的中文名. + * @type {Readonly>} */ -const LEGACY_ACCEPTED_STATUSES = Object.freeze(["accepted", "committing"]); +export const STAGE_LABELS = Object.freeze({ + [STAGES.intake]: "立项", + [STAGES.slicing]: "切片循环", + [STAGES.finished]: "已收尾", +}); + +/** + * 模块组织方式的取值. + * @type {Readonly<{directories: string, filters: string}>} + */ +export const ORGANIZATIONS = Object.freeze({ + directories: "directories", + filters: "filters", +}); + +/** + * 模块组织方式的中文名. + * @type {Readonly>} + */ +export const ORGANIZATION_LABELS = Object.freeze({ + [ORGANIZATIONS.directories]: "按物理目录分模块", + [ORGANIZATIONS.filters]: "文件平铺, 用 Visual Studio 虚拟筛选器分模块", +}); + +/** + * 需要登记命令的验证方式. + * @type {string} + */ +export const AUTO_VERIFICATION = "auto"; + +/** + * 验证方式的中文名, 键为验证方式的取值. + * @type {Readonly>} + */ +export const VERIFICATION_LABELS = Object.freeze({ + [AUTO_VERIFICATION]: "自动测试", + manual: "用户手动运行", + measured: "实测", +}); + +/** + * 里程状态的取值. + * @type {Readonly<{pending: string, active: string, done: string}>} + */ +export const MILESTONE_STATUSES = Object.freeze({ + pending: "pending", + active: "active", + done: "done", +}); + +/** + * 里程状态的中文名. + * @type {Readonly>} + */ +export const MILESTONE_LABELS = Object.freeze({ + [MILESTONE_STATUSES.pending]: "未开始", + [MILESTONE_STATUSES.active]: "进行中", + [MILESTONE_STATUSES.done]: "已完成", +}); + +/** + * 切片种类的取值. + * @type {Readonly<{build: string, findings: string}>} + */ +export const SLICE_KINDS = Object.freeze({ + build: "build", + findings: "findings", +}); + +/** + * 切片种类的中文名. + * @type {Readonly>} + */ +export const SLICE_KIND_LABELS = Object.freeze({ + [SLICE_KINDS.build]: "改代码", + [SLICE_KINDS.findings]: "只产出结论", +}); + +/** + * 切片状态的取值. + * @type {Readonly<{pending: string, planned: string, running: string, reviewing: string, done: string, dropped: string}>} + */ +export const SLICE_STATUSES = Object.freeze({ + pending: "pending", + planned: "planned", + running: "running", + reviewing: "reviewing", + done: "done", + dropped: "dropped", +}); + +/** + * 切片状态的中文名. + * @type {Readonly>} + */ +export const SLICE_STATUS_LABELS = Object.freeze({ + [SLICE_STATUSES.pending]: "未开始", + [SLICE_STATUSES.planned]: "计划中", + [SLICE_STATUSES.running]: "实现中", + [SLICE_STATUSES.reviewing]: "验收中", + [SLICE_STATUSES.done]: "已完成", + [SLICE_STATUSES.dropped]: "已放弃", +}); /** * 读取状态文件时发现版本号与预期不一致时抛出的错误. @@ -63,17 +162,38 @@ export class StateConflictError extends Error { } /** - * @typedef {object} OrderState 当前工单. - * @property {string} id 工单编号. - * @property {string} slug 文件夹短名. - * @property {string} folder 工单文件夹名. - * @property {string} kind 工单类型. - * @property {string | null} slice 所属切片编号. - * @property {string} status 工单状态. - * @property {number} round 发布轮次. - * @property {string} baseCommit 基准提交. - * @property {string} [issuedDigest] 最近一次发布时工单文件内容的摘要. - * @property {boolean} [prechecked] 是否已做过发布前核对. + * @typedef {object} SliceState 一个切片. + * @property {string} id 切片编号. + * @property {string} name 切片名称. + * @property {string} kind 切片种类, 见 SLICE_KINDS. + * @property {boolean} skeleton 是否为最小骨架. + * @property {boolean} interactive 是否由用户正在对话的会话直接做. + * @property {string} status 切片状态, 见 SLICE_STATUSES. + * @property {string | null} file 切片文件的项目相对路径; 还没有写计划时为 null. + */ + +/** + * @typedef {object} MilestoneState 一个里程. + * @property {string} id 里程编号. + * @property {string} name 里程名称. + * @property {string} goal 里程目标. + * @property {string} status 里程状态, 见 MILESTONE_STATUSES. + * @property {SliceState[]} slices 里程中的切片. + */ + +/** + * @typedef {object} CurrentSlice 进行中的切片的运行信息. + * @property {string} sliceId 切片编号. + * @property {string | null} baseCommit 开工时的提交; 还没有开工时为 null. + * @property {string | null} planDigest 开工时计划段的摘要; 还没有开工时为 null. + * @property {number} round 轮次, 补做一次加一. + */ + +/** + * @typedef {object} ProjectSettings 项目设置. + * @property {string | null} organization 模块组织方式, 见 ORGANIZATIONS; 没有登记时为 null. + * @property {{mode: string, commands: string[]} | null} verification 验证方式; 没有登记时为 null. + * @property {string[]} conventions 项目自己的约定, 写入项目规范. */ /** @@ -82,23 +202,14 @@ export class StateConflictError extends Error { * @property {number} revision 写入次数, 用于并发比较. * @property {string} skillVersion 写入该状态的技能版本. * @property {{status: string, initializedAt: string, uninstalledAt?: string}} init 初始化信息. - * @property {number | null} stage 当前阶段编号, 0 至 6; 尚未开始时为 null. - * @property {string} step 当前步骤标识. - * @property {string | null} milestone 当前里程编号. - * @property {string | null} slice 当前切片编号. - * @property {OrderState | null} order 当前工单. - * @property {object[]} milestones 里程列表. - * @property {object[]} slices 切片列表. - * @property {object[]} orders 已结束的工单摘要. - * @property {object[]} risks 风险列表. - * @property {object[]} decisions 决策列表. - * @property {object[]} changes 变更列表. - * @property {import("./code-checks.mjs").CodeChecks | null} codeChecks 登记的代码检查命令; 尚未登记时为 null. - * @property {Record} models 子代理模型分配中与默认不同的部分, 键见 subagent-models.mjs. + * @property {string} stage 项目阶段, 见 STAGES. + * @property {ProjectSettings} project 项目设置. + * @property {MilestoneState[]} milestones 里程列表. + * @property {CurrentSlice | null} current 进行中的切片; 没有时为 null. + * @property {{id: string, title: string, supersededBy: string | null}[]} decisions 决策列表. + * @property {Record} next 各类编号的下一个值. * @property {string} lastAction 最后一个动作的中文描述. * @property {string} updatedAt 最后写入时间. - * @property {object[]} skipped 被跳过的阶段及原因. - * @property {Record} next 各类编号的下一个值. */ /** @@ -115,58 +226,31 @@ export function createInitialState({ skillVersion, now }) { revision: 0, skillVersion, init: { status: INIT_ACTIVE, initializedAt: now }, - stage: null, - step: "entry", - milestone: null, - slice: null, - order: null, + stage: STAGES.intake, + project: { organization: null, verification: null, conventions: [] }, milestones: [], - slices: [], - orders: [], - risks: [], + current: null, decisions: [], - changes: [], - codeChecks: null, - models: {}, + next: { milestone: 1, slice: 1, decision: 1 }, lastAction: "初始化", updatedAt: now, - skipped: [], - next: { - milestone: 1, - slice: 1, - order: 1, - decision: 1, - change: 1, - risk: 1, - checkup: 1, - }, }; } /** - * 读取状态文件. + * 读取状态文件, 不判断结构版本. * * @param {string} projectRoot 项目根目录. - * @returns {NavigatorState | undefined} 状态; 文件不存在时为 undefined. - * @throws {SyntaxError} 文件不是合法 JSON 时. - */ -export function readState(projectRoot) { - const file = navigatorPath(projectRoot, "state"); - return existsSync(file) - ? normalizeState(JSON.parse(readFileSync(file, "utf8"))) - : undefined; -} - -/** - * 读取状态文件; 文件不存在或不是合法 JSON 时返回 undefined. - * - * @param {string} projectRoot 项目根目录. - * @returns {NavigatorState | undefined} 状态. + * @returns {Record | undefined} 文件内容; 文件不存在或不是合法 JSON 时为 undefined. * @throws {Error} 读取文件本身失败时. */ -export function readStateIfValid(projectRoot) { +export function readRawState(projectRoot) { + const file = path.join(projectRoot, STATE_FILE); + if (!existsSync(file)) { + return undefined; + } try { - return readState(projectRoot); + return JSON.parse(readFileSync(file, "utf8")); } catch (error) { if (error instanceof SyntaxError) { return undefined; @@ -175,6 +259,27 @@ export function readStateIfValid(projectRoot) { } } +/** + * 判断读取到的内容是不是当前结构的状态. + * + * @param {Record | undefined} raw 读取到的内容. + * @returns {raw is NavigatorState} 是当前结构时返回 true. + */ +export function isCurrentState(raw) { + return raw !== undefined && raw.schema === STATE_SCHEMA_VERSION; +} + +/** + * 读取当前结构的状态. + * + * @param {string} projectRoot 项目根目录. + * @returns {NavigatorState | undefined} 状态; 文件不存在, 损坏或是更早的结构时为 undefined. + */ +export function readState(projectRoot) { + const raw = readRawState(projectRoot); + return isCurrentState(raw) ? raw : undefined; +} + /** * 原子写入状态文件. 写入前核对磁盘上的版本号, 写入时版本号加一. * @@ -185,98 +290,13 @@ export function readStateIfValid(projectRoot) { * @throws {StateConflictError} 当磁盘上的版本号与传入的不一致时. */ export function writeState(projectRoot, state, now) { - const current = readStateIfValid(projectRoot); + const current = readState(projectRoot); if (current !== undefined && current.revision !== state.revision) { throw new StateConflictError( `state: 版本冲突, 磁盘为 ${current.revision}, 写入基于 ${state.revision}`, ); } - const next = normalizeState({ - ...state, - revision: state.revision + 1, - updatedAt: now, - }); - writeJsonAtomically(navigatorPath(projectRoot, "state"), next); + const next = { ...state, revision: state.revision + 1, updatedAt: now }; + writeJsonAtomically(path.join(projectRoot, STATE_FILE), next); return next; } - -/** - * 为旧版本写入的状态补齐字段, 去掉不再使用的字段, 并标为当前结构版本, - * 不修改传入对象. 验收通过后等待或正在提交的旧工单视为已通过并结束. - * - * @param {Record} raw 读取到的状态. - * @returns {NavigatorState} 字段齐全的状态. - */ -function normalizeState(raw) { - const rest = Object.fromEntries( - Object.entries(raw).filter(([key]) => !LEGACY_KEYS.includes(key)), - ); - const order = raw.order ?? null; - const isLegacyAccepted = - order !== null && LEGACY_ACCEPTED_STATUSES.includes(order.status); - const orders = raw.orders ?? []; - const slices = raw.slices ?? []; - return { - ...rest, - schema: STATE_SCHEMA_VERSION, - init: { ...raw.init, status: normalizeInitStatus(raw.init?.status) }, - order: isLegacyAccepted ? null : stripLegacyOrderFields(order), - milestones: raw.milestones ?? [], - slices: isLegacyAccepted - ? slices.map((slice) => - slice.id === order.slice - ? { ...slice, orders: [...(slice.orders ?? []), order.id] } - : slice, - ) - : slices, - orders: isLegacyAccepted - ? [...orders, summarizeLegacyOrder(order)] - : orders, - risks: raw.risks ?? [], - decisions: raw.decisions ?? [], - changes: raw.changes ?? [], - codeChecks: raw.codeChecks ?? null, - models: raw.models ?? {}, - skipped: raw.skipped ?? [], - }; -} - -/** - * 旧结构中 "已写入配置, 等待自检" 的状态现在视为已初始化. - * - * @param {string | undefined} status 读取到的初始化状态. - * @returns {string} 当前结构中的初始化状态. - */ -function normalizeInitStatus(status) { - return status === INIT_UNINSTALLED ? INIT_UNINSTALLED : INIT_ACTIVE; -} - -/** - * 去掉旧工单中不再使用的字段. - * - * @param {Record | null} order 当前工单. - * @returns {OrderState | null} 去掉字段后的工单. - */ -function stripLegacyOrderFields(order) { - if (order === null) { - return null; - } - const { authorizedTests: _authorizedTests, ...rest } = order; - return /** @type {OrderState} */ (rest); -} - -/** - * 生成验收通过后等待或正在提交的旧工单的历史摘要. - * - * @param {Record} order 旧工单. - * @returns {{id: string, slug: string, kind: string, slice: string | null, status: string}} 摘要. - */ -function summarizeLegacyOrder(order) { - return { - id: order.id, - slug: order.slug, - kind: order.kind, - slice: order.slice, - status: "accepted", - }; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/subagent-models.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/subagent-models.mjs deleted file mode 100644 index 27c5c17..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/subagent-models.mjs +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @file 子代理使用的模型: 默认分配, 项目中的改动, 以及派出时的写法. - * - * 模型在派出子代理时用 Agent 工具的 `model` 参数指定: 这个参数优先于子代理定义 - * 与环境变量, 用 SendMessage 续接时也保留 (Claude Code 官方文档). 所以分配写在 - * 状态文件中, 由下一动作写出参数, 不写进子代理定义: 定义写死之后所有项目都受 - * 影响, 续接时是否保留也没有文档依据. - * - * 默认分配: 照单取证的子代理 (核对, 验收, 阅读, 调研) 用 sonnet; 执行子代理跟随 - * 编排会话, 因为它要设计与判断, 也是指出工单有错的那一方. - */ - -import { WorkflowError } from "./workflow-error.mjs"; -import { ORDER_KINDS } from "./workflow-orders.mjs"; - -/** - * 表示跟随编排会话模型的取值: 派出时不传 `model` 参数. - * @type {string} - */ -export const INHERIT_MODEL = "inherit"; - -/** - * 可以指定的模型: Agent 工具 `model` 参数接受的别名, 以及跟随编排会话. - * @type {readonly string[]} - */ -export const MODEL_CHOICES = Object.freeze([ - "sonnet", - "opus", - "haiku", - "fable", - INHERIT_MODEL, -]); - -/** - * 子代理的角色. - * @type {Readonly<{executor: string, checker: string, reviewer: string, reader: string, researcher: string}>} - */ -export const MODEL_ROLES = Object.freeze({ - executor: "executor", - checker: "checker", - reviewer: "reviewer", - reader: "reader", - researcher: "researcher", -}); - -/** - * 各角色的中文名, 用于输出. - * @type {Readonly>} - */ -const ROLE_LABELS = Object.freeze({ - [MODEL_ROLES.executor]: "执行", - [MODEL_ROLES.checker]: "核对", - [MODEL_ROLES.reviewer]: "验收", - [MODEL_ROLES.reader]: "阅读", - [MODEL_ROLES.researcher]: "调研", -}); - -/** - * 各角色的默认模型. - * @type {Readonly>} - */ -const DEFAULT_MODELS = Object.freeze({ - [MODEL_ROLES.executor]: INHERIT_MODEL, - [MODEL_ROLES.checker]: "sonnet", - [MODEL_ROLES.reviewer]: "sonnet", - [MODEL_ROLES.reader]: "sonnet", - [MODEL_ROLES.researcher]: "sonnet", -}); - -/** - * 按工单类型单独指定执行模型时, 状态中键的分隔符, 例如 "executor:fix". - * @type {string} - */ -const KIND_SEPARATOR = ":"; - -/** - * 取得某个角色使用的模型. 执行子代理先看这种工单类型的指定, 再看执行的指定. - * - * @param {Record | undefined} models 状态中登记的分配. - * @param {string} role 角色. - * @param {string} [kind] 工单类型; 只对执行子代理有意义. - * @returns {string} MODEL_CHOICES 中的一个值. - */ -export function resolveModel(models, role, kind) { - const assigned = models ?? {}; - return ( - (kind === undefined - ? undefined - : assigned[`${role}${KIND_SEPARATOR}${kind}`]) ?? - assigned[role] ?? - DEFAULT_MODELS[role] - ); -} - -/** - * 写出派出子代理时 `model` 参数的写法. - * - * @param {Record | undefined} models 状态中登记的分配. - * @param {string} role 角色. - * @param {string} [kind] 工单类型. - * @returns {string} 例如 `model 参数写 "sonnet"`, 或 "不传 model 参数". - */ -export function modelArgument(models, role, kind) { - const model = resolveModel(models, role, kind); - return model === INHERIT_MODEL - ? "不传 model 参数" - : `model 参数写 "${model}"`; -} - -/** - * 登记某个角色使用的模型, 返回新状态. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {object} options 参数. - * @param {string} options.role 角色. - * @param {string} options.model 模型. - * @param {string} [options.kind] 工单类型; 只能与执行子代理一起用. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 角色, 模型或工单类型不合法时. - */ -export function assignModel(state, { role, model, kind }) { - const roles = Object.values(MODEL_ROLES); - if (!roles.includes(role)) { - throw new WorkflowError(`角色应为 ${roles.join(", ")} 之一.`); - } - if (!MODEL_CHOICES.includes(model)) { - throw new WorkflowError(`模型应为 ${MODEL_CHOICES.join(", ")} 之一.`); - } - if (kind !== undefined && role !== MODEL_ROLES.executor) { - throw new WorkflowError("--kind 只能与 executor 一起用."); - } - if (kind !== undefined && !ORDER_KINDS.includes(kind)) { - throw new WorkflowError(`工单类型应为 ${ORDER_KINDS.join(", ")} 之一.`); - } - const key = kind === undefined ? role : `${role}${KIND_SEPARATOR}${kind}`; - return { - ...state, - models: { ...state.models, [key]: model }, - lastAction: `子代理模型 ${key} 设为 ${model}`, - }; -} - -/** - * 描述当前的模型分配, 一行写完; 执行子代理按工单类型另有指定时一并列出. - * - * @param {Record | undefined} models 状态中登记的分配. - * @returns {string} 例如 "执行 跟随会话, 核对 sonnet, 验收 sonnet". - */ -export function describeModels(models) { - const label = (model) => (model === INHERIT_MODEL ? "跟随会话" : model); - const overrides = Object.entries(models ?? {}) - .filter(([key]) => key.includes(KIND_SEPARATOR)) - .map( - ([key, model]) => - `${ROLE_LABELS[MODEL_ROLES.executor]} (${key.split(KIND_SEPARATOR)[1]} 工单) ${label(model)}`, - ); - return [ - ...Object.values(MODEL_ROLES).map( - (role) => `${ROLE_LABELS[role]} ${label(resolveModel(models, role))}`, - ), - ...overrides, - ].join(", "); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/text-units.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/text-units.mjs deleted file mode 100644 index fd20f3f..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/text-units.mjs +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @file 字数的算法: 一个汉字, 或一个连续的英文单词, 数字, 路径算 1 个字; - * 空格与标点不算, 行内代码与网址整体算 1 个字. - * - * 句长检查与结论行的建议字数都用这里的算法, 规则中写的 "字" 只有这一种含义. - */ - -/** - * 字数算法的说明, 写进参考文档与 reply 命令的填写要求. - * @type {string} - */ -export const UNIT_RULE = - "字数的算法: 汉字, 英文单词, 数字, 路径各算 1 字, 行内代码与网址整体算 1 字, 空格与标点不算."; - -/** - * 行内代码. - * @type {RegExp} - */ -const INLINE_CODE_PATTERN = /`[^`]*`/gu; - -/** - * 链接: 只保留链接文字. - * @type {RegExp} - */ -const LINK_PATTERN = /\[([^\]]*)\]\([^)]*\)/gu; - -/** - * 网址. - * @type {RegExp} - */ -const URL_PATTERN = /https?:\/\/\S+/gu; - -/** - * 行内代码与网址在计数时替换成的记号, 按 1 个字计算. - * @type {string} - */ -const TOKEN_REPLACEMENT = "X"; - -/** - * 计数单位: 一个汉字, 或一个连续的英文单词, 数字, 路径. - * @type {RegExp} - */ -const COUNTED_UNIT_PATTERN = - /\p{Script=Han}|[A-Za-z0-9]+(?:[-_./\\][A-Za-z0-9]+)*/gu; - -/** - * 去掉行内代码, 链接地址与网址, 只保留需要检查的文字. - * - * @param {string} content 正文. - * @returns {string} 处理后的文字. - */ -export function plainText(content) { - return content - .replace(INLINE_CODE_PATTERN, TOKEN_REPLACEMENT) - .replace(LINK_PATTERN, "$1") - .replace(URL_PATTERN, TOKEN_REPLACEMENT); -} - -/** - * 计算已经过 {@link plainText} 处理的文字有多少个字. - * - * @param {string} text 文字. - * @returns {number} 字数. - */ -export function countUnits(text) { - return text.match(COUNTED_UNIT_PATTERN)?.length ?? 0; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/tracked-paths.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/tracked-paths.mjs deleted file mode 100644 index 68de72f..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/tracked-paths.mjs +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @file 必须入库的路径: 状态文件, 术语表, 规格中每种记录文件的一个示例位置, - * 以及插件管理的项目配置. - * - * 编排记录与插件写的项目配置都要随仓库入库, 换机器或 clone 之后才能接着推进, - * 所以这些路径被忽略规则漏掉时要报告出来. init 与每次进入共用这里的清单与报告 - * 格式; 提交步骤用这里的范围找出还没入库的技能产物. - */ - -import { - ENGINEERING_RULES_FILE, - GLOSSARY_FILE, - NAVIGATOR_DIRECTORY, - NAVIGATOR_IGNORE_FILE, - PROJECT_SETTINGS_FILE, - STATE_FILE, -} from "./paths.mjs"; -import { listUncommittedPaths } from "./repo.mjs"; - -/** - * 替换记录文件路径模式中 "*" 的示例路径段, 只用于检查忽略规则. - * @type {string} - */ -const SAMPLE_SEGMENT = "0001-sample"; - -/** - * 技能产物的范围: 状态目录与插件管理的项目配置. 草稿由状态目录的忽略规则排除. - * @type {readonly string[]} - */ -export const COMMITTED_PATHSPECS = Object.freeze([ - NAVIGATOR_DIRECTORY, - PROJECT_SETTINGS_FILE, - ENGINEERING_RULES_FILE, -]); - -/** - * 列出尚未入库的技能产物: 状态目录与插件管理的项目配置中相对 HEAD 有改动的 - * 文件, 不含状态文件. 状态文件每条命令都会改写, 只有它改动时不值得单独提交. - * - * @param {string} worktreeRoot 工作区根目录. - * @returns {string[]} 以正斜杠分隔的项目相对路径. - */ -export function listUncommittedProducts(worktreeRoot) { - return listUncommittedPaths(worktreeRoot, COMMITTED_PATHSPECS).filter( - (file) => file !== STATE_FILE, - ); -} - -/** - * 列出必须能入库的项目相对路径, 以正斜杠分隔, 不重复. - * - * @param {import("./spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 路径列表. - */ -export function requiredTrackedPaths(spec) { - const recordFiles = Object.values(spec.files).map((fileSpec) => - fileSpec.pattern.replaceAll("*", SAMPLE_SEGMENT), - ); - return [ - ...new Set([ - NAVIGATOR_IGNORE_FILE, - STATE_FILE, - GLOSSARY_FILE, - PROJECT_SETTINGS_FILE, - ENGINEERING_RULES_FILE, - ...recordFiles, - ]), - ]; -} - -/** - * 按忽略规则分组描述被忽略的路径: 每条规则写明影响的路径数与一个例子. - * - * @param {readonly import("./repo.mjs").IgnoredPath[]} ignored 被忽略的路径. - * @returns {string[]} 每条规则一句描述. - */ -export function summarizeIgnoredPaths(ignored) { - return [...Map.groupBy(ignored, (entry) => entry.rule)].map( - ([rule, entries]) => - `${rule}, 影响 ${entries.length} 个路径, 例如 ${entries[0].path}`, - ); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/version.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/version.mjs index 29d706a..9c96d36 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/version.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/lib/version.mjs @@ -25,12 +25,6 @@ const PLUGIN_MANIFEST = path.join(".claude-plugin", "plugin.json"); */ const UNKNOWN_VERSION = "0.0.0"; -/** - * 参与比较的版本号段数: 主版本, 次版本, 修订号. - * @type {number} - */ -const VERSION_PART_COUNT = 3; - /** * 返回当前运行脚本的版本号. * @@ -48,22 +42,3 @@ export function runtimeVersion(skillRoot = SKILL_ROOT) { } return UNKNOWN_VERSION; } - -/** - * 比较两个语义化版本号的主, 次, 修订号. - * - * @param {string} first 第一个版本号. - * @param {string} second 第二个版本号. - * @returns {number} first 较旧时为负数, 相同时为 0, 较新时为正数. - */ -export function compareVersions(first, second) { - const firstParts = first.split(".").map(Number); - const secondParts = second.split(".").map(Number); - for (let index = 0; index < VERSION_PART_COUNT; index += 1) { - const difference = (firstParts[index] ?? 0) - (secondParts[index] ?? 0); - if (difference !== 0) { - return difference; - } - } - return 0; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-orders.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-orders.mjs deleted file mode 100644 index 024fec9..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-orders.mjs +++ /dev/null @@ -1,306 +0,0 @@ -/** - * @file 工单的类型与状态转换. 所有函数都返回新状态, 不修改传入的状态. - * - * 状态流转: - * drafting (起草) → issued (已发布, 执行子代理工作中) → reviewing (验收中) - * → accepted (已通过). - * issued 可转为 blocked (受阻); blocked 处理后重新 issued, 或撤回为 drafting - * 修改; issued 也可撤回为 drafting; 验收不通过为 rejected; 任何未结束的工单 - * 都可以 voided (作废). accepted, rejected, voided 是结束状态, 结束的工单移入 - * 历史, 当前工单清空. 提交不是工单状态: 验收通过之后由编排会话提交. - */ - -import { allocateNumber } from "./numbering.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 工单状态及允许转入的下一状态. - * @type {Readonly>} - */ -export const ORDER_TRANSITIONS = Object.freeze({ - drafting: ["issued", "voided"], - issued: ["reviewing", "blocked", "drafting", "voided"], - blocked: ["issued", "drafting", "voided"], - reviewing: ["accepted", "rejected"], -}); - -/** - * 结束状态: 工单进入这些状态后移入历史. - * @type {readonly string[]} - */ -export const TERMINAL_ORDER_STATUSES = Object.freeze([ - "accepted", - "rejected", - "voided", -]); - -/** - * 发布状态. - * @type {string} - */ -export const ISSUED_ORDER_STATUS = "issued"; - -/** - * 起草状态: 发布前核对只对这个状态的工单强制要求. - * @type {string} - */ -export const DRAFTING_ORDER_STATUS = "drafting"; - -/** - * 可以请用户审阅的工单状态: 起草中, 以及受阻后修改待重新发布. - * @type {readonly string[]} - */ -export const REVIEWABLE_ORDER_STATUSES = Object.freeze([ - DRAFTING_ORDER_STATUS, - "blocked", -]); - -/** - * 实现工单的类型名. - * @type {string} - */ -export const IMPLEMENTATION_ORDER_KIND = "implementation"; - -/** - * 选型工单的类型名. - * @type {string} - */ -export const SELECTION_ORDER_KIND = "selection"; - -/** - * 运行检查工单的类型名. 运行检查工单不修改任何文件. - * @type {string} - */ -export const RUNCHECK_ORDER_KIND = "runcheck"; - -/** - * 分析工单的类型名: 逆向, 调研等只产出文档与证据脚本的任务. - * @type {string} - */ -export const ANALYSIS_ORDER_KIND = "analysis"; - -/** - * 修复工单的类型名. - * @type {string} - */ -export const FIX_ORDER_KIND = "fix"; - -/** - * 工单类型. - * @type {readonly string[]} - */ -export const ORDER_KINDS = Object.freeze([ - IMPLEMENTATION_ORDER_KIND, - SELECTION_ORDER_KIND, - RUNCHECK_ORDER_KIND, - ANALYSIS_ORDER_KIND, - FIX_ORDER_KIND, -]); - -/** - * 只产出记录的工单类型: 执行子代理只能写回执与本工单的 artifacts/, 不改业务文件. - * @type {readonly string[]} - */ -export const RECORD_ONLY_ORDER_KINDS = Object.freeze([ - RUNCHECK_ORDER_KIND, - ANALYSIS_ORDER_KIND, -]); - -/** - * 判断某种工单是否只产出记录. - * - * @param {string} kind 工单类型. - * @returns {boolean} 只产出记录时返回 true. - */ -export function isRecordOnlyOrderKind(kind) { - return RECORD_ONLY_ORDER_KINDS.includes(kind); -} - -/** - * 工单短名的格式: 小写字母, 数字与连字符. - * @type {RegExp} - */ -const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; - -/** - * 新建工单, 成为当前工单. 工单严格串行: 已有未结束的工单时不能新建. 没有指定 - * 切片时挂在当前切片上, 这样路线中的工单列表按切片统计. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {object} options 工单参数. - * @param {string} options.kind 工单类型. - * @param {string} options.slug 文件夹短名. - * @param {string | undefined} options.slice 所属切片编号; 省略时为当前切片. - * @param {string} options.baseCommit 基准提交. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 已有进行中的工单, 或参数不合法时. - */ -export function createOrder(state, { kind, slug, slice, baseCommit }) { - if (state.order !== null) { - throw new WorkflowError( - `工单 ${state.order.id} 尚未结束 (${state.order.status}), 工单必须逐张完成.`, - ); - } - if (!ORDER_KINDS.includes(kind)) { - throw new WorkflowError(`工单类型应为 ${ORDER_KINDS.join(", ")} 之一.`); - } - if (!SLUG_PATTERN.test(slug)) { - throw new WorkflowError( - "工单短名只能使用小写字母, 数字与连字符, 例如 export-csv.", - ); - } - if ( - slice !== undefined && - !state.slices.some((entry) => entry.id === slice) - ) { - const ids = state.slices.map((entry) => entry.id).join(", ") || "无"; - throw new WorkflowError( - `切片 ${slice} 不存在. 切片编号是四位数字, 不是进展中的序号: 现有切片 ${ids}, 当前切片 ${state.slice ?? "无"}. 不带 --slice 时默认挂在当前切片.`, - ); - } - const { id, next } = allocateNumber(state.next, "order"); - return { - ...state, - next, - order: { - id, - slug, - folder: `${id}-${slug}`, - kind, - slice: slice ?? state.slice ?? null, - status: DRAFTING_ORDER_STATUS, - round: 0, - baseCommit, - }, - lastAction: `新建工单 ${id}`, - }; -} - -/** - * 转换当前工单的状态. 每次发布 (含受阻或撤回后重新发布) 时发布轮次加一, - * 并记下发布时工单文件的摘要与基准提交; 进入结束状态时移入历史. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} status 目标状态. - * @param {{issuedDigest?: string, baseCommit?: string}} [options] 发布时工单文件内容的摘要, 以及发布时的当前提交; 不给基准提交时保持原值. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 没有当前工单或转换不合法时. - */ -export function setOrderStatus( - state, - status, - { issuedDigest, baseCommit } = {}, -) { - const order = state.order; - if (order === null) { - throw new WorkflowError("当前没有工单."); - } - const allowed = ORDER_TRANSITIONS[order.status] ?? []; - if (!allowed.includes(status)) { - throw new WorkflowError( - `工单 ${order.id} 不能从 ${order.status} 转为 ${status}; 允许的下一状态: ${allowed.join(", ") || "无"}.`, - ); - } - const updated = - status === ISSUED_ORDER_STATUS - ? { - ...order, - status, - round: (order.round ?? 0) + 1, - issuedDigest, - baseCommit: baseCommit ?? order.baseCommit, - } - : { ...order, status }; - if (!TERMINAL_ORDER_STATUSES.includes(status)) { - return { - ...state, - order: updated, - lastAction: `工单 ${order.id} 转为 ${status}`, - }; - } - return { - ...state, - order: null, - orders: [...(state.orders ?? []), summarizeOrder(updated)], - slices: state.slices.map((slice) => - slice.id === order.slice - ? { ...slice, orders: [...(slice.orders ?? []), order.id] } - : slice, - ), - lastAction: `工单 ${order.id} 转为 ${status}`, - }; -} - -/** - * 记下当前工单已做过发布前核对. 核对一次即可: 按核对结果修改工单, 或受阻后 - * 修改工单, 都不要求再核对. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 没有当前工单, 或工单已经发布时. - */ -export function markPrechecked(state) { - const order = state.order; - if (order === null) { - throw new WorkflowError("当前没有工单."); - } - if (!REVIEWABLE_ORDER_STATUSES.includes(order.status)) { - throw new WorkflowError( - `工单 ${order.id} 处于 ${order.status}, 发布前核对只用于 ${REVIEWABLE_ORDER_STATUSES.join(", ")} 状态的工单.`, - ); - } - return { - ...state, - order: { ...order, prechecked: true }, - lastAction: `工单 ${order.id} 已做发布前核对`, - }; -} - -/** - * 说明当前工单为什么还不能请用户审阅: 起草中的工单要先做发布前核对. - * - * @param {import("./state.mjs").OrderState} order 当前工单. - * @returns {string | undefined} 原因; 可以审阅时为 undefined. - */ -export function precheckBlocker(order) { - return order.status === DRAFTING_ORDER_STATUS && order.prechecked !== true - ? `工单 ${order.id} 还没有做发布前核对: 运行 precheck-brief, 派核对子代理, 按结果改好工单后运行 order precheck.` - : undefined; -} - -/** - * 统计某个切片连续验收不通过的工单数, 从最近一张往前数. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} sliceId 切片编号. - * @returns {number} 连续不通过的次数. - */ -export function consecutiveRejections(state, sliceId) { - const history = (state.orders ?? []).filter( - (order) => order.slice === sliceId, - ); - let count = 0; - for (let index = history.length - 1; index >= 0; index -= 1) { - if (history[index].status !== "rejected") { - break; - } - count += 1; - } - return count; -} - -/** - * 生成移入历史的工单摘要. - * - * @param {Record} order 结束的工单. - * @returns {{id: string, slug: string, kind: string, slice: string | null, status: string}} 摘要. - */ -function summarizeOrder(order) { - return { - id: order.id, - slug: order.slug, - kind: order.kind, - slice: order.slice, - status: order.status, - }; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-plan.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-plan.mjs deleted file mode 100644 index d102e7a..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-plan.mjs +++ /dev/null @@ -1,242 +0,0 @@ -/** - * @file 阶段, 步骤, 里程与切片的状态转换. 所有函数都返回新状态, 不修改传入的状态. - */ - -import { allocateNumber } from "./numbering.mjs"; -import { requireText } from "./validation.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 里程与切片的状态, 以及显示用的中文. - * @type {Readonly>} - */ -export const PROGRESS_STATUS_LABELS = Object.freeze({ - pending: "未开始", - active: "进行中", - done: "已完成", -}); - -/** - * 步骤标识的格式: 小写字母, 数字与连字符. - * @type {RegExp} - */ -const STEP_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; - -/** - * 项目完成时最后一个阶段的步骤标识, 只由 finish 命令设置. - * @type {string} - */ -export const FINISHED_STEP = "finished"; - -/** - * @typedef {object} RoadmapDraft 推进路线草稿: 编排会话写在 drafts/ 中的 JSON. - * @property {{id?: string, name: string, goal: string, metrics: string[], slices: {id?: string, name: string}[]}[]} milestones 里程及其切片, 按顺序. - */ - -/** - * 进入某个阶段, 步骤重置为 start. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {number} stage 阶段编号. - * @param {number} lastStage 最后一个阶段的编号, 取自模板规格. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 阶段编号不合法时. - */ -export function enterStage(state, stage, lastStage) { - requireStage(stage, lastStage); - return { ...state, stage, step: "start", lastAction: `进入阶段 ${stage}` }; -} - -/** - * 设置当前阶段内的步骤. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} step 步骤标识. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 步骤标识不合法时. - */ -export function setStep(state, step) { - if (!STEP_PATTERN.test(step)) { - throw new WorkflowError("步骤标识只能使用小写字母, 数字与连字符."); - } - if (step === FINISHED_STEP) { - throw new WorkflowError( - `步骤 ${FINISHED_STEP} 只由 finish 命令设置: 用户在 "项目收尾" 中确认收尾后运行 finish.`, - ); - } - return { ...state, step, lastAction: `步骤 ${step}` }; -} - -/** - * 确认收尾, 项目完成: 只能在最后一个阶段进行, 步骤设为 FINISHED_STEP. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {number} lastStage 最后一个阶段的编号, 取自模板规格. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 当前不在最后一个阶段时. - */ -export function finishProject(state, lastStage) { - if (state.stage !== lastStage) { - throw new WorkflowError( - `项目只能在阶段 ${lastStage} 确认收尾, 当前在阶段 ${state.stage ?? "无"}.`, - ); - } - return { ...state, step: FINISHED_STEP, lastAction: "确认收尾, 项目完成" }; -} - -/** - * 判断项目是否已确认收尾. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {number} lastStage 最后一个阶段的编号, 取自模板规格. - * @returns {boolean} 已确认收尾时返回 true. - */ -export function isProjectFinished(state, lastStage) { - return state.stage === lastStage && state.step === FINISHED_STEP; -} - -/** - * 记录跳过某个阶段及原因. 跳过必须由用户认可, 原因必须写明. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {number} stage 被跳过的阶段编号. - * @param {string} reason 跳过原因. - * @param {object} options 其它参数. - * @param {number} options.lastStage 最后一个阶段的编号, 取自模板规格. - * @param {string} options.now 当前时间, ISO 格式. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 原因为空或阶段编号不合法时. - */ -export function skipStage(state, stage, reason, { lastStage, now }) { - if (reason.trim() === "") { - throw new WorkflowError("跳过阶段必须写明原因."); - } - requireStage(stage, lastStage); - return { - ...state, - skipped: [ - ...state.skipped, - { stage, reason: reason.trim(), approvedAt: now }, - ], - lastAction: `跳过阶段 ${stage}`, - }; -} - -/** - * 按草稿更新推进路线: 带编号的里程与切片保留原状态, 不带编号的分配新编号. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {RoadmapDraft} draft 推进路线草稿. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 草稿结构不合法, 或引用了不存在的编号时. - */ -export function applyRoadmap(state, draft) { - if (!Array.isArray(draft?.milestones) || draft.milestones.length === 0) { - throw new WorkflowError("推进路线草稿至少要有一个里程."); - } - let next = state.next; - const milestones = []; - const slices = []; - for (const milestoneDraft of draft.milestones) { - const milestone = resolveEntry( - state.milestones, - milestoneDraft, - next, - "milestone", - ); - next = milestone.next; - milestones.push({ - id: milestone.id, - name: requireText(milestoneDraft.name, "里程名称"), - goal: requireText(milestoneDraft.goal, "里程目标"), - metrics: Array.isArray(milestoneDraft.metrics) - ? milestoneDraft.metrics - : [], - status: milestone.existing?.status ?? "pending", - }); - for (const sliceDraft of milestoneDraft.slices ?? []) { - const slice = resolveEntry(state.slices, sliceDraft, next, "slice"); - next = slice.next; - slices.push({ - id: slice.id, - milestone: milestone.id, - name: requireText(sliceDraft.name, "切片名称"), - status: slice.existing?.status ?? "pending", - orders: slice.existing?.orders ?? [], - }); - } - } - return { ...state, next, milestones, slices, lastAction: "更新推进路线" }; -} - -/** - * 设置里程或切片的状态; 设为进行中时同时成为当前里程或切片. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {"milestone" | "slice"} kind 类别. - * @param {string} id 编号. - * @param {string} status 状态: pending, active 或 done. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 编号不存在或状态不合法时. - */ -export function setProgressStatus(state, kind, id, status) { - if (!Object.hasOwn(PROGRESS_STATUS_LABELS, status)) { - throw new WorkflowError( - `状态应为 ${Object.keys(PROGRESS_STATUS_LABELS).join(", ")} 之一.`, - ); - } - const listKey = kind === "milestone" ? "milestones" : "slices"; - if (!state[listKey].some((entry) => entry.id === id)) { - throw new WorkflowError( - `${kind === "milestone" ? "里程" : "切片"} ${id} 不存在.`, - ); - } - const list = state[listKey].map((entry) => - entry.id === id ? { ...entry, status } : entry, - ); - const pointer = status === "active" ? id : state[kind]; - return { - ...state, - [listKey]: list, - [kind]: pointer, - lastAction: `${kind === "milestone" ? "里程" : "切片"} ${id} 转为 ${PROGRESS_STATUS_LABELS[status]}`, - }; -} - -/** - * 确认阶段编号合法. - * - * @param {number} stage 阶段编号. - * @param {number} lastStage 最后一个阶段的编号. - * @returns {void} - * @throws {WorkflowError} 阶段编号不合法时. - */ -function requireStage(stage, lastStage) { - if (!Number.isInteger(stage) || stage < 0 || stage > lastStage) { - throw new WorkflowError(`阶段编号应为 0 至 ${lastStage} 的整数.`); - } -} - -/** - * 找到草稿条目对应的已有条目, 或为新条目分配编号. - * - * @param {{id: string}[]} existingList 已有条目. - * @param {{id?: string}} draftEntry 草稿条目. - * @param {Record} next 计数器. - * @param {string} kind 编号类别. - * @returns {{id: string, existing: Record | undefined, next: Record}} 编号, 已有条目与计数器. - * @throws {WorkflowError} 草稿引用了不存在的编号时. - */ -function resolveEntry(existingList, draftEntry, next, kind) { - if (draftEntry.id === undefined) { - const allocated = allocateNumber(next, kind); - return { id: allocated.id, existing: undefined, next: allocated.next }; - } - const existing = existingList.find((entry) => entry.id === draftEntry.id); - if (existing === undefined) { - throw new WorkflowError( - `草稿中的编号 ${draftEntry.id} 不存在; 新条目请不写编号.`, - ); - } - return { id: existing.id, existing, next }; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-records.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-records.mjs deleted file mode 100644 index b4c72d3..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/workflow-records.mjs +++ /dev/null @@ -1,194 +0,0 @@ -/** - * @file 风险, 决策与变更的状态管理. 所有函数都返回新状态, 不修改传入的状态. - * - * 这些条目的状态与编号只存在 `state.json` 中; 风险表格整份由脚本生成, - * 决策与变更的正文由编排会话写在对应的记录文件里, 以编号对应. - */ - -import { allocateNumber } from "./numbering.mjs"; -import { requireLabel, requireText } from "./validation.mjs"; -import { WorkflowError } from "./workflow-error.mjs"; - -/** - * 风险严重程度及显示用的中文. - * @type {Readonly>} - */ -export const RISK_SEVERITY_LABELS = Object.freeze({ - high: "高", - medium: "中", - low: "低", -}); - -/** - * 风险状态及显示用的中文. - * @type {Readonly>} - */ -export const RISK_STATUS_LABELS = Object.freeze({ - open: "待处理", - investigating: "调研中", - resolved: "已处理", - accepted: "已接受", -}); - -/** - * 决策状态及显示用的中文. - * @type {Readonly>} - */ -export const DECISION_STATUS_LABELS = Object.freeze({ - approved: "已批准", - superseded: "已取代", -}); - -/** - * 变更状态及显示用的中文. - * @type {Readonly>} - */ -export const CHANGE_STATUS_LABELS = Object.freeze({ - accepted: "已接受", - deferred: "已延后", - dropped: "已放弃", -}); - -/** - * 新增一条风险. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {{description: string, severity: string, source: string, handling?: string}} draft 风险草稿. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 草稿字段不合法时. - */ -export function addRisk(state, draft) { - requireLabel(draft.severity, RISK_SEVERITY_LABELS, "严重程度"); - const { id, next } = allocateNumber(state.next, "risk"); - const risk = { - id, - description: requireText(draft.description, "风险描述"), - severity: draft.severity, - source: requireText(draft.source, "风险来源"), - status: "open", - handling: draft.handling?.trim() || "无", - }; - return { - ...state, - next, - risks: [...state.risks, risk], - lastAction: `新增风险 ${id}`, - }; -} - -/** - * 更新风险的状态与处理方式. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} id 风险编号. - * @param {string} status 新状态. - * @param {string | undefined} handling 处理方式; 省略时保留原值. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 编号不存在或状态不合法时. - */ -export function setRiskStatus(state, id, status, handling) { - requireLabel(status, RISK_STATUS_LABELS, "风险状态"); - requireExisting(state.risks, id, "风险"); - return { - ...state, - risks: state.risks.map((risk) => - risk.id === id - ? { ...risk, status, handling: handling?.trim() || risk.handling } - : risk, - ), - lastAction: `风险 ${id} 转为 ${RISK_STATUS_LABELS[status]}`, - }; -} - -/** - * 新增一条已批准的决策, 并返回其编号供编排会话写入决策记录. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} title 决策标题. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 标题为空时. - */ -export function addDecision(state, title) { - const { id, next } = allocateNumber(state.next, "decision"); - const decision = { - id, - title: requireText(title, "决策标题"), - status: "approved", - }; - return { - ...state, - next, - decisions: [...state.decisions, decision], - lastAction: `新增决策 ${id}`, - }; -} - -/** - * 把一条决策标为被另一条取代. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} id 被取代的决策编号. - * @param {string} replacementId 取代它的决策编号. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 任一编号不存在时. - */ -export function supersedeDecision(state, id, replacementId) { - requireExisting(state.decisions, id, "决策"); - requireExisting(state.decisions, replacementId, "决策"); - return { - ...state, - decisions: state.decisions.map((decision) => - decision.id === id - ? { ...decision, status: "superseded", supersededBy: replacementId } - : decision, - ), - lastAction: `决策 ${id} 被决策 ${replacementId} 取代`, - }; -} - -/** - * 新增一条变更, 状态为用户的处理结果. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @param {string} title 变更标题. - * @param {string} status 变更状态. - * @returns {import("./state.mjs").NavigatorState} 新状态. - * @throws {WorkflowError} 标题为空或状态不合法时. - */ -export function addChange(state, title, status) { - requireLabel(status, CHANGE_STATUS_LABELS, "变更状态"); - const { id, next } = allocateNumber(state.next, "change"); - const change = { id, title: requireText(title, "变更标题"), status }; - return { - ...state, - next, - changes: [...state.changes, change], - lastAction: `新增变更 ${id}`, - }; -} - -/** - * 分配一个体检编号, 供编排会话写入体检记录. - * - * @param {import("./state.mjs").NavigatorState} state 当前状态. - * @returns {{id: string, state: import("./state.mjs").NavigatorState}} 新编号与新状态. - */ -export function addCheckup(state) { - const { id, next } = allocateNumber(state.next, "checkup"); - return { id, state: { ...state, next, lastAction: `完成体检 ${id}` } }; -} - -/** - * 确认编号在列表中存在. - * - * @param {{id: string}[]} list 条目列表. - * @param {string} id 编号. - * @param {string} label 类别名, 用于错误信息. - * @returns {void} - * @throws {WorkflowError} 编号不存在时. - */ -function requireExisting(list, id, label) { - if (!list.some((entry) => entry.id === id)) { - throw new WorkflowError(`${label} ${id} 不存在.`); - } -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/writing-checks.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/writing-checks.mjs deleted file mode 100644 index c4a201b..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/writing-checks.mjs +++ /dev/null @@ -1,466 +0,0 @@ -/** - * @file 写作规则检查: 句长, 段落, 列表, 加粗, "的" 字, 黑话, 比喻, 翻译腔, - * 句首裸指代词, 表格对齐与篇幅. - * - * 规则与阈值全部来自规格的 writing 一节. 只用于体检与自查, 同时报告 "问题" 与 - * "提示"; 回复结束时不按这些规则打回. 代码块不检查. - */ - -import { parseMarkdown } from "./markdown.mjs"; -import { displayWidth } from "./table.mjs"; -import { countUnits, plainText } from "./text-units.mjs"; - -/** - * 列表项的格式: 缩进, 列表标记, 正文. - * @type {RegExp} - */ -const LIST_ITEM_PATTERN = /^(\s*)(?:[-*+]|\d+\.)\s+(.*)$/u; - -/** - * 表格行的格式: 以竖线开头. - * @type {RegExp} - */ -const TABLE_ROW_PATTERN = /^\s*\|/u; - -/** - * 切分无标点片段的位置: 后接空白或行尾的英文标点, 括号, 引号, 表格竖线, 以及中文标点. - * @type {RegExp} - */ -const PHRASE_BREAK_PATTERN = - /[,.;:!?](?=\s|$)|[()"'|]|[、。,;:!?()“”‘’]/u; - -/** - * 切分句子的位置: 后接空白或行尾的句末标点与冒号, 表格竖线, 以及中文句末标点. - * @type {RegExp} - */ -const SENTENCE_BREAK_PATTERN = /[.;:!?](?=\s|$)|[|。;:!?]/u; - -/** - * 加粗文字. - * @type {RegExp} - */ -const BOLD_PATTERN = /\*\*[^*]+\*\*/gu; - -/** - * 句首可以忽略的字符: 空白, 引号, 括号. - * @type {RegExp} - */ -const SENTENCE_LEAD_PATTERN = /^[\s"'(“‘(]+/u; - -/** - * @typedef {object} WritingFinding 一条写作问题. - * @property {string} label 类别, 4 个汉字. - * @property {"problem" | "hint"} severity 级别. - * @property {number} line 行号, 从 1 开始. - * @property {string} message 说明, 写成可以照着改的中文句子. - */ - -/** - * @typedef {object} TextLine 参与检查的一行. - * @property {"prose" | "list" | "table" | "heading" | "blank" | "block"} kind 行的种类. - * @property {string} content 去掉列表标记后的正文; 标题, 空行与代码块为空字符串. - * @property {string} raw 原始行. - * @property {number} indent 列表项的缩进, 其它行为 0. - * @property {number} line 行号, 从 1 开始. - */ - -/** - * 检查一段 Markdown 文本的写作规则. - * - * @param {object} options 检查参数. - * @param {string} options.text Markdown 全文. - * @param {import("./spec.mjs").TemplateSpec} options.spec 模板规格. - * @param {number | undefined} options.maxLines 篇幅上限行数; 不限制时为 undefined. - * @returns {WritingFinding[]} 按行号排序的问题. - */ -export function checkWriting({ text, spec, maxLines }) { - const writing = spec.writing; - const lines = collectLines(parseMarkdown(text)); - const contentLines = lines.filter((line) => line.content !== ""); - return [ - ...contentLines.flatMap((line) => checkPhrases(line, writing)), - ...contentLines.flatMap((line) => checkSentenceStarts(line, writing)), - ...contentLines.flatMap((line) => checkWords(line, writing)), - ...checkParagraphs(lines, writing), - ...checkLists(lines, writing), - ...checkBold(lines, writing), - ...checkTables(lines, writing), - ...checkLength(text, writing, maxLines), - ].sort((first, second) => first.line - second.line); -} - -/** - * 把一条问题格式化为一行说明. - * - * @param {WritingFinding} finding 问题. - * @returns {string} 说明文字. - */ -export function formatFinding(finding) { - return `第 ${finding.line} 行, ${finding.label}: ${finding.message}`; -} - -/** - * 把解析结果展开成逐行的检查单元; 代码块不展开. - * - * @param {import("./markdown.mjs").MarkdownItem[]} items 解析结果. - * @returns {TextLine[]} 各行. - */ -function collectLines(items) { - return items.flatMap((item) => { - if (item.kind === "heading") { - return [lineOf("heading", "", item.text, item.line)]; - } - if (item.kind === "block") { - return [lineOf("block", "", "", item.line)]; - } - return [classifyTextLine(item.text, item.line)]; - }); -} - -/** - * 判断围栏之外一行的种类. - * - * @param {string} text 行内容. - * @param {number} line 行号. - * @returns {TextLine} 检查单元. - */ -function classifyTextLine(text, line) { - if (text.trim() === "") { - return lineOf("blank", "", text, line); - } - if (TABLE_ROW_PATTERN.test(text)) { - return lineOf("table", text.trim(), text, line); - } - const listItem = LIST_ITEM_PATTERN.exec(text); - if (listItem !== null) { - return { - ...lineOf("list", listItem[2], text, line), - indent: listItem[1].length, - }; - } - return lineOf("prose", text.trim(), text, line); -} - -/** - * 构造检查单元. - * - * @param {TextLine["kind"]} kind 种类. - * @param {string} content 正文. - * @param {string} raw 原始行. - * @param {number} line 行号. - * @returns {TextLine} 检查单元. - */ -function lineOf(kind, content, raw, line) { - return { kind, content, raw, indent: 0, line }; -} - -/** - * 检查一行中每个无标点片段的字数与 "的" 字个数. - * - * @param {TextLine} line 检查单元. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkPhrases(line, writing) { - const { sentence, particle } = writing; - return plainText(line.content) - .split(PHRASE_BREAK_PATTERN) - .flatMap((phrase) => { - const length = countUnits(phrase); - const particles = [...phrase].filter( - (character) => character === particle.character, - ).length; - const findings = []; - if (length > sentence.maxLength) { - findings.push( - finding( - sentence, - line.line, - `有一段 ${length} 字没有标点, 超过 ${sentence.maxLength} 字, 请拆成短句.`, - ), - ); - } else if (length >= sentence.hintLength) { - findings.push({ - ...finding( - sentence, - line.line, - `有一段 ${length} 字没有标点, 可以考虑拆句.`, - ), - severity: "hint", - }); - } - if (particles > particle.maxPerPhrase) { - findings.push( - finding( - particle, - line.line, - `一个片段中有 ${particles} 个 "${particle.character}", 请拆开修饰语.`, - ), - ); - } - return findings; - }); -} - -/** - * 检查每个句子的开头是否是没有名词的指代词. - * - * @param {TextLine} line 检查单元. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkSentenceStarts(line, writing) { - const patterns = writing.pronoun.patterns.map( - (pattern) => new RegExp(pattern, "u"), - ); - return plainText(line.content) - .split(SENTENCE_BREAK_PATTERN) - .flatMap((sentence) => { - const start = sentence.replace(SENTENCE_LEAD_PATTERN, ""); - const match = patterns - .map((pattern) => pattern.exec(start)) - .find((result) => result !== null); - return match === undefined - ? [] - : [ - finding( - writing.pronoun, - line.line, - `句首 "${match[0]}" 指代不明, 请写出具体名称.`, - ), - ]; - }); -} - -/** - * 检查黑话, 比喻用词与翻译腔句式. - * - * @param {TextLine} line 检查单元. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkWords(line, writing) { - const text = plainText(line.content); - const { jargon, metaphor, translationese } = writing; - return [ - ...jargon.words - .filter((word) => text.includes(word)) - .map((word) => - finding(jargon, line.line, `"${word}" 属于黑话, 请改用具体说法.`), - ), - ...metaphor.words - .filter((word) => text.includes(word)) - .map((word) => - finding(metaphor, line.line, `"${word}" 是比喻用词, 请直接说明事实.`), - ), - ...translationese.patterns.flatMap(({ pattern, advice }) => { - const match = new RegExp(pattern, "u").exec(text); - return match === null - ? [] - : [finding(translationese, line.line, `"${match[0]}": ${advice}`)]; - }), - ]; -} - -/** - * 检查段落行数: 连续的普通文本行构成一个段落. - * - * @param {TextLine[]} lines 各行. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkParagraphs(lines, writing) { - const { paragraph } = writing; - const paragraphs = lines.reduce((groups, line) => { - if (line.kind === "prose") { - groups[groups.length - 1].push(line); - } else if (groups[groups.length - 1].length > 0) { - groups.push([]); - } - return groups; - }, /** @type {TextLine[][]} */ ([[]])); - return paragraphs - .filter((group) => group.length > paragraph.maxLines) - .map((group) => - finding( - paragraph, - group[0].line, - `段落有 ${group.length} 行, 超过 ${paragraph.maxLines} 行, 请拆段或改成列表.`, - ), - ); -} - -/** - * 检查列表: 同一层超过项数上限, 或嵌套超过层数上限. 空行不打断列表, - * 其它非列表行打断列表. - * - * @param {TextLine[]} lines 各行. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkLists(lines, writing) { - const { listLength, listDepth } = writing; - const findings = []; - let levels = []; - for (const line of lines) { - if (line.kind === "blank") { - continue; - } - if (line.kind !== "list") { - levels = []; - continue; - } - levels = levels.filter((level) => level.indent <= line.indent); - const current = levels[levels.length - 1]; - if (current === undefined || current.indent < line.indent) { - levels = [...levels, { indent: line.indent, count: 1 }]; - if (levels.length === listDepth.maxDepth + 1) { - findings.push( - finding( - listDepth, - line.line, - `列表嵌套超过 ${listDepth.maxDepth} 层, 请把深层内容拆成单独的节或表格.`, - ), - ); - } - continue; - } - levels = [ - ...levels.slice(0, -1), - { indent: current.indent, count: current.count + 1 }, - ]; - if (current.count + 1 === listLength.maxItems + 1) { - findings.push( - finding( - listLength, - line.line, - `同一层列表超过 ${listLength.maxItems} 项, 请分组或改成表格.`, - ), - ); - } - } - return findings; -} - -/** - * 检查每节的加粗次数: 一级与二级标题划分节. - * - * @param {TextLine[]} lines 各行. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkBold(lines, writing) { - const { bold } = writing; - const sections = lines.reduce( - (groups, line) => { - if (line.kind === "heading") { - groups.push({ line: line.line, count: 0 }); - } else { - groups[groups.length - 1].count += - line.content.match(BOLD_PATTERN)?.length ?? 0; - } - return groups; - }, - [{ line: 1, count: 0 }], - ); - return sections - .filter((section) => section.count > bold.maxPerSection) - .map((section) => - finding( - bold, - section.line, - `本节加粗 ${section.count} 处, 超过 ${bold.maxPerSection} 处, 加粗太多就失去了强调作用.`, - ), - ); -} - -/** - * 检查表格源码是否对齐: 同一表格各行竖线的显示列位置一致. - * - * @param {TextLine[]} lines 各行. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {WritingFinding[]} 问题. - */ -function checkTables(lines, writing) { - const tables = lines.reduce((groups, line, index) => { - if (line.kind !== "table") { - return groups; - } - if (lines[index - 1]?.kind === "table") { - groups[groups.length - 1].push(line); - } else { - groups.push([line]); - } - return groups; - }, /** @type {TextLine[][]} */ ([])); - return tables - .filter((rows) => { - const layouts = rows.map((row) => pipeColumns(row.raw).join(",")); - return layouts.some((layout) => layout !== layouts[0]); - }) - .map((rows) => - finding( - writing.table, - rows[0].line, - "表格各行的竖线没有对齐, 请按显示宽度补齐空格 (一个汉字占两列).", - ), - ); -} - -/** - * 计算一行中未转义竖线的显示列位置. - * - * @param {string} raw 表格行. - * @returns {number[]} 各竖线的列位置. - */ -function pipeColumns(raw) { - const characters = [...raw.trimEnd()]; - return characters.reduce( - (state, character, index) => { - const isPipe = character === "|" && characters[index - 1] !== "\\"; - return { - width: state.width + displayWidth(character), - columns: isPipe ? [...state.columns, state.width] : state.columns, - }; - }, - { width: 0, columns: /** @type {number[]} */ ([]) }, - ).columns; -} - -/** - * 检查全文非空行数是否超过篇幅上限. - * - * @param {string} text 全文. - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @param {number | undefined} maxLines 篇幅上限. - * @returns {WritingFinding[]} 问题. - */ -function checkLength(text, writing, maxLines) { - if (maxLines === undefined) { - return []; - } - const count = text - .split(/\r?\n/u) - .filter((line) => line.trim() !== "").length; - return count > maxLines - ? [ - finding( - writing.length, - 1, - `全文 ${count} 行, 超过 ${maxLines} 行, 请删减重复内容或拆分文件.`, - ), - ] - : []; -} - -/** - * 按规则的类别与级别构造一条问题. - * - * @param {import("./spec.mjs").WritingRule} rule 规则. - * @param {number} line 行号. - * @param {string} message 说明. - * @returns {WritingFinding} 问题. - */ -function finding(rule, line, message) { - return { label: rule.label, severity: rule.severity, line, message }; -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/lib/writing-rules.mjs b/plugins/waypoint/skills/project-navigator/runtime/lib/writing-rules.mjs deleted file mode 100644 index d74cd44..0000000 --- a/plugins/waypoint/skills/project-navigator/runtime/lib/writing-rules.mjs +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @file 写作规则的中文说明, 由规格中的阈值与词表生成. - * - * 参考文档的规则表与 reply 命令的填写要求都用这里的说明, 两处写法保持一致. - */ - -/** - * 生成每条写作规则在什么情况下判为违规的说明, 键与规格 writing 一节的键相同. - * - * @param {import("./spec.mjs").WritingSpec} writing 写作规则. - * @returns {Readonly>} 各规则的说明. - */ -export function describeWritingRules(writing) { - return Object.freeze({ - structure: "记录文件的标题, 节与键名不符合规格", - sentence: `没有标点的片段超过 ${writing.sentence.maxLength} 字`, - paragraph: `连续的普通文本超过 ${writing.paragraph.maxLines} 行`, - listLength: `同一层列表超过 ${writing.listLength.maxItems} 项`, - listDepth: `列表嵌套超过 ${writing.listDepth.maxDepth} 层`, - bold: `一节中加粗超过 ${writing.bold.maxPerSection} 处`, - particle: `一个片段中 "${writing.particle.character}" 超过 ${writing.particle.maxPerPhrase} 个`, - jargon: "使用黑话, 见禁用词", - metaphor: "使用比喻用词, 见禁用词", - translationese: "使用翻译腔句式, 见禁用词", - pronoun: '句首使用没有名词的指代词, 例如 "它", "这是"', - table: "表格源码的竖线没有按显示宽度对齐", - length: `回复超过 ${writing.length.maxReplyLines} 行, 或记录文件超过 ${writing.length.maxFileLines} 行 (只追加的记录不限)`, - }); -} diff --git a/plugins/waypoint/skills/project-navigator/runtime/navigator.mjs b/plugins/waypoint/skills/project-navigator/runtime/navigator.mjs index 5ba1e79..540cb10 100644 --- a/plugins/waypoint/skills/project-navigator/runtime/navigator.mjs +++ b/plugins/waypoint/skills/project-navigator/runtime/navigator.mjs @@ -5,35 +5,22 @@ * 用法: project-navigator <命令> [参数] * * 命令: - * - 初始化与进入: init, uninstall, enter, status - * - 骨架与参考: reply <回复类型> [--option <组号>], template <文件种类> [--option <结构编号>], ref <名称> - * - 阶段: stage <阶段编号>, step <步骤标识>, skip <阶段编号> --from <草稿>, finish - * - 推进路线: roadmap --from <草稿>, milestone <编号> <状态>, slice <编号> <状态> - * - 工单: order new|set|precheck, precheck-brief, exec-brief, review-brief, research-brief - * - 子代理模型: model, model set <角色> <模型> [--kind <工单类型>] - * - 源码证据: evidence, evidence check <仓库> <版本> <路径> <行号> - * - 记录: risk, decision, change - * - 体检与规范: check [文件...], standards --from <草稿>, codecheck set --from <草稿> - * - 问题报告: report [--from <草稿>] + * - 初始化与进入: init [--archive], uninstall, enter, status + * - 参考: ref <名称> + * - 立项: setup [--from <草稿>], roadmap --from <草稿> + * - 切片: slice plan <编号> --slug <短名>, slice start [--decided], slice replan, + * slice review, slice accept [--accept-unverified], slice redo, slice drop + * - 委派提示词: brief exec, brief review + * - 里程与收尾: milestone done <编号>, finish + * - 记录: decision add --from <草稿>, check [--filters] * * enter 由技能加载时的 `!` 命令调用, 任何情况下都以退出码 0 结束, 错误写进输出, * 否则 Claude Code 会中止整个技能调用. 其余命令出错时以退出码 1 结束. */ -import { existsSync } from "node:fs"; import { parseArgs } from "node:util"; -import { COMMAND_HANDLERS } from "./commands/handlers.mjs"; -import { - EVENT_KINDS, - REPORT_STEP, - isInternalError, - isRecommendedCommand, - readGuidance, - recordEvent, - stackLines, -} from "./lib/diagnostics.mjs"; -import { findWorktreeRoot, navigatorPath } from "./lib/paths.mjs"; +import { COMMAND_HANDLERS, ENTER_COMMAND } from "./commands/handlers.mjs"; /** * 命令出错时的退出码. @@ -41,23 +28,16 @@ import { findWorktreeRoot, navigatorPath } from "./lib/paths.mjs"; */ const EXIT_FAILURE = 1; -/** - * 出错时也以退出码 0 结束的命令. - * @type {string} - */ -const ENTER_COMMAND = "enter"; - /** * 命令行选项的定义. * @type {import("node:util").ParseArgsConfig["options"]} */ const OPTIONS = { - option: { type: "string", default: "1" }, - kind: { type: "string" }, - slug: { type: "string" }, - slice: { type: "string" }, from: { type: "string" }, - by: { type: "string" }, + slug: { type: "string" }, + archive: { type: "boolean", default: false }, + decided: { type: "boolean", default: false }, + filters: { type: "boolean", default: false }, "accept-unverified": { type: "boolean", default: false }, }; @@ -85,10 +65,8 @@ function main() { }), ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); printLines([ - `- 脚本错误: ${message}`, - ...diagnoseFailure(error, process.argv.slice(2)), + `- 脚本错误: ${error instanceof Error ? error.message : String(error)}`, ]); if (!isEnter) { process.exitCode = EXIT_FAILURE; @@ -96,49 +74,6 @@ function main() { } } -/** - * 判断一次命令失败是不是技能本身的问题, 是的话记下异常并给出上报的做法. - * 两种情况算技能问题: 脚本内部错误; 上一次 "下一动作" 要求运行的命令被拒绝. - * 记录本身出错时不影响命令的输出. - * - * @param {unknown} error 捕获到的错误. - * @param {readonly string[]} args 命令行参数. - * @returns {string[]} 追加在错误之后的输出行; 不是技能问题时为空. - */ -function diagnoseFailure(error, args) { - try { - const projectRoot = findWorktreeRoot(process.cwd()); - if ( - projectRoot === undefined || - !existsSync(navigatorPath(projectRoot, "state")) - ) { - return []; - } - const commandLine = args.filter((arg) => !arg.startsWith("-")).join(" "); - const isInternal = isInternalError(error); - if ( - !isInternal && - !isRecommendedCommand(readGuidance(projectRoot), commandLine) - ) { - return []; - } - recordEvent(projectRoot, { - kind: isInternal ? EVENT_KINDS.internalError : EVENT_KINDS.refused, - source: commandLine, - message: error instanceof Error ? error.message : String(error), - stack: isInternal ? stackLines(error) : [], - at: new Date().toISOString(), - }); - return [ - isInternal - ? `- 技能异常: 这是技能脚本的内部错误, 不是操作问题. ${REPORT_STEP}` - : `- 技能异常: 这条命令是上一次 "下一动作" 要求运行的, 却被拒绝. 按拒绝理由能继续时照做; ${REPORT_STEP}`, - ]; - } catch { - return []; - } -} - /** * 按命令名调用对应的处理函数. * diff --git a/plugins/waypoint/skills/project-navigator/spec/slice.json b/plugins/waypoint/skills/project-navigator/spec/slice.json new file mode 100644 index 0000000..c124ed4 --- /dev/null +++ b/plugins/waypoint/skills/project-navigator/spec/slice.json @@ -0,0 +1,151 @@ +{ + "titlePrefix": "切片", + "placeholder": "(待填写", + "none": "无", + "doneStatus": "完成", + "reworkStatus": "补做中", + "verdicts": { + "pass": "通过", + "fail": "不通过", + "unverified": "未验证" + }, + "parts": [ + { + "id": "plan", + "heading": "计划", + "owner": "编排会话", + "sections": [ + { + "id": "goal", + "title": "目标", + "hint": "一两句话: 做完后使用者能做到什么, 或查清什么" + }, + { + "id": "criteria", + "title": "完成标准", + "hint": "编号列表, 每条写成从入口或界面能看到的结果, 并写明怎么验证" + }, + { + "id": "excluded", + "title": "明确不做", + "hint": "本切片不碰的事; 没有时写 无" + }, + { + "id": "decisions", + "title": "需要决定", + "hint": "必须由用户决定的事, 用户回答后把答案写在这里; 没有时写 无" + }, + { + "id": "modules", + "title": "模块安排", + "kinds": ["build"], + "table": ["文件", "模块", "层", "说明"], + "keys": ["唯一定义", "复用"] + }, + { + "id": "location", + "title": "结论位置", + "kinds": ["findings"], + "hint": "结论写进 .navigator/findings/ 下的哪些文件" + }, + { + "id": "assumptions", + "title": "工作假设", + "hint": "写计划时对路径, 接口, 数据格式等细节的判断; 与实际不符时以实际为准" + }, + { + "id": "rework", + "title": "补做", + "initial": "无" + } + ] + }, + { + "id": "result", + "heading": "执行结果", + "owner": "实现方", + "sections": [ + { + "id": "status", + "title": "执行状态", + "hint": "全部做完后改成 完成; 没做完时保留本行" + }, + { + "id": "changes", + "title": "改动清单", + "hint": "逐个列出新增, 改动, 删除的文件" + }, + { + "id": "wiring", + "title": "接线证据", + "kinds": ["build"], + "hint": "从程序入口到本切片新代码的调用路径, 每一步写 文件:行号" + }, + { + "id": "corrections", + "title": "修正", + "table": ["计划原文", "实际情况", "证据位置", "处理办法"] + }, + { + "id": "pending", + "title": "待定标准", + "hint": "按事实做不到的完成标准: 编号, 原因, 证据位置; 没有时写 无" + }, + { + "id": "tests", + "title": "自测记录", + "hint": "运行过的命令与结果; 需要用户亲手验证的步骤" + }, + { + "id": "notes", + "title": "中途记录", + "hint": "交回之前已经查清的内容; 没有时写 无" + } + ] + }, + { + "id": "review", + "heading": "验收", + "owner": "编排会话", + "sections": [ + { + "id": "verdicts", + "title": "标准核对", + "table": ["标准", "结论", "证据"] + }, + { + "id": "wiringCheck", + "title": "接线核对", + "kinds": ["build"], + "hint": "按验收子代理交回的内容原样写入" + }, + { + "id": "structure", + "title": "结构核对", + "kinds": ["build"], + "hint": "按验收子代理交回的内容原样写入" + }, + { + "id": "scope", + "title": "范围核对", + "hint": "按验收子代理交回的内容原样写入" + }, + { + "id": "correctionCheck", + "title": "修正核对", + "hint": "按验收子代理交回的内容原样写入" + }, + { + "id": "manual", + "title": "人工验收", + "hint": "需要用户亲手确认的步骤; 没有时写 无" + }, + { + "id": "conclusion", + "title": "用户结论", + "hint": "用户作出决定后写下原话与日期" + } + ] + } + ] +} diff --git a/plugins/waypoint/skills/project-navigator/spec/templates.json b/plugins/waypoint/skills/project-navigator/spec/templates.json deleted file mode 100644 index 1ca6de4..0000000 --- a/plugins/waypoint/skills/project-navigator/spec/templates.json +++ /dev/null @@ -1,772 +0,0 @@ -{ - "version": 2, - "format": { - "titleLength": 4, - "keyLength": 4, - "numberWidth": 4, - "conclusionLabel": "结论", - "conclusionMaxLength": 40, - "emptyValue": "无", - "progressTitle": "当前进展", - "progressKeys": ["当前阶段", "当前里程", "当前切片", "当前工单"], - "progressLabels": ["阶段", "里程", "切片", "工单"], - "progressSeparator": " · ", - "orderStages": { - "drafting": "起草中", - "issued": "执行中", - "blocked": "受阻", - "reviewing": "验收中" - } - }, - "writing": { - "structure": { "label": "结构不符", "severity": "problem" }, - "sentence": { - "label": "句子过长", - "severity": "problem", - "maxLength": 40, - "hintLength": 20 - }, - "paragraph": { "label": "段落过长", "severity": "problem", "maxLines": 4 }, - "listLength": { "label": "列表过长", "severity": "problem", "maxItems": 7 }, - "listDepth": { "label": "列表过深", "severity": "problem", "maxDepth": 2 }, - "bold": { "label": "加粗过多", "severity": "hint", "maxPerSection": 3 }, - "particle": { - "label": "的字过多", - "severity": "hint", - "character": "的", - "maxPerPhrase": 2 - }, - "jargon": { - "label": "黑话用词", - "severity": "problem", - "words": [ - "赋能", - "抓手", - "闭环", - "打通", - "沉淀", - "颗粒度", - "心智", - "底层逻辑", - "顶层设计", - "组合拳", - "拉通", - "倒逼", - "反哺", - "护城河", - "飞轮", - "势能", - "赛道", - "深耕", - "丝滑", - "无缝" - ] - }, - "metaphor": { - "label": "比喻用词", - "severity": "problem", - "words": [ - "就像", - "好比", - "犹如", - "仿佛", - "如同", - "宛如", - "比作", - "打个比方" - ] - }, - "translationese": { - "label": "翻译腔调", - "severity": "hint", - "patterns": [ - { - "pattern": "进行了?\\p{Script=Han}{2}", - "advice": "去掉 \"进行\", 直接用后面的动词." - }, - { - "pattern": "[作做]出了?(决定|改进|调整|修改|选择|判断|回应)", - "advice": "去掉 \"作出\", 直接用后面的动词." - }, - { - "pattern": "对于[^,.;:!?]{1,20}来说", - "advice": "去掉 \"对于...来说\", 直接说对象." - }, - { - "pattern": "在[^,.;:!?]{1,20}的情况下", - "advice": "把 \"在...的情况下\" 改为 \"如果...\"." - }, - { - "pattern": "通过[^,.;:!?]{1,20}的方式", - "advice": "把 \"通过...的方式\" 改为 \"用...\"." - } - ] - }, - "pronoun": { - "label": "指代不明", - "severity": "problem", - "patterns": [ - "^它们?", - "^其(?![中它他余实次])", - "^此(?![外前后时])", - "^[这那](是|样|就|会|也|里)" - ] - }, - "table": { "label": "表格未齐", "severity": "hint" }, - "length": { - "label": "篇幅过长", - "severity": "hint", - "maxReplyLines": 60, - "maxFileLines": 200 - } - }, - "stages": ["盘点", "立项", "主线", "选型", "骨架", "切片", "收尾"], - "processTerms": [ - [ - "编排会话", - "调用本技能的会话: 规划, 记录, 发布工单, 组织验收, 不写业务代码" - ], - [ - "执行子代理", - "编排会话在后台派出的子代理: 按一张工单写代码, 测试, 写回执" - ], - ["阶段", "项目推进的七个大步: 盘点, 立项, 主线, 选型, 骨架, 切片, 收尾"], - ["主线", "使用者完成核心任务的最短操作路径"], - ["骨架", "能把主线从头跑到尾的最小可运行版本, 是第一个切片"], - ["里程", "一组切片完成后可以演示的成果, 结束时做一次复查"], - ["切片", "从界面到数据贯通的一段使用者可见的功能, 做完即可运行"], - [ - "工单", - "交给执行子代理的一项任务: 默认一个切片一张, 超出执行或验收能力时拆分" - ], - ["判据", "工单完成的检验条件, 每条只验证一件事"], - ["回执", "执行子代理完成或受阻后写的汇报, 存在工单文件夹的 receipt.md"], - ["验收", "验收子代理逐条核对判据, 再由用户决定是否通过"], - ["体检", "检查记录文件是否冗长, 是否好懂"] - ], - "replies": { - "初始设置": { - "id": "setup", - "sections": [ - { - "title": "检查结果", - "keys": ["版本控制", "运行环境", "初始标记", "放行规则"] - }, - { "title": "设置内容", "keys": ["状态目录", "放行规则", "初始标记"] } - ], - "optionSets": [ - { - "title": "是否设置", - "choices": ["开始设置, 写入配置.", "暂不设置, 稍后再来."] - }, - { "title": "如何继续", "choices": ["已经完成, 重新检查."] } - ] - }, - "首次接入": { - "id": "entry", - "sections": [ - { "title": "仓库情况", "keys": ["版本控制", "已有代码", "初始需求"] } - ], - "optionSets": [ - { - "title": "如何开始", - "choices": ["新建项目, 从立项开始.", "已有项目, 从盘点开始."] - } - ] - }, - "恢复进度": { - "id": "resume", - "sections": [ - { "title": "上次位置", "keys": ["最后动作", "记录时间"] }, - { "title": "当前情况", "keys": ["工单状态", "工作区域"] }, - { "title": "后续操作", "keys": ["下一动作"] } - ], - "optionSets": [ - { - "title": "如何继续", - "choices": ["按照计划, 继续推进.", "暂不继续, 稍后再来."] - } - ] - }, - "需求理解": { - "id": "understanding", - "sections": [ - { "title": "原话摘录" }, - { "title": "我的假设" }, - { "title": "调研方向" } - ], - "optionSets": [ - { - "title": "是否准确", - "choices": ["理解准确, 开始调研.", "有所偏差, 请指出来."] - } - ] - }, - "调研报告": { - "id": "research", - "sections": [ - { "title": "报告文件", "keys": ["文件路径", "来源数量", "选型问题"] }, - { "title": "已知事实" }, - { "title": "关键假设" }, - { "title": "未知事项" } - ], - "optionSets": [ - { - "title": "是否相符", - "choices": [ - "认知相符, 开始讨论.", - "跳过讨论, 直接简报.", - "有所出入, 指出哪条." - ] - } - ] - }, - "头脑风暴": { - "id": "brainstorm", - "sections": [ - { "title": "本轮角度", "keys": ["角度序号", "角度名称", "所处阶段"] }, - { "title": "本轮问题" }, - { "title": "我的点子", "keys": ["点子内容", "请你评价"] } - ], - "optionSets": [ - { - "title": "如何回答", - "buttons": false, - "choices": ["按照编号, 逐条回答.", "本题跳过, 说明原因."] - }, - { - "title": "是否收敛", - "choices": ["开始收敛, 整理结论.", "继续发散, 换个角度."] - } - ] - }, - "产出确认": { - "id": "confirm", - "sections": [ - { "title": "产出文件", "keys": ["文件路径", "文件内容", "条目数量"] }, - { "title": "内容要点" } - ], - "optionSets": [ - { - "title": "是否确认", - "choices": ["确认无误, 继续推进.", "需要修改, 请提需求."] - } - ] - }, - "规范对照": { - "id": "standards", - "sections": [ - { "title": "对照结果", "keys": ["一致条目", "单方独有", "互相矛盾"] }, - { "title": "本轮条目" } - ], - "optionSets": [ - { - "title": "如何回答", - "buttons": false, - "choices": ["按照编号, 逐条回答.", "全部采用, 插件默认."] - }, - { - "title": "是否纳入", - "choices": ["全部纳入, 写入规范.", "按照编号, 逐条决定."] - }, - { - "title": "注释语言", - "choices": [ - "中文注释, 默认选项.", - "英文注释, 改用英文.", - "其它语言, 直接写明." - ] - } - ] - }, - "工单审阅": { - "id": "approve", - "sections": [ - { - "title": "审阅要点", - "keys": ["任务目标", "需要决定", "明确不做", "粒度依据", "工单文件"] - } - ], - "optionSets": [ - { - "title": "是否发布", - "choices": ["发布工单, 开始执行.", "需要修改, 请提需求."] - } - ] - }, - "工单执行": { - "id": "running", - "sections": [ - { - "title": "执行情况", - "keys": ["工单文件", "回执路径", "执行方式", "中途停止"], - "presets": { "中途停止": "回复 \"停止执行\", 工单撤回后可以修改" } - } - ], - "optionSets": [] - }, - "工单验收": { - "id": "reviewing", - "sections": [ - { "title": "验收情况", "keys": ["回执路径", "验收方式"] }, - { "title": "核对重点" } - ], - "optionSets": [] - }, - "等待结果": { - "id": "waiting", - "sections": [ - { "title": "等待情况", "keys": ["等待对象", "任务内容", "后续动作"] } - ], - "optionSets": [] - }, - "等待操作": { - "id": "paused", - "orderStage": "待你操作", - "sections": [ - { - "title": "操作内容", - "keys": ["等待原因", "完成之后"], - "presets": { - "完成之后": "回复 \"操作完成\", 并写明实际做了什么; 不做时回复 \"停止执行\"" - } - }, - { "title": "操作步骤" } - ], - "optionSets": [] - }, - "验收报告": { - "id": "review", - "orderStage": "待你决定", - "sections": [ - { "title": "验收结论", "keys": ["总体结论", "判据通过", "回执来源"] }, - { "title": "判据核对" }, - { - "title": "专项检查", - "keys": [ - "回执一致", - "接线核查", - "范围核查", - "测试改动", - "测试运行", - "修正核对", - "规范核查" - ] - }, - { "title": "人工验收" }, - { "title": "处理建议", "keys": ["建议选项", "主要依据"] } - ], - "optionSets": [ - { - "title": "验收结论", - "choices": [ - "通过验收, 提交改动.", - "通过验收, 提交推送.", - "发现问题, 描述现象." - ] - }, - { - "title": "如何处理", - "choices": ["补正工单, 立即发布.", "暂不修复, 说明原因."] - }, - { - "title": "未验判据", - "buttons": false, - "choices": ["接受现状, 提交改动.", "补正工单, 立即发布."] - }, - { - "title": "是否采用", - "buttons": false, - "choices": ["采用结论, 提交记录.", "另选方案, 重新选型."] - } - ] - }, - "受阻处理": { - "id": "blocked", - "sections": [ - { - "title": "受阻原因", - "keys": ["受阻类型", "规格原文", "实际情况", "证据位置", "影响判据"] - } - ], - "optionSets": [ - { - "title": "如何处理", - "choices": [ - "修改工单, 重新发布.", - "先做调研, 再定方案.", - "调整范围, 进入变更." - ] - }, - { - "title": "如何处理", - "choices": ["拆分工单, 重新发布.", "保持原样, 说明原因."] - }, - { - "title": "如何处理", - "choices": ["操作完成, 继续执行.", "暂不操作, 说明原因."] - }, - { - "title": "如何处理", - "buttons": false, - "choices": ["按照编号, 逐条回答.", "转为变更, 重新评估."] - } - ] - }, - "需求变更": { - "id": "change", - "sections": [ - { - "title": "变更内容", - "keys": ["变更编号", "变更描述", "变更原因", "提出来源"] - }, - { - "title": "影响分析", - "keys": ["简报影响", "路径影响", "决策影响", "切片影响", "工单影响"] - } - ], - "optionSets": [ - { - "title": "如何处理", - "choices": [ - "接受变更, 更新记录.", - "延后处理, 列入后续.", - "放弃变更, 记录原因." - ] - } - ] - }, - "里程复查": { - "id": "milestone", - "sections": [ - { "title": "里程成果", "keys": ["完成切片", "运行方式", "版本提交"] }, - { "title": "简报对照" }, - { - "title": "偏离检查", - "keys": ["范围偏离", "约束偏离", "新增风险", "文档体检"] - }, - { "title": "复盘要点", "keys": ["有效做法", "改进事项"] } - ], - "optionSets": [ - { - "title": "如何推进", - "choices": ["继续推进, 开始下一里程.", "调整计划, 先走变更流程."] - } - ] - }, - "项目收尾": { - "id": "closing", - "sections": [ - { "title": "总体验收" }, - { - "title": "项目统计", - "keys": ["里程数量", "切片数量", "工单数量", "变更数量"] - }, - { "title": "复盘要点", "keys": ["有效做法", "改进事项", "遗留事项"] } - ], - "optionSets": [ - { - "title": "是否收尾", - "choices": ["确认收尾, 项目完成.", "继续迭代, 新开里程."] - } - ] - }, - "项目完成": { - "id": "complete", - "sections": [ - { - "title": "完成情况", - "keys": ["最近提交", "工作区域", "远程推送"] - } - ], - "optionSets": [ - { - "title": "后续安排", - "choices": [ - "继续迭代, 新开里程.", - "推送远程, 保持完成.", - "暂无安排, 保持完成." - ] - } - ] - }, - "自由答复": { - "id": "freeform", - "sections": [{ "title": "答复内容", "freeform": true }], - "optionSets": [ - { - "title": "后续安排", - "choices": ["回到流程, 继续推进.", "还有问题, 继续答复."] - } - ] - }, - "体检报告": { - "id": "checkup", - "sections": [ - { "title": "体检范围", "keys": ["文件数量", "脚本检查", "语义检查"] }, - { "title": "问题清单" }, - { "title": "分类统计" } - ], - "optionSets": [ - { - "title": "如何处理", - "choices": ["逐条修正, 更新文档.", "暂不修正, 记录在案."] - } - ] - }, - "运行受阻": { - "id": "halted", - "sections": [ - { - "title": "受阻情况", - "keys": ["受阻操作", "受阻原因", "原始提示", "影响范围"] - }, - { "title": "处理办法" } - ], - "optionSets": [ - { - "title": "如何继续", - "choices": ["已经处理, 重新尝试.", "暂停编排, 稍后再来."] - } - ] - }, - "问题报告": { - "id": "report", - "sections": [ - { "title": "报告概况", "keys": ["报告文件", "能否继续"] }, - { "title": "报告内容", "freeform": true } - ], - "optionSets": [ - { - "title": "如何继续", - "choices": ["记录问题, 继续推进.", "暂停编排, 等待修复."] - }, - { - "title": "如何继续", - "choices": ["已经修复, 重新尝试.", "暂停编排, 等待修复."] - } - ] - } - }, - "files": { - "order": { - "pattern": ".navigator/orders/*/order.md", - "title": "执行工单", - "hasProgress": true, - "sections": [ - { - "title": "审阅要点", - "keys": ["任务目标", "需要决定", "明确不做", "粒度依据"] - }, - { - "title": "工单概况", - "keys": ["背景原因", "完成标志", "基准提交", "回执路径"] - }, - { "title": "执行守则" }, - { "title": "工作假设" }, - { "title": "工作范围", "keys": ["要做的事", "不做的事"] }, - { "title": "验收判据" }, - { "title": "相关材料" } - ] - }, - "receipt": { - "pattern": ".navigator/orders/*/receipt.md", - "title": "工单回执", - "hasProgress": true, - "variants": [ - [ - { - "title": "回执概况", - "keys": ["执行状态", "基准提交", "完成时间"] - }, - { "title": "假设核对" }, - { - "title": "事实修正", - "table": { - "columns": ["工单原文", "实际情况", "证据位置", "处理办法"], - "allowEmpty": true - } - }, - { "title": "模块划分", "keys": ["新增模块", "改动模块", "入口改动"] }, - { "title": "判据自检" }, - { "title": "改动清单" }, - { - "title": "测试记录", - "keys": ["测试命令", "测试结果", "测试改动", "外部影响", "取证记录"] - }, - { "title": "运行方式", "keys": ["启动命令", "验证步骤"] }, - { - "title": "偏差记录", - "keys": ["实现决定", "需要操作", "发现风险", "待定判据", "受阻详情"] - } - ], - [ - { - "title": "回执概况", - "keys": ["执行状态", "基准提交", "完成时间"] - }, - { "title": "假设核对" }, - { "title": "现有能力" }, - { "title": "候选方案" }, - { - "title": "需求核对", - "table": { - "columns": ["需求", "结论", "依据"], - "choices": { "结论": ["能", "不能"] } - } - }, - { - "title": "能力核实", - "table": { - "columns": ["能力", "仓库", "版本", "路径", "行号", "说明"], - "allowEmpty": true - } - }, - { "title": "选型结论" }, - { "title": "一致检查" }, - { "title": "改动清单" }, - { - "title": "偏差记录", - "keys": ["实现决定", "需要操作", "发现风险", "受阻详情"] - } - ] - ] - }, - "review": { - "pattern": ".navigator/orders/*/review*.md", - "title": "验收记录", - "hasProgress": true, - "sections": [ - { - "title": "验收结论", - "keys": ["总体结论", "判据通过", "回执来源", "验收轮次", "验收时间"] - }, - { - "title": "判据核对", - "table": { - "columns": ["判据", "结论", "证据"], - "choices": { "结论": ["通过", "不通过", "未验证"] } - } - }, - { - "title": "专项检查", - "keys": [ - "回执一致", - "接线核查", - "范围核查", - "测试改动", - "测试运行", - "修正核对", - "规范核查" - ] - }, - { - "title": "人工验收", - "keys": ["验收步骤", "用户结论"], - "presets": { "用户结论": "待用户确认" } - } - ] - }, - "brief": { - "pattern": ".navigator/plan/brief.md", - "title": "项目简报", - "sections": [ - { "title": "基本信息", "keys": ["项目名称", "简报版本", "最近变更"] }, - { "title": "问题背景", "keys": ["背景说明", "核心问题"] }, - { "title": "使用人群", "keys": ["使用人群", "使用场景"] }, - { "title": "成品样子" }, - { "title": "成功指标" }, - { "title": "范围边界", "keys": ["必须包含", "明确不做"] }, - { "title": "硬性约束" }, - { "title": "已知未知" } - ] - }, - "glossary": { - "pattern": ".navigator/plan/glossary.md", - "title": "术语定义", - "sections": [{ "title": "流程术语" }, { "title": "项目术语" }] - }, - "survey": { - "pattern": ".navigator/plan/survey.md", - "title": "现状报告", - "sections": [ - { - "title": "盘点概况", - "keys": ["盘点时间", "基准提交", "源码规模", "运行检查"] - }, - { "title": "已有能力" }, - { "title": "运行情况", "keys": ["构建结果", "启动结果", "测试结果"] }, - { "title": "目标差距" } - ] - }, - "research": { - "pattern": ".navigator/plan/research.md", - "title": "调研报告", - "hasProgress": true, - "sections": [ - { "title": "需求理解", "keys": ["原话摘录", "调研方向"] }, - { "title": "同类方案" }, - { "title": "使用人群" }, - { "title": "行业做法" }, - { "title": "约束风险" }, - { "title": "关键术语" }, - { "title": "选型问题" }, - { "title": "结论汇总" }, - { "title": "来源列表" } - ] - }, - "brainstorm": { - "pattern": ".navigator/plan/brainstorm.md", - "title": "头脑风暴", - "sections": [ - { "title": "记录概况", "keys": ["角度总数", "完成轮次", "所处阶段"] } - ], - "entryPattern": "^轮次 \\d{4}: .+$", - "closingSections": [ - { "title": "收敛结果", "keys": ["必须要做", "明确不做", "优先验证"] } - ] - }, - "main-flow": { - "pattern": ".navigator/plan/main-flow.md", - "title": "主线流程", - "sections": [ - { "title": "场景清单" }, - { "title": "主线步骤" }, - { "title": "外部依赖" } - ] - }, - "decisions": { - "pattern": ".navigator/plan/decisions.md", - "title": "决策记录", - "sections": [], - "entryPattern": "^决策 \\d{4}: .+$", - "entryKeys": [ - "决策状态", - "批准时间", - "来源工单", - "决策内容", - "决策理由", - "可否回退", - "影响范围" - ] - }, - "changes": { - "pattern": ".navigator/plan/changes.md", - "title": "变更记录", - "sections": [], - "entryPattern": "^变更 \\d{4}: .+$" - }, - "milestone-reviews": { - "pattern": ".navigator/plan/milestone-reviews.md", - "title": "里程复查", - "sections": [], - "entryPattern": "^(里程 \\d{4}: .+|项目收尾)$" - }, - "checkups": { - "pattern": ".navigator/plan/checkups.md", - "title": "体检记录", - "sections": [], - "entryPattern": "^体检 \\d{4}: .+$" - } - } -} diff --git a/plugins/waypoint/skills/project-navigator/standards/engineering.md b/plugins/waypoint/skills/project-navigator/standards/engineering.md index dfd4b99..57910bb 100644 --- a/plugins/waypoint/skills/project-navigator/standards/engineering.md +++ b/plugins/waypoint/skills/project-navigator/standards/engineering.md @@ -1,25 +1,39 @@ # 工程规范 -本项目遵循以下规范. 本文件由 project-navigator 在规范对照后写入; 需要修改时, 在编排会话中重新做规范对照. +## 模块组织 + + + +- 按职责或功能划分物理目录, 一个模块一个目录, 形成清晰的模块边界. +- 新文件放进所属模块的目录; 新建模块时新建目录. + + + + +- 本项目是 Visual Studio 项目: 源码文件平铺在工程目录下, 不按模块建物理子目录. +- 模块用虚拟筛选器 (`.vcxproj.filters`) 表达: 一个模块一个筛选器, 每个源文件都归入所属模块的筛选器. +- 新增源文件时, 同时在 `.vcxproj` 中登记, 并在 `.vcxproj.filters` 中归入筛选器. +- 文档等非源码目录不受平铺限制. + + ## 架构与模块化 1. 修改前先读取项目相关代码, 文档, 配置, 测试和局部规则. 优先遵循既有架构, 目录结构, 模式, 命名和技术栈. -2. 默认按职责或功能划分物理目录, 形成清晰的模块边界. Visual Studio 项目例外: 源码物理目录保持扁平, 通过项目筛选器表达模块边界. -3. 每个文件, 模块, 函数和类等等只承担一个清晰职责. 模块必须高内聚, 低耦合, 通过明确接口通信, 可独立理解和测试. -4. 优先复用已有实现. 出现真实重复时再提取共享单元, 不为假想复用过早抽象. -5. 常量, 类型, 配置, 规则, schema 和业务知识必须保持单一数据源. -6. 分离表现层, 业务层和数据层. 跨层只能通过明确接口交互. +2. 每个文件, 模块, 函数和类只承担一个清晰职责. 模块必须高内聚, 低耦合, 通过明确接口通信, 可独立理解和测试. +3. 常量, 类型, 配置, 规则, schema 和业务知识必须保持单一数据源: 只在一处定义, 其它地方引用. +4. 按职责分层: 分离表现层, 业务层和数据层. 跨层只能通过明确接口交互, 依赖方向指向稳定的业务核心. +5. 优先复用已有实现. 出现两处真实重复时再提取共享单元; 在此之前不建通用模块, 不把代码下沉到公共层. +6. 严格执行 YAGNI. 不增加未请求的功能, 兼容路径, 扩展点或无关重构. 7. 依赖稳定抽象. 在替换实现或测试需要时, 通过适配层隔离框架, 数据库, 第三方服务和外部细节. 8. 优先组合而非继承. 同类问题采用一致结构, 消除循环依赖, 魔法数字和魔法字符串. -9. 严格执行 YAGNI. 不增加未请求功能, 兼容路径, 扩展点或无关重构. -10. 难以测试是职责不清或耦合过高的设计信号, 应先改善模块边界和接口设计. +9. 难以测试是职责不清或耦合过高的设计信号, 应先改善模块边界和接口设计. ## 模块粒度 1. 按职责拆分, 判断标准是 "一个模块只承担一件事". 不为假想的扩展提前拆分: 拆分跟着真实出现的职责走. -2. 新功能能以新增模块接入时, 不改动已有模块的内部实现. -3. 验收时检查: 本次新功能是否迫使多个已有模块改动内部实现. 如果是, 说明模块边界不合适, 先拆出新模块. +2. 避免过度细粒度拆分. 模块与文件的粒度以 "职责清晰, 便于理解" 为准, 不为拆而拆. +3. 新功能能以新增模块接入时, 不改动已有模块的内部实现. 新功能迫使多个已有模块改动内部实现时, 说明模块边界不合适, 先调整边界. ## 代码质量与注释 @@ -40,9 +54,7 @@ - 仅在不存在合适成熟方案时, 才自行实现必要能力. 2. 不得在存在合适成熟库时重复造轮子. 3. 不得为了轻量化, 包体积, 是否偏重, 版本锁定, 部分功能暂未使用或其他非质量因素, 放弃更成熟可靠的生产级方案并选择小众库. -4. 新增依赖时, 优先选择安全, 可靠, 稳定, 高效, 活跃, 长期维护且最适合当前项目的生产级库. -5. 已确定技术栈必须一致采用. 例如项目已使用 Shadcn 和 Tailwind CSS 时, UI 必须优先使用 Shadcn 与 Tailwind CSS, 禁止使用原生 UI 组件或引入其他 UI 框架. -6. 实现功能前先判断现有依赖能否完成需求. 现有依赖不具备能力时, 再选择符合生产级要求的第三方库. -7. 对不确定的库, 框架, SDK, API, CLI 或云服务行为, 先使用 Context7 查询准确版本和权威文档. Context7 不可用时查询官方文档. -8. 不混用无关的组件系统, 数据层, 测试框架, 状态管理方案或部署工具. -9. 选型必须准确: 逐条回答候选库能否实现每条需求, 不凭记忆回答. 结论为能的需求附依据: 源码证据 (仓库地址, 版本或提交, 文件路径与行号), 源码看不出来时写临时测试实际运行. 只有文档或记忆作依据的结论视为未核实. +4. 已确定技术栈必须一致采用, 不混用无关的组件系统, 数据层, 测试框架, 状态管理方案或部署工具. +5. 实现功能前先判断现有依赖能否完成需求. 现有依赖不具备能力时, 再选择符合生产级要求的第三方库. +6. 对不确定的库, 框架, SDK, API, CLI 或云服务行为, 先查询准确版本的权威文档, 不凭记忆下结论. +7. 选型必须有依据: 逐条回答候选库能否实现每条需求, 依据是源码, 官方文档中的明确说法, 或一次实际运行. 只有记忆作依据的结论视为未核实. diff --git a/scripts/gen-navigator-docs.mjs b/scripts/gen-navigator-docs.mjs deleted file mode 100644 index d80f664..0000000 --- a/scripts/gen-navigator-docs.mjs +++ /dev/null @@ -1,416 +0,0 @@ -/** - * @file 从 project-navigator 的模板规格生成参考文档与执行手册. - * - * 用法: node scripts/gen-navigator-docs.mjs [--check] - * - * 回复类型, 记录文件与写作规则的唯一来源是 `spec/templates.json`; 标点规则的唯一 - * 来源是 `shared/punctuation.md`. 本脚本把它们写进技能的参考文档与执行手册, - * 手写的说明文字放在 `scripts/navigator-docs/` 中, 以占位行标出生成内容的位置. - * 生成结果经过 prettier 格式化; `--check` 只比较不写入, 有差异时以退出码 1 结束. - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import * as prettier from "prettier"; - -import { KEY_SECTION_RULE } from "../plugins/waypoint/skills/project-navigator/runtime/lib/file-guide.mjs"; -import { FREEFORM_RULE } from "../plugins/waypoint/skills/project-navigator/runtime/lib/freeform-sections.mjs"; -import { OPTION_LETTERS } from "../plugins/waypoint/skills/project-navigator/runtime/lib/render.mjs"; -import { - BUTTON_COUNT, - BUTTON_TOOL, -} from "../plugins/waypoint/skills/project-navigator/runtime/lib/reply-guide.mjs"; -import { isFreeformSection } from "../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs"; -import { renderTable } from "../plugins/waypoint/skills/project-navigator/runtime/lib/table.mjs"; -import { UNIT_RULE } from "../plugins/waypoint/skills/project-navigator/runtime/lib/text-units.mjs"; -import { describeWritingRules } from "../plugins/waypoint/skills/project-navigator/runtime/lib/writing-rules.mjs"; - -/** - * 仓库根目录的绝对路径. - * @type {string} - */ -const REPO_ROOT = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -/** - * 技能目录的绝对路径. - * @type {string} - */ -const SKILL_DIRECTORY = path.join( - REPO_ROOT, - "plugins", - "waypoint", - "skills", - "project-navigator", -); - -/** - * 手写说明文字所在目录. - * @type {string} - */ -const SOURCE_DIRECTORY = path.join(REPO_ROOT, "scripts", "navigator-docs"); - -/** - * 模板规格文件. - * @type {string} - */ -const SPEC_FILE = path.join(SKILL_DIRECTORY, "spec", "templates.json"); - -/** - * 标点规则文件. - * @type {string} - */ -const PUNCTUATION_FILE = path.join( - REPO_ROOT, - "plugins", - "waypoint", - "shared", - "punctuation.md", -); - -/** - * 插入在生成文件一级标题之后的说明. - * @type {string} - */ -const GENERATED_NOTICE = - ""; - -/** - * 源文件中标记生成内容位置的占位行. - * @type {RegExp} - */ -const PLACEHOLDER_PATTERN = /^$/gmu; - -/** - * 只比较不写入的参数. - * @type {string} - */ -const CHECK_FLAG = "--check"; - -/** - * 生成结果与已有文件不一致时的退出码. - * @type {number} - */ -const EXIT_FAILURE = 1; - -/** - * 级别的显示名. - * @type {Readonly>} - */ -const SEVERITY_LABELS = Object.freeze({ problem: "问题", hint: "提示" }); - -/** - * @typedef {object} GenerationContext 生成内容需要的输入. - * @property {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").TemplateSpec} spec 模板规格. - * @property {string} punctuation 标点规则原文. - */ - -/** - * 占位行名称及其生成函数. - * @type {Readonly string[]>>} - */ -const GENERATORS = Object.freeze({ - "receipt-structures": ({ spec }) => receiptStructures(spec), - punctuation: ({ punctuation }) => punctuationRules(punctuation), - "writing-rules": ({ spec }) => writingRulesTable(spec), - "unit-rule": () => [UNIT_RULE], - "writing-words": ({ spec }) => writingWords(spec), -}); - -/** - * 生成目标, 相对于技能目录: 由源文件填充占位行, 或完全由规格生成. - * @type {readonly {target: string, source?: string, render?: (context: GenerationContext) => string}[]} - */ -const OUTPUTS = Object.freeze([ - { target: "references/replies.md", render: repliesDocument }, - { target: "references/files.md", render: filesDocument }, - { target: "references/writing.md", source: "writing.md" }, - { target: "guide/executor.md", source: "executor-guide.md" }, -]); - -/** - * 生成全部文档; 检查模式下只报告与已有文件不一致的目标. - * - * @returns {Promise} - */ -async function main() { - const isCheck = process.argv.includes(CHECK_FLAG); - const context = { - spec: JSON.parse(readFileSync(SPEC_FILE, "utf8")), - punctuation: readFileSync(PUNCTUATION_FILE, "utf8"), - }; - const stale = []; - for (const output of OUTPUTS) { - const file = path.join(SKILL_DIRECTORY, output.target); - const content = await formatMarkdown(file, buildDocument(output, context)); - const existing = existsSync(file) ? readFileSync(file, "utf8") : undefined; - if (existing === content) { - continue; - } - if (isCheck) { - stale.push(output.target); - } else { - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, content, "utf8"); - console.log(`已生成 ${output.target}`); - } - } - if (stale.length > 0) { - console.error( - `以下文件与规格不一致, 请运行 npm run gen:docs: ${stale.join(", ")}`, - ); - process.exitCode = EXIT_FAILURE; - } -} - -/** - * 生成一个目标文档的全文, 并在一级标题之后插入生成说明. - * - * @param {{target: string, source?: string, render?: (context: GenerationContext) => string}} output 生成目标. - * @param {GenerationContext} context 生成输入. - * @returns {string} 文档全文. - * @throws {Error} 源文件中出现未知的占位行时. - */ -function buildDocument(output, context) { - const body = - output.render === undefined - ? fillPlaceholders( - readFileSync( - path.join(SOURCE_DIRECTORY, output.source ?? ""), - "utf8", - ), - context, - ) - : output.render(context); - const [title, ...rest] = body.split("\n"); - return [title, "", GENERATED_NOTICE, ...rest].join("\n"); -} - -/** - * 把源文件中的占位行替换为生成内容. - * - * @param {string} source 源文件全文. - * @param {GenerationContext} context 生成输入. - * @returns {string} 替换后的全文. - * @throws {Error} 占位行名称未知时. - */ -function fillPlaceholders(source, context) { - return source.replace(PLACEHOLDER_PATTERN, (_line, name) => { - if (!Object.hasOwn(GENERATORS, name)) { - throw new Error(`gen-navigator-docs: 未知的占位行 "${name}"`); - } - return GENERATORS[name](context).join("\n"); - }); -} - -/** - * 用仓库的 prettier 配置格式化 Markdown. - * - * @param {string} file 目标文件路径, 用于解析配置. - * @param {string} content 文档全文. - * @returns {Promise} 格式化后的全文. - */ -async function formatMarkdown(file, content) { - const options = (await prettier.resolveConfig(file)) ?? {}; - return prettier.format(content, { ...options, filepath: file }); -} - -/** - * 生成回复类型参考文档. - * - * @param {GenerationContext} context 生成输入. - * @returns {string} 文档全文. - */ -function repliesDocument({ spec }) { - return [ - "# 回复类型", - "", - `每条回复都以 "# 类型" 开头, 下方第一行是 "${spec.format.conclusionLabel}: ..." 一句话结论, 其后是脚本计算的进展行, 然后是各节, 最后是一个选项块; 不需要用户选择的回复 (下方 "选项" 写 "无") 不带选项块. 恰好 ${BUTTON_COUNT} 项的选项组, 在提供 \`${BUTTON_TOOL}\` 工具的应用中同时做成按钮, 按钮文字是选项的第一个分句, 写法见填写要求中的 "按钮" 一条; 规格中关闭了按钮的组除外 (选择后还要打字补充, 或选了就会提交). 回复前运行 \`project-navigator reply <编号> --option <组号>\` 取得填写要求与骨架, 按要求在骨架上填写. 回复结束时只校验结构: 标题, 结论行与选项块.`, - ...Object.entries(spec.replies).flatMap(([title, reply]) => [ - "", - ...replySummary(title, reply), - ]), - "", - ].join("\n"); -} - -/** - * 生成一种回复的摘要: 编号, 各节与键名, 各组选项. - * - * @param {string} title 回复类型. - * @param {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").ReplySpec} reply 回复规格. - * @returns {string[]} 各行. - */ -function replySummary(title, reply) { - return [ - `## ${title}`, - "", - `- 编号: \`${reply.id}\``, - `- 各节: ${reply.sections.map(sectionLabel).join("; ")}`, - ...(reply.optionSets.length === 0 - ? ["- 选项: 无, 不需要用户选择, 回复不带选项块."] - : [ - "- 选项:", - ...reply.optionSets.map( - (set, index) => - ` - 第 ${index + 1} 组 [${set.title}]: ${set.choices.map((choice, choiceIndex) => `${OPTION_LETTERS[choiceIndex]}. ${choice}`).join(" ")}`, - ), - ]), - ]; -} - -/** - * 描述一个节: 标题, 键名 (含骨架预填的值) 或表格, 是否为自由正文. - * - * @param {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").SectionSpec} section 节规格. - * @returns {string} 描述. - */ -function sectionLabel(section) { - const keys = - section.keys === undefined - ? "" - : ` (${section.keys.map((key) => keyLabel(key, section.presets)).join(", ")})`; - const table = - section.table === undefined ? "" : ` (${tableLabel(section.table)})`; - const freeform = isFreeformSection(section) ? ` (${FREEFORM_RULE})` : ""; - return `${section.title}${keys}${table}${freeform}`; -} - -/** - * 描述一个键名; 骨架中预填了值时附上该值. - * - * @param {string} key 键名. - * @param {Record | undefined} presets 节中预填的键值. - * @returns {string} 描述. - */ -function keyLabel(key, presets) { - return presets?.[key] === undefined - ? key - : `${key} [骨架预填: ${presets[key]}]`; -} - -/** - * 描述表格节: 表格的列, 以及只能取固定值的列. - * - * @param {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").TableSpec} table 表格规格. - * @returns {string} 描述. - */ -function tableLabel(table) { - const choices = Object.entries(table.choices ?? {}).map( - ([column, values]) => `, "${column}" 只能填 ${values.join("/")}`, - ); - return `表格, 列为 ${table.columns.join(", ")}${choices.join("")}`; -} - -/** - * 生成记录文件参考文档. - * - * @param {GenerationContext} context 生成输入. - * @returns {string} 文档全文. - */ -function filesDocument({ spec }) { - return [ - "# 记录文件", - "", - `\`.navigator/\` 下的记录文件按以下结构编写. 新建文件前运行 \`project-navigator template <种类>\` 取得填写要求与骨架, 按要求在骨架上填写, 写完后运行 \`project-navigator check <文件>\` 自查. 每个文件以一级标题开头, 文件中不放代码块. ${KEY_SECTION_RULE}; 脚本按键值行读取记录.`, - ...Object.entries(spec.files).flatMap(([kind, fileSpec]) => [ - "", - `## ${fileSpec.title}`, - "", - `- 种类: \`${kind}\``, - `- 路径: \`${fileSpec.pattern}\``, - `- 当前进展: ${fileSpec.hasProgress === true ? "有" : "无"}`, - ...(fileSpec.variants ?? [fileSpec.sections ?? []]).map( - (sections, index, all) => - `- 固定节${all.length > 1 ? ` (结构 ${index + 1})` : ""}: ${sections.map(sectionLabel).join("; ") || "无"}`, - ), - ...(fileSpec.entryPattern === undefined - ? [] - : [`- 条目标题: 匹配 \`${fileSpec.entryPattern}\`, 只追加不修改`]), - ...(fileSpec.entryKeys === undefined - ? [] - : [`- 条目键名: ${fileSpec.entryKeys.join(", ")}`]), - ...(fileSpec.closingSections === undefined - ? [] - : [ - `- 收尾节: ${fileSpec.closingSections.map(sectionLabel).join("; ")}`, - ]), - ]), - "", - ].join("\n"); -} - -/** - * 生成回执的两种结构说明. - * - * @param {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 各行. - */ -function receiptStructures(spec) { - const names = ["实现类工单", "选型类工单"]; - return [ - `回执以 "# ${spec.files.receipt.title}" 开头, 第一节为 "## ${spec.format.progressTitle}". 之后各节:`, - "", - ...spec.files.receipt.variants.map( - (sections, index) => - `- ${names[index] ?? `结构 ${index + 1}`} (\`--option ${index + 1}\`): ${sections.map(sectionLabel).join("; ")}`, - ), - "", - `括号中是键名. ${KEY_SECTION_RULE}, 编排会话的脚本按键值行读取回执. 表格与多行内容写进没有键名的节, 完整输出放进证据文件后在值中写出路径.`, - ]; -} - -/** - * 取出标点规则的条目. - * - * @param {string} punctuation 标点规则原文. - * @returns {string[]} 规则各行. - */ -function punctuationRules(punctuation) { - return punctuation.split("\n").filter((line) => line.startsWith("- ")); -} - -/** - * 生成写作规则表. - * - * @param {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 表格各行. - */ -function writingRulesTable(spec) { - const writing = spec.writing; - const base = describeWritingRules(writing); - const descriptions = { - ...base, - sentence: `${base.sentence}; 达到 ${writing.sentence.hintLength} 字时给出提示`, - }; - return renderTable( - ["类别", "级别", "规则"], - Object.entries(writing).map(([key, rule]) => [ - rule.label, - SEVERITY_LABELS[rule.severity], - descriptions[key] ?? "", - ]), - ); -} - -/** - * 生成禁用词清单. - * - * @param {import("../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs").TemplateSpec} spec 模板规格. - * @returns {string[]} 各行. - */ -function writingWords(spec) { - const { jargon, metaphor, translationese } = spec.writing; - return [ - `- ${jargon.label}: ${jargon.words.join(", ")}.`, - `- ${metaphor.label}: ${metaphor.words.join(", ")}.`, - `- ${translationese.label}:`, - ...translationese.patterns.map(({ advice }) => ` - ${advice}`), - ]; -} - -await main(); diff --git a/scripts/navigator-docs/executor-guide.md b/scripts/navigator-docs/executor-guide.md deleted file mode 100644 index b16654a..0000000 --- a/scripts/navigator-docs/executor-guide.md +++ /dev/null @@ -1,148 +0,0 @@ -# 执行手册 - -本手册写给执行子代理: 编排会话在后台派出你, 委派提示词给出了一张工单. 先完整读完本手册, 再读工单. 你工作期间不能向用户提问: 需要用户决定或操作的事, 写进回执, 以受阻结束. - -## 职责边界 - -- 你按一张工单完成一个使用者可见的行为变化, 回答一个选型问题, 或做一次运行检查或分析. 调研, 选型, 写代码, 构建, 测试都由你完成. -- 编排会话负责规划与验收, 用户负责所有决定. -- 你在 `.navigator/` 下只写本工单的回执与证据文件 (见下方 "证据文件"), 守卫拒绝写其它编排记录. 不规划下一步, 不提交, 不推送. -- 插件命令只用 `status`, `template`, `ref`, `check <文件>` 与 `evidence`, 都以 `project-navigator` 开头. 不运行改变编排状态的命令 (例如 `order`, `stage`). -- 你在仓库根目录工作, 不使用 worktree. - -## 执行流程 - -1. 对账: 运行 `git status` 与 `git log -1`. 当前提交应等于工单概况中的 "基准提交", 工作区不应有与本工单无关的改动. 不一致时不动手, 写回执, 执行状态写 "受阻", 受阻类型写 "对账不符". -2. 核对假设: 逐条检查工单 "工作假设" 是否成立, 读代码与配置核实, 不凭记忆, 结果写进回执 "假设核对". 不成立的假设以实际为准, 按下方 "事实修正" 处理, 不因此停下. -3. 做计划: 先定模块划分, 写法见下方 "模块划分"; 再列出完成每条验收判据的步骤与验证方式. 计划不需要用户确认, 但要写进回执的 "模块划分" 与 "判据自检". -4. 实施: 只做工单 "工作范围" 中要做的事; 遵守工单 "执行守则" 与项目规范 `.claude/rules/engineering.md`. -5. 自检: 逐条核对验收判据, 运行代码检查与测试. 需要真实账号, 会产生费用或外部副作用的测试不要运行, 把命令写进回执 "测试记录" 的 "外部影响", 由用户决定. -6. 写回执: 运行 `project-navigator template receipt` 取得填写要求与骨架 (选型工单加 `--option 2`), 按要求填好后写入工单文件夹中的 `receipt.md`, 再运行 `project-navigator check <回执路径>` 自查结构. "回执概况" 的 "执行状态" 写 "完成" 或 "受阻". -7. 交回: 按委派提示词的 "交回格式" 结束. - -## 模块划分 - -回执 "模块划分" 一节写明代码结构, 验收子代理会对照实际改动核对: - -- 新增模块: 每个模块写路径与一句职责, 用分号隔开. 一个模块只承担一件事. -- 改动模块: 只列需要改动内部实现的已有模块, 每个写明原因. 新功能迫使多个已有模块改动内部实现时, 说明模块边界不合适, 先拆出新模块. -- 入口改动: 写入口文件改了什么. 入口只做读取配置, 组装与调用, 流程的步骤写在各自的模块中. - -没有的项写 "无". - -## 代码检查 - -- 会改动代码的工单, 验收判据中有一条 "代码检查通过", 写明检查命令. 自检时运行这些命令, 没有报错才算通过. -- 不放宽检查规则, 不加忽略标记来通过检查. 工单的工作范围包含配置检查工具时除外; 其它情况确实需要调整, 以受阻结束并说明原因. -- 选型工单涉及语言或技术栈时, 同时选定格式化与代码检查工具, 规则中打开函数长度与复杂度检查, 给出能在仓库根目录直接运行的检查命令. -- 检查范围只含项目代码: 用工具自身的排除配置排除 `.navigator/` 与 `.claude/`. 这两个目录中有编排记录与插件管理的配置, 被纳入检查后, 编排记录的格式会让以后每张工单的检查都通不过. - -## 项目配置 - -- 工单要求接线 Claude Code hook 时, 可以改 `.claude/settings.json` 的 `hooks` 字段与 `.claude/` 下项目自己的文件 (例如 `.claude/hooks/` 中的脚本). -- 不改 `.claude/settings.json` 的 `permissions` 字段中本技能的放行规则 (以 `project-navigator` 或 `.navigator/` 开头的规则), 不改 `.claude/rules/engineering.md`: 前者由 init 管理, 后者由编排会话的 `standards` 命令写入. 需要调整时写进回执的 "需要操作". - -## 事实修正 - -工单由编排会话在动手之前写成, 其中的细节只是当时的判断. 逆向, 对接外部系统这类任务, 很多事实做了才知道. 工单的内容因此分两层: - -- 硬约束: "审阅要点" 的任务目标, 需要决定, 明确不做, 以及 "工作范围". 这些是用户认可过的, 你不能改. -- 工作假设: 其余的细节, 包括 "工作假设" 一节, 以及 "执行守则" 与 "验收判据" 中写到的数据格式, 字段含义, 路径, 接口名, 具体实例与实现途径. - -工作假设与实际不符时, 以实际为准继续做, 不算受阻: - -- 先取得证据 (文件路径与行号, 命令输出, 取证记录, 或 `artifacts/` 下的脚本), 再按实际情况实现. 不为了符合工单而做出与事实不符的东西. -- 在回执 "事实修正" 表中每项修正写一行: "工单原文" 摘录原句并写明所在的节; "实际情况" 写查到的事实; "证据位置" 写能复核的位置; "处理办法" 写你改成了怎么做. 没有修正时只留表头. -- 判据引用了被修正的细节时, 按修正后的事实自检, "判据自检" 中写明 "按事实修正第 N 行". -- 修正之后任务目标仍要达成, 改动仍在工作范围之内. - -以下情况不属于事实修正, 仍按受阻处理: 按实际情况任务目标达不成; 要做 "明确不做" 的事或超出 "工作范围"; 有几种做法而取舍会影响用户 (例如许可, 费用, 对外行为); 工单 "需要决定" 中写的事. 拿不准时按受阻处理. - -## 受阻情形 - -遇到以下情况立即停下, 写回执, 执行状态写 "受阻", 不自行变通. 细节与实际不符不在其列, 见上方 "事实修正"; 另有两种例外, 见下方 "待定判据" 与 "等待操作": - -- 对账不符. -- 按实际情况, 任务目标达不成. -- 规格有歧义而几种理解会做出不同的结果, 或判据之间互相矛盾. -- 需要超出工作范围的改动, 例如改其它模块的公共接口. -- 需要用户操作或决定, 例如提供账号, 密钥, 安装软件, 或在几种做法中选一种. -- 按选型守则找不到满足需求的方案. - -回执 "偏差记录" 的 "受阻详情" 写受阻类型 (对账不符, 目标不可达, 规格问题, 范围过大, 需要用户操作, 需要用户决定), 规格原文, 实际情况与证据位置. 用户处理后, 编排会话可能让你接着做: 收到继续的消息时, 先运行 `git status` 核对工作区, 再从受阻处继续, 最后重写回执. - -## 待定判据 - -工单的其余部分不受影响, 只有个别判据照原文做不到时, 不以受阻结束: 把其余工作做完, 这几条判据留作待定, 由用户在验收时决定. 这样用户只决定一次, 不用整张工单往返一轮. - -- 与事实修正的界线: 判据换一个符合事实的细节就能满足时 (例如点名的实例不存在, 另一个实例带有同样的特征), 属于事实修正, 照做并记录. 判据要求的东西按事实根本做不出来, 或要用户在两种说法中选一种时, 才留作待定. -- 适用情形: 判据要求的特征在实际数据中不存在; 判据要用户在两种说法中选一种, 而选哪种都不改变已经完成的工作. -- 不适用: 用户的决定会改变其余部分的做法 (例如随包带哪种格式的文件); 任务目标达不成; 要改动工作范围之外的文件. 这些情况仍按受阻处理. -- 写法: 回执 "执行状态" 写 "完成"; "偏差记录" 的 "待定判据" 逐条写判据编号, 规格原文, 实际情况与证据位置, 以及可选的处理办法 (例如 "改用实例 X 核对" 或 "以单元测试代替"). "判据自检" 中这几条写 "待定". 没有待定判据时写 "无". -- 不替用户选: 不按自己选的办法改写判据后写 "通过". - -## 等待操作 - -工单没有问题, 只是要用户亲手做一件事才能继续时 (例如在被调试的程序中做一次操作, 连接一台设备, 登录一个账号), 不写回执, 中途交回: - -- 交回之前把断点, 监听等准备工作做好, 把已经查清的内容写进 `artifacts/` 下的文档: 会话中断后, 新的执行子代理靠这些文档接着做. -- 交回内容第一行写 "执行状态: 等待操作", 其后逐条写要用户做的操作: 写到用户不用追问就能照做, 写明做完后要告诉你什么. -- 编排会话转来用户的回复后, 从停下的地方继续. 需要再次操作时照此再交回一次. -- 全部做完后照常写回执. 回执 "偏差记录" 的 "需要操作" 写 "无", 中途请用户做过的操作写进 "实现决定". -- 要用户做决定, 或要修改工单时, 不用这种方式, 按受阻处理. - -## 技能问题 - -- 问题出在技能自身时 (插件命令输出 "技能异常" 一行, 本手册与骨架或自查命令对不上, 守卫拒绝了本手册允许的写入), 不绕开, 不修改技能文件. -- 在交回内容的末尾加一行 "技能问题: ...", 只写遇到的情况: 运行的命令或操作, 原始提示原样摘录. 不写原因推测与处理建议. 上报由编排会话完成, 你不运行 `report`. -- 不影响工单时照常完成; 无法继续时以受阻结束, 受阻类型写 "需要用户操作". - -## 取证记录 - -- 判据要靠 MCP 工具看到的结果来确认时 (例如抓包工具记下的请求), 在回执 "测试记录" 的 "取证记录" 写明: 工具的完整名称, 定位参数 (过滤条件或请求编号), 发生的时间段. 验收子代理按这些位置用同一工具重新查看. -- 验收结束之前不清空, 不覆盖这些记录, 例如不清空抓包列表. -- 没有用到 MCP 工具时写 "无". - -## 证据文件 - -- 工单要求留存, 或验收需要独立复核的产物 (例如抓包原文, 复现脚本, 样例输入输出), 写进本工单文件夹的 `artifacts/` 目录, 例如 `.navigator/orders/0007-export-csv/artifacts/`. 这些文件随本工单一起入库, 验收子代理按路径读取. -- 证据文件不是业务代码, 程序运行时不依赖它们. 业务需要的数据与测试夹具仍放在项目目录中. -- 运行其中的脚本时, 输出写到会话临时目录或标准输出, 不在 `artifacts/` 中生成新文件, 例如 Python 加 `-B`, 不生成 `__pycache__`. -- 写 `artifacts/` 下的文档被运行环境拒绝时 (提示不是守卫给出的, 例如要求子代理把结论作为文字交回), 不算受阻: 把该文档的路径与全文放进最终回复, 由编排会话原样写入; 回执 "改动清单" 照常列出这个文件, 注明 "由编排会话写入". 回执与脚本仍由你自己写. -- 在回执 "改动清单" 中列出每个证据文件; 判据自检引用证据文件时写出路径. 需要验收子代理运行的脚本, 写进回执 "测试记录" 的 "测试命令". - -## 回执结构 - - - -## 运行检查与分析工单 - -- 委派提示词的第 1 条规则写 "本工单只产出记录" 时 (运行检查工单与分析工单, 例如逆向与调研): 只写本工单的回执, 以及 `artifacts/` 下的文档与证据脚本. 守卫拒绝改动业务文件与项目配置. -- 回执 "模块划分" 三项都写 "无". -- 结论都要能追到证据: 文档中的每条结论写明依据的证据文件路径, 命令输出或取证记录. 证据脚本要能在仓库根目录直接运行, 运行命令写进回执 "测试记录" 的 "测试命令". -- 构建与运行程序时产生的文件 (例如构建目录) 不算改动, 但不要改动已入库的文件. 确实需要改代码时, 写进回执与 "需要操作", 由编排会话另发实现工单. - -## 选型工单 - -- 选型只回答一件事: 选定的库能不能实现工单列出的每条需求. 按项目规范 `.claude/rules/engineering.md` 中 "技术选型" 一节的守则选择候选. -- 回执 "需求核对" 表每条需求一行, 结论只写 "能" 或 "不能". "依据" 列写源码证据的位置 (例如 "能力核实第 2 行"), 或测试文件路径与运行命令; 结论为不能时写原因. 回执其它内容不需要附证据. -- 先看源码. 源码看不出来时 (例如行为, 兼容性或几个库能否配合), 写临时测试实际运行: 测试文件放在本工单的 `artifacts/` 下, 运行命令写进 "依据", 验收子代理会重新运行. 测试的依赖装在会话临时目录, 不装进项目环境. -- 只有文档或记忆作依据的结论视为未核实, 验收时判为不通过. -- 回执 "能力核实" 是源码证据表, 每行是 "需求核对" 引用的一段源码; 全部用测试核实时只留表头. 仓库, 版本与路径三列写在行内代码中, 格式化工具不会改写其中的下划线. 各列写法: - - 仓库: 公开 git 仓库的 https 地址, 例如 `https://github.com/encode/httpx`. - - 版本: 仓库中的 tag, 分支或完整提交号, 按仓库实际的写法, 例如有的仓库 tag 带 `v`. - - 路径: 仓库中的文件路径, 不是安装后包里的路径; 例如源码放在 `src/` 下的仓库要带上 `src/`. - - 行号: `N` 或 `N-M`, 只引用证明该能力的那一段; 范围过长时自查命令会提示拆成多行. - - 说明: 这几行代码证明了什么. -- 每行证据写好后运行 `project-navigator evidence check <仓库> <版本> <路径> <行号>` 自查: 命令从原仓库取回这几行, 输出的源码要能支持 "说明". 写入回执后, 再运行 `project-navigator evidence` 核对整张表. 验收子代理会用同一条命令从原仓库重新取源码核对. -- 没有公开 git 仓库的依赖拿不出源码证据, 改用临时测试核实. -- 只给出结论与理由, 由用户决定是否采用; 采用之前不在项目中安装依赖, 不写实现代码. 取证需要的源码用上面的命令查看, 或下载到会话临时目录. - -## 写作要求 - -- 用简体中文, 朴素直白: 不自造简称, 不打比方, 不用没有名词的 "它", "这", "其". -- 路径, 命令与标识符原样写出, 放在行内代码中. - -## 标点与排版 - - diff --git a/scripts/navigator-docs/writing.md b/scripts/navigator-docs/writing.md deleted file mode 100644 index 95874af..0000000 --- a/scripts/navigator-docs/writing.md +++ /dev/null @@ -1,39 +0,0 @@ -# 写作规则 - -编排会话写的每一条回复与每一份记录文件都按本文件写. 这些规则是写作建议: 回复结束时 hook 只校验结构 (标题, 结论行, 选项块), 不按写作规则打回; 体检命令 `check` 同时报告 "问题" 与 "提示". - -## 结论先行 - -- 每条回复标题下方第一行是 "结论: ...": 先说结果, 再说要用户做的选择, 尽量短. 读者注意力有限, 只读这一行也要能做决定. -- 细节放在结论之后, 读者可以跳过; 选项块放在最后. - -## 朴素中文 - -- 一句话只说一件事; 不铺垫, 不复述, 不写空话套话. -- 不自造简称: 名词第一次出现时写全称; 新术语先写进术语表 `.navigator/plan/glossary.md`. -- 不打比方, 不用黑话: 直接写事实, 数字与文件路径. -- 不用没有名词的指代: 写 "工单 0007", 不写 "它"; 写 "这个切片", 不写 "这". -- 例子要完整: 写出输入, 操作与结果. -- 一处只讲一个焦点: 一节一个主题, 一段一个意思. -- 不依赖读者记忆: 需要前文信息时, 重写关键内容或给出文件路径. - -## 面向注意力障碍与强迫倾向的读者 - -- 结构固定: 标题, 结论行, 进展行与选项块的位置每次都一样, 读者不用重新寻找. -- 信息分块: 段落, 列表与嵌套都有上限, 见下方规则表. -- 对齐整齐: 编号与计数按固定位数补零, 同一组选项字数一致, 表格源码按显示宽度对齐. -- 下一步明确: 需要用户选择的回复都以选项块结尾, 选项之外不留开放问题; 不需要用户选择的回复 (例如 "工单执行") 不带选项块, 避免用户以为要先选择才会继续. - -## 规则表 - - - - - -## 禁用词 - - - -## 标点与排版 - - diff --git a/tests/project-navigator/cli.test.mjs b/tests/project-navigator/cli.test.mjs index f7abe59..f5b0d0f 100644 --- a/tests/project-navigator/cli.test.mjs +++ b/tests/project-navigator/cli.test.mjs @@ -1,6 +1,6 @@ /** - * @file 命令行测试: 进入, 初始化, 忽略规则检查, 从旧版本升级, 卸载, 参考文件, - * 回复骨架与自查. + * @file 命令行测试: 进入与初始化, 旧版本记录的归档, 忽略规则检查, 卸载, 参考文件, + * 项目设置, 路线与决策. */ import assert from "node:assert/strict"; @@ -8,341 +8,320 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { test } from "node:test"; +import { orchestratorMarker } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/orchestrator-marker.mjs"; import { - LEGACY_COMMAND_PATH, - LEGACY_HOOK_ENTRY_ARGUMENT, -} from "../../plugins/waypoint/skills/project-navigator/runtime/lib/paths.mjs"; -import { NAVIGATOR_PERMISSION_RULES } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/settings.mjs"; -import { - commandSkeleton, + DEFAULT_ROADMAP, + DEFAULT_SETTINGS, commitPaths, createTemporaryDirectory, createTemporaryRepository, initializeProject, runCommand, - runGit, - runHook, - writeTranscript, + startProject, + writeDraft, } from "./helpers.mjs"; /** - * 读取项目中的 JSON 文件. - * - * @param {string} root 项目根目录. - * @param {string} relative 项目相对路径. - * @returns {any} 解析后的内容. - */ -function readJson(root, relative) { - return JSON.parse(readFileSync(path.join(root, relative), "utf8")); -} - -/** - * 写入项目中的文件, 目录不存在时先创建. - * - * @param {string} root 项目根目录. - * @param {string} relative 项目相对路径. - * @param {string} content 文件内容. - * @returns {void} + * 1.x 写入的状态文件的样子. + * @type {Readonly>} */ -function writeProjectFile(root, relative, content) { - const file = path.join(root, relative); - mkdirSync(path.dirname(file), { recursive: true }); - writeFileSync(file, content, "utf8"); -} - -test("进入: 不在 Git 仓库中时提示先初始化仓库", (context) => { - const directory = createTemporaryDirectory(); - context.after(directory.cleanup); - const output = runCommand(["enter"], directory.root); - assert.equal(output.status, 0); - assert.match(output.stdout, /当前目录不是 Git 仓库/u); - assert.match(output.stdout, /repo-init/u); +const LEGACY_STATE = Object.freeze({ + schema: 5, + skillVersion: "1.5.0", + init: { status: "active" }, }); -test("进入与初始化: 带编排标记, 初始化写入状态目录与放行规则, 之后回复首次接入", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { root } = repository; - const before = runCommand(["enter"], root).stdout; - assert.match(before, /- 编排标记: project-navigator 编排标记 [0-9a-f]{12}/u); - assert.match(before, /用户选 A 后运行 init/u); - const init = runCommand(["init"], root).stdout; - assert.match(init, /设置结果: 已写入/u); - assert.match(init, /调用 waypoint:commit-message 提交 "初始化"/u); - for (const file of [".claude/settings.json", ".claude/settings.local.json"]) { - const allow = readJson(root, file).permissions.allow; - for (const rule of NAVIGATOR_PERMISSION_RULES) { - assert.ok(allow.includes(rule), `${file} 缺少 ${rule}`); - } - assert.equal(readJson(root, file).hooks, undefined, "不写项目级 hook"); +test("命令行: 不在 Git 仓库中时 enter 以退出码 0 说明原因", () => { + const { root, cleanup } = createTemporaryDirectory(); + try { + const result = runCommand(["enter"], root); + assert.equal(result.status, 0); + assert.match(result.stdout, /不是 Git 仓库/u); + assert.match(runCommand(["init"], root).stdout, /设置结果: 未执行/u); + } finally { + cleanup(); } - assert.ok(existsSync(path.join(root, ".navigator", "plan", "glossary.md"))); - assert.ok(!existsSync(path.join(root, ".navigator", "bin")), "不复制脚本"); - const status = runCommand(["status"], root).stdout; - assert.doesNotMatch(status, /编排标记/u, "status 不输出编排标记"); - assert.match(status, /- 下一动作: 回复 "首次接入" \(reply entry\)/u); }); -test("进入: 已有进度时, 调用技能先回复恢复进度; 会话中途的 status 给出实际的下一步", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { project } = initializeProject(repository.root); - project(["stage", "1"]); - assert.match( - runCommand(["enter"], repository.root).stdout, - /回复 "恢复进度" \(reply resume\)/u, - ); - assert.match(project(["status"]).stdout, /继续阶段 1 的步骤 start/u); +test("命令行: 未初始化时 enter 带编排标记并询问初始化, status 不带标记", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + const entered = runCommand(["enter"], root).stdout; + assert.ok(entered.includes(orchestratorMarker(root))); + assert.match(entered, /询问用户是否在本项目初始化/u); + assert.ok( + !runCommand(["status"], root).stdout.includes(orchestratorMarker(root)), + ); + assert.equal(runCommand(["setup"], root).status, 1); + assert.equal(runCommand(["unknown"], root).status, 1); + assert.equal(runCommand(["enter", "--bad-option"], root).status, 0); + } finally { + cleanup(); + } }); -test("初始化: 项目的忽略规则漏掉状态目录时不做任何改动", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { root } = repository; - writeProjectFile(root, ".gitignore", ".navigator/\n"); - commitPaths(root, [".gitignore"], "ignore"); - const init = runCommand(["init"], root).stdout; - assert.match(init, /设置结果: 未执行/u); - assert.match(init, /\.gitignore:1: \.navigator\//u); - assert.ok(!existsSync(path.join(root, ".navigator", "state.json"))); +test("命令行: init 写入状态目录与放行规则, 不复制脚本, 重复运行不出错", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + initializeProject(root); + for (const file of [ + ".navigator/state.json", + ".navigator/roadmap.md", + ".navigator/.gitignore", + ".claude/settings.json", + ".claude/settings.local.json", + ]) { + assert.ok(existsSync(path.join(root, file)), file); + } + assert.ok(!existsSync(path.join(root, ".navigator/bin"))); + const settings = JSON.parse( + readFileSync(path.join(root, ".claude/settings.json"), "utf8"), + ); + assert.ok(settings.permissions.allow.includes("Bash(project-navigator *)")); + assert.equal(settings.hooks, undefined); + assert.match(runCommand(["init"], root).stdout, /设置结果: 已写入/u); + assert.match( + runCommand(["init", "--archive"], root).stdout, + /不需要 --archive/u, + ); + const status = runCommand(["status"], root).stdout; + assert.match(status, /当前进展: 阶段 立项/u); + assert.match(status, /ref intake/u); + } finally { + cleanup(); + } }); -test("升级: 清理旧版本的 hook, 放行规则, 脚本副本, 会话登记与快照, 撤回等回执的工单", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { root } = repository; - const userHook = { - matcher: "Write", - hooks: [{ type: "command", command: "npx prettier --write" }], - }; - writeProjectFile( - root, - ".claude/settings.json", - JSON.stringify({ - permissions: { - allow: ["Bash(npm test)", `Bash(node ${LEGACY_COMMAND_PATH} *)`], - }, - hooks: { - PreToolUse: [ - { - matcher: "*", - hooks: [ - { - type: "command", - command: "node", - args: [LEGACY_HOOK_ENTRY_ARGUMENT], - }, - ], - }, - userHook, - ], - }, - }), - ); - writeProjectFile(root, ".navigator/bin/runtime/hook.mjs", "// old\n"); - writeProjectFile(root, ".navigator/guide/executor.md", "# 旧手册\n"); - writeProjectFile( - root, - ".navigator/state.json", - JSON.stringify({ - schema: 4, - revision: 3, - skillVersion: "0.11.0", - init: { status: "active", initializedAt: "2026-09-01T00:00:00.000Z" }, - stage: 5, - step: "start", - milestone: null, - slice: null, - order: { - id: "0002", - slug: "export", - folder: "0002-export", - kind: "implementation", - slice: null, - status: "issued", - round: 1, - baseCommit: "a".repeat(40), - authorizedTests: ["npm test"], - }, - milestones: [], - slices: [], - orders: [], - risks: [], - decisions: [], - changes: [], - codeChecks: { commands: ["npm run lint"], reason: null }, - evidenceTools: ["mcp__tool"], - lastCommit: "a".repeat(40), - stageCommit: null, - lastAction: "工单 0002 转为 issued", - updatedAt: "2026-09-01T00:00:00.000Z", - skipped: [], - next: { - order: 3, - milestone: 1, - slice: 1, - decision: 1, - change: 1, - risk: 1, - checkup: 1, - }, - }), - ); - const gitDirectory = path.join(root, ".git", "navigator"); - writeProjectFile(root, ".git/navigator/orchestrator.json", "{}"); - writeProjectFile(root, ".git/navigator/prompts.json", "[]"); - runGit(root, ["update-ref", "refs/navigator/snapshots", "HEAD"]); - const enter = runCommand(["enter"], root).stdout; - assert.match(enter, /由旧版本 0\.11\.0 初始化, 需要升级/u); - assert.match(enter, /用户选 A 后运行 init/u); - assert.ok( - !existsSync(path.join(gitDirectory, "orchestrator.json")), - "进入时删掉旧的编排登记, 旧 hook 不再拦下升级命令", - ); - const init = runCommand(["init"], root).stdout; - assert.match(init, /设置结果: 已升级/u); - assert.match(init, /清理旧版本: \.navigator\/bin\/, \.navigator\/guide\//u); - const settings = readJson(root, ".claude/settings.json"); - assert.deepEqual(settings.hooks, { PreToolUse: [userHook] }); - assert.ok(settings.permissions.allow.includes("Bash(npm test)")); - assert.ok( - !settings.permissions.allow.includes(`Bash(node ${LEGACY_COMMAND_PATH} *)`), - ); - assert.ok(!existsSync(path.join(root, ".navigator", "bin"))); - assert.ok(!existsSync(path.join(gitDirectory, "prompts.json"))); - assert.throws(() => - runGit(root, ["rev-parse", "--verify", "refs/navigator/snapshots"]), - ); - const state = readJson(root, ".navigator/state.json"); - assert.equal(state.order.status, "drafting"); - assert.equal(state.order.authorizedTests, undefined); - for (const key of ["lastCommit", "stageCommit", "evidenceTools"]) { - assert.ok(!(key in state), key); +test("命令行: 旧版本的记录不读取, 用户同意后归档再初始化", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + mkdirSync(path.join(root, ".navigator/plan"), { recursive: true }); + writeFileSync( + path.join(root, ".navigator/state.json"), + JSON.stringify(LEGACY_STATE), + "utf8", + ); + writeFileSync(path.join(root, ".navigator/plan/brief.md"), "旧\n", "utf8"); + assert.match(runCommand(["enter"], root).stdout, /init --archive/u); + assert.match(runCommand(["status"], root).stdout, /由旧版本 1\.5\.0 写入/u); + assert.equal(runCommand(["setup"], root).status, 1); + assert.match(runCommand(["init"], root).stdout, /设置结果: 未执行/u); + const archived = runCommand(["init", "--archive"], root).stdout; + assert.match(archived, /旧记录已改名为 \.navigator-1\.x\//u); + assert.match(archived, /以及 \.navigator-1\.x/u); + assert.equal( + readFileSync(path.join(root, ".navigator-1.x/plan/brief.md"), "utf8"), + "旧\n", + ); + assert.match(runCommand(["status"], root).stdout, /阶段 立项/u); + } finally { + cleanup(); } - assert.match(runCommand(["status"], root).stdout, /已初始化/u); }); -test("升级: 旧版本中提交中的工单读取时视为已通过并结束", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { root, project } = { - ...repository, - ...initializeProject(repository.root), - }; - const state = readJson(root, ".navigator/state.json"); - writeProjectFile( - root, - ".navigator/state.json", - JSON.stringify({ - ...state, - order: { - id: "0001", - slug: "demo", - folder: "0001-demo", - kind: "implementation", - slice: null, - status: "committing", - round: 1, - baseCommit: "a".repeat(40), - }, - }), - ); - const status = project(["status"]).stdout; - assert.doesNotMatch(status, /工单状态/u); - assert.match(status, /工单 无 \(上一张 0001 已结束, accepted\)/u); +test("命令行: 编排记录会被项目的忽略规则漏掉时, init 与 enter 都停下", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + writeFileSync(path.join(root, ".gitignore"), ".claude/\n", "utf8"); + const result = runCommand(["init"], root).stdout; + assert.match(result, /设置结果: 未执行/u); + assert.match(result, /\.claude\//u); + assert.ok(!existsSync(path.join(root, ".navigator/state.json"))); + writeFileSync(path.join(root, ".gitignore"), "", "utf8"); + initializeProject(root); + writeFileSync( + path.join(root, ".gitignore"), + "findings/\nslices/\n", + "utf8", + ); + assert.match(runCommand(["status"], root).stdout, /下一动作: 立项/u); + writeFileSync(path.join(root, ".gitignore"), ".claude/rules/\n", "utf8"); + assert.match( + runCommand(["status"], root).stdout, + /下一动作: 告诉用户编排记录或项目配置会被 Git 忽略/u, + ); + } finally { + cleanup(); + } }); -test("卸载: 移除放行规则, 项目标为已卸载后插件的 hook 不做任何事", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { root } = repository; - initializeProject(root); - const transcript = writeTranscript(root, true); - context.after(transcript.cleanup); - const write = { - session_id: "s", - transcript_path: transcript.path, - hook_event_name: "PreToolUse", - tool_name: "Write", - tool_input: { file_path: path.join(root, "src", "app.js"), content: "" }, - }; - assert.equal( - runHook(write, root).output?.hookSpecificOutput?.permissionDecision, - "deny", - ); - assert.match(runCommand(["uninstall"], root).stdout, /已移除放行规则/u); - assert.equal( - readJson(root, ".claude/settings.local.json").permissions, - undefined, - ); - assert.equal(runHook(write, root).output, undefined); - assert.match(runCommand(["enter"], root).stdout, /初始标记: 已卸载/u); +test("命令行: 卸载移除放行规则并让 hook 停用, 记录保留, 重新 init 恢复", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + initializeProject(root); + assert.match(runCommand(["uninstall"], root).stdout, /已移除放行规则/u); + const settings = JSON.parse( + readFileSync(path.join(root, ".claude/settings.json"), "utf8"), + ); + assert.equal(settings.permissions, undefined); + assert.match(runCommand(["status"], root).stdout, /初始标记: 已卸载/u); + assert.equal(runCommand(["setup"], root).status, 1); + assert.match(runCommand(["init"], root).stdout, /已写入/u); + assert.match(runCommand(["status"], root).stdout, /已初始化/u); + } finally { + cleanup(); + } }); -test("参考文件: ref 输出执行手册与参考文件, 未知名称列出可用的名称", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - assert.match( - runCommand(["ref", "executor"], repository.root).stdout, - /^# 执行手册/u, - ); - assert.match( - runCommand(["ref", "order-loop"], repository.root).stdout, - /工单粒度/u, - ); - const unknown = runCommand(["ref", "nothing"], repository.root); - assert.equal(unknown.status, 1); - assert.match(unknown.stdout, /order-loop/u); +test("命令行: ref 输出参考文件, 实现手册附标点规则, 名称不存在时列出可用名称", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + for (const name of ["intake", "slice-loop", "change", "standards"]) { + const result = runCommand(["ref", name], root); + assert.equal(result.status, 0, name); + assert.match(result.stdout, /^# /u, name); + } + const guide = runCommand(["ref", "executor"], root).stdout; + assert.match(guide, /^# 实现手册/u); + assert.match(guide, /# 标点与排版规则/u); + const missing = runCommand(["ref", "nothing"], root); + assert.equal(missing.status, 1); + assert.match(missing.stdout, /intake, slice-loop/u); + } finally { + cleanup(); + } }); -test("回复骨架: 标题下方是结论行与进展行, 结尾是选项块, 没有人类总结块", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - initializeProject(repository.root); - const skeleton = commandSkeleton( - runCommand(["reply", "entry"], repository.root).stdout, - ); - assert.match( - skeleton, - /^# 首次接入\n\n结论: <待填写>\n\n阶段 无 · 里程 无 · 切片 无 · 工单 无\n/u, - ); - assert.match(skeleton, /```\n\[如何开始\][\s\S]*```\n?$/u); - assert.doesNotMatch(skeleton, /<- \+/u); +test("命令行: setup 按工程文件给出建议, 登记后按组织方式写入项目规范", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + const { project } = initializeProject(root); + assert.match(project(["setup"]).stdout, /组织建议: directories/u); + writeFileSync(path.join(root, "app.vcxproj"), "", "utf8"); + commitPaths(root, ["app.vcxproj"], "vs"); + assert.match( + project(["setup"]).stdout, + /组织建议: filters .*app\.vcxproj/u, + ); + const invalid = project([ + "setup", + "--from", + writeDraft(root, "bad.json", { + organization: "filters", + verification: { mode: "auto" }, + }), + ]); + assert.equal(invalid.status, 1); + assert.match(invalid.stdout, /至少写一条/u); + const rulesFile = path.join(root, ".claude/rules/engineering.md"); + const setup = (organization) => + project([ + "setup", + "--from", + writeDraft(root, "setup.json", { + organization, + verification: { mode: "manual" }, + conventions: ["注释用简体中文"], + }), + ]); + assert.equal(setup("filters").status, 0); + const filters = readFileSync(rulesFile, "utf8"); + assert.match(filters, /虚拟筛选器/u); + assert.doesNotMatch(filters, /一个模块一个目录|begin:|end:/u); + assert.match(filters, /## 项目约定\n\n- 注释用简体中文/u); + assert.equal(setup("directories").status, 0); + const directories = readFileSync(rulesFile, "utf8"); + assert.match(directories, /一个模块一个目录/u); + assert.doesNotMatch(directories, /虚拟筛选器|begin:|end:/u); + assert.match(project(["status"]).stdout, /验证方式为用户手动运行/u); + } finally { + cleanup(); + } }); -test("填写要求: 两个选项的组给出按钮文字, 其它组与没有选项的回复不给", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - initializeProject(repository.root); - const guide = (/** @type {string[]} */ ...args) => - runCommand(["reply", ...args], repository.root).stdout; - assert.match( - guide("entry"), - /- 按钮: .*mcp__ccd_turn__wrap_up.*"新建项目", "已有项目".*选项块照常保留/u, - ); - assert.doesNotMatch(guide("research"), /- 按钮: /u); - assert.doesNotMatch(guide("brainstorm"), /- 按钮: /u); - assert.match(guide("brainstorm", "--option", "2"), /"开始收敛", "继续发散"/u); - assert.doesNotMatch(guide("running"), /- 按钮: /u); +test("命令行: 路线要在需求与项目设置之后, 第一个切片必须是改代码的骨架", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + const { project } = initializeProject(root); + const roadmap = (draft) => + project(["roadmap", "--from", writeDraft(root, "roadmap.json", draft)]); + assert.match(roadmap(DEFAULT_ROADMAP).stdout, /还没有需求文件/u); + writeFileSync( + path.join(root, ".navigator/requirements.md"), + "# 需求\n", + "utf8", + ); + assert.match(project(["status"]).stdout, /登记项目设置/u); + assert.match(roadmap(DEFAULT_ROADMAP).stdout, /请先运行 setup/u); + project([ + "setup", + "--from", + writeDraft(root, "setup.json", DEFAULT_SETTINGS), + ]); + assert.match(project(["status"]).stdout, /写路线/u); + assert.match( + roadmap({ + milestones: [ + { + name: "甲", + goal: "乙", + slices: [{ name: "查", kind: "findings" }], + }, + ], + }).stdout, + /最小骨架, 种类必须是 build/u, + ); + assert.match( + roadmap({ + milestones: [ + { + name: "甲", + goal: "乙", + slices: [{ name: "做", interactive: true }], + }, + ], + }).stdout, + /不能是交互式/u, + ); + assert.match( + roadmap({ milestones: [{ name: "甲", goal: "乙" }] }).stdout, + /排出它的全部切片/u, + ); + const written = roadmap(DEFAULT_ROADMAP); + assert.equal(written.status, 0, written.stdout); + assert.match(written.stdout, /切片 0001: 最小骨架 \(最小骨架\)/u); + assert.match(written.stdout, /提交步骤: .*立项完成/u); + const view = readFileSync(path.join(root, ".navigator/roadmap.md"), "utf8"); + assert.match(view, /## 里程 0001: 能导出 \(进行中\)/u); + assert.match(view, /- \[ \] 0003 查清格式 \(只产出结论, 交互式\)/u); + assert.match(view, /切片到这个里程开始时再排/u); + } finally { + cleanup(); + } }); -test("自查: check 带文件时只报告问题, 不分配体检编号, 不写状态", (context) => { - const repository = createTemporaryRepository(); - context.after(repository.cleanup); - const { root } = repository; - const { project } = initializeProject(root); - writeProjectFile( - root, - ".navigator/plan/brief.md", - "# 项目简报\n\n一段文字.\n", - ); - const before = readFileSync(path.join(root, ".navigator/state.json"), "utf8"); - const output = project(["check", ".navigator/plan/brief.md"]).stdout; - assert.doesNotMatch(output, /体检编号/u); - assert.match(output, /结构不符/u); - assert.equal( - readFileSync(path.join(root, ".navigator/state.json"), "utf8"), - before, - ); - assert.match(project(["check"]).stdout, /体检编号: 0001/u); +test("命令行: decision add 登记决策并追加条目, 可以取代以前的决策", () => { + const { root, cleanup } = createTemporaryRepository(); + try { + const { project } = startProject(root); + const add = (draft) => + project(["decision", "add", "--from", writeDraft(root, "d.json", draft)]); + const first = { + title: "用 CSV", + decision: "导出格式用 CSV", + reason: "用户要用表格软件打开", + rejected: "JSON", + }; + assert.equal(add(first).status, 0); + assert.equal( + add({ ...first, title: "改用 TSV", supersedes: "0001" }).status, + 0, + ); + assert.match(add({ ...first, supersedes: "0009" }).stdout, /不存在/u); + assert.match(add({ title: "缺字段" }).stdout, /不能为空/u); + const text = readFileSync( + path.join(root, ".navigator/decisions.md"), + "utf8", + ); + assert.match(text, /^# 决策记录/u); + assert.match(text, /## 决策 0001: 用 CSV\n\n- 日期: \d{4}-\d{2}-\d{2}/u); + assert.match(text, /## 决策 0002: 改用 TSV[\s\S]*- 取代: 决策 0001/u); + const state = JSON.parse( + readFileSync(path.join(root, ".navigator/state.json"), "utf8"), + ); + assert.equal(state.decisions[0].supersededBy, "0002"); + assert.match(project(["decision"]).stdout, /decision add --from <草稿>/u); + } finally { + cleanup(); + } }); diff --git a/tests/project-navigator/code-checks.test.mjs b/tests/project-navigator/code-checks.test.mjs deleted file mode 100644 index 8e9d523..0000000 --- a/tests/project-navigator/code-checks.test.mjs +++ /dev/null @@ -1,183 +0,0 @@ -/** - * @file 代码检查测试: 登记, 固定判据, 新建与发布前核对, 要运行的检查命令与骨架预填. - */ - -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - CRITERIA_SECTION, - codeCheckCriterion, - creationBlocker, - issueBlocker, - orderCheckCommands, - orderSkeletonPrefills, - registerCodeChecks, -} from "../../plugins/waypoint/skills/project-navigator/runtime/lib/code-checks.mjs"; -import { loadSpec } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs"; -import { createInitialState } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs"; -import { WorkflowError } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/workflow-error.mjs"; -import { createOrder } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/workflow-orders.mjs"; - -/** - * 测试用的时间. - * @type {string} - */ -const NOW = "2026-09-23T00:00:00.000Z"; - -/** - * 测试用的检查命令. - * @type {readonly string[]} - */ -const COMMANDS = Object.freeze(["ruff check .", "ruff format --check ."]); - -/** - * 建立带一张当前工单的状态. - * - * @param {string} kind 工单类型. - * @param {string[] | undefined} commands 已登记的检查命令; undefined 表示尚未登记. - * @returns {import("../../plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs").NavigatorState} 状态. - */ -function stateWithOrder(kind, commands) { - const initial = createInitialState({ skillVersion: "0.1.0", now: NOW }); - const registered = - commands === undefined - ? initial - : registerCodeChecks(initial, { commands, reason: "不适用" }); - return createOrder(registered, { - kind, - slug: "demo", - slice: undefined, - baseCommit: "a".repeat(40), - }); -} - -/** - * 生成只含验收判据一节的工单文字. - * - * @param {string[]} criteria 判据各行. - * @returns {string} 工单文字. - */ -function orderText(criteria) { - return ["# 执行工单", "", `## ${CRITERIA_SECTION}`, "", ...criteria, ""].join( - "\n", - ); -} - -test("代码检查: 判据节的标题与规格中的工单节一致", () => { - const titles = loadSpec().files.order.sections.map( - (section) => section.title, - ); - assert.ok(titles.includes(CRITERIA_SECTION)); -}); - -test("代码检查: 登记时去掉空白与重复, 有命令时不保留原因", () => { - const state = registerCodeChecks( - stateWithOrder("implementation", undefined), - { - commands: [" ruff check . ", "ruff check .", ""], - reason: "多余", - }, - ); - assert.deepEqual(state.codeChecks, { - commands: ["ruff check ."], - reason: null, - }); -}); - -test("代码检查: 没有命令时必须写原因", () => { - const state = stateWithOrder("implementation", undefined); - assert.throws( - () => registerCodeChecks(state, { commands: [], reason: " " }), - WorkflowError, - ); - assert.deepEqual( - registerCodeChecks(state, { commands: [], reason: "纯文档项目" }) - .codeChecks, - { commands: [], reason: "纯文档项目" }, - ); -}); - -test("代码检查: 选型工单不需要检查判据", () => { - assert.equal( - issueBlocker(stateWithOrder("selection", undefined), ""), - undefined, - ); -}); - -test("代码检查: 实现工单在登记检查命令之前不能发布", () => { - const blocker = issueBlocker(stateWithOrder("implementation", undefined), ""); - assert.match(blocker ?? "", /codecheck set/u); -}); - -test("代码检查: 会改动代码的工单在登记检查命令之前不能新建", () => { - const initial = createInitialState({ skillVersion: "0.1.0", now: NOW }); - for (const kind of ["implementation", "fix"]) { - assert.match(creationBlocker(initial, kind) ?? "", /codecheck set/u); - } - for (const kind of ["selection", "runcheck"]) { - assert.equal(creationBlocker(initial, kind), undefined); - } - const declined = registerCodeChecks(initial, { - commands: [], - reason: "纯文档项目", - }); - assert.equal(creationBlocker(declined, "fix"), undefined); -}); - -test("代码检查: 登记为没有命令时, 实现工单可以发布", () => { - assert.equal(issueBlocker(stateWithOrder("fix", []), ""), undefined); -}); - -test("代码检查: 验收判据中缺少检查判据时不能发布", () => { - const state = stateWithOrder("implementation", [...COMMANDS]); - const criterion = codeCheckCriterion(COMMANDS); - assert.match( - issueBlocker(state, orderText(["1. 页面能打开."])) ?? "", - /缺少代码检查判据/u, - ); - assert.equal( - issueBlocker(state, orderText([`1. ${criterion}`, "2. 页面能打开."])), - undefined, - ); - const elsewhere = [ - "# 执行工单", - "", - "## 执行守则", - "", - criterion, - "", - `## ${CRITERIA_SECTION}`, - "", - "1. 页面能打开.", - ].join("\n"); - assert.match( - issueBlocker(state, elsewhere) ?? "", - /缺少代码检查判据/u, - "判据写在其它节不算", - ); -}); - -test("代码检查: 只有会改动代码的工单要运行检查命令", () => { - assert.deepEqual( - orderCheckCommands(stateWithOrder("implementation", [...COMMANDS])), - [...COMMANDS], - ); - assert.deepEqual( - orderCheckCommands(stateWithOrder("selection", [...COMMANDS])), - [], - ); - assert.deepEqual(orderCheckCommands(stateWithOrder("fix", undefined)), []); -}); - -test("代码检查: 实现工单的骨架预填检查判据, 选型工单不预填", () => { - assert.deepEqual( - orderSkeletonPrefills(stateWithOrder("implementation", [...COMMANDS])), - { [CRITERIA_SECTION]: [`1. ${codeCheckCriterion(COMMANDS)}`] }, - ); - assert.deepEqual( - orderSkeletonPrefills(stateWithOrder("selection", [...COMMANDS])), - {}, - ); - assert.deepEqual(orderSkeletonPrefills(undefined), {}); -}); diff --git a/tests/project-navigator/docs.test.mjs b/tests/project-navigator/docs.test.mjs index e750547..8e002e1 100644 --- a/tests/project-navigator/docs.test.mjs +++ b/tests/project-navigator/docs.test.mjs @@ -1,84 +1,111 @@ /** - * @file 文档检查: 生成文档与规格同步, SKILL.md 与规格一致且不超过篇幅上限. + * @file 规则文件测试: SKILL.md 的路由表与命令速查对得上实际的文件与命令, 工程规范 + * 的两种写法都能生成, 子代理定义与守卫中的名称一致. */ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { loadSpec } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/spec.mjs"; +import { + COMMAND_HANDLERS, + ENTER_COMMAND, +} from "../../plugins/waypoint/skills/project-navigator/runtime/commands/handlers.mjs"; +import { SUBAGENT_TYPES } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/guard.mjs"; +import { renderEngineeringRules } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/project-settings.mjs"; +import { ORGANIZATIONS } from "../../plugins/waypoint/skills/project-navigator/runtime/lib/state.mjs"; import { SKILL_DIRECTORY } from "./helpers.mjs"; /** - * 仓库根目录. - * @type {string} - */ -const REPO_ROOT = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", - "..", -); - -/** - * SKILL.md 的 token 上限 (官方建议技能主文件保持在 5000 token 以内). + * SKILL.md 的篇幅上限 (行). * @type {number} */ -const SKILL_TOKEN_LIMIT = 5000; +const SKILL_LINE_LIMIT = 150; /** - * 估算 token 时, 非汉字字符每多少个约合一个 token. - * @type {number} + * 插件名与子代理名之间的分隔符. + * @type {string} */ -const CHARACTERS_PER_TOKEN = 4; +const PLUGIN_SEPARATOR = ":"; /** - * 粗略估算文本的 token 数: 每个汉字按 1 个计算, 其余字符每 4 个按 1 个计算. - * 这是偏保守的近似, 只用于防止 SKILL.md 失控增长. + * 读取技能目录中的文件. * - * @param {string} text 文本. - * @returns {number} 估算的 token 数. + * @param {...string} segments 相对于技能目录的路径段. + * @returns {string} 文件全文. */ -function estimateTokens(text) { - const han = text.match(/\p{Script=Han}/gu)?.length ?? 0; - return han + Math.ceil(([...text].length - han) / CHARACTERS_PER_TOKEN); +function readSkillFile(...segments) { + return readFileSync(path.join(SKILL_DIRECTORY, ...segments), "utf8"); } -test("文档: 生成的参考文档与执行手册和规格同步", () => { - const result = spawnSync( - process.execPath, - [path.join(REPO_ROOT, "scripts", "gen-navigator-docs.mjs"), "--check"], - { cwd: REPO_ROOT, encoding: "utf8" }, +test("规则: SKILL.md 篇幅受控, 路由表引用的参考文件都存在", () => { + const skill = readSkillFile("SKILL.md"); + assert.ok(skill.split("\n").length <= SKILL_LINE_LIMIT); + const routed = [...skill.matchAll(/\| `([a-z-]+)`\s+\|$/gmu)].map( + (match) => match[1], ); - assert.equal(result.status, 0, result.stderr); + assert.deepEqual(routed, ["intake", "slice-loop", "change"]); + for (const name of routed) { + assert.ok( + existsSync(path.join(SKILL_DIRECTORY, "references", `${name}.md`)), + name, + ); + } }); -test("文档: SKILL.md 写明的结论行前缀与规格一致", () => { - const text = readFileSync(path.join(SKILL_DIRECTORY, "SKILL.md"), "utf8"); - assert.ok(text.includes(`"${loadSpec().format.conclusionLabel}:"`)); +test("规则: 命令速查覆盖每条命令, 没有写不存在的命令", () => { + const skill = readSkillFile("SKILL.md"); + const documented = new Set( + [...skill.matchAll(/^\| `([^`]+)`(?:, `([^`]+)`)?/gmu)].flatMap((match) => + [match[1], match[2]] + .filter((entry) => entry !== undefined) + .map((entry) => entry.split(" ")[0]), + ), + ); + for (const name of ["intake", "slice-loop", "change"]) { + documented.delete(name); + } + assert.deepEqual( + [...documented].sort(), + Object.keys(COMMAND_HANDLERS) + .filter((command) => command !== ENTER_COMMAND) + .sort(), + ); }); -test("文档: 路由表与命令速查引用的参考文件都存在", () => { - const text = readFileSync(path.join(SKILL_DIRECTORY, "SKILL.md"), "utf8"); - const names = [...text.matchAll(/\| `([a-z-]+)` +\|$/gmu)].map( - (match) => match[1], +test("规则: 工程规范的两种模块组织写法都能生成, 不留标记", () => { + const standards = readSkillFile("standards", "engineering.md"); + const rendered = Object.values(ORGANIZATIONS).map((organization) => + renderEngineeringRules({ standards, organization, conventions: [] }), ); - assert.ok(names.length > 0); - for (const name of names) { - assert.ok( - existsSync(path.join(SKILL_DIRECTORY, "references", `${name}.md`)), - `路由表中的 ${name} 没有对应的参考文件`, - ); + for (const text of rendered) { + assert.match(text, /^# 工程规范\n\n本文件由 project-navigator/u); + assert.match(text, /## 模块组织\n\n- /u); + assert.match(text, /单一数据源/u); + assert.match(text, /不建通用模块/u); + assert.doesNotMatch(text, /