@openprd/cli 0.1.11 → 0.1.19

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 (147) hide show
  1. package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
  2. package/.openprd/changes/adaptive-project-framing/design.md +47 -0
  3. package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
  4. package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
  6. package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
  7. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
  8. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
  9. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
  10. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
  11. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
  12. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
  13. package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
  14. package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
  15. package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
  16. package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
  17. package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
  18. package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
  19. package/.openprd/changes/openprd/.openprd.yaml +2 -0
  20. package/.openprd/changes/openprd/design.md +46 -0
  21. package/.openprd/changes/openprd/proposal.md +36 -0
  22. package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
  23. package/.openprd/changes/openprd/task-events.jsonl +23 -0
  24. package/.openprd/changes/openprd/tasks.md +242 -0
  25. package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
  26. package/.openprd/changes/openprd-generated-change/design.md +50 -0
  27. package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
  28. package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
  29. package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
  30. package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
  31. package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
  32. package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
  33. package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
  34. package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
  35. package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
  36. package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
  37. package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
  38. package/.openprd/changes/test-strategy-router/design.md +65 -0
  39. package/.openprd/changes/test-strategy-router/proposal.md +45 -0
  40. package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
  41. package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
  42. package/.openprd/changes/test-strategy-router/tasks.md +132 -0
  43. package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
  44. package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
  45. package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
  46. package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
  47. package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
  48. package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
  49. package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
  50. package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
  51. package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
  52. package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
  53. package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
  54. package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
  55. package/.openprd/design/README.md +11 -6
  56. package/.openprd/design/README_EN.md +4 -1
  57. package/.openprd/design/active/asset-spec.md +6 -0
  58. package/.openprd/design/active/direction-plan.md +5 -5
  59. package/.openprd/design/active/selected-direction.md +2 -0
  60. package/.openprd/design/anti-slop.md +26 -1
  61. package/.openprd/design/assets/surface-presets.md +6 -0
  62. package/.openprd/design/checklists/ui-quality-gate.md +7 -0
  63. package/.openprd/design/lenses/frontend-lenses.md +28 -0
  64. package/.openprd/design/recipes/content-experience.md +8 -1
  65. package/.openprd/design/recipes/operational-tool.md +7 -0
  66. package/.openprd/design/recipes/product-launch.md +8 -1
  67. package/.openprd/design/templates/README.md +6 -3
  68. package/.openprd/design/templates/README_EN.md +6 -3
  69. package/.openprd/design/templates/ops-dashboard.html +8 -1
  70. package/.openprd/design/templates/product-launch.html +2 -1
  71. package/.openprd/design/themes/theme-catalog.json +23 -5
  72. package/.openprd/engagements/active/prd.md +137 -112
  73. package/.openprd/standards/config.json +7 -1
  74. package/AGENTS.md +13 -3
  75. package/README.md +127 -14
  76. package/README_EN.md +156 -13
  77. package/package.json +1 -1
  78. package/scripts/dev-check-wrapup-copy.mjs +46 -21
  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 +2 -0
  88. package/skills/openprd-router/SKILL.md +2 -1
  89. package/skills/openprd-shared/SKILL.md +26 -16
  90. package/skills/openprd-standards/SKILL.md +4 -2
  91. package/src/agent-canonical-content.js +810 -0
  92. package/src/agent-integration.js +258 -777
  93. package/src/canvas-app-client-app.js +838 -0
  94. package/src/canvas-app-client-helpers.js +1138 -0
  95. package/src/canvas-app-styles.js +898 -0
  96. package/src/canvas-app.html.js +135 -0
  97. package/src/canvas-binding.js +414 -0
  98. package/src/canvas-codex-relay.js +430 -0
  99. package/src/canvas-i18n.js +282 -0
  100. package/src/canvas-scene.js +377 -0
  101. package/src/canvas-session-store.js +148 -0
  102. package/src/canvas-workspace.js +1264 -0
  103. package/src/cli/args.js +72 -4
  104. package/src/cli/basic-print.js +3 -0
  105. package/src/cli/canvas-print.js +106 -0
  106. package/src/cli/doctor-print.js +20 -0
  107. package/src/cli/print.js +2 -0
  108. package/src/cli/quality-commands.js +222 -0
  109. package/src/cli/quality-print.js +28 -2
  110. package/src/cli/run-print.js +5 -0
  111. package/src/cli/workflow-print.js +3 -0
  112. package/src/codex-app-server-relay.js +251 -0
  113. package/src/codex-hook-runner-template.mjs +978 -58
  114. package/src/design-starter-support.js +2 -2
  115. package/src/design-starter.js +39 -3
  116. package/src/dev-standards.js +125 -10
  117. package/src/diagram-core.js +6 -20
  118. package/src/discovery.js +2 -8
  119. package/src/docs-compact.js +125 -0
  120. package/src/execution-strategy.js +1 -1
  121. package/src/fleet.js +9 -3
  122. package/src/guide.js +227 -0
  123. package/src/html-artifacts.js +428 -7
  124. package/src/knowledge.js +45 -11
  125. package/src/language-policy.js +63 -3
  126. package/src/learning-review.js +1 -1
  127. package/src/loop.js +1 -1
  128. package/src/openprd.js +68 -131
  129. package/src/openspec/change-validate.js +2 -2
  130. package/src/openspec/constants.js +6 -7
  131. package/src/openspec/execute.js +2 -2
  132. package/src/openspec/generate.js +318 -102
  133. package/src/openspec/migration.js +168 -0
  134. package/src/openspec/paths.js +1 -22
  135. package/src/prd-core.js +1 -1
  136. package/src/quality-visual-review.js +257 -14
  137. package/src/quality.js +46 -8
  138. package/src/review-presentation.js +60 -0
  139. package/src/run-harness.js +41 -3
  140. package/src/session-evidence.js +363 -0
  141. package/src/standards.js +64 -0
  142. package/src/test-strategy.js +7 -4
  143. package/src/visual-compare-centering.js +622 -0
  144. package/src/visual-compare-core.js +1123 -0
  145. package/src/visual-compare.js +620 -698
  146. package/src/workspace-core.js +94 -4
  147. package/src/workspace-workflow.js +46 -1
