@rowansenne/workflow-run 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  > 参考产物:{{开发方案路径,没有写「无」}}
6
6
  > DocNexus:{{沉淀后写 docnexus/library/<path>;未沉淀写「未沉淀」,未接入写「未接入」}}
7
7
 
8
- <!-- F8 / B5 交付时按此落盘到 requirement.mjs path delivery 给出的路径(workflow-run/docs/requirements/<需求名>/delivery.md)。小改动每节写一两行即可,不适用的节写「无」;验证结果必须如实 -->
8
+ <!-- F8 / B5 交付时按此落盘到 requirement.mjs path delivery 给出的路径(workflow-run/docs/requirements/<需求名>/<项目名>/delivery.md)。小改动每节写一两行即可,不适用的节写「无」;验证结果必须如实 -->
9
9
 
10
10
  ## 结果 *
11
11
 
@@ -22,9 +22,14 @@
22
22
 
23
23
  ## 验证 *
24
24
 
25
+ | 验收项 | 测试 / 操作 | 结果 |
26
+ |---|---|---|
27
+ | {{F1 / F3 的验收标准,每条一行;缺陷修复写「现象不再出现」}} | {{测试类.方法,或手动操作步骤}} | {{通过 / 失败 / 未验证(原因)}} |
28
+
25
29
  - 构建:{{命令}} → {{通过 / 失败 / 未运行(原因)}}
26
30
  - 测试:{{命令}} → {{通过 n 个 / 失败 / 未运行(原因)}};新增测试:{{列表}}
27
- - 未验证项:{{需要用户在什么环境怎么验证;没有写「无」}}
31
+ - 开工前已有的失败:{{Step 0 基线里已经失败的测试 / 编译错误,本次未处理;没有写「无」,没跑基线写原因}}
32
+ - 未验证项:{{表里「未验证」的验收项及其他:需要用户在什么环境怎么验证;没有写「无」}}
28
33
 
29
34
  ## 根因(如有)
30
35
 
@@ -45,7 +50,7 @@
45
50
 
46
51
  ## 范围外发现
47
52
 
48
- {{顺带看到但本次未处理的问题;无则写「无」}}
53
+ {{顺带看到但本次未处理的问题;开工前工作区里与本需求无关的改动(文件列表,未提交)及自审在其中发现的问题;无则写「无」}}
49
54
 
50
55
  ## 建议下一步
51
56
 
package/dev/skill.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "name": "dev",
3
3
  "order": 2,
4
4
  "requires": [
5
- "workflow"
5
+ "workflow",
6
+ "project-review"
6
7
  ],
7
8
  "optional": [
8
- "project-review",
9
9
  "database-query",
10
10
  "md-to-wiki",
11
11
  "create-tech-md-doc",
@@ -70,7 +70,7 @@ node <skillDir>/scripts/md-to-wiki.mjs <mdPath> workflow-run/tmp/md-to-wiki/_wik
70
70
  - `references/upload.md`:通道 A / B 的调用细节与验证
71
71
  - `references/troubleshooting.md`:转换脚本说明、XHTML 规则、校验命令、常见错误
72
72
 
73
- 典型串联:`create-tech-md-doc` / `create-release-md-doc` 生成 `workflow-run/docs/requirements/<需求名>/` 下的 md → 本 skill 发布。
73
+ 典型串联:`create-tech-md-doc` / `create-release-md-doc` 生成 `workflow-run/docs/requirements/<需求名>/<项目名>/` 下的 md → 本 skill 发布。
74
74
 
75
75
  ## 终端命令
76
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rowansenne/workflow-run",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "项目级 Agent Skills 一键初始化:workflow / dev / project-review / code-graph / database-query / md-to-wiki / 技术与上线方案生成",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -30,10 +30,11 @@ description: "Review source code (any language; Java/Spring, Go, TypeScript, Pyt
30
30
  3. 找有没有别处已经兜住:调用方校验、`@Validated` / 全局异常处理、数据库约束、框架默认行为、配置。涉及索引、字段、唯一约束、表大小的发现,以第 2 步拉到的实际表结构为准(复核列写「已与 <连接名> 核对」)。已兜住 → 删掉或降级,并在说明里写依据。
31
31
  4. 确认是本次改动引入的:改动前就存在的问题移到「未覆盖与待确认」,不计入合并结论。
32
32
  结果:「成立」在报告「复核」列标「✅」;「可能成立」标「⚠」;「不成立」从问题表里删掉(独立复核否掉的,在「未覆盖与待确认」里记一句「复核排除 N 条」)。复核方式(独立 / 自行)写进报告「审查方式」元信息。
33
- 7. **输出报告**:按报告模板输出(`node <skills>/workflow/scripts/resolve.mjs template project-review report.md` 取 `path`;退出码 2 时停下并按 `hint` 处理),落盘规则见 Artifact Location。
33
+ 7. **输出报告**(由 dev 自审调用时跳过第 7~9 步,问题表、影响面、未覆盖项交回 dev 统一交付,见 dev 的 F7):按报告模板输出(`node <skills>/workflow/scripts/resolve.mjs template project-review report.md` 取 `path`;退出码 2 时停下并按 `hint` 处理),落盘规则见 Artifact Location。
34
34
  8. **沉淀到 DocNexus(需用户授权)**:`resolve.mjs status` 的 `docnexus.ok` 为 true 时,交付报告后**每次都问**用户「要不要启动 DocNexus 流程,把本次审查沉淀到 DocNexus?」,并从本次发现里列出建议沉淀的内容:同类问题再次出现(知识库里已有类似沉淀,或本次多处出现)、新确立的编码约定、容易踩的坑;没有特别值得记的也照问,并说明这一点。未接入时不提问,报告的「DocNexus」元信息行写「未接入」。
35
35
  - 用户同意 → 按 DocNexus 入口 skill `docnexus` 的 capture 流程写入;正文只写知识,不写分支、路径;有 facts 时按 `<skills>/workflow/references/docnexus.md`「沉淀的来源」的格式写 provenance;分类用 `node <skills>/workflow/scripts/resolve.mjs category project-review` 的结果作为调用方显式分类封存(见 `<skills>/workflow/references/docnexus.md`「写入 DocNexus 的分类」)。只沉淀问题模式和约定,不沉淀某次审查的逐条清单,也不沉淀「⚠ 待确认」的发现;知识库已有同类沉淀时更新那一篇(带 `--base-version`),不要重复新建。写入成功后把报告的「DocNexus」元信息行改成 `docnexus/library/<path>`(多篇用「、」分隔),并回报给用户。
36
36
  - 用户不同意 → 元信息行保持「未沉淀」。
37
+ 9. **记录运行反馈**(不问用户):按 `<skills>/workflow/references/feedback.md` 运行 `node <skills>/workflow/scripts/feedback.mjs add --skill project-review --model <模型名> --mode review --depth <快审|标准|深审> --rounds <提问轮数> --corrections <用户纠正审查范围的次数> --findings <C>/<H>/<M>/<L>`,有跳过的步骤、不清楚或做不到的规则、用户的抱怨时再加 `--skipped` / `--unclear` / `--feedback`。只写过程,不写代码和业务名词;报错就提一句,不影响交付。
37
38
 
38
39
  ## 只报会出问题的
39
40
 
@@ -74,7 +75,7 @@ description: "Review source code (any language; Java/Spring, Go, TypeScript, Pyt
74
75
 
75
76
  ## Artifact Location
76
77
 
77
- 审查报告按需求归档在 `workflow-run/docs/requirements/<需求名>/`,规则见 `<skills>/workflow/references/requirements.md`:
78
+ 审查报告按需求归档在 `workflow-run/docs/requirements/<需求名>/<项目名>/`,规则见 `<skills>/workflow/references/requirements.md`:
78
79
 
79
80
  - 路径用 `node <skills>/workflow/scripts/requirement.mjs path review`(分支对比按 dev 分支加 `--branch <dev分支>`),写到输出的 `path`(文件名固定为 `review.md`);已有报告时脚本先把它归档,输出的 `previous` 是上次报告,复审对照它。
80
81
  - 退出码 2(这个分支还没绑定需求):问用户这个需求叫什么,默认值用输出的 `suggested_name`,再 `requirement.mjs bind --name "<需求名>" [--branch <dev分支>]`;用户不给名字时 `bind` 按分支名生成。
@@ -49,7 +49,7 @@ dev(需求 → 方案 → 实现 → 测试 → 自审)
49
49
  → DocNexus 沉淀(可选,需授权)
50
50
  ```
51
51
 
52
- 开始完整链路时先确定需求名:`node <skills>/workflow/scripts/requirement.mjs which`,当前分支没绑定就问用户这个需求叫什么并 `bind`(见 `references/requirements.md`),之后各段产物都落在 `workflow-run/docs/requirements/<需求名>/`。
52
+ 开始完整链路时先确定需求名:`node <skills>/workflow/scripts/requirement.mjs which`,当前分支没绑定就问用户这个需求叫什么并 `bind`(见 `references/requirements.md`),之后各段产物都落在 `workflow-run/docs/requirements/<需求名>/<项目名>/`。
53
53
 
54
54
  提交、推送、发布 wiki、写入 DocNexus 都是对外动作,每一步都要用户明确同意。
55
55
 
@@ -63,7 +63,7 @@ dev(需求 → 方案 → 实现 → 测试 → 自审)
63
63
  | `references/templates.md` | 模板来源判定(`resolve.mjs template`)、创建与修改模板(`/workflow template`)、模板随知识库更新 |
64
64
  | `references/docnexus.md` | 写入 DocNexus 的分类(`resolve.mjs category`)、按分类参考知识库(`knowledge.mjs`) |
65
65
  | `references/status.md` | `/workflow status` 的输出格式 |
66
- | `references/requirements.md` | 产物按需求归档(`workflow-run/docs/requirements/<需求名>/`):什么时候问需求名、`requirement.mjs` 绑定分支与给出落盘路径、`meta.json` |
66
+ | `references/requirements.md` | 产物按需求归档(`workflow-run/docs/requirements/<需求名>/<项目名>/`):什么时候问需求名、`requirement.mjs` 绑定分支与给出落盘路径、`meta.json` |
67
67
  | `references/tables.md` | 确定改动涉及哪些表(`tables.mjs`)、只读拉取实际表结构 / 索引 / 行数核对(`query.mjs --schema --indexes`),没有库连接时怎么退回 |
68
68
  | `references/codegraph.md` | 用 CodeGraph 代码图谱读代码、查调用链与影响面(MCP 工具与 CLI 对照、图谱看不到的部分) |
69
69
 
@@ -0,0 +1,34 @@
1
+ # 运行反馈
2
+
3
+ 每次 skill 跑完,用 `workflow/scripts/feedback.mjs add` 追加一行过程记录,写到 `workflow-run/feedback/<git 用户名>.jsonl`(每人一个文件,随 `workflow-run/` 提交)。团队攒两三周后用 `npx @rowansenne/workflow-run feedback` 汇总,作为精简 SKILL.md 的依据:哪些步骤常被跳过、哪些规则读不懂或做不到、提问是不是太多。装了新版本(`init` / `upgrade`)时会清掉本项目旧版本的记录,从新版本重新收集。
4
+
5
+ ## 规则
6
+
7
+ - **不问用户、不打断**:交付时静默记录,不为了记录多问一句,也不请用户打分。
8
+ - **只写过程,不写内容**:不写代码、类名、方法名、表名、接口路径、业务名词、客户信息。这个文件会被发给维护 skill 的人。
9
+ - **如实**:跳过了就写跳过,问了几轮就写几轮;不美化。拿不准的数字不写那个参数。
10
+ - **失败不影响交付**:命令报错就在对话里提一句,不重试、不排查。
11
+ - **一次运行记一行**:dev 内部调用 project-review 自审时只由 dev 记,project-review 不另记。
12
+
13
+ ## 参数
14
+
15
+ 所有参数都是 `--名称 值`,值里有空格时加双引号;`--skipped`、`--unclear`、`--feedback` 可重复。
16
+
17
+ | 参数 | 值 | 说明 |
18
+ |---|---|---|
19
+ | `--skill` | `dev` / `project-review` | 必填 |
20
+ | `--model` | 你的模型名,如 `claude-opus-5-5`、`qwen3-coder` | 不知道就写所在的工具名 |
21
+ | `--mode` | `feature` / `bug` / `review` | dev 的功能开发 / 缺陷修复;审查写 `review` |
22
+ | `--size` | `S` / `M` / `L` | dev 的规模 |
23
+ | `--fast` | `yes` / `no` | dev 是否走了快速通道 |
24
+ | `--depth` | `快审` / `标准` / `深审` | project-review 的深度 |
25
+ | `--rounds` | 整数 | 向用户提问的轮数(澄清、确认方案都算;只问提交那一下不算) |
26
+ | `--corrections` | 整数 | 用户纠正你对需求 / 审查范围理解的次数 |
27
+ | `--acceptance` | `已验证/总数`,如 `3/4` | dev 交付说明「验证」表里的验收项 |
28
+ | `--baseline-failures` | 整数 | dev Step 0 基线里已有的失败数 |
29
+ | `--findings` | `C/H/M/L`,如 `0/1/2/0` | project-review 各级别发现条数(复核后) |
30
+ | `--skipped` | `<步骤>: <原因>`,如 `F6: 缺 Redis,集成测试没跑` | 本该做却没做的步骤,每步一条 |
31
+ | `--unclear` | `<章节>: <哪条规则、怎么不清楚>`,如 `F4: S 和快速通道的条件重复,不确定要不要都满足` | **最有用的一项**:读不懂、互相矛盾、在这个项目做不到、或你觉得多余的规则;没有就不写 |
32
+ | `--feedback` | 一句话 | 用户主动说的抱怨或建议的大意(如「问得太多」「交付说明太长」);用户没说就不写 |
33
+
34
+ `<步骤>` / `<章节>` 用 SKILL.md 里的编号(`Step 0`、`F3`、`B2`,project-review 写 `第 3 步`),汇总时按它归类。
@@ -13,7 +13,7 @@
13
13
  | `job_annotation` | 定时任务注解(如 `@XxlJob`、`@Scheduled`) | 技术方案「定时任务变更」 |
14
14
  | `data_review` | 上线前是否需要数据团队评审库表(`true` / `false`) | 技术方案模块 7、上线方案 2.3 |
15
15
  | `notes` | 其他项目约定,一条一句(如「启动需要本地 Redis」) | 全部 |
16
- | `artifacts` | 可选:`{ "project": "order-service", "commit_prompt": true }`。`project` 是 `workflow-run/` 为独立文档仓库时这个项目在里面的名字(产物在 `workflow-run/docs/requirements/<需求名>/<项目名>/`);`commit_prompt: false` 关闭写完后的提交提示。个人设置可写在 `workflow/profile.local.json`(不提交)覆盖 | 见 `references/requirements.md`,用 `requirement.mjs config` 修改 |
16
+ | `artifacts` | 可选:`{ "project": "order-service", "commit_prompt": true }`。`project` 是这个项目的产物目录名(产物在 `workflow-run/docs/requirements/<需求名>/<项目名>/`,随代码提交和独立文档仓库两种用法都一样):随代码提交时不配置就取 git 远端仓库名,第一次落盘时自动写进来;独立文档仓库时要先配置;`commit_prompt: false` 关闭写完后的提交提示。个人设置可写在 `workflow/profile.local.json`(不提交)覆盖 | 见 `references/requirements.md`,用 `requirement.mjs config` 修改 |
17
17
  | `docnexus_categories` | 可选,覆盖各 skill 写入 DocNexus 时的分类,如 `{"create-tech-md-doc": "技术方案"}`;不填用 skill 默认 | 见 `references/docnexus.md` |
18
18
 
19
19
  其他 skill 读画像统一用 `node <skills>/workflow/scripts/resolve.mjs profile`:输出 `profile`(文件内容)、`empty`(空字段)、`complete`(`main_branch` / `build` / `test` 都已填写)、`env`(本机系统与终端,见下)。
@@ -1,9 +1,9 @@
1
1
  # 产物按需求归档(dev / project-review / 技术方案 / 上线方案共用)
2
2
 
3
- 一个需求的全部产物放在同一个目录,整条链路一眼看全,`/workflow view` 也直接读这里。本工具的产出都在项目根的 `workflow-run/` 里(需求产物、DocNexus 工作区、项目模板、临时文件),不往项目的其他地方写。默认 `workflow-run/` 随代码提交(下图);团队也可以把独立的文档仓库克隆到 `workflow-run/`,见最后一节。
3
+ 一个需求的全部产物放在同一个目录,整条链路一眼看全,`/workflow view` 也直接读这里。本工具的产出都在项目根的 `workflow-run/` 里(需求产物、DocNexus 工作区、项目模板、临时文件),不往项目的其他地方写。默认 `workflow-run/` 随代码提交;团队也可以让 `workflow-run/` 成为独立的文档仓库,见最后一节。两种用法的目录结构完全一样(下图),切换时文件不用挪。
4
4
 
5
5
  ```
6
- workflow-run/docs/requirements/<需求名>/
6
+ workflow-run/docs/requirements/<需求名>/<项目名>/
7
7
  ├── meta.json # 需求名、绑定的分支、各产物(种类、版本、日期、wiki 发布记录、DocNexus 沉淀)
8
8
  ├── plan.md # 开发方案(dev,L 规模或用户要求时)
9
9
  ├── delivery.md # 交付说明(dev)
@@ -12,7 +12,9 @@ workflow-run/docs/requirements/<需求名>/
12
12
  └── release-doc.md # 上线方案(create-release-md-doc)
13
13
  ```
14
14
 
15
- 这个目录写在项目根的 `.ignore` 里,不参与代码搜索(找历史决策用 DocNexus)。读写一律按下面脚本给出的路径,不要靠搜索找这些文件,也不要自己拼路径。两种放法下各 skill 的做法完全一样,区别都在脚本里。
15
+ 一个需求横跨多个服务时,各服务在同一个需求目录下各占一个 `<项目名>/`。项目名是 `workflow/profile.json` 的 `artifacts.project`:随代码提交时不用配置,脚本取 git 远端仓库名,第一次 `bind` 时写进 `profile.json` 固定下来;独立文档仓库时要先配置(见最后一节)。
16
+
17
+ 这个目录写在项目根的 `.ignore` 里,不参与代码搜索(找历史决策用 DocNexus)。读写一律按下面脚本给出的路径,不要靠搜索找这些文件,也不要自己拼路径。两种用法下各 skill 的做法完全一样,区别都在脚本里。
16
18
 
17
19
  ## 需求名:流程开始或第一次落盘时问用户
18
20
 
@@ -41,14 +43,14 @@ node <skills>/workflow/scripts/requirement.mjs which
41
43
  node <skills>/workflow/scripts/requirement.mjs path <plan|delivery|review|tech|release>
42
44
  ```
43
45
 
44
- - 输出的 `path` 就是要写的文件(相对项目根)。文档里引用产物(「参考产物」、上线方案引用技术方案等)写输出的 `cite`:默认模式下和 `path` 相同;文档仓库模式下是「文档仓库名:仓库内路径」,各服务、各人的克隆里都一样。每种产物的文件名固定(`review.md`、`tech-doc.md` …),引用它的地方不会因为出了新版而失效。已有时,脚本先把当前版复制到 `workflow-run/tmp/old-artifacts/<需求名>/<种类>_v<N>.md`,输出的 `previous` 就是这份(复审对照上次的报告读它),`version` 是这次的版本号;然后把新版直接写到 `path`。
46
+ - 输出的 `path` 就是要写的文件(相对项目根)。文档里引用产物(「参考产物」、上线方案引用技术方案等)写输出的 `cite`:默认模式下和 `path` 相同;文档仓库模式下是「文档仓库名:仓库内路径」,各服务、各人的克隆里都一样。每种产物的文件名固定(`review.md`、`tech-doc.md` …),引用它的地方不会因为出了新版而失效。已有时,脚本先把当前版复制到 `workflow-run/tmp/old-artifacts/<需求名>/<项目名>/<种类>_v<N>.md`,输出的 `previous` 就是这份(复审对照上次的报告读它),`version` 是这次的版本号;然后把新版直接写到 `path`。
45
47
  - 退出码 2:当前分支还没绑定需求,按上一节问用户需求名,`bind` 之后再运行一次。
46
48
  - `wiki_record` 非空:上一版已经发布到 wiki。新版的**第一行原样写这条记录**,md-to-wiki 再发布时会更新同一个页面,不会新建页面。
47
49
  - 写完运行 `requirement.mjs sync <path>`,更新 `meta.json`。md-to-wiki 发布后改了第一行的记录,或补写了 DocNexus 元信息行,也要再 sync 一次。
48
- - sync 时新版内容和归档的上一版不同,才算新版本(`meta.json` 的 `versions` 加一);没写就放弃的话版本号不变。输出里 `archived` 非空时,告诉用户归档了哪些文件、在哪里(`workflow-run/tmp/old-artifacts/<需求名>/`,文档仓库模式是 `<需求名>/<项目名>/`,不提交),请用户确认不再需要后自己删除;**不要替用户删**。随代码或文档仓库提交过的旧版也能从 git 历史找回。
50
+ - sync 时新版内容和归档的上一版不同,才算新版本(`meta.json` 的 `versions` 加一);没写就放弃的话版本号不变。输出里 `archived` 非空时,告诉用户归档了哪些文件、在哪里(`workflow-run/tmp/old-artifacts/<需求名>/<项目名>/`,不提交),请用户确认不再需要后自己删除;**不要替用户删**。随代码或文档仓库提交过的旧版也能从 git 历史找回。
49
51
  - sync 输出里 `commit` 非空:按下一节「文档仓库的提交」处理。
50
52
 
51
- ## 独立文档仓库(克隆到 workflow-run/)
53
+ ## 独立文档仓库
52
54
 
53
55
  团队想让代码仓库保持干净、或者一个需求横跨多个服务时,用户在项目根运行(由用户执行,克隆可能要他们自己认证):
54
56
 
@@ -56,11 +58,11 @@ node <skills>/workflow/scripts/requirement.mjs path <plan|delivery|review|tech|r
56
58
  npx @rowansenne/workflow-run docs-repo <团队文档仓库地址> [--project <项目名>]
57
59
  ```
58
60
 
59
- 它把文档仓库克隆到 `workflow-run/`,代码仓库的 `.gitignore` 加 `/workflow-run/`,并配置好项目名。`workflow-run/` 自己是 git 仓库就是这个模式,不用别的配置。
61
+ `workflow-run/` 里还没有内容时它直接克隆;已经有产物、DocNexus 时原地 `git init` 接上远端(目录结构相同,文件不用挪;和远端同路径冲突时退回原状并列出冲突的文件)。然后代码仓库的 `.gitignore` 加 `/workflow-run/`,并配置好项目名。`workflow-run/` 自己是 git 仓库就是这个模式,不用别的配置。
60
62
 
61
- - 布局:`workflow-run/docs/requirements/<需求名>/<项目名>/plan.md`、`delivery.md`……一个需求横跨多个服务时,各服务在同一个需求目录下各占一个子目录。绑定需求时 `which` 会列出 `other_projects`(其他服务已有、已拉到本地的需求),用同一个名字 `bind` 就加入了那个需求。
63
+ - 布局和随代码提交时一样。绑定需求时 `which` 会列出 `other_projects`(其他服务已有、已拉到本地的需求),用同一个名字 `bind` 就加入了那个需求。
62
64
  - DocNexus 也在这个仓库里(`workflow-run/docnexus/`),各服务共用一个知识库。
63
- - `project`:这个项目在文档仓库里的名字,写在 `workflow/profile.json` 的 `artifacts.project`(随代码提交,团队共用),**只需配置一次**。没配时脚本退出码 2(`need_project`)并给出 `suggested_project`(git 远端仓库名):问用户一次,然后运行 `requirement.mjs config --project <项目名>`,之后一直沿用。
65
+ - `project`:这个项目在文档仓库里的名字,写在 `workflow/profile.json` 的 `artifacts.project`(随代码提交,团队共用),**只需配置一次**;随代码提交时已经自动写过的就直接沿用。没配时脚本退出码 2(`need_project`)并给出 `suggested_project`(git 远端仓库名):问用户一次,然后运行 `requirement.mjs config --project <项目名>`,之后一直沿用。
64
66
  - 查看或修改配置:`requirement.mjs config [--project <项目名>] [--commit-prompt on|off] [--local]`。
65
67
  - `/workflow view` 在这个模式下显示文档仓库里所有项目的需求,一个需求下按项目区分产物。
66
68
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // 按分支汇总各 skill 的产物:开发方案、交付说明、审查报告、技术方案、上线方案,以及它们的 wiki 发布记录。
3
- // 产物按需求归档在 workflow-run/docs/requirements/<需求>/(独立文档仓库模式再分 <项目>/,见 requirement.mjs)。
3
+ // 产物按需求归档在 workflow-run/docs/requirements/<需求>/<项目>/(两种模式结构相同,见 requirement.mjs)。
4
4
  // 直接扫描 Markdown,从元信息行读分支(「分支:」「对比分支:」),
5
5
  // 从第一行的 <!-- md-to-wiki: ... --> 读发布记录(md-to-wiki 上传后写入)。只读。
6
6
  //
@@ -44,14 +44,15 @@ export function parseWiki(text) {
44
44
 
45
45
  const headerDate = (text) => /(\d{4}-\d{2}-\d{2})/.exec(text.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/).slice(0, 8).join('\n'))?.[1] ?? ''; // 跳过 md-to-wiki 发布记录里的 at=
46
46
 
47
- // workflow-run/docs/requirements/<需求>/<文件> → 产物条目;不是已知产物返回 null
47
+ // workflow-run/docs/requirements/<需求>/<项目>/<文件> → 产物条目;不是已知产物返回 null。
48
+ // 分支里还没迁移的旧结构(<需求>/<文件>,没有项目这一层)也认,project 为 null
48
49
  function entry(rel, text, mtime) {
49
50
  const parts = rel.split('/');
50
- if (parts.length !== 5 || parts.slice(0, 3).join('/') !== REQ_DIR) return null;
51
- const p = parseFile(parts[4]);
51
+ if ((parts.length !== 5 && parts.length !== 6) || parts.slice(0, 3).join('/') !== REQ_DIR) return null;
52
+ const p = parseFile(parts.at(-1));
52
53
  const k = p && KINDS.find((x) => x.kind === p.kind);
53
54
  // 排序键:文档头里的日期 + 版本号(需求目录里的文件名不带日期)
54
- return k ? { kind: k.kind, label: k.label, path: rel, requirement: parts[3], branch: parseBranch(text), date: `${headerDate(text)}#${String(p.version).padStart(3, '0')}`, mtime, wiki: parseWiki(text) } : null;
55
+ return k ? { kind: k.kind, label: k.label, path: rel, requirement: parts[3], project: parts.length === 6 ? parts[4] : null, branch: parseBranch(text), date: `${headerDate(text)}#${String(p.version).padStart(3, '0')}`, mtime, wiki: parseWiki(text) } : null;
55
56
  }
56
57
 
57
58
  const byDate = (a, b) => a.date.localeCompare(b.date) || a.mtime - b.mtime;
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // 运行反馈:skill 每跑完一次追加一行到 workflow-run/feedback/<git 用户名>.jsonl,攒两三周后汇总,作为精简 SKILL.md 的依据。
3
+ // 只记过程(提问轮数、跳过的步骤、哪条规则不清楚……),不记代码、类名、表名、业务名词;结果类数据(审查发现、未验证项)report.mjs 从产物里统计。
4
+ // 每人一个文件、随 workflow-run/ 提交,多人不冲突;装了新版本(init / upgrade)时清掉本项目旧版本的记录(git 历史里仍可找回)。
5
+ // 字段含义与记录规则见 workflow/references/feedback.md。
6
+ //
7
+ // 用法(在项目根执行;--root 可指定项目根):
8
+ // node .agents/skills/workflow/scripts/feedback.mjs add --skill dev --model <模型> [--mode feature] [--size M] [--fast no] [--rounds 2] …
9
+ // node .agents/skills/workflow/scripts/feedback.mjs summary [--json]
10
+ // --skipped / --unclear / --feedback 可重复。add 输出写到的文件(JSON)。退出码:0 正常;1 参数错误
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { homeDir, homeIsRepo, layout, suggestProject, dirName, HOME } from './requirement.mjs';
17
+
18
+ const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
19
+ export const FEEDBACK_DIR = 'feedback';
20
+
21
+ // 字段 → 类型;list 可重复
22
+ const FIELDS = {
23
+ skill: 'string', model: 'string', env: 'string',
24
+ mode: ['feature', 'bug', 'review'], size: ['S', 'M', 'L'], depth: ['快审', '标准', '深审'], fast: ['yes', 'no'],
25
+ rounds: 'int', corrections: 'int', 'baseline-failures': 'int',
26
+ acceptance: /^\d+\/\d+$/, findings: /^\d+\/\d+\/\d+\/\d+$/,
27
+ skipped: 'list', unclear: 'list', feedback: 'list',
28
+ };
29
+
30
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
31
+ const gitOut = (root, args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' }).stdout?.trim() ?? '';
32
+
33
+ export const feedbackDir = (root) => path.join(homeDir(root), FEEDBACK_DIR);
34
+ export const projectName = (root) => layout(root).project ?? suggestProject(root);
35
+
36
+ // 安装清单里的版本(skills 装在 .agents/skills/,清单在同一目录);从源码仓库直接运行时用 package.json
37
+ export function installedVersion() {
38
+ return readJson(path.join(SKILLS_DIR, '.workflow-run.json'))?.version ?? readJson(path.join(SKILLS_DIR, 'package.json'))?.version ?? 'unknown';
39
+ }
40
+
41
+ export function userFile(root) {
42
+ const who = gitOut(root, ['config', 'user.name']) || gitOut(root, ['config', 'user.email']).split('@')[0] || 'unknown';
43
+ return path.join(feedbackDir(root), `${dirName(who)}.jsonl`);
44
+ }
45
+
46
+ export function parseAdd(argv) {
47
+ const rec = {};
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const m = /^--([\w-]+)$/.exec(argv[i]);
50
+ const type = m && FIELDS[m[1]];
51
+ if (!type) throw new Error(`未知参数:${argv[i]}`);
52
+ const v = argv[++i];
53
+ if (v === undefined || v.startsWith('--')) throw new Error(`--${m[1]} 缺少值`);
54
+ const key = m[1];
55
+ if (type === 'list') { (rec[key] ??= []).push(v.trim()); continue; }
56
+ if (type === 'int') { if (!/^\d+$/.test(v)) throw new Error(`--${key} 需要非负整数`); rec[key] = Number(v); continue; }
57
+ if (Array.isArray(type) && !type.includes(v)) throw new Error(`--${key} 只能是 ${type.join(' / ')}`);
58
+ if (type instanceof RegExp && !type.test(v)) throw new Error(`--${key} 格式不对(${type.source})`);
59
+ rec[key] = v;
60
+ }
61
+ if (!rec.skill) throw new Error('缺少 --skill');
62
+ return rec;
63
+ }
64
+
65
+ export function add(root, rec, { now = new Date() } = {}) {
66
+ const file = userFile(root);
67
+ const line = { ts: now.toISOString(), version: installedVersion(), project: projectName(root), ...rec };
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`);
70
+ return { ok: true, file: path.relative(root, file).split(path.sep).join('/'), record: line };
71
+ }
72
+
73
+ export function readAll(root) {
74
+ const dir = feedbackDir(root);
75
+ if (!fs.existsSync(dir)) return [];
76
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')).sort().flatMap((f) => fs.readFileSync(path.join(dir, f), 'utf8')
77
+ .split('\n').filter((l) => l.trim()).map((l) => { try { return { user: f.replace(/\.jsonl$/, ''), ...JSON.parse(l) }; } catch { return null; } }).filter(Boolean));
78
+ }
79
+
80
+ // 同一个人在两个分支上各追加一行,合并时会在文件末尾冲突:用 git 内置的 union 合并保留两边的行。
81
+ // workflow-run/ 是独立文档仓库时规则写进它自己的 .gitattributes。返回写入的文件(已有规则时 null)
82
+ export function ensureUnionMerge(root) {
83
+ const [file, rule] = homeIsRepo(root) ? [path.join(homeDir(root), '.gitattributes'), `${FEEDBACK_DIR}/*.jsonl merge=union`] : [path.join(root, '.gitattributes'), `${HOME}/${FEEDBACK_DIR}/*.jsonl merge=union`];
84
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
85
+ if (text.split(/\r?\n/).some((l) => l.trim() === rule)) return null;
86
+ fs.writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${rule}\n`);
87
+ return { file: path.relative(root, file).split(path.sep).join('/'), rule };
88
+ }
89
+
90
+ // 新版本装好后:删掉本项目旧版本的记录,其他项目(独立文档仓库里各服务共用 feedback/)的不动。返回删掉的条数
91
+ export function rotate(root, version, project = projectName(root)) {
92
+ const dir = feedbackDir(root);
93
+ if (!fs.existsSync(dir)) return 0;
94
+ let removed = 0;
95
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.jsonl'))) {
96
+ const p = path.join(dir, f);
97
+ const lines = fs.readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
98
+ const keep = lines.filter((l) => { let r; try { r = JSON.parse(l); } catch { return true; } return r.version === version || (r.project && r.project !== project); });
99
+ if (keep.length === lines.length) continue;
100
+ removed += lines.length - keep.length;
101
+ fs.writeFileSync(p, keep.length ? `${keep.join('\n')}\n` : '');
102
+ }
103
+ return removed;
104
+ }
105
+
106
+ // 「F6: 缺中间件」→ F6;没有章节前缀的归到「其他」
107
+ const sectionOf = (s) => /^([A-Z]?\d+|Step ?\d+|第\s*\d+\s*步)\s*[::]/i.exec(s)?.[1].replace(/\s+/g, '') ?? '其他';
108
+ const groupBy = (xs, f) => xs.reduce((m, x) => { (m[f(x)] ??= []).push(x); return m; }, {});
109
+ const countBy = (xs, f) => xs.reduce((m, x) => { const k = f(x); m[k] = (m[k] ?? 0) + 1; return m; }, {});
110
+ const sorted = (m) => Object.entries(m).sort((a, b) => b[1] - a[1]);
111
+ const avg = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 10) / 10 : null);
112
+
113
+ export function summarize(records) {
114
+ const bySkill = {};
115
+ for (const [skill, rs] of Object.entries(groupBy(records, (r) => r.skill))) {
116
+ const notes = (k) => rs.flatMap((r) => r[k] ?? []);
117
+ const acc = rs.filter((r) => r.acceptance).map((r) => r.acceptance.split('/').map(Number));
118
+ const findings = rs.filter((r) => r.findings).map((r) => r.findings.split('/').map(Number));
119
+ bySkill[skill] = {
120
+ runs: rs.length,
121
+ mode: countBy(rs.filter((r) => r.mode), (r) => r.mode),
122
+ size: countBy(rs.filter((r) => r.size), (r) => r.size),
123
+ depth: countBy(rs.filter((r) => r.depth), (r) => r.depth),
124
+ fast_path: rs.filter((r) => r.fast === 'yes').length,
125
+ avg_rounds: avg(rs.filter((r) => r.rounds !== undefined).map((r) => r.rounds)),
126
+ runs_with_corrections: rs.filter((r) => r.corrections > 0).length,
127
+ acceptance: acc.length ? { verified: acc.reduce((a, [v]) => a + v, 0), total: acc.reduce((a, [, t]) => a + t, 0) } : null,
128
+ runs_with_baseline_failures: rs.filter((r) => r['baseline-failures'] > 0).length,
129
+ findings: findings.length ? ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].reduce((o, k, i) => ({ ...o, [k]: findings.reduce((a, f) => a + f[i], 0) }), {}) : null,
130
+ skipped: groupBy(notes('skipped'), sectionOf),
131
+ unclear: groupBy(notes('unclear'), sectionOf),
132
+ feedback: notes('feedback'),
133
+ };
134
+ }
135
+ return {
136
+ records: records.length,
137
+ from: records.map((r) => r.ts).sort()[0] ?? null,
138
+ to: records.map((r) => r.ts).sort().at(-1) ?? null,
139
+ versions: countBy(records, (r) => r.version), projects: countBy(records, (r) => r.project),
140
+ users: Object.keys(countBy(records, (r) => r.user)).length, models: countBy(records, (r) => r.model ?? '未写'),
141
+ skills: bySkill,
142
+ };
143
+ }
144
+
145
+ function toMarkdown(s) {
146
+ const kv = (m) => sorted(m).map(([k, v]) => `${k} ${v}`).join(',') || '无';
147
+ const L = ['# 运行反馈汇总', '', `- 记录:${s.records} 条(${s.from?.slice(0, 10) ?? '—'} ~ ${s.to?.slice(0, 10) ?? '—'}),${s.users} 人`, `- 版本:${kv(s.versions)}`, `- 项目:${kv(s.projects)}`, `- 模型:${kv(s.models)}`, ''];
148
+ for (const [skill, k] of Object.entries(s.skills)) {
149
+ L.push(`## ${skill}(${k.runs} 次)`, '');
150
+ if (Object.keys(k.mode).length) L.push(`- 模式:${kv(k.mode)}`);
151
+ if (Object.keys(k.size).length) L.push(`- 规模:${kv(k.size)};快速通道 ${k.fast_path} 次`);
152
+ if (Object.keys(k.depth).length) L.push(`- 深度:${kv(k.depth)}`);
153
+ if (k.avg_rounds !== null) L.push(`- 平均提问轮数:${k.avg_rounds};用户纠正过需求理解:${k.runs_with_corrections} 次`);
154
+ if (k.acceptance) L.push(`- 验收项:已验证 ${k.acceptance.verified} / ${k.acceptance.total}`);
155
+ if (k.runs_with_baseline_failures) L.push(`- 开工前已有失败:${k.runs_with_baseline_failures} 次`);
156
+ if (k.findings) L.push(`- 审查发现:${Object.entries(k.findings).map(([a, b]) => `${a} ${b}`).join(',')}`);
157
+ for (const [title, groups] of [['跳过的步骤', k.skipped], ['不清楚 / 矛盾 / 做不到的规则', k.unclear]]) {
158
+ const g = sorted(Object.fromEntries(Object.entries(groups).map(([sec, xs]) => [sec, xs.length])));
159
+ if (!g.length) continue;
160
+ L.push('', `### ${title}`, '');
161
+ for (const [sec, n] of g) { L.push(`- **${sec}**(${n})`); for (const x of groups[sec]) L.push(` - ${x}`); }
162
+ }
163
+ if (k.feedback.length) { L.push('', '### 用户反馈', ''); for (const x of k.feedback) L.push(`- ${x}`); }
164
+ L.push('');
165
+ }
166
+ if (!s.records) L.push('还没有记录。');
167
+ return L.join('\n');
168
+ }
169
+
170
+ function main(argv) {
171
+ let root = process.cwd();
172
+ const rest = [];
173
+ for (let i = 0; i < argv.length; i++) { if (argv[i] === '--root') root = path.resolve(argv[++i] ?? '.'); else rest.push(argv[i]); }
174
+ const [cmd, ...args] = rest;
175
+ if (cmd === 'add') {
176
+ try { console.log(JSON.stringify(add(root, parseAdd(args)), null, 2)); return 0; } catch (e) { console.error(e.message); return 1; }
177
+ }
178
+ if (cmd === 'summary' && args.every((a) => a === '--json')) {
179
+ const s = summarize(readAll(root));
180
+ console.log(args.includes('--json') ? JSON.stringify(s, null, 2) : toMarkdown(s));
181
+ return 0;
182
+ }
183
+ console.error('用法: feedback.mjs [--root <项目根>] add --skill <skill> [--model …] … | summary [--json]');
184
+ return 1;
185
+ }
186
+
187
+ export { main };
188
+ const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
189
+ if (invoked) process.exit(main(process.argv.slice(2)));
@@ -3,9 +3,12 @@
3
3
  // wiki 发布记录与 DocNexus 沉淀,供 /workflow view 等直接读取。
4
4
  // 本工具的产出都在项目根的 workflow-run/ 里(需求产物 docs/requirements/、DocNexus 工作区 docnexus/、项目模板 templates/、临时文件 tmp/),
5
5
  // 不往项目的其他地方写。两种用法:
6
- // - 默认:workflow-run/ 随代码提交,需求产物在 workflow-run/docs/requirements/<需求名>/;这个目录写进项目根 .ignore,不参与代码搜索
7
- // - 独立文档仓库:团队文档仓库克隆到 workflow-run/(它自己是 git 仓库,代码仓库忽略它),产物在 workflow-run/docs/requirements/<需求名>/<项目名>/,
8
- // 一个需求横跨多个服务时各项目各占一个子目录;文档仓库单独提交(写完由用户确认,可用 artifacts.commit_prompt 关闭提交提示)
6
+ // 两种用法的目录结构完全一样:产物都在 workflow-run/docs/requirements/<需求名>/<项目名>/,一个需求横跨多个服务时各项目各占一个子目录,
7
+ // 所以两种用法之间切换只是换一个 git 仓库来跟踪 workflow-run/,文件不用挪:
8
+ // - 默认:workflow-run/ 随代码提交;项目名没配置时取 git 远端仓库名(第一次 bind 时写进 profile.json,之后固定)
9
+ // - 独立文档仓库:团队文档仓库克隆到 workflow-run/(它自己是 git 仓库,代码仓库忽略它),项目名要先配置(各服务在同一个仓库里,名字由团队定);
10
+ // 文档仓库单独提交(写完由用户确认,可用 artifacts.commit_prompt 关闭提交提示)
11
+ // 需求目录写进项目根 .ignore,不参与代码搜索。旧版本(随代码提交时没有 <项目名>/ 这一层)的产物由安装器 upgrade 时迁移(migrateLayout)
9
12
  // 需求名由用户在流程开始或第一次落盘时给出;用户不给时按分支名生成。分支 → 需求的绑定记在 meta.json 的 branches。
10
13
  // 说明见 workflow/references/requirements.md。
11
14
  //
@@ -110,13 +113,17 @@ export function artifactsConfig(root) {
110
113
  return { project: c.project ? String(c.project) : '', commit_prompt: c.commit_prompt !== false, profile_dir: dir };
111
114
  }
112
115
 
113
- // 产物位置:project = workflow-run/docs/requirements/<需求>/(随代码提交);repo = 同一位置下再分 <项目>/(workflow-run/ 是独立的文档仓库)
116
+ // 产物位置:两种模式都是 workflow-run/docs/requirements/<需求>/<项目>/。
117
+ // mode 只决定谁来提交(project = 随代码提交,repo = workflow-run/ 是独立的文档仓库)、引用怎么写、项目名能不能自动取
114
118
  export function layout(root, cfg = artifactsConfig(root)) {
115
119
  const home = homeDir(root);
116
120
  const base = path.join(root, REQ_DIR);
117
- if (!homeIsRepo(root)) return { mode: 'project', ok: true, base, home, repo: null, project: null, commit_prompt: cfg.commit_prompt, reqDir: (slug) => path.join(base, slug), ref: (abs) => relOf(root, abs) };
121
+ if (!homeIsRepo(root)) {
122
+ const project = cfg.project ? dirName(cfg.project) : suggestProject(root);
123
+ return { mode: 'project', ok: true, base, home, repo: null, project, project_configured: !!cfg.project, commit_prompt: cfg.commit_prompt, reqDir: (slug) => path.join(base, slug, project), ref: (abs) => relOf(root, abs) };
124
+ }
118
125
  const repoName = remoteName(home);
119
- const L = { mode: 'repo', base, home, repo: home, repo_rel: HOME, repo_name: repoName, project: cfg.project ? dirName(cfg.project) : null, commit_prompt: cfg.commit_prompt };
126
+ const L = { mode: 'repo', base, home, repo: home, repo_rel: HOME, repo_name: repoName, project: cfg.project ? dirName(cfg.project) : null, project_configured: !!cfg.project, commit_prompt: cfg.commit_prompt };
120
127
  L.reqDir = (slug) => path.join(base, slug, L.project ?? '_');
121
128
  // 文档里引用产物用「文档仓库名:仓库内路径」,各服务、各人的克隆里都一样
122
129
  L.ref = (abs) => `${repoName}:${toPosix(path.relative(home, abs))}`;
@@ -157,9 +164,10 @@ export function requirements(root, L = layout(root)) {
157
164
  return out;
158
165
  }
159
166
 
160
- // 文档仓库里所有项目的需求(跨服务视图、bind 时找同名需求):{ slug, name, projects:[{ project, abs, dir, meta }] }
167
+ // 所有项目的需求(跨服务视图、bind 时找同名需求):{ slug, name, projects:[{ project, abs, dir, meta }] }。
168
+ // 两种模式结构相同;随代码提交时通常只有本项目一个
161
169
  export function repoRequirements(root, L = layout(root)) {
162
- if (L.mode !== 'repo' || !isDir(L.base)) return [];
170
+ if (!isDir(L.base)) return [];
163
171
  const out = [];
164
172
  for (const d of fs.readdirSync(L.base, { withFileTypes: true })) {
165
173
  if (skipDir(d)) continue;
@@ -178,7 +186,7 @@ export function repoRequirements(root, L = layout(root)) {
178
186
  // 产物文件(view / artifacts.mjs 读):{ abs, rel(相对项目根), cite(文档里引用它的写法), slug, project, kind, version }
179
187
  export function artifactFiles(root, { allProjects = false } = {}) {
180
188
  const L = layout(root);
181
- const dirs = allProjects && L.mode === 'repo'
189
+ const dirs = allProjects
182
190
  ? repoRequirements(root, L).flatMap((r) => r.projects.map((p) => ({ abs: p.abs, slug: r.slug, project: p.project })))
183
191
  : requirements(root, L).map((r) => ({ abs: r.abs, slug: r.slug, project: L.project }));
184
192
  const out = [];
@@ -251,15 +259,15 @@ export function which(root, branch) {
251
259
  const L = layout(root);
252
260
  if (!L.ok) return notReady(L);
253
261
  const r = find(root, branch, L);
254
- const where = L.mode === 'repo' ? { mode: 'repo', repo: L.repo_rel, project: L.project } : { mode: 'project' };
262
+ const where = L.mode === 'repo' ? { mode: 'repo', repo: L.repo_rel, project: L.project } : { mode: 'project', project: L.project };
255
263
  if (r) return { bound: true, ...where, branch, name: r.name, dir: r.dir, meta: sync(root, r.dir) };
256
264
  const recent = requirements(root, L).sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, 5).map(({ name, dir, branches, updated }) => ({ name, dir, branches, updated }));
257
- // 文档仓库模式:其他项目已有的需求(跨服务需求,本项目可以用同一个名字加入)
265
+ // 其他项目已有的需求(跨服务需求,本项目可以用同一个名字加入);文档仓库里常见,随代码提交时一般没有
258
266
  const mine = new Set(requirements(root, L).map((x) => x.slug));
259
- const others = L.mode === 'repo' ? repoRequirements(root, L).filter((x) => !mine.has(x.slug)).sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, 10).map((x) => ({ name: x.name, projects: x.projects.map((p) => p.project), updated: x.updated })) : [];
267
+ const others = repoRequirements(root, L).filter((x) => !mine.has(x.slug)).sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, 10).map((x) => ({ name: x.name, projects: x.projects.map((p) => p.project), updated: x.updated }));
260
268
  return {
261
- bound: false, ...where, branch, suggested_name: branch ? dirName(branch.replace(/\//g, '-')) : null, recent, ...(L.mode === 'repo' ? { other_projects: others } : {}),
262
- hint: `当前分支还没有绑定需求:问用户这个需求叫什么(给出 suggested_name 作为默认;也可以选 recent 里已有的需求继续${L.mode === 'repo' ? ',或加入 other_projects 里其他服务已有的需求' : ''}),然后运行 bind --name <需求名>;用户不给就运行 bind(按分支名生成)。`,
269
+ bound: false, ...where, branch, suggested_name: branch ? dirName(branch.replace(/\//g, '-')) : null, recent, ...(others.length || L.mode === 'repo' ? { other_projects: others } : {}),
270
+ hint: `当前分支还没有绑定需求:问用户这个需求叫什么(给出 suggested_name 作为默认;也可以选 recent 里已有的需求继续${others.length || L.mode === 'repo' ? ',或加入 other_projects 里其他服务已有的需求' : ''}),然后运行 bind --name <需求名>;用户不给就运行 bind(按分支名生成)。`,
263
271
  };
264
272
  }
265
273
 
@@ -269,25 +277,28 @@ export function bind(root, { name = null, branch, base = null }) {
269
277
  if (!branch) return { ok: false, error: '无法确定当前分支(detached HEAD?),请用 --branch 指定' };
270
278
  const named = name != null && String(name).trim() !== '';
271
279
  const display = named ? String(name).trim() : branch.replace(/\//g, '-');
272
- // 同名需求沿用(文档仓库模式按整个仓库找,跨服务的需求共用一个目录);目录名被别的需求占了就加 -2、-3
273
- const pool = L.mode === 'repo' ? repoRequirements(root, L).map((r) => ({ slug: r.slug, name: r.name })) : requirements(root, L).map((r) => ({ slug: r.slug, name: r.name }));
280
+ // 同名需求沿用(按所有项目找,跨服务的需求共用一个目录);目录名被别的需求占了就加 -2、-3
281
+ const pool = repoRequirements(root, L).map((r) => ({ slug: r.slug, name: r.name }));
274
282
  const target = pool.find((r) => r.name === display) ?? null;
275
283
  let slug = target?.slug;
276
284
  if (!slug) {
277
285
  slug = dirName(display);
278
- for (let i = 2; pool.some((r) => r.slug === slug) || (L.mode === 'project' && isDir(L.reqDir(slug))); i++) slug = `${dirName(display)}-${i}`;
286
+ for (let i = 2; pool.some((r) => r.slug === slug) || isDir(L.reqDir(slug)); i++) slug = `${dirName(display)}-${i}`;
279
287
  }
288
+ // 随代码提交时项目名是自动取的:第一次落盘时写进 profile.json 固定下来,之后改了 git 远端名也不会换目录
289
+ // (只在项目里装过 skills、有 profile.json 时写;直接从安装包运行脚本时不写)
290
+ if (!L.project_configured && fs.existsSync(path.join(profileDir(root), 'profile.json'))) setConfig(root, { project: L.project });
280
291
  const dir = L.reqDir(slug);
281
292
  const existedHere = isDir(dir);
282
293
  fs.mkdirSync(dir, { recursive: true });
283
294
  const f = path.join(dir, 'meta.json');
284
- const meta = readJson(f) ?? { name: target?.name ?? display, named_by: named ? 'user' : 'branch', ...(L.mode === 'repo' ? { project: L.project } : {}), branches: [], base, created: now() };
295
+ const meta = readJson(f) ?? { name: target?.name ?? display, named_by: named ? 'user' : 'branch', project: L.project, branches: [], base, created: now() };
285
296
  if (!meta.branches.includes(branch)) meta.branches.push(branch);
286
297
  if (base && !meta.base) meta.base = base;
287
298
  meta.updated = now();
288
299
  fs.writeFileSync(f, `${JSON.stringify(meta, null, 2)}\n`);
289
300
  const rel = relOf(root, dir);
290
- return { ok: true, mode: L.mode, created: !existedHere, joined: !!target && !existedHere && L.mode === 'repo', name: meta.name, named_by: meta.named_by, dir: rel, ...(L.mode === 'repo' ? { project: L.project } : {}), branches: sync(root, rel).branches };
301
+ return { ok: true, mode: L.mode, created: !existedHere, joined: !!target && !existedHere, name: meta.name, named_by: meta.named_by, dir: rel, project: L.project, branches: sync(root, rel).branches };
291
302
  }
292
303
 
293
304
  // 本次落盘路径:文件名固定(review.md …),已有时先把当前版复制到 tmp/old-artifacts/<种类>_v<N>.md(previous,复审对照用),
@@ -347,6 +358,55 @@ export function pending(root, branch, dirRel = null) {
347
358
  };
348
359
  }
349
360
 
361
+ // ------------------------------------------------------------------ 旧结构迁移
362
+ // 旧版本随代码提交时没有 <项目名>/ 这一层:文件直接放在 docs/requirements/<需求>/ 下。
363
+ // 列出这样的需求目录(目录里直接有文件的;隐藏文件不算)
364
+ export function legacyRequirements(root) {
365
+ const base = path.join(root, REQ_DIR);
366
+ if (!isDir(base)) return [];
367
+ return fs.readdirSync(base, { withFileTypes: true }).filter((d) => !skipDir(d)).map((d) => d.name)
368
+ .filter((slug) => fs.readdirSync(path.join(base, slug), { withFileTypes: true }).some((e) => e.isFile() && !e.name.startsWith('.')));
369
+ }
370
+
371
+ // 把旧结构的需求目录挪到 <需求>/<项目名>/:被 git 跟踪的用 git mv(保留历史),其余直接移动;
372
+ // tmp/old-artifacts/<需求>/ 下归档的上一版也一起挪,复审对照还能找到。
373
+ // 目标里已有同名文件时这个需求整个跳过,留给用户处理。项目名没配置时按 layout 自动取的写进 profile.json。
374
+ // 返回 { project, moved: [{ from, to }], skipped: [{ dir, reason }] }
375
+ export function migrateLayout(root) {
376
+ const L = layout(root);
377
+ const slugs = legacyRequirements(root);
378
+ if (!slugs.length) return { project: L.project, moved: [], skipped: [] };
379
+ if (!L.project) return { project: null, moved: [], skipped: slugs.map((s) => ({ dir: `${REQ_DIR}/${s}`, reason: '还没配置项目名' })) };
380
+ const repoOf = (abs) => (L.mode === 'repo' ? L.repo : root);
381
+ const tracked = (abs) => git(repoOf(abs), ['ls-files', '--error-unmatch', '--', path.relative(repoOf(abs), abs)]).status === 0;
382
+ const moved = []; const skipped = [];
383
+ const move = (from, to, useGit) => {
384
+ fs.mkdirSync(path.dirname(to), { recursive: true });
385
+ const cwd = repoOf(from);
386
+ if (useGit && tracked(from) && git(cwd, ['mv', '--', path.relative(cwd, from), path.relative(cwd, to)]).status === 0) return;
387
+ fs.renameSync(from, to);
388
+ };
389
+ for (const slug of slugs) {
390
+ const dir = path.join(L.base, slug);
391
+ const files = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name);
392
+ const target = path.join(dir, L.project);
393
+ const clash = files.filter((f) => fs.existsSync(path.join(target, f)));
394
+ if (clash.length) { skipped.push({ dir: relOf(root, dir), reason: `${relOf(root, target)} 里已有 ${clash.join('、')}` }); continue; }
395
+ for (const f of files) {
396
+ move(path.join(dir, f), path.join(target, f), true);
397
+ moved.push({ from: relOf(root, path.join(dir, f)), to: relOf(root, path.join(target, f)) });
398
+ }
399
+ const m = readJson(path.join(target, 'meta.json'));
400
+ if (m && m.project !== L.project) fs.writeFileSync(path.join(target, 'meta.json'), `${JSON.stringify({ ...m, project: L.project }, null, 2)}\n`);
401
+ const old = path.join(root, OLD_DIR, slug);
402
+ if (isDir(old)) {
403
+ for (const e of fs.readdirSync(old, { withFileTypes: true })) if (e.isFile()) move(path.join(old, e.name), path.join(old, L.project, e.name), false);
404
+ }
405
+ }
406
+ if (moved.length && !L.project_configured && fs.existsSync(path.join(profileDir(root), 'profile.json'))) setConfig(root, { project: L.project });
407
+ return { project: L.project, moved, skipped };
408
+ }
409
+
350
410
  // ------------------------------------------------------------------ 配置
351
411
  // 修改 artifacts 配置:团队共用的写 profile.json,--local 写个人的 profile.local.json(不提交)
352
412
  export function setConfig(root, patch, { local = false } = {}) {