sillyspec 3.29.5 → 3.30.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 (110) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
  5. package/.claude/skills/sillyspec-quick/SKILL.md +18 -116
  6. package/AGENTS.md +38 -20
  7. package/SKILL.md +9 -3
  8. package/assets/command-cards/flow.md +32 -0
  9. package/assets/command-cards/run-brainstorm.md +1 -1
  10. package/assets/command-cards/run-quick.md +8 -25
  11. package/package.json +2 -1
  12. package/packages/dashboard/server/executor.js +2 -1
  13. package/packages/dashboard/server/parser.js +7 -7
  14. package/r7-test-env/README-R7-TEST.md +93 -0
  15. package/r7-test-env/demo-project/lib/calc.js +6 -0
  16. package/r7-test-env/demo-project/package.json +1 -0
  17. package/r7-test-env/demo-project/test.js +10 -0
  18. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  19. package/r7-test-env/sillyspec-r7.cmd +3 -0
  20. package/round4/account.mjs +18 -5
  21. package/round4/b1-analyze-task08.mjs +40 -0
  22. package/round4/b1-b1-target-validation.md +32 -0
  23. package/round4/b3a-prompt-audit-result.md +66 -0
  24. package/round4/b3a-prompt-audit.mjs +89 -0
  25. package/round4/optimization-plan.md +165 -0
  26. package/round4/r4-final-report.html +347 -0
  27. package/round4/r5-scenario-matrix.md +46 -0
  28. package/src/change-list.js +9 -0
  29. package/src/classify-change.js +6 -0
  30. package/src/command-cards.js +2 -1
  31. package/src/commit-guard.js +2 -0
  32. package/src/config-schema.js +64 -12
  33. package/src/db.js +35 -4
  34. package/src/decision-distill.js +9 -3
  35. package/src/docs-check.js +1 -1
  36. package/src/doctor-diagnostics.js +44 -2
  37. package/src/flow-draft.js +748 -0
  38. package/src/flow-parity.js +236 -0
  39. package/src/flow-review.js +184 -0
  40. package/src/flow.js +1492 -0
  41. package/src/foreign-declared.js +17 -1
  42. package/src/fr-index.js +396 -22
  43. package/src/fs-atomic.js +5 -1
  44. package/src/git-helper.js +2 -2
  45. package/src/greenfield-bootstrap.js +98 -0
  46. package/src/handoff.js +120 -7
  47. package/src/hunk-attribution.js +191 -0
  48. package/src/index.js +570 -16
  49. package/src/knowledge-classify.js +47 -1
  50. package/src/knowledge-digest.js +162 -0
  51. package/src/knowledge-quicklog.js +246 -0
  52. package/src/machine-draft.js +128 -0
  53. package/src/machine-interface.js +107 -5
  54. package/src/mcp-server.js +1 -0
  55. package/src/module-resolve.js +3 -1
  56. package/src/preview-progress.js +160 -0
  57. package/src/progress/change-registry.js +11 -3
  58. package/src/progress/shared.js +3 -2
  59. package/src/progress.js +56 -13
  60. package/src/quicklog.js +44 -6
  61. package/src/redomain.js +147 -0
  62. package/src/review-material-pack.js +204 -1
  63. package/src/run/archive-distill.js +4 -2
  64. package/src/run/bg-sync.js +67 -0
  65. package/src/run/command.js +123 -10
  66. package/src/run/complete-handlers.js +327 -72
  67. package/src/run/complete.js +350 -71
  68. package/src/run/gate-snapshot-ledger.js +213 -0
  69. package/src/run/gate-snapshot.js +250 -34
  70. package/src/run/gates.js +370 -233
  71. package/src/run/green-cache.js +123 -0
  72. package/src/run/junction-rm.js +70 -0
  73. package/src/run/next.js +14 -0
  74. package/src/run/prompt.js +185 -55
  75. package/src/run/quick-audit.js +185 -12
  76. package/src/run/shared.js +120 -3
  77. package/src/run/stage.js +182 -216
  78. package/src/run/test-ledger.js +264 -0
  79. package/src/run/verify-quality-scan.js +84 -7
  80. package/src/scope-audit.js +6 -1
  81. package/src/sentinel-assertions.js +114 -0
  82. package/src/setup.js +3 -3
  83. package/src/spec-sync.js +41 -1
  84. package/src/stage-contract.js +26 -8
  85. package/src/stage-review-checklist.js +2 -0
  86. package/src/stage-review.js +7 -2
  87. package/src/stage-templates.js +157 -0
  88. package/src/stages/archive.js +2 -0
  89. package/src/stages/brainstorm.js +24 -106
  90. package/src/stages/execute.js +221 -84
  91. package/src/stages/knowledge.js +74 -1
  92. package/src/stages/plan-postcheck.js +352 -1
  93. package/src/stages/plan.js +23 -1
  94. package/src/stages/quick.js +10 -2
  95. package/src/stages/verify.js +4 -2
  96. package/src/sync.js +81 -23
  97. package/src/task-done.js +181 -0
  98. package/src/task-review.js +8 -55
  99. package/src/test-bindings.js +549 -0
  100. package/src/timeline.js +218 -0
  101. package/src/ui-visual.js +175 -0
  102. package/src/verify-draft.js +214 -0
  103. package/src/verify-postcheck.js +860 -158
  104. package/src/verify-probes.js +59 -3
  105. package/src/watcher.js +1031 -0
  106. package/src/worktree-apply.js +44 -4
  107. package/src/worktree-deps.js +22 -8
  108. package/src/worktree-policy.js +42 -0
  109. package/src/worktree.js +102 -15
  110. package/templates/agents-instruction.md +37 -18
