sillyspec 3.28.12 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -223
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
  4. package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
  5. package/package.json +1 -1
  6. package/src/blast-surface.js +168 -0
  7. package/src/ceremony-tier.js +359 -0
  8. package/src/change-risk-profile.js +75 -202
  9. package/src/config-schema.js +21 -0
  10. package/src/decision-distill.js +32 -25
  11. package/src/decisions-io.js +32 -0
  12. package/src/design-facts.js +1 -0
  13. package/src/doctor-diagnostics.js +148 -1
  14. package/src/fr-index.js +359 -0
  15. package/src/index.js +92 -5
  16. package/src/knowledge-stats.js +124 -2
  17. package/src/modules.js +28 -0
  18. package/src/prefill.js +491 -0
  19. package/src/probe7-anchor-check.js +8 -6
  20. package/src/quick-gate-profile.js +8 -4
  21. package/src/quick-session-owner.js +63 -0
  22. package/src/review-dispatch.js +473 -1
  23. package/src/review-material-pack.js +265 -0
  24. package/src/review-tier.js +185 -41
  25. package/src/run/archive-distill.js +35 -0
  26. package/src/run/command.js +158 -87
  27. package/src/run/complete-handlers.js +82 -1
  28. package/src/run/complete.js +37 -1
  29. package/src/run/gates.js +301 -19
  30. package/src/run/prompt.js +454 -21
  31. package/src/run/shared.js +36 -0
  32. package/src/run/verify-quality-scan.js +14 -6
  33. package/src/scope-audit.js +8 -1
  34. package/src/span-risk-surface.js +127 -0
  35. package/src/stage-contract-spec.js +2 -2
  36. package/src/stage-contract.js +172 -98
  37. package/src/stage-review.js +26 -2
  38. package/src/stages/brainstorm.js +34 -16
  39. package/src/stages/execute.js +10 -2
  40. package/src/stages/plan.js +29 -12
  41. package/src/stages/verify.js +7 -7
  42. package/src/taskcard.js +3 -0
  43. package/src/verify-postcheck.js +375 -20
  44. package/src/verify-probes.js +964 -30
  45. package/src/worktree.js +1 -1
  46. package/templates/prompts/taskcard-rules.md +1 -0
  47. package/templates/prompts/verify-probes.md +1 -1
@@ -26,12 +26,22 @@ import { execSync, spawnSync } from 'child_process'
26
26
  import { createHash } from 'crypto'
27
27
  import { IR_STRICT_SINCE } from './constants.js'
28
28
  import { gitQuiet } from './git-helper.js'
29
+ import { resolveRuntimeRoot } from './run/shared.js'
29
30
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
30
31
  import { join } from 'path'
31
32
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
32
33
  import { reconcileCrossRepoDeclarations } from './cross-repo-reconcile.js'
33
34
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
34
35
  import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
36
+ // ceremony 双跑收口(2026-09-18-ceremony-risk-pricing task-04 / FR-03 / D-003):引擎与账本均为
37
+ // 纯/叶子依赖(ceremony-tier → change-risk-profile;friction-ledger → fs-atomic+quicklog),无环。
38
+ import { resolveChangeRisk } from './change-risk-profile.js'
39
+ import { loadBlastDeclarationsAllProjects } from './blast-surface.js'
40
+ import { loadSpanRiskPatternsAllProjects } from './span-risk-surface.js'
41
+ import { reconcileDualRun, computeCeremonyTier, CEREMONY_TIERS } from './ceremony-tier.js'
42
+ import { mergeFrictionEntry } from './friction-ledger.js'
43
+ import { discoverModuleIndex } from './decision-distill.js'
44
+ import { writeAtomicSync } from './fs-atomic.js'
35
45
  // target_files 声明侧解析(Wave 1 已落地):依赖链已核实无环——plan-postcheck 不反向依赖本模块,
36
46
  // 且本模块已经 worktree-apply.js:21 间接依赖 plan-postcheck,此处改直连不引入新环(task-04)
37
47
  import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
@@ -1120,8 +1130,22 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1120
1130
  // 不到 → 形态 A 并入失效 → ②类假红。_readWorktreeMeta 双候选(specBase 优先 +
1121
1131
  // cwd/.sillyspec 兜底,contract-matrix 既有样板)两形全覆盖。
1122
1132
  const found = changeName ? _readWorktreeMeta(opts.specBase || null, cwd, changeName) : null
