@openprd/cli 0.1.1 → 0.1.8

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 (137) hide show
  1. package/.openprd/README.md +43 -69
  2. package/.openprd/README_EN.md +84 -0
  3. package/.openprd/benchmarks/index.md +7 -0
  4. package/.openprd/benchmarks/sources.yaml +25 -3
  5. package/.openprd/discovery/config.json +16 -2
  6. package/.openprd/engagements/active/flows.md +19 -14
  7. package/.openprd/engagements/active/handoff.md +11 -4
  8. package/.openprd/engagements/active/prd.md +99 -71
  9. package/.openprd/engagements/active/review.html +4 -4
  10. package/.openprd/engagements/active/roles.md +9 -8
  11. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +4 -4
  12. package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +18 -0
  13. package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +18 -0
  14. package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +18 -0
  15. package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +18 -0
  16. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +78 -0
  17. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +129 -0
  18. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +41 -0
  19. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +14 -0
  20. package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +49 -0
  21. package/.openprd/knowledge/index.json +44 -4
  22. package/.openprd/reviews/v0001.html +195 -129
  23. package/.openprd/reviews/v0002.html +1150 -0
  24. package/.openprd/reviews/v0003.html +1150 -0
  25. package/.openprd/reviews/v0004.html +1150 -0
  26. package/.openprd/reviews/v0005.html +1150 -0
  27. package/.openprd/standards/config.json +12 -9
  28. package/.openprd/state/changes.json +17 -2
  29. package/.openprd/state/current.json +399 -63
  30. package/.openprd/state/release-ledger.json +344 -0
  31. package/.openprd/state/version-index.json +52 -0
  32. package/.openprd/state/versions/v0002.json +264 -0
  33. package/.openprd/state/versions/v0002.md +183 -0
  34. package/.openprd/state/versions/v0003.json +269 -0
  35. package/.openprd/state/versions/v0003.md +188 -0
  36. package/.openprd/state/versions/v0004.json +274 -0
  37. package/.openprd/state/versions/v0004.md +193 -0
  38. package/.openprd/state/versions/v0005.json +299 -0
  39. package/.openprd/state/versions/v0005.md +189 -0
  40. package/.openprd/templates/agent/intake.md +5 -4
  41. package/.openprd/templates/b2b/intake.md +5 -4
  42. package/.openprd/templates/base/intake.md +10 -4
  43. package/.openprd/templates/company/README.md +9 -7
  44. package/.openprd/templates/company/README_EN.md +12 -0
  45. package/.openprd/templates/consumer/intake.md +5 -4
  46. package/.openprd/templates/industry/README.md +12 -10
  47. package/.openprd/templates/industry/README_EN.md +18 -0
  48. package/.openprd/templates/project/README.md +11 -9
  49. package/.openprd/templates/project/README_EN.md +16 -0
  50. package/.openprd/templates/session/README.md +11 -9
  51. package/.openprd/templates/session/README_EN.md +16 -0
  52. package/AGENTS.md +12 -8
  53. package/README.md +399 -438
  54. package/README_CN.md +4 -578
  55. package/README_EN.md +850 -0
  56. package/docs/assets/openprd-requirement-routing-en.png +0 -0
  57. package/docs/assets/openprd-requirement-routing-en.svg +102 -0
  58. package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
  59. package/docs/assets/openprd-requirement-routing-zh.png +0 -0
  60. package/docs/assets/openprd-requirement-routing-zh.svg +102 -0
  61. package/package.json +6 -2
  62. package/scripts/dev-check-wrapup-copy.mjs +110 -0
  63. package/scripts/openprd-github-release-notes.mjs +99 -0
  64. package/scripts/quality-perf-check.mjs +203 -0
  65. package/skills/openprd-benchmark-router/SKILL.md +1 -0
  66. package/skills/openprd-benchmark-router/references/benchmark-sources.md +1 -0
  67. package/skills/openprd-benchmark-router/references/source-policy.md +2 -0
  68. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  69. package/skills/openprd-harness/SKILL.md +46 -24
  70. package/skills/openprd-harness/references/workflow-gates.md +15 -0
  71. package/skills/openprd-quality/SKILL.md +10 -4
  72. package/skills/openprd-requirement-intake/SKILL.md +31 -20
  73. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -6
  74. package/skills/openprd-requirement-intake/references/routing-rubric.md +10 -2
  75. package/skills/openprd-router/SKILL.md +2 -2
  76. package/skills/openprd-shared/SKILL.md +51 -23
  77. package/skills/openprd-standards/SKILL.md +2 -1
  78. package/src/agent-integration.js +265 -65
  79. package/src/benchmark/constants.js +107 -0
  80. package/src/benchmark/operations.js +235 -0
  81. package/src/benchmark/registry.js +64 -0
  82. package/src/benchmark/render.js +115 -0
  83. package/src/benchmark/source.js +617 -0
  84. package/src/benchmark/storage.js +121 -0
  85. package/src/benchmark/verify.js +235 -0
  86. package/src/benchmark.js +50 -851
  87. package/src/change-summary.js +339 -0
  88. package/src/cli/args.js +67 -6
  89. package/src/cli/basic-print.js +365 -0
  90. package/src/cli/benchmark-print.js +91 -0
  91. package/src/cli/change-print.js +221 -0
  92. package/src/cli/doctor-print.js +268 -0
  93. package/src/cli/growth-print.js +176 -0
  94. package/src/cli/print.js +73 -1384
  95. package/src/cli/quality-print.js +284 -0
  96. package/src/cli/run-print.js +297 -0
  97. package/src/cli/shared-print.js +127 -0
  98. package/src/cli/workflow-print.js +195 -0
  99. package/src/codex-hook-runner-template.mjs +639 -117
  100. package/src/codex-runtime.js +324 -0
  101. package/src/dev-standards.js +178 -5
  102. package/src/diagram-core.js +5 -5
  103. package/src/discovery.js +2 -1
  104. package/src/execution-strategy.js +369 -0
  105. package/src/fleet.js +4 -0
  106. package/src/github-release.js +156 -0
  107. package/src/growth.js +311 -13
  108. package/src/html-artifact-utils.js +25 -0
  109. package/src/html-artifacts.js +157 -1596
  110. package/src/knowledge.js +1176 -75
  111. package/src/language-policy.js +2 -112
  112. package/src/learning-html-artifact.js +1031 -0
  113. package/src/learning-review.js +3 -2
  114. package/src/loop.js +280 -9
  115. package/src/openprd.js +341 -38
  116. package/src/openspec/change-validate.js +0 -9
  117. package/src/openspec/execute.js +79 -3
  118. package/src/openspec/generate.js +33 -20
  119. package/src/openspec/tasks.js +33 -2
  120. package/src/prd-core.js +10 -9
  121. package/src/product-type-copy.js +69 -0
  122. package/src/quality-html-artifact.js +108 -9
  123. package/src/quality-learning.js +30 -0
  124. package/src/quality-visual-review.js +237 -0
  125. package/src/quality.js +329 -43
  126. package/src/registry-hygiene.js +54 -0
  127. package/src/release-ledger.js +413 -0
  128. package/src/review-presentation.js +12 -6
  129. package/src/run-harness.js +722 -48
  130. package/src/session-binding.js +40 -3
  131. package/src/session-registry.js +159 -0
  132. package/src/standards.js +5 -3
  133. package/src/test-strategy.js +386 -0
  134. package/src/visual-compare.js +915 -34
  135. package/src/work-unit-migration.js +5 -1
  136. package/src/workspace-core.js +343 -19
  137. package/src/workspace-workflow.js +538 -134
