sillyspec 3.28.7 → 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 (51) 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/package.json +1 -1
  7. package/src/agent-session-log.js +446 -35
  8. package/src/config-cat.js +6 -3
  9. package/src/constants.js +3 -0
  10. package/src/db.js +12 -3
  11. package/src/docs-check.js +137 -12
  12. package/src/docs-gate.js +3 -0
  13. package/src/doctor-diagnostics.js +19 -7
  14. package/src/friction-tally.js +7 -2
  15. package/src/git-helper.js +6 -4
  16. package/src/hooks/worktree-guard.js +59 -29
  17. package/src/index.js +168 -64
  18. package/src/local-detect.js +30 -15
  19. package/src/local-register.js +13 -4
  20. package/src/mcp-server.js +38 -8
  21. package/src/progress/change-registry.js +41 -35
  22. package/src/progress/consistency-doctor.js +11 -4
  23. package/src/progress/stage-machine.js +3 -1
  24. package/src/progress/step-store.js +30 -17
  25. package/src/progress.js +23 -0
  26. package/src/quicklog.js +44 -18
  27. package/src/run/command.js +17 -3
  28. package/src/run/complete-handlers.js +3 -3
  29. package/src/run/complete.js +28 -9
  30. package/src/run/concurrent-detect.js +1 -1
  31. package/src/run/gate-snapshot.js +115 -5
  32. package/src/run/gates.js +35 -5
  33. package/src/run/prompt.js +9 -1
  34. package/src/run/quick-audit.js +25 -2
  35. package/src/run/shared.js +98 -10
  36. package/src/run/verify-quality-scan.js +14 -3
  37. package/src/spec-sync.js +19 -14
  38. package/src/stage-contract-spec.js +4 -3
  39. package/src/stage-review.js +6 -5
  40. package/src/stages/brainstorm.js +1 -1
  41. package/src/stages/execute.js +6 -1
  42. package/src/stages/plan-postcheck.js +57 -12
  43. package/src/sync.js +66 -4
  44. package/src/task-review.js +41 -6
  45. package/src/verify-facts-schema.js +10 -3
  46. package/src/verify-postcheck.js +21 -6
  47. package/src/verify-probes.js +8 -3
  48. package/src/worktree-apply.js +56 -18
  49. package/src/worktree-cross.js +12 -18
  50. package/src/worktree.js +138 -25
  51. package/src/wt-commit.js +128 -0
@@ -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 走 */ }
@@ -1067,7 +1067,7 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1067
1067
  if (outputText) steps[currentIdx].output = null
1068
1068
  // 摩擦计数(friction-signal-hint task-04):quick 审计被拦也是摩擦信号——exit 前记
1069
1069
  // 一笔(changeName=quick 会话 id,tally 落 session 目录,成功收尾 consume 后随目录清理)。
1070
- recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-audit' })
1070
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-audit' })
1071
1071
  process.exit(1)
1072
1072
  }
1073
1073
  // P0-2(2026-09-02 跨 agent 工单):触及 src/test 的 quick --done 内置 test+lint
@@ -1090,7 +1090,7 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1090
1090
  if (outputText) steps[currentIdx].output = null
1091
1091
  // 摩擦计数(friction-signal-hint task-04):test/lint 实测门禁拦下同记摩擦——exit 前
1092
1092
  // 落 session tally,与 quick-audit 同款静默降级。
1093
- recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-test-lint' })
1093
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'gate_rollback', detail: 'quick-test-lint' })
1094
1094
  process.exit(1)
1095
1095
  }
1096
1096
  progress.lastQuickReview = review
@@ -1367,7 +1367,7 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
1367
1367
  // tally 随目录删除,晚于此就读不到。QUICKLOG 完成打印之后 consume,提示里的 postmortem
1368
1368
  // 建议有落点可循;全零/读失败零输出,摩擦提示失败不影响收尾。
1369
1369
  try {
1370
- const fr = consumeFrictionHint({ cwd, changeName, platformOpts })
1370
+ const fr = await consumeFrictionHint({ cwd, changeName, platformOpts })
1371
1371
  if (fr && fr.hint) console.log(`\n${fr.hint}`)
1372
1372
  } catch { /* 摩擦提示失败不影响收尾 */ }
1373
1373
 
@@ -673,7 +673,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
673
673
  if (stageName === 'verify') {
674
674
  try {
675
675
  const { consumeFrictionHint } = await import('../friction-tally.js')
676
- const r = consumeFrictionHint({ cwd, changeName, platformOpts })
676
+ const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
677
677
  if (r && r.hint) console.log(`\n${r.hint}`)
678
678
  } catch { /* 摩擦提示失败不影响收尾 */ }
679
679
  }
@@ -1071,8 +1071,14 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1071
1071
  const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
1072
1072
  const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
1073
1073
  const r = readReview(reviewPath)