1123
- if (found) {
1124
- const wtGitDir = found.gitDir
1133
+ // native-worktree 收养态(ql-20260918-010,回放实验实证):meta 缺席但 cwd 自身就是
1134
+ // linked worktree(git-dir ≠ git-common-dir)——cwd 即工作区,未提交+已提交补齐两段
1135
+ // 照走;否则模块命中面只剩未提交的 CLI 再生文件(回放:4 个 .claude/skills/*.md),
1136
+ // 真实已提交改动全盲 → 0 命中 → integrationRan=not-ran 误触发 PASS 封顶(假 NOTES)。
1137
+ let wtGitDir = found ? found.gitDir : null
1138
+ let nativeWorktree = false
1139
+ if (!wtGitDir) {
1140
+ const gd = gitQuiet(cwd, ['rev-parse', '--git-dir'], { timeout: 15000 })
1141
+ const gcd = gitQuiet(cwd, ['rev-parse', '--git-common-dir'], { timeout: 15000 })
1142
+ const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
1143
+ if (gd && gcd && norm(gd) !== norm(gcd) && norm(gcd) !== '.git') {
1144
+ wtGitDir = cwd
1145
+ nativeWorktree = true
1146
+ }
1147
+ }
1148
+ if (wtGitDir) {
1125
1149
  const wtStatus = gitQuiet(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30000, trim: false })
1126
1150
  // P1 修复(2026-09-07):过滤器改 filterDeliverableFiles 口径(worktree-apply.js 同款)——
1127
1151
  // 此前 `.sillyspec/` 一刀切把 docs 交付物也滤掉,声明了模块文档的 task 在形态 A 下落②类假红
@@ -1135,17 +1159,25 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1135
1159
  !p.startsWith('.sillyspec/quicklog/'))
1136
1160
  // 已提交口径补齐(2026-09-10 用户反馈②「先提交则 diff 空」时序两难的 diff 半边):
1137
1161
  // worktree 内已 commit 的改动对主仓 base..HEAD 与 status --porcelain 双双不可见——
1138
- // 用 merge-base(主仓 HEAD, worktree HEAD)(= worktree 创建锚点)到 worktree HEAD 的
1139
- // commit diff 补入,未提交 ∪ 已提交两形态全覆盖。wtGitDir===cwd(in-place 退化)时
1140
- // 已提交改动已在 mainFiles,跳过防双并。fail-open:git 失败退回 status-only 现状。
1162
+ // diff 基线**优先 meta 锚点(baselineCommit>actualBaseHash>baseHash,resolveMainChangedFiles
1163
+ // 同口径)**,三锚全缺才退 merge-base(wtHead, mainHead)。坑 fact-face-checkpoint-pollution
1164
+ // (2026-09-19 five-cuts 实证:45 文件对账面虚增 23 条):旧口径恒用 merge-base 且注释
1165
+ // 假设其=「worktree 创建锚点」——但 baseline checkpoint 提交(_createBaselineCheckpoint,
1166
+ // worktree.js:816)落在 worktree 分支上、主仓 HEAD 不动,merge-base 指到 checkpoint 之前,
1167
+ // checkpoint 卷入的并行会话文件整段进窗口。wtGitDir===cwd(in-place 退化)时已提交改动
1168
+ // 已在 mainFiles,跳过防双并。fail-open:git 失败退回 status-only 现状。
1141
1169
  let committedFiles = []
1142
1170
  if (wtGitDir !== cwd) {
1143
1171
  try {
1144
1172
  const mainHead = gitQuiet(cwd, ['rev-parse', 'HEAD'], { timeout: 15000 })
1145
1173
  const wtHead = gitQuiet(wtGitDir, ['rev-parse', 'HEAD'], { timeout: 15000 })
1146
- const mb = (mainHead && wtHead)
1147
- ? gitQuiet(wtGitDir, ['merge-base', wtHead, mainHead], { timeout: 15000 })
1174
+ const metaBase = found && found.meta
1175
+ ? (found.meta.baselineCommit || found.meta.actualBaseHash || found.meta.baseHash || null)
1148
1176
  : null
1177
+ const mb = metaBase
1178
+ || ((mainHead && wtHead)
1179
+ ? gitQuiet(wtGitDir, ['merge-base', wtHead, mainHead], { timeout: 15000 })
1180
+ : null)
1149
1181
  if (mb) {
1150
1182
  const diffOut = gitQuiet(wtGitDir, ['diff', '--name-only', mb, wtHead], { timeout: 30000 })
1151
1183
  committedFiles = String(diffOut || '').split('\n')
@@ -1156,6 +1188,30 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1156
1188
  !p.startsWith('.sillyspec/quicklog/'))
1157
1189
  }
1158
1190
  } catch { /* 已提交补齐失败退回 status-only(fail-open,不拖垮未提交并入) */ }
