sillyspec 3.33.0 → 3.33.1

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 (62) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +11 -3
  3. package/AGENTS.md +1 -1
  4. package/assets/command-cards/flow.md +1 -1
  5. package/package.json +1 -1
  6. package/src/archive-delta.js +4 -3
  7. package/src/change-delete.js +6 -5
  8. package/src/contract-matrix.js +2 -1
  9. package/src/cross-placement.js +5 -1
  10. package/src/db.js +8 -2
  11. package/src/deleted-ledger.js +2 -1
  12. package/src/doctor-diagnostics.js +8 -3
  13. package/src/flow-draft.js +2 -1
  14. package/src/flow.js +111 -62
  15. package/src/friction-ledger.js +2 -1
  16. package/src/friction-tally.js +8 -4
  17. package/src/handoff.js +2 -1
  18. package/src/hooks/worktree-guard.js +5 -5
  19. package/src/index.js +41 -7
  20. package/src/knowledge-hits.js +2 -1
  21. package/src/knowledge-vector.js +2 -1
  22. package/src/progress/consistency-doctor.js +5 -2
  23. package/src/progress/stage-machine.js +6 -2
  24. package/src/progress.js +19 -8
  25. package/src/quicklog.js +194 -9
  26. package/src/review-dispatch.js +4 -3
  27. package/src/review-tier.js +2 -1
  28. package/src/route-hindsight.js +4 -3
  29. package/src/run/bg-sync.js +10 -3
  30. package/src/run/command.js +13 -11
  31. package/src/run/complete-handlers.js +40 -37
  32. package/src/run/complete.js +12 -8
  33. package/src/run/gate-snapshot-ledger.js +2 -1
  34. package/src/run/gate-snapshot.js +57 -11
  35. package/src/run/gates.js +31 -7
  36. package/src/run/prompt.js +6 -5
  37. package/src/run/scan-profile.js +2 -1
  38. package/src/run/shared.js +109 -5
  39. package/src/run/stage.js +5 -4
  40. package/src/run/test-ledger.js +2 -1
  41. package/src/run/verify-quality-scan.js +11 -5
  42. package/src/runtime-paths.js +189 -0
  43. package/src/scan-refresh.js +4 -3
  44. package/src/scope-audit.js +7 -6
  45. package/src/sentinel-assertions.js +37 -0
  46. package/src/spec-sync.js +38 -22
  47. package/src/stage-contract.js +24 -2
  48. package/src/stage-review.js +3 -2
  49. package/src/stages/brainstorm.js +1 -1
  50. package/src/sync-noise.js +3 -2
  51. package/src/sync.js +71 -15
  52. package/src/task-done.js +2 -1
  53. package/src/task-review.js +7 -5
  54. package/src/task-tick.js +23 -1
  55. package/src/validate-artifacts.js +2 -1
  56. package/src/verify-draft.js +2 -1
  57. package/src/verify-postcheck.js +11 -10
  58. package/src/verify-probes.js +157 -52
  59. package/src/watcher.js +15 -9
  60. package/src/worktree-apply.js +2 -1
  61. package/src/write-audit.js +2 -1
  62. package/templates/agents-instruction.md +1 -1
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
21
+ import { runtimeStatePath } from './runtime-paths.js'
21
22
  import { join, dirname } from 'node:path'
22
23
  import jsYaml from 'js-yaml'
23
24
  import { writeAtomicSync } from './fs-atomic.js'
