@openprd/cli 0.1.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 (154) hide show
  1. package/.openprd/README.md +82 -0
  2. package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +14 -0
  3. package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +14 -0
  4. package/.openprd/benchmarks/index.md +37 -0
  5. package/.openprd/benchmarks/sources.yaml +56 -0
  6. package/.openprd/config.yaml +50 -0
  7. package/.openprd/discovery/config.json +21 -0
  8. package/.openprd/engagements/active/flows.md +30 -0
  9. package/.openprd/engagements/active/handoff.md +9 -0
  10. package/.openprd/engagements/active/intake.md +15 -0
  11. package/.openprd/engagements/active/prd.md +161 -0
  12. package/.openprd/engagements/active/review.html +61 -0
  13. package/.openprd/engagements/active/roles.md +21 -0
  14. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +23 -0
  15. package/.openprd/exports/.gitkeep +0 -0
  16. package/.openprd/knowledge/index.json +7 -0
  17. package/.openprd/quality/config.json +229 -0
  18. package/.openprd/reviews/v0001.html +1256 -0
  19. package/.openprd/schema/diagram-architecture.schema.yaml +49 -0
  20. package/.openprd/schema/diagram-product-flow.schema.yaml +52 -0
  21. package/.openprd/schema/prd.schema.yaml +121 -0
  22. package/.openprd/sessions/.gitkeep +0 -0
  23. package/.openprd/standards/config.json +88 -0
  24. package/.openprd/standards/file-manual-template.md +28 -0
  25. package/.openprd/standards/folder-readme-template.md +28 -0
  26. package/.openprd/state/.gitkeep +0 -0
  27. package/.openprd/state/changes.json +12 -0
  28. package/.openprd/state/current.json +169 -0
  29. package/.openprd/state/version-index.json +15 -0
  30. package/.openprd/state/versions/.gitkeep +0 -0
  31. package/.openprd/state/versions/v0001.json +121 -0
  32. package/.openprd/state/versions/v0001.md +161 -0
  33. package/.openprd/templates/agent/intake.md +6 -0
  34. package/.openprd/templates/agent/prd.md +21 -0
  35. package/.openprd/templates/b2b/intake.md +6 -0
  36. package/.openprd/templates/b2b/prd.md +24 -0
  37. package/.openprd/templates/base/intake.md +18 -0
  38. package/.openprd/templates/base/prd.md +67 -0
  39. package/.openprd/templates/company/README.md +10 -0
  40. package/.openprd/templates/consumer/intake.md +6 -0
  41. package/.openprd/templates/consumer/prd.md +19 -0
  42. package/.openprd/templates/diagram/architecture.contract.json +53 -0
  43. package/.openprd/templates/diagram/product-flow.contract.json +76 -0
  44. package/.openprd/templates/industry/README.md +16 -0
  45. package/.openprd/templates/manifest.yaml +27 -0
  46. package/.openprd/templates/project/README.md +14 -0
  47. package/.openprd/templates/session/README.md +14 -0
  48. package/AGENTS.md +44 -0
  49. package/CONTRIBUTING.md +30 -0
  50. package/LICENSE +21 -0
  51. package/README.md +727 -0
  52. package/README_CN.md +583 -0
  53. package/SECURITY.md +23 -0
  54. package/bin/openprd.js +5 -0
  55. package/docs/assets/openprd-capability-overview-en.png +0 -0
  56. package/docs/assets/openprd-capability-overview-zh.png +0 -0
  57. package/docs/assets/openprd-learning-html.png +0 -0
  58. package/docs/assets/openprd-quality-html.png +0 -0
  59. package/docs/assets/openprd-review-html.png +0 -0
  60. package/docs/assets/openprd-scenario-overview.png +0 -0
  61. package/docs/assets/openprd-scenario-overview.svg +114 -0
  62. package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
  63. package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
  64. package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
  65. package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
  66. package/package.json +59 -0
  67. package/scripts/openprd-dev-check.mjs +5 -0
  68. package/scripts/openprd-review-presentation.mjs +82 -0
  69. package/skills/openprd-benchmark-router/SKILL.md +92 -0
  70. package/skills/openprd-benchmark-router/agents/openai.yaml +4 -0
  71. package/skills/openprd-benchmark-router/references/benchmark-sources.md +74 -0
  72. package/skills/openprd-benchmark-router/references/evaluation-lenses.md +66 -0
  73. package/skills/openprd-benchmark-router/references/source-policy.md +35 -0
  74. package/skills/openprd-diagram-review/SKILL.md +91 -0
  75. package/skills/openprd-diagram-review/agents/openai.yaml +4 -0
  76. package/skills/openprd-diagram-review/examples/architecture-zh.md +8 -0
  77. package/skills/openprd-diagram-review/examples/product-flow-zh.md +7 -0
  78. package/skills/openprd-diagram-review/references/cocoon-patterns.md +17 -0
  79. package/skills/openprd-diagram-review/references/diagram-contracts.md +126 -0
  80. package/skills/openprd-diagram-review/references/review-checklist.md +10 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +196 -0
  82. package/skills/openprd-discovery-loop/agents/openai.yaml +3 -0
  83. package/skills/openprd-harness/SKILL.md +179 -0
  84. package/skills/openprd-harness/agents/openai.yaml +4 -0
  85. package/skills/openprd-harness/examples/full-workflow-zh.md +9 -0
  86. package/skills/openprd-harness/references/command-map.md +71 -0
  87. package/skills/openprd-harness/references/examples.md +26 -0
  88. package/skills/openprd-harness/references/usage-guide.md +335 -0
  89. package/skills/openprd-harness/references/workflow-gates.md +51 -0
  90. package/skills/openprd-learning-review/SKILL.md +75 -0
  91. package/skills/openprd-learning-review/agents/openai.yaml +4 -0
  92. package/skills/openprd-learning-review/references/content-contract.md +125 -0
  93. package/skills/openprd-learning-review/references/ebook-reader.md +46 -0
  94. package/skills/openprd-learning-review/references/evidence-manifest.md +55 -0
  95. package/skills/openprd-learning-review/references/genre-library.md +43 -0
  96. package/skills/openprd-learning-review/references/prompt-engineering.md +71 -0
  97. package/skills/openprd-learning-review/references/quality-rubric.md +28 -0
  98. package/skills/openprd-learning-review/references/retrieval-worked-example.md +40 -0
  99. package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +67 -0
  100. package/skills/openprd-quality/SKILL.md +101 -0
  101. package/skills/openprd-requirement-intake/SKILL.md +76 -0
  102. package/skills/openprd-requirement-intake/agents/openai.yaml +4 -0
  103. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +105 -0
  104. package/skills/openprd-requirement-intake/references/routing-rubric.md +64 -0
  105. package/skills/openprd-router/SKILL.md +40 -0
  106. package/skills/openprd-shared/SKILL.md +142 -0
  107. package/skills/openprd-shared/agents/openai.yaml +4 -0
  108. package/skills/openprd-shared/references/language-and-review.md +50 -0
  109. package/skills/openprd-shared/references/operating-rules.md +65 -0
  110. package/skills/openprd-shared/references/skill-architecture.md +70 -0
  111. package/skills/openprd-standards/SKILL.md +79 -0
  112. package/skills/openprd-standards/agents/openai.yaml +4 -0
  113. package/src/agent-integration.js +1717 -0
  114. package/src/benchmark.js +873 -0
  115. package/src/cli/args.js +460 -0
  116. package/src/cli/print.js +1423 -0
  117. package/src/codex-hook-runner-template.mjs +2422 -0
  118. package/src/dev-standards.js +372 -0
  119. package/src/diagram-core.js +1047 -0
  120. package/src/diagram-workspace.js +262 -0
  121. package/src/discovery.js +709 -0
  122. package/src/fleet.js +531 -0
  123. package/src/fs-utils.js +83 -0
  124. package/src/growth.js +545 -0
  125. package/src/html-artifacts.js +3803 -0
  126. package/src/knowledge.js +668 -0
  127. package/src/language-policy.js +142 -0
  128. package/src/learning-review.js +1655 -0
  129. package/src/loop.js +1290 -0
  130. package/src/openprd.js +1136 -0
  131. package/src/openspec/change-lifecycle.js +359 -0
  132. package/src/openspec/change-validate.js +248 -0
  133. package/src/openspec/constants.js +12 -0
  134. package/src/openspec/execute.js +300 -0
  135. package/src/openspec/generate.js +692 -0
  136. package/src/openspec/paths.js +111 -0
  137. package/src/openspec/tasks.js +352 -0
  138. package/src/prd-core.js +656 -0
  139. package/src/quality-html-artifact.js +1414 -0
  140. package/src/quality-learning.js +658 -0
  141. package/src/quality.js +1262 -0
  142. package/src/review-presentation.js +240 -0
  143. package/src/run-harness.js +1470 -0
  144. package/src/self-update.js +329 -0
  145. package/src/session-binding.js +140 -0
  146. package/src/source-inventory.js +224 -0
  147. package/src/standards.js +914 -0
  148. package/src/time.js +33 -0
  149. package/src/visual-compare.js +216 -0
  150. package/src/work-unit-migration.js +232 -0
  151. package/src/work-unit.js +88 -0
  152. package/src/workspace-core.js +1706 -0
  153. package/src/workspace-registry.js +162 -0
  154. package/src/workspace-workflow.js +1797 -0
@@ -0,0 +1,914 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { DEFAULT_DEVELOPMENT_STANDARDS, validateDevelopmentStandardsConfig } from './dev-standards.js';
4
+ import { DEFAULT_GROWTH_CONFIG, validateGrowthConfig } from './growth.js';
5
+
6
+ export const OPENPRD_STANDARDS_CONFIG = path.join('.openprd', 'standards', 'config.json');
7
+ export const OPENPRD_STANDARDS_DIR = path.join('.openprd', 'standards');
8
+ export const STANDARD_DOCS_ROOT = path.join('docs', 'basic');
9
+ export const STANDARD_MANUAL_SECTIONS = ['核心功能', '输入', '输出', '定位', '依赖', '维护规则'];
10
+ const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.css', '.html', '.swift', '.py', '.go', '.rs']);
11
+ const SOURCE_IGNORE_DIRS = new Set([
12
+ '.git',
13
+ '.openprd',
14
+ 'openprd',
15
+ '.openspec',
16
+ '.codex',
17
+ '.claude',
18
+ '.cursor',
19
+ 'docs',
20
+ 'node_modules',
21
+ 'vendor',
22
+ 'dist',
23
+ 'build',
24
+ 'out',
25
+ 'release',
26
+ 'releases',
27
+ 'coverage',
28
+ 'reports',
29
+ 'test-results',
30
+ '.next',
31
+ '.venv',
32
+ 'venv',
33
+ '__pycache__',
34
+ '.tmp',
35
+ 'tmp',
36
+ 'temp',
37
+ 'third_party',
38
+ 'third-party',
39
+ 'external',
40
+ 'generated',
41
+ '__fixtures__',
42
+ 'fixtures',
43
+ ]);
44
+ const DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS = [
45
+ 'next-env.d.ts',
46
+ '**/next-env.d.ts',
47
+ '.project-tools/**',
48
+ '**/.project-tools/**',
49
+ '**/Pods/**',
50
+ '.tmp/**',
51
+ 'tmp/**',
52
+ 'temp/**',
53
+ '**/marketplace-sources/**',
54
+ '**/marketplace-candidates/**',
55
+ '**/skill-sources/**',
56
+ '**/legacy-data/**',
57
+ '**/legacy-public/**',
58
+ '**/legacy-cache/**',
59
+ '**/cache/**',
60
+ '**/generated/**',
61
+ '**/vendor/**',
62
+ '**/out/**',
63
+ '**/release/**',
64
+ '**/releases/**',
65
+ '**/reports/**',
66
+ '**/test-results/**',
67
+ '**/third_party/**',
68
+ '**/third-party/**',
69
+ '**/__fixtures__/**',
70
+ '**/fixtures/**',
71
+ ];
72
+ const EXTERNAL_REFERENCE_CANDIDATE_MIN_FILES = 5;
73
+ const PROVISIONAL_EXTERNAL_REFERENCE_PATH_SEGMENTS = new Set([
74
+ 'toolkit-sources',
75
+ ]);
76
+ const DOC_PLACEHOLDER_PATTERNS = [
77
+ /待补充/,
78
+ /说明当前项目/,
79
+ /描述产品/,
80
+ /说明产品/,
81
+ /说明前端/,
82
+ /说明后端/,
83
+ /说明该文件/,
84
+ /说明该文件夹/,
85
+ ];
86
+
87
+ export const STANDARD_DOCS = [
88
+ {
89
+ fileName: 'file-structure.md',
90
+ title: '项目文件结构',
91
+ sections: ['项目定位', '核心目录', '文件组织规则', '维护规则'],
92
+ body: [
93
+ '# 项目文件结构',
94
+ '',
95
+ '## 项目定位',
96
+ '',
97
+ '说明当前项目的主要边界、运行入口和核心模块分布。',
98
+ '',
99
+ '## 核心目录',
100
+ '',
101
+ '- 待补充: 列出主要源码、资源、测试、脚本和文档目录。',
102
+ '',
103
+ '## 文件组织规则',
104
+ '',
105
+ '- 新增文件时,应同步确认所在文件夹说明书是否需要更新。',
106
+ '- 跨模块移动文件时,应更新本文件中的目录结构和职责说明。',
107
+ '',
108
+ '## 维护规则',
109
+ '',
110
+ '- 每次新增、删除、移动目录或核心文件后,必须检查并更新本文件。',
111
+ '- 本文档只记录项目结构事实,不承载具体功能需求细节。',
112
+ '',
113
+ ].join('\n'),
114
+ },
115
+ {
116
+ fileName: 'app-flow.md',
117
+ title: '产品流程说明',
118
+ sections: ['核心流程', '用户路径', '状态变化', '维护规则'],
119
+ body: [
120
+ '# 产品流程说明',
121
+ '',
122
+ '## 核心流程',
123
+ '',
124
+ '描述产品从入口到完成目标的主要流程。',
125
+ '',
126
+ '## 用户路径',
127
+ '',
128
+ '- 待补充: 主要用户如何进入、操作和完成关键任务。',
129
+ '',
130
+ '## 状态变化',
131
+ '',
132
+ '- 待补充: 关键状态、异常状态和恢复路径。',
133
+ '',
134
+ '## 维护规则',
135
+ '',
136
+ '- 每次用户流程、页面跳转、任务状态或异常处理发生变化后,必须检查并更新本文件。',
137
+ '',
138
+ ].join('\n'),
139
+ },
140
+ {
141
+ fileName: 'prd.md',
142
+ title: '产品逻辑说明',
143
+ sections: ['问题与目标', '用户故事', '功能范围', '验收标准', '维护规则'],
144
+ body: [
145
+ '# 产品逻辑说明',
146
+ '',
147
+ '## 问题与目标',
148
+ '',
149
+ '说明产品要解决的问题、目标用户和成功标准。',
150
+ '',
151
+ '## 用户故事',
152
+ '',
153
+ '- 待补充: 作为某类用户,我希望完成某个目标,从而获得某个结果。',
154
+ '',
155
+ '## 功能范围',
156
+ '',
157
+ '- 待补充: 当前版本包含的能力和明确不包含的内容。',
158
+ '',
159
+ '## 验收标准',
160
+ '',
161
+ '- 待补充: 功能完成后可以被验证的用户结果。',
162
+ '',
163
+ '## 维护规则',
164
+ '',
165
+ '- 每次需求边界、用户故事、验收标准发生变化后,必须检查并更新本文件。',
166
+ '',
167
+ ].join('\n'),
168
+ },
169
+ {
170
+ fileName: 'frontend-guidelines.md',
171
+ title: '前端开发规范',
172
+ sections: ['适用范围', '界面结构', '交互规范', '维护规则'],
173
+ body: [
174
+ '# 前端开发规范',
175
+ '',
176
+ '## 适用范围',
177
+ '',
178
+ '说明前端界面、组件、交互和样式的项目级约定。',
179
+ '',
180
+ '## 界面结构',
181
+ '',
182
+ '- 待补充: 页面、组件、状态和资源组织方式。',
183
+ '',
184
+ '## 交互规范',
185
+ '',
186
+ '- 待补充: 常见操作、反馈、空状态和错误状态的处理方式。',
187
+ '',
188
+ '## 维护规则',
189
+ '',
190
+ '- 每次新增界面模式、组件规范或交互规则后,必须检查并更新本文件。',
191
+ '',
192
+ ].join('\n'),
193
+ },
194
+ {
195
+ fileName: 'backend-structure.md',
196
+ title: '后端架构设计',
197
+ sections: ['适用范围', '服务边界', 'CLI 接入面', 'API 接入面', '数据流', '维护规则'],
198
+ body: [
199
+ '# 后端架构设计',
200
+ '',
201
+ '## 适用范围',
202
+ '',
203
+ '说明后端服务、接口、数据处理和外部依赖的项目级架构约定。',
204
+ '',
205
+ '## 服务边界',
206
+ '',
207
+ '- 待补充: 主要服务、模块职责和调用边界。',
208
+ '',
209
+ '## CLI 接入面',
210
+ '',
211
+ '- 待补充: 说明命令入口、参数或子命令、输出契约,以及 `help`、`doctor`、`dry-run`、`status` 等 CLI 能力;如果当前项目不提供 CLI,也要明确写不适用及原因。',
212
+ '',
213
+ '## API 接入面',
214
+ '',
215
+ '- 待补充: 说明 HTTP/RPC/WebSocket/内部服务接口、协议、返回结构和调用边界;如果当前项目不提供 API,也要明确写不适用及原因。',
216
+ '',
217
+ '## 数据流',
218
+ '',
219
+ '- 待补充: 数据输入、处理、存储和输出路径。',
220
+ '',
221
+ '## 维护规则',
222
+ '',
223
+ '- 每次服务边界、CLI/API 接入契约、数据流、存储或外部依赖发生变化后,必须检查并更新本文件。',
224
+ '',
225
+ ].join('\n'),
226
+ },
227
+ {
228
+ fileName: 'tech-stack.md',
229
+ title: '项目技术栈',
230
+ sections: ['运行环境', '核心依赖', '工具链', '维护规则'],
231
+ body: [
232
+ '# 项目技术栈',
233
+ '',
234
+ '## 运行环境',
235
+ '',
236
+ '- 待补充: 语言、运行时、平台和版本要求。',
237
+ '',
238
+ '## 核心依赖',
239
+ '',
240
+ '- 待补充: 框架、SDK、服务和关键第三方库。',
241
+ '',
242
+ '## 工具链',
243
+ '',
244
+ '- 待补充: 构建、测试、发布和质量检查命令。',
245
+ '',
246
+ '## 维护规则',
247
+ '',
248
+ '- 每次新增、移除或升级核心依赖、运行时和工具链后,必须检查并更新本文件。',
249
+ '',
250
+ ].join('\n'),
251
+ },
252
+ ];
253
+
254
+ export const STANDARD_TEMPLATE_FILES = [
255
+ {
256
+ relativePath: path.join(OPENPRD_STANDARDS_DIR, 'file-manual-template.md'),
257
+ title: '文件说明书模板',
258
+ body: [
259
+ '# 文件说明书模板',
260
+ '',
261
+ '文件说明书应位于代码文件起始位置,并根据文件类型使用对应注释语法。',
262
+ '',
263
+ '## 核心功能',
264
+ '',
265
+ '一句话描述该文件的核心职责。',
266
+ '',
267
+ '## 输入',
268
+ '',
269
+ '描述该文件接收的数据来源、参数或上游信号。',
270
+ '',
271
+ '## 输出',
272
+ '',
273
+ '描述该文件对外提供的方法、状态、事件或结果。',
274
+ '',
275
+ '## 定位',
276
+ '',
277
+ '描述该文件在当前模块或文件夹中的职责边界。',
278
+ '',
279
+ '## 依赖',
280
+ '',
281
+ '列出关键内部文件、模块或第三方依赖。',
282
+ '',
283
+ '## 维护规则',
284
+ '',
285
+ '- 每次修改代码逻辑后,必须检查并更新文件说明书。',
286
+ '- 新增文件时,如果文件说明书缺失,必须补齐。',
287
+ '- 文件说明书已存在时,仍需判断本次变更是否影响核心功能、输入、输出、定位、依赖或维护规则;如有影响必须更新。',
288
+ '- 变更影响所在文件夹职责时,必须同步更新文件夹 README。',
289
+ '',
290
+ ].join('\n'),
291
+ },
292
+ {
293
+ relativePath: path.join(OPENPRD_STANDARDS_DIR, 'folder-readme-template.md'),
294
+ title: '文件夹说明书模板',
295
+ body: [
296
+ '# 文件夹说明书模板',
297
+ '',
298
+ '文件夹说明书应保存为当前目录下的 `[模块名]_[文件夹名]_README.md`。',
299
+ '',
300
+ '## 核心功能',
301
+ '',
302
+ '一句话描述该文件夹承载的能力和目标。',
303
+ '',
304
+ '## 输入',
305
+ '',
306
+ '描述进入该文件夹模块的数据、事件或上游依赖。',
307
+ '',
308
+ '## 输出',
309
+ '',
310
+ '描述该文件夹模块对外暴露的视图、服务、接口或数据。',
311
+ '',
312
+ '## 定位',
313
+ '',
314
+ '描述该文件夹在项目结构中的位置和职责边界。',
315
+ '',
316
+ '## 依赖',
317
+ '',
318
+ '列出关键内部模块、资源、服务和第三方依赖。',
319
+ '',
320
+ '## 维护规则',
321
+ '',
322
+ '- 每次新增、删除、移动文件或调整职责后,必须检查并更新本 README。',
323
+ '- README 已存在时,仍需判断本次变更是否影响文件夹核心功能、输入、输出、定位、依赖或维护规则;如有影响必须更新。',
324
+ '- 新增源码文件夹时,如果文件夹说明书缺失,必须补齐。',
325
+ '- 文件夹职责影响项目基础文档时,必须同步更新 `docs/basic/`。',
326
+ '',
327
+ ].join('\n'),
328
+ },
329
+ ];
330
+
331
+ function cjoin(...parts) {
332
+ return path.join(...parts);
333
+ }
334
+
335
+ function exists(filePath) {
336
+ return fs.access(filePath).then(() => true).catch(() => false);
337
+ }
338
+
339
+ async function readText(filePath) {
340
+ return fs.readFile(filePath, 'utf8');
341
+ }
342
+
343
+ async function readJson(filePath) {
344
+ const text = await readText(filePath);
345
+ return JSON.parse(text);
346
+ }
347
+
348
+ async function writeText(filePath, text) {
349
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
350
+ await fs.writeFile(filePath, text, 'utf8');
351
+ }
352
+
353
+ async function writeJson(filePath, value) {
354
+ await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
355
+ }
356
+
357
+ function requiredDocPath(projectRoot, fileName) {
358
+ return cjoin(projectRoot, STANDARD_DOCS_ROOT, fileName);
359
+ }
360
+
361
+ function standardsConfigPath(projectRoot) {
362
+ return cjoin(projectRoot, OPENPRD_STANDARDS_CONFIG);
363
+ }
364
+
365
+ function validateTextSections(relativePath, text, sections, errors) {
366
+ for (const section of sections) {
367
+ if (!text.includes(`## ${section}`)) {
368
+ errors.push(`${relativePath} 缺少章节: ${section}`);
369
+ }
370
+ }
371
+ }
372
+
373
+ function sourceManualReadmeName(projectRoot, dirPath) {
374
+ const moduleName = path.basename(projectRoot).replace(/[^a-zA-Z0-9_-]+/g, '-');
375
+ const folderName = path.basename(dirPath).replace(/[^a-zA-Z0-9_-]+/g, '-');
376
+ return `${moduleName}_${folderName}_README.md`;
377
+ }
378
+
379
+ function shouldIgnoreDir(dirName) {
380
+ return SOURCE_IGNORE_DIRS.has(dirName);
381
+ }
382
+
383
+ function toPosixPath(value) {
384
+ return String(value ?? '').split(path.sep).join('/');
385
+ }
386
+
387
+ function shellQuoteArg(value) {
388
+ const text = String(value ?? '');
389
+ if (/^[a-zA-Z0-9_./:-]+$/.test(text)) {
390
+ return text;
391
+ }
392
+ return `'${text.replaceAll("'", "'\\''")}'`;
393
+ }
394
+
395
+ function globToRegExp(pattern) {
396
+ const escaped = toPosixPath(pattern)
397
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
398
+ .replace(/\*\*/g, '\u0000')
399
+ .replace(/\*/g, '[^/]*')
400
+ .replace(/\u0000/g, '.*');
401
+ return new RegExp(`^${escaped}$`);
402
+ }
403
+
404
+ function matchesPattern(relativePath, patterns = []) {
405
+ const normalized = toPosixPath(relativePath).replace(/^\/+/, '');
406
+ return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
407
+ }
408
+
409
+ function matchesIgnoredPath(relativePath, patterns = [], options = {}) {
410
+ const normalized = toPosixPath(relativePath).replace(/^\/+/, '');
411
+ if (!normalized) {
412
+ return false;
413
+ }
414
+ if (matchesPattern(normalized, patterns)) {
415
+ return true;
416
+ }
417
+ if (!options.directory) {
418
+ return false;
419
+ }
420
+ if (matchesPattern(`${normalized}/__openprd_dir__`, patterns)) {
421
+ return true;
422
+ }
423
+ return patterns.some((pattern) => {
424
+ const normalizedPattern = toPosixPath(pattern).replace(/^\/+/, '');
425
+ if (!normalizedPattern.endsWith('/**')) {
426
+ return false;
427
+ }
428
+ const prefix = normalizedPattern.slice(0, -3).replace(/\/$/, '');
429
+ return normalized === prefix || normalized.startsWith(`${prefix}/`);
430
+ });
431
+ }
432
+
433
+ function normalizeStringList(value) {
434
+ if (!Array.isArray(value)) {
435
+ return [];
436
+ }
437
+ return value.filter((item) => typeof item === 'string' && item.trim()).map((item) => item.trim());
438
+ }
439
+
440
+ function normalizeExternalReferencePath(projectRoot, value) {
441
+ const raw = String(value ?? '').trim();
442
+ if (!raw) {
443
+ throw new Error('external reference path is required.');
444
+ }
445
+ const absolutePath = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(projectRoot, raw);
446
+ const relativePath = toPosixPath(path.relative(projectRoot, absolutePath)).replace(/\/+$/, '');
447
+ if (!relativePath || relativePath === '.') {
448
+ throw new Error('external reference path cannot be the project root.');
449
+ }
450
+ if (relativePath.startsWith('../') || path.isAbsolute(relativePath)) {
451
+ throw new Error(`external reference path must be inside the project: ${raw}`);
452
+ }
453
+ return relativePath;
454
+ }
455
+
456
+ function normalizeExternalReferencePaths(projectRoot, value) {
457
+ const paths = [];
458
+ for (const item of normalizeStringList(value)) {
459
+ try {
460
+ paths.push(normalizeExternalReferencePath(projectRoot, item));
461
+ } catch {
462
+ // Invalid user config is reported elsewhere by standards config validation.
463
+ }
464
+ }
465
+ return [...new Set(paths)].sort();
466
+ }
467
+
468
+ function externalReferenceIgnorePatterns(projectRoot, config = {}) {
469
+ return normalizeExternalReferencePaths(projectRoot, config?.externalReferences?.paths)
470
+ .map((externalPath) => `${externalPath}/**`);
471
+ }
472
+
473
+ function sourceManualIgnorePatterns(config = {}) {
474
+ return [
475
+ ...DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS,
476
+ ...normalizeStringList(config?.sourceManual?.ignorePaths),
477
+ ...normalizeStringList(config?.fileManual?.ignorePaths),
478
+ ...normalizeStringList(config?.folderManual?.ignorePaths),
479
+ ];
480
+ }
481
+
482
+ async function collectSourceFiles(projectRoot, dirPath = projectRoot, files = [], ignorePatterns = DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS, discovery = { externalReferenceCandidates: [] }) {
483
+ const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => []);
484
+ for (const entry of entries) {
485
+ const absolutePath = cjoin(dirPath, entry.name);
486
+ const relativePath = toPosixPath(path.relative(projectRoot, absolutePath));
487
+ if (entry.isDirectory()) {
488
+ if (relativePath && await exists(cjoin(absolutePath, '.git'))) {
489
+ discovery.externalReferenceCandidates.push({
490
+ path: relativePath,
491
+ reason: 'nested-git',
492
+ confidence: 'high',
493
+ });
494
+ }
495
+ if (!shouldIgnoreDir(entry.name) && !matchesIgnoredPath(relativePath, ignorePatterns, { directory: true })) {
496
+ await collectSourceFiles(projectRoot, absolutePath, files, ignorePatterns, discovery);
497
+ }
498
+ continue;
499
+ }
500
+ if (!entry.isFile()) {
501
+ continue;
502
+ }
503
+ if (SOURCE_FILE_EXTENSIONS.has(path.extname(entry.name)) && !matchesIgnoredPath(relativePath, ignorePatterns)) {
504
+ files.push(absolutePath);
505
+ }
506
+ }
507
+ return files;
508
+ }
509
+
510
+ function pathIsUnder(relativePath, parentPath) {
511
+ const normalized = toPosixPath(relativePath).replace(/^\/+/, '');
512
+ const parent = toPosixPath(parentPath).replace(/^\/+|\/+$/g, '');
513
+ return normalized === parent || normalized.startsWith(`${parent}/`);
514
+ }
515
+
516
+ function longestDetectedCandidateForPath(relativePath, detectedCandidates) {
517
+ return detectedCandidates
518
+ .filter((candidate) => pathIsUnder(relativePath, candidate.path))
519
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null;
520
+ }
521
+
522
+ function fallbackCandidateRoot(relativePath) {
523
+ const parts = toPosixPath(relativePath).split('/').filter(Boolean);
524
+ if (parts.length >= 2) {
525
+ return parts.slice(0, 2).join('/');
526
+ }
527
+ return null;
528
+ }
529
+
530
+ function buildExternalReferenceCandidates({ filesMissingManual, foldersMissingManual, detectedCandidates }) {
531
+ const grouped = new Map();
532
+ const add = (candidatePath, kind, reason = 'large-manual-gap', confidence = 'medium') => {
533
+ if (!candidatePath) return;
534
+ const current = grouped.get(candidatePath) ?? {
535
+ path: candidatePath,
536
+ reason,
537
+ confidence,
538
+ missingFiles: 0,
539
+ missingFolders: 0,
540
+ suggestedCommand: `openprd standards . --classify-external ${shellQuoteArg(candidatePath)}`,
541
+ };
542
+ if (current.reason !== 'nested-git' && reason === 'nested-git') {
543
+ current.reason = reason;
544
+ current.confidence = confidence;
545
+ }
546
+ if (kind === 'file') current.missingFiles += 1;
547
+ if (kind === 'folder') current.missingFolders += 1;
548
+ grouped.set(candidatePath, current);
549
+ };
550
+
551
+ const detected = [...new Map(detectedCandidates.map((candidate) => [candidate.path, candidate])).values()];
552
+ for (const relativePath of filesMissingManual) {
553
+ const detectedRoot = longestDetectedCandidateForPath(relativePath, detected);
554
+ add(detectedRoot?.path ?? fallbackCandidateRoot(relativePath), 'file', detectedRoot?.reason, detectedRoot?.confidence);
555
+ }
556
+ for (const relativePath of foldersMissingManual) {
557
+ const detectedRoot = longestDetectedCandidateForPath(relativePath, detected);
558
+ add(detectedRoot?.path ?? fallbackCandidateRoot(relativePath), 'folder', detectedRoot?.reason, detectedRoot?.confidence);
559
+ }
560
+
561
+ return [...grouped.values()]
562
+ .filter((candidate) => candidate.reason === 'nested-git' || candidate.missingFiles >= EXTERNAL_REFERENCE_CANDIDATE_MIN_FILES)
563
+ .sort((a, b) => (b.missingFiles + b.missingFolders) - (a.missingFiles + a.missingFolders));
564
+ }
565
+
566
+ function shouldProvisionallyIgnoreExternalReferenceCandidate(candidate) {
567
+ if (!candidate?.path) {
568
+ return false;
569
+ }
570
+ if (candidate.reason === 'nested-git') {
571
+ return true;
572
+ }
573
+ return toPosixPath(candidate.path)
574
+ .split('/')
575
+ .filter(Boolean)
576
+ .some((segment) => PROVISIONAL_EXTERNAL_REFERENCE_PATH_SEGMENTS.has(segment));
577
+ }
578
+
579
+ function filterRelativePathsOutsideParents(relativePaths, parentPaths = []) {
580
+ if (!Array.isArray(parentPaths) || parentPaths.length === 0) {
581
+ return relativePaths;
582
+ }
583
+ return relativePaths.filter((relativePath) => !parentPaths.some((parentPath) => pathIsUnder(relativePath, parentPath)));
584
+ }
585
+
586
+ async function isOpenPrdToolProject(projectRoot) {
587
+ const packageJson = await readJson(cjoin(projectRoot, 'package.json')).catch(() => null);
588
+ return packageJson?.name === '@openprd/cli';
589
+ }
590
+
591
+ function hasAllManualSections(text) {
592
+ return STANDARD_MANUAL_SECTIONS.every((section) => text.includes(`## ${section}`));
593
+ }
594
+
595
+ function hasHeaderManual(text) {
596
+ const header = text.split(/\r?\n/).slice(0, 80).join('\n');
597
+ return hasAllManualSections(header);
598
+ }
599
+
600
+ async function validateSourceManuals(projectRoot, errors, checks, options = {}) {
601
+ const discovery = { externalReferenceCandidates: [] };
602
+ const sourceFiles = await collectSourceFiles(projectRoot, projectRoot, [], options.ignorePatterns, discovery);
603
+ const filesMissingManualRaw = [];
604
+ const foldersMissingManualRaw = [];
605
+
606
+ for (const filePath of sourceFiles) {
607
+ const relativePath = path.relative(projectRoot, filePath);
608
+ const text = await readText(filePath).catch(() => '');
609
+ if (!hasHeaderManual(text)) {
610
+ filesMissingManualRaw.push(relativePath);
611
+ }
612
+ }
613
+
614
+ const sourceDirsRaw = new Set(sourceFiles.map((filePath) => path.dirname(filePath)));
615
+ for (const dirPath of sourceDirsRaw) {
616
+ const expectedPath = cjoin(dirPath, sourceManualReadmeName(projectRoot, dirPath));
617
+ const relativePath = path.relative(projectRoot, expectedPath);
618
+ const text = await readText(expectedPath).catch(() => null);
619
+ if (!text || !hasAllManualSections(text)) {
620
+ foldersMissingManualRaw.push(relativePath);
621
+ }
622
+ }
623
+
624
+ const externalReferenceCandidates = buildExternalReferenceCandidates({
625
+ filesMissingManual: filesMissingManualRaw,
626
+ foldersMissingManual: foldersMissingManualRaw,
627
+ detectedCandidates: discovery.externalReferenceCandidates,
628
+ });
629
+ const provisionalExternalReferencePaths = externalReferenceCandidates
630
+ .filter(shouldProvisionallyIgnoreExternalReferenceCandidate)
631
+ .map((candidate) => candidate.path);
632
+ const filteredSourceFilePaths = sourceFiles.filter((filePath) => !provisionalExternalReferencePaths.some((parentPath) => pathIsUnder(path.relative(projectRoot, filePath), parentPath)));
633
+ const sourceDirs = new Set(filteredSourceFilePaths.map((filePath) => path.dirname(filePath)));
634
+ const filesMissingManual = filterRelativePathsOutsideParents(filesMissingManualRaw, provisionalExternalReferencePaths);
635
+ const foldersMissingManual = filterRelativePathsOutsideParents(foldersMissingManualRaw, provisionalExternalReferencePaths);
636
+
637
+ for (const relativePath of filesMissingManual) {
638
+ errors.push(`${relativePath} 缺少文件说明书;请在文件头部补齐 ${STANDARD_MANUAL_SECTIONS.join('、')}。`);
639
+ }
640
+ for (const relativePath of foldersMissingManual) {
641
+ errors.push(`${relativePath} 缺少文件夹说明书或章节不完整。`);
642
+ }
643
+
644
+ checks.push(`源文件说明书: ${filteredSourceFilePaths.length - filesMissingManual.length}/${filteredSourceFilePaths.length}。`);
645
+ checks.push(`文件夹说明书: ${sourceDirs.size - foldersMissingManual.length}/${sourceDirs.size}。`);
646
+ if (options.externalReferencePaths?.length) {
647
+ checks.push(`外部参考源码: ${options.externalReferencePaths.length} 个已跳过逐文件说明书。`);
648
+ }
649
+ if (provisionalExternalReferencePaths.length > 0) {
650
+ checks.push(`外部参考候选: ${provisionalExternalReferencePaths.length} 个已暂按候选跳过逐文件说明书,待人工确认。`);
651
+ }
652
+
653
+ return {
654
+ ignorePatterns: options.ignorePatterns ?? DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS,
655
+ externalReferencePaths: options.externalReferencePaths ?? [],
656
+ provisionalExternalReferencePaths,
657
+ externalReferenceCandidates,
658
+ sourceFiles: filteredSourceFilePaths.map((filePath) => path.relative(projectRoot, filePath)),
659
+ sourceDirs: [...sourceDirs].map((dirPath) => path.relative(projectRoot, dirPath) || '.'),
660
+ filesMissingManual,
661
+ foldersMissingManual,
662
+ };
663
+ }
664
+
665
+ async function writeIfNeeded(filePath, text, options = {}) {
666
+ const fileExists = await exists(filePath);
667
+ if (fileExists && !options.force) {
668
+ return 'skipped';
669
+ }
670
+ await writeText(filePath, text);
671
+ return fileExists ? 'overwritten' : 'created';
672
+ }
673
+
674
+ function buildStandardsConfig() {
675
+ return {
676
+ version: 1,
677
+ docsRoot: STANDARD_DOCS_ROOT.replaceAll(path.sep, '/'),
678
+ requiredDocs: STANDARD_DOCS.map((doc) => doc.fileName),
679
+ fileManual: {
680
+ enabled: true,
681
+ template: 'file-manual-template.md',
682
+ requiredSections: STANDARD_MANUAL_SECTIONS,
683
+ placement: 'file-header',
684
+ ignorePaths: DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS,
685
+ },
686
+ folderManual: {
687
+ enabled: true,
688
+ template: 'folder-readme-template.md',
689
+ naming: '[module]_[folder]_README.md',
690
+ requiredSections: STANDARD_MANUAL_SECTIONS,
691
+ ignorePaths: DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS,
692
+ },
693
+ sourceManual: {
694
+ ignorePaths: DEFAULT_SOURCE_MANUAL_IGNORE_PATTERNS,
695
+ },
696
+ developmentStandards: DEFAULT_DEVELOPMENT_STANDARDS,
697
+ growth: DEFAULT_GROWTH_CONFIG,
698
+ externalReferences: {
699
+ paths: [],
700
+ },
701
+ qualityGates: {
702
+ changeValidateRequiresStandards: true,
703
+ taskVerifyUsesStandards: true,
704
+ discoveryVerifyRequiresStandards: true,
705
+ sourceManuals: true,
706
+ },
707
+ };
708
+ }
709
+
710
+ export async function standardsConfigExists(projectRoot) {
711
+ return exists(standardsConfigPath(projectRoot));
712
+ }
713
+
714
+ export async function initStandardsWorkspace(projectRoot, options = {}) {
715
+ const changed = [];
716
+ const configPath = standardsConfigPath(projectRoot);
717
+ const configStatus = await writeIfNeeded(configPath, `${JSON.stringify(buildStandardsConfig(), null, 2)}\n`, options);
718
+ changed.push({ path: path.relative(projectRoot, configPath), status: configStatus });
719
+
720
+ for (const template of STANDARD_TEMPLATE_FILES) {
721
+ const templatePath = cjoin(projectRoot, template.relativePath);
722
+ const status = await writeIfNeeded(templatePath, template.body, options);
723
+ changed.push({ path: path.relative(projectRoot, templatePath), status });
724
+ }
725
+
726
+ for (const doc of STANDARD_DOCS) {
727
+ const docPath = requiredDocPath(projectRoot, doc.fileName);
728
+ const status = await writeIfNeeded(docPath, doc.body, options);
729
+ changed.push({ path: path.relative(projectRoot, docPath), status });
730
+ }
731
+
732
+ const report = await checkStandardsWorkspace(projectRoot);
733
+ return {
734
+ ok: report.ok,
735
+ action: 'init',
736
+ projectRoot,
737
+ docsRoot: STANDARD_DOCS_ROOT,
738
+ changed,
739
+ report,
740
+ };
741
+ }
742
+
743
+ export async function checkStandardsWorkspace(projectRoot, options = {}) {
744
+ const errors = [];
745
+ const warnings = [];
746
+ const checks = [];
747
+ const configPath = standardsConfigPath(projectRoot);
748
+
749
+ if (!(await exists(configPath))) {
750
+ if (options.optional) {
751
+ return {
752
+ ok: true,
753
+ valid: true,
754
+ skipped: true,
755
+ projectRoot,
756
+ docsRoot: STANDARD_DOCS_ROOT,
757
+ errors,
758
+ warnings,
759
+ checks: ['OpenPrd standards are not initialized.'],
760
+ requiredDocs: [],
761
+ templateFiles: [],
762
+ };
763
+ }
764
+ errors.push(`${OPENPRD_STANDARDS_CONFIG} is required. Run: openprd standards . --init`);
765
+ return {
766
+ ok: false,
767
+ valid: false,
768
+ skipped: false,
769
+ projectRoot,
770
+ docsRoot: STANDARD_DOCS_ROOT,
771
+ errors,
772
+ warnings,
773
+ checks,
774
+ requiredDocs: [],
775
+ templateFiles: [],
776
+ };
777
+ }
778
+
779
+ const config = await readJson(configPath).catch((error) => {
780
+ errors.push(`Invalid ${OPENPRD_STANDARDS_CONFIG}: ${error.message}`);
781
+ return null;
782
+ });
783
+
784
+ if (config) {
785
+ if (config.docsRoot !== 'docs/basic') {
786
+ errors.push(`${OPENPRD_STANDARDS_CONFIG} docsRoot must be docs/basic.`);
787
+ }
788
+ validateDevelopmentStandardsConfig(config, errors);
789
+ validateGrowthConfig(config, errors);
790
+ for (const externalPath of normalizeStringList(config?.externalReferences?.paths)) {
791
+ try {
792
+ normalizeExternalReferencePath(projectRoot, externalPath);
793
+ } catch (error) {
794
+ errors.push(`${OPENPRD_STANDARDS_CONFIG} externalReferences.paths has invalid path ${externalPath}: ${error.message}`);
795
+ }
796
+ }
797
+ }
798
+
799
+ const externalReferencePaths = normalizeExternalReferencePaths(projectRoot, config?.externalReferences?.paths);
800
+ const ignorePatterns = [
801
+ ...sourceManualIgnorePatterns(config ?? {}),
802
+ ...externalReferenceIgnorePatterns(projectRoot, config ?? {}),
803
+ ];
804
+ const requiredDocs = [];
805
+ const sourceFiles = await collectSourceFiles(projectRoot, projectRoot, [], ignorePatterns);
806
+ const hasProjectSource = sourceFiles.length > 0;
807
+ const openPrdToolProject = await isOpenPrdToolProject(projectRoot);
808
+ for (const doc of STANDARD_DOCS) {
809
+ const docPath = requiredDocPath(projectRoot, doc.fileName);
810
+ const relativePath = path.relative(projectRoot, docPath);
811
+ const existsDoc = await exists(docPath);
812
+ requiredDocs.push({ path: relativePath, exists: existsDoc });
813
+ if (!existsDoc) {
814
+ errors.push(`${relativePath} is required.`);
815
+ continue;
816
+ }
817
+ const text = await readText(docPath);
818
+ if (!text.includes(`# ${doc.title}`)) {
819
+ errors.push(`${relativePath} 缺少标题: ${doc.title}`);
820
+ }
821
+ validateTextSections(relativePath, text, doc.sections, errors);
822
+ if (hasProjectSource && !openPrdToolProject && options.docsContent !== false && DOC_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text))) {
823
+ errors.push(`${relativePath} 仍包含模板占位内容,必须更新为当前项目事实。`);
824
+ }
825
+ }
826
+
827
+ const templateFiles = [];
828
+ for (const template of STANDARD_TEMPLATE_FILES) {
829
+ const templatePath = cjoin(projectRoot, template.relativePath);
830
+ const relativePath = path.relative(projectRoot, templatePath);
831
+ const existsTemplate = await exists(templatePath);
832
+ templateFiles.push({ path: relativePath, exists: existsTemplate });
833
+ if (!existsTemplate) {
834
+ errors.push(`${relativePath} is required.`);
835
+ continue;
836
+ }
837
+ const text = await readText(templatePath);
838
+ validateTextSections(relativePath, text, STANDARD_MANUAL_SECTIONS, errors);
839
+ }
840
+
841
+ const enforceSourceManuals = hasProjectSource
842
+ && options.sourceManuals !== false
843
+ && !openPrdToolProject
844
+ && config?.qualityGates?.sourceManuals !== false
845
+ && config?.fileManual?.enabled !== false
846
+ && config?.folderManual?.enabled !== false;
847
+ const manualReport = enforceSourceManuals
848
+ ? await validateSourceManuals(projectRoot, errors, checks, { ignorePatterns, externalReferencePaths })
849
+ : {
850
+ ignorePatterns,
851
+ externalReferencePaths,
852
+ externalReferenceCandidates: [],
853
+ sourceFiles: [],
854
+ sourceDirs: [],
855
+ filesMissingManual: [],
856
+ foldersMissingManual: [],
857
+ };
858
+
859
+ checks.push(`Standards docs root: ${STANDARD_DOCS_ROOT.replaceAll(path.sep, '/')}`);
860
+ checks.push(`Required docs: ${requiredDocs.filter((doc) => doc.exists).length}/${STANDARD_DOCS.length}.`);
861
+ checks.push(`Manual templates: ${templateFiles.filter((file) => file.exists).length}/${STANDARD_TEMPLATE_FILES.length}.`);
862
+ const lineConfig = config?.developmentStandards?.codeFileLines ?? DEFAULT_DEVELOPMENT_STANDARDS.codeFileLines;
863
+ checks.push(`Development standards: code files ok <= ${lineConfig.okMax} lines, attention <= ${lineConfig.attentionMax} lines.`);
864
+ checks.push(`Growth layer: ${config?.growth?.enabled === false ? 'disabled' : 'enabled'} with user review required before applying candidates.`);
865
+
866
+ return {
867
+ ok: errors.length === 0,
868
+ valid: errors.length === 0,
869
+ skipped: false,
870
+ projectRoot,
871
+ docsRoot: STANDARD_DOCS_ROOT,
872
+ errors,
873
+ warnings,
874
+ checks,
875
+ requiredDocs,
876
+ templateFiles,
877
+ manualReport,
878
+ };
879
+ }
880
+
881
+ export async function classifyExternalReferenceWorkspace(projectRoot, options = {}) {
882
+ const configPath = standardsConfigPath(projectRoot);
883
+ if (!(await exists(configPath))) {
884
+ throw new Error(`${OPENPRD_STANDARDS_CONFIG} is required. Run: openprd standards . --init`);
885
+ }
886
+ const config = await readJson(configPath);
887
+ const externalPath = normalizeExternalReferencePath(projectRoot, options.externalReference);
888
+ const absoluteExternalPath = cjoin(projectRoot, externalPath);
889
+ const externalStat = await fs.stat(absoluteExternalPath).catch(() => null);
890
+ if (!externalStat?.isDirectory()) {
891
+ throw new Error(`external reference path must be an existing directory: ${externalPath}`);
892
+ }
893
+ const paths = new Set(normalizeExternalReferencePaths(projectRoot, config?.externalReferences?.paths));
894
+ const alreadyPresent = paths.has(externalPath);
895
+ paths.add(externalPath);
896
+ const updatedConfig = {
897
+ ...config,
898
+ externalReferences: {
899
+ ...(config.externalReferences ?? {}),
900
+ paths: [...paths].sort(),
901
+ },
902
+ };
903
+ await writeJson(configPath, updatedConfig);
904
+ return {
905
+ ok: true,
906
+ action: 'classify-external-reference',
907
+ projectRoot,
908
+ docsRoot: STANDARD_DOCS_ROOT,
909
+ path: externalPath,
910
+ alreadyPresent,
911
+ configPath: path.relative(projectRoot, configPath),
912
+ changed: alreadyPresent ? [] : [{ path: externalPath, status: 'external-reference' }],
913
+ };
914
+ }