sillyspec 3.28.8 → 3.28.10

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 (69) hide show
  1. package/.claude/full.json +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  3. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  4. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  5. package/AGENTS.md +3 -2
  6. package/README.md +35 -11
  7. package/SKILL.md +24 -10
  8. 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
  9. package/package.json +1 -1
  10. package/src/change-list.js +73 -20
  11. package/src/change-risk-profile.js +20 -0
  12. package/src/config-schema.js +62 -0
  13. package/src/contract-matrix.js +84 -5
  14. package/src/cross-repo-reconcile.js +134 -0
  15. package/src/db.js +11 -3
  16. package/src/decision-distill.js +15 -3
  17. package/src/design-facts.js +89 -20
  18. package/src/docs-gate.js +105 -6
  19. package/src/doctor-diagnostics.js +224 -2
  20. package/src/friction-ledger.js +171 -0
  21. package/src/hooks/worktree-guard.js +13 -1
  22. package/src/index.js +206 -18
  23. package/src/knowledge-classify.js +382 -0
  24. package/src/knowledge-hits.js +74 -0
  25. package/src/knowledge-stats.js +180 -0
  26. package/src/probe7-anchor-check.js +69 -0
  27. package/src/progress/change-registry.js +183 -8
  28. package/src/progress/shared.js +3 -2
  29. package/src/progress.js +100 -8
  30. package/src/quick-gate-profile.js +251 -0
  31. package/src/quicklog.js +164 -18
  32. package/src/review-dispatch.js +2 -1
  33. package/src/run/command.js +131 -13
  34. package/src/run/complete-handlers.js +511 -21
  35. package/src/run/complete.js +123 -23
  36. package/src/run/concurrent-detect.js +98 -0
  37. package/src/run/gate-snapshot.js +110 -2
  38. package/src/run/gates.js +141 -16
  39. package/src/run/prompt.js +221 -11
  40. package/src/run/quick-audit.js +111 -13
  41. package/src/run/shared.js +147 -11
  42. package/src/run/stage.js +6 -0
  43. package/src/scan-diff.js +61 -30
  44. package/src/scan-postcheck.js +74 -0
  45. package/src/scan-refresh.js +477 -0
  46. package/src/scan-staleness.js +25 -13
  47. package/src/scope-audit.js +456 -56
  48. package/src/stage-contract.js +185 -2
  49. package/src/stage-review.js +23 -7
  50. package/src/stages/brainstorm.js +6 -0
  51. package/src/stages/cmd-existence.js +9 -3
  52. package/src/stages/execute.js +85 -3
  53. package/src/stages/knowledge.js +76 -6
  54. package/src/stages/plan-postcheck.js +32 -5
  55. package/src/stages/quick.js +1 -1
  56. package/src/stages/verify.js +1 -0
  57. package/src/sync-noise.js +170 -6
  58. package/src/sync.js +141 -9
  59. package/src/task-review.js +366 -46
  60. package/src/verify-postcheck.js +212 -55
  61. package/src/verify-probes.js +727 -16
  62. package/src/worktree-apply.js +685 -48
  63. package/src/worktree-deps.js +124 -3
  64. package/src/worktree.js +391 -15
  65. package/templates/agents-instruction.md +2 -2
  66. package/templates/prompts/testcase-design.md +2 -1
  67. package/templates/prompts/verify-probes.md +4 -1
  68. package/.patch16-tmp.mjs +0 -169
  69. package/sa-rename-probe/src/new.js +0 -4
@@ -22,7 +22,8 @@ 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'
25
+ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer } from './shared.js'
26
+ import { isExplicitReviewWrite, collectWorktreeChangedFiles } 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 整目录删除。
@@ -180,6 +181,29 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
180
181
  console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
181
182
  }
182
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
+
183
207
  // ── P0-2(noai-ir-roadmap §3):--output 省略 → CLI 按事实合成步骤摘要 ──
184
208
  // agent 手打摘要是每步命中的幻觉源;事实面(步骤名 + 变更窗口 + 门禁以 CLI 校验为准)
185
209
  // CLI 本就持有,纯事实合成零编造(判断词留给 gate)。语义性说明(方案取舍/用户反馈)仍应
