@openprd/cli 0.1.10 → 0.1.18

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 (146) hide show
  1. package/.openprd/benchmarks/index.md +17 -15
  2. package/.openprd/benchmarks/sources.yaml +53 -1
  3. package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
  4. package/.openprd/changes/adaptive-project-framing/design.md +47 -0
  5. package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
  6. package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
  7. package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
  8. package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
  9. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
  10. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
  11. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
  12. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
  13. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
  14. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
  15. package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
  16. package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
  17. package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
  18. package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
  19. package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
  20. package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
  21. package/.openprd/changes/openprd/.openprd.yaml +2 -0
  22. package/.openprd/changes/openprd/design.md +46 -0
  23. package/.openprd/changes/openprd/proposal.md +36 -0
  24. package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
  25. package/.openprd/changes/openprd/task-events.jsonl +23 -0
  26. package/.openprd/changes/openprd/tasks.md +242 -0
  27. package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
  28. package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
  29. package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
  30. package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
  31. package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
  32. package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
  33. package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
  34. package/.openprd/changes/test-strategy-router/design.md +65 -0
  35. package/.openprd/changes/test-strategy-router/proposal.md +45 -0
  36. package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
  37. package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
  38. package/.openprd/changes/test-strategy-router/tasks.md +132 -0
  39. package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
  40. package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
  41. package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
  42. package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
  43. package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
  44. package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
  45. package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
  46. package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
  47. package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
  48. package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
  49. package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
  50. package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
  51. package/.openprd/design/README.md +11 -6
  52. package/.openprd/design/README_EN.md +4 -1
  53. package/.openprd/design/active/asset-spec.md +6 -0
  54. package/.openprd/design/active/direction-plan.md +5 -5
  55. package/.openprd/design/active/selected-direction.md +2 -0
  56. package/.openprd/design/anti-slop.md +26 -1
  57. package/.openprd/design/assets/surface-presets.md +6 -0
  58. package/.openprd/design/checklists/ui-quality-gate.md +7 -0
  59. package/.openprd/design/lenses/frontend-lenses.md +28 -0
  60. package/.openprd/design/recipes/content-experience.md +8 -1
  61. package/.openprd/design/recipes/operational-tool.md +7 -0
  62. package/.openprd/design/recipes/product-launch.md +8 -1
  63. package/.openprd/design/templates/README.md +6 -3
  64. package/.openprd/design/templates/README_EN.md +6 -3
  65. package/.openprd/design/templates/ops-dashboard.html +8 -1
  66. package/.openprd/design/templates/product-launch.html +2 -1
  67. package/.openprd/design/themes/theme-catalog.json +23 -5
  68. package/.openprd/engagements/active/prd.md +103 -100
  69. package/.openprd/schema/prd.schema.yaml +15 -0
  70. package/.openprd/standards/config.json +7 -1
  71. package/.openprd/templates/base/intake.md +13 -0
  72. package/.openprd/templates/base/prd.md +15 -0
  73. package/AGENTS.md +14 -4
  74. package/README.md +107 -14
  75. package/README_EN.md +131 -13
  76. package/package.json +1 -1
  77. package/scripts/dev-check-wrapup-copy.mjs +46 -21
  78. package/skills/openprd-benchmark-router/SKILL.md +2 -1
  79. package/skills/openprd-diagram-review/SKILL.md +27 -2
  80. package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  82. package/skills/openprd-frontend-design/SKILL.md +44 -20
  83. package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
  84. package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
  85. package/skills/openprd-harness/SKILL.md +22 -16
  86. package/skills/openprd-quality/SKILL.md +10 -8
  87. package/skills/openprd-requirement-intake/SKILL.md +5 -3
  88. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +2 -2
  89. package/skills/openprd-requirement-intake/references/startup-validation-lens.md +98 -13
  90. package/skills/openprd-router/SKILL.md +2 -1
  91. package/skills/openprd-shared/SKILL.md +27 -17
  92. package/skills/openprd-standards/SKILL.md +4 -2
  93. package/src/agent-canonical-content.js +808 -0
  94. package/src/agent-integration.js +259 -778
  95. package/src/brainstorm-artifacts.js +156 -8
  96. package/src/brainstorm-presentation.js +14 -4
  97. package/src/brainstorm.js +165 -2
  98. package/src/canvas-app.html.js +2910 -0
  99. package/src/canvas-i18n.js +274 -0
  100. package/src/canvas-workspace.js +2271 -0
  101. package/src/cli/args.js +72 -4
  102. package/src/cli/basic-print.js +3 -0
  103. package/src/cli/canvas-print.js +106 -0
  104. package/src/cli/doctor-print.js +20 -0
  105. package/src/cli/print.js +2 -0
  106. package/src/cli/quality-commands.js +222 -0
  107. package/src/cli/quality-print.js +37 -5
  108. package/src/cli/run-print.js +5 -0
  109. package/src/cli/workflow-print.js +3 -0
  110. package/src/codex-app-server-relay.js +251 -0
  111. package/src/codex-hook-runner-template.mjs +963 -65
  112. package/src/design-starter-support.js +2 -2
  113. package/src/design-starter.js +39 -3
  114. package/src/dev-standards.js +125 -10
  115. package/src/diagram-core.js +390 -171
  116. package/src/discovery.js +2 -8
  117. package/src/docs-compact.js +125 -0
  118. package/src/execution-strategy.js +1 -1
  119. package/src/fleet.js +9 -3
  120. package/src/guide.js +227 -0
  121. package/src/html-artifacts.js +825 -51
  122. package/src/knowledge.js +101 -20
  123. package/src/language-policy.js +63 -3
  124. package/src/learning-review.js +1 -1
  125. package/src/loop.js +1 -1
  126. package/src/openprd.js +68 -131
  127. package/src/openspec/change-validate.js +2 -2
  128. package/src/openspec/constants.js +6 -7
  129. package/src/openspec/execute.js +2 -2
  130. package/src/openspec/generate.js +318 -102
  131. package/src/openspec/migration.js +168 -0
  132. package/src/openspec/paths.js +1 -22
  133. package/src/prd-core.js +102 -3
  134. package/src/quality-html-artifact.js +30 -5
  135. package/src/quality-visual-review.js +257 -14
  136. package/src/quality.js +66 -18
  137. package/src/review-presentation.js +60 -0
  138. package/src/run-harness.js +41 -3
  139. package/src/session-evidence.js +363 -0
  140. package/src/standards.js +64 -0
  141. package/src/test-strategy.js +7 -4
  142. package/src/visual-compare-centering.js +622 -0
  143. package/src/visual-compare-core.js +1123 -0
  144. package/src/visual-compare.js +620 -698
  145. package/src/workspace-core.js +132 -4
  146. package/src/workspace-workflow.js +172 -10
