sillyspec 3.28.7 → 3.28.9

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 (79) hide show
  1. package/.claude/skills/sillyspec-quick/SKILL.md +2 -0
  2. package/.husky/pre-push +3 -1
  3. package/AGENTS.md +3 -2
  4. package/README.md +35 -11
  5. package/SKILL.md +24 -10
  6. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  7. package/package.json +1 -1
  8. package/src/agent-session-log.js +446 -35
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-cat.js +6 -3
  11. package/src/config-schema.js +32 -0
  12. package/src/constants.js +3 -0
  13. package/src/db.js +23 -6
  14. package/src/decision-distill.js +15 -3
  15. package/src/design-facts.js +33 -3
  16. package/src/docs-check.js +137 -12
  17. package/src/docs-gate.js +108 -6
  18. package/src/doctor-diagnostics.js +243 -9
  19. package/src/friction-ledger.js +171 -0
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +72 -30
  23. package/src/index.js +356 -81
  24. package/src/knowledge-classify.js +363 -0
  25. package/src/knowledge-hits.js +74 -0
  26. package/src/knowledge-stats.js +180 -0
  27. package/src/local-detect.js +30 -15
  28. package/src/local-register.js +13 -4
  29. package/src/mcp-server.js +38 -8
  30. package/src/progress/change-registry.js +224 -43
  31. package/src/progress/consistency-doctor.js +11 -4
  32. package/src/progress/shared.js +3 -2
  33. package/src/progress/stage-machine.js +3 -1
  34. package/src/progress/step-store.js +30 -17
  35. package/src/progress.js +123 -8
  36. package/src/quick-gate-profile.js +251 -0
  37. package/src/quicklog.js +199 -32
  38. package/src/review-dispatch.js +2 -1
  39. package/src/run/command.js +124 -15
  40. package/src/run/complete-handlers.js +514 -24
  41. package/src/run/complete.js +82 -25
  42. package/src/run/concurrent-detect.js +99 -1
  43. package/src/run/gate-snapshot.js +115 -5
  44. package/src/run/gates.js +85 -14
  45. package/src/run/prompt.js +143 -6
  46. package/src/run/quick-audit.js +136 -15
  47. package/src/run/shared.js +174 -20
  48. package/src/run/stage.js +6 -0
  49. package/src/run/verify-quality-scan.js +14 -3
  50. package/src/scan-diff.js +61 -30
  51. package/src/scan-postcheck.js +74 -0
  52. package/src/scan-refresh.js +477 -0
  53. package/src/scan-staleness.js +25 -13
  54. package/src/scope-audit.js +365 -35
  55. package/src/spec-sync.js +19 -14
  56. package/src/stage-contract-spec.js +4 -3
  57. package/src/stage-contract.js +185 -2
  58. package/src/stage-review.js +29 -12
  59. package/src/stages/brainstorm.js +7 -1
  60. package/src/stages/execute.js +91 -4
  61. package/src/stages/knowledge.js +52 -3
  62. package/src/stages/plan-postcheck.js +57 -12
  63. package/src/stages/quick.js +1 -1
  64. package/src/stages/verify.js +1 -0
  65. package/src/sync-noise.js +114 -6
  66. package/src/sync.js +201 -11
  67. package/src/task-review.js +333 -27
  68. package/src/verify-facts-schema.js +10 -3
  69. package/src/verify-postcheck.js +41 -6
  70. package/src/verify-probes.js +348 -7
  71. package/src/worktree-apply.js +594 -62
  72. package/src/worktree-cross.js +12 -18
  73. package/src/worktree-deps.js +100 -2
  74. package/src/worktree.js +255 -30
  75. package/src/wt-commit.js +128 -0
  76. package/templates/agents-instruction.md +2 -2
  77. package/templates/prompts/testcase-design.md +2 -1
  78. package/templates/prompts/verify-probes.md +4 -1
  79. package/sa-rename-probe/src/new.js +0 -4
@@ -23,6 +23,7 @@ import { writeAtomicSync } from '../fs-atomic.js'
23
23
  import { gitQuiet } from '../git-helper.js'
24
24
  import { withFileLock } from '../quicklog.js'
25
25
  import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
