@openprd/cli 0.1.8 → 0.1.10

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 (134) hide show
  1. package/.openprd/benchmarks/index.md +15 -1
  2. package/.openprd/config.yaml +10 -0
  3. package/.openprd/design/README.md +47 -0
  4. package/.openprd/design/README_EN.md +25 -0
  5. package/.openprd/design/active/asset-spec.md +15 -0
  6. package/.openprd/design/active/direction-plan.md +7 -0
  7. package/.openprd/design/active/facts-sheet.md +13 -0
  8. package/.openprd/design/active/image-preflight.md +9 -0
  9. package/.openprd/design/active/selected-direction.md +11 -0
  10. package/.openprd/design/anti-slop.md +16 -0
  11. package/.openprd/design/assets/dark-mesh.svg +14 -0
  12. package/.openprd/design/assets/paper-grid.svg +14 -0
  13. package/.openprd/design/assets/surface-presets.md +21 -0
  14. package/.openprd/design/assets/workbench-grid.svg +16 -0
  15. package/.openprd/design/checklists/ui-quality-gate.md +22 -0
  16. package/.openprd/design/components/component-catalog.md +42 -0
  17. package/.openprd/design/layouts/layout-catalog.json +45 -0
  18. package/.openprd/design/lenses/frontend-lenses.md +31 -0
  19. package/.openprd/design/recipes/content-experience.md +26 -0
  20. package/.openprd/design/recipes/operational-tool.md +25 -0
  21. package/.openprd/design/recipes/product-launch.md +26 -0
  22. package/.openprd/design/templates/README.md +36 -0
  23. package/.openprd/design/templates/README_EN.md +31 -0
  24. package/.openprd/design/templates/content-home.html +415 -0
  25. package/.openprd/design/templates/ops-dashboard.html +340 -0
  26. package/.openprd/design/templates/product-launch.html +360 -0
  27. package/.openprd/design/themes/theme-catalog.json +77 -0
  28. package/.openprd/engagements/active/prd.md +111 -100
  29. package/.openprd/schema/prd.schema.yaml +10 -0
  30. package/.openprd/templates/base/intake.md +4 -0
  31. package/.openprd/templates/base/prd.md +16 -6
  32. package/AGENTS.md +10 -7
  33. package/README.md +28 -10
  34. package/README_EN.md +23 -3
  35. package/package.json +41 -2
  36. package/scripts/openprd-codex-isolated-worker.mjs +799 -0
  37. package/skills/openprd-benchmark-router/SKILL.md +4 -0
  38. package/skills/openprd-frontend-design/SKILL.md +161 -0
  39. package/skills/openprd-frontend-design/references/design-asset-contract.md +34 -0
  40. package/skills/openprd-frontend-design/references/direction-engine.md +46 -0
  41. package/skills/openprd-harness/SKILL.md +38 -21
  42. package/skills/openprd-learning-review/SKILL.md +2 -1
  43. package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +1 -1
  44. package/skills/openprd-quality/SKILL.md +6 -4
  45. package/skills/openprd-requirement-intake/SKILL.md +27 -5
  46. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -3
  47. package/skills/openprd-requirement-intake/references/routing-rubric.md +9 -5
  48. package/skills/openprd-requirement-intake/references/startup-validation-lens.md +23 -0
  49. package/skills/openprd-router/SKILL.md +13 -5
  50. package/skills/openprd-shared/SKILL.md +38 -25
  51. package/src/agent-integration.js +126 -75
  52. package/src/brainstorm-artifacts.js +1657 -0
  53. package/src/brainstorm-presentation.js +223 -0
  54. package/src/brainstorm.js +603 -0
  55. package/src/cli/args.js +51 -3
  56. package/src/cli/doctor-print.js +34 -8
  57. package/src/cli/print.js +6 -0
  58. package/src/cli/quality-print.js +70 -0
  59. package/src/cli/run-print.js +43 -1
  60. package/src/cli/shared-print.js +33 -2
  61. package/src/cli/workflow-print.js +21 -0
  62. package/src/codex-hook-runner-template.mjs +858 -65
  63. package/src/design-starter-support.js +462 -0
  64. package/src/design-starter.js +1207 -0
  65. package/src/knowledge.js +377 -21
  66. package/src/learning-review.js +3 -3
  67. package/src/loop.js +839 -101
  68. package/src/openprd.js +86 -1
  69. package/src/openspec/change-validate.js +1 -0
  70. package/src/prd-core.js +18 -0
  71. package/src/quality-learning.js +50 -6
  72. package/src/quality.js +2 -2
  73. package/src/run-harness.js +191 -29
  74. package/src/self-update.js +301 -4
  75. package/src/standards.js +46 -5
  76. package/src/visual-prepare.js +940 -0
  77. package/src/workspace-core.js +137 -0
  78. package/src/workspace-workflow.js +190 -7
  79. package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +0 -14
  80. package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +0 -14
  81. package/.openprd/discovery/config.json +0 -35
  82. package/.openprd/engagements/active/flows.md +0 -35
  83. package/.openprd/engagements/active/handoff.md +0 -16
  84. package/.openprd/engagements/active/review.html +0 -61
  85. package/.openprd/engagements/active/roles.md +0 -22
  86. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +0 -23
  87. package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +0 -18
  88. package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +0 -18
  89. package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +0 -18
  90. package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +0 -18
  91. package/.openprd/exports/.gitkeep +0 -0
  92. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +0 -78
  93. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +0 -129
  94. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +0 -41
  95. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +0 -14
  96. package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +0 -49
  97. package/.openprd/knowledge/index.json +0 -47
  98. package/.openprd/reviews/v0001.html +0 -1322
  99. package/.openprd/reviews/v0002.html +0 -1150
  100. package/.openprd/reviews/v0003.html +0 -1150
  101. package/.openprd/reviews/v0004.html +0 -1150
  102. package/.openprd/reviews/v0005.html +0 -1150
  103. package/.openprd/sessions/.gitkeep +0 -0
  104. package/.openprd/state/changes.json +0 -27
  105. package/.openprd/state/current.json +0 -505
  106. package/.openprd/state/release-ledger.json +0 -344
  107. package/.openprd/state/version-index.json +0 -67
  108. package/.openprd/state/versions/.gitkeep +0 -0
  109. package/.openprd/state/versions/v0001.json +0 -121
  110. package/.openprd/state/versions/v0001.md +0 -161
  111. package/.openprd/state/versions/v0002.json +0 -264
  112. package/.openprd/state/versions/v0002.md +0 -183
  113. package/.openprd/state/versions/v0003.json +0 -269
  114. package/.openprd/state/versions/v0003.md +0 -188
  115. package/.openprd/state/versions/v0004.json +0 -274
  116. package/.openprd/state/versions/v0004.md +0 -193
  117. package/.openprd/state/versions/v0005.json +0 -299
  118. package/.openprd/state/versions/v0005.md +0 -189
  119. package/docs/assets/openprd-capability-overview-en.png +0 -0
  120. package/docs/assets/openprd-capability-overview-zh.png +0 -0
  121. package/docs/assets/openprd-learning-html.png +0 -0
  122. package/docs/assets/openprd-quality-html.png +0 -0
  123. package/docs/assets/openprd-requirement-routing-en.png +0 -0
  124. package/docs/assets/openprd-requirement-routing-en.svg +0 -102
  125. package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
  126. package/docs/assets/openprd-requirement-routing-zh.png +0 -0
  127. package/docs/assets/openprd-requirement-routing-zh.svg +0 -102
  128. package/docs/assets/openprd-review-html.png +0 -0
  129. package/docs/assets/openprd-scenario-overview.png +0 -0
  130. package/docs/assets/openprd-scenario-overview.svg +0 -114
  131. package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
  132. package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
  133. package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
  134. package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
