sillyspec 3.28.9 → 3.28.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/package.json +1 -1
  6. package/src/change-list.js +73 -20
  7. package/src/change-risk-profile.js +51 -3
  8. package/src/config-schema.js +51 -0
  9. package/src/contract-matrix.js +84 -5
  10. package/src/cross-repo-reconcile.js +134 -0
  11. package/src/design-facts.js +94 -18
  12. package/src/docs-check.js +14 -10
  13. package/src/docs-debt.js +7 -2
  14. package/src/docs-gate.js +42 -15
  15. package/src/endpoint-extractor.js +50 -2
  16. package/src/foreign-declared.js +25 -16
  17. package/src/hooks/worktree-guard.js +75 -2
  18. package/src/index.js +147 -2
  19. package/src/knowledge-classify.js +20 -1
  20. package/src/machine-interface.js +26 -0
  21. package/src/probe7-anchor-check.js +87 -0
  22. package/src/progress/stage-machine.js +34 -5
  23. package/src/progress.js +1 -1
  24. package/src/quicklog.js +240 -5
  25. package/src/run/command.js +25 -2
  26. package/src/run/complete-handlers.js +172 -1
  27. package/src/run/complete.js +143 -11
  28. package/src/run/gate-snapshot.js +372 -2
  29. package/src/run/gates.js +130 -12
  30. package/src/run/prompt.js +166 -7
  31. package/src/run/shared.js +81 -1
  32. package/src/scope-audit.js +101 -31
  33. package/src/stage-contract-spec.js +14 -0
  34. package/src/stage-contract.js +314 -8
  35. package/src/stage-review-checklist.js +3 -1
  36. package/src/stage-review.js +137 -2
  37. package/src/stages/archive.js +9 -5
  38. package/src/stages/brainstorm.js +7 -1
  39. package/src/stages/cmd-existence.js +9 -3
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/knowledge.js +24 -3
  42. package/src/stages/plan-postcheck.js +74 -10
  43. package/src/stages/plan.js +8 -1
  44. package/src/stages/quick.js +5 -5
  45. package/src/stages/verify.js +3 -2
  46. package/src/sync-noise.js +57 -1
  47. package/src/sync.js +77 -4
  48. package/src/task-review.js +84 -29
  49. package/src/verify-facts-schema.js +78 -9
  50. package/src/verify-postcheck.js +439 -68
  51. package/src/verify-probes.js +1156 -45
  52. package/src/worktree-apply.js +323 -8
  53. package/src/worktree-deps.js +24 -1
  54. package/src/worktree.js +274 -10
  55. package/src/write-audit.js +55 -0
@@ -28,9 +28,10 @@ import { IR_STRICT_SINCE } from './constants.js'
28
28
  import { gitQuiet } from './git-helper.js'
29
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
30
30
  import { join } from 'path'
31
- import { verifyApiParity } from './contract-matrix.js'
31
+ import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
32
+ import { reconcileCrossRepoDeclarations } from './cross-repo-reconcile.js'
32
33
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
33
- import { filterDeliverableFiles } from './worktree-apply.js'
34
+ import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
34
35
  // target_files 声明侧解析(Wave 1 已落地):依赖链已核实无环——plan-postcheck 不反向依赖本模块,
35
36
  // 且本模块已经 worktree-apply.js:21 间接依赖 plan-postcheck,此处改直连不引入新环(task-04)
36
37
  import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
@@ -367,7 +368,7 @@ function collectPathTokens(text) {
367
368
 
368
369
  /**
369
370
  * 解析 module-impact.md 的「模块影响矩阵」+「未匹配文件」两节,按检查面归类影响证据。
370
- * 纯机械启发式(确定性:同输入同输出);矩阵行 <!--TODO--> 未回填 → 保守计行为类
371
+ * 纯机械启发式(确定性:同输入同输出);矩阵行 <!--TODO--> 未回填 → 保守计行为类 probe1-noqa
371
372
  * (影响面未知时多测不漏测)。矩阵节缺失 / 无数据行 → parseable=false(调用方降级)。
372
373
  *
373
374
  * @param {string|null} mdText
@@ -392,8 +393,8 @@ function classifyModuleImpactEvidence(mdText) {
392
393
  const tok = IMPACT_BEHAVIORAL_TOKENS.find((w) => rowText.includes(w))
393
394
  if (tok) { out.behavioral.push(`(影响类型:${tok})`); classified++ }
394
395
  }
395
- // 矩阵行 <!--TODO--> 未回填 → 影响面未知,保守计行为类
396
- if (String(rowText).includes('<!--TODO')) { out.behavioral.push('<!--TODO--> 未回填行'); classified++ }
396
+ // 矩阵行 <!--TODO--> 未回填 → 影响面未知,保守计行为类 /* probe1-noqa */
397
+ if (String(rowText).includes('<!--TODO')) { out.behavioral.push('<!--TODO--> 未回填行'); classified++ } /* probe1-noqa */
397
398
  return classified
398
399
  }