@@ -494,7 +518,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
494
518
  // archive「确认归档」收尾:--confirm 门控 + archiveChangeDirectory 移动 + 推荐文档校验
495
519
  // (抽成 handleArchiveConfirmStep;缺 --confirm 时返回 early-return 对象,completeStep 透传)
496
520
  {
497
- const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts })
521
+ const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts, isSkipApply, sessionFlag })
498
522
  if (_archiveEarlyReturn) return _archiveEarlyReturn
499
523
  }
500
524
 
@@ -568,8 +592,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
568
592
  console.log(` 恢复:sillyspec run ${stageName} --continue --answer "用户回答"${changeName ? ` --change ${changeName}` : ''}`)
569
593
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
570
594
  }
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 })
595
+ // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion);
596
+ // isNoDocs(task-02)--no-docs 豁免随链透传进 auditQuickCompletion options;
597
+ // sessionFlag(task-03)--session 随链透传进 quick 轻量归档链所有权校验
598
+ await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, isAllowDelete, isNoDocs, sessionFlag, platformOpts, pm, quickFiles })
573
599
 
574
600
  // ── reopen --done 回填(坑 brainstorm-reopen-step-state-desync)──
575
601
  // nextPendingIdx === -1 且无 waiting,说明要进阶段完成分支。此时若存在 stale 步骤
@@ -663,6 +689,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
663
689
  const total = steps.length
664
690
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
665
691
 
692
+ // 平台模式产物落点指针(platform-docs-dual-location,2026-09-15 EHS 实证):完成时点在
693
+ // 工作树 .sillyspec/ 刷新人类可读指针(本地模式零行为,fail-soft 内部全兜)。
694
+ writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
695
+
666
696
  // task-04(2026-09-10-change-scope-audit):execute 完成打范围对账全表+落 .runtime 快照 /
667
697
  // verify 完成出一行漂移确认。advisory fail-soft(D-006)——helper 内部全兜,绝不阻断完成。
668
698
  await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
@@ -675,7 +705,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
675
705
  const { consumeFrictionHint } = await import('../friction-tally.js')
676
706
  const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
677
707
  if (r && r.hint) console.log(`\n${r.hint}`)
678
- } catch { /* 摩擦提示失败不影响收尾 */ }
708
+ // 台账滚动(2026-09-15-tax-governance FR-02 主滚动点):consume 得到的非零 counts 按
709
+ // change 滚动合并进 <runtimeRoot>/friction-ledger.json 幸存台账(merge-by-change——
710
+ // --reopen 重跑二次 consume 累加非双计);空 counts 跳过写(防干净收尾落空文件);
711
+ // archivedAt 此时不落(prune 侧落定);runtimeRoot 与 consume 同源(specBase 同变量
712
+ // + resolveRuntimeRoot 单点)。台账写失败 fail-soft 在既有 try/catch 内,绝不阻断收尾。
713
+ if (changeName && r && r.counts && Object.keys(r.counts).length > 0) {
714
+ const { mergeFrictionEntry } = await import('../friction-ledger.js')
715
+ await mergeFrictionEntry(resolveRuntimeRoot(platformOpts, specBase), { change: changeName, counts: r.counts })
716
+ }
717
+ } catch { /* 摩擦提示/台账失败不影响收尾 */ }
679
718
  }
680
719
 
