sillyspec 3.28.6 → 3.28.8

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-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
  7. package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
  8. package/meta.json +11 -0
  9. package/package.json +1 -1
  10. package/src/agent-session-log.js +446 -35
  11. package/src/config-cat.js +6 -3
  12. package/src/config-schema.js +13 -0
  13. package/src/constants.js +3 -0
  14. package/src/db.js +12 -3
  15. package/src/decision-distill.js +6 -2
  16. package/src/dispatch/probe.js +8 -1
  17. package/src/docs-check.js +137 -12
  18. package/src/docs-gate.js +3 -0
  19. package/src/doctor-diagnostics.js +19 -7
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +59 -29
  23. package/src/index.js +168 -64
  24. package/src/knowledge-match.js +90 -6
  25. package/src/local-detect.js +30 -15
  26. package/src/local-register.js +13 -4
  27. package/src/mcp-server.js +38 -8
  28. package/src/progress/change-registry.js +58 -26
  29. package/src/progress/consistency-doctor.js +11 -4
  30. package/src/progress/stage-machine.js +3 -1
  31. package/src/progress/step-store.js +30 -17
  32. package/src/progress.js +26 -0
  33. package/src/quicklog.js +44 -18
  34. package/src/review-dispatch.js +7 -1
  35. package/src/run/command.js +17 -3
  36. package/src/run/complete-handlers.js +40 -10
  37. package/src/run/complete.js +48 -17
  38. package/src/run/concurrent-detect.js +1 -1
  39. package/src/run/gate-snapshot.js +115 -5
  40. package/src/run/gates.js +35 -5
  41. package/src/run/prompt.js +61 -1
  42. package/src/run/quick-audit.js +80 -4
  43. package/src/run/shared.js +98 -10
  44. package/src/run/verify-quality-scan.js +14 -3
  45. package/src/scope-audit.js +89 -14
  46. package/src/semantic-guard.js +207 -0
  47. package/src/sillyhub-mcp/client.js +14 -0
  48. package/src/spec-sync.js +19 -14
  49. package/src/stage-contract-spec.js +4 -3
  50. package/src/stage-review.js +6 -5
  51. package/src/stages/brainstorm.js +1 -1
  52. package/src/stages/execute.js +6 -1
  53. package/src/stages/plan-postcheck.js +57 -12
  54. package/src/sync.js +66 -4
  55. package/src/task-review.js +41 -6
  56. package/src/verify-facts-schema.js +10 -3
  57. package/src/verify-postcheck.js +21 -6
  58. package/src/verify-probes.js +8 -3
  59. package/src/worktree-apply.js +56 -18
  60. package/src/worktree-cross.js +12 -18
  61. package/src/worktree.js +138 -25
  62. package/src/wt-commit.js +128 -0
package/src/quicklog.js CHANGED
@@ -67,6 +67,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
67
67
  if (!restored) { try { unlinkSync(claim) } catch {} }
68
68
  }
69
69
  } catch {}
70
+ // 偷锁重试前同样受超时与退避约束(坑 quicklog-lock-steal-spin,2026-09-12 审查批 A-③):
71
+ // 旧版 continue 直接跳过下方检查——锁文件被 AV/索引器长期占用、renameSync 持续失败
72
+ // (每次被外层 catch 吞掉)时忙等自旋:不检查 timeoutMs、不让出 CPU,命令挂死 + 100% CPU。
73
+ if (Date.now() - start > timeoutMs) {
74
+ throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
75
+ }
76
+ await sleep(retryMs)
70
77
  continue
71
78
  }
72
79
  } catch {}
@@ -80,7 +87,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
80
87
  return await fn()
81
88
  } finally {
82
89
  try { closeSync(fd) } catch {}
83
- try { unlinkSync(lockPath) } catch {} // 释放(容忍已不存在)
90
+ // 释放前校验持有者身份(坑 quicklog-lock-release-mismatch,2026-09-12 审查批 A-③):
91
+ // 本进程临界区超 staleMs 被他进程偷锁后,lockPath 上可能是**他人新建**的锁——无条件
92
+ // unlink 误删它会让第三方抢锁成功,形成双进程并发临界区。仅内容仍为本会话 myId 才删
93
+ // (读失败=锁已不在/被偷,容忍——偷锁路径自会回收旧 claim)。
94
+ try {
95
+ if (readFileSync(lockPath, 'utf8') === myId) unlinkSync(lockPath)
96
+ } catch {}
84
97
  }
85
98
  }
86
99
 
@@ -718,7 +731,12 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
718
731
  const files = Array.isArray(allowedFiles) ? allowedFiles : []
719
732
  const today = todayStamp()
720
733
 