1074
- // 端到端 task:必须 pass(cannot_verify 不算);普通 task:非 fail 即可(坑 execute-batch-complete-endtoend-checkbox)
1075
- 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))
1076
1082
  if (shouldAutoCheckTask(r, endToEnd, ctx)) {
1077
1083
  checkedCount++
1078
1084
  return `${p1}x${p2}` // 勾选
@@ -1212,6 +1218,11 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
1212
1218
  if (step.status === 'pending' || step.status === 'in-progress') {
1213
1219
  step.status = 'completed'
1214
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
1215
1226
  aligned++
1216
1227
  }
1217
1228
  }
@@ -1251,10 +1262,11 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
1251
1262
  process.exit(1)
1252
1263
  }
1253
1264
 
1254
- // 查找下一个 pending 或 in-progress 的步骤
1255
- 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')
1256
1268
  if (currentIdx === -1) {
1257
- 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\` 看全局下一步。`)
1258
1270
  process.exit(1)
1259
1271
  }
1260
1272
 
@@ -1513,7 +1525,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1513
1525
  if (stageName === 'verify') {
1514
1526
  try {
1515
1527
  const { consumeFrictionHint } = await import('../friction-tally.js')
1516
- const r = consumeFrictionHint({ cwd, changeName, platformOpts })
1528
+ const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
1517
1529
  if (r && r.hint) console.log(`\n${r.hint}`)
1518
1530
  } catch { /* 摩擦提示失败不影响收尾 */ }
1519
1531
  }
@@ -1569,7 +1581,10 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
1569
1581
  }
1570
1582
 
1571
1583
  const steps = stageData.steps
1572
- 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')
1573
1588
 
1574
1589
  if (currentIdx === -1) {
1575
1590
  const wsIdx = steps.findIndex(s => s.status === 'waiting')
@@ -1577,11 +1592,15 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
1577
1592
  console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
1578
1593
  console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
1579
1594
  } else {
1580
- 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\` 看全局下一步。`)
1581
1596
  }
1582
1597
  process.exit(1)
1583
1598
  }
1584
1599
 