@@ -0,0 +1,213 @@
1
+ /**
2
+ * gate-snapshot-ledger.js — 门禁隔离快照的生命周期账本与陈旧回收
3
+ * (2026-09-24-gate-snapshot-lifecycle,task-01;D-003@v2 / D-004@v2 / D-005@v1)
4
+ *
5
+ * 存在理由:门禁快照(%TEMP%/sillyspec-gate-*,src/run/gate-snapshot.js 建、quick/verify
6
+ * 门 finally 清)在三条路径必然泄漏——进程被杀(cleanup 根本没跑)、Windows rmSync 无重试
7
+ * 遇 EPERM/junction 锁、git worktree remove 失败留 prunable 注册。实证攒到 41 个目录+2 个
8
+ * prunable 注册且全仓无清扫机制。本模块提供:
9
+ * ①账本(create 登记 / cleanup 销账,幂等原子写)——崩溃残留的可追踪凭据;
10
+ * ②路径与条目 fail-closed 守卫——账本是可变 JSON,删除原语调用前必过(D-005@v1);
11
+ * ③TTL×pid 三态保守判定(D-003@v2)与「目录+worktree 注册双清」回收执行(D-004@v2):
12
+ * 双清确认才销号——删不掉的残留必须留在账本里等下一轮,否则永久失追踪。
13
+ *
14
+ * 信任边界:删除原语仅由本模块的回收路径调用,且每个条目先过 isSafeLedgerEntry;
15
+ * gate-snapshot.js 自身 cleanup 的 root 来自 mkdtemp(生成即受信)不经过账本。
16
+ * 异常面:全部 fail-open 吞掉(账本/回收任一异常零阻断,退现状残留)。
17
+ */
18
+ import { execFileSync } from 'node:child_process'
19
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
20
+ import { basename, dirname, join, resolve, sep } from 'node:path'
21
+ import { tmpdir } from 'node:os'
22
+ import { writeAtomicSync } from '../fs-atomic.js'
23
+ import { safeRemoveDirWithLinks, unlinkLinkPath, collectLinkPathsUnder } from './junction-rm.js'
24
+
25
+ const LEDGER_FILE = 'active-gate-snapshots.json'
26
+ const HOUR_MS = 3600_000
27
+ const STALE_HOURS_DEFAULT = 24
28
+
29
+ /** 账本文件路径(runtime 域:.sillyspec/.runtime/active-gate-snapshots.json) */
30
+ export function gateSnapshotLedgerPath(runtimeRoot) {
31
+ return join(runtimeRoot, LEDGER_FILE)
32
+ }
33
+
34
+ /** 读账本(损坏/缺失/非数组 → 空数组,fail-open) */
35
+ export function readGateSnapshotLedger(runtimeRoot) {
36
+ if (!runtimeRoot) return []
37
+ try {
38
+ const p = gateSnapshotLedgerPath(runtimeRoot)
39
+ if (!existsSync(p)) return []
40
+ const data = JSON.parse(readFileSync(p, 'utf8'))
41
+ if (!Array.isArray(data)) return []
42
+ return data.filter((e) => e && typeof e === 'object' && typeof e.snapshotRoot === 'string')
43
+ } catch {
44
+ return []
45
+ }
46
+ }
47
+
48
+ function writeLedger(runtimeRoot, entries) {
49
+ mkdirSync(runtimeRoot, { recursive: true })
50
+ writeAtomicSync(gateSnapshotLedgerPath(runtimeRoot), JSON.stringify(entries, null, 2))
51
+ }
52
+
53
+ /** 登记快照(幂等:同 root 重复调用不追加;runtimeRoot 缺失退 no-op) */
54
+ export function registerGateSnapshot({ runtimeRoot, snapshotRoot, pid = process.pid }) {
55
+ if (!runtimeRoot || !snapshotRoot) return
56
+ try {
57
+ const entries = readGateSnapshotLedger(runtimeRoot)
58
+ if (entries.some((e) => e.snapshotRoot === snapshotRoot)) return
59
+ entries.push({ snapshotRoot, pid, createdAt: Date.now() })
60
+ writeLedger(runtimeRoot, entries)
61
+ } catch { /* fail-open:登记失败仅退化为残留不可追踪,不阻断门禁 */ }
62
+ }
63
+
64
+ /** 销账(幂等:缺失不抛;调用方负责先确认双清——本函数不判目录/注册状态) */
65
+ export function unregisterGateSnapshot({ runtimeRoot, snapshotRoot }) {
66
+ if (!runtimeRoot || !snapshotRoot) return
67
+ try {
68
+ const entries = readGateSnapshotLedger(runtimeRoot)
69
+ const next = entries.filter((e) => e.snapshotRoot !== snapshotRoot)
70
+ if (next.length === entries.length) return
71
+ writeLedger(runtimeRoot, next)
72
+ } catch { /* fail-open:销账失败保留条目,下轮重试 */ }
73
+ }
74
+
75
+ /**
76
+ * 路径守卫(D-005@v1):必须是 tmpdir 的**直接子目录**且 basename 严格匹配
77
+ * sillyspec-gate-* ——账本可被篡改,删除原语调用前唯一路径闸门。
78
+ */
79
+ export function isSafeSnapshotRoot(snapshotRoot, { tmpBase = tmpdir() } = {}) {
80
+ if (typeof snapshotRoot !== 'string' || !snapshotRoot) return false
81
+ if (snapshotRoot.includes('..')) return false
82
+ // 路径比较口径:win32 不区分大小写(TEMP/TMP 跨进程大小写漂移会让合法残留永不满足守卫)
83
+ const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
84
+ const norm = resolve(snapshotRoot)
85
+ if (fold(dirname(norm)) !== fold(resolve(tmpBase))) return false
86
+ return /^sillyspec-gate-/.test(basename(norm))
87
+ }
88
+
89
+ /** 条目结构守卫:根过路径守卫 ∧ pid 正整数 ∧ createdAt 有限毫秒值 */
90
+ export function isSafeLedgerEntry(entry, opts) {
91
+ return !!entry
92
+ && isSafeSnapshotRoot(entry.snapshotRoot, opts)
93
+ && Number.isInteger(entry.pid) && entry.pid > 0
94
+ && Number.isFinite(entry.createdAt)
95
+ }
96
+
97
+ /** staleHours 值域:非有限/非正 → 回退 24h(D-003@v2 单一入口) */
98
+ export function resolveStaleHours(value = process.env.SILLYSPEC_GATE_SNAPSHOT_STALE_HOURS) {
99
+ const n = Number(value)
100
+ return Number.isFinite(n) && n > 0 ? n : STALE_HOURS_DEFAULT
101
+ }
102
+
103
+ /**
104
+ * pid 活性三态(D-003@v2,破坏性场景从严):成功/EPERM=活,ESRCH=死,
105
+ * 无效 pid/其他异常=按活跳过。与 bg-sync 终止扫描口径(无效 pid 按死)有意分歧——
106
+ * 那边误判代价=进程残留,这边误判代价=删掉活跃快照目录。
107
+ */
108
+ function defaultIsProcessAlive(pid) {
109
+ if (!Number.isInteger(pid) || pid <= 0) return true
110
+ try {
111
+ process.kill(pid, 0)
112
+ return true
113
+ } catch (e) {
114
+ return !(e && e.code === 'ESRCH')
115
+ }
116
+ }
117
+
118
+ /**
119
+ * 纯判定:守卫 ∧ 超 TTL ∧ 明确死 三合取才 stale;其余(未超期/存活/守卫不过/
120
+ * 异常)一律保留。staleHours 非法值回退 24h。
121
+ */
122
+ export function selectStaleSnapshots(entries, { now = Date.now(), staleHours, isProcessAlive = defaultIsProcessAlive } = {}) {
123
+ const hours = resolveStaleHours(staleHours)
124
+ const probe = typeof isProcessAlive === 'function' ? isProcessAlive : defaultIsProcessAlive
125
+ const stale = []
126
+ const alive = []
127
+ for (const e of Array.isArray(entries) ? entries : []) {
128
+ if (!isSafeLedgerEntry(e)) { alive.push(e); continue }
129
+ if ((now - e.createdAt) / HOUR_MS <= hours) { alive.push(e); continue }
130
+ let isAlive
131
+ try { isAlive = !!probe(e.pid) } catch { isAlive = true }
132
+ if (isAlive) { alive.push(e); continue }
133
+ stale.push(e)
134
+ }
135
+ return { stale, alive }
136
+ }
137
+
138
+ function defaultRunGit(cwd, args) {
139
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000, windowsHide: true }).trim()
140
+ }
141
+
142
+ function defaultRemoveDir(p) {
143
+ // junction 安全删除(坑 junction-pierce-rm-sync,2026-09-24 实证):残留快照内
144
+ // node_modules/venv junction 的目标是主仓真身,裸 rmSync(recursive) 穿透链接清空目标
145
+ // (收口期三次实证)——删前先解链(junction-rm.js 单一实现);解链失败抛错由调用方
146
+ // 按「删不掉保留条目待下轮」处置,绝不回落裸 rmSync。
147
+ safeRemoveDirWithLinks(p, { maxRetries: 5, retryDelay: 100 })
148
+ }
149
+
150
+ /** worktree 注册是否仍登记该 root(跨平台路径归一 + win32 大小写不敏感) */
151
+ function defaultWorktreeRegistered(cwd, snapshotRoot) {
152
+ const out = defaultRunGit(cwd, ['worktree', 'list', '--porcelain'])
153
+ const norm = (s) => {
154
+ const v = String(s).replace(/\\/g, '/')
155
+ return process.platform === 'win32' ? v.toLowerCase() : v
156
+ }
157
+ const target = norm(snapshotRoot)
158
+ return out.split('\n').some((line) => line.startsWith('worktree ') && norm(line.slice('worktree '.length)) === target)
159
+ }
160
+
161
+ /**
162
+ * 回收执行(D-004@v2):逐条 remove → rmSync(remove 失败补 prune),双清确认
163
+ * (目录不存在 ∧ worktree list 无注册)才销号;删不掉的计入 skipped 保留条目。
164
+ * 返回 {reclaimed, skipped};异常全吞退零副作用。
165
+ */
166
+ export function reclaimStaleGateSnapshots({
167
+ runtimeRoot,
168
+ cwd,
169
+ now = Date.now(),
170
+ staleHours,
171
+ isProcessAlive = defaultIsProcessAlive,
172
+ runGit = defaultRunGit,
173
+ removeDir = defaultRemoveDir,
174
+ worktreeRegistered = defaultWorktreeRegistered,
175
+ } = {}) {
176
+ if (!runtimeRoot || !cwd) return { reclaimed: [], skipped: [] }
177
+ try {
178
+ const { stale } = selectStaleSnapshots(readGateSnapshotLedger(runtimeRoot), { now, staleHours, isProcessAlive })
179
+ const reclaimed = []
180
+ const skipped = []
181
+ for (const e of stale) {
182
+ const root = e.snapshotRoot
183
+ let removeFailed = false
184
+ // 先解链(坑 git-worktree-remove-pierce,2026-09-24 实证:git worktree remove --force
185
+ // 在 Windows 跟随 junction 删主仓真身内容——残留快照内 node_modules/venv/copy 链接必须
186
+ // 先解链再删。解链失败按删不掉处置:保留条目待下轮,绝不带着链接跑删除)。
187
+ let linksCleared = true
188
+ try {
189
+ for (const link of collectLinkPathsUnder(root)) unlinkLinkPath(link)
190
+ } catch { linksCleared = false }
191
+ if (linksCleared) {
192
+ try { runGit(cwd, ['worktree', 'remove', '--force', root]) } catch { removeFailed = true }
193
+ try { removeDir(root) } catch { /* 目录删不掉→下方双清判定保留条目 */ }
194
+ } else {
195
+ removeFailed = true
196
+ }
197
+ if (removeFailed) { try { runGit(cwd, ['worktree', 'prune']) } catch { /* prune 失败→下方判定保留 */ } }
198
+ let dirGone = false
199
+ let regGone = false
200
+ try { dirGone = !existsSync(root) } catch { dirGone = false }
201
+ try { regGone = !worktreeRegistered(cwd, root) } catch { regGone = false }
202
+ if (dirGone && regGone) {
203
+ unregisterGateSnapshot({ runtimeRoot, snapshotRoot: root })
204
+ reclaimed.push(root)
205
+ } else {
206
+ skipped.push(root)
207
+ }
208
+ }
209
+ return { reclaimed, skipped }
210
+ } catch {
211
+ return { reclaimed: [], skipped: [] }
212
+ }
213
+ }
@@ -11,16 +11,103 @@
11
11
  * junction 复用主仓依赖(零拷贝),local.yaml 从主仓复制(gitignore 不进 HEAD),在快照