26
+ import { isExplicitReviewWrite } from '../task-review.js'
26
27
 
27
28
  // auto 模式 brainstorm 步骤表感知解析(2026-09-08 E2E 实证 bug:--done/--wait 推进主模式 8 步表,
28
29
  // 与 runAutoMode getAutoSteps 的 4 步 auto 表双轨互踩——ensureAutoStage 判非 auto 表重种清零,
@@ -119,7 +120,7 @@ export function synthesizeStepOutput({ stageName, stepName, cwd }) {
119
120
  }
120
121
 
121
122
  export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
122
- const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false, quickFiles = [] } = options
123
+ const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false, isNoDocs = false, isSkipApply = false, sessionFlag = null, quickFiles = [] } = options
123
124
  // specRoot(平台) > specDriftAnchor(worktree 漂移锚定主仓) > cwd/.sillyspec(本地)——
124
125
  // 与 prompt.js resolvePromptSpecBase 同序:--done 收尾的 user-inputs.md/超长 artifact 落盘
125
126
  // 必须与 prompt 渲染同一根,否则写进 worktree 副本目录、随 cleanup 整目录删除。
@@ -494,7 +495,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
494
495
  // archive「确认归档」收尾:--confirm 门控 + archiveChangeDirectory 移动 + 推荐文档校验
495
496
  // (抽成 handleArchiveConfirmStep;缺 --confirm 时返回 early-return 对象,completeStep 透传)
496
497
  {
497
- const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts })
498
+ const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts, isSkipApply, sessionFlag })
498
499
  if (_archiveEarlyReturn) return _archiveEarlyReturn
499
500
  }
500
501
 
@@ -568,8 +569,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
568
569
  console.log(` 恢复:sillyspec run ${stageName} --continue --answer "用户回答"${changeName ? ` --change ${changeName}` : ''}`)
569
570
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
570
571
  }
571
- // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion)
572
- await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, isAllowDelete, platformOpts, pm, quickFiles })
572
+ // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion);
573
+ // isNoDocs(task-02)--no-docs 豁免随链透传进 auditQuickCompletion options;
574
+ // sessionFlag(task-03)--session 随链透传进 quick 轻量归档链所有权校验
575
+ await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, isAllowDelete, isNoDocs, sessionFlag, platformOpts, pm, quickFiles })
573
576
 
574
577
  // ── reopen --done 回填(坑 brainstorm-reopen-step-state-desync)──
575
578
  // nextPendingIdx === -1 且无 waiting,说明要进阶段完成分支。此时若存在 stale 步骤
@@ -673,9 +676,18 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
673
676
  if (stageName === 'verify') {
674
677
  try {
675
678
  const { consumeFrictionHint } = await import('../friction-tally.js')
676
- const r = consumeFrictionHint({ cwd, changeName, platformOpts })
679
+ const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
677
680
  if (r && r.hint) console.log(`\n${r.hint}`)
678
- } catch { /* 摩擦提示失败不影响收尾 */ }
681
+ // 台账滚动(2026-09-15-tax-governance FR-02 主滚动点):consume 得到的非零 counts 按
682
+ // change 滚动合并进 <runtimeRoot>/friction-ledger.json 幸存台账(merge-by-change——
683
+ // --reopen 重跑二次 consume 累加非双计);空 counts 跳过写(防干净收尾落空文件);
684
+ // archivedAt 此时不落(prune 侧落定);runtimeRoot 与 consume 同源(specBase 同变量
685
+ // + resolveRuntimeRoot 单点)。台账写失败 fail-soft 在既有 try/catch 内,绝不阻断收尾。
686
+ if (changeName && r && r.counts && Object.keys(r.counts).length > 0) {
687
+ const { mergeFrictionEntry } = await import('../friction-ledger.js')
688
+ await mergeFrictionEntry(resolveRuntimeRoot(platformOpts, specBase), { change: changeName, counts: r.counts })
689
+ }
690
+ } catch { /* 摩擦提示/台账失败不影响收尾 */ }
679
691
  }
680
692
 
