sillyspec 3.28.2 → 3.28.4

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 (67) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +16 -55
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-continue/SKILL.md +8 -38
  4. package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +19 -249
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-propose/SKILL.md +6 -77
  8. package/.claude/skills/sillyspec-resume/SKILL.md +8 -57
  9. package/.claude/skills/sillyspec-state/SKILL.md +7 -46
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +14 -156
  11. package/AGENTS.md +24 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/src/archive-delta.js +127 -2
  15. package/src/change-delete.js +2 -1
  16. package/src/change-list.js +7 -2
  17. package/src/change-risk-profile.js +77 -6
  18. package/src/config-cat.js +12 -1
  19. package/src/constants.js +9 -1
  20. package/src/design-facts.js +64 -0
  21. package/src/dispatch/backends/sillyhub-mcp.js +17 -5
  22. package/src/docs-check.js +153 -27
  23. package/src/docs-migrate.js +124 -0
  24. package/src/doctor-diagnostics.js +441 -5
  25. package/src/foreign-declared.js +53 -2
  26. package/src/index.js +353 -28
  27. package/src/module-impact.js +77 -12
  28. package/src/plan-adopt-waves.js +13 -5
  29. package/src/progress/change-registry.js +35 -0
  30. package/src/progress.js +64 -1
  31. package/src/quick-recommend.js +6 -1
  32. package/src/quicklog.js +10 -2
  33. package/src/run/command.js +159 -13
  34. package/src/run/complete-handlers.js +162 -9
  35. package/src/run/complete.js +104 -5
  36. package/src/run/gates.js +116 -8
  37. package/src/run/progress-confirm.js +66 -0
  38. package/src/run/prompt.js +235 -36
  39. package/src/run/quick-audit.js +12 -5
  40. package/src/run/scan-profile.js +20 -2
  41. package/src/run/shared.js +84 -11
  42. package/src/run/stage.js +42 -0
  43. package/src/runtime-hygiene.js +84 -0
  44. package/src/scan-postcheck.js +5 -1
  45. package/src/spec-sync.js +63 -8
  46. package/src/stage-contract.js +83 -3
  47. package/src/stage-review.js +3 -0
  48. package/src/stages/archive.js +4 -4
  49. package/src/stages/brainstorm-auto.js +2 -1
  50. package/src/stages/brainstorm.js +11 -25
  51. package/src/stages/doctor.js +41 -515
  52. package/src/stages/execute.js +10 -14
  53. package/src/stages/plan-postcheck.js +191 -7
  54. package/src/stages/plan.js +7 -27
  55. package/src/stages/quick.js +15 -13
  56. package/src/stages/verify.js +23 -21
  57. package/src/sync-noise.js +158 -0
  58. package/src/sync.js +42 -17
  59. package/src/task-review.js +225 -1
  60. package/src/verify-facts-schema.js +141 -0
  61. package/src/verify-postcheck.js +421 -49
  62. package/src/verify-probes.js +142 -13
  63. package/src/workflow.js +6 -0
  64. package/src/worktree-apply.js +51 -13
  65. package/src/worktree.js +5 -1
  66. package/templates/agents-instruction.md +1 -1
  67. package/.claude/skills/verify-per-user/SKILL.md +0 -6
@@ -23,8 +23,9 @@
23
23
  */
24
24
 
25
25
  import { execSync, spawnSync } from 'child_process'
26
+ import { IR_STRICT_SINCE } from './constants.js'
26
27
  import { gitQuiet } from './git-helper.js'
27
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs'
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
28
29
  import { join } from 'path'
29
30
  import { verifyApiParity } from './contract-matrix.js'
30
31
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
@@ -36,10 +37,30 @@ import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
36
37
  // fs/path/git-helper/change-list/plan-postcheck/contract-matrix/foreign-declared/run-shared,
37
38
  // 不反向依赖本模块,此处直连不引入循环
38
39
  import { runVerifyProbes } from './verify-probes.js'
40
+ import { parseEvidenceSlots, classifyVerifiedFile } from './verify-facts-schema.js'
39
41
 
40
42
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
41
43
  const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
42
44
  const OUTPUT_TAIL_CHARS = 4000
45
+ // 判账失败行台账上限(坑 verify-test-reconcile-tail-blindspot):对账按完整输出判、tail 只存
46
+ // 末 4000 字符 → 失败行落在盲区时只剩 reason 里 5 行×120 字符采样,归因必须全量复跑。修法是
47
+ // 判账行集(remaining/exempted 全量原文)随结果透传落盘/打印;台账只防病态体量——单行截
48
+ // 300 字符、行数截 200,超出注明计数。judge 判账仍用全量行集,不受此截断影响。
49
+ const FAILURE_LEDGER_MAX_LINES = 200
50
+ const FAILURE_LEDGER_MAX_CHARS = 300
51
+
52
+ /** 失败行台账截断(落盘/展示用;判账本身不受影响)。export 供 test 验证截断语义 */
53
+ export function capFailureLedger(lines) {
54
+ if (!Array.isArray(lines) || lines.length === 0) return []
55
+ const out = lines.slice(0, FAILURE_LEDGER_MAX_LINES).map(l => {
56
+ const s = String(l)
57
+ return s.length > FAILURE_LEDGER_MAX_CHARS ? s.slice(0, FAILURE_LEDGER_MAX_CHARS) + '…' : s
58
+ })
59
+ if (lines.length > FAILURE_LEDGER_MAX_LINES) {
60
+ out.push(`…(共 ${lines.length} 行,仅列前 ${FAILURE_LEDGER_MAX_LINES} 行;判账按全量行集不受此截断影响)`)
61
+ }
62
+ return out
63
+ }
43
64
 
