sillyspec 3.28.3 → 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 (56) 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 +24 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/src/archive-delta.js +82 -0
  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 +3 -1
  20. package/src/docs-check.js +34 -0
  21. package/src/doctor-diagnostics.js +441 -5
  22. package/src/foreign-declared.js +53 -2
  23. package/src/index.js +181 -6
  24. package/src/module-impact.js +77 -12
  25. package/src/plan-adopt-waves.js +13 -5
  26. package/src/progress/change-registry.js +16 -0
  27. package/src/progress.js +62 -1
  28. package/src/quick-recommend.js +6 -1
  29. package/src/quicklog.js +10 -2
  30. package/src/run/command.js +30 -9
  31. package/src/run/complete-handlers.js +152 -6
  32. package/src/run/complete.js +45 -0
  33. package/src/run/gates.js +94 -6
  34. package/src/run/prompt.js +96 -5
  35. package/src/run/shared.js +74 -6
  36. package/src/run/stage.js +35 -0
  37. package/src/runtime-hygiene.js +84 -0
  38. package/src/spec-sync.js +63 -8
  39. package/src/stage-contract.js +83 -3
  40. package/src/stage-review.js +3 -0
  41. package/src/stages/archive.js +4 -4
  42. package/src/stages/brainstorm.js +2 -8
  43. package/src/stages/doctor.js +41 -515
  44. package/src/stages/plan-postcheck.js +191 -7
  45. package/src/stages/plan.js +5 -26
  46. package/src/stages/quick.js +11 -11
  47. package/src/stages/verify.js +10 -6
  48. package/src/sync-noise.js +158 -0
  49. package/src/sync.js +42 -17
  50. package/src/task-review.js +25 -1
  51. package/src/verify-facts-schema.js +141 -0
  52. package/src/verify-postcheck.js +257 -19
  53. package/src/verify-probes.js +142 -13
  54. package/src/workflow.js +6 -0
  55. package/src/worktree-apply.js +51 -13
  56. package/src/worktree.js +5 -1
@@ -25,7 +25,7 @@
25
25
  import { execSync, spawnSync } from 'child_process'
26
26
  import { IR_STRICT_SINCE } from './constants.js'
27
27
  import { gitQuiet } from './git-helper.js'
28
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs'
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
29
29
  import { join } from 'path'
30
30
  import { verifyApiParity } from './contract-matrix.js'
31
31
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
@@ -37,6 +37,7 @@ import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
37
37
  // fs/path/git-helper/change-list/plan-postcheck/contract-matrix/foreign-declared/run-shared,
38
38
  // 不反向依赖本模块,此处直连不引入循环
39
39
  import { runVerifyProbes } from './verify-probes.js'
40
+ import { parseEvidenceSlots, classifyVerifiedFile } from './verify-facts-schema.js'
40
41
 
41
42
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
42
43
  const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
@@ -115,6 +116,36 @@ function extractLintCommand(yamlText) {
115
116
  return null
116
117
  }
