@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,658 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { cjoin, exists, readJson, readText } from './fs-utils.js'
4
+
5
+ const DEFAULT_CORRELATION_ALIASES = {
6
+ trace_id: ['trace_id', 'traceId', 'traceid'],
7
+ span_id: ['span_id', 'spanId', 'spanid'],
8
+ request_id: ['request_id', 'requestId', 'requestid'],
9
+ task_id: ['task_id', 'taskId', 'taskid'],
10
+ user_session_id: ['user_session_id', 'userSessionId', 'userSession', 'session_id', 'sessionId', 'conversation_id', 'conversationId'],
11
+ error_id: ['error_id', 'errorId', 'errorid', 'diagnosticRef', 'diagnostic_ref'],
12
+ }
13
+
14
+ const EXTRA_CONTEXT_ALIASES = ['traceparent', 'tracestate', 'diagnosticRef', 'diagnostic_ref', 'conversationHash', 'conversation_hash', 'accountHash', 'account_hash']
15
+
16
+ const DIAGNOSTIC_FILE_RULES = [
17
+ { kind: 'diagnostic-report', test: (relativePath) => /diagnostic[-_ ]?report|framework.*diagnostic/i.test(relativePath) },
18
+ { kind: 'root-cause-candidates', test: (relativePath) => /root[-_ ]?cause/i.test(relativePath) },
19
+ { kind: 'timeline', test: (relativePath) => /timeline/i.test(relativePath) },
20
+ { kind: 'runtime-events', test: (relativePath) => /runtime[-_ ]?events?/i.test(relativePath) || /(^|\/)events(\.|\/|$)/i.test(relativePath) },
21
+ ]
22
+
23
+ function firstString(...values) {
24
+ for (const value of values) {
25
+ if (typeof value === 'string' && value.trim()) {
26
+ return value.trim()
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ function normalizeStatus(value) {
33
+ const text = String(value ?? '').trim().toLowerCase()
34
+ if (!text) return 'unknown'
35
+ if (['pass', 'ready', 'healthy', 'ok', 'production-ready'].includes(text)) return 'pass'
36
+ if (['fail', 'failed', 'error', 'critical', 'needs-attention', 'warning', 'warn'].includes(text)) return 'needs-attention'
37
+ return text
38
+ }
39
+
40
+ function slugify(value, fallback = 'diagnostic') {
41
+ const slug = String(value ?? '')
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
44
+ .replace(/^-+|-+$/g, '')
45
+ .slice(0, 80)
46
+ return slug || fallback
47
+ }
48
+
49
+ function uniq(items) {
50
+ return [...new Set(items.filter(Boolean))]
51
+ }
52
+
53
+ function pickStringArray(...values) {
54
+ const items = []
55
+ for (const value of values) {
56
+ if (!value) continue
57
+ if (typeof value === 'string' && value.trim()) {
58
+ items.push(value.trim())
59
+ continue
60
+ }
61
+ if (Array.isArray(value)) {
62
+ for (const item of value) {
63
+ if (typeof item === 'string' && item.trim()) {
64
+ items.push(item.trim())
65
+ } else if (item && typeof item === 'object') {
66
+ const nested = firstString(item.title, item.label, item.name, item.message, item.summary, item.description)
67
+ if (nested) items.push(nested)
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return uniq(items)
73
+ }
74
+
75
+ function isObject(value) {
76
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
77
+ }
78
+
79
+ function hasAnyAlias(value, aliases, depth = 0) {
80
+ if (!value || depth > 4) return false
81
+ if (Array.isArray(value)) {
82
+ return value.some((entry) => hasAnyAlias(entry, aliases, depth + 1))
83
+ }
84
+ if (!isObject(value)) {
85
+ return false
86
+ }
87
+ const keys = Object.keys(value)
88
+ if (keys.some((key) => aliases.includes(key))) {
89
+ return true
90
+ }
91
+ return Object.values(value).some((entry) => hasAnyAlias(entry, aliases, depth + 1))
92
+ }
93
+
94
+ function collectCorrelationFields(records, requiredCorrelationFields = []) {
95
+ const fields = []
96
+ for (const field of requiredCorrelationFields) {
97
+ const aliases = DEFAULT_CORRELATION_ALIASES[field] ?? [field, field.replace(/_id$/, 'Id'), field.replace(/_/g, '')]
98
+ if (records.some((record) => hasAnyAlias(record, aliases))) {
99
+ fields.push(field)
100
+ }
101
+ }
102
+ return fields
103
+ }
104
+
105
+ function collectExtraContextFields(records) {
106
+ return EXTRA_CONTEXT_ALIASES.filter((field) => records.some((record) => hasAnyAlias(record, [field])))
107
+ }
108
+
109
+ function extractEventName(record) {
110
+ if (!isObject(record)) return null
111
+ return firstString(
112
+ record.event,
113
+ record.eventName,
114
+ record.name,
115
+ record.type,
116
+ record.action,
117
+ record.stage,
118
+ record.code,
119
+ )
120
+ }
121
+
122
+ function collectEventNames(records) {
123
+ return uniq(records.map((record) => extractEventName(record)).filter(Boolean)).slice(0, 8)
124
+ }
125
+
126
+ function toRootCauseCandidate(candidate) {
127
+ if (!candidate) return null
128
+ if (typeof candidate === 'string') {
129
+ return { title: candidate }
130
+ }
131
+ if (!isObject(candidate)) {
132
+ return null
133
+ }
134
+ const title = firstString(candidate.title, candidate.name, candidate.reason, candidate.category, candidate.summary, candidate.code)
135
+ if (!title) return null
136
+ return {
137
+ title,
138
+ category: firstString(candidate.category, candidate.type, candidate.group, candidate.label),
139
+ evidence: pickStringArray(candidate.evidence, candidate.matches, candidate.observations, candidate.summary, candidate.description).slice(0, 3),
140
+ nextSteps: pickStringArray(candidate.nextSteps, candidate.actions, candidate.recommendations, candidate.suggestedNextSteps, candidate.steps).slice(0, 4),
141
+ }
142
+ }
143
+
144
+ function extractRootCauseCandidates(payload) {
145
+ const pools = [
146
+ payload,
147
+ payload?.rootCauseCandidates,
148
+ payload?.rootCauses,
149
+ payload?.candidates,
150
+ payload?.causes,
151
+ payload?.diagnosticReport?.rootCauseCandidates,
152
+ payload?.diagnosticReport?.candidates,
153
+ ]
154
+ const candidates = []
155
+ for (const pool of pools) {
156
+ if (!pool) continue
157
+ if (Array.isArray(pool)) {
158
+ candidates.push(...pool.map((entry) => toRootCauseCandidate(entry)).filter(Boolean))
159
+ continue
160
+ }
161
+ if (isObject(pool)) {
162
+ for (const key of ['rootCauseCandidates', 'rootCauses', 'candidates', 'causes']) {
163
+ if (Array.isArray(pool[key])) {
164
+ candidates.push(...pool[key].map((entry) => toRootCauseCandidate(entry)).filter(Boolean))
165
+ }
166
+ }
167
+ }
168
+ }
169
+ const seen = new Set()
170
+ return candidates.filter((candidate) => {
171
+ const key = candidate.title.toLowerCase()
172
+ if (seen.has(key)) return false
173
+ seen.add(key)
174
+ return true
175
+ }).slice(0, 6)
176
+ }
177
+
178
+ function extractSummaryText(payload) {
179
+ if (!payload) return null
180
+ if (typeof payload.summary === 'string') return payload.summary.trim()
181
+ if (isObject(payload.summary)) {
182
+ return firstString(payload.summary.title, payload.summary.headline, payload.summary.message, payload.summary.summary, payload.summary.description)
183
+ }
184
+ return firstString(payload.title, payload.problem, payload.message, payload.description)
185
+ }
186
+
187
+ function extractEventRecords(payload) {
188
+ if (!payload) return []
189
+ if (Array.isArray(payload)) {
190
+ return payload.filter((entry) => isObject(entry))
191
+ }
192
+ if (!isObject(payload)) {
193
+ return []
194
+ }
195
+ for (const key of ['events', 'runtimeEvents', 'timeline', 'items', 'entries', 'records']) {
196
+ if (Array.isArray(payload[key])) {
197
+ return payload[key].filter((entry) => isObject(entry))
198
+ }
199
+ }
200
+ if (extractEventName(payload)) {
201
+ return [payload]
202
+ }
203
+ return []
204
+ }
205
+
206
+ async function parseJsonLines(filePath, limit = 200) {
207
+ const text = await readText(filePath).catch(() => '')
208
+ const records = []
209
+ for (const line of text.split(/\r?\n/)) {
210
+ const trimmed = line.trim()
211
+ if (!trimmed) continue
212
+ try {
213
+ const parsed = JSON.parse(trimmed)
214
+ if (isObject(parsed)) {
215
+ records.push(parsed)
216
+ }
217
+ } catch {
218
+ continue
219
+ }
220
+ if (records.length >= limit) break
221
+ }
222
+ return records
223
+ }
224
+
225
+ async function loadEventFile(filePath) {
226
+ const ext = path.extname(filePath).toLowerCase()
227
+ if (ext === '.jsonl' || ext === '.log') {
228
+ return parseJsonLines(filePath)
229
+ }
230
+ if (ext === '.json') {
231
+ const parsed = await readJson(filePath).catch(() => null)
232
+ return extractEventRecords(parsed)
233
+ }
234
+ return []
235
+ }
236
+
237
+ async function collectDiagnosticFiles(rootPath, depth = 0) {
238
+ if (depth > 4) return []
239
+ const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => [])
240
+ const files = []
241
+ for (const entry of entries) {
242
+ const fullPath = path.join(rootPath, entry.name)
243
+ if (entry.isDirectory()) {
244
+ files.push(...await collectDiagnosticFiles(fullPath, depth + 1))
245
+ continue
246
+ }
247
+ files.push(fullPath)
248
+ }
249
+ return files
250
+ }
251
+
252
+ function classifyDiagnosticFile(rootPath, filePath) {
253
+ const relativePath = path.relative(rootPath, filePath).split(path.sep).join('/')
254
+ for (const rule of DIAGNOSTIC_FILE_RULES) {
255
+ if (rule.test(relativePath)) {
256
+ return {
257
+ kind: rule.kind,
258
+ path: filePath,
259
+ relativePath,
260
+ }
261
+ }
262
+ }
263
+ return null
264
+ }
265
+
266
+ function relativeToProject(projectRoot, filePath) {
267
+ if (!filePath) return filePath
268
+ const relativePath = path.relative(projectRoot, filePath)
269
+ if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
270
+ return relativePath.split(path.sep).join('/')
271
+ }
272
+ return filePath
273
+ }
274
+
275
+ function buildEvidenceSources(projectRoot, sources) {
276
+ return sources.map((source) => ({
277
+ kind: source.kind,
278
+ path: relativeToProject(projectRoot, source.path),
279
+ }))
280
+ }
281
+
282
+ function buildDiagnosticQueryExamples({ correlationFields, eventNames, rootCauseCandidates, missingCorrelationFields }) {
283
+ const steps = []
284
+ if (correlationFields.length > 0) {
285
+ steps.push(`先按 ${correlationFields.join(' / ')} 过滤同一次故障的链路,再对齐 runtime-events 和 timeline。`)
286
+ } else {
287
+ steps.push('先按时间窗口、用户会话或任务编号缩小范围,再核对 runtime-events 和 timeline。')
288
+ }
289
+ if (eventNames.length > 0) {
290
+ steps.push(`围绕关键事件 ${eventNames.slice(0, 3).join(' -> ')} 回看成功到失败的断点位置。`)
291
+ }
292
+ if (rootCauseCandidates.length > 0) {
293
+ steps.push(`优先验证 root-cause-candidates 中的 ${rootCauseCandidates[0].title} 是否与当前证据一致。`)
294
+ }
295
+ if (missingCorrelationFields.length > 0) {
296
+ steps.push(`当前证据还缺少 ${missingCorrelationFields.join(', ')},后续同类路径应默认补齐,避免再次为定位问题加日志。`)
297
+ }
298
+ return steps
299
+ }
300
+
301
+ function buildDiagnosticPrevention({ correlationFields, missingCorrelationFields }) {
302
+ const requirements = [
303
+ '关键路径默认保留 runtime-events、timeline、root-cause-candidates、diagnostic-report 四类证据,而不是等故障出现后再补日志。',
304
+ '失败事件和补偿事件使用稳定事件名,避免把根因埋在自由文本里。',
305
+ '修复完成后保留一份成功与失败对照诊断包,并运行 openprd quality . --learn --from <diagnostics-dir> 更新项目经验。',
306
+ ]
307
+ if (correlationFields.length > 0) {
308
+ requirements.unshift(`同一条链路的前端、后端、异步任务和 Agent 日志统一携带 ${correlationFields.join(', ')}。`)
309
+ }
310
+ if (missingCorrelationFields.length > 0) {
311
+ requirements.push(`当前样本里还缺少 ${missingCorrelationFields.join(', ')},后续关键路径应在实现阶段补齐。`)
312
+ }
313
+ return requirements
314
+ }
315
+
316
+ function buildDiagnosticVerificationSteps({ correlationFields, eventNames }) {
317
+ const steps = [
318
+ '复现一次同类路径,确认新的诊断包仍能导出 runtime-events、timeline、root-cause-candidates 和 diagnostic-report。',
319
+ '修复后再次执行同一路径,确认时间线不再在历史失败断点中断。',
320
+ '把最终诊断包与质量报告一起归档,确保后续 Agent 能直接复用已有排查路径。',
321
+ ]
322
+ if (correlationFields.length > 0) {
323
+ steps.unshift(`确认复现链路中的关键事件都带有 ${correlationFields.join(', ')}。`)
324
+ }
325
+ if (eventNames.length > 0) {
326
+ steps.splice(1, 0, `重点核对 ${eventNames.slice(0, 3).join(' -> ')} 的顺序是否符合预期。`)
327
+ }
328
+ return steps
329
+ }
330
+
331
+ function normalizeQualityReportSource(projectRoot, report, sourcePath, requiredCorrelationFields) {
332
+ const attentionGates = Array.isArray(report.readiness?.attentionGates) ? report.readiness.attentionGates : []
333
+ const correlationFields = Array.isArray(report.observability?.correlationFields)
334
+ ? report.observability.correlationFields
335
+ : collectCorrelationFields([report.observability ?? {}], requiredCorrelationFields)
336
+ const missingCorrelationFields = requiredCorrelationFields.filter((field) => !correlationFields.includes(field))
337
+ return {
338
+ kind: 'quality-report',
339
+ sourceId: report.id,
340
+ sourcePath,
341
+ primaryPath: sourcePath,
342
+ sourcePaths: [sourcePath],
343
+ title: `质量报告 ${report.id}`,
344
+ status: normalizeStatus(report.summary?.status),
345
+ symptoms: attentionGates.map((gate) => `质量门禁需要关注: ${gate}`),
346
+ attentionGates,
347
+ correlationFields,
348
+ extraContextFields: [],
349
+ missingCorrelationFields,
350
+ eventNames: [],
351
+ rootCauseCandidates: attentionGates.map((gate) => ({
352
+ title: `质量门禁未闭环: ${gate}`,
353
+ nextSteps: [
354
+ '检查最新 HTML 质量报告里的证据链和建议动作。',
355
+ '补齐当前任务缺失的可观测性、护栏、测试或性能证据。',
356
+ ],
357
+ })),
358
+ evidenceSources: buildEvidenceSources(projectRoot, [{ kind: 'quality-report', path: sourcePath }]),
359
+ queryExamples: [
360
+ '先阅读最新 HTML 质量报告,再回查对应门禁的原始证据。',
361
+ correlationFields.length > 0
362
+ ? `确认日志是否已统一携带 ${correlationFields.join(' / ')}。`
363
+ : '确认核心路径是否已有统一链路字段;没有的话应先补诊断骨架。',
364
+ '对照 attention gates 把修复动作、验证证据和后续防复发要求写回项目经验。',
365
+ ],
366
+ abstractPattern: '质量缺口反复出现,通常是因为可观测性、护栏、测试与复盘知识被分散维护,没有进入同一套项目级诊断闭环。',
367
+ triggers: attentionGates,
368
+ prevention: [
369
+ '阶段性开发后运行质量验证。',
370
+ '声明就绪前先审阅 HTML 质量评估报告。',
371
+ '把重复或高影响修复沉淀为带日志入口和验证步骤的项目经验 Skill。',
372
+ ],
373
+ verificationSteps: [
374
+ '运行 openprd quality . --verify 并确认需要关注的门禁已经闭环。',
375
+ '打开 HTML 报告,核对证据链、评估结论和后续动作是否一致。',
376
+ '重新执行任务级 verify 命令,并把最终证据路径保留在质量报告里。',
377
+ ],
378
+ }
379
+ }
380
+
381
+ function normalizeDiagnosticSource(projectRoot, payload, sourceMeta, requiredCorrelationFields) {
382
+ const diagnosticReport = isObject(payload?.diagnosticReport) ? payload.diagnosticReport : (isObject(payload) ? payload : {})
383
+ const records = [
384
+ ...extractEventRecords(payload?.runtimeEvents),
385
+ ...extractEventRecords(payload?.timeline),
386
+ ...extractEventRecords(payload?.events),
387
+ ...extractEventRecords(diagnosticReport?.runtimeEvents),
388
+ ...extractEventRecords(diagnosticReport?.timeline),
389
+ ].slice(0, 400)
390
+ const rootCauseCandidates = extractRootCauseCandidates(payload).length > 0
391
+ ? extractRootCauseCandidates(payload)
392
+ : extractRootCauseCandidates(diagnosticReport)
393
+ const correlationFields = collectCorrelationFields([payload, diagnosticReport, ...records], requiredCorrelationFields)
394
+ const extraContextFields = collectExtraContextFields([payload, diagnosticReport, ...records])
395
+ const missingCorrelationFields = requiredCorrelationFields.filter((field) => !correlationFields.includes(field))
396
+ const eventNames = collectEventNames(records)
397
+ const summaryText = extractSummaryText(diagnosticReport) ?? extractSummaryText(payload)
398
+ const rootTitle = rootCauseCandidates[0]?.title ?? null
399
+ const title = firstString(
400
+ diagnosticReport.title,
401
+ diagnosticReport.problem,
402
+ diagnosticReport.name,
403
+ summaryText,
404
+ rootTitle,
405
+ sourceMeta.fallbackTitle,
406
+ ) ?? '诊断问题'
407
+ const status = normalizeStatus(firstString(diagnosticReport.status, diagnosticReport.summary?.status, payload?.status) ?? 'needs-attention')
408
+ const evidenceSources = buildEvidenceSources(projectRoot, sourceMeta.evidenceSources)
409
+ const symptoms = uniq([
410
+ summaryText,
411
+ diagnosticReport.problem,
412
+ diagnosticReport.message,
413
+ ...rootCauseCandidates.map((candidate) => candidate.title),
414
+ ]).slice(0, 6)
415
+ return {
416
+ kind: sourceMeta.kind,
417
+ sourceId: sourceMeta.sourceId ?? slugify(title, 'diagnostic'),
418
+ sourcePath: sourceMeta.sourcePath,
419
+ primaryPath: sourceMeta.primaryPath,
420
+ sourcePaths: uniq(sourceMeta.evidenceSources.map((source) => source.path)),
421
+ title,
422
+ status,
423
+ symptoms,
424
+ attentionGates: [],
425
+ correlationFields,
426
+ extraContextFields,
427
+ missingCorrelationFields,
428
+ eventNames,
429
+ rootCauseCandidates,
430
+ evidenceSources,
431
+ queryExamples: buildDiagnosticQueryExamples({ correlationFields, eventNames, rootCauseCandidates, missingCorrelationFields }),
432
+ abstractPattern: '同类故障通常会先在 runtime-events、timeline、root-cause-candidates 和 diagnostic-report 中留下证据。只要实现阶段就把这些结构化诊断面铺好,后续多数问题都能先靠现有证据定位,而不是临时补日志。',
433
+ triggers: uniq([
434
+ ...eventNames,
435
+ ...rootCauseCandidates.map((candidate) => candidate.title),
436
+ ...symptoms,
437
+ ]).slice(0, 8),
438
+ prevention: buildDiagnosticPrevention({ correlationFields, missingCorrelationFields }),
439
+ verificationSteps: buildDiagnosticVerificationSteps({ correlationFields, eventNames }),
440
+ }
441
+ }
442
+
443
+ async function resolveDirectSource(filePath, projectRoot, requiredCorrelationFields) {
444
+ const stat = await fs.stat(filePath).catch(() => null)
445
+ if (!stat) return null
446
+ if (stat.isDirectory()) {
447
+ const bundleFiles = (await collectDiagnosticFiles(filePath))
448
+ .map((entry) => classifyDiagnosticFile(filePath, entry))
449
+ .filter(Boolean)
450
+ const evidenceSources = bundleFiles.length > 0
451
+ ? bundleFiles
452
+ : [{ kind: 'diagnostic-bundle', path: filePath, relativePath: path.basename(filePath) }]
453
+ const diagnosticReportFile = bundleFiles.find((file) => file.kind === 'diagnostic-report')
454
+ const diagnosticReport = diagnosticReportFile ? await readJson(diagnosticReportFile.path).catch(() => null) : null
455
+ const rootCauseFiles = bundleFiles.filter((file) => file.kind === 'root-cause-candidates')
456
+ const rootCausePayloads = await Promise.all(rootCauseFiles.map((file) => readJson(file.path).catch(() => null)))
457
+ const eventFiles = bundleFiles.filter((file) => file.kind === 'runtime-events' || file.kind === 'timeline')
458
+ const eventPayloads = await Promise.all(eventFiles.map((file) => loadEventFile(file.path)))
459
+ if (!diagnosticReport && rootCauseFiles.length === 0 && eventFiles.length === 0) {
460
+ return null
461
+ }
462
+ return normalizeDiagnosticSource(projectRoot, {
463
+ diagnosticReport,
464
+ rootCauseCandidates: rootCausePayloads.flatMap((entry) => extractRootCauseCandidates(entry ?? [])),
465
+ runtimeEvents: eventPayloads.filter((entry, index) => eventFiles[index]?.kind === 'runtime-events').flat(),
466
+ timeline: eventPayloads.filter((entry, index) => eventFiles[index]?.kind === 'timeline').flat(),
467
+ }, {
468
+ kind: 'diagnostic-bundle',
469
+ sourcePath: filePath,
470
+ primaryPath: diagnosticReportFile?.path ?? rootCauseFiles[0]?.path ?? eventFiles[0]?.path ?? filePath,
471
+ sourceId: diagnosticReport?.id ?? slugify(path.basename(filePath), 'diagnostic-bundle'),
472
+ fallbackTitle: path.basename(filePath),
473
+ evidenceSources: evidenceSources.map((source) => ({ kind: source.kind, path: source.path })),
474
+ }, requiredCorrelationFields)
475
+ }
476
+
477
+ const ext = path.extname(filePath).toLowerCase()
478
+ if (ext === '.jsonl' || ext === '.log') {
479
+ const records = await loadEventFile(filePath)
480
+ if (records.length === 0) return null
481
+ return normalizeDiagnosticSource(projectRoot, { runtimeEvents: records }, {
482
+ kind: 'runtime-events',
483
+ sourcePath: filePath,
484
+ primaryPath: filePath,
485
+ sourceId: slugify(path.basename(filePath, ext), 'runtime-events'),
486
+ fallbackTitle: path.basename(filePath),
487
+ evidenceSources: [{ kind: 'runtime-events', path: filePath }],
488
+ }, requiredCorrelationFields)
489
+ }
490
+
491
+ if (ext !== '.json') {
492
+ return null
493
+ }
494
+
495
+ const parsed = await readJson(filePath).catch(() => null)
496
+ if (!parsed) return null
497
+ if (typeof parsed.id === 'string' && isObject(parsed.summary) && isObject(parsed.readiness) && Array.isArray(parsed.gates)) {
498
+ return normalizeQualityReportSource(projectRoot, parsed, filePath, requiredCorrelationFields)
499
+ }
500
+
501
+ const classified = classifyDiagnosticFile(path.dirname(filePath), filePath)
502
+ if (classified?.kind === 'root-cause-candidates') {
503
+ return normalizeDiagnosticSource(projectRoot, { rootCauseCandidates: extractRootCauseCandidates(parsed) }, {
504
+ kind: 'root-cause-candidates',
505
+ sourcePath: filePath,
506
+ primaryPath: filePath,
507
+ sourceId: slugify(path.basename(filePath, ext), 'root-cause'),
508
+ fallbackTitle: path.basename(filePath),
509
+ evidenceSources: [{ kind: 'root-cause-candidates', path: filePath }],
510
+ }, requiredCorrelationFields)
511
+ }
512
+
513
+ const eventRecords = extractEventRecords(parsed)
514
+ if (eventRecords.length > 0 || extractRootCauseCandidates(parsed).length > 0 || isObject(parsed.diagnosticReport) || isObject(parsed.summary)) {
515
+ return normalizeDiagnosticSource(projectRoot, parsed, {
516
+ kind: classified?.kind === 'timeline' ? 'timeline' : 'diagnostic-report',
517
+ sourcePath: filePath,
518
+ primaryPath: filePath,
519
+ sourceId: slugify(parsed.id ?? path.basename(filePath, ext), 'diagnostic'),
520
+ fallbackTitle: path.basename(filePath),
521
+ evidenceSources: [{ kind: classified?.kind ?? 'diagnostic-report', path: filePath }],
522
+ }, requiredCorrelationFields)
523
+ }
524
+
525
+ return null
526
+ }
527
+
528
+ export async function resolveQualityLearningSource(projectRoot, options = {}) {
529
+ const requiredCorrelationFields = Array.isArray(options.requiredCorrelationFields) ? options.requiredCorrelationFields : []
530
+ const from = options.from
531
+ if (from) {
532
+ const direct = path.isAbsolute(from) ? from : cjoin(projectRoot, from)
533
+ if (await exists(direct)) {
534
+ const source = await resolveDirectSource(direct, projectRoot, requiredCorrelationFields)
535
+ if (source) {
536
+ return { ok: true, source }
537
+ }
538
+ return {
539
+ ok: false,
540
+ error: 'Unsupported learn source. Provide a quality report JSON, an extracted diagnostics directory, or runtime-events / timeline / root-cause JSON evidence.',
541
+ }
542
+ }
543
+
544
+ const asReportId = cjoin(projectRoot, '.openprd', 'quality', 'reports', `${from}.json`)
545
+ if (await exists(asReportId)) {
546
+ const source = await resolveDirectSource(asReportId, projectRoot, requiredCorrelationFields)
547
+ if (source) {
548
+ return { ok: true, source }
549
+ }
550
+ }
551
+ return {
552
+ ok: false,
553
+ error: `Learn source not found: ${from}`,
554
+ }
555
+ }
556
+
557
+ if (options.latestReportPath && await exists(options.latestReportPath)) {
558
+ const source = await resolveDirectSource(options.latestReportPath, projectRoot, requiredCorrelationFields)
559
+ if (source) {
560
+ return { ok: true, source }
561
+ }
562
+ }
563
+
564
+ return {
565
+ ok: false,
566
+ error: 'No quality report or diagnostic evidence found. Run: openprd quality . --verify or pass --from <diagnostics-dir>',
567
+ }
568
+ }
569
+
570
+ function renderList(items, fallback) {
571
+ const list = items.filter(Boolean)
572
+ if (list.length === 0) {
573
+ return `- ${fallback}`
574
+ }
575
+ return list.map((item) => `- ${item}`).join('\n')
576
+ }
577
+
578
+ function renderEvidenceSources(evidenceSources) {
579
+ return renderList(
580
+ evidenceSources.map((source) => `${source.kind}: \`${source.path}\``),
581
+ '当前来源没有显式证据清单,后续应补齐标准诊断目录。',
582
+ )
583
+ }
584
+
585
+ function renderRootCauseCandidates(rootCauseCandidates) {
586
+ return renderList(
587
+ rootCauseCandidates.map((candidate) => {
588
+ const evidence = candidate.evidence?.length ? `;证据: ${candidate.evidence.join(' / ')}` : ''
589
+ const nextSteps = candidate.nextSteps?.length ? `;下一步: ${candidate.nextSteps.join(' / ')}` : ''
590
+ return `${candidate.title}${evidence}${nextSteps}`
591
+ }),
592
+ '当前还没有明确根因候选,先按证据顺序排查并补齐 root-cause-candidates。',
593
+ )
594
+ }
595
+
596
+ function renderCorrelationSection({ correlationFields, extraContextFields, missingCorrelationFields }) {
597
+ const lines = []
598
+ if (correlationFields.length > 0) {
599
+ lines.push(`已识别标准关联字段: ${correlationFields.join(', ')}`)
600
+ }
601
+ if (extraContextFields.length > 0) {
602
+ lines.push(`辅助上下文字段: ${extraContextFields.join(', ')}`)
603
+ }
604
+ if (missingCorrelationFields.length > 0) {
605
+ lines.push(`当前样本缺少: ${missingCorrelationFields.join(', ')};后续关键路径应默认补齐。`)
606
+ }
607
+ return renderList(lines, '当前来源没有显式标准关联字段,后续应把 trace/request/task/error 级别字段纳入默认日志信封。')
608
+ }
609
+
610
+ export function renderExperienceSkill({ skillName, source }) {
611
+ const triggers = source.kind === 'quality-report'
612
+ ? [
613
+ '某项任务改动了前端、后端、agent 工作流或错误处理行为。',
614
+ '这次变更缺少日志关联、业务成本与滥用护栏、冒烟覆盖、性能证据,或已经出现重复问题模式。',
615
+ source.attentionGates.length > 0 ? `最近一次质量报告的关注门禁包括:${source.attentionGates.join(', ')}。` : null,
616
+ ]
617
+ : [
618
+ '用户反馈同类故障再次出现,且需要快速沿着已有诊断证据定位。',
619
+ source.eventNames.length > 0 ? `运行态再次出现事件:${source.eventNames.join(', ')}。` : null,
620
+ source.symptoms.length > 0 ? `本次症状包括:${source.symptoms.join(';')}。` : null,
621
+ ]
622
+
623
+ return `---
624
+ name: ${skillName}
625
+ description: 由 OpenPrd 从 ${source.kind} 自动沉淀的项目级排查经验。目标是在相似问题再次出现时优先复用现有诊断证据,而不是临时补日志。
626
+ ---
627
+
628
+ # ${skillName}
629
+
630
+ ## 触发条件
631
+
632
+ ${renderList(triggers, '当同类问题再次出现,先复用这套排查路径。')}
633
+
634
+ ## 先看哪些证据
635
+
636
+ ${renderEvidenceSources(source.evidenceSources)}
637
+
638
+ ## 关联字段
639
+
640
+ ${renderCorrelationSection(source)}
641
+
642
+ ## 排查顺序
643
+
644
+ ${renderList(source.queryExamples, '先按时间线和失败断点回看证据,再补最小必要日志。')}
645
+
646
+ ## 常见根因
647
+
648
+ ${renderRootCauseCandidates(source.rootCauseCandidates)}
649
+
650
+ ## 防复发要求
651
+
652
+ ${renderList(source.prevention, '修复后把新的排查模式沉淀为项目知识。')}
653
+
654
+ ## 验证方式
655
+
656
+ ${renderList(source.verificationSteps, '修复后重新复现,并确认现有诊断证据足以定位。')}
657
+ `
658
+ }