sillyspec 3.33.8 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run/prompt.js CHANGED
@@ -96,9 +96,9 @@ import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
96
96
  // 静态 import 不引入环(stages/* 反向引用本文件会撞 index.js 顶层 stageRegistry TDZ——见
97
97
  // buildKnowledgeInjection docstring)。
98
98
  import { matchKnowledge } from '../knowledge-match.js'
99
- import { matchQuicklogContext } from '../knowledge-quicklog.js'
100
99
  import { appendKnowledgeHit } from '../knowledge-hits.js'
101
- import { readActiveFrDigest } from '../fr-index.js'
100
+ import { readActiveFrDigest, deliverableFilesFromDesignText } from '../fr-index.js'
101
+ import { matchKnowledgeWithScope, graphPathCandidates, scopeTelemetryRefs } from '../knowledge-graph.js'
102
102
  // 前置失败清单 validator 面(2026-09-18-preflight-slimming task-01):design-facts /
103
103
  // stage-contract-engine 均为 src/ 叶子方向(不反向 import 本模块),静态 import 不引入环。
104
104
  import { validateDesignFileList } from '../design-facts.js'
@@ -524,33 +524,49 @@ function pickTopKnowledgeEntries(entries, maxFiles) {
524
524
  return picked
525
525
  }
526
526
 
527
- /** 命中正文段渲染(段头 + Status/Sources 命中报告语义 + top-3 文件截断正文)。 */
527
+ /** 命中正文段渲染(段头 + Status/Sources 命中报告语义 + top-3 文件截断正文)。
528
+ * 路由行分离(FR-04,2026-11-02-knowledge-rank-denoise):entries 仅手工条目注入正文;
529
+ * routeHits(fr/decisions 路由行)以指针行呈现,保 implemented 决策可见性;空 entries 且
530
+ * 无 routeHits 时返回空串(不出段头空段)。 */
528
531
  function renderKnowledgeInjectSection(knowledgeResult, { knowledgeDir, maxFiles, maxLines }) {
529
532
  const ref = e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)
533
+ const humanEntries = knowledgeResult.entries || []
534
+ const routeHits = knowledgeResult.routeHits || []
535
+ const scopeEntriesPreview = knowledgeResult.scopeEntries || []
536
+ if (humanEntries.length === 0 && routeHits.length === 0 && scopeEntriesPreview.length === 0) return ''
530
537
  const lines = []
531
538
  lines.push(`📚 命中知识(CLI 按任务描述机械匹配,top-${maxFiles})`)