@@ -491,9 +491,33 @@ const REVIEW_PRESENTATION_CONTRACT = {
491
491
  USER_CHANGE_SUMMARY_GUIDE.panelExamples.risk,
492
492
  ],
493
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
+ },
494
506
  },
495
507
  },
496
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
+ },
497
521
  {
498
522
  id: 'review-map-card-text',
499
523
  area: '需求关系图 / 需求流程图',
@@ -627,12 +651,125 @@ export function buildReviewPresentationFeedback(snapshot) {
627
651
  });
628
652
  });
629
653
 
654
+ validateReviewDesignDirections(snapshot, violations, addViolation);
655
+
630
656
  return {
631
657
  contract: REVIEW_PRESENTATION_CONTRACT,
632
658
  violations,
633
659
  };
634
660
  }
635
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
+
636
773
  function normalizedReviewVisibleText(value) {
637
774
  return String(value ?? '').replace(/\s+/g, ' ').trim();
638
775
  }
@@ -869,6 +1006,31 @@ function renderReviewProjectVersion(projectRelease) {
869
1006
  `;
870
1007
  }
871
1008
 
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
+
872
1034
  function renderReviewOverview(snapshot, sectionsData, agenda = []) {
873
1035
  const problem = sectionsData.problem?.problemStatement || '尚未形成明确问题定义';
874
1036
  const goal = reviewList(sectionsData.goals?.goals)[0] ?? null;
@@ -880,7 +1042,7 @@ function renderReviewOverview(snapshot, sectionsData, agenda = []) {
880
1042
  <li>
881
1043
  <a href="#review-panel-${escapeHtml(item.kind)}">
882
1044
  <span class="review-agenda-name">${escapeHtml(item.title)}</span>
883
- <span class="review-agenda-count${item.count === 0 ? ' empty' : ''}">${item.count === 0 ? '暂无内容' : `${item.count} 条`}</span>
1045
+ <span class="review-agenda-count${item.count === 0 ? ' empty' : ''}">${item.count === 0 ? '暂无内容' : `${item.count} ${escapeHtml(item.unit || '条')}`}</span>
884
1046
  </a>
885
1047
  </li>`).join('')}
