@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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // 产物按需求归档:开发方案、交付说明、审查报告、技术方案、上线方案按需求放在一起,meta.json 汇总需求名、绑定的分支、各产物、
2
+ // 产物按需求归档:方案、交付说明、审查报告、技术方案、上线方案按需求放在一起,meta.json 汇总需求名、绑定的分支、各产物、
3
3
  // wiki 发布记录与 DocNexus 沉淀,供 /workflow view 等直接读取。
4
4
  // 本工具的产出都在项目根的 workflow-run/ 里(需求产物 docs/requirements/、DocNexus 工作区 docnexus/、项目模板 templates/、临时文件 tmp/),
5
5
  // 不往项目的其他地方写。两种用法:
@@ -28,411 +28,18 @@ import fs from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import { spawnSync } from 'node:child_process';
30
30
  import { fileURLToPath } from 'node:url';
31
-
32
- const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
33
- // 本工具在项目里的唯一目录
34
- export const HOME = 'workflow-run';
35
- export const REQ_DIR = `${HOME}/docs/requirements`;
36
- export const TEMPLATES_DIR = `${HOME}/templates`;
37
- export const TMP_DIR = `${HOME}/tmp`;
38
- export const IGNORE_LINE = `${HOME}/docs/`;
39
- export const homeDir = (root) => path.join(root, HOME);
40
- // workflow-run/ 自己是 git 仓库(团队文档仓库克隆到这里)= 独立文档仓库模式
41
- export const homeIsRepo = (root) => fs.existsSync(path.join(root, HOME, '.git'));
42
- // 产物种类 → 需求目录里的文件名(只放最新版 <base>.md;版本号在 meta.json 的 versions,旧版归档为 <base>_v<N>.md)
43
- export const FILES = { plan: 'plan', delivery: 'delivery', review: 'review', tech: 'tech-doc', release: 'release-doc' };
44
- const LABELS = { plan: '开发方案', delivery: '交付说明', review: '审查报告', tech: '技术方案', release: '上线方案' };
45
- // 安装后的 skills 目录(安装器从安装包调用本脚本时,到项目里找画像)
46
- const INSTALL_BASES = ['.agents/skills', '.claude/skills', '.qwen/skills', '.codebuddy/skills'];
47
-
48
- const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
49
- const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
50
- const toPosix = (p) => p.split(path.sep).join('/');
51
- const now = () => new Date().toISOString();
52
- const relOf = (root, abs) => toPosix(path.relative(root, abs));
53
- const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
54
- const skipDir = (d) => !d.isDirectory() || d.name.startsWith('.');
55
-
56
- // 需求名 / 项目名 → 目录名:保留中文,去掉路径与文件系统不允许的字符,空白换成 -
57
- export function dirName(name) {
58
- const s = String(name).normalize('NFC').trim()
59
- .replace(/[\\/:*?"<>|#%{}^~[\]`'$&@!=+,;]+/g, '-')
60
- .replace(/\s+/g, '-')
61
- .replace(/-{2,}/g, '-')
62
- .replace(/^[-.]+|[-.]+$/g, '');
63
- return [...s].slice(0, 60).join('') || 'unnamed';
64
- }
65
-
66
- // 文件名 → { kind, version };不是需求目录里的产物返回 null
67
- export function parseFile(name) {
68
- for (const [kind, base] of Object.entries(FILES)) {
69
- const m = new RegExp(`^${base}(?:_v(\\d+))?\\.md$`).exec(name);
70
- if (m) return { kind, version: m[1] ? Number(m[1]) : 1 };
71
- }
72
- return null;
73
- }
74
-
31
+ import { HOME, setConfig, artifactsConfig, layout } from '../lib/project.mjs';
32
+ import { which, bind, nextPath, list, pending, sync } from '../lib/requirements.mjs';
33
+ import { requirements, find } from '../lib/requirement-read.mjs';
34
+ export { HOME, REQ_DIR, TEMPLATES_DIR, TMP_DIR, IGNORE_LINE, homeDir, homeIsRepo, dirName, profileDir, artifactsConfig, layout, suggestProject, setConfig, ensureIgnore } from '../lib/project.mjs';
35
+ export { requirements, repoRequirements, artifactFiles, find } from '../lib/requirement-read.mjs';
36
+ export { OLD_DIR, sync, which, bind, nextPath, list, pending, legacyRequirements, migrateLayout } from '../lib/requirements.mjs';
37
+ export { FILES, parseFile, parseBranch, parseDocnexus } from '../lib/artifact-model.mjs';
75
38
  function currentBranch(root) {
76
- const r = git(root, ['symbolic-ref', '--short', 'HEAD']);
39
+ const r = spawnSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' });
77
40
  return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
78
41
  }
79
42
 
80
- const head = (text, n = 20) => text.split(/\r?\n/).slice(0, n).join('\n');
81
- function parseBranch(text) {
82
- const m = head(text).match(/(?:对比)?分支[::]\s*`?([^`\s||←,,))]+)`?/);
83
- return m && !/^[{<]/.test(m[1]) ? m[1] : null;
84
- }
85
- function parseWiki(text) {
86
- const m = /^<!-- md-to-wiki: ([^\n]*?) -->/.exec(text);
87
- if (!m) return null;
88
- const kv = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
89
- return kv.pageId ? { pageId: kv.pageId, version: kv.version ? Number(kv.version) : null, url: kv.url ?? null, at: kv.at ?? null } : null;
90
- }
91
- export function parseDocnexus(text) {
92
- const v = /DocNexus[::]\s*([^|\n]*)/.exec(head(text))?.[1]?.trim();
93
- return v && /docnexus\/library\//.test(v) ? v.split(/[、,,]\s*/).map((s) => s.trim()).filter(Boolean) : null;
94
- }
95
- // 文档头里的日期(跳过第一行的 md-to-wiki 发布记录等 HTML 注释,那里的 at= 是发布时间)
96
- const headerDate = (text) => /(\d{4}-\d{2}-\d{2})/.exec(head(text.replace(/<!--[\s\S]*?-->/g, ''), 8))?.[1] ?? null;
97
-
98
- // ------------------------------------------------------------------ 配置与布局
99
- // 画像所在目录:本脚本所在的 skills 目录;从安装包调用时(安装器)到项目里已安装的 skills 目录找
100
- function profileDir(root) {
101
- const own = path.join(SKILLS_DIR, 'workflow');
102
- if (fs.existsSync(path.join(own, 'profile.json')) || fs.existsSync(path.join(own, 'profile.local.json'))) return own;
103
- for (const b of INSTALL_BASES) { const d = path.join(root, b, 'workflow'); if (fs.existsSync(path.join(d, 'profile.json'))) return d; }
104
- return own;
105
- }
106
-
107
- // workflow/profile.json 的 artifacts,profile.local.json(个人设置,不提交)覆盖其上
108
- export function artifactsConfig(root) {
109
- const dir = profileDir(root);
110
- const team = readJson(path.join(dir, 'profile.json'))?.artifacts ?? {};
111
- const mine = readJson(path.join(dir, 'profile.local.json'))?.artifacts ?? {};
112
- const c = { ...team, ...mine };
113
- return { project: c.project ? String(c.project) : '', commit_prompt: c.commit_prompt !== false, profile_dir: dir };
114
- }
115
-
116
- // 产物位置:两种模式都是 workflow-run/docs/requirements/<需求>/<项目>/。
117
- // mode 只决定谁来提交(project = 随代码提交,repo = workflow-run/ 是独立的文档仓库)、引用怎么写、项目名能不能自动取
118
- export function layout(root, cfg = artifactsConfig(root)) {
119
- const home = homeDir(root);
120
- const base = path.join(root, REQ_DIR);
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
- }
125
- const repoName = remoteName(home);
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 };
127
- L.reqDir = (slug) => path.join(base, slug, L.project ?? '_');
128
- // 文档里引用产物用「文档仓库名:仓库内路径」,各服务、各人的克隆里都一样
129
- L.ref = (abs) => `${repoName}:${toPosix(path.relative(home, abs))}`;
130
- if (!L.project) return { ...L, ok: false, need_project: true, suggested_project: suggestProject(root), hint: '文档仓库模式需要项目名(产物放在 workflow-run/docs/requirements/<需求名>/<项目名>/),只需配置一次:问用户这个项目在文档仓库里叫什么(默认 suggested_project),然后运行 config --project <项目名>' };
131
- return { ...L, ok: true };
132
- }
133
-
134
- // 文档仓库名:它的 git 远端仓库名,没有远端时用 workflow-run
135
- const remoteNames = new Map();
136
- function remoteName(home) {
137
- if (!remoteNames.has(home)) {
138
- const url = git(home, ['remote', 'get-url', 'origin']).stdout?.trim();
139
- remoteNames.set(home, url ? url.replace(/[/\\]+$/, '').replace(/\.git$/, '').split(/[/\\:]/).pop() || HOME : HOME);
140
- }
141
- return remoteNames.get(home);
142
- }
143
-
144
- // 项目名建议:git 远端仓库名,取不到用项目目录名
145
- export function suggestProject(root) {
146
- const url = git(root, ['remote', 'get-url', 'origin']).stdout?.trim();
147
- const name = url ? url.replace(/[/\\]+$/, '').replace(/\.git$/, '').split(/[/\\:]/).pop() : path.basename(path.resolve(root));
148
- return dirName(name);
149
- }
150
-
151
- // ------------------------------------------------------------------ 读需求
152
- // 当前项目的需求目录:{ dir(相对项目根), abs, slug, name, branches, updated, meta }
153
- export function requirements(root, L = layout(root)) {
154
- if (!L.ok && L.mode === 'repo') return [];
155
- const out = [];
156
- if (!isDir(L.base)) return out;
157
- for (const d of fs.readdirSync(L.base, { withFileTypes: true })) {
158
- if (skipDir(d)) continue;
159
- const abs = L.reqDir(d.name);
160
- if (!isDir(abs)) continue;
161
- const meta = readJson(path.join(abs, 'meta.json')) ?? {};
162
- out.push({ dir: relOf(root, abs), abs, slug: d.name, name: meta.name ?? d.name, branches: meta.branches ?? [], updated: meta.updated ?? null, meta });
163
- }
164
- return out;
165
- }
166
-
167
- // 所有项目的需求(跨服务视图、bind 时找同名需求):{ slug, name, projects:[{ project, abs, dir, meta }] }。
168
- // 两种模式结构相同;随代码提交时通常只有本项目一个
169
- export function repoRequirements(root, L = layout(root)) {
170
- if (!isDir(L.base)) return [];
171
- const out = [];
172
- for (const d of fs.readdirSync(L.base, { withFileTypes: true })) {
173
- if (skipDir(d)) continue;
174
- const projects = fs.readdirSync(path.join(L.base, d.name), { withFileTypes: true }).filter((p) => p.isDirectory() && !p.name.startsWith('.')).map((p) => {
175
- const abs = path.join(L.base, d.name, p.name);
176
- return { project: p.name, abs, dir: relOf(root, abs), meta: readJson(path.join(abs, 'meta.json')) ?? {} };
177
- });
178
- if (!projects.length) continue;
179
- const name = projects.map((p) => p.meta.name).find(Boolean) ?? d.name;
180
- const updated = projects.map((p) => p.meta.updated ?? '').sort().at(-1) || null;
181
- out.push({ slug: d.name, name, updated, projects });
182
- }
183
- return out;
184
- }
185
-
186
- // 产物文件(view / artifacts.mjs 读):{ abs, rel(相对项目根), cite(文档里引用它的写法), slug, project, kind, version }
187
- export function artifactFiles(root, { allProjects = false } = {}) {
188
- const L = layout(root);
189
- const dirs = allProjects
190
- ? repoRequirements(root, L).flatMap((r) => r.projects.map((p) => ({ abs: p.abs, slug: r.slug, project: p.project })))
191
- : requirements(root, L).map((r) => ({ abs: r.abs, slug: r.slug, project: L.project }));
192
- const out = [];
193
- for (const d of dirs) {
194
- const versions = versionsOf(d.abs);
195
- for (const name of fs.readdirSync(d.abs)) {
196
- const p = parseFile(name);
197
- if (!p) continue;
198
- const abs = path.join(d.abs, name);
199
- out.push({ abs, rel: relOf(root, abs), cite: L.ref(abs), slug: d.slug, project: d.project, ...p, ...(name === `${FILES[p.kind]}.md` ? { version: versions[p.kind] ?? 1 } : {}) });
200
- }
201
- }
202
- return out;
203
- }
204
-
205
- // 分支绑定的需求:多个需求都绑了这个分支时取最近更新的
206
- export function find(root, branch, L = layout(root)) {
207
- const hits = requirements(root, L).filter((r) => r.branches.includes(branch));
208
- hits.sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
209
- return hits[0] ?? null;
210
- }
211
-
212
- // 同种产物只留最新版,文件名固定(review.md、tech-doc.md …),文档里的引用不会失效。版本号记在 meta.json 的 versions。
213
- // path 时把当前版复制到 workflow-run/tmp/old-artifacts/<需求目录(相对 requirements/)>/<种类>_v<N>.md(不提交),
214
- // 写完 sync <path> 时新版内容和这份副本不同才算新版本(versions 加一);没写就放弃的话版本号不变
215
- export const OLD_DIR = `${TMP_DIR}/old-artifacts`;
216
- const archivePath = (root, dirAbs, kind, v) => path.join(root, OLD_DIR, path.relative(path.join(root, REQ_DIR), dirAbs), `${FILES[kind]}_v${v}.md`);
217
- const versionsOf = (dirAbs) => readJson(path.join(dirAbs, 'meta.json'))?.versions ?? {};
218
-
219
- // 按目录里的文件重建 meta.json(保留需求名、分支、创建时间)
220
- // bump:写完新版后(sync <path>)确认新版本——和 path 时归档的上一版副本内容不同就把版本号加一,返回归档了哪些
221
- export function sync(root, dirRel, { bump = false } = {}) {
222
- const dir = path.resolve(root, dirRel);
223
- const f = path.join(dir, 'meta.json');
224
- const old = readJson(f) ?? {};
225
- const versions = { ...(old.versions ?? {}) };
226
- const archived = [];
227
- if (bump) {
228
- for (const kind of Object.keys(FILES)) {
229
- const cur = path.join(dir, `${FILES[kind]}.md`);
230
- const v = versions[kind] ?? 1;
231
- const arc = archivePath(root, dir, kind, v);
232
- if (!fs.existsSync(cur) || !fs.existsSync(arc) || fs.readFileSync(arc).equals(fs.readFileSync(cur))) continue;
233
- versions[kind] = v + 1;
234
- archived.push({ kind, version: v, file: relOf(root, arc) });
235
- }
236
- }
237
- const artifacts = fs.readdirSync(dir).map((name) => {
238
- const p = parseFile(name);
239
- if (!p) return null;
240
- const text = fs.readFileSync(path.join(dir, name), 'utf8');
241
- return { kind: p.kind, version: name === `${FILES[p.kind]}.md` ? versions[p.kind] ?? 1 : p.version, file: name, branch: parseBranch(text), date: headerDate(text), wiki: parseWiki(text), docnexus: parseDocnexus(text) };
242
- }).filter(Boolean).sort((a, b) => Object.keys(FILES).indexOf(a.kind) - Object.keys(FILES).indexOf(b.kind) || a.version - b.version);
243
- const branches = [...new Set([...(old.branches ?? []), ...artifacts.map((a) => a.branch).filter(Boolean)])];
244
- const latest = Object.fromEntries(Object.keys(FILES).map((k) => [k, artifacts.filter((a) => a.kind === k).at(-1)?.file ?? null]));
245
- // 内容没变就不写(meta.json 随需求目录提交,避免无谓的改动);产物有变化才更新 updated
246
- const changed = JSON.stringify(old.artifacts ?? null) !== JSON.stringify(artifacts) || JSON.stringify(old.branches ?? null) !== JSON.stringify(branches);
247
- const meta = {
248
- name: old.name ?? path.basename(dir), named_by: old.named_by ?? 'user', ...(old.project ? { project: old.project } : {}), branches, base: old.base ?? null,
249
- created: old.created ?? now(), updated: changed || !old.updated ? now() : old.updated, latest, ...(Object.keys(versions).length ? { versions } : {}), artifacts,
250
- };
251
- const out = `${JSON.stringify(meta, null, 2)}\n`;
252
- if (!fs.existsSync(f) || fs.readFileSync(f, 'utf8') !== out) fs.writeFileSync(f, out);
253
- return bump ? { ...meta, archived } : meta;
254
- }
255
-
256
- const notReady = (L) => ({ ok: false, mode: L.mode, ...(L.need_project ? { need_project: true, suggested_project: L.suggested_project } : {}), hint: L.hint });
257
-
258
- export function which(root, branch) {
259
- const L = layout(root);
260
- if (!L.ok) return notReady(L);
261
- const r = find(root, branch, L);
262
- const where = L.mode === 'repo' ? { mode: 'repo', repo: L.repo_rel, project: L.project } : { mode: 'project', project: L.project };
263
- if (r) return { bound: true, ...where, branch, name: r.name, dir: r.dir, meta: sync(root, r.dir) };
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 }));
265
- // 其他项目已有的需求(跨服务需求,本项目可以用同一个名字加入);文档仓库里常见,随代码提交时一般没有
266
- const mine = new Set(requirements(root, L).map((x) => x.slug));
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 }));
268
- return {
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(按分支名生成)。`,
271
- };
272
- }
273
-
274
- export function bind(root, { name = null, branch, base = null }) {
275
- const L = layout(root);
276
- if (!L.ok) return notReady(L);
277
- if (!branch) return { ok: false, error: '无法确定当前分支(detached HEAD?),请用 --branch 指定' };
278
- const named = name != null && String(name).trim() !== '';
279
- const display = named ? String(name).trim() : branch.replace(/\//g, '-');
280
- // 同名需求沿用(按所有项目找,跨服务的需求共用一个目录);目录名被别的需求占了就加 -2、-3
281
- const pool = repoRequirements(root, L).map((r) => ({ slug: r.slug, name: r.name }));
282
- const target = pool.find((r) => r.name === display) ?? null;
283
- let slug = target?.slug;
284
- if (!slug) {
285
- slug = dirName(display);
286
- for (let i = 2; pool.some((r) => r.slug === slug) || isDir(L.reqDir(slug)); i++) slug = `${dirName(display)}-${i}`;
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 });
291
- const dir = L.reqDir(slug);
292
- const existedHere = isDir(dir);
293
- fs.mkdirSync(dir, { recursive: true });
294
- const f = path.join(dir, 'meta.json');
295
- const meta = readJson(f) ?? { name: target?.name ?? display, named_by: named ? 'user' : 'branch', project: L.project, branches: [], base, created: now() };
296
- if (!meta.branches.includes(branch)) meta.branches.push(branch);
297
- if (base && !meta.base) meta.base = base;
298
- meta.updated = now();
299
- fs.writeFileSync(f, `${JSON.stringify(meta, null, 2)}\n`);
300
- const rel = relOf(root, dir);
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 };
302
- }
303
-
304
- // 本次落盘路径:文件名固定(review.md …),已有时先把当前版复制到 tmp/old-artifacts/<种类>_v<N>.md(previous,复审对照用),
305
- // 并带上它第一行的 wiki 发布记录;新版直接写到 path(覆盖当前版),写完 sync <path> 确认版本号
306
- export function nextPath(root, kind, branch) {
307
- if (!FILES[kind]) return { code: 1, out: { ok: false, error: `未知产物种类「${kind}」:${Object.keys(FILES).join(' / ')}` } };
308
- const L = layout(root);
309
- if (!L.ok) return { code: 2, out: notReady(L) };
310
- const r = find(root, branch, L);
311
- if (!r) return { code: 2, out: { ok: false, ...which(root, branch) } };
312
- const abs = path.join(r.abs, `${FILES[kind]}.md`);
313
- let v = 1; let previous = null; let record = null;
314
- if (fs.existsSync(abs)) {
315
- const cur = versionsOf(r.abs)[kind] ?? 1;
316
- const arc = archivePath(root, r.abs, kind, cur);
317
- fs.mkdirSync(path.dirname(arc), { recursive: true });
318
- fs.copyFileSync(abs, arc);
319
- v = cur + 1; previous = relOf(root, arc);
320
- record = /^<!-- md-to-wiki: [^\n]*-->/.exec(fs.readFileSync(abs, 'utf8'))?.[0] ?? null;
321
- }
322
- return { code: 0, out: { ok: true, mode: L.mode, name: r.name, dir: r.dir, path: relOf(root, abs), cite: L.ref(abs), version: v, previous, wiki_record: record } };
323
- }
324
-
325
- export function list(root) {
326
- const L = layout(root);
327
- return requirements(root, L).map((r) => {
328
- const m = sync(root, r.dir);
329
- return { name: m.name, dir: r.dir, branches: m.branches, named_by: m.named_by, created: m.created, artifacts: m.artifacts.length, latest: m.latest };
330
- }).sort((a, b) => String(b.created).localeCompare(String(a.created)));
331
- }
332
-
333
- // ------------------------------------------------------------------ 文档仓库的提交提示
334
- // 当前需求(或给定目录)在文档仓库里未提交的改动;项目模式下产物随代码提交,不单独提示
335
- export function pending(root, branch, dirRel = null) {
336
- const L = layout(root);
337
- if (L.mode === 'project') return { mode: 'project', commit_prompt: false, note: `产物在项目仓库的 ${HOME}/ 里,随代码一起提交` };
338
- if (!L.ok) return notReady(L);
339
- const abs = dirRel ? path.resolve(root, dirRel) : find(root, branch, L)?.abs;
340
- if (!abs) return { ok: false, mode: 'repo', error: '当前分支没有绑定需求' };
341
- const inRepo = toPosix(path.relative(L.repo, abs));
342
- const top = git(L.repo, ['rev-parse', '--show-toplevel']);
343
- if (top.status !== 0) return { ok: true, mode: 'repo', repo: L.repo_rel, is_git: false, files: [], commit_prompt: L.commit_prompt, note: '文档仓库还不是 git 仓库,产物只保存在本地' };
344
- const st = git(L.repo, ['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all', '--', inRepo]);
345
- const files = st.stdout.split('\n').filter(Boolean).map((l) => l.slice(3));
346
- const meta = readJson(path.join(abs, 'meta.json')) ?? {};
347
- const kinds = [...new Set(files.map((f) => parseFile(path.basename(f))?.kind).filter(Boolean))].map((k) => LABELS[k]);
348
- const message = `docs(${meta.name ?? path.basename(path.dirname(abs))}): ${L.project}${kinds.length ? ` ${kinds.join('、')}` : ' 更新产物'}`;
349
- // DocNexus 也在这个文档仓库里(workflow-run/docnexus/):知识库的改动单独列出,单独提交,提交信息写沉淀了什么
350
- const kn = isDir(path.join(L.repo, 'docnexus', 'library'))
351
- ? git(L.repo, ['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all', '--', 'docnexus']).stdout.split('\n').filter(Boolean).map((l) => l.slice(3))
352
- : [];
353
- return {
354
- ok: true, mode: 'repo', repo: L.repo_rel, is_git: true, project: L.project, dir: relOf(root, abs), files, commit_prompt: L.commit_prompt,
355
- suggested_message: files.length ? message : null,
356
- commands: files.length ? [`git -C ${JSON.stringify(L.repo_rel)} add -- ${JSON.stringify(inRepo)}`, `git -C ${JSON.stringify(L.repo_rel)} commit -m ${JSON.stringify(message)}`] : [],
357
- ...(kn.length ? { knowledge_files: kn, knowledge_commands: [`git -C ${JSON.stringify(L.repo_rel)} add -- docnexus`, `git -C ${JSON.stringify(L.repo_rel)} commit -m "knowledge: <沉淀了什么>"`] } : {}),
358
- };
359
- }
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
-
410
- // ------------------------------------------------------------------ 配置
411
- // 修改 artifacts 配置:团队共用的写 profile.json,--local 写个人的 profile.local.json(不提交)
412
- export function setConfig(root, patch, { local = false } = {}) {
413
- const dir = profileDir(root);
414
- const f = path.join(dir, local ? 'profile.local.json' : 'profile.json');
415
- const cur = readJson(f) ?? {};
416
- const a = { ...(cur.artifacts ?? {}) };
417
- if (patch.project !== undefined) a.project = dirName(patch.project);
418
- if (patch.commit_prompt !== undefined) a.commit_prompt = patch.commit_prompt;
419
- fs.mkdirSync(dir, { recursive: true });
420
- fs.writeFileSync(f, `${JSON.stringify({ ...cur, artifacts: a }, null, 2)}\n`);
421
- const L = layout(root);
422
- return { file: relOf(root, f), artifacts: a, effective: { mode: L.mode, repo: L.repo_rel ?? null, project: L.project, commit_prompt: L.commit_prompt, ok: L.ok, hint: L.ok ? null : L.hint } };
423
- }
424
-
425
- // .ignore 里加上需求目录(ripgrep 与 agent 的搜索跳过它;git 不受影响)。返回是否改动
426
- export function ensureIgnore(root) {
427
- const f = path.join(root, '.ignore');
428
- const text = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
429
- const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
430
- if (text.split(/\r?\n/).some((l) => norm(l) === norm(IGNORE_LINE))) return false;
431
- const add = `# workflow-run:需求产物(开发方案、交付说明、审查报告、技术 / 上线方案)不参与代码搜索,检索交给 DocNexus\n${IGNORE_LINE}\n`;
432
- fs.writeFileSync(f, text ? `${text.replace(/\s*$/, '')}\n\n${add}` : add);
433
- return true;
434
- }
435
-
436
43
  // ------------------------------------------------------------------ CLI
437
44
  const USAGE = '用法: requirement.mjs which | bind [--name <需求名>] | path <plan|delivery|review|tech|release> | sync [<目录|文件> | --all] | pending | list | config [--project <名>] [--commit-prompt on|off] [--local] (通用:--root <项目根> --branch <分支>)';
438
45