12
12
  * cwd 跑对账。快照基建任何失败 → 返回 null,调用方回退主仓现行为(零回归兜底)。
13
13
  *
14
- * 快照生命周期:一次门禁一批(create → run → cleanup);崩溃残留由 git worktree prune /
15
- * 临时目录自然回收(OS tmp 清理),不进主仓 .runtime。
14
+ * 快照生命周期:一次门禁一批(create → run → cleanup);崩溃残留(进程被杀时 cleanup
15
+ * 不跑)由账本回收路径兜底——gate-snapshot-ledger.js 登记 create、cleanup 双清确认后销账,
16
+ * 下个门禁建快照前按 TTL×pid 双闸自愈回收(账本落 runtime 域 .sillyspec/.runtime/,
17
+ * 不进 git 不上平台)。
16
18
  */
17
19
  import { execFileSync, spawnSync } from 'node:child_process'
18
- import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync, existsSync, readdirSync, statSync, symlinkSync, readFileSync } from 'node:fs'
19
- import { join, dirname, relative, resolve } from 'node:path'
20
+ import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, existsSync, readdirSync, statSync, symlinkSync, readFileSync } from 'node:fs'
21
+ import { join, dirname, relative, resolve, isAbsolute } from 'node:path'
20
22
  import { tmpdir } from 'node:os'