117
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
+
118
149
  /**
119
150
  * verify --done 实测跑 local.yaml commands.lint(2026-08-21 审查 CLI-1)。
120
151
  * 此前 lint 全靠 agent 自跑自报("我跑过 lint 了"纯口头),与 test 侧的对账不对称——
@@ -162,18 +193,36 @@ export function runVerifyLintCheck({ cwd, specBase }) {
162
193
  }
163
194
  const durationMs = Date.now() - startedAt
164
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 } })
165
199
 
166
200
  return {
167
- status: exitCode === 0 ? 'passed' : 'failed',
201
+ status,
168
202
  command,
169
203
  exitCode,
170
204
  durationMs,
171
205
  outputTail,
172
- reason: exitCode === 0 ? null : reason,
206
+ reason: finalReason,
207
+ tally, // { failedRuns, totalRuns } | null——观察期失败率,升级硬门的决策依据
173
208
  }
174
209
  }
175
210
 
176
- /** 打印 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
+ */
177
226
  export function printVerifyLintCheck(result) {
178
227
  if (result.status === 'skipped') {
179
228
  console.warn(`\n⚠️ Verify lint 实测跳过:${result.reason}`)
@@ -183,8 +232,22 @@ export function printVerifyLintCheck(result) {
183
232
  console.log(`\n✅ Verify lint 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
184
233
  return
185
234
  }
186
- console.error(`\n⚠️ Verify lint 实测失败(advisory,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
187
- 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
+ }
188
251
  if (result.outputTail) {
189
252
  const tail = result.outputTail.split('\n').slice(-15).join('\n')
190
253
  console.error(' 输出(末尾):')
@@ -1620,6 +1683,9 @@ export function writeRunResult({ specBase, changeName, result, extra = {} }) {
1620
1683
  ...extra,
1621
1684
  }, null, 2) + '\n')
1622
1685
  result.resultPath = resultPath
1686
+ // verify-runs 回收不在此处(ql-20260908-005 已定分野):变更归属类证据走归档时精确
1687
+ // 回收(pruneArchivedChangeRuntime 按目录内 JSON change 字段归属);写入侧滚动是
1688
+ // 启发式,不进证据类目录。
1623
1689
  } catch (e) {
1624
1690
  console.warn(`⚠️ verify 实测结果落盘失败: ${e.message}`)
1625
1691
  }
@@ -1877,7 +1943,7 @@ export function printVerifyDeletionCheck(result) {
1877
1943
  * @param {{cwd:string, specBase:string, changeName?:string}} args
1878
1944
  * @returns {{status:'skipped'|'passed'|'warning', items:Array, unacknowledged:Array, summary:string, reason:string|null}}
1879
1945
  */
1880
- export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null }) {
1946
+ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null, verifyStartAt = null }) {
1881
1947
  if (!changeName) {
1882
1948
  return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 changeName(quick 等无关联变更场景),evidence 对账跳过' }
1883
1949
  }
@@ -1898,6 +1964,13 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
1898
1964
  const verifyResultPath = join(specBase, 'changes', changeName, 'verify-result.md')
1899
1965
  const report = existsSync(verifyResultPath) ? readFileSync(verifyResultPath, 'utf8') : ''
1900
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
+
1901
1974
  const unacknowledged = []
1902
1975
  for (const item of items) {
1903
1976
  const task = item && item.task
@@ -1912,16 +1985,115 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
1912
1985
  }
1913
1986
 
1914
1987
  if (unacknowledged.length > 0) {
1915
- return { status: 'warning', items, unacknowledged,
1988
+ return { status: 'warning', items, unacknowledged, legacy: true,
1916
1989
  summary: `${items.length} 个 cannot_verify evidence 任务中 ${unacknowledged.length} 个未在 verify-result.md 体现`, reason: null }
1917
1990
  }
1918
- return { status: 'passed', items, unacknowledged: [],
1991
+ return { status: 'passed', items, unacknowledged: [], legacy: true,
1919
1992
  summary: `${items.length} 个 cannot_verify evidence 任务均在 verify-result.md 体现(满足度由 agent 自报告)`, reason: null }
1920
1993
  }
1921
1994
 
1922
- /** 打印 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 不阻断) */
1923
2094
  export function printVerifyRequiredEvidenceCheck(result) {
1924
2095
  if (result.status === 'skipped') return // 静默:无 evidence 文件 / 无 changeName / 解析失败
2096
+ if (result.status === 'blocked') return // 阻断明细由 gates 接线侧统一输出(本函数不重复打印)
1925
2097
  if (result.status === 'passed') {
1926
2098
  console.log(`\n✅ required-evidence 对账通过:${result.summary}`)
1927
2099
  return
@@ -2255,24 +2427,35 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2255
2427
  }
2256
2428
 
2257
2429
  // —— 三类差集 ——
2258
- 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));
2259
2442
  const declaredPaths = [...new Set(decl.declarations.map(d => d.path))].sort()
2260
- const declaredSet = new Set(declaredPaths)
2443
+ const declaredKeySet = new Set(declaredPaths.map(pathKey))
2261
2444
  // ①交集(计数进 evidence):唯一路径口径(多卡声明同文件只计一次)
2262
2445
  for (const path of declaredPaths) {
2263
- if (actualSet.has(path)) matched.push(path)
2446
+ if (actualKeySet.has(pathKey(path))) matched.push(path)
2264
2447
  }
2265
2448
  // ②声明没做(计划落空,ERROR):逐卡逐条报 task-NN + path;NEW: 声明的取剥前缀 path、带
2266
- // isNew 标(“新建文件没建”与“存量文件没动”的修复指引不同)
2449
+ // isNew 标("新建文件没建"与"存量文件没动"的修复指引不同)
2267
2450
  for (const d of decl.declarations) {
2268
- if (!actualSet.has(d.path)) {
2451
+ if (!actualKeySet.has(pathKey(d.path))) {
2269
2452
  missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
2270
2453
  }
2271
2454
  }
2272
2455
  // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)
2273
- 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))))
2274
2457
  for (const path of actual.files) {
2275
- if (declaredSet.has(path)) continue
2458
+ if (declaredKeySet.has(pathKey(path))) continue
2276
2459
  const s = suspect.get(normalizeReviewChangedFile(path))
2277
2460
  undeclared.push(s ? { path, suspectTask: s } : { path })
2278
2461
  }
@@ -2558,7 +2741,62 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2558
2741
  note: 'contract gap(missing backend)行数与重跑不符(探针5 扫描根/缓存口径环境敏感,WARNING 不阻断)' })
2559
2742
  }
2560
2743
 
2561
- 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
+ }
2562
2798
  const severity = mismatches.some(m => m.severity === 'error') ? 'error' : 'warning'
2563
- return finish('mismatch', severity, mismatches, null)
2799
+ const mismatchResult = finish('mismatch', severity, mismatches, null)
2800
+ mismatchResult.factsConsistency = factsConsistency
2801
+ return mismatchResult
2564
2802
  }
@@ -11,8 +11,8 @@
11
11
  * 探针2(关键词提取半语义)/探针3.4 集成盲区/3.5 断言抽查/探针4(决策追踪语义)留 agent。
12
12
  *
13
13
  * verify-result.md 骨架:七章节固定结构 + 探针结果机械预填 + 其余章节 <!--TODO--> 占位。
14
- * 结论章节留「待填」——extractVerifyConclusion 找不到 PASS/FAIL 关键词即判不过,骨架不能
15
- * 直接过门(与 symbol-impact 骨架同款防偷懒语义)。
14
+ * 结论走「结论枚举:」固定槽行(刀③)——占位符不含枚举词,槽未填 extractVerifyConclusionSlot
15
+ * 返回 '' 即判不过,骨架不能直接过门(与 symbol-impact 骨架同款防偷懒语义)。
16
16
  * P3b 增量:①章节标题行末尾 claims 层标注(可复跑探针/确定性检查/人工判断,纯后缀不新增行);
17
17
  * ②--init 同步落盘 verify-facts.json 机器底稿(探针命令行 + 首跑关键指标 + 时间戳,CLI 全权写,
18
18
  * 供事后独立复跑审计;重复 --init 覆盖为最近一次 init 快照)。
@@ -20,6 +20,9 @@
20
20
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
21
21
  import { join, dirname, basename } from 'path'
22
22
  import { gitQuiet } from './git-helper.js'
23
+ import {
24
+ FACTS_SCHEMA_VERSION, EVIDENCE_SLOT_HEADING, RECEIPT_SLOT_HEADING, parseEvidenceSlots,
25
+ } from './verify-facts-schema.js'
23
26
  import { parseFileChangeListDetailed } from './change-list.js'
24
27
  import { parseAllowedPaths } from './stages/plan-postcheck.js'
25
28
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
@@ -310,8 +313,11 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
310
313
  const p3 = (result && result.probe3) || {}
311
314
  const p5 = (result && result.probe5) || {}
312
315
  const p6 = (result && result.probe6) || {}
316
+ // v2(2026-09-08-ir-verify-facts):probes 机器段原样;conclusion/tests/requiredEvidence/
317
+ // runtimeEvidence/factsConsistency 五段是 slot-backfill/实测回填段(D-001@v2),--init 快照
318
+ // 不落键(writeVerifyFacts 分段合并时保留既有固化段),由 backfillFactsFromMdAndTests 填。
313
319
  return {
314
- schemaVersion: 1,
320
+ schemaVersion: 2,
315
321
  change: changeName,
316
322
  generatedAt: now || new Date().toISOString(),
317
323
  probes: {
@@ -350,37 +356,160 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
350
356
  }
351
357
 
352
358
  /**
353
- * verify-probes --init 落盘 verify-facts.json(CLI 全权写)。重复 --init 无条件覆盖——语义是
354
- * 「最近一次机械预填的审计底稿快照」(骨架已存在跳过时 facts 照样刷新),agent 勿手改:
355
- * 事后复跑审计的对照快照,防篡改一致性检查的对比基准是 verify-result.md 正文(非本文件)。
359
+ * verify-probes --init 落盘 verify-facts.json(CLI 全权写)。v2(2026-09-08-ir-verify-facts)改
360
+ * 分段合并:re-init 刷新机器段(probes/generatedAt),保留既有 slot-backfill/实测固化段
361
+ * (conclusion/tests/requiredEvidence/runtimeEvidence)——旧「无条件覆盖」会抹掉 --done 回填数据
362
+ * (设计审查 P1-5)。agent 勿手改:复跑审计的对照快照,防篡改基准是 verify-result.md 正文。
356
363
  * @param {string} changeDir 变更目录(spec 根下 changes/<name>)
357
364
  * @param {object} result runVerifyProbes 返回值
358
365
  * @param {string} changeName 变更名(统一命令行呈现)
359
366
  * @returns {{ facts: object, path: string }}
360
367
  */
361
368
  export function writeVerifyFacts(changeDir, result, changeName) {
362
- const facts = buildVerifyFacts(result, { changeName })
369
+ const fresh = buildVerifyFacts(result, { changeName })
363
370
  const factsPath = join(changeDir, 'verify-facts.json')
371
+ let facts = fresh
372
+ try {
373
+ const prev = JSON.parse(readFileSync(factsPath, 'utf8'))
374
+ if (prev && prev.schemaVersion === FACTS_SCHEMA_VERSION) {
375
+ facts = {
376
+ ...fresh,
377
+ conclusion: prev.conclusion,
378
+ tests: prev.tests,
379
+ requiredEvidence: prev.requiredEvidence,
380
+ runtimeEvidence: prev.runtimeEvidence,
381
+ factsConsistency: prev.factsConsistency, // 保留至上次对比结论(下次 --done 重算覆盖)
382
+ }
383
+ }
384
+ } catch { /* 首次落盘/v1/损坏 → 全新快照(v1 无固化段,直接升 v2) */ }
364
385
  writeFileSync(factsPath, JSON.stringify(facts, null, 2) + '\n')
365
- console.log(`📝 已刷新 verify-facts.json 机器底稿: ${factsPath}(CLI 全权写,勿手改)`)
386
+ console.log(`📝 已刷新 verify-facts.json 机器底稿: ${factsPath}(v2 分段合并,固化段保留;CLI 全权写,勿手改)`)
366
387
  return { facts, path: factsPath }
367
388
  }
368
389
 
390
+ /**
391
+ * --done 回填(D-001@v2 slot-backfill):结论枚举槽 + 证据/回执槽解析固化进 facts;
392
+ * testCheckResult 提供时回填 tests 段(实测在 runValidators 之后 → gates 二次回填,FR-03 次序)。
393
+ * 机器段(probes/factsConsistency)不经本函数(writeVerifyFacts / checkProbeConsistency 所有)。
394
+ * fail-soft:facts 缺失时原地升 v2(probes 保留或空);落盘失败 warn 不抛。
395
+ * @param {string} factsPath verify-facts.json 路径
396
+ * @param {{ verifyMd: string, testCheckResult?: object|null }} opts
397
+ * @returns {{ facts: object, conclusion: string|null, evidenceCount: number, receiptCount: number, testsBackfilled: boolean }}
398
+ */
399
+ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResult = null, conclusion = null }) {
400
+ let facts = null
401
+ try { facts = JSON.parse(readFileSync(factsPath, 'utf8')) } catch { /* 缺失见下 */ }
402
+ // 只固化既有底稿,不无中生有(2026-09-08-ir-verify-facts 接线实证):无 facts 的存量变更
403
+ // (未跑过 --init)若被回填凭空建出 facts.json,checkProbeConsistency 的「有 facts 无探针
404
+ // 子节 → error」判别会把存量兼容 skip 误升 error(run-complete-step-verify e2e 抓出)。
405
+ // 底稿创建唯一入口是 verify-probes --init。
406
+ if (!facts) {
407
+ return { facts: null, skipped: true, conclusion: null, evidenceCount: 0, receiptCount: 0, testsBackfilled: false, reason: '无 verify-facts.json(未跑 --init),回填跳过——底稿创建唯一入口是 --init' }
408
+ }
409
+ if (facts.schemaVersion !== FACTS_SCHEMA_VERSION) {
410
+ facts = { ...facts, schemaVersion: FACTS_SCHEMA_VERSION, probes: (facts && facts.probes) || {} }
411
+ }
412
+ const slots = parseEvidenceSlots(verifyMd || '')
413
+ // conclusion 由调用方传 stage-contract.extractVerifyConclusionSlot(verifyMd) 的结果
414
+ // (避免 verify-probes → stage-contract 静态 import 的潜在环,分层单向)
415
+ const conclusionSlot = conclusion || null
416
+ if (conclusionSlot) facts.conclusion = conclusionSlot
417
+ if (slots.hasEvidenceSlot && slots.requiredEvidence.length > 0) {
418
+ facts.requiredEvidence = slots.requiredEvidence.map(e => ({
419
+ task: e.task, status: e.status, verifiedFiles: e.verifiedFiles,
420
+ ...(e.exempt ? { exempt: true, exemptionReason: e.exemptionReason } : {}),
421
+ }))
422
+ }
423
+ if (slots.hasReceiptSlot && slots.runtimeEvidence.length > 0) {
424
+ facts.runtimeEvidence = slots.runtimeEvidence
425
+ }
426
+ let testsBackfilled = false
427
+ if (testCheckResult && testCheckResult.status && testCheckResult.status !== 'skipped') {
428
+ facts.tests = {
429
+ command: testCheckResult.command || null,
430
+ exitCode: typeof testCheckResult.exitCode === 'number' ? testCheckResult.exitCode : null,
431
+ strategy: testCheckResult.mode || testCheckResult.strategy || null,
432
+ failedRemaining: testCheckResult.failureNames || testCheckResult.failedTests || [],
433
+ passedAt: new Date().toISOString(),
434
+ }
435
+ testsBackfilled = true
436
+ }
437
+ try {
438
+ writeFileSync(factsPath, JSON.stringify(facts, null, 2) + '\n')
439
+ } catch (e) {
440
+ console.warn(`⚠️ facts 回填落盘失败(fail-soft,不阻断): ${e && e.message ? e.message : e}`)
441
+ }
442
+ return { facts, conclusion: conclusionSlot, evidenceCount: slots.requiredEvidence.length, receiptCount: slots.runtimeEvidence.length, testsBackfilled }
443
+ }
444
+
445
+ /**
446
+ * md 槽段缺失补齐(--init 段落级补齐,2026-09-08-ir-verify-facts R-02):已存在的
447
+ * verify-result.md 缺「证据账/集成验证回执」槽段时仅追加骨架(不触碰既有正文),幂等二跑零改动。
448
+ * @param {string} mdPath verify-result.md 路径
449
+ * @param {Array<{task:string,evidence:string[]}>} [requiredEvidenceItems] verify-required-evidence.json items(预填 task 行)
450
+ * @returns {{ added: string[] }} 追加的槽段标题列表
451
+ */
452
+ export function backfillMissingEvidenceSlots(mdPath, requiredEvidenceItems = []) {
453
+ let text = ''
454
+ try { text = readFileSync(mdPath, 'utf8') } catch { return { added: [] } }
455
+ const normalized = text.replace(/\r\n/g, '\n')
456
+ const added = []
457
+ const blocks = []
458
+ if (!normalized.includes(EVIDENCE_SLOT_HEADING)) {
459
+ const lines = [EVIDENCE_SLOT_HEADING, '[层:人工判断——CLI 核验]', '', '<!-- 无 cannot_verify 任务时本节写「无」 -->']
460
+ if (requiredEvidenceItems.length > 0) {
461
+ for (const it of requiredEvidenceItems) {
462
+ lines.push(`- ${it.task}: <待填:三选一> | verifiedFiles: <精确路径,逗号分隔>(satisfied 必填;豁免时填 missing 并加(豁免:<一句话理由>)后缀)`)
463
+ }
464
+ } else {
465
+ lines.push('无(本次变更无 cannot_verify 任务)')
466
+ }
467
+ lines.push('')
468
+ blocks.push(lines.join('\n'))
469
+ added.push(EVIDENCE_SLOT_HEADING)
470
+ }
471
+ if (!normalized.includes(RECEIPT_SLOT_HEADING)) {
472
+ blocks.push([
473
+ RECEIPT_SLOT_HEADING, '[层:自述声明——CLI 一致性校验]', '',
474
+ '<!-- integration-critical/deployment-critical 变更必填;其余写「无」 -->',
475
+ '- claim: <待填:一句话> | command: <待填:命令> | exit: <待填:0 或非 0> | log: <待填:日志路径>',
476
+ '',
477
+ ].join('\n'))
478
+ added.push(RECEIPT_SLOT_HEADING)
479
+ }
480
+ if (added.length > 0) {
481
+ writeFileSync(mdPath, normalized.replace(/\n?$/, '\n') + blocks.join('\n') + '\n')
482
+ }
483
+ return { added }
484
+ }
485
+
369
486
  /**
370
487
  * 生成 verify-result.md 骨架(七章节;探针结果机械预填,语义章节 <!--TODO--> 占位)。
371
- * 结论章节留「待填」——extractVerifyConclusion 无 PASS/FAIL 关键词即判不过,骨架不能直接过门。
488
+ * 结论走「结论枚举:」固定槽行(刀③,2026-09-08)——占位符不含枚举词(<待填:三选一>),
489
+ * 槽未填 → extractVerifyConclusionSlot 返回 '' → gate 判不过(fail-closed;旧占位符
490
+ * `<待填:PASS 或 FAIL>` 含 PASS 字样会被窗口正则误读成已填 PASS,已修)。
372
491
  * 章节标题行末尾带 claims 层标注(P3b):探针结果=可复跑探针 / 测试结果=确定性检查 / 其余=
373
- * 人工判断——纯渲染层后缀,不新增行,verify gate 结论提取按关键词窗口制不受影响。
492
+ * 人工判断——纯渲染层后缀,不新增行;结论解析槽优先于标题关键词窗口(存量兼容)。
374
493
  * @returns {string|null} 骨架全文;无 design.md/tasks.md(非完整流程变更)→ null
375
494
  */
376
495
  export function generateVerifyResultSkeleton(result) {
377
496
  const L = [
378
497
  '# 验证报告(骨架由 `sillyspec verify-probes --change <变更名> --init` 生成)',
379
498
  '',
380
- '> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论必须写明 PASS / FAIL**——',
381
- '> 留「待填」会被 gate 判不过(fail-closed)。',
499
+ '> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论只认「结论枚举:」槽行**——',
500
+ '> 槽行留「<待填:三选一>」会被 gate 判不过(fail-closed),正文其他位置的 PASS/FAIL 字样不参与判定。',
501
+ '',
502
+ '## 结论 [层:人工判断]',
503
+ '',
504
+ '结论枚举:`<待填:三选一>`(把尖括号占位整体替换为 PASS / PASS WITH NOTES / FAIL 之一;一句话理由写在枚举后同行或下一行)',
505
+ '',
506
+ '## 证据账(cannot_verify 任务) [层:人工判断——CLI 核验]',
507
+ '<!-- 无 cannot_verify 任务时本节写「无」;有则逐 task 一行 -->',
508
+ '- task-NN: <待填:三选一> | verifiedFiles: <精确路径,逗号分隔>(satisfied 必填;豁免时填 missing 并加(豁免:<一句话理由>)后缀)',
382
509
  '',
383
- '## 结论:<待填:PASS 或 FAIL(+一句话理由)> [层:人工判断]',
510
+ '## 集成验证回执 [层:自述声明——CLI 一致性校验]',
511
+ '<!-- integration-critical/deployment-critical 变更必填;其余写「无」 -->',
512
+ '- claim: <待填:一句话> | command: <待填:命令> | exit: <待填:0 或非 0> | log: <待填:日志路径>',
384
513
  '',
385
514
  '## 任务完成度 [层:人工判断]',
386
515
  '<!--TODO: 逐 task 对照 tasks.md 勾选与验收标准,完成/未完成/存疑三态-->',
package/src/workflow.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs'
13
13
  import { join, resolve, basename } from 'path'
14
14
  import jsYaml from 'js-yaml'
15
+ import { pruneTimestampedEntries } from './runtime-hygiene.js'
15
16
  import { WORKFLOW_STATUS } from './constants.js'
16
17
  import { contentNonEmpty, lineCount, missingSections, placeholderLineMatches } from './check-primitives.js'
17
18
  import { collectInvalidDocRefs } from './docs-check.js'
@@ -626,6 +627,11 @@ export function saveWorkflowRun(result, options = {}) {
626
627
 
627
628
  try {
628
629
  writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8')
630
+ // 写入侧滚动裁剪(runtime-hygiene.js,2026-09-08 系统性回收):workflow-runs/ 只写不收,
631
+ // 实证本仓 21 份累积。文件名以零填充时间戳开头,name 字典序 == 时间序。fail-open 静默。
632
+ try {
633
+ pruneTimestampedEntries({ dir: runDir, keep: 30 })
634
+ } catch { /* 卫生动作 fail-open */ }
629
635
  return filepath
630
636
  } catch (e) {
631
637
  console.warn('⚠️ 保存 workflow run 失败:', e.message)