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.
@@ -62,6 +62,10 @@ function isDecisionRoute(entry) {
62
62
  }
63
63
 
64
64
  const DECISION_HEADER_RE = /^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+)\s*(.*)$/
65
+
66
+ /** 底稿模板标题特征词(与 4A provenance 分类同源单一口径):harvestSlot4Decision 产的
67
+ * process 级流程取舍框架词——不是具体方案否决,deathPath 不应赋予(2026-11-02-series-final-cleanup)。 */
68
+ const TEMPLATE_TITLE_RE = /设计期风险与放弃方案|风险与死路|design\s*槽\s*4\s*收割|底稿/
65
69
  const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件|supersedes)\s*[::]\s*(.*)$/
66
70
  // 「锚点:」单独标签精确匹配读入(仿 docs-check.js parseDecisionEntries 先例)——绝不并入
67
71
  // DECISION_FIELD_RE:其 else-if 链末尾的 reason 回填(else if (!cur.reason))会把锚点值
@@ -130,7 +134,11 @@ function parseDecisionFile(filePath, file) {
130
134
  // 死路注记识别(2026-09-28-knowledge-inject-ranking):「理由」含「死路:」字面标记的教训型
131
135
  // 条目——记录它的决策本体是 accepted/implemented(死路只是理由里的注记),按状态过滤会被
132
136
  // 压制到组外,恰丢最该防复潮的(实测:D-001@v1 枚举开放世界排 148/188 位永不回显)。
133
- cur.deathPath = /死路[::]/.test(cur.reason || '')
137
+ // 底稿资格门(2026-11-02-series-final-cleanup):模板标题条目(设计期风险与放弃方案/风险
138
+ // 与死路/底稿——harvestSlot4Decision 产的 process 级流程取舍)不获 deathPath——它们不是真
139
+ // 否决,标 deathPath 会占防复潮席位且使停车场清零条件永不可满足(终审实证 4C/5/6 归档蒸馏
140
+ // 又产出 4 条底稿 D-485~488)
141
+ cur.deathPath = /死路[::]/.test(cur.reason || '') && !TEMPLATE_TITLE_RE.test(cur.title || '')
134
142
  hits.push(cur)
135
143
  }
136
144
  cur = null