1600
+ if (steps[currentIdx].status === 'blocked') {
1601
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 此前被门控阻断(blocked),本次 --skip 将跳过该步——若门控条件已修复,更宜直接 --done 推进。`)
1602
+ }
1603
+
1585
1604
  const defSteps = await getStageStepsAutoAware(stageName, cwd, progress, platformOpts?.specRoot || null)
1586
1605
  const stepDef = defSteps ? defSteps[currentIdx] : null
1587
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 定向跑」:
package/src/run/gates.js CHANGED
@@ -485,8 +485,14 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
485
485
  * 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
486
486
  * 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
487
487
  * 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
488
+ *
489
+ * 批量乐观标记一并回滚(坑 execute-batch-rollback-half-state,2026-08-29 用户实证):execute
490
+ * 批量完成(detectExecuteBatchFinish)把剩余步骤批量标 completed 后任一收尾 gate 失败,原先只
491
+ * 回滚 currentIdx → 落库「步骤 11 pending + 12-16 completed」半套状态,步骤序倒挂、要再补一次
492
+ * --done 才对齐。批量标记打 _batchAligned 戳(内存字段不落库),gate 失败 = 批量完成整体不成立,
493
+ * 戳上的步骤随当前步一起回滚 pending,下次 --done 满足条件再重新批量标记。
488
494
  */
489
- function rollbackStageCompletion(stageData, steps, currentIdx) {
495
+ export function rollbackStageCompletion(stageData, steps, currentIdx) {
490
496
  // 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
491
497
  if (stageData.status === 'completed') {
492
498
  stageData.status = 'in-progress'
@@ -496,6 +502,14 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
496
502
  steps[currentIdx].status = 'pending'
497
503
  steps[currentIdx].completedAt = null
498
504
  }
505
+ for (const s of steps) {
506
+ if (!s) continue
507
+ if (s._batchAligned && s.status === 'completed') {
508
+ s.status = 'pending'
509
+ s.completedAt = null
510
+ }
511
+ s._batchAligned = false
512
+ }
499
513
  }
500
514
 
501
515
  /**
@@ -511,7 +525,7 @@ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx,
511
525
  // friction 尾参(friction-signal-hint task-02):gate 失败摩擦埋点标签 {type, detail}——各调用点
512
526
  // 按所属 gate 段传来源标签(审查两段走 review_rejected),未传兜底通用 gate-cascade。
513
527
  // recordFrictionEvent 自带静默降级(非法类型/写失败返回 null),不影响回滚/返回语义。
514
- recordFrictionEvent({ cwd, changeName, platformOpts, ...(friction || { type: 'gate_rollback', detail: 'gate-cascade' }) })
528
+ void recordFrictionEvent({ cwd, changeName, platformOpts, ...(friction || { type: 'gate_rollback', detail: 'gate-cascade' }) })
515
529
  rollbackStageCompletion(stageData, steps, currentIdx)
516
530
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
517
531
  pm._write(cwd, progress, changeName)
@@ -646,7 +660,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
646
660
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
647
661
  verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
648
662
  if (verifyGateSnap) {
649
- console.log(`🧪 Verify 实测隔离快照:HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
663
+ console.log(`🧪 Verify 实测隔离快照(根:${verifyGateSnap.snapshotRoot}):HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
650
664
  }
651
665
  } catch { /* 快照链路异常 → 主仓现行为 */ }
652
666
  }
@@ -679,7 +693,11 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
679
693
  }
680
694
  } catch { /* fail-soft:回填失败不影响门禁 */ }
681
695
  if (testCheck.status === 'failed') {
682
- if (verifyGateSnap) { try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null }
696
+ if (verifyGateSnap) {
697
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
698
+ printSnapshotFailureHint(verifyGateSnap) } catch { /* 提示失败不影响阻断 */ }
699
+ try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null
700
+ }
683
701
  console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
684
702
  // 并行 WIP 归因鉴别(坑 verify-reconcile-foreign-wip):单一实现迁至 verify-quality-scan.js
685
703
  // renderVerifyTestAttribution(P0-1 前置二:noAI 质量扫描失败路径与本 --done 路径共用,
@@ -700,13 +718,21 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
700
718
  if (lintCheck) {
701
719
  console.log(`\n♻️ Verify lint 对账:复用 noAI 质量扫描步的实测结果(代码指纹匹配,免重跑)`)
702
720
  } else {
703
- lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
721
+ lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: verifyGateSnap ? 5 * 60 * 1000 : undefined })
722
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
723
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
724
+ // 并行噪声混入时失败输出带归属鉴定提示)
725
+ if (verifyGateSnap && lintCheck.status === 'failed' && /超时/.test(String(lintCheck.reason || ''))) {
726
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
727
+ lintCheck = runVerifyLintCheck({ cwd, specBase })
728
+ }
704
729
  if (lintCheck.status !== 'skipped') {
705
730
  console.log(`\n⏳ Verify lint 对账:CLI 亲自执行 local.yaml 的 commands.lint…`)
706
731
  }
707
732
  }
708
733
  // 快照收尾(test+lint 两检查都已完成;parity/删除探针走主仓 git 事实不依赖快照)
709
734
  const usedSnapshot = Boolean(verifyGateSnap)
735
+ const usedSnapInfo = verifyGateSnap ? { snapshotRoot: verifyGateSnap.snapshotRoot, changeFileCount: verifyGateSnap.changeFileCount, sourceRoot: verifyGateSnap.sourceRoot } : null
710
736
  if (verifyGateSnap) { try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null }
711
737
  printVerifyLintCheck(lintCheck)
712
738
  // lint 硬门(2026-09-09 升硬:观察期 14 次 5 败全真阳性,pre-push 本就硬拦——fail-fast 前移;
@@ -714,6 +740,10 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
714
740
  const { shouldBlockVerifyLint } = await import('../verify-postcheck.js')
715
741
  if (shouldBlockVerifyLint(lintCheck)) {
716
742
  console.error('\n❌ verify 阶段被阻断:CLI 亲自实测 lint 失败(agent 的 lint 自报告与实测不符时以实测为准)。')
743
+ if (usedSnapInfo) {
744
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
745
+ printSnapshotFailureHint(usedSnapInfo) } catch { /* 提示失败不影响阻断 */ }
746
+ }
717
747
  if (lintCheck.outputTail) {
718
748
  const tail = lintCheck.outputTail.split('\n').slice(-8).join('\n')
719
749
  console.error(' 输出(末尾):')
package/src/run/prompt.js CHANGED
@@ -22,7 +22,7 @@ import { basename, join } from 'node:path'
22
22
  import { existsSync, readFileSync, mkdirSync, readdirSync } from 'node:fs'
23
23
  import { writeAtomicSync } from '../fs-atomic.js'
24
24
  import { stageRegistry } from '../stages/index.js'
25
- import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, parsePorcelainPath, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
25
+ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, parsePorcelainPath, WAIT_MARKER_RE, QUICK_SID_RE, triggerStepStartSync } from './shared.js'
26
26
  import { renderSemanticGuardBlock, readSemanticGuardEnabled } from '../semantic-guard.js'
27
27
  import { renderStageContract } from '../stage-contract-spec.js'
28
28
  import { nowWallClock } from '../datetime.js'
@@ -309,6 +309,14 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
309
309
  return false
310
310
  }
311
311
 
312
+ // X3 渲染侧接线(2026-08-29-change-delete-closure-and-spec-pull 遗留活跃坑收口;曾因并行
313
+ // 会话提交丢弃一次,2026-09-12 复检重接):步骤 prompt 渲染即步骤起点——补推一次 progress
314
+ // 刷新平台 last_pushed_at(停滞判定不再把长步骤误报为「停滞」)。不 await:prompt 渲染不被
315
+ // 网络阻塞;未连接平台静默 / quick 会话降级跳过 / 8s 熔断 / 失败只 warn,契约全在
316
+ // triggerStepStartSync 内(shared.js docstring 即本接线点的说明),Node 事件循环保证在飞
317
+ // POST 落地后进程才退出。
318
+ triggerStepStartSync(cwd, changeName, platformOpts)
319
+
312
320
  // ── 首个 agent 可见步索引(「首步一次建立」类注入的锚点)──
313
321
  // step0 为 noAI(brainstorm/execute/verify 的 step1「进度确认」,2026-09-07)时不渲染 prompt,
314
322
  // persona/护栏/完成契约/铁律/平台路径约束等 step0 专属注入须顺延到首个渲染步,否则整段丢失
@@ -137,6 +137,17 @@ export function printQuickAuditReview(review) {
137
137
  }
138
138
  console.warn(` quick 不强制文档同步,欠账已随本条 QUICKLOG「审计:」行落盘(可事后追溯)。若改动触及接口/契约,建议顺手同步模块文档。`)
139
139
  }
140
+ // spec 共享面他者噪声(坑 foreign-spec-churn-fail-closed):并行会话归档移动/文档收尾的
141
+ // 删除与 EOL-only 假 M——软警告点名归因,不再逼 --allow-delete/--allow-new 解锁。
142
+ if (review.foreignSpecChurn && review.foreignSpecChurn.length > 0) {
143
+ console.warn(`⚠️ spec 共享面 ${review.foreignSpecChurn.length} 个非本会话声明的删除(.sillyspec/ docs/,疑似并行会话归档移动/收尾——归其会话收口,本会话不阻断):`)
144
+ for (const f2 of review.foreignSpecChurn.slice(0, 6)) console.warn(` - ${f2}`)
145
+ if (review.foreignSpecChurn.length > 6) console.warn(` … 共 ${review.foreignSpecChurn.length} 个`)
146
+ }
147
+ if (review.eolOnlyFiles && review.eolOnlyFiles.length > 0) {
148
+ console.warn(`⚠️ ${review.eolOnlyFiles.length} 个文件仅行尾重写(内容零变化,gen:types/编辑器 CRLF 假 M——已剔出本会话审计):${review.eolOnlyFiles.slice(0, 5).join('、')}${review.eolOnlyFiles.length > 5 ? ' 等' : ''}`)
149
+ console.warn(` 根治:.gitattributes 声明 text=auto eol=lf(或按文件类型钉死),生成器重写不再制造假 M`)
150
+ }
140
151
  if (review.docsCheckHint && review.docsCheckHint.invalid > 0) {
141
152
  console.warn(`\n📎 文档引用失效(docs check):本次改动的文档含 ${review.docsCheckHint.invalid}/${review.docsCheckHint.total} 处失效 file:line 引用。`)
142
153
  // 逐条指名(2026-09-11 用户实证:只报计数不指名哪处,四轮复现才定位——门禁输出
@@ -338,16 +349,28 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
338
349
  if (snapshot) {
339
350
  gateCwd = snapshot.snapshotRoot
340
351
  gateSpecBase = join(snapshot.snapshotRoot, '.sillyspec')
341
- console.log(`🧪 门禁隔离快照:HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
352
+ console.log(`🧪 门禁隔离快照(根:${snapshot.snapshotRoot}):HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
342
353
  }
343
354
  } catch { /* 快照链路异常 → 主仓现行为 */ }
344
355
  }
345
356
  try {
346
357
  const test = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
347
- const lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
358
+ let lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snapshot ? 5 * 60 * 1000 : undefined })
359
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
360
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
361
+ // 并行噪声混入时失败输出带归属鉴定提示)
362
+ if (snapshot && lint.status === 'failed' && /超时/.test(String(lint.reason || ''))) {
363
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
364
+ lint = runVerifyLintCheck({ cwd, specBase })
365
+ }
348
366
  const failed = []
349
367
  if (test.status === 'failed') failed.push('test')
350
368
  if (lint.status === 'failed') failed.push('lint')
369
+ if (failed.length > 0 && snapshot) {
370
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
371
+ printSnapshotFailureHint({ snapshotRoot: snapshot.snapshotRoot, changeFileCount: snapshot.overlaid, sourceRoot: snapshot.sourceRoot || null }, { offEnv: 'SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF' })
372
+ } catch { /* 提示失败不影响门禁语义 */ }
373
+ }
351
374
  return {
352
375
  action: failed.length > 0 ? 'fail' : 'pass',
353
376
  failed,