23
+ import { registerGateSnapshot, unregisterGateSnapshot, reclaimStaleGateSnapshots } from './gate-snapshot-ledger.js'
24
+ import { safeRemoveDirWithLinks, unlinkLinkPath, collectLinkPathsUnder } from './junction-rm.js'
25
+
26
+ /**
27
+ * worktree 会话跳快照判定(2026-09-23 R9/R10 双实证根治):cwd 位于 sillyspec 会话专属
28
+ * execute worktree(.sillyspec/.runtime/worktrees/<change>)时,隔离快照的立命之本——
29
+ * 「主仓共享工作区并行会话脏文件不污染本会话门禁」——天然不成立问题不存在:worktree
30
+ * 本身就是单会话独占的隔离环境。而快照在此场景只剩纯成本:venv/node_modules junction
31
+ * 冻结与慢 I/O 面(R9 3MB/0CPU 冻结、R10 15min 慢跑均出自 Temp 快照内)、快照构建时延、
32
+ * 双层 overlay。跳过 = 直接在 worktree cwd 实测(口径与主仓 fallback 同构)。
33
+ * 判定(跨平台):路径分隔符先归一为 /,再匹配 /.sillyspec/.runtime/worktrees/<name>。
34
+ */
35
+ export function shouldSkipGateSnapshotForWorktree(cwd) {
36
+ const p = String(cwd || '').replace(/\\/g, '/')
37
+ return /\/\.sillyspec\/\.runtime\/worktrees\/[^/]+/.test(p)
38
+ }
21
39
 
22
40
  function git(cwd, args) {
23
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000 }).trim()
41
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000, windowsHide: true }).trim()
42
+ }
43
+
44
+ /** worktree 注册是否仍登记该 root(跨平台路径归一 + win32 大小写不敏感) */
45
+ function worktreeListContains(cwd, snapshotRoot) {
46
+ const out = git(cwd, ['worktree', 'list', '--porcelain'])
47
+ const norm = (s) => {
48
+ const v = String(s).replace(/\\/g, '/')
49
+ return process.platform === 'win32' ? v.toLowerCase() : v
50
+ }
51
+ const target = norm(snapshotRoot)
52
+ return out.split('\n').some((line) => line.startsWith('worktree ') && norm(line.slice('worktree '.length)) === target)
53
+ }
54
+
55
+ /**
56
+ * 快照目录 + worktree 注册的清理体(2026-09-24-gate-snapshot-lifecycle task-02,FR-02)——
57
+ * Windows 硬化:rmSync 带重试(EPERM/junction 锁是本次 41 个残留目录的实证成因),
58
+ * worktree remove 失败后补 prune 清 prunable 注册(本次实证 2 条)。
59
+ * git/删目录/存在性判定的依赖均可注入(故障注入测试见 test/gate-snapshot-cleanup.test.mjs)。
60
+ * 返回双清结果供调用方决定账本销账(D-004@v2);全失败不抛(fail-open)。
61
+ */
62
+ const REMOVE_DIR_RETRY = { maxRetries: 5, retryDelay: 100 }
63
+
64
+ export function cleanupSnapshot({
65
+ snapshotRoot,
66
+ cwd,
67
+ runGit = (c, a) => git(c, a),
68
+ // junction 安全删除(坑 git-worktree-remove-pierce,2026-09-24 实证):`git worktree remove
69
+ // --force` 的递归删在 Windows **跟随 junction 删目标内容**(实测 victim 复现;worktree.js
70
+ // cleanup 链的 unlinkNodeModulesLinks 先解链正是防它)——快照内 node_modules/venv/copy 面/
71
+ // 钉定缓存链接全部指向主仓真身,必须**先解链再交给 git/rmSync 删目录本体**。解链失败 →
72
+ // 保护性放弃本次删除(目录留账本待回收——双清契约天然兼容)。
73
+ removeDir = (p, opts) => safeRemoveDirWithLinks(p, opts),
74
+ dirExists = (p) => existsSync(p),
75
+ worktreeRegistered = (c, p) => worktreeListContains(c, p),
76
+ }) {
77
+ let worktreeCleaned = true
78
+ // 0. 先解链快照内全部 junction/reparse 点(含深层:venv 族与 gate_snapshot.copy 面在
79
+ // 子目录层)。解链失败不删目录(穿透风险下,保护主仓优先于清理彻底性)。
80
+ let linksCleared = true
81
+ try {
82
+ for (const link of collectLinkPathsUnder(snapshotRoot)) unlinkLinkPath(link)
83
+ } catch (e) {
84
+ linksCleared = false
85
+ console.warn(`⚠️ 快照 junction 解链失败(保护主仓链接目标,本次不删快照目录——条目留账本待下轮回收): ${e && e.message ? e.message : e}`)
86
+ }
87
+ if (linksCleared) {
88
+ // 注:worktree remove/prune 无 --quiet 选项(非法 flag 会必抛并被 catch 吞掉——旧实现
89
+ // 正是因此让 git remove 每次都失败、只剩无重试 rmSync 兜底,41 个残留目录的根因之一)
90
+ try {
91
+ runGit(cwd, ['worktree', 'remove', '--force', snapshotRoot])
92
+ } catch {
93
+ worktreeCleaned = false
94
+ }
95
+ } else {
96
+ worktreeCleaned = false
97
+ }
98
+ let dirRemoved = !dirExists(snapshotRoot)
99
+ if (linksCleared && !dirRemoved) {
100
+ try { removeDir(snapshotRoot, REMOVE_DIR_RETRY) } catch { /* 删不掉→下方按实际存在性判定 */ }
101
+ dirRemoved = !dirExists(snapshotRoot)
102
+ }
103
+ if (!worktreeCleaned) {
104
+ // remove 失败:prune 清掉「目录已不在但注册悬空」的 prunable 条目
105
+ try { runGit(cwd, ['worktree', 'prune']) } catch { /* prune 失败→下方复核 */ }
106
+ }
107
+ // 双清契约(D-004@v2):worktreeCleaned 只认 git worktree list 的确认答复——remove 退出码
108
+ // 为零不等于注册已清(prune 未生效或 list 仍含该 root 时仍可能残留)。查询异常按未清。
109
+ try { worktreeCleaned = !worktreeRegistered(cwd, snapshotRoot) } catch { worktreeCleaned = false }
110
+ return { dirRemoved, worktreeCleaned }
24
111
  }