44
65
  /**
45
66
  * 行尾归一(坑 verify-modules-crlf-blanket-fallback,2026-08-20 实证):Windows 仓的
@@ -95,6 +116,36 @@ function extractLintCommand(yamlText) {
95
116
  return null
96
117
  }
97
118
 
119
+ /**
120
+ * lint advisory 观察期计数(刀③,2026-09-08):advisory 失败不阻断,但「观察期后升级硬门」
121
+ * 需要失败率数据支撑(更硬 vs 更吵的决策依据——硬门误伤纯文档/跨平台路径会制造新噪音)。
122
+ * 每次非 skipped 实测追加计数进 <specBase>/.runtime/verify-lint-tally.json(passed/failed
123
+ * 都记,分母同要);history 截尾 20 条。写失败静默降级——计数器不许反向阻断 verify。
124
+ */
125
+ function recordVerifyLintTally({ specBase, result }) {
126
+ try {
127
+ const runtimeDir = join(specBase, '.runtime')
128
+ mkdirSync(runtimeDir, { recursive: true })
129
+ const tallyPath = join(runtimeDir, 'verify-lint-tally.json')
130
+ let tally = { totalRuns: 0, failedRuns: 0, history: [] }
131
+ try {
132
+ tally = { ...tally, ...JSON.parse(readFileSync(tallyPath, 'utf8')) }
133
+ } catch { /* 首跑/文件损坏 → 重置重新累计 */ }
134
+ tally.totalRuns += 1
135
+ if (result.status === 'failed') tally.failedRuns += 1
136
+ tally.lastRunAt = new Date().toISOString()
137
+ tally.lastStatus = result.status
138
+ tally.history = [
139
+ ...(Array.isArray(tally.history) ? tally.history : []),
140
+ { at: tally.lastRunAt, status: result.status, reason: result.reason || null },
141
+ ].slice(-20)
142
+ writeFileSync(tallyPath, JSON.stringify(tally, null, 2))
143
+ return { failedRuns: tally.failedRuns, totalRuns: tally.totalRuns }
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
98
149
  /**
99
150
  * verify --done 实测跑 local.yaml commands.lint(2026-08-21 审查 CLI-1)。
100
151
  * 此前 lint 全靠 agent 自跑自报("我跑过 lint 了"纯口头),与 test 侧的对账不对称——
@@ -142,18 +193,36 @@ export function runVerifyLintCheck({ cwd, specBase }) {
142
193
  }
143
194
  const durationMs = Date.now() - startedAt
144
195
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
196
+ const status = exitCode === 0 ? 'passed' : 'failed'
197
+ const finalReason = exitCode === 0 ? null : reason
198
+ const tally = recordVerifyLintTally({ specBase, result: { status, reason: finalReason } })
145
199
 
146
200
  return {
147
- status: exitCode === 0 ? 'passed' : 'failed',
201
+ status,
148
202
  command,
149
203
  exitCode,
150
204
  durationMs,
151
205
  outputTail,
152
- reason: exitCode === 0 ? null : reason,
206
+ reason: finalReason,
207
+ tally, // { failedRuns, totalRuns } | null——观察期失败率,升级硬门的决策依据
153
208
  }
154
209
  }
155
210
 
156
- /** 打印 lint 实测结果(advisory:失败不阻断,只把口头汇报对上账) */
211
+ /**
212
+ * lint 硬门决策(2026-09-09 升硬,外部数据驱动):观察期 tally 14 次 5 败全真阳性零误伤,
213
+ * 且 pre-push 钩子本就硬拦——verify 侧硬门只是同一标准 fail-fast 前移。
214
+ * 逃生档:SILLYSPEC_VERIFY_LINT_GATE=advisory(审计留痕,对齐 quick 门 skip env 先例)。
215
+ * @returns {boolean} failed 且非逃生档 → true(调用方 rollback)
216
+ */
217
+ export function shouldBlockVerifyLint(lintCheck, env = process.env) {
218
+ if (!lintCheck || lintCheck.status !== 'failed') return false
219
+ return env.SILLYSPEC_VERIFY_LINT_GATE !== 'advisory'
220
+ }
221
+
222
+ /**
223
+ * 打印 lint 实测结果(2026-09-09 起按 shouldBlockVerifyLint 分支):硬门档「已阻断」+
224
+ * 逃生 env 指引(rollback 由 gates 接线侧执行);advisory 档保留旧观察期措辞。
225
+ */
157
226
  export function printVerifyLintCheck(result) {
158
227
  if (result.status === 'skipped') {
159
228
  console.warn(`\n⚠️ Verify lint 实测跳过:${result.reason}`)
@@ -163,8 +232,22 @@ export function printVerifyLintCheck(result) {
163
232
  console.log(`\n✅ Verify lint 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
164
233
  return
165
234
  }
166
- console.error(`\n⚠️ Verify lint 实测失败(advisory,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
167
- console.error(' agent 的 lint 自报告与实测不符时以实测为准;请修复后重跑,避免格式债推迟到 commit 被 pre-commit hook 拦截。')
235
+ // 文案随门禁档位分支(2026-09-09 复核修正 P2:硬门档下旧「不阻断/观察期」措辞与
236
+ // gates 的 rollback 行为打架,agent 会误判 CLI 抽风)——rollback 本体在 gates 接线侧。
237
+ if (shouldBlockVerifyLint(result)) {
238
+ console.error(`\n❌ Verify lint 实测失败——verify 完成已阻断(2026-09-09 起硬门):\`${result.command}\` — ${result.reason}`)
239
+ console.error(' agent 的 lint 自报告与实测不符时以实测为准;修复后重跑 --done(进度不丢)。')
240
+ if (result.tally) {
241
+ console.error(` 📊 累计 ${result.tally.failedRuns}/${result.tally.totalRuns} 次(.runtime/verify-lint-tally.json)。`)
242
+ }
243
+ console.error(' 确认要跳过实测:SILLYSPEC_VERIFY_LINT_GATE=advisory(审计留痕)。')
244
+ } else {
245
+ console.error(`\n⚠️ Verify lint 实测失败(advisory 档,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
246
+ console.error(' agent 的 lint 自报告与实测不符时以实测为准;请修复后重跑,避免格式债推迟到 commit 被 pre-commit hook 拦截。')
247
+ if (result.tally) {
248
+ console.error(` 📊 lint advisory 失败累计 ${result.tally.failedRuns}/${result.tally.totalRuns} 次(数据落 .runtime/verify-lint-tally.json)。`)
249
+ }
250
+ }
168
251
  if (result.outputTail) {
169
252
  const tail = result.outputTail.split('\n').slice(-15).join('\n')
170
253
  console.error(' 输出(末尾):')
@@ -586,24 +669,37 @@ export function extractKnownFailures(yamlText) {
586
669
  if (inline) {
587
670
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
588
671
  }
589
- // 坑 verify-known-failures-comment-line-truncation(2026-08-28 连续踩两次):块内注释行/空行
590
- // 打断「连续列表项」的捕获链 → 只取到注释前的项,注释后的项静默丢失 → 清单残缺、
591
- // verify 实测 remaining>0 假红。块定义放行注释行与空行(提取侧本就只认 `- ` 项行,
592
- // 吸收进块的注释不影响结果)。
593
- const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
594
- if (block) {
595
- return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
596
- .map(s => {
597
- const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
598
- const full = item.match(/^(['"])(.*)\1$/)
599
- if (full) return full[2] // 整体引号值原样保留(内部 # 不是注释)
600
- const stripped = item.replace(/[ \t]+#.*$/, '').trim() // 行尾注释须 # 前有空白(YAML 语义),裸 # 不截
601
- const q2 = stripped.match(/^(['"])(.*)\1$/)
602
- return q2 ? q2[2] : stripped
603
- })
604
- .filter(Boolean)
605
- }
606
- return []
672
+ // 坑 verify-known-failures-comment-line-truncation(2026-08-28 连续踩两次)→ 坑
673
+ // verify-known-failures-block-fragile-chain(2026-09-07 工具复盘升级修复):块式捕获曾要求
674
+ // 「- 项行 / 注释行 / 空行」**连续成链**((...)+),任何一行不合形态——手改残留的缩进杂行、
675
+ // 空项 ` -`、误删半截的段残迹——链条即断,其后所有条目**静默丢失**(实证形态:G/H 注释段
676
+ // 踩着 F 段残迹重写,F 段遗留行断链 → G/H 条目全丢 → 清单残缺 → verify 假红,且丢得多静默
677
+ // 就多难归因)。改为逐行扫描:块 = known_failures: 头之后连续的「缩进行 / 注释行(任意缩进,
678
+ // 含列首)/ 空行」,首个列首正文行收块;块内杂行一律容忍,`- ` 项行无论隔着什么都提取——
679
+ // 只有 YAML 本就不合法的形态(列首裸 `- 项`)才不取,不再因断链静默丢项。
680
+ const lines = yaml.split('\n')
681
+ const headIdx = lines.findIndex(l => /^known_failures:\s*(?:#.*)?$/.test(l))
682
+ if (headIdx === -1) return []
683
+ const itemRe = /^[ \t]+-[ \t]+(.+)$/
684
+ const items = []
685
+ for (let i = headIdx + 1; i < lines.length; i++) {
686
+ const l = lines[i]
687
+ if (l.trim() === '') continue // 空行:块内分隔,放行
688
+ if (/^[ \t]*#/.test(l)) continue // 注释行(任意缩进含列首):段头/归因注记,放行
689
+ if (!/^[ \t]/.test(l)) break // 列首正文行:下一个顶层键,收块
690
+ const m = l.match(itemRe) // 其余缩进行一律留在块内;项行才提取(杂行不提取但不断链)
691
+ if (m) items.push(m[1])
692
+ }
693
+ return items
694
+ .map(s => {
695
+ const item = s.trim()
696
+ const full = item.match(/^(['"])(.*)\1$/)
697
+ if (full) return full[2] // 整体引号值原样保留(内部 # 不是注释)
698
+ const stripped = item.replace(/[ \t]+#.*$/, '').trim() // 行尾注释须 # 前有空白(YAML 语义),裸 # 不截
699
+ const q2 = stripped.match(/^(['"])(.*)\1$/)
700
+ return q2 ? q2[2] : stripped
701
+ })
702
+ .filter(Boolean)
607
703
  }
608
704
 
609
705
  // 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
@@ -698,11 +794,15 @@ export function partitionFailures(output, knownFailures) {
698
794
  if (ENV_NOISE_RE.test(bare)) continue
699
795
  if (PER_TEST_FAIL_RE.test(bare) && !SUMMARY_LINE_RE.test(bare)) failureLines.push(l)
700
796
  }
701
- const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
797
+ // 豁免匹配同样在剥 ANSI 后的行上做(坑 verify-known-failures-ansi-exemption-split,2026-09-07
798
+ // 工具复盘):TTY 捕获的失败行里色码把可见词拦腰拆开(`× \x1b[31mtests/foo.test.ts\x1b[0m > case`
799
+ // 连不成 `tests/foo.test.ts > case`),同一行被迫拆成两段各配一条模式才能豁免。模式侧同步剥
800
+ // ANSI(从原始输出誊抄来的模式可能自带码);返回仍保留原文(remaining/exempted 展示不变形)。
801
+ const pats = (knownFailures || []).map(p => String(p).replace(ANSI_RE, '').toLowerCase()).filter(Boolean)
702
802
  const exempted = []
703
803
  const remaining = []
704
804
  for (const l of failureLines) {
705
- const ll = l.toLowerCase()
805
+ const ll = l.replace(ANSI_RE, '').toLowerCase()
706
806
  if (pats.some(p => ll.includes(p))) exempted.push(l)
707
807
  else remaining.push(l)
708
808
  }
@@ -718,19 +818,28 @@ export function partitionFailures(output, knownFailures) {
718
818
  * 有未豁免失败行 / 检测不到失败行(保守)→ failed
719
819
  * fail-safe:检测不到失败行绝不自动 pass(避免解析盲区导致假 PASS)。
720
820
  *
721
- * @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number }}
821
+ * 判账行集全量随结果返回(坑 verify-test-reconcile-tail-blindspot):对账按完整输出判,但
822
+ * outputTail 只留末 OUTPUT_TAIL_CHARS 字符——失败行落在盲区时 reason 采样不足以归因,只能
823
+ * 全量复跑。remainingLines/exemptedLines(全量原文行)由调用方透传到 test-result.json 与
824
+ * 控制台明细,盲区归因不再依赖复跑;passed 分支同样带 exemptedLines(豁免披露复核需要)。
825
+ *
826
+ * @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number,
827
+ * remainingLines: string[], exemptedLines: string[] }}
722
828
  */
723
829
  export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
724
- if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0 }
830
+ if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0, remainingLines: [], exemptedLines: [] }
831
+ const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures || [])
725
832
  if (!knownFailures || knownFailures.length === 0) {
726
- return { status: 'failed', reason: baseReason, exemptedCount: 0 }
833
+ // 无清单分支同样带行集:豁免与否都不该丢判账依据(tail 盲区归因同权)
834
+ return { status: 'failed', reason: baseReason, exemptedCount: 0, remainingLines: remaining, exemptedLines: [] }
727
835
  }
728
- const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures)
729
836
  if (failureLines.length > 0 && remaining.length === 0) {
730
837
  return {
731
838
  status: 'passed',
732
839
  reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
733
840
  exemptedCount: exempted.length,
841
+ remainingLines: [],
842
+ exemptedLines: exempted,
734
843
  }
735
844
  }
736
845
  // 剩余未豁免失败行:列出具体行 + 预存债指引(坑 verify-known-failures-stale-list)。
@@ -742,7 +851,9 @@ export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailur
742
851
  const t = String(l).trim()
743
852
  return t.length > 120 ? t.slice(0, 120) + '…' : t
744
853
  })
745
- const more = remaining.length > 5 ? `\n …(其余 ${remaining.length - 5} 行见上方测试输出)` : ''
854
+ // 指针改指台账(坑 verify-test-reconcile-tail-blindspot):原「见上方测试输出」只对 tail
855
+ // 窗口内的行成立,盲区行必须指向完整台账(控制台明细段 + test-result.json 的 failure_remaining)
856
+ const more = remaining.length > 5 ? `\n …(其余 ${remaining.length - 5} 行完整清单见下方「未豁免失败行」明细,已全量落盘 test-result.json 的 failure_remaining——不受输出 tail 截断影响)` : ''
746
857
  detail = `${remaining.length} 个失败行未命中 known_failures 清单:\n - ${sample.join('\n - ')}${more}\n → 若是预存债(变更前就失败),加入 local.yaml 的 known_failures 清单;若是本次变更引入的真实失败,请修复`
747
858
  } else {
748
859
  detail = '失败输出未检测到可豁免的失败行(保守判 fail)'
@@ -751,6 +862,8 @@ export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailur
751
862
  status: 'failed',
752
863
  reason: baseReason ? `${baseReason}(${detail})` : detail,
753
864
  exemptedCount: exempted.length,
865
+ remainingLines: remaining,
866
+ exemptedLines: exempted,
754
867
  }
755
868
  }
756
869
 
@@ -814,6 +927,8 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
814
927
  outputTail,
815
928
  reason: judged.reason,
816
929
  exemptedCount: judged.exemptedCount,
930
+ failureRemaining: judged.remainingLines,
931
+ failureExempted: judged.exemptedLines,
817
932
  }
818
933
  }
819
934
 
@@ -1062,6 +1177,13 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1062
1177
 
1063
1178
  const rawStrategy = extractTestStrategy(yamlText)
1064
1179
  const knownFailures = extractKnownFailures(yamlText)
1180
+ if (knownFailures.length > 0) {
1181
+ // 豁免清单加载可见性(坑 verify-known-failures-block-fragile-chain 的观测面):条数即时报出
1182
+ // ——清单被块内残迹截断/误删段时「N 条」与预期不符一眼可见,不必等假红再反推。
1183
+ // 走 stderr(console.warn):本模块运行期叙述统一不碰 stdout(machine-interface 调用时
1184
+ // stdout 留给机器可读输出)
1185
+ console.warn(`📋 known_failures 豁免清单已加载:${knownFailures.length} 条模式(local.yaml)`)
1186
+ }
1065
1187
 
1066
1188
  // —— evidence-auto 生效策略解析(D-005@v2 / task-11)——
1067
1189
  // 按变更目录 module-impact.md 影响面取生效策略(行为→module、纯文档/门禁→skip、
@@ -1304,6 +1426,8 @@ function runCrossRepoFullTest(entry) {
1304
1426
  mode: 'cross-repo-full',
1305
1427
  repoKey: entry.repoKey,
1306
1428
  exemptedCount: judged.exemptedCount,
1429
+ failureRemaining: judged.remainingLines,
1430
+ failureExempted: judged.exemptedLines,
1307
1431
  }
1308
1432
  }
1309
1433
 
@@ -1327,6 +1451,9 @@ function mergeResultStatus({ status, mainResult, crossResults, reason }) {
1327
1451
  resultPath: mainResult.resultPath,
1328
1452
  mode: 'cross-repo-merged',
1329
1453
  fallbackReason: mainResult.fallbackReason || null,
1454
+ // 判账行集跨仓合并(坑 verify-test-reconcile-tail-blindspot):mergedTail 截断不影响台账
1455
+ failureRemaining: [...(mainResult.failureRemaining || []), ...crossResults.flatMap(r => r.result.failureRemaining || [])],
1456
+ failureExempted: [...(mainResult.failureExempted || []), ...crossResults.flatMap(r => r.result.failureExempted || [])],
1330
1457
  }
1331
1458
  }
1332
1459
 
@@ -1457,6 +1584,8 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
1457
1584
  mode: 'full',
1458
1585
  fallbackReason,
1459
1586
  exemptedCount: judged.exemptedCount,
1587
+ failureRemaining: judged.remainingLines,
1588
+ failureExempted: judged.exemptedLines,
1460
1589
  }
1461
1590
 
1462
1591
  writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
@@ -1500,6 +1629,10 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }
1500
1629
  mode: 'module-subset',
1501
1630
  fallbackReason: null,
1502
1631
  exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
1632
+ // 判账行集聚合(坑 verify-test-reconcile-tail-blindspot):合并 tail 会二次截断,行集不截——
1633
+ // 每模块台账另见 extra.modules[].failure_remaining(带模块归属)
1634
+ failureRemaining: perModule.flatMap(r => r.failureRemaining || []),
1635
+ failureExempted: perModule.flatMap(r => r.failureExempted || []),
1503
1636
  }
1504
1637
 
1505
1638
  writeRunResult({
@@ -1515,6 +1648,8 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }
1515
1648
  duration_ms: r.durationMs,
1516
1649
  output_tail: r.outputTail,
1517
1650
  reason: r.reason,
1651
+ ...(r.failureRemaining && r.failureRemaining.length ? { failure_remaining: capFailureLedger(r.failureRemaining) } : {}),
1652
+ ...(r.failureExempted && r.failureExempted.length ? { failure_exempted: capFailureLedger(r.failureExempted) } : {}),
1518
1653
  })),
1519
1654
  },
1520
1655
  })
@@ -1523,9 +1658,9 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }
1523
1658
 
1524
1659
  /**
1525
1660
  * 结果落盘到 .runtime/verify-runs/<ts>/test-result.json(供追溯与 SillyHub 消费)。
1526
- * 多模块时 extra.modules 描述各模块明细。
1661
+ * 多模块时 extra.modules 描述各模块明细。export 供 test 验证台账落盘形状。
1527
1662
  */
1528
- function writeRunResult({ specBase, changeName, result, extra = {} }) {
1663
+ export function writeRunResult({ specBase, changeName, result, extra = {} }) {
1529
1664
  try {
1530
1665
  const ts = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '')
1531
1666
  const runDir = join(specBase, '.runtime', 'verify-runs', ts)
@@ -1538,11 +1673,19 @@ function writeRunResult({ specBase, changeName, result, extra = {} }) {
1538
1673
  status: result.status,
1539
1674
  duration_ms: result.durationMs,
1540
1675
  output_tail: result.outputTail,
1676
+ // 判账失败行台账(坑 verify-test-reconcile-tail-blindspot):按完整输出判账的行集全量
1677
+ // 落盘(受 capFailureLedger 防病态体量截断)——失败行落在 output_tail 盲区时归因读这里,
1678
+ // 不再需要全量复跑。空集不落键(成功/跳过运行不添噪声)。
1679
+ ...(result.failureRemaining && result.failureRemaining.length ? { failure_remaining: capFailureLedger(result.failureRemaining) } : {}),
1680
+ ...(result.failureExempted && result.failureExempted.length ? { failure_exempted: capFailureLedger(result.failureExempted) } : {}),
1541
1681
  reason: result.reason,
1542
1682
  ran_at: new Date().toISOString(),
1543
1683
  ...extra,
1544
1684
  }, null, 2) + '\n')
1545
1685
  result.resultPath = resultPath
1686
+ // verify-runs 回收不在此处(ql-20260908-005 已定分野):变更归属类证据走归档时精确
1687
+ // 回收(pruneArchivedChangeRuntime 按目录内 JSON change 字段归属);写入侧滚动是
1688
+ // 启发式,不进证据类目录。
1546
1689
  } catch (e) {
1547
1690
  console.warn(`⚠️ verify 实测结果落盘失败: ${e.message}`)
1548
1691
  }
@@ -1565,11 +1708,30 @@ export function printVerifyTestCheck(result) {
1565
1708
  if (result.exemptedCount > 0) {
1566
1709
  console.log(`\n✅ Verify 实测通过(含 ${result.exemptedCount} 个 known_failures 豁免):\`${result.command}\` — ${result.reason}`)
1567
1710
  console.warn(' ⚠️ 本次 PASS 依赖 known_failures 豁免清单——请人工复核清单是否过宽(避免误豁免本变更引入的真实失败)。')
1711
+ // 豁免披露明细(坑 verify-test-reconcile-tail-blindspot):被豁免的失败行同样可能落在
1712
+ // tail 盲区——复核"清单是否过宽"不能只看 tail。前 10 条样例进控制台,全量台账在
1713
+ // test-result.json 的 failure_exempted。
1714
+ if (result.failureExempted && result.failureExempted.length) {
1715
+ console.warn(` 已豁免失败行(共 ${result.failureExempted.length} 行,前 10 条;全量见 test-result.json 的 failure_exempted):`)
1716
+ for (const line of capFailureLedger(result.failureExempted).slice(0, 10)) console.warn(` ~ ${line}`)
1717
+ }
1568
1718
  } else {
1569
1719
  console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
1570
1720
  }
1571
1721
  } else {
1572
1722
  console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
1723
+ // 未豁免失败行台账(坑 verify-test-reconcile-tail-blindspot):判账按完整输出、下方 tail
1724
+ // 只留末段——失败行落在盲区时 tail 里看不到,归因靠本段全量行集(≤30 条样例进控制台,
1725
+ // 全量落盘 failure_remaining),不再需要全量复跑定位。
1726
+ if (result.failureRemaining && result.failureRemaining.length) {
1727
+ const ledger = capFailureLedger(result.failureRemaining)
1728
+ const shown = ledger.slice(0, 30)
1729
+ console.error(` 未豁免失败行(按完整输出判账,不受 tail 截断影响;共 ${result.failureRemaining.length} 行):`)
1730
+ for (const line of shown) console.error(` ! ${line}`)
1731
+ if (ledger.length > shown.length) {
1732
+ console.error(` ! …(其余 ${ledger.length - shown.length} 行全量见 test-result.json 的 failure_remaining)`)
1733
+ }
1734
+ }
1573
1735
  if (result.outputTail) {
1574
1736
  const tail = result.outputTail.split('\n').slice(-20).join('\n')
1575
1737
  console.error(' 输出(末尾):')
@@ -1781,7 +1943,7 @@ export function printVerifyDeletionCheck(result) {
1781
1943
  * @param {{cwd:string, specBase:string, changeName?:string}} args
1782
1944
  * @returns {{status:'skipped'|'passed'|'warning', items:Array, unacknowledged:Array, summary:string, reason:string|null}}
1783
1945
  */
1784
- export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null }) {
1946
+ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null, verifyStartAt = null }) {
1785
1947
  if (!changeName) {
1786
1948
  return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 changeName(quick 等无关联变更场景),evidence 对账跳过' }
1787
1949
  }
@@ -1802,6 +1964,13 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
1802
1964
  const verifyResultPath = join(specBase, 'changes', changeName, 'verify-result.md')
1803
1965
  const report = existsSync(verifyResultPath) ? readFileSync(verifyResultPath, 'utf8') : ''
1804
1966
 
1967
+ // ── v2 槽优先(2026-09-08-ir-verify-facts FR-02):「## 证据账」槽段在场走分类核验;
1968
+ // 无槽(存量)降级 legacy 子串提及对账——行为等同旧版(D-001@v2,warning 不阻断)。──
1969
+ const slots = parseEvidenceSlots(report)
1970
+ if (slots.hasEvidenceSlot) {
1971
+ return runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt })
1972
+ }
1973
+
1805
1974
  const unacknowledged = []
1806
1975
  for (const item of items) {
1807
1976
  const task = item && item.task
@@ -1816,16 +1985,115 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
1816
1985
  }
1817
1986
 
1818
1987
  if (unacknowledged.length > 0) {
1819
- return { status: 'warning', items, unacknowledged,
1988
+ return { status: 'warning', items, unacknowledged, legacy: true,
1820
1989
  summary: `${items.length} 个 cannot_verify evidence 任务中 ${unacknowledged.length} 个未在 verify-result.md 体现`, reason: null }
1821
1990
  }
1822
- return { status: 'passed', items, unacknowledged: [],
1991
+ return { status: 'passed', items, unacknowledged: [], legacy: true,
1823
1992
  summary: `${items.length} 个 cannot_verify evidence 任务均在 verify-result.md 体现(满足度由 agent 自报告)`, reason: null }
1824
1993
  }
1825
1994
 
1826
- /** 打印 required-evidence 对账结果(advisory,不阻断 verify 完成) */
1995
+ /**
1996
+ * v2 分类核验(design Phase 2,FR-02):证据账槽行状态 + verifiedFiles 逐文件核验。
1997
+ * code 类(classifyVerifiedFile):存在 × mtime ≥ verifyStartAt × git diff 交集三核验;
1998
+ * artifact 类(.runtime/日志/文档):存在 × mtime(diff 交集豁免——resolveVerifyChangedFiles
1999
+ * 天然不覆盖该类路径,硬要求交集必假红)。verifyStartAt 缺省走 R-05 fallback(design.md
2000
+ * created_at 之后宽容 + warning)。status 语义扩 'blocked'(missing 无豁免 / satisfied 核验
2001
+ * 不过)——gates 接线(task-03)据此阻断 verify 完成。
2002
+ */
2003
+ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt }) {
2004
+ const warnings = []
2005
+ let startAt = verifyStartAt ? new Date(verifyStartAt).getTime() : null
2006
+ if (!startAt) {
2007
+ // R-05 fallback:DB 不可得/接线间隙(W2→W3)——design.md created_at 之后即宽容
2008
+ try {
2009
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
2010
+ if (existsSync(designPath)) {
2011
+ const fm = readFileSync(designPath, 'utf8').match(/^---\r?\n([\s\S]*?)\r?\n---/)
2012
+ const cm = fm && fm[1].match(/^created_at:\s*(.+)$/m)
2013
+ if (cm) {
2014
+ const t = new Date(cm[1].trim()).getTime()
2015
+ if (!Number.isNaN(t)) { startAt = t; warnings.push('verifyStartAt 未提供,走 design.md created_at 宽容基准(R-05 fallback)') }
2016
+ }
2017
+ }
2018
+ } catch { /* fallback 的 fallback:不设基准(只核存在性) */ }
2019
+ if (!startAt) warnings.push('verifyStartAt 与 design created_at 均不可得——mtime 核验降级为仅存在性')
2020
+ }
2021
+
2022
+ // git diff 集合(code 类交集核验用;fail-soft 拿不到 → diffHit 记 null 不判红)
2023
+ let changedSet = null
2024
+ try {
2025
+ const changed = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2026
+ changedSet = new Set((changed || []).map(p => String(p).replace(/\\/g, '/')))
2027
+ } catch { changedSet = null }
2028
+
2029
+ const detailed = []
2030
+ let blockedCount = 0
2031
+ for (const item of items) {
2032
+ const task = item && item.task
2033
+ if (!task) continue
2034
+ const slot = slots.requiredEvidence.find(e => e.task === task) || null
2035
+ if (!slot) {
2036
+ detailed.push({ task, verdict: item.verdict || 'cannot_verify', status: 'missing', exempt: false,
2037
+ verification: null, reason: `cannot_verify 任务 ${task} 未在证据账槽段登记(逐 task 一行:状态三选一 + verifiedFiles)` })
2038
+ blockedCount++
2039
+ continue
2040
+ }
2041
+ const perFile = []
2042
+ let allOk = true
2043
+ if (slot.status === 'satisfied' || slot.status === 'partial') {
2044
+ if (slot.verifiedFiles.length === 0) {
2045
+ allOk = false
2046
+ perFile.push({ path: null, pathClass: null, filesExist: false, mtimeOk: null, diffHit: null,
2047
+ reason: `${slot.status} 但 verifiedFiles 为空(精确路径必填)` })
2048
+ }
2049
+ for (const vf of slot.verifiedFiles) {
2050
+ const pathClass = classifyVerifiedFile(vf)
2051
+ const abs = join(cwd, vf)
2052
+ const filesExist = existsSync(abs)
2053
+ let mtimeOk = null
2054
+ if (filesExist && startAt) {
2055
+ try { mtimeOk = statSync(abs).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
2056
+ }
2057
+ let diffHit = null
2058
+ if (pathClass === 'code') {
2059
+ diffHit = changedSet === null ? null : changedSet.has(vf)
2060
+ } else {
2061
+ diffHit = true // artifact 类豁免 diff(design Phase 2:日志/文档天然不在 diff)
2062
+ }
2063
+ const ok = filesExist && mtimeOk !== false && diffHit !== false
2064
+ if (!ok) allOk = false
2065
+ perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit,
2066
+ reason: !filesExist ? '文件不存在'
2067
+ : (mtimeOk === false ? `mtime 早于 verifyStartAt(${new Date(startAt).toISOString()})`
2068
+ : (diffHit === false ? '不在本变更 git diff 内' : null)) })
2069
+ }
2070
+ }
2071
+ const failed = (slot.status === 'satisfied' && !allOk) || (slot.status === 'missing' && !slot.exempt)
2072
+ if (failed) blockedCount++
2073
+ detailed.push({
2074
+ task, verdict: item.verdict || 'cannot_verify', status: slot.status, exempt: slot.exempt,
2075
+ verification: perFile.length > 0 ? perFile : null,
2076
+ reason: failed
2077
+ ? (slot.status === 'missing' ? 'missing 且无(豁免:<理由>)后缀——cannot_verify 未闭环'
2078
+ : (slot.status === 'satisfied' ? 'satisfied 核验不过(见 verification 明细)' : 'partial 但 verifiedFiles 全部核验不过'))
2079
+ : (slot.exempt ? `missing 已豁免:${slot.exemptionReason}`
2080
+ : (slot.status === 'partial' ? 'partial(部分核验通过,理由见槽行括注)' : '核验通过')),
2081
+ })
2082
+ }
2083
+
2084
+ if (blockedCount > 0) {
2085
+ return { status: 'blocked', items, detailed, warnings,
2086
+ unacknowledged: detailed.filter(d => d.status === 'missing' && !d.exempt && !d.verification).map(d => ({ task: d.task, reason: d.reason })),
2087
+ summary: `${items.length} 个 cannot_verify 任务中 ${blockedCount} 个未闭环(missing 无豁免或核验不过)`, reason: null }
2088
+ }
2089
+ return { status: 'passed', items, detailed, unacknowledged: [], warnings,
2090
+ summary: `${items.length} 个 cannot_verify 任务证据账全部闭环(状态 + 分类核验通过)`, reason: null }
2091
+ }
2092
+
2093
+ /** 打印 required-evidence 对账结果(v2:blocked 由 gates 阻断;legacy warning 不阻断) */
1827
2094
  export function printVerifyRequiredEvidenceCheck(result) {
1828
2095
  if (result.status === 'skipped') return // 静默:无 evidence 文件 / 无 changeName / 解析失败
2096
+ if (result.status === 'blocked') return // 阻断明细由 gates 接线侧统一输出(本函数不重复打印)
1829
2097
  if (result.status === 'passed') {
1830
2098
  console.log(`\n✅ required-evidence 对账通过:${result.summary}`)
1831
2099
  return
@@ -2101,7 +2369,7 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2101
2369
  * sources: string[], // actual 实际用到的源(诊断可见性;additive)
2102
2370
  * }}
2103
2371
  */
2104
- export function reconcileTargetFiles({ cwd, specBase = null, changeName = null, runtimeRoot = null }) {
2372
+ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null, runtimeRoot = null, strictMode = false }) {
2105
2373
  const matched = []
2106
2374
  const missing = []
2107
2375
  const undeclared = []
@@ -2124,6 +2392,19 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2124
2392
  skipReason: `变更 ${changeName} 无 task 卡(tasks/ 缺失或为空),target_files 对账跳过`, notes, form: null, sources: [] }
2125
2393
  }
2126
2394
  if (decl.declarations.length === 0) {
2395
+ // IR 严格档(2026-09-07-ir-hardening D-003@v1,FR-01):主仓卡全部零声明 → strictViolation。
2396
+ // 判据(Grill P2-④修订):noDeclarationCount > 0 且 == cardCount - 跨仓卡数——跨仓卡不参与
2397
+ // 计数也不充当豁免(防「塞一张跨仓卡让全部主仓卡免检」绕过)。部分主仓卡已声明 → 不触发(灰度 WARNING)。
2398
+ if (strictMode && decl.noDeclarationCount > 0
2399
+ && decl.noDeclarationCount === decl.cardCount - decl.crossRepoCards.length) {
2400
+ return { status: 'skipped', matched, missing, undeclared,
2401
+ skipReason: `严格模式变更(created_at ≥ IR_STRICT_SINCE)主仓 task 卡全部未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount - decl.crossRepoCards.length} 张主仓卡${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})`,
2402
+ notes, form: null, sources: [],
2403
+ strictViolation: {
2404
+ code: 'target_files_all_missing_strict',
2405
+ message: `严格模式要求每个主仓 task 卡声明 target_files(计划改动的文件清单,taskcard 骨架已预置字段)——逐卡 Edit 填入:已存在文件写仓根相对精确路径,新建文件加 NEW: 前缀;填毕重跑 --done(进度不丢)`,
2406
+ } }
2407
+ }
2127
2408
  // 存量零红门禁:全部卡无声明 / 全部跨仓 → WARNING 语义跳过,不产生 missing/undeclared
2128
2409
  const reason = decl.noDeclarationCount === 0
2129
2410
  ? `本变更 task 卡全部为跨仓卡(${decl.crossRepoCards.length} 张),主仓 target_files 对账跳过`
@@ -2146,24 +2427,35 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2146
2427
  }
2147
2428
 
2148
2429
  // —— 三类差集 ——
2149
- const actualSet = new Set(actual.files)
2430
+ // 对账键(2026-09-08 用户反馈⑥:计划文件名 vs 实测文件名的字面差脆):声明侧 task 卡是
2431
+ // agent 手写(大小写随手、尾部注记、./ 前缀残留),actual 侧是 git 机器产出——字面相等
2432
+ // 判定下这些形态差全部落②类假红(missing_declared,ERROR 态阻断 verify)。统一 canonical
2433
+ // key 再比对:normalizeReviewChangedFile 归一(./ 前缀/尾部注记/行内注释/反斜杠)+
2434
+ // win32/darwin 大小写折叠(默认文件系统大小写不敏感,两形指同一文件;Linux 保持敏感)。
2435
+ // 报告侧仍用原始字面串(用户按自己写的名字找到声明处)。
2436
+ const foldCase = process.platform === 'win32' || process.platform === 'darwin';
2437
+ const pathKey = (p) => {
2438
+ const n = normalizeReviewChangedFile(p);
2439
+ return foldCase ? n.toLowerCase() : n;
2440
+ };
2441
+ const actualKeySet = new Set(actual.files.map(pathKey));
2150
2442
  const declaredPaths = [...new Set(decl.declarations.map(d => d.path))].sort()
2151
- const declaredSet = new Set(declaredPaths)
2443
+ const declaredKeySet = new Set(declaredPaths.map(pathKey))
2152
2444
  // ①交集(计数进 evidence):唯一路径口径(多卡声明同文件只计一次)
2153
2445
  for (const path of declaredPaths) {
2154
- if (actualSet.has(path)) matched.push(path)
2446
+ if (actualKeySet.has(pathKey(path))) matched.push(path)
2155
2447
  }
2156
2448
  // ②声明没做(计划落空,ERROR):逐卡逐条报 task-NN + path;NEW: 声明的取剥前缀 path、带
2157
- // isNew 标(“新建文件没建”与“存量文件没动”的修复指引不同)
2449
+ // isNew 标("新建文件没建"与"存量文件没动"的修复指引不同)
2158
2450
  for (const d of decl.declarations) {
2159
- if (!actualSet.has(d.path)) {
2451
+ if (!actualKeySet.has(pathKey(d.path))) {
2160
2452
  missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
2161
2453
  }
2162
2454
  }
2163
2455
  // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)
2164
- const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredSet.has(p)))
2456
+ const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredKeySet.has(pathKey(p))))
2165
2457
  for (const path of actual.files) {
2166
- if (declaredSet.has(path)) continue
2458
+ if (declaredKeySet.has(pathKey(path))) continue
2167
2459
  const s = suspect.get(normalizeReviewChangedFile(path))
2168
2460
  undeclared.push(s ? { path, suspectTask: s } : { path })
2169
2461
  }
@@ -2337,7 +2629,20 @@ function detectHeadAdvanceSinceFacts(cwd, facts) {
2337
2629
  * // 诊断(additive):探针子节在场性;报告未读到的早期 skip 为 null
2338
2630
  * }}
2339
2631
  */
2340
- export function checkProbeConsistency({ cwd, specBase = null, changeName = null, runtimeRoot = null }) {
2632
+ /**
2633
+ * IR 严格模式判别(change: 2026-09-07-ir-hardening,D-001@v1):
2634
+ * changes.created_at ≥ IR_STRICT_SINCE → true(严格档:P3b 探针段缺失 / P3a 主仓卡全零声明 ERROR)。
2635
+ * fail-open:读不到 created_at(无行/db 异常/pm 缺失)→ false,落存量豁免不误伤。
2636
+ * @param {{ getChangeCreatedAt?: Function }} opts.pm - ProgressManager(或含同名方法的对象)
2637
+ */
2638
+ export function isStrictChange({ pm, cwd, changeName } = {}) {
2639
+ if (!pm || typeof pm.getChangeCreatedAt !== 'function' || !changeName) return false
2640
+ const createdAt = pm.getChangeCreatedAt(cwd, changeName)
2641
+ if (!createdAt || typeof createdAt !== 'string') return false
2642
+ return createdAt >= IR_STRICT_SINCE
2643
+ }
2644
+
2645
+ export function checkProbeConsistency({ cwd, specBase = null, changeName = null, runtimeRoot = null, strictMode = false }) {
2341
2646
  if (!changeName) {
2342
2647
  return { status: 'skipped', severity: null, mismatches: [],
2343
2648
  skipReason: '无 changeName(quick 等无关联变更场景),探针一致性抽查跳过', subsections: null }
@@ -2370,8 +2675,20 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2370
2675
  note: 'verify-facts.json 在场而正文探针子节全缺——疑似 agent 删除机械预填段(对比基准是正文,删 facts.json 绕不过防护)',
2371
2676
  }], null)
2372
2677
  }
2678
+ if (strictMode) {
2679
+ // IR 严格档(2026-09-07-ir-hardening D-002@v1,FR-01):闸门后变更不跑 verify-probes --init
2680
+ // 即零子节——不再是「存量兼容 skip」而是 ERROR(可绕过口收紧)。文案必须带可执行指引(R-02)。
2681
+ return finish('mismatch', 'error', [{
2682
+ probe: 'prefill',
2683
+ code: 'probe_prefill_missing_strict',
2684
+ expected: '#### 探针 预填子节在场',
2685
+ actual: '全部缺失',
2686
+ severity: 'error',
2687
+ note: '严格模式变更(created_at ≥ IR_STRICT_SINCE)的 verify-result.md 缺探针预填子节——先跑 sillyspec verify-probes --change <变更名> --init 生成九章节骨架并如实填写,再重跑 --done',
2688
+ }], null)
2689
+ }
2373
2690
  return finish('skipped', null, [],
2374
- 'verify-result.md 无 #### 探针 子节且无 verify-facts.json(存量旧格式报告),探针一致性抽查跳过')
2691
+ 'verify-result.md 无 #### 探针 子节且无 verify-facts.json(存量旧格式报告,闸门前变更),探针一致性抽查跳过')
2375
2692
  }