532
- lines.push(`Status: matched | Entries: ${knowledgeResult.entries.length} | Sources:`)
533
- for (const e of knowledgeResult.entries) lines.push(` - ${ref(e)}`)
534
- lines.push(`(命中清单如上;正文按 INDEX 行序注入前 ${maxFiles} 个不同文件,单文件首 ${maxLines} 行截断——未注入条目需要时按 Sources 路径自行读取)`)
535
- for (const e of pickTopKnowledgeEntries(knowledgeResult.entries, maxFiles)) {
536
- lines.push('')
537
- lines.push(`── ${ref(e)}${e.display ? `(${e.display})` : ''} ──`)
538
- let body = ''
539
- try {
540
- // CRLF/CR 归一(Windows 知识文件 + 行数截断按 \n 切分)
541
- body = readFileSync(join(knowledgeDir, e.file), 'utf8').replace(/\r\n?/g, '\n')
542
- } catch (err) {
543
- lines.push(`(文件不可读:${err && err.message ? err.message : err})`)
544
- continue
545
- }
546
- const bodyLines = body.split('\n')
547
- if (bodyLines.length > maxLines) {
548
- lines.push(bodyLines.slice(0, maxLines).join('\n'))
549
- lines.push('…(截断)')
550
- } else {
551
- lines.push(body.replace(/\n+$/, ''))
539
+ if (humanEntries.length > 0) {
540
+ lines.push(`Status: matched | Entries: ${humanEntries.length} | Sources:`)
541
+ for (const e of humanEntries) lines.push(` - ${ref(e)}`)
542
+ lines.push(`(命中清单如上;正文按相关度注入前 ${maxFiles} 个不同文件,单文件首 ${maxLines} 行截断——未注入条目需要时按 Sources 路径自行读取)`)
543
+ for (const e of pickTopKnowledgeEntries(humanEntries, maxFiles)) {
544
+ lines.push('')
545
+ lines.push(`── ${ref(e)}${e.display ? `(${e.display})` : ''} ──`)
546
+ let body = ''
547
+ try {
548
+ // CRLF/CR 归一(Windows 知识文件 + 行数截断按 \n 切分)
549
+ body = readFileSync(join(knowledgeDir, e.file), 'utf8').replace(/\r\n?/g, '\n')
550
+ } catch (err) {
551
+ lines.push(`(文件不可读:${err && err.message ? err.message : err})`)
552
+ continue
553
+ }
554
+ const bodyLines = body.split('\n')
555
+ if (bodyLines.length > maxLines) {
556
+ lines.push(bodyLines.slice(0, maxLines).join('\n'))
557
+ lines.push('…(截断)')
558
+ } else {
559
+ lines.push(body.replace(/\n+$/, ''))
560
+ }
552
561
  }
553
562
  }
563
+ if (routeHits.length > 0) {
564
+ lines.push(`🧭 决策/FR 路由命中(专用通道,按需 Read):${routeHits.slice(0, 3).map(ref).join(';')}`)
565
+ }
566
+ const scopeEntries = knowledgeResult.scopeEntries || []
567
+ if (scopeEntries.length > 0) {
568
+ lines.push(`📚 触达文件关联手工条目(scope,按需 Read):${scopeEntries.slice(0, 3).map(ref).join(';')}`)
569
+ }
554
570
  return lines.join('\n')
555
571
  }
556
572
 
@@ -567,30 +583,28 @@ function renderKnowledgeInjectSection(knowledgeResult, { knowledgeDir, maxFiles,
567
583
  * report/json:matchKnowledge 原样透传(execute 调用方沿用旧 report.json 落盘与未命中替换口径)。
568
584
  * 遥测 append 单独 fail-soft(hits 写失败不影响注入本体);匹配/渲染异常向上抛由调用方降级。
569
585
  */
570
- export function buildKnowledgeInjection({ knowledgeDir, runtimeDir, change, query, maxFiles = KNOWLEDGE_INJECT_MAX_FILES, maxLines = KNOWLEDGE_INJECT_MAX_LINES } = {}) {
571
- const knowledgeResult = matchKnowledge(knowledgeDir, String(query || ''))
572
- // quicklog 检索面(知识可见性导线):与 INDEX 知识面独立——INDEX 未命中时 quicklog 照常
573
- // 注入(两 retrieval 面互为补充);fail-open(无 quicklog/异常 → 空 hits 零注入)。
574
- // specBase 由 knowledgeDir 上溯(两个注入点传的都是 <specBase>/knowledge)。
575
- const quick = knowledgeDir
576
- ? matchQuicklogContext(dirname(knowledgeDir), String(query || ''))
577
- : { hits: [], report: '' }
578
- if (!knowledgeResult.matched && quick.hits.length === 0) {
586
+ export function buildKnowledgeInjection({ knowledgeDir, runtimeDir, change, query, scopeFiles = [], maxFiles = KNOWLEDGE_INJECT_MAX_FILES, maxLines = KNOWLEDGE_INJECT_MAX_LINES } = {}) {
587
+ // 融合(2026-11-02-knowledge-fusion):同步入口 = matchKnowledge(路由/词片)+ scope 融合;
588
+ // scopeFiles 为机器键(design 交付清单/quick allowedFiles),键规整在 graphPathCandidates 内做
589
+ const knowledgeResult = matchKnowledgeWithScope(knowledgeDir, String(query || ''), { scopeFiles })
590
+ if (!knowledgeResult.matched) {
579
591
  return { matched: false, section: '', report: knowledgeResult.report, json: knowledgeResult.json }
580
592
  }
581
593
  const kSection = knowledgeResult.matched ? renderKnowledgeInjectSection(knowledgeResult, { knowledgeDir, maxFiles, maxLines }) : ''
582
- const qSection = quick.report || ''
583
- const section = [kSection, qSection].filter(Boolean).join('\n\n')
594
+ const section = kSection
595
+ const routeFiles = (knowledgeResult.routeHits || []).map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file))
596
+ const scopeMatchedFiles = scopeTelemetryRefs(knowledgeResult, { includeRejected: false })
584
597
  try {
585
598
  appendKnowledgeHit(runtimeDir, {
586
599
  type: 'inject',
587
600
  change: change || '',
588
601
  query: String(query || ''),
589
- matchedFiles: knowledgeResult.entries.map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
590
- quicklogIds: quick.hits.map(h => h.qlId),
602
+ matchedFiles: [...knowledgeResult.entries.map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)), ...routeFiles],
603
+ // scope 展示面独立字段(2026-11-02-knowledge-engine-tail FR-02):不并入 matchedFiles
604
+ ...(scopeMatchedFiles.length > 0 ? { scopeMatchedFiles } : {}),
591
605
  })
592
606
  } catch { /* 遥测 fail-soft(R-04):hits 落盘失败不阻断注入本体 */ }
593
- return { matched: true, section, report: knowledgeResult.report, json: knowledgeResult.json, quickHits: quick.hits }
607
+ return { matched: true, section, report: knowledgeResult.report, json: knowledgeResult.json}
594
608
  }
595
609
 
596
610
  // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
@@ -1492,11 +1506,20 @@ async function outputStepImpl(stageName, stepIndex, steps, cwd, changeName, dbPr
1492
1506
  } catch {}
1493
1507
  }
1494
1508
  const runtimeDir = join(effectiveSpecBase, '.runtime')
1509
+ // scope 键(FR-04):execute step 用 design 交付清单(机器键,图内精确路径由 graphPathCandidates 规整)
1510
+ let scopeFiles = []
1511
+ if (changeName) {
1512
+ try {
1513
+ const designText = readFileSync(join(effectiveSpecBase, 'changes', changeName, 'design.md'), 'utf8')
1514
+ scopeFiles = graphPathCandidates(deliverableFilesFromDesignText(designText))
1515
+ } catch { /* 无 design.md = 无键(不编造) */ }
1516
+ }
1495
1517
  const knowledgeInjection = buildKnowledgeInjection({
1496
1518
  knowledgeDir,
1497
1519
  runtimeDir,
1498
1520
  change: changeName || '',
1499
1521
  query: taskContext,
1522
+ scopeFiles,
1500
1523
  })