25
112
 
26
113
  /**
@@ -50,7 +137,7 @@ function runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidFiles) {
50
137
  let acted = false
51
138
  for (const f of overlaidFiles) {
52
139
  if (reverted.has(f)) continue
53
- const bad = smokeImportPython(pythonBin, snapshotRoot, f)
140
+ const bad = smokeImportPython(pythonBin, snapshotRoot, f, byModule)
54
141
  if (!bad) continue
55
142
  acted = true
56
143
  // 依赖归因:报错点名另一 overlay 文件 → 回退依赖( importer 自身往往无恙)
@@ -82,32 +169,66 @@ function findSnapshotPython(envRelDirs, snapshotRoot) {
82
169
  }
83
170
 
84
171
  /**
85
- * 单文件 import 冒烟:`python -c "import <mod>"`,cwd 取该文件所在包根(模块路径按
86
- * 目录结构推导:剥 .py、/→.、丢 __init__;相对包根 import)。非 0 退出且 stderr 命中
87
- * ImportError/ModuleNotFoundError/SyntaxError 才判坏(其余失败如缺第三方依赖是环境问题,
88
- * 不回退——回退语义只针对「部分态坏文件」)。超时 10s 防挂。
172
+ * 单文件 import 冒烟:`python -c "import <mod>"`,包根从浅到深全尝试后才定性。
173
+ *
174
+ * 坑 verify-gate-worktree-crossrepo-three-defects 缺陷一(2026-09-21 实证):旧实现首个
175
+ * import-class 错误立即判坏——浅根(快照根)下 `No module named 'backend'`(backend 不是
176
+ * 快照根顶层包,包根本就该在 backend/)属 **cwd 假阳性**,却触发交付文件被回退 HEAD →
177
+ * 快照内丢 stats() 等新增 → 模块子集 AttributeError 连环假红。
178
+ *
179
+ * 定性规则(全根耗尽后):
180
+ * - 任一根 import 成功 → 文件健康(null);
181
+ * - SyntaxError → 立即判坏(语法坏与包根无关);
182
+ * - `cannot import name X`(模块已解析、缺名)→ 判坏(真部分态,根无关);
183
+ * - `ModuleNotFoundError: No module named 'X'` → 仅当 X 末段映射到**另一 overlay .py**
184
+ * (byModule)才判坏(overlay 依赖缺失);第三方依赖缺失/浅根前缀 → 环境问题不判坏
185
+ * (回退语义只针对「部分态坏文件」,坑原文口径)。
186
+ *
187
+ * @param {string} pythonBin 解释器
188
+ * @param {string} snapshotRoot 快照根
189
+ * @param {string} relFile overlay 文件(仓库根相对 POSIX)
190
+ * @param {Map<string,string>} byModule 末段模块名 → overlay 文件(缺失归因用)
89
191
  * @returns {string|null} 失败原因(判坏时);通过/环境性失败返回 null
90
192
  */
