@openprd/cli 0.1.18 → 0.1.21

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 (118) hide show
  1. package/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
  2. package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
  3. package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
  4. package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
  6. package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
  7. package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
  8. package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
  9. package/.openprd/changes/openprd-generated-change/design.md +50 -0
  10. package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
  11. package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
  12. package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
  13. package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
  14. package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
  15. package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
  16. package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
  17. package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
  18. package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
  19. package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
  20. package/.openprd/design/active/asset-spec.md +19 -14
  21. package/.openprd/design/active/direction-plan.md +19 -3
  22. package/.openprd/design/active/facts-sheet.md +16 -7
  23. package/.openprd/design/active/image-preflight.md +6 -5
  24. package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
  25. package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
  26. package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
  27. package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
  28. package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
  29. package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
  30. package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
  31. package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
  32. package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
  33. package/.openprd/design/active/selected-direction.md +23 -9
  34. package/.openprd/engagements/active/control-plane-architecture.json +203 -0
  35. package/.openprd/engagements/active/control-plane-intake.json +418 -0
  36. package/.openprd/engagements/active/prd.md +186 -100
  37. package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
  38. package/.openprd/i18n-config.json +12 -0
  39. package/.openprd/ledger/events.jsonl +6 -0
  40. package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
  41. package/.openprd/manifest.json +21 -0
  42. package/AGENTS.md +1 -0
  43. package/README.md +22 -0
  44. package/README_EN.md +27 -0
  45. package/package.json +1 -1
  46. package/skills/openprd-frontend-design/SKILL.md +16 -0
  47. package/skills/openprd-harness/SKILL.md +1 -1
  48. package/skills/openprd-quality/SKILL.md +1 -1
  49. package/skills/openprd-requirement-intake/SKILL.md +1 -1
  50. package/skills/openprd-shared/SKILL.md +1 -1
  51. package/src/adapters/adapter-spi.js +193 -0
  52. package/src/adapters/capability-envelope.js +98 -0
  53. package/src/adapters/event-normalizer.js +55 -0
  54. package/src/adapters/index.js +4 -0
  55. package/src/adapters/install-safety.js +249 -0
  56. package/src/agent-canonical-content.js +6 -2
  57. package/src/agent-integration.js +147 -42
  58. package/src/canvas-app-client-app.js +838 -0
  59. package/src/canvas-app-client-helpers.js +1138 -0
  60. package/src/canvas-app-styles.js +898 -0
  61. package/src/canvas-app.html.js +6 -2781
  62. package/src/canvas-binding.js +414 -0
  63. package/src/canvas-codex-relay.js +430 -0
  64. package/src/canvas-i18n.js +8 -0
  65. package/src/canvas-scene.js +377 -0
  66. package/src/canvas-session-store.js +148 -0
  67. package/src/canvas-workspace.js +59 -1066
  68. package/src/cli/args.js +63 -4
  69. package/src/cli/gate-print.js +17 -0
  70. package/src/cli/quality-commands.js +18 -0
  71. package/src/cli/quality-print.js +10 -0
  72. package/src/cli/runtime-print.js +24 -0
  73. package/src/codex-hook-runner-template.mjs +87 -77
  74. package/src/codex-runtime.js +48 -5
  75. package/src/context/cache.js +245 -0
  76. package/src/context/compiler.js +438 -0
  77. package/src/context/constants.js +30 -0
  78. package/src/context/index.js +39 -0
  79. package/src/context/redaction.js +84 -0
  80. package/src/context/stable.js +69 -0
  81. package/src/context/telemetry.js +42 -0
  82. package/src/dev-standards.js +57 -0
  83. package/src/fleet.js +112 -95
  84. package/src/gates/index.js +2 -0
  85. package/src/gates/scoped-gates.js +256 -0
  86. package/src/gates/store.js +126 -0
  87. package/src/gates/workspace.js +41 -0
  88. package/src/html-artifacts.js +725 -28
  89. package/src/kernel/atomic-store.js +299 -0
  90. package/src/kernel/event-envelope.js +166 -0
  91. package/src/kernel/index.js +4 -0
  92. package/src/kernel/project-ledger.js +467 -0
  93. package/src/kernel/project-manifest.js +205 -0
  94. package/src/knowledge-v3/index.js +1 -0
  95. package/src/knowledge-v3/lifecycle.js +290 -0
  96. package/src/knowledge.js +14 -7
  97. package/src/openprd.js +71 -2
  98. package/src/review-model.js +413 -0
  99. package/src/review-presentation.js +1 -1
  100. package/src/run-harness.js +431 -37
  101. package/src/runtime/cli_runtime_README.md +28 -0
  102. package/src/runtime/errors.js +66 -0
  103. package/src/runtime/index.js +44 -0
  104. package/src/runtime/lane-schema.js +141 -0
  105. package/src/runtime/lane-store.js +279 -0
  106. package/src/runtime/task-runtime.js +449 -0
  107. package/src/runtime/workspace.js +179 -0
  108. package/src/runtime/write-set.js +206 -0
  109. package/src/session-binding.js +16 -3
  110. package/src/session-registry.js +59 -1
  111. package/src/upgrade/fleet-mutation.js +166 -0
  112. package/src/upgrade/fleet-transaction.js +398 -0
  113. package/src/upgrade/transaction-store.js +416 -0
  114. package/src/visual-compare-core.js +66 -27
  115. package/src/visual-compare.js +18 -12
  116. package/src/workspace-core.js +109 -7
  117. package/src/workspace-registry.js +39 -1
  118. package/src/workspace-workflow.js +18 -15
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import { buildReviewFallbackPanelItems, CHANGE_SUMMARY_VERBS, USER_CHANGE_SUMMARY_GUIDE } from './change-summary.js';
4
4
  import { cjoin, writeText } from './fs-utils.js';