1501
1524
  substitute(
1502
1525
  /\{KNOWLEDGE_HIT_REPORT\}/g,
@@ -2012,11 +2035,18 @@ async function outputStepImpl(stageName, stepIndex, steps, cwd, changeName, dbPr
2012
2035
  const kiSpecBase = resolvePromptSpecBase(platformOpts, cwd)
2013
2036
  const kiQuery = String(readQuickGuardField(changeName, kiSpecBase, 'taskDescription') || '')
2014
2037
  if (kiQuery.trim() !== '') {
2038
+ // scope 键(FR-04):quick 仅 guard.allowedFiles(--files 声明)存在时传,缺失不编造
2039
+ let kiScope = []
2040
+ try {
2041
+ const declared = readQuickGuardField(changeName, kiSpecBase, 'allowedFiles')
2042
+ if (declared) kiScope = graphPathCandidates(String(declared).split(/[,,\s]+/))
2043
+ } catch { /* 无声明 = 无键 */ }
2015
2044
  const ki = buildKnowledgeInjection({
2016
2045
  knowledgeDir: join(kiSpecBase, 'knowledge'),
2017
2046
  runtimeDir: join(kiSpecBase, '.runtime'),
2018
2047
  change: changeName || '',
2019
2048
  query: kiQuery,
2049
+ scopeFiles: kiScope,
2020
2050
  })
2021
2051
  if (ki.section) {
2022
2052
  promptText = promptText + '\n\n' + ki.section
@@ -401,7 +401,7 @@ export function buildTestSurfaceAdvisory(files) {
401
401
  if (CODE_PATH_SEGMENT_RE.test(f) || CODE_EXTENSION_RE.test(f)) srcCount++
402
402
  }
403
403
  if (srcCount < MIN_SRC_FILES_FOR_TEST_ADVISORY || testCount > 0) return null
404
- return `⚠️ 测试面厚度 advisory:交付面 ${srcCount} 个 src 类文件但测试面为空(0 个测试文件改动,P2 账本测试面无增量可对账),确认无需测试增量?(advisory 不阻断——轻量变更/burst 实证测试量仅为旧流程 40%)`
404
+ return `⚠️ 测试面厚度 advisory:交付面 ${srcCount} 个 src 类文件但测试面为空(0 个测试文件改动,P2 账本测试面无增量可对账),确认无需测试增量?(advisory 不阻断——轻量变更/burst 实证测试量仅为旧流程 40%)另空实现判据:若把实现改成全部返回空测试仍全绿,则测试是摆设——要补就补断言可观察行为的真测试(2026-10-29-open-metrics-gate task-04)`
405
405
  }
406
406
 
407
407
  /**
@@ -477,7 +477,7 @@ export function mergeGateFiles(audited, declared) {
477
477
 
478
478
  export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], declaredFiles = [], changeName = null, skipSentinel = false }) {
479
479
  if (process.env.SILLYSPEC_QUICK_TEST_GATE === 'skip') {
480
- return { action: 'skip', failed: [], reason: 'SILLYSPEC_QUICK_TEST_GATE=skip 显式跳过(审计留痕)', test: null, lint: null }
480
+ return { action: 'skip', failed: [], reason: 'SILLYSPEC_QUICK_TEST_GATE=skip 显式跳过(审计留痕)', test: null, lint: null, metrics: null, metricsRoutes: null }
481
481
  }
482
482
  const audited = Array.isArray(changedFiles) ? changedFiles : []
483
483
  // 门禁文件集 = 审计 ∪ 声明边界(并集,2026-09-23 ql-018 修复):旧口径二选一(审计非空时
@@ -499,10 +499,10 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
499
499
  (f) => typeof f === 'string' && (CODE_PATH_SEGMENT_RE.test(f) || CODE_EXTENSION_RE.test(f)),
500
500
  )
501
501
  if (files.length === 0) {
502
- return { action: 'skip', failed: [], reason: `无变更文件清单(${fileSource}口径均空——brownfield 无 guard 或空审计),跳过`, test: null, lint: null }
502
+ return { action: 'skip', failed: [], reason: `无变更文件清单(${fileSource}口径均空——brownfield 无 guard 或空审计),跳过`, test: null, lint: null, metrics: null, metricsRoutes: null }
503
503
  }
504
504
  if (codeFiles.length === 0) {
505
- return { action: 'skip', failed: [], reason: `纯 doc/配置改动(${files.length} 个文件均未触及 src/test,规则 8 语义跳过 test+lint)`, test: null, lint: null }
505
+ return { action: 'skip', failed: [], reason: `纯 doc/配置改动(${files.length} 个文件均未触及 src/test,规则 8 语义跳过 test+lint)`, test: null, lint: null, metrics: null, metricsRoutes: null }
506
506
  }
507
507
 
508
508
  // ── 测试最低面 advisory(资产三小件②):放在两条早退之后——纯 doc/env skip/空清单
@@ -517,7 +517,7 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
517
517
  // (Grill X-001 附注)。specBase 同理用主仓的(快照副本 local.yaml 不参与开关判定)。
518
518
  const semanticGuard = buildSemanticGuardHits({ cwd, specBase, files, changeName })
519
519
 
520
- const { runVerifyTestCheck, runVerifyLintCheck } = await import('../verify-postcheck.js')
520
+ const { runVerifyTestCheck, runVerifyLintCheck, runVerifyMetricsCheck, runVerifyMetricsRoutesCheck } = await import('../verify-postcheck.js')
521
521
 
522
522
  // ── 隔离快照执行(2026-09-10 驾驭小结第六批②,用户实证「lint 实测对账在主仓跑被并行
523
523
  // 会话脏文件拦门」):HEAD 干净快照 + 本会话文件 overlay 后在快照内跑 test+lint——并行
@@ -565,6 +565,8 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
565
565
  const failed = []
566
566
  let test = null
567
567
  let lint = null
568
+ let metrics = null
569
+ let metricsRoutes = null
568
570
  try {
569
571
  // ── P2 三键账本(D-001@v1,batch3 task-01):同码同环境免重跑(fail-closed——无记录/
570
572
  // 键不等/分量不可得=真跑,行为不变)。quick 会话名 per-change 天然隔离账本文件。──
@@ -621,6 +623,30 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
621
623
  console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
622
624
  lint = runVerifyLintCheck({ cwd, specBase })
623
625
  }
626
+ // ── 收口指标门(2026-10-29-open-metrics-gate task-03,ub-mode「确定性质量门」采纳):
627
+ // commands.metrics 配置才执行,lint 同款隔离快照口径 + 超时回退主仓;非零退出计入
628
+ // failed 阻断收口(通过标准由命令自身定义——design 风险节声明的边界)。files 透传注入
629
+ // SILLYSPEC_CHANGE_FILES(审计∪声明口径,大仓增量门只扫变更面,存量债不拦新变更)。──
630
+ metrics = runVerifyMetricsCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snapshot ? 5 * 60 * 1000 : undefined, changeFiles: files })
631
+ if (snapshot && metrics.status === 'failed' && /超时/.test(String(metrics.reason || ''))) {
632
+ console.warn('⚠️ 快照 metrics 超时(junction I/O 慢)→ 主仓复跑 metrics(并行噪声可能混入——失败先做归属鉴定)')
633
+ metrics = runVerifyMetricsCheck({ cwd, specBase, changeFiles: files })
634
+ }
635
+ if (metrics.status === 'failed') {
636
+ failed.push('metrics')
637
+ }
638
+ // ── 路由指标门(2026-10-29-metrics-routes task-02——多语言/monorepo 路径集合路由):
639
+ // 单命令门之后执行;failed 归并(同计 'metrics' 一次——两形态是同一道门的两种配置
640
+ // 形态,重复计会让 failed 数组出现同名项)。files 同源注入路由(gate 命中子集)。──
641
+ metricsRoutes = runVerifyMetricsRoutesCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snapshot ? 5 * 60 * 1000 : undefined, changeFiles: files })
642
+ const routeTimeoutHit = (metricsRoutes.gates || []).some((g) => g && g.status === 'failed' && /超时/.test(String(g.reason || '')))
643
+ if (snapshot && metricsRoutes.status === 'failed' && routeTimeoutHit) {
644
+ console.warn('⚠️ 快照 metrics 路由 gate 超时(junction I/O 慢)→ 主仓复跑路由门(并行噪声可能混入——失败先做归属鉴定)')
645
+ metricsRoutes = runVerifyMetricsRoutesCheck({ cwd, specBase, changeFiles: files })
646
+ }
647
+ if (metricsRoutes.status === 'failed' && !failed.includes('metrics')) {
648
+ failed.push('metrics')
649
+ }
624
650
  // 哨兵断言(2026-09-25-sentinel-wiring,与 flow done 同判):tasks.md 全勾但零完成
625
651
  // 证据(区间提交消息标题与正文均无 task-NN token 且无对应 review.json)→ 计入 failed 拒收。
626
652
  // 证据面=整条提交消息(2026-09-25-thin-done-gate-calibration 坑2,同 flow 侧 %B%x1e 口径)。
@@ -700,14 +726,32 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
700
726
  lint.resultPath = persistLintResult({ specBase, changeName: typeof changeName === 'string' ? changeName : null, testResultPath: test && test.resultPath, lint }) || lint.resultPath || null
701
727
  }
702
728
  } catch { /* 持久化 best-effort,不影响门禁语义 */ }
729
+ // metrics 结果持久化(2026-10-29-open-metrics-gate task-03):同 lint 语义——并入 test 的
730
+ // test-result.json(metrics 节)或独立落盘(kind: 'metrics'),证据面供收口评审与排障直读。
731
+ try {
732
+ if (metrics && (metrics.status === 'passed' || metrics.status === 'failed')) {
733
+ const { persistLintResult } = await import('../verify-postcheck.js')
734
+ metrics.resultPath = persistLintResult({ specBase, changeName: typeof changeName === 'string' ? changeName : null, testResultPath: test && test.resultPath, lint: metrics, section: 'metrics' }) || metrics.resultPath || null
735
+ }
736
+ } catch { /* 持久化 best-effort,不影响门禁语义 */ }
737
+ // 路由门证据(2026-10-29-metrics-routes task-02):section 'routes'——并入 test-result.json
738
+ // 的 routes 节或独立件 kind: 'routes'(带 gates 数组),与单命令 metrics 节并存互不覆盖。
739
+ try {
740
+ if (metricsRoutes && (metricsRoutes.status === 'passed' || metricsRoutes.status === 'failed')) {
741
+ const { persistLintResult } = await import('../verify-postcheck.js')
742
+ metricsRoutes.resultPath = persistLintResult({ specBase, changeName: typeof changeName === 'string' ? changeName : null, testResultPath: test && test.resultPath, lint: metricsRoutes, section: 'routes' }) || metricsRoutes.resultPath || null
743
+ }
744
+ } catch { /* 持久化 best-effort,不影响门禁语义 */ }
703
745
  return {
704
746
  action: failed.length > 0 ? 'fail' : 'pass',
705
747
  failed,
706
748
  reason: failed.length > 0
707
749
  ? `实测失败:${failed.join(' + ')}(命令与输出尾部见上;修复后重跑 --done 不丢进度)`
708
- : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)${snapshot ? '(隔离快照口径:并行会话脏文件不计入)' : ''}`,
750
+ : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)${metrics && metrics.status === 'passed' ? ';metrics 指标门实测通过' : ''}${metricsRoutes && metricsRoutes.status === 'passed' ? ';metrics 路由门全过' : ''}${snapshot ? '(隔离快照口径:并行会话脏文件不计入)' : ''}`,
709
751
  test,
710
752
  lint,
753
+ metrics,
754
+ metricsRoutes,
711
755
  // 语义护栏 WARNING(task-06):只增可选字段,action/failed 判定与调用方读取面均不受影响
712
756
  semanticGuard,
713
757
  }
@@ -721,7 +765,7 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
721
765
  // 结果路径重映射(评审 P1 清偿):快照内写下的 resultPath 指向快照临时目录——清理后
722
766
  // existsSync 恒 false,flow done 的 FAIL 三件套会静默失效。回拷后把 test/lint 的
723
767
  // resultPath 改写为主仓路径(对象即返回值,finally 在 return 落地前变异生效)。
724
- for (const r of [test, lint]) {
768
+ for (const r of [test, lint, metrics, metricsRoutes]) {
725
769
  if (r && typeof r.resultPath === 'string' && r.resultPath.startsWith(gateSpecBase)) {
726
770
  r.resultPath = specBase + r.resultPath.slice(gateSpecBase.length)
727
771
  }
@@ -764,16 +808,20 @@ export function printQuickTestLintGate(gate) {
764
808
  console.log(`\n🧪 quick test+lint 门禁 — SKIP:${gate.reason}`)
765
809
  return
766
810
  }
767
- for (const [label, r] of [['test', gate.test], ['lint', gate.lint]]) {
811
+ for (const [label, r] of [['test', gate.test], ['lint', gate.lint], ['metrics', gate.metrics], ['metrics-routes', gate.metricsRoutes]]) {
768
812
  if (!r) continue
769
813
  const icon = r.status === 'failed' ? '❌' : r.status === 'passed' ? '✅' : '⏭️'
770
814
  const dur = typeof r.durationMs === 'number' ? ` (${(r.durationMs / 1000).toFixed(1)}s)` : ''
771
- console.log(`${icon} ${label}: ${r.status}${r.command ? ` ← ${r.command}${dur}` : ''}${r.status === 'skipped' && r.reason ? ` — ${r.reason}` : ''}`)
815
+ const gatesNote = Array.isArray(r.gates) && r.gates.length > 0 ? ` [gates: ${r.gates.map((g) => `${g.name}=${g.status}`).join(', ')}]` : ''
816
+ console.log(`${icon} ${label}: ${r.status}${r.command ? ` ← ${r.command}${dur}` : ''}${gatesNote}${r.status === 'skipped' && r.reason ? ` — ${r.reason}` : ''}`)
772
817
  }
773
818
  if (gate.action === 'fail') {
774
819
  console.error(`\n🚫 quick test+lint 门禁 — BLOCKED:${gate.reason}`)
775
820
  for (const key of gate.failed) {
776
- const r = gate[key]
821
+ // 'metrics' 归并键:单命令门与路由门共用——取有输出尾部者(单命令绿而路由红时尾部挂
822
+ // metricsRoutes;两者均红时先单命令,路由尾部在其 resultPath 证据件可读)
823
+ let r = gate[key]
824
+ if (key === 'metrics' && !(r && r.outputTail) && gate.metricsRoutes && gate.metricsRoutes.outputTail) r = gate.metricsRoutes
777
825
  if (r && r.outputTail) console.error(` ── ${key} 输出尾部 ──\n${r.outputTail}`)
778
826
  }
779
827
  console.error(` quick 已停止:修复后重跑 --done(进度不丢);确认要跳过实测请设 SILLYSPEC_QUICK_TEST_GATE=skip(审计留痕)。`)
@@ -105,7 +105,7 @@ export function computeQualityScanFingerprint({ cwd, specBase }) {
105
105
  * 保持 1——存量记录无 smoke 段照常读回(smokeResult 兜底 null);未配置 smoke 时段形态为
106
106
  * configured=false(记录仍写,供 --done PASS 封顶判 not-configured)。
107
107
  */
108
- export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult, usedSnapshot = false, missReason = null, scopeDecision = null }) {
108
+ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult, metricsResult = null, metricsRoutesResult = null, usedSnapshot = false, missReason = null, scopeDecision = null }) {
109
109
  const path = qualityScanRecordPath(specBase, changeName)
110
110
  if (!path) return null
111
111
  const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
@@ -137,6 +137,10 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
137
137
  testResult,
138
138
  lintResult: lintResult || null,
139
139
  smokeResult: smokeResult || null,
140
+ // metrics 指标门结果(2026-10-29-open-metrics-gate,additive——存量记录无键读侧兼容)
141
+ metricsResult: metricsResult || null,
142
+ // 路由门结果(2026-10-29-metrics-routes,additive 同上)
143
+ metricsRoutesResult: metricsRoutesResult || null,
140
144
  }, null, 2) + '\n')
141
145
  return { path, fingerprint }
142
146
  } catch (e) {
@@ -653,7 +657,7 @@ function warnIfMainRepoDirtyForGate(cwd) {
653
657
  * `run verify` 复入本步重新实测(指纹随代码修复失配,--done 不会复用旧失败结果)。
654
658
  */
655
659
  export async function executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts }) {
656
- const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint, classifyTestFailureArtifact } = await import('../verify-postcheck.js')
660
+ const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint, classifyTestFailureArtifact, runVerifyMetricsCheck, runVerifyMetricsRoutesCheck } = await import('../verify-postcheck.js')
657
661
  // ── RERUN 失败签名闸(r5l-forensic-verdict.md 方案 4 / 评审护栏#6 五态)──
