@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
package/src/growth.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { appendJsonl, cjoin, exists, readJson, readJsonl, writeJson } from './fs-utils.js';
4
+ import { listBenchmarkRecommendationsWorkspace } from './benchmark.js';
4
5
 
5
6
  export const OPENPRD_GROWTH_DIR = path.join('.openprd', 'growth');
6
7
  export const OPENPRD_GROWTH_CANDIDATES = path.join(OPENPRD_GROWTH_DIR, 'candidates.jsonl');
7
8
  export const OPENPRD_GROWTH_ACCEPTED = path.join(OPENPRD_GROWTH_DIR, 'accepted.json');
8
9
  export const OPENPRD_GROWTH_REJECTED = path.join(OPENPRD_GROWTH_DIR, 'rejected.json');
9
10
  export const OPENPRD_GROWTH_LOCAL_PREFERENCES = path.join(OPENPRD_GROWTH_DIR, 'preferences.local.json');
11
+ export const OPENPRD_GROWTH_LEDGER = path.join(OPENPRD_GROWTH_DIR, 'ledger.json');
12
+ export const OPENPRD_GROWTH_EVENTS = path.join(OPENPRD_GROWTH_DIR, 'events.jsonl');
10
13
  export const OPENPRD_STANDARDS_CONFIG = path.join('.openprd', 'standards', 'config.json');
11
14
 