5
5
  import { escapeHtml, listMarkup, slugify } from './html-artifact-utils.js';
6
+ import { buildReviewModel, projectReviewModel } from './review-model.js';
6
7
  export { learningPackagePaths, renderLearningArtifact } from './learning-html-artifact.js';
7
8
  import { renderQualityEvalArtifact as renderQualityEvalArtifactV2 } from './quality-html-artifact.js';
8
9
 
@@ -431,6 +432,7 @@ function formatEvidenceItem(item) {
431
432
  export function buildReviewExportPayload(snapshot) {
432
433
  const sections = snapshot.sections ?? {};
433
434
  const presentation = buildReviewPresentationFeedback(snapshot);
435
+ const reviewModel = buildReviewModel(snapshot);
434
436
  return {
435
437
  versionId: snapshot.versionId,
436
438
  title: snapshot.title,
@@ -448,6 +450,11 @@ export function buildReviewExportPayload(snapshot) {
448
450
  sectionKeys: Object.keys(sections),
449
451
  presentationContract: presentation.contract,
450
452
  presentationFeedback: presentation.violations,
453
+ reviewModel: {
454
+ schema: reviewModel.schema,
455
+ digest: reviewModel.digest,
456
+ approvalContract: reviewModel.approvalContract,
457
+ },
451
458
  exportedAt: new Date().toISOString(),
452
459
  };
453
460
  }
@@ -1006,25 +1013,16 @@ function renderReviewProjectVersion(projectRelease) {
1006
1013
  `;
1007
1014
  }
1008
1015
 
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
- ];
1016
+ function renderReviewStageRail(reviewModel) {
1017
+ const stages = reviewModel.timeline;
1020
1018
  const items = stages.map((stage, index) => `
1021
- <li class="review-stage ${stage.status}">
1019
+ <li class="review-stage ${stage.status}" data-review-timeline-step="${escapeHtml(stage.id)}">
1022
1020
  <span class="review-stage-dot">${stage.status === 'done' ? '✓' : index + 1}</span>
1023
1021
  <span class="review-stage-name">${escapeHtml(stage.title)}</span>
1024
1022
  ${stage.status === 'current' ? '<span class="review-stage-badge">你在这里</span>' : ''}
1025
1023
  </li>`);
1026
1024
  return `
1027
- <nav class="review-stage-rail" aria-label="流程位置">
1025
+ <nav class="review-stage-rail" aria-label="共享决策时间线" data-review-model-digest="${escapeHtml(reviewModel.digest)}">
1028
1026
  <ol>
1029
1027
  ${items.join('\n')}
1030
1028
  </ol>
@@ -1032,8 +1030,12 @@ function renderReviewStageRail(snapshot) {
1032
1030
  }
1033
1031
 
1034
1032
  function renderReviewOverview(snapshot, sectionsData, agenda = []) {
1035
- const problem = sectionsData.problem?.problemStatement || '尚未形成明确问题定义';
1036
- const goal = reviewList(sectionsData.goals?.goals)[0] ?? null;
1033
+ const rawProblem = sectionsData.problem?.problemStatement || '尚未形成明确问题定义';
1034
+ const rawGoal = reviewList(sectionsData.goals?.goals)[0] ?? null;
1035
+ const problem = reviewPresentationMapNode(snapshot, 'problem', '问题定义', rawProblem).value;
1036
+ const goal = rawGoal
1037
+ ? reviewPresentationMapNode(snapshot, 'goal', '目标', rawGoal).value
1038
+ : null;
1037
1039
  const agendaMarkup = agenda.length === 0 ? '' : `
1038
1040
  <aside class="review-agenda" aria-label="评审清单">
1039
1041
  <p class="review-agenda-title">这次评审要确认 ${agenda.length} 块内容</p>
@@ -1556,7 +1558,7 @@ function renderReviewDesignDirections(snapshot) {
1556
1558
  </div>
1557
1559
  ${summary ? `<p class="review-direction-summary">${escapeHtml(summary)}</p>` : ''}
1558
1560
  ${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)}">
1561
+ <button type="button" class="review-direction-select" data-copy-value="${escapeHtml(selectCopy)}" title="复制这个方向的确认结果">
1560
1562
  选这个方向
1561
1563
  </button>
1562
1564
  </div>
@@ -1574,7 +1576,7 @@ function renderReviewDesignDirections(snapshot) {
1574
1576
  </section>`;
1575
1577
  }
1576
1578
 
1577
- function renderReviewDecision(snapshot) {
1579
+ function renderReviewDecision(snapshot, reviewModel) {
1578
1580
  const payload = JSON.stringify(buildReviewExportPayload(snapshot), null, 2);
1579
1581
  const confirmCommand = reviewCommand(snapshot, 'confirmed');
1580
1582
  const reviseCommand = reviewCommand(snapshot, 'needs-revision', '说明需要调整的点');
@@ -1586,12 +1588,12 @@ function renderReviewDecision(snapshot) {
1586
1588
  });
1587
1589
  const reviseCopy = reviewCopyBundle({ label: '需要调整', command: reviseCommand, payload });
1588
1590
  return `
1589
- <nav class="review-bottom-bar" aria-label="评审决定">
1591
+ <nav class="review-bottom-bar" aria-label="评审决定" data-review-model-digest="${escapeHtml(reviewModel.digest)}">
1590
1592
  <div class="review-bottom-bar-inner">
1591
- <button type="button" class="review-bottom-action revise" data-copy-value="${escapeHtml(reviseCopy)}" title="${escapeHtml(reviseCommand)}">
1593
+ <button type="button" class="review-bottom-action revise" data-copy-value="${escapeHtml(reviseCopy)}" title="复制需要调整的反馈">
1592
1594
  需要调整
1593
1595
  </button>
1594
- <button type="button" class="review-bottom-action confirm" data-copy-value="${escapeHtml(confirmCopy)}" title="${escapeHtml(confirmCommand)}">
1596
+ <button type="button" class="review-bottom-action confirm" data-copy-value="${escapeHtml(confirmCopy)}" title="复制认可并继续的确认结果">
1595
1597
  认可并继续
1596
1598
  </button>
1597
1599
  </div>
@@ -1599,7 +1601,225 @@ function renderReviewDecision(snapshot) {
1599
1601
  `;
1600
1602
  }
1601
1603
 
1604
+ function renderReviewWorkbenchHeader(reviewModel) {
1605
+ return `
1606
+ <section class="review-workbench" aria-labelledby="reviewWorkbenchTitle" data-review-model-digest="${escapeHtml(reviewModel.digest)}">
1607
+ <div class="review-workbench-copy">
1608
+ <p class="review-kicker">双轨证据工作台</p>
1609
+ <h2 id="reviewWorkbenchTitle">同一份评审,两种读法</h2>
1610
+ <p>先在业务视图看清价值、范围、流程和风险;需要追溯任务、约束或依据时,再切到工程视图。两边共享同一份确认内容。</p>
1611
+ </div>
1612
+ <div class="review-view-tabs" role="tablist" aria-label="选择评审视图">
1613
+ <button
1614
+ type="button"
1615
+ id="reviewTabBusiness"
1616
+ class="review-view-tab active"
1617
+ role="tab"
1618
+ aria-selected="true"
1619
+ aria-controls="reviewBusinessView"
1620
+ data-review-tab="business"
1621
+ >业务视图<span>先看结论</span></button>
1622
+ <button
1623
+ type="button"
1624
+ id="reviewTabEngineering"
1625
+ class="review-view-tab"
1626
+ role="tab"
1627
+ aria-selected="false"
1628
+ aria-controls="reviewEngineeringView"
1629
+ data-review-tab="engineering"
1630
+ tabindex="-1"
1631
+ >工程视图<span>核对追溯</span></button>
1632
+ </div>
1633
+ <p class="review-workbench-note" aria-live="polite">两种视图共享同一个确认基线,切换视图不会改变评审结论。</p>
1634
+ </section>
1635
+ `;
1636
+ }
1637
+
1638
+ function renderBusinessEvidenceBridge(projection) {
1639
+ const references = projection.evidenceReferences;
1640
+ if (references.length === 0) return '';
1641
+ return `
1642
+ <section class="review-business-evidence" aria-labelledby="reviewBusinessEvidenceTitle">
1643
+ <div>
1644
+ <p class="review-kicker">判断依据</p>
1645
+ <h2 id="reviewBusinessEvidenceTitle">这些结论有 ${references.length} 条依据可核对</h2>
1646
+ <p>这里先保留清楚的业务结论;需要看原始依据时,可以直接定位到工程视图。</p>
1647
+ </div>
1648
+ <div class="review-business-evidence-actions">
1649
+ ${references.map((item) => `
1650
+ <button type="button" data-review-locate="review-evidence-${escapeHtml(item.id)}">${escapeHtml(item.label)}</button>
1651
+ `).join('')}
1652
+ </div>
1653
+ </section>
1654
+ `;
1655
+ }
1656
+
1657
+ function reviewEngineeringRows(items, emptyText) {
1658
+ if (items.length === 0) {
1659
+ return `<p class="review-engineering-empty">${escapeHtml(emptyText)}</p>`;
1660
+ }
1661
+ return `<ul class="review-engineering-list">${items.map((item) => {
1662
+ const candidate = item && typeof item === 'object' && !Array.isArray(item) ? item : { detail: item };
1663
+ const label = normalizedReviewVisibleText(candidate.label ?? candidate.title ?? candidate.summary);
1664
+ const detail = normalizedReviewVisibleText(candidate.detail ?? candidate.value ?? candidate.text);
1665
+ const code = Boolean(candidate.code);
1666
+ return `
1667
+ <li class="review-engineering-item" data-review-filter-item>
1668
+ ${label ? `<strong>${escapeHtml(label)}</strong>` : ''}
1669
+ ${detail ? code ? `<code>${escapeHtml(detail)}</code>` : `<span>${escapeHtml(detail)}</span>` : ''}
1670
+ </li>`;
1671
+ }).join('')}</ul>`;
1672
+ }
1673
+
1674
+ function renderReviewEngineeringSection({ id, title, description, items, emptyText }) {
1675
+ return `
1676
+ <section class="review-engineering-section" id="${escapeHtml(id)}" aria-labelledby="${escapeHtml(id)}-title">
1677
+ <header>
1678
+ <h2 id="${escapeHtml(id)}-title">${escapeHtml(title)}</h2>
1679
+ <p>${escapeHtml(description)}</p>
1680
+ </header>
1681
+ ${reviewEngineeringRows(items, emptyText)}
1682
+ </section>
1683
+ `;
1684
+ }
1685
+
1686
+ function renderReviewEngineeringProjection(projection) {
1687
+ const trace = projection.trace;
1688
+ const sections = projection.sections;
1689
+ const traceItems = [
1690
+ { label: '评审版本', detail: trace.versionId ?? '旧版评审未记录', code: true },
1691
+ { label: '工作单元', detail: trace.workUnitId ?? '尚未绑定', code: true },
1692
+ { label: '内容指纹', detail: projection.modelDigest, code: true },
1693
+ { label: '来源摘要', detail: trace.snapshotDigest ?? '旧版评审未记录', code: true },
1694
+ ...(trace.targetRoot ? [{ label: '项目位置', detail: trace.targetRoot, code: true }] : []),
1695
+ ];
1696
+ const deliveryItems = [
1697
+ ...sections.delivery.functional.map((detail, index) => ({ label: `交付项 ${index + 1}`, detail })),
1698
+ ...sections.delivery.nonFunctional.map((detail, index) => ({ label: `质量要求 ${index + 1}`, detail })),
1699
+ ...sections.tasks.map((task) => ({
1700
+ label: `${task.id}${task.status ? ` · ${task.status}` : ''}`,
1701
+ detail: task.title,
1702
+ })),
1703
+ ];
1704
+ const constraintItems = [
1705
+ ...sections.constraints.technical.map((detail, index) => ({ label: `技术约束 ${index + 1}`, detail })),
1706
+ ...sections.constraints.compliance.map((detail, index) => ({ label: `合规约束 ${index + 1}`, detail })),
1707
+ ...sections.constraints.dependencies.map((detail, index) => ({ label: `依赖 ${index + 1}`, detail })),
1708
+ ];
1709
+ const fileItems = sections.files.map((detail, index) => ({ label: `文件 ${index + 1}`, detail, code: true }));
1710
+ const acceptanceItems = sections.acceptance.map((detail, index) => ({ label: `验收 ${index + 1}`, detail }));
1711
+ const handoffItems = [
1712
+ sections.handoff.owner ? { label: '负责人', detail: sections.handoff.owner } : null,
1713
+ sections.handoff.nextStep ? { label: '下一步', detail: sections.handoff.nextStep } : null,
1714
+ sections.handoff.targetSystem ? { label: '目标范围', detail: sections.handoff.targetSystem } : null,
1715
+ ].filter(Boolean);
1716
+ const evidenceItems = projection.evidenceReferences.map((item, index) => `
1717
+ <article
1718
+ class="review-evidence-item review-engineering-item"
1719
+ id="review-evidence-${escapeHtml(item.id)}"
1720
+ tabindex="-1"
1721
+ data-review-filter-item
1722
+ >
1723
+ <div class="review-evidence-index">${index + 1}</div>
1724
+ <div>
1725
+ <h3>${escapeHtml(item.label)}</h3>
1726
+ <p>${escapeHtml(item.summary)}</p>
1727
+ <div class="review-evidence-meta">
1728
+ <span>${escapeHtml(item.kind === 'problem' ? '问题依据' : item.kind === 'validation' ? '验证依据' : '关联资料')}</span>
1729
+ ${item.source ? `<span>来源:${escapeHtml(item.source)}</span>` : ''}
1730
+ ${item.href ? `<code>${escapeHtml(item.href)}</code>` : ''}
1731
+ </div>
1732
+ </div>
1733
+ </article>
1734
+ `).join('');
1735
+ const filterableCount = traceItems.length + deliveryItems.length + constraintItems.length
1736
+ + fileItems.length + acceptanceItems.length + handoffItems.length + projection.evidenceReferences.length;
1737
+
1738
+ return `
1739
+ <section
1740
+ class="review-projection review-projection-engineering"
1741
+ id="reviewEngineeringView"
1742
+ role="tabpanel"
1743
+ aria-labelledby="reviewTabEngineering"
1744
+ data-review-view="engineering"
1745
+ data-review-model-digest="${escapeHtml(projection.modelDigest)}"
1746
+ hidden
1747
+ >
1748
+ <header class="review-engineering-head">
1749
+ <div>
1750
+ <p class="review-kicker">工程追溯</p>
1751
+ <h1>从同一结论核对任务、约束与证据</h1>
1752
+ <p>这里展示落地所需的版本、任务、文件、测试与依据;底部确认动作仍绑定同一个评审基线。</p>
1753
+ </div>
1754
+ <label class="review-engineering-filter" for="reviewEngineeringFilter">
1755
+ <span>筛选工程内容</span>
1756
+ <input id="reviewEngineeringFilter" type="search" placeholder="搜索任务、约束或证据" autocomplete="off" />
1757
+ <output id="reviewEngineeringFilterCount" for="reviewEngineeringFilter">共 ${filterableCount} 项</output>
1758
+ </label>
1759
+ </header>
1760
+ <div class="review-engineering-grid">
1761
+ ${renderReviewEngineeringSection({
1762
+ id: 'review-engineering-trace',
1763
+ title: '版本与追溯',
1764
+ description: '确认这次评审来自哪份内容,并核对两个视图的共同指纹。',
1765
+ items: traceItems,
1766
+ emptyText: '暂无版本与追溯信息。',
1767
+ })}
1768
+ ${renderReviewEngineeringSection({
1769
+ id: 'review-engineering-delivery',
1770
+ title: '交付与任务',
1771
+ description: '查看必须交付的能力、质量要求和已经绑定的任务。',
1772
+ items: deliveryItems,
1773
+ emptyText: '当前评审稿尚未绑定具体任务。',
1774
+ })}
1775
+ ${renderReviewEngineeringSection({
1776
+ id: 'review-engineering-constraints',
1777
+ title: '约束与依赖',
1778
+ description: '实现时不能越过的技术、合规与依赖边界。',
1779
+ items: constraintItems,
1780
+ emptyText: '暂无额外约束或依赖。',
1781
+ })}
1782
+ ${renderReviewEngineeringSection({
1783
+ id: 'review-engineering-files',
1784
+ title: '文件与改动范围',
1785
+ description: '任务或证据已经明确关联的文件位置。',
1786
+ items: fileItems,
1787
+ emptyText: '当前评审稿尚未绑定具体文件。',
1788
+ })}
1789
+ ${renderReviewEngineeringSection({
1790
+ id: 'review-engineering-acceptance',
1791
+ title: '测试与验收',
1792
+ description: '实现完成后据此判断是否真的达到目标。',
1793
+ items: acceptanceItems,
1794
+ emptyText: '当前评审稿尚未写明验收条件。',
1795
+ })}
1796
+ ${renderReviewEngineeringSection({
1797
+ id: 'review-engineering-handoff',
1798
+ title: '交接信息',
1799
+ description: '明确由谁继续、下一步做什么、影响哪些范围。',
1800
+ items: handoffItems,
1801
+ emptyText: '当前评审稿尚未写明交接信息。',
1802
+ })}
1803
+ </div>
1804
+ <section class="review-evidence-section" aria-labelledby="reviewEvidenceTitle">
1805
+ <header>
1806
+ <p class="review-kicker">共享证据</p>
1807
+ <h2 id="reviewEvidenceTitle">证据索引</h2>
1808
+ <p>业务视图里的“查看依据”会定位到这里;筛选不会改变证据与评审结论的绑定关系。</p>
1809
+ </header>
1810
+ <div class="review-evidence-list">
1811
+ ${evidenceItems || '<p class="review-engineering-empty">当前评审稿尚未登记原始依据。</p>'}
1812
+ </div>
1813
+ </section>
1814
+ <p class="review-engineering-no-results" id="reviewEngineeringNoResults" hidden>没有找到匹配内容,请换一个更短的关键词。</p>
1815
+ </section>
1816
+ `;
1817
+ }
1818
+
1602
1819
  function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1820
+ const reviewModel = buildReviewModel(snapshot);
1821
+ const businessProjection = projectReviewModel(reviewModel, 'business');
1822
+ const engineeringProjection = projectReviewModel(reviewModel, 'engineering');
1603
1823
  const primaryFlows = reviewList(sectionsData.scenarios?.primaryFlows);
1604
1824
  const edgeCases = reviewList(sectionsData.scenarios?.edgeCases);
1605
1825
  const failureModes = reviewList(sectionsData.scenarios?.failureModes);
@@ -2378,6 +2598,344 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2378
2598
  box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
2379
2599
  transform: translateY(0);
2380
2600
  }
2601
+ .review-workbench {
2602
+ display: grid;
2603
+ grid-template-columns: minmax(0, 1fr) auto;
2604
+ gap: 22px;
2605
+ align-items: end;
2606
+ margin-bottom: 18px;
2607
+ padding: 20px 22px;
2608
+ border: 1px solid var(--review-line);
2609
+ border-radius: 8px;
2610
+ background: var(--review-panel);
2611
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
2612
+ }
2613
+ .review-workbench-copy h2 {
2614
+ margin: 0;
2615
+ color: var(--review-text);
2616
+ font-size: 24px;
2617
+ line-height: 1.25;
2618
+ letter-spacing: 0;
2619
+ }
2620
+ .review-workbench-copy > p:last-child,
2621
+ .review-workbench-note {
2622
+ margin: 8px 0 0;
2623
+ color: var(--review-muted);
2624
+ font-size: 14px;
2625
+ line-height: 1.65;
2626
+ }
2627
+ .review-workbench-note {
2628
+ grid-column: 1 / -1;
2629
+ margin: 0;
2630
+ padding-top: 12px;
2631
+ border-top: 1px solid var(--review-line);
2632
+ font-size: 13px;
2633
+ }
2634
+ .review-view-tabs {
2635
+ display: inline-grid;
2636
+ grid-template-columns: repeat(2, minmax(132px, 1fr));
2637
+ gap: 4px;
2638
+ padding: 4px;
2639
+ border: 1px solid var(--review-line);
2640
+ border-radius: 12px;
2641
+ background: var(--review-panel-soft);
2642
+ }
2643
+ .review-view-tab {
2644
+ cursor: pointer;
2645
+ display: grid;
2646
+ gap: 2px;
2647
+ min-height: 52px;
2648
+ border: 1px solid transparent;
2649
+ border-radius: 9px;
2650
+ padding: 8px 14px;
2651
+ background: transparent;
2652
+ color: var(--review-muted);
2653
+ font: inherit;
2654
+ font-size: 14px;
2655
+ font-weight: 800;
2656
+ line-height: 1.2;
2657
+ text-align: left;
2658
+ }
2659
+ .review-view-tab span {
2660
+ font-size: 11px;
2661
+ font-weight: 600;
2662
+ }
2663
+ .review-view-tab:hover,
2664
+ .review-view-tab:focus-visible {
2665
+ border-color: #c7d2fe;
2666
+ color: var(--review-indigo);
2667
+ outline: none;
2668
+ }
2669
+ .review-view-tab.active {
2670
+ border-color: #c7d2fe;
2671
+ background: #ffffff;
2672
+ color: var(--review-indigo);
2673
+ box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
2674
+ }
2675
+ .review-projection[hidden],
2676
+ [data-review-filter-item][hidden],
2677
+ .review-engineering-no-results[hidden] {
2678
+ display: none !important;
2679
+ }
2680
+ .review-business-evidence {
2681
+ display: grid;
2682
+ grid-template-columns: minmax(0, 1fr) minmax(260px, 0.72fr);
2683
+ gap: 22px;
2684
+ align-items: center;
2685
+ margin-top: 18px;
2686
+ padding: 20px;
2687
+ border: 1px solid #99f6e4;
2688
+ border-radius: 8px;
2689
+ background: #f0fdfa;
2690
+ }
2691
+ .review-business-evidence h2 {
2692
+ margin: 0;
2693
+ font-size: 20px;
2694
+ line-height: 1.3;
2695
+ }
2696
+ .review-business-evidence p:last-child {
2697
+ margin: 6px 0 0;
2698
+ color: var(--review-muted);
2699
+ font-size: 14px;
2700
+ line-height: 1.65;
2701
+ }
2702
+ .review-business-evidence-actions {
2703
+ display: flex;
2704
+ flex-wrap: wrap;
2705
+ justify-content: flex-end;
2706
+ gap: 8px;
2707
+ }
2708
+ .review-business-evidence-actions button {
2709
+ cursor: pointer;
2710
+ min-height: 36px;
2711
+ border: 1px solid #5eead4;
2712
+ border-radius: 9px;
2713
+ padding: 0 12px;
2714
+ background: #ffffff;
2715
+ color: #0f766e;
2716
+ font: inherit;
2717
+ font-size: 13px;
2718
+ font-weight: 800;
2719
+ }
2720
+ .review-business-evidence-actions button:hover,
2721
+ .review-business-evidence-actions button:focus-visible {
2722
+ border-color: var(--review-teal);
2723
+ box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
2724
+ outline: none;
2725
+ }
2726
+ .review-projection-engineering {
2727
+ border: 1px solid var(--review-line);
2728
+ border-radius: 8px;
2729
+ background: var(--review-panel-soft);
2730
+ padding: 20px;
2731
+ }
2732
+ .review-engineering-head {
2733
+ display: grid;
2734
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 0.45fr);
2735
+ gap: 24px;
2736
+ align-items: end;
2737
+ padding: 4px 2px 18px;
2738
+ border-bottom: 1px solid var(--review-line);
2739
+ }
2740
+ .review-engineering-head h1 {
2741
+ margin: 0;
2742
+ font-size: 26px;
2743
+ line-height: 1.25;
2744
+ letter-spacing: 0;
2745
+ }
2746
+ .review-engineering-head > div > p:last-child {
2747
+ margin: 8px 0 0;
2748
+ color: var(--review-muted);
2749
+ font-size: 14px;
2750
+ line-height: 1.65;
2751
+ }
2752
+ .review-engineering-filter {
2753
+ display: grid;
2754
+ grid-template-columns: 1fr auto;
2755
+ gap: 7px 10px;
2756
+ align-items: center;
2757
+ }
2758
+ .review-engineering-filter > span {
2759
+ color: var(--review-text);
2760
+ font-size: 13px;
2761
+ font-weight: 800;
2762
+ }
2763
+ .review-engineering-filter input {
2764
+ grid-column: 1 / -1;
2765
+ width: 100%;
2766
+ min-height: 42px;
2767
+ border: 1px solid var(--review-line);
2768
+ border-radius: 9px;
2769
+ padding: 0 12px;
2770
+ background: #ffffff;
2771
+ color: var(--review-text);
2772
+ font: inherit;
2773
+ font-size: 14px;
2774
+ }
2775
+ .review-engineering-filter input:focus {
2776
+ border-color: var(--review-indigo);
2777
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
2778
+ outline: none;
2779
+ }
2780
+ .review-engineering-filter output {
2781
+ justify-self: end;
2782
+ color: var(--review-muted);
2783
+ font-size: 12px;
2784
+ }
2785
+ .review-engineering-grid {
2786
+ display: grid;
2787
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2788
+ gap: 14px;
2789
+ margin-top: 16px;
2790
+ }
2791
+ .review-engineering-section,
2792
+ .review-evidence-section {
2793
+ min-width: 0;
2794
+ border: 1px solid var(--review-line);
2795
+ border-radius: 8px;
2796
+ background: var(--review-panel);
2797
+ overflow: hidden;
2798
+ }
2799
+ .review-engineering-section > header,
2800
+ .review-evidence-section > header {
2801
+ min-height: 92px;
2802
+ padding: 16px 18px;
2803
+ border-bottom: 1px solid var(--review-line);
2804
+ background: #ffffff;
2805
+ }
2806
+ .review-engineering-section h2,
2807
+ .review-evidence-section h2 {
2808
+ margin: 0;
2809
+ font-size: 18px;
2810
+ line-height: 1.3;
2811
+ }
2812
+ .review-engineering-section header p,
2813
+ .review-evidence-section header > p:last-child {
2814
+ margin: 6px 0 0;
2815
+ color: var(--review-muted);
2816
+ font-size: 13px;
2817
+ line-height: 1.55;
2818
+ }
2819
+ .review-engineering-list {
2820
+ margin: 0;
2821
+ padding: 0;
2822
+ list-style: none;
2823
+ }
2824
+ .review-engineering-item {
2825
+ min-width: 0;
2826
+ }
2827
+ .review-engineering-list .review-engineering-item {
2828
+ display: grid;
2829
+ grid-template-columns: minmax(108px, 0.32fr) minmax(0, 1fr);
2830
+ gap: 12px;
2831
+ align-items: start;
2832
+ padding: 12px 18px;
2833
+ border-bottom: 1px solid #eef2f6;
2834
+ font-size: 13px;
2835
+ line-height: 1.6;
2836
+ }
2837
+ .review-engineering-list .review-engineering-item:last-child {
2838
+ border-bottom: 0;
2839
+ }
2840
+ .review-engineering-list strong {
2841
+ color: var(--review-muted);
2842
+ font-size: 12px;
2843
+ line-height: 1.65;
2844
+ }
2845
+ .review-engineering-list span,
2846
+ .review-engineering-list code {
2847
+ min-width: 0;
2848
+ overflow-wrap: anywhere;
2849
+ }
2850
+ .review-engineering-list code,
2851
+ .review-evidence-meta code {
2852
+ color: #3730a3;
2853
+ font-family: var(--review-mono);
2854
+ font-size: 11px;
2855
+ }
2856
+ .review-engineering-empty {
2857
+ margin: 0;
2858
+ padding: 18px;
2859
+ color: var(--review-muted);
2860
+ font-size: 13px;
2861
+ line-height: 1.6;
2862
+ }
2863
+ .review-evidence-section {
2864
+ margin-top: 14px;
2865
+ }
2866
+ .review-evidence-section > header {
2867
+ min-height: 0;
2868
+ }
2869
+ .review-evidence-list {
2870
+ display: grid;
2871
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2872
+ }
2873
+ .review-evidence-item {
2874
+ display: grid;
2875
+ grid-template-columns: 34px minmax(0, 1fr);
2876
+ gap: 12px;
2877
+ padding: 16px 18px;
2878
+ border-right: 1px solid #eef2f6;
2879
+ border-bottom: 1px solid #eef2f6;
2880
+ scroll-margin-top: 22px;
2881
+ transition: background-color 160ms ease, box-shadow 160ms ease;
2882
+ }
2883
+ .review-evidence-item:nth-child(2n) { border-right: 0; }
2884
+ .review-evidence-index {
2885
+ display: inline-grid;
2886
+ place-items: center;
2887
+ width: 30px;
2888
+ height: 30px;
2889
+ border-radius: 999px;
2890
+ background: #ccfbf1;
2891
+ color: var(--review-teal);
2892
+ font-size: 12px;
2893
+ font-weight: 850;
2894
+ }
2895
+ .review-evidence-item h3 {
2896
+ margin: 0;
2897
+ font-size: 14px;
2898
+ line-height: 1.4;
2899
+ }
2900
+ .review-evidence-item p {
2901
+ margin: 6px 0 0;
2902
+ color: var(--review-text);
2903
+ font-size: 13px;
2904
+ line-height: 1.65;
2905
+ }
2906
+ .review-evidence-meta {
2907
+ display: flex;
2908
+ flex-wrap: wrap;
2909
+ gap: 6px 10px;
2910
+ margin-top: 10px;
2911
+ color: var(--review-muted);
2912
+ font-size: 11px;
2913
+ }
2914
+ .review-evidence-item.is-located {
2915
+ background: #ecfdf5;
2916
+ box-shadow: inset 4px 0 0 var(--review-teal);
2917
+ }
2918
+ .review-evidence-item:focus-visible {
2919
+ outline: 3px solid rgba(15, 118, 110, 0.24);
2920
+ outline-offset: -3px;
2921
+ }
2922
+ .review-engineering-no-results {
2923
+ margin: 14px 0 0;
2924
+ padding: 14px 16px;
2925
+ border: 1px solid #fde68a;
2926
+ border-radius: 8px;
2927
+ background: #fffbeb;
2928
+ color: #92400e;
2929
+ font-size: 14px;
2930
+ }
2931
+ @media (prefers-reduced-motion: reduce) {
2932
+ .review-view-tab,
2933
+ .review-evidence-item,
2934
+ .review-business-evidence-actions button {
2935
+ scroll-behavior: auto;
2936
+ transition: none;
2937
+ }
2938
+ }
2381
2939
  .review-directions {
2382
2940
  margin-top: 28px;
2383
2941
  }
@@ -2486,6 +3044,24 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2486
3044
  outline: none;
2487
3045
  }
2488
3046
  @media (max-width: 860px) {
3047
+ .review-workbench,
3048
+ .review-engineering-head,
3049
+ .review-business-evidence {
3050
+ grid-template-columns: 1fr;
3051
+ }
3052
+ .review-view-tabs {
3053
+ width: 100%;
3054
+ }
3055
+ .review-business-evidence-actions {
3056
+ justify-content: flex-start;
3057
+ }
3058
+ .review-engineering-grid,
3059
+ .review-evidence-list {
3060
+ grid-template-columns: 1fr;
3061
+ }
3062
+ .review-evidence-item {
3063
+ border-right: 0;
3064
+ }
2489
3065
  .review-overview {
2490
3066
  grid-template-columns: 1fr;
2491
3067
  }
@@ -2499,6 +3075,21 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2499
3075
  @media (max-width: 620px) {
2500
3076
  .review-page { padding: 18px 12px 128px; }
2501
3077
  .review-topbar { align-items: flex-start; flex-direction: column; }
3078
+ .review-workbench,
3079
+ .review-projection-engineering {
3080
+ padding: 16px;
3081
+ }
3082
+ .review-view-tabs {
3083
+ grid-template-columns: 1fr 1fr;
3084
+ }
3085
+ .review-view-tab {
3086
+ min-width: 0;
3087
+ padding-inline: 10px;
3088
+ }
3089
+ .review-engineering-list .review-engineering-item {
3090
+ grid-template-columns: 1fr;
3091
+ gap: 4px;
3092
+ }
2502
3093
  .review-project-version {
2503
3094
  min-width: 0;
2504
3095
  width: 100%;
@@ -2533,15 +3124,27 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2533
3124
  <div class="review-brand">OpenPrd / 评审面板</div>
2534
3125
  ${renderReviewProjectVersion(visibleProjectRelease)}
2535
3126
  </header>
2536
- ${renderReviewStageRail(snapshot)}
2537
- ${renderReviewOverview(snapshot, sectionsData, panelAgenda)}
2538
- ${renderReviewGapPanel(sectionsData)}
2539
- ${renderReviewDesignDirections(snapshot)}
2540
- ${renderReviewFlowSvg(snapshot, sectionsData)}
2541
- <section class="review-panel-grid" aria-label="固定评审项">
2542
- ${panels.join('\n')}
3127
+ ${renderReviewStageRail(reviewModel)}
3128
+ ${renderReviewWorkbenchHeader(reviewModel)}
3129
+ <section
3130
+ class="review-projection review-projection-business"
3131
+ id="reviewBusinessView"
3132
+ role="tabpanel"
3133
+ aria-labelledby="reviewTabBusiness"
3134
+ data-review-view="business"
3135
+ data-review-model-digest="${escapeHtml(businessProjection.modelDigest)}"
3136
+ >
3137
+ ${renderReviewOverview(snapshot, sectionsData, panelAgenda)}
3138
+ ${renderReviewGapPanel(sectionsData)}
3139
+ ${renderReviewDesignDirections(snapshot)}
3140
+ ${renderReviewFlowSvg(snapshot, sectionsData)}
3141
+ <section class="review-panel-grid" aria-label="固定评审项">
3142
+ ${panels.join('\n')}
3143
+ </section>
3144
+ ${renderBusinessEvidenceBridge(businessProjection)}
2543
3145
  </section>
2544
- ${renderReviewDecision(snapshot)}
3146
+ ${renderReviewEngineeringProjection(engineeringProjection)}
3147
+ ${renderReviewDecision(snapshot, reviewModel)}
2545
3148
  <script>
2546
3149
  async function copyReviewText(text) {
2547
3150
  try {
@@ -2569,6 +3172,100 @@ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
2569
3172
  flashCopied(button);
2570
3173
  });
2571
3174
  });
3175
+
3176
+ const reviewTabs = Array.from(document.querySelectorAll('[data-review-tab]'));
3177
+ const reviewViews = Array.from(document.querySelectorAll('[data-review-view]'));
3178
+ const engineeringFilter = document.getElementById('reviewEngineeringFilter');
3179
+ const engineeringFilterCount = document.getElementById('reviewEngineeringFilterCount');
3180
+ const engineeringNoResults = document.getElementById('reviewEngineeringNoResults');
3181
+ const prefersReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
3182
+
3183
+ function setReviewView(view, updateUrl = true) {
3184
+ const nextView = view === 'engineering' ? 'engineering' : 'business';
3185
+ reviewTabs.forEach((tab) => {
3186
+ const active = tab.dataset.reviewTab === nextView;
3187
+ tab.classList.toggle('active', active);
3188
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
3189
+ tab.tabIndex = active ? 0 : -1;
3190
+ });
3191
+ reviewViews.forEach((panel) => {
3192
+ panel.hidden = panel.dataset.reviewView !== nextView;
3193
+ });
3194
+ document.documentElement.dataset.reviewView = nextView;
3195
+ if (updateUrl) {
3196
+ try {
3197
+ const url = new URL(window.location.href);
3198
+ if (nextView === 'engineering') url.searchParams.set('view', 'engineering');
3199
+ else url.searchParams.delete('view');
3200
+ window.history.replaceState(null, '', url);
3201
+ } catch (error) {
3202
+ // Local file previews can deny history updates; the view still switches.
3203
+ }
3204
+ }
3205
+ }
3206
+
3207
+ reviewTabs.forEach((tab, index) => {
3208
+ tab.addEventListener('click', () => setReviewView(tab.dataset.reviewTab));
3209
+ tab.addEventListener('keydown', (event) => {
3210
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
3211
+ event.preventDefault();
3212
+ let nextIndex = index;
3213
+ if (event.key === 'ArrowLeft') nextIndex = (index - 1 + reviewTabs.length) % reviewTabs.length;
3214
+ if (event.key === 'ArrowRight') nextIndex = (index + 1) % reviewTabs.length;
3215
+ if (event.key === 'Home') nextIndex = 0;
3216
+ if (event.key === 'End') nextIndex = reviewTabs.length - 1;
3217
+ const nextTab = reviewTabs[nextIndex];
3218
+ setReviewView(nextTab.dataset.reviewTab);
3219
+ nextTab.focus();
3220
+ });
3221
+ });
3222
+
3223
+ function applyEngineeringFilter() {
3224
+ if (!engineeringFilter) return;
3225
+ const query = engineeringFilter.value.trim().toLocaleLowerCase('zh-CN');
3226
+ const items = Array.from(document.querySelectorAll('#reviewEngineeringView [data-review-filter-item]'));
3227
+ let visibleCount = 0;
3228
+ items.forEach((item) => {
3229
+ const matches = !query || item.textContent.toLocaleLowerCase('zh-CN').includes(query);
3230
+ item.hidden = !matches;
3231
+ if (matches) visibleCount += 1;
3232
+ });
3233
+ if (engineeringFilterCount) {
3234
+ engineeringFilterCount.textContent = query ? '找到 ' + visibleCount + ' 项' : '共 ' + items.length + ' 项';
3235
+ }
3236
+ if (engineeringNoResults) engineeringNoResults.hidden = visibleCount !== 0;
3237
+ }
3238
+
3239
+ if (engineeringFilter) {
3240
+ engineeringFilter.addEventListener('input', applyEngineeringFilter);
3241
+ applyEngineeringFilter();
3242
+ }
3243
+
3244
+ document.querySelectorAll('[data-review-locate]').forEach((button) => {
3245
+ button.addEventListener('click', () => {
3246
+ const target = document.getElementById(button.dataset.reviewLocate || '');
3247
+ if (!target) return;
3248
+ setReviewView('engineering');
3249
+ if (engineeringFilter) {
3250
+ engineeringFilter.value = '';
3251
+ applyEngineeringFilter();
3252
+ }
3253
+ window.requestAnimationFrame(() => {
3254
+ target.classList.add('is-located');
3255
+ target.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'center' });
3256
+ target.focus({ preventScroll: true });
3257
+ window.setTimeout(() => target.classList.remove('is-located'), 1800);
3258
+ });
3259
+ });
3260
+ });
3261
+
3262
+ try {
3263
+ const initialView = new URL(window.location.href).searchParams.get('view');
3264
+ setReviewView(initialView === 'engineering' ? 'engineering' : 'business', false);
3265
+ } catch (error) {
3266
+ setReviewView('business', false);
3267
+ }
3268
+
2572
3269
  document.querySelectorAll('.review-direction-select').forEach((button) => {
2573
3270
  button.addEventListener('click', () => {
2574
3271
  document.querySelectorAll('.review-direction-card').forEach((card) => {