@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,1655 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { buildPrdSnapshot, formatVersionId } from './prd-core.js';
5
+ import { compactTimestamp, timestamp } from './time.js';
6
+ import { loadLatestVersionSnapshot, loadWorkspace, readVersionIndex, resolveActiveTemplatePack, resolveCurrentProductType } from './workspace-core.js';
7
+ import { appendText, exists, readJson, readText, readYaml, writeJson, writeText, writeYaml } from './fs-utils.js';
8
+ import { learningPackagePaths, openArtifactInBrowser, renderLearningArtifact, writeHtmlArtifact } from './html-artifacts.js';
9
+
10
+ const LEARNING_REVIEW_SCHEMA_VERSION = 1;
11
+ const LEARNING_AGENT_CONTEXT_SCHEMA = 'openprd.learning-agent-context.v1';
12
+ const DEFAULT_LEARNING_REVIEW_SETTINGS = {
13
+ enabled: true,
14
+ autoOpen: true,
15
+ defaultGenre: 'internet-product',
16
+ sourceScope: 'workspace',
17
+ };
18
+
19
+ const GENRE_LIBRARY = {
20
+ 'internet-product': {
21
+ id: 'internet-product',
22
+ label: '互联网产品',
23
+ voice: '先讲用户价值、设计意图与关键取舍,再落到结构、证据和可迁移原则;避免写成技术说明书或文件导览。',
24
+ chapterLabels: ['问题与价值', '关键设计', '取舍与原理', '迁移示例', '边界与下一步'],
25
+ opening: '把这一轮复盘当成一堂产品与架构课:先看要解决什么,再看为什么这样设计,最后带走可迁移的判断方法。',
26
+ closing: '真正值得带走的不是文件名,而是以后再遇到类似问题时还能复用的判断框架。',
27
+ },
28
+ scientific: {
29
+ id: 'scientific',
30
+ label: '严肃科研',
31
+ voice: '严谨、可验证、重证据、重边界。',
32
+ chapterLabels: ['研究问题', '方法框架', '证据链', '复现实验', '结论回收'],
33
+ opening: '这份复盘更像一篇研究记录:先定义问题,再说明方法,然后把证据链摆平。',
34
+ closing: '每个结论都要回到来源,任何推断都要留下注记。',
35
+ },
36
+ 'fairy-tale': {
37
+ id: 'fairy-tale',
38
+ label: '童话故事',
39
+ voice: '温暖、清楚、带一点故事感,但不丢事实。',
40
+ chapterLabels: ['故事开头', '旅程地图', '线索与证据', '角色示例', '回家路上'],
41
+ opening: '这本书像一则故事:先认识角色,再找到线索,最后带着礼物回到现实。',
42
+ closing: '故事讲完以后,真正带走的是可以再次使用的线索和方法。',
43
+ },
44
+ 'web-novel': {
45
+ id: 'web-novel',
46
+ label: '网文小说',
47
+ voice: '节奏更强、推进更快、强调冲突和转折。',
48
+ chapterLabels: ['开卷入局', '结构铺陈', '证据反转', '范例拆招', '收束留白'],
49
+ opening: '这一轮复盘像一章网文:先入局,再铺陈,最后把关键证据翻出来。',
50
+ closing: '结尾不求拖长,只求把下一次推进的线索留稳。',
51
+ },
52
+ xianxia: {
53
+ id: 'xianxia',
54
+ label: '仙侠修真',
55
+ voice: '带一点修炼感,但仍然要稳住事实和证据。',
56
+ chapterLabels: ['筑基', '观想', '破境', '传功', '归元'],
57
+ opening: '这次学习更像一次筑基:把地基、经脉和边界先稳住,后面才好破境。',
58
+ closing: '真正的进阶不是词藻,而是可以反复使用的证据和方法。',
59
+ },
60
+ };
61
+
62
+ const GENRE_ALIASES = new Map([
63
+ ['产品', 'internet-product'],
64
+ ['互联网', 'internet-product'],
65
+ ['互联网产品', 'internet-product'],
66
+ ['代码', 'internet-product'],
67
+ ['code', 'internet-product'],
68
+ ['project', 'internet-product'],
69
+ ['学术', 'scientific'],
70
+ ['科研', 'scientific'],
71
+ ['严肃科研', 'scientific'],
72
+ ['scientific', 'scientific'],
73
+ ['童话', 'fairy-tale'],
74
+ ['fairy', 'fairy-tale'],
75
+ ['fairy-tale', 'fairy-tale'],
76
+ ['网文', 'web-novel'],
77
+ ['小说', 'web-novel'],
78
+ ['web-novel', 'web-novel'],
79
+ ['仙侠', 'xianxia'],
80
+ ['修真', 'xianxia'],
81
+ ['xianxia', 'xianxia'],
82
+ ]);
83
+
84
+ const STYLE_PROMPT_PACKS = {
85
+ 'internet-product': {
86
+ defaultStyle: 'teaching-brief',
87
+ styles: {
88
+ 'teaching-brief': {
89
+ id: 'teaching-brief',
90
+ label: '教学型拆解',
91
+ concept: '把一次复盘写成能教会读者做产品与架构判断的短书:先讲问题和价值,再讲关键设计、取舍、原理、迁移方式与适用边界;必要时补一眼看懂的比喻卡和图文解释。',
92
+ titlePatterns: [
93
+ '《{topic}》设计判断课',
94
+ '《{topic}》产品与架构学习手记',
95
+ '《{topic}》原理拆解',
96
+ ],
97
+ outlineArc: ['问题与价值', '关键设计', '取舍与原理', '迁移示例', '边界与下一步'],
98
+ imageryBank: ['判断框架', '设计杠杆', '权衡面', '迁移路径', '适用边界'],
99
+ sentenceRhythm: '先用一句话说明这章要教会读者什么,再解释设计动机、关键取舍和可迁移原则。',
100
+ taboo: [
101
+ '不要把章节写成文件导览、模块清单、技术点罗列或实现流水账。',
102
+ '不要只说用了什么技术,而不解释为什么这样设计、解决了什么问题。',
103
+ '不要把 evidenceIds 当正文主角;证据应该支撑判断,而不是淹没判断。',
104
+ ],
105
+ systemPrompt: [
106
+ '你是 OpenPrd 复盘学习包的教学型写作 Agent。',
107
+ '你的目标是帮助读者学会产品设计思路、架构思路、关键原理和判断方法,而不是罗列技术点或文件清单。',
108
+ '正文优先回答五件事:解决什么问题、为什么这样设计、这样设计换来了什么、付出了什么、何时适用或不适用。',
109
+ '如果目标读者偏产品、运营或非技术读者,优先补充“一眼看懂”的 visualExplainer,用具体场景和生活化比喻降低理解门槛。',
110
+ '只有当技术细节能够支撑设计动机、关键取舍、失败模式或验证结论时,才引入对应技术点。',
111
+ '事实层必须来自证据清单;表达层可以更像教学型短书,但不能虚构。'
112
+ ].join('\n'),
113
+ titlePrompt: [
114
+ '输入: topic、genre、substyle、agent-context、evidence summary。',
115
+ '输出: 一个像“学习手记/判断课/原理拆解”的标题和一个能概括价值的副标题。',
116
+ '要求: 标题保留 topic 核心名词;副标题要体现“为什么这件事值得学”,而不是重复文件路径或工具名。'
117
+ ].join('\n'),
118
+ outlinePrompt: [
119
+ '输入: 章节目标、证据类别、读者学习路径。',
120
+ '输出: 最多三层目录。',
121
+ '优先把目录组织成“问题与价值 / 关键设计 / 取舍与原理 / 迁移示例 / 边界与下一步”这类教学路径。',
122
+ '不要把目录写成按文件、模块、命令、日志顺序平铺的技术说明书。'
123
+ ].join('\n'),
124
+ chapterPrompt: [
125
+ '输入: agent-context、source excerpts、claims、gaps、related task metadata。',
126
+ '输出: 自行设计章节标题、摘要、正文、retrievalBlocks、workedExamples,以及在合适时补充 visualExplainer。',
127
+ '要求: 每章都要先说明这一章教会读者什么,再解释设计动机、关键取舍、验证方式和可迁移原则。',
128
+ '如果本章适合给产品或非技术读者阅读,visualExplainer 应补充一个具体场景、一个生活化比喻,以及 2-4 条看图重点。',
129
+ '如果引用技术细节,必须顺带说明它背后的设计原因、代价或适用边界。'
130
+ ].join('\n'),
131
+ proseRewritePrompt: [
132
+ '把材料改写成“理解问题 -> 理解设计 -> 理解取舍 -> 学会迁移”的阅读路径。',
133
+ '优先使用“为什么这样设计 / 这种设计换来了什么 / 代价是什么 / 以后什么时候复用”这类句式。',
134
+ '在不牺牲事实的前提下,可以把抽象机制翻成贴近日常决策的场景化比喻,帮助产品或非技术读者先形成直觉。',
135
+ '不要按文件名、模块名、技术名词逐项介绍,除非这些内容正好支撑一个设计判断。',
136
+ '每段至少保留一个明确事实锚点,但不要让事实锚点取代读者该学会的原则。'
137
+ ].join('\n'),
138
+ evidenceBindingPrompt: [
139
+ '每个关键判断必须保留 evidenceIds。',
140
+ '证据用于支撑“为什么这样设计”和“这个判断从哪里来”,不是为了堆砌路径或技术名词。',
141
+ '如果句子是综合推断,要明确写成“综合这些证据可以推断……”,避免伪装成直接事实。'
142
+ ].join('\n'),
143
+ qualityReviewPrompt: [
144
+ '检查 1: 读者读完后,带走的是判断框架、设计原理和取舍方法,而不是技术点列表。',
145
+ '检查 2: 是否回答了“为什么这样设计、换来了什么、付出了什么、何时适用/不适用”。',
146
+ '检查 3: 是否仍能从每章回到 evidenceIds,而不是凭感觉写结论。',
147
+ '检查 4: 是否避免把内容写成文件导览、实现清单或技术说明书。',
148
+ '检查 5: 如果用了 visualExplainer,它是否真正帮助非技术读者理解,而不是只换一种说法重复正文。'
149
+ ].join('\n'),
150
+ },
151
+ },
152
+ },
153
+ xianxia: {
154
+ defaultStyle: 'cultivation',
155
+ styles: {
156
+ cultivation: {
157
+ id: 'cultivation',
158
+ label: '修行札记',
159
+ concept: '把项目学习写成一次可回溯的修行:证据是灵根,结构是经脉,实践是破境。',
160
+ titlePatterns: [
161
+ '《{topic}》修行札记',
162
+ '《{topic}》证道小卷',
163
+ '《{topic}》归藏篇',
164
+ ],
165
+ outlineArc: ['筑基立卷', '观脉识图', '破雾辨源', '传功成谱', '归元再启'],
166
+ imageryBank: ['灵根', '经脉', '法门', '玉简', '破境', '归藏', '心法', '炉火'],
167
+ sentenceRhythm: '长短句交错;每段先给意象,再落回事实、路径或证据。',
168
+ taboo: [
169
+ '不要把证据不存在的内容写成神迹或事实。',
170
+ '不要堆砌玄幻名词盖过学习目标。',
171
+ '不要牺牲路径、文件、命令和验证结果的可追溯性。',
172
+ ],
173
+ systemPrompt: [
174
+ '你是 OpenPrd 复盘学习书的风格迁移 Agent。',
175
+ '你的任务不是虚构故事,而是根据 agent-context 和 evidence-manifest 写出仙侠修行札记。',
176
+ '事实层必须完全来自证据清单;风格层只能改变表达、结构节奏和意象。',
177
+ ].join('\n'),
178
+ titlePrompt: [
179
+ '输入: topic、genre、substyle、agent-context、evidence summary。',
180
+ '输出: 一个像书名的标题和一个短副题。',
181
+ '要求: 标题可带“札记/小卷/归藏/心法”等书籍意象,但必须保留 topic 的核心名词。',
182
+ ].join('\n'),
183
+ outlinePrompt: [
184
+ '输入: 章节目标、证据类别、读者学习路径。',
185
+ '输出: 最多三层目录。',
186
+ '第 1 层是卷/章,第 2 层是本章心法、检索练习、工作示例、证据锚点。',
187
+ '不要把 R1/R2 这类具体检索题放进目录;练习题只留在正文内。',
188
+ ].join('\n'),
189
+ chapterPrompt: [
190
+ '输入: agent-context、source excerpts、claims、gaps、related task metadata。',
191
+ '输出: 自行设计章节标题、摘要、正文、retrievalBlocks 和 workedExamples。',
192
+ '要求: 每章先用修行意象开场,再把意象落回文件、状态、验证或任务路径。',
193
+ ].join('\n'),
194
+ proseRewritePrompt: [
195
+ '围绕“做了什么/为什么/如何验证”写成“立基/观脉/破境/传功/归元”的阅读路径。',
196
+ '每段至少保留一个明确事实锚点,例如 `.openprd/`、docs/basic、loop finish、reader.html、证据清单。',
197
+ '不要改写文件名、命令名、schema、packageId 和 source id。',
198
+ ].join('\n'),
199
+ evidenceBindingPrompt: [
200
+ '每个关键判断必须保留 evidenceIds。',
201
+ '如果句子是综合推断,使用“由这些证据合参可知”一类表达,而不是绝对断言。',
202
+ '风格词只能包装证据,不能替代证据。',
203
+ ].join('\n'),
204
+ qualityReviewPrompt: [
205
+ '检查 1: 标题、大纲、章节是否像修行札记,而不是普通项目报告。',
206
+ '检查 2: 是否仍能从每章回到 evidenceIds。',
207
+ '检查 3: 是否有玄幻词盖过事实、命令、路径、验证结果。',
208
+ '检查 4: 目录是否可读,最多三层,适合展开/收起。',
209
+ ].join('\n'),
210
+ },
211
+ },
212
+ },
213
+ };
214
+
215
+ function slugify(value, fallback = 'learning-review') {
216
+ const slug = String(value ?? '')
217
+ .toLowerCase()
218
+ .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
219
+ .replace(/^-+|-+$/g, '')
220
+ .replace(/-+/g, '-')
221
+ .slice(0, 96);
222
+ return slug || fallback;
223
+ }
224
+
225
+ function hashText(value) {
226
+ return crypto.createHash('sha1').update(String(value ?? '')).digest('hex');
227
+ }
228
+
229
+ function stripText(value) {
230
+ return String(value ?? '')
231
+ .replace(/\r/g, '')
232
+ .trim();
233
+ }
234
+
235
+ function excerptText(value, limit = 320) {
236
+ const text = stripText(value);
237
+ if (!text) return '';
238
+ const paragraphs = text.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean);
239
+ const sample = paragraphs.slice(0, 2).join('\n\n') || text;
240
+ return sample.length <= limit ? sample : `${sample.slice(0, limit).trimEnd()}…`;
241
+ }
242
+
243
+ function normalizeGenreId(value, fallback = DEFAULT_LEARNING_REVIEW_SETTINGS.defaultGenre) {
244
+ if (!value) return fallback;
245
+ const normalized = String(value).trim();
246
+ if (!normalized) return fallback;
247
+ const lower = normalized.toLowerCase();
248
+ if (GENRE_LIBRARY[lower]) return lower;
249
+ if (GENRE_ALIASES.has(normalized)) return GENRE_ALIASES.get(normalized);
250
+ if (GENRE_ALIASES.has(lower)) return GENRE_ALIASES.get(lower);
251
+ return GENRE_LIBRARY[lower] ? lower : fallback;
252
+ }
253
+
254
+ function inferGenreId(topic, snapshot) {
255
+ const haystack = [topic, snapshot?.title, snapshot?.problemStatement, snapshot?.owner]
256
+ .filter(Boolean)
257
+ .join(' ')
258
+ .toLowerCase();
259
+ if (/仙侠|修真/.test(haystack)) return 'xianxia';
260
+ if (/童话|故事/.test(haystack)) return 'fairy-tale';
261
+ if (/科研|学术|论文|实验/.test(haystack)) return 'scientific';
262
+ if (/小说|网文/.test(haystack)) return 'web-novel';
263
+ return 'internet-product';
264
+ }
265
+
266
+ function inferTopic(snapshot, options = {}) {
267
+ return String(options.topic ?? snapshot?.title ?? snapshot?.problemStatement ?? snapshot?.owner ?? 'OpenPrd 复盘学习').trim();
268
+ }
269
+
270
+ function ensureChapterLabels(genre) {
271
+ return Array.isArray(genre.chapterLabels) && genre.chapterLabels.length >= 5
272
+ ? genre.chapterLabels.slice(0, 5)
273
+ : GENRE_LIBRARY['internet-product'].chapterLabels;
274
+ }
275
+
276
+ function normalizeStyleId(value, fallback) {
277
+ const text = String(value ?? '').trim().toLowerCase();
278
+ if (!text) return fallback;
279
+ if (['修行', '修行札记', 'cultivation'].includes(text)) return 'cultivation';
280
+ if (['宗门', '宗门权谋', 'sect', 'sect-intrigue'].includes(text)) return 'sect-intrigue';
281
+ if (['炼器', 'artifact', 'artifact-refining'].includes(text)) return 'artifact-refining';
282
+ return text;
283
+ }
284
+
285
+ function resolveStylePromptPack(genreId, requestedStyle = null) {
286
+ const family = STYLE_PROMPT_PACKS[genreId];
287
+ if (!family) {
288
+ return {
289
+ genreId,
290
+ styleId: 'default',
291
+ label: '默认风格迁移',
292
+ concept: '保持事实层不变,只做轻量语气迁移。',
293
+ prompts: {
294
+ system: '根据 agent-context 和 evidence-manifest 写作,按 genre voice 调整表达。',
295
+ title: '保留 topic 核心名词,根据证据生成清晰标题。',
296
+ outline: '根据任务事实生成可扫描目录。',
297
+ chapter: '根据证据顺序组织章节,优化阅读节奏。',
298
+ proseRewrite: '改写表达,不改写事实锚点。',
299
+ evidenceBinding: '保留 evidenceIds 和 source paths。',
300
+ qualityReview: '检查事实是否可追溯,风格是否一致。',
301
+ },
302
+ };
303
+ }
304
+
305
+ const styleId = normalizeStyleId(requestedStyle, family.defaultStyle);
306
+ const style = family.styles[styleId] ?? family.styles[family.defaultStyle];
307
+ return {
308
+ genreId,
309
+ styleId: style.id,
310
+ label: style.label,
311
+ concept: style.concept,
312
+ titlePatterns: style.titlePatterns,
313
+ outlineArc: style.outlineArc,
314
+ imageryBank: style.imageryBank,
315
+ sentenceRhythm: style.sentenceRhythm,
316
+ taboo: style.taboo,
317
+ prompts: {
318
+ system: style.systemPrompt,
319
+ title: style.titlePrompt,
320
+ outline: style.outlinePrompt,
321
+ chapter: style.chapterPrompt,
322
+ proseRewrite: style.proseRewritePrompt,
323
+ evidenceBinding: style.evidenceBindingPrompt,
324
+ qualityReview: style.qualityReviewPrompt,
325
+ },
326
+ };
327
+ }
328
+
329
+ async function readLearningReviewSettings(ws) {
330
+ const raw = ws.data.config?.learningReview ?? {};
331
+ return {
332
+ ...DEFAULT_LEARNING_REVIEW_SETTINGS,
333
+ ...raw,
334
+ enabled: raw.enabled !== false,
335
+ autoOpen: raw.autoOpen !== false,
336
+ defaultGenre: normalizeGenreId(raw.defaultGenre, DEFAULT_LEARNING_REVIEW_SETTINGS.defaultGenre),
337
+ sourceScope: raw.sourceScope ?? DEFAULT_LEARNING_REVIEW_SETTINGS.sourceScope,
338
+ };
339
+ }
340
+
341
+ async function readCurrentState(ws) {
342
+ return {
343
+ ...(ws.data.currentState ?? {}),
344
+ learningReview: {
345
+ ...((ws.data.currentState ?? {}).learningReview ?? {}),
346
+ },
347
+ };
348
+ }
349
+
350
+ async function persistLearningConfig(ws, learningReview, options = {}) {
351
+ const config = {
352
+ ...(ws.data.config ?? {}),
353
+ learningReview: {
354
+ ...DEFAULT_LEARNING_REVIEW_SETTINGS,
355
+ ...(ws.data.config?.learningReview ?? {}),
356
+ ...learningReview,
357
+ },
358
+ };
359
+ await writeYaml(ws.paths.config, config);
360
+
361
+ const currentState = await readCurrentState(ws);
362
+ currentState.learningReview = {
363
+ ...currentState.learningReview,
364
+ enabled: config.learningReview.enabled,
365
+ defaultGenre: config.learningReview.defaultGenre,
366
+ autoOpen: config.learningReview.autoOpen,
367
+ sourceScope: config.learningReview.sourceScope,
368
+ updatedAt: timestamp(),
369
+ lastAction: options.action ?? 'update-config',
370
+ };
371
+ await writeJson(ws.paths.currentState, currentState);
372
+
373
+ return {
374
+ ws: {
375
+ ...ws,
376
+ data: {
377
+ ...ws.data,
378
+ config,
379
+ currentState,
380
+ },
381
+ },
382
+ config: config.learningReview,
383
+ currentState,
384
+ };
385
+ }
386
+
387
+ async function findLatestLoopReport(projectRoot) {
388
+ const reportsDir = path.join(projectRoot, '.openprd', 'harness', 'test-reports');
389
+ const entries = await fs.readdir(reportsDir, { withFileTypes: true }).catch(() => []);
390
+ const files = [];
391
+ for (const entry of entries) {
392
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
393
+ const abs = path.join(reportsDir, entry.name);
394
+ const stat = await fs.stat(abs).catch(() => null);
395
+ if (!stat) continue;
396
+ files.push({ path: abs, mtimeMs: stat.mtimeMs });
397
+ }
398
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
399
+ return files[0]?.path ?? null;
400
+ }
401
+
402
+ function resolveProjectFile(projectRoot, value) {
403
+ if (!value) return null;
404
+ const candidate = String(value);
405
+ return path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(projectRoot, candidate);
406
+ }
407
+
408
+ function scopeAllows(scope, groups) {
409
+ if (!scope || scope === 'all') return true;
410
+ const normalized = String(scope).trim().toLowerCase();
411
+ if (normalized === 'workspace') {
412
+ return groups.includes('workspace') || groups.includes('docs');
413
+ }
414
+ if (normalized === 'docs') {
415
+ return groups.includes('docs') || groups.includes('workspace');
416
+ }
417
+ if (normalized === 'loop') {
418
+ return groups.includes('workspace') || groups.includes('docs') || groups.includes('loop');
419
+ }
420
+ return groups.includes(normalized);
421
+ }
422
+
423
+ function makeEvidenceCandidate({ id, title, path: absolutePath, kind, groups, summary, excerptLength = 320, note }) {
424
+ return {
425
+ id,
426
+ title,
427
+ path: absolutePath,
428
+ kind,
429
+ groups,
430
+ summary,
431
+ excerptLength,
432
+ note,
433
+ };
434
+ }
435
+
436
+ async function summarizeEvidenceCandidate(candidate) {
437
+ if (!(await exists(candidate.path))) {
438
+ return null;
439
+ }
440
+
441
+ if (candidate.kind === 'json') {
442
+ const value = await readJson(candidate.path).catch(() => null);
443
+ const raw = JSON.stringify(value, null, 2);
444
+ return {
445
+ id: candidate.id,
446
+ title: candidate.title,
447
+ type: candidate.kind,
448
+ groups: candidate.groups,
449
+ path: candidate.path,
450
+ relativePath: null,
451
+ summary: typeof candidate.summary === 'function' ? candidate.summary(value) : (candidate.summary ?? ''),
452
+ excerpt: excerptText(raw, candidate.excerptLength),
453
+ digest: hashText(raw),
454
+ note: candidate.note ?? null,
455
+ data: value,
456
+ };
457
+ }
458
+
459
+ const text = await readText(candidate.path);
460
+ return {
461
+ id: candidate.id,
462
+ title: candidate.title,
463
+ type: candidate.kind,
464
+ groups: candidate.groups,
465
+ path: candidate.path,
466
+ relativePath: null,
467
+ summary: typeof candidate.summary === 'function' ? candidate.summary(text) : (candidate.summary ?? ''),
468
+ excerpt: excerptText(text, candidate.excerptLength),
469
+ digest: hashText(text),
470
+ note: candidate.note ?? null,
471
+ data: text,
472
+ };
473
+ }
474
+
475
+ async function buildEvidenceManifest(ws, snapshot, options = {}) {
476
+ const scope = options.sourceScope ?? DEFAULT_LEARNING_REVIEW_SETTINGS.sourceScope;
477
+ const related = options.related ?? {};
478
+ const changeId = related.changeId ?? options.changeId ?? null;
479
+ const testReportPath = resolveProjectFile(ws.projectRoot, related.testReport ?? options.testReport);
480
+ const latestVersion = await loadLatestVersionSnapshot(ws);
481
+ const latestLoopReport = await findLatestLoopReport(ws.projectRoot);
482
+ const docsRoot = path.join(ws.projectRoot, 'docs', 'basic');
483
+ const candidateDefs = [
484
+ makeEvidenceCandidate({
485
+ id: 'current-state',
486
+ title: '当前状态',
487
+ path: ws.paths.currentState,
488
+ kind: 'json',
489
+ groups: ['workspace', 'loop'],
490
+ summary: (value) => `状态: ${value?.status ?? 'unknown'} · PRD 版本: ${value?.prdVersion ?? 0}`,
491
+ }),
492
+ makeEvidenceCandidate({
493
+ id: 'task-graph',
494
+ title: '任务图',
495
+ path: ws.paths.taskGraph,
496
+ kind: 'json',
497
+ groups: ['workspace', 'loop'],
498
+ summary: (value) => `下一个就绪节点: ${value?.nextReadyNode ?? 'unknown'} · 节点数: ${Array.isArray(value?.nodes) ? value.nodes.length : 0}`,
499
+ }),
500
+ makeEvidenceCandidate({
501
+ id: 'version-index',
502
+ title: '版本索引',
503
+ path: ws.paths.versionIndex,
504
+ kind: 'json',
505
+ groups: ['workspace', 'docs'],
506
+ summary: (value) => `版本数: ${Array.isArray(value) ? value.length : 0}`,
507
+ excerptLength: 220,
508
+ }),
509
+ makeEvidenceCandidate({
510
+ id: 'active-prd',
511
+ title: '当前 PRD',
512
+ path: ws.paths.activePrd,
513
+ kind: 'text',
514
+ groups: ['workspace', 'docs'],
515
+ summary: () => '当前工作区的主 PRD 文档。',
516
+ }),
517
+ makeEvidenceCandidate({
518
+ id: 'active-flows',
519
+ title: '流程文档',
520
+ path: ws.paths.activeFlows,
521
+ kind: 'text',
522
+ groups: ['workspace', 'docs'],
523
+ summary: () => '主流程与边界流程的工作区文档。',
524
+ }),
525
+ makeEvidenceCandidate({
526
+ id: 'active-roles',
527
+ title: '角色文档',
528
+ path: ws.paths.activeRoles,
529
+ kind: 'text',
530
+ groups: ['workspace', 'docs'],
531
+ summary: () => '角色和类型专项信息的工作区文档。',
532
+ }),
533
+ makeEvidenceCandidate({
534
+ id: 'active-handoff',
535
+ title: '交接文档',
536
+ path: ws.paths.activeHandoff,
537
+ kind: 'text',
538
+ groups: ['workspace', 'docs'],
539
+ summary: () => '交接目标、下一步与版本信息。',
540
+ }),
541
+ makeEvidenceCandidate({
542
+ id: 'decision-log',
543
+ title: '决策记录',
544
+ path: ws.paths.decisionLog,
545
+ kind: 'text',
546
+ groups: ['workspace', 'docs'],
547
+ summary: () => '围绕协同过程积累的决策记录。',
548
+ }),
549
+ makeEvidenceCandidate({
550
+ id: 'open-questions',
551
+ title: '开放问题',
552
+ path: ws.paths.openQuestionsLog,
553
+ kind: 'text',
554
+ groups: ['workspace', 'docs'],
555
+ summary: () => '仍需要确认或继续推进的问题。',
556
+ }),
557
+ makeEvidenceCandidate({
558
+ id: 'progress',
559
+ title: '进度记录',
560
+ path: ws.paths.progressLog,
561
+ kind: 'text',
562
+ groups: ['workspace', 'docs'],
563
+ summary: () => '工作区的过程进展与阶段性总结。',
564
+ }),
565
+ makeEvidenceCandidate({
566
+ id: 'verification',
567
+ title: '验证记录',
568
+ path: ws.paths.verificationLog,
569
+ kind: 'text',
570
+ groups: ['workspace', 'loop'],
571
+ summary: () => '验证命令、验证结果和回归结论。',
572
+ }),
573
+ makeEvidenceCandidate({
574
+ id: 'docs-basic-file-structure',
575
+ title: 'docs/basic/file-structure.md',
576
+ path: path.join(docsRoot, 'file-structure.md'),
577
+ kind: 'text',
578
+ groups: ['docs'],
579
+ summary: () => '文件结构和目录边界的基础文档。',
580
+ }),
581
+ makeEvidenceCandidate({
582
+ id: 'docs-basic-app-flow',
583
+ title: 'docs/basic/app-flow.md',
584
+ path: path.join(docsRoot, 'app-flow.md'),
585
+ kind: 'text',
586
+ groups: ['docs'],
587
+ summary: () => '产品流程和状态流的基础文档。',
588
+ }),
589
+ makeEvidenceCandidate({
590
+ id: 'docs-basic-backend-structure',
591
+ title: 'docs/basic/backend-structure.md',
592
+ path: path.join(docsRoot, 'backend-structure.md'),
593
+ kind: 'text',
594
+ groups: ['docs'],
595
+ summary: () => '后端模块和数据流的基础文档。',
596
+ }),
597
+ makeEvidenceCandidate({
598
+ id: 'docs-basic-frontend-guidelines',
599
+ title: 'docs/basic/frontend-guidelines.md',
600
+ path: path.join(docsRoot, 'frontend-guidelines.md'),
601
+ kind: 'text',
602
+ groups: ['docs'],
603
+ summary: () => '前端阅读器和交互规范的基础文档。',
604
+ }),
605
+ makeEvidenceCandidate({
606
+ id: 'docs-basic-prd',
607
+ title: 'docs/basic/prd.md',
608
+ path: path.join(docsRoot, 'prd.md'),
609
+ kind: 'text',
610
+ groups: ['docs'],
611
+ summary: () => '产品需求和验收标准的基础文档。',
612
+ }),
613
+ makeEvidenceCandidate({
614
+ id: 'docs-basic-tech-stack',
615
+ title: 'docs/basic/tech-stack.md',
616
+ path: path.join(docsRoot, 'tech-stack.md'),
617
+ kind: 'text',
618
+ groups: ['docs'],
619
+ summary: () => '运行环境和依赖工具链的基础文档。',
620
+ }),
621
+ ];
622
+
623
+ if (changeId) {
624
+ const changeRoot = path.join(ws.projectRoot, 'openprd', 'changes', changeId);
625
+ candidateDefs.push(
626
+ makeEvidenceCandidate({
627
+ id: 'change-proposal',
628
+ title: `Change ${changeId} proposal`,
629
+ path: path.join(changeRoot, 'proposal.md'),
630
+ kind: 'text',
631
+ groups: ['loop', 'change'],
632
+ summary: () => `变更 ${changeId} 的目标、范围和背景。`,
633
+ excerptLength: 520,
634
+ }),
635
+ makeEvidenceCandidate({
636
+ id: 'change-tasks',
637
+ title: `Change ${changeId} tasks`,
638
+ path: path.join(changeRoot, 'tasks.md'),
639
+ kind: 'text',
640
+ groups: ['loop', 'change'],
641
+ summary: () => `变更 ${changeId} 的任务拆分与完成状态。`,
642
+ excerptLength: 640,
643
+ }),
644
+ makeEvidenceCandidate({
645
+ id: 'change-task-events',
646
+ title: `Change ${changeId} task events`,
647
+ path: path.join(changeRoot, 'task-events.jsonl'),
648
+ kind: 'text',
649
+ groups: ['loop', 'change'],
650
+ summary: () => `变更 ${changeId} 的任务推进事件。`,
651
+ excerptLength: 520,
652
+ }),
653
+ );
654
+ }
655
+
656
+ candidateDefs.push(
657
+ makeEvidenceCandidate({
658
+ id: 'loop-feature-list',
659
+ title: 'Loop feature list',
660
+ path: path.join(ws.projectRoot, '.openprd', 'harness', 'feature-list.json'),
661
+ kind: 'json',
662
+ groups: ['loop'],
663
+ summary: (value) => `任务总数: ${Array.isArray(value?.tasks) ? value.tasks.length : 0} · 当前 change: ${value?.changeId ?? 'unknown'}`,
664
+ excerptLength: 520,
665
+ }),
666
+ makeEvidenceCandidate({
667
+ id: 'loop-state',
668
+ title: 'Loop state',
669
+ path: path.join(ws.projectRoot, '.openprd', 'harness', 'loop-state.json'),
670
+ kind: 'json',
671
+ groups: ['loop'],
672
+ summary: (value) => `Loop 状态: ${value?.status ?? value?.phase ?? 'unknown'}`,
673
+ excerptLength: 420,
674
+ }),
675
+ );
676
+
677
+ if (latestVersion?.snapshot) {
678
+ candidateDefs.push(makeEvidenceCandidate({
679
+ id: `version-${latestVersion.snapshot.versionId}`,
680
+ title: `版本快照 ${latestVersion.snapshot.versionId}`,
681
+ path: path.join(ws.paths.versionsDir, `${latestVersion.snapshot.versionId}.md`),
682
+ kind: 'text',
683
+ groups: ['workspace', 'docs'],
684
+ summary: () => `最新已合成版本的 Markdown 快照。`,
685
+ excerptLength: 280,
686
+ }));
687
+ }
688
+
689
+ if (testReportPath) {
690
+ candidateDefs.push(makeEvidenceCandidate({
691
+ id: 'task-test-report',
692
+ title: '当前任务回归报告',
693
+ path: testReportPath,
694
+ kind: 'text',
695
+ groups: ['loop'],
696
+ summary: () => '本次触发 learning review 的任务回归报告。',
697
+ excerptLength: 720,
698
+ }));
699
+ }
700
+
701
+ if (latestLoopReport) {
702
+ candidateDefs.push(makeEvidenceCandidate({
703
+ id: 'latest-loop-report',
704
+ title: '最新回归报告',
705
+ path: latestLoopReport,
706
+ kind: 'text',
707
+ groups: ['loop'],
708
+ summary: () => '最近一次 loop finish 产出的回归测试报告。',
709
+ excerptLength: 280,
710
+ }));
711
+ }
712
+
713
+ const sources = [];
714
+ for (const candidate of candidateDefs) {
715
+ if (!scopeAllows(scope, candidate.groups)) continue;
716
+ const source = await summarizeEvidenceCandidate(candidate);
717
+ if (!source) continue;
718
+ source.relativePath = path.relative(ws.workspaceRoot, source.path).split(path.sep).join('/');
719
+ sources.push(source);
720
+ }
721
+
722
+ const claims = [];
723
+ const pushClaim = (statement, sourceIds, confidence, kind = 'fact') => {
724
+ claims.push({
725
+ id: `claim-${claims.length + 1}`,
726
+ statement,
727
+ sourceIds,
728
+ confidence,
729
+ kind,
730
+ });
731
+ };
732
+
733
+ const sourceIds = sources.map((source) => source.id);
734
+ for (const source of sources) {
735
+ if (!source.summary) continue;
736
+ pushClaim(
737
+ `${source.title}: ${source.summary}`,
738
+ [source.id],
739
+ source.type === 'json' ? 0.94 : 0.88,
740
+ 'source-summary',
741
+ );
742
+ }
743
+
744
+ const gaps = [];
745
+ if (!sourceIds.includes('latest-loop-report') && !sourceIds.includes('task-test-report') && scope === 'loop') {
746
+ gaps.push({
747
+ id: 'missing-loop-report',
748
+ description: '当前 sourceScope=loop,但未找到可复用的 `.openprd/harness/test-reports/*.md` 回归报告。',
749
+ severity: 'medium',
750
+ });
751
+ }
752
+ if (!sourceIds.some((id) => id.startsWith('docs-basic-'))) {
753
+ gaps.push({
754
+ id: 'missing-docs-basic',
755
+ description: '本次学习包没有引用 docs/basic 基线文档,内容会更偏工作区状态而少一点规范参照。',
756
+ severity: 'low',
757
+ });
758
+ }
759
+
760
+ return {
761
+ version: LEARNING_REVIEW_SCHEMA_VERSION,
762
+ generatedAt: timestamp(),
763
+ sourceScope: scope,
764
+ sourceCount: sources.length,
765
+ claimCount: claims.length,
766
+ sources,
767
+ claims,
768
+ gaps,
769
+ };
770
+ }
771
+
772
+ function genreVoiceLine(genre) {
773
+ return genre.voice || GENRE_LIBRARY['internet-product'].voice;
774
+ }
775
+
776
+ function buildStylePromptEngineering(stylePromptPack) {
777
+ return {
778
+ version: 1,
779
+ mode: 'agent-in-the-loop-style-transfer',
780
+ promptPackId: `${stylePromptPack.genreId}.${stylePromptPack.styleId}`,
781
+ label: stylePromptPack.label,
782
+ concept: stylePromptPack.concept,
783
+ titlePatterns: stylePromptPack.titlePatterns ?? [],
784
+ outlineArc: stylePromptPack.outlineArc ?? [],
785
+ imageryBank: stylePromptPack.imageryBank ?? [],
786
+ sentenceRhythm: stylePromptPack.sentenceRhythm ?? null,
787
+ taboo: stylePromptPack.taboo ?? [],
788
+ prompts: stylePromptPack.prompts,
789
+ loop: [
790
+ 'Agent 先读取 agent-context 和 evidence-manifest。',
791
+ 'Agent 使用 title/outline/chapter/proseRewrite prompts 自行写出标题、大纲、正文和需要的 visualExplainer。',
792
+ 'Agent 使用 evidenceBinding prompt 保留 evidenceIds、路径和不可改写字段。',
793
+ 'Agent 使用 qualityReview prompt 做风格一致性与事实不漂移检查。',
794
+ '通过后把内容写入 learning-content.json,再渲染 reader.html。',
795
+ ],
796
+ };
797
+ }
798
+
799
+ function buildStyleTransferReport(stylePromptPack, chapters) {
800
+ const promptPackId = stylePromptPack.id ?? `${stylePromptPack.genreId}.${stylePromptPack.styleId}`;
801
+ return {
802
+ promptPackId,
803
+ appliedAt: timestamp(),
804
+ agentLoopRequired: true,
805
+ transformedSurfaces: [
806
+ 'title',
807
+ 'subtitle',
808
+ 'outline',
809
+ 'chapter semanticTitle',
810
+ 'chapter summary',
811
+ 'chapter paragraphs',
812
+ 'chapter visualExplainer',
813
+ 'reader chrome',
814
+ ],
815
+ preservedSurfaces: [
816
+ 'packageId',
817
+ 'schema',
818
+ 'sourceScope',
819
+ 'evidenceIds',
820
+ 'package paths',
821
+ 'source digests',
822
+ ],
823
+ qualityChecks: [
824
+ '风格迁移后仍保留每章 evidenceIds。',
825
+ '目录最多三层,适合展开和收起。',
826
+ '正文保留 `.openprd/`、docs/basic、loop、reader.html 或证据清单等事实锚点。',
827
+ 'visualExplainer 只能帮助理解,不能替代证据链或虚构不存在的截图/场景。',
828
+ '右侧证据面板不再参与阅读主界面,证据改为归档和章节内轻量锚点。',
829
+ ],
830
+ chapterCount: chapters.length,
831
+ };
832
+ }
833
+
834
+ function packagePathPayload(packagePaths) {
835
+ return {
836
+ readerHtml: packagePaths.readerHtml,
837
+ assetsDir: packagePaths.assetsDir,
838
+ packageJson: packagePaths.packageJson,
839
+ contentJson: packagePaths.contentJson,
840
+ contentMarkdown: packagePaths.contentMarkdown,
841
+ evidenceManifest: packagePaths.evidenceManifest,
842
+ agentContext: packagePaths.agentContext,
843
+ agentPrompt: packagePaths.agentPrompt,
844
+ };
845
+ }
846
+
847
+ function snapshotPayload(snapshot) {
848
+ return {
849
+ versionId: snapshot?.versionId ?? null,
850
+ versionNumber: snapshot?.versionNumber ?? null,
851
+ productType: snapshot?.productType ?? null,
852
+ templatePack: snapshot?.templatePack ?? null,
853
+ digest: snapshot?.digest ?? null,
854
+ };
855
+ }
856
+
857
+ function genrePayload(genre) {
858
+ return {
859
+ id: genre.id,
860
+ label: genre.label,
861
+ voice: genreVoiceLine(genre),
862
+ chapterLabels: ensureChapterLabels(genre),
863
+ opening: genre.opening,
864
+ closing: genre.closing,
865
+ };
866
+ }
867
+
868
+ function stylePromptPayload(stylePromptEngineering, stylePromptPack) {
869
+ return {
870
+ id: stylePromptEngineering.promptPackId,
871
+ styleId: stylePromptPack.styleId,
872
+ label: stylePromptPack.label,
873
+ concept: stylePromptPack.concept,
874
+ };
875
+ }
876
+
877
+ function buildOutputContractSpec() {
878
+ return {
879
+ schema: 'openprd.learning-content.v1',
880
+ agentOwnedFields: [
881
+ 'title',
882
+ 'subtitle',
883
+ 'learningGoals',
884
+ 'overviewParagraphs',
885
+ 'outline',
886
+ 'chapters',
887
+ 'nextActions',
888
+ ],
889
+ chapterShape: {
890
+ required: ['id', 'label', 'semanticTitle', 'summary', 'paragraphs', 'evidenceIds'],
891
+ optional: ['retrievalBlocks', 'workedExamples', 'visualExplainer'],
892
+ retrievalBlockShape: ['prompt', 'hint', 'answer'],
893
+ workedExampleShape: ['title', 'scenario', 'steps', 'principle'],
894
+ visualExplainerShape: {
895
+ required: ['title', 'analogy', 'scene', 'whyItMatters', 'takeaways'],
896
+ optional: ['image'],
897
+ imageShape: ['path', 'alt', 'caption', 'prompt'],
898
+ },
899
+ },
900
+ rules: [
901
+ '不要让 CLI 生成标题、大纲或正文;这些字段必须由 Agent 根据证据写出。',
902
+ '每章 evidenceIds 只能引用 evidence-manifest.json 中存在的 source id。',
903
+ '正文中的任务事实必须能回到 evidence-manifest 的 source、claim 或 excerpt。',
904
+ '可以写推断,但要在表达上说明它来自多个证据的综合判断。',
905
+ '优先写清用户价值、设计动机、关键取舍、适用边界和可迁移原则,不要把正文写成技术说明书、文件导览或实现清单。',
906
+ '只有当技术细节能支撑设计原理、取舍、失败模式或验证结论时,才引入对应技术点。',
907
+ '面向产品、运营或非技术读者时,优先给主要章节补 `visualExplainer`:用具体场景、生活化比喻和 2-4 条看图重点帮助理解。',
908
+ '`visualExplainer.image.path` 可以写成相对 `assetsDir` 的路径;图片只用于帮助理解,不能替代 evidenceIds 或伪装成事实截图。',
909
+ ],
910
+ };
911
+ }
912
+
913
+ function buildAgentContext({ packageId, topic, genre, stylePromptPack, trigger, sourceScope, snapshot, evidenceManifest, related = {}, packagePaths }) {
914
+ const stylePromptEngineering = buildStylePromptEngineering(stylePromptPack);
915
+ return {
916
+ version: LEARNING_REVIEW_SCHEMA_VERSION,
917
+ schema: LEARNING_AGENT_CONTEXT_SCHEMA,
918
+ packageId,
919
+ generatedAt: timestamp(),
920
+ topic,
921
+ trigger,
922
+ sourceScope,
923
+ genre: genrePayload(genre),
924
+ stylePromptPack: stylePromptPayload(stylePromptEngineering, stylePromptPack),
925
+ snapshot: snapshotPayload(snapshot),
926
+ related,
927
+ paths: packagePathPayload(packagePaths),
928
+ evidence: {
929
+ manifestPath: packagePaths.evidenceManifest,
930
+ sourceCount: evidenceManifest.sourceCount,
931
+ claimCount: evidenceManifest.claimCount,
932
+ sources: (evidenceManifest.sources ?? []).map((source) => ({
933
+ id: source.id,
934
+ title: source.title,
935
+ type: source.type,
936
+ relativePath: source.relativePath,
937
+ summary: source.summary,
938
+ excerpt: source.excerpt,
939
+ digest: source.digest,
940
+ })),
941
+ claims: evidenceManifest.claims ?? [],
942
+ gaps: evidenceManifest.gaps ?? [],
943
+ },
944
+ stylePromptEngineering,
945
+ outputContract: buildOutputContractSpec(),
946
+ renderCommand: `openprd learn . --content-json ${packagePaths.contentJson} --open`,
947
+ };
948
+ }
949
+
950
+ function renderAgentPromptMarkdown(agentContext) {
951
+ const sourceLines = (agentContext.evidence.sources ?? []).map((source) => (
952
+ `- ${source.id}: ${source.title} (${source.relativePath ?? '无路径'})`
953
+ ));
954
+ return [
955
+ '# OpenPrd 复盘学习包 Agent 写作提示',
956
+ '',
957
+ `学习包: ${agentContext.packageId}`,
958
+ `主题: ${agentContext.topic}`,
959
+ `触发方式: ${agentContext.trigger}`,
960
+ `来源范围: ${agentContext.sourceScope}`,
961
+ '',
962
+ '## 你的任务',
963
+ '',
964
+ '请你亲自完成复盘学习正文。CLI 只准备了证据、约束、路径和 HTML 阅读器外壳。',
965
+ '',
966
+ '所有读者可见的标题、副标题、目录项、章节标题、段落、检索练习、工作示例、visualExplainer 和下一步都由你负责撰写。',
967
+ '',
968
+ '默认读者期待学会的是: 这件事为什么值得做、为什么这样设计、关键取舍是什么、哪些原则以后还能复用。',
969
+ '如果读者偏产品、运营或非技术角色,优先补充“一眼看懂”的比喻卡,让他们先理解机制,再回到证据。',
970
+ '不要把正文写成技术说明书、文件清单、模块导览或实现流水账。',
971
+ '只有当技术细节能支撑设计原理、权衡、失败模式或验证结论时,才写技术细节。',
972
+ '',
973
+ '## 输入',
974
+ '',
975
+ `- Agent 上下文: ${agentContext.paths.agentContext}`,
976
+ `- 证据清单: ${agentContext.paths.evidenceManifest}`,
977
+ `- 输出内容 JSON: ${agentContext.paths.contentJson}`,
978
+ `- 阅读器 HTML: ${agentContext.paths.readerHtml}`,
979
+ `- 图片素材目录: ${agentContext.paths.assetsDir}`,
980
+ '',
981
+ '## 证据来源',
982
+ '',
983
+ ...sourceLines,
984
+ '',
985
+ '## 输出规则',
986
+ '',
987
+ ...agentContext.outputContract.rules.map((rule) => `- ${rule}`),
988
+ '',
989
+ '## 必需 JSON 结构',
990
+ '',
991
+ '```json',
992
+ JSON.stringify(agentContext.outputContract, null, 2),
993
+ '```',
994
+ '',
995
+ '写入 `learning-content.json` 后,使用下面命令重新渲染:',
996
+ '',
997
+ '```sh',
998
+ agentContext.renderCommand,
999
+ '```',
1000
+ '',
1001
+ ].join('\n');
1002
+ }
1003
+
1004
+ function buildPendingLearningContract({ packageId, topic, genre, stylePromptPack, trigger, sourceScope, snapshot, related = {}, packagePaths, agentContext }) {
1005
+ return {
1006
+ version: LEARNING_REVIEW_SCHEMA_VERSION,
1007
+ schema: 'openprd.learning-content.v1',
1008
+ packageId,
1009
+ generatedAt: timestamp(),
1010
+ contentMode: 'agent-authored',
1011
+ authoringStatus: 'awaiting-agent-content',
1012
+ title: '',
1013
+ topic,
1014
+ subtitle: '',
1015
+ genre: agentContext?.genre ?? genrePayload(genre),
1016
+ stylePromptPack: agentContext?.stylePromptPack ?? stylePromptPayload(buildStylePromptEngineering(stylePromptPack), stylePromptPack),
1017
+ stylePromptEngineering: agentContext?.stylePromptEngineering ?? buildStylePromptEngineering(stylePromptPack),
1018
+ styleTransfer: {
1019
+ promptPackId: `${stylePromptPack.genreId}.${stylePromptPack.styleId}`,
1020
+ agentLoopRequired: true,
1021
+ appliedAt: null,
1022
+ chapterCount: 0,
1023
+ },
1024
+ trigger,
1025
+ sourceScope,
1026
+ audience: null,
1027
+ snapshot: snapshotPayload(snapshot),
1028
+ learningGoals: [],
1029
+ overviewParagraphs: [],
1030
+ outline: [],
1031
+ chapters: [],
1032
+ evidenceManifestPath: packagePaths.evidenceManifest,
1033
+ agentContextPath: packagePaths.agentContext,
1034
+ agentPromptPath: packagePaths.agentPrompt,
1035
+ packagePaths: packagePathPayload(packagePaths),
1036
+ related,
1037
+ nextActions: [],
1038
+ };
1039
+ }
1040
+
1041
+ function normalizeStringList(value, fieldName, errors, { required = false } = {}) {
1042
+ if (!Array.isArray(value)) {
1043
+ if (required) errors.push(`${fieldName} 必须是非空数组`);
1044
+ return [];
1045
+ }
1046
+ const list = value.map((item) => String(item ?? '').trim()).filter(Boolean);
1047
+ if (required && list.length === 0) errors.push(`${fieldName} 必须是非空数组`);
1048
+ return list;
1049
+ }
1050
+
1051
+ function normalizeVisualExplainer(value, fieldName, errors) {
1052
+ if (value == null) return null;
1053
+ if (!value || typeof value !== 'object') {
1054
+ errors.push(`${fieldName} 必须是对象`);
1055
+ return null;
1056
+ }
1057
+ for (const childField of ['title', 'analogy', 'scene', 'whyItMatters']) {
1058
+ if (!String(value?.[childField] ?? '').trim()) {
1059
+ errors.push(`${fieldName}.${childField} 必填`);
1060
+ }
1061
+ }
1062
+ normalizeStringList(value?.takeaways, `${fieldName}.takeaways`, errors, { required: true });
1063
+ if (value.image != null) {
1064
+ if (!value.image || typeof value.image !== 'object') {
1065
+ errors.push(`${fieldName}.image 必须是对象`);
1066
+ } else {
1067
+ const hasPath = String(value.image.path ?? '').trim().length > 0;
1068
+ if (hasPath && !String(value.image.alt ?? '').trim()) {
1069
+ errors.push(`${fieldName}.image.alt 必填`);
1070
+ }
1071
+ }
1072
+ }
1073
+ return value;
1074
+ }
1075
+
1076
+ const INTERNET_PRODUCT_DIRECTION_SIGNALS = {
1077
+ value: ['问题', '价值', '目标', '用户', '场景', '需求', '机会'],
1078
+ design: ['设计', '方案', '架构', '结构', '流程', '机制', '路径', '入口', '判断'],
1079
+ tradeoff: ['取舍', '代价', '成本', '边界', '风险', '收益', '权衡', '适用', '不适用', '约束'],
1080
+ };
1081
+
1082
+ function collectLearningNarrativeFragments(raw) {
1083
+ const outlineFragments = (raw.outline ?? []).flatMap((item) => [item?.title, item?.subtitle]);
1084
+ const chapterFragments = (raw.chapters ?? []).flatMap((chapter) => [
1085
+ chapter?.label,
1086
+ chapter?.semanticTitle,
1087
+ chapter?.summary,
1088
+ ...(chapter?.paragraphs ?? []),
1089
+ chapter?.visualExplainer?.title,
1090
+ chapter?.visualExplainer?.analogy,
1091
+ chapter?.visualExplainer?.scene,
1092
+ chapter?.visualExplainer?.whyItMatters,
1093
+ ...(chapter?.visualExplainer?.takeaways ?? []),
1094
+ chapter?.visualExplainer?.image?.caption,
1095
+ ...(chapter?.retrievalBlocks ?? []).flatMap((block) => [block?.prompt, block?.hint, block?.answer]),
1096
+ ...(chapter?.workedExamples ?? []).flatMap((example) => [example?.title, example?.scenario, ...(example?.steps ?? []), example?.principle]),
1097
+ ]);
1098
+ return [
1099
+ raw.title,
1100
+ raw.subtitle,
1101
+ ...(raw.learningGoals ?? []),
1102
+ ...(raw.overviewParagraphs ?? []),
1103
+ ...outlineFragments,
1104
+ ...chapterFragments,
1105
+ ...(raw.nextActions ?? []),
1106
+ ].filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean);
1107
+ }
1108
+
1109
+ function includesAnySignal(text, signals) {
1110
+ return signals.some((signal) => text.includes(signal));
1111
+ }
1112
+
1113
+ function validateInternetProductDirection(raw, errors) {
1114
+ const narrative = collectLearningNarrativeFragments(raw).join('\n');
1115
+ if (!narrative) return;
1116
+ if (!includesAnySignal(narrative, INTERNET_PRODUCT_DIRECTION_SIGNALS.value)) {
1117
+ errors.push('internet-product 复盘必须明确说明问题、价值、目标或用户场景,不能只列技术对象。');
1118
+ }
1119
+ if (!includesAnySignal(narrative, INTERNET_PRODUCT_DIRECTION_SIGNALS.design)) {
1120
+ errors.push('internet-product 复盘必须解释为什么这样设计,不能只有文件、模块或命令顺序。');
1121
+ }
1122
+ if (!includesAnySignal(narrative, INTERNET_PRODUCT_DIRECTION_SIGNALS.tradeoff)) {
1123
+ errors.push('internet-product 复盘必须写出取舍、代价、边界或适用条件,而不是只给实现清单。');
1124
+ }
1125
+ const manualSignals = (narrative.match(/(?:\.openprd\/|docs\/basic\/|[A-Za-z0-9._-]+\.(?:md|json|js|ts|tsx|jsx|html|css))/g) ?? []).length;
1126
+ const conceptSignals = Object.values(INTERNET_PRODUCT_DIRECTION_SIGNALS)
1127
+ .flat()
1128
+ .filter((signal, index, list) => list.indexOf(signal) === index)
1129
+ .filter((signal) => narrative.includes(signal))
1130
+ .length;
1131
+ if (manualSignals >= 6 && conceptSignals < 5) {
1132
+ errors.push('internet-product 复盘当前更像技术说明书:路径和文件引用过多,但产品/架构判断不足。');
1133
+ }
1134
+ }
1135
+
1136
+ function validateAgentAuthoredContent(raw, evidenceManifest, genreId = null) {
1137
+ const errors = [];
1138
+ if (!raw || typeof raw !== 'object') {
1139
+ throw new Error('无效的 Agent 学习内容: JSON 根节点必须是对象。');
1140
+ }
1141
+ if (raw.schema && raw.schema !== 'openprd.learning-content.v1') {
1142
+ errors.push('schema 必须是 openprd.learning-content.v1');
1143
+ }
1144
+ if (!String(raw.title ?? '').trim()) errors.push('title 必填');
1145
+ if (!Array.isArray(raw.outline) || raw.outline.length === 0) errors.push('outline 必须是非空数组');
1146
+ if (!Array.isArray(raw.chapters) || raw.chapters.length === 0) errors.push('chapters 必须是非空数组');
1147
+
1148
+ const sourceIds = new Set((evidenceManifest.sources ?? []).map((source) => source.id));
1149
+ for (const [index, chapter] of (raw.chapters ?? []).entries()) {
1150
+ const label = `chapters[${index}]`;
1151
+ for (const field of ['id', 'label', 'semanticTitle', 'summary']) {
1152
+ if (!String(chapter?.[field] ?? '').trim()) errors.push(`${label}.${field} 必填`);
1153
+ }
1154
+ normalizeStringList(chapter?.paragraphs, `${label}.paragraphs`, errors, { required: true });
1155
+ if (!Array.isArray(chapter?.evidenceIds) || chapter.evidenceIds.length === 0) {
1156
+ errors.push(`${label}.evidenceIds 必须是非空数组`);
1157
+ } else {
1158
+ for (const evidenceId of chapter.evidenceIds) {
1159
+ if (!sourceIds.has(evidenceId)) errors.push(`${label}.evidenceIds 包含未知来源 id: ${evidenceId}`);
1160
+ }
1161
+ }
1162
+ for (const [blockIndex, block] of (chapter?.retrievalBlocks ?? []).entries()) {
1163
+ if (!String(block?.prompt ?? '').trim()) errors.push(`${label}.retrievalBlocks[${blockIndex}].prompt 必填`);
1164
+ if (!String(block?.answer ?? '').trim()) errors.push(`${label}.retrievalBlocks[${blockIndex}].answer 必填`);
1165
+ }
1166
+ for (const [exampleIndex, example] of (chapter?.workedExamples ?? []).entries()) {
1167
+ if (!String(example?.title ?? '').trim()) errors.push(`${label}.workedExamples[${exampleIndex}].title 必填`);
1168
+ if (!String(example?.scenario ?? '').trim()) errors.push(`${label}.workedExamples[${exampleIndex}].scenario 必填`);
1169
+ normalizeStringList(example?.steps, `${label}.workedExamples[${exampleIndex}].steps`, errors, { required: true });
1170
+ }
1171
+ normalizeVisualExplainer(chapter?.visualExplainer, `${label}.visualExplainer`, errors);
1172
+ }
1173
+ if (genreId === 'internet-product') validateInternetProductDirection(raw, errors);
1174
+ if (errors.length > 0) {
1175
+ throw new Error(`无效的 Agent 学习内容: ${errors.join('; ')}`);
1176
+ }
1177
+ }
1178
+
1179
+ function normalizeAgentAuthoredContent(raw, shell, evidenceManifest) {
1180
+ validateAgentAuthoredContent(raw, evidenceManifest, shell.genre?.id ?? null);
1181
+ const errors = [];
1182
+ return {
1183
+ ...shell,
1184
+ generatedAt: timestamp(),
1185
+ authoringStatus: 'agent-authored',
1186
+ title: String(raw.title).trim(),
1187
+ subtitle: String(raw.subtitle ?? '').trim(),
1188
+ audience: raw.audience ?? shell.audience,
1189
+ learningGoals: normalizeStringList(raw.learningGoals, 'learningGoals', errors),
1190
+ overviewParagraphs: normalizeStringList(raw.overviewParagraphs, 'overviewParagraphs', errors),
1191
+ outline: raw.outline,
1192
+ chapters: raw.chapters,
1193
+ nextActions: normalizeStringList(raw.nextActions, 'nextActions', errors),
1194
+ styleTransfer: buildStyleTransferReport(shell.stylePromptPack, raw.chapters ?? []),
1195
+ agentNotes: raw.agentNotes ?? null,
1196
+ };
1197
+ }
1198
+
1199
+ function buildLearningContract({ packageId, topic, genre, stylePromptPack, trigger, sourceScope, snapshot, evidenceManifest, related = {}, packagePaths, agentContext, authoredContent = null }) {
1200
+ const shell = buildPendingLearningContract({
1201
+ packageId,
1202
+ topic,
1203
+ genre,
1204
+ stylePromptPack,
1205
+ trigger,
1206
+ sourceScope,
1207
+ snapshot,
1208
+ related,
1209
+ packagePaths,
1210
+ agentContext,
1211
+ });
1212
+ if (!authoredContent) return shell;
1213
+ return normalizeAgentAuthoredContent(authoredContent, shell, evidenceManifest);
1214
+ }
1215
+
1216
+ function renderLearningMarkdown({ content, evidenceManifest }) {
1217
+ if (content.authoringStatus === 'awaiting-agent-content') {
1218
+ const lines = [
1219
+ '---',
1220
+ `schema: ${content.schema}`,
1221
+ `version: ${content.version}`,
1222
+ `packageId: ${content.packageId}`,
1223
+ `topic: ${content.topic}`,
1224
+ `trigger: ${content.trigger}`,
1225
+ `sourceScope: ${content.sourceScope}`,
1226
+ `evidenceManifestPath: ${content.evidenceManifestPath}`,
1227
+ `agentContextPath: ${content.agentContextPath}`,
1228
+ `agentPromptPath: ${content.agentPromptPath}`,
1229
+ 'authoringStatus: awaiting-agent-content',
1230
+ '---',
1231
+ '',
1232
+ '# 等待 Agent 写作',
1233
+ '',
1234
+ '本文件不会替 Agent 生成复盘标题、大纲或正文。请让 Agent 读取写作提示和证据清单后,写入 `learning-content.json`。',
1235
+ '',
1236
+ '## 写作入口',
1237
+ '',
1238
+ `- Agent 写作提示: ${content.agentPromptPath}`,
1239
+ `- Agent 上下文: ${content.agentContextPath}`,
1240
+ `- 证据清单: ${content.evidenceManifestPath}`,
1241
+ `- 内容 JSON: ${content.packagePaths?.contentJson ?? ''}`,
1242
+ `- 图片素材目录: ${content.packagePaths?.assetsDir ?? ''}`,
1243
+ '',
1244
+ '## 证据清单',
1245
+ '',
1246
+ ];
1247
+ for (const source of evidenceManifest.sources ?? []) {
1248
+ lines.push(`- ${source.id}: ${source.title} (${source.relativePath})`);
1249
+ }
1250
+ lines.push('');
1251
+ return `${lines.join('\n')}`;
1252
+ }
1253
+
1254
+ const lines = [
1255
+ '---',
1256
+ `schema: ${content.schema}`,
1257
+ `version: ${content.version}`,
1258
+ `packageId: ${content.packageId}`,
1259
+ `title: ${content.title}`,
1260
+ `topic: ${content.topic}`,
1261
+ `genreId: ${content.genre.id}`,
1262
+ `genreLabel: ${content.genre.label}`,
1263
+ `stylePromptPack: ${content.stylePromptPack?.id ?? 'default'}`,
1264
+ `trigger: ${content.trigger}`,
1265
+ `sourceScope: ${content.sourceScope}`,
1266
+ `evidenceManifestPath: ${content.evidenceManifestPath}`,
1267
+ '---',
1268
+ '',
1269
+ `# ${content.title}`,
1270
+ '',
1271
+ `> ${content.subtitle}`,
1272
+ '',
1273
+ '## 你会学到什么',
1274
+ '',
1275
+ ...content.learningGoals.map((item) => `- ${item}`),
1276
+ '',
1277
+ '## 读法',
1278
+ '',
1279
+ '- 先看目录,再看章节标题。',
1280
+ '- 如果章节里有“一眼看懂”图卡,先用它建立直觉,再读正文和证据。',
1281
+ '- 章节内先读叙述,再做检索练习,最后看工作示例。',
1282
+ '- 所有重要判断都可以回到证据清单。',
1283
+ '',
1284
+ '## 提示词工程',
1285
+ '',
1286
+ `- 提示词包: ${content.stylePromptEngineering?.promptPackId ?? 'default'}`,
1287
+ `- 风格目标: ${content.stylePromptEngineering?.concept ?? '保持事实层不变,优化表达层。'}`,
1288
+ `- 句式节奏: ${content.stylePromptEngineering?.sentenceRhythm ?? '按题材调整。'}`,
1289
+ '',
1290
+ '### 系统提示词',
1291
+ '',
1292
+ '```text',
1293
+ content.stylePromptEngineering?.prompts?.system ?? '',
1294
+ '```',
1295
+ '',
1296
+ '### 标题提示词',
1297
+ '',
1298
+ '```text',
1299
+ content.stylePromptEngineering?.prompts?.title ?? '',
1300
+ '```',
1301
+ '',
1302
+ '### 大纲提示词',
1303
+ '',
1304
+ '```text',
1305
+ content.stylePromptEngineering?.prompts?.outline ?? '',
1306
+ '```',
1307
+ '',
1308
+ '### 正文改写提示词',
1309
+ '',
1310
+ '```text',
1311
+ content.stylePromptEngineering?.prompts?.proseRewrite ?? '',
1312
+ '```',
1313
+ '',
1314
+ '### 质量检查提示词',
1315
+ '',
1316
+ '```text',
1317
+ content.stylePromptEngineering?.prompts?.qualityReview ?? '',
1318
+ '```',
1319
+ '',
1320
+ '## 证据包结构',
1321
+ '',
1322
+ '```text',
1323
+ `.openprd/learning/archive/${content.packageId}/`,
1324
+ ' assets/',
1325
+ ' learning-package.json',
1326
+ ' learning-content.json',
1327
+ ' learning-content.md',
1328
+ ' evidence-manifest.json',
1329
+ ' reader.html',
1330
+ '```',
1331
+ '',
1332
+ ];
1333
+
1334
+ for (const chapter of content.chapters) {
1335
+ lines.push(`## ${chapter.label} · ${chapter.semanticTitle}`);
1336
+ lines.push('');
1337
+ lines.push(chapter.summary);
1338
+ lines.push('');
1339
+ if (chapter.visualExplainer) {
1340
+ lines.push('### 一眼看懂');
1341
+ lines.push('');
1342
+ lines.push(`- 标题: ${chapter.visualExplainer.title}`);
1343
+ lines.push(`- 比喻: ${chapter.visualExplainer.analogy}`);
1344
+ lines.push(`- 场景: ${chapter.visualExplainer.scene}`);
1345
+ lines.push(`- 作用: ${chapter.visualExplainer.whyItMatters}`);
1346
+ if ((chapter.visualExplainer.takeaways ?? []).length > 0) {
1347
+ lines.push('- 看图重点:');
1348
+ for (const takeaway of chapter.visualExplainer.takeaways ?? []) {
1349
+ lines.push(` - ${takeaway}`);
1350
+ }
1351
+ }
1352
+ if (chapter.visualExplainer.image?.path) {
1353
+ lines.push(`- 图片: ${chapter.visualExplainer.image.path}`);
1354
+ }
1355
+ if (chapter.visualExplainer.image?.caption) {
1356
+ lines.push(`- 图注: ${chapter.visualExplainer.image.caption}`);
1357
+ }
1358
+ lines.push('');
1359
+ }
1360
+ for (const paragraph of chapter.paragraphs ?? []) {
1361
+ lines.push(paragraph);
1362
+ lines.push('');
1363
+ }
1364
+ if ((chapter.retrievalBlocks ?? []).length > 0) {
1365
+ lines.push('### 检索练习');
1366
+ lines.push('');
1367
+ for (const [index, block] of chapter.retrievalBlocks.entries()) {
1368
+ lines.push(`1. ${block.prompt}`);
1369
+ if (block.hint) lines.push(` - 提示: ${block.hint}`);
1370
+ lines.push(` - 参考答案: ${block.answer}`);
1371
+ }
1372
+ lines.push('');
1373
+ }
1374
+ if ((chapter.workedExamples ?? []).length > 0) {
1375
+ lines.push('### 工作示例');
1376
+ lines.push('');
1377
+ for (const example of chapter.workedExamples) {
1378
+ lines.push(`- ${example.title}`);
1379
+ lines.push(` - 场景: ${example.scenario}`);
1380
+ lines.push(' - 步骤:');
1381
+ for (const step of example.steps ?? []) {
1382
+ lines.push(` - ${step}`);
1383
+ }
1384
+ if (example.principle) {
1385
+ lines.push(` - 原则: ${example.principle}`);
1386
+ }
1387
+ }
1388
+ lines.push('');
1389
+ }
1390
+ if ((chapter.evidenceIds ?? []).length > 0) {
1391
+ lines.push('### 证据引用');
1392
+ lines.push('');
1393
+ for (const evidenceId of chapter.evidenceIds) {
1394
+ lines.push(`- ${evidenceId}`);
1395
+ }
1396
+ lines.push('');
1397
+ }
1398
+ }
1399
+
1400
+ lines.push('## 证据清单');
1401
+ lines.push('');
1402
+ for (const source of evidenceManifest.sources ?? []) {
1403
+ lines.push(`- ${source.id}: ${source.title} (${source.relativePath})`);
1404
+ }
1405
+ lines.push('');
1406
+ lines.push('## 下一步');
1407
+ lines.push('');
1408
+ for (const action of content.nextActions ?? []) {
1409
+ lines.push(`- ${action}`);
1410
+ }
1411
+ lines.push('');
1412
+ return `${lines.join('\n')}`;
1413
+ }
1414
+
1415
+ async function writeLearningPackageIndex(ws, indexEntry) {
1416
+ const current = await readJson(ws.paths.learningIndex).catch(() => ({
1417
+ version: LEARNING_REVIEW_SCHEMA_VERSION,
1418
+ generatedAt: timestamp(),
1419
+ updatedAt: timestamp(),
1420
+ currentPackageId: null,
1421
+ packages: [],
1422
+ }));
1423
+ const packages = Array.isArray(current.packages) ? current.packages.filter((item) => item.packageId !== indexEntry.packageId) : [];
1424
+ packages.unshift(indexEntry);
1425
+ const next = {
1426
+ version: LEARNING_REVIEW_SCHEMA_VERSION,
1427
+ generatedAt: current.generatedAt ?? timestamp(),
1428
+ updatedAt: timestamp(),
1429
+ currentPackageId: indexEntry.packageId,
1430
+ packages,
1431
+ };
1432
+ await writeJson(ws.paths.learningIndex, next);
1433
+ await writeJson(ws.paths.learningCurrent, indexEntry);
1434
+ return next;
1435
+ }
1436
+
1437
+ async function ensureLearningDirs(ws) {
1438
+ await fs.mkdir(ws.paths.learningDir, { recursive: true });
1439
+ await fs.mkdir(ws.paths.learningArchiveDir, { recursive: true });
1440
+ }
1441
+
1442
+ async function generateLearningReviewWorkspace(projectRoot, options = {}) {
1443
+ const ws = await loadWorkspace(projectRoot);
1444
+ if (!(await exists(ws.workspaceRoot))) {
1445
+ throw new Error(`Missing workspace: ${ws.workspaceRoot}`);
1446
+ }
1447
+
1448
+ await ensureLearningDirs(ws);
1449
+ const settings = await readLearningReviewSettings(ws);
1450
+ if (options.respectConfig !== false && settings.enabled === false) {
1451
+ return {
1452
+ ok: true,
1453
+ action: 'learning-review-generate',
1454
+ skipped: true,
1455
+ reason: '复盘学习模式已关闭',
1456
+ ws,
1457
+ settings,
1458
+ opened: false,
1459
+ };
1460
+ }
1461
+
1462
+ const versionIndex = await readVersionIndex(ws);
1463
+ const currentState = ws.data.currentState ?? {};
1464
+ const latestVersion = await loadLatestVersionSnapshot(ws);
1465
+ const snapshot = latestVersion?.snapshot ?? buildPrdSnapshot(ws, {
1466
+ ...currentState,
1467
+ versionNumber: currentState.prdVersion ?? (versionIndex.at(-1)?.versionNumber ?? 0),
1468
+ versionId: currentState.prdVersion > 0
1469
+ ? formatVersionId(currentState.prdVersion)
1470
+ : (versionIndex.at(-1)?.versionId ?? 'v0000'),
1471
+ productType: resolveCurrentProductType(ws),
1472
+ templatePack: resolveActiveTemplatePack(ws),
1473
+ status: currentState.status ?? 'draft',
1474
+ });
1475
+
1476
+ const topic = inferTopic(snapshot, options);
1477
+ const genreId = normalizeGenreId(options.genre ?? settings.defaultGenre ?? inferGenreId(topic, snapshot));
1478
+ const genre = GENRE_LIBRARY[genreId] ?? GENRE_LIBRARY['internet-product'];
1479
+ const stylePromptPack = resolveStylePromptPack(genre.id, options.style);
1480
+ const trigger = options.trigger ?? 'manual';
1481
+ const sourceScope = options.sourceScope ?? settings.sourceScope ?? DEFAULT_LEARNING_REVIEW_SETTINGS.sourceScope;
1482
+ const packageId = options.packageId ?? `lr-${compactTimestamp()}-${slugify(topic || genre.id, genre.id)}`;
1483
+ const packagePaths = learningPackagePaths(ws, packageId);
1484
+ await fs.mkdir(packagePaths.dir, { recursive: true });
1485
+ await fs.mkdir(packagePaths.assetsDir, { recursive: true });
1486
+
1487
+ const related = {
1488
+ changeId: options.changeId ?? null,
1489
+ taskId: options.taskId ?? null,
1490
+ verifyCommand: options.verifyCommand ?? null,
1491
+ testReport: options.testReport ?? null,
1492
+ commitSha: options.commitSha ?? null,
1493
+ };
1494
+ const evidenceManifest = await buildEvidenceManifest(ws, snapshot, { sourceScope, related });
1495
+ const agentContext = buildAgentContext({
1496
+ packageId,
1497
+ topic,
1498
+ genre,
1499
+ stylePromptPack,
1500
+ trigger,
1501
+ sourceScope,
1502
+ snapshot,
1503
+ evidenceManifest,
1504
+ related,
1505
+ packagePaths,
1506
+ });
1507
+ const authoredContent = options.content
1508
+ ?? (options.contentJson ? await readJson(resolveProjectFile(ws.projectRoot, options.contentJson)) : null);
1509
+ const content = buildLearningContract({
1510
+ packageId,
1511
+ topic,
1512
+ genre,
1513
+ stylePromptPack,
1514
+ trigger,
1515
+ sourceScope,
1516
+ snapshot,
1517
+ evidenceManifest,
1518
+ related,
1519
+ packagePaths,
1520
+ agentContext,
1521
+ authoredContent,
1522
+ });
1523
+ const shouldOpen = Boolean(options.open ?? settings.autoOpen) && content.authoringStatus !== 'awaiting-agent-content';
1524
+
1525
+ const markdown = renderLearningMarkdown({ content, evidenceManifest });
1526
+ const packageMeta = {
1527
+ version: LEARNING_REVIEW_SCHEMA_VERSION,
1528
+ generatedAt: content.generatedAt,
1529
+ packageId,
1530
+ title: content.title || 'OpenPrd 复盘学习包',
1531
+ topic,
1532
+ genreId: genre.id,
1533
+ genreLabel: genre.label,
1534
+ styleId: stylePromptPack.styleId,
1535
+ styleLabel: stylePromptPack.label,
1536
+ promptPackId: `${stylePromptPack.genreId}.${stylePromptPack.styleId}`,
1537
+ trigger,
1538
+ sourceScope,
1539
+ contentMode: content.contentMode,
1540
+ authoringStatus: content.authoringStatus,
1541
+ needsAgentDraft: content.authoringStatus === 'awaiting-agent-content',
1542
+ autoOpen: shouldOpen,
1543
+ related: content.related,
1544
+ paths: packagePathPayload(packagePaths),
1545
+ sourceCount: evidenceManifest.sourceCount,
1546
+ claimCount: evidenceManifest.claimCount,
1547
+ chapterCount: content.chapters.length,
1548
+ };
1549
+
1550
+ await writeJson(packagePaths.packageJson, packageMeta);
1551
+ await writeJson(packagePaths.agentContext, agentContext);
1552
+ await writeText(packagePaths.agentPrompt, renderAgentPromptMarkdown(agentContext));
1553
+ await writeJson(packagePaths.contentJson, content);
1554
+ await writeText(packagePaths.contentMarkdown, markdown);
1555
+ await writeJson(packagePaths.evidenceManifest, evidenceManifest);
1556
+ await writeHtmlArtifact(packagePaths.readerHtml, renderLearningArtifact({
1557
+ packageMeta,
1558
+ content,
1559
+ evidenceManifest,
1560
+ }));
1561
+
1562
+ const indexEntry = {
1563
+ ...packageMeta,
1564
+ relativeDir: path.relative(ws.workspaceRoot, packagePaths.dir).split(path.sep).join('/'),
1565
+ };
1566
+ const learningIndex = await writeLearningPackageIndex(ws, indexEntry);
1567
+
1568
+ let opened = false;
1569
+ if (shouldOpen) {
1570
+ try {
1571
+ await openArtifactInBrowser(packagePaths.readerHtml);
1572
+ opened = true;
1573
+ } catch {
1574
+ opened = false;
1575
+ }
1576
+ }
1577
+
1578
+ const nextState = await readCurrentState(ws);
1579
+ nextState.learningReview = {
1580
+ ...(nextState.learningReview ?? {}),
1581
+ enabled: settings.enabled,
1582
+ defaultGenre: settings.defaultGenre,
1583
+ autoOpen: settings.autoOpen,
1584
+ sourceScope,
1585
+ lastPackageId: packageId,
1586
+ lastGeneratedAt: content.generatedAt,
1587
+ lastGenreId: genre.id,
1588
+ lastStyleId: stylePromptPack.styleId,
1589
+ lastTopic: topic,
1590
+ lastTrigger: trigger,
1591
+ lastAuthoringStatus: content.authoringStatus,
1592
+ lastOpened: opened,
1593
+ };
1594
+ await writeJson(ws.paths.currentState, nextState);
1595
+
1596
+ await appendText(ws.paths.progressLog, `\n## ${timestamp()}\n\n- 已生成学习包 ${packageId}。\n- 写作状态: ${content.authoringStatus}。\n- 题材: ${genre.label}。\n- HTML: ${path.relative(ws.workspaceRoot, packagePaths.readerHtml).split(path.sep).join('/')}。\n- Agent 写作提示: ${path.relative(ws.workspaceRoot, packagePaths.agentPrompt).split(path.sep).join('/')}。\n`);
1597
+ await appendText(ws.paths.decisionLog, `\n## ${timestamp()}\n\n- 复盘学习包已生成: ${packageId}。\n- 写作状态: ${content.authoringStatus}。\n- 证据源: ${evidenceManifest.sourceCount}。\n- 章节数: ${content.chapters.length}。\n`);
1598
+
1599
+ return {
1600
+ ok: true,
1601
+ action: 'learning-review-generate',
1602
+ ws,
1603
+ settings,
1604
+ snapshot,
1605
+ genre,
1606
+ packageId,
1607
+ packageMeta,
1608
+ packagePaths,
1609
+ learningIndex,
1610
+ evidenceManifest,
1611
+ agentContext,
1612
+ content,
1613
+ opened,
1614
+ skipped: false,
1615
+ };
1616
+ }
1617
+
1618
+ async function setLearningReviewModeWorkspace(projectRoot, enabled, options = {}) {
1619
+ const ws = await loadWorkspace(projectRoot);
1620
+ if (!(await exists(ws.workspaceRoot))) {
1621
+ throw new Error(`Missing workspace: ${ws.workspaceRoot}`);
1622
+ }
1623
+
1624
+ await ensureLearningDirs(ws);
1625
+ const settings = await readLearningReviewSettings(ws);
1626
+ const result = await persistLearningConfig(ws, {
1627
+ enabled: Boolean(enabled),
1628
+ }, {
1629
+ action: enabled ? 'enable-learning-review' : 'disable-learning-review',
1630
+ });
1631
+
1632
+ await appendText(ws.paths.progressLog, `\n## ${timestamp()}\n\n- 复盘学习模式已${enabled ? '开启' : '关闭'}。\n- 默认题材: ${result.config.defaultGenre}。\n`);
1633
+
1634
+ return {
1635
+ ok: true,
1636
+ action: 'learning-review-config',
1637
+ ws: result.ws,
1638
+ settings: {
1639
+ ...settings,
1640
+ enabled: Boolean(enabled),
1641
+ },
1642
+ config: result.config,
1643
+ enabled: Boolean(enabled),
1644
+ opened: false,
1645
+ };
1646
+ }
1647
+
1648
+ export {
1649
+ buildEvidenceManifest,
1650
+ buildLearningContract,
1651
+ generateLearningReviewWorkspace,
1652
+ normalizeGenreId,
1653
+ readLearningReviewSettings,
1654
+ setLearningReviewModeWorkspace,
1655
+ };