@@ -41,7 +42,7 @@ const DISPATCH_STATES = ['dispatching', 'in-flight', 'completed', 'failed', 'aba
41
42
 
42
43
  /** 在途记录路径:{SPEC_ROOT}/.runtime/review-dispatch-<change>.json */
43
44
  function dispatchRecordPath(specBase, changeName) {
44
- return join(specBase, '.runtime', `review-dispatch-${changeName}.json`)
45
+ return runtimeStatePath(join(specBase, '.runtime'), `review-dispatch-${changeName}.json`)
45
46
  }
46
47
 
47
48
  /** 读在途记录(无/坏 → null,fail-open 调用方按无在途处理)。 */
@@ -459,7 +460,7 @@ function appendShadowLedgerSkip(runtimeRoot, skip) {
459
460
  */
460
461
  function readCeremonyTierForShadow(runtimeRoot, changeName) {
461
462
  try {
462
- const p = join(runtimeRoot, `ceremony-tier-${changeName}.json`)
463
+ const p = runtimeStatePath(runtimeRoot, `ceremony-tier-${changeName}.json`)
463
464
  if (!existsSync(p)) return null
464
465
  const doc = JSON.parse(readFileSync(p, 'utf8'))
465
466
  if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null
@@ -480,7 +481,7 @@ function generateShadowReviewRunId() {
480
481
  // stage-review.js:751 的主线在途提示内联读的也是主线文件名,不会被影子记录污染)──
481
482
 
482
483
  function shadowRecordPath(specBase, changeName) {
483
- return join(specBase, '.runtime', `review-dispatch-shadow-${changeName}.json`)
484
+ return runtimeStatePath(join(specBase, '.runtime'), `review-dispatch-shadow-${changeName}.json`)
484
485
  }
485
486
 
486
487
  function readShadowRecord(specBase, changeName) {
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { parseFileChangeList } from './change-list.js'
38
+ import { runtimeStatePath } from './runtime-paths.js'
38
39
  import { computeCeremonyTier, CEREMONY_TIERS } from './ceremony-tier.js'
39
40
  import { readCeremonyPricingConfig } from './ceremony-config.js'
40
41
  import { resolveChangeRisk, extractExplicitRiskLevel } from './change-risk-profile.js'
@@ -151,7 +152,7 @@ export function classifyReviewTier({ planLevel, designPath, riskDetection, frict
151
152
  if (designPath) {
152
153
  try {
153
154
  const changeDir = dirname(designPath)
154
- const tierPath = join(dirname(dirname(changeDir)), '.runtime', `ceremony-tier-${basename(changeDir)}.json`)
155
+ const tierPath = runtimeStatePath(join(dirname(dirname(changeDir)), '.runtime'), `ceremony-tier-${basename(changeDir)}.json`)
155
156
  if (existsSync(tierPath)) {
156
157
  const tf = JSON.parse(readFileSync(tierPath, 'utf8'))
157
158
  if (tf && CEREMONY_TIERS.includes(tf.tier)) tierFileTier = tf.tier
@@ -37,6 +37,7 @@ import { createHash } from 'node:crypto'
37
37
  import { join, basename, dirname } from 'node:path'
38
38
  import { writeAtomicSync } from './fs-atomic.js'
39
39
  import { computeEditRatio } from './flow-draft.js'
40
+ import { runtimeStatePath } from './runtime-paths.js'
40
41
 
41
42
  /** 超阈判定常量(可调;口径:比例两路为严格大于——恰等于阈值不算超,保守防误标 R-01;
42
43
  * 计数两路为大于等于——2 维/2 次即系统性信号)。 */
@@ -107,7 +108,7 @@ function num(v) {
107
108
  export function snapshotBaseline({ specBase, change, changeDir }) {
108
109
  try {
109
110
  const runtimeRoot = join(specBase, '.runtime')
110
- const p = join(runtimeRoot, `${BASELINE_PREFIX}${change}.json`)
111
+ const p = runtimeStatePath(runtimeRoot, `${BASELINE_PREFIX}${change}.json`)
111
112
  if (existsSync(p)) return { captured: false, reason: 'already-exists(首写者胜)' }
112
113
  const payload = {
113
114
  schemaVersion: 1,
@@ -130,7 +131,7 @@ export function snapshotBaseline({ specBase, change, changeDir }) {
130
131
  */
131
132
  export function baselineSha256({ specBase, change }) {
132
133
  try {
133
- const p = join(specBase, '.runtime', `${BASELINE_PREFIX}${change}.json`)
134
+ const p = runtimeStatePath(join(specBase, '.runtime'), `${BASELINE_PREFIX}${change}.json`)
134
135
  if (!existsSync(p)) return null
135
136
  return createHash('sha256').update(readTextSafe(p)).digest('hex')
136
137
  } catch {
@@ -161,7 +162,7 @@ export function computeHindsightMetrics({ changeDir, reviewJson, flowState }) {
161
162
  // ① 首版快照 → 终稿行级改写比
162
163
  let designRewriteRatio = 0
163
164
  let tasksRewriteRatio = 0
164
- const baselineRaw = readTextSafe(join(specBase, '.runtime', `${BASELINE_PREFIX}${change}.json`))
165
+ const baselineRaw = readTextSafe(runtimeStatePath(join(specBase, '.runtime'), `${BASELINE_PREFIX}${change}.json`))
165
166
  let baseline = null
166
167
  if (baselineRaw != null) { try { const j = JSON.parse(baselineRaw); baseline = j && typeof j === 'object' ? j : null } catch { baseline = null } }
167
168
  if (baseline) {
@@ -36,12 +36,18 @@
36
36
  * 刻意不赌);父进程内 shared.js 已加载,动态 import 走缓存无额外成本。
37
37
  */
38
38
  import { spawn } from 'child_process';
39
+ import { runtimeTempPath } from '../runtime-paths.js'
39
40
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
40
41
  import { join } from 'path';
41
42
 
42
43
  export const BG_LOCK_FILENAME = 'spec-sync-bg.lock';
43
44
  export const BG_LOG_FILENAME = 'spec-sync-bg.log';
44
45
 
46
+ /** 后台同步日志路径(2026-10-18 布局分层:可随时删除的产物归 temp/)。 */
47
+ export function bgSyncLogPath(runtimeRoot) {
48
+ return runtimeTempPath(runtimeRoot, BG_LOG_FILENAME);
49
+ }
50
+
45
51
  // 后台轮参数:单轮预算下限(与 env SILLYSPEC_SYNC_TIMEOUT_MS 取 max)、轮数与总预算帽
46
52
  // (防慢平台下无限续轮);锁时效(pid 活性之外的第二道判死——pid 复用假活兜底,
47
53
  // 子进程自身总预算 ~2.5min + 收尾余量远小于该值,超时必是异常残留)。
@@ -108,7 +114,7 @@ export function isBgSyncLockLive(lock, now = Date.now()) {
108
114
  /** 子进程起步截尾日志(>1MB 保后 512KB,从整行边界起)。失败只影响日志体积。 */
109
115
  function truncateLogIfNeeded(runtimeRoot) {
110
116
  try {
111
- const p = join(runtimeRoot, BG_LOG_FILENAME);
117
+ const p = bgSyncLogPath(runtimeRoot);
112
118
  if (statSync(p).size <= LOG_TRUNCATE_THRESHOLD) return;
113
119
  const buf = readFileSync(p);
114
120
  const keep = buf.subarray(Math.max(0, buf.length - LOG_TRUNCATE_KEEP));
@@ -143,15 +149,16 @@ export async function spawnBackgroundSync(cwd, changeName, platformOpts = {}, op
143
149
  // 单飞合并:活后台轮在跑,把「还有新状态要同步」并入其下一轮。置位失败(并发写锁)
144
150
  // 只损失合并信号——后台轮收尾锁自清,下一条命令重新 spawn,收敛不丢。
145
151
  try { writeBgSyncLock(runtimeRoot, { ...lock, rerunQueued: true }); } catch { /* 见上 */ }
146
- return { status: 'coalesced', logPath: join(runtimeRoot, BG_LOG_FILENAME) };
152
+ return { status: 'coalesced', logPath: bgSyncLogPath(runtimeRoot) };
147
153
  }
148
154
 
149
155
  // 日志 fd:子进程 stdout/stderr 直接 append 进日志文件(跨平台 fd 继承);开不了退 ignore
150
- const logPath = join(runtimeRoot, BG_LOG_FILENAME);
156
+ const logPath = bgSyncLogPath(runtimeRoot);
151
157
  let stdio = ['ignore', 'ignore', 'ignore'];
152
158
  let logFd = null;
153
159
  try {
154
160
  mkdirSync(runtimeRoot, { recursive: true });
161
+ mkdirSync(join(runtimeRoot, 'temp'), { recursive: true }); // temp/ 可被整体删除,写前重建
155
162
  logFd = openSync(logPath, 'a');
156
163
  stdio = ['ignore', logFd, logFd];
157
164
  } catch {
@@ -20,6 +20,7 @@
20
20
  * './run/stage.js' → './shared.js' 等同级
21
21
  */
22
22
  import { basename, join, resolve, dirname } from 'node:path'
23
+ import { runtimeStatePath } from '../runtime-paths.js'
23
24
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, unlinkSync, statSync } from 'node:fs'
24
25
  import { randomBytes, randomUUID } from 'node:crypto'
25
26
  import { fileURLToPath } from 'node:url'
@@ -29,7 +30,7 @@ import { resolveQuickLinkedChanges } from './quick-audit.js'
29
30
  import { outputStep, collectStageWaitHistory } from './prompt.js'
30
31
  import { completeStep, completeStepBurst, skipStep, waitStep, continueStep, synthesizeStepOutput } from './complete.js'
31
32
  import { runStage } from './stage.js'
32
- import { sanitizeDesc, deriveChangeTitle } from '../quicklog.js'
33
+ import { sanitizeDesc, deriveShortChangeTitle } from '../quicklog.js'
33
34
  import { ProgressManager, resolveSessionIdentity } from '../progress.js'
34
35
  import { validateChangeExists, checkTransition } from '../stage-contract.js'
35
36
  import { READONLY_AUXILIARY_STAGES } from '../constants.js'
@@ -232,7 +233,7 @@ export async function ensureStageSteps(progress, stageName, cwd, specDir = null)
232
233
  function emitReadonlyEmptyGuide() {
233
234
  console.log('ℹ️ 未找到进度数据(只读查询不建变更)')
234
235
  console.log(' → 可开始轻量变更(--input 引号内换行合法,下例可照抄):')
235
- console.log(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
236
+ console.log(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景')
236
237
  console.log('')
237
238
  console.log(' 成功标准:')
238
239
  console.log(' - <可验证标准>"')
@@ -858,7 +859,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
858
859
  quickFallbackUsed = true
859
860
  } else {
860
861
  console.log(' 本仓无近期 quick 会话记录(current marker 与 quick-sessions 均空)——新工作请走轻量变更(--input 引号内换行合法,可照抄):')
861
- console.log(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
862
+ console.log(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景')
862
863
  console.log('')
863
864
  console.log(' 成功标准:')
864
865
  console.log(' - <可验证标准>"')
@@ -1301,11 +1302,12 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1301
1302
  }
1302
1303
  }
1303
1304
  } catch { /* 守卫异常不拦正常注册(fail-open)*/ }
1304
- // 创建时即写 title(与 name/KEY 同时落 db):--input 首行推导 deriveChangeTitle(≤50 字,
1305
- // 用户需求 2026-09-29)优先,长输入兜底 sanitizeDesc 压行,无则用 name 兜底。
1305
+ // 创建时即写 title(与 name/KEY 同时落 db):--input 首行过提炼 deriveShortChangeTitle
1306
+ // (目标 20/上限 30 自然标点截断;2026-10-16-short-change-title,50 字硬切残句淘汰)
1307
+ // 优先,长输入兜底 sanitizeDesc 压行,无则用 name 兜底。
1306
1308
  // proposal 还没写、无权威标题来源,这是临时值;brainstorm/plan 完成 proposal/design 落盘后由
1307
1309
  // complete.js 通用完成路径 deriveTitleFromLinkedChange 刷新为真实 # 标题(与 quick 启动 title 同源)。
1308
- progress = pm.initChange(cwd, autoChange, { title: inputText ? (deriveChangeTitle(inputText) || sanitizeDesc(inputText)) : autoChange })
1310
+ progress = pm.initChange(cwd, autoChange, { title: inputText ? (deriveShortChangeTitle(inputText) || sanitizeDesc(inputText)) : autoChange })
1309
1311
  } else if (isAuxiliary) {
1310
1312
  let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
1311
1313
  // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
@@ -1332,7 +1334,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1332
1334
  changeName = autoName
1333
1335
  if (!progress) {
1334
1336
  // auxiliary 创建也写 title(--input 优先 / name 兜底),同上完整流程语义。
1335
- progress = pm.initChange(cwd, autoName, { title: inputText ? (deriveChangeTitle(inputText) || sanitizeDesc(inputText)) : autoName })
1337
+ progress = pm.initChange(cwd, autoName, { title: inputText ? (deriveShortChangeTitle(inputText) || sanitizeDesc(inputText)) : autoName })
1336
1338
  // initChange 可能因 project 表为空返回 null
1337
1339
  if (!progress) {
1338
1340
  progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
@@ -1354,7 +1356,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1354
1356
  // initChangeIfNoActive 单语句原子抢占,败者走同一引导。
1355
1357
  const date = new Date().toISOString().slice(0, 10)
1356
1358
  const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
1357
- const claim = pm.initChangeIfNoActive(cwd, autoName, { title: inputText ? (deriveChangeTitle(inputText) || sanitizeDesc(inputText)) : autoName })
1359
+ const claim = pm.initChangeIfNoActive(cwd, autoName, { title: inputText ? (deriveShortChangeTitle(inputText) || sanitizeDesc(inputText)) : autoName })
1358
1360
  if (!claim.created) {
1359
1361
  const activeChanges = claim.activeChanges || []
1360
1362
  console.error(`❌ 已存在 ${activeChanges.length} 个活跃变更,run brainstorm 无 --change 不会自动创建新变更。`)
@@ -1377,7 +1379,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1377
1379
  // 正是并发双建的温床)——1 活跃现与多活跃同走 exit 2 引导。
1378
1380
  const date = new Date().toISOString().slice(0, 10)
1379
1381
  const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
1380
- const claim = pm.initChangeIfNoActive(cwd, autoName, { title: inputText ? (deriveChangeTitle(inputText) || sanitizeDesc(inputText)) : autoName })
1382
+ const claim = pm.initChangeIfNoActive(cwd, autoName, { title: inputText ? (deriveShortChangeTitle(inputText) || sanitizeDesc(inputText)) : autoName })
1381
1383
  if (!claim.created) {
1382
1384
  const activeCount = claim.activeChanges || []
1383
1385
  console.error(`❌ 已存在 ${activeCount.length} 个活跃变更,run auto 无法自动定位目标。`)
@@ -2076,7 +2078,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
2076
2078
  if (classification.mode === 'quick') {
2077
2079
  console.log(`📊 auto 模式分类:${classification.reason} 判为小变更`)
2078
2080
  console.log(` 小变更请走轻量变更(quick 通道已退役,v3.30.0 起拒绝新会话;--input 引号内换行合法,可照抄):`)
2079
- console.log(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
2081
+ console.log(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景')
2080
2082
  console.log('')
2081
2083
  console.log(' 成功标准:')
2082
2084
  console.log(' - <可验证标准>"')
@@ -2382,7 +2384,7 @@ function printAutoCompletionSummary(pm, cwd, changeName, flowStages, specBase) {
2382
2384
  // last-delta 模块清单(sidecar 缺失跳过)
2383
2385
  try {
2384
2386
  const runtimeRoot = join((specBase || join(cwd, '.sillyspec')), '.runtime')
2385
- const sidecarPath = join(runtimeRoot, 'last-delta.json')
2387
+ const sidecarPath = runtimeStatePath(runtimeRoot, 'last-delta.json')
2386
2388
  if (existsSync(sidecarPath)) {
2387
2389
  const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'))
2388
2390
  if (Array.isArray(sidecar.affectedModules) && sidecar.affectedModules.length > 0) {
@@ -30,6 +30,7 @@
30
30
  * (非被外层吞);process.exit 不可被 try 捕获 → 搬迁行为完全等价。
31
31
  */
32
32
  import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path'
33
+ import { runtimeStatePath, runtimeStateFileNames } from '../runtime-paths.js'
33
34
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, renameSync } from 'node:fs'
34
35
  import { createHash } from 'node:crypto'
35
36
  import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
@@ -161,8 +162,9 @@ function ownerFromReviewedFiles(reviewedFiles) {
161
162
  * 归档 / change-delete 无 delta 同样可删。
162
163
  *
163
164
  * 归属 fail-closed(禁 mtime 猜、禁后缀匹配):
164
- * apply-pathspec-<change>.txt 精确文件名
165
- * prompt-inject-<change>.json 精确文件名(2026-09-18-preflight-slimming:注入分叉账本回收登记)
165
+ * state/ 全家族散文件 + 根目录同名兜底(runtime-paths.js runtimeStateFileNames 唯一清单:
166
+ * draft-ledger / watcher 三件套 / test 台账 / ceremony 档位 / 冲突标记 / marker 等,
167
+ * 含 apply-pathspec-<change>.txt、prompt-inject-<change>.json 两个历史登记家族)
166
168
  * execute-runs/<runId>/ 仅当 `change` 戳全等(无戳旧 run 留下,不按覆盖度启发式删)
167
169
  * stage-reviews/<dir>/ 仅当 review.json reviewedFiles 首段 === changeName
168
170
  * verify-runs/<ts>/ 仅当目录内 JSON 的 change 字段集合 size=1 且等于本变更
@@ -193,33 +195,26 @@ export function pruneArchivedChangeRuntime(runtimeRoot, changeName) {
193
195
  } catch { /* 单份失败不连坐,下次归档/删变更再收 */ }
194
196
  }
195
197
 
198
+ // 2026-10-18 布局分层:per-change 散文件全部落 state/,回收名单从三家族扩到全家族
199
+ // (runtime-paths.js STATE_FAMILIES 唯一清单源,禁 glob 的精确名形态不变)。
200
+ // 每个精确名试双路径:state/(新布局)+ 根目录(未迁移旧仓/混版本迟到文件兜底——
201
+ // 迁移幂等收敛后根路径命中恒为零)。friction-tally 删前残余滚台账语义保留(见函数头)。
196
202
  try {
197
- const pathspec = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
198
- if (existsSync(pathspec)) gone(pathspec)
199
- } catch {}
200
-
201
- // prompt-inject 账本(2026-09-18-preflight-slimming Phase 2 / D-002,Grill 评审 fail②):
202
- // .runtime/prompt-inject-<change>.json 是 change 级派生文件(阶段感知注入分叉的账本),
203
- // 变更归档/删除后无读者——不登记回收则按变更数累积孤儿。精确文件名,对齐上方
204
- // apply-pathspec / 下方 friction-tally 同款形态(禁 glob/后缀匹配)。
205
- try {
206
- const ledger = join(runtimeRoot, `prompt-inject-${changeName}.json`)
207
- if (existsSync(ledger)) gone(ledger)
208
- } catch {}
209
-
210
- // friction tally(friction-signal-hint FR-05):摩擦计数随变更终态一并回收——真实变更的
211
- // friction-tally-<change>.json 落 runtimeRoot,变更归档/删除后无读者,残留即孤儿文件。
212
- // 删前残余先滚台账(tax-governance FR-02 兜底滚动点,见函数头注释)。
213
- try {
214
- const fp = join(runtimeRoot, `friction-tally-${changeName}.json`)
215
- if (existsSync(fp)) {
216
- const merged = mergeFrictionEntrySync(runtimeRoot, {
217
- change: changeName,
218
- counts: residualFrictionCounts(fp),
219
- archivedAt: new Date().toISOString(),
220
- })
221
- ledgerAppend = !!(merged && merged.ok && !merged.skipped)
222
- gone(fp)
203
+ for (const name of runtimeStateFileNames(changeName)) {
204
+ for (const p of [runtimeStatePath(runtimeRoot, name), join(runtimeRoot, name)]) {
205
+ try {
206
+ if (!existsSync(p)) continue
207
+ if (name === `friction-tally-${changeName}.json`) {
208
+ const merged = mergeFrictionEntrySync(runtimeRoot, {
209
+ change: changeName,
210
+ counts: residualFrictionCounts(p),
211
+ archivedAt: new Date().toISOString(),
212
+ })
213
+ ledgerAppend = !!(merged && merged.ok && !merged.skipped)
214
+ }
215
+ gone(p)
216
+ } catch { /* 单路径失败不连坐 */ }
217
+ }
223
218
  }
224
219
  } catch {}
225
220
 
@@ -320,15 +315,22 @@ export async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, p
320
315
  // 手工短名变更(如 login)时误删自动日期前缀变更(2026-08-01-login 天然以 '-login' 结尾)
321
316
  // 的 marker。stage 集合与写入侧 stageReviewMarkerPath 的调用方一致。
322
317
  const REVIEW_STAGES = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
323
- const markers = readdirSync(runtimeRoot).filter((f) =>
324
- f === `current-execute-run-id-${archiveChangeName}` ||
325
- REVIEW_STAGES.some(st => f === `current-stage-review-run-id-${st}-${archiveChangeName}`),
326
- )
327
- for (const m of markers) {
328
- try { unlinkSync(join(runtimeRoot, m)) } catch {}
318
+ // 2026-10-18 布局分层后 marker 落 state/;根目录扫描保留为旧布局兜底(迁移幂等收敛)
319
+ const markerDirs = [join(runtimeRoot, 'state'), runtimeRoot]
320
+ let markerCleaned = 0
321
+ for (const markerDir of markerDirs) {
322
+ let names = []
323
+ try { names = readdirSync(markerDir) } catch { continue }
324
+ const markers = names.filter((f) =>
325
+ f === `current-execute-run-id-${archiveChangeName}` ||
326
+ REVIEW_STAGES.some(st => f === `current-stage-review-run-id-${st}-${archiveChangeName}`),
327
+ )
328
+ for (const m of markers) {
329
+ try { unlinkSync(join(markerDir, m)); markerCleaned++ } catch {}
330
+ }
329
331
  }
330
- if (markers.length > 0) {
331
- console.log(`🧹 归档清理 ${markers.length} 个 runId marker(execute/stage-review)`)
332
+ if (markerCleaned > 0) {
333
+ console.log(`🧹 归档清理 ${markerCleaned} 个 runId marker(execute/stage-review)`)
332
334
  }
333
335
  // 取证回收必须在下方 worktree 块的 `if (!meta) return` 之前:无 meta 早退是常态
334
336
  // (apply 已 cleanup),漏接就会让 execute-runs/verify-runs 继续按变更数累积。
@@ -2803,7 +2805,8 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
2803
2805
  try {
2804
2806
  const runtimeDir = join(cwd, '.sillyspec', '.runtime');
2805
2807
  mkdirSync(runtimeDir, { recursive: true });
2806
- writeFileSync(join(runtimeDir, `execute-cleanup-${changeName}.json`), JSON.stringify({
2808
+ try { mkdirSync(join(runtimeDir, 'state'), { recursive: true }); } catch { /* 回执 fail-open */ }
2809
+ writeFileSync(runtimeStatePath(runtimeDir, `execute-cleanup-${changeName}.json`), JSON.stringify({
2807
2810
  change: changeName, cleanedAt: new Date().toISOString(),
2808
2811
  reason: 'execute-completed-no-unapplied-changes',
2809
2812
  }, null, 2) + '\n', 'utf8');
@@ -17,12 +17,13 @@
17
17
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
18
18
  */
19
19
  import { join, isAbsolute, resolve } from 'node:path'
20
+ import { runtimeStatePath } from '../runtime-paths.js'
20
21
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
21
22
  import { writeAtomicSync } from '../fs-atomic.js'
22
23
  import { gitQuiet } from '../git-helper.js'
23
24
  import { writeCloseTraceArtifacts, projectTraceFaceRows } from '../flow-parity.js'
24
25
  import { withFileLock } from '../quicklog.js'
25
- import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer, isRetiredAutoBrainstormTable, printRetiredAutoBrainstormRefusal } from './shared.js'
26
+ import { triggerSync, triggerSyncTerminalStrict, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer, isRetiredAutoBrainstormTable, printRetiredAutoBrainstormRefusal } from './shared.js'
26
27
  import { isExplicitReviewWrite, collectWorktreeChangedFiles } from '../task-review.js'
27
28
 
28
29
  // 退役自动道守门(2026-10-14-brainstorm-auto-retire):brainstorm-auto 4 步表已随自动道删除。
@@ -828,7 +829,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
828
829
 
829
830
  // gate 全过:persist completed(task-01 移后)。此处到 _write 之间若崩,DB 仍 in-progress(内存已 completed 但未落盘),下次进 CLI 读 DB 即 in-progress,不产生"假 completed"。
830
831
  pm._write(cwd, progress, changeName)
831
- triggerSync(cwd, changeName, platformOpts, { completion: true })
832
+ // 终态强校验(2026-10-17-archive-half-state-location-stuck-active):状态机驱动——仅
833
+ // DB status 已达 archived/deleted(archive 阶段完成即此态)走内联强校验,其余阶段
834
+ // 原样委托 triggerSync(后台化不变,非终态零行为变化)。见 run/shared.js 契约注释。
835
+ await triggerSyncTerminalStrict(cwd, changeName, platformOpts, { completion: true })
832
836
 
833
837
  const total = steps.length
834
838
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
@@ -843,7 +847,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
843
847
  let stageLedger = null
844
848
  let crossStage = false
845
849
  try {
846
- const ledgerPath = join(specBase, '.runtime', `stage-session-ledger-${changeName}.json`)
850
+ const ledgerPath = runtimeStatePath(join(specBase, '.runtime'), `stage-session-ledger-${changeName}.json`)
847
851
  try { stageLedger = JSON.parse(readFileSync(ledgerPath, 'utf8')) } catch { /* 首阶段 */ }
848
852
  const r = updateStageSessionLedger(stageLedger, process.env.SILLYSPEC_SESSION_ID, stageName)
849
853
  stageLedger = r.ledger
@@ -1018,7 +1022,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
1018
1022
  if (stageName === 'execute' && changeName && defSteps && defSteps[currentIdx] && defSteps[nextPendingIdx] &&
1019
1023
  /Wave \d+ 执行/.test(defSteps[currentIdx].name || '') && /Wave \d+ 执行/.test(defSteps[nextPendingIdx].name || '')) {
1020
1024
  try {
1021
- const ledgerPath = join(specBase, '.runtime', `wave-session-ledger-${changeName}.json`)
1025
+ const ledgerPath = runtimeStatePath(join(specBase, '.runtime'), `wave-session-ledger-${changeName}.json`)
1022
1026
  let ledger = null
1023
1027
  try { ledger = JSON.parse(readFileSync(ledgerPath, 'utf8')) } catch { /* 首 Wave */ }
1024
1028
  const { ledger: nextLedger, crossWaveAdvisory } = updateWaveSessionLedger(ledger, process.env.SILLYSPEC_SESSION_ID, defSteps[currentIdx].name)
@@ -1141,7 +1145,7 @@ async function printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts,
1141
1145
  snapshot = JSON.parse(readFileSync(join(specBase, 'changes', changeName, 'scope-audit.json'), 'utf8'))
1142
1146
  } catch {
1143
1147
  try {
1144
- snapshot = JSON.parse(readFileSync(join(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
1148
+ snapshot = JSON.parse(readFileSync(runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
1145
1149
  } catch { /* 快照缺失/损坏 → 跳过对比只打当前 totals */ }
1146
1150
  }
1147
1151
  }
@@ -1559,7 +1563,7 @@ export async function autoTickTasksFromCommitSubjects({ changeName, cwd, specBas
1559
1563
  try {
1560
1564
  const _tasksMdPath = join(specBase, 'changes', changeName, 'tasks.md')
1561
1565
  if (!existsSync(_tasksMdPath)) return { ticked: 0, skippedNoAnchor: 0 }
1562
- const _runIdFile = join(resolveRuntimeRoot(platformOpts, specBase), `current-execute-run-id-${changeName}`)
1566
+ const _runIdFile = runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), `current-execute-run-id-${changeName}`)
1563
1567
  let _tickLog = ''
1564
1568
  try {
1565
1569
  if (existsSync(_runIdFile)) {
@@ -1640,7 +1644,7 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1640
1644
  // plan.md Wave 引用行无 checkbox,无处可勾)。tasks.md 存在性作前置。
1641
1645
  const tasksMdPath = join(changeDir, 'tasks.md')
1642
1646
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBaseLc)
1643
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1647
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
1644
1648
  if (!existsSync(tasksMdPath) || !existsSync(runIdFile)) {
1645
1649
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
1646
1650
  }
@@ -1798,7 +1802,7 @@ export async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd,
1798
1802
  // 平台模式 runtimeRoot / worktree 漂移 specDriftAnchor 不透传时 marker 恒读不到
1799
1803
  // → 每个 task 进 blockedTasks → 批量完成静默失效
1800
1804
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
1801
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1805
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
1802
1806
  // ── 缺席草稿自动补齐(坑 task-review-retired-archive-mismatch,2026-10-15 归档门口实证)──
1803
1807
  // Task Review 层退役(2026-09-26)后 execute 批量完成不再产 per-task review.json,但本函数
1804
1808
  // 下方复核循环与 archive step2 完成度判定(真相源=review.json verdict)仍以它在场为前提——
@@ -16,6 +16,7 @@
16
16
  * 异常面:全部 fail-open 吞掉(账本/回收任一异常零阻断,退现状残留)。
17
17
  */
18
18
  import { execFileSync } from 'node:child_process'
19
+ import { runtimeStatePath } from '../runtime-paths.js'
19
20
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
20
21
  import { basename, dirname, join, resolve, sep } from 'node:path'
21
22
  import { tmpdir } from 'node:os'
@@ -28,7 +29,7 @@ const STALE_HOURS_DEFAULT = 24
28
29
 
29
30
  /** 账本文件路径(runtime 域:.sillyspec/.runtime/active-gate-snapshots.json) */
30
31
  export function gateSnapshotLedgerPath(runtimeRoot) {
31
- return join(runtimeRoot, LEDGER_FILE)
32
+ return runtimeStatePath(runtimeRoot, LEDGER_FILE)
32
33
  }
33
34
 
34
35
  /** 读账本(损坏/缺失/非数组 → 空数组,fail-open) */
@@ -494,7 +494,12 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
494
494
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
495
495
  * @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
496
496
  */
497
- export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null, runtimeRoot = null }) {
497
+ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null, runtimeRoot = null, failRef = null }) {
498
+ // failRef 可选出参(坑 snapshot-fallback-reason-blind,2026-10-16 下游实证):所有失败分支
499
+ // 把结构化原因写进 failRef.reason——调用方(verify --done 实测门 / noAI 质量扫描)消费它
500
+ // 替换硬编码笼统文案,降级原因可直读(pnpm 布局拒建 ≠ 文件集为空 ≠ junction 失败)。
501
+ // 返回契约不变(失败恒 null);不传 failRef 行为与既有逐字节一致。
502
+ const setFail = (reason) => { if (failRef && typeof failRef === 'object') failRef.reason = reason }
498
503
  let snapshotRoot = null
499
504
  try {
500
505
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
@@ -519,6 +524,7 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
519
524
  // 与其让沙箱报无关错误逼 advisory,不如布局命中即作废快照回退主仓(宁可主仓口径)。
520
525
  const layoutHit = detectSymlinkStoreLayout(cwd)
521
526
  if (layoutHit) {
527
+ setFail(`symlink-store 布局拒建(${layoutHit}):node_modules 符号链接 store 经 junction 跨根失效,实测必假败——设计性回退主仓实测`)
522
528
  console.warn(`⚠️ 门禁快照对 ${layoutHit} 布局不可靠(node_modules 符号链接 store 经 junction 跨根失效,实测必假败)——跳过快照回退主仓实测;主仓失败再做污染归属鉴定`)
523
529
  return null
524
530
  }
@@ -625,6 +631,7 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
625
631
  // 回退主仓现行为(主仓口径可能被并行脏文件污染,但环境真实;两害取轻 + 回退原因可见)。
626
632
  const envMissing = envDirsLinked(cwd, snapshotRoot)
627
633
  if (envMissing.length > 0) {
634
+ setFail(`环境目录链接不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测`)
628
635
  console.warn(`⚠️ 门禁快照环境不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测(宁可主仓口径不可假沙箱硬挂;回退后失败先做污染归属鉴定)`)
629
636
  // 与正常 cleanup/失败 catch 同一清理体:双清确认才销账(该早退曾残留旧式 --quiet
630
637
  // 清理与无重试 rmSync——独立审查 P1-1 根因未除尽的漏网面)
@@ -723,7 +730,10 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
723
730
  return { snapshotRoot, cleanup, overlaid, pinnedCacheLinks }
724
731
  } catch (e) {
725
732
  // 基建失败(worktree add 拒绝/磁盘满/…):尽力清理后回退主仓;销账同走双清确认
726
- // (D-004@v2:清不掉的条目保留待回收——它不是悬空条目,是待回收记录)
733
+ // (D-004@v2:清不掉的条目保留待回收——它不是悬空条目,是待回收记录)。
734
+ // 异常细节不再静默(坑 snapshot-fallback-reason-blind):failRef 带原因 + warn 落日志。
735
+ setFail(`快照基建异常: ${e && e.message ? e.message : e}`)
736
+ try { console.warn(`⚠️ 门禁快照基建失败(回退主仓实测): ${e && e.message ? e.message : e}`) } catch { /* 输出失败不影响回退 */ }
727
737
  if (snapshotRoot) {
728
738
  try {
729
739
  const { dirRemoved, worktreeCleaned } = cleanupSnapshot({ snapshotRoot, cwd })
@@ -870,7 +880,8 @@ export function printSnapshotFailureHint(snapInfo, { offEnv = 'SILLYSPEC_VERIFY_
870
880
  * @param {{ cwd: string, changeName: string, specBase: string, platformOpts?: object }} opts
871
881
  * @returns {Promise<{snapshotRoot: string, cleanup: () => void, overlaid: number, changeFileCount: number}|null>}
872
882
  */
873
- export async function createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts = {} }) {
883
+ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts = {}, failRef = null }) {
884
+ const setFail = (reason) => { if (failRef && typeof failRef === 'object') failRef.reason = reason }
874
885
  try {
875
886
  const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
876
887
  const { resolveRuntimeRoot } = await import('./shared.js')
@@ -879,10 +890,19 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
879
890
  let verifyRuntimeRoot = null
880
891
  try { verifyRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase) } catch { verifyRuntimeRoot = null }
881
892
  const { splitOwnVsForeignDiffFiles } = await import('../foreign-declared.js')
882
- let changeFiles = resolveVerifyChangedFiles(cwd, changeName, null, {
893
+ // 变更文件集为空三分成因区分(坑 snapshot-fallback-reason-blind):null=git 不可用/解析
894
+ // 失败;[]=HEAD 无 diff;他者声明过滤清空在下方单独计——三者处置同为回退主仓,但排查
895
+ // 出路完全不同,笼统「变更文件集为空」曾把 pnpm 布局拒建也误标成它。
896
+ const rawChangeFiles = resolveVerifyChangedFiles(cwd, changeName, null, {
883
897
  specBase, includeWorkingTree: true,
884
- }) || []
885
- if (changeFiles.length === 0) return null // 无变更文件集 → 无从定向,回退主仓
898
+ })
899
+ let changeFiles = rawChangeFiles || []
900
+ if (changeFiles.length === 0) {
901
+ setFail(rawChangeFiles === null
902
+ ? '变更文件集为空(git 不可用/解析失败——非 git 仓或 git 异常,无从定向 overlay)'
903
+ : '变更文件集为空(HEAD 无 diff——改动可能已全部提交,无可隔离面)')
904
+ return null // 无变更文件集 → 无从定向,回退主仓
905
+ }
886
906
  // 他者声明归属过滤(坑 verify-reconcile-foreign-wip 同款):in-place 模式 working-tree
887
907
  // 并入后主仓全部在途文件进场——他者活跃变更显式声明的文件(quick --files / 他者 design
888
908
  // 清单)剔除,只留本变更相关面(无主文件保留,fail-closed 口径与 probe6 一致)。
@@ -891,7 +911,10 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
891
911
  if (foreign.length > 0) {
892
912
  const foreignSet = new Set(foreign.map(x => x.file))
893
913
  changeFiles = changeFiles.filter(f => !foreignSet.has(f))
894
- if (changeFiles.length === 0) return null
914
+ if (changeFiles.length === 0) {
915
+ setFail(`变更文件集为空(${foreign.length} 个 diff 文件全部被他者声明过滤——并行会话声明面覆盖了本变更全部在途文件)`)
916
+ return null
917
+ }
895
918
  }
896
919
  } catch { /* 过滤失败退全量(fail-closed:宁可多 overlay 不漏本变更文件) */ }
897
920
 
@@ -920,8 +943,12 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
920
943
  }
921
944
  } catch { /* meta 读取失败 → 主仓 cwd 源(files 已含本变更 working-tree 改动) */ }
922
945
 
923
- const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase, runtimeRoot: verifyRuntimeRoot })
924
- if (!snap) return null
946
+ const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase, runtimeRoot: verifyRuntimeRoot, failRef })
947
+ if (!snap) {
948
+ // createGateSnapshot 已把具体原因写进同一 failRef;无 reason(旧形态兜底)补笼统口径
949
+ if (failRef && typeof failRef === 'object' && !failRef.reason) setFail('快照基建不可用(createGateSnapshot 返回 null,无更细原因)')
950
+ return null
951
+ }
925
952
 
926
953
  // 变更文档随快照:module-impact.md(test_strategy=evidence-auto 消费)/tasks.md 等
927
954
  // 在 .sillyspec(gitignore)不进 HEAD,从主仓 specBase 复制 changes/<name>/**
@@ -940,7 +967,26 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
940
967
  } catch { /* 文档复制失败不连坐快照(test_strategy 降级 module 集仍可跑) */ }
941
968
 
942
969
  return { ...snap, changeFileCount: changeFiles.length, sourceRoot }
943
- } catch {
944
- return null // 任一链路异常 → 回退主仓现行为
970
+ } catch (e) {
971
+ // 任一链路异常 → 回退主仓现行为。异常细节不再静默(坑 snapshot-fallback-reason-blind):
972
+ // failRef 带原因 + warn 落日志,排查不再靠 stdout 考古。
973
+ setFail(`快照链路异常: ${e && e.message ? e.message : e}`)
974
+ try { console.warn(`⚠️ 门禁快照链路异常(回退主仓实测): ${e && e.stack ? e.stack.split('\n')[0] : e}`) } catch { /* 输出失败不影响回退 */ }
975
+ return null
945
976
  }
946
977
  }
978
+
979
+ /**
980
+ * 快照口径观测字段(坑 snapshot-fallback-reason-blind,FR-04 落盘面):--done 实测门把
981
+ * used_snapshot 与降级原因经 runVerifyTestCheck 的 observability 通道带进 test-result.json
982
+ * extra(对齐 2026-10-09 复用观测先例)。快照在场时记录随快照销毁(无可降级事实);
983
+ * 回退主仓时记录恰好落在主仓 .runtime——降级证据存活在主仓,与用途一致。
984
+ * @param {boolean} used 快照是否真实在场
985
+ * @param {{ reason?: string }|null} failRef createVerifyGateSnapshot 的出参(可 null)
986
+ * @returns {{ used_snapshot: boolean, snapshot_fallback_reason?: string }}
987
+ */
988
+ export function snapshotObservability(used, failRef) {
989
+ if (used) return { used_snapshot: true }
990
+ const reason = failRef && failRef.reason ? String(failRef.reason) : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)'
991
+ return { used_snapshot: false, snapshot_fallback_reason: reason }
992
+ }