@@ -19,7 +19,7 @@ function card(title, body) {
19
19
  `;
20
20
  }
21
21
 
22
- function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [], footer = '', statusBadge = null, topMeta = [] }) {
22
+ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [], footer = '', statusBadge = null, topMeta = [], bottomActions = [] }) {
23
23
  return `<!DOCTYPE html>
24
24
  <html lang="zh-CN">
25
25
  <head>
@@ -29,26 +29,23 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
29
29
  <style>
30
30
  :root {
31
31
  color-scheme: light;
32
- --bg: #f7f4ed;
33
- --panel: rgba(255,255,255,0.88);
34
- --text: #1f2937;
35
- --muted: #6b7280;
36
- --line: rgba(31,41,55,0.12);
37
- --accent: #d97706;
38
- --accent-soft: rgba(217,119,6,0.12);
32
+ --bg: #f6f8fb;
33
+ --panel: #ffffff;
34
+ --text: #172033;
35
+ --muted: #667085;
36
+ --line: #d8dee8;
37
+ --accent: #2563eb;
38
+ --accent-soft: rgba(37,99,235,0.1);
39
39
  --danger: #dc2626;
40
40
  --danger-soft: rgba(220,38,38,0.08);
41
41
  --ok: #15803d;
42
42
  --ok-soft: rgba(21,128,61,0.08);
43
43
  --mono: "JetBrains Mono","SFMono-Regular",Menlo,monospace;
44
- --serif: "Iowan Old Style","Palatino Linotype","Book Antiqua",Palatino,serif;
45
44
  }
46
45
  * { box-sizing: border-box; }
47
46
  body {
48
47
  margin: 0;
49
- background:
50
- radial-gradient(circle at top left, rgba(217,119,6,0.08), transparent 25%),
51
- linear-gradient(180deg, #faf8f2 0%, var(--bg) 100%);
48
+ background: var(--bg);
52
49
  color: var(--text);
53
50
  font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
54
51
  }
@@ -68,7 +65,7 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
68
65
  padding: 6px 12px;
69
66
  border-radius: 999px;
70
67
  border: 1px solid var(--line);
71
- background: rgba(255,255,255,0.72);
68
+ background: #ffffff;
72
69
  color: var(--muted);
73
70
  font-size: 12px;
74
71
  letter-spacing: 0.08em;
@@ -129,10 +126,10 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
129
126
  }
130
127
  h1 {
131
128
  margin: 0;
132
- font-size: clamp(34px, 5vw, 56px);
133
- line-height: 1;
134
- font-family: var(--serif);
135
- font-weight: 600;
129
+ font-size: clamp(30px, 4.4vw, 46px);
130
+ line-height: 1.15;
131
+ font-weight: 700;
132
+ letter-spacing: -0.01em;
136
133
  }
137
134
  .subtitle {
138
135
  max-width: 880px;
@@ -161,10 +158,9 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
161
158
  }
162
159
  .card {
163
160
  border: 1px solid var(--line);
164
- border-radius: 20px;
161
+ border-radius: 16px;
165
162
  background: var(--panel);
166
- backdrop-filter: blur(8px);
167
- box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05);
163
+ box-shadow: 0 10px 24px rgba(23,32,51,0.06);
168
164
  overflow: hidden;
169
165
  }
170
166
  .card-header {
@@ -178,10 +174,9 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
178
174
  padding: 12px 18px 18px;
179
175
  }
180
176
  .metric {
181
- font-size: 30px;
182
- line-height: 1.1;
183
- font-family: var(--serif);
184
- font-weight: 600;
177
+ font-size: 28px;
178
+ line-height: 1.15;
179
+ font-weight: 700;
185
180
  }
186
181
  .metric-sub {
187
182
  margin-top: 8px;
@@ -193,7 +188,7 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
193
188
  padding: 10px 12px;
194
189
  border-radius: 12px;
195
190
  border: 1px solid var(--line);
196
- background: rgba(255,255,255,0.72);
191
+ background: #fbfcfe;
197
192
  }
198
193
  .mini-metric-value {
199
194
  font-size: 18px;
@@ -227,7 +222,7 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
227
222
  padding: 12px 14px;
228
223
  border-radius: 14px;
229
224
  border: 1px solid var(--line);
230
- background: rgba(255,255,255,0.7);
225
+ background: #fbfcfe;
231
226
  }
232
227
  .qa-label,
233
228
  .option-title,
@@ -277,8 +272,9 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
277
272
  overflow-x: auto;
278
273
  padding: 14px;
279
274
  border-radius: 14px;
280
- background: #161b22;
281
- color: #e5e7eb;
275
+ border: 1px solid var(--line);
276
+ background: #f9fafb;
277
+ color: var(--text);
282
278
  font-family: var(--mono);
283
279
  font-size: 13px;
284
280
  line-height: 1.6;
@@ -288,6 +284,46 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
288
284
  color: var(--muted);
289
285
  font-size: 13px;
290
286
  }
287
+ .page-bottom-bar {
288
+ position: fixed;
289
+ inset-inline: 0;
290
+ bottom: 0;
291
+ z-index: 30;
292
+ padding: 14px 24px calc(14px + env(safe-area-inset-bottom));
293
+ background: rgba(246,248,251,0.92);
294
+ backdrop-filter: blur(8px);
295
+ border-top: 1px solid var(--line);
296
+ }
297
+ .page-bottom-bar-inner {
298
+ max-width: 980px;
299
+ margin: 0 auto;
300
+ display: flex;
301
+ justify-content: flex-end;
302
+ gap: 12px;
303
+ }
304
+ .page-bottom-action {
305
+ appearance: none;
306
+ border-radius: 12px;
307
+ padding: 10px 22px;
308
+ font-size: 15px;
309
+ font-weight: 600;
310
+ cursor: pointer;
311
+ border: 1px solid var(--line);
312
+ background: #ffffff;
313
+ color: var(--text);
314
+ }
315
+ .page-bottom-action.primary {
316
+ border-color: rgba(21,128,61,0.35);
317
+ background: var(--ok-soft);
318
+ color: var(--ok);
319
+ }
320
+ .page-bottom-action.danger {
321
+ border-color: rgba(220,38,38,0.35);
322
+ background: var(--danger-soft);
323
+ color: var(--danger);
324
+ }
325
+ .page-bottom-action:hover { filter: brightness(0.97); }
326
+ .has-bottom-bar { padding-bottom: 96px; }
291
327
  @media (max-width: 700px) {
292
328
  .page { padding: 20px 14px 40px; }
293
329
  .subtitle { font-size: 16px; }
@@ -295,7 +331,7 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
295
331
  </style>
296
332
  </head>
297
333
  <body>
298
- <main class="page">
334
+ <main class="page${bottomActions.length ? ' has-bottom-bar' : ''}">
299
335
  <header class="hero">
300
336
  <div class="hero-topline">
301
337
  <div class="eyebrow">${escapeHtml(eyebrow)}</div>
@@ -308,6 +344,15 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
308
344
  <section class="summary-grid">${summaryCards.join('\n')}</section>
309
345
  <section class="section-grid">${sections.join('\n')}</section>
310
346
  ${footer ? `<div class="footer">${escapeHtml(footer)}</div>` : ''}
347
+ ${bottomActions.length ? `
348
+ <nav class="page-bottom-bar" aria-label="下一步反馈">
349
+ <div class="page-bottom-bar-inner">
350
+ ${bottomActions.map((action) => `
351
+ <button type="button" class="page-bottom-action ${escapeHtml(action.tone ?? 'primary')}" data-copy-value="${escapeHtml(action.copyText)}">
352
+ ${escapeHtml(action.label)}
353
+ </button>`).join('')}
354
+ </div>
355
+ </nav>` : ''}
311
356
  <script>
312
357
  document.querySelectorAll('[data-copy-target]').forEach((button) => {
313
358
  button.addEventListener('click', async () => {
@@ -319,6 +364,14 @@ function pageShell({ title, subtitle, eyebrow, summaryCards = [], sections = [],
319
364
  setTimeout(() => { button.textContent = old; }, 1200);
320
365
  });
321
366
  });
367
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
368
+ button.addEventListener('click', async () => {
369
+ await navigator.clipboard.writeText(button.dataset.copyValue || '');
370
+ const old = button.textContent;
371
+ button.textContent = '✓ 已复制,回到对话里粘贴给我';
372
+ setTimeout(() => { button.textContent = old; }, 1600);
373
+ });
374
+ });
322
375
  </script>
323
376
  </main>
324
377
  </body>
@@ -438,9 +491,33 @@ const REVIEW_PRESENTATION_CONTRACT = {
438
491
  USER_CHANGE_SUMMARY_GUIDE.panelExamples.risk,
439
492
  ],
440
493
  },