@@ -264,21 +272,31 @@ function escapeRegex(s) {
264
272
  * - 过短关键词(<2 字符)视为噪音,不参与匹配
265
273
  * - 非 ASCII(中文等)用子串匹配
266
274
  * - ASCII 关键词用词边界匹配,避免子串误命中(如 "DB" 不命中 "dashboard")
275
+ * 词边界口径(2026-11-02-knowledge-rank-denoise FR-02):`_` 是词字符、`-` 是边界——
276
+ * node 不命中 node_modules,但 worktree 仍命中 kebab-case 变更名(2026-05-29-worktree-*)。
277
+ * 导出供评测适配层(eval/knowledge)复用同一判定——零行为变化。
267
278
  */
268
- function keywordMatchesContext(keyword, contextLower) {
279
+ export function keywordMatchesContext(keyword, contextLower) {
269
280
  const kw = keyword.toLowerCase().trim()
270
281
  if (kw.length < 2) return false
271
282
  if (/[^\x00-\x7f]/.test(kw)) return contextLower.includes(kw)
272
- return new RegExp(`(^|[^a-z0-9])${escapeRegex(kw)}([^a-z0-9]|$)`).test(contextLower)
283
+ return new RegExp(`(^|[^a-z0-9_])${escapeRegex(kw)}([^a-z0-9_]|$)`).test(contextLower)
273
284
  }
274
285
 
275
286
  /** 查询词片抽取:CJK 连续段 bigram + ≥4 字符 ASCII 词(小写;短词与标点数字不参与;
276
- * 纯 ASCII 查询不产词片——中文知识库,英文查询维持既有零命中行为)。 */
277
- function queryShingles(taskContext) {
287
+ * 纯 ASCII 查询不产词片——中文知识库,英文查询维持既有零命中行为)。
288
+ * snake/kebab 复合词保留整词并额外产出 ≥4 字符拆分片段(FR-02:查询 worktree_apply 时
289
+ * worktree、apply 仍可命中正文只写 worktree 的条目)。导出供评测与单测(零行为变化)。 */
290
+ export function queryShingles(taskContext) {
278
291
  const raw = String(taskContext || '')
279
292
  const shingles = new Set()
280
293
  if (/[\u4e00-\u9fff]/.test(raw)) {
281
- for (const w of raw.toLowerCase().match(/[a-z][a-z0-9-]{3,}/g) || []) shingles.add(w)
294
+ for (const w of raw.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) || []) {
295
+ shingles.add(w)
296
+ if (/[_-]/.test(w)) {
297
+ for (const part of w.split(/[_-]/)) if (part.length >= 4) shingles.add(part)
298
+ }
299
+ }
282
300
  }
283
301
  for (const seg of raw.match(/[\u4e00-\u9fff]+/g) || []) {
284
302
  for (let i = 0; i < seg.length - 1; i++) shingles.add(seg.slice(i, i + 2))
@@ -286,10 +304,11 @@ function queryShingles(taskContext) {
286
304
  return [...shingles]
287
305
  }
288
306
 
289
- /** ASCII 词片词边界命中(CJK 词片 includes 即可);转义防正则注入。 */
307
+ /** ASCII 词片词边界命中(CJK 词片 includes 即可);转义防正则注入。
308
+ * 边界口径与 keywordMatchesContext 统一:`_` 词字符、`-` 边界(FR-02)。 */
290
309
  function asciiWordHit(sh, text) {
291
310
  if (!/^[a-z]/.test(sh)) return text.includes(sh)
292
- return new RegExp(`(^|[^a-z0-9-])${sh.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}([^a-z0-9-]|$)`, 'i').test(text)
311
+ return new RegExp(`(^|[^a-z0-9_])${sh.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_]|$)`, 'i').test(text)
293
312
  }
294
313
 
295
314
  /** 停车场文件(未归类 unmapped 域——decisions 与 fr 两面):检索核心零返回,文件本身
@@ -302,6 +321,44 @@ export function isParkingFile(file) {
302
321
  return PARKING_FILES.has(String(file || '').replace(/\\/g, '/'))
303
322
  }
304
323
 
324
+ /** 已修复条目显式词(FR-03,2026-11-02-knowledge-rank-denoise):只认 INDEX display/anchor
325
+ * 的显式词,不读正文 heading(锚点→heading 归一脆弱);绿色标记不作唯一键(平台实测
326
+ * 6 条绿色非词条目中 4 条是现行事实/债务,误杀代价高于漏 2 条已解决项)。 */
327
+ const RESOLVED_WORDS = /已修复|已解决|已退役/
328
+
329
+ /** 锚点/标题容错归一(坑 knowledge-hits-anchor-drift T4):锚点三代规则并存(CLI slug /
330
+ * INDEX 手写 / 平台 slugify_anchor)对 emoji 前缀、点号、斜杠、首尾中划线的处理互不一致
331
+ * ——归一到「仅字母数字 + 小写」后比对,三代产物归一相等;截断(60 字符)由调用方前缀
332
+ * 容忍处理。单一源:knowledge 命令校验与图谱 section 匹配(2026-11-02-scope-recall)共用。 */
333
+ export function normalizeAnchorForCompare(s) {
334
+ return String(s || '').replace(/[^\p{L}\p{N}]+/gu, '').toLowerCase()
335
+ }
336
+
337
+ /** 条目是否「已知问题-已修复」类(默认注入/检索排除;--include-resolved 才返回)。
338
+ * 双源判定(2026-11-03 接线):① INDEX display/anchor 标题词(既有——覆盖未迁移到状态行格式的条目)
339
+ * ② 目标文件正文状态行(`- 状态:已修复/已解决/已退役`——4C 落盘后主判据)。任一命中即 resolved。 */
340
+ function isResolvedRoute(entry, knowledgeRoot) {
341
+ const file = String(entry.file || '').replace(/\\/g, '/')
342
+ const isKnownIssues = entry.category === 'Known Issues' || /(^|\/)known-issues\.md$/.test(file)
343
+ if (!isKnownIssues) return false
344
+ if (RESOLVED_WORDS.test(`${entry.display || ''} ${entry.anchor || ''}`)) return true
345
+ // 正文状态行判据:读目标文件,按 anchor 匹配 heading 后查段内状态行
346
+ if (knowledgeRoot && entry.anchor) {
347
+ try {
348
+ const content = readFileSync(join(knowledgeRoot, file), 'utf8')
349
+ const key = normalizeAnchorForCompare(entry.anchor)
350
+ const blocks = content.replace(/\r\n?/g, '\n').split(/^## /m).slice(1)
351
+ for (const b of blocks) {
352
+ const hn = normalizeAnchorForCompare(b.split('\n')[0])
353
+ if (hn === key || (hn.length >= 6 && key.length >= 6 && (hn.startsWith(key) || key.startsWith(hn)))) {
354
+ return /^\s*-\s*状态[::]\s*(已修复|已解决|已退役)/m.test(b)
355
+ }
356
+ }
357
+ } catch { /* 文件不可读回退标题词 */ }
358
+ }
359
+ return false
360
+ }
361
+
305
362
  /**
306
363
  * 零命中查询侧词片回退(2026-09-29-decision-route-vocab):路由行零命中时,只测「查询自己的
307
364
  * 词片」——逐 decisions 域文件数词片在条目文本(标题∪理由)的出现次数,落在「跨条目复现但
@@ -380,7 +437,7 @@ export function fallbackByQueryShingles(indexDir, taskContext) {
380
437
  * 命中的 Decisions 路由行所指向 decisions/<域>.md 内的全部 D-xxx@vN 条目,rejected 优先排序;
381
438
  * 无 decisions 库 / 路由行未命中 → decisionHits: [](其余字段行为与现状一致)。
382
439
  */
383
- export function matchByRouting(indexDir, taskContext) {
440
+ export function matchByRouting(indexDir, taskContext, opts = {}) {
384
441
  const indexPath = join(indexDir, 'INDEX.md')
385
442
 
386
443
  // INDEX.md 不存在
@@ -413,19 +470,59 @@ export function matchByRouting(indexDir, taskContext) {
413
470
  // 只命中停车场行 → 按零命中处理(hybrid 链继续走向量/图/词片层),json.parkingFiltered
414
471
  // 披露被滤文件供 search 面提示人工直查(可见性不因去噪丢失)。
415
472
  const parkingFiltered = [...new Set(matchedRaw.filter((e) => isParkingFile(e.file)).map((e) => e.file))]
416
- const matched = matchedRaw.filter((e) => !isParkingFile(e.file))
473
+ const parkingExcluded = matchedRaw.filter((e) => !isParkingFile(e.file))
474
+ // 已修复过滤(FR-03,2026-11-02-knowledge-rank-denoise):known-issues 显式词条目默认排除,
475
+ // includeResolved 才返回;json.resolvedFiltered 披露(仿 parkingFiltered)。
476
+ const resolvedFiltered = opts.includeResolved ? [] : parkingExcluded.filter((e) => isResolvedRoute(e, indexDir))
477
+ const resolvedFiles = [...new Set(resolvedFiltered.map((e) => e.file))]
478
+ const resolvedNote = resolvedFiles.length > 0
479
+ ? { resolvedFiltered: resolvedFiles, resolvedNote: '已修复条目已过滤——需查看加 --include-resolved' }
480
+ : {}
481
+ const matchedAll = parkingExcluded.filter((e) => !resolvedFiltered.includes(e))
482
+
483
+ // 路由行分离(FR-04,2026-11-02-knowledge-rank-denoise):fr/decisions 路由行不进面向人的
484
+ // entries(各有 decisionHits / readActiveFrDigest 专用通道),改走条件式 routeHits——
485
+ // 消费面据此出指针行,避免静默丢失 implemented 决策与 FR 的可见性;顶层键仅在非空时出现
486
+ // (无路由行命中的场景保持旧四键+decisionHits 结构不变)。
487
+ const isRouteRow = (e) => isDecisionRoute(e) || /^fr\//.test(String(e.file || '').replace(/\\/g, '/'))
488
+ const humanEntries = matchedAll.filter((e) => !isRouteRow(e))
489
+ const routeHits = matchedAll.filter(isRouteRow)
490
+
491
+ // DF 相关度排序(FR-05,2026-11-02-knowledge-rank-denoise):分母 = entries 可展示语料
492
+ // (排除 fr/decisions 路由行、停车场、已修复条目);命中数优先 → Σ(1/DF) 破平 → INDEX 行序兜底。
493
+ // 泛词(出现在大量路由行的流程词)权重被压缩,长尾具体词上浮;同分顺序确定(重复运行一致)。
494
+ const humanCorpus = allEntries.filter((e) => !isRouteRow(e) && !isParkingFile(e.file) && !isResolvedRoute(e, indexDir))
495
+ const dfMap = new Map()
496
+ for (const e of humanCorpus) {
497
+ const seenKw = new Set()
498
+ for (const kw of e.keywords) {
499
+ const k = String(kw).toLowerCase()
500
+ if (seenKw.has(k)) continue
501
+ seenKw.add(k)
502
+ dfMap.set(k, (dfMap.get(k) || 0) + 1)
503
+ }
504
+ }
505
+ const scoredHuman = humanEntries.map((e, i) => {
506
+ const matchedKws = [...new Set(e.keywords.map((k) => String(k).toLowerCase()))]
507
+ .filter((k) => keywordMatchesContext(k, contextLower))
508
+ const hits = matchedKws.length
509
+ const rarity = matchedKws.reduce((s, k) => s + 1 / Math.max(1, dfMap.get(k) || 1), 0)
510
+ return { e, i, hits, rarity }
511
+ })
512
+ scoredHuman.sort((a, b) => (b.hits - a.hits) || (b.rarity - a.rarity) || (a.i - b.i))
513
+ const rankedHuman = scoredHuman.map((x) => x.e)
417
514
 
418
- if (matched.length === 0) {
515
+ if (rankedHuman.length === 0 && routeHits.length === 0) {
419
516
  return {
420
517
  matched: false,
421
518
  entries: [],
422
519
  report: 'Status: no matches',
423
- json: { matched: false, entry_count: 0, entries: [], ...(parkingFiltered.length > 0 ? { parkingFiltered } : {}) },
520
+ json: { matched: false, entry_count: 0, entries: [], ...(parkingFiltered.length > 0 ? { parkingFiltered } : {}), ...resolvedNote },
424
521
  decisionHits: []
425
522
  }
426
523
  }
427
524
 
428
- const sources = matched.map(e => {
525
+ const sources = [...rankedHuman, ...routeHits].map(e => {
429
526
  const base = e.anchor ? `${e.file}#${e.anchor}` : e.file
430
527
  return ` - ${base}`
431
528
  }).join('\n')
@@ -434,20 +531,25 @@ export function matchByRouting(indexDir, taskContext) {
434
531
  'Knowledge Context',
435
532
  '─────────────────',
436
533
  `Status: matched`,
437
- `Entries: ${matched.length}`,
534
+ `Entries: ${rankedHuman.length}`,
535
+ ...(routeHits.length > 0 ? [`Route hits: ${routeHits.length}`] : []),
438
536
  'Sources:',
439
537
  sources
440
538
  ].join('\n')
441
539
 
442
540
  const json = {
443
541
  matched: true,
444
- entry_count: matched.length,
445
- entries: matched.map(e => ({
542
+ entry_count: rankedHuman.length,
543
+ entries: rankedHuman.map(e => ({
446
544
  file: e.file,
447
545
  anchor: e.anchor,
448
546
  keywords: e.keywords,
449
547
  category: e.category
450
- }))
548
+ })),
549
+ ...(routeHits.length > 0 ? {
550
+ routeHits: routeHits.map(e => ({ file: e.file, anchor: e.anchor, keywords: e.keywords, category: e.category })),
551
+ } : {}),
552
+ ...resolvedNote
451
553
  }
452
554
 
453
555
  // decisionHits(task-04):任务上下文命中的 Decisions 路由行 → 解析其指向的
@@ -457,7 +559,7 @@ export function matchByRouting(indexDir, taskContext) {
457
559
  // D-001@v1 排 148/188 位)。重叠判据复用 fr-index frTitleOverlap(字符 bigram,
458
560
  // 封闭面非语义判定)。
459
561
  // 不引入新顶层 hits 字段,不改 matched/entries/report/json 四个既有键。
460
- const decisionRoutes = matched.filter(isDecisionRoute)
562
+ const decisionRoutes = routeHits.filter(isDecisionRoute)
461
563
  const allDecisionHits = decisionRoutes.length > 0 ? parseDecisionEntries(indexDir, decisionRoutes) : []
462
564
  // 评分文本 = id+标题;评分前剥除数字与标点(2026-09-28-knowledge-score-denoise:{DECISION_HITS}
463
565
  // 场景 taskContext=ASCII 变更名,日期「2026-09-28」与 id「D-009@v1」共享 -0/09 数字 bigram 即
@@ -473,7 +575,14 @@ export function matchByRouting(indexDir, taskContext) {
473
575
  ...allDecisionHits.filter((h) => h.status !== 'rejected' && !h.deathPath).sort(byOverlapDesc),
474
576
  ].map((h) => ({ ...h, score: relScore(h) }))
475
577
 
476
- return { matched: true, entries: matched, report, json, decisionHits }
578
+ return {
579
+ matched: true,
580
+ entries: rankedHuman,
581
+ ...(routeHits.length > 0 ? { routeHits } : {}),
582
+ report,
583
+ json,
584
+ decisionHits,
585
+ }
477
586
  }
478
587
 
479
588
  /**
@@ -481,8 +590,8 @@ export function matchByRouting(indexDir, taskContext) {
481
590
  * 异步三层入口(平台向量优先于本地回退)见 knowledge-vector.js matchKnowledgeHybrid
482
591
  * (2026-09-29-knowledge-vector-recall)——同步调用方与既有测试全部走本函数。
483
592
  */
484
- export function matchKnowledge(indexDir, taskContext) {
485
- const r = matchByRouting(indexDir, taskContext)
593
+ export function matchKnowledge(indexDir, taskContext, opts = {}) {
594
+ const r = matchByRouting(indexDir, taskContext, opts)
486
595
  if (r.matched) return r
487
596
  const fb = fallbackByQueryShingles(indexDir, taskContext)
488
597
  return fb || r
@@ -4,6 +4,9 @@
4
4
  * 职责:聚合 .runtime/knowledge-hits.jsonl 遥测事件流(task-01 KnowledgeHitsAPI 写入:
5
5
  * task-04 注入点写 type:inject、task-02 classify 写 type:classify),输出近 N 天命中矩阵
6
6
  * (文件 × 次数 × 最近命中时间)与从未命中清单(对照 INDEX.md 全集标注疑似死重)。
7
+ * 注(2026-11-02-knowledge-engine-tail FR-02):scope 展示面走独立字段 `scopeMatchedFiles`
8
+ * (top3+3 展示子集)——命中矩阵/neverHit 只按 `matchedFiles`(查询通道)统计,scope 通道
9
+ * 不参与 neverHit 判定(防同一决策域文件反复计命中原决定死重)。
7
10
  * 纯只读:不写 hits/INDEX/知识文件任何输入;死重清单只报告,不自动删除/合并(非目标)。
8
11
  *
9
12
  * 计数口径:
@@ -236,6 +236,24 @@ export function buildResultFromPlatform(indexDir, pvResults) {
236
236
  }
237
237
  }
238
238
 
239
+ /**
240
+ * base(路由/向量结果)+ scopeFiles → 融合结果(2026-11-02-knowledge-engine-tail FR-01)。
241
+ * 路由命中与向量命中两条路径共用本实现(消除「同一 early-return 病只修一半」):
242
+ * scopeFiles 经 graphPathCandidates 规整;scope 构建/遍历失败返回 base 原对象(fail-soft)。
243
+ */
244
+ async function fuseBaseWithScope(base, indexDir, scopeFiles) {
245
+ if (!base || !base.matched) return base
246
+ const raw = Array.isArray(scopeFiles) ? scopeFiles.filter(Boolean) : []
247
+ if (raw.length === 0) return base
248
+ try {
249
+ const { graphPathCandidates, getKnowledgeGraph, scopeRecall, fuseScopeRecall } = await import('./knowledge-graph.js')
250
+ const files = graphPathCandidates(raw)
251
+ if (files.length === 0) return base
252
+ const specRoot = String(indexDir).replace(/[\\/]knowledge[\\/]?$/, '')
253
+ return fuseScopeRecall(base, scopeRecall(getKnowledgeGraph(specRoot), files)) || base
254
+ } catch { return base }
255
+ }
256
+
239
257
  /**
240
258
  * 异步三层检索入口(四消费方使用:flow 注入段/complete 门/prompt {DECISION_HITS}/
241
259
  * knowledge search CLI):路由 → 平台向量(已连接且开关开)→ scope 遍历 → 本地词片 → 空。
@@ -245,16 +263,23 @@ export function buildResultFromPlatform(indexDir, pvResults) {
245
263
  * 缺省/空数组 → 遍历层整体跳过,行为与不携带该参数的旧行为逐字段一致(回归钉死)。
246
264
  */
247
265
  export async function matchKnowledgeHybrid(indexDir, taskContext, opts = {}) {
248
- const r = matchByRouting(indexDir, taskContext)
249
- if (r.matched) return r
266
+ const r = matchByRouting(indexDir, taskContext, opts)
267
+ const scopeFiles = Array.isArray(opts.scopeFiles) ? opts.scopeFiles.filter(Boolean) : []
268
+ if (r.matched) {
269
+ // 融合(2026-11-02-knowledge-fusion):路由命中后仍跑 scope——热文件触达的 rejected/手工条目
270
+ // 原先被 early-return 永久遮住。只追加 scopeDecisionHits/scopeEntries(不重排路由结果);
271
+ // scope 构建失败原样返回路由结果;仍不跑平台向量(路由命中零网络)。
272
+ return fuseBaseWithScope(r, indexDir, scopeFiles)
273
+ }
250
274
  const cwd = opts.cwd || dirname(dirname(String(indexDir)))
251
275
  if (vectorEnabled(cwd)) {
252
276
  const pv = await platformVectorRecall({ cwd, query: taskContext, limit: opts.limit || 10 })
253
277
  const built = pv ? buildResultFromPlatform(indexDir, pv.results) : null
254
- if (built) return built
278
+ // 向量命中路径同样融合(FR-01 对称性):平台向量自动探测开启、词面弱、路由零命中时,
279
+ // scope 增量不应因向量 early-return 丢失。
280
+ if (built) return fuseBaseWithScope(built, indexDir, scopeFiles)
255
281
  }
256
282
  // scope 遍历层(第三层):只在 scope 非空且向量零命中后接管;fail-soft(图构建异常静默降级词片)
257
- const scopeFiles = Array.isArray(opts.scopeFiles) ? opts.scopeFiles.filter(Boolean) : []
258
283
  if (scopeFiles.length > 0) {
259
284
  try {
260
285
  const { buildKnowledgeGraph, scopeRecall, buildScopeRecallResult } = await import('./knowledge-graph.js')
@@ -10,9 +10,10 @@
10
10
  * 目标域同 id 同变更冲突跳过、幂等;INDEX 路由行随原语内 sync 维护(FR-02 泛词已退役)。
11
11
  * 只动 knowledge 活库,不回写归档件(D-unmapped-152 死路:归档是冻结审计面)。
12
12
  */
13
- import { existsSync, readFileSync } from 'fs'
13
+ import { existsSync, readFileSync, unlinkSync } from 'fs'
14
14
  import { join } from 'path'
15
- import { splitKnowledgeSections, discoverModuleIndex } from './decision-distill.js'
15
+ import { splitKnowledgeSections, discoverModuleIndex, joinKnowledgeFile, syncIndexRoutingLines, DECISIONS_ROUTE_STRIP_TOKENS, FR_ROUTE_STRIP_TOKENS } from './decision-distill.js'
16
+ import { writeAtomicSync } from './fs-atomic.js'
16
17
  import { matchedModuleIds, frCoverageFiles, FR_SECTION_RE } from './fr-index.js'
17
18
  import { anchorFilePaths } from './knowledge-match.js'
18
19
  import { redomainDecisionEntries, sectionMeta } from './decision-redomain.js'
@@ -181,12 +182,46 @@ export async function cmdRedomainUnmapped(dir, args, opts = {}) {
181
182
  ? (args[args.indexOf('--face') + 1] === 'decisions' ? 'decisions' : args[args.indexOf('--face') + 1] === 'fr' ? 'fr' : 'all')
182
183
  : 'all'
183
184
  const apply = args.includes('--apply')
185
+ const mapIdx = args.indexOf('--map')
186
+ const mapFile = mapIdx >= 0 ? args[mapIdx + 1] : null
184
187
  const emit = (ok, data, error) => console.log(JSON.stringify({ ok, ...data, ...(error ? { error } : {}) }, null, 2))
185
188
 
189
+ // --map 缺值显式拒绝(评审 P2:静默回落启发式通道 = 显式契约被切到投票且带写盘)
190
+ if (mapIdx >= 0 && (!mapFile || mapFile.startsWith('--'))) {
191
+ emit(false, {}, { code: 'map_value_required', usage: '--map <file.json>([{id, domain}],domain 可为 __prune__)' })
192
+ return
193
+ }
194
+
186
195
  if (!existsSync(join(knowledgeRoot, 'INDEX.md'))) {
187
196
  emit(false, {}, { code: 'knowledge_dir_missing', path: knowledgeRoot })
188
197
  return
189
198
  }
199
+ // --map 显式映射模式(2026-11-02-knowledge-redomain-map FR-01):旁路投票;apply fail-fast
200
+ if (mapFile) {
201
+ let map = null
202
+ try { map = JSON.parse(readFileSync(mapFile, 'utf8')) } catch (e) {
203
+ emit(false, {}, { code: 'map_file_invalid', file: mapFile, detail: e && e.message })
204
+ return
205
+ }
206
+ if (!Array.isArray(map) || map.some((e) => !e || typeof e.id !== 'string' || typeof e.domain !== 'string')) {
207
+ emit(false, {}, { code: 'map_entries_invalid', expected: '[{id, domain}]' })
208
+ return
209
+ }
210
+ const faces = face === 'all' ? ['decisions', 'fr'] : [face]
211
+ try {
212
+ if (apply) {
213
+ const results = faces.map((f) => applyFromMap({ knowledgeRoot, archiveRoot, face: f, map }))
214
+ emit(true, { mode: 'apply-map', face, results })
215
+ return
216
+ }
217
+ const plans = faces.map((f) => planFromMap({ knowledgeRoot, face: f, map }))
218
+ emit(true, { mode: 'dry-run-map', face, plans })
219
+ return
220
+ } catch (e) {
221
+ emit(false, {}, { code: 'map_apply_failed', detail: e && e.message ? e.message : String(e) })
222
+ return
223
+ }
224
+ }
190
225
  if (apply) {
191
226
  const faces = face === 'all' ? ['decisions', 'fr'] : [face]
192
227
  const { results, plan } = applyUnmappedRedomain({ knowledgeRoot, archiveRoot, faces })
@@ -202,3 +237,129 @@ export async function cmdRedomainUnmapped(dir, args, opts = {}) {
202
237
  const plan = planUnmappedRedomain({ knowledgeRoot, archiveRoot })
203
238
  emit(true, { mode: 'dry-run', face, decisions: plan.decisions, fr: plan.fr })
204
239
  }
240
+
241
+ // ═══ 2026-11-02-knowledge-redomain-map:--map 显式映射模式(FR-01)═══
242
+ // 显式契约 [{id, domain}](domain 可为 __prune__ 删段)——旁路启发式投票;apply fail-fast
243
+ // (显式契约不容半途容错);守恒/幂等断言以 apply 时刻文件为准;supersedes 留源作警告。
244
+
245
+ export const PRUNE_DOMAIN = '__prune__'
246
+
247
+ /** unmapped 文件段解析(face 决定契约)。 */
248
+ function parseUnmappedFace(knowledgeRoot, face) {
249
+ const fromPath = face === 'fr' ? join(knowledgeRoot, 'fr', 'unmapped.md') : join(knowledgeRoot, 'decisions', 'unmapped.md')
250
+ if (!existsSync(fromPath)) return null
251
+ const parsed = face === 'fr'
252
+ ? splitKnowledgeSections(readFileSync(fromPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
253
+ : splitKnowledgeSections(readFileSync(fromPath, 'utf8'))
254
+ const metas = parsed.sections.map((s) => (face === 'fr' ? frMeta(s) : sectionMeta(s))).filter(Boolean)
255
+ return { fromPath, parsed, metas, total: metas.length }
256
+ }
257
+
258
+ function frMeta(s) {
259
+ const h = FR_HEADER_RE.exec(s.lines[0] || '')
260
+ return h ? { id: h[1], title: (h[2] || '').trim() } : null
261
+ }
262
+
263
+ /** 显式映射 → 计划:迁移 groups + prunes + unknownIds(部分未知=报错;全部未知=已全部处理)。 */
264
+ export function planFromMap({ knowledgeRoot, face, map }) {
265
+ const src = parseUnmappedFace(knowledgeRoot, face)
266
+ if (!src) throw new Error(`源文件不存在(face=${face}: unmapped.md)`)
267
+ const byId = new Map(src.metas.map((m) => [m.id, m]))
268
+ const unknownIds = map.map((e) => e.id).filter((id) => !byId.has(id))
269
+ const groups = new Map()
270
+ const prunes = []
271
+ for (const e of map) {
272
+ if (!byId.has(e.id)) continue
273
+ if (e.domain === PRUNE_DOMAIN) { prunes.push(e.id); continue }
274
+ if (!groups.has(e.domain)) groups.set(e.domain, [])
275
+ groups.get(e.domain).push(e.id)
276
+ }
277
+ return {
278
+ face,
279
+ total: src.total,
280
+ groups: [...groups.entries()].map(([domain, ids]) => ({ domain, ids })).sort((a, b) => a.domain.localeCompare(b.domain)),
281
+ prunes,
282
+ unknownIds,
283
+ alreadyApplied: unknownIds.length === map.length && map.length > 0,
284
+ }
285
+ }
286
+
287
+ /** __prune__ 原语:从源文件删段——精确计数断言、裁空删文件 + INDEX 同步(prune-only 也调)。 */
288
+ export function pruneUnmappedEntries({ knowledgeRoot, face, anchors }) {
289
+ const src = parseUnmappedFace(knowledgeRoot, face)
290
+ if (!src) throw new Error(`源文件不存在(face=${face}: unmapped.md)`)
291
+ const anchorSet = new Set(anchors)
292
+ const staying = []
293
+ const pruned = []
294
+ for (const s of src.parsed.sections) {
295
+ const meta = face === 'fr' ? frMeta(s) : sectionMeta(s)
296
+ if (!meta) { staying.push(s); continue }
297
+ if (anchorSet.has(meta.id)) pruned.push(meta.id)
298
+ else staying.push(s)
299
+ }
300
+ if (pruned.length !== anchors.length) {
301
+ const got = new Set(pruned)
302
+ throw new Error(`prune 精确计数断言失败:未命中 ${anchors.filter((a) => !got.has(a)).join('、')}`)
303
+ }
304
+ const sourceDeleted = staying.length === 0
305
+ if (sourceDeleted) unlinkSync(src.fromPath)
306
+ else writeAtomicSync(src.fromPath, joinKnowledgeFile(src.parsed.preamble, staying))
307
+ try {
308
+ // fr 面必须传 FR_ROUTE_STRIP_TOKENS(评审 P2:传 {} 落默认 Decisions 段——fr 裁空后 INDEX 残留幽灵路由行)
309
+ syncIndexRoutingLines(knowledgeRoot, face === 'fr' ? { stripTokens: FR_ROUTE_STRIP_TOKENS } : { stripTokens: DECISIONS_ROUTE_STRIP_TOKENS })
310
+ } catch { /* INDEX 缺席/同步失败不拦裁剪(幂等可重跑) */ }
311
+ return { pruned, sourceDeleted }
312
+ }
313
+
314
+ /** supersedes 留源警告:被处理段的 supersedes 引用(基号)与留守段 id 基号相同 → 警告。 */
315
+ function supersedesLeftBehind(knowledgeRoot, face, processedIds) {
316
+ const src = parseUnmappedFace(knowledgeRoot, face)
317
+ if (!src) return []
318
+ const baseOf = (s) => String(s || '').replace(/@v\d+$/, '')
319
+ const processedBases = new Set(processedIds.map(baseOf))
320
+ const stayingBases = new Set(src.metas.map((m) => m.id).filter((id) => !processedBases.has(baseOf(id))).map(baseOf))
321
+ const warnings = []
322
+ for (const s of src.parsed.sections) {
323
+ const meta = face === 'fr' ? frMeta(s) : sectionMeta(s)
324
+ if (!meta || !processedBases.has(baseOf(meta.id))) continue
325
+ const sup = (s.lines.join('\n').match(/^supersedes[::]\s*(\S+)/m) || [])[1]
326
+ if (sup && stayingBases.has(baseOf(sup))) warnings.push(`${meta.id} 的 supersedes 目标 ${sup} 留在源文件`)
327
+ }
328
+ return warnings
329
+ }
330
+
331
+ /**
332
+ * --map apply(fail-fast):迁移组 + 裁剪顺序执行,任何失败整体上抛(已写部分靠幂等复跑收敛)。
333
+ * 守恒断言(apply 后重算段头 = 原段头 − moved − pruned)与幂等(全部已处理 → alreadyApplied)内嵌。
334
+ */
335
+ export function applyFromMap({ knowledgeRoot, archiveRoot, face, map }) {
336
+ const plan = planFromMap({ knowledgeRoot, face, map })
337
+ if (plan.alreadyApplied) return { face, alreadyApplied: true, moved: 0, pruned: 0, conservation: 'ok', warnings: [] }
338
+ if (plan.unknownIds.length > 0) throw new Error(`映射表含源文件不存在的 id:${plan.unknownIds.join('、')}`)
339
+ const processed = [...plan.groups.flatMap((g) => g.ids), ...plan.prunes]
340
+ const warnings = supersedesLeftBehind(knowledgeRoot, face, processed)
341
+ let moved = 0
342
+ const movedByDomain = {}
343
+ for (const g of plan.groups) {
344
+ const res = face === 'fr'
345
+ ? redomainFrEntries({ knowledgeRoot, from: 'unmapped', to: g.domain, anchors: g.ids })
346
+ : redomainDecisionEntries({ knowledgeRoot, from: 'unmapped', to: g.domain, anchors: g.ids })
347
+ moved += res.moved.length
348
+ movedByDomain[g.domain] = res.moved.length
349
+ if (Array.isArray(res.skippedConflicts) && res.skippedConflicts.length > 0) {
350
+ warnings.push(`目标域 ${g.domain} 冲突跳过:${res.skippedConflicts.join('、')}`)
351
+ }
352
+ }
353
+ let pruned = 0
354
+ if (plan.prunes.length > 0) {
355
+ pruned = pruneUnmappedEntries({ knowledgeRoot, face, anchors: plan.prunes }).pruned.length
356
+ }
357
+ // 守恒断言:apply 后源段头 = 原 − moved − pruned(skippedConflicts 留守 → moved 不含)
358
+ const after = parseUnmappedFace(knowledgeRoot, face)
359
+ const afterTotal = after ? after.total : 0
360
+ const expected = plan.total - moved - pruned
361
+ if (afterTotal !== expected) {
362
+ throw new Error(`守恒断言失败:apply 后段头 ${afterTotal} ≠ 原段头 ${plan.total} − moved ${moved} − pruned ${pruned}`)
363
+ }
364
+ return { face, alreadyApplied: false, moved, movedByDomain, pruned, conservation: `ok(${plan.total} − ${moved} − ${pruned} = ${afterTotal})`, warnings }
365
+ }
@@ -532,7 +532,10 @@ async function _completeStepInner(pm, progress, stageName, cwd, outputText, inpu
532
532
  : () => false
533
533
  const _echoHits = _hits.filter((h) => !_answered(h))
534
534
  const _entries = km.matched ? (km.entries || []) : []
535
- if (_echoHits.length > 0 || _entries.length > 0) {
535
+ const _routeHits = km.matched ? (km.routeHits || []) : []
536
+ const _scopeHitsAll = km.matched ? (km.scopeDecisionHits || []) : []
537
+ const _scopeHits = _scopeHitsAll.filter((h) => h.deathPath || h.status === 'rejected').slice(0, 3)
538
+ if (_echoHits.length > 0 || _entries.length > 0 || _routeHits.length > 0 || _scopeHits.length > 0) {
536
539
  const { deathPathNote, decisionChangeQualifier } = await import('../knowledge-match.js')
537
540
  console.warn(`\n⚠️ [knowledge-gate] 方案/决策知识命中(设计时点防复潮提示,warn 不阻断):`)
538
541
  for (const h of _echoHits.slice(0, 5)) {
@@ -546,7 +549,23 @@ async function _completeStepInner(pm, progress, stageName, cwd, outputText, inpu
546
549
  const _base = e.anchor ? `${e.file}#${e.anchor}` : e.file
547
550
  console.warn(` - 知识条目 ${_base}${e.display ? `(${e.display})` : ''}`)
548
551
  }
552
+ for (const e of _routeHits.slice(0, 3)) {
553
+ const _base = e.anchor ? `${e.file}#${e.anchor}` : e.file
554
+ console.warn(` - 路由命中 ${_base}(按需 Read)`)
555
+ }
556
+ // 融合增量(2026-11-02-knowledge-fusion FR-03):scope 否决独立小节(≤3,已回应过滤同源)
557
+ for (const h of _scopeHits.filter((h) => !_answered(h))) {
558
+ const _via = h.via === 'two-hop' ? '两跳' : '直连'
559
+ console.warn(` - 触达文件关联否决 ${h.id} ${h.title}(${h.file}|${decisionChangeQualifier(h.change)}|${_via})${h.status === 'rejected' ? `否决理由:${h.reason || '(未记录)'}` : `⚰️死路:${deathPathNote(h.reason)}`}`)
560
+ }
549
561
  console.warn(` 须在对应决策的 evidence 回应或说明不复潮(v1 提示级;local.yaml commands.knowledge-gate: false 可关本回显)`)
562
+ // 用户纠正转写自检(2026-11-02-constraint-mechanize FR-02):本变更 decisions.md 有
563
+ // 「用户原话保留」事故类条目时提示转写层——点火器转写纪律(用户纠正是生成时刻约束力
564
+ // 最强的力量,跨会话零存续,最强用法是当场转写进测试/门/接口/规约)
565
+ const _hasUserCorrection = _dm && /用户原话保留|落地层[::]/.test(_dm)
566
+ if (_hasUserCorrection) {
567
+ console.warn(` 🔥 用户纠正转写自检:本变更记录了用户纠正——原话转写进了哪一层(测试/门/接口/规约)?未转写则在对应决策补「落地层:」字段(D-002 先例)`)
568
+ }
550
569
  }
551
570
  }
552
571
  }
@@ -999,7 +999,9 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
999
999
  }
1000
1000
  } catch { /* 文档复制失败不连坐快照(test_strategy 降级 module 集仍可跑) */ }
1001
1001
 
1002
- return { ...snap, changeFileCount: changeFiles.length, sourceRoot }
1002
+ // changeFiles 透出(2026-10-29-open-metrics-gate task-03):厚道 verify 质量扫描的 metrics
1003
+ // 增量门注入(SILLYSPEC_CHANGE_FILES)需要本快照自算的变更文件集——additive,既有消费方零感知。
1004
+ return { ...snap, changeFileCount: changeFiles.length, changeFiles, sourceRoot }
1003
1005
  } catch (e) {
1004
1006
  // 任一链路异常 → 回退主仓现行为。异常细节不再静默(坑 snapshot-fallback-reason-blind):
1005
1007
  // failRef 带原因 + warn 落日志,排查不再靠 stdout 考古。