@openprd/cli 0.1.1 → 0.1.8

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 (137) hide show
  1. package/.openprd/README.md +43 -69
  2. package/.openprd/README_EN.md +84 -0
  3. package/.openprd/benchmarks/index.md +7 -0
  4. package/.openprd/benchmarks/sources.yaml +25 -3
  5. package/.openprd/discovery/config.json +16 -2
  6. package/.openprd/engagements/active/flows.md +19 -14
  7. package/.openprd/engagements/active/handoff.md +11 -4
  8. package/.openprd/engagements/active/prd.md +99 -71
  9. package/.openprd/engagements/active/review.html +4 -4
  10. package/.openprd/engagements/active/roles.md +9 -8
  11. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +4 -4
  12. package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +18 -0
  13. package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +18 -0
  14. package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +18 -0
  15. package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +18 -0
  16. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +78 -0
  17. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +129 -0
  18. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +41 -0
  19. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +14 -0
  20. package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +49 -0
  21. package/.openprd/knowledge/index.json +44 -4
  22. package/.openprd/reviews/v0001.html +195 -129
  23. package/.openprd/reviews/v0002.html +1150 -0
  24. package/.openprd/reviews/v0003.html +1150 -0
  25. package/.openprd/reviews/v0004.html +1150 -0
  26. package/.openprd/reviews/v0005.html +1150 -0
  27. package/.openprd/standards/config.json +12 -9
  28. package/.openprd/state/changes.json +17 -2
  29. package/.openprd/state/current.json +399 -63
  30. package/.openprd/state/release-ledger.json +344 -0
  31. package/.openprd/state/version-index.json +52 -0
  32. package/.openprd/state/versions/v0002.json +264 -0
  33. package/.openprd/state/versions/v0002.md +183 -0
  34. package/.openprd/state/versions/v0003.json +269 -0
  35. package/.openprd/state/versions/v0003.md +188 -0
  36. package/.openprd/state/versions/v0004.json +274 -0
  37. package/.openprd/state/versions/v0004.md +193 -0
  38. package/.openprd/state/versions/v0005.json +299 -0
  39. package/.openprd/state/versions/v0005.md +189 -0
  40. package/.openprd/templates/agent/intake.md +5 -4
  41. package/.openprd/templates/b2b/intake.md +5 -4
  42. package/.openprd/templates/base/intake.md +10 -4
  43. package/.openprd/templates/company/README.md +9 -7
  44. package/.openprd/templates/company/README_EN.md +12 -0
  45. package/.openprd/templates/consumer/intake.md +5 -4
  46. package/.openprd/templates/industry/README.md +12 -10
  47. package/.openprd/templates/industry/README_EN.md +18 -0
  48. package/.openprd/templates/project/README.md +11 -9
  49. package/.openprd/templates/project/README_EN.md +16 -0
  50. package/.openprd/templates/session/README.md +11 -9
  51. package/.openprd/templates/session/README_EN.md +16 -0
  52. package/AGENTS.md +12 -8
  53. package/README.md +399 -438
  54. package/README_CN.md +4 -578
  55. package/README_EN.md +850 -0
  56. package/docs/assets/openprd-requirement-routing-en.png +0 -0
  57. package/docs/assets/openprd-requirement-routing-en.svg +102 -0
  58. package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
  59. package/docs/assets/openprd-requirement-routing-zh.png +0 -0
  60. package/docs/assets/openprd-requirement-routing-zh.svg +102 -0
  61. package/package.json +6 -2
  62. package/scripts/dev-check-wrapup-copy.mjs +110 -0
  63. package/scripts/openprd-github-release-notes.mjs +99 -0
  64. package/scripts/quality-perf-check.mjs +203 -0
  65. package/skills/openprd-benchmark-router/SKILL.md +1 -0
  66. package/skills/openprd-benchmark-router/references/benchmark-sources.md +1 -0
  67. package/skills/openprd-benchmark-router/references/source-policy.md +2 -0
  68. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  69. package/skills/openprd-harness/SKILL.md +46 -24
  70. package/skills/openprd-harness/references/workflow-gates.md +15 -0
  71. package/skills/openprd-quality/SKILL.md +10 -4
  72. package/skills/openprd-requirement-intake/SKILL.md +31 -20
  73. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -6
  74. package/skills/openprd-requirement-intake/references/routing-rubric.md +10 -2
  75. package/skills/openprd-router/SKILL.md +2 -2
  76. package/skills/openprd-shared/SKILL.md +51 -23
  77. package/skills/openprd-standards/SKILL.md +2 -1
  78. package/src/agent-integration.js +265 -65
  79. package/src/benchmark/constants.js +107 -0
  80. package/src/benchmark/operations.js +235 -0
  81. package/src/benchmark/registry.js +64 -0
  82. package/src/benchmark/render.js +115 -0
  83. package/src/benchmark/source.js +617 -0
  84. package/src/benchmark/storage.js +121 -0
  85. package/src/benchmark/verify.js +235 -0
  86. package/src/benchmark.js +50 -851
  87. package/src/change-summary.js +339 -0
  88. package/src/cli/args.js +67 -6
  89. package/src/cli/basic-print.js +365 -0
  90. package/src/cli/benchmark-print.js +91 -0
  91. package/src/cli/change-print.js +221 -0
  92. package/src/cli/doctor-print.js +268 -0
  93. package/src/cli/growth-print.js +176 -0
  94. package/src/cli/print.js +73 -1384
  95. package/src/cli/quality-print.js +284 -0
  96. package/src/cli/run-print.js +297 -0
  97. package/src/cli/shared-print.js +127 -0
  98. package/src/cli/workflow-print.js +195 -0
  99. package/src/codex-hook-runner-template.mjs +639 -117
  100. package/src/codex-runtime.js +324 -0
  101. package/src/dev-standards.js +178 -5
  102. package/src/diagram-core.js +5 -5
  103. package/src/discovery.js +2 -1
  104. package/src/execution-strategy.js +369 -0
  105. package/src/fleet.js +4 -0
  106. package/src/github-release.js +156 -0
  107. package/src/growth.js +311 -13
  108. package/src/html-artifact-utils.js +25 -0
  109. package/src/html-artifacts.js +157 -1596
  110. package/src/knowledge.js +1176 -75
  111. package/src/language-policy.js +2 -112
  112. package/src/learning-html-artifact.js +1031 -0
  113. package/src/learning-review.js +3 -2
  114. package/src/loop.js +280 -9
  115. package/src/openprd.js +341 -38
  116. package/src/openspec/change-validate.js +0 -9
  117. package/src/openspec/execute.js +79 -3
  118. package/src/openspec/generate.js +33 -20
  119. package/src/openspec/tasks.js +33 -2
  120. package/src/prd-core.js +10 -9
  121. package/src/product-type-copy.js +69 -0
  122. package/src/quality-html-artifact.js +108 -9
  123. package/src/quality-learning.js +30 -0
  124. package/src/quality-visual-review.js +237 -0
  125. package/src/quality.js +329 -43
  126. package/src/registry-hygiene.js +54 -0
  127. package/src/release-ledger.js +413 -0
  128. package/src/review-presentation.js +12 -6
  129. package/src/run-harness.js +722 -48
  130. package/src/session-binding.js +40 -3
  131. package/src/session-registry.js +159 -0
  132. package/src/standards.js +5 -3
  133. package/src/test-strategy.js +386 -0
  134. package/src/visual-compare.js +915 -34
  135. package/src/work-unit-migration.js +5 -1
  136. package/src/workspace-core.js +343 -19
  137. package/src/workspace-workflow.js +538 -134
@@ -1,30 +1,15 @@
1
1
  import path from 'node:path';
2
- import { pathToFileURL } from 'node:url';
3
2
  import { spawn } from 'node:child_process';
3
+ import { buildReviewFallbackPanelItems, CHANGE_SUMMARY_VERBS, USER_CHANGE_SUMMARY_GUIDE } from './change-summary.js';
4
4
  import { cjoin, writeText } from './fs-utils.js';
5
+ import { escapeHtml, listMarkup, slugify } from './html-artifact-utils.js';
6
+ export { learningPackagePaths, renderLearningArtifact } from './learning-html-artifact.js';
5
7
  import { renderQualityEvalArtifact as renderQualityEvalArtifactV2 } from './quality-html-artifact.js';
6
8
 
7
- function escapeHtml(value) {
8
- return String(value ?? '')
9
- .replace(/&/g, '&')
10
- .replace(/</g, '&lt;')
11
- .replace(/>/g, '&gt;')
12
- .replace(/"/g, '&quot;')
13
- .replace(/'/g, '&#39;');
14
- }
15
-
16
9
  function leafName(value) {
17
10
  return String(value ?? '').split(/[\\/]/).filter(Boolean).at(-1) ?? String(value ?? '');
18
11
  }
19
12
 
20
- function listMarkup(items, emptyText = '暂无') {
21
- const normalized = Array.isArray(items) ? items.filter(Boolean) : [];
22
- if (normalized.length === 0) {
23
- return `<li class="empty">${escapeHtml(emptyText)}</li>`;
24
- }
25
- return normalized.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
26
- }
27
-
28
13
  function card(title, body) {
29
14
  return `
30
15
  <section class="card">
@@ -347,15 +332,6 @@ function metricCard(title, metric, subtext) {
347
332
  `);
348
333
  }
349
334
 
350
- function slugify(value, fallback = 'artifact') {
351
- const slug = String(value ?? '')
352
- .toLowerCase()
353
- .replace(/[^a-z0-9]+/g, '-')
354
- .replace(/^-+|-+$/g, '')
355
- .slice(0, 80);
356
- return slug || fallback;
357
- }
358
-
359
335
  function formatClarificationQuestion(item) {
360
336
  return `
361
337
  <div class="qa-item ${item.reason === 'missing' ? 'warning' : ''}">
@@ -415,6 +391,7 @@ export function buildReviewExportPayload(snapshot) {
415
391
  '确认主流程与失败路径',
416
392
  '确认关键风险与开放问题',
417
393
  ],
394
+ summaryStyle: USER_CHANGE_SUMMARY_GUIDE,
418
395
  sectionKeys: Object.keys(sections),
419
396
  presentationContract: presentation.contract,
420
397
  presentationFeedback: presentation.violations,
@@ -424,6 +401,7 @@ export function buildReviewExportPayload(snapshot) {
424
401
 
425
402
  const REVIEW_PRESENTATION_CONTRACT = {
426
403
  intent: '这些限制用于反馈给 Agent 重新概括,不由 HTML 模板截断原文。',
404
+ summaryStyle: USER_CHANGE_SUMMARY_GUIDE,
427
405
  expectedDataShape: {
428
406
  reviewPresentation: {
429
407
  diagram: {
@@ -448,16 +426,16 @@ const REVIEW_PRESENTATION_CONTRACT = {
448
426
  ],
449
427
  panels: {
450
428
  flow: [
451
- { summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
429
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.flow,
452
430
  ],
453
431
  function: [
454
- { summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
432
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.function,
455
433
  ],
456
434
  guardrail: [
457
- { summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
435
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.guardrail,
458
436
  ],
459
437
  risk: [
460
- { summary: '15 字内标签', detail: '用户能读懂的一句话说明' },
438
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.risk,
461
439
  ],
462
440
  },
463
441
  },
@@ -482,18 +460,23 @@ const REVIEW_PRESENTATION_CONTRACT = {
482
460
  area: '四个评审卡片',
483
461
  target: '重点摘要胶囊',
484
462
  maxChars: 15,
485
- action: '请重写成短标签,保留结论,不要堆叠长句。',
463
+ action: `请重写成短标签,优先使用 ${CHANGE_SUMMARY_VERBS.join(' / ')} 这类用户能一眼看懂的动作词,再补必要对象。`,
486
464
  },
487
465
  {
488
466
  id: 'review-panel-detail-format',
489
467
  area: '四个评审卡片',
490
468
  target: '明细分点',
491
469
  format: '- **摘要内容**:明细一句话',
492
- action: '请写入 reviewPresentation.panels.<kind>[],把每个明细改写为“加粗短摘要 + 一句话说明”,方便用户先扫重点再读细节。',
470
+ action: `请写入 reviewPresentation.panels.<kind>[],把每个明细改写为“加粗短摘要 + 一句话说明”,短摘要优先使用 ${CHANGE_SUMMARY_VERBS.join(' / ')}。`,
493
471
  },
494
472
  ],
495
473
  };
496
474
 
475
+ function reviewPanelExample(kind) {
476
+ const example = USER_CHANGE_SUMMARY_GUIDE.panelExamples[kind] ?? { summary: '15字内标签', detail: '一句话说明' };
477
+ return `{ "summary": "${example.summary}", "detail": "${example.detail}" }`;
478
+ }
479
+
497
480
  export function buildReviewPresentationFeedback(snapshot) {
498
481
  const sectionsData = snapshot.sections ?? {};
499
482
  const violations = [];
@@ -565,7 +548,7 @@ export function buildReviewPresentationFeedback(snapshot) {
565
548
  jsonPath: `reviewPresentation.panels.${group.kind}[${index}]`,
566
549
  expectedFormat: '- **摘要内容**:明细一句话',
567
550
  currentText: normalizedReviewVisibleText(group.items[index]),
568
- action: `请写入 reviewPresentation.panels.${group.kind}[${index}],格式为 { "summary": "15 字内标签", "detail": "一句话说明" }。`,
551
+ action: `请写入 reviewPresentation.panels.${group.kind}[${index}],格式例如 ${reviewPanelExample(group.kind)}。`,
569
552
  });
570
553
  });
571
554
  panelItems.forEach((item, index) => {
@@ -586,7 +569,7 @@ export function buildReviewPresentationFeedback(snapshot) {
586
569
  jsonPath: `reviewPresentation.panels.${group.kind}[${index}]`,
587
570
  expectedFormat: '- **摘要内容**:明细一句话',
588
571
  currentText: normalizedReviewVisibleText(item),
589
- action: `请写入 reviewPresentation.panels.${group.kind}[${index}],格式为 { "summary": "15 字内标签", "detail": "一句话说明" }。`,
572
+ action: `请写入 reviewPresentation.panels.${group.kind}[${index}],格式例如 ${reviewPanelExample(group.kind)}。`,
590
573
  });
591
574
  });
592
575
  });
@@ -671,6 +654,12 @@ function hasReviewPresentationPanel(snapshot, kind) {
671
654
  return Array.isArray(reviewPresentationPanels(snapshot)[kind]);
672
655
  }
673
656
 
657
+ function reviewPanelFallbackType(kind) {
658
+ if (kind === 'function') return '新增';
659
+ if (kind === 'flow') return '优化';
660
+ return '调整';
661
+ }
662
+
674
663
  function normalizeReviewPresentationPanelItem(item) {
675
664
  if (item && typeof item === 'object' && !Array.isArray(item)) {
676
665
  const summary = normalizedReviewVisibleText(item.summary ?? item.title ?? item.label);
@@ -686,7 +675,10 @@ function normalizeReviewPresentationPanelItem(item) {
686
675
  function reviewPresentationPanelItems(snapshot, kind, fallbackItems) {
687
676
  const items = reviewPresentationPanels(snapshot)[kind];
688
677
  if (!Array.isArray(items)) {
689
- return fallbackItems;
678
+ return buildReviewFallbackPanelItems(fallbackItems, {
679
+ fallbackType: reviewPanelFallbackType(kind),
680
+ summaryMaxLength: 15,
681
+ });
690
682
  }
691
683
  return items.map(normalizeReviewPresentationPanelItem).filter(Boolean);
692
684
  }
@@ -784,6 +776,46 @@ function reviewIcon(kind) {
784
776
  return `<span class="review-icon review-icon-${escapeHtml(kind)}" aria-hidden="true">${icons[kind] ?? icons.flow}</span>`;
785
777
  }
786
778
 
779
+ function reviewReleaseStatusLabel(status) {
780
+ const labels = {
781
+ draft: '草稿版本',
782
+ current: '当前版本',
783
+ released: '已发布版本',
784
+ };
785
+ return labels[status] ?? '版本轨道';
786
+ }
787
+
788
+ function resolveReviewProjectRelease(projectRelease) {
789
+ if (!projectRelease || typeof projectRelease !== 'object' || Array.isArray(projectRelease)) {
790
+ return null;
791
+ }
792
+ const version = typeof projectRelease.currentVersion === 'string' ? projectRelease.currentVersion.trim() : '';
793
+ if (!version) return null;
794
+ return {
795
+ version,
796
+ status: typeof projectRelease.currentStatus === 'string' ? projectRelease.currentStatus.trim() : '',
797
+ itemCount: Number.isFinite(projectRelease.itemCount) ? projectRelease.itemCount : null,
798
+ };
799
+ }
800
+
801
+ function renderReviewProjectVersion(projectRelease) {
802
+ if (!projectRelease) return '';
803
+ const meta = [];
804
+ if (projectRelease.status) {
805
+ meta.push(reviewReleaseStatusLabel(projectRelease.status));
806
+ }
807
+ if (projectRelease.itemCount > 0) {
808
+ meta.push(`${projectRelease.itemCount} 条变化`);
809
+ }
810
+ return `
811
+ <div class="review-project-version" aria-label="项目版本">
812
+ <span class="review-project-version-label">项目版本</span>
813
+ <strong class="review-project-version-value">${escapeHtml(projectRelease.version)}</strong>
814
+ ${meta.length ? `<span class="review-project-version-meta">${escapeHtml(meta.join(' · '))}</span>` : ''}
815
+ </div>
816
+ `;
817
+ }
818
+
787
819
  function renderReviewOverview(snapshot, sectionsData) {
788
820
  const problem = sectionsData.problem?.problemStatement || '尚未形成明确问题定义';
789
821
  return `
@@ -1157,6 +1189,10 @@ function reviewCopyBundle({ label, command, payload, message = null }) {
1157
1189
  ].filter(Boolean).join('\n\n');
1158
1190
  }
1159
1191
 
1192
+ function reviewContinueMessage(snapshot) {
1193
+ return '这版需求确认稿已经通过。请先记录这次确认结果,并继续推进后续落地内容。只有后面确实需要额外授权时,再用人话说明影响和下一步。';
1194
+ }
1195
+
1160
1196
  function shellQuote(value) {
1161
1197
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
1162
1198
  }
@@ -1182,7 +1218,12 @@ function renderReviewDecision(snapshot) {
1182
1218
  const payload = JSON.stringify(buildReviewExportPayload(snapshot), null, 2);
1183
1219
  const confirmCommand = reviewCommand(snapshot, 'confirmed');
1184
1220
  const reviseCommand = reviewCommand(snapshot, 'needs-revision', '说明需要调整的点');
1185
- const confirmCopy = reviewCopyBundle({ label: '认可方案', command: confirmCommand, payload });
1221
+ const confirmCopy = reviewCopyBundle({
1222
+ label: '认可并继续下一步',
1223
+ command: confirmCommand,
1224
+ payload,
1225
+ message: reviewContinueMessage(snapshot),
1226
+ });
1186
1227
  const reviseCopy = reviewCopyBundle({ label: '需要调整', command: reviseCommand, payload });
1187
1228
  return `
1188
1229
  <nav class="review-bottom-bar" aria-label="评审决定">
@@ -1191,17 +1232,18 @@ function renderReviewDecision(snapshot) {
1191
1232
  需要调整
1192
1233
  </button>
1193
1234
  <button type="button" class="review-bottom-action confirm" data-copy-value="${escapeHtml(confirmCopy)}" title="${escapeHtml(confirmCommand)}">
1194
- 认可方案
1235
+ 认可并继续
1195
1236
  </button>
1196
1237
  </div>
1197
1238
  </nav>
1198
1239
  `;
1199
1240
  }
1200
1241
 
1201
- function renderReviewPage({ snapshot, sectionsData }) {
1242
+ function renderReviewPage({ snapshot, sectionsData, projectRelease }) {
1202
1243
  const primaryFlows = reviewList(sectionsData.scenarios?.primaryFlows);
1203
1244
  const edgeCases = reviewList(sectionsData.scenarios?.edgeCases);
1204
1245
  const failureModes = reviewList(sectionsData.scenarios?.failureModes);
1246
+ const visibleProjectRelease = resolveReviewProjectRelease(projectRelease ?? snapshot.projectRelease);
1205
1247
  const flowPanelItems = reviewPresentationPanelItems(snapshot, 'flow', [
1206
1248
  ...primaryFlows,
1207
1249
  ...edgeCases,
@@ -1295,7 +1337,8 @@ function renderReviewPage({ snapshot, sectionsData }) {
1295
1337
  .review-topbar {
1296
1338
  display: flex;
1297
1339
  align-items: center;
1298
- justify-content: flex-start;
1340
+ justify-content: space-between;
1341
+ flex-wrap: wrap;
1299
1342
  gap: 16px;
1300
1343
  margin-bottom: 16px;
1301
1344
  }
@@ -1312,6 +1355,35 @@ function renderReviewPage({ snapshot, sectionsData }) {
1312
1355
  font-weight: 700;
1313
1356
  letter-spacing: 0;
1314
1357
  }
1358
+ .review-project-version {
1359
+ display: grid;
1360
+ gap: 4px;
1361
+ min-width: 220px;
1362
+ margin-left: auto;
1363
+ padding: 12px 16px;
1364
+ border: 1px solid #bfdbfe;
1365
+ border-radius: 16px;
1366
+ background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
1367
+ box-shadow: 0 12px 24px rgba(37, 99, 235, 0.08);
1368
+ }
1369
+ .review-project-version-label {
1370
+ color: var(--review-blue);
1371
+ font-size: 12px;
1372
+ font-weight: 800;
1373
+ letter-spacing: 0;
1374
+ }
1375
+ .review-project-version-value {
1376
+ color: var(--review-text);
1377
+ font-family: var(--review-mono);
1378
+ font-size: 22px;
1379
+ line-height: 1.1;
1380
+ }
1381
+ .review-project-version-meta {
1382
+ color: var(--review-muted);
1383
+ font-size: 12px;
1384
+ font-weight: 700;
1385
+ letter-spacing: 0;
1386
+ }
1315
1387
  .review-kicker {
1316
1388
  margin: 0 0 6px;
1317
1389
  color: var(--review-muted);
@@ -1691,6 +1763,11 @@ function renderReviewPage({ snapshot, sectionsData }) {
1691
1763
  @media (max-width: 620px) {
1692
1764
  .review-page { padding: 18px 12px 128px; }
1693
1765
  .review-topbar { align-items: flex-start; flex-direction: column; }
1766
+ .review-project-version {
1767
+ min-width: 0;
1768
+ width: 100%;
1769
+ margin-left: 0;
1770
+ }
1694
1771
  .review-overview { padding: 18px; }
1695
1772
  .review-overview h1 {
1696
1773
  font-size: 26px;
@@ -1718,6 +1795,7 @@ function renderReviewPage({ snapshot, sectionsData }) {
1718
1795
  <main class="review-page">
1719
1796
  <header class="review-topbar">
1720
1797
  <div class="review-brand">OpenPrd / 评审面板</div>
1798
+ ${renderReviewProjectVersion(visibleProjectRelease)}
1721
1799
  </header>
1722
1800
  ${renderReviewOverview(snapshot, sectionsData)}
1723
1801
  ${renderReviewFlowSvg(snapshot, sectionsData)}
@@ -2095,9 +2173,9 @@ export function renderPlaygroundArtifact({ snapshot, state, markdownPath, patchP
2095
2173
  </html>`;
2096
2174
  }
2097
2175
 
2098
- export function renderReviewArtifact({ snapshot }) {
2176
+ export function renderReviewArtifact({ snapshot, projectRelease = null }) {
2099
2177
  const sectionsData = snapshot.sections ?? {};
2100
- return renderReviewPage({ snapshot, sectionsData });
2178
+ return renderReviewPage({ snapshot, sectionsData, projectRelease });
2101
2179
  }
2102
2180
 
2103
2181
  export function renderRegressionArtifact({ task, report }) {
@@ -2151,1571 +2229,69 @@ export function renderRegressionArtifact({ task, report }) {
2151
2229
  });
2152
2230
  }
2153
2231
 
2154
- function qualityStatusLabel(status) {
2155
- if (status === 'pass') return '通过';
2156
- if (status === 'fail') return '失败';
2157
- if (status === 'needs-evidence') return '需补证据';
2158
- if (status === 'advisory') return '建议关注';
2159
- if (status === 'waived') return '已豁免';
2160
- return '需关注';
2161
- }
2162
-
2163
- function qualityStatusClass(status) {
2164
- if (status === 'pass') return 'success';
2165
- if (status === 'fail') return 'warning';
2166
- return 'warning';
2167
- }
2168
-
2169
- function qualityBadgeClass(status) {
2170
- if (status === 'production-ready') return 'status-pass';
2171
- if (status === 'failed') return 'status-fail';
2172
- return 'status-warn';
2173
- }
2174
-
2175
- function miniMetric(title, metric, subtext) {
2176
- return `
2177
- <div class="mini-metric">
2178
- <div class="mini-metric-label">${escapeHtml(title)}</div>
2179
- <div class="mini-metric-value">${escapeHtml(metric)}</div>
2180
- <div class="mini-metric-sub">${escapeHtml(subtext)}</div>
2181
- </div>
2182
- `;
2183
- }
2184
-
2185
- function auditStatusClass(status) {
2186
- if (status === 'pass' || status === 'production-ready' || status === 'waived') return 'audit-pass';
2187
- if (status === 'fail' || status === 'failed' || status === 'needs-attention') return 'audit-fail';
2188
- if (status === 'needs-evidence') return 'audit-evidence';
2189
- return 'audit-advisory';
2190
- }
2191
-
2192
- function auditGateDecision(gate) {
2193
- if (gate.required && gate.status === 'pass') return '本期必测块已通过';
2194
- if (gate.required && gate.status === 'waived') return '已豁免,需保留依据';
2195
- if (gate.required) return '本期必测未通过,不能宣称就绪';
2196
- if (gate.status === 'pass') return '按风险确认项已有证据';
2197
- if (gate.status === 'advisory') return '当前可选,风险进入范围后升级';
2198
- return '当前不阻断,建议补证据';
2199
- }
2200
-
2201
- function auditChips(items, emptyText = '无') {
2202
- const list = Array.isArray(items) ? items.filter(Boolean) : [];
2203
- if (list.length === 0) return `<span class="audit-chip muted">${escapeHtml(emptyText)}</span>`;
2204
- return list.map((item) => `<span class="audit-chip">${escapeHtml(item)}</span>`).join('\n');
2205
- }
2206
-
2207
- function auditActionItems(report) {
2208
- const requiredGates = report.gates.filter((gate) => gate.required);
2209
- const failingRequired = requiredGates.filter((gate) => !['pass', 'waived'].includes(gate.status));
2210
- const advisory = report.gates.filter((gate) => !gate.required && gate.status !== 'pass');
2211
- if (failingRequired.length > 0) {
2212
- return failingRequired.map((gate) => `${reviewerGateDisplay(gate)}: ${gate.warnings[0] ?? gate.evidence?.summary ?? '补齐必需证据后再继续'}`);
2213
- }
2214
- if (advisory.length > 0) {
2215
- return advisory.map((gate) => `${reviewerGateDisplay(gate)}: ${auditGateDecision(gate)}`);
2216
- }
2217
- return ['当前本期必测块全部通过;继续保留本次执行证据,交付前复跑 openprd run --verify。'];
2218
- }
2219
-
2220
- function auditEvidenceRows(report) {
2221
- return report.gates.flatMap((gate) => {
2222
- const sources = gate.evidence?.sources ?? [];
2223
- if (sources.length === 0) {
2224
- return [{
2225
- gate,
2226
- source: '未提供',
2227
- path: gate.required ? '缺少必需证据' : '当前场景未要求',
2228
- empty: true,
2229
- }];
2230
- }
2231
- return sources.map((source) => ({
2232
- gate,
2233
- source: source.source ?? 'evidence',
2234
- path: source.path ?? 'unknown',
2235
- empty: false,
2236
- }));
2237
- });
2238
- }
2239
-
2240
- function reviewerGateFocus(gate) {
2241
- const focusByGate = {
2242
- smoke: '看主流程和最容易出错的失败路径是否真的跑过,而不是只写了测试文件。',
2243
- 'feature-coverage': '看本次需求、任务和验收点是否都被覆盖;如果没有激活任务,要确认这是不是合理状态。',
2244
- 'business-guardrails': '看成本、免费额度、滥用、报警和止损动作是否讲清楚,避免上线后失控。',
2245
- traceability: '看出问题时能不能从用户动作追到请求、任务和错误,方便复现和定位。',
2246
- redaction: '看日志、截图、报告和错误信息里是否可能泄露用户隐私、密钥或敏感业务数据。',
2247
- 'normal-performance': '看普通规模下是否会卡顿、超时、资源异常,是否有可比较的基线。',
2248
- 'extreme-performance': '看大数据、并发、异常输入或边界规模下是否有兜底,不只是跑小样本。',
2249
- knowledge: '看这次发现的问题是否值得沉淀,避免下次 Agent 或团队重复踩同一个坑。',
2250
- };
2251
- return focusByGate[gate.id] ?? '看这个测试块是否和本次需求相关,证据是否来自本次执行。';
2252
- }
2253
-
2254
- function reviewerGateQuestion(gate) {
2255
- if (gate.required && gate.status === 'pass') {
2256
- return '你可以抽查证据是否对应本次需求;如果证据太泛,要求 Agent 补本次执行记录。';
2257
- }
2258
- if (gate.required) {
2259
- return '这里不能放行。请要求 Agent 补证据、修复问题,并重新生成回归测试报告。';
2260
- }
2261
- if (gate.status === 'pass') {
2262
- return '当前不阻断;你只需要判断这项是否和本次风险相关,必要时抽查证据。';
2263
- }
2264
- return '你要决定是否接受本次不补;如果准备发布或风险变高,应要求升级为本期必测。';
2265
- }
2266
-
2267
- function reviewerScenarioLabel(tag) {
2268
- const labels = {
2269
- core: '基础验证',
2270
- frontend: '界面体验',
2271
- desktop: '桌面端体验',
2272
- backend: '服务与数据处理',
2273
- businessCost: '成本与滥用风险',
2274
- security: '隐私与安全',
2275
- performance: '性能风险',
2276
- extreme: '极端场景',
2277
- release: '上线交付',
2278
- legacy: '历史兼容',
2279
- };
2280
- return labels[tag] ?? tag;
2281
- }
2282
-
2283
- function reviewerGateLabel(report, gateId) {
2284
- const gate = report.gates.find((item) => item.id === gateId);
2285
- return reviewerGateDisplay(gate ?? { id: gateId, label: gateId });
2286
- }
2287
-
2288
- function reviewerGateDisplay(gate) {
2289
- if (gate.id === 'knowledge') return '经验沉淀';
2290
- return String(gate.label ?? gate.id).replace(/\s*Skill\s*/g, ' ');
2291
- }
2292
-
2293
- function reviewerEnforcementLabel(value) {
2294
- if (value === 'blocking') return '严格阻断';
2295
- if (value === 'advisory') return '建议模式';
2296
- return value ?? '未标明';
2297
- }
2298
-
2299
- function reviewerPolicyLabels(report) {
2300
- const policy = report.qualityPolicy ?? { scenarioTags: [], requiredGates: [], optionalGates: [] };
2301
- return {
2302
- scenarioLabels: policy.scenarioTags.map(reviewerScenarioLabel),
2303
- requiredLabels: policy.requiredGates.map((gateId) => reviewerGateLabel(report, gateId)),
2304
- optionalLabels: policy.optionalGates.map((gateId) => reviewerGateLabel(report, gateId)),
2305
- };
2306
- }
2307
-
2308
- function reviewerDecisionPayload(report, actionItems) {
2309
- const labels = reviewerPolicyLabels(report);
2310
- const failingRequired = report.gates
2311
- .filter((gate) => gate.required && !['pass', 'waived'].includes(gate.status))
2312
- .map(reviewerGateDisplay);
2313
- const advisory = report.gates
2314
- .filter((gate) => !gate.required && gate.status !== 'pass')
2315
- .map(reviewerGateDisplay);
2316
- return [
2317
- `我看了回归测试报告 ${report.id},我的确认意见如下:`,
2318
- '',
2319
- `1. 场景判断:${labels.scenarioLabels.join(', ') || '未标明'}。我认为这个场景【接受 / 不接受】,原因是:`,
2320
- `2. 本期必测块:${labels.requiredLabels.join(', ') || '无'}。我认为这些回归结果【可信 / 不可信】,需要补充:`,
2321
- `3. 需确认遗漏:${advisory.join(', ') || '无'}。我选择【不属于本期,可延期 / 属于本期,需要现在补齐】。`,
2322
- `4. 放行结论:${failingRequired.length === 0 ? '本期必测块可以继续,但请按我的选择处理需确认遗漏。' : `我不同意放行,未通过项包括:${failingRequired.join(', ')}`}`,
2323
- '',
2324
- '我希望 Agent 接下来处理:',
2325
- ...actionItems.map((item) => `- ${item}`),
2326
- ].join('\n');
2327
- }
2328
-
2329
- function reviewerEvidencePayload(report) {
2330
- const missing = report.gates.filter((gate) => gate.status !== 'pass' || !gate.evidence?.present);
2331
- return [
2332
- `请根据回归测试报告 ${report.id} 补齐或解释以下回归遗漏:`,
2333
- '',
2334
- ...missing.map((gate) => [
2335
- `- ${reviewerGateDisplay(gate)}(${gate.required ? '本期必测' : '按风险确认'},当前状态:${qualityStatusLabel(gate.status)})`,
2336
- ` 我要确认:${reviewerGateFocus(gate)}`,
2337
- ` 你需要补充:${regressionHumanText(gate.warnings[0] ?? gate.evidence?.summary ?? '本次执行证据、覆盖范围和判断理由')}`,
2338
- ].join('\n')),
2339
- '',
2340
- '补完后请重新运行 openprd quality . --verify 和 openprd run . --verify,并给我新的报告链接。',
2341
- ].join('\n');
2342
- }
2343
-
2344
- function reviewerScenarioPayload(report) {
2345
- const labels = reviewerPolicyLabels(report);
2346
- return [
2347
- `我想重新确认回归测试场景。当前报告 ${report.id} 的场景是:${labels.scenarioLabels.join(', ') || '未标明'}。`,
2348
- '',
2349
- '请你重新判断:',
2350
- '- 这个需求是否其实应该按上线交付 / 隐私与安全 / 性能风险 / 服务与数据处理 / 极端场景处理?',
2351
- '- 哪些按风险确认的测试块应该升级为本期必测?',
2352
- '- 如果仍保持当前场景,请用面向评审者的语言解释为什么。',
2353
- '',
2354
- `当前本期必测块:${labels.requiredLabels.join(', ') || '无'}`,
2355
- ].join('\n');
2356
- }
2357
-
2358
- function reviewCopyCard(title, description, payload) {
2359
- return `
2360
- <div class="review-copy">
2361
- <div class="review-copy-head">
2362
- <div>
2363
- <strong>${escapeHtml(title)}</strong>
2364
- <span>${escapeHtml(description)}</span>
2365
- </div>
2366
- <button type="button" data-copy-nearest>复制回对话</button>
2367
- </div>
2368
- <textarea readonly data-review-copy>${escapeHtml(payload)}</textarea>
2369
- </div>
2370
- `;
2371
- }
2372
-
2373
- function regressionGateCopyPayload(gate, report) {
2374
- const warnings = gate.warnings.map(regressionHumanText);
2375
- const missingText = warnings.length > 0
2376
- ? warnings.map((item) => `- ${item}`).join('\n')
2377
- : `- ${gate.evidence?.summary ?? '请补充本次执行证据和判断理由'}`;
2378
- return [
2379
- `请处理回归测试报告 ${report.id} 里的这项问题:${reviewerGateDisplay(gate)}`,
2380
- '',
2381
- `当前状态:${regressionResultLabel(gate)}`,
2382
- `本期要求:${gate.required ? '本期必须处理' : '请判断是否属于本期'}`,
2383
- `我关心的是:${regressionBlockDescription(gate)}`,
2384
- '',
2385
- '当前问题:',
2386
- missingText,
2387
- '',
2388
- '请你接下来:',
2389
- '1. 如果属于本期需求,请直接修复、补测或补证据。',
2390
- '2. 如果你认为可以延期,请用需求视角说明原因、影响和后续条件。',
2391
- '3. 处理后重新运行 openprd quality . --verify 和 openprd run . --verify,并给我新的报告链接。',
2392
- ].join('\n');
2393
- }
2394
-
2395
- function regressionGateHints(gate) {
2396
- if (gate.required && gate.status !== 'pass') {
2397
- return [
2398
- '不要让用户决策是否修;先按本期必测块修复或补证据。',
2399
- '补完后重新生成报告,确认必须修复数归零。',
2400
- ];
2401
- }
2402
- if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
2403
- return [
2404
- '如果这是具体需求交付,先补 active change/tasks.md。',
2405
- '把新增、修改、删除、异常路径拆成可验收任务后再回归。',
2406
- ];
2407
- }
2408
- if (gate.status === 'needs-evidence') {
2409
- return [
2410
- '先确认项目是否只是有能力但缺本次执行证据。',
2411
- '如果属于本期风险,补一次实际运行记录,而不是只引用脚本存在。',
2412
- ];
2413
- }
2414
- return [
2415
- '先判断它是否真的属于本期需求风险。',
2416
- '属于本期就补测;不属于本期才写清延期理由和触发条件。',
2417
- ];
2418
- }
2419
-
2420
- function regressionGateSimpleSuggestion(gate) {
2421
- if (gate.required && gate.status !== 'pass') return '先修复或补证据,再重跑报告。';
2422
- if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
2423
- return '具体需求交付时先补 tasks.md。';
2424
- }
2425
- if (gate.status === 'needs-evidence') return '如果属于本期,就补本次执行证据。';
2426
- return '相关就补测,不相关就写清延期理由。';
2427
- }
2428
-
2429
- function regressionResultLabel(gate) {
2430
- if (gate.status === 'pass') return '已通过';
2431
- if (gate.required) return '未通过';
2432
- if (gate.status === 'needs-evidence') return '缺少证据';
2433
- if (gate.status === 'advisory') return '需确认是否本期处理';
2434
- return qualityStatusLabel(gate.status);
2435
- }
2436
-
2437
- function regressionResultClass(gate) {
2438
- if (gate.status === 'pass') return 'audit-pass';
2439
- if (gate.required) return 'audit-fail';
2440
- if (gate.status === 'needs-evidence') return 'audit-evidence';
2441
- return 'audit-advisory';
2442
- }
2443
-
2444
- function regressionExpectation(gate) {
2445
- if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
2446
- return '全项目检查可接受;具体需求交付时必须有任务拆解';
2447
- }
2448
- if (gate.required) return '本期必须通过';
2449
- return '本期默认不阻断;若需求涉及此风险,应升级为本期测试';
2450
- }
2451
-
2452
- function regressionTreatment(gate) {
2453
- if (gate.id === 'feature-coverage' && gate.evidence?.summary === '当前没有激活任务清单') {
2454
- return '如果这是具体需求交付,应先补 active change/tasks,否则无法证明新增/修改/删除等需求项逐项回归。';
2455
- }
2456
- if (gate.required && gate.status === 'pass') return '不需要人工评审;保留证据即可继续。';
2457
- if (gate.required) return '应当现在修复或补证据,修完后重新生成报告。';
2458
- if (gate.status === 'pass') return '已覆盖,可作为辅助证据保留。';
2459
- return '需要判断是否属于本期需求;属于就现在补测,不属于才记录为后续风险。';
2460
- }
2461
-
2462
- function regressionBlockDescription(gate) {
2463
- const descriptions = {
2464
- smoke: '核心路径能否跑通,至少覆盖主流程和最关键的失败路径。',
2465
- 'feature-coverage': '需求拆解项是否全部完成,验收点是否有对应回归。',
2466
- 'business-guardrails': '成本、额度、滥用、报警、止损是否有明确保护。',
2467
- traceability: '出问题时是否能追到用户动作、请求、任务和错误。',
2468
- redaction: '报告、日志和错误信息是否会暴露隐私、密钥或敏感数据。',
2469
- 'normal-performance': '普通规模下是否可用、不卡顿、不超时。',
2470
- 'extreme-performance': '大数据、并发、异常输入、边界规模是否有兜底。',
2471
- knowledge: '本次问题是否需要沉淀成经验,避免下次重复漏测。',
2472
- };
2473
- return descriptions[gate.id] ?? reviewerGateFocus(gate);
2474
- }
2475
-
2476
- function regressionHumanText(value) {
2477
- return String(value ?? '')
2478
- .replace(/阻断此门禁/g, '作为本期必测阻断')
2479
- .replace(/必需门禁/g, '本期必测块')
2480
- .replace(/可选门禁/g, '按风险确认项')
2481
- .replace(/门禁/g, '测试块');
2482
- }
2483
-
2484
- function regressionTaskStatus(task) {
2485
- if (task.done) return '已完成';
2486
- if (task.blocked) return '阻塞';
2487
- return '未完成';
2488
- }
2489
-
2490
- function regressionRequirementRows(activeTasks, report) {
2491
- const tasks = activeTasks.tasks ?? [];
2492
- const requiredGates = report.gates.filter((gate) => gate.required);
2493
- const requiredPassed = requiredGates.filter((gate) => ['pass', 'waived'].includes(gate.status)).length;
2494
- const failingRequired = requiredGates.filter((gate) => !['pass', 'waived'].includes(gate.status));
2495
- const advisoryGates = report.gates.filter((gate) => !gate.required && gate.status !== 'pass');
2496
- if (tasks.length === 0) {
2497
- return `
2498
- <tr class="audit-evidence">
2499
- <td>当前没有激活需求任务</td>
2500
- <td>项目级必测 ${escapeHtml(`${requiredPassed}/${requiredGates.length}`)} 通过</td>
2501
- <td>如果这是具体需求交付,应先生成或保留 tasks.md,再逐项回归。</td>
2502
- </tr>
2503
- `;
2504
- }
2505
- return tasks.map((task) => {
2506
- const statusClass = !task.done || failingRequired.length > 0
2507
- ? 'audit-fail'
2508
- : advisoryGates.length > 0
2509
- ? 'audit-advisory'
2510
- : 'audit-pass';
2511
- const conclusion = !task.done
2512
- ? '不能放行,应完成或明确延期原因。'
2513
- : failingRequired.length > 0
2514
- ? '不能放行,仍有本期必测块未通过。'
2515
- : advisoryGates.length > 0
2516
- ? '功能已完成;需确认风险项是否属于本期。'
2517
- : '通过,无需人工评审。';
2518
- return `
2519
- <tr class="${statusClass}">
2520
- <td>
2521
- <strong>${escapeHtml(task.title)}</strong>
2522
- <span><code>${escapeHtml(`${task.source}:${task.line}`)}</code></span>
2523
- </td>
2524
- <td>${escapeHtml(regressionTaskStatus(task))} · 必测 ${escapeHtml(`${requiredPassed}/${requiredGates.length}`)}</td>
2525
- <td>${escapeHtml(conclusion)}</td>
2526
- </tr>
2527
- `;
2528
- }).join('\n');
2529
- }
2530
-
2531
- function regressionGateSummaryCards(report) {
2532
- return report.gates.map((gate) => `
2533
- <div class="audit-block-card ${regressionResultClass(gate)}">
2534
- <div class="audit-block-card-head">
2535
- <strong>${escapeHtml(reviewerGateDisplay(gate))}</strong>
2536
- <span class="audit-status ${regressionResultClass(gate)}">${escapeHtml(regressionResultLabel(gate))}</span>
2537
- </div>
2538
- <div class="audit-block-meta">
2539
- <span>${gate.required ? '本期必测' : '按风险确认'}</span>
2540
- <span>${gate.evidence?.present ? `${gate.evidence.sources.length} 条证据` : '缺少本次证据'}</span>
2541
- </div>
2542
- </div>
2543
- `).join('\n');
2544
- }
2545
-
2546
- function regressionExceptionItems(report) {
2547
- const items = report.gates.filter((gate) => gate.required ? gate.status !== 'pass' : gate.status !== 'pass');
2548
- if (items.length === 0) {
2549
- return '<div class="audit-empty">没有未通过或需确认的回归块。查看者只需要确认报告对应的是本次需求即可。</div>';
2550
- }
2551
- return items.map((gate) => `
2552
- <div class="audit-risk-card ${regressionResultClass(gate)}">
2553
- <div class="audit-risk-card-head">
2554
- <div>
2555
- <strong>${escapeHtml(reviewerGateDisplay(gate))}</strong>
2556
- <span>${escapeHtml(regressionResultLabel(gate))}</span>
2557
- </div>
2558
- <button type="button" data-copy-nearest>复制给 Agent</button>
2559
- </div>
2560
- <p>${escapeHtml(regressionBlockDescription(gate))}</p>
2561
- <div class="qa-meta">${escapeHtml(regressionGateSimpleSuggestion(gate))}</div>
2562
- <textarea readonly class="copy-source" data-review-copy>${escapeHtml(regressionGateCopyPayload(gate, report))}</textarea>
2563
- </div>
2564
- `).join('\n');
2565
- }
2566
-
2567
2232
  export function renderQualityEvalArtifact({ report }) {
2568
2233
  return renderQualityEvalArtifactV2({ report });
2569
2234
  }
2570
2235
 
2571
- function learningSourceAnchor(sourceId) {
2572
- return `source-${slugify(sourceId, 'source')}`;
2573
- }
2574
-
2575
- function learningAssetUrl(rawPath) {
2576
- const value = String(rawPath ?? '').trim();
2577
- if (!value) return null;
2578
- if (/^(?:https?:|data:|file:)/i.test(value)) return value;
2579
- if (path.isAbsolute(value)) return pathToFileURL(value).href;
2580
- return encodeURI(value.split(path.sep).join('/'));
2581
- }
2582
-
2583
- function formatLearningParagraphs(paragraphs) {
2584
- const list = Array.isArray(paragraphs) ? paragraphs.filter(Boolean) : [];
2585
- return list.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join('\n');
2586
- }
2587
-
2588
- function formatLearningEvidenceChips(sourceIds) {
2589
- const list = Array.isArray(sourceIds) ? sourceIds.filter(Boolean) : [];
2590
- if (list.length === 0) {
2591
- return '<span class="evidence-chip muted">暂无证据引用</span>';
2592
- }
2593
- return list.map((id) => `
2594
- <span class="evidence-chip">${escapeHtml(id)}</span>
2595
- `).join('\n');
2596
- }
2597
-
2598
- function formatLearningRetrievalBlocks(blocks, chapterId) {
2599
- const list = Array.isArray(blocks) ? blocks.filter(Boolean) : [];
2600
- if (list.length === 0) return '';
2601
- return `
2602
- <section class="learning-block retrieval" id="${escapeHtml(chapterId)}-retrieval">
2603
- <h4>检索练习</h4>
2604
- ${list.map((block, index) => `
2605
- <details class="retrieval-item" id="${escapeHtml(chapterId)}-retrieval-${index + 1}">
2606
- <summary><span>R${index + 1}</span>${escapeHtml(block.prompt)}</summary>
2607
- ${block.hint ? `<div class="retrieval-hint">提示: ${escapeHtml(block.hint)}</div>` : ''}
2608
- <div class="retrieval-answer">参考答案: ${escapeHtml(block.answer)}</div>
2609
- </details>
2610
- `).join('\n')}
2611
- </section>
2612
- `;
2613
- }
2614
-
2615
- function formatLearningWorkedExamples(examples, chapterId) {
2616
- const list = Array.isArray(examples) ? examples.filter(Boolean) : [];
2617
- if (list.length === 0) return '';
2618
- return `
2619
- <section class="learning-block worked" id="${escapeHtml(chapterId)}-worked">
2620
- <h4>工作示例</h4>
2621
- ${list.map((example, index) => `
2622
- <div class="worked-item" id="${escapeHtml(chapterId)}-worked-${index + 1}">
2623
- <div class="worked-title">${escapeHtml(example.title)}</div>
2624
- <p>${escapeHtml(example.scenario)}</p>
2625
- <ol>${listMarkup(example.steps, '暂无步骤')}</ol>
2626
- ${example.principle ? `<div class="worked-principle">原则: ${escapeHtml(example.principle)}</div>` : ''}
2627
- </div>
2628
- `).join('\n')}
2629
- </section>
2630
- `;
2631
- }
2632
-
2633
- function formatLearningVisualExplainer(explainer, chapterId) {
2634
- if (!explainer || typeof explainer !== 'object') return '';
2635
- const takeaways = Array.isArray(explainer.takeaways) ? explainer.takeaways.filter(Boolean) : [];
2636
- const imageUrl = learningAssetUrl(explainer.image?.path);
2637
- const hasImage = Boolean(imageUrl);
2638
- return `
2639
- <section class="learning-block visual" id="${escapeHtml(chapterId)}-visual">
2640
- <div class="visual-header">
2641
- <div class="visual-kicker">一眼看懂</div>
2642
- <h4>${escapeHtml(explainer.title ?? '图文解释')}</h4>
2643
- </div>
2644
- <div class="visual-grid${hasImage ? ' has-image' : ''}">
2645
- ${hasImage ? `
2646
- <figure class="visual-figure">
2647
- <img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(explainer.image?.alt ?? explainer.title ?? 'visual explainer')}" loading="lazy" />
2648
- ${explainer.image?.caption ? `<figcaption>${escapeHtml(explainer.image.caption)}</figcaption>` : ''}
2649
- </figure>
2650
- ` : ''}
2651
- <div class="visual-copy">
2652
- <div class="visual-note">
2653
- <div class="visual-label">比喻</div>
2654
- <p>${escapeHtml(explainer.analogy ?? '')}</p>
2655
- </div>
2656
- <div class="visual-note">
2657
- <div class="visual-label">场景</div>
2658
- <p>${escapeHtml(explainer.scene ?? '')}</p>
2659
- </div>
2660
- <div class="visual-note">
2661
- <div class="visual-label">为什么这张图有用</div>
2662
- <p>${escapeHtml(explainer.whyItMatters ?? '')}</p>
2663
- </div>
2664
- ${takeaways.length > 0 ? `
2665
- <div class="visual-note">
2666
- <div class="visual-label">看图重点</div>
2667
- <ul class="visual-takeaways">${listMarkup(takeaways, '暂无重点')}</ul>
2668
- </div>
2669
- ` : ''}
2670
- </div>
2671
- </div>
2672
- </section>
2673
- `;
2674
- }
2675
-
2676
- function formatLearningEvidenceDetails(chapter, sourcesById) {
2677
- const ids = Array.isArray(chapter.evidenceIds) ? chapter.evidenceIds.filter(Boolean) : [];
2678
- if (ids.length === 0) return '';
2679
- return `
2680
- <details class="chapter-evidence" id="${escapeHtml(chapter.id)}-evidence">
2681
- <summary>
2682
- <span class="evidence-summary-title">本章出处</span>
2683
- <span class="evidence-summary-count">${ids.length} 个来源</span>
2684
- </summary>
2685
- <div class="evidence-mini-list">
2686
- ${ids.map((id) => {
2687
- const source = sourcesById.get(id);
2688
- return `
2689
- <div class="evidence-mini-card">
2690
- <strong>${escapeHtml(source?.title ?? id)}</strong>
2691
- <span>${escapeHtml(source?.relativePath ?? source?.path ?? id)}</span>
2692
- ${source?.summary ? `<p>${escapeHtml(source.summary)}</p>` : ''}
2693
- </div>
2694
- `;
2695
- }).join('\n')}
2696
- </div>
2697
- </details>
2698
- `;
2699
- }
2700
-
2701
- function formatLearningChapter(chapter, index, sourcesById) {
2702
- return `
2703
- <section class="chapter${index === 0 ? ' active' : ''}" id="${escapeHtml(chapter.id)}" data-chapter-index="${index}"${index === 0 ? '' : ' hidden'}>
2704
- <div class="chapter-kicker" id="${escapeHtml(chapter.id)}-reading">第 ${index + 1} 章 · ${escapeHtml(chapter.label)}</div>
2705
- <h2>${escapeHtml(chapter.semanticTitle)}</h2>
2706
- <p class="chapter-summary">${escapeHtml(chapter.summary)}</p>
2707
- ${formatLearningVisualExplainer(chapter.visualExplainer, chapter.id)}
2708
- ${formatLearningParagraphs(chapter.paragraphs)}
2709
- ${formatLearningRetrievalBlocks(chapter.retrievalBlocks, chapter.id)}
2710
- ${formatLearningWorkedExamples(chapter.workedExamples, chapter.id)}
2711
- ${formatLearningEvidenceDetails(chapter, sourcesById)}
2712
- </section>
2713
- `;
2236
+ export async function writeHtmlArtifact(filePath, html) {
2237
+ await writeText(filePath, html);
2238
+ return filePath;
2714
2239
  }
2715
2240
 
2716
- function formatLearningOutlineNode(node, indexPath = '1', activeChapterId = null) {
2717
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
2718
- const label = `
2719
- <span class="outline-jump depth-${escapeHtml(node.depth ?? 1)}${node.id === activeChapterId ? ' active' : ''}" data-target-id="${escapeHtml(node.id)}">
2720
- <span class="outline-number">${escapeHtml(indexPath)}</span>
2721
- <span class="outline-copy">
2722
- <strong>${escapeHtml(node.title)}</strong>
2723
- ${node.subtitle ? `<small>${escapeHtml(node.subtitle)}</small>` : ''}
2724
- </span>
2725
- </span>
2726
- `;
2727
- if (!hasChildren) return `<li>${label}</li>`;
2728
- return `
2729
- <li>
2730
- <details class="outline-branch" open>
2731
- <summary>${label}</summary>
2732
- <ol>
2733
- ${node.children.map((child, childIndex) => formatLearningOutlineNode(child, `${indexPath}.${childIndex + 1}`, activeChapterId)).join('\n')}
2734
- </ol>
2735
- </details>
2736
- </li>
2737
- `;
2241
+ export async function openArtifactInBrowser(filePath) {
2242
+ const platform = process.platform;
2243
+ const command = platform === 'darwin'
2244
+ ? 'open'
2245
+ : platform === 'win32'
2246
+ ? 'cmd'
2247
+ : 'xdg-open';
2248
+ const args = platform === 'win32'
2249
+ ? ['/c', 'start', '', filePath]
2250
+ : [filePath];
2251
+ const child = spawn(command, args, {
2252
+ detached: true,
2253
+ stdio: 'ignore',
2254
+ });
2255
+ child.unref();
2738
2256
  }
2739
2257
 
2740
- function formatLearningSource(source) {
2741
- return `
2742
- <section class="source-card" id="${escapeHtml(learningSourceAnchor(source.id))}">
2743
- <div class="source-title">${escapeHtml(source.title)}</div>
2744
- <div class="source-meta">${escapeHtml(source.type)} · ${escapeHtml(source.relativePath ?? source.path ?? '')}</div>
2745
- ${source.summary ? `<p>${escapeHtml(source.summary)}</p>` : ''}
2746
- ${source.excerpt ? `<pre>${escapeHtml(source.excerpt)}</pre>` : ''}
2747
- <div class="source-digest">digest: ${escapeHtml(source.digest ?? 'none')}</div>
2748
- </section>
2749
- `;
2258
+ export function canonicalReviewPath(ws, versionId) {
2259
+ return cjoin(ws.workspaceRoot, 'reviews', `${slugify(versionId, 'review')}.html`);
2750
2260
  }
2751
2261
 
2752
- function formatLearningClaim(claim) {
2753
- return `
2754
- <div class="claim-item">
2755
- <div class="claim-statement">${escapeHtml(claim.statement)}</div>
2756
- <div class="claim-meta">confidence: ${escapeHtml(claim.confidence ?? 'unknown')} · sources: ${(claim.sourceIds ?? []).map((id) => escapeHtml(id)).join(', ') || 'none'}</div>
2757
- </div>
2758
- `;
2262
+ function toRelativeHref(fromFilePath, targetFilePath) {
2263
+ const relative = path.relative(path.dirname(fromFilePath), targetFilePath) || path.basename(targetFilePath);
2264
+ return relative.split(path.sep).join('/');
2759
2265
  }
2760
2266
 
2761
- function formatLearningEmptyState(content, packageMeta, evidenceManifest) {
2762
- const promptPath = content.agentPromptPath ?? packageMeta?.paths?.agentPrompt ?? null;
2763
- const contextPath = content.agentContextPath ?? packageMeta?.paths?.agentContext ?? null;
2764
- const contentPath = content.packagePaths?.contentJson ?? packageMeta?.paths?.contentJson ?? null;
2765
- const assetsDir = content.packagePaths?.assetsDir ?? packageMeta?.paths?.assetsDir ?? null;
2766
- const renderCommand = contentPath ? `openprd learn . --content-json ${contentPath} --open` : null;
2767
- const sourceCount = evidenceManifest?.sourceCount ?? (evidenceManifest?.sources?.length ?? 0);
2768
- const claimCount = evidenceManifest?.claimCount ?? (evidenceManifest?.claims?.length ?? 0);
2769
- const gapCount = Array.isArray(evidenceManifest?.gaps) ? evidenceManifest.gaps.length : 0;
2770
- return `
2771
- <section class="empty-reader" id="agent-authoring">
2772
- <p class="chapter-kicker">证据包待写作</p>
2773
- <h2>还没有生成可阅读正文</h2>
2774
- <p>这一步只完成了学习包归档和证据收集。真正给人阅读的标题、大纲、章节、检索练习和工作示例,还需要由 Agent 根据证据写入内容 JSON 后再渲染。</p>
2775
- <div class="stat-grid">
2776
- <div class="stat"><div class="stat-value">${sourceCount}</div><div class="stat-label">份证据来源</div></div>
2777
- <div class="stat"><div class="stat-value">${claimCount}</div><div class="stat-label">条结构化判断</div></div>
2778
- <div class="stat"><div class="stat-value">${gapCount}</div><div class="stat-label">个待补缺口</div></div>
2779
- </div>
2780
- <ol class="empty-steps">
2781
- <li>让 Agent 读取写作提示、上下文和证据清单。</li>
2782
- <li>由 Agent 把标题、目录、章节正文、检索练习、工作示例和需要的 visualExplainer 写进 <code>learning-content.json</code>。</li>
2783
- <li>写完后重新执行渲染命令,再打开阅读器查看成品。</li>
2784
- </ol>
2785
- <div class="empty-paths">
2786
- ${promptPath ? `<div><strong>写作提示</strong><span>${escapeHtml(promptPath)}</span></div>` : ''}
2787
- ${contextPath ? `<div><strong>上下文</strong><span>${escapeHtml(contextPath)}</span></div>` : ''}
2788
- ${contentPath ? `<div><strong>内容 JSON</strong><span>${escapeHtml(contentPath)}</span></div>` : ''}
2789
- ${assetsDir ? `<div><strong>图片素材目录</strong><span>${escapeHtml(assetsDir)}</span></div>` : ''}
2790
- ${renderCommand ? `<div><strong>重渲染命令</strong><span>${escapeHtml(renderCommand)}</span></div>` : ''}
2791
- </div>
2792
- </section>
2793
- `;
2794
- }
2795
-
2796
- export function renderLearningArtifact({ packageMeta, content, evidenceManifest }) {
2797
- const chapters = Array.isArray(content.chapters) ? content.chapters : [];
2798
- const sources = Array.isArray(evidenceManifest.sources) ? evidenceManifest.sources : [];
2799
- const claims = Array.isArray(evidenceManifest.claims) ? evidenceManifest.claims : [];
2800
- const gaps = Array.isArray(evidenceManifest.gaps) ? evidenceManifest.gaps : [];
2801
- const isAwaitingAgent = content.authoringStatus === 'awaiting-agent-content' || chapters.length === 0;
2802
- const title = content.title || packageMeta?.title || 'OpenPrd 复盘学习包';
2803
- const outline = Array.isArray(content.outline) && content.outline.length > 0
2804
- ? content.outline
2805
- : chapters.map((chapter, index) => ({
2806
- id: chapter.id,
2807
- depth: 1,
2808
- title: `第 ${index + 1} 章 · ${chapter.label}`,
2809
- subtitle: chapter.semanticTitle,
2810
- children: [],
2811
- }));
2812
- const sourcesById = new Map(sources.map((source) => [source.id, source]));
2813
- const initialChapterId = chapters[0]?.id ?? outline[0]?.id ?? null;
2814
- const initialProgressPercent = chapters.length > 0 ? String((1 / chapters.length) * 100) : '0';
2815
-
2267
+ export function renderReviewEntryHtml({ entryPath, reviewPath, title = 'OpenPrd Review' }) {
2268
+ const href = escapeHtml(toRelativeHref(entryPath, reviewPath));
2816
2269
  return `<!DOCTYPE html>
2817
2270
  <html lang="zh-CN">
2818
2271
  <head>
2819
2272
  <meta charset="UTF-8" />
2820
2273
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2274
+ <meta http-equiv="refresh" content="0; url=${href}" />
2821
2275
  <title>${escapeHtml(title)}</title>
2822
2276
  <style>
2823
2277
  :root {
2824
2278
  color-scheme: light;
2825
- --bg: #f6fbff;
2826
- --bg-deep: #eef6ff;
2827
- --paper: #ffffff;
2828
- --panel: rgba(255, 255, 255, 0.96);
2829
- --ink: #171411;
2830
- --text: #1f2b3d;
2831
- --muted: #66758b;
2832
- --line: rgba(121, 151, 194, 0.28);
2833
- --line-strong: rgba(91, 126, 177, 0.32);
2834
- --accent: #ef7b43;
2835
- --accent-deep: #d95f26;
2836
- --accent-soft: #fff2e8;
2837
- --amber: #8a5a2b;
2838
- --amber-soft: #f6e7d4;
2839
- --jade: #ef7b43;
2840
- --wash: #f5f9ff;
2841
- --danger-soft: rgba(220,38,38,0.08);
2842
- --reader-scale: 1;
2843
- --mono: "JetBrains Mono","SFMono-Regular",Menlo,monospace;
2844
- --serif: "Songti SC","Noto Serif CJK SC","Iowan Old Style","Palatino Linotype",serif;
2845
- --ui: "Avenir Next","Gill Sans","Trebuchet MS",sans-serif;
2279
+ --bg: #f8fafc;
2280
+ --panel: #ffffff;
2281
+ --text: #111827;
2282
+ --muted: #6b7280;
2283
+ --line: rgba(17,24,39,0.12);
2284
+ --accent: #2563eb;
2846
2285
  }
2847
2286
  * { box-sizing: border-box; }
2848
- html { scroll-behavior: smooth; }
2849
2287
  body {
2850
2288
  margin: 0;
2851
- background:
2852
- linear-gradient(90deg, rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
2853
- linear-gradient(rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
2854
- radial-gradient(circle at top, rgba(255,255,255,0.82), transparent 30%),
2855
- linear-gradient(180deg, #fbfdff 0%, var(--bg) 50%, var(--bg-deep) 100%);
2856
- background-size: 56px 56px, 56px 56px, auto, auto;
2289
+ min-height: 100vh;
2290
+ display: grid;
2291
+ place-items: center;
2292
+ background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
2857
2293
  color: var(--text);
2858
- font-family: var(--ui);
2859
- overflow: hidden;
2860
- }
2861
- .shell {
2862
- display: grid;
2863
- grid-template-columns: minmax(280px, 330px) minmax(0, 980px);
2864
- gap: 18px;
2865
- max-width: 1340px;
2866
- height: 100vh;
2867
- margin: 0 auto;
2868
- padding: 18px;
2869
- }
2870
- .side-panel,
2871
- .reader {
2872
- border: 1px solid var(--line);
2873
- border-radius: 18px;
2874
- background: var(--panel);
2875
- box-shadow: 0 20px 50px rgba(92, 122, 168, 0.14);
2876
- }
2877
- .side-panel {
2878
- position: sticky;
2879
- top: 18px;
2880
- align-self: start;
2881
- max-height: calc(100vh - 36px);
2882
- overflow: auto;
2883
- padding: 18px;
2884
- background:
2885
- linear-gradient(180deg, rgba(255,255,255,0.985), rgba(252,254,255,0.985)),
2886
- var(--panel);
2887
- }
2888
- .reader {
2889
- min-width: 0;
2890
- background: var(--paper);
2891
- overflow: hidden;
2892
- position: relative;
2893
- display: grid;
2894
- grid-template-rows: auto minmax(0, 1fr);
2895
- height: calc(100vh - 36px);
2896
- }
2897
- .reader-header {
2898
- border-bottom: 1px solid var(--line);
2899
- background:
2900
- linear-gradient(135deg, rgba(255,255,255,0.995), rgba(249,252,255,0.98)),
2901
- var(--paper);
2902
- padding: 16px 30px 10px;
2903
- }
2904
- .reader-scroll {
2905
- min-height: 0;
2906
- overflow-y: auto;
2907
- overscroll-behavior: contain;
2908
- scrollbar-gutter: stable;
2909
- scroll-padding-top: 24px;
2910
- }
2911
- .eyebrow {
2912
- margin: 0 0 8px;
2913
- color: var(--accent);
2914
- font-size: 13px;
2915
- font-weight: 800;
2916
- text-transform: uppercase;
2917
- letter-spacing: 0;
2918
- }
2919
- h1 {
2920
- margin: 0;
2921
- font-family: var(--serif);
2922
- font-size: clamp(27px, 3.2vw, 36px);
2923
- line-height: 1.14;
2924
- font-weight: 700;
2925
- letter-spacing: 0.01em;
2926
- color: var(--ink);
2927
- }
2928
- .subtitle {
2929
- margin: 10px 0 0;
2930
- color: var(--muted);
2931
- line-height: 1.55;
2932
- font-size: 15px;
2933
- }
2934
- .meta-row,
2935
- .controls,
2936
- .chapter-evidence {
2937
- display: flex;
2938
- flex-wrap: wrap;
2939
- gap: 8px;
2940
- align-items: center;
2941
- }
2942
- .meta-row { margin-top: 8px; }
2943
- .meta-details {
2944
- margin-top: 10px;
2945
- color: var(--muted);
2946
- font-size: 12px;
2947
- }
2948
- .meta-details summary,
2949
- .retrieval-item summary,
2950
- .chapter-evidence summary {
2951
- list-style: none;
2952
- }
2953
- .meta-details summary::-webkit-details-marker,
2954
- .retrieval-item summary::-webkit-details-marker,
2955
- .chapter-evidence summary::-webkit-details-marker {
2956
- display: none;
2957
- }
2958
- .meta-details summary {
2959
- width: fit-content;
2960
- cursor: pointer;
2961
- color: var(--accent-deep);
2962
- font-weight: 650;
2963
- line-height: 1.4;
2964
- display: inline-flex;
2965
- align-items: center;
2966
- gap: 8px;
2967
- }
2968
- .meta-details summary::before,
2969
- .retrieval-item summary::before,
2970
- .chapter-evidence summary::before {
2971
- content: "▸";
2972
- display: inline-flex;
2973
- align-items: center;
2974
- justify-content: center;
2975
- width: 12px;
2976
- color: var(--accent-deep);
2977
- font-size: 11px;
2978
- transform-origin: 50% 50%;
2979
- transition: transform 120ms ease;
2980
- }
2981
- .meta-details[open] summary::before,
2982
- .retrieval-item[open] summary::before,
2983
- .chapter-evidence[open] summary::before {
2984
- transform: rotate(90deg);
2985
- }
2986
- .meta-pill,
2987
- .evidence-chip {
2988
- display: inline-flex;
2989
- width: fit-content;
2990
- border: 1px solid var(--line);
2991
- border-radius: 999px;
2992
- padding: 4px 8px;
2993
- background: rgba(255,255,255,0.86);
2994
- color: var(--muted);
2995
- font-size: 10.5px;
2996
- text-decoration: none;
2997
- }
2998
- .evidence-chip {
2999
- color: var(--accent);
3000
- background: var(--accent-soft);
3001
- border-color: rgba(239,123,67,0.22);
3002
- }
3003
- .evidence-chip.muted {
3004
- color: var(--muted);
3005
- background: #f8fafc;
3006
- }
3007
- .controls {
3008
- justify-content: space-between;
3009
- margin-top: 9px;
3010
- border-top: 1px solid var(--line);
3011
- padding-top: 9px;
3012
- background: transparent;
3013
- gap: 14px;
3014
- }
3015
- .button-row { display: flex; gap: 8px; flex-wrap: wrap; }
3016
- button {
3017
- border: 1px solid var(--line);
3018
- border-radius: 999px;
3019
- padding: 7px 10px;
3020
- background: rgba(255, 255, 255, 0.96);
3021
- color: var(--text);
3022
- font: inherit;
3023
- font-size: 14px;
3024
- cursor: pointer;
3025
- }
3026
- button:hover { border-color: var(--accent); }
3027
- button:disabled { color: var(--muted); cursor: not-allowed; opacity: 0.58; }
3028
- .progress-wrap {
3029
- min-width: 180px;
3030
- flex: 1;
3031
- }
3032
- .progress-meta {
3033
- display: flex;
3034
- justify-content: space-between;
3035
- gap: 10px;
3036
- color: var(--muted);
3037
- font-size: 12px;
3038
- margin-bottom: 6px;
3039
- }
3040
- .progress-track {
3041
- height: 7px;
3042
- border-radius: 999px;
3043
- background: #e5dfd4;
3044
- overflow: hidden;
3045
- }
3046
- .progress-bar {
3047
- height: 100%;
3048
- width: 0%;
3049
- border-radius: inherit;
3050
- background: var(--accent);
3051
- transition: width 180ms ease;
3052
- }
3053
- .toc-title,
3054
- .panel-title {
3055
- margin: 0 0 12px;
3056
- font-size: 14px;
3057
- font-weight: 800;
3058
- color: var(--accent-deep);
3059
- }
3060
- .toc-subtitle {
3061
- margin: -4px 0 16px;
3062
- color: var(--muted);
3063
- line-height: 1.6;
3064
- font-size: 13px;
3065
- }
3066
- .outline-list,
3067
- .outline-list ol {
3068
- list-style: none;
3069
- margin: 0;
3070
- padding: 0;
3071
- }
3072
- .outline-list ol {
3073
- margin-left: 12px;
3074
- padding-left: 12px;
3075
- border-left: 1px solid var(--line);
3076
- }
3077
- .outline-branch summary {
3078
- list-style: none;
3079
- }
3080
- .outline-branch summary::-webkit-details-marker { display: none; }
3081
- .outline-jump {
3082
- display: grid;
3083
- grid-template-columns: 42px 1fr;
3084
- gap: 10px;
3085
- width: 100%;
3086
- text-align: left;
3087
- border-color: transparent;
3088
- background: transparent;
3089
- color: var(--text);
3090
- line-height: 1.45;
3091
- padding: 9px 8px;
3092
- border-radius: 12px;
3093
- border: 1px solid transparent;
3094
- cursor: pointer;
3095
- }
3096
- .outline-jump:hover {
3097
- border-color: rgba(239,123,67,0.18);
3098
- background: rgba(255, 246, 239, 0.78);
3099
- color: var(--accent-deep);
3100
- }
3101
- .outline-jump.active {
3102
- border-color: rgba(239,123,67,0.24);
3103
- background: linear-gradient(180deg, rgba(255,246,239,0.96), rgba(255,250,245,0.98));
3104
- color: var(--accent-deep);
3105
- }
3106
- .outline-jump.active .outline-number,
3107
- .outline-jump.active .outline-copy strong {
3108
- color: var(--accent-deep);
3109
- }
3110
- .outline-jump.active .outline-copy small {
3111
- color: #b27044;
3112
- }
3113
- .outline-number {
3114
- color: var(--amber);
3115
- font-family: var(--serif);
3116
- font-weight: 800;
3117
- }
3118
- .outline-copy strong,
3119
- .outline-copy small {
3120
- display: block;
3121
- }
3122
- .outline-copy strong {
3123
- font-weight: 700;
3124
- }
3125
- .outline-copy small {
3126
- margin-top: 3px;
3127
- color: var(--muted);
3128
- font-size: 12px;
3129
- }
3130
- .stat-grid {
3131
- display: grid;
3132
- grid-template-columns: 1fr;
3133
- gap: 8px;
3134
- margin-top: 16px;
3135
- }
3136
- .stat {
3137
- border: 1px solid var(--line);
3138
- border-radius: 14px;
3139
- padding: 10px;
3140
- background: var(--wash);
3141
- }
3142
- .stat-value {
3143
- font-family: var(--serif);
3144
- font-size: 26px;
3145
- font-weight: 700;
3146
- }
3147
- .stat-label {
3148
- color: var(--muted);
3149
- font-size: 12px;
3150
- margin-top: 2px;
3151
- }
3152
- .chapter {
3153
- padding: 38px 52px 54px;
3154
- min-height: 100%;
3155
- }
3156
- .chapter[hidden] { display: none; }
3157
- .chapter.active {
3158
- box-shadow: none;
3159
- }
3160
- .chapter-kicker {
3161
- color: var(--accent-deep);
3162
- font-size: 13px;
3163
- font-weight: 700;
3164
- letter-spacing: 0.04em;
3165
- margin-bottom: 8px;
3166
- }
3167
- .chapter h2 {
3168
- margin: 0 0 12px;
3169
- font-family: var(--serif);
3170
- font-size: 38px;
3171
- line-height: 1.24;
3172
- font-weight: 600;
3173
- letter-spacing: 0.01em;
3174
- }
3175
- .chapter-summary {
3176
- margin: 0 0 20px;
3177
- color: var(--muted);
3178
- font-size: 15px;
3179
- line-height: 1.8;
3180
- max-width: 36em;
3181
- }
3182
- .chapter > p {
3183
- max-width: 42em;
3184
- }
3185
- .chapter p,
3186
- .learning-block p,
3187
- .source-card p {
3188
- font-size: calc(17px * var(--reader-scale));
3189
- line-height: 1.85;
3190
- }
3191
- .learning-block {
3192
- margin: 34px 0 0;
3193
- border: 0;
3194
- border-top: 1px solid var(--line);
3195
- border-radius: 0;
3196
- padding: 22px 0 0;
3197
- background: transparent;
3198
- }
3199
- .learning-block h4 {
3200
- margin: 0 0 14px;
3201
- font-family: var(--serif);
3202
- font-size: 24px;
3203
- line-height: 1.3;
3204
- font-weight: 600;
3205
- letter-spacing: 0.01em;
3206
- }
3207
- .learning-block.retrieval,
3208
- .learning-block.worked,
3209
- .learning-block.visual {
3210
- border-top-color: rgba(239,123,67,0.2);
3211
- }
3212
- .learning-block.visual {
3213
- padding-top: 26px;
3214
- }
3215
- .visual-header {
3216
- display: grid;
3217
- gap: 6px;
3218
- margin-bottom: 18px;
3219
- }
3220
- .visual-kicker {
3221
- color: var(--accent-deep);
3222
- font-size: 12px;
3223
- font-weight: 800;
3224
- letter-spacing: 0.12em;
3225
- }
3226
- .visual-header h4 {
3227
- margin: 0;
3228
- font-size: 30px;
3229
- line-height: 1.28;
3230
- font-weight: 600;
3231
- }
3232
- .visual-grid {
3233
- display: grid;
3234
- gap: 26px;
3235
- }
3236
- .visual-grid.has-image {
3237
- grid-template-columns: minmax(0, 1.28fr) minmax(240px, 320px);
3238
- align-items: start;
3239
- }
3240
- .visual-copy {
3241
- display: grid;
3242
- gap: 0;
3243
- border-left: 1px solid var(--line);
3244
- padding-left: 22px;
3245
- }
3246
- .visual-note {
3247
- border: 0;
3248
- border-radius: 0;
3249
- background: transparent;
3250
- padding: 0 0 16px;
3251
- }
3252
- .visual-note + .visual-note {
3253
- border-top: 1px solid rgba(121, 151, 194, 0.22);
3254
- padding-top: 16px;
3255
- }
3256
- .visual-label {
3257
- color: var(--accent-deep);
3258
- font-size: 11px;
3259
- font-weight: 700;
3260
- letter-spacing: 0.12em;
3261
- margin-bottom: 8px;
3262
- }
3263
- .visual-note p {
3264
- margin: 0;
3265
- }
3266
- .visual-takeaways {
3267
- margin: 0;
3268
- padding-left: 20px;
3269
- }
3270
- .visual-figure {
3271
- margin: 0;
3272
- border: 1px solid rgba(121, 151, 194, 0.2);
3273
- border-radius: 16px;
3274
- overflow: hidden;
3275
- background: rgba(255,255,255,0.98);
3276
- box-shadow: 0 18px 42px rgba(91, 126, 177, 0.08);
3277
- }
3278
- .visual-figure img {
3279
- display: block;
3280
- width: 100%;
3281
- height: auto;
3282
- background:
3283
- linear-gradient(90deg, rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
3284
- linear-gradient(rgba(95, 129, 181, 0.07) 0 1px, transparent 1px 100%),
3285
- #f8fbff;
3286
- background-size: 24px 24px, 24px 24px, auto;
3287
- }
3288
- .visual-figure figcaption {
3289
- padding: 12px 14px 14px;
3290
- border-top: 1px solid rgba(121, 151, 194, 0.16);
3291
- color: var(--muted);
3292
- font-size: 12px;
3293
- line-height: 1.6;
3294
- }
3295
- .retrieval-item {
3296
- border-top: 1px solid var(--line);
3297
- padding: 16px 0;
3298
- }
3299
- .retrieval-item:first-of-type { border-top: 0; }
3300
- .retrieval-item summary {
3301
- cursor: pointer;
3302
- font-weight: 650;
3303
- line-height: 1.6;
3304
- display: flex;
3305
- gap: 10px;
3306
- align-items: flex-start;
3307
- }
3308
- .retrieval-item summary span {
3309
- display: inline-flex;
3310
- color: var(--accent);
3311
- font-family: var(--mono);
3312
- font-size: 11px;
3313
- min-width: 24px;
3314
- padding-top: 2px;
3315
- }
3316
- .retrieval-hint,
3317
- .retrieval-answer {
3318
- color: var(--muted);
3319
- line-height: 1.7;
3320
- margin-top: 8px;
3321
- margin-left: 34px;
3322
- }
3323
- .worked-item {
3324
- padding: 18px 0;
3325
- border-top: 1px solid var(--line);
3326
- }
3327
- .worked-item:first-of-type {
3328
- padding-top: 6px;
3329
- border-top: 0;
3330
- }
3331
- .worked-title {
3332
- font-family: var(--serif);
3333
- font-size: 24px;
3334
- font-weight: 600;
3335
- line-height: 1.35;
3336
- }
3337
- .worked-principle {
3338
- color: var(--muted);
3339
- line-height: 1.7;
3340
- margin-top: 12px;
3341
- padding-top: 12px;
3342
- border-top: 1px solid rgba(239,123,67,0.16);
3343
- }
3344
- ol,
3345
- ul {
3346
- margin: 10px 0 0;
3347
- padding-left: 20px;
3348
- line-height: 1.75;
3349
- }
3350
- .chapter-evidence {
3351
- display: block;
3352
- margin-top: 24px;
3353
- padding-top: 16px;
3354
- border-top: 1px solid var(--line);
3355
- }
3356
- .chapter-evidence summary {
3357
- cursor: pointer;
3358
- display: flex;
3359
- flex-wrap: wrap;
3360
- align-items: center;
3361
- gap: 10px;
3362
- color: var(--muted);
3363
- }
3364
- .evidence-summary-title {
3365
- color: var(--accent-deep);
3366
- font-size: 13px;
3367
- font-weight: 700;
3368
- letter-spacing: 0.04em;
3369
- }
3370
- .evidence-summary-count {
3371
- color: var(--muted);
3372
- font-size: 12px;
3373
- }
3374
- .evidence-mini-list {
3375
- display: grid;
3376
- gap: 0;
3377
- margin-top: 12px;
3378
- }
3379
- .evidence-mini-card {
3380
- border: 0;
3381
- border-top: 1px solid rgba(121, 151, 194, 0.16);
3382
- border-radius: 0;
3383
- background: transparent;
3384
- padding: 12px 0;
3385
- }
3386
- .evidence-mini-card:first-child {
3387
- border-top: 0;
3388
- padding-top: 0;
3389
- }
3390
- .evidence-mini-card strong,
3391
- .evidence-mini-card span {
3392
- display: block;
3393
- }
3394
- .evidence-mini-card strong {
3395
- font-weight: 650;
3396
- font-size: 14px;
3397
- line-height: 1.5;
3398
- }
3399
- .evidence-mini-card span {
3400
- color: var(--muted);
3401
- font-family: var(--mono);
3402
- font-size: 11px;
3403
- letter-spacing: 0.02em;
3404
- margin-top: 4px;
3405
- }
3406
- .evidence-mini-card p {
3407
- margin: 6px 0 0;
3408
- color: var(--muted);
3409
- font-size: 13px;
3410
- line-height: 1.6;
3411
- }
3412
- .source-card,
3413
- .claim-item,
3414
- .gap-item {
3415
- border: 1px solid var(--line);
3416
- border-radius: 10px;
3417
- padding: 12px;
3418
- background: #fffefa;
3419
- margin-bottom: 10px;
3420
- }
3421
- .source-title,
3422
- .claim-statement {
3423
- font-weight: 800;
3424
- line-height: 1.5;
3425
- }
3426
- .source-meta,
3427
- .claim-meta,
3428
- .source-digest {
3429
- color: var(--muted);
3430
- font-size: 12px;
3431
- line-height: 1.6;
3432
- margin-top: 4px;
3433
- }
3434
- .source-card pre {
3435
- margin: 10px 0 0;
3436
- white-space: pre-wrap;
3437
- word-break: break-word;
3438
- border-radius: 8px;
3439
- background: #111827;
3440
- color: #e5e7eb;
3441
- padding: 10px;
3442
- font-family: var(--mono);
3443
- font-size: 12px;
3444
- line-height: 1.5;
3445
- }
3446
- .gap-item {
3447
- border-color: rgba(220,38,38,0.16);
3448
- background: var(--danger-soft);
3449
- }
3450
- .empty-reader {
3451
- margin: 38px 52px 54px;
3452
- padding: 28px;
3453
- border: 1px dashed var(--line-strong);
3454
- border-radius: 16px;
3455
- background: var(--wash);
3456
- }
3457
- .empty-reader h2 {
3458
- margin: 0 0 12px;
3459
- font-family: var(--serif);
3460
- font-size: 34px;
3461
- line-height: 1.2;
3462
- }
3463
- .empty-reader p {
3464
- margin: 0;
3465
- color: var(--muted);
3466
- font-size: 17px;
3467
- line-height: 1.8;
3468
- }
3469
- .empty-steps {
3470
- margin: 18px 0 0;
3471
- padding-left: 22px;
3472
- color: var(--muted);
3473
- line-height: 1.8;
3474
- }
3475
- .empty-steps li + li {
3476
- margin-top: 6px;
3477
- }
3478
- .empty-paths {
3479
- display: grid;
3480
- gap: 10px;
3481
- margin-top: 18px;
3482
- }
3483
- .empty-paths div {
3484
- display: grid;
3485
- gap: 4px;
3486
- border: 1px solid var(--line);
3487
- border-radius: 12px;
3488
- padding: 10px 12px;
3489
- background: #fffefa;
3490
- }
3491
- .empty-paths strong {
3492
- color: var(--accent-deep);
3493
- font-size: 13px;
3494
- }
3495
- .empty-paths span {
3496
- color: var(--muted);
3497
- font-family: var(--mono);
3498
- font-size: 12px;
3499
- overflow-wrap: anywhere;
3500
- }
3501
- @media (max-width: 1120px) {
3502
- body { overflow: auto; }
3503
- .shell { grid-template-columns: 1fr; height: auto; min-height: 100vh; padding: 12px; }
3504
- .side-panel {
3505
- position: static;
3506
- max-height: none;
3507
- }
3508
- .reader { height: auto; }
3509
- .reader-scroll { height: auto; overflow: visible; }
3510
- .chapter { min-height: auto; padding: 24px 20px 30px; }
3511
- .visual-grid.has-image { grid-template-columns: 1fr; }
3512
- .visual-copy {
3513
- border-left: 0;
3514
- border-top: 1px solid var(--line);
3515
- padding-left: 0;
3516
- padding-top: 18px;
3517
- }
3518
- }
3519
- @media (max-width: 700px) {
3520
- .reader-header { padding: 18px 20px 12px; }
3521
- h1 { font-size: 30px; }
3522
- .chapter h2 { font-size: 28px; }
3523
- .learning-block h4,
3524
- .visual-header h4,
3525
- .worked-title {
3526
- font-size: 24px;
3527
- }
3528
- .stat-grid { grid-template-columns: 1fr; }
3529
- .controls { display: grid; gap: 12px; }
3530
- .chapter { padding: 30px 22px 38px; }
3531
- }
3532
- </style>
3533
- </head>
3534
- <body>
3535
- <main class="shell">
3536
- <aside class="side-panel">
3537
- <p class="toc-title">书籍大纲</p>
3538
- <p class="toc-subtitle">最多三层展开。先读章名,再进入心法、练习与示例。</p>
3539
- <ol class="outline-list">
3540
- ${outline.length > 0 ? outline.map((node, index) => formatLearningOutlineNode(node, `${index + 1}`, initialChapterId)).join('\n') : '<li><span class="outline-jump"><span class="outline-number">0</span><span class="outline-copy"><strong>证据包待写作</strong><small>正文完成后显示目录</small></span></span></li>'}
3541
- </ol>
3542
- </aside>
3543
-
3544
- <article class="reader">
3545
- <header class="reader-header">
3546
- <p class="eyebrow">OpenPrd 复盘学习 · ${escapeHtml(content.genre?.label ?? '默认题材')}</p>
3547
- <h1>${escapeHtml(title)}</h1>
3548
- <p class="subtitle">${escapeHtml(content.subtitle ?? '')}</p>
3549
- <details class="meta-details">
3550
- <summary>生成信息</summary>
3551
- <div class="meta-row">
3552
- <span class="meta-pill">topic: ${escapeHtml(content.topic ?? '未指定')}</span>
3553
- <span class="meta-pill">genre: ${escapeHtml(content.genre?.id ?? 'unknown')}</span>
3554
- <span class="meta-pill">风格: ${escapeHtml(content.stylePromptPack?.styleId ?? packageMeta?.styleId ?? 'default')}</span>
3555
- <span class="meta-pill">trigger: ${escapeHtml(packageMeta?.trigger ?? content.trigger ?? 'manual')}</span>
3556
- </div>
3557
- </details>
3558
- <div class="controls">
3559
- <div class="button-row">
3560
- <button type="button" id="prevChapter" disabled>上一章</button>
3561
- <button type="button" id="nextChapter"${chapters.length <= 1 ? ' disabled' : ''}>下一章</button>
3562
- <button type="button" id="smallerText">A-</button>
3563
- <button type="button" id="largerText">A+</button>
3564
- </div>
3565
- <div class="progress-wrap">
3566
- <div class="progress-meta">
3567
- <span id="progressTitle">阅读进度</span>
3568
- <span id="progressText">${chapters.length > 0 ? `1/${chapters.length}` : '0/0'}</span>
3569
- </div>
3570
- <div class="progress-track"><div class="progress-bar" id="progressBar" style="width: ${initialProgressPercent}%"></div></div>
3571
- </div>
3572
- </div>
3573
- </header>
3574
- <div class="reader-scroll" tabindex="0" aria-label="OpenPrd 复盘学习阅读器 · 当前章节正文">
3575
- ${chapters.length > 0 ? chapters.map((chapter, index) => formatLearningChapter(chapter, index, sourcesById)).join('\n') : formatLearningEmptyState(content, packageMeta, evidenceManifest)}
3576
- </div>
3577
- </article>
3578
- </main>
3579
- <script>
3580
- const scrollRoot = document.querySelector('.reader-scroll');
3581
- const chapters = Array.from(document.querySelectorAll('.chapter'));
3582
- const outlineItems = Array.from(document.querySelectorAll('[data-target-id]'));
3583
- const prevButton = document.getElementById('prevChapter');
3584
- const nextButton = document.getElementById('nextChapter');
3585
- const progressBar = document.getElementById('progressBar');
3586
- const progressText = document.getElementById('progressText');
3587
- let activeIndex = 0;
3588
- let fontScale = Number(localStorage.getItem('openprd-learning-font-scale') || '1');
3589
-
3590
- function clamp(value, min, max) {
3591
- return Math.max(min, Math.min(max, value));
3592
- }
3593
-
3594
- function applyFontScale() {
3595
- fontScale = clamp(fontScale, 0.9, 1.25);
3596
- document.documentElement.style.setProperty('--reader-scale', String(fontScale));
3597
- localStorage.setItem('openprd-learning-font-scale', String(fontScale));
3598
- }
3599
-
3600
- function setActive(index, shouldScroll = false) {
3601
- if (chapters.length === 0) return;
3602
- activeIndex = clamp(index, 0, chapters.length - 1);
3603
- chapters.forEach((chapter, chapterIndex) => {
3604
- const isActive = chapterIndex === activeIndex;
3605
- chapter.hidden = !isActive;
3606
- chapter.classList.toggle('active', isActive);
3607
- });
3608
- const activeChapterId = chapters[activeIndex].id;
3609
- outlineItems.forEach((item) => item.classList.toggle('active', item.dataset.targetId === activeChapterId));
3610
- prevButton.disabled = activeIndex === 0;
3611
- nextButton.disabled = activeIndex === chapters.length - 1;
3612
- progressText.textContent = String(activeIndex + 1) + '/' + String(chapters.length);
3613
- progressBar.style.width = String(((activeIndex + 1) / chapters.length) * 100) + '%';
3614
- if (shouldScroll) {
3615
- scrollRoot?.scrollTo({ top: 0, behavior: 'smooth' });
3616
- }
3617
- }
3618
-
3619
- function scrollToReaderTarget(target) {
3620
- if (!target || !scrollRoot) return;
3621
- const rootTop = scrollRoot.getBoundingClientRect().top;
3622
- const targetTop = target.getBoundingClientRect().top;
3623
- scrollRoot.scrollTo({
3624
- top: scrollRoot.scrollTop + targetTop - rootTop - 18,
3625
- behavior: 'smooth',
3626
- });
3627
- }
3628
-
3629
- outlineItems.forEach((item) => {
3630
- item.addEventListener('click', () => {
3631
- const target = document.getElementById(item.dataset.targetId);
3632
- if (!target) return;
3633
- const chapterIndex = chapters.findIndex((chapter) => chapter.id === target.id || chapter.contains(target));
3634
- if (chapterIndex >= 0) setActive(chapterIndex, false);
3635
- scrollToReaderTarget(target);
3636
- });
3637
- });
3638
- prevButton.addEventListener('click', () => setActive(activeIndex - 1, true));
3639
- nextButton.addEventListener('click', () => setActive(activeIndex + 1, true));
3640
- document.getElementById('smallerText').addEventListener('click', () => {
3641
- fontScale -= 0.05;
3642
- applyFontScale();
3643
- });
3644
- document.getElementById('largerText').addEventListener('click', () => {
3645
- fontScale += 0.05;
3646
- applyFontScale();
3647
- });
3648
- document.addEventListener('keydown', (event) => {
3649
- if (event.key === 'ArrowRight' || event.key === 'PageDown') setActive(activeIndex + 1, true);
3650
- if (event.key === 'ArrowLeft' || event.key === 'PageUp') setActive(activeIndex - 1, true);
3651
- });
3652
-
3653
- applyFontScale();
3654
- setActive(0, false);
3655
- </script>
3656
- </body>
3657
- </html>`;
3658
- }
3659
-
3660
- export async function writeHtmlArtifact(filePath, html) {
3661
- await writeText(filePath, html);
3662
- return filePath;
3663
- }
3664
-
3665
- export async function openArtifactInBrowser(filePath) {
3666
- const platform = process.platform;
3667
- const command = platform === 'darwin'
3668
- ? 'open'
3669
- : platform === 'win32'
3670
- ? 'cmd'
3671
- : 'xdg-open';
3672
- const args = platform === 'win32'
3673
- ? ['/c', 'start', '', filePath]
3674
- : [filePath];
3675
- const child = spawn(command, args, {
3676
- detached: true,
3677
- stdio: 'ignore',
3678
- });
3679
- child.unref();
3680
- }
3681
-
3682
- export function canonicalReviewPath(ws, versionId) {
3683
- return cjoin(ws.workspaceRoot, 'reviews', `${slugify(versionId, 'review')}.html`);
3684
- }
3685
-
3686
- function toRelativeHref(fromFilePath, targetFilePath) {
3687
- const relative = path.relative(path.dirname(fromFilePath), targetFilePath) || path.basename(targetFilePath);
3688
- return relative.split(path.sep).join('/');
3689
- }
3690
-
3691
- export function renderReviewEntryHtml({ entryPath, reviewPath, title = 'OpenPrd Review' }) {
3692
- const href = escapeHtml(toRelativeHref(entryPath, reviewPath));
3693
- return `<!DOCTYPE html>
3694
- <html lang="zh-CN">
3695
- <head>
3696
- <meta charset="UTF-8" />
3697
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3698
- <meta http-equiv="refresh" content="0; url=${href}" />
3699
- <title>${escapeHtml(title)}</title>
3700
- <style>
3701
- :root {
3702
- color-scheme: light;
3703
- --bg: #f8fafc;
3704
- --panel: #ffffff;
3705
- --text: #111827;
3706
- --muted: #6b7280;
3707
- --line: rgba(17,24,39,0.12);
3708
- --accent: #2563eb;
3709
- }
3710
- * { box-sizing: border-box; }
3711
- body {
3712
- margin: 0;
3713
- min-height: 100vh;
3714
- display: grid;
3715
- place-items: center;
3716
- background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
3717
- color: var(--text);
3718
- font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
2294
+ font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
3719
2295
  }
3720
2296
  .panel {
3721
2297
  width: min(560px, calc(100vw - 32px));
@@ -3776,21 +2352,6 @@ export function artifactBundlePaths(ws, artifactId) {
3776
2352
  };
3777
2353
  }
3778
2354
 
3779
- export function learningPackagePaths(ws, packageId) {
3780
- const dir = cjoin(ws.paths.learningArchiveDir, slugify(packageId, 'learning-package'));
3781
- return {
3782
- dir,
3783
- readerHtml: cjoin(dir, 'reader.html'),
3784
- assetsDir: cjoin(dir, 'assets'),
3785
- packageJson: cjoin(dir, 'learning-package.json'),
3786
- contentJson: cjoin(dir, 'learning-content.json'),
3787
- contentMarkdown: cjoin(dir, 'learning-content.md'),
3788
- evidenceManifest: cjoin(dir, 'evidence-manifest.json'),
3789
- agentContext: cjoin(dir, 'agent-context.json'),
3790
- agentPrompt: cjoin(dir, 'agent-prompt.md'),
3791
- };
3792
- }
3793
-
3794
2355
  export function renderMarkdownDataDocument({ title, sections }) {
3795
2356
  const lines = [`# ${title}`, ''];
3796
2357
  for (const section of sections) {