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
package/src/run/gates.js CHANGED
@@ -28,6 +28,7 @@
28
28
  * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
29
29
  */
30
30
  import { basename, join, relative, dirname } from 'node:path'
31
+ import { runtimeStatePath } from '../runtime-paths.js'
31
32
  import { createHash } from 'node:crypto'
32
33
  import { execFileSync } from 'node:child_process'
33
34
  import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -444,8 +445,8 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
444
445
  if (worktreeGone && changeName) {
445
446
  try {
446
447
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
447
- const legitimatelyEnded = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
448
- || existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
448
+ const legitimatelyEnded = existsSync(runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`))
449
+ || existsSync(runtimeStatePath(runtimeRoot, `execute-cleanup-${changeName}.json`))
449
450
  if (legitimatelyEnded) {
450
451
  console.log('ℹ️ worktree 已在 apply/execute 收尾时正当清理(凭据在 .runtime),deps 门放行')
451
452
  return true
@@ -549,7 +550,7 @@ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx,
549
550
 
550
551
  /** 档位状态文件路径(runtimeRoot 下 per-change;命名对齐 apply-pathspec-<change>.txt 先例) */
551
552
  function ceremonyTierFilePath(runtimeRoot, changeName) {
552
- return join(runtimeRoot, `ceremony-tier-${changeName}.json`)
553
+ return runtimeStatePath(runtimeRoot, `ceremony-tier-${changeName}.json`)
553
554
  }
554
555
 
555
556
  /**
@@ -1100,17 +1101,24 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1100
1101
  // 变更文档随快照(test_strategy 消费)。并行会话在途文件物理不进快照、不参与本门判定。
1101
1102
  // 快照基建失败 → 回退主仓现行为(失败路径沿用 renderVerifyTestAttribution 归因提示)。
1102
1103
  let verifyGateSnap = null
1104
+ let snapshotFailRef = null // createVerifyGateSnapshot 出参:结构化失败原因(FR-04,替代硬编码笼统文案)
1103
1105
  if (!process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF) {
1104
1106
  try {
1105
1107
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
1106
- verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
1108
+ snapshotFailRef = {}
1109
+ verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts, failRef: snapshotFailRef })
1107
1110
  if (verifyGateSnap) {
1108
1111
  console.log(`🧪 Verify 实测隔离快照(根:${verifyGateSnap.snapshotRoot}):HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
1109
1112
  } else {
1110
- // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账
1113
+ // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账;
1114
+ // FR-04(2026-10-16-fix-downstream-tool-defects):reason 消费 failRef 结构化原因
1115
+ // (pnpm 布局拒建 / 文件集为空三分成因 / junction 失败 / 基建异常详情),不再笼统
1111
1116
  try {
1112
1117
  const { reportGateSnapshotFallback } = await import('./gate-snapshot.js')
1113
- if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'verify --done 实测门', reason: 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)' })
1118
+ const fbReason = snapshotFailRef && snapshotFailRef.reason
1119
+ ? `createVerifyGateSnapshot 返回 null:${snapshotFailRef.reason}`
1120
+ : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)'
1121
+ if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'verify --done 实测门', reason: fbReason })
1114
1122
  } catch { /* 报告失败不改变回退行为(含测试 mock 模块缺导出的形态) */ }
1115
1123
  }
1116
1124
  } catch (e) {
@@ -1120,6 +1128,22 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1120
1128
  } catch { /* 报告失败不改变回退行为(含测试 mock 模块缺导出的形态) */ }
1121
1129
  }
1122
1130
  }
1131
+ // FR-04 降级事实落盘:used_snapshot 与失败原因随 test-result.json extra 持久化(快照在场
1132
+ // 时记录随快照销毁;回退主仓时恰好落在主仓 .runtime——降级证据存活面与用途一致)。
1133
+ // 评审 P3-2 整改:OFF 开关是主动关闸,不得记成「基建故障/文件集为空」的笼统回退原因。
1134
+ let snapObsFields
1135
+ if (process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF) {
1136
+ snapObsFields = { used_snapshot: false, snapshot_fallback_reason: 'SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF=1 主动关闸(快照未尝试,非基建故障)' }
1137
+ } else if (verifyGateSnap) {
1138
+ snapObsFields = { used_snapshot: true }
1139
+ } else {
1140
+ try {
1141
+ const { snapshotObservability } = await import('./gate-snapshot.js')
1142
+ snapObsFields = snapshotObservability(false, snapshotFailRef)
1143
+ } catch {
1144
+ snapObsFields = { used_snapshot: false, snapshot_fallback_reason: snapshotFailRef && snapshotFailRef.reason ? String(snapshotFailRef.reason) : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)' }
1145
+ }
1146
+ }
1123
1147
  const gateCwd = verifyGateSnap ? verifyGateSnap.snapshotRoot : cwd
1124
1148
  const gateSpecBase = verifyGateSnap ? join(verifyGateSnap.snapshotRoot, '.sillyspec') : specBase
1125
1149
  let testCheck
@@ -1220,7 +1244,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1220
1244
  `质量扫描: ${reusableScan ? '命中(上层未消费)' : '指纹失配/无记录'}`,
1221
1245
  `green-cache: ${greenHit ? '命中(上层未消费)' : '指纹失配/过期/无记录'}`,
