@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.
@@ -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(`✅ 知识收集完成:共 ${totalEntries} 条记录`));
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 updated for ${archivedChange}, ${knowledgeResult.bestPractices} best practices, ${knowledgeResult.implicitConventions} implicit conventions, ${tempFilesCleaned} temp files cleaned`,
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
- // Create knowledge subdirectories(仅三类:best-practices / implicit-conventions / troubleshooting;
128
- // troubleshooting 由 zhuanspec:knowledge skill 单独写入,此处不做归档提取)
129
- const bestPracticesDir = path.join(knowledgeDir, 'best-practices');
130
- const implicitConventionsDir = path.join(knowledgeDir, 'implicit-conventions');
131
- await FileSystemUtils.createDirectory(bestPracticesDir);
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 entries = [];
185
+ const candidates = [];
147
186
  for (const match of matches) {
148
187
  const content = match[0].trim();
149
188
  if (content.length > 50) {
150
- entries.push(content);
189
+ candidates.push(content);
151
190
  }
152
191
  }
153
- if (entries.length > 0) {
192
+ if (candidates.length > 0) {
154
193
  const timestamp = getBeijingTimeForFilename();
155
- const fileName = `${archivedChange}-${type}-${timestamp}.md`;
156
- const filePath = path.join(targetDirs[type], fileName);
157
- const typeLabel = type === 'bestPractices' ? 'Best Practices' : 'Implicit Conventions';
158
- const doc = `# ${typeLabel} from ${archivedChange}
159
-
160
- Extracted on: ${getBeijingTime()}
161
-
162
- ${entries.join('\n\n---\n\n')}
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] = entries.length;
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 cwd = process.cwd();
316
- const changeDir = path.join(cwd, 'zhuanspec', 'changes', changeId);
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
- else if (line.match(/^##\s+(MODIFIED|Modified|\u4fee\u6539)/i))
513
+ }
514
+ else if (line.match(/^##\s+(MODIFIED|Modified|\u4fee\u6539)/i)) {
515
+ flushScenario();
341
516
  currentSection = 'MODIFIED';
342
- else if (line.match(/^##\s+(REMOVED|Removed|\u5220\u9664)/i))
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
- currentReq.scenarios.push({ id: `SC-${scenarioCounter}`, description: scenarioDesc });
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
- const { execSync } = await import('child_process');
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.description
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
- const fileLower = file.toLowerCase();
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: pitfallAdditionalContext,
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
  */