399
400
  for (const line of lines) {
@@ -413,7 +414,7 @@ function classifyModuleImpactEvidence(mdText) {
413
414
  matrixRows++
414
415
  consumeRow(trimmed)
415
416
  } else if (section === 'unmatched') {
416
- // 骨架产出 `- \`path\` <!--TODO-->` 列表行;手写形态可能是 `| 文件 | 处置说明 |` 表行
417
+ // 骨架产出 `- \`path\` <!--TODO-->` 列表行;手写形态可能是 `| 文件 | 处置说明 |` 表行 /* probe1-noqa */
417
418
  const bullet = trimmed.match(/^-\s+(.+)$/)
418
419
  if (bullet) { consumeRow(bullet[1]); continue }
419
420
  if (trimmed.startsWith('|')) {
@@ -605,6 +606,46 @@ function parseFlowValue(flowText, key) {
605
606
  return v.length > 0 ? v : null
606
607
  }
607
608
 
609
+ /**
610
+ * module 子集测试面的依赖测试自动发现(坑 module-test-face-rot,2026-09-16 本会话 5325f55
611
+ * 实证:local.yaml modules 的 test 命令是硬编码文件清单,src/verify-probes.js 命中 cli-core
612
+ * 但其断言测试 verify-probes-facts.test.mjs 不在清单 → quick --done 的 module 收窄实测漏掉
613
+ * 全量断言,回归漏到合并态才暴露)。发现口径:变更 src 文件的**直接 import 测试**(test/
614
+ * 下 *.test.* 内含 `../src/<changed>` 引用——静态/动态 import 通吃,子串匹配只可能多跑不漏)
615
+ * ∪ 变更的 test 文件本身;已被命中模块命令串覆盖的(命令串含该文件名)排除防重复。
616
+ * @param {{cwd: string, changedFiles: string[], coveredCommands?: string[]}} args
617
+ * @returns {string[]} 相对 cwd 的测试文件路径(posix 形态,已排序;无可发现 → [])
618
+ */
619
+ export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommands = [] }) {
620
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
621
+ const norm = (p) => String(p).replace(/\\/g, '/')
622
+ const changedSrc = [...new Set(changedFiles.map(norm))]
623
+ .filter(p => p.startsWith('src/') && /\.(js|mjs|cjs|ts)$/.test(p))
624
+ const changedTests = [...new Set(changedFiles.map(norm))]
625
+ .filter(p => /^test\//.test(p) && /\.test\.[cm]?js$/.test(p))
626
+ if (changedSrc.length === 0 && changedTests.length === 0) return []
627
+ const covered = coveredCommands.filter(Boolean).join(' ')
628
+ const found = new Set()
629
+ const testDir = join(cwd, 'test')
630
+ let entries = []
631
+ try { entries = readdirSync(testDir) } catch { entries = [] }
632
+ for (const f of entries) {
633
+ if (!/\.test\.[cm]?js$/.test(f)) continue
634
+ if (covered.includes(f)) continue
635
+ let content = ''
636
+ try { content = readFileSync(join(testDir, f), 'utf8') } catch { continue }
637
+ for (const src of changedSrc) {
638
+ if (content.includes(`../${src}`)) { found.add(`test/${f}`); break }
639
+ }
640
+ }
641
+ for (const t of changedTests) {
642
+ const fname = t.split('/').pop()
643
+ if (covered.includes(fname)) continue
644
+ if (existsSync(join(cwd, t))) found.add(t)
645
+ }
646
+ return [...found].sort()
647
+ }
648
+
608
649
  /**
609
650
  * 根据变更文件列表 + modules 映射,算出被命中的模块(去重保序)。
610
651
  * 文件路径以 module.path 为前缀(含子目录)即视为命中。
@@ -762,7 +803,21 @@ const PY_TEST_ID_RE = /^\S+::\S+$/
762
803
  // 通过行用例名恰含这些字样(如「✓ … 超时后 syncStatus=failed」)会被误判失败行——
763
804
  // 2710 用例套件 382 个"失败行"里 378 假阳性,known_failures 无法逐条枚举而实质失效。
764
805
  // 框架输出里通过行恒以通过标记开头、失败行恒以失败标记开头,行首判定即足以分离两类。
765
- const PASS_LINE_RE = /^\s*(?:[✓√✔]|PASS\b)/
806
+ // ✅ 补入标记集(2026-09-16-friction5-hardening verify 移交项②实证):本仓自定义 harness 用
807
+ // 「✅ PASS: <断言名>」打印通过行,断言名高频含 fail-closed/failed/×3(乘号)——✅ 不在集内时
808
+ // 整行落 \bFAIL\b/×/failed 子串命中成假失败行(一轮 verify 实测 20/27 未豁免行为此噪声)。
809
+ const PASS_LINE_RE = /^\s*(?:[✓√✔✅]|PASS\b)/
810
+ // CLI advisory 行(行首 emoji 前缀,剥 ANSI 后判定):⚠️ warn / ℹ️ info / 🔄 进度是工具自身的
811
+ // 提示通道(如 gate-snapshot 的「⚠️ 快照 overlay 冒烟:…import 失败」——warn 正文含 ModuleNotFoundError,
812
+ // 被子串命中成失败行)。测试框架的失败行恒以 ✕✗✘×/FAIL/--- FAIL 开头,不用这些 emoji 前缀——
813
+ // 行首判定零误伤;漏检风险由 judgeWithKnownFailures 的 fail-safe(检测不到失败行不判 pass)兜底。
814
+ const ADVISORY_LINE_RE = /^[ \t]*(?:⚠️?|ℹ️?|🔄)/
815
+ // 工具词汇 fail-<x> 复合词中和(判账前从行内剥除,2026-09-16 verify 移交项②):fail-closed/
816
+ // fail-open/fail-soft/fail-safe/fail-loud/fail-fast 是本工具的行为契约词汇,PER_TEST_FAIL_RE 的
817
+ // \bFAIL\b(i 标志)会把「(fail-closed)」「--- 1.3 fail-open 三态 ---」整行误收;剥除后再测,
818
+ // 行内其余真失败词(FAILED/assertionerror/error: 等)不受影响(如 AssertionError 断言消息里
819
+ // 出现 fail-closed 仍照常命中)。
820
+ const FAIL_COMPOUND_NEUTRALIZE_RE = /fail-(?:closed|open|soft|safe|loud|fast)/gi
766
821
  // ANSI 色码剥离(分类用):TTY 捕获的输出里 ✓/× 前缀可能被色码包裹,行首锚定会失配。
767
822
  const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
768
823
 
@@ -807,8 +862,10 @@ export function partitionFailures(output, knownFailures) {
807
862
  else pyWarnCtx = false
808
863
  }
809
864
  if (PASS_LINE_RE.test(bare)) continue
865
+ if (ADVISORY_LINE_RE.test(bare)) continue
810
866
  if (ENV_NOISE_RE.test(bare)) continue
811
- if (PER_TEST_FAIL_RE.test(bare) && !SUMMARY_LINE_RE.test(bare)) failureLines.push(l)
867
+ // fail-<x> 工具词汇中和后再测(见 FAIL_COMPOUND_NEUTRALIZE_RE 注释):真失败词不被剥除
868
+ if (PER_TEST_FAIL_RE.test(bare.replace(FAIL_COMPOUND_NEUTRALIZE_RE, '')) && !SUMMARY_LINE_RE.test(bare)) failureLines.push(l)
812
869
  }
813
870
  // 豁免匹配同样在剥 ANSI 后的行上做(坑 verify-known-failures-ansi-exemption-split,2026-09-07
814
871
  // 工具复盘):TTY 捕获的失败行里色码把可见词拦腰拆开(`× \x1b[31mtests/foo.test.ts\x1b[0m > case`
@@ -1049,20 +1106,20 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1049
1106
 
1050
1107
  // 并入 worktree 未提交改动(坑 module-subset-zero-hit-uncommitted,2026-08-21 实证:
1051
1108
  // 子代理默认不 commit,真实改动全在 worktree working-tree——只看 base..HEAD commit diff
1052
- // 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend 模块)。与
1053
- // generateTaskReviewDrafts 的并入口径同源:meta.worktreePath 下 status --porcelain 文件
1109
+ // 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend 模块)。口径与
1110
+ // collectWorktreeChangedFiles(task-review.js,D-004@v1 单一真相 helper)同源,但本段是
1111
+ // form A 专属变体(滤网保 .sillyspec/docs/** 交付物)——已知残留 R-09,待下次触碰统一。
1054
1112
  // (排除 .sillyspec/ 运行时产物)。opt-in(默认关):d drafts 有自己的并入点,避免双并。
1055
1113
  if (includeWorkingTree && mainFiles !== null) {
1056
1114
  try {
1057
- // P1 修复(2026-09-07):metaPath 吃 opts.specBase——此前硬编码 join(cwd,'.sillyspec'),
1058
- // 平台模式(specRoot 与 source_root 分离)下 worktree meta 静默读不到 → 形态 A 并入失效 → ②类假红。
1059
- // 与下方 resolveMainChangedFiles 的 specBase 兜底同口径。
1060
- const metaPath = join(opts.specBase || join(cwd, '.sillyspec'), '.runtime', 'worktrees', changeName, 'meta.json')
1061
- if (changeName && existsSync(metaPath)) {
1062
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1063
- const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1064
- ? meta.worktreePath
1065
- : cwd
1115
+ // 双根读取(坑 platform-dual-root-form-misjudge,2026-09-15 复盘实证):P1 修复
1116
+ // (2026-09-07)只吃 opts.specBase 单路径——平台模式(specRoot 与 source_root 分离)下
1117
+ // meta 若落项目侧 .sillyspec/.runtime(resolveRuntimeRoot 的 localSpecBase 兜底形)仍读
1118
+ // 不到 → 形态 A 并入失效 → ②类假红。_readWorktreeMeta 双候选(specBase 优先 +
1119
+ // cwd/.sillyspec 兜底,contract-matrix 既有样板)两形全覆盖。
1120
+ const found = changeName ? _readWorktreeMeta(opts.specBase || null, cwd, changeName) : null
1121
+ if (found) {
1122
+ const wtGitDir = found.gitDir
1066
1123
  const wtStatus = gitQuiet(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30000, trim: false })
1067
1124
  // P1 修复(2026-09-07):过滤器改 filterDeliverableFiles 口径(worktree-apply.js 同款)——
1068
1125
  // 此前 `.sillyspec/` 一刀切把 docs 交付物也滤掉,声明了模块文档的 task 在形态 A 下落②类假红
@@ -1152,16 +1209,16 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1152
1209
  function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1153
1210
  const sb = specBase || join(cwd, '.sillyspec')
1154
1211
  if (changeName) {
1155
- const metaPath = join(sb, '.runtime', 'worktrees', changeName, 'meta.json')
1156
- if (existsSync(metaPath)) {
1157
- let meta = null
1158
- try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
1212
+ // 双根读取(坑 platform-dual-root-form-misjudge):specBase(平台模式=specRoot)单路径在
1213
+ // meta 落项目侧 .sillyspec 时静默 miss → 锚点 diff 整链失效落主仓 fallback。双候选
1214
+ // (specBase 优先 + cwd/.sillyspec 兜底)两形全覆盖;sb 仍作他者声明过滤根(原语义不动)。
1215
+ const found = _readWorktreeMeta(specBase || null, cwd, changeName)
1216
+ if (found) {
1217
+ const meta = found.meta
1159
1218
  // 优先 baselineCommit/actualBaseHash(baseline checkpoint 之后),回退 baseHash
1160
1219
  const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
1161
1220
  if (diffBase) {
1162
- const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1163
- ? meta.worktreePath
1164
- : cwd
1221
+ const gitDir = found.gitDir
1165
1222
  const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
1166
1223
  // 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
1167
1224
  // wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
@@ -1326,7 +1383,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1326
1383
  },
1327
1384
  })
1328
1385
  } else if (action === 'module-subset') {
1329
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
1386
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures, changedFiles: lastChangedFiles })
1330
1387
  } else if (action === 'module-zero-hit-skip') {
1331
1388
  // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
1332
1389
  // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
@@ -1366,6 +1423,9 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1366
1423
  // 跨仓仓不参与 module 子集策略,只跑 full npm test(design §6 + §5.4);
1367
1424
  // 跨仓仓无 package.json → 跳过 + warn 不阻断(design §9 兼容策略)。
1368
1425
  // 任一跨仓仓 fail → 整体 fail(合并语义)。
1426
+ // 主仓 skipped(test_strategy: skip / evidence-auto 推荐跳过 / module 0 命中)→ 进短路档:
1427
+ // 未自配 commands.test 的跨仓不再 fallback npm test 假败(task-05 / FR-07,见
1428
+ // runCrossRepoTestUnderMainSkip 逐仓三态)。
1369
1429
  return mergeCrossRepoResults(mainResult, ctx)
1370
1430
  }
1371
1431
 
@@ -1378,6 +1438,9 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1378
1438
  * - 跨仓仓无 package.json → 跳过 + console.warn(不阻断 verify)
1379
1439
  * - 跨仓仓只跑 full npm test,不参与 module 子集策略(module 映射主仓强相关)
1380
1440
  * - 任一仓 fail → 整体 fail;主仓 skipped + 跨仓仓 passed → 整体 passed(跨仓仓有测试即有效)
1441
+ * - 主仓 skipped → 短路档(task-05 / FR-07):未自配 commands.test 的跨仓随主仓 skip 短路
1442
+ * (不跑 fallback npm test 假败),自配了 commands.test 的仍跑,own test_strategy: skip
1443
+ * 的单独 skip(runCrossRepoTestUnderMainSkip 逐仓三态)
1381
1444
  *
1382
1445
  * 单仓 ctx(无跨仓 entry)→ 直接返主仓结果,零行为变化(GOAL-2)。
1383
1446
  *
@@ -1393,9 +1456,15 @@ function mergeCrossRepoResults(mainResult, ctx) {
1393
1456
  }
1394
1457
  if (crossEntries.length === 0) return mainResult
1395
1458
 
1459
+ // 主仓 skip 短路档(task-05 / FR-07 / design §5):主仓 skipped(test_strategy: skip /
1460
+ // evidence-auto 推荐跳过 / module 0 命中)时,未自配 commands.test 的跨仓不再 fallback
1461
+ // `npm test`(无 test script 的仓必 exit 1 → npm missing-script 假败打回整批)——随主仓
1462
+ // skip 短路;自配了 commands.test 的跨仓仍跑(用户显式意图,主仓 skip 不连带豁免);own
1463
+ // local.yaml 配 test_strategy: skip 的跨仓单独 skip(逐仓生效)。主仓 passed/failed 零变化。
1464
+ const mainSkipped = !!(mainResult && mainResult.status === 'skipped')
1396
1465
  const crossResults = []
1397
1466
  for (const entry of crossEntries) {
1398
- const crossResult = runCrossRepoFullTest(entry)
1467
+ const crossResult = mainSkipped ? runCrossRepoTestUnderMainSkip(entry) : runCrossRepoFullTest(entry)
1399
1468
  crossResults.push({ repoKey: entry.repoKey, projectRoot: entry.projectRoot, result: crossResult })
1400
1469
  }
1401
1470
 
@@ -1413,10 +1482,66 @@ function mergeCrossRepoResults(mainResult, ctx) {
1413
1482
  return mergeResultInfo(mainResult, crossResults)
1414
1483
  }
1415
1484
 
1485
+ /**
1486
+ * 主仓 skip 短路档下跑单个跨仓仓测试(task-05 / FR-07,逐仓三态判定):
1487
+ * - own local.yaml(<projectRoot>/.sillyspec/local.yaml)配 test_strategy: skip → 该仓
1488
+ * 单独 skip(逐仓生效,独立于主仓策略)
1489
+ * - own local.yaml 配 commands.test → 仍走 runCrossRepoFullTest 执行(用户显式意图,
1490
+ * 主仓 skip 不连带豁免实测)
1491
+ * - 未自配 commands.test(无 local.yaml / 读取失败 / 无 commands.test)→ 随主仓 skip
1492
+ * 短路,不跑 fallback `npm test`(无 test script 的仓必 exit 1 → npm missing-script
1493
+ * 假败,与坑 cross-repo-no-test-script 同族的环境性假红——但那是主仓非 skip 时的豁免
1494
+ * 通道,语义不动)。出路注记:配 own local.yaml 的 commands.test 即可纳入实测。
1495
+ *
1496
+ * 合并语义(上游 mergeCrossRepoResults):跨仓全 skipped → 整体保持主仓 skipped(reason
1497
+ * 带短路与出路注记);自配 commands.test 的跨仓 failed → 整体 fail(真败非假败,合理打回)。
1498
+ *
1499
+ * @param {object} entry - RepoEntry(isMain=false)
1500
+ * @returns {object} 结果 shape 对齐 runCrossRepoFullTest 返回
1501
+ */
1502
+ function runCrossRepoTestUnderMainSkip(entry) {
1503
+ const crossLocalYaml = join(entry.projectRoot, '.sillyspec', 'local.yaml')
1504
+ let yamlText = null
1505
+ if (existsSync(crossLocalYaml)) {
1506
+ try { yamlText = readFileSync(crossLocalYaml, 'utf8') } catch { yamlText = null }
1507
+ }
1508
+ if (yamlText) {
1509
+ // own test_strategy: skip → 该仓单独 skip(显式声明优先于 commands.test)
1510
+ if (extractTestStrategy(yamlText) === 'skip') {
1511
+ return {
1512
+ status: 'skipped',
1513
+ command: null,
1514
+ exitCode: null,
1515
+ durationMs: null,
1516
+ outputTail: null,
1517
+ reason: `跨仓 repo "${entry.repoKey}" own local.yaml test_strategy: skip,该仓单独跳过测试(逐仓生效)`,
1518
+ resultPath: null,
1519
+ mode: 'cross-repo-skip-strategy',
1520
+ repoKey: entry.repoKey,
1521
+ }
1522
+ }
1523
+ // 自配 commands.test → 仍执行(原路径原语义)
1524
+ if (extractTestCommand(yamlText)) return runCrossRepoFullTest(entry)
1525
+ }
1526
+ // 未自配 → 随主仓 skip 短路(不再 fallback npm test 假败)
1527
+ console.warn(`⚠️ 主仓 test_strategy skip 短路档:跨仓 repo "${entry.repoKey}"(${entry.projectRoot})未自配 commands.test,不跑 fallback npm test(假败消除)。如需纳入实测:在 ${crossLocalYaml} 配 commands.test。`)
1528
+ return {
1529
+ status: 'skipped',
1530
+ command: null,
1531
+ exitCode: null,
1532
+ durationMs: null,
1533
+ outputTail: null,
1534
+ reason: `主仓 skip 短路:跨仓 repo "${entry.repoKey}" 未自配 commands.test,随主仓跳过(可配 own local.yaml commands.test 纳入实测)`,
1535
+ resultPath: null,
1536
+ mode: 'cross-repo-main-skip',
1537
+ repoKey: entry.repoKey,
1538
+ }
1539
+ }
1540
+
1416
1541
  /**
1417
1542
  * 在单个跨仓仓根跑 full npm test(跨仓仓不参与 module 子集,只跑 full)。
1418
1543
  * 跨仓仓 own local.yaml 若存在且配 commands.test → 用之;否则 fallback `npm test`。
1419
- * 跨仓仓无 package.json → 跳过 + warn。
1544
+ * 跨仓仓无 package.json,或有 package.json 但无 test 脚本(未被 own local.yaml 覆盖)→ 跳过 + warn。
1420
1545
  *
1421
1546
  * @param {object} entry - RepoEntry(isMain=false)
1422
1547
  * @returns {object} 结果 shape 对齐 runFullCommand 返回
@@ -1453,6 +1578,35 @@ function runCrossRepoFullTest(entry) {
1453
1578
  } catch { /* 读取失败 fallback npm test */ }
1454
1579
  }