721
- return await withFileLock(lockPath, async () => {
734
+ // 平台推送在锁外执行(2026-09-11 审查性能包①):锁内 5s 网络推送 + 逐变更 tasks 锁
735
+ // (最坏各 10s)可使临界区突破 30s stale 偷锁阈值——他进程偷锁后双写者并发,正是本模块
736
+ // 头声明要根治的并发丢更新被重新打开。推送 best-effort 不回写本地,锁外语义等同(原注释
737
+ // 自证),仅放弃「分配即推送」的顺序一致性(平台为 best-effort 面板,乱序窗口极小)。
738
+ let pushPayload = null
739
+ const { qlId } = await withFileLock(lockPath, async () => {
722
740
  const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
723
741
  const nextSeq = maxSeq + 1
724
742
  // XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
@@ -744,9 +762,7 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
744
762
 
745
763
  for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
746
764
 
747
- // 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
748
- // 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
749
- await pushQuicklogEntryToPlatform(specBase, {
765
+ pushPayload = {
750
766
  ql_id: qlId,
751
767
  timestamp: nowDatetime(),
752
768
  title: desc,
@@ -758,10 +774,13 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
758
774
  files: files.map(f => ({ path: f, note: null })),
759
775
  body_sections: {},
760
776
  raw_block: entry,
761
- }).catch(() => {}) // pushQuicklogEntryToPlatform 自身已吞错;双保险防未来改动破坏 best-effort 契约
762
-
777
+ }
763
778
  return { qlId }
764
779
  })
780
+ if (pushPayload) {
781
+ await pushQuicklogEntryToPlatform(specBase, pushPayload).catch(() => {}) // 自身已吞错;双保险防未来改动破坏 best-effort 契约
782
+ }
783
+ return { qlId }
765
784
  }
766
785
 
767
786
  /**
@@ -785,9 +804,11 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
785
804
  const linked = Array.isArray(linkedChanges) ? linkedChanges : []
786
805
  const files = Array.isArray(allowedFiles) ? allowedFiles : []
787
806
 
788
- return await withFileLock(lockPath, async () => {
807
+ // 推送出锁(同 allocateQuicklogEntry 性能包①:临界区只留本地写 + tasks 挂载)
808
+ let pushPayload = null
809
+ const { existed } = await withFileLock(lockPath, async () => {
789
810
  // 双检(锁内):并发兜底同 ID 只落一条
790
- if (findQuicklogEntry(specBase, gitUser, qlId)) return { qlId, existed: true }
811
+ if (findQuicklogEntry(specBase, gitUser, qlId)) return { existed: true }
791
812
  await rotateIfNeeded(userFile, user)
792
813
  const entry = [
793
814
  '',
@@ -799,7 +820,7 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
799
820
  ].join('\n')
800
821
  appendFileSync(userFile, entry)
801
822
  for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
802
- await pushQuicklogEntryToPlatform(specBase, {
823
+ pushPayload = {
803
824
  ql_id: qlId,
804
825
  timestamp: nowDatetime(),
805
826
  title: desc,
@@ -810,9 +831,13 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
810
831
  files: files.map(f => ({ path: f, note: null })),
811
832
  body_sections: {},
812
833
  raw_block: entry,
813
- }).catch(() => {})
814
- return { qlId, existed: false }
834
+ }
835
+ return { existed: false }
815
836
  })
837
+ if (pushPayload && !existed) {
838
+ await pushQuicklogEntryToPlatform(specBase, pushPayload).catch(() => {})
839
+ }
840
+ return { qlId, existed }
816
841
  }
817
842
 
818
843
  /**
@@ -837,10 +862,12 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
837
862
  ? softFiles.filter(Boolean).map((f) => String(f).replace(/\\/g, '/'))
838
863
  : []
839
864
 
865
+ // 推送出锁(性能包①):终态 payload 以锁内落盘结果组装,锁外 best-effort 推送 + sidecar 持久化
866
+ // (sidecar 与推送同源同解析器,同移锁外不改变「以落盘终态为准」语义)
867
+ let finalPayload = null
840
868
  await withFileLock(lockPath, async () => {
841
869
  // 条目可能在主文件或轮转归档中
842
870
  let updatedContent = null
843
- let updatedFile = null
844
871
  for (const f of listQuicklogFiles(quicklogDir)) {
845
872
  const filePath = join(quicklogDir, f)
846
873
  let content = ''
@@ -850,7 +877,6 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
850
877
  if (updated !== null) {
851
878
  await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
852
879
  updatedContent = updated
853
- updatedFile = filePath
854
880
  break
855
881
  }
856
882
  }
@@ -860,16 +886,16 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
860
886
  // (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
861
887
  // 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
862
888
  const rawBlock = extractRawBlock(updatedContent, qlId)
863
- const finalPayload = buildPushPayloadFromRaw(rawBlock, {
889
+ finalPayload = buildPushPayloadFromRaw(rawBlock, {
864
890
  ql_id: qlId,
865
891
  author_raw: user,
866
892
  status: 'completed',
867
893
  linked_changes: linked,
868
894
  })
869
- // P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
870
- writeQuicklogSidecar(specBase, finalPayload)
871
- await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
872
895
  })
896
+ // P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
897
+ writeQuicklogSidecar(specBase, finalPayload)
898
+ await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
873
899
  }
874
900
 
875
901
  /**
@@ -262,7 +262,13 @@ export async function runReviewDispatch(opts = {}) {
262
262
  const probeFn = probe || (await import('./dispatch/probe.js')).probeSillyHub
263
263
  const pr = await probeFn({ client, worktreePath: null, cwd })
264
264
  if (!pr || pr.available !== true) {
265
- return { ok: false, reason: `平台通道不可用(${pr && pr.reason ? pr.reason : 'probe 异常'})`, guidance: renderFallbackGuidance(cwd) }
265
+ const why = pr && pr.reason ? pr.reason : 'probe 异常'
266
+ // token 失效给「重连再配对」出口(2026-09-11 用户实测:gateway/daemon 都活着,
267
+ // 死的只是凭据——一把 platform connect 就能救活平台通道,比直接降级更对症)。
268
+ const tokenHint = why === 'mcp-token-invalid'
269
+ ? `mcp token 已失效(吊销/过期)——重跑 \`sillyspec platform connect ${client && client._url ? client._url : '<平台url>'} --token <user级token>\` 重连(connect 会成对签发 read+dispatch 新 token 自动覆盖写 mcp 段),然后重试平台通道。\n若暂不重连,按通道优先序降级:\n`
270
+ : ''
271
+ return { ok: false, reason: `平台通道不可用(${why})`, guidance: tokenHint + renderFallbackGuidance(cwd) }
266
272
  }
267
273
  // reviewRunId:优先复用 marker(与 prompt/gate 同链,勿手算)
268
274
  let runId = reviewRunId
@@ -22,6 +22,7 @@
22
22
  import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
+ import { fileURLToPath } from 'node:url'
25
26
  import { writeAtomicSync } from '../fs-atomic.js'
26
27
  import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, assertDatedChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, resolveQuickSessionsDir, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, isTempResidueSpecRoot, PLATFORM_MANAGED_FILENAME, warnSelfRefPointerOnce } from './shared.js'
27
28
  import { resolveQuickLinkedChanges } from './quick-audit.js'
@@ -819,6 +820,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
819
820
  const knownFlags = new Set([
820
821
  '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval', '--cancel', '--ql',
821
822
  '--wait', '--continue', '--non-interactive', '--interactive',
823
+ '--wait-interactive', // auto 模式 TTY 直通(带值形态 `--wait-interactive true`,autoFlagValue 消费;ql-20260911-029 补注册——此前漏登记,进命令前即被未知参数 exit(2) 拦死,FR-03 整条死路)
822
824
  '--reason', '--options', '--answer', '--confirm-mode',
823
825
  '--output', '--input', '--change', '--linked-changes',
824
826
  '--req', '--cause', '--solution', '--result',
@@ -1286,11 +1288,14 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1286
1288
  console.error('用法: sillyspec run quick --cancel --change <quick-会话ID> [--ql <ql-xxx>]\n 取消误启动的 quick 会话:QUICKLOG 条目翻「已取消」+ tasks.md 挂载行移除 + 会话目录清理 + db 行注销。\n 已完成/已勾选的会话拒绝取消(真实工作记录)')
1287
1289
  process.exit(2)
1288
1290
  }
1289
- const specBase = resolveSpecDir(cwd, { specDir })
1291
+ // 平台模式对齐(坑 quick-cancel-platform-specbase,2026-09-12 审查批 B-④):此处原用
1292
+ // resolveSpecDir 本地重建 specBase,遮蔽外层平台感知 specBase(:476 specRoot 优先)——
1293
+ // 平台模式 guard 恒读不到、QUICKLOG 翻态落错库。guard 查找同其他收敛点走
1294
+ // resolveQuickSessionsDir(runtimeRoot 可与 specBase/.runtime 异位,multi-agent-review Q4)。
1290
1295
  // qlId 优先 --ql 显式;否则读会话 guard.json(allocate 时 CLI 写入);再退 current marker 旁的会话
1291
1296
  let qlId = qlFlag
1292
1297
  if (!qlId) {
1293
- const guardPath = join(specBase, '.runtime', 'quick-sessions', cancelSessionRaw, 'guard.json')
1298
+ const guardPath = join(resolveQuickSessionsDir(platformOpts, specBase), cancelSessionRaw, 'guard.json')
1294
1299
  try {
1295
1300
  if (existsSync(guardPath)) {
1296
1301
  // 字段名 bug 修复(2026-09-09 §7-5,会话实证:guard 在场仍报「缺失/损坏」):
@@ -1896,6 +1901,15 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1896
1901
  * 无新状态);任一不满足/异常 → 静默或提示回三段式(fail-open,Grill P2-①时序)。
1897
1902
  * 仅 runAutoMode 的首渲染点消费(--done 推进路径不重收)。
1898
1903
  */
1904
+ /**
1905
+ * 本 CLI 入口(bin/sillyspec.js)的绝对路径——wait 直通子进程自调用。
1906
+ * fileURLToPath 解 URL(ql-20260911-029:旧 pathname.replace 手解百分号编码,含空格/中文
1907
+ * 路径 ENOENT;且旧相对层级 ../bin 少一级——src/run/ 到仓根 bin 应为 ../../bin)。导出供单测锁层级。
1908
+ */
1909
+ export function resolveBinSelfPath() {
1910
+ return fileURLToPath(new URL('../../bin/sillyspec.js', import.meta.url))
1911
+ }
1912
+
1899
1913
  export async function maybeWaitInteractive(stepDef, changeName, stageName, { rlFactory = null } = {}) {
1900
1914
  try {
1901
1915
  if (!globalThis.__ssWaitInteractive) return
@@ -1920,7 +1934,7 @@ export async function maybeWaitInteractive(stepDef, changeName, stageName, { rlF
1920
1934
  console.log(`📩 已接收,自动续行:sillyspec run auto --continue --answer "${answer.trim().slice(0, 60)}${answer.trim().length > 60 ? '…' : ''}" ...`)
1921
1935
  // 复用既有 continue 路径:与 runCommand 的 --continue 同源(子进程自调避免递归锁)
1922
1936
  const { execFileSync } = await import('node:child_process')
1923
- const binSelf = new URL('../bin/sillyspec.js', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
1937
+ const binSelf = resolveBinSelfPath()
1924
1938
  try {
1925
1939
  execFileSync(process.execPath, [binSelf, 'run', 'auto', '--continue', '--answer', answer.trim(), '--change', changeName], { stdio: 'inherit', cwd: process.cwd() })
1926
1940
  } catch { /* continue 失败(非 waiting 态等)静默——回三段式由 agent 走 */ }
@@ -22,8 +22,9 @@
22
22
  * archiveChangeDirectory 的 6 处 process.exit 全 exit(1):5 个顶层 guard 直接终止;L1325 在 catch 内主动 exit
23
23
  * (非被外层吞);process.exit 不可被 try 捕获 → 搬迁行为完全等价。
24
24
  */
25
- import { basename, join, resolve, relative, isAbsolute } from 'node:path'
25
+ import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path'
26
26
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
27
+ import { createHash } from 'node:crypto'
27
28
  import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
28
29
  import { gitQuiet } from '../git-helper.js'
29
30
  import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata, isQuicklogFileLineNoise, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, mergeQuickBoundaryFiles } from './shared.js'
@@ -1066,7 +1067,7 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1066
1067
  if (outputText) steps[currentIdx].output = null
1067
1068
  // 摩擦计数(friction-signal-hint task-04):quick 审计被拦也是摩擦信号——exit 前记
1068
1069
  // 一笔(changeName=quick 会话 id,tally 落 session 目录,成功收尾 consume 后随目录清理)。
1069
- recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-audit' })
1070
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-audit' })
1070
1071
  process.exit(1)
1071
1072
  }
1072
1073
  // P0-2(2026-09-02 跨 agent 工单):触及 src/test 的 quick --done 内置 test+lint
@@ -1089,7 +1090,7 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1089
1090
  if (outputText) steps[currentIdx].output = null
1090
1091
  // 摩擦计数(friction-signal-hint task-04):test/lint 实测门禁拦下同记摩擦——exit 前
1091
1092
  // 落 session tally,与 quick-audit 同款静默降级。
1092
- recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-test-lint' })
1093
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-test-lint' })
1093
1094
  process.exit(1)
1094
1095
  }
1095
1096
  progress.lastQuickReview = review
@@ -1313,20 +1314,26 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1313
1314
  if (quickRows && quickRows.length > 0) {
1314
1315
  const patchesDir = join(specBase, 'quicklog', 'patches')
1315
1316
  mkdirSync(patchesDir, { recursive: true })
1316
- const { ...snapRest } = snapResult || {}
1317
- delete snapRest.frozenPatch
1318
- writeFileSync(join(patchesDir, `${qlId}.json`), JSON.stringify({
1317
+ const { frozenPatch: _fp, ...snapRest } = snapResult || {}
1318
+ const snapObj = {
1319
1319
  ...snapRest,
1320
1320
  rows: quickRows,
1321
1321
  totals: { files: quickRows.length, additions: quickRows.reduce((n, r) => n + (Number.isFinite(r.additions) ? r.additions : 0), 0), deletions: quickRows.reduce((n, r) => n + (Number.isFinite(r.deletions) ? r.deletions : 0), 0) },
1322
1322
  qlId,
1323
1323
  sessionId: changeName,
1324
1324
  savedAt: new Date().toISOString(),
1325
- }, null, 2) + '\n')
1325
+ }
1326
+ snapObj.note = 'quick --done 时点冻结(本文件落盘时采集)'
1326
1327
  if (typeof frozenPatch === 'string' && frozenPatch) {
1327
- writeFileSync(join(patchesDir, `${qlId}.patch`), frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n')
1328
+ const patchText = frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n'
1329
+ writeFileSync(join(patchesDir, `${qlId}.patch`), patchText)
1330
+ snapObj.patchSha256 = createHash('sha256').update(patchText, 'utf8').digest('hex')
1331
+ snapObj.patchStatus = 'ok'
1332
+ } else if (frozenPatch === null) {
1333
+ snapObj.patchStatus = 'failed'
1328
1334
  }
1329
- console.log(`📦 范围快照已落 quicklog/patches/${qlId}.json${typeof frozenPatch === 'string' && frozenPatch ? ' + .patch(--done 时点冻结)' : ''}`)
1335
+ writeFileSync(join(patchesDir, `${qlId}.json`), JSON.stringify(snapObj, null, 2) + '\n')
1336
+ console.log(`📦 范围快照已落 quicklog/patches/${qlId}.json${snapObj.patchStatus === 'ok' ? ' + .patch(--done 时点冻结,sha256 已锚)' : snapObj.patchStatus === 'failed' ? '——patch 采集失败已留痕' : ''}`)
1330
1337
  }
1331
1338
  } catch (e) {
1332
1339
  console.warn(` ⚠️ quick 范围快照落盘失败(不阻断收尾):${e && e.message ? String(e.message).split('\n')[0] : e}`)
@@ -1360,7 +1367,7 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1360
1367
  // tally 随目录删除,晚于此就读不到。QUICKLOG 完成打印之后 consume,提示里的 postmortem
1361
1368
  // 建议有落点可循;全零/读失败零输出,摩擦提示失败不影响收尾。
1362
1369
  try {
1363
- const fr = consumeFrictionHint({ cwd, changeName, platformOpts })
1370
+ const fr = await consumeFrictionHint({ cwd, changeName, platformOpts })
1364
1371
  if (fr && fr.hint) console.log(`\n${fr.hint}`)
1365
1372
  } catch { /* 摩擦提示失败不影响收尾 */ }
1366
1373
 
@@ -1465,6 +1472,10 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
1465
1472
  */
1466
1473
  const QUICK_CLOSE_ALLOWED_STAGES = new Set(['', 'scan', 'brainstorm'])
1467
1474
 
1475
+ /** 时近性闸窗口(缺陷②复潮实现):最近进度活动 60 分钟内的变更不自动归档——活跃会话
1476
+ * 步进间隔为分钟级(brainstorm 8 步通常 <1h),僵尸的最后活动以小时/天计。 */
1477
+ const QUICK_CLOSE_ACTIVITY_WINDOW_MS = 60 * 60 * 1000
1478
+
1468
1479
  /**
1469
1480
  * quick --done 完成后,自动关闭任务已全部完成的关联真实变更。
1470
1481
  * quick-<hex> sessionId 自身不在此处理(由调用方单独注销)。
@@ -1514,6 +1525,25 @@ export async function closeQuickLinkedChanges({ pm, cwd, specBase, linkedChanges
1514
1525
  })
1515
1526
  continue
1516
1527
  }
1528
+ // 时近性闸(quick-done-autoarchive-misfire 缺陷②,D-002@v1 复潮,2026-09-11
1529
+ // cross-change-decision-guard 实证):阶段态区分不了「活跃在途」与「启动后弃单」——
1530
+ // brainstorm in_progress + tasks.md 仅含他者 quick 行时「全勾」是空洞真值(完整流程
1531
+ // plan 前无自有任务行),关联 quick --done 勾掉 ql 行即触发在途变更被轻量归档。
1532
+ // 信号取时近性:最近一次 stage/step 完成时刻在窗口内 = 会话分钟级在推进(在途),
1533
+ // 不自动归档;僵尸的最后活动必然陈旧(或无完成步 → null),逃生通道语义不变
1534
+ //(既有「真·僵尸 in_progress→closed」行为保留)。pm 无此方法(旧 mock/旧进度库)
1535
+ // 按无近期活动放行——与 getChangeStage 缺失 skip 的 fail-safe 不同向:本闸误放行的
1536
+ // 最坏后果是回到缺陷②现状(有 D-002 防护兜底),误拦截则僵尸永不清——权衡取放行。
1537
+ if (typeof pm.getLatestActivityAt === 'function') {
1538
+ const latest = pm.getLatestActivityAt(cwd, changeName)
1539
+ if (latest && Date.now() - new Date(latest).getTime() < QUICK_CLOSE_ACTIVITY_WINDOW_MS) {
1540
+ skipped.push({
1541
+ name: changeName,
1542
+ reason: `变更 ${Math.round((Date.now() - new Date(latest).getTime()) / 60000)} 分钟内仍有进度活动(在途会话,非僵尸),不自动归档——确已弃单请走原流程收尾或窗口过后重试`,
1543
+ })
1544
+ continue
1545
+ }
1546
+ }
1517
1547
  if (!isChangeTasksComplete(specBase, changeName)) {
1518
1548
  skipped.push({ name: changeName, reason: 'tasks.md 未全勾选或不存在' })
1519
1549
  continue
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { join } from 'node:path'
20
20
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
21
+ import { createHash } from 'node:crypto'
21
22
  import { writeAtomicSync } from '../fs-atomic.js'
22
23
  import { gitQuiet } from '../git-helper.js'
23
24
  import { withFileLock } from '../quicklog.js'
@@ -672,7 +673,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
672
673
  if (stageName === 'verify') {
673
674
  try {
674
675
  const { consumeFrictionHint } = await import('../friction-tally.js')
675
- const r = consumeFrictionHint({ cwd, changeName, platformOpts })
676
+ const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
676
677
  if (r && r.hint) console.log(`\n${r.hint}`)
677
678
  } catch { /* 摩擦提示失败不影响收尾 */ }
678
679
  }
@@ -817,28 +818,39 @@ function scopeAuditPathSet(result) {
817
818
 
818
819
  /** execute 完成路径:全表 + 审计级快照/patch 落盘(quick-359a48f1:changes/<变更名>/ 随归档
819
820
  * 入库;patch 为收尾时点全量冻结——--file 真·当时内容比对的数据源。写失败只提示,verify
820
- * 对比按无快照降级) */
821
+ * 对比按无快照降级。审查修复:freshActual 绕过 settled 快照捷径防陈旧回写(execute 重跑
822
+ * 场景 C-F01);落盘 note 换冻结措辞防「自述漂移」污染(C-F06);patchSha256/patchStatus
823
+ * 留防篡改锚与失败痕(A-F01/C-F07) */
821
824
  async function printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit, renderScopeAuditTable }) {
822
- const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch: true })
825
+ const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch: true, freshActual: true })
823
826
  console.log(`\n${renderScopeAuditTable(result, { maxRows: 60 })}`)