1222
1246
  ].join(';')
1223
- testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName, ctx, observability: { fingerprint: _obsFingerprint, reuse_decision: { layer: 'real-run', hit: false, reason: _missReason } }, ...(Array.isArray(_restrict) && _restrict.length > 0 ? { restrictFiles: _restrict } : {}) })
1247
+ testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName, ctx, observability: { fingerprint: _obsFingerprint, reuse_decision: { layer: 'real-run', hit: false, reason: _missReason }, ...snapObsFields }, ...(Array.isArray(_restrict) && _restrict.length > 0 ? { restrictFiles: _restrict } : {}) })
1224
1248
  printVerifyTestCheck(testCheck)
1225
1249
  // 自动绑定补全(full-autopilot-parity,R21 thin 迁移):测试已跑、test-result.json 已落盘,
1226
1250
  // verify --done 收口时从测试结果自动补全 requirements.md 的空绑定槽——与 thin 同逻辑(逐行扫描)。
package/src/run/prompt.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * - loadModuleContextIndex/buildModuleContextInjection 内 require('fs'/'path') 改顶部静态 import
20
20
  */
21
21
  import { basename, dirname, join } from 'node:path'
22
+ import { runtimeStatePath } from '../runtime-paths.js'
22
23
  import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync, rmSync } from 'node:fs'
23
24
  import { createHash } from 'node:crypto'
24
25
  import jsYaml from 'js-yaml'
@@ -868,7 +869,7 @@ export async function renderPreflightFailures({ stageName, stepName, cwd, specBa
868
869
  export function shouldInjectFullContext({ changeName, stageName, runtimeRoot } = {}) {
869
870
  try {
870
871
  if (!runtimeRoot || !changeName || !stageName) return { full: true }
871
- const ledgerPath = join(runtimeRoot, `prompt-inject-${changeName}.json`)
872
+ const ledgerPath = runtimeStatePath(runtimeRoot, `prompt-inject-${changeName}.json`)
872
873
  if (!existsSync(ledgerPath)) return { full: true }
873
874
  const ledger = JSON.parse(readFileSync(ledgerPath, 'utf8'))
874
875
  const rec = (ledger && ledger.stages && typeof ledger.stages === 'object') ? ledger.stages[stageName] : null
@@ -906,7 +907,7 @@ function renderPromptInjectSummaryLine({ firstStep, digest, specBase, projectNam
906
907
  * @param {{ runtimeRoot: string, changeName: string, stageName: string, firstStep: number, digest: string }} args
907
908
  */
908
909
  async function recordPromptInjectLedger({ runtimeRoot, changeName, stageName, firstStep, digest }) {
909
- const ledgerPath = join(runtimeRoot, `prompt-inject-${changeName}.json`)
910
+ const ledgerPath = runtimeStatePath(runtimeRoot, `prompt-inject-${changeName}.json`)
910
911
  await withFileLock(ledgerPath + '.lock', () => {
911
912
  let ledger = null
912
913
  try { ledger = JSON.parse(readFileSync(ledgerPath, 'utf8')) } catch { ledger = null }
@@ -1428,7 +1429,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1428
1429
  // 注入结果同步落 runtime JSON(与既有 KNOWLEDGE_HIT_REPORT 落盘口径一致)
1429
1430
  const decRuntimeDir = join(decSpecBase, '.runtime')
1430
1431
  mkdirSync(decRuntimeDir, { recursive: true })
1431
- writeAtomicSync(join(decRuntimeDir, 'decision-hits.json'), JSON.stringify({ matched: decResult.matched, decisionHits: decResult.decisionHits || [] }, null, 2) + '\n')
1432
+ writeAtomicSync(runtimeStatePath(decRuntimeDir, 'decision-hits.json'), JSON.stringify({ matched: decResult.matched, decisionHits: decResult.decisionHits || [] }, null, 2) + '\n')
1432
1433
  } catch (e) {
1433
1434
  substitute(/\{DECISION_HITS\}/g, `[decisions] 决策命中检测异常(${e.message}),跳过`)
1434
1435
  }
@@ -1495,7 +1496,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1495
1496
  )
1496
1497
  // 写入 runtime JSON(既有机制照旧,新旧遥测共存)
1497
1498
  mkdirSync(runtimeDir, { recursive: true })
1498
- writeAtomicSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeInjection.json, null, 2) + '\n')
1499
+ writeAtomicSync(runtimeStatePath(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeInjection.json, null, 2) + '\n')
1499
1500
  } catch (e) {
1500
1501
  substitute(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
1501
1502
  }
@@ -1552,7 +1553,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1552
1553
  let runId = ''
1553
1554
  const execSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1554
1555
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
1555
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1556
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
1556
1557
  try {
1557
1558
  if (existsSync(runIdFile)) {
1558
1559
  const c = readFileSync(runIdFile, 'utf8').trim()
@@ -9,6 +9,7 @@
9
9
  * - executeScanPostcheck 原冗余动态 import('fs'/'path'/'child_process') 删除,改顶部静态(execSync 实际未直接用,纯遗留)
10
10
  */
11
11
  import { join, basename, extname } from 'node:path'
12
+ import { runtimeStatePath } from '../runtime-paths.js'
12
13
  import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'
13
14
  import { safeGit, resolveRuntimeRoot } from './shared.js'
14
15
  import { SCAN_REQUIRED_DOCS } from '../constants.js'
@@ -332,7 +333,7 @@ export async function executeScanResumeCheck(cwd, platformOpts) {
332
333
  // .runtime/last-delta.json(归档/delta 补跑写入的最近变更快照),14 天窗口内提示本轮
333
334
  // scan 优先核对其涉及模块——advisory 不阻断不改变步骤结构;缺失/过期/解析失败静默。
334
335
  try {
335
- const sidecarPath = join(resolveRuntimeRoot(platformOpts, specBase), 'last-delta.json')
336
+ const sidecarPath = runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), 'last-delta.json')
336
337
  if (existsSync(sidecarPath)) {
337
338
  const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'))
338
339
  const ageMs = Date.now() - Date.parse(sidecar.updatedAt || '')
package/src/run/shared.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
12
  */
13
13
  import { basename, join, resolve, dirname, sep } from 'node:path'
14
+ import { runtimeTempPath } from '../runtime-paths.js'
14
15
  import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs'
15
16
  import { fileURLToPath } from 'node:url'
16
17
  import os from 'node:os'
@@ -644,7 +645,7 @@ export function warnSelfRefPointerOnce(cwd, specRoot, message) {
644
645
  try {
645
646
  const dir = join(cwd, '.sillyspec', '.runtime')
646
647
  mkdirSync(dir, { recursive: true })
647
- const markerPath = join(dir, SELFREF_WARN_MARKER)
648
+ const markerPath = runtimeTempPath(dir, SELFREF_WARN_MARKER)
648
649
  const now = Date.now()
649
650
  try {
650
651
  const m = JSON.parse(readFileSync(markerPath, 'utf8'))
@@ -943,7 +944,9 @@ async function raceWithAbort(op, timeoutMs = resolveSyncTotalTimeoutMs()) {
943
944
  const controller = new AbortController()
944
945
  let timer
945
946
  try {
946
- await Promise.race([
947
+ // 透传 op 结果(2026-10-17 终态强校验消费):历史调用方(triggerSync 等)忽略返回值
948
+ // 不受影响;超时分支 resolve(null)——消费方以 null 判「预算熔断」。
949
+ return await Promise.race([
947
950
  op({ signal: controller.signal }),
948
951
  new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(null) }, timeoutMs) }),
949
952
  ])
@@ -1039,6 +1042,86 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
1039
1042
  }
1040
1043
  }
1041
1044
 
1045
+ /**
1046
+ * 终态强校验同步(2026-10-17-archive-half-state-location-stuck-active)。
1047
+ *
1048
+ * 背景(坑档案 docs/sillyspec/archive-half-state-location-stuck-active.md,生产实证):
1049
+ * triggerSync 全链 best-effort + 后台化,重试是事件驱动(「下一条命令」)——而归档/删除
1050
+ * 是变更生命周期最后一个动作,之后大概率没有下一条命令。平台慢窗口内 DB 通道
1051
+ * (status=archived)成功而文件树通道(location)失败 → 半态:平台「进行中」列表
1052
+ * 幽灵滞留已归档变更,无人再碰则无限期。
1053
+ *
1054
+ * 与 triggerSync 的三点差异:
1055
+ * 1. **终态判定驱动**:仅当 DB 该变更 status ∈ (archived, deleted) 才走强校验;
1056
+ * 非终态(普通阶段完成的 completion 同步等)原样委托 triggerSync(后台化不变,
1057
+ * 零行为变化)。归档收尾允许阻塞换一致性——终态动作没有「下次再推」的假设。
1058
+ * 2. **内联有界**:绕过后台化(结果不再藏进 bg 日志文件),sync() 直跑 + raceWithAbort
1059
+ * 预算 = max(env SILLYSPEC_SYNC_TIMEOUT_MS 可调值, 45s 后台轮下限同款);opts.timeoutMs
1060
+ * 显式传入时直接生效(测试注入短窗)。
1061
+ * 3. **失败显性化**:sync() 返回 treeOk!==true(syncSpecTree ok 契约,fail-closed——
1062
+ * 无 ok 字段视同未确认)→ 🚨 告警 + 自救命令 + process.exitCode = 1(对齐
1063
+ * _submitApproval 的「终态网络失败必须可见」先例)。未决冲突(result.conflict)
1064
+ * 不在此列——冲突面已有自己的 resolve 处置横幅,重复告警是噪音。
1065
+ *
1066
+ * @returns {{confirmed: boolean, delegated?: boolean, skipped?: string, result?: object}}
1067
+ */
1068
+ export async function triggerSyncTerminalStrict(cwd, changeName, platformOpts = {}, opts = {}) {
1069
+ // quick 会话无实体变更目录(无归档/删除终态)——沿用既有 best-effort 语义
1070
+ if (changeName && QUICK_SID_RE.test(changeName)) {
1071
+ await triggerSync(cwd, changeName, platformOpts, opts)
1072
+ return { confirmed: false, delegated: true }
1073
+ }
1074
+ const syncMod = await import('../sync.js')
1075
+ // 未连接平台:静默 no-op(本地合法状态;强校验只在平台模式下有意义)
1076
+ if (!syncMod.peekPlatformConnected(cwd, platformOpts)) {
1077
+ return { confirmed: false, skipped: 'not-connected' }
1078
+ }
1079
+ // 终态判定(fail-open:DB 读不到/解析失败 → 非终态委托既有语义,不因校验层故障阻断主链)
1080
+ // opts.changeStatus / opts.syncImpl:测试注入缝(对齐 bakeArchiveTimeline writeImpl 惯例)
1081
+ let dbStatus = opts.changeStatus ?? null
1082
+ if (dbStatus === null) {
1083
+ try {
1084
+ const { resolvePlatformSpecDir, ProgressManager } = await import('../progress.js')
1085
+ const specDir = resolvePlatformSpecDir(cwd)
1086
+ if (specDir && existsSync(join(specDir, '.runtime', 'sillyspec.db'))) {
1087
+ const pm = new ProgressManager({ specDir })
1088
+ const row = pm._ensureDB(cwd).getDb().prepare(
1089
+ 'SELECT status FROM changes WHERE name = ?'
1090
+ ).get(changeName)
1091
+ dbStatus = row ? row.status : null
1092
+ }
1093
+ } catch { /* 判定失败按非终态处理 */ }
1094
+ }
1095
+ if (dbStatus !== 'archived' && dbStatus !== 'deleted') {
1096
+ await triggerSync(cwd, changeName, platformOpts, opts)
1097
+ return { confirmed: false, delegated: true }
1098
+ }
1099
+ // 终态:内联有界同步(绕过后台化——结果必须当场可判)
1100
+ const timeoutMs = opts.timeoutMs ?? Math.max(resolveSyncTotalTimeoutMs(), 45_000)
1101
+ let result = null
1102
+ try {
1103
+ // raceWithAbort 向 op 传入 { signal } 形状对象(非裸 AbortSignal,与既有 triggerSync
1104
+ // 内联用法 `sync(name, cwd, sig)` 同一形状)——默认路径原样透传,禁止二次包装
1105
+ //(dogfood 实证:包装成 { signal: sig } 会把套娃对象传进 AbortSignal.any → TypeError,
1106
+ // 强校验当场以 🚨 告警兜住——安全网本身工作正常,此处修被网住的 bug)。
1107
+ const runSync = opts.syncImpl || ((syncOpts) => syncMod.sync(changeName, cwd, syncOpts))
1108
+ result = await raceWithAbort(runSync, timeoutMs)
1109
+ } catch (e) {
1110
+ result = { synced: 0, errors: [(e && e.message) || String(e)] }
1111
+ }
1112
+ if (result === null) result = { synced: 0, errors: [`同步总预算 ${timeoutMs}ms 熔断`] }
1113
+ // 未决冲突:已有 resolve 处置横幅(syncSpecTree 冲突分支自带),不重复告警
1114
+ if (result.conflict === true) return { confirmed: false, skipped: 'conflict', result }
1115
+ const confirmed = result.treeOk === true
1116
+ if (!confirmed) {
1117
+ const detail = ((result.errors && result.errors[0]) || '文件树通道未确认收敛(见上方 [spec-sync]/[sync] 行)').slice(0, 200)
1118
+ console.error(`🚨 [终态同步] 本地${dbStatus === 'deleted' ? '删除' : '归档'}已完成,但平台文件树推送未确认:${detail}`)
1119
+ console.error(` 平台变更中心可能滞留「进行中」半态。自救(幂等):sillyspec platform sync --change ${changeName}`)
1120
+ if (opts.exitOnUnconfirmed !== false) process.exitCode = 1
1121
+ }
1122
+ return { confirmed, result }
1123
+ }
1124
+
1042
1125
  /**
1043
1126
  * 触发步骤开始上报(X3 / design §8.2,变更 2026-08-29-change-delete-closure-and-spec-pull task-13)。
1044
1127
  *
@@ -1087,7 +1170,8 @@ const AUTO_PULL_THROTTLE_MS = 10_000
1087
1170
  const AUTO_PULL_TIMEOUT_MS = 2_000
1088
1171
 
1089
1172
  function _autoPullThrottlePath(cwd) {
1090
- return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
1173
+ // 2026-10-18 布局分层:节流戳是可再生产物归 temp/;路径助手自带 mkdir-p
1174
+ return runtimeTempPath(join(cwd, '.sillyspec', '.runtime'), 'auto-pull-throttle.json')
1091
1175
  }
1092
1176
 
1093
1177
  function _autoPullRecently(cwd) {
@@ -1546,15 +1630,35 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1546
1630
  // gen:types 重写行尾制造假 M):git diff --ignore-cr-at-eol HEAD 的变更名单是「真实内容变更」
1547
1631
  // 集——tracked 修改出现在 porcelain 但不在此集 = 纯行尾重写。单次对照调用,剔出 changedFiles
1548
1632
  // 归 eolOnlyFiles 软警告(.gitattributes 指引),不再计入文件行/触发各门。
1633
+ // 探测必须走 --numstat:--name-only 不吃 --ignore-cr-at-eol(git 实测怪癖——git ≤2.43 仍把
1634
+ // 行尾-only 文件列进名单,Debian 12 git 2.39.5 / Ubuntu 24.04 git 2.43.0 实测;git ≥2.54 才
1635
+ // 听话),走 --name-only 会让本过滤在老 git 上静默失效、假 M 坑复活。同
1636
+ // verify-postcheck reconcileActualFiles 的 eolOnlyFiles 探测同口径(该处已按此结论实装)。
1549
1637
  // 注意:gitQuiet 不在本模块 import 面(safeGit 才是)——首版用 gitQuiet 触发
1550
1638
  // ReferenceError 被 catch 吞成 null,又被 new Set(null) 包装成空集 → 全部 tracked 修改
1551
1639
  // 被误判 EOL-only 剔除(audit-quick-completion 回归抓出)。改用 safeGit(已 import,
1552
1640
  // 返回 {value,error}),失败显式落 null 透传不过滤。
1641
+ // -z 记录文法(od 实测,git 2.39.5 与 2.54 同形):普通条目 `<A>\t<D>\t<path>\0`;
1642
+ // rename `<A>\t<D>\t\0<old>\0<new>\0`(计数后路径字段为空,旧/新名各占一个 NUL 字段)——
1643
+ // 按位置取新名。非 -z 的 `old => new` 文本形态与「文件名里字面含 ` => `」不可区分(实测
1644
+ // `sub/a => b.js` 真实内容变更会被误判 rename 而剔出 changedFiles——审计放宽),-z 消歧。
1645
+ // -z 同时不做引号/C 转义,路径原样出(与 porcelain 侧键更一致)。
1646
+ const numstatZPaths = (out) => {
1647
+ const toks = String(out).split('\0')
1648
+ const paths = []
1649
+ for (let i = 0; i < toks.length; i++) {
1650
+ const m = toks[i].match(/^(?:\d+|-)\t(?:\d+|-)\t(.*)$/s)
1651
+ if (!m) continue
1652
+ if (m[1] === '') { paths.push(String(toks[i + 2] || '')); i += 2 }
1653
+ else paths.push(m[1])
1654
+ }
1655
+ return paths.filter(Boolean)
1656
+ }
1553
1657
  const eolRawList = (() => {
1554
1658
  try {
1555
- const res = safeGit(cwd, ['diff', '--name-only', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1659
+ const res = safeGit(cwd, ['diff', '--numstat', '-z', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1556
1660
  if (!res || res.error) return null
1557
- return String(res.value || '').split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/'))
1661
+ return numstatZPaths(String(res.value || ''))
1558
1662
  } catch { return null }
1559
1663
  })()
1560
1664
  const eolRealChanged = eolRawList === null ? null : new Set(eolRawList)
package/src/run/stage.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * - 'child_process'(execSync)裸模块名不变
18
18
  */
19
19
  import { join, dirname } from 'node:path'
20
+ import { runtimeStatePath } from '../runtime-paths.js'
20
21
  import { existsSync, readdirSync, readFileSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'
21
22
  import { writeAtomicSync } from '../fs-atomic.js'
22
23
  import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown, predictProtectedQuickFiles, mergeQuickBoundaryFiles, readStageBurst, STAGE_BURST_STAGES, readStageWall, STAGE_WALL_STAGES } from './shared.js'
@@ -55,7 +56,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
55
56
  const curSession = process.env.SILLYSPEC_SESSION_ID || ''
56
57
  const wallStageData = progress.stages && progress.stages[stageName]
57
58
  const firstEntry = !wallStageData || !Array.isArray(wallStageData.steps) || wallStageData.steps.length === 0
58
- const wallLedgerPath = join(specBase, '.runtime', `stage-session-ledger-${changeName}.json`)
59
+ const wallLedgerPath = runtimeStatePath(join(specBase, '.runtime'), `stage-session-ledger-${changeName}.json`)
59
60
  if (curSession && firstEntry && existsSync(wallLedgerPath)) {
60
61
  const wallLedger = JSON.parse(readFileSync(wallLedgerPath, 'utf8'))
61
62
  const { isStageWallBlocked } = await import('./shared.js')
@@ -151,7 +152,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
151
152
  const { generateExecuteRunId, isValidExecuteRunId, claimExecuteRunId } = await import('../task-review.js')
152
153
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
153
154
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
154
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
155
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
155
156
  mkdirSync(runtimeRoot, { recursive: true })
156
157
  // 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
157
158
  try {
@@ -325,7 +326,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
325
326
  startedAt: new Date().toISOString(),
326
327
  forceRescan: quickOpts?.isForceRescan || false,
327
328
  }
328
- const guardFile = join(specBase, '.runtime', 'scan-guard.json')
329
+ const guardFile = runtimeStatePath(join(specBase, '.runtime'), 'scan-guard.json')
329
330
  mkdirSync(dirname(guardFile), { recursive: true })
330
331
  // 原子写:hook 进程(worktree-guard 的 readScanGuard)并发读,裸 truncate→write 的
331
332
  // 半截窗口会被 JSON.parse 吞成 null → 覆盖保护静默失效(fail-open)
@@ -417,7 +418,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
417
418
  // 进程内直调 runCommand 的入口(测试/编程调用)与 --change 指向不存在会话的形态。
418
419
  console.error(`❌ quick 通道已退役,无法启动 quick 会话(若带 --change 则该会话不存在或已清理)。`)
419
420
  console.error(` 新工作请走轻量变更(--input 引号内换行合法,可照抄):`)
420
- console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
421
+ console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景')
421
422
  console.error('')
422
423
  console.error(' 成功标准:')
423
424
  console.error(' - <可验证标准>"')
@@ -36,6 +36,7 @@
36
36
  * (meta.json 并发读先例同款约束)。
37
37
  */
38
38
  import { createHash } from 'crypto'
39
+ import { runtimeStatePath } from '../runtime-paths.js'
39
40
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
40
41
  import { join, relative, isAbsolute } from 'path'
41
42
  import { gitQuiet } from '../git-helper.js'
@@ -209,7 +210,7 @@ function ledgerKeyFor({ runtimeRoot, changeName, projectRoot, testRoot, specBase
209
210
 
210
211
  /** 账本路径(per-change 单文件) */
211
212
  export function testLedgerPath(runtimeRoot, changeName) {
212
- return join(runtimeRoot, `test-ledger-${changeName}.json`)
213
+ return runtimeStatePath(runtimeRoot, `test-ledger-${changeName}.json`)
213
214
  }
214
215
 
215
216
  /**
@@ -27,6 +27,7 @@
27
27
  * 刻意区分)。指纹已含 commands 段原文——配置 smoke 前后指纹自动变化,代码未变不重跑。
28
28
  */
29
29
  import { createHash } from 'node:crypto'
30
+ import { runtimeStatePath } from '../runtime-paths.js'
30
31
  import { execSync, spawnSync } from 'node:child_process'
31
32
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
32
33
  import { join, dirname } from 'node:path'
@@ -43,7 +44,7 @@ const RECORD_SCHEMA_VERSION = 1
43
44
 
44
45
  export function qualityScanRecordPath(specBase, changeName) {
45
46
  if (!changeName) return null
46
- return join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`)
47
+ return runtimeStatePath(join(specBase, '.runtime'), `verify-quality-scan-${changeName}.json`)
47
48
  }
48
49
 
49
50
  /**
@@ -365,7 +366,7 @@ export async function renderVerifyTestAttribution({ cwd, changeName, specBase, r
365
366
  // 第二判据(坑 derived-artifact-stale-baseline / verify-attr2-foreign-wip-misdirection):
366
367
  // 完整语义见 gates.js filterStaleBaselineOverlap 注释——两道过滤(必须 dirty + 剔除他者声明集)。
367
368
  try {
368
- const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
369
+ const pathspecFile = runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`)
369
370
  if (changeName && existsSync(pathspecFile)) {
370
371
  const ownFiles = new Set(readFileSync(pathspecFile, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean).map((f) => f.replace(/\\/g, '/')))
371
372
  if (ownFiles.size > 0) {
@@ -655,14 +656,19 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
655
656
  } else {
656
657
  try {
657
658
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
658
- snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
659
+ const snapshotFailRef = {} // FR-04:结构化失败原因(pnpm 布局拒建/文件集三分成因/junction/基建异常)
660
+ snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts, failRef: snapshotFailRef })
659
661
  if (snap) {
660
662
  console.log(`🧪 noAI 扫描隔离快照(根:${snap.snapshotRoot}):HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
661
663
  } else {
662
- // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账
664
+ // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账;
665
+ // FR-04(2026-10-16-fix-downstream-tool-defects):reason 消费 failRef 结构化原因,不再笼统
663
666
  try {
664
667
  const { reportGateSnapshotFallback } = await import('./gate-snapshot.js')
665
- if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'noAI 质量扫描步', reason: 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)' })
668
+ const fbReason = snapshotFailRef && snapshotFailRef.reason
669
+ ? `createVerifyGateSnapshot 返回 null:${snapshotFailRef.reason}`
670
+ : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)'
671
+ if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'noAI 质量扫描步', reason: fbReason })
666
672
  } catch { /* 报告失败不改变回退行为(含测试 mock 模块缺导出的形态) */ }
667
673
  }
668
674
  } catch (e) {
@@ -0,0 +1,189 @@
1
+ /**
2
+ * runtime-paths.js — .runtime 布局分层(state/ 与 temp/)与旧布局一次性迁移。
3
+ *
4
+ * 背景(2026-10-18 用户反馈):.runtime 根目录堆积 1649 个条目——22 类按 change 命名
5
+ * 等 24 类散文件不在归档回收名单、日志/db 备份与核心
6
+ * 状态混放。分野沿用 runtime-hygiene.js 的定界思路,按「删除是否安全」切两层:
7
+ * - **state/**:按 change 命名的派生状态(watcher 事件流、draft ledger、冲突标记…)。
8
+ * 活跃变更在读,归档/删除后无读者——由 pruneArchivedChangeRuntime 精确回收。
9
+ * - **temp/**:可随时整体删除的产物(watcher.log / spec-sync-bg.log / audit.log /
10
+ * db 备份 .bak|.corrupt-*|.pre-import-* / 锁残留 *.tmp)。无不可再生的状态语义。
11
+ *
12
+ * 家族表是唯一清单源(写入点路径助手 + 归档回收精确名 + 迁移前缀匹配三方共源,
13
+ * 防三处清单漂移)。契约:
14
+ * - 迁移幂等:目标已存在则跳过(不覆盖——混版本窗口防旧进程迟到文件盖掉新文件);
15
+ * 逐条 try/catch fail-open,绝不抛(卫生动作不阻断主流程,与 runtime-hygiene 同族)。
16
+ * - 迁移只动已知家族与已知临时产物,未知文件一律不动(用户/工具自置文件无归属语义)。
17
+ * - 文件名一概保持原样,仅目录前缀变化——写入点改动=一行 join 换助手,读写面零重命名。
18
+ */
19
+ import { existsSync, mkdirSync, readdirSync, renameSync } from 'fs'
20
+ import { join } from 'path'
21
+
22
+ export const RUNTIME_STATE_DIRNAME = 'state'
23
+ export const RUNTIME_TEMP_DIRNAME = 'temp'
24
+
25
+ /**
26
+ * state/ 家族表:prefix 用于迁移匹配与回收名生成;ext 是文件名后缀;
27
+ * stages 特例仅 current-stage-review-run-id(无扩展名且内嵌 stage 枚举
28
+ * brainstorm|plan|execute——stage-review.js stageReviewMarkerPath 的构造形态)。
29
+ */
30
+ const STATE_FAMILIES = [
31
+ { prefix: 'sync-docs-lastpush-', ext: '.json' },
32
+ { prefix: 'draft-ledger-', ext: '.json' },
33
+ { prefix: 'route-hindsight-baseline-', ext: '.json' },
34
+ { prefix: 'watcher-events-', ext: '.jsonl' },
35
+ { prefix: 'watcher-stage-timing-', ext: '.json' },
36
+ { prefix: 'watcher-last-snapshot-', ext: '.json' },
37
+ { prefix: 'test-rerun-', ext: '.json' },
38
+ { prefix: 'test-ledger-', ext: '.json' },
39
+ { prefix: 'ceremony-tier-', ext: '.json' },
40
+ { prefix: 'stage-session-ledger-', ext: '.json' },
41
+ { prefix: 'wave-session-ledger-', ext: '.json' },
42
+ { prefix: 'verify-draft-', ext: '.json' },
43
+ { prefix: 'verify-quality-scan-', ext: '.json' },
44
+ { prefix: 'verify-result-hwm-', ext: '.json' },
45
+ { prefix: 'review-dispatch-', ext: '.json' },
46
+ { prefix: 'review-dispatch-shadow-', ext: '.json' },
47
+ { prefix: 'execute-cleanup-', ext: '.json' },
48
+ { prefix: 'friction-tally-', ext: '.json' },
49
+ { prefix: 'prompt-inject-', ext: '.json' },
50
+ { prefix: 'sync-conflict-', ext: '.json' },
51
+ { prefix: 'spec-sync-conflict-', ext: '.json' },
52
+ { prefix: 'apply-pathspec-', ext: '.txt' },
53
+ { prefix: 'scope-audit-', ext: '.json' },
54
+ { prefix: 'scan-refresh-', ext: '.json' },
55
+ { prefix: 'current-execute-run-id-', ext: '' },
56
+ { prefix: 'current-stage-review-run-id-', ext: '', stages: ['brainstorm', 'plan', 'execute'] },
57
+ ]
58
+
59
+ /**
60
+ * 全局面单例(无 change 归属但有读者)→ state/(2026-10-18 二轮收敛:根目录只留
61
+ * db/锁/会话身份/global/spec-version/user-inputs 六件核心)。迁移精确名匹配。
62
+ */
63
+ export const RUNTIME_STATE_SINGLETONS = [
64
+ 'friction-ledger.json',
65
+ 'deleted-changes.jsonl',
66
+ 'active-gate-snapshots.json',
67
+ 'scan-guard.json',
68
+ 'verify-lint-tally.json',
69
+ 'decision-hits.json',
70
+ 'knowledge-hits.jsonl',
71
+ 'knowledge-hit-report.json',
72
+ 'vector-capability.json',
73
+ 'spec-sync-base.json',
74
+ 'spec-sync-last-success.json',
75
+ 'spec-sync-fail-streak.json',
76
+ 'spec-sync-follow-reported.json',
77
+ 'sync-noise-self-heal.json',
78
+ 'last-delta.json',
79
+ ]
80
+
81
+ /** 可再生全局单例产物(删后可重建、无不可再生状态)→ temp/。 */
82
+ export const RUNTIME_TEMP_SINGLETONS = [
83
+ 'doctor-diagnosis.json',
84
+ 'flow-telemetry.jsonl',
85
+ 'write-audit.jsonl',
86
+ 'anon-session-warn.json',
87
+ 'selfref-pointer-warn.json',
88
+ 'auto-pull-throttle.json',
89
+ ]
90
+
91
+ /** temp/ 侧已知产物:精确名 + 带时间戳/pid 尾的正则(corrupt/pre-import/lock-tmp)。 */
92
+ const TEMP_EXACT = new Set(['watcher.log', 'spec-sync-bg.log', 'audit.log', 'sillyspec.db.bak'])
93
+ const TEMP_PATTERNS = [
94
+ /^sillyspec\.db\.corrupt-/,
95
+ /^sillyspec\.db\.pre-import-/,
96
+ /^watcher\.lock\.\d+\.tmp$/,
97
+ ]
98
+
99
+ /**
100
+ * state/ 下某变更的全家族精确文件名清单(归档/删除回收用;禁 glob 的精确名形态)。
101
+ * @param {string} changeName
102
+ * @returns {string[]}
103
+ */
104
+ export function runtimeStateFileNames(changeName) {
105
+ const names = []
106
+ for (const f of STATE_FAMILIES) {
107
+ if (f.stages) {
108
+ for (const s of f.stages) names.push(`${f.prefix}${s}-${changeName}`)
109
+ } else {
110
+ names.push(`${f.prefix}${changeName}${f.ext}`)
111
+ }
112
+ }
113
+ return names
114
+ }
115
+
116
+ /**
117
+ * 路径助手自带目录保证(mkdir -p 语义,幂等 fail-open):库级直调(测试/embedded)不经
118
+ * CLI 入口迁移,裸 writeFileSync/appendFileSync 落 state|temp 会 ENOENT——写点分散逐个补
119
+ * mkdir 是打地鼠(2026-10-18 实证四起),在唯一路径构造点收口。读检查顺带建空目录无害
120
+ * (init 预建面与入口迁移本就常态建这两层)。
121
+ */
122
+ function _pathWithDir(runtimeRoot, dirname_, filename) {
123
+ const p = join(runtimeRoot, dirname_, filename)
124
+ try { mkdirSync(join(runtimeRoot, dirname_), { recursive: true }) } catch { /* fail-open:写入侧自抛真实原因 */ }
125
+ return p
126
+ }
127
+
128
+ /** @param {string} runtimeRoot @param {string} filename @returns {string} */
129
+ export function runtimeStatePath(runtimeRoot, filename) {
130
+ return _pathWithDir(runtimeRoot, RUNTIME_STATE_DIRNAME, filename)
131
+ }
132
+
133
+ /** @param {string} runtimeRoot @param {string} filename @returns {string} */
134
+ export function runtimeTempPath(runtimeRoot, filename) {
135
+ return _pathWithDir(runtimeRoot, RUNTIME_TEMP_DIRNAME, filename)
136
+ }
137
+
138
+ function _isTempArtifact(name) {
139
+ return TEMP_EXACT.has(name) || TEMP_PATTERNS.some((re) => re.test(name))
140
+ }
141
+
142
+ /**
143
+ * 旧布局一次性迁移:把 .runtime 根目录下已知家族文件移动到 state/ 或 temp/。
144
+ * 幂等(目标存在跳过)、fail-open(单条失败/目录缺失零副作用)、未知文件不动。
145
+ * @param {string} runtimeRoot .runtime 绝对路径
146
+ * @returns {{ stateMoved: number, tempMoved: number, skipped: number }}
147
+ */
148
+ export function migrateRuntimeLayout(runtimeRoot) {
149
+ const out = { stateMoved: 0, tempMoved: 0, skipped: 0 }
150
+ let entries
151
+ try {
152
+ entries = readdirSync(runtimeRoot, { withFileTypes: true })
153
+ } catch {
154
+ return out // 目录缺失(该仓库从未跑过流程)= 无可迁
155
+ }
156
+ const prefixes = STATE_FAMILIES.map((f) => f.prefix)
157
+ const stateSingletons = new Set(RUNTIME_STATE_SINGLETONS)
158
+ const tempSingletons = new Set(RUNTIME_TEMP_SINGLETONS)
159
+ const moves = [] // { name, dest }
160
+ for (const e of entries) {
161
+ if (!e.isFile()) continue
162
+ const name = e.name
163
+ if (prefixes.some((p) => name.startsWith(p)) || stateSingletons.has(name)) moves.push({ name, dest: RUNTIME_STATE_DIRNAME })
164
+ else if (_isTempArtifact(name) || tempSingletons.has(name)) moves.push({ name, dest: RUNTIME_TEMP_DIRNAME })
165
+ // 未知归属:不动(用户/工具自置文件无生命周期语义,迁移不猜)
166
+ }
167
+ // 无条件预建两层目录(幂等廉价):CLI 入口路径保证 state/ 在一切写入前就位——
168
+ // 新仓首跑无文件可搬,靠这里兜住「迁移零动作、写入点裸写 ENOENT」的窗口
169
+ for (const d of [RUNTIME_STATE_DIRNAME, RUNTIME_TEMP_DIRNAME]) {
170
+ try { mkdirSync(join(runtimeRoot, d), { recursive: true }) } catch { /* fail-open */ }
171
+ }
172
+ const madeDirs = new Set()
173
+ for (const { name, dest } of moves) {
174
+ const dst = join(runtimeRoot, dest, name)
175
+ try {
176
+ if (existsSync(dst)) { out.skipped++; continue } // 不覆盖:防旧进程迟到文件盖新文件
177
+ if (!madeDirs.has(dest)) {
178
+ mkdirSync(join(runtimeRoot, dest), { recursive: true }) // 并发 mkdir recursive 不抛
179
+ madeDirs.add(dest)
180
+ }
181
+ renameSync(join(runtimeRoot, name), dst) // 同卷原子;跨进程竞争失败即跳过下次再收
182
+ if (dest === RUNTIME_STATE_DIRNAME) out.stateMoved++
183
+ else out.tempMoved++
184
+ } catch {
185
+ out.skipped++
186
+ }
187
+ }
188
+ return out
189
+ }