91
- function smokeImportPython(pythonBin, snapshotRoot, relFile) {
193
+ function smokeImportPython(pythonBin, snapshotRoot, relFile, byModule) {
92
194
  const noExt = relFile.replace(/\.py$/, '')
93
195
  const parts = noExt.split('/')
94
196
  while (parts.length > 0 && parts[parts.length - 1] === '__init__') parts.pop()
95
197
  if (parts.length === 0) return null
96
- // 包根推导:从深到浅尝试(backend/app/x/y.py → 先 app.x.y(cwd=backend)再 x.y(cwd=backend/app))
198
+ let nameErr = null
199
+ let missingOverlayErr = null
97
200
  for (let cut = 0; cut < parts.length; cut++) {
98
201
  const mod = parts.slice(cut).join('.')
99
202
  if (!mod || !/^[A-Za-z_][\w.]*$/.test(mod)) continue
100
203
  const pkgRoot = join(snapshotRoot, ...parts.slice(0, cut))
101
204
  const r = spawnSync(pythonBin, ['-c', `import ${mod}`], {
102
- cwd: pkgRoot, encoding: 'utf-8', timeout: 10_000,
205
+ cwd: pkgRoot, encoding: 'utf-8', timeout: 10_000, windowsHide: true,
103
206
  })
104
207
  if (r.status === 0) return null
105
208
  const err = String((r.stderr || '') + (r.stdout || ''))
106
- if (/ImportError|ModuleNotFoundError|SyntaxError|cannot import name/i.test(err)) {
107
- return (err.match(/(?:ImportError|ModuleNotFoundError|SyntaxError|cannot import name)[^\n]*/) || ['import 失败'])[0].slice(0, 120)
209
+ if (/SyntaxError/i.test(err)) {
210
+ // 语法坏与包根无关:该文件任何根下都不可导入,立即判坏
211
+ return (err.match(/SyntaxError[^\n]*/) || ['语法错误'])[0].slice(0, 120)
212
+ }
213
+ if (/cannot import name/i.test(err)) {
214
+ // 模块已解析、缺名 = 真部分态(依赖 overlay 兄弟缺符号);记录,全根耗尽后判坏
215
+ if (!nameErr) nameErr = (err.match(/cannot import name[^\n]*/) || ['cannot import name'])[0].slice(0, 120)
216
+ continue
217
+ }
218
+ const mnf = err.match(/ModuleNotFoundError: No module named '([A-Za-z_][\w.]*)'/)
219
+ if (mnf) {
220
+ const missingLast = mnf[1].split('.').pop()
221
+ if (byModule.has(missingLast)) {
222
+ // 缺的模块是 overlay 兄弟(如 crud.py 未进快照)→ 部分态候选,记录
223
+ if (!missingOverlayErr) missingOverlayErr = (err.match(/ModuleNotFoundError[^\n]*/) || ['module not found'])[0].slice(0, 120)
224
+ }
225
+ // else:第三方依赖缺失 / 浅根前缀(cwd 假阳性)→ 环境问题,继续试更深包根
226
+ continue
108
227
  }
109
- // 环境性失败(如缺第三方依赖、非模块入口冲突)→ 不判坏,继续试更浅包根
228
+ // 其余失败(超时/崩溃等)→ 环境性,继续
110
229
  }
230
+ if (nameErr) return nameErr
231
+ if (missingOverlayErr) return missingOverlayErr
111
232
  return null
112
233
  }
113
234
 
@@ -240,7 +361,7 @@ export function runGateSnapshotCommands(snapshotRoot, commands) {
240
361
  ran++
241
362
  let r = null
242
363
  try {
243
- r = spawnSync(cmd, { shell: true, cwd: snapshotRoot, timeout: 300_000, encoding: 'utf8' })
364
+ r = spawnSync(cmd, { shell: true, cwd: snapshotRoot, timeout: 300_000, encoding: 'utf8', windowsHide: true })
244
365
  } catch (e) {
245
366
  const reason = `spawn 异常:${e && e.message ? e.message : e}`
246
367
  failed.push({ cmd, reason })
@@ -332,7 +453,9 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
332
453
  viaLink = existsSync(dst)
333
454
  } catch { viaLink = false }
334
455
  if (!viaLink) {
335
- try { rmSync(dst, { recursive: true, force: true }) } catch { /* 假链接残留清不掉 → 下方复制报错走 warn */ }
456
+ // 假链接残留清理:dst 是 junction 本体——rmSync(dst) 在 Node v24 Windows 会穿透删
457
+ // 链接目标(主仓 copy 面真身,坑 junction-pierce-rm-sync),必须 rmdir 解链语义
458
+ try { unlinkLinkPath(dst) } catch { /* 假链接残留清不掉 → 下方复制报错走 warn */ }
336
459
  if (srcStat.isDirectory()) copyDirRecursive(src, dst)
337
460
  else copyFileSync(src, dst)
338
461
  }
@@ -352,12 +475,25 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
352
475
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
353
476
  * @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
354
477
  */
355
- export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null }) {
478
+ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null, runtimeRoot = null }) {
356
479
  let snapshotRoot = null
357
480
  try {
358
481
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
359
482
  git(cwd, ['rev-parse', 'HEAD'])
360
483
 
484
+ // 陈旧快照自愈(2026-09-24-gate-snapshot-lifecycle,D-002@v1/D-003@v2/D-004@v2):
485
+ // 建快照前同步回收账本内「守卫通过 ∧ 超 TTL ∧ pid 明确死」的条目(目录+worktree 注册
486
+ // 双清,删不掉的保留待下轮)。全 try/catch 恒吞——回收异常零阻断建快照;reclaimed 空
487
+ // 时零输出(正常路径逐字节不变)。runtimeRoot 缺失→账本链路退 no-op(禁 cwd 猜)。
488
+ try {
489
+ if (runtimeRoot) {
490
+ const { reclaimed, skipped } = reclaimStaleGateSnapshots({ runtimeRoot, cwd })
491
+ if (reclaimed.length > 0) {
492
+ console.log(`🧹 门禁快照自愈:回收 ${reclaimed.length} 个失活残留${skipped.length > 0 ? `(${skipped.length} 个删不掉,留账本待下轮)` : ''}`)
493
+ }
494
+ }
495
+ } catch { /* 回收异常 fail-open 退现状 */ }
496
+
361
497
  // symlink-store 布局探测(坑 gate-snapshot-pnpm-store-break,2026-09-12 驾驭第十七批②,
362
498
  // 用户第三次踩「lint 沙箱临时目录跑 pnpm 必假败」):pnpm/bun/lerna 的 node_modules 内部
363
499
  // 是指向 store 的符号链接网——junction 进临时目录后跨根解析失效,实测必假败。
@@ -370,6 +506,8 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
370
506
 
371
507
  snapshotRoot = mkdtempSync(join(tmpdir(), 'sillyspec-gate-'))
372
508
  git(cwd, ['worktree', 'add', '--detach', '--quiet', snapshotRoot, 'HEAD'])
509
+ // 登记账本(D-004@v2):进程被杀时 cleanup 不跑,条目是残留唯一的可追踪凭据
510
+ registerGateSnapshot({ runtimeRoot, snapshotRoot })
373
511
 
374
512
  // 会话文件 overlay:主仓工作区版本覆盖进快照(本会话的最新态;文件不存在=已删,跳过)。
375
513
  // sourceRoot(verify 门定向跑用,2026-09-10 驾驭小结第八批):overlay 源切换为本变更
@@ -422,8 +560,12 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
422
560
  if (cwdDiff && !wtDiff) {
423
561
  src = cwdPath // 主仓直写新、worktree 停在基线 → 取主仓
424
562
  } else if (!(wtDiff && !cwdDiff)) {
425
- console.warn(`⚠️ 快照双写分叉:${f} 在主仓与 worktree 均有不同修改——取主仓(本会话最新编辑位),请尽快对齐两边`)
426
- src = cwdPath
563
+ // M2(2026-09-21-r5-efficiency-batch2 D-002@v2):双写分叉改取 worktree 分支版——sourceRoot=worktree
564
+ // 定向跑的语义=验证本变更分支交付(batch1 实证:分叉取主仓拿走无本变更代码的版本,配上
565
+ // worktree 新增测试文件 → src/test 血统断裂 → module 实测假红一轮)。主仓侧同文件异动是
566
+ // 干扰源,其内容在 apply/对齐后由主仓单源复核兜底。
567
+ console.warn(`⚠️ 快照双写分叉:${f} 在主仓与 worktree 均有不同修改——取 worktree 分支版(本变更交付血统);主仓侧改动若需保留请 apply/对齐后复跑`)
568
+ src = wtPath
427
569
  } // wtDiff && !cwdDiff → 保持 worktree(正常流零改变)
428
570
  }
429
571
  }
