@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
@@ -0,0 +1,218 @@
1
+ // 项目布局与配置;不依赖需求写入或任何命令入口。
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
8
+ const INSTALL_BASES = ['.agents/skills', '.claude/skills', '.qwen/skills', '.codebuddy/skills'];
9
+ const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
10
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
11
+ const toPosix = (p) => p.split(path.sep).join('/');
12
+ const relOf = (root, abs) => toPosix(path.relative(root, abs));
13
+
14
+ // 本工具在项目里的唯一目录
15
+ export const HOME = 'workflow-run';
16
+ export const REQ_DIR = `${HOME}/docs/requirements`;
17
+ export const TEMPLATES_DIR = `${HOME}/templates`;
18
+ export const TMP_DIR = `${HOME}/tmp`;
19
+ export const IGNORE_LINE = `${HOME}/docs/`;
20
+ export const homeDir = (root) => path.join(root, HOME);
21
+ // workflow-run/ 自己是 git 仓库(团队文档仓库克隆到这里)= 独立文档仓库模式
22
+ export const homeIsRepo = (root) => fs.existsSync(path.join(root, HOME, '.git'));
23
+ // 产物种类 → 需求目录里的文件名(只放最新版 <base>.md;版本号在 meta.json 的 versions,旧版归档为 <base>_v<N>.md)
24
+ // 需求名 / 项目名 → 目录名:保留中文,去掉路径与文件系统不允许的字符,空白换成 -
25
+ export function dirName(name) {
26
+ const s = String(name).normalize('NFC').trim()
27
+ .replace(/[\\/:*?"<>|#%{}^~[\]`'$&@!=+,;]+/g, '-')
28
+ .replace(/\s+/g, '-')
29
+ .replace(/-{2,}/g, '-')
30
+ .replace(/^[-.]+|[-.]+$/g, '');
31
+ return [...s].slice(0, 60).join('') || 'unnamed';
32
+ }
33
+
34
+ // ------------------------------------------------------------------ 配置与布局
35
+ // 画像所在目录:本脚本所在的 skills 目录;从安装包调用时(安装器)到项目里已安装的 skills 目录找
36
+ export function profileDir(root) {
37
+ const own = path.join(SKILLS_DIR, 'workflow');
38
+ if (fs.existsSync(path.join(own, 'profile.json')) || fs.existsSync(path.join(own, 'profile.local.json'))) return own;
39
+ for (const b of INSTALL_BASES) { const d = path.join(root, b, 'workflow'); if (fs.existsSync(path.join(d, 'profile.json'))) return d; }
40
+ return own;
41
+ }
42
+
43
+ // workflow/profile.json 的 artifacts,profile.local.json(个人设置,不提交)覆盖其上
44
+ export function artifactsConfig(root) {
45
+ const dir = profileDir(root);
46
+ const team = readJson(path.join(dir, 'profile.json'))?.artifacts ?? {};
47
+ const mine = readJson(path.join(dir, 'profile.local.json'))?.artifacts ?? {};
48
+ const c = { ...team, ...mine };
49
+ return { project: c.project ? String(c.project) : '', commit_prompt: c.commit_prompt !== false, profile_dir: dir };
50
+ }
51
+
52
+ // 产物位置:两种模式都是 workflow-run/docs/requirements/<需求>/<项目>/。
53
+ // mode 只决定谁来提交(project = 随代码提交,repo = workflow-run/ 是独立的文档仓库)、引用怎么写、项目名能不能自动取
54
+ export function layout(root, cfg = artifactsConfig(root)) {
55
+ const home = homeDir(root);
56
+ const base = path.join(root, REQ_DIR);
57
+ if (!homeIsRepo(root)) {
58
+ const project = cfg.project ? dirName(cfg.project) : suggestProject(root);
59
+ 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) };
60
+ }
61
+ const repoName = remoteName(home);
62
+ 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 };
63
+ L.reqDir = (slug) => path.join(base, slug, L.project ?? '_');
64
+ // 文档里引用产物用「文档仓库名:仓库内路径」,各服务、各人的克隆里都一样
65
+ L.ref = (abs) => `${repoName}:${toPosix(path.relative(home, abs))}`;
66
+ if (!L.project) return { ...L, ok: false, need_project: true, suggested_project: suggestProject(root), hint: '文档仓库模式需要项目名(产物放在 workflow-run/docs/requirements/<需求名>/<项目名>/),只需配置一次:问用户这个项目在文档仓库里叫什么(默认 suggested_project),然后运行 config --project <项目名>' };
67
+ return { ...L, ok: true };
68
+ }
69
+
70
+ // 文档仓库名:它的 git 远端仓库名,没有远端时用 workflow-run
71
+ const remoteNames = new Map();
72
+ function remoteName(home) {
73
+ if (!remoteNames.has(home)) {
74
+ const url = git(home, ['remote', 'get-url', 'origin']).stdout?.trim();
75
+ remoteNames.set(home, url ? url.replace(/[/\\]+$/, '').replace(/\.git$/, '').split(/[/\\:]/).pop() || HOME : HOME);
76
+ }
77
+ return remoteNames.get(home);
78
+ }
79
+
80
+ // 项目名建议:git 远端仓库名,取不到用项目目录名
81
+ export function suggestProject(root) {
82
+ const url = git(root, ['remote', 'get-url', 'origin']).stdout?.trim();
83
+ const name = url ? url.replace(/[/\\]+$/, '').replace(/\.git$/, '').split(/[/\\:]/).pop() : path.basename(path.resolve(root));
84
+ return dirName(name);
85
+ }
86
+
87
+ export function setConfig(root, patch, { local = false } = {}) {
88
+ const dir = profileDir(root);
89
+ const f = path.join(dir, local ? 'profile.local.json' : 'profile.json');
90
+ const cur = readJson(f) ?? {};
91
+ const a = { ...(cur.artifacts ?? {}) };
92
+ if (patch.project !== undefined) a.project = dirName(patch.project);
93
+ if (patch.commit_prompt !== undefined) a.commit_prompt = patch.commit_prompt;
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ fs.writeFileSync(f, `${JSON.stringify({ ...cur, artifacts: a }, null, 2)}\n`);
96
+ const L = layout(root);
97
+ 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 } };
98
+ }
99
+
100
+ // .ignore 里加上需求目录(ripgrep 与 agent 的搜索跳过它;git 不受影响)。返回是否改动
101
+ export function ensureIgnore(root) {
102
+ const f = path.join(root, '.ignore');
103
+ const text = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
104
+ const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
105
+ if (text.split(/\r?\n/).some((l) => norm(l) === norm(IGNORE_LINE))) return false;
106
+ const add = `# workflow-run:需求产物(方案、交付说明、审查报告、技术 / 上线方案)不参与代码搜索,检索交给 DocNexus\n${IGNORE_LINE}\n`;
107
+ fs.writeFileSync(f, text ? `${text.replace(/\s*$/, '')}\n\n${add}` : add);
108
+ return true;
109
+ }
110
+
111
+
112
+ const PROFILE_FIELDS = ['main_branch', 'build', 'test', 'test_single', 'lint', 'config_center', 'deploy.platform', 'deploy.regions', 'job_annotation'];
113
+ const PROFILE_REQUIRED = ['main_branch', 'build', 'test'];
114
+
115
+ const OS_NAMES = { win32: 'windows', darwin: 'macos', linux: 'linux' };
116
+ export function detectEnv() {
117
+ const os = OS_NAMES[process.platform] ?? process.platform;
118
+ let shell;
119
+ if (process.platform === 'win32') {
120
+ shell = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 15000 }).status === 0 ? 'pwsh' : 'powershell';
121
+ } else shell = path.basename(process.env.SHELL || 'sh');
122
+ return { os, shell };
123
+ }
124
+ // 终端的写法提示:PowerShell 5.1 没有 && / ||,pwsh 7 有
125
+ export const SHELL_NOTES = {
126
+ powershell: 'Windows PowerShell 5.1:没有 && 和 ||,多条命令用 ; 分开;不要用 2>/dev/null、grep、head 等 Unix 命令',
127
+ pwsh: 'PowerShell 7:支持 &&;不要用 2>/dev/null、grep、head 等 Unix 命令',
128
+ };
129
+ export function readEnv() {
130
+ const rec = readJson(path.join(SKILLS_DIR, 'workflow', 'profile.local.json'))?.env;
131
+ const env = rec?.os ? { os: rec.os, shell: rec.shell ?? null, source: 'install' } : { os: OS_NAMES[process.platform] ?? process.platform, shell: null, source: 'runtime' };
132
+ const note = SHELL_NOTES[env.shell];
133
+ return { ...env, ...(note ? { note } : {}) };
134
+ }
135
+
136
+ // 开发环境配置(toolchain):画像里的路径可能写 $JAVA_HOME / ${VAR} / %VAR% / ~,用 env_vars 与进程环境展开。
137
+ // env_vars 优先(画像里显式声明的是对的),缺失的键回退到本机进程环境(HOME、USERPROFILE 这类)。
138
+ // 展开后为空 = 变量确实没配(不是「留空」,要报出来);路径里有空格照常,不做 shell 求值。
139
+ const VAR_RE = /\$\{(\w+)\}|\$(\w+)|%(\w+)%/g;
140
+ export function expandPath(v, vars = {}, env = process.env) {
141
+ if (typeof v !== 'string' || !v) return { raw: v ?? '', resolved: '', missing: [] };
142
+ const look = (n) => (vars[n] !== undefined && vars[n] !== '' ? vars[n] : (env[n] ?? ''));
143
+ const home = look('HOME') || look('USERPROFILE') || '';
144
+ let s = v;
145
+ if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = home ? home + s.slice(1) : s;
146
+ const missing = [];
147
+ for (const m of v.matchAll(VAR_RE)) { const n = m[1] ?? m[2] ?? m[3]; if (!look(n)) missing.push(n); }
148
+ const resolved = s.replace(VAR_RE, (m, a, b, c) => look(a ?? b ?? c)).replace(/[\\/]+$/, '');
149
+ return { raw: v, resolved, missing };
150
+ }
151
+
152
+ // 画像里的 toolchain(profile.json)与个人覆盖(profile.local.json),逐个子字段合并,后者优先。
153
+ // 「没声明」和「显式写空」是两回事:本机写 "" 表示**不使用**团队那个值(用工具自己的默认配置),
154
+ // 所以要按 null / "" 区分来源,不能只看值真不真。
155
+ export function readToolchain(root) {
156
+ const dir = path.join(SKILLS_DIR, 'workflow');
157
+ const team = readJson(path.join(dir, 'profile.json')) ?? {};
158
+ const mine = readJson(path.join(dir, 'profile.local.json')) ?? {};
159
+ const declared = (o) => typeof o === 'string';
160
+ // 个人声明过(含 "")就以个人为准;否则用团队值。cleared 表示被个人显式留空
161
+ const merge = (t, m) => {
162
+ if (declared(m)) return m === '' ? { value: '', source: 'local', cleared: true } : { value: m, source: 'local' };
163
+ if (declared(t) && t !== '') return { value: t, source: 'team' };
164
+ return { value: '', source: null };
165
+ };
166
+ const vars = { ...(team.env_vars ?? {}), ...(mine.env_vars ?? {}) };
167
+ const fields = {
168
+ java_home: merge(team.toolchain?.java_home, mine.toolchain?.java_home),
169
+ 'maven.settings': merge(team.toolchain?.maven?.settings, mine.toolchain?.maven?.settings),
170
+ 'maven.repository': merge(team.toolchain?.maven?.repository, mine.toolchain?.maven?.repository),
171
+ gradle_user_home: merge(team.toolchain?.gradle_user_home, mine.toolchain?.gradle_user_home),
172
+ node: merge(team.toolchain?.node, mine.toolchain?.node),
173
+ };
174
+ const out = {};
175
+ const cleared = [];
176
+ for (const [k, { value, source, cleared: c }] of Object.entries(fields)) {
177
+ if (c) { cleared.push(k); continue; } // 个人明确不用这个字段:不展开、不校验、也不报缺失
178
+ if (value) out[k] = { ...expandPath(value, vars), source };
179
+ }
180
+ // toolchain 为空时也给出 env_vars,便于 agent 知道该用什么变量写路径
181
+ return { fields: out, env_vars: vars, cleared, configured: Object.keys(out).length > 0 };
182
+ }
183
+
184
+ // 每个字段的路径在本机是否存在(写入画像前、构建跑不起来时核对用)。
185
+ // 只判断存在性,不判断版本对不对;current 是本机当前进程的值,便于和画像对照
186
+ export function checkToolchain(root, { env = process.env } = {}) {
187
+ const tc = readToolchain(root);
188
+ const check = (f) => {
189
+ // 变量没配时展开结果可能是别处真存在的路径($MISSING/bin 展开成 /bin),必须按「没配」算不通过
190
+ let exists = false;
191
+ try { exists = !f.missing.length && !!f.resolved && fs.existsSync(f.resolved); } catch { exists = false; }
192
+ const kind = exists ? (() => { try { return fs.statSync(f.resolved).isDirectory() ? 'dir' : 'file'; } catch { return 'other'; } })() : null;
193
+ return { ...f, exists, kind, ...(f.missing.length ? { unset_vars: f.missing } : {}), ...(f.raw !== f.resolved ? { expanded: true } : {}) };
194
+ };
195
+ const fields = Object.fromEntries(Object.entries(tc.fields).map(([k, f]) => [k, check(f)]));
196
+ const bad = Object.entries(fields).filter(([, f]) => !f.exists).map(([k]) => k);
197
+ return {
198
+ fields, configured: tc.configured, env_vars: tc.env_vars, ok: bad.length === 0,
199
+ missing: bad,
200
+ // 个人在 profile.local.json 里显式留空、表示不用团队那个值的字段(用工具默认配置)
201
+ ...(tc.cleared.length ? { cleared: tc.cleared } : {}),
202
+ // 本机当前值:和画像对不上时,多半是画像抄的是别人的机器
203
+ current: { JAVA_HOME: env.JAVA_HOME ?? null, MAVEN_HOME: env.MAVEN_HOME ?? null, GRADLE_USER_HOME: env.GRADLE_USER_HOME ?? null, node: path.dirname(process.execPath) },
204
+ how: tc.configured ? (bad.length ? `核对 toolchain 里不存在或变量没配的字段:${bad.join('、')}(本机值见 current);改本机的那份用 profile.local.json,见 workflow/references/profile.md` : null) : '还没有配置开发环境:运行 /workflow profile 由脚本推断后确认,见 workflow/references/profile.md',
205
+ };
206
+ }
207
+
208
+ export function readProfile(root) {
209
+ const f = path.join(SKILLS_DIR, 'workflow', 'profile.json');
210
+ const profile = readJson(f);
211
+ const get = (k) => k.split('.').reduce((o, p) => (o == null ? undefined : o[p]), profile);
212
+ const isEmpty = (v) => v == null || v === '' || (Array.isArray(v) && !v.length);
213
+ const empty = profile ? PROFILE_FIELDS.filter((k) => isEmpty(get(k))) : PROFILE_FIELDS;
214
+ return {
215
+ path: relOf(root, f), exists: !!profile, profile: profile ?? {}, empty, env: readEnv(), toolchain: readToolchain(root),
216
+ complete: !!profile && PROFILE_REQUIRED.every((k) => !empty.includes(k)),
217
+ };
218
+ }
@@ -0,0 +1,68 @@
1
+ // 需求目录读取与查询;不写索引或运行状态。
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { layout } from './project.mjs';
5
+ import { FILES, parseFile } from './artifact-model.mjs';
6
+ import { readMeta } from './meta-store.mjs';
7
+
8
+ const relative = (root, file) => path.relative(root, file).split(path.sep).join('/');
9
+ const directories = (dir) => fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('.'));
10
+
11
+ export function readRequirementDirectories(root, L = layout(root), { allProjects = false, diagnostics = [] } = {}) {
12
+ if ((!allProjects && !L.ok && L.mode === 'repo') || !fs.existsSync(L.base)) return [];
13
+ const out = [];
14
+ for (const req of directories(L.base)) {
15
+ for (const project of directories(path.join(L.base, req.name))) {
16
+ if (!allProjects && project.name !== L.project) continue;
17
+ const abs = path.join(L.base, req.name, project.name);
18
+ const file = path.join(abs, 'meta.json');
19
+ let meta = {}; let valid = true;
20
+ try {
21
+ meta = readMeta(file);
22
+ if ((meta.branches !== undefined && (!Array.isArray(meta.branches) || meta.branches.some((b) => typeof b !== 'string')))
23
+ || (meta.runs !== undefined && (!meta.runs || typeof meta.runs !== 'object' || Array.isArray(meta.runs)))) throw new Error(`无效的需求绑定或运行记录:${file}`);
24
+ } catch (e) {
25
+ diagnostics.push({ code: 'invalid_meta', path: relative(root, file), message: e.message });
26
+ meta = {}; valid = false;
27
+ }
28
+ out.push({ abs, dir: relative(root, abs), slug: req.name, project: project.name, name: meta.name ?? req.name,
29
+ branches: meta.branches ?? [], updated: meta.updated ?? null, meta, valid });
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ export function requirements(root, L = layout(root)) {
36
+ return readRequirementDirectories(root, L).map(({ dir, abs, slug, name, branches, updated, meta }) => ({ dir, abs, slug, name, branches, updated, meta }));
37
+ }
38
+
39
+ export function groupRequirements(dirs) {
40
+ const groups = new Map();
41
+ for (const d of dirs) {
42
+ if (!groups.has(d.slug)) groups.set(d.slug, []);
43
+ groups.get(d.slug).push(d);
44
+ }
45
+ return [...groups].map(([slug, rows]) => ({ slug, name: rows.map((d) => d.meta.name).find(Boolean) ?? slug,
46
+ updated: rows.map((d) => d.updated ?? '').sort().at(-1) || null,
47
+ projects: rows.map(({ project, abs, dir, meta }) => ({ project, abs, dir, meta })) }));
48
+ }
49
+
50
+ export function repoRequirements(root, L = layout(root)) {
51
+ return groupRequirements(readRequirementDirectories(root, L, { allProjects: true }));
52
+ }
53
+
54
+ export function artifactFiles(root, { allProjects = false } = {}) {
55
+ const L = layout(root);
56
+ return readRequirementDirectories(root, L, { allProjects }).flatMap((d) => fs.readdirSync(d.abs).flatMap((name) => {
57
+ const p = parseFile(name);
58
+ if (!p) return [];
59
+ const abs = path.join(d.abs, name);
60
+ return [{ abs, rel: relative(root, abs), cite: L.ref(abs), slug: d.slug, project: d.project, ...p,
61
+ ...(name === `${FILES[p.kind]}.md` ? { version: d.meta.versions?.[p.kind] ?? 1 } : {}) }];
62
+ }));
63
+ }
64
+
65
+ export function find(root, branch, L = layout(root)) {
66
+ return requirements(root, L).filter((r) => r.branches.includes(branch))
67
+ .sort((a, b) => String(b.updated).localeCompare(String(a.updated)))[0] ?? null;
68
+ }
@@ -0,0 +1,219 @@
1
+ // 需求绑定、产物版本与索引写入;调用只读查询和底层原子存储。
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { updateMeta } from './meta-store.mjs';
6
+ import { FILES, LABELS, parseFile, parseBranch, parseWiki, parseDocnexus, headerDate } from './artifact-model.mjs';
7
+ import { HOME, REQ_DIR, TMP_DIR, dirName, profileDir, layout, setConfig } from './project.mjs';
8
+ import { requirements, repoRequirements, find } from './requirement-read.mjs';
9
+ import { loadWorkspace } from './workspace-read.mjs';
10
+ const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
11
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
12
+ const relOf = (root, abs) => path.relative(root, abs).split(path.sep).join('/');
13
+ const toPosix = (p) => p.split(path.sep).join('/');
14
+ const now = () => new Date().toISOString();
15
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
16
+ const skipDir = (d) => !d.isDirectory() || d.name.startsWith('.');
17
+
18
+ // 同种产物只留最新版,文件名固定(review.md、tech-doc.md …),文档里的引用不会失效。版本号记在 meta.json 的 versions。
19
+ // path 时把当前版复制到 workflow-run/tmp/old-artifacts/<需求目录(相对 requirements/)>/<种类>_v<N>.md(不提交),
20
+ // 写完 sync <path> 时新版内容和这份副本不同才算新版本(versions 加一);没写就放弃的话版本号不变
21
+ export const OLD_DIR = `${TMP_DIR}/old-artifacts`;
22
+ const archivePath = (root, dirAbs, kind, v) => path.join(root, OLD_DIR, path.relative(path.join(root, REQ_DIR), dirAbs), `${FILES[kind]}_v${v}.md`);
23
+ const versionsOf = (dirAbs) => readJson(path.join(dirAbs, 'meta.json'))?.versions ?? {};
24
+
25
+ // 按目录里的文件重建 meta.json(保留需求名、分支、创建时间)
26
+ // bump:写完新版后(sync <path>)确认新版本——和 path 时归档的上一版副本内容不同就把版本号加一,返回归档了哪些
27
+ export function sync(root, dirRel, { bump = false } = {}) {
28
+ const dir = path.resolve(root, dirRel);
29
+ const f = path.join(dir, 'meta.json');
30
+ const archived = [];
31
+ const result = updateMeta(f, (old) => {
32
+ const versions = { ...(old.versions ?? {}) };
33
+ if (bump) {
34
+ for (const kind of Object.keys(FILES)) {
35
+ const cur = path.join(dir, `${FILES[kind]}.md`);
36
+ const v = versions[kind] ?? 1;
37
+ const arc = archivePath(root, dir, kind, v);
38
+ if (!fs.existsSync(cur) || !fs.existsSync(arc) || fs.readFileSync(arc).equals(fs.readFileSync(cur))) continue;
39
+ versions[kind] = v + 1;
40
+ archived.push({ kind, version: v, file: relOf(root, arc) });
41
+ }
42
+ }
43
+ const artifacts = fs.readdirSync(dir).map((name) => {
44
+ const p = parseFile(name);
45
+ if (!p) return null;
46
+ const text = fs.readFileSync(path.join(dir, name), 'utf8');
47
+ 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) };
48
+ }).filter(Boolean).sort((a, b) => Object.keys(FILES).indexOf(a.kind) - Object.keys(FILES).indexOf(b.kind) || a.version - b.version);
49
+ const branches = old.branches ?? []; // 绑定只由 bind 更新;文档头不隐式修改需求归属。
50
+ const latest = Object.fromEntries(Object.keys(FILES).map((k) => [k, artifacts.filter((a) => a.kind === k).at(-1)?.file ?? null]));
51
+ // 内容没变就不写(meta.json 随需求目录提交,避免无谓的改动);产物有变化才更新 updated
52
+ const changed = JSON.stringify(old.artifacts ?? null) !== JSON.stringify(artifacts) || JSON.stringify(old.branches ?? null) !== JSON.stringify(branches);
53
+ const meta = {
54
+ ...old,
55
+ name: old.name ?? path.basename(dir), named_by: old.named_by ?? 'user', ...(old.project ? { project: old.project } : {}), branches, base: old.base ?? null,
56
+ created: old.created ?? now(), updated: changed || !old.updated ? now() : old.updated, latest, ...(Object.keys(versions).length ? { versions } : {}), artifacts,
57
+ };
58
+ return meta;
59
+ }, { root });
60
+ return bump ? { ...result, archived } : result;
61
+ }
62
+
63
+ const notReady = (L) => ({ ok: false, mode: L.mode, ...(L.need_project ? { need_project: true, suggested_project: L.suggested_project } : {}), hint: L.hint });
64
+
65
+ export function which(root, branch) {
66
+ const L = layout(root);
67
+ if (!L.ok) return notReady(L);
68
+ const r = find(root, branch, L);
69
+ const where = L.mode === 'repo' ? { mode: 'repo', repo: L.repo_rel, project: L.project } : { mode: 'project', project: L.project };
70
+ if (r) return { bound: true, ...where, branch, name: r.name, dir: r.dir, meta: sync(root, r.dir) };
71
+ 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 }));
72
+ // 其他项目已有的需求(跨服务需求,本项目可以用同一个名字加入);文档仓库里常见,随代码提交时一般没有
73
+ const mine = new Set(requirements(root, L).map((x) => x.slug));
74
+ 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 }));
75
+ return {
76
+ bound: false, ...where, branch, suggested_name: branch ? dirName(branch.replace(/\//g, '-')) : null, recent, ...(others.length || L.mode === 'repo' ? { other_projects: others } : {}),
77
+ hint: `当前分支还没有绑定需求:问用户这个需求叫什么(给出 suggested_name 作为默认;也可以选 recent 里已有的需求继续${others.length || L.mode === 'repo' ? ',或加入 other_projects 里其他服务已有的需求' : ''}),然后运行 bind --name <需求名>;用户不给就运行 bind(按分支名生成)。`,
78
+ };
79
+ }
80
+
81
+ export function bind(root, { name = null, branch, base = null }) {
82
+ const L = layout(root);
83
+ if (!L.ok) return notReady(L);
84
+ if (!branch) return { ok: false, error: '无法确定当前分支(detached HEAD?),请用 --branch 指定' };
85
+ const named = name != null && String(name).trim() !== '';
86
+ const display = named ? String(name).trim() : branch.replace(/\//g, '-');
87
+ // 同名需求沿用(按所有项目找,跨服务的需求共用一个目录);目录名被别的需求占了就加 -2、-3
88
+ const pool = repoRequirements(root, L).map((r) => ({ slug: r.slug, name: r.name }));
89
+ const target = pool.find((r) => r.name === display) ?? null;
90
+ let slug = target?.slug;
91
+ if (!slug) {
92
+ slug = dirName(display);
93
+ for (let i = 2; pool.some((r) => r.slug === slug) || isDir(L.reqDir(slug)); i++) slug = `${dirName(display)}-${i}`;
94
+ }
95
+ // 随代码提交时项目名是自动取的:第一次落盘时写进 profile.json 固定下来,之后改了 git 远端名也不会换目录
96
+ // (只在项目里装过 skills、有 profile.json 时写;直接从安装包运行脚本时不写)
97
+ if (!L.project_configured && fs.existsSync(path.join(profileDir(root), 'profile.json'))) setConfig(root, { project: L.project });
98
+ const dir = L.reqDir(slug);
99
+ const existedHere = isDir(dir);
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const f = path.join(dir, 'meta.json');
102
+ const meta = updateMeta(f, (old) => {
103
+ const value = Object.keys(old).length ? old : { name: target?.name ?? display, named_by: named ? 'user' : 'branch', project: L.project, branches: [], base, created: now() };
104
+ if (!value.branches.includes(branch)) value.branches.push(branch);
105
+ if (base && !value.base) value.base = base;
106
+ value.updated = now();
107
+ return value;
108
+ }, { root });
109
+ const rel = relOf(root, dir);
110
+ 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 };
111
+ }
112
+
113
+ // 本次落盘路径:文件名固定(review.md …),已有时先把当前版复制到 tmp/old-artifacts/<种类>_v<N>.md(previous,复审对照用),
114
+ // 并带上它第一行的 wiki 发布记录;新版直接写到 path(覆盖当前版),写完 sync <path> 确认版本号
115
+ export function nextPath(root, kind, branch) {
116
+ if (!FILES[kind]) return { code: 1, out: { ok: false, error: `未知产物种类「${kind}」:${Object.keys(FILES).join(' / ')}` } };
117
+ const L = layout(root);
118
+ if (!L.ok) return { code: 2, out: notReady(L) };
119
+ const r = find(root, branch, L);
120
+ if (!r) return { code: 2, out: { ok: false, ...which(root, branch) } };
121
+ const abs = path.join(r.abs, `${FILES[kind]}.md`);
122
+ let v = 1; let previous = null; let record = null;
123
+ if (fs.existsSync(abs)) {
124
+ const cur = versionsOf(r.abs)[kind] ?? 1;
125
+ const arc = archivePath(root, r.abs, kind, cur);
126
+ fs.mkdirSync(path.dirname(arc), { recursive: true });
127
+ fs.copyFileSync(abs, arc);
128
+ v = cur + 1; previous = relOf(root, arc);
129
+ record = /^<!-- md-to-wiki: [^\n]*-->/.exec(fs.readFileSync(abs, 'utf8'))?.[0] ?? null;
130
+ }
131
+ 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 } };
132
+ }
133
+
134
+ export function list(root) {
135
+ const workspace = loadWorkspace(root);
136
+ return workspace.requirements.map((r) => {
137
+ const artifacts = workspace.artifacts.filter((a) => a.requirement === r.slug && a.project === r.project).sort((a, b) => a.version - b.version);
138
+ const latest = Object.fromEntries(Object.keys(FILES).map((kind) => [kind, artifacts.filter((a) => a.kind === kind).at(-1)?.path.split('/').at(-1) ?? null]));
139
+ return { name: r.name, dir: r.dir, branches: r.branches, named_by: r.meta.named_by, created: r.meta.created, artifacts: artifacts.length, latest,
140
+ ...(!r.valid ? { diagnostics: workspace.diagnostics.filter((d) => d.path.startsWith(r.dir + '/')) } : {}) };
141
+ }).sort((a, b) => String(b.created).localeCompare(String(a.created)));
142
+ }
143
+
144
+ // ------------------------------------------------------------------ 文档仓库的提交提示
145
+ // 当前需求(或给定目录)在文档仓库里未提交的改动;项目模式下产物随代码提交,不单独提示
146
+ export function pending(root, branch, dirRel = null) {
147
+ const L = layout(root);
148
+ if (L.mode === 'project') return { mode: 'project', commit_prompt: false, note: `产物在项目仓库的 ${HOME}/ 里,随代码一起提交` };
149
+ if (!L.ok) return notReady(L);
150
+ const abs = dirRel ? path.resolve(root, dirRel) : find(root, branch, L)?.abs;
151
+ if (!abs) return { ok: false, mode: 'repo', error: '当前分支没有绑定需求' };
152
+ const inRepo = toPosix(path.relative(L.repo, abs));
153
+ const top = git(L.repo, ['rev-parse', '--show-toplevel']);
154
+ if (top.status !== 0) return { ok: true, mode: 'repo', repo: L.repo_rel, is_git: false, files: [], commit_prompt: L.commit_prompt, note: '文档仓库还不是 git 仓库,产物只保存在本地' };
155
+ const st = git(L.repo, ['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all', '--', inRepo]);
156
+ const files = st.stdout.split('\n').filter(Boolean).map((l) => l.slice(3));
157
+ const meta = readJson(path.join(abs, 'meta.json')) ?? {};
158
+ const kinds = [...new Set(files.map((f) => parseFile(path.basename(f))?.kind).filter(Boolean))].map((k) => LABELS[k]);
159
+ const message = `docs(${meta.name ?? path.basename(path.dirname(abs))}): ${L.project}${kinds.length ? ` ${kinds.join('、')}` : ' 更新产物'}`;
160
+ // DocNexus 也在这个文档仓库里(workflow-run/docnexus/):知识库的改动单独列出,单独提交,提交信息写沉淀了什么
161
+ const kn = isDir(path.join(L.repo, 'docnexus', 'library'))
162
+ ? git(L.repo, ['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all', '--', 'docnexus']).stdout.split('\n').filter(Boolean).map((l) => l.slice(3))
163
+ : [];
164
+ return {
165
+ ok: true, mode: 'repo', repo: L.repo_rel, is_git: true, project: L.project, dir: relOf(root, abs), files, commit_prompt: L.commit_prompt,
166
+ suggested_message: files.length ? message : null,
167
+ 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)}`] : [],
168
+ ...(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: <沉淀了什么>"`] } : {}),
169
+ };
170
+ }
171
+
172
+ // ------------------------------------------------------------------ 旧结构迁移
173
+ // 旧版本随代码提交时没有 <项目名>/ 这一层:文件直接放在 docs/requirements/<需求>/ 下。
174
+ // 列出这样的需求目录(目录里直接有文件的;隐藏文件不算)
175
+ export function legacyRequirements(root) {
176
+ const base = path.join(root, REQ_DIR);
177
+ if (!isDir(base)) return [];
178
+ return fs.readdirSync(base, { withFileTypes: true }).filter((d) => !skipDir(d)).map((d) => d.name)
179
+ .filter((slug) => fs.readdirSync(path.join(base, slug), { withFileTypes: true }).some((e) => e.isFile() && !e.name.startsWith('.')));
180
+ }
181
+
182
+ // 把旧结构的需求目录挪到 <需求>/<项目名>/:被 git 跟踪的用 git mv(保留历史),其余直接移动;
183
+ // tmp/old-artifacts/<需求>/ 下归档的上一版也一起挪,复审对照还能找到。
184
+ // 目标里已有同名文件时这个需求整个跳过,留给用户处理。项目名没配置时按 layout 自动取的写进 profile.json。
185
+ // 返回 { project, moved: [{ from, to }], skipped: [{ dir, reason }] }
186
+ export function migrateLayout(root) {
187
+ const L = layout(root);
188
+ const slugs = legacyRequirements(root);
189
+ if (!slugs.length) return { project: L.project, moved: [], skipped: [] };
190
+ if (!L.project) return { project: null, moved: [], skipped: slugs.map((s) => ({ dir: `${REQ_DIR}/${s}`, reason: '还没配置项目名' })) };
191
+ const repoOf = (abs) => (L.mode === 'repo' ? L.repo : root);
192
+ const tracked = (abs) => git(repoOf(abs), ['ls-files', '--error-unmatch', '--', path.relative(repoOf(abs), abs)]).status === 0;
193
+ const moved = []; const skipped = [];
194
+ const move = (from, to, useGit) => {
195
+ fs.mkdirSync(path.dirname(to), { recursive: true });
196
+ const cwd = repoOf(from);
197
+ if (useGit && tracked(from) && git(cwd, ['mv', '--', path.relative(cwd, from), path.relative(cwd, to)]).status === 0) return;
198
+ fs.renameSync(from, to);
199
+ };
200
+ for (const slug of slugs) {
201
+ const dir = path.join(L.base, slug);
202
+ const files = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name);
203
+ const target = path.join(dir, L.project);
204
+ const clash = files.filter((f) => fs.existsSync(path.join(target, f)));
205
+ if (clash.length) { skipped.push({ dir: relOf(root, dir), reason: `${relOf(root, target)} 里已有 ${clash.join('、')}` }); continue; }
206
+ for (const f of files) {
207
+ move(path.join(dir, f), path.join(target, f), true);
208
+ moved.push({ from: relOf(root, path.join(dir, f)), to: relOf(root, path.join(target, f)) });
209
+ }
210
+ const m = readJson(path.join(target, 'meta.json'));
211
+ if (m && m.project !== L.project) fs.writeFileSync(path.join(target, 'meta.json'), `${JSON.stringify({ ...m, project: L.project }, null, 2)}\n`);
212
+ const old = path.join(root, OLD_DIR, slug);
213
+ if (isDir(old)) {
214
+ 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);
215
+ }
216
+ }
217
+ if (moved.length && !L.project_configured && fs.existsSync(path.join(profileDir(root), 'profile.json'))) setConfig(root, { project: L.project });
218
+ return { project: L.project, moved, skipped };
219
+ }