824
827
  try {
825
- const changeDir = join(specBase, 'changes', changeName)
828
+ // 归档竞态(审查 C-F10):目录已被并行归档移动 → 写归档侧,不在活跃区重建假目录
829
+ const activeDir = join(specBase, 'changes', changeName)
830
+ const changeDir = existsSync(activeDir) ? activeDir : join(specBase, 'changes', 'archive', changeName)
826
831
  mkdirSync(changeDir, { recursive: true })
827
832
  const { frozenPatch, ...snap } = result
828
- writeFileSync(join(changeDir, 'scope-audit.json'), JSON.stringify({ ...snap, savedAt: new Date().toISOString() }, null, 2) + '\n')
833
+ snap.note = 'execute --done 时点冻结(本文件落盘时采集)'
829
834
  if (typeof frozenPatch === 'string') {
830
- writeFileSync(join(changeDir, 'scope-audit.patch'), frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n')
835
+ const patchText = frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n'
836
+ writeFileSync(join(changeDir, 'scope-audit.patch'), patchText)
837
+ snap.patchSha256 = createHash('sha256').update(patchText, 'utf8').digest('hex')
838
+ snap.patchStatus = 'ok'
839
+ } else if (frozenPatch === null) {
840
+ snap.patchStatus = 'failed'
831
841
  }
832
- console.log(` 📦 范围快照已落变更目录(scope-audit.json${typeof frozenPatch === 'string' ? ' + scope-audit.patch(收尾时点冻结)' : ''})`)
842
+ writeFileSync(join(changeDir, 'scope-audit.json'), JSON.stringify({ ...snap, savedAt: new Date().toISOString() }, null, 2) + '\n')
843
+ console.log(` 📦 范围快照已落变更目录(scope-audit.json${snap.patchStatus === 'ok' ? ' + scope-audit.patch(收尾时点冻结,sha256 已锚)' : snap.patchStatus === 'failed' ? '——patch 采集失败已留痕' : ''})`)
833
844
  } catch (e) {
834
845
  console.warn(` ⚠️ 范围对账快照写入失败(不阻断,verify 漂移对比将按无快照降级):${e && e.message ? String(e.message).split('\n')[0] : e}`)
835
846
  }
836
847
  }
