@openprd/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.openprd/README.md +82 -0
  2. package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +14 -0
  3. package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +14 -0
  4. package/.openprd/benchmarks/index.md +37 -0
  5. package/.openprd/benchmarks/sources.yaml +56 -0
  6. package/.openprd/config.yaml +50 -0
  7. package/.openprd/discovery/config.json +21 -0
  8. package/.openprd/engagements/active/flows.md +30 -0
  9. package/.openprd/engagements/active/handoff.md +9 -0
  10. package/.openprd/engagements/active/intake.md +15 -0
  11. package/.openprd/engagements/active/prd.md +161 -0
  12. package/.openprd/engagements/active/review.html +61 -0
  13. package/.openprd/engagements/active/roles.md +21 -0
  14. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +23 -0
  15. package/.openprd/exports/.gitkeep +0 -0
  16. package/.openprd/knowledge/index.json +7 -0
  17. package/.openprd/quality/config.json +229 -0
  18. package/.openprd/reviews/v0001.html +1256 -0
  19. package/.openprd/schema/diagram-architecture.schema.yaml +49 -0
  20. package/.openprd/schema/diagram-product-flow.schema.yaml +52 -0
  21. package/.openprd/schema/prd.schema.yaml +121 -0
  22. package/.openprd/sessions/.gitkeep +0 -0
  23. package/.openprd/standards/config.json +88 -0
  24. package/.openprd/standards/file-manual-template.md +28 -0
  25. package/.openprd/standards/folder-readme-template.md +28 -0
  26. package/.openprd/state/.gitkeep +0 -0
  27. package/.openprd/state/changes.json +12 -0
  28. package/.openprd/state/current.json +169 -0
  29. package/.openprd/state/version-index.json +15 -0
  30. package/.openprd/state/versions/.gitkeep +0 -0
  31. package/.openprd/state/versions/v0001.json +121 -0
  32. package/.openprd/state/versions/v0001.md +161 -0
  33. package/.openprd/templates/agent/intake.md +6 -0
  34. package/.openprd/templates/agent/prd.md +21 -0
  35. package/.openprd/templates/b2b/intake.md +6 -0
  36. package/.openprd/templates/b2b/prd.md +24 -0
  37. package/.openprd/templates/base/intake.md +18 -0
  38. package/.openprd/templates/base/prd.md +67 -0
  39. package/.openprd/templates/company/README.md +10 -0
  40. package/.openprd/templates/consumer/intake.md +6 -0
  41. package/.openprd/templates/consumer/prd.md +19 -0
  42. package/.openprd/templates/diagram/architecture.contract.json +53 -0
  43. package/.openprd/templates/diagram/product-flow.contract.json +76 -0
  44. package/.openprd/templates/industry/README.md +16 -0
  45. package/.openprd/templates/manifest.yaml +27 -0
  46. package/.openprd/templates/project/README.md +14 -0
  47. package/.openprd/templates/session/README.md +14 -0
  48. package/AGENTS.md +44 -0
  49. package/CONTRIBUTING.md +30 -0
  50. package/LICENSE +21 -0
  51. package/README.md +727 -0
  52. package/README_CN.md +583 -0
  53. package/SECURITY.md +23 -0
  54. package/bin/openprd.js +5 -0
  55. package/docs/assets/openprd-capability-overview-en.png +0 -0
  56. package/docs/assets/openprd-capability-overview-zh.png +0 -0
  57. package/docs/assets/openprd-learning-html.png +0 -0
  58. package/docs/assets/openprd-quality-html.png +0 -0
  59. package/docs/assets/openprd-review-html.png +0 -0
  60. package/docs/assets/openprd-scenario-overview.png +0 -0
  61. package/docs/assets/openprd-scenario-overview.svg +114 -0
  62. package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
  63. package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
  64. package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
  65. package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
  66. package/package.json +59 -0
  67. package/scripts/openprd-dev-check.mjs +5 -0
  68. package/scripts/openprd-review-presentation.mjs +82 -0
  69. package/skills/openprd-benchmark-router/SKILL.md +92 -0
  70. package/skills/openprd-benchmark-router/agents/openai.yaml +4 -0
  71. package/skills/openprd-benchmark-router/references/benchmark-sources.md +74 -0
  72. package/skills/openprd-benchmark-router/references/evaluation-lenses.md +66 -0
  73. package/skills/openprd-benchmark-router/references/source-policy.md +35 -0
  74. package/skills/openprd-diagram-review/SKILL.md +91 -0
  75. package/skills/openprd-diagram-review/agents/openai.yaml +4 -0
  76. package/skills/openprd-diagram-review/examples/architecture-zh.md +8 -0
  77. package/skills/openprd-diagram-review/examples/product-flow-zh.md +7 -0
  78. package/skills/openprd-diagram-review/references/cocoon-patterns.md +17 -0
  79. package/skills/openprd-diagram-review/references/diagram-contracts.md +126 -0
  80. package/skills/openprd-diagram-review/references/review-checklist.md +10 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +196 -0
  82. package/skills/openprd-discovery-loop/agents/openai.yaml +3 -0
  83. package/skills/openprd-harness/SKILL.md +179 -0
  84. package/skills/openprd-harness/agents/openai.yaml +4 -0
  85. package/skills/openprd-harness/examples/full-workflow-zh.md +9 -0
  86. package/skills/openprd-harness/references/command-map.md +71 -0
  87. package/skills/openprd-harness/references/examples.md +26 -0
  88. package/skills/openprd-harness/references/usage-guide.md +335 -0
  89. package/skills/openprd-harness/references/workflow-gates.md +51 -0
  90. package/skills/openprd-learning-review/SKILL.md +75 -0
  91. package/skills/openprd-learning-review/agents/openai.yaml +4 -0
  92. package/skills/openprd-learning-review/references/content-contract.md +125 -0
  93. package/skills/openprd-learning-review/references/ebook-reader.md +46 -0
  94. package/skills/openprd-learning-review/references/evidence-manifest.md +55 -0
  95. package/skills/openprd-learning-review/references/genre-library.md +43 -0
  96. package/skills/openprd-learning-review/references/prompt-engineering.md +71 -0
  97. package/skills/openprd-learning-review/references/quality-rubric.md +28 -0
  98. package/skills/openprd-learning-review/references/retrieval-worked-example.md +40 -0
  99. package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +67 -0
  100. package/skills/openprd-quality/SKILL.md +101 -0
  101. package/skills/openprd-requirement-intake/SKILL.md +76 -0
  102. package/skills/openprd-requirement-intake/agents/openai.yaml +4 -0
  103. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +105 -0
  104. package/skills/openprd-requirement-intake/references/routing-rubric.md +64 -0
  105. package/skills/openprd-router/SKILL.md +40 -0
  106. package/skills/openprd-shared/SKILL.md +142 -0
  107. package/skills/openprd-shared/agents/openai.yaml +4 -0
  108. package/skills/openprd-shared/references/language-and-review.md +50 -0
  109. package/skills/openprd-shared/references/operating-rules.md +65 -0
  110. package/skills/openprd-shared/references/skill-architecture.md +70 -0
  111. package/skills/openprd-standards/SKILL.md +79 -0
  112. package/skills/openprd-standards/agents/openai.yaml +4 -0
  113. package/src/agent-integration.js +1717 -0
  114. package/src/benchmark.js +873 -0
  115. package/src/cli/args.js +460 -0
  116. package/src/cli/print.js +1423 -0
  117. package/src/codex-hook-runner-template.mjs +2422 -0
  118. package/src/dev-standards.js +372 -0
  119. package/src/diagram-core.js +1047 -0
  120. package/src/diagram-workspace.js +262 -0
  121. package/src/discovery.js +709 -0
  122. package/src/fleet.js +531 -0
  123. package/src/fs-utils.js +83 -0
  124. package/src/growth.js +545 -0
  125. package/src/html-artifacts.js +3803 -0
  126. package/src/knowledge.js +668 -0
  127. package/src/language-policy.js +142 -0
  128. package/src/learning-review.js +1655 -0
  129. package/src/loop.js +1290 -0
  130. package/src/openprd.js +1136 -0
  131. package/src/openspec/change-lifecycle.js +359 -0
  132. package/src/openspec/change-validate.js +248 -0
  133. package/src/openspec/constants.js +12 -0
  134. package/src/openspec/execute.js +300 -0
  135. package/src/openspec/generate.js +692 -0
  136. package/src/openspec/paths.js +111 -0
  137. package/src/openspec/tasks.js +352 -0
  138. package/src/prd-core.js +656 -0
  139. package/src/quality-html-artifact.js +1414 -0
  140. package/src/quality-learning.js +658 -0
  141. package/src/quality.js +1262 -0
  142. package/src/review-presentation.js +240 -0
  143. package/src/run-harness.js +1470 -0
  144. package/src/self-update.js +329 -0
  145. package/src/session-binding.js +140 -0
  146. package/src/source-inventory.js +224 -0
  147. package/src/standards.js +914 -0
  148. package/src/time.js +33 -0
  149. package/src/visual-compare.js +216 -0
  150. package/src/work-unit-migration.js +232 -0
  151. package/src/work-unit.js +88 -0
  152. package/src/workspace-core.js +1706 -0
  153. package/src/workspace-registry.js +162 -0
  154. package/src/workspace-workflow.js +1797 -0
package/src/time.js ADDED
@@ -0,0 +1,33 @@
1
+ const SHANGHAI_TIME_ZONE = 'Asia/Shanghai';
2
+
3
+ function partsFrom(date = new Date()) {
4
+ const formatter = new Intl.DateTimeFormat('zh-CN', {
5
+ timeZone: SHANGHAI_TIME_ZONE,
6
+ year: 'numeric',
7
+ month: '2-digit',
8
+ day: '2-digit',
9
+ hour: '2-digit',
10
+ minute: '2-digit',
11
+ second: '2-digit',
12
+ hour12: false,
13
+ });
14
+ const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
15
+ return {
16
+ year: parts.year,
17
+ month: parts.month,
18
+ day: parts.day,
19
+ hour: parts.hour,
20
+ minute: parts.minute,
21
+ second: parts.second,
22
+ };
23
+ }
24
+
25
+ export function timestamp(date = new Date()) {
26
+ const parts = partsFrom(date);
27
+ return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
28
+ }
29
+
30
+ export function compactTimestamp(date = new Date()) {
31
+ const parts = partsFrom(date);
32
+ return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
33
+ }
@@ -0,0 +1,216 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import sharp from 'sharp';
4
+ import { compactTimestamp } from './time.js';
5
+
6
+ const DEFAULT_PANEL_WIDTH = 1180;
7
+ const DEFAULT_QUALITY = 85;
8
+ const DEFAULT_REFERENCE_LABEL = '效果图';
9
+ const DEFAULT_ACTUAL_LABEL = '实现截图';
10
+ const OUTPUT_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp']);
11
+
12
+ function normalizeFormat(format, outPath) {
13
+ const requested = String(format || '').trim().toLowerCase();
14
+ if (requested) {
15
+ if (!OUTPUT_FORMATS.has(requested)) {
16
+ throw new Error(`Unsupported visual compare format: ${format}. Use jpg, png, or webp.`);
17
+ }
18
+ return requested === 'jpeg' ? 'jpg' : requested;
19
+ }
20
+
21
+ const ext = path.extname(String(outPath || '')).slice(1).toLowerCase();
22
+ if (OUTPUT_FORMATS.has(ext)) {
23
+ return ext === 'jpeg' ? 'jpg' : ext;
24
+ }
25
+ return 'jpg';
26
+ }
27
+
28
+ function outputExtension(format) {
29
+ return format === 'jpeg' ? 'jpg' : format;
30
+ }
31
+
32
+ function defaultOutputPath(projectRoot, format) {
33
+ return path.join(
34
+ projectRoot,
35
+ '.openprd',
36
+ 'harness',
37
+ 'visual-reviews',
38
+ `visual-compare-${compactTimestamp()}.${outputExtension(format)}`,
39
+ );
40
+ }
41
+
42
+ function parsePositiveInteger(value, fallback, label) {
43
+ if (value === null || value === undefined || value === '') {
44
+ return fallback;
45
+ }
46
+ const parsed = Number.parseInt(String(value), 10);
47
+ if (!Number.isFinite(parsed) || parsed <= 0) {
48
+ throw new Error(`${label} must be a positive integer.`);
49
+ }
50
+ return parsed;
51
+ }
52
+
53
+ function parseQuality(value) {
54
+ const quality = parsePositiveInteger(value, DEFAULT_QUALITY, '--quality');
55
+ if (quality < 1 || quality > 100) {
56
+ throw new Error('--quality must be between 1 and 100.');
57
+ }
58
+ return quality;
59
+ }
60
+
61
+ function escapeXml(value) {
62
+ return String(value)
63
+ .replaceAll('&', '&amp;')
64
+ .replaceAll('<', '&lt;')
65
+ .replaceAll('>', '&gt;')
66
+ .replaceAll('"', '&quot;')
67
+ .replaceAll("'", '&apos;');
68
+ }
69
+
70
+ function labelSvg(label) {
71
+ const text = escapeXml(label);
72
+ const charCount = Array.from(label).length;
73
+ const width = Math.max(126, charCount * 26 + 42);
74
+ return Buffer.from(`
75
+ <svg width="${width}" height="46" viewBox="0 0 ${width} 46" xmlns="http://www.w3.org/2000/svg">
76
+ <rect x="0" y="0" width="${width}" height="46" rx="14" fill="#111827" fill-opacity="0.82"/>
77
+ <rect x="0.75" y="0.75" width="${width - 1.5}" height="44.5" rx="13.25" fill="none" stroke="#ffffff" stroke-opacity="0.22" stroke-width="1.5"/>
78
+ <text x="21" y="30" fill="#ffffff" font-size="22" font-weight="700" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${text}</text>
79
+ </svg>`);
80
+ }
81
+
82
+ async function resizePanel(inputPath, panelWidth) {
83
+ const source = path.resolve(inputPath);
84
+ const metadata = await sharp(source).metadata();
85
+ if (!metadata.width || !metadata.height) {
86
+ throw new Error(`Cannot read image dimensions: ${inputPath}`);
87
+ }
88
+ const { data, info } = await sharp(source)
89
+ .rotate()
90
+ .resize({
91
+ width: panelWidth,
92
+ fit: 'inside',
93
+ withoutEnlargement: true,
94
+ })
95
+ .png()
96
+ .toBuffer({ resolveWithObject: true });
97
+ return {
98
+ input: data,
99
+ width: info.width,
100
+ height: info.height,
101
+ source,
102
+ original: {
103
+ width: metadata.width,
104
+ height: metadata.height,
105
+ format: metadata.format ?? null,
106
+ },
107
+ };
108
+ }
109
+
110
+ function encodePipeline(image, format, quality) {
111
+ if (format === 'png') {
112
+ return image.png();
113
+ }
114
+ if (format === 'webp') {
115
+ return image.webp({ quality });
116
+ }
117
+ return image.jpeg({ quality });
118
+ }
119
+
120
+ async function visualCompareWorkspace(projectRoot, options = {}) {
121
+ const reference = options.reference;
122
+ const actual = options.actual;
123
+ if (!reference) {
124
+ throw new Error('Missing --reference image path.');
125
+ }
126
+ if (!actual) {
127
+ throw new Error('Missing --actual image path.');
128
+ }
129
+
130
+ const format = normalizeFormat(options.format, options.out);
131
+ const outputPath = options.out
132
+ ? path.resolve(projectRoot, options.out)
133
+ : defaultOutputPath(projectRoot, format);
134
+ const quality = parseQuality(options.quality);
135
+ const maxPanelWidth = parsePositiveInteger(options.maxPanelWidth, DEFAULT_PANEL_WIDTH, '--max-panel-width');
136
+ const referenceLabel = options.referenceLabel || DEFAULT_REFERENCE_LABEL;
137
+ const actualLabel = options.actualLabel || DEFAULT_ACTUAL_LABEL;
138
+
139
+ const referencePanel = await resizePanel(reference, maxPanelWidth);
140
+ const actualPanel = await resizePanel(actual, maxPanelWidth);
141
+ const panelWidth = Math.min(
142
+ maxPanelWidth,
143
+ Math.max(referencePanel.width, actualPanel.width),
144
+ );
145
+ const maxPanelHeight = Math.max(referencePanel.height, actualPanel.height);
146
+ const margin = 24;
147
+ const gap = 24;
148
+ const canvasWidth = margin * 2 + panelWidth * 2 + gap;
149
+ const canvasHeight = margin * 2 + maxPanelHeight;
150
+ const leftPanelX = margin;
151
+ const rightPanelX = margin + panelWidth + gap;
152
+ const top = margin;
153
+
154
+ const referenceLeft = leftPanelX + Math.round((panelWidth - referencePanel.width) / 2);
155
+ const actualLeft = rightPanelX + Math.round((panelWidth - actualPanel.width) / 2);
156
+ const referenceTop = top + Math.round((maxPanelHeight - referencePanel.height) / 2);
157
+ const actualTop = top + Math.round((maxPanelHeight - actualPanel.height) / 2);
158
+
159
+ const canvas = sharp({
160
+ create: {
161
+ width: canvasWidth,
162
+ height: canvasHeight,
163
+ channels: 3,
164
+ background: '#111827',
165
+ },
166
+ }).composite([
167
+ { input: referencePanel.input, left: referenceLeft, top: referenceTop },
168
+ { input: actualPanel.input, left: actualLeft, top: actualTop },
169
+ { input: labelSvg(referenceLabel), left: referenceLeft + 16, top: referenceTop + 16 },
170
+ { input: labelSvg(actualLabel), left: actualLeft + 16, top: actualTop + 16 },
171
+ ]);
172
+
173
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
174
+ await encodePipeline(canvas, format, quality).toFile(outputPath);
175
+
176
+ return {
177
+ ok: true,
178
+ action: 'visual-compare',
179
+ projectRoot,
180
+ outputPath,
181
+ format,
182
+ quality: format === 'png' ? null : quality,
183
+ maxPanelWidth,
184
+ labels: {
185
+ reference: referenceLabel,
186
+ actual: actualLabel,
187
+ },
188
+ reference: {
189
+ path: referencePanel.source,
190
+ original: referencePanel.original,
191
+ rendered: {
192
+ width: referencePanel.width,
193
+ height: referencePanel.height,
194
+ },
195
+ },
196
+ actual: {
197
+ path: actualPanel.source,
198
+ original: actualPanel.original,
199
+ rendered: {
200
+ width: actualPanel.width,
201
+ height: actualPanel.height,
202
+ },
203
+ },
204
+ canvas: {
205
+ width: canvasWidth,
206
+ height: canvasHeight,
207
+ },
208
+ nextActions: [
209
+ '把输出图片作为视觉评审证据查看:左侧效果图,右侧实现截图。',
210
+ '如果仍有明显差异,继续按效果图复刻并重新运行 visual-compare。',
211
+ '只有对比图确认一致后,才声明本阶段界面视觉实现完成。',
212
+ ],
213
+ };
214
+ }
215
+
216
+ export { visualCompareWorkspace };
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { canonicalReviewPath, defaultReviewArtifactPath, renderReviewArtifact, renderReviewEntryHtml, writeHtmlArtifact } from './html-artifacts.js';
5
+ import { summarizeSnapshot } from './prd-core.js';
6
+ import { appendWorkflowEvent, buildWorkflowTaskGraph, loadWorkspace, readVersionIndex, readVersionSnapshot, writeVersionIndex, writeVersionSnapshot } from './workspace-core.js';
7
+ import { exists, readText, writeJson } from './fs-utils.js';
8
+ import { writeWorkUnitBinding } from './work-unit.js';
9
+ import { timestamp } from './time.js';
10
+
11
+ function legacyWorkUnitId(ws, snapshot) {
12
+ const versionId = snapshot.versionId ?? 'v0000';
13
+ const source = [
14
+ ws.projectRoot,
15
+ versionId,
16
+ snapshot.digest ?? '',
17
+ snapshot.title ?? '',
18
+ ].join('\0');
19
+ const suffix = crypto.createHash('sha256').update(source).digest('hex').slice(0, 8);
20
+ return `wu-legacy-${versionId}-${suffix}`;
21
+ }
22
+
23
+ function sortVersionIds(versionIds) {
24
+ return [...versionIds].sort((a, b) => {
25
+ const numberA = Number(String(a).replace(/^v/i, ''));
26
+ const numberB = Number(String(b).replace(/^v/i, ''));
27
+ if (Number.isFinite(numberA) && Number.isFinite(numberB) && numberA !== numberB) {
28
+ return numberA - numberB;
29
+ }
30
+ return String(a).localeCompare(String(b));
31
+ });
32
+ }
33
+
34
+ async function collectVersionIds(ws) {
35
+ const index = await readVersionIndex(ws);
36
+ const ids = new Set(index.map((entry) => entry.versionId).filter(Boolean));
37
+ const files = await fs.readdir(ws.paths.versionsDir).catch(() => []);
38
+ for (const file of files) {
39
+ if (file.endsWith('.json')) {
40
+ ids.add(path.basename(file, '.json'));
41
+ }
42
+ }
43
+ return sortVersionIds(ids);
44
+ }
45
+
46
+ async function loadBackfillSnapshot(ws, versionId) {
47
+ const snapshot = await readVersionSnapshot(ws, versionId);
48
+ if (!snapshot) {
49
+ return null;
50
+ }
51
+ if (snapshot.content) {
52
+ return snapshot;
53
+ }
54
+ const markdownPath = path.join(ws.paths.versionsDir, `${versionId}.md`);
55
+ const content = await readText(markdownPath).catch(() => null);
56
+ return content ? { ...snapshot, content } : snapshot;
57
+ }
58
+
59
+ function resolveBackfillStatus(ws, snapshot, isLatest) {
60
+ const currentState = ws.data.currentState ?? {};
61
+ const stored = currentState.reviewStatus;
62
+ if (isLatest && stored?.versionId === snapshot.versionId && stored.status) {
63
+ return stored.status;
64
+ }
65
+ if (isLatest && ['frozen', 'handed_off'].includes(currentState.status)) {
66
+ return 'confirmed';
67
+ }
68
+ return 'pending-confirmation';
69
+ }
70
+
71
+ function buildBackfilledSnapshot(ws, snapshot, isLatest) {
72
+ const content = snapshot.content ?? '';
73
+ const digest = snapshot.digest ?? crypto.createHash('sha256').update(content).digest('hex');
74
+ return {
75
+ ...snapshot,
76
+ digest,
77
+ workUnitId: snapshot.workUnitId ?? legacyWorkUnitId(ws, { ...snapshot, digest }),
78
+ targetRoot: snapshot.targetRoot ?? (isLatest ? ws.data.currentState?.targetRoot : null) ?? ws.projectRoot,
79
+ };
80
+ }
81
+
82
+ async function writeReviewBundle(ws, snapshot, isLatest) {
83
+ const activeReviewArtifact = defaultReviewArtifactPath(ws);
84
+ const reviewPath = canonicalReviewPath(ws, snapshot.versionId);
85
+ await writeHtmlArtifact(reviewPath, renderReviewArtifact({ snapshot }));
86
+
87
+ if (isLatest) {
88
+ await writeHtmlArtifact(activeReviewArtifact, renderReviewEntryHtml({
89
+ entryPath: activeReviewArtifact,
90
+ reviewPath,
91
+ title: `${snapshot.title} / 评审入口`,
92
+ }));
93
+ }
94
+
95
+ return {
96
+ activeReviewArtifact: isLatest ? activeReviewArtifact : null,
97
+ reviewPath,
98
+ };
99
+ }
100
+
101
+ function snapshotNeedsIdentityUpdate(before, after) {
102
+ return before.digest !== after.digest
103
+ || before.workUnitId !== after.workUnitId
104
+ || before.targetRoot !== after.targetRoot;
105
+ }
106
+
107
+ async function backfillWorkUnitsWorkspace(projectRoot, options = {}) {
108
+ const ws = await loadWorkspace(projectRoot);
109
+ if (!(await exists(ws.workspaceRoot))) {
110
+ throw new Error(`Missing workspace: ${ws.workspaceRoot}`);
111
+ }
112
+
113
+ const dryRun = Boolean(options.dryRun);
114
+ const versionIds = await collectVersionIds(ws);
115
+ const latestVersionId = versionIds.at(-1) ?? null;
116
+ const changes = [];
117
+ const bindings = [];
118
+ const errors = [];
119
+ let changedVersions = 0;
120
+
121
+ for (const versionId of versionIds) {
122
+ const snapshot = await loadBackfillSnapshot(ws, versionId);
123
+ if (!snapshot) {
124
+ errors.push(`Missing version snapshot: ${versionId}`);
125
+ continue;
126
+ }
127
+
128
+ const isLatest = versionId === latestVersionId;
129
+ const nextSnapshot = buildBackfilledSnapshot(ws, snapshot, isLatest);
130
+ const needsSnapshotUpdate = snapshotNeedsIdentityUpdate(snapshot, nextSnapshot);
131
+ const status = resolveBackfillStatus(ws, nextSnapshot, isLatest);
132
+ const activeReviewArtifact = isLatest ? defaultReviewArtifactPath(ws) : null;
133
+
134
+ changes.push({
135
+ path: path.relative(ws.projectRoot, path.join(ws.paths.versionsDir, `${versionId}.json`)),
136
+ status: dryRun ? 'planned' : (needsSnapshotUpdate ? 'updated' : 'unchanged'),
137
+ versionId,
138
+ workUnitId: nextSnapshot.workUnitId,
139
+ });
140
+ changes.push({
141
+ path: path.relative(ws.projectRoot, canonicalReviewPath(ws, nextSnapshot.versionId)),
142
+ status: dryRun ? 'planned' : 'refreshed',
143
+ versionId,
144
+ workUnitId: nextSnapshot.workUnitId,
145
+ });
146
+
147
+ if (dryRun) {
148
+ changedVersions += 1;
149
+ continue;
150
+ }
151
+
152
+ if (needsSnapshotUpdate) {
153
+ await writeVersionSnapshot(ws, nextSnapshot);
154
+ }
155
+ const writtenReview = await writeReviewBundle(ws, nextSnapshot, isLatest);
156
+ const binding = await writeWorkUnitBinding(ws, {
157
+ snapshot: nextSnapshot,
158
+ reviewPath: writtenReview.reviewPath,
159
+ activeReviewPath: writtenReview.activeReviewArtifact,
160
+ targetRoot: nextSnapshot.targetRoot,
161
+ status,
162
+ });
163
+ if (binding) {
164
+ bindings.push(binding);
165
+ changes.push({
166
+ path: path.relative(ws.projectRoot, binding.path),
167
+ status: 'updated',
168
+ versionId,
169
+ workUnitId: nextSnapshot.workUnitId,
170
+ });
171
+ }
172
+
173
+ changedVersions += 1;
174
+ }
175
+
176
+ if (!dryRun && versionIds.length > 0) {
177
+ const snapshots = [];
178
+ for (const versionId of versionIds) {
179
+ const snapshot = await readVersionSnapshot(ws, versionId);
180
+ if (snapshot) {
181
+ snapshots.push(summarizeSnapshot(snapshot));
182
+ }
183
+ }
184
+ await writeVersionIndex(ws, snapshots);
185
+
186
+ const latestSnapshot = await readVersionSnapshot(ws, latestVersionId);
187
+ if (latestSnapshot) {
188
+ const currentState = ws.data.currentState ?? {};
189
+ const status = resolveBackfillStatus(ws, latestSnapshot, true);
190
+ const nextState = {
191
+ ...currentState,
192
+ latestVersionId: latestSnapshot.versionId,
193
+ latestVersionDigest: latestSnapshot.digest,
194
+ activeWorkUnitId: latestSnapshot.workUnitId,
195
+ targetRoot: latestSnapshot.targetRoot,
196
+ reviewStatus: {
197
+ ...(currentState.reviewStatus ?? {}),
198
+ versionId: latestSnapshot.versionId,
199
+ workUnitId: latestSnapshot.workUnitId,
200
+ status,
201
+ reviewPath: canonicalReviewPath(ws, latestSnapshot.versionId),
202
+ entryPath: defaultReviewArtifactPath(ws),
203
+ artifact: defaultReviewArtifactPath(ws),
204
+ stableArtifact: canonicalReviewPath(ws, latestSnapshot.versionId),
205
+ updatedAt: currentState.reviewStatus?.updatedAt ?? timestamp(),
206
+ },
207
+ };
208
+ await writeJson(ws.paths.currentState, nextState);
209
+ await writeJson(ws.paths.taskGraph, buildWorkflowTaskGraph(latestSnapshot));
210
+ }
211
+
212
+ await appendWorkflowEvent(ws, 'work_units_backfilled', {
213
+ versions: versionIds.length,
214
+ changedVersions,
215
+ });
216
+ }
217
+
218
+ return {
219
+ ok: errors.length === 0,
220
+ action: 'backfill-work-units',
221
+ projectRoot: ws.projectRoot,
222
+ workspaceRoot: ws.workspaceRoot,
223
+ dryRun,
224
+ totalVersions: versionIds.length,
225
+ changedVersions,
226
+ bindings,
227
+ changes,
228
+ errors,
229
+ };
230
+ }
231
+
232
+ export { backfillWorkUnitsWorkspace };
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ import { readJson, writeJson } from './fs-utils.js';
4
+ import { timestamp } from './time.js';
5
+
6
+ function normalizeWorkUnitId(value) {
7
+ if (value === null || value === undefined || value === '') {
8
+ return null;
9
+ }
10
+ const text = String(value).trim();
11
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text)) {
12
+ throw new Error('Work unit id must start with a letter or number and only contain letters, numbers, dot, underscore, colon, or dash.');
13
+ }
14
+ return text;
15
+ }
16
+
17
+ function generateWorkUnitId() {
18
+ const compactTime = timestamp().replace(/[^0-9]/g, '').slice(0, 14);
19
+ return `wu-${compactTime}-${crypto.randomBytes(4).toString('hex')}`;
20
+ }
21
+
22
+ function workUnitFileName(workUnitId) {
23
+ return `${workUnitId.replace(/[^A-Za-z0-9._-]/g, '_')}.json`;
24
+ }
25
+
26
+ function workUnitStatePath(ws, workUnitId) {
27
+ return path.join(ws.workspaceRoot, 'engagements', 'work-units', workUnitFileName(workUnitId));
28
+ }
29
+
30
+ function resolveTargetRoot(ws, value) {
31
+ return value ? path.resolve(ws.projectRoot, value) : ws.projectRoot;
32
+ }
33
+
34
+ async function readWorkUnitBinding(ws, workUnitId) {
35
+ if (!workUnitId) {
36
+ return null;
37
+ }
38
+ return readJson(workUnitStatePath(ws, workUnitId)).catch(() => null);
39
+ }
40
+
41
+ async function writeWorkUnitBinding(ws, {
42
+ snapshot,
43
+ reviewArtifact,
44
+ stableReviewArtifact,
45
+ reviewPath,
46
+ activeReviewPath,
47
+ reviewBundle,
48
+ targetRoot,
49
+ status = 'pending-confirmation',
50
+ }) {
51
+ if (!snapshot.workUnitId) {
52
+ return null;
53
+ }
54
+ const filePath = workUnitStatePath(ws, snapshot.workUnitId);
55
+ const previous = await readJson(filePath).catch(() => null);
56
+ const binding = {
57
+ ...(previous ?? {}),
58
+ version: 1,
59
+ workUnitId: snapshot.workUnitId,
60
+ title: snapshot.title,
61
+ status,
62
+ projectRoot: ws.projectRoot,
63
+ workspaceRoot: ws.workspaceRoot,
64
+ targetRoot: targetRoot ?? snapshot.targetRoot ?? ws.projectRoot,
65
+ latestVersionId: snapshot.versionId,
66
+ latestVersionDigest: snapshot.digest,
67
+ reviewPath: reviewPath ?? stableReviewArtifact ?? null,
68
+ activeReviewPath: activeReviewPath ?? reviewArtifact ?? null,
69
+ reviewArtifact: reviewPath ?? stableReviewArtifact ?? null,
70
+ activeReviewArtifact: activeReviewPath ?? reviewArtifact ?? null,
71
+ artifactBundle: reviewBundle ?? previous?.artifactBundle ?? null,
72
+ createdAt: previous?.createdAt ?? snapshot.createdAt,
73
+ updatedAt: timestamp(),
74
+ };
75
+ await writeJson(filePath, binding);
76
+ return {
77
+ ...binding,
78
+ path: filePath,
79
+ };
80
+ }
81
+
82
+ export {
83
+ generateWorkUnitId,
84
+ normalizeWorkUnitId,
85
+ readWorkUnitBinding,
86
+ resolveTargetRoot,
87
+ writeWorkUnitBinding,
88
+ };