681
693
  if (stageName === 'execute') {
@@ -834,7 +846,7 @@ async function printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts,
834
846
  if (typeof frozenPatch === 'string') {
835
847
  const patchText = frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n'
836
848
  writeFileSync(join(changeDir, 'scope-audit.patch'), patchText)
837
- snap.patchSha256 = createHash('sha256').update(patchText, 'utf8').digest('hex')
849
+ snap.patchSha256 = createHash('sha256').update(patchText.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
838
850
  snap.patchStatus = 'ok'
839
851
  } else if (frozenPatch === null) {
840
852
  snap.patchStatus = 'failed'
@@ -975,35 +987,49 @@ function prefetchDiffFileSet(ctx) {
975
987
  * changedFiles 非空且 git diff 实测非空。防 allowed_paths 误归属他人 diff 或陈旧 review.json 导致
976
988
  * 空任务被自动勾选/批量放行。ctx 缺省时保持现行判定(向后兼容)。
977
989
  *
978
- * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string, reviewerNotes?:string, changedFiles?:string[]}}} r readReview 结果
990
+ * ql-20260915-001 修复②(坑 review-write-draft-marker-stuck,2026-09-14 用户实证:文档面任务
991
+ * 反复「草稿 changedFiles 为空,跳过自动勾选」断链):显式 CLI 写入(writeTaskReview 通道,
992
+ * writtenBy 锚定,带 agent 供给 verdict)一律按非草稿处理——不受 changedFiles 空/实测 diff 空
993
+ * 限制(.sillyspec/ 文档面任务归因被滤、纯验证任务零 diff 是合法形态;agent 已用
994
+ * review write --force --changed-files 显式表态,verdict 即结论)。草稿守卫警告同步改为
995
+ * 可行动:含 task 名与解锁命令示例,不再只报现象。
996
+ *
997
+ * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string, reviewerNotes?:string, changedFiles?:string[], task?:string, writtenBy?:string}}} r readReview 结果
979
998
  * @param {boolean} endToEnd 是否端到端/deployment-critical task
980
999
  * @param {{gitDir?:string, base?:string, head?:string}|null} [ctx] 实测 diff 上下文;缺省保持现行判定
1000
+ * @param {string} [changeName] 变更名(草稿守卫警告里解锁命令示例用)
981
1001
  * @returns {boolean}
982
1002
  */
