sillyspec 3.28.9 → 3.28.11

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 (55) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/package.json +1 -1
  6. package/src/change-list.js +73 -20
  7. package/src/change-risk-profile.js +51 -3
  8. package/src/config-schema.js +51 -0
  9. package/src/contract-matrix.js +84 -5
  10. package/src/cross-repo-reconcile.js +134 -0
  11. package/src/design-facts.js +94 -18
  12. package/src/docs-check.js +14 -10
  13. package/src/docs-debt.js +7 -2
  14. package/src/docs-gate.js +42 -15
  15. package/src/endpoint-extractor.js +50 -2
  16. package/src/foreign-declared.js +25 -16
  17. package/src/hooks/worktree-guard.js +75 -2
  18. package/src/index.js +147 -2
  19. package/src/knowledge-classify.js +20 -1
  20. package/src/machine-interface.js +26 -0
  21. package/src/probe7-anchor-check.js +87 -0
  22. package/src/progress/stage-machine.js +34 -5
  23. package/src/progress.js +1 -1
  24. package/src/quicklog.js +240 -5
  25. package/src/run/command.js +25 -2
  26. package/src/run/complete-handlers.js +172 -1
  27. package/src/run/complete.js +143 -11
  28. package/src/run/gate-snapshot.js +372 -2
  29. package/src/run/gates.js +130 -12
  30. package/src/run/prompt.js +166 -7
  31. package/src/run/shared.js +81 -1
  32. package/src/scope-audit.js +101 -31
  33. package/src/stage-contract-spec.js +14 -0
  34. package/src/stage-contract.js +314 -8
  35. package/src/stage-review-checklist.js +3 -1
  36. package/src/stage-review.js +137 -2
  37. package/src/stages/archive.js +9 -5
  38. package/src/stages/brainstorm.js +7 -1
  39. package/src/stages/cmd-existence.js +9 -3
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/knowledge.js +24 -3
  42. package/src/stages/plan-postcheck.js +74 -10
  43. package/src/stages/plan.js +8 -1
  44. package/src/stages/quick.js +5 -5
  45. package/src/stages/verify.js +3 -2
  46. package/src/sync-noise.js +57 -1
  47. package/src/sync.js +77 -4
  48. package/src/task-review.js +84 -29
  49. package/src/verify-facts-schema.js +78 -9
  50. package/src/verify-postcheck.js +439 -68
  51. package/src/verify-probes.js +1156 -45
  52. package/src/worktree-apply.js +323 -8
  53. package/src/worktree-deps.js +24 -1
  54. package/src/worktree.js +274 -10
  55. package/src/write-audit.js +55 -0
@@ -16,14 +16,14 @@
16
16
  * - stages/plan-postcheck.js: executePlanPostcheck(noAI 硬门)
17
17
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
18
18
  */
19
- import { join } from 'node:path'
19
+ import { join, isAbsolute, resolve } from 'node:path'
20
20
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
21
21
  import { createHash } from 'node:crypto'
22
22
  import { writeAtomicSync } from '../fs-atomic.js'
23
23
  import { gitQuiet } from '../git-helper.js'
24
24
  import { withFileLock } from '../quicklog.js'
25
- import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
26
- import { isExplicitReviewWrite } from '../task-review.js'
25
+ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer } from './shared.js'
26
+ import { isExplicitReviewWrite, collectWorktreeChangedFiles } from '../task-review.js'
27
27
 
28
28
  // auto 模式 brainstorm 步骤表感知解析(2026-09-08 E2E 实证 bug:--done/--wait 推进主模式 8 步表,
29
29
  // 与 runAutoMode getAutoSteps 的 4 步 auto 表双轨互踩——ensureAutoStage 判非 auto 表重种清零,
@@ -43,10 +43,10 @@ async function getStageStepsAutoAware(stageName, cwd, progress, specDir) {
43
43
  import { executeScanPreflight, executeScanPostcheck, computeScanProfile, executeScanDetectProjects, executeScanResumeCheck, executeScanFinalize } from './scan-profile.js'
44
44
  import { executeProgressConfirm } from './progress-confirm.js'
45
45
  import { AUXILIARY_STAGES } from '../constants.js'
46
- import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
46
+ import { executePlanPostcheck as runPlanPostcheckLib, parseRepoRegistry } from '../stages/plan-postcheck.js'
47
47
  import { outputStep, collectStageWaitHistory } from './prompt.js'
48
48
  import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, warnMissingUiPrototype, completeStageGates, readDesignScale } from './gates.js'
49
- import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
49
+ import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact, assertWaveTasksComplete } from './complete-handlers.js'
50
50
  import { formatExecuteSummary } from '../worktree-apply.js'
51
51
  import { validateDecisionModuleRefs } from '../design-facts.js'
52
52
  import { isEndToEndTaskText } from '../change-risk-profile.js'
@@ -181,6 +181,29 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
181
181
  console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
182
182
  }
183
183
 
184
+ // ── 意图断言(坑 execute-concurrent-done-skips-next-wave 终解,2026-09-08 实证)──
185
+ // --step <名|序号> 显式声明本次要完成的步骤;与当前待完成步不一致 → fail-closed 拒绝。
186
+ // 防并发 --done 主形态:会话 B 先推进 Wave N,会话 A 随后的 --done 落到 Wave N+1 且用
187
+ // Wave N 的旧摘要静默完成未实现步骤——CLI 短进程无法读心,只有显式声明能硬拦。
188
+ // 序号 1-based;名称精确或前缀匹配(Wave 名常带「Wave 3 — xxx」长尾,前缀够用且防呆)。
189
+ if (options.stepAssert) {
190
+ const declared = String(options.stepAssert).trim()
191
+ const currentName = steps[currentIdx]?.name || ''
192
+ let match = false
193
+ if (/^\d+$/.test(declared)) {
194
+ match = currentIdx === parseInt(declared, 10) - 1
195
+ } else {
196
+ match = currentName === declared || currentName.startsWith(declared)
197
+ }
198
+ if (!match) {
199
+ console.error(`❌ --step 意图断言不匹配:声明完成「${declared}」,当前待完成步骤是「${currentName}」(第 ${currentIdx + 1}/${steps.length} 步)。`)
200
+ console.error(` 不一致的最常见原因:并行会话刚推进了本变更的步骤(你读的是旧进度)。`)
201
+ console.error(` 核对:sillyspec progress show${changeName ? ` --change ${changeName}` : ''};确认要做的是当前步则去掉 --step 重跑,确属误判自己想做的步则修正 --step 值。`)
202
+ process.exit(1)
203
+ }
204
+ console.log(`🛡️ 意图断言通过:--step「${declared}」= 当前步骤「${currentName}」`)
205
+ }
206
+
184
207
  // ── P0-2(noai-ir-roadmap §3):--output 省略 → CLI 按事实合成步骤摘要 ──
185
208
  // agent 手打摘要是每步命中的幻觉源;事实面(步骤名 + 变更窗口 + 门禁以 CLI 校验为准)
186
209
  // CLI 本就持有,纯事实合成零编造(判断词留给 gate)。语义性说明(方案取舍/用户反馈)仍应
@@ -465,6 +488,19 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
465
488
  process.exit(1)
466
489
  }
467
490
 
491
+ // ── Wave 步骤完成度门(2026-09-17-pass-cap-semantics task-08 / FR-12 / D-013@v1)──
492
+ // 动机锚:2026-09-17 本变更执行中 Wave 2 越位实证——review write 退出码被 shell 管道吞掉后
493
+ // --done 落在下一 Wave,步骤被静默标 completed;既有防护(--step 可选断言 / 60s 并发横幅)
494
+ // 均非阻断。本门 fail-closed:名为「Wave N 执行」的步骤 --done 时本 Wave 任一 task 的
495
+ // tasks.md checkbox 未勾 → exit 1(先 autoCheckPlanFromReviews 幂等补勾再核对,见
496
+ // complete-handlers.js assertWaveTasksComplete)。门自身异常 fail-open warn——门 bug 不
497
+ // 锁死完成路径,正常判定路径硬拦;exit 时 status 尚未赋 completed,DB 不落假完成态。
498
+ try {
499
+ await assertWaveTasksComplete({ steps, currentIdx, changeName, cwd, specBase, platformOpts })
500
+ } catch (e) {
501
+ console.warn(`⚠️ Wave 完成度门异常,fail-open 放行(不阻断本次完成): ${(e && e.message) || e}`)
502
+ }
503
+
468
504
  steps[currentIdx].status = 'completed'
469
505
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
470
506
  if (outputText) {
@@ -666,6 +702,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
666
702
  const total = steps.length
667
703
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
668
704
 
705
+ // 平台模式产物落点指针(platform-docs-dual-location,2026-09-15 EHS 实证):完成时点在
706
+ // 工作树 .sillyspec/ 刷新人类可读指针(本地模式零行为,fail-soft 内部全兜)。
707
+ writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
708
+
669
709
  // task-04(2026-09-10-change-scope-audit):execute 完成打范围对账全表+落 .runtime 快照 /
670
710
  // verify 完成出一行漂移确认。advisory fail-soft(D-006)——helper 内部全兜,绝不阻断完成。
671
711
  await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
@@ -931,7 +971,11 @@ function readTaskCardText(changeDir, taskNum) {
931
971
  * 用于 autoCheckPlanFromReviews(勾选层)和 detectExecuteBatchFinish(批量层)。
932
972
  * 失败返回 null(调用方降级:勾选层不勾、批量层不过滤——向后兼容)。
933
973
  *
934
- * @returns {Promise<{gitDir:string, base:string, head:string}|null>}
974
+ * 2026-09-15-worktree-dual-truth-gates task-04(坑④/D-004@v1):ctx 增透传 cwd/changeName/meta/
975
+ * baselineFiles——prefetchDiffFileSet 消费 collectWorktreeChangedFiles(与草稿归因同口径)并剔
976
+ * baseline 夹带。扩展字段对 shouldAutoCheckTask 只读 gitDir/base/head/diffFileSet 零影响。
977
+ *
978
+ * @returns {Promise<{gitDir:string, base:string, head:string, cwd:string, changeName:string, meta:object|null, baselineFiles:string[]}|null>}
935
979
  */
936
980
  async function buildDraftContext(cwd, changeName) {
937
981
  try {
@@ -952,7 +996,13 @@ async function buildDraftContext(cwd, changeName) {
952
996
  }
953
997
  }
954
998
  if (base && head && gitDir) {
955
- return { gitDir, base, head }
999
+ return {
1000
+ gitDir, base, head,
1001
+ cwd, changeName,
1002
+ meta: meta || null,
1003
+ // baselineFiles 缺失/非数组按 [] 容错(prefetchDiffFileSet 剔除夹带用)
1004
+ baselineFiles: Array.isArray(meta?.baselineFiles) ? meta.baselineFiles : [],
1005
+ }
956
1006
  }
957
1007
  return null
958
1008
  } catch (e) {
@@ -962,16 +1012,96 @@ async function buildDraftContext(cwd, changeName) {
962
1012
  }
963
1013
 
964
1014
  /**
965
- * 预取 base..head 全量 diff 文件集(一次 git spawn),供草稿零 diff 守卫按 task 内存归属判定。
1015
+ * 预取草稿零 diff 守卫用的全量 diff 文件集,供 shouldAutoCheckTask 按 task 内存归属判定。
966
1016
  * 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
967
- * 8 task ≈ 8-16 次串行 spawn)。失败返回 null → 调用方回退逐 task 实测路径。
1017
+ * 8 task ≈ 8-16 次串行 spawn)。失败返回 ctx 原样 → 调用方回退逐 task 实测路径。
1018
+ *
1019
+ * 2026-09-15-worktree-dual-truth-gates task-04(坑④ worktree-tick-guard-commit-only / D-004@v1):
1020
+ * 单算 base..head(已提交)在子代理默认不 commit 时恒空/缺文件 → 守卫全跳过勾选。现并入
1021
+ * collectWorktreeChangedFiles(task-review.js 单一真相:porcelain 未提交 ∪ committed
1022
+ * merge-base 补齐,与草稿归因同口径),再剔 meta.baselineFiles(R-07 防伪底线:merge-base..
1023
+ * wtHEAD 含 baseline checkpoint 提交,夹带文件=非本变更改动,不剔则「声明未做恰被夹带」的
1024
+ * task 误勾)。全空时仍不设 diffFileSet(退回逐 task 实测路径,原语义)。
1025
+ *
1026
+ * task-05 FR-08:另并入跨仓仓根双源(diff HEAD~1..HEAD ∪ status porcelain,见函数体
1027
+ * collectCrossRepoDiffRoots 注记)——跨仓 task 的 changedFiles 按其仓根相对路径命中。
968
1028
  */
1029
+ /**
1030
+ * 跨仓仓根收集(task-05 / FR-08 diff 源层):读侧口径 = <cwd>/.sillyspec/local.yaml 的
1031
+ * parseRepoRegistry(与 design-facts.js 有段头分支 / execute MultiRepoContext 同款);相对
1032
+ * 注册路径按 cwd resolve,绝对路径原样,根不存在跳过。local.yaml 不可读/解析失败 fail-open
1033
+ * 返回 [](不炸勾选主流程、不放大勾选面)。
1034
+ */
1035
+ function collectCrossRepoDiffRoots(cwd) {
1036
+ const roots = []
1037
+ try {
1038
+ if (!cwd) return roots
1039
+ const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
1040
+ if (!existsSync(localYamlPath)) return roots
1041
+ const registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8'))
1042
+ if (!registry) return roots
1043
+ for (const raw of registry.values()) {
1044
+ if (!raw) continue
1045
+ const root = isAbsolute(raw) ? raw : resolve(cwd, raw)
1046
+ if (root && existsSync(root)) roots.push(root)
1047
+ }
1048
+ } catch { /* registry 不可读 → 空表(fail-open) */ }
1049
+ return roots
1050
+ }
1051
+
969
1052
  function prefetchDiffFileSet(ctx) {
970
1053
  if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
971
1054
  try {
972
1055
  const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
973
- if (!out) return ctx
974
- return { ...ctx, diffFileSet: new Set(out.split('\n').filter(Boolean)) }
1056
+ const diffFileSet = new Set(out ? out.split('\n').filter(Boolean) : [])
1057
+ // D-004@v1 并入:porcelain 未提交 ∪ committed 补齐(helper 自身 fail-open,git 失败返 []
1058
+ // → 退回 base..head 现状,不放大勾选面);独立 try 防 helper 意外拖垮既有 base..head 集
1059
+ try {
1060
+ if (ctx.cwd && ctx.changeName) {
1061
+ for (const f of collectWorktreeChangedFiles(ctx.cwd, ctx.changeName, ctx.meta)) {
1062
+ if (f) diffFileSet.add(f)
1063
+ }
1064
+ }
1065
+ } catch { /* helper 异常 → 退回 base..head 现状(fail-open) */ }
1066
+ // 剔 baseline 夹带:按正斜杠归一 exact 匹配(两侧同 git 口径路径);baselineFiles 缺失按 [] 容错
1067
+ const baselineFiles = Array.isArray(ctx.baselineFiles) ? ctx.baselineFiles : []
1068
+ for (const b of baselineFiles) {
1069
+ const n = String(b).replace(/\\/g, '/').trim()
1070
+ if (n) diffFileSet.delete(n)
1071
+ }
1072
+ // task-05 FR-08:跨仓仓根 diff 双源并入(adopt 通道跨仓 review 的 changedFiles 是其仓根
1073
+ // 相对路径,只算主仓源时 shouldAutoCheckTask 的 ctx.diffFileSet.has(f) 恒 miss → 跨仓
1074
+ // task 自动勾选断链)。双源 = git diff --name-only HEAD~1..HEAD ∪ git status --porcelain
1075
+ // --untracked-files=all(cross-repo-reconcile.js:94-106 双源口径只读复用,数组参数防
1076
+ // shell 拆词),产物正斜杠归一;置于剔 baseline 之后(跨仓仓不含主仓 baseline checkpoint
1077
+ // 提交,不被主仓夹带清单误剔)。git 失败/registry 不可读 fail-open 跳过该仓不炸主流程。
1078
+ try {
1079
+ for (const root of collectCrossRepoDiffRoots(ctx.cwd)) {
1080
+ let anySource = false
1081
+ const diffOut = gitQuiet(root, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: 30 * 1000 })
1082
+ if (diffOut !== null) {
1083
+ anySource = true
1084
+ for (const f of String(diffOut).split('\n')) {
1085
+ const n = String(f || '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
1086
+ if (n) diffFileSet.add(n)
1087
+ }
1088
+ }
1089
+ const statusOut = gitQuiet(root, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30 * 1000, trim: false })
1090
+ if (statusOut !== null) {
1091
+ anySource = true
1092
+ for (const line of String(statusOut).split('\n')) {
1093
+ // porcelain 行 = XY<space>path(剥前 3 列);rename 取 -> 后新路径;剥引号、正斜杠归一
1094
+ let p = line.slice(3).trim()
1095
+ if (p.includes(' -> ')) p = p.slice(p.lastIndexOf(' -> ') + 4).trim()
1096
+ p = p.replace(/^"|"$/g, '').replace(/\\/g, '/')
1097
+ if (p) diffFileSet.add(p)
1098
+ }
1099
+ }
1100
+ if (!anySource) console.warn(`⚠️ 跨仓仓根 ${root} git 不可用/非仓库(diff 与 status 双失败),跳过该仓 diff 源(fail-open,不阻断勾选主流程)`)
1101
+ }
1102
+ } catch { /* 跨仓源异常 → 不炸主流程(fail-open) */ }
1103
+ if (diffFileSet.size === 0) return ctx
1104
+ return { ...ctx, diffFileSet }
975
1105
  } catch {
976
1106
  return ctx
977
1107
  }
@@ -1546,6 +1676,8 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1546
1676
  // gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
1547
1677
  pm._write(cwd, progress, changeName)
1548
1678
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
1679
+ // 平台模式产物落点指针:与 completeStep 完成分支同款(本路径不漏)。
1680
+ writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
1549
1681
  // task-04:wait 解除完成路径与 completeStep 完成分支同源注入(Grill G-2:本路径不漏,
1550
1682
  // 否则 execute 快照缺失 → verify 假报「无快照可对比」)。
1551
1683
  await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
@@ -14,7 +14,7 @@
14
14
  * 快照生命周期:一次门禁一批(create → run → cleanup);崩溃残留由 git worktree prune /
15
15
  * 临时目录自然回收(OS tmp 清理),不进主仓 .runtime。
16
16
  */
17
- import { execFileSync } from 'node:child_process'
17
+ import { execFileSync, spawnSync } from 'node:child_process'
18
18
  import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync, existsSync, readdirSync, statSync, symlinkSync, readFileSync } from 'node:fs'
19
19
  import { join, dirname, relative, resolve } from 'node:path'
20
20
  import { tmpdir } from 'node:os'
@@ -23,12 +23,336 @@ function git(cwd, args) {
23
23
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000 }).trim()
24
24
  }
25
25
 
26
+ /**
27
+ * overlay import 冒烟多轮收敛(坑 verify-sandbox-overlay-partial-state-importerror):
28
+ * 单轮回退有两个盲点——①坏的是依赖文件(缺名/缺模块),报错的是 importer,回退 importer
29
+ * 不解决问题;②一次回退后依赖关系变化。策略:最多 3 轮,每轮对未回退的 overlay .py 冒烟;
30
+ * 失败时解析报错点名的模块名 → 若能映射到**另一个 overlay .py 文件**则回退那个依赖文件
31
+ *(事故形态:crud.py 半成品缺名,importer 报错点名 crud → 回退 crud 的 HEAD 健版),
32
+ * 否则回退报错文件自身(语法坏/自身缺依赖);HEAD 无版的新文件回退不了 → warn 保留。
33
+ */
34
+ function runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidFiles) {
35
+ const reverted = new Set()
36
+ const byModule = new Map() // 末段模块名 → 文件(映射报错点名)
37
+ for (const f of overlaidFiles) byModule.set(f.split('/').pop().replace(/\.py$/, ''), f)
38
+ const revertToHead = (f, why) => {
39
+ try {
40
+ writeFileSync(join(snapshotRoot, f), git(snapshotRoot, ['show', `HEAD:${f}`]))
41
+ reverted.add(f)
42
+ console.warn(`⚠️ 快照 overlay 冒烟:${f} ${why}——已回退 HEAD 版(并行会话半成品部分态不进沙箱)`)
43
+ return true
44
+ } catch {
45
+ console.warn(`⚠️ 快照 overlay 冒烟:${f} ${why}且 HEAD 无该文件(新增半成品)——保留原样,模块子集实测若红先查此文件`)
46
+ return false
47
+ }
48
+ }
49
+ for (let round = 0; round < 3; round++) {
50
+ let acted = false
51
+ for (const f of overlaidFiles) {
52
+ if (reverted.has(f)) continue
53
+ const bad = smokeImportPython(pythonBin, snapshotRoot, f)
54
+ if (!bad) continue
55
+ acted = true
56
+ // 依赖归因:报错点名另一 overlay 文件 → 回退依赖( importer 自身往往无恙)
57
+ const named = bad.match(/(?:No module named|from)\s+['"]?([A-Za-z_][\w.]*)/i)
58
+ const depFile = named ? byModule.get(named[1].split('.').pop()) : null
59
+ if (depFile && depFile !== f && !reverted.has(depFile)) {
60
+ if (revertToHead(depFile, `被 ${f} 的 import 报错点名(${bad.slice(0, 60)})`)) continue
61
+ }
62
+ revertToHead(f, `import 失败(${bad.slice(0, 60)})`)
63
+ }
64
+ if (!acted) return
65
+ }
66
+ }
67
+
68
+ /**
69
+ * 快照内寻找 python 解释器(overlay 冒烟用):环境目录发现集里的 venv 族
70
+ *(.venv/venv/env)在快照内已 junction——按平台拼 Scripts/bin 下的 python 路径。
71
+ * @returns {string|null} 解释器绝对路径;找不到返回 null
72
+ */
73
+ function findSnapshotPython(envRelDirs, snapshotRoot) {
74
+ for (const rel of envRelDirs) {
75
+ if (!/(^|\/)(\.?venv|env)(\/|$)/.test(rel)) continue
76
+ const bin = process.platform === 'win32'
77
+ ? join(snapshotRoot, rel, 'Scripts', 'python.exe')
78
+ : join(snapshotRoot, rel, 'bin', 'python')
79
+ if (existsSync(bin)) return bin
80
+ }
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * 单文件 import 冒烟:`python -c "import <mod>"`,cwd 取该文件所在包根(模块路径按
86
+ * 目录结构推导:剥 .py、/→.、丢 __init__;相对包根 import)。非 0 退出且 stderr 命中
87
+ * ImportError/ModuleNotFoundError/SyntaxError 才判坏(其余失败如缺第三方依赖是环境问题,
88
+ * 不回退——回退语义只针对「部分态坏文件」)。超时 10s 防挂。
89
+ * @returns {string|null} 失败原因(判坏时);通过/环境性失败返回 null
90
+ */
91
+ function smokeImportPython(pythonBin, snapshotRoot, relFile) {
92
+ const noExt = relFile.replace(/\.py$/, '')
93
+ const parts = noExt.split('/')
94
+ while (parts.length > 0 && parts[parts.length - 1] === '__init__') parts.pop()
95
+ if (parts.length === 0) return null
96
+ // 包根推导:从深到浅尝试(backend/app/x/y.py → 先 app.x.y(cwd=backend)再 x.y(cwd=backend/app))
97
+ for (let cut = 0; cut < parts.length; cut++) {
98
+ const mod = parts.slice(cut).join('.')
99
+ if (!mod || !/^[A-Za-z_][\w.]*$/.test(mod)) continue
100
+ const pkgRoot = join(snapshotRoot, ...parts.slice(0, cut))
101
+ const r = spawnSync(pythonBin, ['-c', `import ${mod}`], {
102
+ cwd: pkgRoot, encoding: 'utf-8', timeout: 10_000,
103
+ })
104
+ if (r.status === 0) return null
105
+ 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)
108
+ }
109
+ // 环境性失败(如缺第三方依赖、非模块入口冲突)→ 不判坏,继续试更浅包根
110
+ }
111
+ return null
112
+ }
113
+
114
+ /**
115
+ * local.yaml `gate_snapshot:` 段 `copy:` 列表的轻量行扫描(与 verify-postcheck
116
+ * extractLintCommand 同风格——单键定向取值,不引 js-yaml,本文件零新依赖)。
117
+ *
118
+ * 认块列表(缩进 `- <path>` 条目)与 inline flow(`copy: [a, b]`)两形态(对齐
119
+ * worktree.supplyFiles 等既有数组键的「块列表或 inline flow 均可解析」约定);空行/注释行
120
+ * 跳过不破段;离开 gate_snapshot 段(缩进回收到段头及以下 / 新顶层键)即止。容错:值剥
121
+ * 尾注(` #…`)与成对引号。
122
+ * @param {string} yamlText local.yaml 原文
123
+ * @returns {string[]} copy 条目原始值(未规整,交 applyGateSnapshotCopy 逐条规整)
124
+ */
125
+ function _stripYamlValue(v) {
126
+ let s = String(v).trim()
127
+ const hash = s.indexOf(' #')
128
+ if (hash >= 0) s = s.slice(0, hash).trim()
129
+ if ((s.startsWith('"') && s.endsWith('"') && s.length >= 2) || (s.startsWith("'") && s.endsWith("'") && s.length >= 2)) {
130
+ s = s.slice(1, -1).trim()
131
+ }
132
+ return s
133
+ }
134
+
135
+ function parseGateSnapshotCopy(yamlText) {
136
+ if (!yamlText) return []
137
+ const out = []
138
+ let inGate = false // 位于 gate_snapshot: 段内
139
+ let gateIndent = -1
140
+ let inCopy = false // 位于段内 copy: 列表内
141
+ let copyIndent = -1
142
+ for (const raw of String(yamlText).split(/\r?\n/)) {
143
+ const content = raw.trim()
144
+ if (!content || content.startsWith('#')) continue // 空行/注释行不破段
145
+ const indent = raw.match(/^[ \t]*/)[0].length
146
+ if (!inGate) {
147
+ if (/^gate_snapshot:\s*(?:#.*)?$/.test(content)) { inGate = true; gateIndent = indent; inCopy = false }
148
+ continue
149
+ }
150
+ if (indent <= gateIndent) { inGate = false; inCopy = false; continue } // 出段(新顶层键)
151
+ if (!inCopy) {
152
+ const flow = content.match(/^copy:\s*\[([^\]]*)\]/)
153
+ if (flow) {
154
+ for (const part of flow[1].split(',')) {
155
+ const v = _stripYamlValue(part)
156
+ if (v) out.push(v)
157
+ }
158
+ continue // inline flow 当行自洽,继续扫段内后续内容
159
+ }
160
+ if (/^copy:\s*(?:#.*)?$/.test(content)) { inCopy = true; copyIndent = indent }
161
+ continue // 段内其它子键忽略
162
+ }
163
+ if (indent < copyIndent) { inCopy = false; continue } // 缩进回收出列表
164
+ const item = content.match(/^-\s*(.+)$/)
165
+ if (!item) { inCopy = false; continue } // 同层非列表行 = 列表结束
166
+ const v = _stripYamlValue(item[1])
167
+ if (v) out.push(v)
168
+ }
169
+ return out
170
+ }
171
+
172
+ /**
173
+ * local.yaml `gate_snapshot:` 段 `commands:` 列表扫描(2026-09-17-feedback-hardening
174
+ * R1 / FR-01 / D-002@v2,坑 gate-snapshot-missing-generated-artifacts 二阶——copy 面只搬
175
+ * 「主仓现有态」,主仓侧生成物缺失(fresh clone 未跑 postinstall)或过期(build-id 类
176
+ * 构建期产物随版本变)时供给缺失/陈旧态,快照内全量 lint/test 必挂,用户 2026-09-17
177
+ * 负面①实证)。与 parseGateSnapshotCopy 同风格段内单键扫描(本文件零新依赖,不引
178
+ * js-yaml);认块列表(缩进 `- <cmd>` 条目)与 inline flow(`commands: [a, b]`)双形态
179
+ *(对齐 supplyFiles/copy 既有数组键约定);空行/注释行不破段;缩进回收到段头及以下/
180
+ * 新顶层键即出段——段外同名键(顶层 `commands:` 命令段 / 其它段内嵌 commands)不误收。
181
+ * @param {string} yamlText local.yaml 原文
182
+ * @returns {string[]} 命令串(未配置/空段/读失败 → [])
183
+ */
184
+ export function parseGateSnapshotCommands(yamlText) {
185
+ if (!yamlText) return []
186
+ const out = []
187
+ let inGate = false // 位于 gate_snapshot: 段内
188
+ let gateIndent = -1
189
+ let inCmds = false // 位于段内 commands: 列表内
190
+ let cmdsIndent = -1
191
+ for (const raw of String(yamlText).split(/\r?\n/)) {
192
+ const content = raw.trim()
193
+ if (!content || content.startsWith('#')) continue // 空行/注释行不破段
194
+ const indent = raw.match(/^[ \t]*/)[0].length
195
+ if (!inGate) {
196
+ if (/^gate_snapshot:\s*(?:#.*)?$/.test(content)) { inGate = true; gateIndent = indent; inCmds = false }
197
+ continue
198
+ }
199
+ if (indent <= gateIndent) { inGate = false; inCmds = false; continue } // 出段(新顶层键)
200
+ if (!inCmds) {
201
+ const flow = content.match(/^commands:\s*\[([^\]]*)\]/)
202
+ if (flow) {
203
+ for (const part of flow[1].split(',')) {
204
+ const v = _stripYamlValue(part)
205
+ if (v) out.push(v)
206
+ }
207
+ continue // inline flow 当行自洽,继续扫段内后续内容
208
+ }
209
+ if (/^commands:\s*(?:#.*)?$/.test(content)) { inCmds = true; cmdsIndent = indent }
210
+ continue // 段内其它子键(copy 等)忽略
211
+ }
212
+ if (indent < cmdsIndent) { inCmds = false; continue } // 缩进回收出列表
213
+ const item = content.match(/^-\s*(.+)$/)
214
+ if (!item) { inCmds = false; continue } // 同层非列表行 = 列表结束
215
+ const v = _stripYamlValue(item[1])
216
+ if (v) out.push(v)
217
+ }
218
+ return out
219
+ }
220
+
221
+ /**
222
+ * gate_snapshot.commands 执行段(D-002@v2):快照构建期在快照根逐条执行生成物命令,产出
223
+ * 「本仓应然态」。调用契约:cwd=**快照根**(环境目录 junction 已就位;命令产出真实文件
224
+ * → 后续 copy 面对已存在 dst 跳过,新鲜度优先)。每条 300s 超时帽;非零退出/超时只
225
+ * warn(含 cmd 与输出尾 ≤300 字)继续下一条——fail-open 与 copy 面同策略,不作废快照;
226
+ * 执行结果不落 meta(快照即用即弃,console 输出即审计面)。信任级与
227
+ * commands.install/test/lint 同:主仓 local.yaml 用户自持配置,不加命令白名单。
228
+ * 内部导出(createGateSnapshot 唯一运行时消费方;直测断言返回值)。
229
+ * @param {string} snapshotRoot 快照根(命令 cwd)
230
+ * @param {string[]} commands 命令串列表(parseGateSnapshotCommands 产物)
231
+ * @returns {{ ran: number, failed: Array<{cmd: string, reason: string}> }} ran=实际执行条数(含失败),failed=逐条失败记录
232
+ */
233
+ export function runGateSnapshotCommands(snapshotRoot, commands) {
234
+ if (!Array.isArray(commands) || commands.length === 0) return { ran: 0, failed: [] }
235
+ let ran = 0
236
+ const failed = []
237
+ for (const raw of commands) {
238
+ const cmd = String(raw).trim()
239
+ if (!cmd) continue
240
+ ran++
241
+ let r = null
242
+ try {
243
+ r = spawnSync(cmd, { shell: true, cwd: snapshotRoot, timeout: 300_000, encoding: 'utf8' })
244
+ } catch (e) {
245
+ const reason = `spawn 异常:${e && e.message ? e.message : e}`
246
+ failed.push({ cmd, reason })
247
+ console.warn(`⚠️ gate_snapshot.commands 条目失败(${reason},不作废快照):${cmd}`)
248
+ continue
249
+ }
250
+ const timedOut = r && r.error && (r.error.code === 'ETIMEDOUT' || /ETIMEDOUT/i.test(String(r.error.message || '')))
251
+ if (r.error || r.status !== 0) {
252
+ const reason = timedOut
253
+ ? '超时(>300s 帽)'
254
+ : (r.error ? `spawn 失败:${r.error.message}` : `非零退出码 ${r.status}`)
255
+ failed.push({ cmd, reason })
256
+ const tail = String((r.stdout || '') + (r.stderr || '')).trim().slice(-300)
257
+ console.warn(`⚠️ gate_snapshot.commands 条目失败(${reason},不作废快照继续下一条):${cmd}${tail ? `|输出尾:${tail}` : ''}`)
258
+ continue
259
+ }
260
+ }
261
+ return { ran, failed }
262
+ }
263
+
264
+ /** 目录递归复制(copy 面 junction 不可用时的回退)。符号链接条目跳过(生成物目录内不追链)。 */
265
+ function copyDirRecursive(srcDir, dstDir) {
266
+ mkdirSync(dstDir, { recursive: true })
267
+ for (const e of readdirSync(srcDir, { withFileTypes: true })) {
268
+ const s = join(srcDir, e.name)
269
+ const d = join(dstDir, e.name)
270
+ if (e.isDirectory()) copyDirRecursive(s, d)
271
+ else if (e.isFile()) copyFileSync(s, d)
272
+ }
273
+ }
274
+
275
+ /**
276
+ * gate_snapshot.copy 面(2026-09-16-friction5-hardening R4 / FR-04 / D-002@v1,坑
277
+ * gate-snapshot-missing-generated-artifacts——用户 2026-09-16 驾驭小结④实证只能
278
+ * SNAPSHOT_OFF 对照):HEAD 快照缺 gitignored 生成物(api-types/generated 类不进 HEAD
279
+ * 也不在会话文件集)→ 快照内 lint/test 环境性假败。按主仓 local.yaml 的 copy 清单把
280
+ * 生成物从主仓 junction 链接进快照(失败回退复制),消除环境性假败。
281
+ *
282
+ * 调用契约:cwd=**主仓**根(local.yaml 从主仓读——快照内 local.yaml 是稍后复制的复制件,
283
+ * copy 面必须先于复制生效);在 overlay 之后调用(dst 已存在 = overlay 已覆盖本变更最新态,
284
+ * 跳过不覆盖)。
285
+ *
286
+ * 逐条容错(fail-open,与本文件快照基建策略一致,任何失败不作废快照):路径规整
287
+ *(String、反斜杠→正斜杠、trim);含 '..' 或绝对路径形态(POSIX / 与 Windows 盘符)拒绝
288
+ * warn 跳过;主仓不存在 warn 跳过;junction 抛错回退递归 copy(目录)/ copyFileSync(文件);
289
+ * 再失败 warn 跳过。成功 ≥1 条 console.log 一行报备。未配置/空清单/读失败 → 全段空转
290
+ * 零输出零行为(存量 local.yaml 逐字节不变)。
291
+ *
292
+ * ⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录(constraints 明示,
293
+ * config-schema desc 与 renderExample 注释同步警告)。
294
+ *
295
+ * 新增内部导出(symbol-impact:内部测试直测边界条目用;createGateSnapshot 唯一运行时消费方)。
296
+ * @param {string} cwd 主仓根
297
+ * @param {string} snapshotRoot 快照根
298
+ * @returns {number} 成功链接/复制的条目数
299
+ */
300
+ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
301
+ let entries = []
302
+ try {
303
+ const cfgPath = join(cwd, '.sillyspec', 'local.yaml')
304
+ if (!existsSync(cfgPath)) return 0
305
+ entries = parseGateSnapshotCopy(readFileSync(cfgPath, 'utf8'))
306
+ } catch { return 0 } // 读侧容错:解析/读失败与未配置同兜底(空转,不连坐快照)
307
+ if (!Array.isArray(entries) || entries.length === 0) return 0
308
+ let linked = 0
309
+ for (const raw of entries) {
310
+ const p = String(raw).replace(/\\/g, '/').trim()
311
+ if (!p) continue
312
+ if (p.includes('..') || p.startsWith('/') || /^[A-Za-z]:\//.test(p)) {
313
+ console.warn(`⚠️ gate_snapshot.copy 条目「${p}」含 .. 或绝对路径形态,拒绝(快照外写面)——跳过`)
314
+ continue
315
+ }
316
+ let srcStat = null
317
+ try { srcStat = statSync(join(cwd, p)) } catch { /* 主仓不存在,下方 warn 跳过 */ }
318
+ if (!srcStat) {
319
+ console.warn(`⚠️ gate_snapshot.copy 条目主仓不存在,跳过:${p}`)
320
+ continue
321
+ }
322
+ const src = join(cwd, p)
323
+ const dst = join(snapshotRoot, p)
324
+ if (existsSync(dst)) continue // overlay 已覆盖(本变更最新态优先)
325
+ try {
326
+ mkdirSync(dirname(dst), { recursive: true })
327
+ let viaLink = false
328
+ try {
329
+ symlinkSync(src, dst, 'junction')
330
+ // Windows 文件 junction 可「建成但不解析」(重解析点指向非目录,symlinkSync 不抛、
331
+ // existsSync 为假)——链接后验真,假成功回退复制(平台差异实测坑)
332
+ viaLink = existsSync(dst)
333
+ } catch { viaLink = false }
334
+ if (!viaLink) {
335
+ try { rmSync(dst, { recursive: true, force: true }) } catch { /* 假链接残留清不掉 → 下方复制报错走 warn */ }
336
+ if (srcStat.isDirectory()) copyDirRecursive(src, dst)
337
+ else copyFileSync(src, dst)
338
+ }
339
+ linked++
340
+ } catch (e) {
341
+ console.warn(`⚠️ gate_snapshot.copy 条目「${p}」junction/复制均失败:${e && e.message ? e.message : e}——跳过(不作废快照)`)
342
+ }
343
+ }
344
+ if (linked > 0) {
345
+ console.log(`🔬 门禁快照 copy 面:${linked} 个 gate_snapshot.copy 条目自主仓 junction/复制进快照(⚠️ junction 是活链接,快照内再跑生成命令会写穿到主仓该目录)`)
346
+ }
347
+ return linked
348
+ }
349
+
26
350
  /**
27
351
  * 创建隔离快照:HEAD worktree + 会话文件 overlay + node_modules junction + local.yaml。
28
352
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
29
353
  * @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
30
354
  */
31
- export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
355
+ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false }) {
32
356
  let snapshotRoot = null
33
357
  try {
34
358
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
@@ -103,6 +427,32 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
103
427
  return null
104
428
  }
105
429
 
430
+ // gate_snapshot.commands 命令面(D-002@v2,2026-09-17-feedback-hardening R1,用户
431
+ // 2026-09-17 负面①实证「全量快照必挂」):主仓侧生成物缺失(fresh clone 未跑
432
+ // postinstall)或过期时,copy 面只搬「主仓现有态」供给缺失/陈旧态——本段在环境链接
433
+ // 就位后、copy 面前于快照根逐条执行 postinstall 类生成命令,产出「本仓应然态」(命令
434
+ // 产出真实文件 → copy 面对已存在 dst 跳过,新鲜度优先)。配置恒从主仓 cwd 读原文
435
+ //(local.yaml 复制段在后面,快照内副本不采信——与 copy 键同源)。未配置 → 全段空转
436
+ // 零输出(存量快照逐字节不变);整段 try/catch fail-open:任何异常只 warn 不作废快照。
437
+ try {
438
+ const gsCfgPath = join(cwd, '.sillyspec', 'local.yaml')
439
+ const gsCommands = existsSync(gsCfgPath) ? parseGateSnapshotCommands(readFileSync(gsCfgPath, 'utf8')) : []
440
+ if (gsCommands.length > 0) {
441
+ console.log(`🔬 门禁快照 commands 面:${gsCommands.length} 条 gate_snapshot.commands 在快照内逐条执行(cwd=快照根,产出本仓应然态生成物)`)
442
+ console.warn(`⚠️ 环境目录是活链接:命令不得改写 node_modules/venv 等环境目录(写会穿透到主仓)——只放生成物命令(npm run gen:build-id 类)`)
443
+ runGateSnapshotCommands(snapshotRoot, gsCommands)
444
+ }
445
+ } catch (e) {
446
+ console.warn(`⚠️ gate_snapshot.commands 执行段异常(不作废快照):${e && e.message ? e.message : e}`)
447
+ }
448
+
449
+ // gate_snapshot.copy 面(R4 / D-002@v1,2026-09-16-friction5-hardening):gitignored
450
+ // 生成物从主仓 junction/复制进快照。位置=环境目录链接段之后(环境优先级最高)、
451
+ // local.yaml 复制段之前(从主仓 cwd 读原文——快照内 local.yaml 是稍后才复制的复制件,
452
+ // 且 copy 面必须先于复制生效);overlay 已在上面完成(dst 已存在即跳过,本变更最新态优先)。
453
+ // 未配置 gate_snapshot.copy → 全段空转零行为(见 applyGateSnapshotCopy jsdoc)。
454
+ applyGateSnapshotCopy(cwd, snapshotRoot)
455
+
106
456
  // local.yaml(gitignore 不进 HEAD;门禁命令配置来源)+ package-lock 保持 HEAD 版(npm test 不装新依赖)
107
457
  for (const cfg of [join('.sillyspec', 'local.yaml')]) {
108
458
  const src = join(cwd, cfg)
@@ -112,6 +462,26 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
112
462
  }
113
463
  }
114
464
 
465
+ // ── overlay import 闭合冒烟(坑 verify-sandbox-overlay-partial-state-importerror,
466
+ // 2026-09-13 实证:in-place 变更的文件集含主仓未提交并行 WIP——「改了引用方没改被引用方」
467
+ // 的部分态进快照,模块子集测试 197 ERROR 只能人工三步排查)──
468
+ // 对 overlay 进快照的 .py 文件逐个 `python -c import`(用快照内链接的 venv 解释器);
469
+ // ImportError/SyntaxError → 该文件回退 HEAD 版(git show)+ warn——半成品坏文件不进沙箱,
470
+ // HEAD 健版保证 import 图闭合。新增文件(HEAD 无版)无法回退 → 醒目 warn 保留。
471
+ // 全链 fail-open:找不到解释器/基建异常只 warn 不作废快照(主场景是 Python 项目,
472
+ // JS/TS 的 vitest load 冒烟成本高留后续)。
473
+ const overlaidPyFiles = files.filter(f => typeof f === 'string' && f.endsWith('.py')
474
+ && !f.startsWith('.sillyspec/')
475
+ && existsSync(join(snapshotRoot, f)))
476
+ if (overlaidPyFiles.length > 0 && !skipImportSmoke) {
477
+ const pythonBin = findSnapshotPython(envRelDirs, snapshotRoot)
478
+ if (pythonBin) {
479
+ runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidPyFiles)
480
+ } else {
481
+ console.warn(`⚠️ 快照 overlay 冒烟跳过:快照内未找到 python 解释器(venv 族未链接)——并行半成品部分态可能引发 ImportError 假红,实测红先 SNAPSHOT_OFF 对照`)
482
+ }
483
+ }
484
+
115
485
  const cleanup = () => {
116
486
  try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {
117
487
  try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch { /* 残留交 OS tmp 清理 */ }