@openprd/cli 0.1.0 → 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 (138) 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 +402 -441
  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 +39 -23
  73. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -6
  74. package/skills/openprd-requirement-intake/references/routing-rubric.md +22 -8
  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/self-update.js +1 -1
  131. package/src/session-binding.js +40 -3
  132. package/src/session-registry.js +159 -0
  133. package/src/standards.js +5 -3
  134. package/src/test-strategy.js +386 -0
  135. package/src/visual-compare.js +915 -34
  136. package/src/work-unit-migration.js +5 -1
  137. package/src/workspace-core.js +343 -19
  138. package/src/workspace-workflow.js +538 -134
package/src/knowledge.js CHANGED
@@ -1,14 +1,28 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { cjoin, exists, readJson, writeJson, writeText } from './fs-utils.js';
3
+ import { appendJsonl, cjoin, exists, readJson, readJsonl, writeJson, writeText } from './fs-utils.js';
4
4
  import { resolveQualityLearningSource } from './quality-learning.js';
5
5
  import { timestamp } from './time.js';
6
6
 
7
7
  const KNOWLEDGE_DIR = cjoin('.openprd', 'knowledge');
8
8
  const KNOWLEDGE_INDEX = cjoin(KNOWLEDGE_DIR, 'index.json');
9
+ const KNOWLEDGE_SKILLS_DIR = cjoin(KNOWLEDGE_DIR, 'skills');
9
10
  const KNOWLEDGE_CANDIDATES_DIR = cjoin(KNOWLEDGE_DIR, 'candidates');
10
11
  const KNOWLEDGE_DRAFTS_DIR = cjoin(KNOWLEDGE_DIR, 'drafts');
12
+ const KNOWLEDGE_ADOPTION_LOG = cjoin(KNOWLEDGE_DIR, 'adoption.jsonl');
13
+ const KNOWLEDGE_REVIEW_SIGNAL_LOG = cjoin(KNOWLEDGE_DIR, 'review-signals.jsonl');
11
14
  const OPENPRD_HARNESS_TURN_STATE = cjoin('.openprd', 'harness', 'turn-state.json');
15
+ const QUALITY_LATEST_REPORT = cjoin('.openprd', 'quality', 'reports', 'latest.json');
16
+ const PENDING_KNOWLEDGE_CANDIDATE_STATUSES = new Set(['pending-review', 'pending']);
17
+ const REVIEWED_KNOWLEDGE_CANDIDATE_STATUSES = new Set([
18
+ 'promoted',
19
+ 'merged',
20
+ 'rejected',
21
+ 'archived',
22
+ 'reviewed-noise',
23
+ 'reviewed-duplicate',
24
+ 'reviewed-weak-signal',
25
+ ]);
12
26
 
13
27
  const CODE_EXTENSIONS = new Set([
14
28
  '.c',
@@ -48,7 +62,7 @@ function knowledgePath(projectRoot, relativePath) {
48
62
 
49
63
  function defaultKnowledgeIndex() {
50
64
  return {
51
- version: 1,
65
+ version: 2,
52
66
  updatedAt: timestamp(),
53
67
  incidents: [],
54
68
  patterns: [],
@@ -65,10 +79,27 @@ function normalizeStringList(value) {
65
79
  .filter(Boolean);
66
80
  }
67
81
 
82
+ function normalizeArray(value) {
83
+ return Array.isArray(value) ? value.filter((item) => item !== null && item !== undefined) : [];
84
+ }
85
+
68
86
  function uniq(items) {
69
87
  return [...new Set(items.filter(Boolean))];
70
88
  }
71
89
 
90
+ function defaultSkillAdoption() {
91
+ return {
92
+ hitCount: 0,
93
+ referencedCount: 0,
94
+ injectedCount: 0,
95
+ lastHitAt: null,
96
+ lastReferencedAt: null,
97
+ lastInjectedAt: null,
98
+ lastSource: null,
99
+ recentEvents: [],
100
+ };
101
+ }
102
+
72
103
  function slugify(value, fallback = 'knowledge') {
73
104
  const slug = String(value ?? '')
74
105
  .toLowerCase()
@@ -100,10 +131,164 @@ function readJsonObject(value) {
100
131
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
101
132
  }
102
133
 
134
+ function trimPreview(value, max = 220) {
135
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
136
+ if (!text) return null;
137
+ return text.length > max ? `${text.slice(0, max - 1)}...` : text;
138
+ }
139
+
140
+ function normalizeSkillAdoption(value) {
141
+ const current = readJsonObject(value) ?? {};
142
+ const recentEvents = Array.isArray(current.recentEvents)
143
+ ? current.recentEvents
144
+ .map((event) => readJsonObject(event))
145
+ .filter(Boolean)
146
+ .slice(0, 12)
147
+ : [];
148
+ return {
149
+ ...defaultSkillAdoption(),
150
+ ...current,
151
+ hitCount: Number.isFinite(Number(current.hitCount)) ? Number(current.hitCount) : 0,
152
+ referencedCount: Number.isFinite(Number(current.referencedCount)) ? Number(current.referencedCount) : 0,
153
+ injectedCount: Number.isFinite(Number(current.injectedCount)) ? Number(current.injectedCount) : 0,
154
+ recentEvents,
155
+ };
156
+ }
157
+
158
+ function normalizeSkillIndexEntry(entry = {}) {
159
+ const skill = readJsonObject(entry) ?? {};
160
+ return {
161
+ ...skill,
162
+ skillName: firstString(skill.skillName, path.basename(path.dirname(String(skill.path ?? ''))), 'knowledge-skill') ?? 'knowledge-skill',
163
+ path: firstString(skill.path),
164
+ sourceKind: firstString(skill.sourceKind),
165
+ sourceRef: firstString(skill.sourceRef),
166
+ candidateId: firstString(skill.candidateId),
167
+ candidateIds: uniq([
168
+ ...normalizeStringList(skill.candidateIds),
169
+ ...normalizeStringList(skill.candidateId ? [skill.candidateId] : []),
170
+ ]),
171
+ categories: normalizeStringList(skill.categories),
172
+ triggerHints: normalizeStringList(skill.triggerHints),
173
+ touchedFiles: normalizeStringList(skill.touchedFiles),
174
+ evidencePaths: normalizeStringList(skill.evidencePaths),
175
+ rootCauseLabels: normalizeStringList(skill.rootCauseLabels),
176
+ description: firstString(skill.description),
177
+ summary: firstString(skill.summary),
178
+ adoption: normalizeSkillAdoption(skill.adoption),
179
+ };
180
+ }
181
+
182
+ function extractCandidateIds(skill = {}) {
183
+ return uniq([
184
+ ...normalizeStringList(skill.candidateIds),
185
+ ...normalizeStringList(skill.candidateId ? [skill.candidateId] : []),
186
+ ...String(skill.sourceRef ?? '')
187
+ .split(',')
188
+ .map((item) => item.trim())
189
+ .filter((item) => /^candidate-[a-z0-9-]+$/i.test(item)),
190
+ ]);
191
+ }
192
+
193
+ function normalizeSearchText(value) {
194
+ return String(value ?? '')
195
+ .toLowerCase()
196
+ .replace(/[`"'()[\]{}:;,!?]/g, ' ')
197
+ .replace(/[_/\\.-]+/g, ' ')
198
+ .replace(/\s+/g, ' ')
199
+ .trim();
200
+ }
201
+
202
+ function normalizeSearchTokens(value) {
203
+ const text = normalizeSearchText(value);
204
+ const asciiTokens = text
205
+ .split(/\s+/)
206
+ .map((item) => item.trim())
207
+ .filter((item) => item.length >= 3);
208
+ const hanTokens = String(value ?? '').match(/[\u4e00-\u9fa5]{2,}/g) ?? [];
209
+ return uniq([...asciiTokens, ...hanTokens]);
210
+ }
211
+
212
+ function sortByLength(items = []) {
213
+ return [...items].sort((left, right) => String(right).length - String(left).length);
214
+ }
215
+
216
+ function scoreQueryAgainstFields(queryText, queryTokens, fields = []) {
217
+ let score = 0;
218
+ const matchedOn = [];
219
+ for (const field of fields) {
220
+ const text = String(field ?? '').trim();
221
+ if (!text) continue;
222
+ const normalized = normalizeSearchText(text);
223
+ if (!normalized) continue;
224
+ let matched = false;
225
+ if (normalized.length >= 6 && queryText.includes(normalized)) {
226
+ matched = true;
227
+ score += normalized.length >= 18 ? 10 : 7;
228
+ } else {
229
+ const fieldTokens = normalizeSearchTokens(text);
230
+ const overlap = fieldTokens.filter((token) => queryTokens.includes(token));
231
+ if (overlap.length > 0) {
232
+ matched = true;
233
+ score += Math.min(overlap.length, 4) * 2;
234
+ }
235
+ }
236
+ if (matched) {
237
+ matchedOn.push(trimPreview(text, 120));
238
+ }
239
+ }
240
+ return {
241
+ score,
242
+ matchedOn: uniq(matchedOn).slice(0, 6),
243
+ };
244
+ }
245
+
246
+ function parseMarkdownSectionList(markdown, headings = []) {
247
+ if (!markdown) return [];
248
+ const lines = String(markdown).split(/\r?\n/);
249
+ const sectionSet = new Set(headings);
250
+ const collected = [];
251
+ let active = false;
252
+ for (const line of lines) {
253
+ const heading = line.match(/^##\s+(.+?)\s*$/);
254
+ if (heading) {
255
+ active = sectionSet.has(heading[1].trim());
256
+ continue;
257
+ }
258
+ if (!active) continue;
259
+ const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
260
+ if (bullet) {
261
+ collected.push(bullet[1].trim());
262
+ }
263
+ }
264
+ return uniq(collected);
265
+ }
266
+
267
+ function parseSkillMetadataFromText(markdown) {
268
+ const text = String(markdown ?? '');
269
+ const frontmatter = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
270
+ const descriptionLine = frontmatter?.[1]
271
+ ?.split(/\r?\n/)
272
+ .map((line) => line.trim())
273
+ .find((line) => line.startsWith('description:'));
274
+ const description = descriptionLine ? descriptionLine.replace(/^description:\s*/, '').trim() : null;
275
+ const triggerHints = parseMarkdownSectionList(text, ['触发场景', '常见误判', '先看什么', '收尾顺序', '反模式', '下次触发时先看什么']);
276
+ const rootCauseLabels = parseMarkdownSectionList(text, ['可复用模式']);
277
+ const evidencePaths = uniq((text.match(/`([^`]+)`/g) ?? [])
278
+ .map((entry) => entry.replace(/`/g, '').trim())
279
+ .filter((entry) => entry.includes('/') || entry.endsWith('.md') || entry.endsWith('.js') || entry.endsWith('.ts')));
280
+ return {
281
+ description,
282
+ triggerHints,
283
+ rootCauseLabels,
284
+ evidencePaths,
285
+ };
286
+ }
287
+
103
288
  async function ensureKnowledgeWorkspace(projectRoot) {
104
289
  await fs.mkdir(knowledgePath(projectRoot, cjoin(KNOWLEDGE_DIR, 'incidents')), { recursive: true });
105
290
  await fs.mkdir(knowledgePath(projectRoot, cjoin(KNOWLEDGE_DIR, 'patterns')), { recursive: true });
106
- await fs.mkdir(knowledgePath(projectRoot, cjoin(KNOWLEDGE_DIR, 'skills')), { recursive: true });
291
+ await fs.mkdir(knowledgePath(projectRoot, KNOWLEDGE_SKILLS_DIR), { recursive: true });
107
292
  await fs.mkdir(knowledgePath(projectRoot, KNOWLEDGE_CANDIDATES_DIR), { recursive: true });
108
293
  await fs.mkdir(knowledgePath(projectRoot, KNOWLEDGE_DRAFTS_DIR), { recursive: true });
109
294
  const indexPath = knowledgePath(projectRoot, KNOWLEDGE_INDEX);
@@ -134,10 +319,185 @@ async function writeKnowledgeIndex(projectRoot, index) {
134
319
  });
135
320
  }
136
321
 
322
+ async function readCandidateSupportBundle(projectRoot, candidateId) {
323
+ const candidate = await readCandidateById(projectRoot, candidateId);
324
+ if (!candidate) {
325
+ return null;
326
+ }
327
+ const candidateDir = candidate.files?.candidateDir ?? knowledgePath(projectRoot, cjoin(KNOWLEDGE_CANDIDATES_DIR, candidateId));
328
+ const rootCauseCandidates = await readJson(path.join(candidateDir, 'root-cause-candidates.json')).catch(() => []);
329
+ return {
330
+ candidateId,
331
+ categories: normalizeStringList(candidate.categories),
332
+ touchedFiles: normalizeStringList(candidate.touchedFiles),
333
+ evidencePaths: uniq([
334
+ toRelativeProjectPath(projectRoot, candidate.files?.candidate),
335
+ ...normalizeStringList(candidate.touchedFiles),
336
+ ]),
337
+ rootCauseLabels: uniq(normalizeArray(rootCauseCandidates)
338
+ .map((item) => firstString(item?.title, item?.label, item?.name))
339
+ .filter(Boolean)),
340
+ triggerHints: uniq([
341
+ ...normalizeStringList(candidate.reasons),
342
+ ...normalizeStringList(candidate.reviewSignals?.map((signal) => signal.summary)),
343
+ ]),
344
+ summary: firstString(candidate.summary),
345
+ };
346
+ }
347
+
348
+ async function hydrateKnowledgeSkillEntry(projectRoot, entry, cache = new Map()) {
349
+ const current = normalizeSkillIndexEntry(entry);
350
+ const skillPath = current.path
351
+ ? (path.isAbsolute(current.path) ? path.resolve(current.path) : knowledgePath(projectRoot, current.path))
352
+ : null;
353
+ const markdown = skillPath ? await fs.readFile(skillPath, 'utf8').catch(() => '') : '';
354
+ const parsedSkill = parseSkillMetadataFromText(markdown);
355
+ const candidateIds = extractCandidateIds(current);
356
+ const candidateBundles = [];
357
+ for (const candidateId of candidateIds) {
358
+ const cacheKey = `candidate:${candidateId}`;
359
+ if (!cache.has(cacheKey)) {
360
+ cache.set(cacheKey, readCandidateSupportBundle(projectRoot, candidateId));
361
+ }
362
+ const bundle = await cache.get(cacheKey);
363
+ if (bundle) {
364
+ candidateBundles.push(bundle);
365
+ }
366
+ }
367
+ const next = {
368
+ ...current,
369
+ candidateId: current.candidateId ?? candidateIds[0] ?? null,
370
+ candidateIds,
371
+ categories: uniq([
372
+ ...current.categories,
373
+ ...candidateBundles.flatMap((bundle) => bundle.categories),
374
+ ]).slice(0, 24),
375
+ triggerHints: uniq([
376
+ ...current.triggerHints,
377
+ ...parsedSkill.triggerHints,
378
+ ...candidateBundles.flatMap((bundle) => bundle.triggerHints),
379
+ ]).slice(0, 24),
380
+ touchedFiles: uniq([
381
+ ...current.touchedFiles,
382
+ ...candidateBundles.flatMap((bundle) => bundle.touchedFiles),
383
+ ]).slice(0, 24),
384
+ evidencePaths: uniq([
385
+ ...current.evidencePaths,
386
+ ...parsedSkill.evidencePaths,
387
+ ...candidateBundles.flatMap((bundle) => bundle.evidencePaths),
388
+ ]).slice(0, 24),
389
+ rootCauseLabels: uniq([
390
+ ...current.rootCauseLabels,
391
+ ...parsedSkill.rootCauseLabels,
392
+ ...candidateBundles.flatMap((bundle) => bundle.rootCauseLabels),
393
+ ]).slice(0, 24),
394
+ description: current.description ?? parsedSkill.description,
395
+ summary: current.summary ?? candidateBundles.map((bundle) => bundle.summary).find(Boolean) ?? null,
396
+ adoption: normalizeSkillAdoption(current.adoption),
397
+ };
398
+ return next;
399
+ }
400
+
401
+ function serializeComparable(value) {
402
+ return JSON.stringify(value);
403
+ }
404
+
405
+ async function hydrateKnowledgeSkills(projectRoot) {
406
+ const index = await readKnowledgeIndex(projectRoot);
407
+ const cache = new Map();
408
+ const hydratedSkills = [];
409
+ let changed = false;
410
+ for (const skill of index.skills.map((entry) => normalizeSkillIndexEntry(entry))) {
411
+ const hydrated = await hydrateKnowledgeSkillEntry(projectRoot, skill, cache);
412
+ hydratedSkills.push(hydrated);
413
+ if (serializeComparable(hydrated) !== serializeComparable(skill)) {
414
+ changed = true;
415
+ }
416
+ }
417
+ if (changed) {
418
+ await writeKnowledgeIndex(projectRoot, {
419
+ ...index,
420
+ skills: hydratedSkills,
421
+ });
422
+ }
423
+ return {
424
+ index: changed ? { ...index, skills: hydratedSkills } : index,
425
+ skills: hydratedSkills,
426
+ };
427
+ }
428
+
429
+ function buildKnowledgeAdoptionSummary(skills = []) {
430
+ const totals = {
431
+ hit: 0,
432
+ referenced: 0,
433
+ injected: 0,
434
+ };
435
+ const activeSkills = {
436
+ hit: 0,
437
+ referenced: 0,
438
+ injected: 0,
439
+ };
440
+ for (const skill of skills.map((entry) => normalizeSkillIndexEntry(entry))) {
441
+ const adoption = normalizeSkillAdoption(skill.adoption);
442
+ totals.hit += adoption.hitCount;
443
+ totals.referenced += adoption.referencedCount;
444
+ totals.injected += adoption.injectedCount;
445
+ if (adoption.hitCount > 0) activeSkills.hit += 1;
446
+ if (adoption.referencedCount > 0) activeSkills.referenced += 1;
447
+ if (adoption.injectedCount > 0) activeSkills.injected += 1;
448
+ }
449
+ return {
450
+ totals,
451
+ activeSkills,
452
+ totalSkills: skills.length,
453
+ };
454
+ }
455
+
137
456
  function upsertBy(items, key, value, max = 200) {
138
457
  return [value, ...items.filter((item) => item?.[key] !== value[key])].slice(0, max);
139
458
  }
140
459
 
460
+ function normalizeCandidateStatus(status) {
461
+ const normalized = String(status ?? '').trim();
462
+ if (!normalized || normalized === 'pending') return 'pending-review';
463
+ return normalized;
464
+ }
465
+
466
+ function isPendingKnowledgeCandidateStatus(status) {
467
+ return PENDING_KNOWLEDGE_CANDIDATE_STATUSES.has(String(status ?? '').trim() || 'pending-review');
468
+ }
469
+
470
+ function isReviewedKnowledgeCandidateStatus(status) {
471
+ const normalized = normalizeCandidateStatus(status);
472
+ return REVIEWED_KNOWLEDGE_CANDIDATE_STATUSES.has(normalized)
473
+ || !isPendingKnowledgeCandidateStatus(normalized);
474
+ }
475
+
476
+ function candidateStatusGroup(status) {
477
+ const normalized = normalizeCandidateStatus(status);
478
+ if (isPendingKnowledgeCandidateStatus(normalized)) return 'pending';
479
+ if (['promoted', 'merged'].includes(normalized)) return 'promoted';
480
+ if (normalized === 'rejected') return 'rejected';
481
+ if (normalized === 'archived') return 'archived';
482
+ return 'reviewed';
483
+ }
484
+
485
+ function resolveCandidateStatus(candidateStatus, indexStatus) {
486
+ const hasCandidateStatus = candidateStatus !== undefined && candidateStatus !== null && String(candidateStatus).trim();
487
+ const hasIndexStatus = indexStatus !== undefined && indexStatus !== null && String(indexStatus).trim();
488
+ const normalizedCandidate = normalizeCandidateStatus(candidateStatus);
489
+ const normalizedIndex = normalizeCandidateStatus(indexStatus);
490
+ if (
491
+ hasCandidateStatus
492
+ && isPendingKnowledgeCandidateStatus(normalizedCandidate)
493
+ && hasIndexStatus
494
+ && isReviewedKnowledgeCandidateStatus(normalizedIndex)
495
+ ) {
496
+ return normalizedIndex;
497
+ }
498
+ return hasCandidateStatus ? normalizedCandidate : normalizedIndex;
499
+ }
500
+
141
501
  function signalSummary(signal) {
142
502
  if (!signal) return null;
143
503
  const parts = [];
@@ -165,6 +525,64 @@ function normalizeReviewSignal(projectRoot, signal = {}) {
165
525
  };
166
526
  }
167
527
 
528
+ function normalizeTouchedFiles(projectRoot, value) {
529
+ return uniq(normalizeStringList(value).map((file) => toRelativeProjectPath(projectRoot, file))).filter(Boolean);
530
+ }
531
+
532
+ async function readRecentKnowledgeReviewSignals(projectRoot, options = {}) {
533
+ const limit = Math.max(1, Number(options.limit ?? 24));
534
+ const entries = await readJsonl(knowledgePath(projectRoot, KNOWLEDGE_REVIEW_SIGNAL_LOG)).catch(() => []);
535
+ return entries
536
+ .map((signal) => normalizeReviewSignal(projectRoot, signal))
537
+ .filter((signal) => signal.summary || signal.touchedFiles.length > 0 || signal.kind)
538
+ .slice(-limit)
539
+ .reverse();
540
+ }
541
+
542
+ function hasOverlap(left = [], right = []) {
543
+ const rightSet = new Set(right);
544
+ return left.some((item) => rightSet.has(item));
545
+ }
546
+
547
+ function buildReviewContext(projectRoot, raw = {}, options = {}) {
548
+ const rawTouchedFiles = normalizeTouchedFiles(projectRoot, raw.touchedFiles);
549
+ const optionTouchedFiles = normalizeTouchedFiles(projectRoot, options.touchedFiles);
550
+ const optionSignal = options.signal ? normalizeReviewSignal(projectRoot, options.signal) : null;
551
+ const recentSignals = Array.isArray(options.recentSignals)
552
+ ? options.recentSignals.map((signal) => normalizeReviewSignal(projectRoot, signal))
553
+ : [];
554
+ const embeddedSignals = Array.isArray(raw.reviewSignals)
555
+ ? raw.reviewSignals.map((signal) => normalizeReviewSignal(projectRoot, signal))
556
+ : [];
557
+ const latestSignalTouchedFiles = [
558
+ ...(embeddedSignals.find((signal) => signal.touchedFiles.length > 0)?.touchedFiles ?? []),
559
+ ...(recentSignals.find((signal) => signal.touchedFiles.length > 0)?.touchedFiles ?? []),
560
+ ];
561
+ const touchedFiles = optionTouchedFiles.length > 0
562
+ ? optionTouchedFiles
563
+ : (optionSignal?.touchedFiles?.length ? optionSignal.touchedFiles : (latestSignalTouchedFiles.length > 0 ? latestSignalTouchedFiles : rawTouchedFiles));
564
+ const signalEntries = [];
565
+ if (optionSignal) {
566
+ signalEntries.push(optionSignal);
567
+ }
568
+ for (const signal of [...embeddedSignals, ...recentSignals]) {
569
+ const isSameSignal = optionSignal && signal.id === optionSignal.id && signal.kind === optionSignal.kind;
570
+ if (isSameSignal) continue;
571
+ if (!optionSignal) {
572
+ signalEntries.push(signal);
573
+ continue;
574
+ }
575
+ if (signal.touchedFiles.length > 0 && hasOverlap(signal.touchedFiles, touchedFiles)) {
576
+ signalEntries.push(signal);
577
+ }
578
+ }
579
+ const reviewSignals = uniq(signalEntries.map((signal) => JSON.stringify(signal))).map((entry) => JSON.parse(entry));
580
+ return {
581
+ touchedFiles,
582
+ reviewSignals,
583
+ };
584
+ }
585
+
168
586
  function isSubstantiveTouchedFile(filePath) {
169
587
  const normalized = String(filePath ?? '').split(path.sep).join('/');
170
588
  if (!normalized) return false;
@@ -201,6 +619,81 @@ function buildKnowledgeCategories({ source, touchedFiles, reviewSignals }) {
201
619
  return uniq(categories);
202
620
  }
203
621
 
622
+ function applicabilityFromTouchedFiles(touchedFiles = []) {
623
+ const normalized = touchedFiles.map((file) => String(file).split(path.sep).join('/'));
624
+ const hints = [];
625
+ if (normalized.some((file) => file.startsWith('src/') || file.startsWith('app/') || file.startsWith('lib/'))) {
626
+ hints.push('适用于项目源码或核心流程已经落地、需要把实现经验固化为项目知识的任务。');
627
+ }
628
+ if (normalized.some((file) => file.startsWith('test/') || file.startsWith('tests/'))) {
629
+ hints.push('适用于本轮补过验证或测试夹具,后续同类需求需要同步复用验证方式的任务。');
630
+ }
631
+ if (normalized.some((file) => file.startsWith('docs/basic/'))) {
632
+ hints.push('适用于这轮改动同时影响 docs/basic、CLI 契约或实现说明,需要把文档同步经验一起沉淀的任务。');
633
+ }
634
+ if (normalized.some((file) => /(hook|harness|agent|skill|quality|run-harness|growth|loop)/i.test(file))) {
635
+ hints.push('特别适用于 Agent、hook、harness、quality 或 growth 工作流改动,避免下次再次靠聊天上下文兜底。');
636
+ }
637
+ if (hints.length === 0 && normalized.length > 0) {
638
+ hints.push(`适用于再次改动 ${normalized.slice(0, 4).join('、')} 这类相关文件时,优先复用本轮模式。`);
639
+ }
640
+ return hints;
641
+ }
642
+
643
+ function summarizeReviewSignalKinds(reviewSignals = []) {
644
+ return uniq(reviewSignals.map((signal) => signal.kind).filter(Boolean)).slice(0, 6);
645
+ }
646
+
647
+ function buildKnowledgeAbstraction({
648
+ candidate,
649
+ source,
650
+ touchedFiles,
651
+ reviewSignals,
652
+ relativeCandidateDir,
653
+ relativeDraftSkillPath,
654
+ }) {
655
+ const triggerConditions = uniq([
656
+ ...candidate.reasons,
657
+ ...normalizeStringList(source.triggers),
658
+ ...source.symptoms.map((item) => `症状: ${item}`),
659
+ ...reviewSignals.map((signal) => {
660
+ const summary = signalSummary(signal);
661
+ return summary ? `${signal.kind}: ${summary}` : signal.kind;
662
+ }),
663
+ ]).slice(0, 8);
664
+ const applicability = uniq([
665
+ source.abstractPattern ? `抽象模式: ${source.abstractPattern}` : null,
666
+ ...applicabilityFromTouchedFiles(touchedFiles),
667
+ ]).slice(0, 6);
668
+ const verificationSteps = uniq([
669
+ ...reviewSignals.map((signal) => signal.summary).filter(Boolean),
670
+ ...source.verificationSteps,
671
+ ]).slice(0, 8);
672
+ const typicalInputs = uniq([
673
+ firstString(source.title, candidate.title) ? `任务摘要: ${firstString(source.title, candidate.title)}` : null,
674
+ touchedFiles.length > 0 ? `相关文件: ${touchedFiles.slice(0, 6).join('、')}` : null,
675
+ source.evidenceSources.length > 0
676
+ ? `已有证据: ${source.evidenceSources.slice(0, 4).map((item) => `${item.kind}:${item.path}`).join(';')}`
677
+ : null,
678
+ reviewSignals.length > 0
679
+ ? `验证信号: ${summarizeReviewSignalKinds(reviewSignals).join('、')}`
680
+ : null,
681
+ ]).slice(0, 6);
682
+ const typicalOutputs = uniq([
683
+ relativeCandidateDir ? `knowledge candidate: ${relativeCandidateDir}/candidate.json` : null,
684
+ relativeCandidateDir ? `诊断报告: ${relativeCandidateDir}/diagnostic-report.json` : null,
685
+ relativeDraftSkillPath ? `draft skill: ${relativeDraftSkillPath}` : null,
686
+ verificationSteps[0] ? `验证结论: ${verificationSteps[0]}` : null,
687
+ ]).slice(0, 6);
688
+ return {
689
+ triggerConditions,
690
+ applicability,
691
+ verificationSteps,
692
+ typicalInputs,
693
+ typicalOutputs,
694
+ };
695
+ }
696
+
204
697
  function categoryReason(category) {
205
698
  if (category === 'hidden-debug-knowledge') {
206
699
  return '本轮结果里已经出现可复用的症状、排查线索或根因模式,不应该只留在当前对话里。';
@@ -253,6 +746,144 @@ async function loadRawReviewInput(projectRoot, from) {
253
746
  return { sourcePath: resolved, raw: readJsonObject(parsed) };
254
747
  }
255
748
 
749
+ function shouldIgnoreInferredTouchedPath(relativePath) {
750
+ const normalized = String(relativePath ?? '').split(path.sep).join('/');
751
+ return [
752
+ '.git/',
753
+ 'node_modules/',
754
+ '.openprd/',
755
+ 'dist/',
756
+ 'build/',
757
+ 'coverage/',
758
+ 'test-results/',
759
+ '.next/',
760
+ '.turbo/',
761
+ ].some((prefix) => normalized.startsWith(prefix));
762
+ }
763
+
764
+ function looksLikeInferredTouchedFile(relativePath) {
765
+ const normalized = String(relativePath ?? '').split(path.sep).join('/');
766
+ if (!normalized || shouldIgnoreInferredTouchedPath(normalized)) {
767
+ return false;
768
+ }
769
+ if (normalized === 'AGENTS.md' || /^docs\/basic\//.test(normalized) || /^skills\/.+\/SKILL\.md$/.test(normalized)) {
770
+ return true;
771
+ }
772
+ if (/^(src|app|lib|server|scripts|test|tests|templates)\//.test(normalized)) {
773
+ return true;
774
+ }
775
+ return CODE_EXTENSIONS.has(path.extname(normalized).toLowerCase());
776
+ }
777
+
778
+ async function inferRecentTouchedFiles(projectRoot, options = {}) {
779
+ const limit = Math.max(1, Number(options.limit ?? 8));
780
+ const lookbackMs = Math.max(1, Number(options.lookbackMs ?? (4 * 60 * 60 * 1000)));
781
+ const nowValue = Date.now();
782
+ const collected = [];
783
+ async function walk(dir) {
784
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
785
+ for (const entry of entries) {
786
+ const fullPath = path.join(dir, entry.name);
787
+ const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join('/');
788
+ if (entry.isDirectory()) {
789
+ if (shouldIgnoreInferredTouchedPath(`${relativePath}/`)) {
790
+ continue;
791
+ }
792
+ await walk(fullPath);
793
+ continue;
794
+ }
795
+ if (!entry.isFile() || !looksLikeInferredTouchedFile(relativePath)) {
796
+ continue;
797
+ }
798
+ const stat = await fs.stat(fullPath).catch(() => null);
799
+ if (!stat) continue;
800
+ collected.push({
801
+ path: relativePath,
802
+ mtimeMs: Number(stat.mtimeMs ?? 0),
803
+ });
804
+ }
805
+ }
806
+ await walk(projectRoot);
807
+ const sorted = collected.sort((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path));
808
+ const recent = sorted.filter((file) => nowValue - file.mtimeMs <= lookbackMs);
809
+ const selected = (recent.length > 0 ? recent : sorted).slice(0, limit).map((file) => file.path);
810
+ return uniq(selected);
811
+ }
812
+
813
+ function buildSyntheticReviewSource(projectRoot, options = {}) {
814
+ const currentSignal = options.signal ? normalizeReviewSignal(projectRoot, options.signal) : null;
815
+ const recentSignals = Array.isArray(options.recentSignals)
816
+ ? options.recentSignals.map((signal) => normalizeReviewSignal(projectRoot, signal))
817
+ : [];
818
+ const touchedFiles = uniq([
819
+ ...normalizeTouchedFiles(projectRoot, options.touchedFiles),
820
+ ...(currentSignal?.touchedFiles ?? []),
821
+ ...recentSignals.flatMap((signal) => signal.touchedFiles),
822
+ ]).slice(0, 8);
823
+ const summaries = uniq([
824
+ currentSignal?.summary,
825
+ ...recentSignals.map((signal) => signal.summary),
826
+ ]).filter(Boolean).slice(0, 6);
827
+ const signalKinds = uniq([
828
+ currentSignal?.kind,
829
+ ...recentSignals.map((signal) => signal.kind),
830
+ ]).filter(Boolean).slice(0, 6);
831
+ const attentionGates = uniq([
832
+ ...(currentSignal?.attentionGates ?? []),
833
+ ...recentSignals.flatMap((signal) => signal.attentionGates ?? []),
834
+ ]);
835
+ const title = firstString(
836
+ options.title,
837
+ summaries[0],
838
+ touchedFiles[0] ? `完成态回顾 ${path.basename(touchedFiles[0])}` : null,
839
+ '已完成任务回顾',
840
+ ) ?? '已完成任务回顾';
841
+ return {
842
+ kind: 'completion-review',
843
+ sourceId: slugify(firstString(options.sourceId, title), 'completion-review'),
844
+ sourcePath: firstString(options.sourcePath, KNOWLEDGE_REVIEW_SIGNAL_LOG),
845
+ primaryPath: firstString(options.sourcePath, KNOWLEDGE_REVIEW_SIGNAL_LOG),
846
+ sourcePaths: [firstString(options.sourcePath, KNOWLEDGE_REVIEW_SIGNAL_LOG)].filter(Boolean),
847
+ title,
848
+ status: currentSignal?.ok === false || currentSignal?.productionReady === false ? 'needs-attention' : 'pass',
849
+ symptoms: summaries,
850
+ attentionGates,
851
+ correlationFields: [],
852
+ extraContextFields: [],
853
+ missingCorrelationFields: [],
854
+ eventNames: signalKinds,
855
+ rootCauseCandidates: touchedFiles.slice(0, 4).map((file) => ({
856
+ title: `复用 ${file} 中已经验证过的实现与回归模式`,
857
+ nextSteps: ['按本轮验证链路补齐最小证据,再决定是否 promote 为项目级 skill。'],
858
+ })),
859
+ evidenceSources: [
860
+ ...touchedFiles.slice(0, 6).map((file) => ({ kind: 'touched-file', path: file })),
861
+ ...signalKinds.slice(0, 4).map((kind) => ({ kind: 'review-signal', path: kind })),
862
+ ],
863
+ queryExamples: [
864
+ touchedFiles.length > 0 ? `先复看本轮改动文件:${touchedFiles.slice(0, 4).join('、')}。` : null,
865
+ signalKinds.length > 0 ? `对齐本轮验证信号:${signalKinds.join('、')}。` : null,
866
+ '把本轮触发条件、适用范围、验证步骤和典型输入输出抽成 candidate,避免只留在当前对话里。',
867
+ ].filter(Boolean),
868
+ abstractPattern: '当一轮实现已经达到可交付状态时,即使没有 turn-state,也要从最近验证信号和最近改动文件中自动抽出可复用的项目经验。',
869
+ triggers: uniq([
870
+ ...summaries,
871
+ ...signalKinds.map((kind) => `完成信号: ${kind}`),
872
+ ...touchedFiles.map((file) => `相关文件: ${file}`),
873
+ ]).slice(0, 8),
874
+ prevention: [
875
+ '任务完成后自动生成 knowledge candidate,再由维护者决定 promote、reject 或 archive。',
876
+ '即使没有 hook turn-state,也要回退到最近验证信号和最近改动文件完成后置沉淀。',
877
+ '保持验证证据、实现文件和知识草案之间的最小关联,减少下次复盘时重新拼上下文的成本。',
878
+ ],
879
+ verificationSteps: [
880
+ ...summaries,
881
+ '确认自动抽象出来的触发条件、适用范围、典型输入输出和验证步骤与本轮交付一致。',
882
+ '再次执行当前主验证命令,确认输出与知识草案描述没有偏差。',
883
+ ].filter(Boolean),
884
+ };
885
+ }
886
+
256
887
  function renderList(items, fallback) {
257
888
  const list = items.filter(Boolean);
258
889
  if (list.length === 0) {
@@ -262,22 +893,11 @@ function renderList(items, fallback) {
262
893
  }
263
894
 
264
895
  function renderKnowledgeDraftSkill({ skillName, candidate, source, relativeCandidateDir }) {
265
- const triggerItems = uniq([
266
- ...candidate.reasons,
267
- ...source.symptoms.map((item) => `症状: ${item}`),
268
- ...candidate.reviewSignals.map((signal) => {
269
- const summary = signalSummary(signal);
270
- return summary ? `${signal.kind}: ${summary}` : signal.kind;
271
- }),
272
- ]);
896
+ const abstraction = readJsonObject(candidate.abstraction) ?? {};
273
897
  const inspectItems = uniq([
274
898
  ...candidate.touchedFiles.map((file) => `\`${file}\``),
275
899
  ...source.evidenceSources.map((item) => `\`${item.path}\``),
276
900
  ]);
277
- const verificationItems = uniq([
278
- ...candidate.reviewSignals.map((signal) => signal.summary).filter(Boolean),
279
- ...source.verificationSteps,
280
- ]);
281
901
  return `---
282
902
  name: ${skillName}
283
903
  description: OpenPrd 在本轮回顾时自动生成的待确认项目经验草案。
@@ -289,9 +909,21 @@ description: OpenPrd 在本轮回顾时自动生成的待确认项目经验草
289
909
  > 候选目录:\`${relativeCandidateDir}\`
290
910
  > Promote:\`openprd quality . --learn --from ${relativeCandidateDir}\`
291
911
 
292
- ## 为什么值得沉淀
912
+ ## 触发条件
913
+
914
+ ${renderList(abstraction.triggerConditions ?? [], '本轮实现已经出现值得复用的排查或修复模式。')}
915
+
916
+ ## 适用范围
293
917
 
294
- ${renderList(triggerItems, '本轮实现已经出现值得复用的排查或修复模式。')}
918
+ ${renderList(abstraction.applicability ?? [], '当同类任务再次出现时,优先复用本轮已经验证过的实现与回归模式。')}
919
+
920
+ ## 典型输入
921
+
922
+ ${renderList(abstraction.typicalInputs ?? [], '至少带上当前任务摘要、相关文件和现有验证证据。')}
923
+
924
+ ## 典型输出
925
+
926
+ ${renderList(abstraction.typicalOutputs ?? [], '至少产出 knowledge candidate、诊断报告和可复用验证结论。')}
295
927
 
296
928
  ## 下次触发时先看什么
297
929
 
@@ -303,11 +935,11 @@ ${renderList(source.rootCauseCandidates.map((candidateItem) => candidateItem.tit
303
935
 
304
936
  ## 验证方式
305
937
 
306
- ${renderList(verificationItems, '修复后重新走一遍本轮验证链路,确认问题不再复现。')}
938
+ ${renderList(abstraction.verificationSteps ?? [], '修复后重新走一遍本轮验证链路,确认问题不再复现。')}
307
939
  `;
308
940
  }
309
941
 
310
- function buildCandidateDiagnosticReport({ candidateId, title, summary, source, touchedFiles, reviewSignals }) {
942
+ function buildCandidateDiagnosticReport({ candidateId, title, summary, source, touchedFiles, reviewSignals, abstraction }) {
311
943
  return {
312
944
  id: candidateId,
313
945
  knowledgeCandidateId: candidateId,
@@ -322,6 +954,7 @@ function buildCandidateDiagnosticReport({ candidateId, title, summary, source, t
322
954
  message: summary,
323
955
  touchedFiles,
324
956
  reviewSignals,
957
+ abstraction,
325
958
  runtimeEvents: reviewSignals.map((signal) => ({
326
959
  eventName: signal.kind,
327
960
  status: signal.ok === false || signal.productionReady === false ? 'needs-attention' : 'pass',
@@ -362,8 +995,10 @@ function buildKnowledgeCandidateMeta({
362
995
  categories,
363
996
  reasons,
364
997
  touchedFiles,
998
+ touchedFileSource,
365
999
  reviewSignals,
366
1000
  existingCandidate,
1001
+ abstraction,
367
1002
  }) {
368
1003
  return {
369
1004
  version: 1,
@@ -378,7 +1013,9 @@ function buildKnowledgeCandidateMeta({
378
1013
  categories,
379
1014
  reasons,
380
1015
  touchedFiles,
1016
+ touchedFileSource,
381
1017
  reviewSignals,
1018
+ abstraction,
382
1019
  files: {
383
1020
  candidate: candidatePath,
384
1021
  candidateDir,
@@ -390,6 +1027,8 @@ function buildKnowledgeCandidateMeta({
390
1027
 
391
1028
  export async function recordKnowledgeReviewSignal(projectRoot, signal = {}) {
392
1029
  const statePath = knowledgePath(projectRoot, OPENPRD_HARNESS_TURN_STATE);
1030
+ const normalized = normalizeReviewSignal(projectRoot, signal);
1031
+ await appendJsonl(knowledgePath(projectRoot, KNOWLEDGE_REVIEW_SIGNAL_LOG), normalized).catch(() => null);
393
1032
  if (!(await exists(statePath))) {
394
1033
  return { ok: true, recorded: false, reason: 'turn-state-missing', turnStatePath: statePath };
395
1034
  }
@@ -398,7 +1037,6 @@ export async function recordKnowledgeReviewSignal(projectRoot, signal = {}) {
398
1037
  if (!current) {
399
1038
  return { ok: true, recorded: false, reason: 'turn-state-invalid', turnStatePath: statePath };
400
1039
  }
401
- const normalized = normalizeReviewSignal(projectRoot, signal);
402
1040
  const existingSignals = Array.isArray(current.reviewSignals) ? current.reviewSignals : [];
403
1041
  const reviewSignals = [normalized, ...existingSignals.filter((item) => item?.id !== normalized.id)].slice(0, 24);
404
1042
  const touchedFiles = uniq([
@@ -440,23 +1078,29 @@ export async function recordKnowledgeReviewSignal(projectRoot, signal = {}) {
440
1078
 
441
1079
  export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
442
1080
  await ensureKnowledgeWorkspace(projectRoot);
443
- const from = options.from ?? ((await exists(knowledgePath(projectRoot, OPENPRD_HARNESS_TURN_STATE))) ? OPENPRD_HARNESS_TURN_STATE : null);
444
- if (!from) {
445
- return {
446
- ok: true,
447
- action: 'quality-knowledge-review',
448
- skipped: true,
449
- reason: 'no-review-source',
450
- };
451
- }
452
-
453
- const rawInput = await loadRawReviewInput(projectRoot, from);
454
- const resolved = await resolveQualityLearningSource(projectRoot, {
455
- from,
456
- latestReportPath: options.latestReportPath ?? null,
457
- requiredCorrelationFields: Array.isArray(options.requiredCorrelationFields) ? options.requiredCorrelationFields : [],
458
- });
459
- if (!resolved.ok) {
1081
+ const recentSignals = await readRecentKnowledgeReviewSignals(projectRoot, { limit: 24 });
1082
+ const latestQuality = await readJson(knowledgePath(projectRoot, QUALITY_LATEST_REPORT)).catch(() => null);
1083
+ const latestReportPath = firstString(options.latestReportPath, latestQuality?.jsonPath, latestQuality?.reportPath);
1084
+ const turnStateSource = (await exists(knowledgePath(projectRoot, OPENPRD_HARNESS_TURN_STATE))) ? OPENPRD_HARNESS_TURN_STATE : null;
1085
+ const from = firstString(options.from, turnStateSource, latestReportPath);
1086
+ const rawInput = from ? await loadRawReviewInput(projectRoot, from) : { sourcePath: null, raw: null };
1087
+ const resolved = from
1088
+ ? await resolveQualityLearningSource(projectRoot, {
1089
+ from,
1090
+ latestReportPath,
1091
+ requiredCorrelationFields: Array.isArray(options.requiredCorrelationFields) ? options.requiredCorrelationFields : [],
1092
+ })
1093
+ : { ok: false, error: 'no-review-source' };
1094
+ const source = resolved.ok
1095
+ ? resolved.source
1096
+ : buildSyntheticReviewSource(projectRoot, {
1097
+ signal: options.signal,
1098
+ recentSignals,
1099
+ touchedFiles: options.touchedFiles,
1100
+ sourcePath: rawInput.sourcePath ?? latestReportPath ?? KNOWLEDGE_REVIEW_SIGNAL_LOG,
1101
+ title: firstString(options.title, readJsonObject(rawInput.raw)?.title),
1102
+ });
1103
+ if (!resolved.ok && source.evidenceSources.length === 0 && source.rootCauseCandidates.length === 0) {
460
1104
  return {
461
1105
  ok: true,
462
1106
  action: 'quality-knowledge-review',
@@ -464,24 +1108,36 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
464
1108
  reason: resolved.error,
465
1109
  };
466
1110
  }
467
-
468
- const source = resolved.source;
469
1111
  const raw = readJsonObject(rawInput.raw) ?? {};
470
- const touchedFiles = uniq([
471
- ...normalizeStringList(raw.touchedFiles).map((file) => toRelativeProjectPath(projectRoot, file)),
472
- ...normalizeStringList(options.touchedFiles).map((file) => toRelativeProjectPath(projectRoot, file)),
473
- ]).filter(Boolean);
1112
+ const explicitTouchedFiles = normalizeTouchedFiles(projectRoot, options.touchedFiles);
1113
+ const optionSignal = options.signal ? normalizeReviewSignal(projectRoot, options.signal) : null;
1114
+ const rawTouchedFiles = normalizeTouchedFiles(projectRoot, raw.touchedFiles);
1115
+ const reviewContext = buildReviewContext(projectRoot, raw, {
1116
+ ...options,
1117
+ recentSignals,
1118
+ });
1119
+ let touchedFiles = reviewContext.touchedFiles;
1120
+ let touchedFileSource = explicitTouchedFiles.length > 0
1121
+ ? 'explicit'
1122
+ : optionSignal?.touchedFiles?.length
1123
+ ? 'signal'
1124
+ : rawTouchedFiles.length > 0
1125
+ ? 'review-source'
1126
+ : (recentSignals.some((signal) => signal.touchedFiles.length > 0) ? 'recent-signals' : null);
1127
+ if (touchedFiles.length === 0) {
1128
+ touchedFiles = await inferRecentTouchedFiles(projectRoot, { limit: 8 });
1129
+ if (touchedFiles.length > 0) {
1130
+ touchedFileSource = 'inferred-recent-files';
1131
+ }
1132
+ }
474
1133
  const substantiveTouchedFiles = touchedFiles.filter(isSubstantiveTouchedFile);
475
- const embeddedSignals = Array.isArray(raw.reviewSignals) ? raw.reviewSignals : [];
476
- const reviewSignals = uniq([
477
- ...embeddedSignals.map((signal) => JSON.stringify(normalizeReviewSignal(projectRoot, signal))),
478
- ...(options.signal ? [JSON.stringify(normalizeReviewSignal(projectRoot, options.signal))] : []),
479
- ]).map((entry) => JSON.parse(entry));
1134
+ const reviewSignals = reviewContext.reviewSignals;
480
1135
  const categories = buildKnowledgeCategories({ source, touchedFiles: substantiveTouchedFiles, reviewSignals });
481
1136
  const reasons = categories.map(categoryReason);
482
1137
  const hasStrongSignal = categories.length > 0
483
1138
  || source.rootCauseCandidates.length > 0
484
1139
  || source.symptoms.length > 1
1140
+ || source.kind === 'completion-review'
485
1141
  || reviewSignals.some((signal) => signal.ok === true || signal.productionReady === true);
486
1142
 
487
1143
  if (substantiveTouchedFiles.length === 0 || !hasStrongSignal) {
@@ -509,12 +1165,15 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
509
1165
  const timelinePath = path.join(candidateDir, 'timeline.json');
510
1166
  const draftSkillPath = knowledgePath(projectRoot, cjoin(KNOWLEDGE_DRAFTS_DIR, names.skillName, 'SKILL.md'));
511
1167
  const existingCandidate = await readJson(candidatePath).catch(() => null);
1168
+ const relativeCandidateDir = path.relative(projectRoot, candidateDir).split(path.sep).join('/');
1169
+ const relativeDraftSkillPath = path.relative(projectRoot, draftSkillPath).split(path.sep).join('/');
512
1170
  const reviewSummary = [
513
- `本轮修改了 ${substantiveTouchedFiles.length} 个可沉淀文件。`,
1171
+ `本轮围绕 ${substantiveTouchedFiles.length} 个可沉淀文件生成回顾。`,
514
1172
  reasons[0] ?? '这次实现已经具备项目级经验抽象价值。',
515
1173
  reviewSignals.length > 0 ? `已记录 ${reviewSignals.length} 条回顾信号。` : null,
1174
+ touchedFileSource === 'inferred-recent-files' ? '本轮 touched files 来自最近修改文件推断。' : null,
516
1175
  ].filter(Boolean).join(' ');
517
- const candidate = buildKnowledgeCandidateMeta({
1176
+ const draftCandidate = buildKnowledgeCandidateMeta({
518
1177
  projectRoot,
519
1178
  candidateId,
520
1179
  candidatePath,
@@ -526,10 +1185,23 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
526
1185
  categories,
527
1186
  reasons,
528
1187
  touchedFiles: substantiveTouchedFiles,
1188
+ touchedFileSource,
529
1189
  reviewSignals,
530
1190
  existingCandidate: readJsonObject(existingCandidate) ?? null,
1191
+ abstraction: null,
531
1192
  });
532
- const relativeCandidateDir = path.relative(projectRoot, candidateDir).split(path.sep).join('/');
1193
+ const abstraction = buildKnowledgeAbstraction({
1194
+ candidate: draftCandidate,
1195
+ source,
1196
+ touchedFiles: substantiveTouchedFiles,
1197
+ reviewSignals,
1198
+ relativeCandidateDir,
1199
+ relativeDraftSkillPath,
1200
+ });
1201
+ const candidate = {
1202
+ ...draftCandidate,
1203
+ abstraction,
1204
+ };
533
1205
  await writeJson(candidatePath, candidate);
534
1206
  await writeJson(diagnosticReportPath, buildCandidateDiagnosticReport({
535
1207
  candidateId,
@@ -538,6 +1210,7 @@ export async function reviewKnowledgeWorkspace(projectRoot, options = {}) {
538
1210
  source,
539
1211
  touchedFiles: substantiveTouchedFiles,
540
1212
  reviewSignals,
1213
+ abstraction,
541
1214
  }));
542
1215
  await writeJson(rootCausePath, source.rootCauseCandidates.length > 0 ? source.rootCauseCandidates : substantiveTouchedFiles.map((file) => ({ title: `Inspect ${file}` })));
543
1216
  await writeJson(timelinePath, reviewSignals.map((signal) => ({
@@ -605,6 +1278,438 @@ function candidateIdFromSourcePath(projectRoot, sourcePath) {
605
1278
  return match ? match[1] : null;
606
1279
  }
607
1280
 
1281
+ function candidateIdFromPath(projectRoot, candidatePath) {
1282
+ const direct = candidateIdFromSourcePath(projectRoot, candidatePath);
1283
+ if (direct) return direct;
1284
+ const basename = path.basename(String(candidatePath ?? ''));
1285
+ return basename && basename !== 'candidate.json' ? basename : null;
1286
+ }
1287
+
1288
+ async function readCandidateById(projectRoot, candidateId) {
1289
+ if (!candidateId) return null;
1290
+ const candidatePath = knowledgePath(projectRoot, cjoin(KNOWLEDGE_CANDIDATES_DIR, candidateId, 'candidate.json'));
1291
+ const candidate = await readJson(candidatePath).catch(() => null);
1292
+ if (!candidate) return null;
1293
+ return {
1294
+ ...candidate,
1295
+ candidateId: candidate.candidateId ?? candidate.id ?? candidateId,
1296
+ status: normalizeCandidateStatus(candidate.status),
1297
+ files: {
1298
+ ...(candidate.files ?? {}),
1299
+ candidate: candidate.files?.candidate ?? candidatePath,
1300
+ candidateDir: candidate.files?.candidateDir ?? path.dirname(candidatePath),
1301
+ },
1302
+ };
1303
+ }
1304
+
1305
+ function candidateIndexEntry(projectRoot, candidate, patch = {}) {
1306
+ const candidateId = candidate.candidateId ?? candidate.id ?? patch.candidateId;
1307
+ const candidatePath = candidate.files?.candidate
1308
+ ?? knowledgePath(projectRoot, cjoin(KNOWLEDGE_CANDIDATES_DIR, candidateId, 'candidate.json'));
1309
+ return {
1310
+ candidateId,
1311
+ status: normalizeCandidateStatus(candidate.status),
1312
+ path: candidatePath,
1313
+ sourceKind: candidate.sourceKind ?? null,
1314
+ sourceRef: candidate.sourceRef ?? null,
1315
+ title: candidate.title ?? candidateId,
1316
+ draftSkillPath: candidate.files?.draftSkill ?? null,
1317
+ ...patch,
1318
+ };
1319
+ }
1320
+
1321
+ async function syncKnowledgeCandidateIndex(projectRoot, candidate, patch = {}) {
1322
+ const index = await readKnowledgeIndex(projectRoot);
1323
+ const entry = candidateIndexEntry(projectRoot, candidate, patch);
1324
+ await writeKnowledgeIndex(projectRoot, {
1325
+ ...index,
1326
+ candidates: upsertBy(index.candidates, 'candidateId', entry),
1327
+ drafts: entry.draftSkillPath
1328
+ ? upsertBy(index.drafts, 'skillName', {
1329
+ skillName: path.basename(path.dirname(entry.draftSkillPath)),
1330
+ path: entry.draftSkillPath,
1331
+ candidateId: entry.candidateId,
1332
+ status: entry.status,
1333
+ })
1334
+ : index.drafts,
1335
+ });
1336
+ return entry;
1337
+ }
1338
+
1339
+ function mergeCandidateWithIndex(candidate, indexEntry, projectRoot) {
1340
+ const candidateId = candidate?.candidateId ?? candidate?.id ?? indexEntry?.candidateId ?? candidateIdFromPath(projectRoot, indexEntry?.path);
1341
+ const status = resolveCandidateStatus(candidate?.status, indexEntry?.status);
1342
+ const candidatePath = candidate?.files?.candidate
1343
+ ?? indexEntry?.path
1344
+ ?? (candidateId ? knowledgePath(projectRoot, cjoin(KNOWLEDGE_CANDIDATES_DIR, candidateId, 'candidate.json')) : null);
1345
+ const draftSkillPath = candidate?.files?.draftSkill ?? indexEntry?.draftSkillPath ?? null;
1346
+ return {
1347
+ ...(indexEntry ?? {}),
1348
+ ...(candidate ?? {}),
1349
+ candidateId,
1350
+ status,
1351
+ statusGroup: candidateStatusGroup(status),
1352
+ pending: isPendingKnowledgeCandidateStatus(status),
1353
+ reviewed: isReviewedKnowledgeCandidateStatus(status),
1354
+ path: candidatePath,
1355
+ draftSkillPath,
1356
+ title: candidate?.title ?? indexEntry?.title ?? candidateId,
1357
+ sourceKind: candidate?.sourceKind ?? indexEntry?.sourceKind ?? null,
1358
+ sourceRef: candidate?.sourceRef ?? indexEntry?.sourceRef ?? null,
1359
+ files: {
1360
+ ...(candidate?.files ?? {}),
1361
+ candidate: candidatePath,
1362
+ candidateDir: candidate?.files?.candidateDir ?? (candidatePath ? path.dirname(candidatePath) : null),
1363
+ draftSkill: draftSkillPath,
1364
+ },
1365
+ };
1366
+ }
1367
+
1368
+ function buildCandidateCounts(candidates) {
1369
+ const counts = {
1370
+ total: candidates.length,
1371
+ pending: 0,
1372
+ promoted: 0,
1373
+ rejected: 0,
1374
+ archived: 0,
1375
+ reviewed: 0,
1376
+ byStatus: {},
1377
+ };
1378
+ for (const candidate of candidates) {
1379
+ counts.byStatus[candidate.status] = (counts.byStatus[candidate.status] ?? 0) + 1;
1380
+ counts[candidate.statusGroup] = (counts[candidate.statusGroup] ?? 0) + 1;
1381
+ }
1382
+ return counts;
1383
+ }
1384
+
1385
+ function buildKnowledgeMatchQuery(options = {}) {
1386
+ const candidateFields = [
1387
+ options.message,
1388
+ options.prompt,
1389
+ options.promptPreview,
1390
+ options.recommendationTitle,
1391
+ options.recommendationReason,
1392
+ options.activeChange,
1393
+ options.nextTaskTitle,
1394
+ ...(normalizeStringList(options.relatedFiles)),
1395
+ ].filter(Boolean);
1396
+ const text = candidateFields.join('\n');
1397
+ return {
1398
+ text,
1399
+ normalizedText: normalizeSearchText(text),
1400
+ tokens: normalizeSearchTokens(text),
1401
+ };
1402
+ }
1403
+
1404
+ function scoreKnowledgeSkillMatch(skill, query) {
1405
+ const fileHints = uniq([
1406
+ ...skill.touchedFiles,
1407
+ ...skill.touchedFiles.map((file) => path.basename(file)),
1408
+ ...skill.evidencePaths,
1409
+ ...skill.evidencePaths.map((file) => path.basename(file)),
1410
+ ]);
1411
+ const fields = [
1412
+ skill.skillName,
1413
+ skill.description,
1414
+ skill.summary,
1415
+ ...skill.categories,
1416
+ ...skill.triggerHints,
1417
+ ...skill.rootCauseLabels,
1418
+ ...fileHints,
1419
+ ];
1420
+ const result = scoreQueryAgainstFields(query.normalizedText, query.tokens, fields);
1421
+ return {
1422
+ score: result.score,
1423
+ matchedOn: result.matchedOn,
1424
+ matchSummary: result.matchedOn.length > 0
1425
+ ? `命中 ${result.matchedOn.slice(0, 3).join(' / ')}`
1426
+ : '根据当前上下文自动命中',
1427
+ };
1428
+ }
1429
+
1430
+ export async function resolveKnowledgeSkillMatches(projectRoot, options = {}) {
1431
+ await ensureKnowledgeWorkspace(projectRoot);
1432
+ const { skills } = await hydrateKnowledgeSkills(projectRoot);
1433
+ const query = buildKnowledgeMatchQuery(options);
1434
+ if (!query.normalizedText) {
1435
+ return {
1436
+ ok: true,
1437
+ action: 'knowledge-match',
1438
+ projectRoot,
1439
+ query: '',
1440
+ matched: [],
1441
+ summary: {
1442
+ matched: 0,
1443
+ },
1444
+ };
1445
+ }
1446
+ const matches = skills
1447
+ .map((skill) => {
1448
+ const match = scoreKnowledgeSkillMatch(skill, query);
1449
+ return match.score > 0
1450
+ ? {
1451
+ ...skill,
1452
+ score: match.score,
1453
+ matchedOn: match.matchedOn,
1454
+ matchSummary: match.matchSummary,
1455
+ }
1456
+ : null;
1457
+ })
1458
+ .filter(Boolean)
1459
+ .sort((left, right) => right.score - left.score || left.skillName.localeCompare(right.skillName))
1460
+ .slice(0, Math.max(1, Number(options.limit ?? 3)));
1461
+ return {
1462
+ ok: true,
1463
+ action: 'knowledge-match',
1464
+ projectRoot,
1465
+ query: trimPreview(query.text, 320),
1466
+ matched: matches,
1467
+ summary: {
1468
+ matched: matches.length,
1469
+ },
1470
+ };
1471
+ }
1472
+
1473
+ function adoptionStageField(stage) {
1474
+ if (stage === 'referenced') {
1475
+ return { count: 'referencedCount', at: 'lastReferencedAt' };
1476
+ }
1477
+ if (stage === 'injected') {
1478
+ return { count: 'injectedCount', at: 'lastInjectedAt' };
1479
+ }
1480
+ return { count: 'hitCount', at: 'lastHitAt' };
1481
+ }
1482
+
1483
+ export async function recordKnowledgeSkillAdoption(projectRoot, options = {}) {
1484
+ const requestedStages = uniq(normalizeStringList(options.stages ?? [options.stage]))
1485
+ .filter((stage) => ['hit', 'referenced', 'injected'].includes(stage));
1486
+ if (requestedStages.length === 0) {
1487
+ return {
1488
+ ok: true,
1489
+ action: 'knowledge-adoption',
1490
+ projectRoot,
1491
+ updated: 0,
1492
+ stages: [],
1493
+ summary: buildKnowledgeAdoptionSummary([]),
1494
+ };
1495
+ }
1496
+ const matchedSkills = normalizeArray(options.matches)
1497
+ .map((skill) => normalizeSkillIndexEntry(skill))
1498
+ .filter((skill) => skill.skillName);
1499
+ if (matchedSkills.length === 0) {
1500
+ const { skills } = await hydrateKnowledgeSkills(projectRoot);
1501
+ return {
1502
+ ok: true,
1503
+ action: 'knowledge-adoption',
1504
+ projectRoot,
1505
+ updated: 0,
1506
+ stages: requestedStages,
1507
+ summary: buildKnowledgeAdoptionSummary(skills),
1508
+ };
1509
+ }
1510
+
1511
+ const { index, skills } = await hydrateKnowledgeSkills(projectRoot);
1512
+ const nowValue = timestamp();
1513
+ const skillNameSet = new Set(matchedSkills.map((skill) => skill.skillName));
1514
+ const nextSkills = [];
1515
+ let updated = 0;
1516
+ for (const skill of skills) {
1517
+ if (!skillNameSet.has(skill.skillName)) {
1518
+ nextSkills.push(skill);
1519
+ continue;
1520
+ }
1521
+ const match = matchedSkills.find((item) => item.skillName === skill.skillName) ?? skill;
1522
+ const adoption = normalizeSkillAdoption(skill.adoption);
1523
+ for (const stage of requestedStages) {
1524
+ const field = adoptionStageField(stage);
1525
+ adoption[field.count] += 1;
1526
+ adoption[field.at] = nowValue;
1527
+ adoption.lastSource = firstString(options.source, adoption.lastSource);
1528
+ adoption.recentEvents = [
1529
+ {
1530
+ at: nowValue,
1531
+ stage,
1532
+ source: firstString(options.source),
1533
+ sessionId: firstString(options.sessionId),
1534
+ promptPreview: trimPreview(options.promptPreview),
1535
+ matchSummary: firstString(match.matchSummary),
1536
+ matchedOn: normalizeStringList(match.matchedOn).slice(0, 4),
1537
+ },
1538
+ ...adoption.recentEvents,
1539
+ ].slice(0, 12);
1540
+ await appendJsonl(knowledgePath(projectRoot, KNOWLEDGE_ADOPTION_LOG), {
1541
+ version: 1,
1542
+ at: nowValue,
1543
+ stage,
1544
+ skillName: skill.skillName,
1545
+ source: firstString(options.source),
1546
+ sessionId: firstString(options.sessionId),
1547
+ promptPreview: trimPreview(options.promptPreview),
1548
+ matchSummary: firstString(match.matchSummary),
1549
+ matchedOn: normalizeStringList(match.matchedOn).slice(0, 6),
1550
+ });
1551
+ }
1552
+ nextSkills.push({
1553
+ ...skill,
1554
+ adoption,
1555
+ });
1556
+ updated += 1;
1557
+ }
1558
+ await writeKnowledgeIndex(projectRoot, {
1559
+ ...index,
1560
+ skills: nextSkills,
1561
+ });
1562
+ return {
1563
+ ok: true,
1564
+ action: 'knowledge-adoption',
1565
+ projectRoot,
1566
+ updated,
1567
+ stages: requestedStages,
1568
+ summary: buildKnowledgeAdoptionSummary(nextSkills),
1569
+ };
1570
+ }
1571
+
1572
+ export async function listKnowledgeCandidates(projectRoot, options = {}) {
1573
+ await ensureKnowledgeWorkspace(projectRoot);
1574
+ const index = await readKnowledgeIndex(projectRoot);
1575
+ const byId = new Map();
1576
+ for (const entry of index.candidates) {
1577
+ if (!entry?.candidateId) continue;
1578
+ byId.set(entry.candidateId, mergeCandidateWithIndex(null, entry, projectRoot));
1579
+ }
1580
+ const candidateRoot = knowledgePath(projectRoot, KNOWLEDGE_CANDIDATES_DIR);
1581
+ const dirs = await fs.readdir(candidateRoot, { withFileTypes: true }).catch(() => []);
1582
+ for (const entry of dirs) {
1583
+ if (!entry.isDirectory()) continue;
1584
+ const candidate = await readCandidateById(projectRoot, entry.name);
1585
+ if (!candidate) continue;
1586
+ byId.set(candidate.candidateId, mergeCandidateWithIndex(candidate, byId.get(candidate.candidateId), projectRoot));
1587
+ }
1588
+ const all = [...byId.values()].sort((left, right) => {
1589
+ const leftAt = left.updatedAt ?? left.reviewedAt ?? left.createdAt ?? '';
1590
+ const rightAt = right.updatedAt ?? right.reviewedAt ?? right.createdAt ?? '';
1591
+ return String(rightAt).localeCompare(String(leftAt));
1592
+ });
1593
+ const status = normalizeCandidateStatus(options.status ?? 'pending-review');
1594
+ const filtered = status === 'all'
1595
+ ? all
1596
+ : all.filter((candidate) => normalizeCandidateStatus(candidate.status) === status);
1597
+ const pending = all.filter((candidate) => candidate.pending);
1598
+ const reviewed = all.filter((candidate) => !candidate.pending);
1599
+ return {
1600
+ ok: true,
1601
+ action: 'knowledge-candidates',
1602
+ projectRoot,
1603
+ status: options.status ?? 'pending-review',
1604
+ candidates: filtered,
1605
+ pending,
1606
+ reviewed,
1607
+ counts: buildCandidateCounts(all),
1608
+ files: {
1609
+ knowledgeIndex: knowledgePath(projectRoot, KNOWLEDGE_INDEX),
1610
+ candidatesDir: candidateRoot,
1611
+ },
1612
+ };
1613
+ }
1614
+
1615
+ async function updateKnowledgeCandidateStatus(projectRoot, options = {}) {
1616
+ await ensureKnowledgeWorkspace(projectRoot);
1617
+ const candidateId = options.id ?? candidateIdFromPath(projectRoot, options.path);
1618
+ if (!candidateId) {
1619
+ return {
1620
+ ok: false,
1621
+ action: `knowledge-${options.action ?? 'update'}`,
1622
+ projectRoot,
1623
+ errors: ['Knowledge candidate id is required.'],
1624
+ };
1625
+ }
1626
+ const candidate = await readCandidateById(projectRoot, candidateId);
1627
+ if (!candidate) {
1628
+ return {
1629
+ ok: false,
1630
+ action: `knowledge-${options.action ?? 'update'}`,
1631
+ projectRoot,
1632
+ candidateId,
1633
+ errors: [`Knowledge candidate not found: ${candidateId}`],
1634
+ };
1635
+ }
1636
+ const status = normalizeCandidateStatus(options.status);
1637
+ const nowValue = timestamp();
1638
+ const reviewReason = firstString(options.reason, options.notes, options.reviewDecision);
1639
+ const patch = {
1640
+ status,
1641
+ updatedAt: nowValue,
1642
+ reviewedAt: status === 'pending-review' ? candidate.reviewedAt ?? null : nowValue,
1643
+ reviewedBy: status === 'pending-review' ? candidate.reviewedBy ?? null : firstString(options.reviewedBy, 'codex'),
1644
+ reviewDecision: firstString(options.reviewDecision, reviewReason, status),
1645
+ reviewReason: reviewReason ?? null,
1646
+ };
1647
+ if (status === 'rejected') {
1648
+ patch.rejectedAt = nowValue;
1649
+ }
1650
+ if (status === 'archived') {
1651
+ patch.archivedAt = nowValue;
1652
+ }
1653
+ if (status === 'pending-review') {
1654
+ patch.restoredAt = nowValue;
1655
+ patch.reviewDecision = null;
1656
+ patch.reviewReason = null;
1657
+ }
1658
+ const nextCandidate = {
1659
+ ...candidate,
1660
+ ...patch,
1661
+ files: candidate.files,
1662
+ };
1663
+ const candidatePath = nextCandidate.files.candidate;
1664
+ await writeJson(candidatePath, nextCandidate);
1665
+ const indexPatch = {
1666
+ ...patch,
1667
+ reviewedAt: nextCandidate.reviewedAt,
1668
+ reviewedBy: nextCandidate.reviewedBy,
1669
+ reviewDecision: nextCandidate.reviewDecision,
1670
+ reviewReason: nextCandidate.reviewReason,
1671
+ };
1672
+ const entry = await syncKnowledgeCandidateIndex(projectRoot, nextCandidate, indexPatch);
1673
+ return {
1674
+ ok: true,
1675
+ action: `knowledge-${options.action ?? status}`,
1676
+ projectRoot,
1677
+ candidateId,
1678
+ candidate: mergeCandidateWithIndex(nextCandidate, entry, projectRoot),
1679
+ files: {
1680
+ candidate: candidatePath,
1681
+ knowledgeIndex: knowledgePath(projectRoot, KNOWLEDGE_INDEX),
1682
+ },
1683
+ };
1684
+ }
1685
+
1686
+ export async function rejectKnowledgeCandidate(projectRoot, options = {}) {
1687
+ return updateKnowledgeCandidateStatus(projectRoot, {
1688
+ ...options,
1689
+ action: 'reject',
1690
+ status: 'rejected',
1691
+ reviewDecision: firstString(options.reason, options.notes, 'rejected'),
1692
+ });
1693
+ }
1694
+
1695
+ export async function archiveKnowledgeCandidate(projectRoot, options = {}) {
1696
+ return updateKnowledgeCandidateStatus(projectRoot, {
1697
+ ...options,
1698
+ action: 'archive',
1699
+ status: 'archived',
1700
+ reviewDecision: firstString(options.reason, options.notes, 'archived'),
1701
+ });
1702
+ }
1703
+
1704
+ export async function restoreKnowledgeCandidate(projectRoot, options = {}) {
1705
+ return updateKnowledgeCandidateStatus(projectRoot, {
1706
+ ...options,
1707
+ action: 'restore',
1708
+ status: 'pending-review',
1709
+ reviewDecision: null,
1710
+ });
1711
+ }
1712
+
608
1713
  export async function markKnowledgeCandidatePromoted(projectRoot, options = {}) {
609
1714
  await ensureKnowledgeWorkspace(projectRoot);
610
1715
  const candidateId = candidateIdFromSourcePath(projectRoot, options.sourcePath)
@@ -619,36 +1724,26 @@ export async function markKnowledgeCandidatePromoted(projectRoot, options = {})
619
1724
  if (!candidate) {
620
1725
  return { ok: true, updated: false };
621
1726
  }
622
- await writeJson(candidatePath, {
1727
+ const nextCandidate = {
623
1728
  ...candidate,
1729
+ candidateId: candidate.candidateId ?? candidate.id ?? candidateId,
624
1730
  status: 'promoted',
625
1731
  promotedAt: timestamp(),
626
1732
  promotedSkillPath: options.skillPath ?? null,
627
1733
  promotedIncidentPath: options.incidentPath ?? null,
628
1734
  promotedPatternPath: options.patternPath ?? null,
629
1735
  updatedAt: timestamp(),
630
- });
631
- const index = await readKnowledgeIndex(projectRoot);
632
- await writeKnowledgeIndex(projectRoot, {
633
- ...index,
634
- candidates: upsertBy(index.candidates, 'candidateId', {
635
- ...(index.candidates.find((item) => item.candidateId === candidateId) ?? {}),
636
- candidateId,
637
- status: 'promoted',
638
- path: candidatePath,
639
- draftSkillPath: candidate.files?.draftSkill ?? null,
640
- sourceKind: candidate.sourceKind ?? null,
641
- sourceRef: candidate.sourceRef ?? null,
642
- title: candidate.title ?? candidateId,
643
- }),
644
- drafts: candidate.files?.draftSkill
645
- ? upsertBy(index.drafts, 'skillName', {
646
- skillName: path.basename(path.dirname(candidate.files.draftSkill)),
647
- path: candidate.files.draftSkill,
648
- candidateId,
649
- status: 'promoted',
650
- })
651
- : index.drafts,
1736
+ files: {
1737
+ ...(candidate.files ?? {}),
1738
+ candidate: candidate.files?.candidate ?? candidatePath,
1739
+ candidateDir: candidate.files?.candidateDir ?? path.dirname(candidatePath),
1740
+ },
1741
+ };
1742
+ await writeJson(candidatePath, nextCandidate);
1743
+ await syncKnowledgeCandidateIndex(projectRoot, nextCandidate, {
1744
+ status: 'promoted',
1745
+ promotedAt: nextCandidate.promotedAt,
1746
+ promotedSkillPath: nextCandidate.promotedSkillPath,
652
1747
  });
653
1748
  return {
654
1749
  ok: true,
@@ -661,8 +1756,14 @@ export async function markKnowledgeCandidatePromoted(projectRoot, options = {})
661
1756
  export {
662
1757
  deriveKnowledgeNames,
663
1758
  ensureKnowledgeWorkspace,
1759
+ isPendingKnowledgeCandidateStatus,
1760
+ isReviewedKnowledgeCandidateStatus,
1761
+ KNOWLEDGE_ADOPTION_LOG,
664
1762
  KNOWLEDGE_CANDIDATES_DIR,
665
1763
  KNOWLEDGE_DRAFTS_DIR,
666
1764
  KNOWLEDGE_INDEX,
1765
+ KNOWLEDGE_SKILLS_DIR,
1766
+ normalizeCandidateStatus,
667
1767
  OPENPRD_HARNESS_TURN_STATE,
668
- };
1768
+ buildKnowledgeAdoptionSummary,
1769
+ };