sillyspec 3.33.8 → 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.
@@ -305,60 +305,20 @@ export function renderVerifyReceipt({ change, baseline, head, gateSummary, revie
305
305
  }
306
306
 
307
307
  /**
308
- * design 槽4(风险与死路)实质作答收割 → decisions.md(已有不覆盖)。
309
- * @returns {{harvested: boolean, reason?: string}}
308
+ * design 槽4(风险与死路)收割——已下线(2026-11-02-slot4-harvest-retire)。
309
+ *
310
+ * 历史动机(2026-09-25-thin-parity-assets):轻量变更决策产出从零到一,合成底稿条目随蒸馏
311
+ * 链进 knowledge。4A 数据对账实证产物全是模板底稿(本仓 47 条、平台 173 条),标题模板化、
312
+ * 无真否决语义——底稿资格门(series-final-cleanup)已解决行为面(不占防复潮席位),本变更
313
+ * 根治数据面:不再合成伪决策条目。风险分析内容保留在 design.md 归档冻结件(信息零丢失)。
314
+ *
315
+ * 函数保留签名与导出(flow.js 调用点已摘除,anchorBackfillCandidate 同文件导出不能删)。
316
+ * @returns {{harvested: boolean, reason: string}} 恒 {harvested:false}
310
317
  */
311
318
  export function harvestSlot4Decision({ changeDir, change }) {
312
319
  const decPath = join(changeDir, 'decisions.md')
313
320
  if (existsSync(decPath)) return { harvested: false, reason: 'decisions.md 已在场(不覆盖)' }
314
- let dText
315
- try { dText = readFileSync(join(changeDir, 'design.md'), 'utf8') } catch { return { harvested: false, reason: '无 design.md' } }
316
- const lines = dText.replace(/\r\n/g, '\n').split('\n')
317
- let inSlot = false
318
- const buf = []
319
- for (const line of lines) {
320
- if (/^<!--\s*AGENT:槽4/.test(line)) { inSlot = true; continue }
321
- if (inSlot && (/^<!--/.test(line) || /^#{1,6}\s/.test(line))) break
322
- if (inSlot) buf.push(line)
323
- }
324
- let answer = buf.join('\n').trim()
325
- // v2 纯 markdown(2026-10-04-thin-docs-v2):无槽标记时改读「风险与死路」节正文(复用
326
- // flow-review 的 v2 节读取——剥离问题原文行;单一源 DESIGN_QUESTIONS 防镜像漂移)
327
- if (!inSlot) {
328
- const risksSec = DESIGN_QUESTIONS.sections.find((s) => s.key === 'risks')
329
- answer = readV2SectionAnswer(dText, risksSec.heading, DESIGN_QUESTIONS)
330
- }
331
- if (!answer || /^不适用/.test(answer)) return { harvested: false, reason: '槽4 空/不适用' }
332
- // 锚点自动回填(2026-10-15-decision-anchor-backfill 收割端):风险段作答文本恰含一个
333
- // 路径 token → 写入 - 锚点:行(源 decisions.md 是真相源,蒸馏自然带上、人可改);
334
- // 0/多候选不写(蒸馏端渲染时还会再兜底一次同款判定)。单一源 anchorBackfillCandidate。
335
- const anchorCand = anchorBackfillCandidate(answer)
336
- const anchorLine = anchorCand ? [`- 锚点:${anchorCand}`] : []
337
- const text = [
338
- '---',
339
- `author: flow-machine-draft`,
340
- `created_at: ${new Date().toISOString()}`,
341
- '---',
342
- `# 决策记录(Decisions)— ${change}`,
343
- '',
344
- // 标题自解释(2026-10-15-harvest-title-clarify):读者面是 decisions 文档与 knowledge,
345
- // 机制出处(槽4 收割)由 frontmatter author: flow-machine-draft 承载,标题只说内容与性质。
346
- `## D-001@v1: 设计期风险与放弃方案(底稿)`,
347
- `- 类型:process`,
348
- `- 状态:confirmed`,
349
- ...anchorLine,
350
- // 类型/状态字段(2026-09-26-slot4-distill-fix):蒸馏链入选要 status∈{confirmed|accepted|rejected}
351
- // 且 type∈七类白名单,字段解析只认 `- 字段:值` 列表行形态——此前收割条目零字段致永不入选,
352
- // 「随蒸馏链进 knowledge」断链(thin-agent-tasks 教训留档归档而 knowledge 零落地的实证)。
353
- // 槽4 是定案的流程/方法论取舍,process+confirmed 语义成立。
354
- `- 答案:${answer.replace(/\s*\n+\s*/g, ' ')}`,
355
- // 正文用「答案」标签(同修):distill 落盘「理由:」行取 entry.answer——此前「- 决策:」非白名单
356
- // 字段,正文不随条目进 knowledge(骨架落盘内容丢失)。一行展平(评审 P1 清偿):字段解析是
357
- // 单行契约,多行槽4 作答经 \n 续行会静默丢后续行——收割端压平为单行(空格分隔)保全文。
358
- '',
359
- ].join('\n')
360
- writeAtomicSync(decPath, text)
361
- return { harvested: true }
321
+ return { harvested: false, reason: '底稿收割已下线(2026-11-02-slot4-harvest-retire)——风险分析保留在 design.md 归档件,不再合成伪决策条目' }
362
322
  }
363
323
 
364
324
  export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary, motiveFromInput }
@@ -303,6 +303,10 @@ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLi
303
303
  ` 没有任何声明边界时写「无声明边界」。未裁决=未审。`,
304
304
  ` 4. 作答中的防护声明(如有):声称的防护机制在代码里真实存在且生效?`,
305
305
  ` 5. 生成物同步(如涉及):类型/契约文件与实现对齐?`,
306
+ // 测试有效性与根因性(2026-10-29-open-metrics-gate task-04,ub-mode「Test Behavior, Not
307
+ // Implementation」/「Fix Root Causes」转写):从「测试存在且绿」升级到「测试有效」——空实现
308
+ // 判据给评审员可操作的假测试检验法,根因判据拦「吞崩溃 guard 冒充修复」。
309
+ ` 6. 测试有效性与根因性(假测试判据两问):空实现判据——把绑定测试所测的实现改成全部返回空的空操作,若测试仍可能全绿则测试无效(断言须测可观察行为,mock 一切零断言的摆设测试按无效报);根因判据——diff 中的修复是否只是吞崩溃/藏失败的 guard(兜底掩盖≠根因消除,须指出并按严重度裁决)。`,
306
310
  // 项目评审探针(2026-10-12-review-probe-injection):local.yaml review_probes 数据驱动注入——
307
311
  // 检查单生产权收归 CLI 的项目特有面(dogfood 实证:主代理手写清单没想到的面无人查)。
308
312
  // 未配置零注入——本段缺省时任务书与历史形态逐字一致(零回归)。
package/src/flow.js CHANGED
@@ -31,6 +31,7 @@ import { join, relative, dirname } from 'node:path'
31
31
  import { DatabaseSync } from 'node:sqlite'
32
32
  import yaml from 'js-yaml'
33
33
  import { git, gitQuiet } from './git-helper.js'
34
+ import { scopeTelemetryRefs } from './knowledge-graph.js'
34
35
  import { writeAtomicSync } from './fs-atomic.js'
35
36
  import { resolveRuntimeRoot, triggerSync, triggerSyncTerminalStrict, assertDatedChangeName } from './run/shared.js'
36
37
  import { detectUiTouch, buildUiGuidanceLines, runUiVisualProbe, readUiVisualGate, UI_EVIDENCE_FILENAME } from './ui-visual.js'
@@ -308,6 +309,7 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
308
309
  const alwaysRepo = [...scoped.always, ...scoped.repo]
309
310
  summary.scopedEntries = alwaysRepo.length
310
311
  scopedLines.push(' 🧭 工具中立性(内置规约 00,每变更恒注入):不枚举定义开放世界、不锁死开发语言/框架——配置与机制用 CLI 自有分类(批类别/路径集合),新增语言只加探测适配。')
312
+ scopedLines.push(' 🧭 反例(任务词汇——违规现场词面相似即命中):评测工具缺省写死两个仓的路径即违规——每条路径必须来自 --repo 参数或缺省当前仓;出现具体项目名/项目特定 env/硬编码仓表即违规(2026-11-02 用户原话纠正实证)。')
311
313
  for (const e of alwaysRepo.slice(0, 5)) {
312
314
  scopedLines.push(` 📌 [${e.scope}] ${e.id}:${e.summary}`)
313
315
  }
@@ -366,13 +368,42 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
366
368
  const src = km.entries.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
367
369
  lines.push(` 📚 知识命中(INDEX 关键词,按需 Read):${src}`)
368
370
  }
371
+ // routeHits 指针行(FR-04,2026-11-02-knowledge-rank-denoise):entries 移出 fr/decisions
372
+ // 路由行后,implemented 决策与 FR 的可见性由这里承载(只指路不注入正文)。
373
+ if (km.matched && (km.routeHits || []).length > 0) {
374
+ const src = km.routeHits.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
375
+ lines.push(` 🧭 决策/FR 路由命中(专用通道,按需 Read):${src}`)
376
+ }
377
+ // 融合增量(2026-11-02-knowledge-fusion FR-03):scope 否决独立小节——路由 rejected top-5
378
+ // 不被热文件历史否决挤占(独立语义:触碰过的文件曾被否决/记死路)
379
+ const scopeRejected = (km.scopeDecisionHits || []).filter((h) => h.deathPath || h.status === 'rejected').slice(0, 3)
380
+ if (scopeRejected.length > 0) {
381
+ lines.push(` 🧭 触达文件关联否决(scope 视角——触碰过的文件曾被否决/记死路,防复潮——除非复潮条件满足勿重提):`)
382
+ for (const h of scopeRejected) {
383
+ const note = h.status === 'rejected' ? (h.reason || '(未记录)') : `死路——${deathPathNote(h.reason)}`
384
+ lines.push(` - ${h.id} ${h.title}(${h.file}|${decisionChangeQualifier(h.change)}|${h.via === 'two-hop' ? '两跳' : '直连'})${h.status === 'rejected' ? '否决理由' : '⚰️'}:${note}`)
385
+ }
386
+ }
387
+ const scopeEntries = km.scopeEntries || []
388
+ if (scopeEntries.length > 0) {
389
+ const src = scopeEntries.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
390
+ lines.push(` 📚 触达文件关联手工条目(按需 Read):${src}`)
391
+ }
369
392
  // 遥测导线(2026-10-15-thin-inject-telemetry):轻量道注入此前不落 hits.jsonl——thin 常态化后
370
393
  // inject 事件流断流(本仓 10-09 后归零实证),knowledge stats 从未命中判定被盲区污染。
371
394
  // 分级字段(A2-lite):injectedFiles=正文实际注入(thin 面恒空——只出按需 Read 行)、
372
395
  // mentionedFiles=显式展示给 agent 的锚点(📚 行 top-3)。既有 matchedFiles 口径不变
373
396
  // (knowledge-stats 消费面零回归)。仅命中面非空才落盘;fail-soft(R-04)不阻断注入。
374
- const thinMatchedFiles = km.matched ? (km.entries || []).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : []
375
- if (thinMatchedFiles.length > 0 || rejected.length > 0) {
397
+ const thinMatchedFiles = km.matched ? [...(km.entries || []), ...(km.routeHits || [])].map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : []
398
+ // scope 展示面遥测(FR-02):独立字段 scopeMatchedFiles(不并入 matchedFiles——防 neverHit 失真);
399
+ // mentionedFiles 按实际展示集合(📚 top3 + 🧭 top3 + scope top3+3,去重)
400
+ const scopeMatchedFiles = km.matched ? scopeTelemetryRefs(km) : []
401
+ const mentionedFiles = [...new Set([
402
+ ...(km.entries || []).slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
403
+ ...(km.routeHits || []).slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
404
+ ...scopeMatchedFiles,
405
+ ])].slice(0, 12)
406
+ if (thinMatchedFiles.length > 0 || rejected.length > 0 || scopeMatchedFiles.length > 0) {
376
407
  try {
377
408
  const { appendKnowledgeHit } = await import('./knowledge-hits.js')
378
409
  appendKnowledgeHit(join(specBase, '.runtime'), {
@@ -381,7 +412,8 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
381
412
  query: kmQuery,
382
413
  matchedFiles: thinMatchedFiles,
383
414
  injectedFiles: [],
384
- mentionedFiles: thinMatchedFiles.slice(0, 3),
415
+ mentionedFiles,
416
+ ...(scopeMatchedFiles.length > 0 ? { scopeMatchedFiles } : {}),
385
417
  rejectedCount: rejected.length,
386
418
  source: 'thin-start',
387
419
  })
@@ -2710,13 +2742,8 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
2710
2742
  // requirements 永不进 knowledge/fr,知识复利在新默认道断流;轻量变更无 design.md,域路由
2711
2743
  // 以基线以来交付 diff 供 deliverableFiles,伪域回退同口径)
2712
2744
  if (st.substeps?.distill === 'done') { skip('distill') } else {
2713
- // 槽4收割(2026-09-25-thin-parity-assets:design「风险与死路」实质作答合成 decisions.md——
2714
- // 轻量变更决策产出从零到一;已有 decisions 不覆盖)
2715
- try {
2716
- const { harvestSlot4Decision } = await import('./flow-parity.js')
2717
- const h = harvestSlot4Decision({ changeDir, change })
2718
- if (h.harvested) console.log(`🌱 槽4(风险与死路)收割 → decisions.md(底稿——作答里的放弃方案请拆成 rejected 条目再收口;随蒸馏链进 knowledge)`)
2719
- } catch { /* 收割 best-effort */ }
2745
+ // 槽4收割已下线(2026-11-02-slot4-harvest-retire):design 风险段不再合成伪决策条目——
2746
+ // 风险分析保留在 design.md 归档件(信息零丢失),底稿条目 4A 实证为纯模板噪声
2720
2747
  // 模块变更索引机械回写(2026-10-12-thin-module-gate-and-feed 二档):命中模块的卡/sidecar
2721
2748
  // 追加一行本变更条目——零语义判断(日期/变更名/动机首行全已知),split-changelog 契约 sidecar
2722
2749
  // 优先、卡内「## 变更索引」节次之、变更名在场即跳过(断点续跑幂等)。moduleScope 由上方
package/src/index.js CHANGED
@@ -153,7 +153,7 @@ SillySpec CLI — 规范驱动开发工具包
153
153
  影子存档面:列出 task tick 自动落的存档链,预览/按路径读档
154
154
  sillyspec overlap 查询活跃变更 target-file 声明重叠(advisory——检出即附
155
155
  worktree 分流与落地序列指引)
156
- sillyspec knowledge <search --query "..." --limit N
156
+ sillyspec knowledge <search --query "..." --limit N [--include-resolved]
157
157
  | inspect --id "..."
158
158
  | validate | refresh
159
159
  | propose --title "..." --category <name>
@@ -3624,50 +3624,9 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
3624
3624
  }
3625
3625
  break;
3626
3626
  }
3627
- case 'quicklog': {
3628
- // known-issues ④ v1(2026-09-16):QUICKLOG 多会话条目交织——本会话提交 git add 整文件
3629
- // 会夹带并行会话未完成条目,只能手工四步舞(备份→剥离→commit→恢复)。本命令一键机制化:
3630
- // 切片(HEAD 基线+本会话条目块)→ 显式 pathspec 提交(QUICKLOG 文件+patches sidecar+额外
3631
- // pathspec)→ 恢复工作区(并行条目留未提交态)。参数面照抄 wt-commit 先例(--change/-m/--
3632
- // pathspec)+ 可重复 --ql(guard 缺失/损坏时的显式逃生门;含已取消条目)。
3633
- if (filteredArgs[1] !== 'commit') {
3634
- console.error(`❌ 未知 quicklog 子命令: ${filteredArgs[1] || '(空)'}——当前支持: sillyspec quicklog commit [--change <quick会话ID>] -m <信息> [--ql <ql-id>]... [-- <额外pathspec...>]`);
3635
- process.exit(1);
3636
- }
3637
- const qlRest = filteredArgs.slice(2);
3638
- const qlDd = qlRest.indexOf('--');
3639
- const qlFlags = qlDd === -1 ? qlRest : qlRest.slice(0, qlDd);
3640
- const qlExtra = qlDd === -1 ? [] : qlRest.slice(qlDd + 1);
3641
- let qlChange = null;
3642
- let qlMessage = null;
3643
- const qlIds = [];
3644
- for (let i = 0; i < qlFlags.length; i++) {
3645
- if (qlFlags[i] === '--change' && qlFlags[i + 1]) qlChange = qlFlags[++i];
3646
- else if ((qlFlags[i] === '-m' || qlFlags[i] === '--message') && qlFlags[i + 1]) qlMessage = qlFlags[++i];
3647
- else if (qlFlags[i] === '--ql' && qlFlags[i + 1]) qlIds.push(qlFlags[++i]);
3648
- }
3649
- try {
3650
- const { resolvePlatformSpecDir, resolvePlatformOpts } = await import('./progress.js');
3651
- const qlSpecBase = resolvePlatformSpecDir(dir, specDir);
3652
- const { resolveQuickSessionsDir } = await import('./run/shared.js');
3653
- const qlSessionsDir = resolveQuickSessionsDir(resolvePlatformOpts(dir, specDir), qlSpecBase);
3654
- let qlUser = 'unknown';
3655
- try { qlUser = (await import('./git-helper.js')).git(dir, ['config', 'user.name']) || 'unknown'; } catch {}
3656
- const { runQuicklogCommit } = await import('./quicklog.js');
3657
- const r = await runQuicklogCommit({
3658
- specBase: qlSpecBase, cwd: dir, gitUser: qlUser, qlIds,
3659
- changeName: qlChange, sessionsDir: qlSessionsDir,
3660
- message: qlMessage, extraPathspecs: qlExtra,
3661
- });
3662
- if (json) console.log(JSON.stringify({ command: 'quicklog commit', ...r }, null, 2));
3663
- else if (r.skipped) console.log(`⏭️ quicklog commit 无新内容跳过(HEAD 不动):${r.shortHead}(条目均已收编:${(r.skippedQlIds || []).join(', ') || '(无)'})`);
3664
- else console.log(`✅ quicklog commit 完成:${r.shortHead}(${r.files.length} 文件;收编 ${(r.committedQlIds || []).join(', ')})\n 并行会话条目已恢复为工作区未提交态;备份:${(r.backupPaths || []).join(' , ')}`);
3665
- } catch (e) {
3666
- console.error(`❌ quicklog commit 失败:${e.message}`);
3667
- process.exit(1);
3668
- }
3669
- break;
3670
- }
3627
+ // quicklog CLI 子命令已下线(2026-11-03-quicklog-business-retire):
3628
+ // quicklog 业务面(QUICKLOG 日志系统)用户已停用;工具函数(withFileLock/标题推导等)
3629
+ // 保留在 quicklog.js 供其他模块消费。knowledge-quicklog.js 解析层同批删除。
3671
3630
  case 'worktree': {
3672
3631
  const { WorktreeManager } = await import('./worktree.js');
3673
3632
  const { ProgressManager, resolveSessionIdentity } = await import('./progress.js');
@@ -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) {