@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
@@ -0,0 +1,54 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ function pathWithin(parentPath, childPath) {
5
+ const relativePath = path.relative(parentPath, childPath);
6
+ return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
7
+ }
8
+
9
+ function analyzeWorkspaceRegistryHygiene(entries = [], options = {}) {
10
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
11
+ const issues = [];
12
+ const normalized = entries
13
+ .filter((entry) => entry?.workspaceRoot)
14
+ .map((entry) => ({
15
+ workspaceRoot: path.resolve(entry.workspaceRoot),
16
+ realpath: path.resolve(entry.realpath ?? entry.workspaceRoot),
17
+ }))
18
+ .sort((left, right) => left.workspaceRoot.localeCompare(right.workspaceRoot));
19
+
20
+ for (const entry of normalized) {
21
+ if (entry.workspaceRoot === homeDir || entry.realpath === homeDir) {
22
+ issues.push({
23
+ kind: 'overbroad-root',
24
+ severity: 'warning',
25
+ workspaceRoot: entry.workspaceRoot,
26
+ message: `Workspace root ${entry.workspaceRoot} 过宽,容易把多个不相关项目混进同一 registry 视野。`,
27
+ });
28
+ }
29
+ }
30
+
31
+ for (let index = 0; index < normalized.length; index += 1) {
32
+ for (let nextIndex = index + 1; nextIndex < normalized.length; nextIndex += 1) {
33
+ const current = normalized[index];
34
+ const next = normalized[nextIndex];
35
+ if (!pathWithin(current.realpath, next.realpath) && !pathWithin(current.workspaceRoot, next.workspaceRoot)) {
36
+ continue;
37
+ }
38
+ issues.push({
39
+ kind: 'ambiguous-nesting',
40
+ severity: 'warning',
41
+ workspaceRoot: current.workspaceRoot,
42
+ relatedWorkspaceRoot: next.workspaceRoot,
43
+ message: `Workspace root ${current.workspaceRoot} 与 ${next.workspaceRoot} 存在父子嵌套,恢复会话时需要额外消歧。`,
44
+ });
45
+ }
46
+ }
47
+
48
+ return {
49
+ ok: issues.length === 0,
50
+ issues,
51
+ };
52
+ }
53
+
54
+ export { analyzeWorkspaceRegistryHygiene };
@@ -0,0 +1,413 @@
1
+ /*
2
+ * 核心功能
3
+ * 维护可选的项目级 release/version ledger,统一记录当前版本、版本状态、版本内变化项与本地 tag 元数据。
4
+ *
5
+ * 输入
6
+ * 接收项目根目录、版本号、变化说明、tag 同步结果等项目级 release 信息。
7
+ *
8
+ * 输出
9
+ * 读写 `.openprd/state/release-ledger.json`,并返回 handoff、status、commit 可复用的结构化版本摘要。
10
+ *
11
+ * 定位
12
+ * 位于 OpenPrd 的项目发布事实层,刻意与内部 PRD `v000x` 快照版本分离。
13
+ *
14
+ * 依赖
15
+ * 依赖 fs-utils、change-summary 和基础时间工具,不依赖 workspace workflow。
16
+ *
17
+ * 维护规则
18
+ * 版本号默认按 semver 提示,但必须允许项目保留自己的版本体系;不可把远端 tag 风险静默吞掉。
19
+ */
20
+ import { buildChangeEntries, buildChangeEntry, buildChangeSummaryFromEntries } from './change-summary.js';
21
+ import { cjoin, readJson, writeJson } from './fs-utils.js';
22
+ import { timestamp } from './time.js';
23
+
24
+ export const RELEASE_LEDGER_STATUSES = ['draft', 'current', 'released'];
25
+ export const RELEASE_LEDGER_STRATEGY = 'semver';
26
+ export const RELEASE_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
27
+
28
+ function normalizeReleaseStatus(status, fallback = 'current') {
29
+ return RELEASE_LEDGER_STATUSES.includes(status) ? status : fallback;
30
+ }
31
+
32
+ function normalizeReleaseVersionInput(value) {
33
+ const text = String(value ?? '').trim();
34
+ if (!text) return '';
35
+ if (/^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/iu.test(text)) {
36
+ return text.slice(1);
37
+ }
38
+ return text;
39
+ }
40
+
41
+ function buildSemverHint(version) {
42
+ const normalized = normalizeReleaseVersionInput(version);
43
+ if (!normalized) {
44
+ return { matchesSemver: false, normalizedVersion: normalized, warning: '未设置项目版本号。' };
45
+ }
46
+ if (RELEASE_SEMVER_PATTERN.test(normalized)) {
47
+ return { matchesSemver: true, normalizedVersion: normalized, warning: null };
48
+ }
49
+ return {
50
+ matchesSemver: false,
51
+ normalizedVersion: normalized,
52
+ warning: `当前项目版本 ${normalized} 未匹配 x.y.z 形式;OpenPrd 仍会保留它,但不会把 semver 当成强校验。`,
53
+ };
54
+ }
55
+
56
+ function normalizeReleaseTag(tag) {
57
+ if (!tag || typeof tag !== 'object' || Array.isArray(tag)) return null;
58
+ return {
59
+ name: typeof tag.name === 'string' ? tag.name.trim() : null,
60
+ localSha: typeof tag.localSha === 'string' && tag.localSha.trim() ? tag.localSha.trim() : null,
61
+ remoteSha: typeof tag.remoteSha === 'string' && tag.remoteSha.trim() ? tag.remoteSha.trim() : null,
62
+ remoteStatus: typeof tag.remoteStatus === 'string' && tag.remoteStatus.trim() ? tag.remoteStatus.trim() : null,
63
+ updatedAt: typeof tag.updatedAt === 'string' && tag.updatedAt.trim() ? tag.updatedAt.trim() : null,
64
+ warning: typeof tag.warning === 'string' && tag.warning.trim() ? tag.warning.trim() : null,
65
+ };
66
+ }
67
+
68
+ function normalizeReleaseSource(source) {
69
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
70
+ const next = {};
71
+ for (const [key, value] of Object.entries(source)) {
72
+ if (value === null || value === undefined) continue;
73
+ if (typeof value === 'string') {
74
+ const trimmed = value.trim();
75
+ if (!trimmed) continue;
76
+ next[key] = trimmed;
77
+ continue;
78
+ }
79
+ next[key] = value;
80
+ }
81
+ return Object.keys(next).length > 0 ? next : null;
82
+ }
83
+
84
+ function normalizeReleaseItem(raw) {
85
+ if (!raw) return null;
86
+
87
+ const entry = buildChangeEntry(
88
+ typeof raw === 'string'
89
+ ? raw
90
+ : raw.sentence ?? raw.detail ?? raw.summary ?? raw.title ?? '',
91
+ { fallbackType: raw.type ?? '调整', summaryMaxLength: 15 },
92
+ );
93
+ if (!entry) return null;
94
+
95
+ return {
96
+ type: entry.type,
97
+ summary: entry.summary,
98
+ detail: entry.detail,
99
+ sentence: entry.sentence,
100
+ recordedAt: typeof raw.recordedAt === 'string' && raw.recordedAt.trim() ? raw.recordedAt.trim() : null,
101
+ source: normalizeReleaseSource(raw.source),
102
+ };
103
+ }
104
+
105
+ function normalizeReleaseVersionEntry(raw) {
106
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
107
+ const version = normalizeReleaseVersionInput(raw.version);
108
+ if (!version) return null;
109
+ const items = Array.isArray(raw.items)
110
+ ? raw.items.map((item) => normalizeReleaseItem(item)).filter(Boolean)
111
+ : [];
112
+ return {
113
+ version,
114
+ status: normalizeReleaseStatus(raw.status),
115
+ createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt.trim() : null,
116
+ updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt.trim() : null,
117
+ notes: typeof raw.notes === 'string' && raw.notes.trim() ? raw.notes.trim() : null,
118
+ items,
119
+ tag: normalizeReleaseTag(raw.tag),
120
+ };
121
+ }
122
+
123
+ function normalizeReleaseVersions(values) {
124
+ const items = Array.isArray(values) ? values : [];
125
+ const versions = [];
126
+ const seen = new Set();
127
+ for (const item of items) {
128
+ const entry = normalizeReleaseVersionEntry(item);
129
+ if (!entry) continue;
130
+ if (seen.has(entry.version)) continue;
131
+ seen.add(entry.version);
132
+ versions.push(entry);
133
+ }
134
+ return versions;
135
+ }
136
+
137
+ export function defaultReleaseLedger() {
138
+ return {
139
+ version: 1,
140
+ enabled: false,
141
+ strategy: RELEASE_LEDGER_STRATEGY,
142
+ currentVersion: null,
143
+ versions: [],
144
+ createdAt: null,
145
+ updatedAt: null,
146
+ };
147
+ }
148
+
149
+ export function normalizeReleaseLedger(raw) {
150
+ const defaults = defaultReleaseLedger();
151
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
152
+ return defaults;
153
+ }
154
+ const versions = normalizeReleaseVersions(raw.versions);
155
+ const currentVersion = normalizeReleaseVersionInput(raw.currentVersion);
156
+ return {
157
+ version: 1,
158
+ enabled: raw.enabled === true,
159
+ strategy: typeof raw.strategy === 'string' && raw.strategy.trim() ? raw.strategy.trim() : RELEASE_LEDGER_STRATEGY,
160
+ currentVersion: versions.some((entry) => entry.version === currentVersion) ? currentVersion : (currentVersion || null),
161
+ versions,
162
+ createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt.trim() : null,
163
+ updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt.trim() : null,
164
+ };
165
+ }
166
+
167
+ export function releaseLedgerPath(projectRoot) {
168
+ return cjoin(projectRoot, '.openprd', 'state', 'release-ledger.json');
169
+ }
170
+
171
+ export async function loadReleaseLedger(projectRoot) {
172
+ const filePath = releaseLedgerPath(projectRoot);
173
+ const raw = await readJson(filePath).catch(() => null);
174
+ return {
175
+ filePath,
176
+ exists: raw !== null,
177
+ ledger: normalizeReleaseLedger(raw),
178
+ };
179
+ }
180
+
181
+ export async function saveReleaseLedger(projectRoot, ledger) {
182
+ const filePath = releaseLedgerPath(projectRoot);
183
+ const normalized = normalizeReleaseLedger({
184
+ ...ledger,
185
+ updatedAt: timestamp(),
186
+ createdAt: ledger?.createdAt ?? timestamp(),
187
+ });
188
+ await writeJson(filePath, normalized);
189
+ return { filePath, ledger: normalized };
190
+ }
191
+
192
+ export function findReleaseVersionEntry(ledger, version) {
193
+ const normalized = normalizeReleaseVersionInput(version);
194
+ return normalizeReleaseLedger(ledger).versions.find((entry) => entry.version === normalized) ?? null;
195
+ }
196
+
197
+ export function getCurrentReleaseEntry(ledger) {
198
+ const normalized = normalizeReleaseLedger(ledger);
199
+ return normalized.currentVersion
200
+ ? normalized.versions.find((entry) => entry.version === normalized.currentVersion) ?? null
201
+ : null;
202
+ }
203
+
204
+ function ensureReleaseLedgerEntry(ledger, version, options = {}) {
205
+ const normalizedLedger = normalizeReleaseLedger(ledger);
206
+ const normalizedVersion = normalizeReleaseVersionInput(version);
207
+ if (!normalizedVersion) {
208
+ throw new Error('项目版本号不能为空。');
209
+ }
210
+ let entry = normalizedLedger.versions.find((item) => item.version === normalizedVersion) ?? null;
211
+ const now = timestamp();
212
+ if (!entry) {
213
+ entry = {
214
+ version: normalizedVersion,
215
+ status: normalizeReleaseStatus(options.status),
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ notes: options.notes ?? null,
219
+ items: [],
220
+ tag: null,
221
+ };
222
+ normalizedLedger.versions.push(entry);
223
+ }
224
+ if (options.status) {
225
+ entry.status = normalizeReleaseStatus(options.status, entry.status);
226
+ }
227
+ entry.updatedAt = now;
228
+ if (!entry.createdAt) entry.createdAt = now;
229
+ return { ledger: normalizedLedger, entry };
230
+ }
231
+
232
+ export function setReleaseLedgerEnabled(ledger, enabled) {
233
+ const normalized = normalizeReleaseLedger(ledger);
234
+ const next = {
235
+ ...normalized,
236
+ enabled: Boolean(enabled),
237
+ createdAt: normalized.createdAt ?? timestamp(),
238
+ updatedAt: timestamp(),
239
+ };
240
+ return { ledger: next };
241
+ }
242
+
243
+ export function setCurrentReleaseVersion(ledger, version, options = {}) {
244
+ const normalized = normalizeReleaseLedger(ledger);
245
+ const nextVersion = normalizeReleaseVersionInput(version);
246
+ if (!nextVersion) {
247
+ throw new Error('项目版本号不能为空。');
248
+ }
249
+ const previousCurrent = getCurrentReleaseEntry(normalized);
250
+ const ensured = ensureReleaseLedgerEntry(normalized, nextVersion, {
251
+ status: options.status ?? 'current',
252
+ notes: options.notes ?? null,
253
+ });
254
+ const next = ensured.ledger;
255
+ for (const item of next.versions) {
256
+ if (item.version === nextVersion) {
257
+ item.status = normalizeReleaseStatus(options.status ?? 'current');
258
+ item.updatedAt = timestamp();
259
+ continue;
260
+ }
261
+ if (item.status === 'current') {
262
+ item.status = normalizeReleaseStatus(options.previousStatus ?? 'released', 'released');
263
+ item.updatedAt = timestamp();
264
+ }
265
+ }
266
+ next.enabled = true;
267
+ next.currentVersion = nextVersion;
268
+ next.createdAt = next.createdAt ?? timestamp();
269
+ next.updatedAt = timestamp();
270
+ return {
271
+ ledger: next,
272
+ entry: ensured.entry,
273
+ previousVersion: previousCurrent && previousCurrent.version !== nextVersion ? previousCurrent.version : null,
274
+ semver: buildSemverHint(nextVersion),
275
+ };
276
+ }
277
+
278
+ export function setReleaseVersionStatus(ledger, status, options = {}) {
279
+ const normalized = normalizeReleaseLedger(ledger);
280
+ const targetVersion = normalizeReleaseVersionInput(options.version ?? normalized.currentVersion);
281
+ if (!targetVersion) {
282
+ throw new Error('还没有可更新状态的项目版本;请先设置当前版本号。');
283
+ }
284
+ const ensured = ensureReleaseLedgerEntry(normalized, targetVersion, { status });
285
+ const next = ensured.ledger;
286
+ ensured.entry.status = normalizeReleaseStatus(status);
287
+ ensured.entry.updatedAt = timestamp();
288
+ if (ensured.entry.status === 'current') {
289
+ next.currentVersion = targetVersion;
290
+ for (const entry of next.versions) {
291
+ if (entry.version !== targetVersion && entry.status === 'current') {
292
+ entry.status = 'released';
293
+ entry.updatedAt = timestamp();
294
+ }
295
+ }
296
+ }
297
+ next.enabled = true;
298
+ next.createdAt = next.createdAt ?? timestamp();
299
+ next.updatedAt = timestamp();
300
+ return { ledger: next, entry: ensured.entry };
301
+ }
302
+
303
+ function releaseItemKey(item) {
304
+ return [
305
+ item.type,
306
+ item.detail,
307
+ item.source?.kind ?? '',
308
+ item.source?.taskId ?? '',
309
+ item.source?.manualId ?? '',
310
+ ].join('::');
311
+ }
312
+
313
+ export function appendReleaseEntry(ledger, rawValue, options = {}) {
314
+ const normalized = normalizeReleaseLedger(ledger);
315
+ const targetVersion = normalizeReleaseVersionInput(options.version ?? normalized.currentVersion);
316
+ if (!targetVersion) {
317
+ throw new Error('还没有当前项目版本;请先设置版本号再累计变化条目。');
318
+ }
319
+ const ensured = ensureReleaseLedgerEntry(normalized, targetVersion, { status: options.status ?? undefined });
320
+ const next = ensured.ledger;
321
+ next.enabled = true;
322
+ if (!next.currentVersion) {
323
+ next.currentVersion = targetVersion;
324
+ }
325
+
326
+ const entries = buildChangeEntries(Array.isArray(rawValue) ? rawValue : [rawValue], {
327
+ fallbackType: options.fallbackType ?? '调整',
328
+ summaryMaxLength: 15,
329
+ });
330
+ const now = timestamp();
331
+ const added = [];
332
+ for (const item of entries) {
333
+ const candidate = normalizeReleaseItem({
334
+ ...item,
335
+ recordedAt: now,
336
+ source: options.source ?? null,
337
+ });
338
+ if (!candidate) continue;
339
+ const existing = ensured.entry.items.find((entry) => releaseItemKey(entry) === releaseItemKey(candidate));
340
+ if (existing) {
341
+ existing.recordedAt = now;
342
+ existing.source = normalizeReleaseSource({ ...(existing.source ?? {}), ...(options.source ?? {}) });
343
+ continue;
344
+ }
345
+ ensured.entry.items.push(candidate);
346
+ added.push(candidate);
347
+ }
348
+ ensured.entry.updatedAt = now;
349
+ next.updatedAt = now;
350
+ next.createdAt = next.createdAt ?? now;
351
+ return { ledger: next, entry: ensured.entry, added };
352
+ }
353
+
354
+ export function updateReleaseTag(ledger, options = {}) {
355
+ const normalized = normalizeReleaseLedger(ledger);
356
+ const targetVersion = normalizeReleaseVersionInput(options.version ?? normalized.currentVersion);
357
+ if (!targetVersion) {
358
+ throw new Error('还没有当前项目版本;无法记录 tag 状态。');
359
+ }
360
+ const ensured = ensureReleaseLedgerEntry(normalized, targetVersion, {});
361
+ ensured.entry.tag = normalizeReleaseTag({
362
+ ...(ensured.entry.tag ?? {}),
363
+ name: options.name ?? targetVersion,
364
+ localSha: options.localSha ?? ensured.entry.tag?.localSha ?? null,
365
+ remoteSha: options.remoteSha ?? ensured.entry.tag?.remoteSha ?? null,
366
+ remoteStatus: options.remoteStatus ?? ensured.entry.tag?.remoteStatus ?? null,
367
+ updatedAt: options.updatedAt ?? timestamp(),
368
+ warning: options.warning ?? null,
369
+ });
370
+ ensured.entry.updatedAt = timestamp();
371
+ ensured.ledger.updatedAt = timestamp();
372
+ ensured.ledger.createdAt = ensured.ledger.createdAt ?? timestamp();
373
+ return { ledger: ensured.ledger, entry: ensured.entry };
374
+ }
375
+
376
+ export function buildReleaseLedgerSummary(ledger, options = {}) {
377
+ const normalized = normalizeReleaseLedger(ledger);
378
+ const current = options.version
379
+ ? findReleaseVersionEntry(normalized, options.version)
380
+ : getCurrentReleaseEntry(normalized);
381
+ const semver = buildSemverHint(current?.version ?? normalized.currentVersion);
382
+ return {
383
+ enabled: normalized.enabled,
384
+ strategy: normalized.strategy,
385
+ currentVersion: current?.version ?? normalized.currentVersion ?? null,
386
+ currentStatus: current?.status ?? null,
387
+ versionCount: normalized.versions.length,
388
+ itemCount: current?.items?.length ?? 0,
389
+ items: current?.items ?? [],
390
+ semver,
391
+ tag: current?.tag ?? null,
392
+ };
393
+ }
394
+
395
+ export function buildReleaseChangeSummary(ledger, options = {}) {
396
+ const normalized = normalizeReleaseLedger(ledger);
397
+ const entry = options.version
398
+ ? findReleaseVersionEntry(normalized, options.version)
399
+ : getCurrentReleaseEntry(normalized);
400
+ if (!entry || entry.items.length === 0) {
401
+ return {
402
+ title: `${options.version ?? normalized.currentVersion ?? '当前版本'}变化摘要`,
403
+ perspective: null,
404
+ preferredVerbs: [],
405
+ items: [],
406
+ markdown: '',
407
+ };
408
+ }
409
+ return buildChangeSummaryFromEntries(entry.items, {
410
+ title: `${entry.version} 变化摘要`,
411
+ limit: options.limit,
412
+ });
413
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
+ import { USER_CHANGE_SUMMARY_GUIDE } from './change-summary.js';
5
+ import { buildReleaseLedgerSummary, loadReleaseLedger } from './release-ledger.js';
4
6
 
5
7
  import {
6
8
  buildReviewPresentationFeedback,
@@ -34,23 +36,23 @@ export const REVIEW_PRESENTATION_TEMPLATE = {
34
36
  ],
35
37
  panels: {
36
38
  flow: [
37
- { summary: '15字内标签', detail: '一句话说明主流程' },
39
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.flow,
38
40
  ],
39
41
  function: [
40
- { summary: '15字内标签', detail: '一句话说明功能约束' },
42
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.function,
41
43
  ],
42
44
  guardrail: [
43
- { summary: '15字内标签', detail: '一句话说明业务护栏' },
45
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.guardrail,
44
46
  ],
45
47
  risk: [
46
- { summary: '15字内标签', detail: '一句话说明风险问题' },
48
+ USER_CHANGE_SUMMARY_GUIDE.panelExamples.risk,
47
49
  ],
48
50
  },
49
51
  };
50
52
 
51
53
  export function buildReviewPresentationTemplatePayload() {
52
54
  return {
53
- intent: 'Agent 先按这个模板写 reviewPresentation,再用本脚本校验;不要让 HTML 截断文案。',
55
+ intent: 'Agent 先按这个模板写 reviewPresentation,再用本脚本校验;短标签优先使用新增、修复、优化、调整、移除这类用户可感知变化。',
54
56
  presentationTemplate: REVIEW_PRESENTATION_TEMPLATE,
55
57
  presentationContract: buildReviewPresentationFeedback({ sections: {} }).contract,
56
58
  };
@@ -184,7 +186,11 @@ async function renderValidatedReviewPresentation(projectRoot, snapshot) {
184
186
  const workspaceRoot = path.join(projectRoot, '.openprd');
185
187
  const canonicalReview = canonicalReviewPath({ workspaceRoot }, snapshot.versionId);
186
188
  const activeReviewEntry = defaultReviewArtifactPath({ workspaceRoot });
187
- await writeHtmlArtifact(canonicalReview, renderReviewArtifact({ snapshot }));
189
+ const releaseLedger = await loadReleaseLedger(projectRoot);
190
+ await writeHtmlArtifact(canonicalReview, renderReviewArtifact({
191
+ snapshot,
192
+ projectRelease: buildReleaseLedgerSummary(releaseLedger.ledger),
193
+ }));
188
194
 
189
195
  const versionIndexPath = path.join(workspaceRoot, 'state', 'version-index.json');
190
196
  const versionIndex = await readJson(versionIndexPath);