1191
+ } else if (nativeWorktree) {
1192
+ // native 态已提交补齐(ql-20260918-010 根因②):主锚点必须取主仓检出(common dir)
1193
+ // HEAD——原口径在 cwd 跑 rev-parse HEAD 取到 worktree 自身 HEAD,merge-base(wtHead,
1194
+ // wtHead) 恒自身 → committed diff 恒空。common dir HEAD(主仓当前分支)与 worktree
1195
+ // HEAD 的 merge-base = 分叉锚点,回放场景 = 收养基线。fail-open 同上。
1196
+ try {
1197
+ const gcd = gitQuiet(cwd, ['rev-parse', '--git-common-dir'], { timeout: 15000 })
1198
+ const wtHead = gitQuiet(cwd, ['rev-parse', 'HEAD'], { timeout: 15000 })
1199
+ const mainHead = gcd
1200
+ ? gitQuiet(cwd, ['--git-dir=' + String(gcd).trim(), 'rev-parse', 'HEAD'], { timeout: 15000 })
1201
+ : null
1202
+ const mb = (mainHead && wtHead)
1203
+ ? gitQuiet(cwd, ['merge-base', wtHead, String(mainHead).trim()], { timeout: 15000 })
1204
+ : null
1205
+ if (mb) {
1206
+ const diffOut = gitQuiet(cwd, ['diff', '--name-only', String(mb).trim(), wtHead], { timeout: 30000 })
1207
+ committedFiles = String(diffOut || '').split('\n')
1208
+ .map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1209
+ .filter(p => p && p !== '.sillyspec' &&
1210
+ !p.startsWith('.sillyspec/changes/') &&
1211
+ !p.startsWith('.sillyspec/.runtime/') &&
1212
+ !p.startsWith('.sillyspec/quicklog/'))
1213
+ }
1214
+ } catch { /* native 已提交补齐失败退回 status-only(fail-open) */ }
1159
1215
  }
1160
1216
  const merged = [...new Set([...(mainFiles || []), ...wtFiles, ...committedFiles])]
1161
1217
  if (wtFiles.length > 0 || committedFiles.length > 0) {
@@ -1166,7 +1222,27 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1166
1222
  }
1167
1223
 
1168
1224
  // 无 ctx / ctx 无跨仓 entry → 主仓 diff 即结果(零回归)
1169
- if (!ctx || typeof ctx.repos !== 'object' || ctx.repos === null) return mainFiles
1225
+ // ql-20260918-012 主仓形态补源:apply 后 meta/diff 链在主仓可双双落空(变更已提交进 HEAD、
1226
+ // meta 已被 execute 收尾清理)→ 文件集退化为 porcelain(被并行会话未提交文件污染,批2 verify
1227
+ // 实证 5 个 .claude 假面+0 命中假 skip)。对齐 resolveReconcileActualFiles 的 B3 兜底:apply-pathspec
1228
+ // 清单存在则并入(读取失败静默;该清单由 worktree apply 落盘,apply 后必在,是主仓形态最可靠的声明面)。
1229
+ if (!ctx || typeof ctx.repos !== 'object' || ctx.repos === null) {
1230
+ try {
1231
+ const rtRoot = resolveRuntimeRoot({ runtimeRoot: opts.runtimeRoot, specDriftAnchor: opts.specDriftAnchor }, opts.specBase || join(cwd, '.sillyspec'))
1232
+ const pathspecFile = join(rtRoot, `apply-pathspec-${changeName}.txt`)
1233
+ if (existsSync(pathspecFile)) {
1234
+ const declared = readFileSync(pathspecFile, 'utf8').split('\n').map(l => l.trim()).filter(Boolean)
1235
+ .map(p => p.replace(/\\/g, '/'))
1236
+ .filter(p => p && !p.startsWith('.sillyspec/changes/') && !p.startsWith('.sillyspec/.runtime/'))
1237
+ if (declared.length > 0) {
1238
+ // mainFiles 为 null(meta/diff 双落空、git 不可用)时声明面直接成为文件集——
1239
+ // 否则 0 命中假 skip 复发(ql-20260918-012 直测实证);为数组则并集
1240
+ mainFiles = Array.isArray(mainFiles) ? [...new Set([...mainFiles, ...declared])] : declared
1241
+ }
1242
+ }
1243
+ } catch { /* apply-pathspec 补源失败退回原集(fail-open) */ }
1244
+ return mainFiles
1245
+ }
1170
1246
  const crossEntries = []
1171
1247
  for (const entry of ctx.repos.values()) {
1172
1248
  if (entry && entry.isMain === false) crossEntries.push(entry)
@@ -1231,7 +1307,18 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1231
1307
  // 不损原 worktree 结果。
1232
1308
  if (files !== null && gitDir !== cwd) {
1233
1309
  try {
1234
- const mainFiles = runGitDiffNameOnly(cwd, `${diffBase}..HEAD`)
1310
+ // 主仓侧窗口锚 fork 点(merge-base(HEAD, diffBase))——坑 fact-face-checkpoint-pollution
1311
+ // (2026-09-19 five-cuts 实证:事实面 45 文件虚增 23 条):旧口径 `${diffBase}..HEAD`
1312
+ // 在「主仓 HEAD 未前移、checkpoint 落 worktree 分支」时是反向区间——checkpoint 卷入的
1313
+ // 并行会话文件在主仓侧全显示为删除、--name-only 照样列名,overlay 整段泄漏进并集。
1314
+ // 锚 fork 点后:主仓没动 → 窗口空(零泄漏);wt-commit 流(提交落主仓 HEAD)→ fork..HEAD
1315
+ // 恰捕获那些提交,语义不变。merge-base 不可得(对象缺失等极端)→ 退旧区间(不放大)。
1316
+ let mainFiles = null
1317
+ const fork = gitQuiet(cwd, ['merge-base', 'HEAD', String(diffBase)], { timeout: 15000 })
1318
+ if (typeof fork === 'string' && fork.trim()) {
1319
+ mainFiles = runGitDiffNameOnly(cwd, `${fork.trim()}..HEAD`)
1320
+ }
1321
+ if (mainFiles === null) mainFiles = runGitDiffNameOnly(cwd, `${diffBase}..HEAD`)
1235
1322
  if (Array.isArray(mainFiles)) {
1236
1323
  const merged = [...new Set([...files, ...mainFiles])]
1237
1324
  const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, merged, { specBase: sb })
@@ -1399,17 +1486,28 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1399
1486
  ]
1400
1487
  console.warn(`⚠️ 模块 0 命中诊断(对照 path 前缀与 diff 布局是否一致,如 packages/<name> vs <name>/):`)
1401
1488
  for (const l of diagLines) console.warn(` ${l}`)
1402
- mainResult = {
1403
- status: 'skipped',
1404
- command: null,
1405
- exitCode: null,
1406
- durationMs: null,
1407
- outputTail: null,
1408
- reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。' +
1409
- ` 诊断:${diagLines.join(';')}` + eaNote,
1410
- resultPath: null,
1411
- mode: 'module-zero-hit',
1412
- fallbackReason: null,
1489
+ // 变更测试兜底(ql-20260919-007,2026-09-19 实测踩:test-only 变更 0 模块命中时裸 skip,
1490
+ // quick --done 的 test 门禁沦为「据自报告判定」虚设):diff 含测试文件时按 deps(auto)
1491
+ // 同款口径(discoverModuleDependentTests)直接跑「变更的测试文件」——范围仍限本次变更
1492
+ // 不回退全量(0 命中防超时/预存失败的初衷不变);无测试文件维持裸 skip(docs-only 等
1493
+ // 变更无测试面)。hits 传空数组:runModuleSubset 内部同样经 deps(auto) 执行并落台账。
1494
+ const zeroHitDeps = discoverModuleDependentTests({ cwd, changedFiles: lastChangedFiles, coveredCommands: [] })
1495
+ if (zeroHitDeps.length > 0) {
1496
+ console.log(`ℹ️ 模块 0 命中但 diff 含 ${zeroHitDeps.length} 个测试文件——按变更测试子集实测(不回退全量、不再裸 skip)`)
1497
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles })
1498
+ } else {
1499
+ mainResult = {
1500
+ status: 'skipped',
1501
+ command: null,
1502
+ exitCode: null,
1503
+ durationMs: null,
1504
+ outputTail: null,
1505
+ reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。' +
1506
+ ` 诊断:${diagLines.join(';')}` + eaNote,
1507
+ resultPath: null,
1508
+ mode: 'module-zero-hit',
1509
+ fallbackReason: null,
1510
+ }
1413
1511
  }
1414
1512
  } else {
1415
1513
  // —— 全量路径(full / module 无块 / module git 不可用)——
@@ -2848,6 +2946,263 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2848
2946
  return { status, matched, missing, undeclared, undeclaredScaffold: scaffoldTotal, skipReason: null, notes, form: actual.form, sources: actual.sources, crossRepo }
2849
2947
  }
2850
2948
 
2949
+ // ─────────────────────────────────────────────────────────────────────────────
2950
+ // ceremony 双跑收口(2026-09-18-ceremony-risk-pricing task-04 / FR-03 / D-003、
2951
+ // 显式降档复核 D-004):预价信声明、结算信事实——verify --done 与 archive confirm
2952
+ // 两出口用实际 diff 重跑 blast+span 得事实档,与 .runtime/ceremony-tier-<change>.json
2953
+ // 开跑声明档对账,懒 agent 的低报在收口被硬 flag + 记摩擦账 + 落事实面预价种子
2954
+ // (该会话下一 change 的开跑价强制并入事实面)。实际 diff 一律取 resolveReconcileActualFiles
2955
+ // 单点(上方 :2549——三源并集 / 并行会话剔除 / git 降级语义全复用,勿另造第二取数口径)。
2956
+ // 本函数是两出口的唯一实现宿主(gates.js verify 块的调用接线属 task-03 面;archive 出口
2957
+ // 接线在 run/complete-handlers.js handleArchiveConfirmStep)。
2958
+ // ─────────────────────────────────────────────────────────────────────────────
2959
+
2960
+ // 2026-09-19-ceremony-pricing-five-cuts task-03:readCeremonyFactContent 与内容采集上限三常量
2961
+ // 整体删除——事实面零内容扫描(只吃文件名 × 声明面,D-008),常量随之失去消费者。
2962
+
2963
+ /** 种子文件名的会话标识清洗(anon@host / agent 名等 → 文件名安全字符,截 80 字符) */
2964
+ function sanitizeCeremonySeedSession(session) {
2965
+ return String(session).trim().replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'session'
2966
+ }
2967
+
2968
+ /** 会话标识解析(种子文件口径):入参显式给足(archive 出口传 resolveSessionIdentity 结果);
2969
+ * 缺省动态 import progress.js 三级解析(flag > env > anon@host)——动态引是 gates 先例
2970
+ * (progress.js 闭包含 DB/registry,静态互引有环风险);解析失败 → null(种子跳过,判定不受影响) */
2971
+ async function resolveCeremonyDualRunSession({ session, cwd }) {
2972
+ if (typeof session === 'string' && session.trim()) return session.trim()
2973
+ try {
2974
+ const { resolveSessionIdentity } = await import('./progress.js')
2975
+ return resolveSessionIdentity({ cwd, warn: false }).session
2976
+ } catch { return null }
2977
+ }
2978
+
2979
+ /**
2980
+ * ceremony 双跑收口对账检查(verify --done / archive confirm 两出口共用;照 runVerifyTestCheck /
2981
+ * reconcileTargetFiles 检查族先例:单结果对象 + 配套 print 导出)。
2982
+ *
2983
+ * 流程:声明档读 `.runtime/ceremony-tier-<change>.json`(task-03 开跑定价产物)的 tier →
2984
+ * 实际 diff 取 resolveReconcileActualFiles 单点 → 事实面 resolveChangeRisk(文件名 × 项目
2985
+ * 声明危险面,零内容扫描——D-008)+ _module-map 索引组装 reconcileDualRun 入参 → 引擎出
2986
+ * 文件与对应内容)+ _module-map 索引组装 reconcileDualRun 入参 → 引擎出 {factTier, mismatch,
2987
+ * severity}(声明档<事实档 → mismatch/error)。mismatch 时本函数亲自落两件收口产物(fail-soft,
2988
+ * 异常降级留痕不阻断):① 摩擦账 mergeFrictionEntry(counts 记 gate_rollback——现有三枚举中
2989
+ * 唯一双语义契合项:verify 出口低报即 gate 回滚、且 gate_rollback 是 escalateByFriction 消费键
2990
+ * 可供次单升档;扩展第四类型须动 friction-ledger/tally 枚举,不在本卡 allowed_paths);② 事实面
2991
+ * 预价种子 `.runtime/ceremony-fact-seed-<session>.json`(session 取 resolveSessionIdentity
2992
+ * 同一口径,同 session 幂等覆盖不叠加;字段 declaredTier/factTier/mismatch + 事实面输入 factFiles
2993
+ * 等供次单开跑价直用)。
2994
+ *
2995
+ * 跳过面(fail-soft 不误红,存量变更零回归):quick 无 changeName / 档位文件缺失(task-03 未
2996
+ * 落地前的在途变更)/ git 全不可用(actual 降级)。档位文件在场但 tier 非法 → 不跳过,按引擎
2997
+ * reconcileDualRun 的 declaredTier 非法口径对账(fail-safe 宁严勿松,notes 留痕)。
2998
+ *
2999
+ * 显式降档复核(D-004):档位文件 explicitDowngradeAccepted 标记(或 reasons 含该标记)的变更,
3000
+ * 事实面支持(无 mismatch)→ 放行并在 warning 披露;不支持 → 按低报同款 mismatch。
3001
+ *
3002
+ * mismatch 的阻断语义由接线方兑现:verify 出口 errors 硬 flag(阻断回滚,strictViolation / ②类
3003
+ * 先例);archive 出口阻断级警告(只警告不回滚归档,D-006 archive=终态铁律)。violation 载荷
3004
+ * (code/message 含命中明细)随结果透传,接线方零拼装。
3005
+ *
3006
+ * @param {object} opts
3007
+ * @param {string} opts.cwd - 主仓根(git 调用根)
3008
+ * @param {string|null} [opts.specBase] - .sillyspec 根(缺省 join(cwd,'.sillyspec');平台模式传 specRoot)
3009
+ * @param {string|null} [opts.changeName] - 变更名(quick 等无关联场景缺省 → skipped)
3010
+ * @param {string|null} [opts.runtimeRoot] - 运行时根(缺省 join(specBase,'.runtime'))
3011
+ * @param {string|null} [opts.session] - 会话标识(种子文件口径;缺省内部三级解析)
3012
+ * @returns {Promise<{
3013
+ * status: 'ok'|'mismatch'|'skipped'|'degraded',
3014
+ * change: string|null, declaredTier: string|null, factTier: string|null,
3015
+ * mismatch: boolean, severity: 'error'|'none'|null,
3016
+ * explicitDowngradeAccepted: boolean, downgradeRecheckPassed: boolean|null,
3017
+ * skipReason: string|null, notes: string[], factReasons: string[], factFiles: string[],
3018
+ * form: 'worktree'|'post-apply'|null, sources: string[],
3019
+ * violation?: {code: string, message: string},
3020
+ * ledger?: {ok: boolean, skipped?: boolean}, seed?: {written: boolean, path?: string, reason?: string},
3021
+ * at: string,
3022
+ * }>}
3023
+ */
3024
+ export async function runCeremonyDualRunCheck({ cwd, specBase = null, changeName = null, runtimeRoot = null, session = null } = {}) {
3025
+ const notes = []
3026
+ const at = new Date().toISOString()
3027
+ const skippedResult = (reason) => ({ status: 'skipped', change: changeName, declaredTier: null, factTier: null,
3028
+ mismatch: false, severity: null, explicitDowngradeAccepted: false, downgradeRecheckPassed: null,
3029
+ skipReason: reason, notes, factReasons: [], factFiles: [], form: null, sources: [], at })
3030
+
3031
+ if (!changeName) {
3032
+ return skippedResult('无 changeName(quick 等无关联变更场景),ceremony 双跑对账跳过')
3033
+ }
3034
+ const sb = specBase || join(cwd, '.sillyspec')
3035
+ const rt = runtimeRoot || join(sb, '.runtime')
3036
+
3037
+ // —— 声明侧:开跑定价落档(task-03 产物,tier/components/reasons/transitions)——
3038
+ const tierPath = join(rt, `ceremony-tier-${changeName}.json`)
3039
+ let tierFile = null
3040
+ try { tierFile = JSON.parse(readFileSync(tierPath, 'utf8')) } catch { /* 缺失/坏 JSON → 下方跳过 */ }
3041
+ if (tierFile == null || typeof tierFile !== 'object' || Array.isArray(tierFile)) {
3042
+ // 存量零红门禁:档位文件缺失 = 变更先于定价系统起跑(task-03 未覆盖的在途面),跳过不误红。
3043
+ // 与「文件在场但 tier 非法」不同处置:后者走引擎 fail-safe 对账(无法证明声明到位 → 视为低报)。
3044
+ return skippedResult(`档位文件缺失或不可解析(${tierPath}——开跑定价产物,task-03 阶段门落盘),ceremony 双跑跳过(存量变更零红门禁)`)
3045
+ }
3046
+ const declaredTier = typeof tierFile.tier === 'string' ? tierFile.tier : null
3047
+ if (!CEREMONY_TIERS.includes(declaredTier)) {
3048
+ notes.push(`声明档 tier 非法(${JSON.stringify(tierFile.tier)})——按引擎 fail-safe 口径以最低档对账(宁严勿松)`)
3049
+ }
3050
+
3051
+ // —— 事实侧:实际 diff 单点取数(三源并集 / 并行会话剔除 / git 降级语义全复用)——
3052
+ const actual = resolveReconcileActualFiles({ cwd, specBase: sb, runtimeRoot: rt, changeName })
3053
+ if (actual.foreignExcluded > 0) {
3054
+ notes.push(`主仓 status 捕入的 ${actual.foreignExcluded} 个并行会话声明文件已剔除(不参与事实面计价)`)
3055
+ }
3056
+ if (!actual.ok) {
3057
+ return { status: 'degraded', change: changeName, declaredTier, factTier: null,
3058
+ mismatch: false, severity: null, explicitDowngradeAccepted: false, downgradeRecheckPassed: null,
3059
+ skipReason: `git 全部不可用,actual 不可得,双跑对账降级跳过——${actual.degradedReason}`,
3060
+ notes, factReasons: [], factFiles: [], form: actual.form, sources: actual.sources, at }
3061
+ }
3062
+
3063
+ // —— 事实面重跑(2026-09-19-ceremony-pricing-five-cuts task-03:零内容扫描)——
3064
+ // blast = actual.files × 项目声明危险面(loadBlastDeclarationsAllProjects——runCeremonyDualRunCheck
3065
+ // 无 project 语境,扫 specBase/docs/*/modules/_module-map.yaml 取并集,多项目仓保守正确);
3066
+ // 旧口径(读实际 diff 文件全文喂 detectChangeRisk 词表)整类删除——事实面只吃文件名 × 声明面
3067
+ // (自指陷阱消失:门禁引擎源码里的关键词不再是判级输入,D-008)。
3068
+ const blastDeclarations = loadBlastDeclarationsAllProjects(sb)
3069
+ // span 轴同款声明面(2026-09-19-span-risk-pattern-migration task-03):map 顶层 span_risk 段
3070
+ // AllProjects 并集,一次装载双点共用(reconcileDualRun factSpanRiskPatterns + factDetail 披露);
3071
+ // 无声明项目 → 空表(span 模式维关闭,禁回退内置表,D-003)
3072
+ const factSpanRiskPatterns = loadSpanRiskPatternsAllProjects(sb)
3073
+ const factRisk = resolveChangeRisk({ files: actual.files, blastDeclarations })
3074
+ let factModuleIndex = null
3075
+ try { factModuleIndex = discoverModuleIndex(join(sb, 'knowledge')) } catch { /* 缺 map → span 跨模块维跳过 */ }
3076
+
3077
+ // —— 引擎对账(mismatch 判定单点)+ 命中明细(同入参跑 computeCeremonyTier 取 reasons,供披露)——
3078
+ const verdict = reconcileDualRun({ declaredTier, factBlastTier: factRisk.tier, factFiles: actual.files, factModuleIndex, factSpanRiskPatterns })
3079
+ const factDetail = computeCeremonyTier({
3080
+ blastTier: factRisk.tier, declaredFiles: actual.files, moduleIndex: factModuleIndex, spanRiskPatterns: factSpanRiskPatterns, frictionCounts: {},
3081
+ })
3082
+ if (verdict.severity === 'warn') {
3083
+ notes.push(`[advisory] 高报记账(D-005):声明档 ${String(declaredTier)} > 事实档 ${verdict.factTier}——只记账不阻断不记摩擦(高报代价是更重仪式,自罚机制在场)`)
3084
+ }
3085
+
3086
+ // —— 显式降档复核(D-004):档位文件标记(字段或 reasons 痕迹双口径容错——computeCeremonyTier
3087
+ // 的 reasons 含 explicitDowngradeAccepted=true 字样,task-03 落盘形态两者至少其一在场)——
3088
+ const explicitDowngradeAccepted = tierFile.explicitDowngradeAccepted === true
3089
+ || (Array.isArray(tierFile.reasons) && tierFile.reasons.some((r) => /explicitDowngradeAccepted/.test(String(r))))
3090
+ let downgradeRecheckPassed = null
3091
+ if (explicitDowngradeAccepted) {
3092
+ if (verdict.mismatch) {
3093
+ notes.push('显式降级未获事实面支持(design risk_level 降级声明与实际改动矛盾,D-004)——按低报处理')
3094
+ } else {
3095
+ downgradeRecheckPassed = true
3096
+ notes.push(`显式降级经事实面复核通过(声明档 ${declaredTier} ≥ 事实档 ${verdict.factTier},D-004 放行留痕)`)
3097
+ }
3098
+ }
3099
+
3100
+ const result = {
3101
+ status: verdict.mismatch ? 'mismatch' : 'ok',
3102
+ change: changeName, declaredTier, factTier: verdict.factTier,
3103
+ mismatch: verdict.mismatch, severity: verdict.severity,
3104
+ explicitDowngradeAccepted, downgradeRecheckPassed,
3105
+ skipReason: null, notes, factReasons: factDetail.reasons, factFiles: actual.files,
3106
+ form: actual.form, sources: actual.sources, at,
3107
+ }
3108
+ if (!verdict.mismatch) return result
3109
+
3110
+ // —— mismatch 收口产物(fail-soft:账本 API 自身不抛;种子落盘异常降级留痕,均不阻断判定)——
3111
+ result.violation = {
3112
+ code: 'ceremony_dual_run_mismatch',
3113
+ message: `ceremony 双跑对账:声明档 ${String(declaredTier)} < 事实档 ${verdict.factTier}(低报)——${factDetail.reasons.join(';')}`,
3114
+ }
3115
+ // ── 双跑低报自动追赶重定价(坑 ceremony-cascade-deadlock,ql-013 postmortem)────
3116
+ // 声明追赶检查点(escalateCeremonyTierAtGate)位于完成级联末位,双跑 error 在 verify
3117
+ // postcheck 更早拦住 --done——级联走不到末位,追赶永不执行,形成「提示由阶段门重定价
3118
+ // 但阶段门时序不可达」的死锁(five-cuts 实证:3 轮 --done 卡死只能手动改档位文件)。
3119
+ // 修法:mismatch 分支内先按事实面执行一次追赶(applyDeclarationCatchUp 同款语义:
3120
+ // transitions 空→整档换 factTier;非空→max(factTier, 地板)),写回档位文件——本次仍
3121
+ // error(低报惩罚保留:friction 已 +1),但重跑 --done 即对账通过,不需手动改档。
3122
+ // fail-soft:重定价任何异常降级留痕不阻断 error 本身。
3123
+ try {
3124
+ const { applyDeclarationCatchUp } = await import('./ceremony-tier.js')
3125
+ const caught = applyDeclarationCatchUp({
3126
+ currentDoc: tierFile,
3127
+ recomputedTier: verdict.factTier,
3128
+ recomputedComponents: { blast: factRisk.tier, span: (factDetail.components && factDetail.components.span) || 'S0', friction: 'S0' },
3129
+ recomputedReasons: [`双跑低报自动追赶(事实面 tier=${verdict.factTier},证据面 seed ${actual.files.length} 文件)`],
3130
+ frictionCounts: {},
3131
+ })
3132
+ const tierPathForReprice = join(rt, `ceremony-tier-${changeName}.json`)
3133
+ const { writeAtomicSync } = await import('./fs-atomic.js')
3134
+ writeAtomicSync(tierPathForReprice, JSON.stringify(caught, null, 2) + '\n')
3135
+ result.violation.message += `;档位文件已自动按事实面追赶重定价(${String(declaredTier)} → ${caught.tier})——修复低报根因后重跑 --done 即对账通过,勿手动改档(ql-013 死锁修复)`
3136
+ notes.push(`低报自动追赶重定价:${String(declaredTier)} → ${caught.tier}(级联死锁修复,applyDeclarationCatchUp 事实面语义)`)
3137
+ } catch (e) {
3138
+ notes.push(`低报自动追赶重定价失败(fail-soft 不阻断 error 本身):${e && e.message ? e.message : e}`)
3139
+ }
3140
+ result.ledger = await mergeFrictionEntry(rt, { change: changeName, counts: { gate_rollback: 1 } })
3141
+ const sid = await resolveCeremonyDualRunSession({ session, cwd })
3142
+ if (sid) {
3143
+ try {
3144
+ mkdirSync(rt, { recursive: true })
3145
+ const seedPath = join(rt, `ceremony-fact-seed-${sanitizeCeremonySeedSession(sid)}.json`)
3146
+ writeAtomicSync(seedPath, JSON.stringify({
3147
+ change: changeName,
3148
+ declaredTier,
3149
+ factTier: verdict.factTier,
3150
+ mismatch: true,
3151
+ factBlastTier: factRisk.tier,
3152
+ factHitPrefixes: factRisk.hitPrefixes,
3153
+ factFiles: actual.files,
3154
+ at,
3155
+ }, null, 2) + '\n')
3156
+ result.seed = { written: true, path: seedPath, session: sid }
3157
+ } catch (e) {
3158
+ result.seed = { written: false, reason: `种子落盘失败(fail-soft 不阻断):${e && e.message ? e.message : e}` }
3159
+ }
3160
+ } else {
3161
+ result.seed = { written: false, reason: '会话标识不可得(resolveSessionIdentity 解析失败)——种子跳过,判定不受影响' }
3162
+ }
3163
+ return result
3164
+ }
3165
+
3166
+ /**
3167
+ * 打印 ceremony 双跑对账结果(两出口共用渲染)。skipped 静默(存量无档位文件是过渡期常态,
3168
+ * printVerifyParityCheck skipped 先例——零噪音);ok 单行放行(审计可见性:声明面与事实面
3169
+ * 一致);显式降档复核通过 → warning 披露(D-004 留痕);mismatch → ⛔ console.error 列声明档/
3170
+ * 事实档/命中明细 + 修复指引 + 记账/种子落点——出口语义差异(verify=errors 硬 flag 阻断回滚 /
3171
+ * archive=阻断级警告不回滚终态,D-006)在文案末行双披露,阻断侧由各出口接线方兑现。
3172
+ */
3173
+ export function printCeremonyDualRunCheck(result) {
3174
+ if (!result || typeof result !== 'object') return
3175
+ if (result.status === 'skipped') return
3176
+ if (result.status === 'degraded') {
3177
+ console.warn(`\n⚠️ Ceremony 双跑对账降级跳过:${result.skipReason}`)
3178
+ return
3179
+ }
3180
+ if (result.status === 'ok') {
3181
+ if (result.downgradeRecheckPassed) {
3182
+ console.warn(`\n⚠️ Ceremony 双跑对账:显式降级经事实面复核通过(声明档 ${result.declaredTier} ≥ 事实档 ${result.factTier},D-004 放行留痕)`)
3183
+ return
3184
+ }
3185
+ console.log(`\n✅ Ceremony 双跑对账通过:声明档 ${result.declaredTier} ≥ 事实档 ${result.factTier}(实际 diff ${result.factFiles.length} 文件重跑 blast+span)`)
3186
+ return
3187
+ }
3188
+ // mismatch(severity=error)
3189
+ console.error(`\n⛔ Ceremony 双跑对账不通过(低报,severity=error):声明档 ${String(result.declaredTier)} < 事实档 ${result.factTier}`)
3190
+ console.error(` 声明档:${String(result.declaredTier)}(.runtime/ceremony-tier-${result.change}.json 开跑定价)`)
3191
+ console.error(` 事实档:${result.factTier}(实际 diff ${result.factFiles.length} 文件重跑 blast+span,源:${(result.sources || []).join('、') || '-'})`)
3192
+ const reasons = result.factReasons || []
3193
+ for (const r of reasons.slice(0, 10)) console.error(` · ${r}`)
3194
+ if (reasons.length > 10) console.error(` · …还有 ${reasons.length - 10} 条命中明细`)
3195
+ if (result.explicitDowngradeAccepted) console.error(' 显式降级未获事实面支持(design risk_level 降级声明与实际改动矛盾,D-004)——按低报处理')
3196
+ if (result.ledger && result.ledger.ok && !result.ledger.skipped) {
3197
+ console.error(` 已记摩擦账:friction-ledger.json(change=${result.change},gate_rollback +1)`)
3198
+ }
3199
+ if (result.seed && result.seed.written) {
3200
+ console.error(` 已落事实面预价种子:${result.seed.path}(本会话下一 change 的开跑价强制并入事实面)`)
3201
+ }
3202
+ console.error(' 修复:按事实档补做对应档位仪式,或修正降级理由后由阶段门重定价,再重跑收口对账。')
3203
+ console.error(' 出口语义:verify --done 出口为 errors 硬 flag(阻断回滚,修复后重跑 --done 进度不丢);archive confirm 出口为阻断级警告(归档继续,D-006 不回滚终态、不 reopen)。')
3204
+ }
3205
+
2851
3206
  // ─────────────────────────────────────────────────────────────────────────────
2852
3207
  // P3b task-02:verify-result.md 探针预填段一致性抽查(D-002@v1 方案A 正文基准 + D-003@v1
2853
3208
  // G2/G8 锚点规格与判别子;接线 gates.js verify 块是 task-03 的 scope)