494
+ designDirections: {
495
+ note: '可选;只有需求涉及界面/页面/交互时提供。先用 imagegen 生成 3 个差异足够大的交互稿,再把图片路径写进来。',
496
+ directions: [
497
+ {
498
+ id: 'direction-1',
499
+ title: '15 字以内方向名',
500
+ image: '.openprd/design/active/direction-1.png',
501
+ summary: '30 字以内一句话说明这个方向的核心体验',
502
+ tradeoff: '30 字以内说明取舍或适用场景',
503
+ },
504
+ ],
505
+ },
441
506
  },
442
507
  },
443
508
  rules: [
509
+ {
510
+ id: 'review-design-direction-count',
511
+ area: '交互方向评审',
512
+ target: '方向数量',
513
+ action: '涉及界面时提供 2 到 4 个差异足够大的方向(默认 3 个),写入 reviewPresentation.designDirections.directions[]。',
514
+ },
515
+ {
516
+ id: 'review-design-direction-fields',
517
+ area: '交互方向评审',
518
+ target: '每个方向的字段',
519
+ action: '每个方向必须有唯一 id、image 图片路径;title 15 字内、summary 30 字内、tradeoff 30 字内。',
520
+ },
444
521
  {
445
522
  id: 'review-map-card-text',
446
523
  area: '需求关系图 / 需求流程图',
@@ -574,12 +651,125 @@ export function buildReviewPresentationFeedback(snapshot) {
574
651
  });
575
652
  });
576
653
 
654
+ validateReviewDesignDirections(snapshot, violations, addViolation);
655
+
577
656
  return {
578
657
  contract: REVIEW_PRESENTATION_CONTRACT,
579
658
  violations,
580
659
  };
581
660
  }
582
661
 
662
+ export function reviewDesignDirections(snapshot) {
663
+ const block = snapshot?.reviewPresentation?.designDirections;
664
+ if (!block || typeof block !== 'object' || Array.isArray(block)) return null;
665
+ const directions = Array.isArray(block.directions) ? block.directions : [];
666
+ if (directions.length === 0) return null;
667
+ return { ...block, directions };
668
+ }
669
+
670
+ function validateReviewDesignDirections(snapshot, violations, addViolation) {
671
+ const rawBlock = snapshot?.reviewPresentation?.designDirections;
672
+ if (rawBlock == null) return;
673
+ if (typeof rawBlock !== 'object' || Array.isArray(rawBlock) || !Array.isArray(rawBlock.directions)) {
674
+ violations.push({
675
+ ruleId: 'review-design-direction-fields',
676
+ area: '交互方向评审',
677
+ target: 'designDirections 结构',
678
+ jsonPath: 'reviewPresentation.designDirections',
679
+ action: 'designDirections 必须是对象,且 directions 为数组。参考 openprd review-presentation . --template。',
680
+ });
681
+ return;
682
+ }
683
+ const directions = rawBlock.directions;
684
+ if (directions.length < 2 || directions.length > 4) {
685
+ violations.push({
686
+ ruleId: 'review-design-direction-count',
687
+ area: '交互方向评审',
688
+ target: '方向数量',
689
+ jsonPath: 'reviewPresentation.designDirections.directions',
690
+ action: `当前 ${directions.length} 个方向;请提供 2 到 4 个差异足够大的方向(默认 3 个)。`,
691
+ });
692
+ }
693
+ const seenIds = new Set();
694
+ directions.forEach((direction, index) => {
695
+ const basePath = `reviewPresentation.designDirections.directions[${index}]`;
696
+ if (!direction || typeof direction !== 'object' || Array.isArray(direction)) {
697
+ violations.push({
698
+ ruleId: 'review-design-direction-fields',
699
+ area: '交互方向评审',
700
+ target: `方向 ${index + 1}`,
701
+ jsonPath: basePath,
702
+ action: '每个方向必须是对象,包含 id、title、image、summary 字段。',
703
+ });
704
+ return;
705
+ }
706
+ if (/字以内/u.test(`${direction.title ?? ''}${direction.summary ?? ''}${direction.tradeoff ?? ''}`)) {
707
+ violations.push({
708
+ ruleId: 'review-design-direction-fields',
709
+ area: '交互方向评审',
710
+ target: `方向 ${index + 1}`,
711
+ jsonPath: basePath,
712
+ action: '仍是模板占位文案;请填入真实方向内容,或在不涉及界面时删除 designDirections 字段。',
713
+ });
714
+ }
715
+ const id = normalizedReviewVisibleText(direction.id);
716
+ if (!id) {
717
+ violations.push({
718
+ ruleId: 'review-design-direction-fields',
719
+ area: '交互方向评审',
720
+ target: `方向 ${index + 1} id`,
721
+ jsonPath: `${basePath}.id`,
722
+ action: '缺少方向 id;请使用 direction-1 这类稳定标识。',
723
+ });
724
+ } else if (seenIds.has(id)) {
725
+ violations.push({
726
+ ruleId: 'review-design-direction-fields',
727
+ area: '交互方向评审',
728
+ target: `方向 ${index + 1} id`,
729
+ jsonPath: `${basePath}.id`,
730
+ action: `方向 id ${id} 重复;每个方向必须使用唯一 id。`,
731
+ });
732
+ } else {
733
+ seenIds.add(id);
734
+ }
735
+ if (!normalizedReviewVisibleText(direction.image)) {
736
+ violations.push({
737
+ ruleId: 'review-design-direction-fields',
738
+ area: '交互方向评审',
739
+ target: `方向 ${index + 1} 图片`,
740
+ jsonPath: `${basePath}.image`,
741
+ action: '缺少 image 图片路径;请先用 imagegen 生成交互稿,再写入项目内相对路径。',
742
+ });
743
+ }
744
+ addViolation({
745
+ ruleId: 'review-design-direction-fields',
746
+ area: '交互方向评审',
747
+ target: `方向 ${index + 1} 名称`,
748
+ value: direction.title,
749
+ maxChars: 15,
750
+ jsonPath: `${basePath}.title`,
751
+ });
752
+ addViolation({
753
+ ruleId: 'review-design-direction-fields',
754
+ area: '交互方向评审',
755
+ target: `方向 ${index + 1} 卖点`,
756
+ value: direction.summary,
757
+ maxChars: 30,
758
+ jsonPath: `${basePath}.summary`,
759
+ });
760
+ if (direction.tradeoff != null) {
761
+ addViolation({
762
+ ruleId: 'review-design-direction-fields',
763
+ area: '交互方向评审',
764
+ target: `方向 ${index + 1} 取舍`,
765
+ value: direction.tradeoff,
766
+ maxChars: 30,
767
+ jsonPath: `${basePath}.tradeoff`,
768
+ });
769
+ }
770
+ });
771
+ }
772
+
583
773
  function normalizedReviewVisibleText(value) {
584
774
  return String(value ?? '').replace(/\s+/g, ' ').trim();
585
775
  }