983
- export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
1003
+ export function shouldAutoCheckTask(r, endToEnd, ctx = null, changeName = '') {
984
1004
  if (!r?.ok) return false
985
1005
  const spec = r.review?.specVerdict
986
1006
  const quality = r.review?.qualityVerdict
987
1007
  if (spec === 'fail' || quality === 'fail') return false
988
1008
  if (endToEnd) return spec === 'pass' && quality === 'pass'
989
1009
 
1010
+ // ql-20260915-001 修复②:显式 CLI 写入(review write 通道)按非草稿处理,直接勾——
1011
+ // verdict 非 fail 已过上面守卫,文档面/纯验证任务的空 changedFiles 不再拦截
1012
+ if (isExplicitReviewWrite(r.review)) return true
1013
+
990
1014
  // W2 task-04 草稿零 diff 守卫(FR-03)
991
1015
  // ctx 给定且 review 为自动草稿时,要求 changedFiles 非空且实测 diff 非空
992
1016
  if (ctx && r.review?.reviewerNotes && r.review.reviewerNotes.includes('auto-generated draft')) {
1017
+ const taskId = r.review.task || 'task-NN'
1018
+ const unlockCmd = `sillyspec review write${changeName ? ` --change ${changeName}` : ' --change <变更名>'} --task ${taskId} --spec pass --quality pass --force --changed-files <文件>`
993
1019
  const changedFiles = r.review.changedFiles || []
994
1020
  if (changedFiles.length === 0) {
995
- console.warn(`⚠️ 草稿 review changedFiles 为空,跳过自动勾选`)
1021
+ console.warn(`⚠️ 草稿 review changedFiles 为空,跳过自动勾选(task=${taskId})——解锁:${unlockCmd}(纯验证任务用 --changed-files "" + --evidence "<验证说明>")`)
996
1022
  return false
997
1023
  }
998
1024
  if (!ctx.gitDir || !ctx.base || !ctx.head) {
999
- console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
1025
+ console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾(task=${taskId})——解锁:${unlockCmd}`)
1000
1026
  return false
1001
1027
  }
1002
1028
  // 预取路径:一次全量 diff 文件集内存归属(autoCheckPlanFromReviews/批量路径已 prefetch)
1003
1029
  if (ctx.diffFileSet) {
1004
1030
  const hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
1005
1031
  if (hit.length === 0) {
1006
- console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选`)
1032
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(task=${taskId}, base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选——解锁:${unlockCmd}`)
1007
1033
  return false
1008
1034
  }
1009
1035
  console.log(` ✓ 草稿 diff 校验通过(实测 ${hit.length}/${changedFiles.length} 个声明文件有改动)`)
@@ -1014,12 +1040,12 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
1014
1040
  // 用数组参数避免 shell 拆词,复用 git-helper 安全模式
1015
1041
  const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
1016
1042
  if (!diffResult || diffResult.trim() === '') {
1017
- console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0,8)}, head=${ctx.head.slice(0,8)}, files=${changedFiles.length}),跳过自动勾选`)
1043
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(task=${taskId}, base=${ctx.base.slice(0,8)}, head=${ctx.head.slice(0,8)}, files=${changedFiles.length}),跳过自动勾选——解锁:${unlockCmd}`)
1018
1044
  return false
1019
1045
  }
1020
1046
  console.log(` ✓ 草稿 diff 校验通过(${diffResult.trim().split('\n').length} 个文件有改动)`)
1021
1047
  } catch (e) {
1022
- console.warn(`⚠️ 草稿 diff 校验失败(${e && e.message ? e.message : e}),保守不勾`)
1048
+ console.warn(`⚠️ 草稿 diff 校验失败(task=${taskId}: ${e && e.message ? e.message : e}),保守不勾——解锁:${unlockCmd}`)
1023
1049
  return false
1024
1050
  }
1025
1051
  }
@@ -1071,9 +1097,15 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1071
1097
  const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
1072
1098
  const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
1073
1099
  const r = readReview(reviewPath)
1074
- // 端到端 task:必须 pass(cannot_verify 不算);普通 task:非 fail 即可(坑 execute-batch-complete-endtoend-checkbox)
1075
- const endToEnd = isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
1076
- if (shouldAutoCheckTask(r, endToEnd, ctx)) {
1100
+ // 卡片读取延后到 verdict 可用之后(2026-09-11 审查性能包③):review 缺失或 spec/quality
1101
+ // 已 fail 时 endToEnd 取值不影响 shouldAutoCheckTask 结果(恒 false),先读卡片是纯浪费
1102
+ // ——持 tasks 锁期间每 task 省一次 readFileSync。
1103
+ const verdictUsable = r?.ok
1104
+ && r.review?.specVerdict !== 'fail'
1105
+ && r.review?.qualityVerdict !== 'fail'
1106
+ const endToEnd = verdictUsable
1107
+ && isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
1108
+ if (shouldAutoCheckTask(r, endToEnd, ctx, changeName)) {
1077
1109
  checkedCount++
1078
1110
  return `${p1}x${p2}` // 勾选
1079
1111
  }
@@ -1169,7 +1201,10 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
1169
1201
  }
1170
1202
 
1171
1203
  // review 存在:判定是否为自动草稿且零 diff
1172
- if (review?.reviewerNotes && review.reviewerNotes.includes('auto-generated draft')) {
1204
+ // (ql-20260915-001 修复②:显式 CLI 写入(writeTaskReview 通道)按非草稿处理——不阻断
1205
+ // 批量,与勾选层 shouldAutoCheckTask 同口径;agent 已显式给 verdict 的 review 不该因
1206
+ // notes 字面残留 'auto-generated draft' 被当草稿拦截)
1207
+ if (!isExplicitReviewWrite(review) && review?.reviewerNotes && review.reviewerNotes.includes('auto-generated draft')) {
1173
1208
  const changedFiles = review.changedFiles || []
1174
1209
  // 草稿且 changedFiles 为空 → 阻断
1175
1210
  if (changedFiles.length === 0) {
@@ -1212,6 +1247,11 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
1212
1247
  if (step.status === 'pending' || step.status === 'in-progress') {
1213
1248
  step.status = 'completed'
1214
1249
  step.completedAt = now
1250
+ // 乐观预标戳:这是本次 --done 的批量乐观标记而非逐步验证的完成。任一收尾 gate 失败时
1251
+ // rollbackStageCompletion 据此一并回滚(坑 execute-batch-rollback-half-state:只回滚
1252
+ // currentIdx 会落库「步骤 11 pending + 12-16 completed」半套状态)。内存字段,_write 只写
1253
+ // 固定列不落库。
1254
+ step._batchAligned = true
1215
1255
  aligned++
1216
1256
  }
1217
1257
  }
@@ -1251,10 +1291,11 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
1251
1291
  process.exit(1)
1252
1292
  }
1253
1293
 
1254
- // 查找下一个 pending 或 in-progress 的步骤
1255
- const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1294
+ // 查找下一个 pending / in-progress / blocked 的步骤(blocked 同为当前步——与 completeStep
1295
+ // 的 findIndex 口径对齐,否则 blocked 后 --wait 落到其后第一个 pending 步上错步记账)
1296
+ const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
1256
1297
  if (currentIdx === -1) {
1257
- console.error(`没有可以等待的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1298
+ console.error(`没有可以等待的步骤(阶段 ${stageName} 已无 pending/in-progress/blocked 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1258
1299
  process.exit(1)
1259
1300
  }
1260
1301
 
@@ -1513,9 +1554,18 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1513
1554
  if (stageName === 'verify') {
1514
1555
  try {
1515
1556
  const { consumeFrictionHint } = await import('../friction-tally.js')
1516
- const r = consumeFrictionHint({ cwd, changeName, platformOpts })
1557
+ const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
1517
1558
  if (r && r.hint) console.log(`\n${r.hint}`)
1518
- } catch { /* 摩擦提示失败不影响收尾 */ }
1559
+ // 台账滚动(2026-09-15-tax-governance FR-02 主滚动点):consume 得到的非零 counts 按
1560
+ // change 滚动合并进 <runtimeRoot>/friction-ledger.json 幸存台账(merge-by-change——
1561
+ // --reopen 重跑二次 consume 累加非双计);空 counts 跳过写(防干净收尾落空文件);
1562
+ // archivedAt 此时不落(prune 侧落定);runtimeRoot 与 consume 同源(specBase 同变量
1563
+ // + resolveRuntimeRoot 单点)。台账写失败 fail-soft 在既有 try/catch 内,绝不阻断收尾。
1564
+ if (changeName && r && r.counts && Object.keys(r.counts).length > 0) {
1565
+ const { mergeFrictionEntry } = await import('../friction-ledger.js')
1566
+ await mergeFrictionEntry(resolveRuntimeRoot(platformOpts, specBase), { change: changeName, counts: r.counts })
1567
+ }
1568
+ } catch { /* 摩擦提示/台账失败不影响收尾 */ }
1519
1569
  }
1520
1570
  // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
1521
1571
  const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
@@ -1569,7 +1619,10 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
1569
1619
  }
1570
1620
 
1571
1621
  const steps = stageData.steps
1572
- const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1622
+ // blocked 同为当前步——与 completeStep 的 findIndex 口径对齐(坑 deps-gate-blocked-invisible
1623
+ // 在 completeStep 修过但此处漏同步:blocked 后 --skip 落到其后第一个 pending 步上,错步跳过
1624
+ // 且 blocked 步永久卡死只能 --reset)
1625
+ const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
1573
1626
 
1574
1627
  if (currentIdx === -1) {
1575
1628
  const wsIdx = steps.findIndex(s => s.status === 'waiting')
@@ -1577,11 +1630,15 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
1577
1630
  console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
1578
1631
  console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
1579
1632
  } else {
1580
- console.error(`没有待跳过的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1633
+ console.error(`没有待跳过的步骤(阶段 ${stageName} 已无 pending/in-progress/blocked 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
1581
1634
  }
1582
1635
  process.exit(1)
1583
1636
  }
1584
1637
 
1638
+ if (steps[currentIdx].status === 'blocked') {
1639
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 此前被门控阻断(blocked),本次 --skip 将跳过该步——若门控条件已修复,更宜直接 --done 推进。`)
1640
+ }
1641
+
1585
1642
  const defSteps = await getStageStepsAutoAware(stageName, cwd, progress, platformOpts?.specRoot || null)
1586
1643
  const stepDef = defSteps ? defSteps[currentIdx] : null
1587
1644
  if (stepDef && !stepDef.optional) {
@@ -12,6 +12,7 @@
12
12
  * 纯函数无副作用:不写盘、不 console(console 留给调用点 task-02/03)。
13
13
  */
14
14
  import { safeGit, parsePorcelainPath, isQuickMetadata } from './shared.js'
15
+ import { basename } from 'node:path'
15
16
 
16
17
  /**
17
18
  * 内联解析 .sillyspec/changes/<dir>/ 路径取 <dir>。
@@ -151,6 +152,103 @@ export function formatConcurrentWarning(detected) {
151
152
  for (const c of otherActiveChanges) lines.push(` - ${c}`)
152
153
  }
153
154
 
154
- lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作。')
155
+ lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作(execute task 内建议 sillyspec wt-commit 串行提交)。')
155
156
  return lines.join('\n')
156
157
  }
158
+
159
+ // ── 坑 mixed-baseline-drift-hint(ql-20260915-004)committed-drift 混合基线检测 ──
160
+ // 背景(用户 2026-09-15 实证):并行会话合并断言不同步——worktree 内基于旧快照写的测试断言
161
+ // (旧 id),主仓已合入 id 统一修复 → verify 测试门红——测试源=worktree 快照、被测源=主仓
162
+ // HEAD 的混合基线漂移此前无任何提示。本函数算「主仓自本变更基点后的已提交推进 ∩ 本变更
163
+ // 相关文件」,交两挂点(worktree-apply 成功尾声 / gates.js verify 测试门前)advisory 提示。
164
+
165
+ /** 测试形态文件名:foo.test.mjs / foo.spec.js(与探针 7 isProbe7TestPath 的 .test. 口径同族) */
166
+ const DRIFT_TEST_FORM_RE = /\.(?:test|spec)\.[A-Za-z0-9]+$/
167
+
168
+ /** git 路径反斜杠归一(detectCommittedDrift 内部用,与 normalizeGitPath 同义独立小函数) */
169
+ function toPosix(p) {
170
+ return String(p || '').replace(/\\/g, '/')
171
+ }
172
+
173
+ /**
174
+ * 文件名 stem 键:剥扩展名与 .test./.spec. 中缀(foo.js → foo;foo.test.mjs → foo;
175
+ * foo_test.js → foo_test——下划线形不在变体口径内,仅精确匹配兜底)。小写归一。
176
+ */
177
+ function driftStemKey(p) {
178
+ const name = basename(toPosix(p))
179
+ const m = name.match(/^(.+?)\.(?:test|spec)\.[A-Za-z0-9]+$/)
180
+ if (m) return m[1].toLowerCase()
181
+ const dot = name.lastIndexOf('.')
182
+ return (dot > 0 ? name.slice(0, dot) : name).toLowerCase()
183
+ }
184
+
185
+ /**
186
+ * 检测混合基线漂移(非阻断 advisory 用):主仓 `git diff --name-only <baseHash>..<head>`
187
+ * (基点后已提交推进面)∩ 本变更 touchedFiles(或其同 basename 的 .test./.spec. 变体)非空
188
+ * → drift。变体口径:stem 相同且至少一侧为测试形态(src/foo.js ↔ test/foo.test.mjs)——
189
+ * 换位覆盖两方向(他者推进我的源文件的测试变体 / 他者推进我测试文件对应的源文件)。
190
+ *
191
+ * fail-open:baseHash 缺失 / git 异常 → 返回 null(调用方视为未检测,零输出零阻断);
192
+ * diff 空(基点后无推进)→ { drift:false, files:[] }。
193
+ *
194
+ * @param {{ projectRoot: string, baseHash: string, touchedFiles: string[],
195
+ * excludeFiles?: string[], head?: string }} opts
196
+ * - baseHash:worktree 基点(meta.baselineCommit || baseHash)
197
+ * - touchedFiles:本变更交付文件面(apply-manifest files / changedFiles)
198
+ * - excludeFiles:从推进面剔除的文件(verify 挂点传本变更自身面——apply 已提交时
199
+ * baseHash..HEAD 含自身交付,不剔会把「自己合自己」误报成他者推进;apply 挂点用
200
+ * 精确 preMergeHead,无需剔除)
201
+ * - head:推进面终点 ref(缺省 HEAD;merge 路径传 merge 前 HEAD)
202
+ * @returns {{ drift: boolean, files: string[], hint?: string }|null}
203
+ */
204
+ export function detectCommittedDrift({ projectRoot, baseHash, touchedFiles, excludeFiles = [], head = 'HEAD' }) {
205
+ if (!projectRoot || !baseHash) return null
206
+ const touched = (Array.isArray(touchedFiles) ? touchedFiles : []).map(toPosix).filter(Boolean)
207
+ if (touched.length === 0) return null
208
+ // FR fail-open(与 detectConcurrentChanges 同族):git 读不到不崩不误报
209
+ const diff = safeGit(projectRoot, ['diff', '--name-only', `${baseHash}..${head}`], { timeout: 30000 })
210
+ if (diff.error) return null
211
+ if (!diff.value) return { drift: false, files: [] }
212
+
213
+ const excl = new Set((Array.isArray(excludeFiles) ? excludeFiles : []).map(toPosix).filter(Boolean))
214
+ const touchedSet = new Set(touched)
215
+ const touchedStems = new Set(touched.map(driftStemKey).filter(Boolean))
216
+ const touchedHasTestForm = touched.some(f => DRIFT_TEST_FORM_RE.test(basename(f)))
217
+
218
+ const files = []
219
+ for (const raw of diff.value.split('\n').map(s => s.trim()).filter(Boolean)) {
220
+ const p = toPosix(raw)
221
+ if (excl.has(p)) continue
222
+ const exact = touchedSet.has(p)
223
+ // 变体:非精确命中时,stem 相同且至少一侧为测试形态(防 docs/foo.md ↔ test/foo.test.mjs
224
+ // 之外的松散同名误报——两侧都非测试形态的不同目录同名不算相关)
225
+ const variant = !exact
226
+ && touchedStems.has(driftStemKey(p))
227
+ && (DRIFT_TEST_FORM_RE.test(basename(p)) || touchedHasTestForm)
228
+ if (exact || variant) files.push(p)
229
+ }
230
+ if (files.length === 0) return { drift: false, files: [] }
231
+ return {
232
+ drift: true,
233
+ files,
234
+ hint: `主仓自基点 ${String(baseHash).slice(0, 8)} 后已合入触及 ${files.length} 个本变更相关文件的推进(${files.slice(0, 5).join('、')}${files.length > 5 ? ' 等' : ''})——worktree 快照内验证过的断言可能漂移,建议合并态复跑相关测试`,
235
+ }
236
+ }
237
+
238
+ /**
239
+ * committed-drift 检测结果 → 多行 ⚠️ 提示串(两挂点共用文案,与 formatConcurrentWarning 同族)。
240
+ * @param {{ drift: boolean, files?: string[], hint?: string }|null} drift detectCommittedDrift 产物
241
+ * @param {{ tailNote?: string }} [opts] tailNote 追加在首行末的挂点语境注(如 verify 挂点的
242
+ * 「实测若红先做合并态归因」);缺省无
243
+ * @returns {string|null} 无漂移/未检测返回 null
244
+ */
245
+ export function formatCommittedDriftWarning(drift, opts = {}) {
246
+ if (!drift || !drift.drift) return null
247
+ const files = Array.isArray(drift.files) ? drift.files : []
248
+ const note = opts.tailNote ? `(${opts.tailNote})` : ''
249
+ return [
250
+ '⚠️ 混合基线提示:主仓自本变更基点后已合入触及以下本变更相关文件的推进'
251
+ + `(${files.length} 个)——worktree 快照内验证过的断言可能漂移,建议合并态复跑相关测试${note}:`,
252
+ ...files.map(f => ` - ${f}`),
253
+ ].join('\n')
254
+ }
@@ -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 定向跑」: