sillyspec 3.33.7 → 3.33.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +3 -2
  2. package/eval/knowledge/README.md +119 -0
  3. package/eval/knowledge/baseline.json +907 -0
  4. package/eval/knowledge/datasets/organic.jsonl +79 -0
  5. package/eval/knowledge/fixtures/index.mjs +132 -0
  6. package/eval/knowledge/lib/dataset.mjs +171 -0
  7. package/eval/knowledge/lib/engine.mjs +136 -0
  8. package/eval/knowledge/lib/faces.mjs +88 -0
  9. package/eval/knowledge/lib/metrics.mjs +56 -0
  10. package/eval/knowledge/lib/probes.mjs +98 -0
  11. package/eval/knowledge/lib/report.mjs +82 -0
  12. package/eval/knowledge/lib/repos.mjs +41 -0
  13. package/eval/knowledge/lib/snapshot.mjs +55 -0
  14. package/eval/knowledge/run.mjs +206 -0
  15. package/package.json +3 -2
  16. package/src/change-delete.js +24 -1
  17. package/src/checkpoint.js +258 -0
  18. package/src/config-schema.js +36 -0
  19. package/src/flow-parity.js +10 -50
  20. package/src/flow-review.js +4 -0
  21. package/src/flow.js +44 -10
  22. package/src/foreign-declared.js +2 -1
  23. package/src/index.js +100 -46
  24. package/src/knowledge-graph.js +243 -25
  25. package/src/knowledge-match.js +130 -21
  26. package/src/knowledge-stats.js +3 -0
  27. package/src/knowledge-vector.js +29 -4
  28. package/src/overlap-detect.js +68 -0
  29. package/src/redomain-unmapped.js +163 -2
  30. package/src/run/complete.js +20 -1
  31. package/src/run/gate-snapshot.js +3 -1
  32. package/src/run/prompt.js +67 -37
  33. package/src/run/quick-audit.js +58 -10
  34. package/src/run/shared.js +7 -1
  35. package/src/run/verify-quality-scan.js +52 -7
  36. package/src/stages/execute.js +64 -16
  37. package/src/stages/knowledge.js +57 -28
  38. package/src/task-tick.js +16 -0
  39. package/src/verify-postcheck.js +330 -3
  40. package/templates/agents-instruction.md +1 -1
  41. package/src/knowledge-quicklog.js +0 -261
package/src/index.js CHANGED
@@ -148,7 +148,12 @@ SillySpec CLI — 规范驱动开发工具包
148
148
  sillyspec change-rename <旧变更名> <新变更名>
149
149
  sillyspec change-delete <变更名> [--confirm] 删除变更(DB status='deleted' 与归档语义分离 +
150
150
  移目录 + 清 worktree + 推平台墓碑;默认 dry-run)
151
- sillyspec knowledge <search --query "..." --limit N
151
+ sillyspec checkpoint --change <名> [--diff <sha> [--path <p>]] [--restore <sha>
152
+ [--path <p>|--all] --yes] [--delete-ref]
153
+ 影子存档面:列出 task tick 自动落的存档链,预览/按路径读档
154
+ sillyspec overlap 查询活跃变更 target-file 声明重叠(advisory——检出即附
155
+ worktree 分流与落地序列指引)
156
+ sillyspec knowledge <search --query "..." --limit N [--include-resolved]
152
157
  | inspect --id "..."
153
158
  | validate | refresh
154
159
  | propose --title "..." --category <name>
@@ -3619,50 +3624,9 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
3619
3624
  }
3620
3625
  break;
3621
3626
  }
3622
- case 'quicklog': {
3623
- // known-issues ④ v1(2026-09-16):QUICKLOG 多会话条目交织——本会话提交 git add 整文件
3624
- // 会夹带并行会话未完成条目,只能手工四步舞(备份→剥离→commit→恢复)。本命令一键机制化:
3625
- // 切片(HEAD 基线+本会话条目块)→ 显式 pathspec 提交(QUICKLOG 文件+patches sidecar+额外
3626
- // pathspec)→ 恢复工作区(并行条目留未提交态)。参数面照抄 wt-commit 先例(--change/-m/--
3627
- // pathspec)+ 可重复 --ql(guard 缺失/损坏时的显式逃生门;含已取消条目)。
3628
- if (filteredArgs[1] !== 'commit') {
3629
- console.error(`❌ 未知 quicklog 子命令: ${filteredArgs[1] || '(空)'}——当前支持: sillyspec quicklog commit [--change <quick会话ID>] -m <信息> [--ql <ql-id>]... [-- <额外pathspec...>]`);
3630
- process.exit(1);
3631
- }
3632
- const qlRest = filteredArgs.slice(2);
3633
- const qlDd = qlRest.indexOf('--');
3634
- const qlFlags = qlDd === -1 ? qlRest : qlRest.slice(0, qlDd);
3635
- const qlExtra = qlDd === -1 ? [] : qlRest.slice(qlDd + 1);
3636
- let qlChange = null;
3637
- let qlMessage = null;
3638
- const qlIds = [];
3639
- for (let i = 0; i < qlFlags.length; i++) {
3640
- if (qlFlags[i] === '--change' && qlFlags[i + 1]) qlChange = qlFlags[++i];
3641
- else if ((qlFlags[i] === '-m' || qlFlags[i] === '--message') && qlFlags[i + 1]) qlMessage = qlFlags[++i];
3642
- else if (qlFlags[i] === '--ql' && qlFlags[i + 1]) qlIds.push(qlFlags[++i]);
3643
- }
3644
- try {
3645
- const { resolvePlatformSpecDir, resolvePlatformOpts } = await import('./progress.js');
3646
- const qlSpecBase = resolvePlatformSpecDir(dir, specDir);
3647
- const { resolveQuickSessionsDir } = await import('./run/shared.js');
3648
- const qlSessionsDir = resolveQuickSessionsDir(resolvePlatformOpts(dir, specDir), qlSpecBase);
3649
- let qlUser = 'unknown';
3650
- try { qlUser = (await import('./git-helper.js')).git(dir, ['config', 'user.name']) || 'unknown'; } catch {}
3651
- const { runQuicklogCommit } = await import('./quicklog.js');
3652
- const r = await runQuicklogCommit({
3653
- specBase: qlSpecBase, cwd: dir, gitUser: qlUser, qlIds,
3654
- changeName: qlChange, sessionsDir: qlSessionsDir,
3655
- message: qlMessage, extraPathspecs: qlExtra,
3656
- });
3657
- if (json) console.log(JSON.stringify({ command: 'quicklog commit', ...r }, null, 2));
3658
- else if (r.skipped) console.log(`⏭️ quicklog commit 无新内容跳过(HEAD 不动):${r.shortHead}(条目均已收编:${(r.skippedQlIds || []).join(', ') || '(无)'})`);
3659
- else console.log(`✅ quicklog commit 完成:${r.shortHead}(${r.files.length} 文件;收编 ${(r.committedQlIds || []).join(', ')})\n 并行会话条目已恢复为工作区未提交态;备份:${(r.backupPaths || []).join(' , ')}`);
3660
- } catch (e) {
3661
- console.error(`❌ quicklog commit 失败:${e.message}`);
3662
- process.exit(1);
3663
- }
3664
- break;
3665
- }
3627
+ // quicklog CLI 子命令已下线(2026-11-03-quicklog-business-retire):
3628
+ // quicklog 业务面(QUICKLOG 日志系统)用户已停用;工具函数(withFileLock/标题推导等)
3629
+ // 保留在 quicklog.js 供其他模块消费。knowledge-quicklog.js 解析层同批删除。
3666
3630
  case 'worktree': {
3667
3631
  const { WorktreeManager } = await import('./worktree.js');
3668
3632
  const { ProgressManager, resolveSessionIdentity } = await import('./progress.js');
@@ -5055,6 +5019,96 @@ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";
5055
5019
  process.exitCode = r.ok ? 0 : 1;
5056
5020
  break;
5057
5021
  }