@@ -465,11 +607,36 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
465
607
  const envMissing = envDirsLinked(cwd, snapshotRoot)
466
608
  if (envMissing.length > 0) {
467
609
  console.warn(`⚠️ 门禁快照环境不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测(宁可主仓口径不可假沙箱硬挂;回退后失败先做污染归属鉴定)`)
468
- try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {}
469
- try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch {}
610
+ // 与正常 cleanup/失败 catch 同一清理体:双清确认才销账(该早退曾残留旧式 --quiet
611
+ // 清理与无重试 rmSync——独立审查 P1-1 根因未除尽的漏网面)
612
+ try {
613
+ const { dirRemoved: dGone, worktreeCleaned: wClean } = cleanupSnapshot({ snapshotRoot, cwd })
614
+ if (dGone && wClean) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
615
+ } catch {}
470
616
  return null
471
617
  }
472
618
 
619
+ // env 钉定缓存链接面(r5l 方案 5 / P17,通用机制见 resolvePinnedCacheLinks jsdoc):
620
+ // 工具把缓存钉在仓内(UV_CACHE_DIR=.uv-cache 类)时 gitignored 不进 HEAD,快照缺它 →
621
+ // 构建隔离环境(含已装构建后端)缺失 → 快照内构建假红。junction 同相对路径进快照,
622
+ // 与环境目录族同款链接原语。整段 fail-soft:链接失败只 warn 不作废快照(缓存面缺失的
623
+ // 影响是工具自身的构建失败,失败输出自带归因提示);未钉定/钉在仓外 → 全段零行为零输出。
624
+ const pinnedCacheLinks = []
625
+ for (const link of resolvePinnedCacheLinks({ cwd })) {
626
+ if (!existsSync(link.srcAbs)) continue
627
+ const dst = join(snapshotRoot, link.rel)
628
+ try {
629
+ mkdirSync(dirname(dst), { recursive: true })
630
+ symlinkSync(link.srcAbs, dst, 'junction')
631
+ pinnedCacheLinks.push({ envName: link.envName, tool: link.tool, rel: link.rel })
632
+ } catch (e) {
633
+ console.warn(`⚠️ 门禁快照构建缓存链接失败(${link.envName}=${link.rel}):${e && e.message ? e.message : e}——快照内该工具构建可能缺缓存假红,失败先对照主仓口径`)
634
+ }
635
+ }
636
+ if (pinnedCacheLinks.length > 0) {
637
+ console.log(`🔬 门禁快照构建缓存面:${pinnedCacheLinks.map((x) => `${x.envName}=${x.rel}`).join('、')} 已 junction 进快照(P17:gitignored 仓内缓存携带构建隔离环境——缺它快照内构建假红;⚠️ 活链接,快照内构建写穿回主仓缓存)`)
638
+ }
639
+
473
640
  // gate_snapshot.commands 命令面(D-002@v2,2026-09-17-feedback-hardening R1,用户
474
641
  // 2026-09-17 负面①实证「全量快照必挂」):主仓侧生成物缺失(fresh clone 未跑
475
642
  // postinstall)或过期时,copy 面只搬「主仓现有态」供给缺失/陈旧态——本段在环境链接
@@ -526,16 +693,23 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
526
693
  }
527
694
 
528
695
  const cleanup = () => {
529
- try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {
530
- try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch { /* 残留交 OS tmp 清理 */ }
531
- }
696
+ // 残留不再「交 OS tmp 清理」(%TEMP% 不会被 OS 清)——删不掉的快照由账本回收路径
697
+ // (下个 create 前按 TTL×pid 双闸自愈,2026-09-24-gate-snapshot-lifecycle)兜底;
698
+ // 销账只在「目录+worktree 注册双清确认」后执行(D-004@v2),删不掉保留条目待下轮
699
+ try {
700
+ const { dirRemoved, worktreeCleaned } = cleanupSnapshot({ snapshotRoot, cwd })
701
+ if (dirRemoved && worktreeCleaned) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
702
+ } catch { /* 双失败已由 cleanupSnapshot 吞掉;条目保留 */ }
532
703
  }
533
- return { snapshotRoot, cleanup, overlaid }
704
+ return { snapshotRoot, cleanup, overlaid, pinnedCacheLinks }
534
705
  } catch (e) {
535
- // 基建失败(worktree add 拒绝/磁盘满/…):尽力清理后回退主仓
706
+ // 基建失败(worktree add 拒绝/磁盘满/…):尽力清理后回退主仓;销账同走双清确认
707
+ // (D-004@v2:清不掉的条目保留待回收——它不是悬空条目,是待回收记录)
536
708
  if (snapshotRoot) {
537
- try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {}
538
- try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch {}
709
+ try {
710
+ const { dirRemoved, worktreeCleaned } = cleanupSnapshot({ snapshotRoot, cwd })
711
+ if (dirRemoved && worktreeCleaned) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
712
+ } catch {}
539
713
  }
540
714
  return null
541
715
  }