1455
1580
 
1581
+ // 跨仓仓有 package.json 但无 test 脚本,且未被 own local.yaml 覆盖 → 降级跳过 + warn
1582
+ // (坑 cross-repo-no-test-script,2026-09-15 wp EHS 会话实证:spdemo 无 test script,
1583
+ // fallback `npm test` 必 exit 1(npm missing-script)→ verify-test 环境性假红,agent 被迫
1584
+ // 手工给该仓造 .sillyspec/local.yaml + 冒烟脚本。测试基建缺失是仓常态,非代码回归——
1585
+ // 与「无 package.json」同款 skip 处理,hint 指向 own local.yaml 配置通道)。
1586
+ // own local.yaml 显式配了命令 → 无条件执行(用户显式意图,即使无 test script 也跑)。
1587
+ // package.json 解析失败 → 按原行为跑 `npm test`(fail-visible,不静默吞)。
1588
+ if (command === 'npm test') {
1589
+ let hasTestScript = true
1590
+ try {
1591
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'))
1592
+ hasTestScript = !!(pkg && pkg.scripts && Object.prototype.hasOwnProperty.call(pkg.scripts, 'test'))
1593
+ } catch { /* 解析失败维持 npm test 原行为 */ }
1594
+ if (!hasTestScript) {
1595
+ console.warn(`⚠️ 跨仓 repo "${entry.repoKey}"(${projectRoot})package.json 无 test 脚本,跳过该仓测试(非代码回归,不阻断 verify)。如需纳入实测:在 ${join(projectRoot, '.sillyspec', 'local.yaml')} 配 commands.test(如轻量冒烟检查)。`)
1596
+ return {
1597
+ status: 'skipped',
1598
+ command: null,
1599
+ exitCode: null,
1600
+ durationMs: null,
1601
+ outputTail: null,
1602
+ reason: `跨仓 repo "${entry.repoKey}" package.json 无 test 脚本,跳过测试(可配 own local.yaml commands.test 纳入)`,
1603
+ resultPath: null,
1604
+ mode: 'cross-repo-no-test-script',
1605
+ repoKey: entry.repoKey,
1606
+ }
1607
+ }
1608
+ }
1609
+
1456
1610
  const startedAt = Date.now()