@@ -0,0 +1,617 @@
1
+ /*
2
+ * 核心功能
3
+ * 处理 benchmark 信源识别、场景推断、采纳证据归一化和推荐晋级规则。
4
+ *
5
+ * 输入
6
+ * 接收 url/path/source record、observe 参数和候选来源文本描述。
7
+ *
8
+ * 输出
9
+ * 导出 source record 归一化、identity 去重、采纳统计和 promotion 校验能力。
10
+ *
11
+ * 定位
12
+ * 位于 benchmark 领域逻辑层,只管理信源和采纳语义,不直接读写 registry 文件。
13
+ *
14
+ * 依赖
15
+ * 依赖 fs-utils 的 exists、time 的 timestamp,以及 constants 提供的共享阈值与路径规则。
16
+ *
17
+ * 维护规则
18
+ * 新增场景、触发词或晋级规则时,必须保持现有 source id/sourceKey 稳定,避免破坏已落盘数据。
19
+ */
20
+ import path from 'node:path';
21
+ import { exists } from '../fs-utils.js';
22
+ import { timestamp } from '../time.js';
23
+ import {
24
+ DAY_MS,
25
+ DEFAULT_ADOPTION_THRESHOLD,
26
+ DEFAULT_ADOPTION_WINDOW_DAYS,
27
+ MAX_ADOPTION_EVIDENCE,
28
+ OVERBROAD_TRIGGER_TOKENS,
29
+ slugify,
30
+ } from './constants.js';
31
+
32
+ const COMMON_SECOND_LEVEL_DOMAINS = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org']);
33
+
34
+ function isHttpUrl(value) {
35
+ return /^https?:\/\//i.test(String(value ?? '').trim());
36
+ }
37
+
38
+ function isGitHubShorthand(value) {
39
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value ?? '').trim());
40
+ }
41
+
42
+ function normalizeRemoteUrl(value) {
43
+ if (isGitHubShorthand(value)) {
44
+ return `https://github.com/${String(value).trim()}`;
45
+ }
46
+ return String(value ?? '').trim();
47
+ }
48
+
49
+ function toRepoSlug(urlString) {
50
+ try {
51
+ const url = new URL(urlString);
52
+ if (!/github\.com$/i.test(url.hostname)) {
53
+ return null;
54
+ }
55
+ const segments = url.pathname.split('/').filter(Boolean);
56
+ if (segments.length < 2) {
57
+ return null;
58
+ }
59
+ return `${segments[0]}/${segments[1]}`.replace(/\.git$/i, '');
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function normalizeHost(hostname) {
66
+ return String(hostname ?? '').trim().toLowerCase().replace(/^www\./, '');
67
+ }
68
+
69
+ function registrableDomain(hostname) {
70
+ const host = normalizeHost(hostname);
71
+ const parts = host.split('.').filter(Boolean);
72
+ if (parts.length <= 2) {
73
+ return host;
74
+ }
75
+ const last = parts.at(-1);
76
+ const previous = parts.at(-2);
77
+ if (last?.length === 2 && COMMON_SECOND_LEVEL_DOMAINS.has(previous)) {
78
+ return parts.slice(-3).join('.');
79
+ }
80
+ return parts.slice(-2).join('.');
81
+ }
82
+
83
+ function canonicalUrlSourceKey(urlString) {
84
+ try {
85
+ const url = new URL(urlString);
86
+ const repo = toRepoSlug(urlString);
87
+ if (repo) {
88
+ return `github.com/${repo.toLowerCase()}`;
89
+ }
90
+ const domain = registrableDomain(url.hostname);
91
+ const firstPathSegment = url.pathname
92
+ .split('/')
93
+ .filter(Boolean)
94
+ .map((segment) => segment.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5.-]+/g, '-'))
95
+ .find(Boolean);
96
+ return firstPathSegment ? `${domain}/${firstPathSegment}` : domain;
97
+ } catch {
98
+ return String(urlString ?? '').trim().toLowerCase();
99
+ }
100
+ }
101
+
102
+ function dedupe(values) {
103
+ return [...new Set((values ?? []).filter(Boolean))];
104
+ }
105
+
106
+ function normalizeAdoptionEvidence(value) {
107
+ return dedupe((Array.isArray(value) ? value : [])
108
+ .filter((item) => item !== null && item !== undefined)
109
+ .map((item) => {
110
+ if (typeof item === 'string') {
111
+ return { note: item };
112
+ }
113
+ if (typeof item !== 'object') {
114
+ return { note: String(item) };
115
+ }
116
+ return {
117
+ observedAt: item.observedAt ?? null,
118
+ task: item.task ?? null,
119
+ reason: item.reason ?? item.note ?? null,
120
+ adoptedSignal: item.adoptedSignal ?? null,
121
+ source: item.source ?? null,
122
+ };
123
+ })
124
+ .map((item) => JSON.stringify(item)))
125
+ .map((item) => JSON.parse(item))
126
+ .slice(-MAX_ADOPTION_EVIDENCE);
127
+ }
128
+
129
+ function normalizeAdoptionWindowDays(value) {
130
+ const windowDays = Number(value ?? DEFAULT_ADOPTION_WINDOW_DAYS);
131
+ return Number.isInteger(windowDays) && windowDays > 0 ? windowDays : DEFAULT_ADOPTION_WINDOW_DAYS;
132
+ }
133
+
134
+ function parseObservedAt(value) {
135
+ if (!value) return null;
136
+ if (value instanceof Date) {
137
+ return Number.isNaN(value.getTime()) ? null : value;
138
+ }
139
+ const text = String(value).trim();
140
+ if (!text) return null;
141
+ const parsedIso = new Date(text);
142
+ if (!Number.isNaN(parsedIso.getTime())) {
143
+ return parsedIso;
144
+ }
145
+ const match = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/u);
146
+ if (!match) {
147
+ return null;
148
+ }
149
+ const [, year, month, day, hour = '00', minute = '00', second = '00'] = match;
150
+ return new Date(
151
+ Number(year),
152
+ Number(month) - 1,
153
+ Number(day),
154
+ Number(hour),
155
+ Number(minute),
156
+ Number(second),
157
+ );
158
+ }
159
+
160
+ function countRecentAdoptions(evidence, windowDays, now = new Date()) {
161
+ const cutoff = now.getTime() - (normalizeAdoptionWindowDays(windowDays) * DAY_MS);
162
+ return evidence.filter((item) => {
163
+ const observedAt = parseObservedAt(item.observedAt);
164
+ return observedAt && observedAt.getTime() >= cutoff;
165
+ }).length;
166
+ }
167
+
168
+ function inferSourceType(urlString, sourceValue) {
169
+ if (sourceValue?.kind === 'local-file') {
170
+ return 'local-file';
171
+ }
172
+ const normalized = String(urlString ?? '').toLowerCase();
173
+ if (normalized.includes('github.com/')) {
174
+ return 'github';
175
+ }
176
+ if (
177
+ normalized.includes('/docs')
178
+ || normalized.includes('developers.openai.com')
179
+ || normalized.includes('platform.claude.com')
180
+ || normalized.includes('code.claude.com')
181
+ || normalized.includes('ai.google.dev')
182
+ ) {
183
+ return 'official-docs';
184
+ }
185
+ if (
186
+ normalized.includes('/blog/')
187
+ || normalized.includes('/engineering/')
188
+ || normalized.includes('openai.com/index/')
189
+ || normalized.includes('anthropic.com/engineering/')
190
+ || normalized.includes('langchain.com/blog/')
191
+ || normalized.includes('manus.im/blog/')
192
+ ) {
193
+ return 'engineering-article';
194
+ }
195
+ return 'web';
196
+ }
197
+
198
+ function inferResearchMethod(sourceType) {
199
+ if (sourceType === 'github') {
200
+ return 'deepwiki_then_github';
201
+ }
202
+ if (sourceType === 'official-docs') {
203
+ return 'context7_then_official';
204
+ }
205
+ if (sourceType === 'local-file') {
206
+ return 'local_read_first';
207
+ }
208
+ return 'official_page_first';
209
+ }
210
+
211
+ function inferScenarios(text) {
212
+ const normalized = String(text ?? '').toLowerCase();
213
+ const scenarios = [];
214
+ const add = (value) => {
215
+ if (!scenarios.includes(value)) {
216
+ scenarios.push(value);
217
+ }
218
+ };
219
+
220
+ if (/(openprd|openspec|superpowers|prd|product requirements?)/i.test(normalized)) {
221
+ add('openprd-product');
222
+ }
223
+ if (/(cli|doctor|dry-run|command discoverability|developer experience|dx)/i.test(normalized)) {
224
+ add('cli-tooling');
225
+ add('developer-experience');
226
+ }
227
+ if (/(skill|skills|skill discovery|skill install|skill router)/i.test(normalized)) {
228
+ add('skill-design');
229
+ }
230
+ if (/(harness|agent|long-running|workflow loop|managed agents)/i.test(normalized)) {
231
+ add('agent-harness');
232
+ }
233
+ if (/(code review|pr review|pull request review|review lane|reviewer agreement|false positive|hallucination filter|merge recommendation|critical\/high\/medium\/low|deep review|independent reviewers|交叉验证|误报过滤|合并建议|深度代码审查|并行审查|审查分级|独立审查|reviewer agreement)/i.test(normalized)) {
234
+ add('pr-review-harness');
235
+ add('agent-harness');
236
+ }
237
+ if (/(context engineering|context window|context registry|retrieval)/i.test(normalized)) {
238
+ add('context-engineering');
239
+ }
240
+ if (/(prompt engineering|prompting|system prompt|prompt guidance)/i.test(normalized)) {
241
+ add('prompt-engineering');
242
+ }
243
+ if (/(icon|icons|iconfont|lucide|tabler|react icons|phosphor|lobehub|techicons|thiings|图标|图标站|图标库|视觉资产)/i.test(normalized)) {
244
+ add('icon-resources');
245
+ }
246
+
247
+ return scenarios;
248
+ }
249
+
250
+ function inferTriggerWhen(scenarios) {
251
+ const lines = [];
252
+ for (const scenario of scenarios) {
253
+ if (scenario === 'openprd-product') {
254
+ lines.push('设计 OpenPrd / PRD 工作流、需求入口、状态承接或生成规则');
255
+ }
256
+ if (scenario === 'cli-tooling') {
257
+ lines.push('设计 CLI 命令、doctor、dry-run、错误提示、确认流程或可发现性');
258
+ }
259
+ if (scenario === 'skill-design') {
260
+ lines.push('设计 skill 触发、metadata、安装方式、自动识别或项目级覆盖规则');
261
+ }
262
+ if (scenario === 'agent-harness') {
263
+ lines.push('设计 Agent harness、长程任务、状态持久化、验证门禁或人工接管');
264
+ }
265
+ if (scenario === 'pr-review-harness') {
266
+ lines.push('设计 merge 前高风险复核、独立 reviewer 交叉验证、误报过滤、reviewer agreement 或 merge recommendation');
267
+ }
268
+ if (scenario === 'context-engineering') {
269
+ lines.push('设计上下文常驻、按需检索、registry/索引或证据优先级');
270
+ }
271
+ if (scenario === 'prompt-engineering') {
272
+ lines.push('设计系统提示、skill 提示、任务提示或 structured prompting');
273
+ }
274
+ if (scenario === 'developer-experience') {
275
+ lines.push('设计开发者体验、命令组合方式、输出结构或错误恢复路径');
276
+ }
277
+ if (scenario === 'icon-resources') {
278
+ lines.push('选择 UI、AI、技术栈、3D 或功能图标资源站,或选择 Lucide、Tabler、React Icons 等实现库');
279
+ }
280
+ }
281
+ return dedupe(lines).slice(0, 3);
282
+ }
283
+
284
+ function inferNotFor(scenarios) {
285
+ const exclusions = [];
286
+ if (!scenarios.includes('openprd-product')) {
287
+ exclusions.push('普通 PRD / 产品流程设计');
288
+ }
289
+ if (!scenarios.includes('cli-tooling')) {
290
+ exclusions.push('与 CLI 无关的一次性 UI 视觉问题');
291
+ }
292
+ if (!scenarios.includes('agent-harness')) {
293
+ exclusions.push('单次脚本报错或纯环境权限问题');
294
+ }
295
+ if (!scenarios.includes('pr-review-harness')) {
296
+ exclusions.push('与 PR 审查 lane 无关的普通实现任务');
297
+ } else {
298
+ exclusions.push('默认给每个低风险 PR 拉起多 reviewer 并行审查');
299
+ }
300
+ if (!scenarios.includes('prompt-engineering')) {
301
+ exclusions.push('不涉及提示词或上下文工程的纯实现细节');
302
+ }
303
+ if (scenarios.includes('icon-resources')) {
304
+ exclusions.push('不涉及图标、视觉资产或图标实现库选型的任务');
305
+ }
306
+ return dedupe(exclusions).slice(0, 3);
307
+ }
308
+
309
+ function titleFromSource(sourceValue, normalizedUrl, sourceType) {
310
+ if (sourceValue.kind === 'local-file') {
311
+ return path.basename(sourceValue.absolutePath);
312
+ }
313
+ if (sourceType === 'github') {
314
+ return toRepoSlug(normalizedUrl) ?? normalizedUrl;
315
+ }
316
+ try {
317
+ const url = new URL(normalizedUrl);
318
+ const lastSegment = url.pathname.split('/').filter(Boolean).at(-1);
319
+ return lastSegment ? `${url.hostname}/${lastSegment}` : url.hostname;
320
+ } catch {
321
+ return normalizedUrl;
322
+ }
323
+ }
324
+
325
+ function normalizeAdoptionThreshold(value) {
326
+ const threshold = Number(value ?? DEFAULT_ADOPTION_THRESHOLD);
327
+ return Number.isInteger(threshold) && threshold > 0 ? threshold : DEFAULT_ADOPTION_THRESHOLD;
328
+ }
329
+
330
+ function normalizeSourceRecord(record) {
331
+ const promotion = record.promotion && typeof record.promotion === 'object' ? record.promotion : {};
332
+ const evidence = normalizeAdoptionEvidence(record.evidence);
333
+ const windowDays = normalizeAdoptionWindowDays(promotion.windowDays);
334
+ const rawAdoptedCount = Number(record.adoptedCount ?? 0);
335
+ const adoptedCount = Math.max(0, Number.isFinite(rawAdoptedCount) ? rawAdoptedCount : 0, evidence.length);
336
+ return {
337
+ id: record.id,
338
+ title: record.title,
339
+ scope: record.scope ?? 'project',
340
+ status: record.status,
341
+ sourceType: record.sourceType,
342
+ sourceKey: record.sourceKey ?? null,
343
+ url: record.url ?? null,
344
+ path: record.path ?? null,
345
+ repo: record.repo ?? null,
346
+ researchMethod: record.researchMethod,
347
+ scenarios: dedupe(record.scenarios ?? []),
348
+ triggerWhen: dedupe(record.triggerWhen ?? []),
349
+ notFor: dedupe(record.notFor ?? []),
350
+ note: record.note ?? null,
351
+ value: record.value ?? null,
352
+ adoptedCount: Number.isFinite(adoptedCount) && adoptedCount > 0 ? Math.trunc(adoptedCount) : 0,
353
+ lastUsedAt: record.lastUsedAt ?? null,
354
+ evidence,
355
+ recentAdoptedCount: countRecentAdoptions(evidence, windowDays),
356
+ promotion: {
357
+ threshold: Number.isInteger(Number(promotion.threshold)) && Number(promotion.threshold) > 0
358
+ ? Number(promotion.threshold)
359
+ : DEFAULT_ADOPTION_THRESHOLD,
360
+ windowDays,
361
+ recommended: Boolean(promotion.recommended),
362
+ recommendedAt: promotion.recommendedAt ?? null,
363
+ approveCommand: promotion.approveCommand ?? null,
364
+ },
365
+ addedAt: record.addedAt ?? timestamp(),
366
+ approvedAt: record.approvedAt ?? null,
367
+ lastVerified: record.lastVerified ?? null,
368
+ };
369
+ }
370
+
371
+ function computePromotionState(source, threshold = source?.promotion?.threshold, windowDays = source?.promotion?.windowDays) {
372
+ const normalizedSource = normalizeSourceRecord(source ?? {});
373
+ const normalizedThreshold = normalizeAdoptionThreshold(threshold);
374
+ const normalizedWindowDays = normalizeAdoptionWindowDays(windowDays);
375
+ const recentAdoptedCount = countRecentAdoptions(normalizedSource.evidence, normalizedWindowDays);
376
+ const recommended = normalizedSource.status === 'candidate' && recentAdoptedCount >= normalizedThreshold;
377
+ return {
378
+ threshold: normalizedThreshold,
379
+ windowDays: normalizedWindowDays,
380
+ recentAdoptedCount,
381
+ recommended,
382
+ approveCommand: recommended ? `openprd benchmark approve ${normalizedSource.id}` : null,
383
+ };
384
+ }
385
+
386
+ async function resolveSourceInput(projectRoot, source) {
387
+ const raw = String(source ?? '').trim();
388
+ if (!raw) {
389
+ throw new Error('Benchmark source is required.');
390
+ }
391
+
392
+ if (isGitHubShorthand(raw) || isHttpUrl(raw)) {
393
+ const url = normalizeRemoteUrl(raw);
394
+ return { kind: 'remote-url', raw, url };
395
+ }
396
+
397
+ const absolutePath = path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw);
398
+ if (await exists(absolutePath)) {
399
+ return {
400
+ kind: 'local-file',
401
+ raw,
402
+ absolutePath,
403
+ relativePath: path.relative(projectRoot, absolutePath) || path.basename(absolutePath),
404
+ };
405
+ }
406
+
407
+ throw new Error(`Cannot resolve benchmark source: ${raw}`);
408
+ }
409
+
410
+ function buildSourceValue(sourceValue, note) {
411
+ const normalizedUrl = sourceValue.kind === 'remote-url' ? sourceValue.url : null;
412
+ const sourceType = inferSourceType(normalizedUrl, sourceValue);
413
+ const combinedText = [sourceValue.raw, normalizedUrl, sourceValue.relativePath, note].filter(Boolean).join(' ');
414
+ const scenarios = inferScenarios(combinedText);
415
+ const title = titleFromSource(sourceValue, normalizedUrl, sourceType);
416
+ const repo = normalizedUrl ? toRepoSlug(normalizedUrl) : null;
417
+ const sourceKey = normalizedUrl
418
+ ? canonicalUrlSourceKey(normalizedUrl)
419
+ : `file:${sourceValue.relativePath}`;
420
+ const idSeed = sourceKey ?? repo ?? sourceValue.relativePath ?? title;
421
+ const id = slugify(idSeed, 'benchmark-source');
422
+
423
+ return normalizeSourceRecord({
424
+ id,
425
+ title,
426
+ scope: 'project',
427
+ status: 'candidate',
428
+ sourceType,
429
+ sourceKey,
430
+ url: normalizedUrl,
431
+ path: sourceValue.kind === 'local-file' ? sourceValue.relativePath : null,
432
+ repo,
433
+ researchMethod: inferResearchMethod(sourceType),
434
+ scenarios,
435
+ triggerWhen: inferTriggerWhen(scenarios),
436
+ notFor: inferNotFor(scenarios),
437
+ note: note ?? null,
438
+ value: note ?? null,
439
+ addedAt: timestamp(),
440
+ });
441
+ }
442
+
443
+ function sourceIdentity(source) {
444
+ if (source.sourceKey) {
445
+ return `source-key:${source.sourceKey}`;
446
+ }
447
+ if (source.url) {
448
+ return `url:${source.url.toLowerCase()}`;
449
+ }
450
+ if (source.path) {
451
+ return `path:${source.path}`;
452
+ }
453
+ return `id:${source.id}`;
454
+ }
455
+
456
+ function duplicateSource(existingSources, candidate) {
457
+ const wanted = sourceIdentity(candidate);
458
+ return existingSources.find((source) => (
459
+ source.id === candidate.id
460
+ || sourceIdentity(source) === wanted
461
+ || (source.sourceKey && candidate.sourceKey && source.sourceKey === candidate.sourceKey)
462
+ )) ?? null;
463
+ }
464
+
465
+ function buildObservationEvidence(source, options = {}) {
466
+ const note = String(options.notes ?? options.reason ?? '').trim();
467
+ return {
468
+ observedAt: options.observedAt ?? timestamp(),
469
+ task: String(options.task ?? options.event ?? '').trim() || null,
470
+ reason: note || null,
471
+ adoptedSignal: String(options.adoptedSignal ?? 'user-adopted').trim() || 'user-adopted',
472
+ source: source.url ?? source.path ?? null,
473
+ };
474
+ }
475
+
476
+ function withPromotion(source, threshold, windowDays = source?.promotion?.windowDays) {
477
+ const promotionState = computePromotionState(source, threshold, windowDays);
478
+ return normalizeSourceRecord({
479
+ ...source,
480
+ promotion: {
481
+ threshold: promotionState.threshold,
482
+ windowDays: promotionState.windowDays,
483
+ recommended: promotionState.recommended,
484
+ recommendedAt: promotionState.recommended
485
+ ? (source.promotion?.recommendedAt ?? timestamp())
486
+ : null,
487
+ approveCommand: promotionState.approveCommand,
488
+ },
489
+ });
490
+ }
491
+
492
+ function benchmarkRecommendations(sources) {
493
+ return sources
494
+ .filter((source) => source.status === 'candidate' && source.promotion?.recommended)
495
+ .map((source) => ({
496
+ id: source.id,
497
+ title: source.title,
498
+ sourceKey: source.sourceKey ?? source.id,
499
+ adoptedCount: source.recentAdoptedCount,
500
+ totalAdoptedCount: source.adoptedCount,
501
+ threshold: source.promotion.threshold,
502
+ windowDays: source.promotion.windowDays,
503
+ lastUsedAt: source.lastUsedAt,
504
+ approveCommand: source.promotion.approveCommand,
505
+ }));
506
+ }
507
+
508
+ function hasOverbroadTrigger(source) {
509
+ if (!Array.isArray(source.triggerWhen) || source.triggerWhen.length === 0) {
510
+ return true;
511
+ }
512
+ const combined = source.triggerWhen.join(' ').toLowerCase();
513
+ return OVERBROAD_TRIGGER_TOKENS.some((token) => combined.includes(token.toLowerCase()));
514
+ }
515
+
516
+ function normalizeCheckedSource(source) {
517
+ return normalizeSourceRecord({
518
+ ...source,
519
+ lastVerified: timestamp(),
520
+ });
521
+ }
522
+
523
+ function validatePromotionControl(source) {
524
+ const issues = [];
525
+ const promotionState = computePromotionState(source, source.promotion?.threshold, source.promotion?.windowDays);
526
+ const actualRecommended = Boolean(source.promotion?.recommended);
527
+ const actualApproveCommand = source.promotion?.approveCommand ?? null;
528
+ const detail = `最近 ${promotionState.windowDays} 天采纳 ${promotionState.recentAdoptedCount}/${promotionState.threshold} 次`;
529
+
530
+ if (source.adoptedCount < promotionState.recentAdoptedCount) {
531
+ issues.push({
532
+ level: 'error',
533
+ code: 'adoption-count-drift',
534
+ message: `Cumulative adoption count is lower than rolling-window evidence (${source.adoptedCount} < ${promotionState.recentAdoptedCount}).`,
535
+ });
536
+ }
537
+
538
+ if (source.status === 'approved') {
539
+ if (!source.approvedAt) {
540
+ issues.push({
541
+ level: 'error',
542
+ code: 'missing-approved-at',
543
+ message: 'Approved source is missing approvedAt; explicit approval cannot be proven.',
544
+ });
545
+ }
546
+ if (actualRecommended) {
547
+ issues.push({
548
+ level: 'error',
549
+ code: 'approved-source-still-recommended',
550
+ message: 'Approved source must not remain in recommended state.',
551
+ });
552
+ }
553
+ if (actualApproveCommand) {
554
+ issues.push({
555
+ level: 'error',
556
+ code: 'approved-source-has-approve-command',
557
+ message: 'Approved source must not keep an approve command.',
558
+ });
559
+ }
560
+ return issues;
561
+ }
562
+
563
+ if (source.status === 'candidate' && source.approvedAt) {
564
+ issues.push({
565
+ level: 'error',
566
+ code: 'candidate-has-approved-at',
567
+ message: 'Candidate source must not carry approvedAt before explicit approval.',
568
+ });
569
+ }
570
+
571
+ if (actualRecommended !== promotionState.recommended) {
572
+ issues.push({
573
+ level: 'error',
574
+ code: 'promotion-control-drift',
575
+ message: `${detail},当前${actualRecommended ? '已标记推荐' : '未标记推荐'},与应有推荐状态不一致。`,
576
+ });
577
+ }
578
+
579
+ if (promotionState.recommended && actualApproveCommand !== promotionState.approveCommand) {
580
+ issues.push({
581
+ level: 'error',
582
+ code: 'approve-command-drift',
583
+ message: `Recommended candidate approve command drifted. Expected ${promotionState.approveCommand}.`,
584
+ });
585
+ }
586
+
587
+ if (!promotionState.recommended && actualApproveCommand) {
588
+ issues.push({
589
+ level: 'error',
590
+ code: 'stale-approve-command',
591
+ message: `${detail},当前不应继续暴露 approve command。`,
592
+ });
593
+ }
594
+
595
+ return issues;
596
+ }
597
+
598
+ export {
599
+ benchmarkRecommendations,
600
+ buildObservationEvidence,
601
+ buildSourceValue,
602
+ computePromotionState,
603
+ duplicateSource,
604
+ hasOverbroadTrigger,
605
+ isGitHubShorthand,
606
+ isHttpUrl,
607
+ normalizeAdoptionEvidence,
608
+ normalizeAdoptionThreshold,
609
+ normalizeCheckedSource,
610
+ normalizeRemoteUrl,
611
+ normalizeSourceRecord,
612
+ resolveSourceInput,
613
+ sourceIdentity,
614
+ toRepoSlug,
615
+ validatePromotionControl,
616
+ withPromotion,
617
+ };