@@ -816,17 +1006,126 @@ function renderReviewProjectVersion(projectRelease) {
816
1006
  `;
817
1007
  }
818
1008
 
819
- function renderReviewOverview(snapshot, sectionsData) {
1009
+ function renderReviewStageRail(snapshot) {
1010
+ const hasDirections = Boolean(reviewDesignDirections(snapshot));
1011
+ const stages = [
1012
+ { title: '需求澄清', status: 'done' },
1013
+ { title: '需求摘要', status: 'done' },
1014
+ { title: '评审确认', status: 'current' },
1015
+ ...(hasDirections ? [{ title: '方向选择', status: 'pending' }] : []),
1016
+ { title: '任务拆解', status: 'pending' },
1017
+ { title: '实现执行', status: 'pending' },
1018
+ { title: '验证收口', status: 'pending' },
1019
+ ];
1020
+ const items = stages.map((stage, index) => `
1021
+ <li class="review-stage ${stage.status}">
1022
+ <span class="review-stage-dot">${stage.status === 'done' ? '✓' : index + 1}</span>
1023
+ <span class="review-stage-name">${escapeHtml(stage.title)}</span>
1024
+ ${stage.status === 'current' ? '<span class="review-stage-badge">你在这里</span>' : ''}
1025
+ </li>`);
1026
+ return `
1027
+ <nav class="review-stage-rail" aria-label="流程位置">
1028
+ <ol>
1029
+ ${items.join('\n')}
1030
+ </ol>
1031
+ </nav>`;
1032
+ }
1033
+
1034
+ function renderReviewOverview(snapshot, sectionsData, agenda = []) {
820
1035
  const problem = sectionsData.problem?.problemStatement || '尚未形成明确问题定义';
1036
+ const goal = reviewList(sectionsData.goals?.goals)[0] ?? null;
1037
+ const agendaMarkup = agenda.length === 0 ? '' : `
1038
+ <aside class="review-agenda" aria-label="评审清单">
1039
+ <p class="review-agenda-title">这次评审要确认 ${agenda.length} 块内容</p>
1040
+ <ol class="review-agenda-list">
1041
+ ${agenda.map((item) => `
1042
+ <li>
1043
+ <a href="#review-panel-${escapeHtml(item.kind)}">
1044
+ <span class="review-agenda-name">${escapeHtml(item.title)}</span>
1045
+ <span class="review-agenda-count${item.count === 0 ? ' empty' : ''}">${item.count === 0 ? '暂无内容' : `${item.count} ${escapeHtml(item.unit || '条')}`}</span>
1046
+ </a>
1047
+ </li>`).join('')}
1048
+ </ol>
1049
+ <p class="review-agenda-foot">逐块看完后,用页面底部的按钮告诉我结论就可以。</p>
1050
+ </aside>
1051
+ `;
821
1052
  return `
822
1053
  <section class="review-overview" aria-labelledby="reviewOverviewTitle">
823
1054
  <div class="review-overview-copy">
824
1055
  <p class="review-kicker">需求概览</p>
825
1056
  <h1 id="reviewOverviewTitle">${escapeHtml(snapshot.title || 'PRD 评审')}</h1>
826
1057
  <p class="review-problem">${escapeHtml(problem)}</p>
1058
+ ${goal ? `<p class="review-goal"><span class="review-goal-label">这次的目标</span>${escapeHtml(goal)}</p>` : ''}
1059
+ </div>
1060
+ ${agendaMarkup}
1061
+ </section>
1062
+ `;
1063
+ }
1064
+
1065
+ const REVIEW_GAP_PLACEHOLDER = /^待(补充|澄清|确认)/;
1066
+
1067
+ const REVIEW_GAP_FIELDS = [
1068
+ { path: ['problem', 'problemStatement'], label: '问题定义', hint: '还没说清楚这次要解决什么问题', priority: 1 },
1069
+ { path: ['goals', 'goals'], label: '目标', hint: '还没说清楚这次想达成什么', priority: 1 },
1070
+ { path: ['scope', 'inScope'], label: '范围内', hint: '还没列出这次要做的内容', priority: 1 },
1071
+ { path: ['scenarios', 'primaryFlows'], label: '主流程', hint: '还没把用户怎么走完整个流程讲清楚', priority: 1 },
1072
+ { path: ['goals', 'successMetrics'], label: '成功指标', hint: '还没定义怎么算做成了', priority: 2 },
1073
+ { path: ['scope', 'outOfScope'], label: '范围外', hint: '还没说清楚这次明确不做什么', priority: 2 },
1074
+ { path: ['requirements', 'functional'], label: '功能需求', hint: '还没列出必须交付的功能', priority: 2 },
1075
+ { path: ['scenarios', 'edgeCases'], label: '边界情况', hint: '还没考虑特殊情况怎么处理', priority: 3 },
1076
+ { path: ['scenarios', 'failureModes'], label: '失败路径', hint: '还没说清楚出错后怎么恢复', priority: 3 },
1077
+ ];
1078
+
1079
+ function reviewGapFieldMissing(sectionsData, field) {
1080
+ const raw = sectionsData?.[field.path[0]]?.[field.path[1]];
1081
+ if (Array.isArray(raw)) {
1082
+ const items = reviewList(raw);
1083
+ return items.length === 0 || items.every((item) => REVIEW_GAP_PLACEHOLDER.test(item));
1084
+ }
1085
+ const text = String(raw ?? '').trim();
1086
+ return !text || REVIEW_GAP_PLACEHOLDER.test(text);
1087
+ }
1088
+
1089
+ export function collectReviewGaps(sectionsData) {
1090
+ return REVIEW_GAP_FIELDS
1091
+ .filter((field) => reviewGapFieldMissing(sectionsData ?? {}, field))
1092
+ .sort((a, b) => a.priority - b.priority);
1093
+ }
1094
+
1095
+ function renderReviewGapPanel(sectionsData) {
1096
+ const gaps = collectReviewGaps(sectionsData);
1097
+ if (gaps.length === 0) {
1098
+ return `
1099
+ <section class="review-gap review-gap-clear" aria-label="信息完整度">
1100
+ <div class="review-gap-headline">
1101
+ <span class="review-gap-dot" aria-hidden="true"></span>
1102
+ <strong>核心信息已补齐</strong>
1103
+ <span class="review-gap-sub">问题、目标、范围和主流程都已经写清楚,可以放心往下确认。</span>
827
1104
  </div>
828
1105
  </section>
829
1106
  `;
1107
+ }
1108
+ const blocking = gaps.filter((gap) => gap.priority === 1).length;
1109
+ const subText = blocking > 0
1110
+ ? '其中带「关键」标记的内容建议先补齐,再做确认。'
1111
+ : '都不是拦路的问题,但确认前补上会更稳。';
1112
+ return `
1113
+ <section class="review-gap review-gap-pending" aria-label="信息完整度">
1114
+ <div class="review-gap-headline">
1115
+ <span class="review-gap-dot" aria-hidden="true"></span>
1116
+ <strong>确认前还差 ${gaps.length} 件事</strong>
1117
+ <span class="review-gap-sub">${escapeHtml(subText)}</span>
1118
+ </div>
1119
+ <ol class="review-gap-list">
1120
+ ${gaps.map((gap) => `
1121
+ <li class="review-gap-item">
1122
+ <span class="review-gap-tag${gap.priority === 1 ? ' review-gap-tag-key' : ''}">${gap.priority === 1 ? '关键' : '建议'}</span>
1123
+ <strong>${escapeHtml(gap.label)}</strong>
1124
+ <span class="review-gap-hint">${escapeHtml(gap.hint)}</span>
1125
+ </li>`).join('')}
1126
+ </ol>
1127
+ </section>
1128
+ `;
830
1129
  }
831
1130
 
832
1131
  function renderReviewFlowSvg(snapshot, sectionsData) {
@@ -1121,14 +1420,16 @@ function reviewSubtitleText(value) {
1121
1420
  }
1122
1421
 
1123
1422
  function renderReviewPanel({ kind, title, description, items, emptyText, visual = '' }) {
1423
+ const itemCount = Array.isArray(items) ? items.filter(Boolean).length : 0;
1124
1424
  return `
1125
- <section class="review-panel review-panel-${escapeHtml(kind)}">
1425
+ <section class="review-panel review-panel-${escapeHtml(kind)}" id="review-panel-${escapeHtml(kind)}">
1126
1426
  <header class="review-panel-head">
1127
1427
  ${reviewIcon(kind)}
1128
1428
  <div>
1129
1429
  <h3>${escapeHtml(title)}</h3>
1130
1430
  <p>${escapeHtml(reviewSubtitleText(description))}</p>
1131
1431
  </div>
1432
+ <span class="review-panel-count${itemCount === 0 ? ' empty' : ''}" aria-label="条目数">${itemCount === 0 ? '暂无' : `${itemCount} 条`}</span>
1132
1433
  </header>
1133
1434
  <div class="review-chip-row" aria-label="${escapeHtml(title)}重点摘要">
1134
1435
  ${reviewHighlightChips(items, emptyText)}
@@ -1197,7 +1498,7 @@ function shellQuote(value) {
1197
1498
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
1198
1499
  }
1199
1500
 
1200
- function reviewCommand(snapshot, status, notes = null) {
1501
+ function reviewCommand(snapshot, status, notes = null, extra = {}) {
1201
1502
  const parts = ['openprd review . --mark', status];
1202
1503
  if (snapshot.versionId) {
1203
1504
  parts.push('--version', shellQuote(snapshot.versionId));
@@ -1208,12 +1509,71 @@ function reviewCommand(snapshot, status, notes = null) {
1208
1509
  if (snapshot.workUnitId) {
1209
1510
  parts.push('--work-unit', shellQuote(snapshot.workUnitId));
1210
1511
  }
1512
+ if (extra.direction) {
1513
+ parts.push('--direction', shellQuote(extra.direction));
1514
+ }
1211
1515
  if (notes) {
1212
1516
  parts.push('--notes', shellQuote(notes));
1213
1517
  }
1214
1518
  return parts.join(' ');
1215
1519
  }
1216
1520
 
1521
+ function reviewDirectionImageHref(imagePath) {
1522
+ const text = String(imagePath ?? '').trim();
1523
+ if (!text) return null;
1524
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(text)) {
1525
+ return text;
1526
+ }
1527
+ return `../../${text.split(path.sep).join('/')}`;
1528
+ }
1529
+
1530
+ function renderReviewDesignDirections(snapshot) {
1531
+ const block = reviewDesignDirections(snapshot);
1532
+ if (!block) return '';
1533
+ const cards = block.directions.map((direction, index) => {
1534
+ const id = normalizedReviewVisibleText(direction.id) || `direction-${index + 1}`;
1535
+ const title = normalizedReviewVisibleText(direction.title) || `方向 ${index + 1}`;
1536
+ const summary = normalizedReviewVisibleText(direction.summary);
1537
+ const tradeoff = normalizedReviewVisibleText(direction.tradeoff);
1538
+ const href = reviewDirectionImageHref(direction.image);
1539
+ const selectCommand = reviewCommand(snapshot, 'confirmed', null, { direction: id });
1540
+ const selectCopy = reviewCopyBundle({
1541
+ label: `选定交互方向:${title}`,
1542
+ command: selectCommand,
1543
+ payload: JSON.stringify({ versionId: snapshot.versionId, direction: id, title }, null, 2),
1544
+ message: `我选定「${title}」这个交互方向,请按这个方向继续推进实现。`,
1545
+ });
1546
+ const image = href
1547
+ ? `<a class="review-direction-image" href="${escapeHtml(href)}" target="_blank" rel="noopener"><img src="${escapeHtml(href)}" alt="${escapeHtml(title)} 交互稿" loading="lazy" /></a>`
1548
+ : '<div class="review-direction-image review-direction-image-missing">交互稿图片待生成</div>';
1549
+ return `
1550
+ <article class="review-direction-card" data-direction="${escapeHtml(id)}">
1551
+ ${image}
1552
+ <div class="review-direction-body">
1553
+ <div class="review-direction-head">
1554
+ <span class="review-direction-index">方向 ${index + 1}</span>
1555
+ <h3>${escapeHtml(title)}</h3>
1556
+ </div>
1557
+ ${summary ? `<p class="review-direction-summary">${escapeHtml(summary)}</p>` : ''}
1558
+ ${tradeoff ? `<p class="review-direction-tradeoff">取舍:${escapeHtml(tradeoff)}</p>` : ''}
1559
+ <button type="button" class="review-direction-select" data-copy-value="${escapeHtml(selectCopy)}" title="${escapeHtml(selectCommand)}">
1560
+ 选这个方向
1561
+ </button>
1562
+ </div>
1563
+ </article>`;
1564
+ });
1565
+ return `
1566
+ <section class="review-directions" id="review-panel-design-directions" aria-label="交互方向评审">
1567
+ <div class="review-section-heading">
1568
+ <h2>交互方向评审</h2>
1569
+ <p>这是本次界面需求最重要的决定:${block.directions.length} 个交互稿方向选一个。点图片看大图,点“选这个方向”后把复制内容发回给 Agent 即可。</p>
1570
+ </div>
1571
+ <div class="review-direction-grid">
1572
+ ${cards.join('\n')}
1573
+ </div>
1574
+ </section>`;
1575
+ }
1576
+
1217
1577
  function renderReviewDecision(snapshot) {
1218
1578
  const payload = JSON.stringify(buildReviewExportPayload(snapshot), null, 2);
1219
1579
  const confirmCommand = reviewCommand(snapshot, 'confirmed');
@@ -1267,37 +1627,49 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1267
1627
  ...reviewList(sectionsData.risks?.risks),
1268
1628
  ...reviewList(sectionsData.risks?.openQuestions),
1269
1629
  ]);
1270
- const panels = [
1271
- renderReviewPanel({
1630
+ const panelSpecs = [
1631
+ {
1272
1632
  kind: 'flow',
1273
1633
  title: '主流程与边界情况',
1274
1634
  description: '确认用户旅程、关键步骤和恢复路径是否已经讲清楚,能否进入实现前确认',
1275
1635
  emptyText: '暂无主流程、边界情况或失败路径。',
1276
1636
  visual: renderReviewJourneySvg({ primaryFlows, edgeCases, failureModes }),
1277
1637
  items: flowPanelItems,
1278
- }),
1279
- renderReviewPanel({
1638
+ },
1639
+ {
1280
1640
  kind: 'function',
1281
1641
  title: '功能与约束',
1282
1642
  description: '区分必须交付、非功能要求和当前依赖假设',
1283
1643
  emptyText: '暂无功能、非功能要求或依赖约束。',
1284
1644
  items: functionPanelItems,
1285
- }),
1286
- renderReviewPanel({
1645
+ },
1646
+ {
1287
1647
  kind: 'guardrail',
1288
1648
  title: '业务成本与滥用护栏',
1289
1649
  description: '涉及免费额度、消耗型成本或第三方调用时,先确认限制、报警和止损动作',
1290
1650
  emptyText: '暂无业务成本或滥用护栏。',
1291
1651
  items: guardrailPanelItems,
1292
- }),
1293
- renderReviewPanel({
1652
+ },
1653
+ {
1294
1654
  kind: 'risk',
1295
1655
  title: '开放问题与风险',
1296
1656
  description: '需求定稿前还没关掉的问题要留在这里,不要默默假定解决',
1297
1657
  emptyText: '暂无假设、风险或开放问题。',
1298
1658
  items: riskPanelItems,
1299
- }),
1659
+ },
1300
1660
  ];
1661
+ const designDirectionsBlock = reviewDesignDirections(snapshot);
1662
+ const panelAgenda = [
1663
+ ...(designDirectionsBlock
1664
+ ? [{ kind: 'design-directions', title: '交互方向评审(选一个方向)', count: designDirectionsBlock.directions.length, unit: '个方向' }]
1665
+ : []),
1666
+ ...panelSpecs.map((spec) => ({
1667
+ kind: spec.kind,
1668
+ title: spec.title,
1669
+ count: Array.isArray(spec.items) ? spec.items.filter(Boolean).length : 0,
1670
+ })),
1671
+ ];
1672
+ const panels = panelSpecs.map((spec) => renderReviewPanel(spec));
1301
1673
  return `<!DOCTYPE html>
1302
1674
  <html lang="zh-CN">
1303
1675
  <head>
@@ -1342,6 +1714,76 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1342
1714
  gap: 16px;
1343
1715
  margin-bottom: 16px;
1344
1716
  }
1717
+ .review-stage-rail {
1718
+ margin: 0 0 16px;
1719
+ border: 1px solid var(--review-line);
1720
+ border-radius: 14px;
1721
+ background: var(--review-panel);
1722
+ padding: 12px 18px;
1723
+ overflow-x: auto;
1724
+ }
1725
+ .review-stage-rail ol {
1726
+ display: flex;
1727
+ align-items: center;
1728
+ gap: 6px;
1729
+ margin: 0;
1730
+ padding: 0;
1731
+ list-style: none;
1732
+ min-width: max-content;
1733
+ }
1734
+ .review-stage {
1735
+ display: inline-flex;
1736
+ align-items: center;
1737
+ gap: 8px;
1738
+ padding: 6px 10px;
1739
+ border-radius: 999px;
1740
+ font-size: 13px;
1741
+ color: var(--review-muted);
1742
+ white-space: nowrap;
1743
+ }
1744
+ .review-stage + .review-stage::before {
1745
+ content: '→';
1746
+ margin-right: 6px;
1747
+ color: var(--review-line);
1748
+ }
1749
+ .review-stage-dot {
1750
+ display: inline-grid;
1751
+ place-items: center;
1752
+ width: 22px;
1753
+ height: 22px;
1754
+ border-radius: 999px;
1755
+ border: 1px solid var(--review-line);
1756
+ background: var(--review-panel-soft);
1757
+ font-size: 12px;
1758
+ font-weight: 800;
1759
+ }
1760
+ .review-stage.done .review-stage-dot {
1761
+ border-color: #86efac;
1762
+ background: #dcfce7;
1763
+ color: #15803d;
1764
+ }
1765
+ .review-stage.done .review-stage-name {
1766
+ color: var(--review-text);
1767
+ }
1768
+ .review-stage.current {
1769
+ background: #eef2ff;
1770
+ border: 1px solid #c7d2fe;
1771
+ color: var(--review-indigo);
1772
+ font-weight: 800;
1773
+ }
1774
+ .review-stage.current .review-stage-dot {
1775
+ border-color: var(--review-indigo);
1776
+ background: var(--review-indigo);
1777
+ color: #fff;
1778
+ }
1779
+ .review-stage-badge {
1780
+ font-size: 11px;
1781
+ font-weight: 800;
1782
+ color: #fff;
1783
+ background: var(--review-indigo);
1784
+ border-radius: 999px;
1785
+ padding: 2px 8px;
1786
+ }
1345
1787
  .review-brand {
1346
1788
  display: inline-flex;
1347
1789
  align-items: center;
@@ -1399,9 +1841,116 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1399
1841
  box-shadow: 0 16px 34px rgba(15, 23, 42, 0.06);
1400
1842
  }
1401
1843
  .review-overview {
1402
- display: block;
1844
+ display: grid;
1845
+ grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
1846
+ gap: 24px;
1847
+ align-items: start;
1403
1848
  padding: 24px;
1404
1849
  }
1850
+ .review-goal {
1851
+ display: flex;
1852
+ flex-wrap: wrap;
1853
+ align-items: baseline;
1854
+ gap: 8px;
1855
+ max-width: 760px;
1856
+ margin: 14px 0 0;
1857
+ color: var(--review-text);
1858
+ font-size: 15px;
1859
+ line-height: 1.7;
1860
+ }
1861
+ .review-goal-label {
1862
+ flex-shrink: 0;
1863
+ padding: 2px 10px;
1864
+ border-radius: 999px;
1865
+ border: 1px solid rgba(37,99,235,0.3);
1866
+ background: rgba(37,99,235,0.06);
1867
+ color: var(--review-blue);
1868
+ font-size: 12px;
1869
+ font-weight: 600;
1870
+ }
1871
+ .review-agenda {
1872
+ padding: 16px 18px;
1873
+ border-radius: 14px;
1874
+ border: 1px solid var(--review-line);
1875
+ background: var(--review-panel-soft);
1876
+ }
1877
+ .review-agenda-title {
1878
+ margin: 0;
1879
+ color: var(--review-text);
1880
+ font-size: 14px;
1881
+ font-weight: 700;
1882
+ }
1883
+ .review-agenda-list {
1884
+ margin: 12px 0 0;
1885
+ padding: 0;
1886
+ list-style: none;
1887
+ display: grid;
1888
+ gap: 6px;
1889
+ counter-reset: agenda;
1890
+ }
1891
+ .review-agenda-list li { counter-increment: agenda; }
1892
+ .review-agenda-list a {
1893
+ display: flex;
1894
+ align-items: center;
1895
+ gap: 10px;
1896
+ padding: 8px 10px;
1897
+ border-radius: 10px;
1898
+ border: 1px solid transparent;
1899
+ color: var(--review-text);
1900
+ text-decoration: none;
1901
+ font-size: 14px;
1902
+ line-height: 1.5;
1903
+ }
1904
+ .review-agenda-list a::before {
1905
+ content: counter(agenda);
1906
+ flex-shrink: 0;
1907
+ width: 20px;
1908
+ height: 20px;
1909
+ display: inline-flex;
1910
+ align-items: center;
1911
+ justify-content: center;
1912
+ border-radius: 999px;
1913
+ background: rgba(37,99,235,0.1);
1914
+ color: var(--review-blue);
1915
+ font-size: 12px;
1916
+ font-weight: 700;
1917
+ }
1918
+ .review-agenda-list a:hover {
1919
+ border-color: var(--review-line);
1920
+ background: #ffffff;
1921
+ }
1922
+ .review-agenda-name { flex: 1; min-width: 0; }
1923
+ .review-agenda-count {
1924
+ flex-shrink: 0;
1925
+ color: var(--review-blue);
1926
+ font-size: 12px;
1927
+ font-weight: 600;
1928
+ }
1929
+ .review-agenda-count.empty { color: var(--review-muted); font-weight: 400; }
1930
+ .review-agenda-foot {
1931
+ margin: 12px 0 0;
1932
+ color: var(--review-muted);
1933
+ font-size: 13px;
1934
+ line-height: 1.6;
1935
+ }
1936
+ .review-panel-count {
1937
+ flex-shrink: 0;
1938
+ margin-left: auto;
1939
+ padding: 3px 10px;
1940
+ border-radius: 999px;
1941
+ border: 1px solid rgba(37,99,235,0.25);
1942
+ background: rgba(37,99,235,0.06);
1943
+ color: var(--review-blue);
1944
+ font-size: 12px;
1945
+ font-weight: 600;
1946
+ white-space: nowrap;
1947
+ }
1948
+ .review-panel-count.empty {
1949
+ border-color: var(--review-line);
1950
+ background: var(--review-panel-soft);
1951
+ color: var(--review-muted);
1952
+ font-weight: 400;
1953
+ }
1405
1954
  .review-overview-copy,
1406
1955
  .review-panel {
1407
1956
  min-width: 0;
@@ -1427,6 +1976,83 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1427
1976
  line-height: 1.75;
1428
1977
  overflow-wrap: anywhere;
1429
1978
  }
1979
+ .review-gap {
1980
+ margin-top: 18px;
1981
+ padding: 18px 20px;
1982
+ border-radius: 16px;
1983
+ border: 1px solid var(--review-line);
1984
+ background: var(--review-panel);
1985
+ box-shadow: 0 10px 24px rgba(23,32,51,0.05);
1986
+ }
1987
+ .review-gap-clear {
1988
+ border-color: rgba(21,128,61,0.3);
1989
+ background: #f2fbf5;
1990
+ }
1991
+ .review-gap-pending {
1992
+ border-color: rgba(180,83,9,0.3);
1993
+ background: #fffaf2;
1994
+ }
1995
+ .review-gap-headline {
1996
+ display: flex;
1997
+ flex-wrap: wrap;
1998
+ align-items: baseline;
1999
+ gap: 10px;
2000
+ font-size: 16px;
2001
+ }
2002
+ .review-gap-headline strong {
2003
+ color: var(--review-text);
2004
+ font-size: 17px;
2005
+ }
2006
+ .review-gap-dot {
2007
+ width: 10px;
2008
+ height: 10px;
2009
+ border-radius: 999px;
2010
+ align-self: center;
2011
+ }
2012
+ .review-gap-clear .review-gap-dot { background: var(--review-green); }
2013
+ .review-gap-pending .review-gap-dot { background: var(--review-amber); }
2014
+ .review-gap-sub {
2015
+ color: var(--review-muted);
2016
+ font-size: 14px;
2017
+ line-height: 1.6;
2018
+ }
2019
+ .review-gap-list {
2020
+ margin: 14px 0 0;
2021
+ padding: 0;
2022
+ list-style: none;
2023
+ display: grid;
2024
+ gap: 8px;
2025
+ }
2026
+ .review-gap-item {
2027
+ display: flex;
2028
+ flex-wrap: wrap;
2029
+ align-items: baseline;
2030
+ gap: 8px;
2031
+ padding: 10px 12px;
2032
+ border-radius: 12px;
2033
+ border: 1px solid rgba(180,83,9,0.16);
2034
+ background: rgba(255,255,255,0.75);
2035
+ font-size: 14px;
2036
+ line-height: 1.6;
2037
+ }
2038
+ .review-gap-item strong { color: var(--review-text); }
2039
+ .review-gap-hint { color: var(--review-muted); }
2040
+ .review-gap-tag {
2041
+ display: inline-flex;
2042
+ align-items: center;
2043
+ padding: 2px 8px;
2044
+ border-radius: 999px;
2045
+ border: 1px solid var(--review-line);
2046
+ background: #ffffff;
2047
+ color: var(--review-muted);
2048
+ font-size: 12px;
2049
+ font-weight: 600;
2050
+ }
2051
+ .review-gap-tag-key {
2052
+ border-color: rgba(220,38,38,0.35);
2053
+ color: var(--review-red);
2054
+ background: rgba(220,38,38,0.06);
2055
+ }
1430
2056
  .review-map {
1431
2057
  margin-top: 18px;
1432
2058
  padding: 20px;
@@ -1752,6 +2378,113 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1752
2378
  box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
1753
2379
  transform: translateY(0);
1754
2380
  }
2381
+ .review-directions {
2382
+ margin-top: 28px;
2383
+ }
2384
+ .review-direction-grid {
2385
+ display: grid;
2386
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
2387
+ gap: 18px;
2388
+ margin-top: 14px;
2389
+ }
2390
+ .review-direction-card {
2391
+ display: flex;
2392
+ flex-direction: column;
2393
+ background: var(--review-panel);
2394
+ border: 1px solid var(--review-line);
2395
+ border-radius: 16px;
2396
+ overflow: hidden;
2397
+ box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
2398
+ transition: border-color 160ms ease, box-shadow 160ms ease;
2399
+ }
2400
+ .review-direction-card.selected {
2401
+ border-color: var(--review-indigo);
2402
+ box-shadow: 0 0 0 2px var(--review-indigo), 0 12px 24px rgba(79, 70, 229, 0.16);
2403
+ }
2404
+ .review-direction-card.selected .review-direction-select {
2405
+ border-color: var(--review-indigo);
2406
+ background: var(--review-indigo);
2407
+ color: #fff;
2408
+ }
2409
+ .review-direction-image {
2410
+ display: block;
2411
+ background: var(--review-panel-soft);
2412
+ border-bottom: 1px solid var(--review-line);
2413
+ aspect-ratio: 4 / 3;
2414
+ overflow: hidden;
2415
+ }
2416
+ .review-direction-image img {
2417
+ display: block;
2418
+ width: 100%;
2419
+ height: 100%;
2420
+ object-fit: cover;
2421
+ }
2422
+ .review-direction-image-missing {
2423
+ display: flex;
2424
+ align-items: center;
2425
+ justify-content: center;
2426
+ color: var(--review-muted);
2427
+ font-size: 14px;
2428
+ }
2429
+ .review-direction-body {
2430
+ display: flex;
2431
+ flex-direction: column;
2432
+ gap: 8px;
2433
+ padding: 16px;
2434
+ }
2435
+ .review-direction-head {
2436
+ display: flex;
2437
+ align-items: center;
2438
+ gap: 8px;
2439
+ }
2440
+ .review-direction-head h3 {
2441
+ margin: 0;
2442
+ font-size: 17px;
2443
+ }
2444
+ .review-direction-index {
2445
+ flex-shrink: 0;
2446
+ border: 1px solid #c7d2fe;
2447
+ background: #eef2ff;
2448
+ color: var(--review-indigo);
2449
+ border-radius: 999px;
2450
+ padding: 2px 10px;
2451
+ font-size: 12px;
2452
+ font-weight: 800;
2453
+ }
2454
+ .review-direction-summary {
2455
+ margin: 0;
2456
+ color: var(--review-text);
2457
+ font-size: 14px;
2458
+ line-height: 1.6;
2459
+ }
2460
+ .review-direction-tradeoff {
2461
+ margin: 0;
2462
+ color: var(--review-muted);
2463
+ font-size: 13px;
2464
+ line-height: 1.6;
2465
+ }
2466
+ .review-direction-select {
2467
+ cursor: pointer;
2468
+ align-self: flex-start;
2469
+ margin-top: 4px;
2470
+ min-height: 38px;
2471
+ border: 1px solid #c7d2fe;
2472
+ border-radius: 10px;
2473
+ padding: 0 16px;
2474
+ font: inherit;
2475
+ font-size: 14px;
2476
+ font-weight: 800;
2477
+ background: #eef2ff;
2478
+ color: var(--review-indigo);
2479
+ transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease;
2480
+ }
2481
+ .review-direction-select:hover,
2482
+ .review-direction-select:focus-visible {
2483
+ border-color: #a5b4fc;
2484
+ background: #e0e7ff;
2485
+ transform: translateY(-1px);
2486
+ outline: none;
2487
+ }
1755
2488
  @media (max-width: 860px) {
1756
2489
  .review-overview {
1757
2490
  grid-template-columns: 1fr;
@@ -1759,6 +2492,9 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1759
2492
  .review-panel-grid {
1760
2493
  grid-template-columns: 1fr;
1761
2494
  }
2495
+ .review-direction-grid {
2496
+ grid-template-columns: 1fr;
2497
+ }
1762
2498
  }
1763
2499
  @media (max-width: 620px) {
1764
2500
  .review-page { padding: 18px 12px 128px; }
@@ -1797,7 +2533,10 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1797
2533
  <div class="review-brand">OpenPrd / 评审面板</div>
1798
2534
  ${renderReviewProjectVersion(visibleProjectRelease)}
1799
2535
  </header>
1800
- ${renderReviewOverview(snapshot, sectionsData)}
2536
+ ${renderReviewStageRail(snapshot)}
2537
+ ${renderReviewOverview(snapshot, sectionsData, panelAgenda)}
2538
+ ${renderReviewGapPanel(sectionsData)}
2539
+ ${renderReviewDesignDirections(snapshot)}
1801
2540
  ${renderReviewFlowSvg(snapshot, sectionsData)}
1802
2541
  <section class="review-panel-grid" aria-label="固定评审项">
1803
2542
  ${panels.join('\n')}
@@ -1830,6 +2569,18 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1830
2569
  flashCopied(button);
1831
2570
  });
1832
2571
  });
2572
+ document.querySelectorAll('.review-direction-select').forEach((button) => {
2573
+ button.addEventListener('click', () => {
2574
+ document.querySelectorAll('.review-direction-card').forEach((card) => {
2575
+ card.classList.remove('selected');
2576
+ const cardButton = card.querySelector('.review-direction-select');
2577
+ if (cardButton && cardButton !== button) cardButton.textContent = '选这个方向';
2578
+ });
2579
+ const ownCard = button.closest('.review-direction-card');
2580
+ if (ownCard) ownCard.classList.add('selected');
2581
+ setTimeout(() => { button.textContent = '已选定,把复制内容发回给 Agent'; }, 1250);
2582
+ });
2583
+ });
1833
2584
  </script>
1834
2585
  </main>
1835
2586
  </body>
@@ -1945,12 +2696,12 @@ export function renderPlaygroundArtifact({ snapshot, state, markdownPath, patchP
1945
2696
  <style>
1946
2697
  :root {
1947
2698
  color-scheme: light;
1948
- --bg: #fffaf0;
2699
+ --bg: #f6f8fb;
1949
2700
  --panel: #ffffff;
1950
- --line: rgba(15,23,42,0.12);
1951
- --text: #1f2937;
1952
- --muted: #6b7280;
1953
- --accent: #0f766e;
2701
+ --line: #d8dee8;
2702
+ --text: #172033;
2703
+ --muted: #667085;
2704
+ --accent: #2563eb;
1954
2705
  }
1955
2706
  * { box-sizing: border-box; }
1956
2707
  body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
@@ -1967,8 +2718,8 @@ export function renderPlaygroundArtifact({ snapshot, state, markdownPath, patchP
1967
2718
  textarea { width: 100%; border: 1px solid var(--line); border-radius: 12px; padding: 12px; font: inherit; line-height: 1.6; resize: vertical; }
1968
2719
  .actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
1969
2720
  button { border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; background: #fff; cursor: pointer; }
1970
- .primary { background: #0f766e; color: #fff; border-color: #0f766e; }
1971
- pre { margin: 0; border-radius: 14px; background: #111827; color: #e5e7eb; padding: 14px; overflow: auto; white-space: pre-wrap; line-height: 1.6; font-size: 13px; }
2721
+ .primary { background: var(--accent); color: #fff; border-color: var(--accent); }
2722
+ pre { margin: 0; border-radius: 14px; border: 1px solid var(--line); background: #f9fafb; color: var(--text); padding: 14px; overflow: auto; white-space: pre-wrap; line-height: 1.6; font-size: 13px; }
1972
2723
  .hint { color: var(--muted); font-size: 13px; line-height: 1.6; }
1973
2724
  .top-meta {
1974
2725
  display: flex;
@@ -1982,7 +2733,7 @@ export function renderPlaygroundArtifact({ snapshot, state, markdownPath, patchP
1982
2733
  padding: 6px 10px;
1983
2734
  border-radius: 999px;
1984
2735
  border: 1px solid var(--line);
1985
- background: rgba(255,255,255,0.85);
2736
+ background: #ffffff;
1986
2737
  color: var(--muted);
1987
2738
  font-size: 12px;
1988
2739
  }
@@ -2180,14 +2931,29 @@ export function renderReviewArtifact({ snapshot, projectRelease = null }) {
2180
2931
 
2181
2932
  export function renderRegressionArtifact({ task, report }) {
2182
2933
  const passed = report.summary.failed === 0;
2934
+ const failedCases = (report.cases ?? []).filter((item) => !item.passed);
2183
2935
  const summaryCards = [
2184
2936
  metricCard('任务', task.id, task.title),
2185
2937
  metricCard('验证方式', report.kind || 'command', report.verifyCommand || '未指定'),
2186
2938
  metricCard('通过用例', `${report.summary.passed}/${report.summary.total}`, '本次回归通过的测试用例数量'),
2187
2939
  metricCard('失败用例', `${report.summary.failed}`, '需要继续修复或补证据的测试用例数量'),
2188
2940
  ];
2941
+ const verdictCard = card('这次验证的结论', passed
2942
+ ? `
2943
+ <div class="qa-item success">
2944
+ <div class="qa-label">全部 ${report.summary.total} 个用例都通过了</div>
2945
+ <div class="qa-meta">这部分功能按预期工作,可以放心进入下一步。想继续推进就用底部按钮告诉我。</div>
2946
+ </div>
2947
+ `
2948
+ : `
2949
+ <div class="qa-item warning">
2950
+ <div class="qa-label">还有 ${report.summary.failed} 个用例没有通过</div>
2951
+ <div class="qa-meta">没过的是:${failedCases.map((item) => escapeHtml(item.title || item.id)).join('、') || '见下方清单'}。建议先让我继续修复,再重新跑一次验证。</div>
2952
+ </div>
2953
+ `);
2189
2954
 
2190
2955
  const sections = [
2956
+ verdictCard,
2191
2957
  card('回归用例清单', report.cases.map((item) => `
2192
2958
  <div class="qa-item ${item.passed ? 'success' : 'warning'}">
2193
2959
  <div class="qa-label">${escapeHtml(item.id)} · ${escapeHtml(item.title)}</div>
@@ -2226,6 +2992,14 @@ export function renderRegressionArtifact({ task, report }) {
2226
2992
  summaryCards,
2227
2993
  sections,
2228
2994
  footer: '',
2995
+ bottomActions: passed
2996
+ ? [
2997
+ { label: '确认通过,继续下一步', tone: 'primary', copyText: `回归报告 ${task.id} 我看过了,全部用例通过,确认继续推进下一步。` },
2998
+ ]
2999
+ : [
3000
+ { label: '先修复未通过的用例', tone: 'danger', copyText: `回归报告 ${task.id} 里还有 ${report.summary.failed} 个用例没通过(${failedCases.map((item) => item.id).join('、')}),请继续修复后重新跑一次验证。` },
3001
+ { label: '我接受现状,继续下一步', tone: 'neutral', copyText: `回归报告 ${task.id} 的未通过用例我已知晓,先按现状继续推进,后续再处理。` },
3002
+ ],
2229
3003
  });
2230
3004
  }
2231
3005