658
662
  // R5-L 实证:RERUN=1 两次强制重扫各烧 ~8M 重发 + 10m/6.8m 等待,而失败签名未变——重跑
659
663
  // 结果注定相同。故 RERUN=1 生效前先对账失败签名(dedupKey 已含代码指纹×豁免面,再叠测试
@@ -807,6 +811,8 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
807
811
  console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.test(同步,长套件 2~10 分钟无输出属正常)——结果按代码指纹落盘,最终 --done 对账直接复用免重跑。`)
808
812
  let testCheck
809
813
  let lintCheck
814
+ let metricsCheck = null
815
+ let metricsRoutesCheck = null
810
816
  let coverageCheck = null
811
817
  let smokeResult = null
812
818
  try {
@@ -827,6 +833,40 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
827
833
  console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
828
834
  lintCheck = runVerifyLintCheck({ cwd, specBase })
829
835
  }
836
+ // ── 收口指标门(2026-10-29-open-metrics-gate task-03):commands.metrics 配置才执行,
837
+ // 与 quick 道同款语义(隔离快照 + 超时回退主仓 + 非零阻断);snap.changeFiles 是厚道
838
+ // 快照自算的变更文件集(gate-snapshot 透出),透传注入 SILLYSPEC_CHANGE_FILES 增量门。──
839
+ metricsCheck = runVerifyMetricsCheck({
840
+ cwd: gateCwd, specBase: gateSpecBase,
841
+ timeoutMs: snap ? 5 * 60 * 1000 : undefined,
842
+ changeFiles: snap && Array.isArray(snap.changeFiles) ? snap.changeFiles : null,
843
+ })
844
+ if (snap && metricsCheck.status === 'failed' && /超时/.test(String(metricsCheck.reason || ''))) {
845
+ console.warn('⚠️ 快照 metrics 超时(junction I/O 慢)→ 主仓复跑 metrics(并行噪声可能混入——失败先做归属鉴定)')
846
+ metricsCheck = runVerifyMetricsCheck({ cwd, specBase, changeFiles: snap && Array.isArray(snap.changeFiles) ? snap.changeFiles : null })
847
+ }
848
+ if (metricsCheck.status !== 'skipped') {
849
+ const mIcon = metricsCheck.status === 'failed' ? '❌' : '✅'
850
+ console.log(`${mIcon} metrics 指标门实测 ${metricsCheck.status}${metricsCheck.command ? ` ← ${metricsCheck.command}` : ''}${metricsCheck.status === 'failed' && metricsCheck.reason ? ` — ${String(metricsCheck.reason).split('\n')[0]}` : ''}`)
851
+ }
852
+ // ── 路由指标门(2026-10-29-metrics-routes task-02):与 quick 道同款——变更文件面 ∩
853
+ // gate.paths 段全等前缀路由,仅命中 gate 执行;failed 与单命令门归并阻断。──
854
+ metricsRoutesCheck = runVerifyMetricsRoutesCheck({
855
+ cwd: gateCwd, specBase: gateSpecBase,
856
+ timeoutMs: snap ? 5 * 60 * 1000 : undefined,
857
+ changeFiles: snap && Array.isArray(snap.changeFiles) ? snap.changeFiles : null,
858
+ })
859
+ const routeTimeoutHit = (metricsRoutesCheck.gates || []).some((g) => g && g.status === 'failed' && /超时/.test(String(g.reason || '')))
860
+ if (snap && metricsRoutesCheck.status === 'failed' && routeTimeoutHit) {
861
+ console.warn('⚠️ 快照 metrics 路由 gate 超时(junction I/O 慢)→ 主仓复跑路由门(并行噪声可能混入)')
862
+ metricsRoutesCheck = runVerifyMetricsRoutesCheck({ cwd, specBase, changeFiles: snap && Array.isArray(snap.changeFiles) ? snap.changeFiles : null })
863
+ }
864
+ if (metricsRoutesCheck.status !== 'skipped') {
865
+ for (const g of metricsRoutesCheck.gates || []) {
866
+ const icon = g.status === 'failed' ? '❌' : g.status === 'passed' ? '✅' : '⏭️'
867
+ console.log(`${icon} metrics gate「${g.name}」 ${g.status} ← ${g.command}${g.status === 'failed' && g.reason ? ` — ${String(g.reason).split('\n')[0]}` : ''}`)
868
+ }
869
+ }
830
870
  if (lintCheck.status !== 'skipped') {
831
871
  console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
832
872
  }
@@ -852,9 +892,14 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
852
892
  } finally {
853
893
  if (snap) { try { snap.cleanup() } catch {} }
854
894
  }
855
- storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck, usedSnapshot: Boolean(snap), missReason: _missReasonParts.join(';') || null, scopeDecision: { planned: !process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF, actual: Boolean(snap) } })
895
+ storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck, metricsResult: metricsCheck, metricsRoutesResult: metricsRoutesCheck, usedSnapshot: Boolean(snap), missReason: _missReasonParts.join(';') || null, scopeDecision: { planned: !process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF, actual: Boolean(snap) } })
856
896
  const testFailed = testCheck.status === 'failed'
857
897
  const lintBlocked = shouldBlockVerifyLint(lintCheck)
898
+ // metrics 指标门阻断(2026-10-29-open-metrics-gate task-03):非零/超时即 failed——通过标准
899
+ // 由命令自身定义(无 lint 式归属鉴定,design 风险节声明边界);skipped(未配置/环境缺件)不拦。
900
+ const metricsFailed = Boolean(metricsCheck && metricsCheck.status === 'failed')
901
+ // 路由门阻断(2026-10-29-metrics-routes task-02):任一命中 gate 失败即拦——与单命令门同一道门
902
+ const metricsRoutesFailed = Boolean(metricsRoutesCheck && metricsRoutesCheck.status === 'failed')
858
903
  // 2026-10-15-lint-triage-path-unify:lint 归属鉴定降档接线(与 gates --done 路径同源共享
859
904
  // helper)——零交集存量债降档 advisory 不 throw;owned/unattributable/鉴定异常维持硬拦
860
905
  // (保守)。仅在「lint 单独失败」时降档(testFailed 优先 throw——测试失败不作 lint 降档
@@ -867,17 +912,17 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
867
912
  // 摩擦计数(friction-signal-hint task-03):记录条件与 throw 条件**刻意解耦**——advisory 档
868
913
  // lint 失败(SILLYSPEC_VERIFY_LINT_GATE=advisory 时不 throw)同样是摩擦信号,计数不应随
869
914
  // 逃生阀漂移。recordFrictionEvent 自带静默降级,不影响本步推进/throw 语义。
870
- if (testFailed || lintCheck.status === 'failed') {
871
- await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
915
+ if (testFailed || lintCheck.status === 'failed' || metricsFailed || metricsRoutesFailed) {
916
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : (metricsFailed || metricsRoutesFailed) ? 'metrics' : 'lint' })
872
917
  }
873
- if (testFailed || (lintBlocked && !lintAdvisoryDowngraded)) {
918
+ if (testFailed || (lintBlocked && !lintAdvisoryDowngraded) || metricsFailed || metricsRoutesFailed) {
874
919
  if (snap) {
875
920
  try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
876
921
  printSnapshotFailureHint(snap) } catch { /* 提示失败不影响阻断 */ }
877
922
  }
878
923
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
879
924
  await renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot })
880
- const which = testFailed ? '测试' : 'lint'
925
+ const which = testFailed ? '测试' : (metricsFailed || metricsRoutesFailed) ? '指标门(metrics)' : 'lint'
881
926
  throw new Error(`noAI 质量扫描实测${which}失败——本步骤未完成(进度不推进)。输出与归因提示见上方;修复后重跑 sillyspec run verify${changeName ? ` --change ${changeName}` : ''} 复入本步重新实测。`)
882
927
  }
883
928
  console.log(`\n✅ noAI 质量扫描全绿——本步骤自动完成;--done 对账将复用本次实测(代码变更即失配重测)。机械事实持续全绿时,verify-probes --init 会预填 PASS 结论草稿(草稿待确认,复核后可改写)。`)
@@ -22,13 +22,45 @@ import { collectExecuteChangedFiles, computeDecisionTouches, renderDecisionTouch
22
22
  // const 的 TDZ 求值(ESM 环实证 2026-09-14)。此处为同格式本地孪生,漂移由
23
23
  // test/knowledge-inject.test.mjs 格式等价断言锁定——改 prompt.js 版必同步改此处。
24
24
  import { matchKnowledge } from '../knowledge-match.js'
25
- import { matchQuicklogContext, renderQuicklogSection } from '../knowledge-quicklog.js'
25
+ import { matchKnowledgeWithScope, graphPathCandidates, scopeTelemetryRefs } from '../knowledge-graph.js'
26
26
  import { appendKnowledgeHit } from '../knowledge-hits.js'
27
27
 
28
28
  // 机械知识注入限额(与 run/prompt.js KNOWLEDGE_INJECT_MAX_FILES/MAX_LINES 同值孪生,R-02 膨胀控制)
29
29
  const KNOWLEDGE_INJECT_MAX_FILES = 3
30
30
  const KNOWLEDGE_INJECT_MAX_LINES = 40
31
31
 
32
+ /**
33
+ * task 卡机器键解析(2026-11-02-knowledge-fusion 评审 P1):target_files / allowed_paths
34
+ * 三形态——内联列表 `[a, b]`、inline `a, b`、块列表(字段行后 `- item` 行);剥 NEW: 前缀
35
+ * 与反引号/引号。旧实现用单行正则 + \s* 跨行,块列表只捕获首项且带 `- ` 前缀。
36
+ */
37
+ export function parseTaskCardPaths(text) {
38
+ const out = []
39
+ const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const m = /^(?:target_files|allowed_paths)\s*[::]\s*(.*)$/.exec(lines[i])
42
+ if (!m) continue
43
+ const inline = m[1].trim()
44
+ if (inline) {
45
+ const cleaned = inline.replace(/^\[|\]$/g, '')
46
+ for (const part of cleaned.split(/[,,\s]+/)) if (part) out.push(part)
47
+ continue
48
+ }
49
+ // 块列表:后续 `- item`(缩进可选),遇到非列表行停止
50
+ for (let j = i + 1; j < lines.length; j++) {
51
+ const lm = /^\s*-\s+(.+)$/.exec(lines[j])
52
+ if (!lm) break
53
+ out.push(lm[1].trim())
54
+ }
55
+ }
56
+ return out.map((p) => String(p)
57
+ .replace(/^NEW:\s*/i, '')
58
+ .replace(/^`+|`+$/g, '')
59
+ .replace(/^['"]|['"]$/g, '')
60
+ .trim())
61
+ .filter(Boolean)
62
+ }
63
+
32
64
  /**
33
65
  * Wave 粒度机械知识注入段(task-04,FR-04 / D-002@v1 总体方案 D):用本 Wave 各 task 标题拼接
34
66
  * 串跑 matchKnowledge,命中文件正文以纯文本段注入 buildWavePrompt 输出(**禁止 {{include:}} 形式**
@@ -46,21 +78,32 @@ function buildWaveKnowledgeSection(changeDir, wave) {
46
78
  const query = (Array.isArray(wave && wave.tasks) ? wave.tasks : [])
47
79
  .map(t => String((t && t.name) || '')).filter(Boolean).join(' ')
48
80
  if (!query.trim()) return ''
49
- const result = matchKnowledge(knowledgeDir, query)
50
- // quicklog 检索面(知识可见性导线):与 INDEX 知识面独立,INDEX 未命中时照常注入
51
- const quick = matchQuicklogContext(specBase, query)
52
- if (!result.matched && quick.hits.length === 0) return ''
81
+ // scope 键(2026-11-02-knowledge-fusion FR-04):Wave 用 task 卡 target_files/allowed_paths
82
+ // (机器键)→ graphPathCandidates 规整为图内精确文件路径;无卡/无声明不编造
83
+ const scopeFiles = graphPathCandidates((wave.tasks || []).flatMap((t) => {
84
+ const file = t && (t.file || (t.index ? path.join('tasks', `task-${String(t.index).padStart(2, '0')}.md`) : ''))
85
+ if (!file) return []
86
+ try {
87
+ return parseTaskCardPaths(readFileSync(path.join(changeDir, file), 'utf8'))
88
+ } catch { return [] }
89
+ }))
90
+ const result = matchKnowledgeWithScope(knowledgeDir, query, { scopeFiles })
91
+ const humanEntries = result.entries || []
92
+ const routeHits = result.routeHits || []
93
+ const scopeEntries = result.scopeEntries || []
94
+ if (humanEntries.length === 0 && routeHits.length === 0 && scopeEntries.length === 0) return ''
53
95
  const lines = []
54
- if (result.matched) {
96
+ if (humanEntries.length > 0 || routeHits.length > 0 || scopeEntries.length > 0) {
55
97
  // ── 以下与 run/prompt.js renderKnowledgeInjectSection 同格式孪生(含段头文案/截断标记)──
56
98
  const ref = e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)
57
99
  lines.push(`### 📚 命中知识(CLI 按 Wave 任务名机械匹配,top-${KNOWLEDGE_INJECT_MAX_FILES}——勿自行重跑 INDEX 匹配)`)
58
- lines.push(`Status: matched | Entries: ${result.entries.length} | Sources:`)
59
- for (const e of result.entries) lines.push(` - ${ref(e)}`)
60
- lines.push(`(命中清单如上;正文按 INDEX 行序注入前 ${KNOWLEDGE_INJECT_MAX_FILES} 个不同文件,单文件首 ${KNOWLEDGE_INJECT_MAX_LINES} 行截断——未注入条目需要时按 Sources 路径自行读取)`)
100
+ if (humanEntries.length > 0) {
101
+ lines.push(`Status: matched | Entries: ${humanEntries.length} | Sources:`)
102
+ for (const e of humanEntries) lines.push(` - ${ref(e)}`)
103
+ lines.push(`(命中清单如上;正文按相关度注入前 ${KNOWLEDGE_INJECT_MAX_FILES} 个不同文件,单文件首 ${KNOWLEDGE_INJECT_MAX_LINES} 行截断——未注入条目需要时按 Sources 路径自行读取)`)
61
104
  const seen = new Set()
62
105
  const picked = []
63
- for (const e of result.entries) {
106
+ for (const e of humanEntries) {
64
107
  if (seen.has(e.file)) continue
65
108
  seen.add(e.file)
66
109
  picked.push(e)
@@ -85,18 +128,23 @@ function buildWaveKnowledgeSection(changeDir, wave) {
85
128
  }
86
129
  }
87
130
  }
88
- // quicklog 段与知识段之间空一行(run/prompt.js buildKnowledgeInjection 同款拼接口径)
89
- if (quick.hits.length > 0) {
90
- if (lines.length > 0) lines.push('')
91
- lines.push(renderQuicklogSection(quick.hits, { h3: true }))
131
+ if (routeHits.length > 0) {
132
+ lines.push(`🧭 决策/FR 路由命中(专用通道,按需 Read):${routeHits.slice(0, 3).map(ref).join(';')}`)
133
+ }
134
+ if (scopeEntries.length > 0) {
135
+ lines.push(`📚 触达文件关联手工条目(scope,按需 Read):${scopeEntries.slice(0, 3).map(ref).join(';')}`)
136
+ }
92
137
  }
93
138
  try {
94
139
  appendKnowledgeHit(path.join(specBase, '.runtime'), {
95
140
  type: 'inject',
96
141
  change: path.basename(changeDir),
97
142
  query,
98
- matchedFiles: result.matched ? result.entries.map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : [],
99
- quicklogIds: quick.hits.map(h => h.qlId),
143
+ matchedFiles: result.matched ? [...humanEntries, ...routeHits].map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : [],
144
+ ...(result.matched ? (() => {
145
+ const scopeMatchedFiles = scopeTelemetryRefs(result, { includeRejected: false })
146
+ return scopeMatchedFiles.length > 0 ? { scopeMatchedFiles } : {}
147
+ })() : {}),
100
148
  })
101
149
  } catch { /* 遥测 fail-soft(R-04) */ }
102
150
  return lines.join('\n') + '\n'