5022
+ case 'checkpoint': {
5023
+ // 2026-10-28-shadow-checkpoint:影子存档面——task tick 自动落档(refs/sillyspec/
5024
+ // checkpoints/<change>),本动词提供列出/预览/读档/清 ref 的人工面。恢复纪律(FR-03):
5025
+ // --restore 必须显式 --yes;先 --diff 同参预览再执行是推荐路径。
5026
+ const cpVal = (flag) => {
5027
+ const i = filteredArgs.indexOf(flag);
5028
+ return i >= 0 && filteredArgs[i + 1] && !String(filteredArgs[i + 1]).startsWith('--')
5029
+ ? filteredArgs[i + 1] : null;
5030
+ };
5031
+ const cpChange = cpVal('--change');
5032
+ if (!cpChange) {
5033
+ console.error('❌ 用法: sillyspec checkpoint --change <名> [--diff <sha> [--path <p>]] [--restore <sha> [--path <p>|--all] --yes] [--delete-ref]');
5034
+ process.exitCode = 2;
5035
+ break;
5036
+ }
5037
+ const { listCheckpoints, previewRestore, restoreCheckpoint, deleteCheckpointRef } = await import('./checkpoint.js');
5038
+ const cpDiffSha = cpVal('--diff');
5039
+ const cpRestoreSha = cpVal('--restore');
5040
+ const pathArgs = [];
5041
+ for (let i = 0; i < filteredArgs.length - 1; i++) {
5042
+ if (filteredArgs[i] === '--path' && !String(filteredArgs[i + 1]).startsWith('--')) pathArgs.push(filteredArgs[i + 1]);
5043
+ }
5044
+ const cpAll = filteredArgs.includes('--all');
5045
+ const cpYes = filteredArgs.includes('--yes');
5046
+ const cpDeleteRef = filteredArgs.includes('--delete-ref');
5047
+ if (cpDeleteRef) {
5048
+ const okDel = deleteCheckpointRef(dir, cpChange);
5049
+ console.log(okDel ? `🗑️ 已删除 refs/sillyspec/checkpoints/${cpChange}` : `ℹ️ 无 refs/sillyspec/checkpoints/${cpChange}(无可删)`);
5050
+ break;
5051
+ }
5052
+ if (cpDiffSha) {
5053
+ try {
5054
+ const pv = await previewRestore({ repoRoot: dir, sha: cpDiffSha, paths: pathArgs });
5055
+ console.log(pv.stat || '(无差异)');
5056
+ if (pv.patch) console.log(pv.patch);
5057
+ } catch (e) {
5058
+ console.error(`❌ 预览失败:${e && e.message ? e.message : e}`);
5059
+ process.exitCode = 1;
5060
+ }
5061
+ break;
5062
+ }
5063
+ if (cpRestoreSha) {
5064
+ if (!cpYes) {
5065
+ console.error(`❌ 恢复需显式 --yes(建议先 --diff 同参预览):sillyspec checkpoint --change ${cpChange} --diff <sha> [--path p] 再 --restore <sha> --path <p> --yes`);
5066
+ process.exitCode = 2;
5067
+ break;
5068
+ }
5069
+ if (!cpAll && pathArgs.length === 0) {
5070
+ console.error('❌ 整树恢复需 --all(按路径恢复用 --path <p>,可多次)');
5071
+ process.exitCode = 2;
5072
+ break;
5073
+ }
5074
+ const rr = await restoreCheckpoint({ repoRoot: dir, sha: cpRestoreSha, changeName: cpChange, paths: pathArgs, all: cpAll });
5075
+ if (rr.ok) console.log(`🗂️ 已读档 ${cpRestoreSha.slice(0, 8)} → ${(cpAll ? ['.'] : pathArgs).join(' ')}(仅写工作区,共享暂存区未动)`);
5076
+ else { console.error(`❌ ${rr.reason}`); process.exitCode = 1; }
5077
+ break;
5078
+ }
5079
+ const list = await listCheckpoints({ repoRoot: dir, changeName: cpChange });
5080
+ if (!list.length) {
5081
+ console.log(`ℹ️ 变更 ${cpChange} 暂无影子存档(task tick 成功勾选时自动落档;checkpoint.enabled=false 时关闭)`);
5082
+ break;
5083
+ }
5084
+ console.log(`🗂️ 变更 ${cpChange} 影子存档链(${list.length} 档,新→旧):`);
5085
+ for (const c of list) {
5086
+ const when = new Date(c.ts).toISOString().replace('T', ' ').slice(0, 19);
5087
+ console.log(` ${c.sha.slice(0, 8)} ${when} ${c.taskId || '-'} ${c.files} 文件`);
5088
+ }
5089
+ console.log(`\n预览:sillyspec checkpoint --change ${cpChange} --diff <sha> [--path p]|读档:--restore <sha> --path <p> --yes${list.length >= 200 ? '\nℹ️ 列表显示截 200 档(仅展示面)——全量走 git:git log --format="%H %s" refs/sillyspec/checkpoints/' + cpChange : ''}`);
5090
+ break;
5091
+ }
5092
+ case 'overlap': {
5093
+ // 2026-10-28-overlap-route:target-file 声明重叠仓级查询(advisory 恒 exit 0)——
5094
+ // 事前路由信号:检出重叠附 worktree 分流与落地序列指引(不是禁止是分流)。
5095
+ const { detectDeclaredOverlaps } = await import('./overlap-detect.js');
5096
+ let overlaps = [];
5097
+ try { overlaps = detectDeclaredOverlaps({ cwd: dir, specBase: resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec') }); }
5098
+ catch (e) { console.log(`ℹ️ 重叠检测不可用(advisory 面静默退化):${e && e.message ? e.message : e}`); break; }
5099
+ if (!overlaps.length) {
5100
+ console.log(`✅ 无 target-file 声明重叠——各活跃变更声明面互不相交(落地零冲突预期)`);
5101
+ break;
5102
+ }
5103
+ console.log(`⚠️ ${overlaps.length} 个文件被多个活跃变更声明(并行改同一文件是冲突地带):`);
5104
+ for (const o of overlaps) {
5105
+ console.log(` ${o.file} ← ${o.changes.join('、')}`);
5106
+ }
5107
+ console.log(`\n🔀 分流建议:其一转 worktree 形态并行——sillyspec worktree apply <后到变更名>(隔离干活,互不可见互不干扰)。`);
5108
+ console.log(` 落地序列(既有机器):先落地者干净进主线;后落地者 apply --3way 三方合并(同文件不同区域自动过,同段冲突摆出裁决);并发落地由 main-repo.lock 串行。`);
5109
+ console.log(` 确认某方已死:归档后其声明自动退出重叠判定(归档件只读)。`);
5110
+ break;
5111
+ }
5058
5112
  case 'flow': {
5059
5113
  // R7 切片二:2-调用轻量协议命令族(start/done/amend-draft)——协议必需交互=2。
5060
5114
  // --json 全局透传(2026-10-06-flow-status-json):该 flag 在上方全局解析段被剥出
@@ -5806,7 +5860,7 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5806
5860
  break;
5807
5861
  }
5808
5862
  default: {
5809
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'change-delete', 'knowledge', 'platform', 'pull', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime', 'watcher', 'commit', 'push'];
5863
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'change-delete', 'checkpoint', 'overlap', 'knowledge', 'platform', 'pull', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime', 'watcher', 'commit', 'push'];
5810
5864
  const suggestion = didYouMean(command, topCommands);
5811
5865
  console.error(`❌ 未知命令: ${command}`);
5812
5866
  if (command === '--status') {
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
22
22
  import { join } from 'path'
23
- import { parseKnowledgeIndex, parseDecisionEntries, anchorFilePaths } from './knowledge-match.js'
23
+ import { parseKnowledgeIndex, parseDecisionEntries, anchorFilePaths, normalizeAnchorForCompare, matchKnowledge } from './knowledge-match.js'
24
24
  import { modulePathMatches, parseModuleMapSimple } from './modules.js'
25
25
  import { readHotCache, writeHotCache, dirStamp, fileDirStamp } from './hot-cache.js'
26
26
 
@@ -59,6 +59,47 @@ function extractFilePaths(text, cap = 15) {
59
59
  return out
60
60
  }
61
61
 
62
+ /** section 切分:按 `## ` 标题切段(标题到文末;无子标题的整篇为一个无标题段)。 */
63
+ function splitSections(text) {
64
+ const out = []
65
+ let cur = null
66
+ for (const line of text.split('\n')) {
67
+ const m = /^##\s+(.+)$/.exec(line)
68
+ if (m) {
69
+ cur = { title: m[1].trim(), body: '' }
70
+ out.push(cur)
71
+ continue
72
+ }
73
+ if (cur) cur.body += `${line}\n`
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** section 级正文路径提取(2026-11-02-scope-recall FR-01):用归一化锚点匹配 section 标题
79
+ * (长度≥6 的双向前缀容忍,兼容 INDEX slug 与 heading 的 emoji/标点差异),只对命中 section
80
+ * 的文本跑路径提取——全文档粒度实测噪声不可接受(单路径最多挂 65 个条目)。
81
+ * 无锚点条目退化为整篇(保守)。文件读取按次缓存。 */
82
+ function sectionFilePaths(knowledgeDir, ent, cache) {
83
+ const file = posix(ent.file)
84
+ if (!file.endsWith('.md')) return []
85
+ let text = cache.get(file)
86
+ if (text === undefined) {
87
+ try { text = readFileSync(join(knowledgeDir, file), 'utf8').replace(/\r\n?/g, '\n') } catch { text = null }
88
+ cache.set(file, text)
89
+ }
90
+ if (!text) return []
91
+ const key = normalizeAnchorForCompare(ent.anchor)
92
+ if (!key) return extractFilePaths(text, 15).filter((p) => p.includes('/')) // 无锚点整篇回退同样过滤裸名词
93
+ const hit = splitSections(text).find((s) => {
94
+ const hn = normalizeAnchorForCompare(s.title)
95
+ return hn === key || (hn.length >= 6 && key.length >= 6 && (hn.startsWith(key) || key.startsWith(hn)))
96
+ })
97
+ // 裸名词过滤(2026-11-02-knowledge-engine-tail FR-03):section 正文常含 Next.js/Node.js/
98
+ // local.yaml/model.py 这类技术名 token——纯词法规则「必须含 /」才建边(存在性判断会让图依赖
99
+ // 构建时文件系统状态、破坏缓存确定性;只改 section 级,模块卡/scan 采集不动)
100
+ return hit ? extractFilePaths(hit.body, 15).filter((p) => p.includes('/')) : []
101
+ }
102
+
62
103
  // ── 构建器 ──
63
104
 
64
105
  function buildKnowledgeGraphFresh(specRoot) {
@@ -94,11 +135,24 @@ function buildKnowledgeGraphFresh(specRoot) {
94
135
  let routeCount = 0
95
136
  if (existsSync(join(knowledgeDir, 'INDEX.md'))) {
96
137
  addNode('doc:INDEX', 'doc', 'knowledge/INDEX.md', { kind: 'manual-index' })
138
+ const docCache = new Map()
97
139
  for (const ent of indexEntries) {
98
140
  if (/^(decisions|fr|proposed)\//.test(posix(ent.file))) continue // 决策/FR 有自己的节点面
99
141
  const id = ent.anchor ? `entry:${ent.file}#${ent.anchor}` : `entry:${ent.file}`
100
- addNode(id, 'entry', ent.display || ent.anchor || ent.file, { file: ent.file, keywords: ent.keywords })
142
+ addNode(id, 'entry', ent.display || ent.anchor || ent.file, {
143
+ file: ent.file,
144
+ keywords: ent.keywords,
145
+ category: ent.category || '',
146
+ anchor: ent.anchor || '',
147
+ display: ent.display || '',
148
+ })
101
149
  if (addEdge('doc:INDEX', id, 'route', ent.keywords[0] || '')) routeCount++
150
+ // section 级 doc-refs(FR-01,2026-11-02-scope-recall):手工条目标题路径 → file 边
151
+ // (medium 强度——scope 层显式消费,graphPath/impact 不受影响)
152
+ for (const p of sectionFilePaths(knowledgeDir, ent, docCache)) {
153
+ addNode(p, 'file', p.split('/').pop())
154
+ addEdge(id, p, 'doc-refs', '手工条目 section 路径')
155
+ }
102
156
  }
103
157
  }
104
158
 
@@ -349,7 +403,7 @@ function buildKnowledgeGraphFresh(specRoot) {
349
403
  // 陈旧边界(有意接受):指纹感知本目录文件级变化与目录增删,但 docs 树深层就地重写若
350
404
  // 未触及被 stat 的文件面则漏——TTL 10min 硬上限兜底,advisory 面有界陈旧可接受。
351
405
 
352
- const GRAPH_CACHE_VERSION = 1
406
+ const GRAPH_CACHE_VERSION = 4
353
407
  const GRAPH_CACHE_TTL_MS = 10 * 60 * 1000
354
408
 
355
409
  function statSignature(p) {
@@ -375,6 +429,13 @@ function knowledgeGraphFingerprint(specRoot) {
375
429
  } catch { /* docs/ 缺席 = 无项目面(合法形态)*/ }
376
430
  return {
377
431
  index: statSignature(join(knowledgeDir, 'INDEX.md')),
432
+ // 手工文档 stat(FR-01):section doc-refs 依赖 INDEX 引用的手工文档正文——编辑必须触发重建
433
+ manualDocs: existsSync(join(knowledgeDir, 'INDEX.md'))
434
+ ? parseKnowledgeIndex(knowledgeDir)
435
+ .filter((e) => !/^(decisions|fr|proposed)\//.test(posix(e.file)))
436
+ .map((e) => [e.file, statSignature(join(knowledgeDir, e.file))])
437
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0])))
438
+ : null,
378
439
  decisions: fileDirStamp(join(knowledgeDir, 'decisions')),
379
440
  fr: fileDirStamp(join(knowledgeDir, 'fr')),
380
441
  projects,
@@ -832,18 +893,21 @@ export function graphNodesSearch(graph, search, limit = 20) {
832
893
  // ═══════════════════════════════════════════════════════════════
833
894
 
834
895
  /** scope 文件集 → 可达知识集(decisionHits 形态 + entries 素材)。
835
- * 遍历:file ←anchors← decision(+supersedes 链一跳)→ from-change/belongs-module 侧线;
836
- * FR 侧:file ←deliverables(变更维)与 anchors 直连。rejected ∪ 死路 保底进场(防复潮)。 */
896
+ * 遍历:file ←anchors← decision/FR(直连,score=2);supersedes 链一跳;
897
+ * file ←doc-refs← 手工条目(section 级正文路径,medium 直证边 → entries);
898
+ * 两跳防复潮:file ←deliverables← change ←from-change← rejected/deathPath 决策(score=1)。
899
+ * 排序:rejected/死路优先 → direct>two-hop → 变更名日期降序 → id。引擎 cap 20。 */
837
900
  export function scopeRecall(graph, scopeFiles) {
838
901
  const scopes = [...new Set((Array.isArray(scopeFiles) ? scopeFiles : [scopeFiles]).map(posix).filter(Boolean))]
839
902
  if (scopes.length === 0) return null
903
+ const scopeSet = new Set(scopes)
840
904
  const strong = graph.edges.filter((e) => EDGE_STRENGTH[e.type] === 'strong')
841
905
  const reachable = new Set()
842
906
  // 一跳:文件 ←anchors← 决策/FR
843
907
  for (const e of strong) {
844
- if (e.type === 'anchors' && scopes.includes(e.t)) reachable.add(e.s)
908
+ if (e.type === 'anchors' && scopeSet.has(posix(e.t))) reachable.add(e.s)
845
909
  // 变更维:scope 命中变更交付面(scope 可能传变更名而非文件——调用方宽松契约)
846
- if (e.type === 'deliverables' && scopes.includes(e.s)) reachable.add(e.t)
910
+ if (e.type === 'deliverables' && scopeSet.has(posix(e.s))) reachable.add(e.t)
847
911
  }
848
912
  // supersedes 链一跳(版本链追新/旧)
849
913
  for (const id of [...reachable]) {
@@ -853,37 +917,84 @@ export function scopeRecall(graph, scopeFiles) {
853
917
  else if (e.t === id && graph.nodes.has(e.s)) reachable.add(e.s)
854
918
  }
855
919
  }
920
+ // 手工条目 doc-refs(FR-01):medium 边显式消费——不进 strong 集,graphPath/impact 语义不动
921
+ const entryReachable = new Set()
922
+ for (const e of graph.edges) {
923
+ if (e.type !== 'doc-refs' || !scopeSet.has(posix(e.t))) continue
924
+ const n = graph.nodes.get(e.s)
925
+ if (n && n.type === 'entry') entryReachable.add(e.s)
926
+ }
927
+ // 两跳防复潮(FR-02):file ←deliverables← change ←from-change← 决策,只收 rejected/deathPath
928
+ const changesOf = new Set()
929
+ for (const e of graph.edges) {
930
+ if (e.type === 'deliverables' && scopeSet.has(posix(e.t))) changesOf.add(e.s)
931
+ }
932
+ const twoHop = new Set()
933
+ for (const e of graph.edges) {
934
+ if (e.type !== 'from-change' || !changesOf.has(e.t)) continue
935
+ const n = graph.nodes.get(e.s)
936
+ if (!n || n.type !== 'decision') continue
937
+ const h = n.attrs.hit || {}
938
+ if (h.status === 'rejected' || h.deathPath) twoHop.add(e.s)
939
+ }
856
940
  const decisions = []
857
- const frs = []
858
941
  for (const id of reachable) {
859
942
  const n = graph.nodes.get(id)
860
943
  if (!n) continue
861
944
  if (n.type === 'decision') decisions.push(n)
862
- else if (n.type === 'fr') frs.push(n)
945
+ // 注:FR 不参与 scope 两跳(图中无内部 anchors/deliverables 边指向 FR 节点;FR 召回由
946
+ // 域路由 digest(readActiveFrDigest + rankFrDigestForInjection)承载——
947
+ // 2026-11-02-knowledge-engine-tail 删除恒 0 的 frCount 字段)
948
+ }
949
+ // decisionHits:parseDecisionEntries 原生形态(attrs.hit 直出)+ score(direct=2/two-hop=1,
950
+ // 渲染面 h.status==='rejected' && score>0 过滤器依赖)
951
+ const changeDate = (c) => {
952
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(c || ''))
953
+ return m ? m[1] : ''
954
+ }
955
+ const hits = []
956
+ const seen = new Set()
957
+ const pushHit = (n, score, via) => {
958
+ const base = n.attrs.hit || {}
959
+ // 幽灵/畸形过滤(2026-11-02-knowledge-fusion FR-02):supersedes 链上的 ghost 决策节点
960
+ // 无 attrs.hit——真实库已复现 {file:undefined,id:undefined} 的畸形 hit 扩散到消费面
961
+ if (n.attrs.ghost || !base.file || !base.id) return
962
+ // 去重键含 change(2026-11-02-scope-recall 评审 P2):D-id 是变更内局部号,跨变更同号常见——
963
+ // 只按 file#id 去重会让 implemented 直连条目抢先占用键、吞掉 rejected 两跳(渲染面失效)
964
+ const key = `${base.file}#${base.id}#${base.change || ''}`
965
+ if (seen.has(key)) return
966
+ seen.add(key)
967
+ hits.push({ ...base, file: base.file, score, via })
968
+ }
969
+ for (const n of decisions) pushHit(n, 2, 'direct')
970
+ for (const id of twoHop) {
971
+ const n = graph.nodes.get(id)
972
+ if (n) pushHit(n, 1, 'two-hop')
863
973
  }
864
- // decisionHits:parseDecisionEntries 原生形态(attrs.hit 直出——零重解析),rejected∪死路优先
865
- const hits = decisions.map((n) => ({ ...n.attrs.hit, file: n.attrs.hit.file }))
866
974
  const death = (h) => h.status === 'rejected' || h.deathPath
867
- hits.sort((a, b) => ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0)) || ((b.status === 'rejected' ? 1 : 0) - (a.status === 'rejected' ? 1 : 0)))
868
- const rejectedFirst = [...hits.filter(death), ...hits.filter((h) => !death(h))]
869
- if (rejectedFirst.length > 20) rejectedFirst.length = 20
870
- const entries = rejectedFirst.slice(0, 3).map((h) => ({
871
- category: 'Decisions',
872
- keywords: ['scope'],
873
- file: h.file,
874
- anchor: h.id,
875
- display: `${h.file}#${h.id}(scope 遍历召回)`,
876
- }))
877
- return { decisionHits: rejectedFirst, entries, frCount: frs.length, scopeSize: scopes.length }
975
+ hits.sort((a, b) => (Number(death(b)) - Number(death(a)))
976
+ || (b.score - a.score)
977
+ || (changeDate(b.change) > changeDate(a.change) ? 1 : changeDate(b.change) < changeDate(a.change) ? -1 : 0)
978
+ || String(a.id).localeCompare(String(b.id)))
979
+ if (hits.length > 20) hits.length = 20
980
+ // 手工条目(FR-01):doc-refs 命中的 entry 节点,cap 3
981
+ const entries = [...entryReachable].map((id) => {
982
+ const n = graph.nodes.get(id)
983
+ const a = n.attrs || {}
984
+ const file = a.file || String(id).replace(/^entry:/, '').split('#')[0]
985
+ return { category: a.category || 'Scoped', keywords: ['scope'], file, anchor: a.anchor || '', display: a.display || n.label }
986
+ }).slice(0, 3)
987
+ return { decisionHits: hits, entries, scopeSize: scopes.length }
878
988
  }
879
989
 
880
- /** scopeRecall → matchKnowledge 同构四键返回(knowledge-vector 消费——策略面全本地解析)。 */
990
+ /** scopeRecall → matchKnowledge 同构四键返回(knowledge-vector 消费——策略面全本地解析)。
991
+ * entries-only 场景(只命中手工条目、无决策)也必须返回——不得因 decisionHits 空整层短路。 */
881
992
  export function buildScopeRecallResult(recall) {
882
- if (!recall || recall.decisionHits.length === 0) return null
993
+ if (!recall || (recall.decisionHits.length === 0 && recall.entries.length === 0)) return null
883
994
  return {
884
995
  matched: true,
885
996
  entries: recall.entries,
886
- report: `Status: scope-traversal matched (${recall.decisionHits.length} entries from ${recall.scopeSize} scope files)`,
997
+ report: `Status: scope-traversal matched (${recall.decisionHits.length} decisions, ${recall.entries.length} entries from ${recall.scopeSize} scope files)`,
887
998
  json: {
888
999
  matched: true, entry_count: recall.entries.length, scope: true,
889
1000
  entries: recall.entries.map((e) => ({ file: e.file, anchor: e.anchor, keywords: e.keywords, category: e.category })),
@@ -892,6 +1003,113 @@ export function buildScopeRecallResult(recall) {
892
1003
  }
893
1004
  }
894
1005
 
1006
+ // ═══════════════════════════════════════════════════════════════
1007
+ // 检索融合(2026-11-02-knowledge-fusion)
1008
+ // 路由命中原先 early-return,scope 层(热文件触达的 rejected/手工条目)永远不可达。
1009
+ // 契约(子代理审查修正版):只追加不重排——base 的 decisionHits/entries 原对象原序不动,
1010
+ // scope 独有命中进 scopeDecisionHits/scopeEntries(带 via provenance);不做跨通道 score
1011
+ // 排序(路由 Jaccard / scope 2/1 / 向量 / 词片尺度不可比);空 scope/零命中返回 base 原对象。
1012
+ // ═══════════════════════════════════════════════════════════════
1013
+
1014
+ /** 融合纯函数:base 路由结果 + recall(scopeRecall)→ 追加字段的新对象;无可追加时返回 base。 */
1015
+ export function fuseScopeRecall(base, recall) {
1016
+ if (!base || !base.matched || !recall) return base
1017
+ const dKey = (h) => `${h.file}#${h.id}#${h.change || ''}`
1018
+ const eKey = (e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)
1019
+ const seenD = new Set((base.decisionHits || []).map(dKey))
1020
+ const seenE = new Set((base.entries || []).map(eKey))
1021
+ const addD = []
1022
+ for (const h of recall.decisionHits || []) {
1023
+ if (!h || !h.file || !h.id) continue // 畸形命中过滤(ghost 已在 scopeRecall 侧滤)
1024
+ const k = dKey(h)
1025
+ if (seenD.has(k)) continue
1026
+ seenD.add(k)
1027
+ addD.push({ ...h, via: h.via || 'scope' })
1028
+ if (addD.length >= 20) break
1029
+ }
1030
+ const addE = []
1031
+ for (const e of recall.entries || []) {
1032
+ if (!e || !e.file) continue
1033
+ const k = eKey(e)
1034
+ if (seenE.has(k)) continue
1035
+ seenE.add(k)
1036
+ addE.push({ ...e, via: 'scope' })
1037
+ if (addE.length >= 3) break
1038
+ }
1039
+ if (addD.length === 0 && addE.length === 0) return base
1040
+ return {
1041
+ ...base,
1042
+ ...(addD.length > 0 ? { scopeDecisionHits: addD } : {}),
1043
+ ...(addE.length > 0 ? { scopeEntries: addE } : {}),
1044
+ scopeFused: { decisions: addD.length, entries: addE.length },
1045
+ }
1046
+ }
1047
+
1048
+ /** 键规整:机器键(design 交付清单/allowed_paths/target_files)→ 图内精确文件路径。
1049
+ * 过滤 glob、目录前缀(尾斜杠)、无扩展名形态;POSIX 归一去重。宁空不造(不猜前缀展开)。 */
1050
+ export function graphPathCandidates(files) {
1051
+ const out = []
1052
+ const seen = new Set()
1053
+ for (const raw of Array.isArray(files) ? files : [files]) {
1054
+ const p = String(raw || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
1055
+ if (!p || /[?*[{\]]/.test(p)) continue
1056
+ if (p.endsWith('/')) continue
1057
+ if (!/\.[A-Za-z0-9]{1,10}$/.test(p)) continue
1058
+ if (seen.has(p)) continue
1059
+ seen.add(p)
1060
+ out.push(p)
1061
+ }
1062
+ return out
1063
+ }
1064
+
1065
+ /** 进程级图 memo(CLI 短进程生命周期):路由命中后的融合每次多付 ~45ms 图读取,
1066
+ * 同进程多次调用(多 Wave)复用同一图对象。
1067
+ * 注(2026-11-02-knowledge-engine-tail FR-03):本 memo 无失效机制——长驻消费方(daemon 等)
1068
+ * 需自行调用 buildKnowledgeGraph 或注入失效;当前无长驻进程走此路径,standalone 分支也保持
1069
+ * buildKnowledgeGraph(指纹缓存)不统一到 memo(同进程陈旧图有实测)。 */
1070
+ const graphMemo = new Map()
1071
+ export function getKnowledgeGraph(specRoot) {
1072
+ const key = String(specRoot || '')
1073
+ if (graphMemo.has(key)) return graphMemo.get(key)
1074
+ const g = buildKnowledgeGraph(specRoot)
1075
+ graphMemo.set(key, g)
1076
+ return g
1077
+ }
1078
+
1079
+ /** 同步融合入口:matchKnowledge(路由+词片回退)→ scope 融合 → 失败原样返回路由结果。
1080
+ * execute step / Wave 的同步注入路径使用;不改 matchKnowledge 默认语义。 */
1081
+ export function matchKnowledgeWithScope(indexDir, query, opts = {}) {
1082
+ const base = matchKnowledge(indexDir, query, { includeResolved: opts.includeResolved })
1083
+ const scopeFiles = graphPathCandidates(opts.scopeFiles || [])
1084
+ if (!base || !base.matched || scopeFiles.length === 0) return base
1085
+ try {
1086
+ const specRoot = String(indexDir).replace(/[\\/]knowledge[\\/]?$/, '')
1087
+ const recall = scopeRecall(getKnowledgeGraph(specRoot), scopeFiles)
1088
+ return fuseScopeRecall(base, recall) || base
1089
+ } catch {
1090
+ return base // fail-soft:scope 构建失败不掉词片回退(FR-05)
1091
+ }
1092
+ }
1093
+
1094
+ /** scope 展示面遥测引用(2026-11-02-knowledge-engine-tail FR-02):三处注入点统一口径——
1095
+ * 只记实际展示子集(scope 否决 rejected/deathPath top3 + scopeEntries top3),按 ref 去重。
1096
+ * 独立字段 scopeMatchedFiles,不并入 matchedFiles(否则 knowledge-stats 的 neverHit 失真)。
1097
+ * includeRejected:flow 有否决独立小节 → true;prompt/execute 渲染器只展示 scopeEntries → false
1098
+ * (评审 P2-2:遥测必须与实际渲染面对齐,不得记录未展示的条目)。 */
1099
+ export function scopeTelemetryRefs(result, { includeRejected = true } = {}) {
1100
+ const shown = [
1101
+ ...(includeRejected
1102
+ ? ((result && result.scopeDecisionHits) || []).filter((h) => h.deathPath || h.status === 'rejected').slice(0, 3)
1103
+ : []),
1104
+ ...((result && result.scopeEntries) || []).slice(0, 3),
1105
+ ]
1106
+ return [...new Set(shown.map((x) => {
1107
+ if (!x) return ''
1108
+ if (x.id) return `${x.file}#${x.id}`
1109
+ return x.anchor ? `${x.file}#${x.anchor}` : x.file
1110
+ }).filter(Boolean))]
1111
+ }
1112
+
895
1113
  /** 变更 decisions.md(九字段形态)「锚点:」字段 → scope 文件集(complete 门的需求期机器键,D-004)。
896
1114
  * 兼容列表行(`- 锚点:src/x.js:12`)与裸行(`锚点:src/x.js`——归档蒸馏域文件形态)。 */
897
1115
  export function scopeFromDecisionsMd(text) {