837
848
 
838
849
  /** verify 完成路径:读 execute 时点快照对比文件集差集,一行结论(行数不比——execute 形态 A 锚
839
- * worktree meta、verify 形态 B 锚 merge-base,基点不同行数天然抖动,比行数必假阳) */
850
+ * worktree meta、verify 形态 B 锚 merge-base,基点不同行数天然抖动,比行数必假阳)。
851
+ * 审查 C-F01:current 侧 freshActual——settled 快照捷径会让本函数变成快照比快照恒「一致」 */
840
852
  async function printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts, computeChangeScopeAudit }) {
841
- const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
853
+ const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, freshActual: true })
842
854
  const totals = result?.totals && typeof result.totals === 'object' ? result.totals : {}
843
855
  const scopeLine = `变更范围:${totals.files ?? (Array.isArray(result?.rows) ? result.rows.length : 0)} 文件 +${totals.additions ?? 0}/-${totals.deletions ?? 0}`
844
856
  if (result?.ok !== true) {
@@ -1059,8 +1071,14 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1059
1071
  const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
1060
1072
  const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
1061
1073
  const r = readReview(reviewPath)
1062
- // 端到端 task:必须 pass(cannot_verify 不算);普通 task:非 fail 即可(坑 execute-batch-complete-endtoend-checkbox)
1063
- const endToEnd = isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
1074
+ // 卡片读取延后到 verdict 可用之后(2026-09-11 审查性能包③):review 缺失或 spec/quality
1075
+ // 已 fail 时 endToEnd 取值不影响 shouldAutoCheckTask 结果(恒 false),先读卡片是纯浪费
1076
+ // ——持 tasks 锁期间每 task 省一次 readFileSync。
1077
+ const verdictUsable = r?.ok
1078
+ && r.review?.specVerdict !== 'fail'
1079
+ && r.review?.qualityVerdict !== 'fail'
1080
+ const endToEnd = verdictUsable
1081
+ && isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
1064
1082
  if (shouldAutoCheckTask(r, endToEnd, ctx)) {
1065
1083
  checkedCount++
1066
1084
  return `${p1}x${p2}` // 勾选
@@ -1200,6 +1218,11 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
1200
1218
  if (step.status === 'pending' || step.status === 'in-progress') {
1201
1219
  step.status = 'completed'
1202
1220
  step.completedAt = now
1221
+ // 乐观预标戳:这是本次 --done 的批量乐观标记而非逐步验证的完成。任一收尾 gate 失败时
1222
+ // rollbackStageCompletion 据此一并回滚(坑 execute-batch-rollback-half-state:只回滚
1223
+ // currentIdx 会落库「步骤 11 pending + 12-16 completed」半套状态)。内存字段,_write 只写
1224
+ // 固定列不落库。
1225
+ step._batchAligned = true
1203
1226
  aligned++
1204
1227
  }
1205
1228
  }
@@ -1239,10 +1262,11 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
1239
1262
  process.exit(1)
1240
1263
  }
