@rowansenne/workflow-run 0.26.1 → 0.27.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +59 -37
  3. package/bin/lib/artifact-context.mjs +46 -0
  4. package/bin/lib/docnexus.mjs +4 -4
  5. package/bin/lib/doctor.mjs +1 -2
  6. package/bin/lib/install.mjs +1 -1
  7. package/bin/lib/uninstall.mjs +3 -0
  8. package/bin/workflow-run.mjs +20 -29
  9. package/code-graph/SKILL.md.tpl +2 -7
  10. package/create-release-md-doc/SKILL.md.tpl +9 -8
  11. package/create-tech-md-doc/SKILL.md.tpl +9 -8
  12. package/database-query/SKILL.md.tpl +2 -0
  13. package/database-query/skill.json +2 -2
  14. package/dev/SKILL.md.tpl +42 -116
  15. package/dev/references/bug.md +30 -0
  16. package/dev/references/delivery-template.md +6 -6
  17. package/dev/references/delivery.md +48 -0
  18. package/dev/references/implementation.md +19 -0
  19. package/dev/references/plan-template.md +5 -3
  20. package/dev/references/planning.md +45 -0
  21. package/dev/references/preflight.md +35 -0
  22. package/dev/skill.json +2 -2
  23. package/md-to-wiki/SKILL.md.tpl +5 -1
  24. package/md-to-wiki/skill.json +2 -2
  25. package/package.json +2 -2
  26. package/project-review/SKILL.md.tpl +46 -72
  27. package/project-review/references/report.md.tpl +4 -4
  28. package/project-review/references/reporting.md +48 -0
  29. package/project-review/references/review-method.md +56 -0
  30. package/project-review/references/verification.md +30 -0
  31. package/shared/prd-fetch.md +1 -1
  32. package/workflow/SKILL.md.tpl +15 -16
  33. package/workflow/lib/artifact-model.mjs +83 -0
  34. package/workflow/lib/capabilities.mjs +80 -0
  35. package/workflow/lib/document-model.mjs +86 -0
  36. package/workflow/lib/meta-store.mjs +112 -0
  37. package/workflow/lib/project.mjs +218 -0
  38. package/workflow/lib/requirement-read.mjs +68 -0
  39. package/workflow/lib/requirements.mjs +219 -0
  40. package/workflow/lib/runs.mjs +144 -0
  41. package/workflow/lib/workspace-read.mjs +120 -0
  42. package/workflow/references/architecture.md +43 -0
  43. package/workflow/references/capabilities.md +39 -0
  44. package/workflow/references/codegraph.md +3 -3
  45. package/workflow/references/docnexus.md +1 -1
  46. package/workflow/references/requirements.md +25 -13
  47. package/workflow/references/runs.md +98 -0
  48. package/workflow/references/status.md +6 -3
  49. package/workflow/references/templates.md +11 -14
  50. package/workflow/scripts/artifacts.mjs +21 -80
  51. package/workflow/scripts/doc-sections.mjs +2 -55
  52. package/workflow/scripts/docnexus-layout.mjs +2 -24
  53. package/workflow/scripts/knowledge.mjs +4 -2
  54. package/workflow/scripts/lint-doc.mjs +2 -35
  55. package/workflow/scripts/meta-store.mjs +2 -0
  56. package/workflow/scripts/pr-desc.mjs +8 -6
  57. package/workflow/scripts/report.mjs +20 -68
  58. package/workflow/scripts/requirement.mjs +9 -402
  59. package/workflow/scripts/resolve.mjs +22 -190
  60. package/workflow/scripts/review-scope.mjs +2 -1
  61. package/workflow/scripts/run.mjs +37 -0
  62. package/workflow/scripts/tables.mjs +1 -1
  63. package/workflow/scripts/template.mjs +2 -6
  64. package/workflow/scripts/view.mjs +41 -21
@@ -1,30 +1,27 @@
1
1
  # 模板来源(文档类 skill 共用)
2
2
 
3
- 技术方案、上线方案、开发方案、审查清单的模板由脚本判定,各 skill 不自行查找:
3
+ 技术方案、上线方案、方案、审查清单的模板由脚本判定,各 skill 不自行查找:
4
4
 
5
5
  ```
6
6
  node <skills>/workflow/scripts/resolve.mjs template <skill> <文件名>
7
7
  ```
8
8
 
9
- `<skills>` 是本 skill 所在的 skills 目录(如 `.agents/skills`),在项目根执行。输出 JSON:`path` 是要读的模板,`source` 是 `docnexus` / `default`,写进产出的「模板来源」。
9
+ `<skills>` 是本 Skill 所在的 skills 目录(如 `.agents/skills`)。开工快照的 `templates` 已包含解析结果;使用前按需用上述命令刷新。成功时读 `path`,`source` 固定为 `docnexus`。
10
10
 
11
- **skills 自带的默认模板只读**:不要直接编辑 skills 目录里的模板文件(升级时会被覆盖),也不要在 `workflow-run/templates/` 放项目模板(已不再生效)。要改模板只有一条路:写进 DocNexus 知识库,团队通过知识库共享同一份。
11
+ DocNexus 是必装组件,知识库 `workflow-run/docnexus/library/templates/<skill>/<文件名>` 是唯一模板来源。包内模板仅用于首次导入,不作为运行时后备;`workflow-run/templates/` 里的旧项目模板不生效。必装组件不可用统一按 capabilities.md 修复。
12
12
 
13
- 判定规则(实现在脚本里,这里只是说明):
13
+ 缺少本次所需模板时停在该步骤,统一按输出处理:
14
14
 
15
- - **已接入 DocNexus**:只用 `workflow-run/docnexus/library/templates/<skill>/<文件名>`(分类 `doc-template`,普通 Markdown,手工改动立即生效)。不使用 skill 默认模板,也不使用 `workflow-run/templates/`——知识库是唯一来源,避免几份模板各自演化。
16
- - 缺模板时退出码 2,输出 `expected`(应在的位置)、`import_from`(可导入的来源)和 `hint`。**停下这一步**,告诉用户缺哪份模板,给两个选择:在项目根运行 `npx @rowansenne/workflow-run seed-templates`(或重跑安装加 `--seed-templates`);或者由你在用户确认后按 DocNexus 的 capture 流程把 `import_from` 写入 `expected`,以显式分类封存(`draft seal ... --category doc-template`,metadata.category 也写 `doc-template`)。导入后再继续。
17
- - **未接入 DocNexus**:只用 skill 默认模板,不能修改或新建模板。
18
- - 输出里 `stale_project_file` 非空:`workflow-run/templates/` 下的这份已不再生效,提醒用户导入 DocNexus 后删除(未接入时先接入)。
19
- - **技术方案、上线方案没有默认模板**(`create-tech-md-doc`、`create-release-md-doc` 的 `template.md`):各团队发到 wiki 的格式不同,skills 不自带固定格式,第一次使用时按样例创建到知识库。
20
- - 已接入 DocNexus、还没创建:退出码 2,输出 `on_demand: true`、`expected`(知识库里的位置)和 `hint`,按下面「创建模板」处理。
21
- - 未接入 DocNexus:退出码 2,输出 `need_docnexus: true` 和 `hint`。**停下这一步**,告诉用户这两类文档的模板只保存在 DocNexus 里,要先重跑 npx @rowansenne/workflow-run 并加 `--with-docnexus` 接入,再创建模板。不要自己编一份模板凑数。
15
+ - 有内置导入来源:运行 `npx @rowansenne/workflow-run seed-templates` 导入(已有的不覆盖),或经用户确认按 capture 流程把 `import_from` 写入 `expected`。
16
+ - `on_demand: true`:技术方案、上线方案没有内置模板,按下文「创建模板」请用户提供团队样例,不编造通用模板。
17
+ - 模板写入按 `doc-template` 分类封存。完成后刷新能力快照再继续;已有运行保存等待事项,不丢失进度。
18
+ - `stale_project_file` 非空:提示这是已不生效的旧文件,确认已导入知识库后再删除。
22
19
 
23
- `resolve.mjs templates` 列出所有登记模板的来源;登记表在各 skill 的 `skill.json`(`templates` 字段)。`--with-docnexus` 接入时会自动导入默认模板,之后可运行 `npx @rowansenne/workflow-run seed-templates` 补导入(已存在的不覆盖)。
20
+ `resolve.mjs templates` 列出所有登记模板;登记表在各 Skill 的 `skill.json`。安装和升级自动补入内置模板。
24
21
 
25
22
  ## 修改模板(/workflow template)
26
23
 
27
- 用户要改某份模板(「技术方案模板加一节回滚」「上线方案去掉灰度那节」「审查报告的严重问题表加一列」),或要新建、按样例重做模板时,用 `template.mjs` 一路走完,不要直接编辑生效中的模板文件。未接入 DocNexus 时 `draft` / `save` 退出码 2、输出 `need_docnexus: true`:告诉用户默认模板不能改,要先接入 DocNexus(重跑 npx @rowansenne/workflow-run 并加 `--with-docnexus`),不要另找地方保存。`<模板>` 写别名 `tech` / `release` / `plan` / `delivery` / `review` / `checklist`,或 `<skill>/<文件名>`;没说哪份时先 `template.mjs list` 列出来问。
24
+ 用户要改某份模板(「技术方案模板加一节回滚」「上线方案去掉灰度那节」「审查报告的严重问题表加一列」),或要新建、按样例重做模板时,用 `template.mjs` 一路走完,不要直接编辑生效中的模板文件。`<模板>` 写别名 `tech` / `release` / `plan` / `delivery` / `review` / `checklist`,或 `<skill>/<文件名>`;没说哪份时先 `template.mjs list` 列出来问。
28
25
 
29
26
  1. **起草稿**:`node <skills>/workflow/scripts/template.mjs draft <模板>`,把当前生效的模板复制到 `workflow-run/tmp/template-draft/<skill>/<文件名>`(输出的 `draft`)。草稿已存在会沿用(上次没改完);要从当前模板重来加 `--force`。
30
27
  - 退出码 2、`need_sample: true`:还没有这份模板,按下面「创建模板」第 1 步向用户要样例,再运行 `draft <模板> --from <样例文件>`(从 stdin 读用 `--from -`)。用户要「按这份文档重做模板」时同样用 `--from`。
@@ -42,7 +39,7 @@ node <skills>/workflow/scripts/resolve.mjs template <skill> <文件名>
42
39
  需要已接入 DocNexus。技术方案、上线方案第一次生成时没有模板,由用户给一份**团队现有的同类文档**作样例来创建。用户也可以随时主动要求「创建 / 重新生成技术方案(上线方案)模板」,流程相同。
43
40
 
44
41
  1. **要样例**:停下来告诉用户还没有这份模板,请用户给一份团队现有的同类文档:本地 Markdown 路径,或 wiki 页面的 pageId / URL。
45
- - wiki 页面:运行 `node <skills>/md-to-wiki/scripts/upload.mjs get <pageId> --text`,取输出的 `text` 作样例(需要 wiki 已配置,见 `<skills>/workflow/SKILL.md`「增强能力探测」;没配置就请用户导出成 Markdown 或直接贴出内容)。
42
+ - wiki 页面:运行 `node <skills>/md-to-wiki/scripts/upload.mjs get <pageId> --text`,取输出的 `text` 作样例(需要 wiki 已配置,沿用开工 wiki 通道;无通道时请用户导出成 Markdown 或直接贴出内容)。
46
43
  - 用户手头没有样例:请用户列出要哪些章节、每节写什么,按同样的格式整理成模板。不要自己编一份「通用模板」。
47
44
  2. **抽骨架**:把样例存到临时文件(如 `workflow-run/tmp/template-sample.md`),运行 `node <skills>/workflow/scripts/template.mjs draft <模板> --from <样例文件>`(内部用 `make-template.mjs` 抽骨架,结果写进草稿,之后按「修改模板」第 2–4 步完善、校验、保存)。它保留标题层级和表格列,正文换成 `{{...}}` 填写提示,丢掉标题前的元信息块(元信息由生成文档的 skill 自己写)。
48
45
  3. **完善**:在骨架上改:
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // 按分支汇总各 skill 的产物:开发方案、交付说明、审查报告、技术方案、上线方案,以及它们的 wiki 发布记录。
2
+ // 按分支汇总各 skill 的产物:方案、交付说明、审查报告、技术方案、上线方案,以及它们的 wiki 发布记录。
3
3
  // 产物按需求归档在 workflow-run/docs/requirements/<需求>/<项目>/(两种模式结构相同,见 requirement.mjs)。
4
4
  // 直接扫描 Markdown,从元信息行读分支(「分支:」「对比分支:」),
5
5
  // 从第一行的 <!-- md-to-wiki: ... --> 读发布记录(md-to-wiki 上传后写入)。只读。
@@ -13,81 +13,19 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { REQ_DIR, parseFile, artifactFiles } from './requirement.mjs';
17
-
18
- // 产物种类;顺序即链路顺序
19
- export const KINDS = [
20
- { kind: 'plan', label: '开发方案' },
21
- { kind: 'delivery', label: '交付说明' },
22
- { kind: 'review', label: '审查报告' },
23
- { kind: 'tech', label: '技术方案' },
24
- { kind: 'release', label: '上线方案' },
25
- ];
26
-
27
- const HEAD_LINES = 20;
28
- const toPosix = (p) => p.split(path.sep).join('/');
29
-
30
- // 元信息行里的分支:「对比分支:`dev` ←→ `main`」取 dev;「分支:feature/x」「分支:`feature/x`」
31
- export function parseBranch(text) {
32
- const head = text.split(/\r?\n/).slice(0, HEAD_LINES).join('\n');
33
- const m = head.match(/(?:对比)?分支[::]\s*`?([^`\s||←,,))]+)`?/);
34
- return m && !/^[{<]/.test(m[1]) ? m[1] : null; // 未填写的模板占位符不算
35
- }
36
-
37
- export function parseWiki(text) {
38
- const m = /^<!-- md-to-wiki: ([^\n]*?) -->/.exec(text);
39
- if (!m) return null;
40
- const kv = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
41
- return kv.pageId ? { pageId: kv.pageId, version: kv.version ? Number(kv.version) : null, url: kv.url ?? null, at: kv.at ?? null } : null;
42
- }
43
-
44
-
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
-
47
- // workflow-run/docs/requirements/<需求>/<项目>/<文件> → 产物条目;不是已知产物返回 null。
48
- // 分支里还没迁移的旧结构(<需求>/<文件>,没有项目这一层)也认,project 为 null
49
- function entry(rel, text, mtime) {
50
- const parts = rel.split('/');
51
- if ((parts.length !== 5 && parts.length !== 6) || parts.slice(0, 3).join('/') !== REQ_DIR) return null;
52
- const p = parseFile(parts.at(-1));
53
- const k = p && KINDS.find((x) => x.kind === p.kind);
54
- // 排序键:文档头里的日期 + 版本号(需求目录里的文件名不带日期)
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;
56
- }
57
-
58
- const byDate = (a, b) => a.date.localeCompare(b.date) || a.mtime - b.mtime;
59
-
60
- // allProjects:文档仓库模式下连同其他项目的产物一起读(跨服务视图)
61
- export function collect(root, { allProjects = false } = {}) {
62
- const out = [];
63
- for (const f of artifactFiles(root, { allProjects })) {
64
- const k = KINDS.find((x) => x.kind === f.kind);
65
- const text = fs.readFileSync(f.abs, 'utf8');
66
- // 排序键:文档头里的日期 + 版本号(需求目录里的文件名不带日期)
67
- out.push({ kind: k.kind, label: k.label, path: f.rel, cite: f.cite, requirement: f.slug, project: f.project, version: f.version, branch: parseBranch(text), date: `${headerDate(text)}#${String(f.version).padStart(3, '0')}`, mtime: fs.statSync(f.abs).mtimeMs, wiki: parseWiki(text) });
68
- }
69
- // 同一天、无日期的文件按修改时间排
70
- return out.sort(byDate);
16
+ import { runState } from '../lib/runs.mjs';
17
+ import { KINDS } from '../lib/artifact-model.mjs';
18
+ import { loadWorkspace, projectArtifacts, artifactEntry, readGitArtifacts } from '../lib/workspace-read.mjs';
19
+ export { KINDS, parseBranch, parseWiki } from '../lib/artifact-model.mjs';
20
+ export { localBranches } from '../lib/workspace-read.mjs';
21
+
22
+ export function collect(root, { allProjects = false, workspace = null } = {}) {
23
+ const data = workspace ?? loadWorkspace(root, { allProjects });
24
+ return projectArtifacts(data, allProjects).filter((a) => a.source === 'worktree').map(artifactEntry);
71
25
  }
