@zhuan-ai/zhuanspec 2.16.6 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/hooks.js +10 -0
- package/dist/cli/index.js +4 -0
- package/dist/commands/knowledge.d.ts +12 -2
- package/dist/commands/knowledge.js +277 -3
- package/dist/core/hooks/context-load-hook.js +2 -0
- package/dist/core/hooks/knowledge-index.d.ts +54 -2
- package/dist/core/hooks/knowledge-index.js +247 -11
- package/dist/core/hooks/knowledge-usage.d.ts +61 -0
- package/dist/core/hooks/knowledge-usage.js +192 -0
- package/dist/core/hooks/post-archive.js +128 -29
- package/dist/core/hooks/review-hooks.js +198 -43
- package/dist/core/hooks/summarize.js +93 -1
- package/dist/core/hooks/user-input-hook.d.ts +12 -0
- package/dist/core/hooks/user-input-hook.js +38 -0
- package/dist/core/init.js +10 -0
- package/dist/core/metrics/knowledge-funnel.d.ts +49 -0
- package/dist/core/metrics/knowledge-funnel.js +135 -0
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +29 -10
- package/dist/core/templates/codex-hooks-template.js +5 -0
- package/dist/core/templates/slash-command-templates.js +91 -12
- package/package.json +22 -20
|
@@ -3,7 +3,7 @@ import { FileSystemUtils } from '../../utils/file-system.js';
|
|
|
3
3
|
import { readdirSync, rmSync } from 'fs';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { getBeijingTime, getBeijingTimeForFilename } from './record-progress.js';
|
|
6
|
-
import { scanKnowledgeEntries, rebuildIndexContent, } from './knowledge-index.js';
|
|
6
|
+
import { scanKnowledgeEntries, rebuildIndexContent, parseDeprecatesDeclaration, parseFrontMatter, findEntryFileById, setEntryStatus, } from './knowledge-index.js';
|
|
7
7
|
export async function postArchiveHook(options) {
|
|
8
8
|
const output = await runPostArchiveHook(options.archived || '');
|
|
9
9
|
if (options.json) {
|
|
@@ -33,6 +33,15 @@ async function runPostArchiveHook(archivedChange) {
|
|
|
33
33
|
if (knowledgeResult.implicitConventions > 0) {
|
|
34
34
|
console.log(chalk.gray(` - 隐式约定:${knowledgeResult.implicitConventions} 条`));
|
|
35
35
|
}
|
|
36
|
+
// 1.5 失效闭环:解析归档文档 deprecates 声明,把对应知识标 deprecated(必须在 reindex 之前,
|
|
37
|
+
// reindex 时 scanKnowledgeEntries 才能读到新 status,rebuildIndexContent 才会把它移出三分区)。
|
|
38
|
+
const deprecation = await markDeprecatedKnowledge(zhuanspecDir, archivedChange, knowledgeDir);
|
|
39
|
+
if (deprecation.deprecatedFiles.length > 0) {
|
|
40
|
+
console.log(chalk.gray(` → 标记失效知识:${deprecation.deprecatedFiles.length} 条 deprecated`));
|
|
41
|
+
}
|
|
42
|
+
if (deprecation.notFoundIds.length > 0) {
|
|
43
|
+
console.log(chalk.yellow(` ⚠ ${deprecation.notFoundIds.length} 条 deprecates 声明未找到对应知识:${deprecation.notFoundIds.join(', ')}`));
|
|
44
|
+
}
|
|
36
45
|
// 2. Reindex 三分区(修复:archive 前的 refreshBusinessTemplateFromRemote 会用远端 index.md
|
|
37
46
|
// 覆盖本地,导致 review/knowledge skill 期间 appendIndexEntry 的登记行丢失。
|
|
38
47
|
// 这里基于本地 troubleshooting/ best-practices/ implicit-conventions/ 实际文件重建三分区,
|
|
@@ -47,6 +56,11 @@ async function runPostArchiveHook(archivedChange) {
|
|
|
47
56
|
console.log(chalk.gray(` - troubleshooting=${scan.entries.filter((e) => e.category === 'troubleshooting').length}, ` +
|
|
48
57
|
`best-practices=${scan.entries.filter((e) => e.category === 'best-practices').length}, ` +
|
|
49
58
|
`implicit-conventions=${scan.entries.filter((e) => e.category === 'implicit-conventions').length}`));
|
|
59
|
+
if (scan.errors.length > 0) {
|
|
60
|
+
console.log(chalk.red(` ✗ Front Matter 严重缺失 ${scan.errors.length} 条(YAML 格式缺 id/title):`));
|
|
61
|
+
for (const e of scan.errors)
|
|
62
|
+
console.log(chalk.red(` ${e}`));
|
|
63
|
+
}
|
|
50
64
|
if (scan.warnings.length > 0) {
|
|
51
65
|
console.log(chalk.yellow(` ⚠ Front Matter 不完整 ${scan.warnings.length} 条,建议补齐关键词/适用场景`));
|
|
52
66
|
}
|
|
@@ -61,17 +75,50 @@ async function runPostArchiveHook(archivedChange) {
|
|
|
61
75
|
console.log(chalk.gray(` - 已清理:${tempFilesCleaned} 个`));
|
|
62
76
|
}
|
|
63
77
|
const totalEntries = knowledgeResult.bestPractices + knowledgeResult.implicitConventions;
|
|
64
|
-
console.log(chalk.green(`✅
|
|
78
|
+
console.log(chalk.green(`✅ 知识草稿生成完成:共 ${totalEntries} 条候选`));
|
|
79
|
+
// 若有待精炼草稿,注入精炼提示让主 agent 调 @skill:zhuanspec:knowledge 处理
|
|
80
|
+
const additionalContext = totalEntries > 0
|
|
81
|
+
? buildRefinementPrompt(archivedChange, knowledgeResult, knowledgeDir)
|
|
82
|
+
: undefined;
|
|
65
83
|
return {
|
|
66
84
|
continue: true,
|
|
67
|
-
systemMessage: `Post-archive: knowledge
|
|
85
|
+
systemMessage: `Post-archive: ${totalEntries} knowledge candidates staged to .pending/ for ${archivedChange}, ${tempFilesCleaned} temp files cleaned`,
|
|
68
86
|
hookSpecificOutput: {
|
|
69
87
|
bestPracticesCollected: knowledgeResult.bestPractices,
|
|
70
88
|
implicitConventionsCollected: knowledgeResult.implicitConventions,
|
|
71
89
|
tempFilesCleaned,
|
|
90
|
+
additionalContext,
|
|
72
91
|
},
|
|
73
92
|
};
|
|
74
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* 生成精炼提示,注入主 agent 上下文,
|
|
96
|
+
* 引导其调用 @skill:zhuanspec:knowledge 对草稿进行 LLM 精炼后合并入正式库。
|
|
97
|
+
*/
|
|
98
|
+
function buildRefinementPrompt(archivedChange, result, knowledgeDir) {
|
|
99
|
+
const pendingDir = path.join(knowledgeDir, '.pending');
|
|
100
|
+
const lines = [
|
|
101
|
+
`📋 **知识精炼提示**(归档 ${archivedChange} 完成后自动触发)`,
|
|
102
|
+
'',
|
|
103
|
+
`从归档文档中提取到 **${result.bestPractices + result.implicitConventions} 条**知识候选:`,
|
|
104
|
+
result.bestPractices > 0 ? ` - best-practices 候选:${result.bestPractices} 条` : '',
|
|
105
|
+
result.implicitConventions > 0 ? ` - implicit-conventions 候选:${result.implicitConventions} 条` : '',
|
|
106
|
+
'',
|
|
107
|
+
`草稿文件已暂存至:\`${pendingDir}\``,
|
|
108
|
+
'',
|
|
109
|
+
'🔴 **请按以下流程精炼草稿(不可跳过)**:',
|
|
110
|
+
'1. 读取 `.pending/` 下的草稿文件,理解候选内容。',
|
|
111
|
+
'2. 调用 `@skill:zhuanspec:knowledge`(模式 A),将候选内容精炼为:',
|
|
112
|
+
' - 标准 YAML front matter(含 id / trigger / severity / status)',
|
|
113
|
+
' - 三段正文:`## 适用场景` → `## 规则` → `## 反例/正例`',
|
|
114
|
+
'3. 若知识库中已有同 `id` 条目,追加 `source_change` 记录,**不新建文件**。',
|
|
115
|
+
'4. 精炼完成后删除 `.pending/` 下对应草稿。',
|
|
116
|
+
'',
|
|
117
|
+
'⚠️ **降级策略**:若当前无条件调用 skill(如会话已结束),草稿文件将保留,',
|
|
118
|
+
'下次 session 开始时由 context-load hook 重新提示精炼。',
|
|
119
|
+
].filter((l) => l !== undefined);
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
75
122
|
/**
|
|
76
123
|
* Extract knowledge records from change directory (proposal.md, tasks.md, design.md, specs/*.md)
|
|
77
124
|
* and save to knowledge subdirectories
|
|
@@ -124,45 +171,44 @@ async function collectChangeKnowledge(zhuanspecDir, archivedChange, knowledgeDir
|
|
|
124
171
|
return result;
|
|
125
172
|
}
|
|
126
173
|
try {
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
await FileSystemUtils.createDirectory(implicitConventionsDir);
|
|
133
|
-
// Define regex patterns for each knowledge type (Chinese and English)
|
|
134
|
-
// 原“决策类”关键字统一归入 best-practices
|
|
174
|
+
// 草稿暂存目录:knowledge/.pending/
|
|
175
|
+
// 不直接落正式库,由后续 LLM 精炼(@skill:zhuanspec:knowledge)合并为结构化条目
|
|
176
|
+
const pendingDir = path.join(knowledgeDir, '.pending');
|
|
177
|
+
await FileSystemUtils.createDirectory(pendingDir);
|
|
178
|
+
// 正则模式:原”决策类”关键字统一归入 best-practices
|
|
135
179
|
const patterns = {
|
|
136
180
|
bestPractices: /^##\s+(最佳实践|Best\s+Practice|Best\s+Practices|推荐做法|建议|注意事项|决策|Decision|技术决策|架构决策)[\s\S]*?(?=^##[^#]|$)/gm,
|
|
137
181
|
implicitConventions: /^##\s+(隐式约定|Implicit\s+Convention|默认行为|约定|惯例|约定事项)[\s\S]*?(?=^##[^#]|$)/gm,
|
|
138
182
|
};
|
|
139
|
-
const targetDirs = {
|
|
140
|
-
bestPractices: bestPracticesDir,
|
|
141
|
-
implicitConventions: implicitConventionsDir,
|
|
142
|
-
};
|
|
143
|
-
// Extract and save each knowledge type
|
|
144
183
|
for (const [type, pattern] of Object.entries(patterns)) {
|
|
145
184
|
const matches = combinedContent.matchAll(pattern);
|
|
146
|
-
const
|
|
185
|
+
const candidates = [];
|
|
147
186
|
for (const match of matches) {
|
|
148
187
|
const content = match[0].trim();
|
|
149
188
|
if (content.length > 50) {
|
|
150
|
-
|
|
189
|
+
candidates.push(content);
|
|
151
190
|
}
|
|
152
191
|
}
|
|
153
|
-
if (
|
|
192
|
+
if (candidates.length > 0) {
|
|
154
193
|
const timestamp = getBeijingTimeForFilename();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
194
|
+
const typeLabel = type === 'bestPractices' ? 'best-practices' : 'implicit-conventions';
|
|
195
|
+
// 草稿文件名带 .pending 标记,区别于正式库文件
|
|
196
|
+
const fileName = `${archivedChange}-${typeLabel}-${timestamp}.pending.md`;
|
|
197
|
+
const filePath = path.join(pendingDir, fileName);
|
|
198
|
+
const doc = [
|
|
199
|
+
`# [PENDING] ${typeLabel} from ${archivedChange}`,
|
|
200
|
+
'',
|
|
201
|
+
`> 此文件为归档自动提取的原始草稿,需经 @skill:zhuanspec:knowledge 精炼后合并入正式知识库。`,
|
|
202
|
+
`> 提取时间:${getBeijingTime()}`,
|
|
203
|
+
`> 目标分类:knowledge/${typeLabel}/`,
|
|
204
|
+
'',
|
|
205
|
+
'---',
|
|
206
|
+
'',
|
|
207
|
+
candidates.join('\n\n---\n\n'),
|
|
208
|
+
'',
|
|
209
|
+
].join('\n');
|
|
164
210
|
await FileSystemUtils.writeFile(filePath, doc);
|
|
165
|
-
result[type] =
|
|
211
|
+
result[type] = candidates.length;
|
|
166
212
|
}
|
|
167
213
|
}
|
|
168
214
|
return result;
|
|
@@ -171,6 +217,59 @@ ${entries.join('\n\n---\n\n')}
|
|
|
171
217
|
return result;
|
|
172
218
|
}
|
|
173
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* 解析归档的 tasks.md / proposal.md 中的 deprecates 声明,将命中的知识文件标 deprecated。
|
|
222
|
+
* 全程容错:解析/改写失败不抛错、不阻断归档;未命中 id 仅记入 notFoundIds。
|
|
223
|
+
*/
|
|
224
|
+
async function markDeprecatedKnowledge(zhuanspecDir, archivedChange, knowledgeDir) {
|
|
225
|
+
const result = { declaredIds: [], deprecatedFiles: [], notFoundIds: [] };
|
|
226
|
+
const archiveDir = path.join(zhuanspecDir, 'changes', 'archive', archivedChange);
|
|
227
|
+
const sourceFiles = [
|
|
228
|
+
path.join(archiveDir, 'tasks.md'),
|
|
229
|
+
path.join(archiveDir, 'proposal.md'),
|
|
230
|
+
];
|
|
231
|
+
let combined = '';
|
|
232
|
+
for (const filePath of sourceFiles) {
|
|
233
|
+
if (await FileSystemUtils.fileExists(filePath)) {
|
|
234
|
+
try {
|
|
235
|
+
combined += `\n${await FileSystemUtils.readFile(filePath)}`;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// 忽略读失败
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!combined.trim())
|
|
243
|
+
return result;
|
|
244
|
+
result.declaredIds = parseDeprecatesDeclaration(combined);
|
|
245
|
+
for (const id of result.declaredIds) {
|
|
246
|
+
try {
|
|
247
|
+
const file = await findEntryFileById(knowledgeDir, id);
|
|
248
|
+
if (!file) {
|
|
249
|
+
result.notFoundIds.push(id);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const changed = await setEntryStatus(file, 'deprecated');
|
|
253
|
+
if (changed) {
|
|
254
|
+
result.deprecatedFiles.push(path.relative(knowledgeDir, file));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// setEntryStatus 返回 false 有两种情形:已是 deprecated(幂等成功)/ 旧散文式无法改写。
|
|
258
|
+
// 复核当前 status 区分:已失效视为成功(记入 deprecatedFiles),否则归入 notFound。
|
|
259
|
+
const fm = parseFrontMatter(await FileSystemUtils.readFile(file));
|
|
260
|
+
if (fm.isYaml && fm.status === 'deprecated') {
|
|
261
|
+
result.deprecatedFiles.push(path.relative(knowledgeDir, file));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
result.notFoundIds.push(id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
result.notFoundIds.push(id);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
174
273
|
/**
|
|
175
274
|
* Cleanup temporary files from zhuanspec directory
|
|
176
275
|
*/
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import fs from 'fs';
|
|
17
|
+
import { execSync } from 'child_process';
|
|
17
18
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
18
19
|
import { getBeijingTime } from './record-progress.js';
|
|
20
|
+
import { GitRepoDetector } from '../../utils/git-repo-detector.js';
|
|
21
|
+
import { resolveZhuanSpecRoot } from '../../utils/resolve-root.js';
|
|
19
22
|
// Output file paths for skill results
|
|
20
23
|
const CODE_REVIEW_OUTPUT = 'code-review-result.json';
|
|
21
24
|
const UNIT_TEST_OUTPUT = 'unit-test-result.json';
|
|
@@ -306,14 +309,168 @@ export async function specConsistencyResultCheck(changeId, skillOutput) {
|
|
|
306
309
|
// ============================================================
|
|
307
310
|
// Basic Consistency Analysis Fallback
|
|
308
311
|
// ============================================================
|
|
312
|
+
function normalizeComparisonPath(filePath) {
|
|
313
|
+
return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
314
|
+
}
|
|
315
|
+
function buildScenarioKeywords(text) {
|
|
316
|
+
const normalized = text.toLowerCase();
|
|
317
|
+
const keywords = new Set();
|
|
318
|
+
normalized
|
|
319
|
+
.split(/[\s\-_/\uff0c\u3001]+/)
|
|
320
|
+
.map((part) => part.trim())
|
|
321
|
+
.filter((part) => part.length > 3)
|
|
322
|
+
.forEach((part) => keywords.add(part));
|
|
323
|
+
normalized.match(/[a-z0-9_]{4,}/g)?.forEach((part) => keywords.add(part));
|
|
324
|
+
normalized.match(/[0-9]{2,}/g)?.forEach((part) => keywords.add(part));
|
|
325
|
+
const chineseSegments = normalized.match(/[\u4e00-\u9fff0-9]+/g) || [];
|
|
326
|
+
for (const segment of chineseSegments) {
|
|
327
|
+
if (segment.length <= 1)
|
|
328
|
+
continue;
|
|
329
|
+
keywords.add(segment);
|
|
330
|
+
const maxWindow = Math.min(segment.length, 6);
|
|
331
|
+
for (let window = 2; window <= maxWindow; window++) {
|
|
332
|
+
for (let start = 0; start <= segment.length - window; start++) {
|
|
333
|
+
keywords.add(segment.slice(start, start + window));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return Array.from(keywords);
|
|
338
|
+
}
|
|
339
|
+
function pathMatchesScope(candidatePath, scopePath) {
|
|
340
|
+
const candidate = normalizeComparisonPath(candidatePath);
|
|
341
|
+
const scope = normalizeComparisonPath(scopePath);
|
|
342
|
+
if (!candidate || !scope) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
return candidate === scope
|
|
346
|
+
|| candidate.endsWith(`/${scope}`)
|
|
347
|
+
|| scope.endsWith(`/${candidate}`)
|
|
348
|
+
|| candidate.startsWith(`${scope}/`)
|
|
349
|
+
|| scope.startsWith(`${candidate}/`);
|
|
350
|
+
}
|
|
351
|
+
function extractProposalScopePaths(content) {
|
|
352
|
+
const seen = new Set();
|
|
353
|
+
const scopePaths = [];
|
|
354
|
+
const addScopePath = (raw) => {
|
|
355
|
+
const normalized = normalizeComparisonPath(raw.replace(/[`'"*[\]()]/g, '').trim());
|
|
356
|
+
if (!normalized || seen.has(normalized)) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
seen.add(normalized);
|
|
360
|
+
scopePaths.push(normalized);
|
|
361
|
+
};
|
|
362
|
+
const filePattern = /(?:^|[\s`'"([\]|])([a-zA-Z0-9_\-./]+\.(?:ts|tsx|js|jsx|java|py|go|rs|md|json|yaml|yml|sql|kt|scala|c|cpp|cs|sh|xml|html|css|scss|vue|svelte))(?:[\s`'")\]|,]|$)/gm;
|
|
363
|
+
let match;
|
|
364
|
+
while ((match = filePattern.exec(content)) !== null) {
|
|
365
|
+
addScopePath(match[1]);
|
|
366
|
+
}
|
|
367
|
+
const lines = content.split('\n');
|
|
368
|
+
let inAffectedSection = false;
|
|
369
|
+
for (const line of lines) {
|
|
370
|
+
if (/影响范围|Affected code|Affected files|Impact/i.test(line) && line.startsWith('#')) {
|
|
371
|
+
inAffectedSection = true;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (inAffectedSection && line.startsWith('#')) {
|
|
375
|
+
inAffectedSection = false;
|
|
376
|
+
}
|
|
377
|
+
if (inAffectedSection && line.trim().startsWith('-')) {
|
|
378
|
+
const bulletMatch = line.match(/-\s*(.+)/);
|
|
379
|
+
if (bulletMatch) {
|
|
380
|
+
addScopePath(bulletMatch[1]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return scopePaths;
|
|
385
|
+
}
|
|
386
|
+
function buildChangedFileSearchText(repoPath, filePath) {
|
|
387
|
+
const relativePath = normalizeComparisonPath(path.relative(repoPath, filePath));
|
|
388
|
+
const parts = [normalizeComparisonPath(filePath), relativePath];
|
|
389
|
+
try {
|
|
390
|
+
const diffText = execSync(`git diff --no-color --unified=0 HEAD -- "${relativePath}"`, {
|
|
391
|
+
cwd: repoPath,
|
|
392
|
+
encoding: 'utf-8',
|
|
393
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
394
|
+
}).trim();
|
|
395
|
+
if (diffText) {
|
|
396
|
+
parts.push(diffText);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Ignore diff read errors and fall back to current file content only.
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
404
|
+
if (fileContent.trim()) {
|
|
405
|
+
parts.push(fileContent);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Skip unreadable files.
|
|
410
|
+
}
|
|
411
|
+
return parts.join('\n').toLowerCase();
|
|
412
|
+
}
|
|
413
|
+
async function getTouchedFilesForConsistencyAnalysis(workspaceRoot, changeDir) {
|
|
414
|
+
const cwd = process.cwd();
|
|
415
|
+
const currentRepo = GitRepoDetector.findParentGitRepo(cwd);
|
|
416
|
+
// Keep the original behavior first: if cwd is already inside a git repo,
|
|
417
|
+
// use that repo directly and do not change the matching logic.
|
|
418
|
+
if (currentRepo) {
|
|
419
|
+
return GitRepoDetector.getGitDiff(currentRepo).changedFiles.map((filePath) => ({
|
|
420
|
+
filePath,
|
|
421
|
+
searchText: buildChangedFileSearchText(currentRepo, filePath),
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
// Fallback only when cwd itself cannot resolve git diff:
|
|
425
|
+
// use proposal scope to find the corresponding child repository below.
|
|
426
|
+
const proposalPath = path.join(changeDir, 'proposal.md');
|
|
427
|
+
let scopePaths = [];
|
|
428
|
+
try {
|
|
429
|
+
const proposalContent = await FileSystemUtils.readFile(proposalPath);
|
|
430
|
+
scopePaths = extractProposalScopePaths(proposalContent);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return [];
|
|
434
|
+
}
|
|
435
|
+
if (scopePaths.length === 0) {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
const childRepos = GitRepoDetector.findChildGitRepos(workspaceRoot, 3);
|
|
439
|
+
const matchedResults = childRepos
|
|
440
|
+
.map((repoPath) => GitRepoDetector.getGitDiff(repoPath))
|
|
441
|
+
.filter((result) => result.changedFiles.length > 0)
|
|
442
|
+
.filter(({ repoPath, changedFiles }) => changedFiles.some((filePath) => {
|
|
443
|
+
const workspaceRelative = normalizeComparisonPath(path.relative(workspaceRoot, filePath));
|
|
444
|
+
const repoRelative = normalizeComparisonPath(path.relative(repoPath, filePath));
|
|
445
|
+
const repoName = normalizeComparisonPath(path.relative(workspaceRoot, repoPath));
|
|
446
|
+
return scopePaths.some((scopePath) => pathMatchesScope(workspaceRelative, scopePath)
|
|
447
|
+
|| pathMatchesScope(repoRelative, scopePath)
|
|
448
|
+
|| pathMatchesScope(repoName, scopePath));
|
|
449
|
+
}));
|
|
450
|
+
const candidates = [];
|
|
451
|
+
const seen = new Set();
|
|
452
|
+
for (const result of matchedResults) {
|
|
453
|
+
for (const filePath of result.changedFiles) {
|
|
454
|
+
if (seen.has(filePath)) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
seen.add(filePath);
|
|
458
|
+
candidates.push({
|
|
459
|
+
filePath,
|
|
460
|
+
searchText: buildChangedFileSearchText(result.repoPath, filePath),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return candidates;
|
|
465
|
+
}
|
|
309
466
|
/**
|
|
310
467
|
* Run basic consistency analysis when spec-consistency-result.json is missing.
|
|
311
468
|
* Reads specs/ under the change directory, extracts Requirements/Scenarios,
|
|
312
469
|
* searches touched files for keyword matches, and writes a basic result.
|
|
313
470
|
*/
|
|
314
471
|
async function runBasicConsistencyAnalysis(changeId) {
|
|
315
|
-
const
|
|
316
|
-
const changeDir = path.join(
|
|
472
|
+
const workspaceRoot = resolveZhuanSpecRoot();
|
|
473
|
+
const changeDir = path.join(workspaceRoot, 'zhuanspec', 'changes', changeId);
|
|
317
474
|
const specsDir = path.join(changeDir, 'specs');
|
|
318
475
|
// Check if specs directory exists
|
|
319
476
|
if (!await FileSystemUtils.directoryExists(specsDir)) {
|
|
@@ -332,16 +489,38 @@ async function runBasicConsistencyAnalysis(changeId) {
|
|
|
332
489
|
const lines = content.split('\n');
|
|
333
490
|
let currentSection = '';
|
|
334
491
|
let currentReq = null;
|
|
492
|
+
let currentScenario = null;
|
|
335
493
|
let reqCounter = 0;
|
|
336
494
|
let scenarioCounter = 0;
|
|
495
|
+
const flushScenario = () => {
|
|
496
|
+
if (!currentReq || !currentScenario) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const bodyText = currentScenario.bodyLines.join(' ').trim();
|
|
500
|
+
currentReq.scenarios.push({
|
|
501
|
+
id: currentScenario.id,
|
|
502
|
+
description: currentScenario.description,
|
|
503
|
+
matchText: bodyText
|
|
504
|
+
? `${currentScenario.description}\n${bodyText}`
|
|
505
|
+
: currentScenario.description,
|
|
506
|
+
});
|
|
507
|
+
currentScenario = null;
|
|
508
|
+
};
|
|
337
509
|
for (const line of lines) {
|
|
338
|
-
if (line.match(/^##\s+(ADDED|Added|\u65b0\u589e)/i))
|
|
510
|
+
if (line.match(/^##\s+(ADDED|Added|\u65b0\u589e)/i)) {
|
|
511
|
+
flushScenario();
|
|
339
512
|
currentSection = 'ADDED';
|
|
340
|
-
|
|
513
|
+
}
|
|
514
|
+
else if (line.match(/^##\s+(MODIFIED|Modified|\u4fee\u6539)/i)) {
|
|
515
|
+
flushScenario();
|
|
341
516
|
currentSection = 'MODIFIED';
|
|
342
|
-
|
|
517
|
+
}
|
|
518
|
+
else if (line.match(/^##\s+(REMOVED|Removed|\u5220\u9664)/i)) {
|
|
519
|
+
flushScenario();
|
|
343
520
|
currentSection = 'REMOVED';
|
|
521
|
+
}
|
|
344
522
|
else if (line.match(/^###\s+(Requirement|\u9700\u6c42)[\uff1a:]/i)) {
|
|
523
|
+
flushScenario();
|
|
345
524
|
const reqName = line.replace(/^###\s+(Requirement|\u9700\u6c42)[\uff1a:]\s+/i, '').trim();
|
|
346
525
|
if (currentSection === 'ADDED' || currentSection === 'MODIFIED') {
|
|
347
526
|
reqCounter++;
|
|
@@ -353,34 +532,26 @@ async function runBasicConsistencyAnalysis(changeId) {
|
|
|
353
532
|
}
|
|
354
533
|
}
|
|
355
534
|
else if (line.match(/^####\s+(Scenario|\u573a\u666f)[\uff1a:]/i) && currentReq) {
|
|
535
|
+
flushScenario();
|
|
356
536
|
scenarioCounter++;
|
|
357
537
|
const scenarioDesc = line.replace(/^####\s+(Scenario|\u573a\u666f)[\uff1a:]\s+/i, '').trim();
|
|
358
|
-
|
|
538
|
+
currentScenario = { id: `SC-${scenarioCounter}`, description: scenarioDesc, bodyLines: [] };
|
|
539
|
+
}
|
|
540
|
+
else if (currentScenario && !line.match(/^#{1,4}\s/)) {
|
|
541
|
+
const trimmed = line.trim();
|
|
542
|
+
if (trimmed) {
|
|
543
|
+
currentScenario.bodyLines.push(trimmed);
|
|
544
|
+
}
|
|
359
545
|
}
|
|
360
546
|
}
|
|
547
|
+
flushScenario();
|
|
361
548
|
}
|
|
362
549
|
if (allRequirements.length === 0) {
|
|
363
550
|
return null;
|
|
364
551
|
}
|
|
365
|
-
// Get touched files from git
|
|
366
552
|
let touchedFiles = [];
|
|
367
553
|
try {
|
|
368
|
-
|
|
369
|
-
const diffOutput = execSync('git diff --name-only HEAD', {
|
|
370
|
-
cwd,
|
|
371
|
-
encoding: 'utf-8',
|
|
372
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
373
|
-
});
|
|
374
|
-
touchedFiles = diffOutput.split('\n').map(f => f.trim()).filter(f => f.length > 0);
|
|
375
|
-
const statusOutput = execSync('git status --porcelain', {
|
|
376
|
-
cwd,
|
|
377
|
-
encoding: 'utf-8',
|
|
378
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
379
|
-
});
|
|
380
|
-
const statusFiles = statusOutput.split('\n')
|
|
381
|
-
.map(l => l.replace(/^\s*[A-Z?]+\s+/, '').trim())
|
|
382
|
-
.filter(f => f.length > 0);
|
|
383
|
-
touchedFiles = [...new Set([...touchedFiles, ...statusFiles])];
|
|
554
|
+
touchedFiles = await getTouchedFilesForConsistencyAnalysis(workspaceRoot, changeDir);
|
|
384
555
|
}
|
|
385
556
|
catch {
|
|
386
557
|
// No git info available
|
|
@@ -393,34 +564,18 @@ async function runBasicConsistencyAnalysis(changeId) {
|
|
|
393
564
|
for (const req of allRequirements) {
|
|
394
565
|
const scenarios = req.scenarios.length > 0
|
|
395
566
|
? req.scenarios
|
|
396
|
-
: [{ id: `${req.id}-implicit`, description: req.name }];
|
|
567
|
+
: [{ id: `${req.id}-implicit`, description: req.name, matchText: req.name }];
|
|
397
568
|
for (const scenario of scenarios) {
|
|
398
569
|
totalScenarios++;
|
|
399
|
-
const keywords = scenario.
|
|
400
|
-
.toLowerCase()
|
|
401
|
-
.split(/[\s\-_/\uff0c\u3001]+/)
|
|
402
|
-
.filter(w => w.length > 3);
|
|
570
|
+
const keywords = buildScenarioKeywords(scenario.matchText);
|
|
403
571
|
let found = false;
|
|
404
572
|
let matchedFile = '';
|
|
405
573
|
for (const file of touchedFiles) {
|
|
406
|
-
|
|
407
|
-
if (keywords.some(kw => fileLower.includes(kw))) {
|
|
574
|
+
if (keywords.some(kw => file.searchText.includes(kw))) {
|
|
408
575
|
found = true;
|
|
409
|
-
matchedFile = file;
|
|
576
|
+
matchedFile = file.filePath;
|
|
410
577
|
break;
|
|
411
578
|
}
|
|
412
|
-
try {
|
|
413
|
-
const fullPath = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
414
|
-
const fileContent = await FileSystemUtils.readFile(fullPath);
|
|
415
|
-
if (keywords.some(kw => fileContent.toLowerCase().includes(kw))) {
|
|
416
|
-
found = true;
|
|
417
|
-
matchedFile = file;
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
catch {
|
|
422
|
-
// Skip unreadable files
|
|
423
|
-
}
|
|
424
579
|
}
|
|
425
580
|
if (found) {
|
|
426
581
|
coveredCount++;
|
|
@@ -271,6 +271,37 @@ async function runSummarize() {
|
|
|
271
271
|
].join('\n');
|
|
272
272
|
systemMessage += `\n⚠️ 检测到 ${n} 次用户纠偏未沉淀——请先运行 zhuanspec progress list-corrections ${changeId},再用 askUserQuestion 三选项决策,用户选「全部/选择性沉淀」后必须调用 @skill:zhuanspec:knowledge 而非自行 Write。\n`;
|
|
273
273
|
}
|
|
274
|
+
// === 对话经验自动沉淀:检测 resolutionSignal,注入精炼提示 ===
|
|
275
|
+
// 触发条件:apply 阶段 + user_inputs.json 有 resolutionSignal.detected=true + 未问过
|
|
276
|
+
let resolutionAdditionalContext;
|
|
277
|
+
if (phase === 'apply' && progressData?.askedResolutionSaved !== true) {
|
|
278
|
+
try {
|
|
279
|
+
const userInputPath = path.join(metricsDir, 'user_inputs.json');
|
|
280
|
+
if (await FileSystemUtils.fileExists(userInputPath)) {
|
|
281
|
+
const raw = JSON.parse(await FileSystemUtils.readFile(userInputPath));
|
|
282
|
+
const resolved = (raw.inputs ?? []).filter((i) => i.resolutionSignal?.detected === true);
|
|
283
|
+
if (resolved.length > 0) {
|
|
284
|
+
// 汇总命中关键词和上下文片段,供精炼 prompt 参考
|
|
285
|
+
const allKeywords = [...new Set(resolved.flatMap((i) => i.resolutionSignal?.matchedKeywords ?? []))];
|
|
286
|
+
const snippets = resolved
|
|
287
|
+
.map((i) => i.resolutionSignal?.context ?? '')
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
.slice(0, 3) // 最多取 3 条上下文
|
|
290
|
+
.join('\n---\n');
|
|
291
|
+
resolutionAdditionalContext = buildResolutionExtractionPrompt(changeId, allKeywords, snippets);
|
|
292
|
+
// 写回 progress.json 防止重复触发
|
|
293
|
+
if (await FileSystemUtils.fileExists(progressPath)) {
|
|
294
|
+
const full = JSON.parse(await FileSystemUtils.readFile(progressPath));
|
|
295
|
+
full.askedResolutionSaved = true;
|
|
296
|
+
await atomicWriteJson(progressPath, full);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// 读取失败不阻断主流程
|
|
303
|
+
}
|
|
304
|
+
}
|
|
274
305
|
// === Task 0 路径 1:Stop 时按核心产出齐备度自动打 phase baseline ===
|
|
275
306
|
// 幂等:已打标则跳过;先到先得(auto-file-complete 优于 auto-stop)
|
|
276
307
|
try {
|
|
@@ -295,12 +326,16 @@ async function runSummarize() {
|
|
|
295
326
|
catch {
|
|
296
327
|
// baseline 打标失败不阻断 summarize 主流程
|
|
297
328
|
}
|
|
329
|
+
// 合并纠偏沉淀提示和对话经验提示(两者都有时换行分隔)
|
|
330
|
+
const mergedAdditionalContext = [pitfallAdditionalContext, resolutionAdditionalContext]
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
.join('\n\n---\n\n') || undefined;
|
|
298
333
|
return {
|
|
299
334
|
continue: true,
|
|
300
335
|
systemMessage,
|
|
301
336
|
hookSpecificOutput: {
|
|
302
337
|
hookEventName: 'Stop',
|
|
303
|
-
additionalContext:
|
|
338
|
+
additionalContext: mergedAdditionalContext,
|
|
304
339
|
waveCompleted: currentWave?.waveId,
|
|
305
340
|
nextTask,
|
|
306
341
|
allTasksComplete,
|
|
@@ -432,4 +467,61 @@ async function isPhaseOutputsComplete(phase, changeDir, allTasksComplete) {
|
|
|
432
467
|
}
|
|
433
468
|
return false;
|
|
434
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* 生成对话经验精炼提示,注入主 agent 上下文。
|
|
472
|
+
* 不弹 askUserQuestion——直接给 LLM 提取指令,由 LLM 自判断值不值得沉淀。
|
|
473
|
+
*/
|
|
474
|
+
function buildResolutionExtractionPrompt(changeId, keywords, contextSnippets) {
|
|
475
|
+
return [
|
|
476
|
+
'## 🔍 对话经验自动沉淀(Stop hook 触发)',
|
|
477
|
+
'',
|
|
478
|
+
`检测到本次会话(change: \`${changeId}\`)中存在"问题已解决/找到根因"信号。`,
|
|
479
|
+
keywords.length > 0 ? `命中关键词:${keywords.map((k) => `\`${k}\``).join('、')}` : '',
|
|
480
|
+
'',
|
|
481
|
+
'**请从本次完整对话历史中提取隐性知识,按以下步骤执行(不可跳步):**',
|
|
482
|
+
'',
|
|
483
|
+
'### Step 1:判断是否值得沉淀',
|
|
484
|
+
'- ✅ **值得**:非显而易见的 bug、有迷惑性的报错、排查路径有参考价值、跨次复现的隐式约定',
|
|
485
|
+
'- ❌ **不值得**:拼写错误、本地配置笔误、偶发网络问题、已有知识库覆盖的内容',
|
|
486
|
+
'- 若不值得,直接输出「本次问题不适合沉淀(原因:xxx)」,**不写任何文件,流程结束**。',
|
|
487
|
+
'',
|
|
488
|
+
'### Step 2:若值得,调用 @skill:zhuanspec:knowledge(模式 B)写入以下格式',
|
|
489
|
+
'',
|
|
490
|
+
'目标目录:`zhuanspec/knowledge/troubleshooting/`',
|
|
491
|
+
'',
|
|
492
|
+
'```yaml',
|
|
493
|
+
'---',
|
|
494
|
+
'id: ts-{YYYYMMDD}-{2-3词摘要} # 如 ts-20260602-yaml-parse-null',
|
|
495
|
+
'category: troubleshooting',
|
|
496
|
+
'title: {一句话描述问题现象}',
|
|
497
|
+
'trigger:',
|
|
498
|
+
' keywords: [关键报错词, 涉及模块, 操作动作]',
|
|
499
|
+
' paths: ["**/涉及路径/**"] # 可选',
|
|
500
|
+
'severity: high | medium | low # 按复现难度和影响范围',
|
|
501
|
+
'confidence: 0.7~1.0 # 自评把握度',
|
|
502
|
+
`source_change: ${changeId}`,
|
|
503
|
+
'status: active',
|
|
504
|
+
'---',
|
|
505
|
+
'',
|
|
506
|
+
'## 适用场景',
|
|
507
|
+
'遇到 {具体症状描述} 时。',
|
|
508
|
+
'',
|
|
509
|
+
'## 根因',
|
|
510
|
+
'{一句话说清根本原因,不带背景叙事}',
|
|
511
|
+
'',
|
|
512
|
+
'## 排查路径',
|
|
513
|
+
'1. {第一步排查动作} → {结论}',
|
|
514
|
+
'2. {第二步} → {结论}',
|
|
515
|
+
'',
|
|
516
|
+
'## 解法',
|
|
517
|
+
'{可直接复制使用的最终解法}',
|
|
518
|
+
'```',
|
|
519
|
+
'',
|
|
520
|
+
'### 红线',
|
|
521
|
+
'- 禁止绕过 skill 直接 Write/Edit 知识文件。',
|
|
522
|
+
'- 禁止在没有明确根因的情况下强行沉淀。',
|
|
523
|
+
'- 知识库已有同 id 条目时,追加 source_change,不新建文件。',
|
|
524
|
+
...(contextSnippets ? ['', '### 参考上下文片段(来自本次对话检测到解决信号的 prompt)', '```', contextSnippets, '```'] : []),
|
|
525
|
+
].filter((l) => l !== undefined).join('\n');
|
|
526
|
+
}
|
|
435
527
|
//# sourceMappingURL=summarize.js.map
|
|
@@ -27,6 +27,17 @@ export interface CorrectionSignal {
|
|
|
27
27
|
matchedKeywords: string[];
|
|
28
28
|
isRequirementChange?: boolean;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* 对话经验解决信号:检测用户在对话中表达"问题已解决/找到根因"的语义。
|
|
32
|
+
* 用于 Stop hook 触发对话经验自动沉淀流程。
|
|
33
|
+
*/
|
|
34
|
+
export interface ResolutionSignal {
|
|
35
|
+
detected: boolean;
|
|
36
|
+
/** 命中的关键词(最多 4 个,用于 Stop hook 判断置信度) */
|
|
37
|
+
matchedKeywords: string[];
|
|
38
|
+
/** 截断的关键上下文(<=300 字),供 Stop hook 注入精炼 prompt 时参考 */
|
|
39
|
+
context: string;
|
|
40
|
+
}
|
|
30
41
|
/**
|
|
31
42
|
* 用户对四选一的最终选择。由 `zhuanspec progress resolve-correction` 回写。
|
|
32
43
|
*/
|
|
@@ -49,6 +60,7 @@ interface UserInputOutput {
|
|
|
49
60
|
options?: string[];
|
|
50
61
|
};
|
|
51
62
|
}
|
|
63
|
+
export declare function buildResolutionSignal(content: string): ResolutionSignal;
|
|
52
64
|
/**
|
|
53
65
|
* Main hook function - records user input
|
|
54
66
|
*/
|