1241
1264
 
1242
- // 查找下一个 pending 或 in-progress 的步骤
1243
- const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1265
+ // 查找下一个 pending / in-progress / blocked 的步骤(blocked 同为当前步——与 completeStep
1266
+ // 的 findIndex 口径对齐,否则 blocked 后 --wait 落到其后第一个 pending 步上错步记账)
1267
+ const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
1244
1268
  if (currentIdx === -1) {
1245
- console.error(`没有可以等待的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1269
+ console.error(`没有可以等待的步骤(阶段 ${stageName} 已无 pending/in-progress/blocked 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1246
1270
  process.exit(1)
1247
1271
  }
1248
1272
 
@@ -1501,7 +1525,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1501
1525
  if (stageName === 'verify') {
1502
1526
  try {
1503
1527
  const { consumeFrictionHint } = await import('../friction-tally.js')
1504
- const r = consumeFrictionHint({ cwd, changeName, platformOpts })
1528
+ const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
1505
1529
  if (r && r.hint) console.log(`\n${r.hint}`)
1506
1530
  } catch { /* 摩擦提示失败不影响收尾 */ }
1507
1531
  }
@@ -1557,7 +1581,10 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
1557
1581
  }
1558
1582
 
1559
1583
  const steps = stageData.steps
1560
- const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1584
+ // blocked 同为当前步——与 completeStep 的 findIndex 口径对齐(坑 deps-gate-blocked-invisible
1585
+ // 在 completeStep 修过但此处漏同步:blocked 后 --skip 落到其后第一个 pending 步上,错步跳过
1586
+ // 且 blocked 步永久卡死只能 --reset)
1587
+ const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
1561
1588
 
1562
1589
  if (currentIdx === -1) {
1563
1590
  const wsIdx = steps.findIndex(s => s.status === 'waiting')
@@ -1565,11 +1592,15 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
1565
1592
  console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
1566
1593
  console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
1567
1594
  } else {
1568
- console.error(`没有待跳过的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1595
+ console.error(`没有待跳过的步骤(阶段 ${stageName} 已无 pending/in-progress/blocked 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1569
1596
  }
1570
1597
  process.exit(1)
1571
1598
  }
1572
1599
 
1600
+ if (steps[currentIdx].status === 'blocked') {
1601
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 此前被门控阻断(blocked),本次 --skip 将跳过该步——若门控条件已修复,更宜直接 --done 推进。`)
1602
+ }
1603
+
1573
1604
  const defSteps = await getStageStepsAutoAware(stageName, cwd, progress, platformOpts?.specRoot || null)
1574
1605
  const stepDef = defSteps ? defSteps[currentIdx] : null
1575
1606
  if (stepDef && !stepDef.optional) {
@@ -151,6 +151,6 @@ export function formatConcurrentWarning(detected) {
151
151
  for (const c of otherActiveChanges) lines.push(` - ${c}`)
152
152
  }
153
153
 
154
- lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作。')
154
+ lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作(execute task 内建议 sillyspec wt-commit 串行提交)。')
155
155
  return lines.join('\n')
156
156
  }
@@ -34,6 +34,16 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
34
34
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
35
35
  git(cwd, ['rev-parse', 'HEAD'])
36
36
 
37
+ // symlink-store 布局探测(坑 gate-snapshot-pnpm-store-break,2026-09-12 驾驭第十七批②,
38
+ // 用户第三次踩「lint 沙箱临时目录跑 pnpm 必假败」):pnpm/bun/lerna 的 node_modules 内部
39
+ // 是指向 store 的符号链接网——junction 进临时目录后跨根解析失效,实测必假败。
40
+ // 与其让沙箱报无关错误逼 advisory,不如布局命中即作废快照回退主仓(宁可主仓口径)。
41
+ const layoutHit = detectSymlinkStoreLayout(cwd)
42
+ if (layoutHit) {
43
+ console.warn(`⚠️ 门禁快照对 ${layoutHit} 布局不可靠(node_modules 符号链接 store 经 junction 跨根失效,实测必假败)——跳过快照回退主仓实测;主仓失败再做污染归属鉴定`)
44
+ return null
45
+ }
46
+
37
47
  snapshotRoot = mkdtempSync(join(tmpdir(), 'sillyspec-gate-'))
38
48
  git(cwd, ['worktree', 'add', '--detach', '--quiet', snapshotRoot, 'HEAD'])
39
49
 
@@ -58,11 +68,39 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
58
68
  overlaid++
59
69
  }
60
70
 
61
- // node_modules junction(依赖主仓安装态;junction Windows 无需特权,POSIX 用 symlink)
62
- const nmSrc = join(cwd, 'node_modules')
63
- if (existsSync(nmSrc)) {
64
- try { symlinkSync(nmSrc, join(snapshotRoot, 'node_modules'), 'junction') }
65
- catch { /* 依赖链接失败:test/lint 可能跑不起来 → 仍返回快照,命令层报错走 fallback */ }
71
+ // 环境目录链接(坑 gate-snapshot-env-mismatch,2026-09-12 驾驭第十三批③,用户实证:
72
+ // 沙箱实测 venv 不装 dev 依赖(xdist 缺失)+ node_modules 缺失——快照只链 node_modules 时
73
+ // Python 项目的 commands.test 在快照内找不到 venv,误伤持续且难归因)。链接面扩 venv 族
74
+ // (.venv/venv/env——链接后 dev 依赖与主仓同源,不缺 xdist);链接失败/主仓本就没有时
75
+ // ⚠️ 显式可见(环境不一致的门禁会持续误伤——宁可吵不可静默错)。
76
+ // 递归依赖发现(坑 gate-snapshot-monorepo-layout,2026-09-12 驾驭第十五批①,用户实证
77
+ // 「对 monorepo 依赖布局完全不可用,四次重试才定位」):pnpm/nx/lerna workspace 的
78
+ // packages/<pkg>/node_modules 子目录依赖不在根四目录——深度≤3 扫描发现集逐个 junction。
79
+ const envRelDirs = discoverEnvDirs(cwd)
80
+ for (const rel of envRelDirs) {
81
+ const src = join(cwd, rel)
82
+ const dst = join(snapshotRoot, rel)
83
+ try {
84
+ mkdirSync(dirname(dst), { recursive: true })
85
+ symlinkSync(src, dst, 'junction')
86
+ } catch (e) {
87
+ console.warn(`⚠️ 门禁快照环境目录链接失败(${rel}):${e && e.message ? e.message : e}——快照内实测可能因缺该目录误伤,失败时先核对快照环境`)
88
+ }
89
+ }
90
+ if (envRelDirs.length === 0) {
91
+ console.warn(`⚠️ 门禁快照:主仓未发现任何环境目录(node_modules / venv 族,含子包递归)——commands.test/lint 若依赖它们将快照/主仓都不可用(环境未安装?)`)
92
+ }
93
+
94
+ // 环境完整性预检(坑 gate-snapshot-env-mismatch 二阶,2026-09-12 驾驭第十四批①,用户
95
+ // 实证「verify lint 沙箱必挂 node_modules 缺失只能 advisory」):主仓存在某环境目录而
96
+ // 快照内缺失(链接失败/布局差异)→ 快照对该仓 commands 是假环境,实测必挂——快照作废
97
+ // 回退主仓现行为(主仓口径可能被并行脏文件污染,但环境真实;两害取轻 + 回退原因可见)。
98
+ const envMissing = envDirsLinked(cwd, snapshotRoot)
99
+ if (envMissing.length > 0) {
100
+ console.warn(`⚠️ 门禁快照环境不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测(宁可主仓口径不可假沙箱硬挂;回退后失败先做污染归属鉴定)`)
101
+ try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {}
102
+ try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch {}
103
+ return null
66
104
  }
67
105
 
68
106
  // local.yaml(gitignore 不进 HEAD;门禁命令配置来源)+ package-lock 保持 HEAD 版(npm test 不装新依赖)
@@ -90,6 +128,78 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
90
128
  }
91
129
  }
92
130
 
131
+
132
+ /** symlink-store 包管理布局探测:命中即 junction 快照不可靠。返回布局名或 null。 */
133
+ export function detectSymlinkStoreLayout(cwd) {
134
+ try {
135
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'
136
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) return 'bun'
137
+ if (existsSync(join(cwd, 'lerna.json'))) return 'lerna'
138
+ try {
139
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'))
140
+ const pm = pkg && pkg.packageManager ? String(pkg.packageManager) : ''
141
+ if (/pnpm@/.test(pm)) return 'pnpm(packageManager)'
142
+ if (/yarn@/.test(pm)) return 'yarn(packageManager)'
143
+ if (/bun@/.test(pm)) return 'bun(packageManager)'
144
+ } catch { /* 无 package.json/损坏 → 仅锁文件判据 */ }
145
+ return null
146
+ } catch { return null }
147
+ }
148
+
149
+ /** 环境目录名集(根与子包通用) */
150
+ const ENV_DIR_NAMES = new Set(['node_modules', '.venv', 'venv', 'env'])
151
+
152
+ /**
153
+ * 递归依赖发现(坑 gate-snapshot-monorepo-layout):深度 ≤3 扫描(跳过环境目录自身内部/
154
+ * .git/dist/build/.sillyspec),返回所有环境目录的仓库根相对 POSIX 路径(含根级四目录)。
155
+ * pnpm/nx/lerna workspace 的 packages/<pkg>/node_modules 覆盖。
156
+ */
157
+ export function discoverEnvDirs(cwd, { maxDepth = 3 } = {}) {
158
+ const out = []
159
+ const skip = new Set(['.git', 'dist', 'build', '.sillyspec', 'out', 'target'])
160
+ const walk = (dir, rel, depth) => {
161
+ let entries
162
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
163
+ for (const e of entries) {
164
+ if (!e.isDirectory() || skip.has(e.name)) continue
165
+ const childRel = rel ? rel + '/' + e.name : e.name
166
+ if (ENV_DIR_NAMES.has(e.name)) {
167
+ out.push(childRel)
168
+ continue // 环境目录内部不再下钻(node_modules/node_modules 无意义且巨大)
169
+ }
170
+ if (depth < maxDepth) walk(join(dir, e.name), childRel, depth + 1)
171
+ }
172
+ }
173
+ walk(cwd, '', 0)
174
+ return out
175
+ }
176
+
177
+ /**
178
+ * 环境完整性纯检(坑 gate-snapshot-env-mismatch 二阶,发现集口径):主仓存在(递归发现)
179
+ * 而快照缺失的环境目录清单。任一命中 = 快照对该仓 commands 是假环境,调用方作废回退主仓。
180
+ * @returns {string[]} 缺失目录相对路径(空数组 = 完整/主仓本就无环境目录)
181
+ */
182
+ export function envDirsLinked(cwd, snapshotRoot) {
183
+ const missing = []
184
+ for (const rel of discoverEnvDirs(cwd)) {
185
+ try {
186
+ if (existsSync(join(cwd, rel)) && !existsSync(join(snapshotRoot, rel))) missing.push(rel)
187
+ } catch { missing.push(rel) /* 判定异常按缺失算(保守作废快照) */ }
188
+ }
189
+ return missing
190
+ }
191
+
192
+ /**
193
+ * 快照内实测失败的归属提示(坑 gate-snapshot-monorepo-layout 配套:用户四次重试才从日志
194
+ * 摸到 Temp\sillyspec-gate-* 路径——失败时路径/疑点/对照复跑出口必须直给)。
195
+ * 调用方在「快照内执行且失败」的分支打印。
196
+ */
197
+ export function printSnapshotFailureHint(snapInfo, { offEnv = 'SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF' } = {}) {
198
+ if (!snapInfo || !snapInfo.snapshotRoot) return
199
+ console.error(` 🔬 本次实测执行于隔离快照:${snapInfo.snapshotRoot}(HEAD + 本变更 ${snapInfo.changeFileCount ?? '?'} 个文件${snapInfo.sourceRoot ? ',overlay 自 worktree' : ''})`)
200
+ console.error(` 失败疑点排查顺序:① 本变更文件自身的真实失败(最常见)→ 修代码;② 快照环境差异(monorepo 依赖布局/环境目录链接缺失)→ 设 ${offEnv}=1 回退主仓复跑对照——主仓过而快照挂即环境问题;③ 并行污染已被快照隔离,不在疑点内。`)
201
+ }
202
+
93
203
  /**
94
204
  * verify 门专用快照(2026-09-10 驾驭小结第八批,用户第二次真实阻塞:verify 实测门跑
95
205
  * main 工作区,多会话并发任何人的 WIP 都能弄红别人的门)。等价「--worktree 定向跑」: