@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.
- package/.openprd/README.md +43 -69
- package/.openprd/README_EN.md +84 -0
- package/.openprd/benchmarks/index.md +7 -0
- package/.openprd/benchmarks/sources.yaml +25 -3
- package/.openprd/discovery/config.json +16 -2
- package/.openprd/engagements/active/flows.md +19 -14
- package/.openprd/engagements/active/handoff.md +11 -4
- package/.openprd/engagements/active/prd.md +99 -71
- package/.openprd/engagements/active/review.html +4 -4
- package/.openprd/engagements/active/roles.md +9 -8
- package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +4 -4
- package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +18 -0
- package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +18 -0
- package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +18 -0
- package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +18 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +78 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +129 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +41 -0
- package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +14 -0
- package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +49 -0
- package/.openprd/knowledge/index.json +44 -4
- package/.openprd/reviews/v0001.html +195 -129
- package/.openprd/reviews/v0002.html +1150 -0
- package/.openprd/reviews/v0003.html +1150 -0
- package/.openprd/reviews/v0004.html +1150 -0
- package/.openprd/reviews/v0005.html +1150 -0
- package/.openprd/standards/config.json +12 -9
- package/.openprd/state/changes.json +17 -2
- package/.openprd/state/current.json +399 -63
- package/.openprd/state/release-ledger.json +344 -0
- package/.openprd/state/version-index.json +52 -0
- package/.openprd/state/versions/v0002.json +264 -0
- package/.openprd/state/versions/v0002.md +183 -0
- package/.openprd/state/versions/v0003.json +269 -0
- package/.openprd/state/versions/v0003.md +188 -0
- package/.openprd/state/versions/v0004.json +274 -0
- package/.openprd/state/versions/v0004.md +193 -0
- package/.openprd/state/versions/v0005.json +299 -0
- package/.openprd/state/versions/v0005.md +189 -0
- package/.openprd/templates/agent/intake.md +5 -4
- package/.openprd/templates/b2b/intake.md +5 -4
- package/.openprd/templates/base/intake.md +10 -4
- package/.openprd/templates/company/README.md +9 -7
- package/.openprd/templates/company/README_EN.md +12 -0
- package/.openprd/templates/consumer/intake.md +5 -4
- package/.openprd/templates/industry/README.md +12 -10
- package/.openprd/templates/industry/README_EN.md +18 -0
- package/.openprd/templates/project/README.md +11 -9
- package/.openprd/templates/project/README_EN.md +16 -0
- package/.openprd/templates/session/README.md +11 -9
- package/.openprd/templates/session/README_EN.md +16 -0
- package/AGENTS.md +12 -8
- package/README.md +399 -438
- package/README_CN.md +4 -578
- package/README_EN.md +850 -0
- package/docs/assets/openprd-requirement-routing-en.png +0 -0
- package/docs/assets/openprd-requirement-routing-en.svg +102 -0
- package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
- package/docs/assets/openprd-requirement-routing-zh.png +0 -0
- package/docs/assets/openprd-requirement-routing-zh.svg +102 -0
- package/package.json +6 -2
- package/scripts/dev-check-wrapup-copy.mjs +110 -0
- package/scripts/openprd-github-release-notes.mjs +99 -0
- package/scripts/quality-perf-check.mjs +203 -0
- package/skills/openprd-benchmark-router/SKILL.md +1 -0
- package/skills/openprd-benchmark-router/references/benchmark-sources.md +1 -0
- package/skills/openprd-benchmark-router/references/source-policy.md +2 -0
- package/skills/openprd-discovery-loop/SKILL.md +2 -2
- package/skills/openprd-harness/SKILL.md +46 -24
- package/skills/openprd-harness/references/workflow-gates.md +15 -0
- package/skills/openprd-quality/SKILL.md +10 -4
- package/skills/openprd-requirement-intake/SKILL.md +31 -20
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -6
- package/skills/openprd-requirement-intake/references/routing-rubric.md +10 -2
- package/skills/openprd-router/SKILL.md +2 -2
- package/skills/openprd-shared/SKILL.md +51 -23
- package/skills/openprd-standards/SKILL.md +2 -1
- package/src/agent-integration.js +265 -65
- package/src/benchmark/constants.js +107 -0
- package/src/benchmark/operations.js +235 -0
- package/src/benchmark/registry.js +64 -0
- package/src/benchmark/render.js +115 -0
- package/src/benchmark/source.js +617 -0
- package/src/benchmark/storage.js +121 -0
- package/src/benchmark/verify.js +235 -0
- package/src/benchmark.js +50 -851
- package/src/change-summary.js +339 -0
- package/src/cli/args.js +67 -6
- package/src/cli/basic-print.js +365 -0
- package/src/cli/benchmark-print.js +91 -0
- package/src/cli/change-print.js +221 -0
- package/src/cli/doctor-print.js +268 -0
- package/src/cli/growth-print.js +176 -0
- package/src/cli/print.js +73 -1384
- package/src/cli/quality-print.js +284 -0
- package/src/cli/run-print.js +297 -0
- package/src/cli/shared-print.js +127 -0
- package/src/cli/workflow-print.js +195 -0
- package/src/codex-hook-runner-template.mjs +639 -117
- package/src/codex-runtime.js +324 -0
- package/src/dev-standards.js +178 -5
- package/src/diagram-core.js +5 -5
- package/src/discovery.js +2 -1
- package/src/execution-strategy.js +369 -0
- package/src/fleet.js +4 -0
- package/src/github-release.js +156 -0
- package/src/growth.js +311 -13
- package/src/html-artifact-utils.js +25 -0
- package/src/html-artifacts.js +157 -1596
- package/src/knowledge.js +1176 -75
- package/src/language-policy.js +2 -112
- package/src/learning-html-artifact.js +1031 -0
- package/src/learning-review.js +3 -2
- package/src/loop.js +280 -9
- package/src/openprd.js +341 -38
- package/src/openspec/change-validate.js +0 -9
- package/src/openspec/execute.js +79 -3
- package/src/openspec/generate.js +33 -20
- package/src/openspec/tasks.js +33 -2
- package/src/prd-core.js +10 -9
- package/src/product-type-copy.js +69 -0
- package/src/quality-html-artifact.js +108 -9
- package/src/quality-learning.js +30 -0
- package/src/quality-visual-review.js +237 -0
- package/src/quality.js +329 -43
- package/src/registry-hygiene.js +54 -0
- package/src/release-ledger.js +413 -0
- package/src/review-presentation.js +12 -6
- package/src/run-harness.js +722 -48
- package/src/session-binding.js +40 -3
- package/src/session-registry.js +159 -0
- package/src/standards.js +5 -3
- package/src/test-strategy.js +386 -0
- package/src/visual-compare.js +915 -34
- package/src/work-unit-migration.js +5 -1
- package/src/workspace-core.js +343 -19
- 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:
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
+
};
|