681
720
  if (stageName === 'execute') {
@@ -834,7 +873,7 @@ async function printExecuteScopeAudit({ cwd, changeName, specBase, platformOpts,
834
873
  if (typeof frozenPatch === 'string') {
835
874
  const patchText = frozenPatch.endsWith('\n') ? frozenPatch : frozenPatch + '\n'
836
875
  writeFileSync(join(changeDir, 'scope-audit.patch'), patchText)
837
- snap.patchSha256 = createHash('sha256').update(patchText, 'utf8').digest('hex')
876
+ snap.patchSha256 = createHash('sha256').update(patchText.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
838
877
  snap.patchStatus = 'ok'
839
878
  } else if (frozenPatch === null) {
840
879
  snap.patchStatus = 'failed'
@@ -919,7 +958,11 @@ function readTaskCardText(changeDir, taskNum) {
919
958
  * 用于 autoCheckPlanFromReviews(勾选层)和 detectExecuteBatchFinish(批量层)。
920
959
  * 失败返回 null(调用方降级:勾选层不勾、批量层不过滤——向后兼容)。
921
960
  *
922
- * @returns {Promise<{gitDir:string, base:string, head:string}|null>}
961
+ * 2026-09-15-worktree-dual-truth-gates task-04(坑④/D-004@v1):ctx 增透传 cwd/changeName/meta/
962
+ * baselineFiles——prefetchDiffFileSet 消费 collectWorktreeChangedFiles(与草稿归因同口径)并剔
963
+ * baseline 夹带。扩展字段对 shouldAutoCheckTask 只读 gitDir/base/head/diffFileSet 零影响。
964
+ *
965
+ * @returns {Promise<{gitDir:string, base:string, head:string, cwd:string, changeName:string, meta:object|null, baselineFiles:string[]}|null>}
923
966
  */
924
967
  async function buildDraftContext(cwd, changeName) {
925
968
  try {
@@ -940,7 +983,13 @@ async function buildDraftContext(cwd, changeName) {
940
983
  }
941
984
  }
942
985
  if (base && head && gitDir) {
943
- return { gitDir, base, head }
986
+ return {
987
+ gitDir, base, head,
988
+ cwd, changeName,
989
+ meta: meta || null,
990
+ // baselineFiles 缺失/非数组按 [] 容错(prefetchDiffFileSet 剔除夹带用)
991
+ baselineFiles: Array.isArray(meta?.baselineFiles) ? meta.baselineFiles : [],
992
+ }
944
993
  }
945
994
  return null
946
995
  } catch (e) {
@@ -950,16 +999,39 @@ async function buildDraftContext(cwd, changeName) {
950
999
  }
951
1000
 
952
1001
  /**
953
- * 预取 base..head 全量 diff 文件集(一次 git spawn),供草稿零 diff 守卫按 task 内存归属判定。
1002
+ * 预取草稿零 diff 守卫用的全量 diff 文件集,供 shouldAutoCheckTask 按 task 内存归属判定。
954
1003
  * 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
955
- * 8 task ≈ 8-16 次串行 spawn)。失败返回 null → 调用方回退逐 task 实测路径。
1004
+ * 8 task ≈ 8-16 次串行 spawn)。失败返回 ctx 原样 → 调用方回退逐 task 实测路径。
1005
+ *
1006
+ * 2026-09-15-worktree-dual-truth-gates task-04(坑④ worktree-tick-guard-commit-only / D-004@v1):
1007
+ * 单算 base..head(已提交)在子代理默认不 commit 时恒空/缺文件 → 守卫全跳过勾选。现并入
1008
+ * collectWorktreeChangedFiles(task-review.js 单一真相:porcelain 未提交 ∪ committed
1009
+ * merge-base 补齐,与草稿归因同口径),再剔 meta.baselineFiles(R-07 防伪底线:merge-base..
1010
+ * wtHEAD 含 baseline checkpoint 提交,夹带文件=非本变更改动,不剔则「声明未做恰被夹带」的
1011
+ * task 误勾)。全空时仍不设 diffFileSet(退回逐 task 实测路径,原语义)。
956
1012
  */
957
1013
  function prefetchDiffFileSet(ctx) {
958
1014
  if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
959
1015
  try {
960
1016
  const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
961
- if (!out) return ctx
962
- return { ...ctx, diffFileSet: new Set(out.split('\n').filter(Boolean)) }
1017
+ const diffFileSet = new Set(out ? out.split('\n').filter(Boolean) : [])
1018
+ // D-004@v1 并入:porcelain 未提交 ∪ committed 补齐(helper 自身 fail-open,git 失败返 []
1019
+ // → 退回 base..head 现状,不放大勾选面);独立 try 防 helper 意外拖垮既有 base..head 集
1020
+ try {
1021
+ if (ctx.cwd && ctx.changeName) {
1022
+ for (const f of collectWorktreeChangedFiles(ctx.cwd, ctx.changeName, ctx.meta)) {
1023
+ if (f) diffFileSet.add(f)
1024
+ }
1025
+ }
1026
+ } catch { /* helper 异常 → 退回 base..head 现状(fail-open) */ }
1027
+ // 剔 baseline 夹带:按正斜杠归一 exact 匹配(两侧同 git 口径路径);baselineFiles 缺失按 [] 容错
1028
+ const baselineFiles = Array.isArray(ctx.baselineFiles) ? ctx.baselineFiles : []
1029
+ for (const b of baselineFiles) {
1030
+ const n = String(b).replace(/\\/g, '/').trim()
1031
+ if (n) diffFileSet.delete(n)
1032
+ }
1033
+ if (diffFileSet.size === 0) return ctx
1034
+ return { ...ctx, diffFileSet }
963
1035
  } catch {
964
1036
  return ctx
965
1037
  }
@@ -975,35 +1047,49 @@ function prefetchDiffFileSet(ctx) {
975
1047
  * changedFiles 非空且 git diff 实测非空。防 allowed_paths 误归属他人 diff 或陈旧 review.json 导致
976
1048
  * 空任务被自动勾选/批量放行。ctx 缺省时保持现行判定(向后兼容)。
977
1049
  *
978
- * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string, reviewerNotes?:string, changedFiles?:string[]}}} r readReview 结果
1050
+ * ql-20260915-001 修复②(坑 review-write-draft-marker-stuck,2026-09-14 用户实证:文档面任务
1051
+ * 反复「草稿 changedFiles 为空,跳过自动勾选」断链):显式 CLI 写入(writeTaskReview 通道,
1052
+ * writtenBy 锚定,带 agent 供给 verdict)一律按非草稿处理——不受 changedFiles 空/实测 diff 空
1053
+ * 限制(.sillyspec/ 文档面任务归因被滤、纯验证任务零 diff 是合法形态;agent 已用
1054
+ * review write --force --changed-files 显式表态,verdict 即结论)。草稿守卫警告同步改为
1055
+ * 可行动:含 task 名与解锁命令示例,不再只报现象。
1056
+ *
1057
+ * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string, reviewerNotes?:string, changedFiles?:string[], task?:string, writtenBy?:string}}} r readReview 结果
979
1058
  * @param {boolean} endToEnd 是否端到端/deployment-critical task
980
1059
  * @param {{gitDir?:string, base?:string, head?:string}|null} [ctx] 实测 diff 上下文;缺省保持现行判定
1060
+ * @param {string} [changeName] 变更名(草稿守卫警告里解锁命令示例用)
981
1061
  * @returns {boolean}
982
1062
  */
983
- export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
1063
+ export function shouldAutoCheckTask(r, endToEnd, ctx = null, changeName = '') {
984
1064
  if (!r?.ok) return false
985
1065
  const spec = r.review?.specVerdict
986
1066
  const quality = r.review?.qualityVerdict
987
1067
  if (spec === 'fail' || quality === 'fail') return false
988
1068
  if (endToEnd) return spec === 'pass' && quality === 'pass'
989
1069
 
1070
+ // ql-20260915-001 修复②:显式 CLI 写入(review write 通道)按非草稿处理,直接勾——
1071
+ // verdict 非 fail 已过上面守卫,文档面/纯验证任务的空 changedFiles 不再拦截
1072
+ if (isExplicitReviewWrite(r.review)) return true
1073
+
990
1074
  // W2 task-04 草稿零 diff 守卫(FR-03)
991
1075
  // ctx 给定且 review 为自动草稿时,要求 changedFiles 非空且实测 diff 非空
992
1076
  if (ctx && r.review?.reviewerNotes && r.review.reviewerNotes.includes('auto-generated draft')) {
1077
+ const taskId = r.review.task || 'task-NN'
1078
+ const unlockCmd = `sillyspec review write${changeName ? ` --change ${changeName}` : ' --change <变更名>'} --task ${taskId} --spec pass --quality pass --force --changed-files <文件>`
993
1079
  const changedFiles = r.review.changedFiles || []
994
1080
  if (changedFiles.length === 0) {
995
- console.warn(`⚠️ 草稿 review changedFiles 为空,跳过自动勾选`)
1081
+ console.warn(`⚠️ 草稿 review changedFiles 为空,跳过自动勾选(task=${taskId})——解锁:${unlockCmd}(纯验证任务用 --changed-files "" + --evidence "<验证说明>")`)
996
1082
  return false
997
1083
  }
998
1084
  if (!ctx.gitDir || !ctx.base || !ctx.head) {
999
- console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
1085
+ console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾(task=${taskId})——解锁:${unlockCmd}`)
1000
1086
  return false
1001
1087
  }
1002
1088
  // 预取路径:一次全量 diff 文件集内存归属(autoCheckPlanFromReviews/批量路径已 prefetch)
1003
1089
  if (ctx.diffFileSet) {
1004
1090
  const hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
1005
1091
  if (hit.length === 0) {
1006
- console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选`)
1092
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(task=${taskId}, base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选——解锁:${unlockCmd}`)
1007
1093
  return false
1008
1094
  }
1009
1095
  console.log(` ✓ 草稿 diff 校验通过(实测 ${hit.length}/${changedFiles.length} 个声明文件有改动)`)
@@ -1014,12 +1100,12 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
1014
1100
  // 用数组参数避免 shell 拆词,复用 git-helper 安全模式
1015
1101
  const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
1016
1102
  if (!diffResult || diffResult.trim() === '') {
1017
- console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0,8)}, head=${ctx.head.slice(0,8)}, files=${changedFiles.length}),跳过自动勾选`)
1103
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(task=${taskId}, base=${ctx.base.slice(0,8)}, head=${ctx.head.slice(0,8)}, files=${changedFiles.length}),跳过自动勾选——解锁:${unlockCmd}`)
1018
1104
  return false
1019
1105
  }
1020
1106
  console.log(` ✓ 草稿 diff 校验通过(${diffResult.trim().split('\n').length} 个文件有改动)`)
1021
1107
  } catch (e) {
1022
- console.warn(`⚠️ 草稿 diff 校验失败(${e && e.message ? e.message : e}),保守不勾`)
1108
+ console.warn(`⚠️ 草稿 diff 校验失败(task=${taskId}: ${e && e.message ? e.message : e}),保守不勾——解锁:${unlockCmd}`)
1023
1109
  return false
1024
1110
  }
1025
1111
  }
@@ -1079,7 +1165,7 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1079
1165
  && r.review?.qualityVerdict !== 'fail'
1080
1166
  const endToEnd = verdictUsable
1081
1167
  && isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
1082
- if (shouldAutoCheckTask(r, endToEnd, ctx)) {
1168
+ if (shouldAutoCheckTask(r, endToEnd, ctx, changeName)) {
1083
1169
  checkedCount++
1084
1170
  return `${p1}x${p2}` // 勾选
1085
1171
  }
@@ -1175,7 +1261,10 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
1175
1261
  }
1176
1262
 
1177
1263
  // review 存在:判定是否为自动草稿且零 diff
1178
- if (review?.reviewerNotes && review.reviewerNotes.includes('auto-generated draft')) {
1264
+ // (ql-20260915-001 修复②:显式 CLI 写入(writeTaskReview 通道)按非草稿处理——不阻断
1265
+ // 批量,与勾选层 shouldAutoCheckTask 同口径;agent 已显式给 verdict 的 review 不该因
1266
+ // notes 字面残留 'auto-generated draft' 被当草稿拦截)
1267
+ if (!isExplicitReviewWrite(review) && review?.reviewerNotes && review.reviewerNotes.includes('auto-generated draft')) {
1179
1268
  const changedFiles = review.changedFiles || []
1180
1269
  // 草稿且 changedFiles 为空 → 阻断
1181
1270
  if (changedFiles.length === 0) {
@@ -1517,6 +1606,8 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1517
1606
  // gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
1518
1607
  pm._write(cwd, progress, changeName)
1519
1608
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
1609
+ // 平台模式产物落点指针:与 completeStep 完成分支同款(本路径不漏)。
1610
+ writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
1520
1611
  // task-04:wait 解除完成路径与 completeStep 完成分支同源注入(Grill G-2:本路径不漏,
1521
1612
  // 否则 execute 快照缺失 → verify 假报「无快照可对比」)。
1522
1613
  await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
@@ -1527,7 +1618,16 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1527
1618
  const { consumeFrictionHint } = await import('../friction-tally.js')
1528
1619
  const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
1529
1620
  if (r && r.hint) console.log(`\n${r.hint}`)
1530
- } catch { /* 摩擦提示失败不影响收尾 */ }
1621
+ // 台账滚动(2026-09-15-tax-governance FR-02 主滚动点):consume 得到的非零 counts 按
1622
+ // change 滚动合并进 <runtimeRoot>/friction-ledger.json 幸存台账(merge-by-change——
1623
+ // --reopen 重跑二次 consume 累加非双计);空 counts 跳过写(防干净收尾落空文件);
1624
+ // archivedAt 此时不落(prune 侧落定);runtimeRoot 与 consume 同源(specBase 同变量
1625
+ // + resolveRuntimeRoot 单点)。台账写失败 fail-soft 在既有 try/catch 内,绝不阻断收尾。
1626
+ if (changeName && r && r.counts && Object.keys(r.counts).length > 0) {
1627
+ const { mergeFrictionEntry } = await import('../friction-ledger.js')
1628
+ await mergeFrictionEntry(resolveRuntimeRoot(platformOpts, specBase), { change: changeName, counts: r.counts })
1629
+ }
1630
+ } catch { /* 摩擦提示/台账失败不影响收尾 */ }
1531
1631
  }
1532
1632
  // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
1533
1633
  const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
@@ -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>。
@@ -154,3 +155,100 @@ export function formatConcurrentWarning(detected) {
154
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
+ }
@@ -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,100 @@ 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
+
26
114
  /**
27
115
  * 创建隔离快照:HEAD worktree + 会话文件 overlay + node_modules junction + local.yaml。
28
116
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
29
117
  * @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
30
118
  */
31
- export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
119
+ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false }) {
32
120
  let snapshotRoot = null
33
121
  try {
34
122
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
@@ -112,6 +200,26 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
112
200
  }
113
201
  }
114
202
 
203
+ // ── overlay import 闭合冒烟(坑 verify-sandbox-overlay-partial-state-importerror,
204
+ // 2026-09-13 实证:in-place 变更的文件集含主仓未提交并行 WIP——「改了引用方没改被引用方」
205
+ // 的部分态进快照,模块子集测试 197 ERROR 只能人工三步排查)──
206
+ // 对 overlay 进快照的 .py 文件逐个 `python -c import`(用快照内链接的 venv 解释器);
207
+ // ImportError/SyntaxError → 该文件回退 HEAD 版(git show)+ warn——半成品坏文件不进沙箱,
208
+ // HEAD 健版保证 import 图闭合。新增文件(HEAD 无版)无法回退 → 醒目 warn 保留。
209
+ // 全链 fail-open:找不到解释器/基建异常只 warn 不作废快照(主场景是 Python 项目,
210
+ // JS/TS 的 vitest load 冒烟成本高留后续)。
211
+ const overlaidPyFiles = files.filter(f => typeof f === 'string' && f.endsWith('.py')
212
+ && !f.startsWith('.sillyspec/')
213
+ && existsSync(join(snapshotRoot, f)))
214
+ if (overlaidPyFiles.length > 0 && !skipImportSmoke) {
215
+ const pythonBin = findSnapshotPython(envRelDirs, snapshotRoot)
216
+ if (pythonBin) {
217
+ runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidPyFiles)
218
+ } else {
219
+ console.warn(`⚠️ 快照 overlay 冒烟跳过:快照内未找到 python 解释器(venv 族未链接)——并行半成品部分态可能引发 ImportError 假红,实测红先 SNAPSHOT_OFF 对照`)
220
+ }
221
+ }
222
+
115
223
  const cleanup = () => {
116
224
  try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {
117
225
  try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch { /* 残留交 OS tmp 清理 */ }