2376
2693
 
2377
2694
  // —— 重跑探针取当前指标(specDir 显式传 sb:根口径由调用方给定,不做祖先推断/漂移锚定) ——
@@ -2424,7 +2741,62 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2424
2741
  note: 'contract gap(missing backend)行数与重跑不符(探针5 扫描根/缓存口径环境敏感,WARNING 不阻断)' })
2425
2742
  }
2426
2743
 
2427
- if (mismatches.length === 0) return finish('ok', null, [], null)
2744
+ // —— facts 基线对比(2026-09-08-ir-verify-facts FR-05 / task-05):重跑指标 vs
2745
+ // verify-facts.json probes 快照(init 时点)。md 锚点对账查「正文没被改」,本维度查
2746
+ // 「md 被手改对齐新代码后 facts 底稿过期」(P3d 数据源保鲜)。分级沿用现实现口径:
2747
+ // probe1/6=ERROR、probe3/5=WARNING;probe6 继承 HEAD-advance 降级(R-06)。——
2748
+ const factsSnapshot = readVerifyFacts(changeDir)
2749
+ let factsConsistency = null
2750
+ if (factsSnapshot && factsSnapshot.probes) {
2751
+ const fm = []
2752
+ const adv = detectHeadAdvanceSinceFacts(cwd, factsSnapshot)
2753
+ const snap1 = (((factsSnapshot.probes.probe1 || {}).metrics) || {}).matches
2754
+ if (typeof snap1 === 'number' && snap1 !== curProbe1Hits) {
2755
+ fm.push({ probe: 'probe1', snapshot: snap1, rerun: curProbe1Hits, severity: 'error',
2756
+ note: 'facts 快照命中数与重跑不符——md 疑已手改对齐新代码而 facts 底稿过期(修复:verify-probes --init 刷新 facts + 按新结果同步 md 探针段)' })
2757
+ }
2758
+ const snap6 = (((factsSnapshot.probes.probe6 || {}).metrics) || {}).deletions
2759
+ if (!curProbe6.unavailable && typeof snap6 === 'number' && snap6 !== curProbe6Deletions && !adv) {
2760
+ // adv(HEAD 前移)时豁免:同一漂移已由 md 锚点维度的 probe6 WARNING 报出(R-06 继承,
2761
+ // 双维度重复报同一信号是噪音);非 adv 的不符 = facts 底稿被手改对齐——ERROR
2762
+ fm.push({ probe: 'probe6', snapshot: snap6, rerun: curProbe6Deletions, severity: 'error',
2763
+ note: 'facts 快照删除数与重跑不符——底稿疑似过期(修复:verify-probes --init 刷新 facts + 同步 md 探针段)' })
2764
+ }
2765
+ const snap3 = (((factsSnapshot.probes.probe3 || {}).metrics) || {}).hasTest
2766
+ if (typeof snap3 === 'number' && snap3 !== curProbe3HasTest) {
2767
+ fm.push({ probe: 'probe3', snapshot: snap3, rerun: curProbe3HasTest, severity: 'warning',
2768
+ note: 'facts 快照 hasTest 数与重跑不符(测试布局环境敏感,WARNING)' })
2769
+ }
2770
+ const snap5 = (((factsSnapshot.probes.probe5 || {}).metrics) || {}).backendEndpoints
2771
+ const cur5 = (current.probe5 || {}).backendCount
2772
+ if (typeof snap5 === 'number' && typeof cur5 === 'number' && snap5 !== cur5) {
2773
+ fm.push({ probe: 'probe5', snapshot: snap5, rerun: cur5, severity: 'warning',
2774
+ note: 'facts 快照 backend 端点数与重跑不符(扫描根口径环境敏感,WARNING)' })
2775
+ }
2776
+ factsConsistency = {
2777
+ checked: fm.length === 0 ? ['probe1', 'probe3', 'probe5', 'probe6'] : [...new Set(fm.map(x => x.probe))],
2778
+ verdict: fm.length === 0 ? 'match' : 'mismatch',
2779
+ detail: fm.length === 0 ? null : fm,
2780
+ }
2781
+ for (const x of fm) {
2782
+ mismatches.push({ probe: `facts:${x.probe}`, expected: x.rerun, actual: x.snapshot, severity: x.severity, note: x.note })
2783
+ }
2784
+ // 固化 factsConsistency(fail-soft;不动 probes 快照语义——probes 归 --init 所有)
2785
+ try {
2786
+ const factsPath2 = join(changeDir, 'verify-facts.json')
2787
+ const f2 = JSON.parse(readFileSync(factsPath2, 'utf8'))
2788
+ f2.factsConsistency = factsConsistency
2789
+ writeFileSync(factsPath2, JSON.stringify(f2, null, 2) + '\n')
2790
+ } catch { /* 固化失败不阻断对账 */ }
2791
+ }
2792
+
2793
+ if (mismatches.length === 0) {
2794
+ const okResult = finish('ok', null, [], null)
2795
+ okResult.factsConsistency = factsConsistency
2796
+ return okResult
2797
+ }
2428
2798
  const severity = mismatches.some(m => m.severity === 'error') ? 'error' : 'warning'
2429
- return finish('mismatch', severity, mismatches, null)
2799
+ const mismatchResult = finish('mismatch', severity, mismatches, null)
2800
+ mismatchResult.factsConsistency = factsConsistency
2801
+ return mismatchResult
2430
2802
  }