12
15
  export const DEFAULT_GROWTH_CONFIG = {
@@ -78,6 +81,32 @@ function normalizeArray(value) {
78
81
  return Array.isArray(value) ? value.filter((item) => item !== null && item !== undefined) : [];
79
82
  }
80
83
 
84
+ function defaultGrowthLedger() {
85
+ return {
86
+ version: 1,
87
+ updatedAt: nowIso(),
88
+ summary: {
89
+ eventCount: 0,
90
+ observed: 0,
91
+ pendingObserved: 0,
92
+ autoApplied: 0,
93
+ manualApplied: 0,
94
+ reconciledAutoApplied: 0,
95
+ rejected: 0,
96
+ skipped: 0,
97
+ completionCheckpoints: 0,
98
+ skippedReasons: {},
99
+ current: {
100
+ total: 0,
101
+ pending: 0,
102
+ applied: 0,
103
+ rejected: 0,
104
+ },
105
+ },
106
+ recentEvents: [],
107
+ };
108
+ }
109
+
81
110
  function normalizeCandidate(raw = {}) {
82
111
  const type = String(raw.type ?? '').trim();
83
112
  const key = String(raw.key ?? raw.extension ?? raw.pattern ?? raw.preferenceKey ?? '').trim();
@@ -161,6 +190,7 @@ async function ensureGrowthFiles(projectRoot) {
161
190
  const acceptedPath = growthPath(projectRoot, OPENPRD_GROWTH_ACCEPTED);
162
191
  const rejectedPath = growthPath(projectRoot, OPENPRD_GROWTH_REJECTED);
163
192
  const localPreferencesPath = growthPath(projectRoot, OPENPRD_GROWTH_LOCAL_PREFERENCES);
193
+ const ledgerPath = growthPath(projectRoot, OPENPRD_GROWTH_LEDGER);
164
194
  if (!(await exists(acceptedPath))) {
165
195
  await writeJson(acceptedPath, { version: 1, candidates: [] });
166
196
  }
@@ -170,20 +200,194 @@ async function ensureGrowthFiles(projectRoot) {
170
200
  if (!(await exists(localPreferencesPath))) {
171
201
  await writeJson(localPreferencesPath, { version: 1, preferences: {} });
172
202
  }
203
+ if (!(await exists(ledgerPath))) {
204
+ await writeJson(ledgerPath, defaultGrowthLedger());
205
+ }
206
+ }
207
+
208
+ async function readGrowthAutoApplyConfig(projectRoot) {
209
+ const configPath = growthPath(projectRoot, OPENPRD_STANDARDS_CONFIG);
210
+ const config = await readJsonIfExists(configPath, null);
211
+ return normalizeGrowthConfig(config?.growth).autoApply;
212
+ }
213
+
214
+ function normalizeGrowthLedgerEvent(event = {}) {
215
+ return {
216
+ version: 1,
217
+ at: event.at ?? nowIso(),
218
+ action: String(event.action ?? 'observe').trim() || 'observe',
219
+ outcome: String(event.outcome ?? 'recorded').trim() || 'recorded',
220
+ candidateId: event.candidateId ? String(event.candidateId) : null,
221
+ candidateType: event.candidateType ? String(event.candidateType) : null,
222
+ scope: event.scope ? String(event.scope) : null,
223
+ autoApplied: event.autoApplied === true,
224
+ applyMode: event.applyMode ? String(event.applyMode) : null,
225
+ reason: event.reason ? String(event.reason) : null,
226
+ changed: normalizeArray(event.changed).map((item) => String(item)),
227
+ };
228
+ }
229
+
230
+ function summarizeGrowthLedgerEvents(events = [], state = { candidates: [] }) {
231
+ const summary = defaultGrowthLedger().summary;
232
+ summary.eventCount = events.length;
233
+ for (const rawEvent of events) {
234
+ const event = normalizeGrowthLedgerEvent(rawEvent);
235
+ if (event.action === 'observe') {
236
+ summary.observed += 1;
237
+ if (event.outcome === 'pending-recorded') {
238
+ summary.pendingObserved += 1;
239
+ }
240
+ if (event.outcome.startsWith('skipped-')) {
241
+ summary.skipped += 1;
242
+ const reason = event.outcome.replace(/^skipped-/, '') || 'unknown';
243
+ summary.skippedReasons[reason] = (summary.skippedReasons[reason] ?? 0) + 1;
244
+ }
245
+ if (event.autoApplied) {
246
+ summary.autoApplied += 1;
247
+ }
248
+ } else if (event.action === 'apply' && event.applyMode === 'manual') {
249
+ summary.manualApplied += 1;
250
+ } else if (event.action === 'reconcile' && event.autoApplied) {
251
+ summary.reconciledAutoApplied += 1;
252
+ summary.autoApplied += 1;
253
+ } else if (event.action === 'reject') {
254
+ summary.rejected += 1;
255
+ } else if (event.action === 'checkpoint') {
256
+ summary.completionCheckpoints += 1;
257
+ }
258
+ }
259
+ const pending = state.candidates.filter((candidate) => candidate.status === 'pending').length;
260
+ const applied = state.candidates.filter((candidate) => candidate.status === 'applied').length;
261
+ const rejected = state.candidates.filter((candidate) => candidate.status === 'rejected').length;
262
+ summary.current = {
263
+ total: state.candidates.length,
264
+ pending,
265
+ applied,
266
+ rejected,
267
+ };
268
+ return summary;
269
+ }
270
+
271
+ async function syncGrowthLedger(projectRoot, event = null) {
272
+ if (event) {
273
+ await appendJsonl(growthPath(projectRoot, OPENPRD_GROWTH_EVENTS), normalizeGrowthLedgerEvent(event));
274
+ }
275
+ const state = await readGrowthState(projectRoot);
276
+ const events = await readJsonlIfExists(growthPath(projectRoot, OPENPRD_GROWTH_EVENTS));
277
+ const normalizedEvents = events.map((entry) => normalizeGrowthLedgerEvent(entry));
278
+ const ledger = {
279
+ ...defaultGrowthLedger(),
280
+ updatedAt: nowIso(),
281
+ summary: summarizeGrowthLedgerEvents(normalizedEvents, state),
282
+ recentEvents: normalizedEvents.slice(-12).reverse(),
283
+ };
284
+ await writeJson(growthPath(projectRoot, OPENPRD_GROWTH_LEDGER), ledger);
285
+ return ledger;
286
+ }
287
+
288
+ export async function recordGrowthCheckpointWorkspace(projectRoot, options = {}) {
289
+ await ensureGrowthFiles(projectRoot);
290
+ const outcome = String(options.outcome ?? 'quality-verify').trim() || 'quality-verify';
291
+ const reason = String(options.reason ?? outcome).trim() || outcome;
292
+ const scope = String(options.scope ?? 'project').trim() || 'project';
293
+ const changed = normalizeArray(options.changed).map((item) => String(item));
294
+ const recentEvents = await readJsonlIfExists(growthPath(projectRoot, OPENPRD_GROWTH_EVENTS));
295
+ const normalizedRecent = recentEvents
296
+ .map((entry) => normalizeGrowthLedgerEvent(entry))
297
+ .slice(-24)
298
+ .reverse();
299
+ const duplicate = normalizedRecent.find((event) => (
300
+ event.action === 'checkpoint'
301
+ && event.outcome === outcome
302
+ && String(event.reason ?? '') === reason
303
+ && String(event.scope ?? '') === scope
304
+ ));
305
+ if (duplicate) {
306
+ const ledger = await syncGrowthLedger(projectRoot);
307
+ return {
308
+ ok: true,
309
+ action: 'growth-checkpoint',
310
+ projectRoot,
311
+ recorded: false,
312
+ reason: 'duplicate-checkpoint',
313
+ event: duplicate,
314
+ ledger,
315
+ };
316
+ }
317
+ const event = normalizeGrowthLedgerEvent({
318
+ action: 'checkpoint',
319
+ outcome,
320
+ reason,
321
+ scope,
322
+ changed,
323
+ });
324
+ const ledger = await syncGrowthLedger(projectRoot, event);
325
+ return {
326
+ ok: true,
327
+ action: 'growth-checkpoint',
328
+ projectRoot,
329
+ recorded: true,
330
+ event,
331
+ ledger,
332
+ };
333
+ }
334
+
335
+ async function reconcilePendingAutoApplyCandidates(projectRoot, options = {}) {
336
+ const state = await readGrowthState(projectRoot);
337
+ const autoApply = options.autoApply ?? await readGrowthAutoApplyConfig(projectRoot);
338
+ const reconciled = [];
339
+ for (const candidate of state.candidates) {
340
+ if (candidate.status !== 'pending') {
341
+ continue;
342
+ }
343
+ const autoApplyDecision = assessAutoApplyGrowthCandidate(candidate, autoApply);
344
+ if (!autoApplyDecision.ok) {
345
+ continue;
346
+ }
347
+ const applied = await applyGrowthCandidate(projectRoot, candidate, {
348
+ mode: 'auto',
349
+ reason: autoApplyDecision.reason,
350
+ });
351
+ if (applied.ok) {
352
+ reconciled.push(applied.candidate);
353
+ }
354
+ }
355
+ return reconciled;
173
356
  }
174
357
 
175
358
  export async function initGrowthWorkspace(projectRoot) {
176
359
  await ensureGrowthFiles(projectRoot);
360
+ const reconciled = await reconcilePendingAutoApplyCandidates(projectRoot);
361
+ for (const candidate of reconciled) {
362
+ await syncGrowthLedger(projectRoot, {
363
+ action: 'reconcile',
364
+ outcome: 'auto-applied',
365
+ candidateId: candidate.id,
366
+ candidateType: candidate.type,
367
+ scope: candidate.scope,
368
+ autoApplied: true,
369
+ applyMode: 'auto',
370
+ reason: candidate.applyReason ?? 'safe-code-extension',
371
+ changed: candidate.appliedChanges,
372
+ });
373
+ }
374
+ const ledger = reconciled.length > 0
375
+ ? await readJsonIfExists(growthPath(projectRoot, OPENPRD_GROWTH_LEDGER), defaultGrowthLedger())
376
+ : await syncGrowthLedger(projectRoot);
177
377
  return {
178
378
  ok: true,
179
379
  action: 'growth-init',
180
380
  projectRoot,
381
+ reconciledAutoApplied: reconciled,
382
+ ledger,
181
383
  files: {
182
384
  dir: OPENPRD_GROWTH_DIR,
183
385
  candidates: OPENPRD_GROWTH_CANDIDATES,
184
386
  accepted: OPENPRD_GROWTH_ACCEPTED,
185
387
  rejected: OPENPRD_GROWTH_REJECTED,
186
388
  localPreferences: OPENPRD_GROWTH_LOCAL_PREFERENCES,
389
+ ledger: OPENPRD_GROWTH_LEDGER,
390
+ events: OPENPRD_GROWTH_EVENTS,
187
391
  },
188
392
  };
189
393
  }
@@ -204,13 +408,31 @@ async function readGrowthState(projectRoot) {
204
408
  export async function observeGrowthWorkspace(projectRoot, rawCandidate = {}, options = {}) {
205
409
  const candidate = normalizeCandidate(rawCandidate);
206
410
  if (!candidate.type || !candidate.key) {
207
- return { ok: false, action: 'growth-observe', skipped: true, reason: 'missing-type-or-key', candidate };
411
+ const result = { ok: false, action: 'growth-observe', skipped: true, reason: 'missing-type-or-key', candidate };
412
+ result.ledger = await syncGrowthLedger(projectRoot, {
413
+ action: 'observe',
414
+ outcome: 'skipped-missing-type-or-key',
415
+ candidateId: candidate.id,
416
+ candidateType: candidate.type,
417
+ scope: candidate.scope,
418
+ reason: result.reason,
419
+ });
420
+ return result;
208
421
  }
209
422
  await ensureGrowthFiles(projectRoot);
210
423
  const state = await readGrowthState(projectRoot);
211
424
  const existing = state.candidates.find((item) => item.id === candidate.id);
212
425
  if (existing?.status === 'applied' || existing?.status === 'rejected') {
213
- return { ok: true, action: 'growth-observe', skipped: true, reason: `candidate-${existing.status}`, candidate: existing };
426
+ const result = { ok: true, action: 'growth-observe', skipped: true, reason: `candidate-${existing.status}`, candidate: existing, autoApplied: false };
427
+ result.ledger = await syncGrowthLedger(projectRoot, {
428
+ action: 'observe',
429
+ outcome: `skipped-candidate-${existing.status}`,
430
+ candidateId: existing.id,
431
+ candidateType: existing.type,
432
+ scope: existing.scope,
433
+ reason: result.reason,
434
+ });
435
+ return result;
214
436
  }
215
437
  if (existing?.status === 'pending') {
216
438
  const autoApplyDecision = assessAutoApplyGrowthCandidate(existing, options.autoApply);
@@ -219,15 +441,27 @@ export async function observeGrowthWorkspace(projectRoot, rawCandidate = {}, opt
219
441
  mode: 'auto',
220
442
  reason: autoApplyDecision.reason,
221
443
  });
222
- return {
444
+ const result = {
223
445
  ...applied,
224
446
  action: 'growth-observe',
225
447
  skipped: false,
226
448
  autoApplied: true,
227
449
  autoApplyDecision,
228
450
  };
451
+ result.ledger = await syncGrowthLedger(projectRoot, {
452
+ action: 'observe',
453
+ outcome: 'auto-applied',
454
+ candidateId: applied.candidate.id,
455
+ candidateType: applied.candidate.type,
456
+ scope: applied.candidate.scope,
457
+ autoApplied: true,
458
+ applyMode: 'auto',
459
+ reason: autoApplyDecision.reason,
460
+ changed: applied.changed,
461
+ });
462
+ return result;
229
463
  }
230
- return {
464
+ const result = {
231
465
  ok: true,
232
466
  action: 'growth-observe',
233
467
  skipped: true,
@@ -236,6 +470,15 @@ export async function observeGrowthWorkspace(projectRoot, rawCandidate = {}, opt
236
470
  autoApplied: false,
237
471
  autoApplyDecision,
238
472
  };
473
+ result.ledger = await syncGrowthLedger(projectRoot, {
474
+ action: 'observe',
475
+ outcome: 'skipped-candidate-already-pending',
476
+ candidateId: existing.id,
477
+ candidateType: existing.type,
478
+ scope: existing.scope,
479
+ reason: autoApplyDecision.reason,
480
+ });
481
+ return result;
239
482
  }
240
483
  const stored = await writeCandidateEvent(projectRoot, candidate);
241
484
  const autoApplyDecision = assessAutoApplyGrowthCandidate(stored, options.autoApply);
@@ -244,15 +487,27 @@ export async function observeGrowthWorkspace(projectRoot, rawCandidate = {}, opt
244
487
  mode: 'auto',
245
488
  reason: autoApplyDecision.reason,
246
489
  });
247
- return {
490
+ const result = {
248
491
  ...applied,
249
492
  action: 'growth-observe',
250
493
  skipped: false,
251
494
  autoApplied: true,
252
495
  autoApplyDecision,
253
496
  };
497
+ result.ledger = await syncGrowthLedger(projectRoot, {
498
+ action: 'observe',
499
+ outcome: 'auto-applied',
500
+ candidateId: applied.candidate.id,
501
+ candidateType: applied.candidate.type,
502
+ scope: applied.candidate.scope,
503
+ autoApplied: true,
504
+ applyMode: 'auto',
505
+ reason: autoApplyDecision.reason,
506
+ changed: applied.changed,
507
+ });
508
+ return result;
254
509
  }
255
- return {
510
+ const result = {
256
511
  ok: true,
257
512
  action: 'growth-observe',
258
513
  skipped: false,
@@ -260,6 +515,15 @@ export async function observeGrowthWorkspace(projectRoot, rawCandidate = {}, opt
260
515
  autoApplied: false,
261
516
  autoApplyDecision,
262
517
  };
518
+ result.ledger = await syncGrowthLedger(projectRoot, {
519
+ action: 'observe',
520
+ outcome: 'pending-recorded',
521
+ candidateId: stored.id,
522
+ candidateType: stored.type,
523
+ scope: stored.scope,
524
+ reason: autoApplyDecision.reason,
525
+ });
526
+ return result;
263
527
  }
264
528
 
265
529
  export function assessAutoApplyGrowthCandidate(candidate, rawConfig = {}) {
@@ -273,7 +537,10 @@ export function assessAutoApplyGrowthCandidate(candidate, rawConfig = {}) {
273
537
  if (candidate.scope !== 'project') {
274
538
  return { ok: false, reason: 'scope-needs-review' };
275
539
  }
276
- if (typeof candidate.confidence !== 'number' || candidate.confidence < config.minConfidence) {
540
+ if (
541
+ candidate.type !== 'code-extension'
542
+ && (typeof candidate.confidence !== 'number' || candidate.confidence < config.minConfidence)
543
+ ) {
277
544
  return { ok: false, reason: 'confidence-below-threshold' };
278
545
  }
279
546
  const extension = normalizeExtension(candidate.key);
@@ -329,7 +596,9 @@ async function applyGrowthCandidate(projectRoot, candidate, options = {}) {
329
596
 
330
597
  export async function reviewGrowthWorkspace(projectRoot) {
331
598
  const state = await readGrowthState(projectRoot);
599
+ const ledger = await syncGrowthLedger(projectRoot);
332
600
  const pending = state.candidates.filter((candidate) => candidate.status === 'pending');
601
+ const benchmarkRecommendations = await listBenchmarkRecommendationsWorkspace(projectRoot).catch(() => []);
333
602
  return {
334
603
  ok: true,
335
604
  action: 'growth-review',
@@ -342,9 +611,14 @@ export async function reviewGrowthWorkspace(projectRoot) {
342
611
  applied: state.candidates.filter((candidate) => candidate.status === 'applied').length,
343
612
  rejected: state.candidates.filter((candidate) => candidate.status === 'rejected').length,
344
613
  },
345
- nextActions: pending.length === 0
346
- ? ['当前没有待确认增长候选。']
347
- : pending.map((candidate) => `收工复盘时确认后运行 openprd grow . --apply --id ${candidate.id};不采用则运行 openprd grow . --reject --id ${candidate.id}`),
614
+ ledger,
615
+ benchmarkRecommendations,
616
+ nextActions: [
617
+ ...(pending.length === 0
618
+ ? ['当前没有待确认增长候选。']
619
+ : pending.map((candidate) => `收工复盘时确认后运行 openprd grow . --apply --id ${candidate.id};不采用则运行 openprd grow . --reject --id ${candidate.id}`)),
620
+ ...benchmarkRecommendations.map((source) => `信源 ${source.sourceKey} 最近 ${source.windowDays} 天已被采纳 ${source.adoptedCount} 次(累计 ${source.totalAdoptedCount} 次),建议确认后运行 ${source.approveCommand} 纳入 benchmark。`),
621
+ ],
348
622
  };
349
623
  }
350
624
 
@@ -425,7 +699,20 @@ export async function applyGrowthCandidateWorkspace(projectRoot, options = {}) {
425
699
  if (candidate.status !== 'pending') {
426
700
  return { ok: false, action: 'growth-apply', projectRoot, candidate, errors: [`Growth candidate is already ${candidate.status}.`] };
427
701
  }
428
- return applyGrowthCandidate(projectRoot, candidate, { mode: 'manual' });
702
+ const result = await applyGrowthCandidate(projectRoot, candidate, { mode: 'manual' });
703
+ if (result.ok) {
704
+ result.ledger = await syncGrowthLedger(projectRoot, {
705
+ action: 'apply',
706
+ outcome: 'manual-applied',
707
+ candidateId: result.candidate.id,
708
+ candidateType: result.candidate.type,
709
+ scope: result.candidate.scope,
710
+ applyMode: 'manual',
711
+ reason: result.candidate.applyReason,
712
+ changed: result.changed,
713
+ });
714
+ }
715
+ return result;
429
716
  }
430
717
 
431
718
  export async function rejectGrowthCandidateWorkspace(projectRoot, options = {}) {
@@ -452,17 +739,27 @@ export async function rejectGrowthCandidateWorkspace(projectRoot, options = {})
452
739
  rejectedCandidates.push(stored);
453
740
  await writeJson(rejectedPath, { version: 1, candidates: rejectedCandidates });
454
741
 
455
- return {
742
+ const result = {
456
743
  ok: true,
457
744
  action: 'growth-reject',
458
745
  projectRoot,
459
746
  candidate: stored,
460
747
  errors: [],
461
748
  };
749
+ result.ledger = await syncGrowthLedger(projectRoot, {
750
+ action: 'reject',
751
+ outcome: 'rejected',
752
+ candidateId: stored.id,
753
+ candidateType: stored.type,
754
+ scope: stored.scope,
755
+ reason: stored.notes,
756
+ });
757
+ return result;
462
758
  }
463
759
 
464
760
  export async function checkGrowthWorkspace(projectRoot) {
465
761
  const state = await readGrowthState(projectRoot);
762
+ const ledger = await syncGrowthLedger(projectRoot);
466
763
  const pending = state.candidates.filter((candidate) => candidate.status === 'pending');
467
764
  const applied = state.candidates.filter((candidate) => candidate.status === 'applied');
468
765
  const rejected = state.candidates.filter((candidate) => candidate.status === 'rejected');
@@ -478,6 +775,7 @@ export async function checkGrowthWorkspace(projectRoot) {
478
775
  applied: applied.length,
479
776
  rejected: rejected.length,
480
777
  },
778
+ ledger,
481
779
  };
482
780
  }
483
781
 
@@ -488,7 +786,7 @@ export function validateGrowthConfig(config, errors = []) {
488
786
  errors.push(`${OPENPRD_STANDARDS_CONFIG} growth.enabled must be a boolean.`);
489
787
  }
490
788
  if (growth.reviewRequired !== undefined && growth.reviewRequired !== true) {
491
- errors.push(`${OPENPRD_STANDARDS_CONFIG} growth.reviewRequired must remain true; shared rules cannot be auto-applied.`);
789
+ errors.push(`${OPENPRD_STANDARDS_CONFIG} growth.reviewRequired must remain true; non-whitelisted shared rules still require wrap-up review.`);
492
790
  }
493
791
  if (growth.candidateLimit !== undefined) {
494
792
  const limit = Number(growth.candidateLimit);
@@ -0,0 +1,25 @@
1
+ export function escapeHtml(value) {
2
+ return String(value ?? '')
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
8
+ }
9
+
10
+ export function listMarkup(items, emptyText = '暂无') {
11
+ const normalized = Array.isArray(items) ? items.filter(Boolean) : [];
12
+ if (normalized.length === 0) {
13
+ return `<li class="empty">${escapeHtml(emptyText)}</li>`;
14
+ }
15
+ return normalized.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
16
+ }
17
+
18
+ export function slugify(value, fallback = 'artifact') {
19
+ const slug = String(value ?? '')
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 80);
24
+ return slug || fallback;
25
+ }