72
26
 
73
- // 只读扫描一个 git 引用(分支)里提交过的产物;条目带 ref 与 text(正文),mtime 取 0
74
27
  export function collectGit(root, ref) {
75
- const ls = spawnSync('git', ['ls-tree', '-r', '--name-only', ref, '--', REQ_DIR], { cwd: root, encoding: 'utf8' });
76
- if (ls.status !== 0) return [];
77
- const out = [];
78
- for (const rel of ls.stdout.split('\n').filter(Boolean)) {
79
- const show = spawnSync('git', ['show', `${ref}:${rel}`], { cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
80
- if (show.status !== 0) continue;
81
- const e = entry(rel, show.stdout, 0);
82
- if (e) out.push({ ...e, ref, text: show.stdout });
83
- }
84
- return out.sort(byDate);
85
- }
86
-
87
- // 本地分支名列表
88
- export function localBranches(root) {
89
- const r = spawnSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { cwd: root, encoding: 'utf8' });
90
- return r.status === 0 ? r.stdout.split('\n').filter(Boolean) : [];
28
+ return readGitArtifacts(root, ref).map(artifactEntry);
91
29
  }
92
30
 
93
31
  // 下一步建议:按链路顺序找第一个缺的环节,再看哪些文档还没发布到 wiki
@@ -106,16 +44,18 @@ export function nextSteps(latest) {
106
44
  return steps;
107
45
  }
108
46
 
109
- export function branchView(root, branch) {
110
- const items = collect(root).filter((a) => a.branch === branch);
47
+ export function branchView(root, branch, workspace = loadWorkspace(root)) {
48
+ const items = collect(root, { workspace }).filter((a) => a.branch === branch);
111
49
  const latest = {};
112
50
  for (const a of items) latest[a.kind] = a; // 已按时间升序,最后一个即最新
113
51
  const strip = ({ mtime, ...a }) => a;
52
+ const execution = runState(root, branch, workspace);
114
53
  return {
115
54
  branch,
116
55
  artifacts: items.map(strip),
117
56
  latest: Object.fromEntries(KINDS.map((k) => [k.kind, latest[k.kind] ? strip(latest[k.kind]) : null])),
118
- next: nextSteps(latest),
57
+ execution,
58
+ next: execution.current && execution.current.status !== 'completed' ? [execution.next] : nextSteps(latest),
119
59
  };
120
60
  }
121
61
 
@@ -135,15 +75,16 @@ function main(argv) {
135
75
  else if (argv[i] === '--all') all = true;
136
76
  else { console.error('用法: artifacts.mjs [--root <项目根>] [--branch <分支> | --all]'); return 1; }
137
77
  }
78
+ const workspace = loadWorkspace(root);
138
79
  if (all) {
139
- const branches = [...new Set(collect(root).map((a) => a.branch).filter(Boolean))].sort();
140
- const unknown = collect(root).filter((a) => !a.branch).map((a) => a.path);
141
- console.log(JSON.stringify({ branches: branches.map((b) => branchView(root, b)), without_branch: unknown }, null, 2));
80
+ const branches = [...new Set([...workspace.artifacts.map((a) => a.branch), ...workspace.runs.map((r) => r.branch)].filter(Boolean))].sort();
81
+ const unknown = collect(root, { workspace }).filter((a) => !a.branch).map((a) => a.path);
82
+ console.log(JSON.stringify({ branches: branches.map((b) => branchView(root, b, workspace)), without_branch: unknown }, null, 2));
142
83
  return 0;
143
84
  }
144
85
  branch ??= currentBranch(root);
145
86
  if (!branch) { console.error('无法确定当前分支(不在 git 仓库或处于 detached HEAD),请用 --branch 指定'); return 1; }
146
- console.log(JSON.stringify(branchView(root, branch), null, 2));
87
+ console.log(JSON.stringify(branchView(root, branch, workspace), null, 2));
147
88
  return 0;
148
89
  }
149
90
 
@@ -1,55 +1,2 @@
1
- // 读 skill 产物(交付说明、审查报告等)的小工具:按二级标题切节、取表格行、取元信息行。
2
- // report.mjs 与 pr-desc.mjs 共用;标题识别与归一化复用 lint-doc.mjs(跳过代码块、去编号 / 必填 / 「如有」标记),
3
- // 模板标题的写法只在一处定义。只做文本解析,不读写文件。
4
-
5
- import { headings, headingKey } from './lint-doc.mjs';
6
-
7
- const stripComments = (s) => s.replace(/<!--[\s\S]*?-->/g, '');
8
-
9
- // 二级标题(归一化后的 key)→ 正文(去掉 HTML 注释与首尾空行)
10
- export function sections(text) {
11
- const lines = text.split(/\r?\n/);
12
- const hs = headings(text);
13
- const out = new Map();
14
- hs.forEach((h, i) => {
15
- if (h.level !== 2) return;
16
- const next = hs.slice(i + 1).find((x) => x.level <= 2);
17
- const body = lines.slice(h.line, next ? next.line - 1 : lines.length).join('\n');
18
- if (!out.has(h.key)) out.set(h.key, stripComments(body).trim());
19
- });
20
- return out;
21
- }
22
-
23
- // 按标题前缀取一节(「严重问题」匹配「严重问题(CRITICAL / HIGH)」),前缀同样归一化
24
- export function section(secs, prefix) {
25
- const key = headingKey(prefix);
26
- for (const [k, v] of secs) if (k.startsWith(key)) return v;
27
- return null;
28
- }
29
-
30
- // 「无」「无。」或只剩占位符的节当作空
31
- export const isEmpty = (body) => !body || /^(无|无。|none|n\/a)$/i.test(body.trim()) || /^\{\{[^}]*\}\}$/.test(body.trim());
32
-
33
- // 表格数据行(跳过表头、分隔行、全空行和带 {{占位符}} 的模板行),返回单元格数组
34
- export function tableRows(body) {
35
- if (!body) return [];
36
- const lines = body.split('\n').filter((l) => /^\s*\|/.test(l));
37
- return lines.slice(1)
38
- .filter((l) => !/^\s*\|[\s:|-]+\|\s*$/.test(l))
39
- .map((l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()))
40
- .filter((cells) => cells.some((c) => c) && !cells.some((c) => /\{\{/.test(c)));
41
- }
42
-
43
- // 列表项(- / * / 1.),跳过「无」与占位符
44
- export function listItems(body) {
45
- if (!body) return [];
46
- return body.split('\n').map((l) => /^\s*(?:[-*]|\d+[.)])\s+(.*)$/.exec(l)?.[1]?.trim()).filter((x) => x && !isEmpty(x) && !/\{\{/.test(x));
47
- }
48
-
49
- // 开头引用块里的元信息:「> DocNexus:xxx」取 xxx(遇到「|」截断)
50
- export function meta(text, key) {
51
- const head = text.split(/\r?\n/).slice(0, 20).join('\n');
52
- const m = new RegExp(`${key}[::]\\s*([^|\\n]*)`).exec(head);
53
- const v = m?.[1]?.trim();
54
- return v && !/^\{\{/.test(v) ? v : null;
55
- }
1
+ // 保留原导出入口;纯文本解析统一在 document-model。
2
+ export { sections, section, isEmpty, tableRows, listItems, meta } from '../lib/document-model.mjs';
@@ -1,24 +1,2 @@
1
- // DocNexus format 7:项目根运行,内部状态在 .docnexus/,知识输出固定到 workflow-run/docnexus/。
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
-
5
- export const DOCNEXUS_INTERNAL = '.docnexus';
6
- export const DOCNEXUS_OUTPUT = 'workflow-run';
7
- export const docnexusRoot = (root) => path.resolve(root);
8
- export const docnexusWorkspace = (root) => path.resolve(root, DOCNEXUS_OUTPUT, 'docnexus');
9
- export const docnexusInternal = (root) => path.resolve(root, DOCNEXUS_INTERNAL);
10
-
11
- export function docnexusConfigured(root) {
12
- try {
13
- const marker = JSON.parse(fs.readFileSync(path.join(docnexusInternal(root), 'project.json'), 'utf8'));
14
- return marker.format_version === 7 && marker.output_dir === DOCNEXUS_OUTPUT;
15
- } catch { return false; }
16
- }
17
-
18
- export function docnexusBinPath(root) {
19
- const name = process.platform === 'win32' ? 'docnexus.cmd' : 'docnexus';
20
- const bin = path.join(docnexusInternal(root), 'node_modules', '.bin', name);
21
- return fs.existsSync(bin) ? bin : null;
22
- }
23
-
24
- export const docnexusConnected = (root) => docnexusConfigured(root) && docnexusBinPath(root) !== null;
1
+ // 兼容原有导出入口;接入规则统一在共享核心。
2
+ export { DOCNEXUS_INTERNAL, DOCNEXUS_OUTPUT, docnexusRoot, docnexusWorkspace, docnexusInternal, docnexusConfigured, docnexusBinPath, docnexusConnected } from '../lib/capabilities.mjs';
@@ -15,6 +15,7 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { spawnSync } from 'node:child_process';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import { DOCNEXUS_HINT } from '../lib/capabilities.mjs';
18
19
  import { skillMeta, resolveCategory, docnexusConnected, docnexusBin, docnexusRoot } from './resolve.mjs';
19
20
 
20
21
  const IS_WIN = process.platform === 'win32';
@@ -37,7 +38,7 @@ function query(root, category) {
37
38
  export function knowledge(root, skill, { match = [], limit = 20, query: question = '', recallLimit = 5 } = {}) {
38
39
  const meta = skillMeta(skill);
39
40
  if (!meta) return { ok: false, error: 'unknown', message: `未安装 skill「${skill}」` };
40
- if (!docnexusConnected(root)) return { ok: false, error: 'no_docnexus', message: '未接入 DocNexus,跳过知识库参考' };
41
+ if (!docnexusConnected(root)) return { ok: false, error: 'no_docnexus', message: DOCNEXUS_HINT };
41
42
  const terms = match.map((t) => t.toLowerCase()).filter(Boolean);
42
43
  const hit = (d) => !terms.length || terms.some((t) => [d.title, d.summary, d.file_path, ...(d.tags ?? [])].some((v) => String(v ?? '').toLowerCase().includes(t)));
43
44
  const seen = new Set();
@@ -59,7 +60,8 @@ export function knowledge(root, skill, { match = [], limit = 20, query: question
59
60
  }
60
61
  categories.push(entry);
61
62
  }
62
- const out = { ok: true, skill, categories };
63
+ const failed = categories.some((c) => !c.ok || c.recall?.ok === false);
64
+ const out = { ok: !failed, ...(failed ? { error: 'query_failed', message: 'DocNexus 查询失败,修复后重试,不能当作没有相关知识' } : {}), skill, categories };
63
65
  if (question) out.recall_by_category = true;
64
66
  return out;
65
67
  }
@@ -17,44 +17,11 @@ import path from 'node:path';
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { resolveTemplate, skillMeta } from './resolve.mjs';
20
+ import { proseLines, headings } from '../lib/document-model.mjs';
21
+ export { headings, headingKey } from '../lib/document-model.mjs';
20
22
 
21
23
  const TODO = /待填写|待补充|待人工确认/;
22
24
 
23
- // 去掉代码块后返回 [{ line, text }],行号从 1 开始
24
- function proseLines(md) {
25
- const out = [];
26
- let fence = null;
27
- md.replace(/\r/g, '').split('\n').forEach((text, i) => {
28
- const m = text.match(/^\s*(```+|~~~+)/);
29
- if (m) { if (!fence) fence = m[1][0]; else if (m[1][0] === fence) fence = null; return; }
30
- if (!fence) out.push({ line: i + 1, text });
31
- });
32
- return out;
33
- }
34
-
35
- // 章节标题归一化:去掉编号(1. / 4.1 / 一、/ 第一部分)、必填标记 *、可选标记(如有),用于模板与文档比对
36
- export function headingKey(text) {
37
- return text
38
- .replace(/\*+\s*$/, '')
39
- .replace(/[((]\s*如有[^))]*[))]/g, '')
40
- .replace(/^\s*(?:第?[一二三四五六七八九十]+[、..]|[0-9]+(?:\.(?:[0-9]+|x))*[.、.]?)\s*/, '')
41
- .replace(/\s+/g, '')
42
- .trim();
43
- }
44
-
45
- export function headings(md) {
46
- return proseLines(md)
47
- .map(({ line, text }) => ({ line, m: text.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/) }))
48
- .filter((h) => h.m)
49
- .map(({ line, m }) => ({
50
- line, level: m[1].length, text: m[2],
51
- key: headingKey(m[2]),
52
- required: /\*\s*$/.test(m[2]),
53
- optional: /[((]\s*如有/.test(m[2]),
54
- placeholder: /\{\{|<[^>]*填写[^>]*>/.test(m[2]),
55
- }));
56
- }
57
-
58
25
  export function lint(doc, template, { requiredMeta = [] } = {}) {
59
26
  const errors = [];
60
27
  const warnings = [];
@@ -0,0 +1,2 @@
1
+ // 保留原脚本导出入口。
2
+ export { readMeta, updateMeta } from '../lib/meta-store.mjs';
@@ -13,16 +13,18 @@ import path from 'node:path';
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { branchView } from './artifacts.mjs';
16
- import { readProfile } from './resolve.mjs';
17
- import { sections, section, tableRows, isEmpty } from './doc-sections.mjs';
16
+ import { readProfile } from '../lib/project.mjs';
17
+ import { sections, section, tableRows, isEmpty } from '../lib/document-model.mjs';
18
+ import { loadWorkspace } from '../lib/workspace-read.mjs';
18
19
 
19
20
  const git = (root, args) => { const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); return r.status === 0 ? r.stdout.trim() : null; };
20
21
  const keep = (body) => (body && !isEmpty(body) ? body : null);
21
22
  const LEVEL_RE = /CRITICAL|HIGH/i;
22
23
 
23
- export function buildDescription(root, branch, base) {
24
- const view = branchView(root, branch);
25
- const read = (a) => (a ? fs.readFileSync(path.join(root, a.path), 'utf8') : null);
24
+ export function buildDescription(root, branch, base, workspace = loadWorkspace(root)) {
25
+ const view = branchView(root, branch, workspace);
26
+ const texts = new Map(workspace.artifacts.map((a) => [a.path, a.text]));
27
+ const read = (a) => a ? texts.get(a.path) : null;
26
28
  const { delivery, review, plan, tech, release } = view.latest;
27
29
  const L = [];
28
30
  const add = (title, body) => { if (body) L.push(`## ${title}`, '', body, ''); };
@@ -55,7 +57,7 @@ export function buildDescription(root, branch, base) {
55
57
  add('影响面', keep(section(s, '影响面')));
56
58
  }
57
59
 
58
- const docs = [['开发方案', plan], ['交付说明', delivery], ['审查报告', review], ['技术方案', tech], ['上线方案', release]]
60
+ const docs = [['方案', plan], ['交付说明', delivery], ['审查报告', review], ['技术方案', tech], ['上线方案', release]]
59
61
 
60
62
  .filter(([, a]) => a).map(([label, a]) => `- ${label}:\`${a.cite ?? a.path}\`${a.wiki?.url ? `(wiki:${a.wiki.url})` : a.wiki ? `(wiki pageId ${a.wiki.pageId})` : ''}`);
61
63
  add('相关文档', docs.length ? docs.join('\n') : null);
@@ -3,10 +3,10 @@
3
3
  // 数据全部来自已经落盘的产物,不上报、不另存记录:
4
4
  // - 审查:每份报告的 CRITICAL / HIGH / MEDIUM / LOW 条数、复核通过率;复审报告里上次问题的「已修复 / 仍存在 / 不再适用」→ 修复率
5
5
  // - 交付:功能 / 缺陷、S / M / L、需求澄清中确认的决定条数、带「未验证项」的交付
6
- // - 沉淀:交付说明与审查报告的「DocNexus」元信息行 → 已沉淀 / 未沉淀 / 未接入
6
+ // - 沉淀:交付说明与审查报告的「DocNexus」元信息行 → 已沉淀 / 未沉淀 / 未询问 / 无需沉淀 / 未接入
7
7
  // - 知识复用:正文里引用 docnexus/library/ 的产物(开工前召回到并用上了)
8
8
  // - 链路完整度:每个分支有哪些产物、文档有没有发布到 wiki
9
- // S 规模走快速通道的交付不落盘,不在统计内。
9
+ // S 快速通道不落盘(不绑定需求,也不写审查报告 / 交付说明),不在统计内。
10
10
  // 默认只看工作区;产物随特性分支提交、还没合并时,加 --all-branches 用 git 只读扫描所有本地分支(同一路径以工作区为准)。
11
11
  //
12
12
  // 用法(在项目根执行;--root 可指定项目根):
@@ -16,97 +16,47 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { collect, collectGit, localBranches, KINDS } from './artifacts.mjs';
20
- import { sections, section, tableRows, listItems, meta, isEmpty } from './doc-sections.mjs';
19
+ import { KINDS } from '../lib/artifact-model.mjs';
20
+ import { loadWorkspace, projectArtifacts, artifactEntry } from '../lib/workspace-read.mjs';
21
+ export { captureState, citesKnowledge, parseReview, parseDelivery } from '../lib/artifact-model.mjs';
21
22
 
22
23
  const LEVELS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
23
24
  const pct = (a, b) => (b ? Math.round((a / b) * 100) : null);
24
25
 
25
- // DocNexus 元信息行 → captured / not_captured / not_connected / missing
26
- export function captureState(text) {
27
- const v = meta(text, 'DocNexus');
28
- if (!v) return 'missing';
29
- if (/docnexus\/library\//.test(v)) return 'captured';
30
- if (/未接入/.test(v)) return 'not_connected';
31
- if (/未沉淀/.test(v)) return 'not_captured';
32
- return 'missing';
33
- }
34
-
35
- // 正文(元信息行之外)有没有引用知识库文档
36
- export const citesKnowledge = (text) => text.split(/\r?\n/).some((l) => !/^>\s*DocNexus[::]/.test(l) && /docnexus\/library\//.test(l));
37
-
38
- export function parseReview(text) {
39
- const secs = sections(text);
40
- const levels = Object.fromEntries(LEVELS.map((l) => [l, 0]));
41
- let verified = 0; let pending = 0;
42
- for (const cells of tableRows(section(secs, '严重问题'))) {
43
- const lv = LEVELS.find((l) => cells[1]?.toUpperCase().includes(l));
44
- if (lv) levels[lv]++;
45
- const last = cells[cells.length - 1] ?? '';
46
- if (last.includes('✅')) verified++; else if (last.includes('⚠')) pending++;
47
- }
48
- levels.MEDIUM += tableRows(section(secs, '一般问题')).length;
49
- levels.LOW += tableRows(section(secs, '优化建议')).length;
50
- const followUp = { fixed: 0, open: 0, obsolete: 0 };
51
- for (const cells of tableRows(section(secs, '与上次审查对比'))) {
52
- const s = cells[2] ?? '';
53
- if (s.includes('已修复')) followUp.fixed++; else if (s.includes('仍存在')) followUp.open++; else if (s.includes('不再适用')) followUp.obsolete++;
54
- }
55
- return { levels, verified, pending, follow_up: followUp, is_rereview: followUp.fixed + followUp.open + followUp.obsolete > 0 };
56
- }
57
-
58
- export function parseDelivery(text) {
59
- const secs = sections(text);
60
- const type = meta(text, '类型');
61
- const scale = meta(text, '规模');
62
- const unverified = section(secs, '验证')?.match(/未验证项[::]\s*(.*)/)?.[1]?.trim();
63
- return {
64
- type: type && /缺陷/.test(type) ? 'bug' : type ? 'feature' : null,
65
- scale: scale && /^[SML]$/.test(scale) ? scale : null,
66
- decisions: listItems(section(secs, '已确认的决定')).length,
67
- has_unverified: !!unverified && !isEmpty(unverified) && !/^\{\{/.test(unverified),
68
- };
69
- }
70
-
71
26
  // 工作区 + (可选)各本地分支提交过的产物;同一路径只取第一次见到的(工作区优先)
72
- export function gather(root, allBranches) {
73
- const seen = new Set(); const out = [];
74
- const add = (list) => { for (const a of list) if (!seen.has(a.path)) { seen.add(a.path); out.push(a); } };
75
- add(collect(root));
76
- const branches = allBranches ? localBranches(root) : [];
77
- for (const b of branches) add(collectGit(root, b));
78
- return { items: out, scanned: branches };
27
+ export function gather(root, allBranches, workspace = loadWorkspace(root, { allBranches })) {
28
+ return { items: projectArtifacts(workspace).map(artifactEntry), scanned: workspace.scanned_branches };
79
29
  }
80
30
 
81
- export function buildReport(root, { since = null, allBranches = false } = {}) {
82
- const g = gather(root, allBranches);
31
+ export function buildReport(root, { since = null, allBranches = false, workspace = loadWorkspace(root, { allBranches }) } = {}) {
32
+ const g = gather(root, allBranches, workspace);
83
33
  const all = g.items.filter((a) => !since || !a.date || a.date.slice(0, 10) >= since);
84
- const read = (a) => a.text ?? fs.readFileSync(path.join(root, a.path), 'utf8');
34
+ const models = new Map(workspace.artifacts.map((a) => [a.path, a]));
85
35
  const counts = Object.fromEntries(KINDS.map((k) => [k.kind, all.filter((a) => a.kind === k.kind).length]));
86
36
 
87
37
  const review = { reports: 0, rereviews: 0, levels: Object.fromEntries(LEVELS.map((l) => [l, 0])), verified: 0, pending: 0, follow_up: { fixed: 0, open: 0, obsolete: 0 } };
88
38
  const delivery = { total: 0, feature: 0, bug: 0, scale: { S: 0, M: 0, L: 0 }, with_decisions: 0, decisions: 0, with_unverified: 0 };
89
- const capture = { captured: 0, not_captured: 0, not_connected: 0, missing: 0 };
39
+ const capture = { captured: 0, not_captured: 0, not_asked: 0, not_needed: 0, not_connected: 0, missing: 0 };
90
40
  let cites = 0;
91
41
  for (const a of all) {
92
- const text = read(a);
93
- if (citesKnowledge(text)) cites++;
42
+ const model = models.get(a.path);
43
+ if (model.citesKnowledge) cites++;
94
44
  if (a.kind === 'review') {
95
- const r = parseReview(text);
45
+ const r = model.review;
96
46
  review.reports++; if (r.is_rereview) review.rereviews++;
97
47
  for (const l of LEVELS) review.levels[l] += r.levels[l];
98
48
  review.verified += r.verified; review.pending += r.pending;
99
49
  for (const k of Object.keys(review.follow_up)) review.follow_up[k] += r.follow_up[k];
100
50
  }
101
51
  if (a.kind === 'delivery') {
102
- const d = parseDelivery(text);
52
+ const d = model.delivery;
103
53
  delivery.total++;
104
54
  if (d.type) delivery[d.type]++;
105
55
  if (d.scale) delivery.scale[d.scale]++;
106
56
  if (d.decisions) { delivery.with_decisions++; delivery.decisions += d.decisions; }
107
57
  if (d.has_unverified) delivery.with_unverified++;
108
58
  }
109
- if (a.kind === 'delivery' || a.kind === 'review') capture[captureState(text)]++;
59
+ if (a.kind === 'delivery' || a.kind === 'review') capture[model.capture]++;
110
60
  }
111
61
  const f = review.follow_up;
112
62
  review.fix_rate = pct(f.fixed, f.fixed + f.open);
@@ -127,6 +77,7 @@ export function buildReport(root, { since = null, allBranches = false } = {}) {
127
77
  knowledge: { citing: cites, rate: pct(cites, all.length) },
128
78
  branches,
129
79
  without_branch: all.filter((a) => !a.branch).map((a) => a.path),
80
+ ...(workspace.diagnostics.length ? { diagnostics: workspace.diagnostics } : {}),
130
81
  };
131
82
  }
132
83
 
@@ -135,6 +86,7 @@ const show = (v) => (v == null ? '—' : `${v}%`);
135
86
  export function toMarkdown(r) {
136
87
  const L = [];
137
88
  L.push(`# 工作流效果报告${r.since ? `(${r.since} 起)` : ''}`, '');
89
+ for (const d of r.diagnostics ?? []) L.push(`> 数据检查:${d.path}:${d.message}`, '');
138
90
  L.push(r.scanned_branches.length
139
91
  ? `范围:工作区 + ${r.scanned_branches.length} 个本地分支(其中 ${r.from_branches} 份只在分支里)。`
140
92
  : '范围:只看工作区。随特性分支提交、还没合并的产物不在内,加 `--all-branches` 一起统计。', '');
@@ -142,7 +94,7 @@ export function toMarkdown(r) {
142
94
  L.push('`workflow-run/docs/requirements/` 下还没有产物。dev 交付(M / L 规模)、审查报告、技术方案、上线方案落盘后,这里才有数据。');
143
95
  return L.join('\n');
144
96
  }
145
- L.push(`产物 ${r.artifacts} 份:${KINDS.map((k) => `${k.label} ${r.counts[k.kind]}`).join('、')}。S 规模走快速通道的交付不落盘,不在统计内。`, '');
97
+ L.push(`产物 ${r.artifacts} 份:${KINDS.map((k) => `${k.label} ${r.counts[k.kind]}`).join('、')}。S 快速通道不落盘,不在统计内。`, '');
146
98
  const v = r.review;
147
99
  L.push('## 审查', '');
148
100
  L.push(`- 报告 ${v.reports} 份(其中复审 ${v.rereviews} 份);发现 CRITICAL ${v.levels.CRITICAL} / HIGH ${v.levels.HIGH} / MEDIUM ${v.levels.MEDIUM} / LOW ${v.levels.LOW}`);
@@ -155,7 +107,7 @@ export function toMarkdown(r) {
155
107
  L.push(`- 带「未验证项」的交付:${d.with_unverified} 份`, '');
156
108
  const c = r.capture;
157
109
  L.push('## 知识沉淀与复用', '');
158
- L.push(`- 交付与审查问过沉淀的 ${c.captured + c.not_captured} 次中,沉淀 ${c.captured} 次(${show(c.capture_rate)});未接入 DocNexus ${c.not_connected},元信息缺失 ${c.missing}`);
110
+ L.push(`- 交付与审查问过沉淀的 ${c.captured + c.not_captured} 次中,沉淀 ${c.captured} 次(${show(c.capture_rate)});未询问 ${c.not_asked},没有值得沉淀的内容 ${c.not_needed},未接入 DocNexus ${c.not_connected},元信息缺失 ${c.missing}`);
159
111
  L.push(`- 正文引用知识库文档的产物:${r.knowledge.citing} 份(${show(r.knowledge.rate)})`, '');
160
112
  L.push('## 各分支链路', '');
161
113
  L.push(`| 分支(需求) | ${KINDS.map((k) => k.label).join(' | ')} | 未发布到 wiki |`);