sillyspec 3.28.3 → 3.28.5

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 (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
@@ -23,9 +23,10 @@
23
23
  */
24
24
 
25
25
  import { execSync, spawnSync } from 'child_process'
26
+ import { createHash } from 'crypto'
26
27
  import { IR_STRICT_SINCE } from './constants.js'
27
28
  import { gitQuiet } from './git-helper.js'
28
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs'
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
29
30
  import { join } from 'path'
30
31
  import { verifyApiParity } from './contract-matrix.js'
31
32
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
@@ -37,6 +38,7 @@ import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
37
38
  // fs/path/git-helper/change-list/plan-postcheck/contract-matrix/foreign-declared/run-shared,
38
39
  // 不反向依赖本模块,此处直连不引入循环
39
40
  import { runVerifyProbes } from './verify-probes.js'
41
+ import { parseEvidenceSlots, classifyVerifiedFile } from './verify-facts-schema.js'
40
42
 
41
43
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
42
44
  const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
@@ -115,6 +117,36 @@ function extractLintCommand(yamlText) {
115
117
  return null
116
118
  }
117
119
 
120
+ /**
121
+ * lint advisory 观察期计数(刀③,2026-09-08):advisory 失败不阻断,但「观察期后升级硬门」
122
+ * 需要失败率数据支撑(更硬 vs 更吵的决策依据——硬门误伤纯文档/跨平台路径会制造新噪音)。
123
+ * 每次非 skipped 实测追加计数进 <specBase>/.runtime/verify-lint-tally.json(passed/failed
124
+ * 都记,分母同要);history 截尾 20 条。写失败静默降级——计数器不许反向阻断 verify。
125
+ */
126
+ function recordVerifyLintTally({ specBase, result }) {
127
+ try {
128
+ const runtimeDir = join(specBase, '.runtime')
129
+ mkdirSync(runtimeDir, { recursive: true })
130
+ const tallyPath = join(runtimeDir, 'verify-lint-tally.json')
131
+ let tally = { totalRuns: 0, failedRuns: 0, history: [] }
132
+ try {
133
+ tally = { ...tally, ...JSON.parse(readFileSync(tallyPath, 'utf8')) }
134
+ } catch { /* 首跑/文件损坏 → 重置重新累计 */ }
135
+ tally.totalRuns += 1
136
+ if (result.status === 'failed') tally.failedRuns += 1
137
+ tally.lastRunAt = new Date().toISOString()
138
+ tally.lastStatus = result.status
139
+ tally.history = [
140
+ ...(Array.isArray(tally.history) ? tally.history : []),
141
+ { at: tally.lastRunAt, status: result.status, reason: result.reason || null },
142
+ ].slice(-20)
143
+ writeFileSync(tallyPath, JSON.stringify(tally, null, 2))
144
+ return { failedRuns: tally.failedRuns, totalRuns: tally.totalRuns }
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
118
150
  /**
119
151
  * verify --done 实测跑 local.yaml commands.lint(2026-08-21 审查 CLI-1)。
120
152
  * 此前 lint 全靠 agent 自跑自报("我跑过 lint 了"纯口头),与 test 侧的对账不对称——
@@ -162,18 +194,36 @@ export function runVerifyLintCheck({ cwd, specBase }) {
162
194
  }
163
195
  const durationMs = Date.now() - startedAt
164
196
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
197
+ const status = exitCode === 0 ? 'passed' : 'failed'
198
+ const finalReason = exitCode === 0 ? null : reason
199
+ const tally = recordVerifyLintTally({ specBase, result: { status, reason: finalReason } })
165
200
 
166
201
  return {
167
- status: exitCode === 0 ? 'passed' : 'failed',
202
+ status,
168
203
  command,
169
204
  exitCode,
170
205
  durationMs,
171
206
  outputTail,
172
- reason: exitCode === 0 ? null : reason,
207
+ reason: finalReason,
208
+ tally, // { failedRuns, totalRuns } | null——观察期失败率,升级硬门的决策依据
173
209
  }
174
210
  }
175
211
 
176
- /** 打印 lint 实测结果(advisory:失败不阻断,只把口头汇报对上账) */
212
+ /**
213
+ * lint 硬门决策(2026-09-09 升硬,外部数据驱动):观察期 tally 14 次 5 败全真阳性零误伤,
214
+ * 且 pre-push 钩子本就硬拦——verify 侧硬门只是同一标准 fail-fast 前移。
215
+ * 逃生档:SILLYSPEC_VERIFY_LINT_GATE=advisory(审计留痕,对齐 quick 门 skip env 先例)。
216
+ * @returns {boolean} failed 且非逃生档 → true(调用方 rollback)
217
+ */
218
+ export function shouldBlockVerifyLint(lintCheck, env = process.env) {
219
+ if (!lintCheck || lintCheck.status !== 'failed') return false
220
+ return env.SILLYSPEC_VERIFY_LINT_GATE !== 'advisory'
221
+ }
222
+
223
+ /**
224
+ * 打印 lint 实测结果(2026-09-09 起按 shouldBlockVerifyLint 分支):硬门档「已阻断」+
225
+ * 逃生 env 指引(rollback 由 gates 接线侧执行);advisory 档保留旧观察期措辞。
226
+ */
177
227
  export function printVerifyLintCheck(result) {
178
228
  if (result.status === 'skipped') {
179
229
  console.warn(`\n⚠️ Verify lint 实测跳过:${result.reason}`)
@@ -183,8 +233,22 @@ export function printVerifyLintCheck(result) {
183
233
  console.log(`\n✅ Verify lint 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
184
234
  return
185
235
  }
186
- console.error(`\n⚠️ Verify lint 实测失败(advisory,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
187
- console.error(' agent 的 lint 自报告与实测不符时以实测为准;请修复后重跑,避免格式债推迟到 commit 被 pre-commit hook 拦截。')
236
+ // 文案随门禁档位分支(2026-09-09 复核修正 P2:硬门档下旧「不阻断/观察期」措辞与
237
+ // gates 的 rollback 行为打架,agent 会误判 CLI 抽风)——rollback 本体在 gates 接线侧。
238
+ if (shouldBlockVerifyLint(result)) {
239
+ console.error(`\n❌ Verify lint 实测失败——verify 完成已阻断(2026-09-09 起硬门):\`${result.command}\` — ${result.reason}`)
240
+ console.error(' agent 的 lint 自报告与实测不符时以实测为准;修复后重跑 --done(进度不丢)。')
241
+ if (result.tally) {
242
+ console.error(` 📊 累计 ${result.tally.failedRuns}/${result.tally.totalRuns} 次(.runtime/verify-lint-tally.json)。`)
243
+ }
244
+ console.error(' 确认要跳过实测:SILLYSPEC_VERIFY_LINT_GATE=advisory(审计留痕)。')
245
+ } else {
246
+ console.error(`\n⚠️ Verify lint 实测失败(advisory 档,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
247
+ console.error(' agent 的 lint 自报告与实测不符时以实测为准;请修复后重跑,避免格式债推迟到 commit 被 pre-commit hook 拦截。')
248
+ if (result.tally) {
249
+ console.error(` 📊 lint advisory 失败累计 ${result.tally.failedRuns}/${result.tally.totalRuns} 次(数据落 .runtime/verify-lint-tally.json)。`)
250
+ }
251
+ }
188
252
  if (result.outputTail) {
189
253
  const tail = result.outputTail.split('\n').slice(-15).join('\n')
190
254
  console.error(' 输出(末尾):')
@@ -995,8 +1059,33 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
995
1059
  !p.startsWith('.sillyspec/changes/') &&
996
1060
  !p.startsWith('.sillyspec/.runtime/') &&
997
1061
  !p.startsWith('.sillyspec/quicklog/'))
998
- if (wtFiles.length > 0) {
999
- mainFiles = [...new Set([...(mainFiles || []), ...wtFiles])]
1062
+ // 已提交口径补齐(2026-09-10 用户反馈②「先提交则 diff 空」时序两难的 diff 半边):
1063
+ // worktree 内已 commit 的改动对主仓 base..HEAD 与 status --porcelain 双双不可见——
1064
+ // 用 merge-base(主仓 HEAD, worktree HEAD)(= worktree 创建锚点)到 worktree HEAD 的
1065
+ // commit diff 补入,未提交 ∪ 已提交两形态全覆盖。wtGitDir===cwd(in-place 退化)时
1066
+ // 已提交改动已在 mainFiles,跳过防双并。fail-open:git 失败退回 status-only 现状。
1067
+ let committedFiles = []
1068
+ if (wtGitDir !== cwd) {
1069
+ try {
1070
+ const mainHead = gitQuiet(cwd, ['rev-parse', 'HEAD'], { timeout: 15000 })
1071
+ const wtHead = gitQuiet(wtGitDir, ['rev-parse', 'HEAD'], { timeout: 15000 })
1072
+ const mb = (mainHead && wtHead)
1073
+ ? gitQuiet(wtGitDir, ['merge-base', wtHead, mainHead], { timeout: 15000 })
1074
+ : null
1075
+ if (mb) {
1076
+ const diffOut = gitQuiet(wtGitDir, ['diff', '--name-only', mb, wtHead], { timeout: 30000 })
1077
+ committedFiles = String(diffOut || '').split('\n')
1078
+ .map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1079
+ .filter(p => p && p !== '.sillyspec' &&
1080
+ !p.startsWith('.sillyspec/changes/') &&
1081
+ !p.startsWith('.sillyspec/.runtime/') &&
1082
+ !p.startsWith('.sillyspec/quicklog/'))
1083
+ }
1084
+ } catch { /* 已提交补齐失败退回 status-only(fail-open,不拖垮未提交并入) */ }
1085
+ }
1086
+ const merged = [...new Set([...(mainFiles || []), ...wtFiles, ...committedFiles])]
1087
+ if (wtFiles.length > 0 || committedFiles.length > 0) {
1088
+ mainFiles = merged
1000
1089
  }
1001
1090
  }
1002
1091
  } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open) */ }
@@ -1620,6 +1709,9 @@ export function writeRunResult({ specBase, changeName, result, extra = {} }) {
1620
1709
  ...extra,
1621
1710
  }, null, 2) + '\n')
1622
1711
  result.resultPath = resultPath
1712
+ // verify-runs 回收不在此处(ql-20260908-005 已定分野):变更归属类证据走归档时精确
1713
+ // 回收(pruneArchivedChangeRuntime 按目录内 JSON change 字段归属);写入侧滚动是
1714
+ // 启发式,不进证据类目录。
1623
1715
  } catch (e) {
1624
1716
  console.warn(`⚠️ verify 实测结果落盘失败: ${e.message}`)
1625
1717
  }
@@ -1863,6 +1955,40 @@ export function printVerifyDeletionCheck(result) {
1863
1955
  console.warn(' 提示:确认删除是否预期。预期删除请在 design.md 清单用「删除」操作显式声明;误删请恢复。')
1864
1956
  }
1865
1957
 
1958
+ /**
1959
+ * verify-result.md 内容回退检测(2026-09-10 用户反馈③:报告在 verify 中途被平台同步覆盖
1960
+ * 回旧版一次,只能靠人眼发现后重写)。覆盖者在本仓之外(daemon/平台侧按服务端版本回写),
1961
+ * CLI 侧能做的是**检测可见**:每次 CLI 读到该文件时记高水位指纹(content hash + mtime),
1962
+ * 下次读到「内容变了且 mtime 回退」= 旧版复活的确定性指纹(服务端回写会顺带恢复服务端
1963
+ * mtime,时间倒流唯此一路)→ ⚠️ 告警。纯 advisory 不阻断;高水位只前进不后退(回退态
1964
+ * 不覆盖指纹,agent 重写后新指纹正常接管)。fail-open:任何异常视为无回退。
1965
+ * @param {string} specBase spec 根(hwm 文件落 .runtime/)
1966
+ * @param {string} changeName 变更名
1967
+ * @param {string} absPath verify-result.md 绝对路径
1968
+ * @returns {{ regressed: boolean, prevMtime: number|null }}
1969
+ */
1970
+ export function trackVerifyResultRegression(specBase, changeName, absPath) {
1971
+ try {
1972
+ if (!absPath || !existsSync(absPath)) return { regressed: false, prevMtime: null }
1973
+ const st = statSync(absPath)
1974
+ const hash = createHash('sha256').update(readFileSync(absPath)).digest('hex')
1975
+ const hwmPath = join(specBase, '.runtime', `verify-result-hwm-${changeName}.json`)
1976
+ let prev = null
1977
+ try { prev = JSON.parse(readFileSync(hwmPath, 'utf8')) } catch { prev = null }
1978
+ const regressed = !!(prev && typeof prev.hash === 'string' &&
1979
+ prev.hash !== hash && typeof prev.mtimeMs === 'number' &&
1980
+ st.mtimeMs < prev.mtimeMs - 1000)
1981
+ // 高水位只前进:当前 mtime 更新(正常编辑/重写)才覆盖指纹;回退态保留旧指纹供重写后再比对
1982
+ if (!prev || st.mtimeMs >= (prev.mtimeMs || 0)) {
1983
+ try {
1984
+ mkdirSync(join(specBase, '.runtime'), { recursive: true })
1985
+ writeFileSync(hwmPath, JSON.stringify({ hash, mtimeMs: st.mtimeMs, ts: Date.now() }) + '\n')
1986
+ } catch { /* hwm 落盘失败 → 下次当首次(fail-open) */ }
1987
+ }
1988
+ return { regressed, prevMtime: prev && prev.mtimeMs ? prev.mtimeMs : null }
1989
+ } catch { return { regressed: false, prevMtime: null } }
1990
+ }
1991
+
1866
1992
  /**
1867
1993
  * required-evidence 对账(advisory,不阻断 verify 完成)。
1868
1994
  *
@@ -1877,7 +2003,7 @@ export function printVerifyDeletionCheck(result) {
1877
2003
  * @param {{cwd:string, specBase:string, changeName?:string}} args
1878
2004
  * @returns {{status:'skipped'|'passed'|'warning', items:Array, unacknowledged:Array, summary:string, reason:string|null}}
1879
2005
  */
1880
- export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null }) {
2006
+ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null, verifyStartAt = null }) {
1881
2007
  if (!changeName) {
1882
2008
  return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 changeName(quick 等无关联变更场景),evidence 对账跳过' }
1883
2009
  }
@@ -1898,6 +2024,13 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
1898
2024
  const verifyResultPath = join(specBase, 'changes', changeName, 'verify-result.md')
1899
2025
  const report = existsSync(verifyResultPath) ? readFileSync(verifyResultPath, 'utf8') : ''
1900
2026
 
2027
+ // ── v2 槽优先(2026-09-08-ir-verify-facts FR-02):「## 证据账」槽段在场走分类核验;
2028
+ // 无槽(存量)降级 legacy 子串提及对账——行为等同旧版(D-001@v2,warning 不阻断)。──
2029
+ const slots = parseEvidenceSlots(report)
2030
+ if (slots.hasEvidenceSlot) {
2031
+ return runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt })
2032
+ }
2033
+
1901
2034
  const unacknowledged = []
1902
2035
  for (const item of items) {
1903
2036
  const task = item && item.task
@@ -1912,16 +2045,115 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
1912
2045
  }
1913
2046
 
1914
2047
  if (unacknowledged.length > 0) {
1915
- return { status: 'warning', items, unacknowledged,
2048
+ return { status: 'warning', items, unacknowledged, legacy: true,
1916
2049
  summary: `${items.length} 个 cannot_verify evidence 任务中 ${unacknowledged.length} 个未在 verify-result.md 体现`, reason: null }
1917
2050
  }
1918
- return { status: 'passed', items, unacknowledged: [],
2051
+ return { status: 'passed', items, unacknowledged: [], legacy: true,
1919
2052
  summary: `${items.length} 个 cannot_verify evidence 任务均在 verify-result.md 体现(满足度由 agent 自报告)`, reason: null }
1920
2053
  }
1921
2054
 
1922
- /** 打印 required-evidence 对账结果(advisory,不阻断 verify 完成) */
2055
+ /**
2056
+ * v2 分类核验(design Phase 2,FR-02):证据账槽行状态 + verifiedFiles 逐文件核验。
2057
+ * code 类(classifyVerifiedFile):存在 × mtime ≥ verifyStartAt × git diff 交集三核验;
2058
+ * artifact 类(.runtime/日志/文档):存在 × mtime(diff 交集豁免——resolveVerifyChangedFiles
2059
+ * 天然不覆盖该类路径,硬要求交集必假红)。verifyStartAt 缺省走 R-05 fallback(design.md
2060
+ * created_at 之后宽容 + warning)。status 语义扩 'blocked'(missing 无豁免 / satisfied 核验
2061
+ * 不过)——gates 接线(task-03)据此阻断 verify 完成。
2062
+ */
2063
+ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt }) {
2064
+ const warnings = []
2065
+ let startAt = verifyStartAt ? new Date(verifyStartAt).getTime() : null
2066
+ if (!startAt) {
2067
+ // R-05 fallback:DB 不可得/接线间隙(W2→W3)——design.md created_at 之后即宽容
2068
+ try {
2069
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
2070
+ if (existsSync(designPath)) {
2071
+ const fm = readFileSync(designPath, 'utf8').match(/^---\r?\n([\s\S]*?)\r?\n---/)
2072
+ const cm = fm && fm[1].match(/^created_at:\s*(.+)$/m)
2073
+ if (cm) {
2074
+ const t = new Date(cm[1].trim()).getTime()
2075
+ if (!Number.isNaN(t)) { startAt = t; warnings.push('verifyStartAt 未提供,走 design.md created_at 宽容基准(R-05 fallback)') }
2076
+ }
2077
+ }
2078
+ } catch { /* fallback 的 fallback:不设基准(只核存在性) */ }
2079
+ if (!startAt) warnings.push('verifyStartAt 与 design created_at 均不可得——mtime 核验降级为仅存在性')
2080
+ }
2081
+
2082
+ // git diff 集合(code 类交集核验用;fail-soft 拿不到 → diffHit 记 null 不判红)
2083
+ let changedSet = null
2084
+ try {
2085
+ const changed = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2086
+ changedSet = new Set((changed || []).map(p => String(p).replace(/\\/g, '/')))
2087
+ } catch { changedSet = null }
2088
+
2089
+ const detailed = []
2090
+ let blockedCount = 0
2091
+ for (const item of items) {
2092
+ const task = item && item.task
2093
+ if (!task) continue
2094
+ const slot = slots.requiredEvidence.find(e => e.task === task) || null
2095
+ if (!slot) {
2096
+ detailed.push({ task, verdict: item.verdict || 'cannot_verify', status: 'missing', exempt: false,
2097
+ verification: null, reason: `cannot_verify 任务 ${task} 未在证据账槽段登记(逐 task 一行:状态三选一 + verifiedFiles)` })
2098
+ blockedCount++
2099
+ continue
2100
+ }
2101
+ const perFile = []
2102
+ let allOk = true
2103
+ if (slot.status === 'satisfied' || slot.status === 'partial') {
2104
+ if (slot.verifiedFiles.length === 0) {
2105
+ allOk = false
2106
+ perFile.push({ path: null, pathClass: null, filesExist: false, mtimeOk: null, diffHit: null,
2107
+ reason: `${slot.status} 但 verifiedFiles 为空(精确路径必填)` })
2108
+ }
2109
+ for (const vf of slot.verifiedFiles) {
2110
+ const pathClass = classifyVerifiedFile(vf)
2111
+ const abs = join(cwd, vf)
2112
+ const filesExist = existsSync(abs)
2113
+ let mtimeOk = null
2114
+ if (filesExist && startAt) {
2115
+ try { mtimeOk = statSync(abs).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
2116
+ }
2117
+ let diffHit = null
2118
+ if (pathClass === 'code') {
2119
+ diffHit = changedSet === null ? null : changedSet.has(vf)
2120
+ } else {
2121
+ diffHit = true // artifact 类豁免 diff(design Phase 2:日志/文档天然不在 diff)
2122
+ }
2123
+ const ok = filesExist && mtimeOk !== false && diffHit !== false
2124
+ if (!ok) allOk = false
2125
+ perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit,
2126
+ reason: !filesExist ? '文件不存在'
2127
+ : (mtimeOk === false ? `mtime 早于 verifyStartAt(${new Date(startAt).toISOString()})`
2128
+ : (diffHit === false ? '不在本变更 git diff 内' : null)) })
2129
+ }
2130
+ }
2131
+ const failed = (slot.status === 'satisfied' && !allOk) || (slot.status === 'missing' && !slot.exempt)
2132
+ if (failed) blockedCount++
2133
+ detailed.push({
2134
+ task, verdict: item.verdict || 'cannot_verify', status: slot.status, exempt: slot.exempt,
2135
+ verification: perFile.length > 0 ? perFile : null,
2136
+ reason: failed
2137
+ ? (slot.status === 'missing' ? 'missing 且无(豁免:<理由>)后缀——cannot_verify 未闭环'
2138
+ : (slot.status === 'satisfied' ? 'satisfied 核验不过(见 verification 明细)' : 'partial 但 verifiedFiles 全部核验不过'))
2139
+ : (slot.exempt ? `missing 已豁免:${slot.exemptionReason}`
2140
+ : (slot.status === 'partial' ? 'partial(部分核验通过,理由见槽行括注)' : '核验通过')),
2141
+ })
2142
+ }
2143
+
2144
+ if (blockedCount > 0) {
2145
+ return { status: 'blocked', items, detailed, warnings,
2146
+ unacknowledged: detailed.filter(d => d.status === 'missing' && !d.exempt && !d.verification).map(d => ({ task: d.task, reason: d.reason })),
2147
+ summary: `${items.length} 个 cannot_verify 任务中 ${blockedCount} 个未闭环(missing 无豁免或核验不过)`, reason: null }
2148
+ }
2149
+ return { status: 'passed', items, detailed, unacknowledged: [], warnings,
2150
+ summary: `${items.length} 个 cannot_verify 任务证据账全部闭环(状态 + 分类核验通过)`, reason: null }
2151
+ }
2152
+
2153
+ /** 打印 required-evidence 对账结果(v2:blocked 由 gates 阻断;legacy warning 不阻断) */
1923
2154
  export function printVerifyRequiredEvidenceCheck(result) {
1924
2155
  if (result.status === 'skipped') return // 静默:无 evidence 文件 / 无 changeName / 解析失败
2156
+ if (result.status === 'blocked') return // 阻断明细由 gates 接线侧统一输出(本函数不重复打印)
1925
2157
  if (result.status === 'passed') {
1926
2158
  console.log(`\n✅ required-evidence 对账通过:${result.summary}`)
1927
2159
  return
@@ -2052,14 +2284,20 @@ function parsePorcelainFilePaths(raw) {
2052
2284
  * 统一收尾:filterDeliverableFiles 过滤基建产物(.sillyspec/changes|.runtime|quicklog、
2053
2285
  * meta.json——流程产物不算 scope creep)+ 去重排序(输出确定性)。
2054
2286
  *
2055
- * @returns {{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null }}
2287
+ * baseAnchor:与 files 同源的 diff 基点锚(scope-audit 行数采集复用,本模块自身不消费)——
2288
+ * 形态 A 读同一份 worktree meta.json(优先级 baselineCommit>actualBaseHash>baseHash,与
2289
+ * resolveMainChangedFiles 同口径,三锚全缺 null);形态 B 取 merge-base hash(分支不存在/
2290
+ * merge-base 不可得 null);降级返回恒 null。
2291
+ *
2292
+ * @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null, baseAnchor: string|null }}
2056
2293
  */
2057
- function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2294
+ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2058
2295
  const metaPath = join(specBase, '.runtime', 'worktrees', changeName, 'meta.json')
2059
2296
  const form = existsSync(metaPath) ? 'worktree' : 'post-apply'
2060
2297
  const sources = []
2061
2298
  const union = new Set()
2062
2299
  let foreignExcluded = 0
2300
+ let baseAnchor = null
2063
2301
 
2064
2302
  if (form === 'worktree') {
2065
2303
  // —— 形态 A:worktree 存活,整链复用(锚点优先级 baselineCommit>actualBaseHash>baseHash、
@@ -2067,22 +2305,36 @@ function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName })
2067
2305
  const files = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2068
2306
  if (files === null) {
2069
2307
  return { ok: false, form, files: [], sources, foreignExcluded,
2070
- degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)' }
2308
+ degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)', baseAnchor: null }
2071
2309
  }
2310
+ // baseAnchor:读同一份 worktree meta.json 锚点(baselineCommit>actualBaseHash>baseHash,
2311
+ // resolveMainChangedFiles 同口径;解析失败/三锚全缺 → null)
2312
+ try {
2313
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2314
+ baseAnchor = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash || null
2315
+ } catch {}
2072
2316
  sources.push('worktree:diff-base..HEAD', 'worktree:status-porcelain(uncommitted)')
2073
2317
  for (const f of files) union.add(normalizeReconcilePath(f))
2074
2318
  } else {
2075
2319
  // —— 形态 B:post-apply 自取三源 ——
2076
2320
  let diffOk = false
2077
2321
  let statusOk = false
2078
- // B1 merge-base 锚定 diff(分支/merge-base 不可得时静默省略该源,不算 git 失败)
2322
+ // B1 merge-base 锚定 diff(分支/merge-base 不可得时静默省略该源,不算 git 失败)。
2323
+ // 分支删除后回退审计 tag(quick-df1fed77):apply+cleanup 删分支时 worktree.js:1065 打
2324
+ // sillyspec-audit/<branch> tag 锚定 tip(gc 安全)——tag ref 可继续算 merge-base,baseAnchor
2325
+ // 与 B1 diff 源在归档/已提交形态下恢复完整(否则恒 null,行数只剩 HEAD 未提交窗口口径)。
2079
2326
  const branch = 'sillyspec/' + changeName
2080
2327
  const branchHash = gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', branch + '^{commit}'], { timeout: 30 * 1000 })
2081
- if (typeof branchHash === 'string' && branchHash.trim()) {
2328
+ const auditTag = 'sillyspec-audit/' + branch
2329
+ const diffRef = (typeof branchHash === 'string' && branchHash.trim())
2330
+ ? branch
2331
+ : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : null)
2332
+ if (diffRef) {
2082
2333
  // 形态 B 定义下 meta 已删,baseBranch 无从读 → 缺省 'main'(run/prompt.js:764 同缺省);
2083
2334
  // 主分支叫 master 等仓库 merge-base 失败 → 与分支不存在同处置(省略该源)
2084
- const mergeBase = gitQuiet(cwd, ['merge-base', 'main', branch], { timeout: 30 * 1000 })
2335
+ const mergeBase = gitQuiet(cwd, ['merge-base', 'main', diffRef], { timeout: 30 * 1000 })
2085
2336
  if (typeof mergeBase === 'string' && mergeBase.trim()) {
2337
+ baseAnchor = mergeBase.trim()
2086
2338
  const files = runGitDiffNameOnly(cwd, mergeBase.trim())
2087
2339
  if (files !== null) {
2088
2340
  diffOk = true
@@ -2117,12 +2369,12 @@ function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName })
2117
2369
  }
2118
2370
  if (!diffOk && !statusOk) {
2119
2371
  return { ok: false, form, files: [], sources, foreignExcluded,
2120
- degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)' }
2372
+ degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)', baseAnchor: null }
2121
2373
  }
2122
2374
  }
2123
2375
 
2124
2376
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2125
- return { ok: true, form, files, sources, foreignExcluded, degradedReason: null }
2377
+ return { ok: true, form, files, sources, foreignExcluded, degradedReason: null, baseAnchor }
2126
2378
  }
2127
2379
 
2128
2380
  /**
@@ -2255,24 +2507,35 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2255
2507
  }
2256
2508
 
2257
2509
  // —— 三类差集 ——
2258
- const actualSet = new Set(actual.files)
2510
+ // 对账键(2026-09-08 用户反馈⑥:计划文件名 vs 实测文件名的字面差脆):声明侧 task 卡是
2511
+ // agent 手写(大小写随手、尾部注记、./ 前缀残留),actual 侧是 git 机器产出——字面相等
2512
+ // 判定下这些形态差全部落②类假红(missing_declared,ERROR 态阻断 verify)。统一 canonical
2513
+ // key 再比对:normalizeReviewChangedFile 归一(./ 前缀/尾部注记/行内注释/反斜杠)+
2514
+ // win32/darwin 大小写折叠(默认文件系统大小写不敏感,两形指同一文件;Linux 保持敏感)。
2515
+ // 报告侧仍用原始字面串(用户按自己写的名字找到声明处)。
2516
+ const foldCase = process.platform === 'win32' || process.platform === 'darwin';
2517
+ const pathKey = (p) => {
2518
+ const n = normalizeReviewChangedFile(p);
2519
+ return foldCase ? n.toLowerCase() : n;
2520
+ };
2521
+ const actualKeySet = new Set(actual.files.map(pathKey));
2259
2522
  const declaredPaths = [...new Set(decl.declarations.map(d => d.path))].sort()
2260
- const declaredSet = new Set(declaredPaths)
2523
+ const declaredKeySet = new Set(declaredPaths.map(pathKey))
2261
2524
  // ①交集(计数进 evidence):唯一路径口径(多卡声明同文件只计一次)
2262
2525
  for (const path of declaredPaths) {
2263
- if (actualSet.has(path)) matched.push(path)
2526
+ if (actualKeySet.has(pathKey(path))) matched.push(path)
2264
2527
  }
2265
2528
  // ②声明没做(计划落空,ERROR):逐卡逐条报 task-NN + path;NEW: 声明的取剥前缀 path、带
2266
- // isNew 标(“新建文件没建”与“存量文件没动”的修复指引不同)
2529
+ // isNew 标("新建文件没建"与"存量文件没动"的修复指引不同)
2267
2530
  for (const d of decl.declarations) {
2268
- if (!actualSet.has(d.path)) {
2531
+ if (!actualKeySet.has(pathKey(d.path))) {
2269
2532
  missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
2270
2533
  }
2271
2534
  }
2272
2535
  // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)
2273
- const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredSet.has(p)))
2536
+ const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredKeySet.has(pathKey(p))))
2274
2537
  for (const path of actual.files) {
2275
- if (declaredSet.has(path)) continue
2538
+ if (declaredKeySet.has(pathKey(path))) continue
2276
2539
  const s = suspect.get(normalizeReviewChangedFile(path))
2277
2540
  undeclared.push(s ? { path, suspectTask: s } : { path })
2278
2541
  }
@@ -2558,7 +2821,62 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2558
2821
  note: 'contract gap(missing backend)行数与重跑不符(探针5 扫描根/缓存口径环境敏感,WARNING 不阻断)' })
2559
2822
  }
2560
2823
 
2561
- if (mismatches.length === 0) return finish('ok', null, [], null)
2824
+ // —— facts 基线对比(2026-09-08-ir-verify-facts FR-05 / task-05):重跑指标 vs
2825
+ // verify-facts.json probes 快照(init 时点)。md 锚点对账查「正文没被改」,本维度查
2826
+ // 「md 被手改对齐新代码后 facts 底稿过期」(P3d 数据源保鲜)。分级沿用现实现口径:
2827
+ // probe1/6=ERROR、probe3/5=WARNING;probe6 继承 HEAD-advance 降级(R-06)。——
2828
+ const factsSnapshot = readVerifyFacts(changeDir)
2829
+ let factsConsistency = null
2830
+ if (factsSnapshot && factsSnapshot.probes) {
2831
+ const fm = []
2832
+ const adv = detectHeadAdvanceSinceFacts(cwd, factsSnapshot)
2833
+ const snap1 = (((factsSnapshot.probes.probe1 || {}).metrics) || {}).matches
2834
+ if (typeof snap1 === 'number' && snap1 !== curProbe1Hits) {
2835
+ fm.push({ probe: 'probe1', snapshot: snap1, rerun: curProbe1Hits, severity: 'error',
2836
+ note: 'facts 快照命中数与重跑不符——md 疑已手改对齐新代码而 facts 底稿过期(修复:verify-probes --init 刷新 facts + 按新结果同步 md 探针段)' })
2837
+ }
2838
+ const snap6 = (((factsSnapshot.probes.probe6 || {}).metrics) || {}).deletions
2839
+ if (!curProbe6.unavailable && typeof snap6 === 'number' && snap6 !== curProbe6Deletions && !adv) {
2840
+ // adv(HEAD 前移)时豁免:同一漂移已由 md 锚点维度的 probe6 WARNING 报出(R-06 继承,
2841
+ // 双维度重复报同一信号是噪音);非 adv 的不符 = facts 底稿被手改对齐——ERROR
2842
+ fm.push({ probe: 'probe6', snapshot: snap6, rerun: curProbe6Deletions, severity: 'error',
2843
+ note: 'facts 快照删除数与重跑不符——底稿疑似过期(修复:verify-probes --init 刷新 facts + 同步 md 探针段)' })
2844
+ }
2845
+ const snap3 = (((factsSnapshot.probes.probe3 || {}).metrics) || {}).hasTest
2846
+ if (typeof snap3 === 'number' && snap3 !== curProbe3HasTest) {
2847
+ fm.push({ probe: 'probe3', snapshot: snap3, rerun: curProbe3HasTest, severity: 'warning',
2848
+ note: 'facts 快照 hasTest 数与重跑不符(测试布局环境敏感,WARNING)' })
2849
+ }
2850
+ const snap5 = (((factsSnapshot.probes.probe5 || {}).metrics) || {}).backendEndpoints
2851
+ const cur5 = (current.probe5 || {}).backendCount
2852
+ if (typeof snap5 === 'number' && typeof cur5 === 'number' && snap5 !== cur5) {
2853
+ fm.push({ probe: 'probe5', snapshot: snap5, rerun: cur5, severity: 'warning',
2854
+ note: 'facts 快照 backend 端点数与重跑不符(扫描根口径环境敏感,WARNING)' })
2855
+ }
2856
+ factsConsistency = {
2857
+ checked: fm.length === 0 ? ['probe1', 'probe3', 'probe5', 'probe6'] : [...new Set(fm.map(x => x.probe))],
2858
+ verdict: fm.length === 0 ? 'match' : 'mismatch',
2859
+ detail: fm.length === 0 ? null : fm,
2860
+ }
2861
+ for (const x of fm) {
2862
+ mismatches.push({ probe: `facts:${x.probe}`, expected: x.rerun, actual: x.snapshot, severity: x.severity, note: x.note })
2863
+ }
2864
+ // 固化 factsConsistency(fail-soft;不动 probes 快照语义——probes 归 --init 所有)
2865
+ try {
2866
+ const factsPath2 = join(changeDir, 'verify-facts.json')
2867
+ const f2 = JSON.parse(readFileSync(factsPath2, 'utf8'))
2868
+ f2.factsConsistency = factsConsistency
2869
+ writeFileSync(factsPath2, JSON.stringify(f2, null, 2) + '\n')
2870
+ } catch { /* 固化失败不阻断对账 */ }
2871
+ }
2872
+
2873
+ if (mismatches.length === 0) {
2874
+ const okResult = finish('ok', null, [], null)
2875
+ okResult.factsConsistency = factsConsistency
2876
+ return okResult
2877
+ }
2562
2878
  const severity = mismatches.some(m => m.severity === 'error') ? 'error' : 'warning'
2563
- return finish('mismatch', severity, mismatches, null)
2879
+ const mismatchResult = finish('mismatch', severity, mismatches, null)
2880
+ mismatchResult.factsConsistency = factsConsistency
2881
+ return mismatchResult
2564
2882
  }