886
1048
  </ol>
@@ -1336,7 +1498,7 @@ function shellQuote(value) {
1336
1498
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
1337
1499
  }
1338
1500
 
1339
- function reviewCommand(snapshot, status, notes = null) {
1501
+ function reviewCommand(snapshot, status, notes = null, extra = {}) {
1340
1502
  const parts = ['openprd review . --mark', status];
1341
1503
  if (snapshot.versionId) {
1342
1504
  parts.push('--version', shellQuote(snapshot.versionId));
@@ -1347,12 +1509,71 @@ function reviewCommand(snapshot, status, notes = null) {
1347
1509
  if (snapshot.workUnitId) {
1348
1510
  parts.push('--work-unit', shellQuote(snapshot.workUnitId));
1349
1511
  }
1512
+ if (extra.direction) {
1513
+ parts.push('--direction', shellQuote(extra.direction));
1514
+ }
1350
1515
  if (notes) {
1351
1516
  parts.push('--notes', shellQuote(notes));
1352
1517
  }
1353
1518
  return parts.join(' ');
1354
1519
  }
1355
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
+
1356
1577
  function renderReviewDecision(snapshot) {
1357
1578
  const payload = JSON.stringify(buildReviewExportPayload(snapshot), null, 2);
1358
1579
  const confirmCommand = reviewCommand(snapshot, 'confirmed');
@@ -1437,11 +1658,17 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1437
1658
  items: riskPanelItems,
1438
1659
  },
1439
1660
  ];
1440
- const panelAgenda = panelSpecs.map((spec) => ({
1441
- kind: spec.kind,
1442
- title: spec.title,
1443
- count: Array.isArray(spec.items) ? spec.items.filter(Boolean).length : 0,
1444
- }));
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
+ ];
1445
1672
  const panels = panelSpecs.map((spec) => renderReviewPanel(spec));
1446
1673
  return `<!DOCTYPE html>
1447
1674
  <html lang="zh-CN">
@@ -1487,6 +1714,76 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1487
1714
  gap: 16px;
1488
1715
  margin-bottom: 16px;
1489
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
+ }
1490
1787
  .review-brand {
1491
1788
  display: inline-flex;
1492
1789
  align-items: center;
@@ -2081,6 +2378,113 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2081
2378
  box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
2082
2379
  transform: translateY(0);
2083
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
+ }
2084
2488
  @media (max-width: 860px) {
2085
2489
  .review-overview {
2086
2490
  grid-template-columns: 1fr;
@@ -2088,6 +2492,9 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2088
2492
  .review-panel-grid {
2089
2493
  grid-template-columns: 1fr;
2090
2494
  }
2495
+ .review-direction-grid {
2496
+ grid-template-columns: 1fr;
2497
+ }
2091
2498
  }
2092
2499
  @media (max-width: 620px) {
2093
2500
  .review-page { padding: 18px 12px 128px; }
@@ -2126,8 +2533,10 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2126
2533
  <div class="review-brand">OpenPrd / 评审面板</div>
2127
2534
  ${renderReviewProjectVersion(visibleProjectRelease)}
2128
2535
  </header>
2536
+ ${renderReviewStageRail(snapshot)}
2129
2537
  ${renderReviewOverview(snapshot, sectionsData, panelAgenda)}
2130
2538
  ${renderReviewGapPanel(sectionsData)}
2539
+ ${renderReviewDesignDirections(snapshot)}
2131
2540
  ${renderReviewFlowSvg(snapshot, sectionsData)}
2132
2541
  <section class="review-panel-grid" aria-label="固定评审项">
2133
2542
  ${panels.join('\n')}
@@ -2160,6 +2569,18 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2160
2569
  flashCopied(button);
2161
2570
  });
2162
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
+ });
2163
2584
  </script>
2164
2585
  </main>
2165
2586
  </body>
package/src/knowledge.js CHANGED
@@ -3,6 +3,13 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { appendJsonl, cjoin, exists, readJson, readJsonl, writeJson, writeText } from './fs-utils.js';
5
5
  import { resolveQualityLearningSource } from './quality-learning.js';
6
+ import {
7
+ buildSessionEvidenceReviewSource,
8
+ extractSessionEvidenceRecords,
9
+ filterGenericFrameworkTokens,
10
+ normalizeSessionEvidenceRecords,
11
+ sanitizeEvidenceText,
12
+ } from './session-evidence.js';
6
13
  import { timestamp } from './time.js';
7
14
 
8
15
  const KNOWLEDGE_DIR = cjoin('.openprd', 'knowledge');
@@ -211,7 +218,7 @@ function normalizeSearchTokens(value) {
211
218
  .map((item) => item.trim())
212
219
  .filter((item) => item.length >= 3);
213
220
  const hanTokens = String(value ?? '').match(/[\u4e00-\u9fa5]{2,}/g) ?? [];
214
- return uniq([...asciiTokens, ...hanTokens]);
221
+ return filterGenericFrameworkTokens(uniq([...asciiTokens, ...hanTokens]));
215
222
  }
216
223
 
217
224
  function sortByLength(items = []) {
@@ -253,16 +260,17 @@ function scoreQueryAgainstFields(queryText, queryTokens, fields = []) {
253
260
  let score = 0;
254
261
  const matchedOn = [];
255
262
  for (const field of fields) {
256
- const text = String(field ?? '').trim();
263
+ const text = sanitizeEvidenceText(field);
257
264
  if (!text) continue;
258
265
  const normalized = normalizeSearchText(text);
259
266
  if (!normalized) continue;
267
+ const fieldTokens = normalizeSearchTokens(text);
268
+ if (fieldTokens.length === 0) continue;
260
269
  let matched = false;
261
270
  if (normalized.length >= 6 && queryText.includes(normalized)) {
262
271
  matched = true;
263
272
  score += normalized.length >= 18 ? 10 : 7;
264
273
  } else {
265
- const fieldTokens = normalizeSearchTokens(text);
266
274
  const overlap = fieldTokens.filter((token) => queryTokens.includes(token));
267
275
  if (overlap.length > 0) {
268
276
  matched = true;
@@ -889,10 +897,11 @@ function buildTurnReviewTitle(raw, source, touchedFiles = []) {
889
897
  ?? '项目经验草案';
890
898
  }
891
899
 
892
- function knowledgeCandidateFingerprint(categories = [], touchedFiles = []) {
900
+ function knowledgeCandidateFingerprint(categories = [], touchedFiles = [], sourceSeed = null) {
893
901
  const normalizedCategories = [...new Set((categories ?? []).map((item) => String(item ?? '').trim()).filter(Boolean))].sort();
894
902
  const normalizedFiles = [...new Set((touchedFiles ?? []).map((item) => String(item ?? '').trim()).filter(Boolean))].sort();
895
- return crypto.createHash('sha256').update(JSON.stringify([normalizedCategories, normalizedFiles])).digest('hex').slice(0, 16);
903
+ const normalizedSourceSeed = String(sourceSeed ?? '').trim();
904
+ return crypto.createHash('sha256').update(JSON.stringify([normalizedCategories, normalizedFiles, normalizedSourceSeed])).digest('hex').slice(0, 16);
896
905
  }
897
906
 
898
907
  async function findPendingCandidateByFingerprint(projectRoot, fingerprint, excludeId) {
@@ -1195,6 +1204,7 @@ function buildKnowledgeCandidateMeta({
1195
1204
  updatedAt: timestamp(),
1196
1205
  sourceKind: source.kind,
1197
1206
  sourceRef: source.sourceId,
1207
+ evidenceOnly: source.evidenceOnly === true,
1198
1208
  title,
1199
1209
  summary,
1200
1210
  categories,
@@ -1405,6 +1415,11 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
1405
1415
  const turnStateSource = (await exists(knowledgePath(projectRoot, OPENPRD_HARNESS_TURN_STATE))) ? OPENPRD_HARNESS_TURN_STATE : null;
1406
1416
  const from = firstString(options.from, turnStateSource, latestReportPath);
1407
1417
  const rawInput = from ? await loadRawReviewInput(projectRoot, from) : { sourcePath: null, raw: null };
1418
+ const evidenceRecords = normalizeSessionEvidenceRecords([
1419
+ ...normalizeSessionEvidenceRecords(options.evidenceRecords),
1420
+ ...extractSessionEvidenceRecords(options.sessionEvents ?? []),
1421
+ ]);
1422
+ const cleanEvidenceRecords = evidenceRecords.filter((record) => record.sourceKind !== 'context-only');
1408
1423
  const resolved = from
1409
1424
  ? await resolveQualityLearningSource(projectRoot, {
1410
1425
  from,
@@ -1414,13 +1429,20 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
1414
1429
  : { ok: false, error: 'no-review-source' };
1415
1430
  const source = resolved.ok
1416
1431
  ? resolved.source
1432
+ : (cleanEvidenceRecords.length > 0
1433
+ ? buildSessionEvidenceReviewSource({
1434
+ records: cleanEvidenceRecords,
1435
+ sourceId: firstString(options.sourceId, options.title, cleanEvidenceRecords[0]?.text),
1436
+ sourcePath: rawInput.sourcePath ?? latestReportPath ?? KNOWLEDGE_REVIEW_SIGNAL_LOG,
1437
+ title: firstString(options.title, cleanEvidenceRecords.find((record) => record.sourceKind === 'user-direct')?.text),
1438
+ })
1417
1439
  : buildSyntheticReviewSource(projectRoot, {
1418
1440
  signal: options.signal,
1419
1441
  recentSignals,
1420
1442
  touchedFiles: options.touchedFiles,
1421
1443
  sourcePath: rawInput.sourcePath ?? latestReportPath ?? KNOWLEDGE_REVIEW_SIGNAL_LOG,
1422
1444
  title: firstString(options.title, readJsonObject(rawInput.raw)?.title),
1423
- });
1445
+ }));
1424
1446
  if (!resolved.ok && source.evidenceSources.length === 0 && source.rootCauseCandidates.length === 0) {
1425
1447
  return {
1426
1448
  ok: true,
@@ -1455,18 +1477,20 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
1455
1477
  const reviewSignals = reviewContext.reviewSignals;
1456
1478
  const categories = buildKnowledgeCategories({ source, touchedFiles: substantiveTouchedFiles, reviewSignals });
1457
1479
  const reasons = categories.map(categoryReason);
1480
+ const allowEvidenceOnly = options.allowEvidenceOnly === true || source.evidenceOnly === true || source.kind === 'session-evidence-review';
1458
1481
  const hasStrongSignal = categories.length > 0
1459
1482
  || source.rootCauseCandidates.length > 0
1460
1483
  || source.symptoms.length > 1
1461
1484
  || source.kind === 'completion-review'
1485
+ || source.kind === 'session-evidence-review'
1462
1486
  || reviewSignals.some((signal) => signal.ok === true || signal.productionReady === true);
1463
1487
 
1464
- if (substantiveTouchedFiles.length === 0 || !hasStrongSignal) {
1488
+ if ((substantiveTouchedFiles.length === 0 && !allowEvidenceOnly) || !hasStrongSignal) {
1465
1489
  return {
1466
1490
  ok: true,
1467
1491
  action: 'quality-knowledge-review',
1468
1492
  skipped: true,
1469
- reason: substantiveTouchedFiles.length === 0 ? 'no-substantive-touched-files' : 'no-knowledge-signal',
1493
+ reason: substantiveTouchedFiles.length === 0 && !allowEvidenceOnly ? 'no-substantive-touched-files' : 'no-knowledge-signal',
1470
1494
  sourceKind: source.kind,
1471
1495
  sourcePath: source.sourcePath,
1472
1496
  };
@@ -1477,7 +1501,11 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
1477
1501
  let candidateId = rawCandidateRef
1478
1502
  ? (rawCandidateRef.startsWith('candidate-') ? rawCandidateRef : `candidate-${slugify(rawCandidateRef, 'knowledge')}`)
1479
1503
  : `candidate-${slugify(source.sourceId ?? title, 'knowledge')}`;
1480
- const fingerprint = knowledgeCandidateFingerprint(categories, substantiveTouchedFiles);
1504
+ const fingerprint = knowledgeCandidateFingerprint(
1505
+ categories,
1506
+ substantiveTouchedFiles,
1507
+ allowEvidenceOnly ? firstString(source.sourceId, source.title, source.sourcePath) : null,
1508
+ );
1481
1509
  const duplicateCandidate = await findPendingCandidateByFingerprint(projectRoot, fingerprint, candidateId);
1482
1510
  if (duplicateCandidate?.candidateId) {
1483
1511
  candidateId = duplicateCandidate.candidateId;
@@ -1493,11 +1521,15 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
1493
1521
  const existingCandidate = await readJson(candidatePath).catch(() => null);
1494
1522
  const relativeCandidateDir = path.relative(projectRoot, candidateDir).split(path.sep).join('/');
1495
1523
  const relativeDraftSkillPath = path.relative(projectRoot, draftSkillPath).split(path.sep).join('/');
1524
+ const scopeSummary = substantiveTouchedFiles.length > 0
1525
+ ? `本轮围绕 ${substantiveTouchedFiles.length} 个可沉淀文件生成回顾。`
1526
+ : '本轮基于只读证据生成项目经验候选。';
1496
1527
  const reviewSummary = [
1497
- `本轮围绕 ${substantiveTouchedFiles.length} 个可沉淀文件生成回顾。`,
1528
+ scopeSummary,
1498
1529
  reasons[0] ?? '这次实现已经具备项目级经验抽象价值。',
1499
1530
  reviewSignals.length > 0 ? `已记录 ${reviewSignals.length} 条回顾信号。` : null,
1500
1531
  touchedFileSource === 'inferred-recent-files' ? '本轮 touched files 来自最近修改文件推断。' : null,
1532
+ allowEvidenceOnly && substantiveTouchedFiles.length === 0 ? '本轮没有把 AGENTS、system 或 tool schema 命中当成用户证据。' : null,
1501
1533
  ].filter(Boolean).join(' ');
1502
1534
  const draftCandidate = buildKnowledgeCandidateMeta({
1503
1535
  projectRoot,
@@ -1740,7 +1772,9 @@ function buildKnowledgeMatchQuery(options = {}) {
1740
1772
  options.activeChange,
1741
1773
  options.nextTaskTitle,
1742
1774
  ...(normalizeStringList(options.relatedFiles)),
1743
- ].filter(Boolean);
1775
+ ]
1776
+ .map((field) => sanitizeEvidenceText(field))
1777
+ .filter(Boolean);
1744
1778
  const text = candidateFields.join('\n');
1745
1779
  return {
1746
1780
  text,