@@ -562,9 +736,48 @@ export function detectSymlinkStoreLayout(cwd) {
562
736
  /** 环境目录名集(根与子包通用) */
563
737
  const ENV_DIR_NAMES = new Set(['node_modules', '.venv', 'venv', 'env'])
564
738
 
739
+ /**
740
+ * 「env 钉定缓存目录」注册表(r5l-forensic-verdict 方案 5 / P17,用户裁定走通用机制不做
741
+ * 单一工具硬编码):构建/包管理工具用 env var 把缓存钉在**仓内**(相对 cwd 或仓内绝对路径)
742
+ * 时,该目录 gitignored 不进 HEAD——隔离快照(HEAD worktree)天然缺它,快照内构建隔离
743
+ * (uv builds-v0 / pip build 隔离环境)拿不到已装好的构建后端(P17:hatchling EPERM 假红
744
+ * 两轮卡 37min)。机制对任意工具通用:解析「注册的 env var → 仓内路径 → junction 进快照
745
+ * 同相对路径」,工具只是注册表一行数据——扩新工具(cargo/poetry/自定义)加一行即可,
746
+ * 不新增代码分支。家目录缺省形态(~/.cache/uv、%LOCALAPPDATA%/uv 等)**刻意不链接**:
747
+ * 快照内命令继承完整进程 env,家目录绝对路径直达真实缓存,junction 同一批文件零机制增益。
748
+ * pnpm-store 族不入表:符号链接 store 经 junction 跨根失效(detectSymlinkStoreLayout 先例)。
749
+ */
750
+ const PINNED_CACHE_ENV_VARS = [
751
+ { env: 'UV_CACHE_DIR', tool: 'uv' },
752
+ { env: 'PIP_CACHE_DIR', tool: 'pip' },
753
+ { env: 'POETRY_CACHE_DIR', tool: 'poetry' },
754
+ ]
755
+
756
+ /**
757
+ * 解析 env 钉定的仓内缓存目录(纯解析,不做链接/IO 判存在——可测性)。
758
+ * 规则:注册表逐项取 env 值;相对路径按 cwd 解析(与 uv/pip 相对 cwd 语义一致);解析后
759
+ * 落在仓内(relative 不出仓)才算钉定缓存;仓外路径(家目录/自定义绝对位置)经继承 env
760
+ * 直达,不属于本面。同 var 多次出现取首个命中。
761
+ * @returns {{ envName: string, tool: string, srcAbs: string, rel: string }[]} 仓内钉定缓存清单
762
+ */
763
+ export function resolvePinnedCacheLinks({ cwd, env = process.env } = {}) {
764
+ const out = []
765
+ for (const entry of PINNED_CACHE_ENV_VARS) {
766
+ const v = env[entry.env]
767
+ if (!v || !String(v).trim()) continue
768
+ const abs = isAbsolute(String(v).trim()) ? String(v).trim() : resolve(cwd, String(v).trim())
769
+ let rel = null
770
+ try { rel = relative(cwd, abs) } catch { continue }
771
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue // 仓外直达,无需链接
772
+ if (out.some((x) => x.rel === rel.split('\\').join('/'))) continue
773
+ out.push({ envName: entry.env, tool: entry.tool, srcAbs: abs, rel: rel.split('\\').join('/') })
774
+ }
775
+ return out
776
+ }
777
+
565
778
  /**
566
779
  * 递归依赖发现(坑 gate-snapshot-monorepo-layout):深度 ≤3 扫描(跳过环境目录自身内部/
567
- * .git/dist/build/.sillyspec),返回所有环境目录的仓库根相对 POSIX 路径(含根级四目录)。
780
+ * .git/dist/build/.sillyspec),返回所有环境目录的仓库根相对 POSIX 路径(含根四目录)。
568
781
  * pnpm/nx/lerna workspace 的 packages/<pkg>/node_modules 覆盖。
569
782
  */
570
783
  export function discoverEnvDirs(cwd, { maxDepth = 3 } = {}) {
@@ -627,6 +840,10 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
627
840
  try {
628
841
  const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
629
842
  const { resolveRuntimeRoot } = await import('./shared.js')
843
+ // 账本 runtimeRoot(2026-09-24-gate-snapshot-lifecycle task-03):函数级解析一次,
844
+ // 平台/漂移模式口径与本函数内他处一致;解析失败退 null(账本链路 no-op,禁 cwd 猜)
845
+ let verifyRuntimeRoot = null
846
+ try { verifyRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase) } catch { verifyRuntimeRoot = null }
630
847
  const { splitOwnVsForeignDiffFiles } = await import('../foreign-declared.js')
631
848
  let changeFiles = resolveVerifyChangedFiles(cwd, changeName, null, {
632
849
  specBase, includeWorkingTree: true,
@@ -636,8 +853,7 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
636
853
  // 并入后主仓全部在途文件进场——他者活跃变更显式声明的文件(quick --files / 他者 design
637
854
  // 清单)剔除,只留本变更相关面(无主文件保留,fail-closed 口径与 probe6 一致)。
638
855
  try {
639
- const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
640
- const { foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, changeFiles, { specBase, runtimeRoot })
856
+ const { foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, changeFiles, { specBase, runtimeRoot: verifyRuntimeRoot })
641
857
  if (foreign.length > 0) {
642
858
  const foreignSet = new Set(foreign.map(x => x.file))
643
859
  changeFiles = changeFiles.filter(f => !foreignSet.has(f))
@@ -670,7 +886,7 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
670
886
  }
671
887
  } catch { /* meta 读取失败 → 主仓 cwd 源(files 已含本变更 working-tree 改动) */ }
672
888
 
673
- const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase })
889
+ const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase, runtimeRoot: verifyRuntimeRoot })
674
890
  if (!snap) return null
675
891
 
676
892
  // 变更文档随快照:module-impact.md(test_strategy=evidence-auto 消费)/tasks.md 等