1457
1611
  let exitCode = 0
1458
1612
  let output = ''
@@ -1657,12 +1811,21 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
1657
1811
  * 串行跑命中的模块子集,聚合结果。
1658
1812
  * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
1659
1813
  */
1660
- function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
1814
+ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [] }) {
1661
1815
  const subsetStartedAt = Date.now()
1662
1816
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
1817
+ // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
1818
+ // 变更 src 文件的直接断言测试 → module 收窄漏全量断言。附加执行「import 变更 src 的测试 ∪
1819
+ // 变更的 test 文件」中未被命中模块命令覆盖的部分;无额外文件零行为(不新增失败面)。
1820
+ const deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
1821
+ if (deps.length > 0) {
1822
+ perModule.push(runOneModule('deps(auto)', `node --test ${deps.slice(0, 30).join(' ')}`, cwd, knownFailures))
1823
+ console.log(`ℹ️ module 子集已附加依赖测试伪模块 deps(auto):${deps.length} 个(import 变更 src/变更的 test 本体,未被模块命令串覆盖——治硬编码清单腐烂)`)
1824
+ }
1663
1825
  const status = aggregateStatus(perModule)
1664
1826
 
1665
- const command = `module[${hits.map(h => h.name).join(',')}]`
1827
+ let command = `module[${hits.map(h => h.name).join(',')}]`
1828
+ if (deps.length > 0) command += `+deps(${Math.min(deps.length, 30)})`
1666
1829
  const exitCode = status === 'passed' ? 0 : 1
1667
1830
  const durationMs = Date.now() - subsetStartedAt
1668
1831
 
@@ -1860,7 +2023,7 @@ export function printVerifyParityCheck(result) {
1860
2023
  console.warn(' ⚠️ 本次 scope 为 full-repo(diff 与 apply-pathspec 均不可得时的兜底)——missing 大概率是口径错配噪音而非真实 contract gap。')
1861
2024
  console.warn(' 先收窄复跑:sillyspec verify-probes --change <变更名>(change-diff/apply-pathspec 口径)再判定。')
1862
2025
  }
1863
- console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
2026
+ console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。') /* probe1-noqa */
1864
2027
  }
1865
2028
 
1866
2029
  /**
@@ -2038,6 +2201,34 @@ export function trackVerifyResultRegression(specBase, changeName, absPath) {
2038
2201
  * @param {{cwd:string, specBase:string, changeName?:string}} args
2039
2202
  * @returns {{status:'skipped'|'passed'|'warning', items:Array, unacknowledged:Array, summary:string, reason:string|null}}
2040
2203
  */
2204
+ /**
2205
+ * evidence 核验双根解析(坑⑤ / D-005@v1 消费侧双根 + 坑 platform-dual-root-fourth-consumer
2206
+ * 2026-09-16 hunt):apply 前新文件只在 worktree,逐文件核验单查主仓必误报「文件不存在」。
2207
+ * meta 双候选定位(平台模式 specBase=specRoot 时主仓 worktree meta 在项目侧 <cwd>/.sillyspec,
2208
+ * 单查 specBase 必落空——对齐 contract-matrix._readWorktreeMeta 双候选样板);meta 在场 &&
2209
+ * 非 in-place-fallback && worktreePath 目录存在 → [cwd, worktreePath],否则 [cwd]。
2210
+ * 解析异常 fail-open 退单根 + warning(不新增阻断路径)。导出供统一双根夹具测试。
2211
+ */
2212
+ export function resolveEvidenceDualRoots({ cwd, specBase, changeName, warnings = [] }) {
2213
+ let roots = [cwd]
2214
+ try {
2215
+ const metaCandidates = [...new Set([specBase, join(cwd, '.sillyspec')].filter(Boolean))]
2216
+ const metaPath = metaCandidates
2217
+ .map(b => join(b, '.runtime', 'worktrees', changeName, 'meta.json'))
2218
+ .find(p => existsSync(p))
2219
+ if (metaPath) {
2220
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2221
+ if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
2222
+ roots = [cwd, meta.worktreePath]
2223
+ }
2224
+ }
2225
+ } catch (e) {
2226
+ roots = [cwd]
2227
+ warnings.push(`worktree meta 解析失败(${e.message}),evidence 双根核验退主仓单根`)
2228
+ }
2229
+ return roots
2230
+ }
2231
+
2041
2232
  export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null, verifyStartAt = null }) {
2042
2233
  if (!changeName) {
2043
2234
  return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 changeName(quick 等无关联变更场景),evidence 对账跳过' }
@@ -2094,6 +2285,8 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
2094
2285
  * 天然不覆盖该类路径,硬要求交集必假红)。verifyStartAt 缺省走 R-05 fallback(design.md
2095
2286
  * created_at 之后宽容 + warning)。status 语义扩 'blocked'(missing 无豁免 / satisfied 核验
2096
2287
  * 不过)——gates 接线(task-03)据此阻断 verify 完成。
2288
+ * 逐文件存在性/mtime 双根取数(坑⑤ / D-005@v1):候选根 [cwd, worktree 根]——apply 前
2289
+ * 新文件只在 worktree,单查主仓必误报「文件不存在」;meta 缺失/in-place 退单根(零回归)。
2097
2290
  */
2098
2291
  function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt }) {
2099
2292
  const warnings = []
@@ -2121,6 +2314,14 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2121
2314
  changedSet = new Set((changed || []).map(p => String(p).replace(/\\/g, '/')))
2122
2315
  } catch { changedSet = null }
2123
2316
 
2317
+ // ── 候选根解析(坑⑤ / D-005@v1,消费侧双根):apply 前新文件只在 worktree,逐文件核验
2318
+ // 单查主仓 join(cwd, vf) 必误报「文件不存在」错误阻断 verify。worktree 根口径与
2319
+ // resolveVerifyChangedFiles(~1061 metaPath 段)同源:meta 在场 && 非 in-place-fallback
2320
+ // && worktreePath 目录存在 → roots = [cwd, worktreePath];任一不满足退 [cwd] 单根
2321
+ // (零回归)。解析异常 fail-open 退单根 + warning(不新增阻断路径)。静默双根——对齐
2322
+ // buildAcceptanceHints(verify-probes.js:223)双根先例,核验明细 root 字段已可辨。──
2323
+ let roots = resolveEvidenceDualRoots({ cwd, specBase, changeName, warnings })
2324
+
2124
2325
  const detailed = []
2125
2326
  let blockedCount = 0
2126
2327
  for (const item of items) {
@@ -2143,11 +2344,17 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2143
2344
  }
