@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,1047 @@
1
+ import { timestamp } from './time.js';
2
+
3
+ function escapeHtml(value) {
4
+ return `${value ?? ''}`
5
+ .replace(/&/g, '&')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&#39;');
10
+ }
11
+
12
+ function trimText(value, max = 96) {
13
+ const text = `${value ?? ''}`.trim();
14
+ if (!text) return '待补充';
15
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
16
+ }
17
+
18
+ function mermaidId(value, fallback = 'node') {
19
+ const text = `${value ?? ''}`.trim();
20
+ const normalized = text
21
+ .replace(/[^a-zA-Z0-9_]/g, '_')
22
+ .replace(/^_+|_+$/g, '')
23
+ .replace(/_+/g, '_');
24
+ const id = normalized || fallback;
25
+ return /^[a-zA-Z_]/.test(id) ? id : `${fallback}_${id}`;
26
+ }
27
+
28
+ function mermaidText(value, max = 64) {
29
+ return trimText(value, max)
30
+ .replace(/["`]/g, "'")
31
+ .replace(/[|<>]/g, ' ')
32
+ .replace(/\r?\n/g, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim();
35
+ }
36
+
37
+ function mermaidNodeLabel(primary, secondary) {
38
+ const title = mermaidText(primary, 34);
39
+ const subtitle = mermaidText(secondary, 64);
40
+ return subtitle && subtitle !== '待补充' ? `${title}<br/>${subtitle}` : title;
41
+ }
42
+
43
+ function mermaidNodeDeclaration(id, label, type) {
44
+ if (type === 'decision') {
45
+ return ` ${id}{"${label}"}`;
46
+ }
47
+ if (type === 'success') {
48
+ return ` ${id}(["${label}"])`;
49
+ }
50
+ if (type === 'error_path') {
51
+ return ` ${id}[["${label}"]]`;
52
+ }
53
+ return ` ${id}["${label}"]`;
54
+ }
55
+
56
+ function mermaidEdge(source, target, label, type = 'standard') {
57
+ const cleanLabel = mermaidText(label, 42);
58
+ const arrow = type === 'security' || type === 'error_path' ? '-.->' : '-->';
59
+ return cleanLabel && cleanLabel !== '待补充'
60
+ ? ` ${source} ${arrow}|"${cleanLabel}"| ${target}`
61
+ : ` ${source} ${arrow} ${target}`;
62
+ }
63
+
64
+ function normalizeList(value, fallback = []) {
65
+ if (Array.isArray(value)) {
66
+ const items = value.map((item) => `${item ?? ''}`.trim()).filter(Boolean);
67
+ return items.length > 0 ? items : fallback;
68
+ }
69
+ const text = `${value ?? ''}`.trim();
70
+ return text ? [text] : fallback;
71
+ }
72
+
73
+ function takeList(value, count, fallback = []) {
74
+ return normalizeList(value, fallback).slice(0, count);
75
+ }
76
+
77
+ function joinList(value, fallback = '待补充', separator = ' · ') {
78
+ const items = normalizeList(value);
79
+ return items.length > 0 ? items.join(separator) : fallback;
80
+ }
81
+
82
+ function pickValue(primary, fallback) {
83
+ if (primary === null || primary === undefined) return fallback;
84
+ if (typeof primary === 'string' && primary.trim() === '') return fallback;
85
+ if (Array.isArray(primary) && primary.length === 0) return fallback;
86
+ return primary;
87
+ }
88
+
89
+ function theme(type) {
90
+ const themes = {
91
+ frontend: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
92
+ backend: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
93
+ database: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
94
+ cloud: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
95
+ security: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
96
+ external: { fill: '#0f172a', stroke: '#94a3b8', title: '#e2e8f0' },
97
+ user_action: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
98
+ system_process: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
99
+ decision: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
100
+ error_path: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
101
+ success: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
102
+ };
103
+ return themes[type] ?? themes.external;
104
+ }
105
+
106
+ function normalizeCard(card, fallbackTitle = '摘要', fallbackColor = 'external') {
107
+ return {
108
+ title: pickValue(card?.title, fallbackTitle),
109
+ color: pickValue(card?.color, fallbackColor),
110
+ items: normalizeList(card?.items, ['待补充']),
111
+ };
112
+ }
113
+
114
+ function normalizePanel(panel, fallbackTitle = '评审备注', fallbackColor = 'external') {
115
+ return {
116
+ title: pickValue(panel?.title, fallbackTitle),
117
+ color: pickValue(panel?.color, fallbackColor),
118
+ items: normalizeList(panel?.items, ['待补充']),
119
+ };
120
+ }
121
+
122
+ function normalizeLocale(contract) {
123
+ return pickValue(contract?.locale, contract?.lang ?? 'zh-CN');
124
+ }
125
+
126
+ function normalizeReviewStatus(value) {
127
+ return pickValue(value, 'pending-confirmation');
128
+ }
129
+
130
+ function hasCjk(text) {
131
+ return /[\u3400-\u9fff]/.test(text);
132
+ }
133
+
134
+ function englishWords(text) {
135
+ return `${text ?? ''}`.match(/[A-Za-z][A-Za-z0-9+_.-]*/g) ?? [];
136
+ }
137
+
138
+ function isEnglishHeavyText(text) {
139
+ const value = `${text ?? ''}`.trim();
140
+ if (!value || hasCjk(value)) return false;
141
+ const words = englishWords(value);
142
+ return words.length >= 4;
143
+ }
144
+
145
+ function collectDiagramTexts(model) {
146
+ const entries = [];
147
+ const push = (path, value) => {
148
+ if (typeof value === 'string' && value.trim()) {
149
+ entries.push({ path, value: value.trim() });
150
+ }
151
+ };
152
+ push('title', model.title);
153
+ push('subtitle', model.subtitle);
154
+ push('metadata.projectName', model.metadata?.projectName);
155
+ for (const [index, component] of (model.components ?? []).entries()) {
156
+ push(`components.${index}.name`, component.name);
157
+ push(`components.${index}.subtitle`, component.subtitle);
158
+ for (const [detailIndex, detail] of (component.details ?? []).entries()) {
159
+ push(`components.${index}.details.${detailIndex}`, detail);
160
+ }
161
+ }
162
+ for (const [index, flow] of (model.flows ?? []).entries()) {
163
+ push(`flows.${index}.label`, flow.label);
164
+ }
165
+ for (const [index, card] of (model.summaryCards ?? []).entries()) {
166
+ push(`summaryCards.${index}.title`, card.title);
167
+ for (const [itemIndex, item] of (card.items ?? []).entries()) {
168
+ push(`summaryCards.${index}.items.${itemIndex}`, item);
169
+ }
170
+ }
171
+ for (const [index, panel] of (model.sidePanels ?? []).entries()) {
172
+ push(`sidePanels.${index}.title`, panel.title);
173
+ for (const [itemIndex, item] of (panel.items ?? []).entries()) {
174
+ push(`sidePanels.${index}.items.${itemIndex}`, item);
175
+ }
176
+ }
177
+ for (const [index, instruction] of (model.reviewInstructions ?? []).entries()) {
178
+ push(`reviewInstructions.${index}`, instruction);
179
+ }
180
+ return entries;
181
+ }
182
+
183
+ export function validateDiagramLanguage(model) {
184
+ const locale = `${model?.locale ?? 'zh-CN'}`.toLowerCase();
185
+ if (!locale.startsWith('zh')) {
186
+ return { valid: true, errors: [] };
187
+ }
188
+ const offenders = collectDiagramTexts(model)
189
+ .filter((entry) => isEnglishHeavyText(entry.value))
190
+ .slice(0, 12);
191
+ return {
192
+ valid: offenders.length === 0,
193
+ errors: offenders.map((entry) => (
194
+ `${entry.path} 应使用简体中文表达,当前内容偏英文: ${trimText(entry.value, 96)}`
195
+ )),
196
+ };
197
+ }
198
+
199
+ function getAtPath(root, path) {
200
+ return path.split('.').reduce((acc, key) => (acc === null || acc === undefined ? undefined : acc[key]), root);
201
+ }
202
+
203
+ function hasValue(value) {
204
+ if (value === null || value === undefined) return false;
205
+ if (typeof value === 'string') return value.trim() !== '';
206
+ if (Array.isArray(value)) return value.length > 0;
207
+ if (typeof value === 'object') return Object.keys(value).length > 0;
208
+ return true;
209
+ }
210
+
211
+ function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup, summaryCards, sidePanels, footer }) {
212
+ const cards = summaryCards.map((card) => {
213
+ const cardTheme = theme(card.color);
214
+ const items = (card.items ?? []).map((item) => `<li>${escapeHtml(trimText(item, 132))}</li>`).join('');
215
+ return `
216
+ <div class="card">
217
+ <div class="card-header">
218
+ <span class="dot" style="background:${cardTheme.stroke}"></span>
219
+ <span>${escapeHtml(card.title)}</span>
220
+ </div>
221
+ <ul>${items}</ul>
222
+ </div>
223
+ `;
224
+ }).join('\n');
225
+
226
+ const panels = sidePanels.map((panel) => {
227
+ const panelTheme = theme(panel.color);
228
+ const items = (panel.items ?? []).map((item) => `<li>${escapeHtml(trimText(item, 120))}</li>`).join('');
229
+ return `
230
+ <div class="card">
231
+ <div class="card-header">
232
+ <span class="dot" style="background:${panelTheme.stroke}"></span>
233
+ <span>${escapeHtml(panel.title)}</span>
234
+ </div>
235
+ <ul>${items}</ul>
236
+ </div>
237
+ `;
238
+ }).join('\n');
239
+
240
+ return `<!DOCTYPE html>
241
+ <html lang="${escapeHtml(lang)}">
242
+ <head>
243
+ <meta charset="UTF-8" />
244
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
245
+ <title>${escapeHtml(title)}</title>
246
+ <style>
247
+ :root {
248
+ color-scheme: dark;
249
+ --bg: #020617;
250
+ --panel: rgba(15, 23, 42, 0.92);
251
+ --text: #e2e8f0;
252
+ --muted: #94a3b8;
253
+ --grid: rgba(148, 163, 184, 0.12);
254
+ }
255
+ * { box-sizing: border-box; }
256
+ body {
257
+ margin: 0;
258
+ font-family: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
259
+ color: var(--text);
260
+ background:
261
+ linear-gradient(var(--grid) 1px, transparent 1px),
262
+ linear-gradient(90deg, var(--grid) 1px, transparent 1px),
263
+ radial-gradient(circle at top, rgba(34, 211, 238, 0.12), transparent 30%),
264
+ var(--bg);
265
+ background-size: 40px 40px, 40px 40px, 100% 100%, auto;
266
+ }
267
+ .page { max-width: 1240px; margin: 0 auto; padding: 32px 24px 48px; }
268
+ .header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
269
+ .header-copy { display: flex; flex-direction: column; gap: 4px; }
270
+ .pulse {
271
+ width: 12px; height: 12px; border-radius: 999px; background: #22d3ee;
272
+ box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); animation: pulse 2s infinite;
273
+ }
274
+ @keyframes pulse {
275
+ 0% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); }
276
+ 70% { box-shadow: 0 0 0 10px rgba(34, 211, 238, 0); }
277
+ 100% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0); }
278
+ }
279
+ .project-chip {
280
+ display: inline-flex;
281
+ align-items: center;
282
+ gap: 8px;
283
+ width: fit-content;
284
+ padding: 4px 10px;
285
+ border-radius: 999px;
286
+ border: 1px solid rgba(148, 163, 184, 0.24);
287
+ background: rgba(15, 23, 42, 0.72);
288
+ color: #cbd5e1;
289
+ font-size: 11px;
290
+ }
291
+ h1 { margin: 0; font-size: 28px; }
292
+ .subtitle-block { margin: 6px 0 0 24px; color: var(--muted); font-size: 13px; }
293
+ .diagram-shell {
294
+ margin-top: 24px; border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 20px;
295
+ padding: 20px; background: rgba(2, 6, 23, 0.72); backdrop-filter: blur(6px);
296
+ }
297
+ svg { width: 100%; height: auto; display: block; }
298
+ .node-title { font-size: 13px; font-weight: 700; }
299
+ .node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #cbd5e1; }
300
+ .detail { fill: #94a3b8; }
301
+ .summary-grid, .side-grid { display: grid; gap: 16px; margin-top: 24px; }
302
+ .summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
303
+ .side-grid { grid-template-columns: 1fr 1fr; }
304
+ .card {
305
+ border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 16px;
306
+ background: var(--panel); padding: 14px 16px;
307
+ }
308
+ .card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; }
309
+ .dot { width: 10px; height: 10px; border-radius: 999px; }
310
+ ul { padding-left: 18px; margin: 0; color: #cbd5e1; font-size: 12px; line-height: 1.65; }
311
+ .footer { margin-top: 18px; color: var(--muted); }
312
+ </style>
313
+ </head>
314
+ <body>
315
+ <div class="page">
316
+ <div class="header">
317
+ <div class="pulse"></div>
318
+ <div class="header-copy">
319
+ <div class="project-chip">${escapeHtml(projectName ?? title)}</div>
320
+ <h1>${escapeHtml(title)}</h1>
321
+ <p class="subtitle-block">${escapeHtml(subtitle)}</p>
322
+ </div>
323
+ </div>
324
+ <div class="diagram-shell">${svgMarkup}</div>
325
+ <div class="summary-grid">${cards}</div>
326
+ <div class="side-grid">${panels}</div>
327
+ <div class="footer">${escapeHtml(footer)}</div>
328
+ </div>
329
+ </body>
330
+ </html>`;
331
+ }
332
+
333
+ function renderBox(node, layout) {
334
+ const nodeTheme = theme(node.type);
335
+ const detailLines = (node.details ?? []).slice(0, 4);
336
+ const detailMarkup = detailLines.map((line, index) => (
337
+ `<text x="${layout.x + 16}" y="${layout.y + 54 + (index * 16)}" class="detail">${escapeHtml(trimText(line, 42))}</text>`
338
+ )).join('');
339
+
340
+ return `
341
+ <g>
342
+ <rect x="${layout.x}" y="${layout.y}" width="${layout.width}" height="${layout.height}" rx="14" fill="${nodeTheme.fill}" fill-opacity="0.92" stroke="${nodeTheme.stroke}" stroke-width="1.5"></rect>
343
+ <text x="${layout.x + 16}" y="${layout.y + 28}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(node.name)}</text>
344
+ <text x="${layout.x + 16}" y="${layout.y + 44}" class="node-subtitle">${escapeHtml(trimText(node.subtitle, 48))}</text>
345
+ ${detailMarkup}
346
+ </g>
347
+ `;
348
+ }
349
+
350
+ function renderArrow(def) {
351
+ const dashed = def.type === 'security' || def.type === 'error_path' ? 'stroke-dasharray="6,4"' : '';
352
+ const stroke = def.type === 'security' || def.type === 'error_path' ? '#fb7185' : '#7dd3fc';
353
+ return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="2" ${dashed} marker-end="url(#arrowhead)"></path>
354
+ <text x="${def.labelX}" y="${def.labelY}" class="flow-label">${escapeHtml(def.label)}</text>`;
355
+ }
356
+
357
+ function resolveProductLayerTitle(productType) {
358
+ if (productType === 'consumer') return '消费端体验层';
359
+ if (productType === 'b2b') return 'B2B 工作流层';
360
+ if (productType === 'agent') return 'Agent 运行层';
361
+ return '产品体验层';
362
+ }
363
+
364
+ function buildArchitectureComponents(snapshot) {
365
+ const { sections } = snapshot;
366
+ const reviewTarget = sections.handoff.targetSystem ?? 'OpenSpec';
367
+ return [
368
+ {
369
+ id: 'users',
370
+ name: '主要用户',
371
+ type: 'external',
372
+ subtitle: joinList(sections.users.primaryUsers, '用户'),
373
+ details: takeList(sections.users.stakeholders, 3, ['相关方需要确认']),
374
+ },
375
+ {
376
+ id: 'experience',
377
+ name: resolveProductLayerTitle(snapshot.productType),
378
+ type: 'frontend',
379
+ subtitle: trimText(joinList(sections.scenarios.primaryFlows, sections.meta.title)),
380
+ details: takeList(sections.scope.inScope, 3, ['范围仍需细化']),
381
+ },
382
+ {
383
+ id: 'core',
384
+ name: '核心产品逻辑',
385
+ type: 'backend',
386
+ subtitle: trimText(sections.problem.problemStatement ?? '核心逻辑待澄清'),
387
+ details: takeList(sections.requirements.functional, 3, ['功能需求待补充']),
388
+ },
389
+ {
390
+ id: 'integrations',
391
+ name: '依赖与集成',
392
+ type: 'cloud',
393
+ subtitle: trimText(joinList(sections.constraints.dependencies, '暂无外部依赖记录')),
394
+ details: takeList(sections.constraints.dependencies, 4, ['依赖尚未确认']),
395
+ },
396
+ {
397
+ id: 'governance',
398
+ name: '约束与可靠性',
399
+ type: 'security',
400
+ subtitle: trimText(joinList(sections.constraints.compliance, joinList(sections.requirements.nonFunctional, '暂无明确约束'))),
401
+ details: [
402
+ ...takeList(sections.constraints.compliance, 2),
403
+ ...takeList(sections.requirements.nonFunctional, 2),
404
+ ].slice(0, 4),
405
+ },
406
+ {
407
+ id: 'delivery',
408
+ name: '验证与交接',
409
+ type: 'database',
410
+ subtitle: trimText(joinList(sections.goals.successMetrics, '成功指标待确认')),
411
+ details: [
412
+ `目标: ${reviewTarget}`,
413
+ `下一步: ${trimText(sections.handoff.nextStep ?? '确认下一步', 48)}`,
414
+ ...takeList(sections.goals.acceptanceGoals, 2),
415
+ ].slice(0, 4),
416
+ },
417
+ ];
418
+ }
419
+
420
+ export function buildArchitectureDiagramModel(snapshot) {
421
+ const scopeIn = takeList(snapshot.sections.scope.inScope, 3, ['范围待澄清']);
422
+ const scopeOut = takeList(snapshot.sections.scope.outOfScope, 2, ['范围外内容尚未明确']);
423
+ const assumptions = takeList(snapshot.sections.risks.assumptions, 4, ['假设仍需评审']);
424
+ const openQuestions = takeList(snapshot.sections.risks.openQuestions, 4, ['暂无开放问题记录']);
425
+ const primaryFlows = takeList(snapshot.sections.scenarios.primaryFlows, 3, ['主流程仍需确认']);
426
+
427
+ return {
428
+ type: 'architecture',
429
+ version: 1,
430
+ generatedAt: timestamp(),
431
+ locale: 'zh-CN',
432
+ title: '架构评审',
433
+ subtitle: '在需求定稿前评审系统边界、依赖和交接形态。',
434
+ components: buildArchitectureComponents(snapshot),
435
+ flows: [
436
+ { source: 'users', target: 'experience', label: trimText(primaryFlows[0] ?? '用户进入产品流程', 40), type: 'standard' },
437
+ { source: 'experience', target: 'core', label: '产品动作与编排', type: 'standard' },
438
+ { source: 'core', target: 'integrations', label: '依赖与外部服务', type: 'standard' },
439
+ { source: 'core', target: 'governance', label: '策略、可靠性与合规', type: 'security' },
440
+ { source: 'core', target: 'delivery', label: '成功标准与交接', type: 'standard' },
441
+ { source: 'integrations', target: 'delivery', label: '运营就绪', type: 'standard' },
442
+ { source: 'governance', target: 'delivery', label: '评审与确认', type: 'security' },
443
+ ],
444
+ summaryCards: [
445
+ {
446
+ title: '范围',
447
+ color: 'frontend',
448
+ items: [
449
+ `范围内: ${scopeIn.join(' / ')}`,
450
+ `范围外: ${scopeOut.join(' / ')}`,
451
+ `主流程: ${primaryFlows.join(' / ')}`,
452
+ ],
453
+ },
454
+ {
455
+ title: '架构检查',
456
+ color: 'backend',
457
+ items: [
458
+ `核心逻辑: ${takeList(snapshot.sections.requirements.functional, 2, ['功能需求待补充']).join(' / ')}`,
459
+ `依赖: ${takeList(snapshot.sections.constraints.dependencies, 2, ['依赖待补充']).join(' / ')}`,
460
+ `约束: ${takeList(snapshot.sections.constraints.compliance, 2, takeList(snapshot.sections.requirements.nonFunctional, 2, ['约束待补充'])).join(' / ')}`,
461
+ ],
462
+ },
463
+ {
464
+ title: '评审重点',
465
+ color: 'cloud',
466
+ items: [
467
+ `确认缺失假设: ${assumptions.join(' / ')}`,
468
+ `开放问题: ${openQuestions.join(' / ')}`,
469
+ '在需求定稿前请用户确认模块、边界和缺失系统。',
470
+ ],
471
+ },
472
+ ],
473
+ sidePanels: [
474
+ { title: '假设', color: 'database', items: assumptions },
475
+ {
476
+ title: '评审说明',
477
+ color: 'cloud',
478
+ items: [
479
+ '确认这些模块是否反映澄清后的目标架构。',
480
+ '标记缺失系统、边界或外部依赖。',
481
+ '在需求定稿前验证可靠性、合规和交接预期。',
482
+ ],
483
+ },
484
+ ],
485
+ metadata: {
486
+ projectName: snapshot.title,
487
+ productType: snapshot.productType ?? '未分类',
488
+ owner: snapshot.owner,
489
+ versionId: snapshot.versionId,
490
+ targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenSpec',
491
+ reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
492
+ },
493
+ };
494
+ }
495
+
496
+ export function renderArchitectureDiagramHtml(model) {
497
+ const layouts = {
498
+ users: { x: 390, y: 48, width: 300, height: 96 },
499
+ experience: { x: 70, y: 228, width: 290, height: 120 },
500
+ core: { x: 395, y: 228, width: 290, height: 120 },
501
+ delivery: { x: 720, y: 228, width: 290, height: 120 },
502
+ integrations: { x: 180, y: 448, width: 290, height: 120 },
503
+ governance: { x: 610, y: 448, width: 290, height: 120 },
504
+ };
505
+ const fallbackLayouts = [
506
+ { x: 390, y: 48, width: 300, height: 96 },
507
+ { x: 70, y: 228, width: 290, height: 120 },
508
+ { x: 395, y: 228, width: 290, height: 120 },
509
+ { x: 720, y: 228, width: 290, height: 120 },
510
+ { x: 180, y: 448, width: 290, height: 120 },
511
+ { x: 610, y: 448, width: 290, height: 120 },
512
+ ];
513
+
514
+ const arrows = [
515
+ { path: 'M 540 144 C 540 182, 215 176, 215 228', label: model.flows[0]?.label ?? '用户流程', labelX: 312, labelY: 176, type: model.flows[0]?.type ?? 'standard' },
516
+ { path: 'M 360 288 L 395 288', label: model.flows[1]?.label ?? '产品动作', labelX: 366, labelY: 276, type: model.flows[1]?.type ?? 'standard' },
517
+ { path: 'M 685 288 L 720 288', label: model.flows[4]?.label ?? '成功标准', labelX: 694, labelY: 276, type: model.flows[4]?.type ?? 'standard' },
518
+ { path: 'M 540 348 C 540 392, 325 396, 325 448', label: model.flows[2]?.label ?? '依赖', labelX: 300, labelY: 392, type: model.flows[2]?.type ?? 'standard' },
519
+ { path: 'M 540 348 C 540 392, 755 396, 755 448', label: model.flows[3]?.label ?? '约束', labelX: 692, labelY: 392, type: model.flows[3]?.type ?? 'security' },
520
+ { path: 'M 470 568 C 470 610, 820 610, 820 348', label: model.flows[5]?.label ?? '运营就绪', labelX: 596, labelY: 614, type: model.flows[5]?.type ?? 'standard' },
521
+ { path: 'M 820 568 C 920 612, 920 416, 865 348', label: model.flows[6]?.label ?? '评审确认', labelX: 850, labelY: 612, type: model.flows[6]?.type ?? 'security' },
522
+ ];
523
+
524
+ const componentMarkup = model.components
525
+ .map((component, index) => renderBox(component, layouts[component.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
526
+ .join('\n');
527
+ const arrowMarkup = arrows.map(renderArrow).join('\n');
528
+ const svgMarkup = `
529
+ <svg viewBox="0 0 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
530
+ <defs>
531
+ <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
532
+ <polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
533
+ </marker>
534
+ </defs>
535
+ <rect x="40" y="172" width="1000" height="430" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="8,5"></rect>
536
+ <text x="58" y="194" class="legend-label">方案边界</text>
537
+ ${arrowMarkup}
538
+ ${componentMarkup}
539
+ <g>
540
+ <text x="54" y="652" class="legend-label">图例</text>
541
+ <rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">体验</text>
542
+ <rect x="182" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="202" y="676" class="legend-label">核心逻辑</text>
543
+ <rect x="330" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="350" y="676" class="legend-label">验证</text>
544
+ <rect x="476" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="496" y="676" class="legend-label">依赖</text>
545
+ <rect x="640" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="660" y="676" class="legend-label">约束</text>
546
+ <rect x="798" y="666" width="12" height="12" rx="3" fill="#94a3b8"></rect><text x="818" y="676" class="legend-label">外部/用户</text>
547
+ </g>
548
+ </svg>
549
+ `;
550
+
551
+ return renderShell({
552
+ lang: model.locale ?? 'zh-CN',
553
+ title: model.title,
554
+ subtitle: model.subtitle,
555
+ projectName: model.metadata?.projectName ?? model.title,
556
+ svgMarkup,
557
+ summaryCards: model.summaryCards,
558
+ sidePanels: model.sidePanels,
559
+ footer: `负责人: ${model.metadata.owner} · 版本: ${model.metadata.versionId} · 目标: ${model.metadata.targetSystem} · 生成时间: ${model.generatedAt}`,
560
+ });
561
+ }
562
+
563
+ export function buildProductFlowDiagramModel(snapshot) {
564
+ const primaryUsers = takeList(snapshot.sections.users.primaryUsers, 2, ['主要用户']);
565
+ const primaryFlows = takeList(snapshot.sections.scenarios.primaryFlows, 4, ['主流程仍需确认']);
566
+ const edgeCases = takeList(snapshot.sections.scenarios.edgeCases, 3, ['边界情况仍需澄清']);
567
+ const failureModes = takeList(snapshot.sections.scenarios.failureModes, 3, ['失败路径仍需澄清']);
568
+ const goals = takeList(snapshot.sections.goals.goals, 2, ['目标仍需确认']);
569
+ const successMetrics = takeList(snapshot.sections.goals.successMetrics, 2, ['成功指标仍需确认']);
570
+ const openQuestions = takeList(snapshot.sections.risks.openQuestions, 4, ['暂无开放问题记录']);
571
+ const steps = [
572
+ {
573
+ id: 'entry',
574
+ name: '入口触发',
575
+ type: 'user_action',
576
+ lane: primaryUsers[0],
577
+ subtitle: trimText(primaryFlows[0] ?? '用户进入流程'),
578
+ details: takeList(snapshot.sections.scope.inScope, 2, ['范围仍需细化']),
579
+ },
580
+ {
581
+ id: 'experience',
582
+ name: '产品内步骤',
583
+ type: 'system_process',
584
+ lane: '产品',
585
+ subtitle: trimText(primaryFlows[1] ?? snapshot.sections.problem.problemStatement ?? '核心产品步骤'),
586
+ details: takeList(snapshot.sections.requirements.functional, 2, ['功能需求待补充']),
587
+ },
588
+ {
589
+ id: 'decision',
590
+ name: '决策点',
591
+ type: 'decision',
592
+ lane: '决策',
593
+ subtitle: trimText(edgeCases[0] ?? '决策标准待澄清'),
594
+ details: [
595
+ `目标: ${trimText(goals[0], 40)}`,
596
+ `指标: ${trimText(successMetrics[0], 40)}`,
597
+ ],
598
+ },
599
+ {
600
+ id: 'success',
601
+ name: '成功结果',
602
+ type: 'success',
603
+ lane: '结果',
604
+ subtitle: trimText(successMetrics[0] ?? '成功结果仍需确认'),
605
+ details: takeList(snapshot.sections.goals.acceptanceGoals, 2, ['验收目标待补充']),
606
+ },
607
+ {
608
+ id: 'failure',
609
+ name: '失败与恢复',
610
+ type: 'error_path',
611
+ lane: '结果',
612
+ subtitle: trimText(failureModes[0] ?? '失败路径待澄清'),
613
+ details: [
614
+ ...failureModes.slice(0, 2),
615
+ ...openQuestions.slice(0, 2),
616
+ ].slice(0, 4),
617
+ },
618
+ ];
619
+
620
+ return {
621
+ type: 'product-flow',
622
+ version: 1,
623
+ generatedAt: timestamp(),
624
+ locale: 'zh-CN',
625
+ title: '产品流程评审',
626
+ subtitle: '在需求定稿前评审主要旅程、决策点和恢复路径。',
627
+ actors: primaryUsers,
628
+ steps,
629
+ transitions: [
630
+ { from: 'entry', to: 'experience', label: primaryFlows[0] ?? '开始旅程', type: 'standard' },
631
+ { from: 'experience', to: 'decision', label: primaryFlows[1] ?? '系统处理请求', type: 'standard' },
632
+ { from: 'decision', to: 'success', label: goals[0] ?? '成功路径', type: 'standard' },
633
+ { from: 'decision', to: 'failure', label: failureModes[0] ?? '失败路径', type: 'error_path' },
634
+ ],
635
+ summaryCards: [
636
+ {
637
+ title: '参与者与范围',
638
+ color: 'user_action',
639
+ items: [
640
+ `参与者: ${primaryUsers.join(' / ')}`,
641
+ `范围内: ${takeList(snapshot.sections.scope.inScope, 2, ['范围待补充']).join(' / ')}`,
642
+ `范围外: ${takeList(snapshot.sections.scope.outOfScope, 2, ['范围外内容待补充']).join(' / ')}`,
643
+ ],
644
+ },
645
+ {
646
+ title: '流程检查',
647
+ color: 'system_process',
648
+ items: [
649
+ `主流程: ${primaryFlows.join(' / ')}`,
650
+ `边界情况: ${edgeCases.join(' / ')}`,
651
+ `失败模式: ${failureModes.join(' / ')}`,
652
+ ],
653
+ },
654
+ {
655
+ title: '评审重点',
656
+ color: 'decision',
657
+ items: [
658
+ `目标: ${goals.join(' / ')}`,
659
+ `成功指标: ${successMetrics.join(' / ')}`,
660
+ '在需求定稿前确认步骤、决策点和缺失的恢复路径。',
661
+ ],
662
+ },
663
+ ],
664
+ sidePanels: [
665
+ { title: '开放问题', color: 'error_path', items: openQuestions },
666
+ {
667
+ title: '评审说明',
668
+ color: 'decision',
669
+ items: [
670
+ '确认用户旅程和系统响应顺序是否正确。',
671
+ '标记缺失的决策点、失败路径和恢复步骤。',
672
+ '确认该流程是否足以支持进入实现前确认。',
673
+ ],
674
+ },
675
+ ],
676
+ metadata: {
677
+ projectName: snapshot.title,
678
+ productType: snapshot.productType ?? '未分类',
679
+ owner: snapshot.owner,
680
+ versionId: snapshot.versionId,
681
+ targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenSpec',
682
+ reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
683
+ },
684
+ };
685
+ }
686
+
687
+ export function renderProductFlowDiagramHtml(model) {
688
+ const layouts = {
689
+ entry: { x: 90, y: 250, width: 190, height: 112 },
690
+ experience: { x: 330, y: 250, width: 210, height: 112 },
691
+ decision: { x: 590, y: 245, width: 180, height: 122 },
692
+ success: { x: 820, y: 140, width: 180, height: 112 },
693
+ failure: { x: 820, y: 360, width: 180, height: 122 },
694
+ };
695
+ const fallbackLayouts = [
696
+ { x: 90, y: 250, width: 190, height: 112 },
697
+ { x: 330, y: 250, width: 210, height: 112 },
698
+ { x: 590, y: 245, width: 180, height: 122 },
699
+ { x: 820, y: 140, width: 180, height: 112 },
700
+ { x: 820, y: 360, width: 180, height: 122 },
701
+ ];
702
+
703
+ const laneMarkup = [
704
+ { y: 118, label: '用户/触发' },
705
+ { y: 220, label: '核心流程' },
706
+ { y: 438, label: '结果/恢复' },
707
+ ].map((lane) => `
708
+ <g>
709
+ <line x1="70" y1="${lane.y}" x2="1020" y2="${lane.y}" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"></line>
710
+ <text x="74" y="${lane.y - 8}" class="legend-label">${escapeHtml(lane.label)}</text>
711
+ </g>
712
+ `).join('\n');
713
+
714
+ const stepMarkup = model.steps
715
+ .map((step, index) => renderBox(step, layouts[step.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
716
+ .join('\n');
717
+ const transitions = [
718
+ { path: 'M 280 306 L 330 306', label: model.transitions[0]?.label ?? '开始', labelX: 288, labelY: 294, type: model.transitions[0]?.type ?? 'standard' },
719
+ { path: 'M 540 306 L 590 306', label: model.transitions[1]?.label ?? '核心步骤', labelX: 546, labelY: 294, type: model.transitions[1]?.type ?? 'standard' },
720
+ { path: 'M 770 282 C 800 240, 820 220, 820 196', label: model.transitions[2]?.label ?? '成功路径', labelX: 786, labelY: 226, type: model.transitions[2]?.type ?? 'standard' },
721
+ { path: 'M 770 330 C 800 370, 820 400, 820 420', label: model.transitions[3]?.label ?? '失败路径', labelX: 786, labelY: 388, type: model.transitions[3]?.type ?? 'error_path' },
722
+ ].map(renderArrow).join('\n');
723
+
724
+ const svgMarkup = `
725
+ <svg viewBox="0 0 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
726
+ <defs>
727
+ <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
728
+ <polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
729
+ </marker>
730
+ </defs>
731
+ <rect x="56" y="92" width="968" height="520" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="8,5"></rect>
732
+ <text x="74" y="118" class="legend-label">产品流程边界</text>
733
+ ${laneMarkup}
734
+ ${transitions}
735
+ ${stepMarkup}
736
+ <g>
737
+ <text x="54" y="652" class="legend-label">图例</text>
738
+ <rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">用户动作</text>
739
+ <rect x="196" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="216" y="676" class="legend-label">系统处理</text>
740
+ <rect x="372" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="392" y="676" class="legend-label">决策</text>
741
+ <rect x="516" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="536" y="676" class="legend-label">成功</text>
742
+ <rect x="648" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="668" y="676" class="legend-label">错误/恢复</text>
743
+ </g>
744
+ </svg>
745
+ `;
746
+
747
+ return renderShell({
748
+ lang: model.locale ?? 'zh-CN',
749
+ title: model.title,
750
+ subtitle: model.subtitle,
751
+ projectName: model.metadata?.projectName ?? model.title,
752
+ svgMarkup,
753
+ summaryCards: model.summaryCards,
754
+ sidePanels: model.sidePanels,
755
+ footer: `负责人: ${model.metadata.owner} · 版本: ${model.metadata.versionId} · 目标: ${model.metadata.targetSystem} · 生成时间: ${model.generatedAt}`,
756
+ });
757
+ }
758
+
759
+ export function renderProductFlowMermaid(model) {
760
+ const steps = Array.isArray(model.steps) ? model.steps : [];
761
+ const idMap = new Map();
762
+ const declarations = steps.map((step, index) => {
763
+ const id = mermaidId(step.id, `step_${index + 1}`);
764
+ idMap.set(step.id, id);
765
+ const label = mermaidNodeLabel(step.name, step.subtitle ?? step.description);
766
+ return mermaidNodeDeclaration(id, label, step.type);
767
+ });
768
+ const edges = (Array.isArray(model.transitions) ? model.transitions : [])
769
+ .map((transition) => {
770
+ const from = idMap.get(transition.from) ?? mermaidId(transition.from, 'from');
771
+ const to = idMap.get(transition.to) ?? mermaidId(transition.to, 'to');
772
+ return mermaidEdge(from, to, transition.label, transition.type);
773
+ });
774
+
775
+ return [
776
+ 'flowchart LR',
777
+ ...declarations,
778
+ ...edges,
779
+ ].join('\n');
780
+ }
781
+
782
+ export function renderArchitectureMermaid(model) {
783
+ const components = Array.isArray(model.components) ? model.components : [];
784
+ const idMap = new Map();
785
+ const external = [];
786
+ const internal = [];
787
+
788
+ for (const [index, component] of components.entries()) {
789
+ const id = mermaidId(component.id, `component_${index + 1}`);
790
+ idMap.set(component.id, id);
791
+ const label = mermaidNodeLabel(component.name, component.subtitle ?? component.description);
792
+ const declaration = mermaidNodeDeclaration(id, label, component.type === 'security' ? 'error_path' : 'system_process');
793
+ if (component.type === 'external') {
794
+ external.push(declaration);
795
+ } else {
796
+ internal.push(declaration);
797
+ }
798
+ }
799
+
800
+ const edges = (Array.isArray(model.flows) ? model.flows : [])
801
+ .map((flow) => {
802
+ const source = idMap.get(flow.source) ?? mermaidId(flow.source, 'source');
803
+ const target = idMap.get(flow.target) ?? mermaidId(flow.target, 'target');
804
+ return mermaidEdge(source, target, flow.label, flow.type);
805
+ });
806
+
807
+ return [
808
+ 'flowchart LR',
809
+ ...external,
810
+ ' subgraph solution["方案边界"]',
811
+ ...internal.map((line) => ` ${line}`),
812
+ ' end',
813
+ ...edges,
814
+ ].join('\n');
815
+ }
816
+
817
+ export function renderDiagramMermaidFromModel(type, model) {
818
+ if (type === 'product-flow') {
819
+ return renderProductFlowMermaid(model);
820
+ }
821
+ return renderArchitectureMermaid(model);
822
+ }
823
+
824
+ export function buildDiagramArtifact(snapshot, options = {}) {
825
+ const type = options.type ?? 'architecture';
826
+ const contract = options.contract ?? null;
827
+
828
+ if (type === 'product-flow' && contract) {
829
+ const base = buildProductFlowDiagramModel(snapshot);
830
+ const model = {
831
+ ...base,
832
+ ...contract,
833
+ type: 'product-flow',
834
+ locale: normalizeLocale(contract),
835
+ title: pickValue(contract.title, base.title),
836
+ subtitle: pickValue(contract.subtitle, base.subtitle),
837
+ actors: normalizeList(contract.actors, base.actors),
838
+ steps: Array.isArray(contract.steps) && contract.steps.length > 0
839
+ ? contract.steps.map((step, index) => ({
840
+ id: pickValue(step?.id, `step-${index + 1}`),
841
+ name: pickValue(step?.name, `步骤 ${index + 1}`),
842
+ type: pickValue(step?.type, 'system_process'),
843
+ lane: pickValue(step?.lane, '流程'),
844
+ subtitle: pickValue(step?.subtitle, step?.description ?? '待补充'),
845
+ details: normalizeList(step?.details ?? step?.notes ?? step?.data_involved, ['待补充']),
846
+ }))
847
+ : base.steps,
848
+ transitions: Array.isArray(contract.transitions) && contract.transitions.length > 0
849
+ ? contract.transitions.map((transition) => ({
850
+ from: pickValue(transition?.from, transition?.from_step_id),
851
+ to: pickValue(transition?.to, transition?.to_step_id),
852
+ label: pickValue(transition?.label, transition?.condition ?? '流转'),
853
+ type: pickValue(transition?.type, 'standard'),
854
+ }))
855
+ : base.transitions,
856
+ summaryCards: Array.isArray(contract.summaryCards) && contract.summaryCards.length > 0
857
+ ? contract.summaryCards.map((card, index) => normalizeCard(card, `摘要 ${index + 1}`, 'system_process'))
858
+ : base.summaryCards,
859
+ sidePanels: Array.isArray(contract.sidePanels) && contract.sidePanels.length > 0
860
+ ? contract.sidePanels.map((panel, index) => normalizePanel(panel, `面板 ${index + 1}`, 'decision'))
861
+ : [
862
+ normalizePanel({
863
+ title: contract.openQuestionsTitle ?? '开放问题',
864
+ color: 'error_path',
865
+ items: contract.openQuestions,
866
+ }),
867
+ normalizePanel({
868
+ title: contract.reviewInstructionsTitle ?? '评审说明',
869
+ color: 'decision',
870
+ items: contract.reviewInstructions,
871
+ }),
872
+ ],
873
+ metadata: {
874
+ ...base.metadata,
875
+ ...(contract.metadata ?? {}),
876
+ projectName: pickValue(contract?.metadata?.projectName, pickValue(contract.title, base.metadata.projectName)),
877
+ versionId: pickValue(contract?.metadata?.versionId, base.metadata.versionId),
878
+ owner: pickValue(contract?.metadata?.owner, base.metadata.owner),
879
+ targetSystem: pickValue(contract?.metadata?.targetSystem, base.metadata.targetSystem),
880
+ },
881
+ };
882
+
883
+ return { type, model, html: renderProductFlowDiagramHtml(model) };
884
+ }
885
+
886
+ if (type === 'architecture' && contract) {
887
+ const base = buildArchitectureDiagramModel(snapshot);
888
+ const model = {
889
+ ...base,
890
+ ...contract,
891
+ type: 'architecture',
892
+ locale: normalizeLocale(contract),
893
+ title: pickValue(contract.title, base.title),
894
+ subtitle: pickValue(contract.subtitle, base.subtitle),
895
+ components: Array.isArray(contract.components) && contract.components.length > 0
896
+ ? contract.components.map((component, index) => ({
897
+ id: pickValue(component?.id, `component-${index + 1}`),
898
+ name: pickValue(component?.name, `组件 ${index + 1}`),
899
+ type: pickValue(component?.type, 'external'),
900
+ subtitle: pickValue(component?.subtitle, component?.description ?? '待补充'),
901
+ details: normalizeList(component?.details, ['待补充']),
902
+ }))
903
+ : base.components,
904
+ flows: Array.isArray(contract.flows) && contract.flows.length > 0
905
+ ? contract.flows.map((flow) => ({
906
+ source: pickValue(flow?.source, 'source'),
907
+ target: pickValue(flow?.target, 'target'),
908
+ label: pickValue(flow?.label, '流程'),
909
+ type: pickValue(flow?.type, 'standard'),
910
+ }))
911
+ : base.flows,
912
+ summaryCards: Array.isArray(contract.summaryCards) && contract.summaryCards.length > 0
913
+ ? contract.summaryCards.map((card, index) => normalizeCard(card, `摘要 ${index + 1}`, 'frontend'))
914
+ : base.summaryCards,
915
+ sidePanels: Array.isArray(contract.sidePanels) && contract.sidePanels.length > 0
916
+ ? contract.sidePanels.map((panel, index) => normalizePanel(panel, `面板 ${index + 1}`, 'database'))
917
+ : [
918
+ normalizePanel({
919
+ title: contract.assumptionsTitle ?? '假设',
920
+ color: 'database',
921
+ items: contract.assumptions,
922
+ }),
923
+ normalizePanel({
924
+ title: contract.reviewInstructionsTitle ?? '评审说明',
925
+ color: 'cloud',
926
+ items: contract.reviewInstructions,
927
+ }),
928
+ ],
929
+ metadata: {
930
+ ...base.metadata,
931
+ ...(contract.metadata ?? {}),
932
+ projectName: pickValue(contract?.metadata?.projectName, pickValue(contract.title, base.metadata.projectName)),
933
+ versionId: pickValue(contract?.metadata?.versionId, base.metadata.versionId),
934
+ owner: pickValue(contract?.metadata?.owner, base.metadata.owner),
935
+ targetSystem: pickValue(contract?.metadata?.targetSystem, base.metadata.targetSystem),
936
+ },
937
+ };
938
+
939
+ return { type, model, html: renderArchitectureDiagramHtml(model) };
940
+ }
941
+
942
+ if (type === 'product-flow') {
943
+ const model = buildProductFlowDiagramModel(snapshot);
944
+ return {
945
+ type,
946
+ model,
947
+ html: renderProductFlowDiagramHtml(model),
948
+ };
949
+ }
950
+
951
+ const model = buildArchitectureDiagramModel(snapshot);
952
+ return {
953
+ type: 'architecture',
954
+ model,
955
+ html: renderArchitectureDiagramHtml(model),
956
+ };
957
+ }
958
+
959
+ export function renderDiagramArtifactFromModel(type, model) {
960
+ if (type === 'product-flow') {
961
+ return renderProductFlowDiagramHtml(model);
962
+ }
963
+ return renderArchitectureDiagramHtml(model);
964
+ }
965
+
966
+ export function validateDiagramContract(contract, schema) {
967
+ const errors = [];
968
+ if (!schema || typeof schema !== 'object') {
969
+ return { valid: false, errors: ['Missing diagram schema'] };
970
+ }
971
+ if (!contract || typeof contract !== 'object' || Array.isArray(contract)) {
972
+ return { valid: false, errors: ['Diagram contract must be a JSON object'] };
973
+ }
974
+
975
+ const requiredFields = Array.isArray(schema.requiredFields) ? schema.requiredFields : [];
976
+ for (const field of requiredFields) {
977
+ if (!hasValue(getAtPath(contract, field))) {
978
+ errors.push(`Missing required field: ${field}`);
979
+ }
980
+ }
981
+
982
+ const requiredArrays = schema.requiredArrays ?? {};
983
+ for (const [field, minItems] of Object.entries(requiredArrays)) {
984
+ const value = getAtPath(contract, field);
985
+ if (!Array.isArray(value)) {
986
+ errors.push(`Field must be an array: ${field}`);
987
+ continue;
988
+ }
989
+ if (value.length < Number(minItems)) {
990
+ errors.push(`Field requires at least ${minItems} item(s): ${field}`);
991
+ }
992
+ }
993
+
994
+ const itemRequiredFields = schema.itemRequiredFields ?? {};
995
+ for (const [field, nestedFields] of Object.entries(itemRequiredFields)) {
996
+ const list = getAtPath(contract, field);
997
+ if (!Array.isArray(list)) continue;
998
+ for (let index = 0; index < list.length; index += 1) {
999
+ const item = list[index];
1000
+ for (const nestedField of nestedFields) {
1001
+ const aliases = nestedField.split('|');
1002
+ const ok = aliases.some((alias) => hasValue(item?.[alias]));
1003
+ if (!ok) {
1004
+ errors.push(`Missing required field in ${field}[${index}]: ${nestedField}`);
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ const allowedValues = schema.allowedValues ?? {};
1011
+ for (const [field, allowed] of Object.entries(allowedValues)) {
1012
+ const value = getAtPath(contract, field);
1013
+ if (value === undefined) continue;
1014
+ if (Array.isArray(value)) {
1015
+ for (const item of value) {
1016
+ if (!allowed.includes(item)) {
1017
+ errors.push(`Unsupported value in ${field}: ${item}`);
1018
+ }
1019
+ }
1020
+ continue;
1021
+ }
1022
+ if (!allowed.includes(value)) {
1023
+ errors.push(`Unsupported value for ${field}: ${value}`);
1024
+ }
1025
+ }
1026
+
1027
+ const itemAllowedValues = schema.itemAllowedValues ?? {};
1028
+ for (const [field, mapping] of Object.entries(itemAllowedValues)) {
1029
+ const list = getAtPath(contract, field);
1030
+ if (!Array.isArray(list)) continue;
1031
+ for (let index = 0; index < list.length; index += 1) {
1032
+ const item = list[index];
1033
+ for (const [nestedField, allowed] of Object.entries(mapping)) {
1034
+ const value = item?.[nestedField];
1035
+ if (value === undefined || value === null || value === '') continue;
1036
+ if (!allowed.includes(value)) {
1037
+ errors.push(`Unsupported value for ${field}[${index}].${nestedField}: ${value}`);
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ return {
1044
+ valid: errors.length === 0,
1045
+ errors,
1046
+ };
1047
+ }