@@ -0,0 +1,1207 @@
1
+ /*
2
+ * 核心功能
3
+ * 把 OpenPrd 内置前端模板落成当前项目的真实入口文件,并在需要时顺手写实 active design contracts、替换模板占位文案。
4
+ *
5
+ * 输入
6
+ * 接收项目根目录、starter 标识、输出文件路径、是否允许覆盖,以及可选的 brief / title / sections / 无依赖判断。
7
+ *
8
+ * 输出
9
+ * 复制 `.openprd/design/templates/` 下的模板到目标文件,并返回模板来源、输出路径、推荐默认组合,以及是否同步写实 active design contracts。
10
+ *
11
+ * 定位
12
+ * 位于前端实现准备层,承接 `.openprd/design/active/*` 合同与“起第一版骨架”之间最容易卡住的那一跳。
13
+ *
14
+ * 依赖
15
+ * 依赖 workspace-core 读取工作区路径与记录事件,依赖 fs-utils 做模板读取和写入。
16
+ *
17
+ * 维护规则
18
+ * 新增 starter 或 starter 占位字段时,同步更新 STARTER_CATALOG、placeholder map、usage、前端 skill、模板 README 和相关测试。
19
+ */
20
+ import fs from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import {
23
+ buildThemeLockStyle,
24
+ injectThemeLockStyle,
25
+ isRealImageDependentStarterContext,
26
+ loadThemeConfig,
27
+ sourceStarterImages,
28
+ } from './design-starter-support.js';
29
+ import { appendProgress, appendWorkflowEvent, loadWorkspace } from './workspace-core.js';
30
+ import { exists, readText, writeText } from './fs-utils.js';
31
+
32
+ const STARTER_CATALOG = [
33
+ {
34
+ id: 'content-home',
35
+ file: 'content-home.html',
36
+ description: '内容型首页、导览页、展览页、故事型首页',
37
+ starterTitle: '内容型首页',
38
+ defaultSections: ['今日推荐', '主题分区', '浏览结构', '下一步动作'],
39
+ selectedDirection: {
40
+ label: '方向 1',
41
+ lens: 'editorial-contrast',
42
+ theme: 'warm-editorial',
43
+ layout: 'story-map',
44
+ components: ['editorial-hero', 'stat-row', 'story-section', 'feature-grid', 'timeline', 'cta-banner'],
45
+ },
46
+ directions: [
47
+ {
48
+ label: '方向 1',
49
+ logic: 'contrast',
50
+ lens: 'editorial-contrast',
51
+ theme: 'warm-editorial',
52
+ layout: 'story-map',
53
+ suitable: '长期写作首页、专题内容页、个人博客首页',
54
+ risk: '如果信息密度控制不好,首屏会显得偏满。',
55
+ },
56
+ {
57
+ label: '方向 2',
58
+ logic: 'reference-transfer',
59
+ lens: 'catalog-clarity',
60
+ theme: 'quiet-sand',
61
+ layout: 'editorial-rail',
62
+ suitable: '知识整理页、文章归档页、资源导航页',
63
+ risk: '如果目录层级太重,会削弱阅读感。',
64
+ },
65
+ {
66
+ label: '方向 3',
67
+ logic: 'design-lens',
68
+ lens: 'immersive-showcase',
69
+ theme: 'deep-launch',
70
+ layout: 'story-map',
71
+ suitable: '需要更强个人风格的内容展示页',
72
+ risk: '容易更像展示页而不是稳定阅读首页。',
73
+ },
74
+ ],
75
+ },
76
+ {
77
+ id: 'product-launch',
78
+ file: 'product-launch.html',
79
+ description: '产品发布页、品牌发布页、功能发布页、介绍型首页',
80
+ starterTitle: '产品发布页',
81
+ defaultSections: ['关键变化', '核心收益', '适用对象', '上线信息'],
82
+ selectedDirection: {
83
+ label: '方向 1',
84
+ lens: 'product-launch',
85
+ theme: 'deep-launch',
86
+ layout: 'product-spec-runway',
87
+ components: ['spec-hero', 'stat-row', 'feature-grid', 'logo-wall', 'cta-banner'],
88
+ },
89
+ directions: [
90
+ {
91
+ label: '方向 1',
92
+ logic: 'contrast',
93
+ lens: 'product-launch',
94
+ theme: 'deep-launch',
95
+ layout: 'product-spec-runway',
96
+ suitable: '正式发布页、版本更新页、关键能力介绍页',
97
+ risk: '如果证据不足,会只剩氛围没有事实。',
98
+ },
99
+ {
100
+ label: '方向 2',
101
+ logic: 'reference-transfer',
102
+ lens: 'editorial-contrast',
103
+ theme: 'warm-editorial',
104
+ layout: 'pricing-and-proof',
105
+ suitable: '要兼顾解释与决策的介绍页',
106
+ risk: '可能不够强势,发布感会偏弱。',
107
+ },
108
+ {
109
+ label: '方向 3',
110
+ logic: 'design-lens',
111
+ lens: 'immersive-showcase',
112
+ theme: 'deep-launch',
113
+ layout: 'immersive-hero-stack',
114
+ suitable: '视觉驱动型发布叙事',
115
+ risk: '容易把页面做成纯展示而削弱可扫描性。',
116
+ },
117
+ ],
118
+ },
119
+ {
120
+ id: 'ops-dashboard',
121
+ file: 'ops-dashboard.html',
122
+ description: '工具台、后台首页、运营工作台、信息密度型页面',
123
+ starterTitle: '运营工作台',
124
+ defaultSections: ['待处理动作', '主要列表', '关键指标', '当前模块'],
125
+ selectedDirection: {
126
+ label: '方向 1',
127
+ lens: 'operational-density',
128
+ theme: 'tool-neutral',
129
+ layout: 'ops-density-grid',
130
+ components: ['filter-rail', 'summary-panel', 'dense-table', 'settings-group'],
131
+ },
132
+ directions: [
133
+ {
134
+ label: '方向 1',
135
+ logic: 'contrast',
136
+ lens: 'operational-density',
137
+ theme: 'tool-neutral',
138
+ layout: 'ops-density-grid',
139
+ suitable: '后台首页、运营看板、任务工作台',
140
+ risk: '如果主次不清,扫描效率会下降。',
141
+ },
142
+ {
143
+ label: '方向 2',
144
+ logic: 'reference-transfer',
145
+ lens: 'catalog-clarity',
146
+ theme: 'quiet-sand',
147
+ layout: 'catalog-browser',
148
+ suitable: '对象浏览与筛选更重的系统首页',
149
+ risk: '容易太像列表页,动作感不足。',
150
+ },
151
+ {
152
+ label: '方向 3',
153
+ logic: 'design-lens',
154
+ lens: 'operational-density',
155
+ theme: 'deep-launch',
156
+ layout: 'settings-split',
157
+ suitable: '偏控制中心与配置中枢的场景',
158
+ risk: '暗色高密度更考验层级控制。',
159
+ },
160
+ ],
161
+ },
162
+ ];
163
+
164
+ const STARTER_LOOKUP = new Map(
165
+ STARTER_CATALOG.flatMap((starter) => [
166
+ [starter.id, starter],
167
+ [starter.file, starter],
168
+ ]),
169
+ );
170
+
171
+ function normalizePathForDisplay(filePath) {
172
+ return String(filePath ?? '').replaceAll('\\', '/');
173
+ }
174
+
175
+ function escapeHtml(value) {
176
+ return String(value ?? '')
177
+ .replaceAll('&', '&')
178
+ .replaceAll('<', '&lt;')
179
+ .replaceAll('>', '&gt;')
180
+ .replaceAll('"', '&quot;');
181
+ }
182
+
183
+ function normalizePathForMarkdown(value) {
184
+ return String(value ?? '').replaceAll('|', '\\|').trim();
185
+ }
186
+
187
+ function normalizeModuleName(raw, fallback = 'project') {
188
+ const candidate = String(raw ?? '').trim().split('/').filter(Boolean).pop() ?? '';
189
+ const normalized = candidate.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
190
+ return normalized || fallback;
191
+ }
192
+
193
+ function parseStarterDefaults(templateText) {
194
+ const match = String(templateText ?? '').match(/starter-defaults:\s*lens=([^\s]+)\s+theme=([^\s]+)\s+layout=([^\s]+)\s*-->/);
195
+ if (!match) {
196
+ return {
197
+ lens: null,
198
+ theme: null,
199
+ layout: null,
200
+ };
201
+ }
202
+ return {
203
+ lens: match[1] ?? null,
204
+ theme: match[2] ?? null,
205
+ layout: match[3] ?? null,
206
+ };
207
+ }
208
+
209
+ function buildAvailableStarters() {
210
+ return STARTER_CATALOG.map((starter) => ({
211
+ id: starter.id,
212
+ file: starter.file,
213
+ description: starter.description,
214
+ }));
215
+ }
216
+
217
+ function splitSections(value, fallback = []) {
218
+ if (Array.isArray(value)) {
219
+ return value.map((item) => String(item ?? '').trim()).filter(Boolean);
220
+ }
221
+ const text = String(value ?? '').trim();
222
+ if (!text) {
223
+ return [...fallback];
224
+ }
225
+ return text
226
+ .split(/[|,,;;]/)
227
+ .map((item) => item.trim())
228
+ .filter(Boolean);
229
+ }
230
+
231
+ function normalizeBrief(options = {}) {
232
+ return String(options.brief ?? '').trim();
233
+ }
234
+
235
+ function shouldHydrateStarter(options = {}) {
236
+ return Boolean(
237
+ normalizeBrief(options)
238
+ || String(options.title ?? '').trim()
239
+ || String(options.sections ?? '').trim()
240
+ );
241
+ }
242
+
243
+ function deriveDisplayTitle(options = {}, starter) {
244
+ const explicitTitle = String(options.title ?? '').trim();
245
+ if (explicitTitle) {
246
+ return explicitTitle;
247
+ }
248
+ const brief = normalizeBrief(options);
249
+ if (!brief) {
250
+ return starter.starterTitle;
251
+ }
252
+ const shortened = brief
253
+ .replace(/^(一个|一套|请直接实现一个|请实现一个|直接实现一个)/, '')
254
+ .replace(/(首页|页面|原型|界面|网站)$/g, '')
255
+ .trim();
256
+ return shortened || brief;
257
+ }
258
+
259
+ function buildStarterContext(starter, defaults, options = {}) {
260
+ return {
261
+ starterId: starter.id,
262
+ brief: normalizeBrief(options) || `${starter.starterTitle}的第一版落地页面`,
263
+ displayTitle: deriveDisplayTitle(options, starter),
264
+ htmlTitle: deriveDisplayTitle(options, starter),
265
+ sections: splitSections(options.sections, starter.defaultSections),
266
+ defaults,
267
+ noExternalFacts: Boolean(options.noExternalFacts),
268
+ noBrandAssets: Boolean(options.noBrandAssets),
269
+ noRealImages: Boolean(options.noRealImages),
270
+ themeConfig: null,
271
+ imageBundle: null,
272
+ };
273
+ }
274
+
275
+ function pickImageAsset(assets, index) {
276
+ if (!Array.isArray(assets) || index < 0 || index >= assets.length) {
277
+ return null;
278
+ }
279
+ return assets[index];
280
+ }
281
+
282
+ function buildMediaMarkup(asset, fallbackLabel) {
283
+ if (!asset) {
284
+ return `<div class="media-fallback"><span>${escapeHtml(fallbackLabel)}</span></div>`;
285
+ }
286
+ return [
287
+ '<figure class="media-frame">',
288
+ ` <img src="${escapeHtml(asset.relativePath)}" alt="${escapeHtml(asset.alt || asset.title || fallbackLabel)}" loading="eager" />`,
289
+ ` <figcaption>${escapeHtml(asset.title || asset.alt || fallbackLabel)}</figcaption>`,
290
+ '</figure>',
291
+ ].join('\n');
292
+ }
293
+
294
+ function todayStamp() {
295
+ return new Date().toISOString().slice(0, 10);
296
+ }
297
+
298
+ function buildHtmlFileManual(starter, context, relativeOutputPath) {
299
+ const sectionSummary = context.sections.join('、');
300
+ const dependencyLine = context.noExternalFacts && context.noBrandAssets && context.noRealImages
301
+ ? '原生 HTML、CSS 与轻量内联脚本;不依赖构建工具、第三方库或远程接口。'
302
+ : '原生 HTML、CSS 与轻量内联脚本;如果后续补真实素材或事实数据,再同步更新依赖说明。';
303
+ const inputLine = starter.id === 'product-launch'
304
+ ? '用户直接打开静态 HTML 文件;页面内容来自当前任务已确认的发布主题、模块范围与可选事实约束。'
305
+ : '用户直接打开静态 HTML 文件;页面内容来自当前任务已确认的页面主题、模块范围与可选事实约束。';
306
+ const outputLineByStarter = {
307
+ 'content-home': `渲染完整内容型首页,展示 ${sectionSummary},并提供站内锚点与阅读型 CTA。`,
308
+ 'product-launch': `渲染完整发布页,展示 ${sectionSummary},并把变化、证据和下一步动作收进同一页面。`,
309
+ 'ops-dashboard': `渲染完整工作台首页,展示 ${sectionSummary},并把判断、对象和动作入口放在同一视图里。`,
310
+ };
311
+ const purposeLineByStarter = {
312
+ 'content-home': '当前项目的核心首页入口文件,同时承载视觉样式、内容结构和轻量交互。',
313
+ 'product-launch': '当前项目的核心发布页入口文件,同时承载首屏叙事、证据区块和 CTA。',
314
+ 'ops-dashboard': '当前项目的唯一工作台入口文件,同时承载信息密度布局、状态卡与队列区块。',
315
+ };
316
+ return `<!--
317
+ ## 核心功能
318
+ 提供一个可直接在浏览器打开的${context.displayTitle}页面入口,围绕 ${sectionSummary} 组织第一版真实内容。
319
+
320
+ ## 输入
321
+ ${inputLine}
322
+
323
+ ## 输出
324
+ ${outputLineByStarter[starter.id] ?? `渲染 ${relativeOutputPath} 对应的静态页面入口。`}
325
+
326
+ ## 定位
327
+ ${purposeLineByStarter[starter.id] ?? '当前项目的主要页面入口文件。'}
328
+
329
+ ## 依赖
330
+ ${dependencyLine}
331
+
332
+ ## 维护规则
333
+ - \`design-starter\` 完成后默认进入 \`Patch Mode\`:必须在当前入口文件上继续补丁修改;即使需要大幅调整结构,也是在同一路径内覆盖,不做 delete-first,更不要删除后另起新稿。
334
+ - 如果后续用户又给了效果图、设计稿或参考图,以这些参考为准;starter 默认组合只在足够接近时继续沿用,不反过来主导最终风格。
335
+ - 如果确实需要整页重写,先把完整新稿写到同目录 sibling draft,再覆盖回当前入口文件;不要让正式入口出现空窗。
336
+ - starter 一落地后,只允许做一轮就地对焦:快速读一次生成的入口文件和必要的 active design artifacts;这轮对焦结束后,下一步就必须是真实写入口,不要再回头搜网页、翻 \`docs/basic/\` 或继续模板漫游。
337
+ - 把最后一批必要的查事实、查图、读模板动作放在口头宣布之前做完;一旦你已经说“开始覆盖入口文件”或“开始整页重写”,下一步必须是对入口文件或 sibling draft 的实际写操作;不要继续只读扫描、压图或停在口头承诺;必要时 hook 会把这类非写入动作挡回去。
338
+ - 修改页面结构、核心文案、交互方式或视觉主题后,需同步更新本说明书。
339
+ - 变更交付方式、事实边界或项目结构后,需同步更新 \`docs/basic/\` 与相关设计合同。
340
+ -->`;
341
+ }
342
+
343
+ function buildGenericFactsSheet(context) {
344
+ const firstSection = context.sections[0] ?? '首页内容';
345
+ const secondSection = context.sections[1] ?? '信息结构';
346
+ const boundary = context.noExternalFacts
347
+ ? '页面内容以内建文案为准,不依赖外部产品事实或第三方数据。'
348
+ : '页面内容需要按当前任务范围核实外部事实,未核实项需继续补充。';
349
+ return `# Facts Sheet
350
+
351
+ | 字段 | 当前值 | 来源 | verifiedAt | 状态 |
352
+ | --- | --- | --- | --- | --- |
353
+ | 页面主题 | ${normalizePathForMarkdown(context.brief)} | user-request | ${todayStamp()} | verified |
354
+ | 交付形态 | 单文件静态入口,可直接在浏览器打开 | user-request | ${todayStamp()} | verified |
355
+ | 必备模块 | ${normalizePathForMarkdown(context.sections.join('、'))} | user-request | ${todayStamp()} | verified |
356
+ | 内容边界 | ${normalizePathForMarkdown(boundary)} | ${context.noExternalFacts ? 'agent-defined' : 'needs-follow-up'} | ${todayStamp()} | verified |
357
+
358
+ ## 备注
359
+
360
+ - 当前首要模块: ${normalizePathForMarkdown(firstSection)}
361
+ - 当前次级模块: ${normalizePathForMarkdown(secondSection)}
362
+ `;
363
+ }
364
+
365
+ function buildGenericAssetSpec(context) {
366
+ const imageDependent = isRealImageDependentStarterContext({ id: context.starterId }, context);
367
+ const assetLine = context.noBrandAssets
368
+ ? '当前无品牌素材依赖'
369
+ : '如需品牌资产,后续按任务补充';
370
+ const imageLine = context.noRealImages
371
+ ? '页面成立不依赖真实图片'
372
+ : imageDependent
373
+ ? '页面更像依赖真实图片的内容页,优先补与主题直接相关的真实图片'
374
+ : '如需真实图片,后续按任务补充';
375
+ const imageBundle = context.imageBundle ?? null;
376
+ const fetchedImageRows = imageBundle?.succeeded
377
+ ? imageBundle.assets.map((asset) => `| 图片 ${asset.index} | ${normalizePathForMarkdown(asset.title || asset.alt)} | ${normalizePathForMarkdown(asset.pageUrl || asset.sourceUrl)} | fetched | ${normalizePathForMarkdown(`Wikimedia Commons / ${asset.licenseShortName}`)} |`)
378
+ : [];
379
+ return `# Asset Spec
380
+
381
+ | 类型 | 资产 | 来源 | 状态 | 备注 |
382
+ | --- | --- | --- | --- | --- |
383
+ | logo | ${normalizePathForMarkdown(context.noBrandAssets ? '无外部 logo,优先用文字字标' : '待补品牌字标或官方 logo')} | ${context.noBrandAssets ? 'n/a' : '待确认'} | ${context.noBrandAssets ? 'not-needed' : 'defined'} | ${normalizePathForMarkdown(assetLine)} |
384
+ | 产品图 | ${normalizePathForMarkdown(context.noBrandAssets ? '无' : '按任务需要补产品图')} | ${context.noBrandAssets ? 'n/a' : '待确认'} | ${context.noBrandAssets ? 'not-needed' : 'defined'} | ${normalizePathForMarkdown(imageLine)} |
385
+ | UI 图 | ${normalizePathForMarkdown(context.starterId === 'ops-dashboard' ? '如需现有界面截图再补' : '无必需 UI 截图')} | ${context.starterId === 'ops-dashboard' ? '待确认' : 'n/a'} | ${context.starterId === 'ops-dashboard' ? 'defined' : 'not-needed'} | |
386
+ | 摄影 / 插图 | ${normalizePathForMarkdown(context.noRealImages ? '无' : '按任务需要补真实图片或插图')} | ${context.noRealImages ? 'n/a' : '待确认'} | ${context.noRealImages ? 'not-needed' : 'defined'} | ${normalizePathForMarkdown(imageLine)} |
387
+ | 色板 / 字体 | ${normalizePathForMarkdown(`${context.defaults.theme ?? 'starter-default'} 对应的默认 tokens`)} | implementation | defined | 先锁定 starter 默认组合,再按页面细化 |
388
+
389
+ ${fetchedImageRows.length > 0 ? `
390
+ ## 已补真实图片
391
+
392
+ | 类型 | 资产 | 来源 | 状态 | 备注 |
393
+ | --- | --- | --- | --- | --- |
394
+ ${fetchedImageRows.join('\n')}
395
+ ` : ''}
396
+
397
+ ## 冻结变量
398
+
399
+ - lens: ${context.defaults.lens ?? context.starterId}
400
+ - theme: ${context.defaults.theme ?? '待补'}
401
+ - layout: ${context.defaults.layout ?? '待补'}
402
+ ${imageBundle?.succeeded ? `- image-query: ${normalizePathForMarkdown(imageBundle.query)}\n- image-manifest: ${normalizePathForMarkdown(imageBundle.relativeManifestPath ?? '待补')}` : ''}
403
+ `;
404
+ }
405
+
406
+ function buildGenericImagePreflight(context) {
407
+ const imageBundle = context.imageBundle ?? null;
408
+ const imageDependent = isRealImageDependentStarterContext({ id: context.starterId }, context);
409
+ if (context.noRealImages) {
410
+ return `# Image Preflight
411
+
412
+ | 问题 | 回答 |
413
+ | --- | --- |
414
+ | 真实图片是不是页面成立前提 | 不是。当前页面以排版、信息层级和结构节奏成立。 |
415
+ | 必需图片类型 | 无必需图片。 |
416
+ | 计划来源 | 不引入外部图库或实拍。 |
417
+ | 缺失风险 | 如果强行加占位图,会把页面带偏成展示页或营销页。 |
418
+ | 降级方案 | 维持纯文字、标签、数据卡和结构块的内容型表达。 |
419
+ `;
420
+ }
421
+ if (imageBundle?.succeeded) {
422
+ return `# Image Preflight
423
+
424
+ | 问题 | 回答 |
425
+ | --- | --- |
426
+ | 真实图片是不是页面成立前提 | 是,这一版已经按内容主题补齐首批真实图片。 |
427
+ | 必需图片类型 | 主视觉图、内容卡片图或证明性配图。 |
428
+ | 计划来源 | Wikimedia Commons(query: ${normalizePathForMarkdown(imageBundle.query)}) |
429
+ | 当前结果 | 已补 ${imageBundle.assets.length} 张,详见 \`asset-spec.md\` 与 ${normalizePathForMarkdown(imageBundle.relativeManifestPath ?? 'manifest.json')}。 |
430
+ | 缺失风险 | 若后续替换成不相关图片,会削弱页面的主题可信度。 |
431
+ | 降级方案 | 若远程图片失效,保留当前本地缓存文件;若缓存也不可用,再退回清楚的结构化占位。 |
432
+ `;
433
+ }
434
+ if (imageDependent) {
435
+ return `# Image Preflight
436
+
437
+ | 问题 | 回答 |
438
+ | --- | --- |
439
+ | 真实图片是不是页面成立前提 | 大概率是。当前页面更像导览、展览、地点或实物内容页。 |
440
+ | 必需图片类型 | 主视觉图、路线/展项/主题配图,或能证明场景真实性的辅助图。 |
441
+ | 计划来源 | 优先补与页面主题直接相关的真实图片;starter 会优先尝试自动补 Wikimedia Commons 图片。 |
442
+ | 缺失风险 | 如果只有结构没有真实图,页面会明显空心,用户也更难快速相信主题内容。 |
443
+ | 降级方案 | 若暂时缺图,先保留清楚的图片位和来源计划,不要把它伪装成已经完整的营销页。 |
444
+ `;
445
+ }
446
+ return `# Image Preflight
447
+
448
+ | 问题 | 回答 |
449
+ | --- | --- |
450
+ | 真实图片是不是页面成立前提 | 需要按任务继续判断。 |
451
+ | 必需图片类型 | 结合页面主题补充。 |
452
+ | 计划来源 | 当前待确认;若页面主题更接近地点、馆藏、展览、旅行或实物导览,starter 会优先尝试自动补 Wikimedia Commons 图片。 |
453
+ | 缺失风险 | 若图片实际上是内容前提但未补齐,页面会空心化。 |
454
+ | 降级方案 | 明确标注待补图片,并在实现前继续补事实与素材。 |
455
+ `;
456
+ }
457
+
458
+ function buildGenericDirectionPlan(starter, context) {
459
+ const lines = starter.directions.map((direction) => `| ${direction.label} | ${direction.logic} | ${direction.lens} | ${direction.theme} | ${direction.layout} | ${normalizePathForMarkdown(`${context.brief} 下刻意拉开气质和信息组织方式`)} | ${normalizePathForMarkdown(direction.suitable)} | ${normalizePathForMarkdown(direction.risk)} |`);
460
+ return `# Direction Plan
461
+
462
+ | 方向 | 生成逻辑 | lens | theme | layout | 为什么不同 | 适用场景 | 主要风险 |
463
+ | --- | --- | --- | --- | --- | --- | --- | --- |
464
+ ${lines.join('\n')}
465
+ `;
466
+ }
467
+
468
+ function buildGenericSelectedDirection(starter) {
469
+ const selected = starter.selectedDirection;
470
+ return `# Selected Direction
471
+
472
+ - selected: ${selected.label}
473
+ - reason: 优先沿 starter 默认组合落地,减少空白工作区从设计合同跳到真实页面时的摇摆。
474
+ - lens: ${selected.lens}
475
+ - theme: ${selected.theme}
476
+ - layout: ${selected.layout}
477
+ - components:
478
+ ${selected.components.map((component) => ` - ${component}`).join('\n')}
479
+ - follow-up risks:
480
+ - 进入实现时继续控制信息密度,避免局部突然换轨
481
+ - 页面文案要保留当前任务语气,不要把模板示例话术原样带入交付
482
+ `;
483
+ }
484
+
485
+ const BASIC_DOC_FILENAMES = [
486
+ 'file-structure.md',
487
+ 'app-flow.md',
488
+ 'prd.md',
489
+ 'frontend-guidelines.md',
490
+ 'backend-structure.md',
491
+ 'tech-stack.md',
492
+ ];
493
+
494
+ function buildBasicDocMap(projectRoot, starter, context, relativeOutputPath) {
495
+ const projectName = path.basename(projectRoot);
496
+ const sections = [...context.sections];
497
+ while (sections.length < 4) {
498
+ sections.push(`模块 ${sections.length + 1}`);
499
+ }
500
+ const docs = {
501
+ 'file-structure.md': `# 项目文件结构
502
+
503
+ ## 项目定位
504
+
505
+ 这是一个单页静态前端原型工作区,当前交付物是根目录下可直接打开的 \`${relativeOutputPath}\`,用于展示“${context.displayTitle}”。
506
+
507
+ ## 核心目录
508
+
509
+ - \`${relativeOutputPath}\`: 当前项目唯一页面入口,包含结构、样式和轻量交互。
510
+ - \`docs/basic/\`: 项目基础事实文档,记录结构、流程、产品边界、技术栈与前端约束。
511
+ - \`.openprd/design/active/\`: 本次页面实现使用的设计合同,包括事实、素材、方向与选中方案。
512
+ - \`.openprd/\`: OpenPrd 工作区元数据、校验规则、模板与质量报告。
513
+ - \`AGENTS.md\`: 当前仓库的执行入口约束。
514
+
515
+ ## 文件组织规则
516
+
517
+ - 新增文件时,应同步确认所在文件夹说明书是否需要更新。
518
+ - 跨模块移动文件时,应更新本文件中的目录结构和职责说明。
519
+ - 当前项目默认优先保持单文件 HTML 原型;只有出现明确复用边界或复杂交互时,才考虑拆分 CSS、JS 或多页面结构。
520
+
521
+ ## 维护规则
522
+
523
+ - 每次新增、删除、移动目录或核心文件后,必须检查并更新本文件。
524
+ - 本文档只记录项目结构事实,不承载具体功能需求细节。
525
+ `,
526
+ 'app-flow.md': `# 产品流程说明
527
+
528
+ ## 核心流程
529
+
530
+ 用户打开 \`${relativeOutputPath}\` 后,先在首屏判断这页是否正好服务于当前任务,再沿着 ${sections.join('、')} 继续浏览或操作。
531
+
532
+ ## 用户路径
533
+
534
+ - 进入页面后先看首屏摘要与主动作,确认这页提供的核心价值。
535
+ - 再按 ${sections[0]}、${sections[1]}、${sections[2]} 的顺序完成浏览、筛选或判断。
536
+ - 最后通过 ${sections[3]} 收尾,决定是否继续深入、订阅或执行下一步动作。
537
+
538
+ ## 状态变化
539
+
540
+ - 默认状态: 页面直接展示完整静态内容,不依赖登录、接口加载或异步占位。
541
+ - 浏览状态: 用户通过站内锚点、按钮或浏览器自然滚动切换关注区块。
542
+ - 异常状态: 当前版本不引入远程数据或表单提交结果;若后续接入真实数据、订阅或表单,再补充失败与恢复路径。
543
+
544
+ ## 维护规则
545
+
546
+ - 每次用户流程、页面跳转、任务状态或异常处理发生变化后,必须检查并更新本文件。
547
+ `,
548
+ 'prd.md': `# 产品逻辑说明
549
+
550
+ ## 问题与目标
551
+
552
+ 当前项目要解决的问题是:在空白工作区里快速交付一页可直接打开、结构完整、气质稳定的“${context.displayTitle}”,而不是停留在模板占位或概念描述。
553
+
554
+ ## 用户故事
555
+
556
+ - 作为需要快速验证界面方向的使用者,我希望直接打开页面就能看到 ${sections.join('、')},从而判断这版结构和值不值得继续细化。
557
+
558
+ ## 功能范围
559
+
560
+ - 当前版本包含: 单文件静态页面入口、${sections.join('、')}、与本页对应的设计合同和基础文档。
561
+ - 当前版本不包含: 后端服务、真实数据接线、账号体系、持久化存储和 SEO 运营体系。
562
+
563
+ ## 验收标准
564
+
565
+ - 页面可以直接在浏览器打开,不依赖构建命令。
566
+ - 首屏和主要区块都已替换成真实内容,不残留模板占位。
567
+ - \`docs/basic/\`、设计合同、文件说明书和文件夹 README 与当前页面事实保持一致。
568
+
569
+ ## 维护规则
570
+
571
+ - 每次需求边界、用户故事、验收标准发生变化后,必须检查并更新本文件。
572
+ `,
573
+ 'frontend-guidelines.md': `# 前端开发规范
574
+
575
+ ## 适用范围
576
+
577
+ 适用于当前 ${context.displayTitle} 的结构、样式、交互和后续同类静态页面扩展。
578
+
579
+ ## 界面结构
580
+
581
+ - 当前页面采用单文件 HTML 结构,直接在 \`${relativeOutputPath}\` 中维护语义化分区、样式和轻量交互。
582
+ - \`design-starter\` 产出后立即进入 \`Patch Mode\`:后续实现必须继续在这个入口文件上补丁修改;如果结构要大改,也是在当前文件里重排或整页覆盖,不删除重起。
583
+ - 如果后续用户提供效果图、设计稿或参考图,优先以参考图为准;只有现有 starter、theme、layout 足够接近时才继续沿用。
584
+ - 如果确实要整页重写,先写 sibling draft,再覆盖回 \`${relativeOutputPath}\`,不要让正式入口在重写过程中缺失。
585
+ - starter 一落地后,只允许做一轮就地对焦:快速读一次生成的入口文件和必要的 active design artifacts;这轮对焦结束后,下一步就必须是真实写入口,不要再回头搜网页、翻 \`docs/basic/\` 或继续模板漫游。
586
+ - 把最后一批必要的查事实、查图、读模板动作放在口头宣布之前做完;一旦已经说“开始覆盖入口文件”或“开始整页重写”,下一步必须是对 \`${relativeOutputPath}\` 或 sibling draft 的实际写入,不要继续只读扫描、压图或停在口头承诺;必要时 hook 会把这类非写入动作挡回去。
587
+ - 主要区块固定为: ${sections.join('、')}。
588
+ - 当前视觉方向锁定为 \`${context.defaults.lens ?? starter.id}\`,主题采用 \`${context.defaults.theme ?? 'starter-default'}\`,页面骨架采用 \`${context.defaults.layout ?? 'starter-layout'}\`。
589
+ - 优先使用稳定的信息层级、卡片、标签、导航锚点和纯文字表达;不通过花哨装饰替代真实结构。
590
+
591
+ ## 交互规范
592
+
593
+ - 默认优先使用站内锚点、浏览器原生滚动和轻量按钮反馈,保持可预测的浏览体验。
594
+ - 文案语气面向普通用户,优先说明这页能做什么、应该先看哪里、下一步怎么做。
595
+ - 移动端优先保证信息顺序正确,其次才是装饰层次;当布局收窄时,所有网格应自然降到单列。
596
+ - 如果页面本来不依赖外部事实、品牌素材或真实图片,就保持这种轻依赖结构,不为了“更像正式页面”强行补灰块或假素材。
597
+ - \`Patch Mode\` 完成的最低标准是:入口文件本体已经改完,主要占位或“待补”文案已清掉,已准备好的真实图片或参考约束已经真正落进页面。
598
+
599
+ ## 维护规则
600
+
601
+ - 每次新增界面模式、组件规范或交互规则后,必须检查并更新本文件。
602
+ `,
603
+ 'backend-structure.md': `# 后端架构设计
604
+
605
+ ## 适用范围
606
+
607
+ 当前项目是单页静态前端原型,不提供后端服务、数据库或远程处理链路。本文件用于明确哪些后端能力当前不适用。
608
+
609
+ ## 服务边界
610
+
611
+ - 不适用。当前没有独立服务、任务队列、数据存储或外部依赖。
612
+ - 页面内的交互保持在浏览器本地,不构成后端服务边界。
613
+
614
+ ## CLI 接入面
615
+
616
+ - 业务功能层面不提供 CLI。
617
+ - 研发层面使用 OpenPrd 工具链辅助初始化、设计合同、标准校验和项目管理;当前最相关的入口包括 \`openprd design-starter\`、\`openprd standards . --verify\`、\`openprd run . --verify\` 与 \`openprd quality . --verify\`。
618
+
619
+ ## API 接入面
620
+
621
+ - 不适用。当前版本没有 HTTP、RPC、WebSocket 或内部服务接口。
622
+ - 如果后续接入真实数据、账号体系或提交动作,应在此补充接口协议、数据结构、身份边界和失败路径。
623
+
624
+ ## 数据流
625
+
626
+ - 输入: 用户在浏览器中直接打开 \`${relativeOutputPath}\`,并按页面结构浏览或触发轻量交互。
627
+ - 处理: 前端在本地渲染结构、样式与轻量状态,不经过远程服务。
628
+ - 输出: 浏览器直接展示完整页面;当前项目不做服务端存储或远程提交。
629
+
630
+ ## 维护规则
631
+
632
+ - 每次服务边界、CLI/API 接入契约、数据流、存储或外部依赖发生变化后,必须检查并更新本文件。
633
+ `,
634
+ 'tech-stack.md': `# 项目技术栈
635
+
636
+ ## 运行环境
637
+
638
+ - 浏览器直接打开静态 HTML 文件即可运行。
639
+ - 当前页面基于原生 HTML、CSS 和轻量内联脚本,不依赖前端框架或打包工具。
640
+
641
+ ## 核心依赖
642
+
643
+ - 无业务第三方依赖。
644
+ - 研发辅助依赖为 OpenPrd 工作区与其内置设计模板、标准校验和质量检查能力。
645
+
646
+ ## 工具链
647
+
648
+ - \`openprd design-starter . --starter ${starter.id} --out ${relativeOutputPath}\`: 生成首版页面、设计合同与基础文档。
649
+ - \`openprd standards . --verify\`: 校验 \`docs/basic/\`、文件说明书和文件夹 README。
650
+ - \`openprd run . --verify\`: 汇总当前任务与工作区验证状态。
651
+ - \`openprd quality . --verify\`: 生成质量报告并给出当前证据门禁状态。
652
+
653
+ ## 维护规则
654
+
655
+ - 每次新增、移除或升级核心依赖、运行时和工具链后,必须检查并更新本文件。
656
+ `,
657
+ };
658
+
659
+ return docs;
660
+ }
661
+
662
+ function looksLikePlaceholderDoc(text) {
663
+ const normalized = String(text ?? '');
664
+ return normalized.includes('待补充:') || normalized.includes('## 适用范围\n\n说明') || normalized.includes('## 核心流程\n\n描述产品从入口到完成目标的主要流程。');
665
+ }
666
+
667
+ async function maybeWriteBasicDoc(filePath, text) {
668
+ const current = await readText(filePath).catch(() => null);
669
+ if (current && !looksLikePlaceholderDoc(current)) {
670
+ return false;
671
+ }
672
+ await writeText(filePath, text);
673
+ return true;
674
+ }
675
+
676
+ async function resolveRootFolderManualPath(projectRoot) {
677
+ const packageJsonPath = path.join(projectRoot, 'package.json');
678
+ const packageJson = await readText(packageJsonPath).then((text) => JSON.parse(text)).catch(() => null);
679
+ const moduleName = normalizeModuleName(packageJson?.name ?? path.basename(projectRoot), normalizeModuleName(path.basename(projectRoot)));
680
+ const folderName = normalizeModuleName(path.basename(projectRoot), moduleName);
681
+ return path.join(projectRoot, `${moduleName}_${folderName}_README.md`);
682
+ }
683
+
684
+ function buildRootFolderManual(starter, context, relativeOutputPath) {
685
+ const folderLabel = path.basename(relativeOutputPath);
686
+ const sectionSummary = context.sections.join('、');
687
+ return `# 文件夹说明书
688
+
689
+ ## 核心功能
690
+
691
+ 当前根目录承载一个可直接打开的 ${context.displayTitle} 原型,以及支撑该页面交付的基础文档和 OpenPrd 工作区元数据。
692
+
693
+ ## 输入
694
+
695
+ - 用户的页面需求、主题和结构约束。
696
+ - 浏览器对 \`${folderLabel}\` 的直接访问。
697
+ - 研发过程中的设计合同、标准校验和质量检查结果。
698
+
699
+ ## 输出
700
+
701
+ - 可直接在浏览器打开的静态页面入口文件。
702
+ - 记录项目结构、流程、产品边界、前端约束和技术栈的基础文档。
703
+ - 与 ${sectionSummary} 对应的设计合同和质量校验记录。
704
+
705
+ ## 定位
706
+
707
+ 这是当前项目的主工作目录,既包含最终交付页面,也包含保证页面可维护的最小文档底座;当前没有独立源码子模块或后端工程。
708
+
709
+ ## 依赖
710
+
711
+ - \`${relativeOutputPath}\`
712
+ - \`docs/basic/\`
713
+ - \`.openprd/\`
714
+ - \`AGENTS.md\`
715
+
716
+ ## 维护规则
717
+
718
+ - 每次新增、删除、移动文件或改变根目录职责后,必须检查并更新本 README。
719
+ - 后续页面实现必须继续沿用 \`${relativeOutputPath}\` 这个入口文件,并把它视为 \`Patch Mode\` 的稳定基座;如果要大改结构,也是在原文件上调整或整页覆盖,不删除后另起新稿。
720
+ - 如果后续用户给了效果图、设计稿或参考图,以这些参考作为最终裁决;样式库和 starter 只负责提供最近的实现路径。
721
+ - 如果确实要整页重写,先把完整新稿写到 sibling draft,再覆盖回 \`${relativeOutputPath}\`;不要让正式入口在过渡阶段消失。
722
+ - starter 一落地后,只允许做一轮就地对焦:快速读一次生成的入口文件和必要的 active design artifacts;这轮对焦结束后,下一步就必须是真实写入口,不要再回头搜网页、翻 \`docs/basic/\` 或继续模板漫游。
723
+ - 把最后一批必要的查事实、查图、读模板动作放在口头宣布之前做完;一旦已经说“开始覆盖入口文件”或“开始整页重写”,下一步必须是对 \`${relativeOutputPath}\` 或 sibling draft 的实际写入,不要继续只读扫描、压图或停在口头承诺;必要时 hook 会把这类非写入动作挡回去。
724
+ - 页面结构、交付方式或事实边界变化时,必须同步更新 \`docs/basic/\` 与相关设计合同。
725
+ `;
726
+ }
727
+
728
+ async function hydrateWorkspaceBasics(projectRoot, starter, context, relativeOutputPath) {
729
+ const docMap = buildBasicDocMap(projectRoot, starter, context, relativeOutputPath);
730
+ const docsDir = path.join(projectRoot, 'docs', 'basic');
731
+ const updatedDocs = [];
732
+ for (const fileName of BASIC_DOC_FILENAMES) {
733
+ const didWrite = await maybeWriteBasicDoc(path.join(docsDir, fileName), docMap[fileName]);
734
+ if (didWrite) {
735
+ updatedDocs.push(`docs/basic/${fileName}`);
736
+ }
737
+ }
738
+
739
+ const folderManualPath = await resolveRootFolderManualPath(projectRoot);
740
+ const folderManualExists = await exists(folderManualPath);
741
+ if (!folderManualExists) {
742
+ await writeText(folderManualPath, buildRootFolderManual(starter, context, relativeOutputPath));
743
+ }
744
+
745
+ return {
746
+ updatedDocs,
747
+ folderManualCreated: !folderManualExists,
748
+ folderManualPath: path.relative(projectRoot, folderManualPath) || path.basename(folderManualPath),
749
+ };
750
+ }
751
+
752
+ async function writeActiveDesignArtifacts(ws, starter, context) {
753
+ await writeText(ws.paths.designFactsSheet, buildGenericFactsSheet(context));
754
+ await writeText(ws.paths.designAssetSpec, buildGenericAssetSpec(context));
755
+ await writeText(ws.paths.designImagePreflight, buildGenericImagePreflight(context));
756
+ await writeText(ws.paths.designDirectionPlan, buildGenericDirectionPlan(starter, context));
757
+ await writeText(ws.paths.designSelectedDirection, buildGenericSelectedDirection(starter));
758
+ }
759
+
760
+ function replaceLiteralPlaceholders(templateText, replacements) {
761
+ let text = templateText;
762
+ for (const [placeholder, value] of Object.entries(replacements)) {
763
+ text = text.replaceAll(placeholder, value);
764
+ }
765
+ return text;
766
+ }
767
+
768
+ const CONTENT_HOME_GUIDE_RE = /(?:museum|gallery|exhibition|travel|traveler|guide|tour|destination|city|wildlife|birdwatch|species|forest|rainforest|wetland|reserve|heritage|馆藏|展览|博物馆|导览|旅行|旅游|城市|自然|观鸟|物种|雨林|湿地|保护区)/iu;
769
+ const CONTENT_HOME_STORY_RE = /(?:brand|story|narrative|case study|case|品牌故事|专题故事|案例|访谈)/iu;
770
+ const CONTENT_HOME_BLOG_RE = /(?:blog|newsletter|writing|article|博客|写作|专栏|作者|独立开发者)/iu;
771
+
772
+ function buildStarterContentHaystack(context) {
773
+ return [
774
+ context?.displayTitle,
775
+ context?.brief,
776
+ ...(Array.isArray(context?.sections) ? context.sections : []),
777
+ ]
778
+ .map((item) => String(item ?? '').trim())
779
+ .filter(Boolean)
780
+ .join(' ');
781
+ }
782
+
783
+ function findMatchingSection(sections, patterns, fallbackIndex = 0, options = {}) {
784
+ if (!Array.isArray(sections) || sections.length === 0) {
785
+ return null;
786
+ }
787
+ const list = options.fromEnd ? [...sections].reverse() : sections;
788
+ for (const pattern of patterns) {
789
+ const match = list.find((section) => pattern.test(String(section ?? '').trim()));
790
+ if (match) {
791
+ return match;
792
+ }
793
+ }
794
+ const safeIndex = Math.min(Math.max(fallbackIndex, 0), sections.length - 1);
795
+ return sections[safeIndex] ?? sections[0] ?? null;
796
+ }
797
+
798
+ function inferContentHomeEyebrow(context) {
799
+ const haystack = buildStarterContentHaystack(context);
800
+ if (CONTENT_HOME_GUIDE_RE.test(haystack)) {
801
+ return '专题导览首页';
802
+ }
803
+ if (CONTENT_HOME_STORY_RE.test(haystack)) {
804
+ return '故事型内容首页';
805
+ }
806
+ if (CONTENT_HOME_BLOG_RE.test(haystack)) {
807
+ return '内容型首页';
808
+ }
809
+ return '内容入口首页';
810
+ }
811
+
812
+ function buildSectionActionLabel(section, fallback = '继续查看') {
813
+ const label = String(section ?? '').trim();
814
+ if (!label || /^(下一步动作|最终动作|更多内容)$/.test(label)) {
815
+ return fallback;
816
+ }
817
+ if (/订阅/.test(label)) {
818
+ return '订阅后续更新';
819
+ }
820
+ if (/预约|报名/.test(label)) {
821
+ return `查看${label}`;
822
+ }
823
+ if (/门票/.test(label)) {
824
+ return '查看门票信息';
825
+ }
826
+ if (/^(查看|进入|浏览|探索|订阅|预约|报名|加入|了解|开始|前往)/.test(label)) {
827
+ return label;
828
+ }
829
+ if (/入口$/.test(label)) {
830
+ return `进入${label.replace(/入口$/, '') || fallback}`;
831
+ }
832
+ if (label.length <= 10) {
833
+ return `查看${label}`;
834
+ }
835
+ return fallback;
836
+ }
837
+
838
+ function buildContentHomeMediaLabel(section, suffix) {
839
+ const label = String(section ?? '').trim() || '主题';
840
+ return `${label}${suffix}`;
841
+ }
842
+
843
+ function hydrateContentHomeTemplate(templateText, context) {
844
+ const sections = [...context.sections];
845
+ while (sections.length < 5) {
846
+ sections.push(`内容模块 ${sections.length + 1}`);
847
+ }
848
+ const laterSections = sections.slice(1);
849
+ const guideLike = CONTENT_HOME_GUIDE_RE.test(buildStarterContentHaystack(context));
850
+ const primarySection = sections[0];
851
+ const browseSection = findMatchingSection(
852
+ laterSections,
853
+ [/(关键|主题|楼层|浏览|导览|栏目|分区|目录|馆藏|物种|案例|故事|文章)/iu],
854
+ 0,
855
+ ) ?? sections[1];
856
+ const preparationSection = findMatchingSection(
857
+ laterSections,
858
+ [/(装备|准备|提醒|预约|门票|时段|报名|清单|须知|到访)/iu],
859
+ Math.min(1, laterSections.length - 1),
860
+ ) ?? sections[Math.min(2, sections.length - 1)];
861
+ const timelineSection = findMatchingSection(
862
+ laterSections,
863
+ [/(路线|准备|预约|门票|时段|流程|报名|清单|到访)/iu],
864
+ Math.min(1, laterSections.length - 1),
865
+ ) ?? preparationSection;
866
+ const closingSection = findMatchingSection(
867
+ sections,
868
+ [/(订阅|预约|报名|门票|联系|加入|开始|入口|购买|下载)/iu],
869
+ sections.length - 1,
870
+ { fromEnd: true },
871
+ ) ?? sections[sections.length - 1];
872
+ const eyebrow = inferContentHomeEyebrow(context);
873
+ const leadLine = guideLike
874
+ ? `这页围绕${context.brief}展开:先把${primarySection}放到最前,再把${browseSection}、${preparationSection}和${closingSection}串成一条清楚的导览路径。`
875
+ : `这页围绕${context.brief}展开:先把${primarySection}放到首屏,再把${browseSection}、${preparationSection}和${closingSection}串成一条清楚的浏览路径。`;
876
+ const visualHintCopy = context.imageBundle?.succeeded
877
+ ? `这一块先用真实图片把主题钉住,再让文案解释从${primarySection}到${closingSection}的浏览顺序。`
878
+ : guideLike
879
+ ? '这一块要让人一眼看懂页面围绕什么地点、主题或观察对象展开,别退回通用 hero。'
880
+ : '这一块要先让人看懂页面主题和入口关系,不要只堆气氛。';
881
+ const text = replaceLiteralPlaceholders(templateText, {
882
+ '<title>内容型首页模板</title>': `<title>${escapeHtml(context.htmlTitle)}</title>`,
883
+ '内容型首页模板': eyebrow,
884
+ '[品牌名 / 首页主题]': escapeHtml(context.displayTitle),
885
+ '[这里放一句能说明这次首页体验目标的引导文案。它应该同时带出内容气质、用户第一步动作和页面的核心价值。]': escapeHtml(leadLine),
886
+ '[主动作]': buildSectionActionLabel(primarySection, '开始浏览'),
887
+ '[次动作]': buildSectionActionLabel(closingSection, '继续查看'),
888
+ '[关键事实]': `${sections.length} 个内容模块`,
889
+ '[事实说明或时间/价格/地点]': guideLike
890
+ ? '首屏先解决“先看什么、怎么准备、下一步去哪”这 3 个问题。'
891
+ : `从首屏到尾段都围绕${primarySection}组织,不让用户先被营销语气绊住。`,
892
+ '[模块入口]': primarySection,
893
+ '[说明该入口为什么重要]': `先从${primarySection}进入,再决定要不要继续看${browseSection}。`,
894
+ '[准备事项]': preparationSection,
895
+ '[到访提醒 / 条件 / 风险]': `把${preparationSection}提前说明,避免用户翻到后面才发现关键条件。`,
896
+ '[内容型封面区 / 示意图 / 地图 / 馆藏编号]': guideLike ? `${primarySection} / ${browseSection}` : `${primarySection} 内容导览`,
897
+ '[用纸张、纹理、编号、导览标签或卡片秩序承接气质]': guideLike
898
+ ? `用导览标签、地点线索和清楚的卡片层级,把${primarySection}与${browseSection}连起来。`
899
+ : `用标签、分组和稳定留白,把${primarySection}到${closingSection}的关系讲清楚。`,
900
+ '[这里不必是真实摄影,但要有清楚的内容暗示]': visualHintCopy,
901
+ '[避免把它做成通用 SaaS hero]': guideLike
902
+ ? '首屏重点是先认出主题与入口,不要套成通用 SaaS hero。'
903
+ : '首屏重点是先知道从哪进入,再决定是否继续深入。',
904
+ '[今日推荐 / 值得先看的内容]': primarySection,
905
+ '[这里解释这一组内容是按什么逻辑排序,帮助用户快速进入当前主题。]': escapeHtml(`${primarySection}排在最前,是为了让第一次进入的人先抓住这一页今天最重要的线索。`),
906
+ '[推荐项一]': primarySection,
907
+ '[推荐项二]': browseSection,
908
+ '[推荐项三]': preparationSection,
909
+ '[一句说明内容价值、位置或推荐理由。]': '每一张卡片都对应一个清楚入口,帮助用户从浏览快速过渡到下一步动作。',
910
+ '[楼层 / 栏目 / 导览分区]': browseSection,
911
+ '[用清楚的浏览结构告诉用户怎么从“看什么”过渡到“怎么去”。]': `用${browseSection}把首屏内容串起来,让用户知道接下来该往哪一组继续看。`,
912
+ '[路线 / 门票 / 到访准备]': timelineSection,
913
+ '[把行动入口放在内容之后,但别藏得太深。]': `把${timelineSection}放在中段,等用户理解主题后再自然进入下一步动作。`,
914
+ '[从浏览到出发的时间轴]': guideLike ? '从浏览到出发前确认' : '从进入首页到下一步动作',
915
+ '[适合路线规划、参观准备、报名、到访前后流程这类需要顺序感的内容。]': `把${primarySection}、${browseSection}和${closingSection}排成顺序,减少第一次进入时的犹豫。`,
916
+ '[步骤一]': `先看${primarySection},快速知道这页最值得先关注什么。`,
917
+ '[步骤二]': `再用${browseSection}和${timelineSection}补齐判断和准备信息。`,
918
+ '[步骤三]': `最后进入${closingSection},把这次访问接成真正的下一步动作。`,
919
+ '[最后一段转化区标题]': closingSection,
920
+ '[用一句人话把内容价值和下一步动作收拢起来。]': `如果这页刚好帮你把${primarySection}到${closingSection}的脉络看清了,就顺手进入${closingSection},把下一步接上。`,
921
+ '[确认动作]': buildSectionActionLabel(closingSection, '继续查看'),
922
+ });
923
+ return replaceLiteralPlaceholders(text, {
924
+ '[视觉锚点媒体]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 0), buildContentHomeMediaLabel(primarySection, '主视觉待补')),
925
+ '[推荐媒体一]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 0), buildContentHomeMediaLabel(primarySection, '配图待补')),
926
+ '[推荐媒体二]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 1), buildContentHomeMediaLabel(browseSection, '配图待补')),
927
+ '[推荐媒体三]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 2), buildContentHomeMediaLabel(preparationSection, '配图待补')),
928
+ });
929
+ }
930
+
931
+ function hydrateProductLaunchTemplate(templateText, context) {
932
+ const sections = [...context.sections];
933
+ while (sections.length < 4) {
934
+ sections.push(`重点模块 ${sections.length + 1}`);
935
+ }
936
+ const text = replaceLiteralPlaceholders(templateText, {
937
+ '<title>产品发布页模板</title>': `<title>${escapeHtml(context.htmlTitle)}</title>`,
938
+ '[产品名 / 发布主题]': escapeHtml(context.displayTitle),
939
+ '[用一句直接的话讲清楚这次发布带来的变化、面向的人群,以及为什么现在值得点进去。]': escapeHtml(`这页围绕${context.brief}展开,先讲清楚变化,再给出证据、范围和下一步动作。`),
940
+ '[主动作]': '查看重点变化',
941
+ '[次动作]': '了解上线范围',
942
+ '[版本 / 日期]': '当前版本',
943
+ '[把核实过的事实放这里]': context.noExternalFacts ? '当前页先以内建发布信息为准。' : '这里优先放已经核实过的发布事实。',
944
+ '[谁最该看这一页]': sections[2],
945
+ '[把发布信息、受众和动作收成一句清楚的人话。]': `先让${sections[2]}知道这次变化与自己相关,再决定是否继续深入。`,
946
+ '[核心收益]': sections[1],
947
+ '[一句可感知的提升]': '把这次变化说成用户能感知到的提升,而不是内部实现细节。',
948
+ '[最终动作]': sections[3],
949
+ '[一句说明它带来的变化]': '最后把上线信息、适用范围和下一步动作收成一个稳定出口。',
950
+ '[用真实产品图、关键界面,或至少清楚的产品结构示意,不要只放情绪渐变]': '优先放结构示意、数据、界面证据或范围说明。',
951
+ '[这次发布的 3 个重点]': sections[0],
952
+ '[适合把功能、能力、权益或体验升级拆成三个可扫描模块。]': '重点模块先可扫描,再展开细节。',
953
+ '[重点一]': `${sections[0]}之一`,
954
+ '[重点二]': `${sections[0]}之二`,
955
+ '[重点三]': `${sections[0]}之三`,
956
+ '[用证据讲,不只用形容词讲]': '证据要先于形容词。',
957
+ '[证据块一]': '范围与适用对象',
958
+ '[证据块二]': '交付形式与时间',
959
+ '[证据块三]': '风险与限制说明',
960
+ '[这里放规格、案例、截图、兼容范围、交付方式或上线信息。]': '把规格、范围、示例和上线方式放进这一段,避免用户只看到氛围看不到事实。',
961
+ '[收尾 CTA 标题]': sections[3],
962
+ '[适用对象]': sections[2],
963
+ '[数据 / 截图 / 规格 / 范围]': '范围、规格与证据',
964
+ });
965
+ return replaceLiteralPlaceholders(text, {
966
+ '[主视觉媒体]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 0), '主视觉图片待补'),
967
+ '[证据媒体一]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 0), '证据图片待补'),
968
+ '[证据媒体二]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 1), '证据图片待补'),
969
+ '[证据媒体三]': buildMediaMarkup(pickImageAsset(context.imageBundle?.assets, 2), '证据图片待补'),
970
+ });
971
+ }
972
+
973
+ function hydrateOpsDashboardTemplate(templateText, context) {
974
+ const sections = [...context.sections];
975
+ while (sections.length < 5) {
976
+ sections.push(`工作区块 ${sections.length + 1}`);
977
+ }
978
+ return replaceLiteralPlaceholders(templateText, {
979
+ '<title>运营工作台模板</title>': `<title>${escapeHtml(context.htmlTitle)}</title>`,
980
+ '[工作台 / 系统名]': escapeHtml(context.displayTitle),
981
+ '运营工作台模板': '内容发布工作台',
982
+ '[本页标题]': sections[0],
983
+ '[一句说明这页帮助用户每天更快做什么。]': escapeHtml(`这页围绕${context.brief}展开,把判断、对象和下一步动作放在同一视图里。`),
984
+ '[筛选 / 时间范围 / 当前状态]': '最近 7 天 / 全部状态 / 我负责的',
985
+ '[当前模块]': sections[0],
986
+ '[把判断和下一步放在同一视图里。]': '先让人知道要看哪一块,再决定下一步要点什么。',
987
+ '[总览标签一]': sections[2],
988
+ '[总览标签二]': '待发布负荷',
989
+ '[总览标签三]': sections[3],
990
+ '[总览标签四]': sections[4],
991
+ '[指标一]': '5 篇排期内',
992
+ '[指标二]': '2 篇卡在封面',
993
+ '[指标三]': '+38 净订阅',
994
+ '[指标四]': '3 个题目待拍板',
995
+ '[指标说明一]': '这周已排进日历的内容够不够支撑稳定更新,先在这里看清。',
996
+ '[指标说明二]': '如果“差一点就能发”的稿子堆太多,优先把它们推过终点。',
997
+ '[指标说明三]': '订阅变化不只是涨跌,更是判断最近内容方向有没有被接住。',
998
+ '[指标说明四]': '题目池保持轻量,避免同时开太多新坑。',
999
+ '[待处理动作]': sections[0],
1000
+ '[把用户最常扫的对象放在左边。]': '左边先放最高频需要扫的对象和队列。',
1001
+ '[主要列表 / 队列]': sections[1],
1002
+ '[对象一]': '《六月内容排期复盘》',
1003
+ '[对象二]': '《把周报做成可复用资产》',
1004
+ '[对象三]': '《本周产品随手记》',
1005
+ '[状态一]': '待补导语',
1006
+ '[状态二]': '待改标题',
1007
+ '[状态三]': '待二次复核',
1008
+ '[动作一标签]': '今晚 20:00 发布',
1009
+ '[动作二标签]': '明早交给自己复看',
1010
+ '[动作三标签]': '决定发还是归档',
1011
+ '[动作一]': '先推过最接近发布的两篇稿子',
1012
+ '[动作二]': '把本周指标和订阅变化一起看',
1013
+ '[动作三]': '再决定新题目要不要继续开',
1014
+ '[动作一说明]': '今天最值钱的动作不是写新东西,而是把差一点就能发的内容推过终点。',
1015
+ '[动作二说明]': '如果最近三篇内容都没有带来订阅响应,就先调方向,不要只补排期。',
1016
+ '[动作三说明]': '题目池如果已经过满,先砍掉不想写的,再决定要不要开新坑。',
1017
+ '[最近订阅变化]': sections[3],
1018
+ '[用近 7 天的订阅变化帮用户判断哪类内容值得继续发。]': '把最近净增、退订和来源线索放在一起看,帮助判断哪类内容更值得继续发。',
1019
+ '[变化项一]': '产品复盘类 +24',
1020
+ '[变化项二]': '工程方法类 +11',
1021
+ '[变化项三]': '日更碎片类 -6',
1022
+ '[变化说明一]': '净新增最高',
1023
+ '[变化说明二]': '打开率稳定',
1024
+ '[变化说明三]': '退订主要来源',
1025
+ '[变化备注一]': '说明“讲清怎么做判断”的内容,最近比“展示忙碌”更能留住人。',
1026
+ '[变化备注二]': '这类内容虽然增长慢一点,但回访读者更稳。',
1027
+ '[变化备注三]': '如果只是记录日常,而没有明确判断,订阅关系会更容易掉。',
1028
+ '[选题状态]': sections[4],
1029
+ '[把待写、待发和待放弃的题目放到同一块,避免开新坑太轻松。]': '把待写、待发和待放弃放在同一块,避免题目池越来越大但真正发出去的越来越少。',
1030
+ '[选题一]': 'AI 协作复盘: 继续写',
1031
+ '[选题二]': '播客摘要工具: 先暂停',
1032
+ '[选题三]': '六月发布节奏: 本周必须发',
1033
+ '[选题一说明]': '已有结构和例子,下一个动作是补一段最难讲清的判断过程。',
1034
+ '[选题二说明]': '方向不差,但暂时还没有足够强的个人经验,不急着占排期。',
1035
+ '[选题三说明]': '这是最适合承接当前订阅趋势的题目,不应该再往后拖。',
1036
+ '[模块二]': sections[1],
1037
+ '[模块三]': sections[3],
1038
+ '[模块四]': sections[4],
1039
+ });
1040
+ }
1041
+
1042
+ function hydrateStarterTemplate(templateText, starter, context) {
1043
+ if (starter.id === 'content-home') {
1044
+ return hydrateContentHomeTemplate(templateText, context);
1045
+ }
1046
+ if (starter.id === 'product-launch') {
1047
+ return hydrateProductLaunchTemplate(templateText, context);
1048
+ }
1049
+ if (starter.id === 'ops-dashboard') {
1050
+ return hydrateOpsDashboardTemplate(templateText, context);
1051
+ }
1052
+ return templateText;
1053
+ }
1054
+
1055
+ export async function designStarterWorkspace(projectRoot, options = {}) {
1056
+ const ws = await loadWorkspace(projectRoot);
1057
+ const starterKey = String(options.starter ?? 'content-home').trim();
1058
+ const starter = STARTER_LOOKUP.get(starterKey);
1059
+
1060
+ if (!starter) {
1061
+ return {
1062
+ ok: false,
1063
+ action: 'design-starter',
1064
+ projectRoot,
1065
+ requestedStarter: starterKey,
1066
+ availableStarters: buildAvailableStarters(),
1067
+ errors: [
1068
+ `Unknown starter: ${starterKey}. Available starters: ${STARTER_CATALOG.map((item) => item.id).join(', ')}.`,
1069
+ ],
1070
+ };
1071
+ }
1072
+
1073
+ const templatePath = path.join(ws.paths.designTemplatesRoot, starter.file);
1074
+ if (!(await exists(templatePath))) {
1075
+ return {
1076
+ ok: false,
1077
+ action: 'design-starter',
1078
+ projectRoot,
1079
+ starterId: starter.id,
1080
+ templatePath,
1081
+ availableStarters: buildAvailableStarters(),
1082
+ errors: [
1083
+ `Missing starter template: ${templatePath}. Run openprd update . first.`,
1084
+ ],
1085
+ };
1086
+ }
1087
+
1088
+ const outputTarget = String(options.out ?? 'index.html').trim() || 'index.html';
1089
+ const outputPath = path.resolve(projectRoot, outputTarget);
1090
+ const relativeOutputPath = normalizePathForDisplay(path.relative(projectRoot, outputPath) || path.basename(outputPath));
1091
+ const alreadyExists = await exists(outputPath);
1092
+
1093
+ if (alreadyExists && !options.force) {
1094
+ return {
1095
+ ok: false,
1096
+ action: 'design-starter',
1097
+ projectRoot,
1098
+ starterId: starter.id,
1099
+ templatePath,
1100
+ outputPath,
1101
+ relativeOutputPath,
1102
+ availableStarters: buildAvailableStarters(),
1103
+ errors: [
1104
+ `Output already exists: ${relativeOutputPath}. Use --force to overwrite.`,
1105
+ ],
1106
+ };
1107
+ }
1108
+
1109
+ const rawTemplateText = await readText(templatePath);
1110
+ const defaults = parseStarterDefaults(rawTemplateText);
1111
+ const hydrated = shouldHydrateStarter(options);
1112
+ const context = hydrated ? buildStarterContext(starter, defaults, options) : null;
1113
+ if (context?.noRealImages && isRealImageDependentStarterContext(starter, context)) {
1114
+ return {
1115
+ ok: false,
1116
+ action: 'design-starter',
1117
+ projectRoot,
1118
+ starterId: starter.id,
1119
+ templatePath,
1120
+ outputPath,
1121
+ relativeOutputPath,
1122
+ availableStarters: buildAvailableStarters(),
1123
+ errors: [
1124
+ '当前 brief 更像导览、展览、旅行、自然观察或馆藏内容页,不建议带 `--no-real-images`。请去掉这个参数,让 starter 先尝试补首批真实图片;如果你确实只想做纯文字骨架,先在 `image-preflight.md` 明确“真实图片不是页面成立前提”和降级方案后再重试。',
1125
+ ],
1126
+ };
1127
+ }
1128
+ if (context) {
1129
+ context.themeConfig = await loadThemeConfig(ws.paths.designThemes, context.defaults.theme);
1130
+ context.imageBundle = await sourceStarterImages({
1131
+ projectRoot,
1132
+ outputPath,
1133
+ starter,
1134
+ context,
1135
+ });
1136
+ }
1137
+ const hydratedTemplateText = context ? hydrateStarterTemplate(rawTemplateText, starter, context) : rawTemplateText;
1138
+ const themeLockStyle = context
1139
+ ? buildThemeLockStyle({
1140
+ theme: context.themeConfig,
1141
+ projectRoot,
1142
+ outputPath,
1143
+ })
1144
+ : '';
1145
+ const finalTemplateText = context
1146
+ ? injectThemeLockStyle(`${buildHtmlFileManual(starter, context, relativeOutputPath)}\n${hydratedTemplateText}`, themeLockStyle)
1147
+ : rawTemplateText;
1148
+
1149
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
1150
+ await writeText(outputPath, finalTemplateText);
1151
+ let basicsResult = null;
1152
+ if (context) {
1153
+ await writeActiveDesignArtifacts(ws, starter, context);
1154
+ basicsResult = await hydrateWorkspaceBasics(projectRoot, starter, context, relativeOutputPath);
1155
+ }
1156
+
1157
+ await appendWorkflowEvent(ws, 'design_starter_created', {
1158
+ starterId: starter.id,
1159
+ template: normalizePathForDisplay(path.relative(projectRoot, templatePath)),
1160
+ output: relativeOutputPath,
1161
+ defaults,
1162
+ overwritten: alreadyExists,
1163
+ hydrated,
1164
+ brief: context?.brief ?? null,
1165
+ sections: context?.sections ?? null,
1166
+ docsUpdated: basicsResult?.updatedDocs ?? [],
1167
+ fetchedImageCount: context?.imageBundle?.assets?.length ?? 0,
1168
+ imageQuery: context?.imageBundle?.query ?? null,
1169
+ });
1170
+ await appendProgress(ws, [
1171
+ `已从 ${starter.file} 创建第一版页面骨架。`,
1172
+ `输出: ${relativeOutputPath}。`,
1173
+ hydrated ? '已同步写实 active design contracts 并替换模板占位文案。' : null,
1174
+ context?.imageBundle?.succeeded ? `已自动补齐 ${context.imageBundle.assets.length} 张真实图片。` : null,
1175
+ basicsResult?.updatedDocs?.length ? '已同步补齐 docs/basic、文件说明书和根目录 README。' : null,
1176
+ ].filter(Boolean));
1177
+
1178
+ return {
1179
+ ok: true,
1180
+ action: 'design-starter',
1181
+ projectRoot,
1182
+ starterId: starter.id,
1183
+ starterFile: starter.file,
1184
+ description: starter.description,
1185
+ templatePath,
1186
+ relativeTemplatePath: normalizePathForDisplay(path.relative(projectRoot, templatePath)),
1187
+ outputPath,
1188
+ relativeOutputPath,
1189
+ overwritten: alreadyExists,
1190
+ defaults,
1191
+ hydrated,
1192
+ activeArtifactsFilled: hydrated,
1193
+ brief: context?.brief ?? null,
1194
+ sections: context?.sections ?? null,
1195
+ title: context?.displayTitle ?? null,
1196
+ sourceManualFilled: hydrated,
1197
+ docsHydrated: basicsResult?.updatedDocs ?? [],
1198
+ folderManualCreated: basicsResult?.folderManualCreated ?? false,
1199
+ folderManualPath: basicsResult?.folderManualPath ?? null,
1200
+ imageQuery: context?.imageBundle?.query ?? null,
1201
+ fetchedImageCount: context?.imageBundle?.assets?.length ?? 0,
1202
+ fetchedImageManifestPath: context?.imageBundle?.relativeManifestPath ?? null,
1203
+ imageSourceReason: context?.imageBundle?.reason ?? null,
1204
+ };
1205
+ }
1206
+
1207
+ export { STARTER_CATALOG };