2144
2345
  for (const vf of slot.verifiedFiles) {
2145
2346
  const pathClass = classifyVerifiedFile(vf)
2146
- const abs = join(cwd, vf)
2147
- const filesExist = existsSync(abs)
2347
+ // 双根取数(坑⑤ / D-005@v1,形态沿 buildAcceptanceHints 双根先例):任一根存在即
2348
+ // filesExist=true;双根都在取 worktree 根(R-05:新改动所在,主仓旧 mtime 不误判
2349
+ // 过期)——倒序找命中根(roots=[cwd] 时即单根现状;roots=[cwd, wt] 时先试 wt 再回退)。
2350
+ let hitRoot = null
2351
+ for (let i = roots.length - 1; i >= 0; i--) {
2352
+ if (existsSync(join(roots[i], vf))) { hitRoot = roots[i]; break }
2353
+ }
2354
+ const filesExist = hitRoot !== null
2148
2355
  let mtimeOk = null
2149
2356
  if (filesExist && startAt) {
2150
- try { mtimeOk = statSync(abs).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
2357
+ try { mtimeOk = statSync(join(hitRoot, vf)).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
2151
2358
  }
2152
2359
  let diffHit = null
2153
2360
  if (pathClass === 'code') {
@@ -2157,7 +2364,7 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2157
2364
  }
2158
2365
  const ok = filesExist && mtimeOk !== false && diffHit !== false
2159
2366
  if (!ok) allOk = false
2160
- perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit,
2367
+ perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit, root: hitRoot,
2161
2368
  reason: !filesExist ? '文件不存在'
2162
2369
  : (mtimeOk === false ? `mtime 早于 verifyStartAt(${new Date(startAt).toISOString()})`
2163
2370
  : (diffHit === false ? '不在本变更 git diff 内' : null)) })
@@ -2245,7 +2452,7 @@ function normalizeReviewChangedFile(p) {
2245
2452
  * @returns {{ cardCount: number, declarations: Array<{task:string, path:string, isNew:boolean, raw:string, invalid:string|null}>, crossRepoCards: string[], noDeclarationCount: number }}
2246
2453
  */
2247
2454
  function collectDeclaredTargetFiles(specBase, changeName) {
2248
- const out = { cardCount: 0, declarations: [], crossRepoCards: [], noDeclarationCount: 0 }
2455
+ const out = { cardCount: 0, declarations: [], crossRepoCards: [], crossRepoDeclarations: {}, noDeclarationCount: 0 }
2249
2456
  const tasksDir = join(specBase, 'changes', changeName, 'tasks')
2250
2457
  if (!existsSync(tasksDir)) return out
2251
2458
  // task-NN.md 过滤 + 排序:与 plan-postcheck / task-review 的枚举口径一致,输出确定性
@@ -2257,7 +2464,18 @@ function collectDeclaredTargetFiles(specBase, changeName) {
2257
2464
  const fm = content.match(/^---\n([\s\S]*?)\n---/)?.[1] || ''
2258
2465
  const task = (fm.match(/^id:\s*(.+)/m)?.[1] || '').trim() || file.replace(/\.md$/, '')
2259
2466
  const repo = parseRepo(content)
2260
- if (repo) { out.crossRepoCards.push(`${task}(repo: ${repo})`); continue }
2467
+ if (repo) {
2468
+ out.crossRepoCards.push(`${task}(repo: ${repo})`)
2469
+ // 跨仓声明留存(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004 分期):
2470
+ // 主仓对账仍剔除跨仓卡(主仓 actual 永远对不上跨仓声明),但声明不丢弃——
2471
+ // reconcileCrossRepoDeclarations 到各自仓对账(advisory)
2472
+ const crossEntries = parseTargetFiles(content).entries || []
2473
+ if (crossEntries.length > 0) {
2474
+ ;(out.crossRepoDeclarations[repo] = out.crossRepoDeclarations[repo] || [])
2475
+ .push(...crossEntries.map(e => ({ task, path: e.path, isNew: e.isNew })))
2476
+ }
2477
+ continue
2478
+ }
2261
2479
  const { entries, missing } = parseTargetFiles(content)
2262
2480
  if (missing) { out.noDeclarationCount++; continue }
2263
2481
  for (const e of entries) {
@@ -2324,14 +2542,22 @@ function parsePorcelainFilePaths(raw) {
2324
2542
  * resolveMainChangedFiles 同口径,三锚全缺 null);形态 B 取 merge-base hash(分支不存在/
2325
2543
  * merge-base 不可得 null);降级返回恒 null。
2326
2544
  *
2327
- * @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null, baseAnchor: string|null }}
2545
+ * @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, foreignExcludedFiles: string[], degradedReason: string|null, baseAnchor: string|null }}
2328
2546
  */
2329
2547
  export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2330
- const metaPath = join(specBase, '.runtime', 'worktrees', changeName, 'meta.json')
2331
- const form = existsSync(metaPath) ? 'worktree' : 'post-apply'
2548
+ // 形态判定双根化(坑 platform-dual-root-form-misjudge,2026-09-15 复盘实证:平台模式
2549
+ // specBase=specRoot 时项目侧 .sillyspec 的 meta 读不到 → 误判 post-apply → 按主仓 git
2550
+ // 三源对账 → ②类 21 条全量假红阻断 verify 收尾)。_readWorktreeMeta 双候选(specBase
2551
+ // 优先 + cwd/.sillyspec 兜底,contract-matrix 既有样板)为形态判定的唯一权威入口。
2552
+ const wtMeta = _readWorktreeMeta(specBase || null, cwd, changeName)
2553
+ const form = wtMeta ? 'worktree' : 'post-apply'
2332
2554
  const sources = []
2333
2555
  const union = new Set()
2334
2556
  let foreignExcluded = 0
2557
+ // 退栈文件名单(scope-audit-cross-repo-blindness 改进点 3):原只回计数,scope-audit 侧
2558
+ // 无法区分「真未动」与「已写盘但被退栈」——补名单(仅文件名,owners 不重复携带),
2559
+ // 消费方按「planned ∩ excluded ∩ 盘面存在」标「疑似他者已实现」。
2560
+ const foreignExcludedFiles = []
2335
2561
  let baseAnchor = null
2336
2562
 
2337
2563
  if (form === 'worktree') {
@@ -2339,15 +2565,12 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2339
2565
  // working-tree 并入、fail-open 均为该函数既有语义,不另造口径)——
2340
2566
  const files = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2341
2567
  if (files === null) {
2342
- return { ok: false, form, files: [], sources, foreignExcluded,
2568
+ return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
2343
2569
  degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)', baseAnchor: null }
2344
2570
  }
2345
- // baseAnchor:读同一份 worktree meta.json 锚点(baselineCommit>actualBaseHash>baseHash,
2346
- // resolveMainChangedFiles 同口径;解析失败/三锚全缺 → null)
2347
- try {
2348
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2349
- baseAnchor = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash || null
2350
- } catch {}
2571
+ // baseAnchor:读形态判定命中的同一份 worktree meta.json 锚点(baselineCommit>
2572
+ // actualBaseHash>baseHash,resolveMainChangedFiles 同口径;三锚全缺 → null)
2573
+ baseAnchor = wtMeta?.meta?.baselineCommit || wtMeta?.meta?.actualBaseHash || wtMeta?.meta?.baseHash || null
2351
2574
  sources.push('worktree:diff-base..HEAD', 'worktree:status-porcelain(uncommitted)')
2352
2575
  for (const f of files) union.add(normalizeReconcilePath(f))
2353
2576
  } else {
@@ -2386,7 +2609,10 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2386
2609
  let files = parsePorcelainFilePaths(statusRaw)
2387
2610
  if (files.length > 0) {
2388
2611
  const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, files, { specBase })
2389
- if (foreign.length > 0) foreignExcluded = foreign.length
2612
+ if (foreign.length > 0) {
2613
+ foreignExcluded = foreign.length
2614
+ for (const fo of foreign) foreignExcludedFiles.push(normalizeReconcilePath(fo.file))
2615
+ }
2390
2616
  files = own
2391
2617
  }
2392
2618
  for (const f of files) union.add(normalizeReconcilePath(f))
@@ -2403,13 +2629,13 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2403
2629
  } catch { /* 兜底源失败 → 忽略 */ }
2404
2630
  }
2405
2631
  if (!diffOk && !statusOk) {
2406
- return { ok: false, form, files: [], sources, foreignExcluded,
2632
+ return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
2407
2633
  degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)', baseAnchor: null }
2408
2634
  }
2409
2635
  }
2410
2636
 
2411
2637
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2412
- return { ok: true, form, files, sources, foreignExcluded, degradedReason: null, baseAnchor }
2638
+ return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor }
2413
2639
  }
2414
2640
 
2415
2641
  /**
@@ -2419,13 +2645,15 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2419
2645
  * - 归属过滤:run 目录带 change 戳且不等值 → 他变更的 run 跳过(task-NN 跨变更同名,混扫必
2420
2646
  * 错归因;无戳旧 run 保留——向后兼容,task-review.js resolveLatestExecuteRunIdWithTasks 同款)。
2421
2647
  * - runId 形如 exec-YYYY-MM-DD-HHMMSS:字典序倒序 = 新 run 优先(review 以最新执行为准)。
2422
- * - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、首个命中即止——
2423
- * 归因只是③类报告的附注,错比漏代价低,不追求穷举。
2648
+ * - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、全命中收集(多归属,
2649
+ * 坑④ worktree-tick-guard-commit-only:原「首个命中即止」吞掉同文件多 task 的归属)——
2650
+ * 归因只是③类报告的附注,错比漏代价低;同一 taskId 去重,顺序 = 扫描序(run 倒序新优先 +
2651
+ * task 目录字典序)。
2424
2652
  *
2425
2653
  * @param {string} runtimeRoot .sillyspec/.runtime
2426
2654
  * @param {string} changeName
2427
2655
  * @param {string[]} paths 待归因的③类路径(git 口径的干净路径)
2428
- * @returns {Map<string, string>} normalized(path) → taskId
2656
+ * @returns {Map<string, string[]>} normalized(path) → taskId[](全命中收集,保序去重)
2429
2657
  */
2430
2658
  function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2431
2659
  const map = new Map()
@@ -2452,7 +2680,10 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2452
2680
  const taskId = (typeof review.task === 'string' && review.task.trim()) || t
2453
2681
  for (const cf of Array.isArray(review.changedFiles) ? review.changedFiles : []) {
2454
2682
  const n = normalizeReviewChangedFile(cf)
2455
- if (n && wantSet.has(n) && !map.has(n)) map.set(n, taskId)
2683
+ if (!n || !wantSet.has(n)) continue
2684
+ // 多归属:同文件被多 task 声明时全收集(D-004@v1,坑④)——旧单归属在此吞掉后续 task
2685
+ const list = map.get(n)
2686
+ if (list) { if (!list.includes(taskId)) list.push(taskId) } else map.set(n, [taskId])
2456
2687
  }
2457
2688
  }
2458
2689
  }
@@ -2477,7 +2708,7 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2477
2708
  * status: 'ok'|'missing_declared'|'undeclared'|'skipped'|'degraded',
2478
2709
  * matched: string[], // ①交集(唯一路径排序)
2479
2710
  * missing: Array<{task: string, path: string, isNew?: boolean}>, // ②声明没做(isNew 标 NEW: 声明,剥前缀 path)
2480
- * undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因)
2711
+ * undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因,多归属 join('、') 后的 string)
2481
2712
  * skipReason: string|null, // skipped/degraded 的原因(其余状态为 null)
2482
2713
  * notes: string[], // 跨仓卡剔除 / 并行 WIP 剔除等提示(additive)
2483
2714
  * form: 'worktree'|'post-apply'|null, // actual 形态(skipped 无 actual 时为 null;additive)
@@ -2499,8 +2730,24 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2499
2730
 
2500
2731
  // —— 声明侧 ——
2501
2732
  const decl = collectDeclaredTargetFiles(sb, changeName)
2502
- for (const label of decl.crossRepoCards) {
2503
- notes.push(`跨仓 task 卡 ${label} 已剔除——跨仓对账不在本变更范围(D-004),其声明留待后续分期`)
2733
+ // —— 跨仓 per-repo 对账(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004 分期:
2734
+ // 此前 22 个跨仓文件在归档表全标「计划未动」、跨仓全靠人工到对应仓解释)——advisory 不阻断:
2735
+ // 锚点是各仓 HEAD~1..HEAD 最近提交窗口(多 task 同仓只反映最近一笔,锚点脆弱期会产②类假
2736
+ // 信号),只报告不翻转主仓状态。结果经 crossRepo 字段(gates 渲染明细)与 notes(摘要随
2737
+ // 各状态出口流动)双通道出口。
2738
+ let crossRepo = []
2739
+ if (decl.crossRepoCards.length > 0) {
2740
+ try {
2741
+ crossRepo = reconcileCrossRepoDeclarations({ specBase: sb, cwd, declarationsByRepo: decl.crossRepoDeclarations })
2742
+ } catch { crossRepo = [] /* 跨仓对账异常不拖垮主仓链(advisory 定位) */ }
2743
+ for (const label of decl.crossRepoCards) {
2744
+ notes.push(`跨仓 task 卡 ${label} 不进主仓对账(主仓 actual 对不上跨仓声明,D-004)——其声明已转 per-repo 对账(见下方跨仓对账段)`)
2745
+ }
2746
+ for (const r of crossRepo) {
2747
+ notes.push(r.degradedReason
2748
+ ? `跨仓 ${r.repo}:${r.degradedReason}`
2749
+ : `跨仓 ${r.repo} 对账:声明 ${r.declaredCount} / 实测 ${r.actualCount} / 对上 ${r.matched.length} / ②缺 ${r.missing.length} / ③多 ${r.undeclared.length}${r.scaffoldCount > 0 ? `(另有 ${r.scaffoldCount} 脚手架已聚合)` : ''}——advisory(锚点=该仓最近提交窗口)`)
2750
+ }
2504
2751
  }
2505
2752
  if (decl.cardCount === 0) {
2506
2753
  return { status: 'skipped', matched, missing, undeclared,
@@ -2514,7 +2761,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2514
2761
  && decl.noDeclarationCount === decl.cardCount - decl.crossRepoCards.length) {
2515
2762
  return { status: 'skipped', matched, missing, undeclared,
2516
2763
  skipReason: `严格模式变更(created_at ≥ IR_STRICT_SINCE)主仓 task 卡全部未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount - decl.crossRepoCards.length} 张主仓卡${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})`,
2517
- notes, form: null, sources: [],
2764
+ notes, form: null, sources: [], crossRepo,
2518
2765
  strictViolation: {
2519
2766
  code: 'target_files_all_missing_strict',
2520
2767
  message: `严格模式要求每个主仓 task 卡声明 target_files(计划改动的文件清单,taskcard 骨架已预置字段)——逐卡 Edit 填入:已存在文件写仓根相对精确路径,新建文件加 NEW: 前缀;填毕重跑 --done(进度不丢)`,
@@ -2526,7 +2773,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2526
2773
  : `所有 task 卡均未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount} 张无声明` +
2527
2774
  `${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})` +
2528
2775
  `——存量卡可忽略;新卡请补:每条为仓根相对精确文件路径,不存在的文件加 NEW: 前缀`
2529
- return { status: 'skipped', matched, missing, undeclared, skipReason: reason, notes, form: null, sources: [] }
2776
+ return { status: 'skipped', matched, missing, undeclared, skipReason: reason, notes, form: null, sources: [], crossRepo }
2530
2777
  }
2531
2778
 
2532
2779
  // —— actual 侧(三源并集,两形态)——
@@ -2538,7 +2785,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2538
2785
  // 降级(fail-soft 不误红):git 全部不可用 → actual 不可得,不产生 missing/undeclared
2539
2786
  return { status: 'degraded', matched, missing, undeclared,
2540
2787
  skipReason: `git 全部不可用,actual 不可得,对账降级跳过——${actual.degradedReason}`,
2541
- notes, form: actual.form, sources: actual.sources }
2788
+ notes, form: actual.form, sources: actual.sources, crossRepo }
2542
2789
  }
2543
2790
 
2544
2791
  // —— 三类差集 ——
@@ -2567,17 +2814,36 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2567
2814
  missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
2568
2815
  }
2569
2816
  }
2570
- // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)
2817
+ // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)。
2818
+ // 归因多归属(attributeSuspectTasks 返 string[],坑④):组装边界 join('、') 成 string——
2819
+ // gates.js / archive-delta.js 等下游渲染零改动(suspectTask 对外形态保持 string)。
2571
2820
  const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredKeySet.has(pathKey(p))))
2821
+ // 脚手架软桶(坑 reconcile-undeclared-scaffold-flood,2026-09-15 复盘实证:worktree baseline
2822
+ // 拷入的 .claude/skills、attachments 等设施文件 58 条逐条进③类、每轮 verify 重复刷屏):
2823
+ // classifyToolScaffold(worktree-apply / scope-audit 同款口径)命中的工具/平台设施文件聚合
2824
+ // 为一行 note——环境设施面非交付物,逐条列③类只会淹没真 scope creep。全为脚手架时③类
2825
+ // 判 ok(脚手架不构成 scope creep 语义)。
2826
+ const scaffoldBuckets = { platform: 0, tool: 0 }
2827
+ const scaffoldSamples = []
2572
2828
  for (const path of actual.files) {
2573
2829
  if (declaredKeySet.has(pathKey(path))) continue
2830
+ const bucket = classifyToolScaffold(path)
2831
+ if (bucket) {
2832
+ scaffoldBuckets[bucket] = (scaffoldBuckets[bucket] || 0) + 1
2833
+ if (scaffoldSamples.length < 5) scaffoldSamples.push(path)
2834
+ continue
2835
+ }
2574
2836
  const s = suspect.get(normalizeReviewChangedFile(path))
2575
- undeclared.push(s ? { path, suspectTask: s } : { path })
2837
+ undeclared.push(Array.isArray(s) && s.length > 0 ? { path, suspectTask: s.join('、') } : { path })
2838
+ }
2839
+ const scaffoldTotal = scaffoldBuckets.platform + scaffoldBuckets.tool
2840
+ if (scaffoldTotal > 0) {
2841
+ notes.push(`③类另有 ${scaffoldTotal} 个工具/平台脚手架文件(platform ${scaffoldBuckets.platform} / tool ${scaffoldBuckets.tool},如 ${scaffoldSamples.join('、')}${scaffoldTotal > scaffoldSamples.length ? ' 等' : ''})已聚合不逐条列——非交付物(scope-audit 设施桶同口径)`)
2576
2842
  }
2577
2843
 
2578
2844
  // 状态聚合:②在场即 ERROR 态优先(gates 阻断语义靠它兑现);仅③ → WARNING 态
2579
2845
  const status = missing.length > 0 ? 'missing_declared' : (undeclared.length > 0 ? 'undeclared' : 'ok')
2580
- return { status, matched, missing, undeclared, skipReason: null, notes, form: actual.form, sources: actual.sources }
2846
+ return { status, matched, missing, undeclared, undeclaredScaffold: scaffoldTotal, skipReason: null, notes, form: actual.form, sources: actual.sources, crossRepo }
2581
2847
  }
2582
2848
 
2583
2849
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2613,6 +2879,21 @@ export const PROBE5_MISSING_ROW_RE = /^\| ❌ missing \|/
2613
2879
  * unavailable 行 / 无删除行 / ℹ️ note 行天然区分——后三者均无「反引号路径+状态尾缀」形态) */
2614
2880
  export const PROBE6_DELETION_LINE_RE = /^- (?:✅ 合规|❌ 高风险|⚠️ 未声明删除)[^`]* `[^`]+`(git 状态/
2615
2881
 
2882
+ /** probe8 契约外载荷键汇总行:`- ⚠️ 契约外载荷键 N 条(design 契约面未见,…):…`
2883
+ * (renderProbe8Lines,verify-probes.js;N 条计数段与段内其它 ⚠️ 行(疑似字段错位配对/
2884
+ * NOT NULL 列前端未见)区分——契约行是汇总行非逐条行,锚点取行内 N 而非行计数) */
2885
+ export const PROBE8_CONTRACT_ORPHANS_LINE_RE = /^- ⚠️ 契约外载荷键 (\d+) 条/
2886
+
2887
+ /** probe8 契约必填漏发汇总行:`- ⚠️ 契约必填漏发 N 条(契约 required 前端载荷未见,…):…`
2888
+ * (同上汇总行口径,行内 N 为锚) */
2889
+ export const PROBE8_MISSING_REQUIRED_LINE_RE = /^- ⚠️ 契约必填漏发 (\d+) 条/
2890
+
2891
+ /** probe9 守卫不一致汇总行:`- ⚠️ 守卫不一致实体组 N 个(同实体有守卫/无守卫并存——…)`
2892
+ * (renderProbe9Lines,verify-probes.js;同 probe8 契约行汇总行口径——锚点取行内 N 而非
2893
+ * 逐条实体组行计数;`实体组 N 个` 计数段与段内逐条行 `- ⚠️ 实体 X:有守卫 […] / 无守卫 […]`
2894
+ * 及其它 ⚠️ 行(NOT NULL 列等)形态区分) */
2895
+ export const PROBE9_INCONSISTENT_LINE_RE = /^- ⚠️ 守卫不一致实体组 (\d+) 个/
2896
+
2616
2897
  /**
2617
2898
  * `#### 探针 N` 子节定界(G8/R-05):子节 = 标题行起、下一任意 markdown 标题(下一探针
2618
2899
  * 子节 / `##` 章 / `#` 题)前止。探针 2/4 的 agent 补写内容天然落在本子节定界之外,其
@@ -2643,27 +2924,40 @@ function extractProbeSubsections(text) {
2643
2924
  * 导出供渲染→解析 round-trip 测试直接消费(R-01)。
2644
2925
  * @param {string} text verify-result.md 全文
2645
2926
  * @returns {{
2646
- * subsections: { probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, any: boolean },
2927
+ * subsections: { probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, probe8: boolean, probe9: boolean, any: boolean },
2647
2928
  * probe1Hits: number, // probe1 命中行计数(对比 probe1.matches.length)
2648
2929
  * probe3HasTest: number, // probe3 hasTest 行计数(对比 tasks.filter(hasTest).length)
2649
2930
  * probe5SummaryPresent: boolean, // probe5 summary 锚行存在性(pass 形态锚)
2650
2931
  * probe5Missing: number, // `| ❌ missing |` 行计数(fail 形态锚,对比 missingBackend.length)
2651
2932
  * probe6Deletions: number, // probe6 删除条目行计数(对比 deletions.length)
2933
+ * probe8ContractOrphans: number, // probe8 契约外载荷键汇总行内 N 求和(无行=0;对比 contractOrphans.length)
2934
+ * probe8MissingRequired: number, // probe8 契约必填漏发汇总行内 N 求和(无行=0;对比 missingRequired.length)
2935
+ * probe9InconsistentGroups: number, // probe9 守卫不一致汇总行内 N 求和(无行=0;对比 inconsistentGroups.length)
2652
2936
  * }}
2653
2937
  */
2654
2938
  export function parseProbePrefillAnchors(text) {
2655
2939
  const sections = extractProbeSubsections(text)
2656
2940
  const count = (lines, re) => lines.reduce((n, l) => n + (re.test(l) ? 1 : 0), 0)
2941
+ // 汇总行锚点(probe8 契约行/probe9 守卫行是「一行含总数」非逐条行):提取行内 N 求和,无行 → 0
2942
+ // (渲染面恒只输出一行,重复撞形行累加——重复本身就会撞出 mismatch,同子节合并口径)
2943
+ const sumGroupCount = (lines, re) => lines.reduce((n, l) => {
2944
+ const m = l.match(re)
2945
+ return n + (m ? Number(m[1]) : 0)
2946
+ }, 0)
2657
2947
  const s1 = sections['1'] || []
2658
2948
  const s3 = sections['3'] || []
2659
2949
  const s5 = sections['5'] || []
2660
2950
  const s6 = sections['6'] || []
2951
+ const s8 = sections['8'] || []
2952
+ const s9 = sections['9'] || []
2661
2953
  return {
2662
2954
  subsections: {
2663
2955
  probe1: '1' in sections,
2664
2956
  probe3: '3' in sections,
2665
2957
  probe5: '5' in sections,
2666
2958
  probe6: '6' in sections,
2959
+ probe8: '8' in sections,
2960
+ probe9: '9' in sections,
2667
2961
  any: Object.keys(sections).length > 0,
2668
2962
  },
2669
2963
  probe1Hits: count(s1, PROBE1_HIT_LINE_RE),
@@ -2671,6 +2965,9 @@ export function parseProbePrefillAnchors(text) {
2671
2965
  probe5SummaryPresent: count(s5, PROBE5_SUMMARY_LINE_RE) > 0,
2672
2966
  probe5Missing: count(s5, PROBE5_MISSING_ROW_RE),
2673
2967
  probe6Deletions: count(s6, PROBE6_DELETION_LINE_RE),
2968
+ probe8ContractOrphans: sumGroupCount(s8, PROBE8_CONTRACT_ORPHANS_LINE_RE),
2969
+ probe8MissingRequired: sumGroupCount(s8, PROBE8_MISSING_REQUIRED_LINE_RE),
2970
+ probe9InconsistentGroups: sumGroupCount(s9, PROBE9_INCONSISTENT_LINE_RE),
2674
2971
  }
2675
2972
  }
2676
2973
 
@@ -2719,7 +3016,13 @@ function detectHeadAdvanceSinceFacts(cwd, facts) {
2719
3016
  * - HEAD 前进子案(R-06):facts.generatedAt 之后有新 commit 时 probe6 的 `git diff HEAD`
2720
3017
  * 口径整体漂移 → 该 mismatch 降 WARNING 提示重跑 --init;
2721
3018
  * - probe3 hasTest 数 / probe5 锚(summary 行存在性 + missing 行数)不符 = WARNING
2722
- * (测试文件布局与 parity 扫描根环境敏感)。
3019
+ * (测试文件布局与 parity 扫描根环境敏感);
3020
+ * - probe8 契约锚(契约外载荷键/契约必填漏发汇总行内 N)不符 = WARNING(2026-09-16
3021
+ * task-02:契约面解析随 design 形态与清单文件读取环境敏感;重跑降级(fail-soft catch
3022
+ * 兜底无契约键)时跳过该维度不误报);
3023
+ * - probe9 守卫锚(守卫不一致实体组汇总行内 N)不符 = WARNING(2026-09-16
3024
+ * guard-consistency-probe task-02:聚类/信号比对随 design 清单与 Java 文件读取环境
3025
+ * 敏感;重跑降级时跳过该维度不误报)。
2723
3026
  *
2724
3027
  * 接线约定(task-03,gates.js):status='mismatch' 且 severity='error' → 阻断回滚;
2725
3028
  * 'mismatch'+'warning' → 放行告警(envelope probe_consistency_drift);'skipped'/'degraded' →
@@ -2740,7 +3043,7 @@ function detectHeadAdvanceSinceFacts(cwd, facts) {
2740
3043
  * mismatches: Array<{probe: string, expected: *, actual: *, severity: 'error'|'warning', note: string}>,
2741
3044
  * // expected = 当前重跑指标(应然),actual = 正文锚点解析值(agent 可篡改侧)
2742
3045
  * skipReason: string|null,
2743
- * subsections: {probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, any: boolean}|null,
3046
+ * subsections: {probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, probe8: boolean, probe9: boolean, any: boolean}|null,
2744
3047
  * // 诊断(additive):探针子节在场性;报告未读到的早期 skip 为 null
2745
3048
  * }}
2746
3049
  */
@@ -2856,6 +3159,37 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2856
3159
  note: 'contract gap(missing backend)行数与重跑不符(探针5 扫描根/缓存口径环境敏感,WARNING 不阻断)' })
2857
3160
  }
2858
3161
 
3162
+ // probe8 契约锚(WARNING——2026-09-16 task-02:契约外载荷键/契约必填漏发汇总行内 N 对比
3163
+ // 重跑数组长度;契约面解析随 design 形态与清单文件读取环境敏感,不阻断。fail-soft:重跑
3164
+ // 降级(runVerifyProbes 的 catch 兜底对象无契约键)时 curP8*=null 跳过对比不误报)
3165
+ const curProbe8 = current.probe8 || {}
3166
+ const curP8Orphans = Array.isArray(curProbe8.contractOrphans) ? curProbe8.contractOrphans.length : null
3167
+ const curP8MissingRequired = Array.isArray(curProbe8.missingRequired) ? curProbe8.missingRequired.length : null
3168
+ if (curP8Orphans !== null && anchors.probe8ContractOrphans !== curP8Orphans) {
3169
+ mismatches.push({ probe: 'probe8', expected: curP8Orphans, actual: anchors.probe8ContractOrphans, severity: 'warning',
3170
+ note: '契约外载荷键条数与重跑不符(探针8 契约面解析对 design/清单文件形态环境敏感,WARNING 不阻断)——建议重跑 sillyspec verify-probes --change <变更名> --init 刷新预填段' })
3171
+ }
3172
+ if (curP8MissingRequired !== null && anchors.probe8MissingRequired !== curP8MissingRequired) {
3173
+ mismatches.push({ probe: 'probe8', expected: curP8MissingRequired, actual: anchors.probe8MissingRequired, severity: 'warning',
3174
+ note: '契约必填漏发条数与重跑不符(探针8 契约面解析对 design/清单文件形态环境敏感,WARNING 不阻断)——建议重跑 sillyspec verify-probes --change <变更名> --init 刷新预填段' })
3175
+ }
3176
+
3177
+ // probe9 守卫锚(WARNING——2026-09-16 guard-consistency-probe task-02:守卫不一致实体组
3178
+ // 汇总行内 N 对比重跑数组长度;聚类启发式与信号比对随 design 清单及 Java 文件读取环境
3179
+ // 敏感,不阻断。fail-soft:重跑降级(runVerifyProbes 的 catch 兜底)时 probe9 兜底对象与
3180
+ // probe8 不同——inconsistentGroups 恒为数组([]),Array.isArray 判别失效,改以「探针 9
3181
+ // 执行失败」注记判降级跳过对比,不误报)
3182
+ const curProbe9 = current.probe9 || {}
3183
+ const curP9Degraded = (curProbe9.notes || []).some(n => typeof n === 'string' && n.includes('探针 9 执行失败'))
3184
+ const curP9Inconsistent = Array.isArray(curProbe9.inconsistentGroups) && !curP9Degraded
3185
+ ? curProbe9.inconsistentGroups.length : null
3186
+ const curP9GroupCount = typeof curProbe9.groupCount === 'number' && !curP9Degraded
3187
+ ? curProbe9.groupCount : null
3188
+ if (curP9Inconsistent !== null && anchors.probe9InconsistentGroups !== curP9Inconsistent) {
3189
+ mismatches.push({ probe: 'probe9', expected: curP9Inconsistent, actual: anchors.probe9InconsistentGroups, severity: 'warning',
3190
+ note: '守卫不一致实体组数与重跑不符(探针9 聚类/信号比对对 design 清单与 Java 文件形态环境敏感,WARNING 不阻断)——建议重跑 sillyspec verify-probes --change <变更名> --init 刷新预填段' })
3191
+ }
3192
+
2859
3193
  // —— facts 基线对比(2026-09-08-ir-verify-facts FR-05 / task-05):重跑指标 vs
2860
3194
  // verify-facts.json probes 快照(init 时点)。md 锚点对账查「正文没被改」,本维度查
2861
3195
  // 「md 被手改对齐新代码后 facts 底稿过期」(P3d 数据源保鲜)。分级沿用现实现口径:
@@ -2888,8 +3222,45 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2888
3222
  fm.push({ probe: 'probe5', snapshot: snap5, rerun: cur5, severity: 'warning',
2889
3223
  note: 'facts 快照 backend 端点数与重跑不符(扫描根口径环境敏感,WARNING)' })
2890
3224
  }
3225
+ // probe8 契约指标(2026-09-16 task-02,WARNING 同 md 锚点维度口径;metrics fail-soft
3226
+ // 少列键(旧 facts 无 probe8)或重跑降级时不对账不列 checked,不误报)
3227
+ const snap8Metrics = (((factsSnapshot.probes.probe8 || {}).metrics) || {})
3228
+ const p8MetricsAvailable = typeof snap8Metrics.contractOrphans === 'number' || typeof snap8Metrics.missingRequired === 'number'
3229
+ const p8RerunAvailable = curP8Orphans !== null || curP8MissingRequired !== null
3230
+ if (typeof snap8Metrics.contractOrphans === 'number' && curP8Orphans !== null
3231
+ && snap8Metrics.contractOrphans !== curP8Orphans) {
3232
+ fm.push({ probe: 'probe8', snapshot: snap8Metrics.contractOrphans, rerun: curP8Orphans, severity: 'warning',
3233
+ note: 'facts 快照契约外载荷键数与重跑不符(契约面解析环境敏感,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3234
+ }
3235
+ if (typeof snap8Metrics.missingRequired === 'number' && curP8MissingRequired !== null
3236
+ && snap8Metrics.missingRequired !== curP8MissingRequired) {
3237
+ fm.push({ probe: 'probe8', snapshot: snap8Metrics.missingRequired, rerun: curP8MissingRequired, severity: 'warning',
3238
+ note: 'facts 快照契约必填漏发数与重跑不符(契约面解析环境敏感,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3239
+ }
3240
+ // probe9 守卫指标(2026-09-16 guard-consistency-probe task-02,WARNING 同 md 锚点维度口径;
3241
+ // 双指标 = inconsistentGroups(命中面,同 md 锚点维度)+ groupCount(聚类组面,design 清单
3242
+ // Java 文件内容变更的漂移信号)。javaFileCount 不入对账——文件读取受 worktree 存活态等
3243
+ // 环境维度影响(R-02 同款排除口径,probe1 排除 worktreeHits 先例);metrics fail-soft 少列
3244
+ // 键(旧 facts 无 probe9)或重跑降级(curP9*=null)时不对账不列 checked,不误报)
3245
+ const snap9Metrics = (((factsSnapshot.probes.probe9 || {}).metrics) || {})
3246
+ const p9MetricsAvailable = typeof snap9Metrics.inconsistentGroups === 'number' || typeof snap9Metrics.groupCount === 'number'
3247
+ const p9RerunAvailable = curP9Inconsistent !== null || curP9GroupCount !== null
3248
+ if (typeof snap9Metrics.inconsistentGroups === 'number' && curP9Inconsistent !== null
3249
+ && snap9Metrics.inconsistentGroups !== curP9Inconsistent) {
3250
+ fm.push({ probe: 'probe9', snapshot: snap9Metrics.inconsistentGroups, rerun: curP9Inconsistent, severity: 'warning',
3251
+ note: 'facts 快照守卫不一致组数与重跑不符(聚类/信号比对环境敏感,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3252
+ }
3253
+ if (typeof snap9Metrics.groupCount === 'number' && curP9GroupCount !== null
3254
+ && snap9Metrics.groupCount !== curP9GroupCount) {
3255
+ fm.push({ probe: 'probe9', snapshot: snap9Metrics.groupCount, rerun: curP9GroupCount, severity: 'warning',
3256
+ note: 'facts 快照同实体变更方法组数与重跑不符(聚类面随 design 清单 Java 文件内容漂移,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3257
+ }
2891
3258
  factsConsistency = {
2892
- checked: fm.length === 0 ? ['probe1', 'probe3', 'probe5', 'probe6'] : [...new Set(fm.map(x => x.probe))],
3259
+ checked: fm.length === 0
3260
+ ? ['probe1', 'probe3', 'probe5', 'probe6',
3261
+ ...((p8MetricsAvailable && p8RerunAvailable) ? ['probe8'] : []),
3262
+ ...((p9MetricsAvailable && p9RerunAvailable) ? ['probe9'] : [])]
3263
+ : [...new Set(fm.map(x => x.probe))],
2893
3264
  verdict: fm.length === 0 ? 'match' : 'mismatch',
2894
3265
  detail: fm.length === 0 ? null : fm,
2895
3266
  }