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
package/src/run/gates.js CHANGED
@@ -27,7 +27,7 @@ import { writeAtomicSync } from '../fs-atomic.js'
27
27
  import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
28
28
  import { runValidators } from '../stage-contract.js'
29
29
  import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
30
- import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
30
+ import { detectConcurrentChanges, formatConcurrentWarning, detectCommittedDrift, formatCommittedDriftWarning } from './concurrent-detect.js'
31
31
  import { stageRegistry } from '../stages/index.js'
32
32
  import { normalizeTaskId } from '../taskcard.js'
33
33
  import { recordFrictionEvent } from '../friction-tally.js'
@@ -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)
@@ -629,6 +643,28 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
629
643
  // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
630
644
  // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
631
645
  if (stageName === 'verify') {
646
+ // 坑 mixed-baseline-drift-hint(ql-20260915-004):verify 实测门执行前的混合基线 advisory
647
+ //(提前预警不阻断)——apply-manifest 的基点后主仓已提交推进若触及本变更相关文件(含
648
+ // .test. 变体),worktree 快照内验证过的断言可能漂移,本次实测红先做合并态归因。推进面
649
+ // 剔除本变更自身文件面(apply 已提交时 baseHash..HEAD 含自身交付,不剔会自污染误报);
650
+ // 剔除后精确命中信号由 apply 时点提示兜底,此处保变体方向(他者推进我源文件的测试变体 /
651
+ // 我测试文件对应的源文件)。无 manifest(in-place/quick)→ no-op 零行为变化。
652
+ try {
653
+ const driftManifestPath = join(specBase, 'changes', changeName, 'apply-manifest.json')
654
+ if (existsSync(driftManifestPath)) {
655
+ const driftManifest = JSON.parse(readFileSync(driftManifestPath, 'utf8'))
656
+ const driftFace = (driftManifest && Array.isArray(driftManifest.files)
657
+ ? driftManifest.files.map(f => f && f.path).filter(Boolean) : [])
658
+ if (driftManifest && driftManifest.baseHash && driftFace.length > 0) {
659
+ const drift = detectCommittedDrift({
660
+ projectRoot: cwd, baseHash: driftManifest.baseHash,
661
+ touchedFiles: driftFace, excludeFiles: driftFace,
662
+ })
663
+ const driftMsg = formatCommittedDriftWarning(drift, { tailNote: '本次实测若红,先做合并态归因' })
664
+ if (driftMsg) console.warn(driftMsg)
665
+ }
666
+ }
667
+ } catch { /* fail-soft:drift 检测异常不影响 verify 门 */ }
632
668
  const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
633
669
  // P0-1 指纹复用(noai-ir-roadmap §3 前置一·防重复跑):noAI 质量扫描步已实测且代码指纹
634
670
  // 未变 → 直接复用免重跑(长套件 2~10 分钟不再跑两遍);无记录/失配/git 不可用 → 照旧
@@ -646,7 +682,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
646
682
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
647
683
  verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
648
684
  if (verifyGateSnap) {
649
- console.log(`🧪 Verify 实测隔离快照:HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
685
+ console.log(`🧪 Verify 实测隔离快照(根:${verifyGateSnap.snapshotRoot}):HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
650
686
  }
651
687
  } catch { /* 快照链路异常 → 主仓现行为 */ }
652
688
  }
@@ -679,7 +715,11 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
679
715
  }
680
716
  } catch { /* fail-soft:回填失败不影响门禁 */ }
681
717
  if (testCheck.status === 'failed') {
682
- if (verifyGateSnap) { try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null }
718
+ if (verifyGateSnap) {
719
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
720
+ printSnapshotFailureHint(verifyGateSnap) } catch { /* 提示失败不影响阻断 */ }
721
+ try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null
722
+ }
683
723
  console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
684
724
  // 并行 WIP 归因鉴别(坑 verify-reconcile-foreign-wip):单一实现迁至 verify-quality-scan.js
685
725
  // renderVerifyTestAttribution(P0-1 前置二:noAI 质量扫描失败路径与本 --done 路径共用,
@@ -700,13 +740,21 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
700
740
  if (lintCheck) {
701
741
  console.log(`\n♻️ Verify lint 对账:复用 noAI 质量扫描步的实测结果(代码指纹匹配,免重跑)`)
702
742
  } else {
703
- lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
743
+ lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: verifyGateSnap ? 5 * 60 * 1000 : undefined })
744
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
745
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
746
+ // 并行噪声混入时失败输出带归属鉴定提示)
747
+ if (verifyGateSnap && lintCheck.status === 'failed' && /超时/.test(String(lintCheck.reason || ''))) {
748
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
749
+ lintCheck = runVerifyLintCheck({ cwd, specBase })
750
+ }
704
751
  if (lintCheck.status !== 'skipped') {
705
752
  console.log(`\n⏳ Verify lint 对账:CLI 亲自执行 local.yaml 的 commands.lint…`)
706
753
  }
707
754
  }
708
755
  // 快照收尾(test+lint 两检查都已完成;parity/删除探针走主仓 git 事实不依赖快照)
709
756
  const usedSnapshot = Boolean(verifyGateSnap)
757
+ const usedSnapInfo = verifyGateSnap ? { snapshotRoot: verifyGateSnap.snapshotRoot, changeFileCount: verifyGateSnap.changeFileCount, sourceRoot: verifyGateSnap.sourceRoot } : null
710
758
  if (verifyGateSnap) { try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null }
711
759
  printVerifyLintCheck(lintCheck)
712
760
  // lint 硬门(2026-09-09 升硬:观察期 14 次 5 败全真阳性,pre-push 本就硬拦——fail-fast 前移;
@@ -714,6 +762,10 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
714
762
  const { shouldBlockVerifyLint } = await import('../verify-postcheck.js')
715
763
  if (shouldBlockVerifyLint(lintCheck)) {
716
764
  console.error('\n❌ verify 阶段被阻断:CLI 亲自实测 lint 失败(agent 的 lint 自报告与实测不符时以实测为准)。')
765
+ if (usedSnapInfo) {
766
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
767
+ printSnapshotFailureHint(usedSnapInfo) } catch { /* 提示失败不影响阻断 */ }
768
+ }
717
769
  if (lintCheck.outputTail) {
718
770
  const tail = lintCheck.outputTail.split('\n').slice(-8).join('\n')
719
771
  console.error(' 输出(末尾):')
@@ -1121,13 +1173,17 @@ export function readDesignScale(specBase, changeName) {
1121
1173
  /**
1122
1174
  * 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
1123
1175
  * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
1176
+ * 按变更隔离(2026-09-14 quick,用户反馈③):带 changeName 时本变更目录的缺元数据文件逐个
1177
+ * 列出;其他变更目录的命中折叠为一行汇总并点名目录与计数——多 agent 并发仓里并行会话的他者
1178
+ * 文件不再逐条混入本变更收尾输出(同批后续变更包也走汇总行,agent 按目录名自认领)。
1179
+ * changeName 缺省(历史调用面)保持全量逐列。
1124
1180
  */
1125
- export function validateMetadata(cwd, stageName, specBase) {
1181
+ export function validateMetadata(cwd, stageName, specBase, changeName) {
1126
1182
  const changesDir = join(specBase, 'changes')
1127
1183
  if (!existsSync(changesDir)) return
1128
1184
 
1129
1185
  const cutoff = Date.now() - 10 * 60 * 1000
1130
- const missing = []
1186
+ const missing = new Set()
1131
1187
 
1132
1188
  function walk(dir) {
1133
1189
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -1138,19 +1194,34 @@ export function validateMetadata(cwd, stageName, specBase) {
1138
1194
  const mtime = statSync(full).mtimeMs
1139
1195
  if (mtime < cutoff) continue
1140
1196
  const content = readFileSync(full, 'utf-8')
1141
- if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
1142
- if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
1197
+ if (!content.includes('author:') && !content.includes('author:')) missing.add(full)
1198
+ if (!content.includes('created_at:') && !content.includes('created_at:')) missing.add(full)
1143
1199
  } catch (e) { /* skip unreadable files */ }
1144
1200
  }
1145
1201
  }
1146
1202
 
1147
1203
  walk(changesDir)
1148
- const unique = [...new Set(missing)]
1149
- if (unique.length > 0) {
1204
+ if (missing.size === 0) return
1205
+
1206
+ // 归属分组:changes/ 下第一段目录名([\\/] 切分兼容 Windows join 产物的反斜杠)
1207
+ const current = []
1208
+ const otherCounts = new Map()
1209
+ for (const f of missing) {
1210
+ const top = relative(changesDir, f).split(/[\\/]/)[0]
1211
+ if (!changeName || top === changeName) current.push(f)
1212
+ else otherCounts.set(top, (otherCounts.get(top) || 0) + 1)
1213
+ }
1214
+
1215
+ if (current.length > 0) {
1150
1216
  console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
1151
- unique.forEach(f => console.log(` - ${relative(cwd, f) || f}`))
1217
+ current.forEach(f => console.log(` - ${relative(cwd, f) || f}`))
1152
1218
  console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
1153
1219
  }
1220
+ if (otherCounts.size > 0) {
1221
+ const parts = [...otherCounts.entries()].map(([dir, n]) => `${dir}×${n}`).join('、')
1222
+ const total = [...otherCounts.values()].reduce((a, b) => a + b, 0)
1223
+ console.log(`\nℹ️ 另有 ${total} 个其他变更目录的文件近 10 分钟新增且缺元数据(${parts})——已按变更隔离折叠:并行会话产物请忽略;若为本步骤创建的同批变更包,请补 author/created_at`)
1224
+ }
1154
1225
  }
1155
1226
 
1156
1227
  /**
@@ -1309,7 +1380,7 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
1309
1380
  const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
1310
1381
  const total = steps.length
1311
1382
 
1312
- validateMetadata(cwd, stageName, specBase)
1383
+ validateMetadata(cwd, stageName, specBase, changeName)
1313
1384
 
1314
1385
  // 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
1315
1386
  if (settledCount === total && total > 0) {
package/src/run/prompt.js CHANGED
@@ -22,13 +22,18 @@ 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'
29
29
  import { parseModuleMapSimple } from '../modules.js'
30
30
  import { readModuleRecentChanges } from '../module-changelog.js'
31
31
  import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
32
+ // 机械知识注入(2026-09-14-knowledge-loop-close task-04,FR-04):两者均为 fs/path 叶子模块,
33
+ // 静态 import 不引入环(stages/* 反向引用本文件会撞 index.js 顶层 stageRegistry TDZ——见
34
+ // buildKnowledgeInjection docstring)。
35
+ import { matchKnowledge } from '../knowledge-match.js'
36
+ import { appendKnowledgeHit } from '../knowledge-hits.js'
32
37
 
33
38
  /**
34
39
  * 从 _module-map.yaml 读取模块上下文索引
@@ -220,6 +225,95 @@ export function buildQuickSemanticGuardInjection({ specBase, cwd, changeName } =
220
225
  }
221
226
  }
222
227
 
228
+ // ── 机械知识注入共享底座(2026-09-14-knowledge-loop-close task-04,FR-04 / D-002@v1 总体方案 D)──
229
+ // 知识消费从 report 级升级为正文级:matchKnowledge 命中 → 按 entries 出现序(INDEX 行序)取
230
+ // 前 3 个不同 file,单文件首 40 行截断 + 截断标记,渲染「📚 命中知识」段(R-02 膨胀控制);
231
+ // 未命中 section=''(调用方零字节零变化)。命中同时经 task-01 的 appendKnowledgeHit 落
232
+ // .runtime/knowledge-hits.jsonl 一条 type:inject 记录;既有 knowledge-hit-report.json 由
233
+ // execute 调用方照旧落盘(X-004 升级既有机制而非并行新建,新旧遥测共存)。
234
+ //
235
+ // ⚠️ 同款格式在 src/stages/execute.js buildWavePrompt 内有本地孪生实现(Wave 粒度注入)——
236
+ // 本文件无法被 execute.js 静态 import(prompt.js → stages/index.js → execute.js 既有依赖方向,
237
+ // 反向边会在 execute.js 作模块图入口时触发 index.js 顶层 stageRegistry 对其 definition const 的
238
+ // TDZ 求值,2026-09-14 ESM 环实证)。格式漂移由 test/knowledge-inject.test.mjs 的格式等价断言
239
+ // 锁定——改此处必同步改孪生。
240
+ export const KNOWLEDGE_INJECT_MAX_FILES = 3
241
+ export const KNOWLEDGE_INJECT_MAX_LINES = 40
242
+
243
+ /** top-N 选取:按 entries 出现序(INDEX 行序)取前 N 个不同 file(matchKnowledge 布尔 filter
244
+ * 无相关度排序,X-009——出现序即唯一稳定序),每 file 取首个命中 entry 作代表。 */
245
+ function pickTopKnowledgeEntries(entries, maxFiles) {
246
+ const seen = new Set()
247
+ const picked = []
248
+ for (const e of entries) {
249
+ if (seen.has(e.file)) continue
250
+ seen.add(e.file)
251
+ picked.push(e)
252
+ if (picked.length >= maxFiles) break
253
+ }
254
+ return picked
255
+ }
256
+
257
+ /** 命中正文段渲染(段头 + Status/Sources 命中报告语义 + top-3 文件截断正文)。 */
258
+ function renderKnowledgeInjectSection(knowledgeResult, { knowledgeDir, maxFiles, maxLines }) {
259
+ const ref = e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)
260
+ const lines = []
261
+ lines.push(`📚 命中知识(CLI 按任务描述机械匹配,top-${maxFiles})`)
262
+ lines.push(`Status: matched | Entries: ${knowledgeResult.entries.length} | Sources:`)
263
+ for (const e of knowledgeResult.entries) lines.push(` - ${ref(e)}`)
264
+ lines.push(`(命中清单如上;正文按 INDEX 行序注入前 ${maxFiles} 个不同文件,单文件首 ${maxLines} 行截断——未注入条目需要时按 Sources 路径自行读取)`)
265
+ for (const e of pickTopKnowledgeEntries(knowledgeResult.entries, maxFiles)) {
266
+ lines.push('')
267
+ lines.push(`── ${ref(e)}${e.display ? `(${e.display})` : ''} ──`)
268
+ let body = ''
269
+ try {
270
+ // CRLF/CR 归一(Windows 知识文件 + 行数截断按 \n 切分)
271
+ body = readFileSync(join(knowledgeDir, e.file), 'utf8').replace(/\r\n?/g, '\n')
272
+ } catch (err) {
273
+ lines.push(`(文件不可读:${err && err.message ? err.message : err})`)
274
+ continue
275
+ }
276
+ const bodyLines = body.split('\n')
277
+ if (bodyLines.length > maxLines) {
278
+ lines.push(bodyLines.slice(0, maxLines).join('\n'))
279
+ lines.push('…(截断)')
280
+ } else {
281
+ lines.push(body.replace(/\n+$/, ''))
282
+ }
283
+ }
284
+ return lines.join('\n')
285
+ }
286
+
287
+ /**
288
+ * 机械知识注入一站式入口(execute「确认执行范围」step 与 quick step1 两个 prompt.js 内注入点共用)。
289
+ *
290
+ * @param {object} opts
291
+ * @param {string} opts.knowledgeDir - knowledge 目录(INDEX.md 所在)
292
+ * @param {string} opts.runtimeDir - .runtime 目录(hits.jsonl 落点)
293
+ * @param {string} opts.change - 变更名 / quick sessionId(进遥测记录 change 字段)
294
+ * @param {string} opts.query - 匹配查询串(execute: changeName+tasks.md 任务行;quick: guard.taskDescription)
295
+ * @returns {{matched: boolean, section: string, report: string, json: object}}
296
+ * section:命中时的「📚 命中知识」段(含段头与正文);未命中恒 ''(调用方零字节)。
297
+ * report/json:matchKnowledge 原样透传(execute 调用方沿用旧 report.json 落盘与未命中替换口径)。
298
+ * 遥测 append 单独 fail-soft(hits 写失败不影响注入本体);匹配/渲染异常向上抛由调用方降级。
299
+ */
300
+ export function buildKnowledgeInjection({ knowledgeDir, runtimeDir, change, query, maxFiles = KNOWLEDGE_INJECT_MAX_FILES, maxLines = KNOWLEDGE_INJECT_MAX_LINES } = {}) {
301
+ const knowledgeResult = matchKnowledge(knowledgeDir, String(query || ''))
302
+ if (!knowledgeResult.matched) {
303
+ return { matched: false, section: '', report: knowledgeResult.report, json: knowledgeResult.json }
304
+ }
305
+ const section = renderKnowledgeInjectSection(knowledgeResult, { knowledgeDir, maxFiles, maxLines })
306
+ try {
307
+ appendKnowledgeHit(runtimeDir, {
308
+ type: 'inject',
309
+ change: change || '',
310
+ query: String(query || ''),
311
+ matchedFiles: knowledgeResult.entries.map(e => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
312
+ })
313
+ } catch { /* 遥测 fail-soft(R-04):hits 落盘失败不阻断注入本体 */ }
314
+ return { matched: true, section, report: knowledgeResult.report, json: knowledgeResult.json }
315
+ }
316
+
223
317
  // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
224
318
  // 无循环依赖:modules.js 仅 import fs/path/db.js,prompt.js → modules.js 单向)。
225
319
  /**
@@ -309,6 +403,14 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
309
403
  return false
310
404
  }
311
405
 
406
+ // X3 渲染侧接线(2026-08-29-change-delete-closure-and-spec-pull 遗留活跃坑收口;曾因并行
407
+ // 会话提交丢弃一次,2026-09-12 复检重接):步骤 prompt 渲染即步骤起点——补推一次 progress
408
+ // 刷新平台 last_pushed_at(停滞判定不再把长步骤误报为「停滞」)。不 await:prompt 渲染不被
409
+ // 网络阻塞;未连接平台静默 / quick 会话降级跳过 / 8s 熔断 / 失败只 warn,契约全在
410
+ // triggerStepStartSync 内(shared.js docstring 即本接线点的说明),Node 事件循环保证在飞
411
+ // POST 落地后进程才退出。
412
+ triggerStepStartSync(cwd, changeName, platformOpts)
413
+
312
414
  // ── 首个 agent 可见步索引(「首步一次建立」类注入的锚点)──
313
415
  // step0 为 noAI(brainstorm/execute/verify 的 step1「进度确认」,2026-09-07)时不渲染 prompt,
314
416
  // persona/护栏/完成契约/铁律/平台路径约束等 step0 专属注入须顺延到首个渲染步,否则整段丢失
@@ -746,9 +848,13 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
746
848
  }
747
849
 
748
850
  // Knowledge hit report: execute 阶段注入匹配结果
851
+ // 2026-09-14-knowledge-loop-close task-04(FR-04,X-004 升级既有机制):命中清单报告 → 命中
852
+ // 正文注入——命中时占位符替换为「📚 命中知识」段(top-3 不同 file + 首 40 行截断,段头保留
853
+ // Status/Entries/Sources 命中报告语义),并逐条 appendKnowledgeHit 落 hits.jsonl 遥测;
854
+ // 未命中替换值与升级前字节一致(knowledgeResult.report == 'Status: no matches',零膨胀)。
855
+ // 既有 knowledge-hit-report.json 照旧落盘(未命中也写 matched:false 快照,行为不变)。
749
856
  if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
750
857
  try {
751
- const { matchKnowledge } = await import('../knowledge-match.js')
752
858
  const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
753
859
  const knowledgeDir = join(effectiveSpecBase, 'knowledge')
754
860
  // taskContext: changeName + tasks.md 任务名(注册表唯一真相,2026-08-20-task-truth-unify)
@@ -766,12 +872,20 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
766
872
  }
767
873
  } catch {}
768
874
  }
769
- const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
770
- promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
771
- // 写入 runtime JSON
772
875
  const runtimeDir = join(effectiveSpecBase, '.runtime')
876
+ const knowledgeInjection = buildKnowledgeInjection({
877
+ knowledgeDir,
878
+ runtimeDir,
879
+ change: changeName || '',
880
+ query: taskContext,
881
+ })
882
+ promptText = promptText.replace(
883
+ /\{KNOWLEDGE_HIT_REPORT\}/g,
884
+ knowledgeInjection.matched ? knowledgeInjection.section : knowledgeInjection.report
885
+ )
886
+ // 写入 runtime JSON(既有机制照旧,新旧遥测共存)
773
887
  mkdirSync(runtimeDir, { recursive: true })
774
- writeAtomicSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeResult.json, null, 2) + '\n')
888
+ writeAtomicSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeInjection.json, null, 2) + '\n')
775
889
  } catch (e) {
776
890
  promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
777
891
  }
@@ -1100,6 +1214,29 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1100
1214
  }
1101
1215
  }
1102
1216
 
1217
+ // ── 机械知识注入(quick step1,2026-09-14-knowledge-loop-close task-04,FR-04)──
1218
+ // 判定口径与上方模块上下文注入同锚(quickFirstStep):查询串用 guard.taskDescription 现成
1219
+ // 读取(X-008——changeName 是 quick-<hash> 无语义);命中追加「📚 命中知识」段到 prompt 末尾
1220
+ // (top-3 同规则),并 appendKnowledgeHit 落 hits.jsonl;未命中零字节(prompt 与现状字节一致,
1221
+ // 零命中静默)。全链 fail-soft:注入失败不阻断 quick 启动。
1222
+ if (quickFirstStep) {
1223
+ try {
1224
+ const kiSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1225
+ const kiQuery = String(readQuickGuardField(changeName, kiSpecBase, 'taskDescription') || '')
1226
+ if (kiQuery.trim() !== '') {
1227
+ const ki = buildKnowledgeInjection({
1228
+ knowledgeDir: join(kiSpecBase, 'knowledge'),
1229
+ runtimeDir: join(kiSpecBase, '.runtime'),
1230
+ change: changeName || '',
1231
+ query: kiQuery,
1232
+ })
1233
+ if (ki.section) {
1234
+ promptText = promptText + '\n\n' + ki.section
1235
+ }
1236
+ }
1237
+ } catch { /* fail-soft:知识注入异常不阻断 quick step1 prompt 输出 */ }
1238
+ }
1239
+
1103
1240
  // ── 语义护栏进场注入(task-05,FR-03,D-001@v1 模块四)──
1104
1241
  // 判定口径独立:与上方模块上下文注入同锚(quickFirstStep——quick step1「理解任务」),不嵌进
1105
1242
  // {QUICK_CONTEXT_DIGEST} 占位符守卫内——模板改版去占位符时护栏静默失效(plan 审查 N2)。
@@ -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 用户实证:只报计数不指名哪处,四轮复现才定位——门禁输出
@@ -167,14 +178,97 @@ export function printQuickAuditReview(review) {
167
178
  else console.warn(` 行号漂移 → 更新到当前源码;跑 sillyspec docs check 可看建议行号。`)
168
179
  }
169
180
  }
181
+
182
+ // ── [gate] 分级门禁 advisory 块(FR-03,2026-09-14-quick-exit-tiered-gates task-02):L1/L2 画像
183
+ // 打印级别/跨度/模块清单/风险命中/检查项 + --no-docs 指引,L0 零输出。全部 advisory:不改 status
184
+ // 三态与 exit code(D-003);窗口内未声明脏文件维持既有归属分流(上方 ⚖️/🔍 块),不并入文档
185
+ // 认领判定(D-005);数据源 = review.gateProfile(auditQuickCompletion 挂载,scope-audit 重放同源)。
186
+ const gate = review.gateProfile
187
+ if (gate && (gate.level === 'L1' || gate.level === 'L2')) {
188
+ const isL2 = gate.level === 'L2'
189
+ const spanTxt = gate.degraded
190
+ ? 'module-map 缺失(降级档:按文件数判级)'
191
+ : `跨 ${gate.moduleSpan} 模块${Array.isArray(gate.modules) && gate.modules.length > 0 ? `(${gate.modules.map(m => m.id).join(' · ')})` : ''}`
192
+ console.warn(`\n${isL2 ? '🛑' : '🚧'} [gate] ${gate.level} 规模门(${spanTxt} · ${gate.fileCount} 文件:${gate.codeFileCount} 代码 / ${gate.testFileCount} 测试)——advisory 提示不阻断:`)
193
+ if (Array.isArray(gate.unmappedFiles) && gate.unmappedFiles.length > 0) {
194
+ console.warn(` ⚠️ ${gate.unmappedFiles.length} 个文件未命中 module-map(${gate.unmappedFiles.slice(0, 5).join(', ')}${gate.unmappedFiles.length > 5 ? ' 等' : ''})`)
195
+ }
196
+ if (!isL2) {
197
+ // L1 检查项:每文件注记(--file-notes 覆盖率)+ 测试增量(机械规则)
198
+ console.warn(` - 每文件注记检查:${gate.checks?.perFileNotes ? '✅ --file-notes 已覆盖全部变更文件' : `❌ 缺失——补 --file-notes "path1::注 || path2::注"(覆盖变更文件全集)`}`)
199
+ const td = gate.checks?.testDelta
200
+ console.warn(` - 测试增量检查:${td === 'missing' ? `❌ missing——${gate.codeFileCount} 个代码文件改动无测试文件(补 test 改动进本会话)` : td === 'ok' ? '✅ 已含测试改动' : '— 不适用(≤1 代码文件)'}`)
201
+ } else {
202
+ // L2 检查项:模块文档认领(claimed/missing/exempt-no-docs)+ 风险命中的运行时证据要求
203
+ const dc = gate.checks?.docClaim
204
+ const noClaimable = gate.degraded || !Array.isArray(gate.modules) || gate.modules.length === 0
205
+ if (dc === 'exempt-no-docs') {
206
+ console.warn(` - 模块文档认领:🛡️ 已 --no-docs 显式豁免(豁免已留痕 QUICKLOG 审计行)`)
207
+ } else if (noClaimable) {
208
+ // degraded / 无触及模块:computeGateProfile 空真 claimed——如实显示不适用,不出「已认领」误导
209
+ console.warn(` - 模块文档认领:— 不适用(${gate.degraded ? '无 module-map' : '触及文件均未命中模块'},无可认领卡片)`)
210
+ } else if (dc === 'claimed') {
211
+ console.warn(` - 模块文档认领:✅ 触及模块的卡片文件已在改动集`)
212
+ } else {
213
+ console.warn(` - 模块文档认领:❌ missing——触及模块(${Array.isArray(gate.modules) && gate.modules.length > 0 ? gate.modules.map(m => m.id).join(' · ') : '—'})的卡片文件不在改动集`)
214
+ console.warn(` ➜ 同步模块卡进本次改动,或确认无需文档落盘时 --done 带 --no-docs 显式豁免(豁免留痕,不阻断完成)`)
215
+ }
216
+ if (Array.isArray(gate.riskHits) && gate.riskHits.length > 0) {
217
+ console.warn(` - 风险命中 ${gate.riskHits.length} 处(运行时证据要求——风险路径改动需在 --result「结果:」附运行验证说明,advisory 不等待):`)
218
+ for (const h of gate.riskHits.slice(0, 8)) {
219
+ console.warn(` ⚠️ ${h.pattern} ← ${h.file}`)
220
+ }
221
+ if (gate.riskHits.length > 8) console.warn(` … 共 ${gate.riskHits.length} 处`)
222
+ }
223
+ }
224
+ }
170
225
  }
171
226
 
172
227
  /**
173
- * quick 阶段多变更交互式选择关联变更。
174
- * - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
175
- * - 1 活跃变更 → 默认关联它(保持现状友好,不弹交互)
228
+ * [gate] 分级门禁 auditNote 组装(FR-03 task-02,gate-audit-note):L1/L2 → 单行 `[gate]` 注记
229
+ * (随 complete-handlers 既有 auditNotes 通道落 QUICKLOG,--no-docs 豁免同通道留痕);
230
+ * L0 / 无画像 → null(零落账,验收「L0 无 gate 落账行」)。纯函数、与上方 [gate] 打印块同一
231
+ * review.gateProfile 数据源;单独导出为可测(buildSemanticGuardHits 同款考量——避开
232
+ * handleQuickStageCompletion 的 quicklog 重机件直测组装层)。
233
+ * @param {object|null} gate review.gateProfile(auditQuickCompletion 产物;null/异常形状 → null)
234
+ * @returns {string|null}
235
+ */
236
+ export function buildGateAuditNote(gate) {
237
+ if (!gate || typeof gate !== 'object' || (gate.level !== 'L1' && gate.level !== 'L2')) return null
238
+ const span = gate.degraded
239
+ ? 'module-map 缺失(降级档按文件数判级)'
240
+ : `跨 ${gate.moduleSpan} 模块`
241
+ const seg = [`[gate] ${gate.level}(${span} · ${gate.fileCount} 文件:${gate.codeFileCount} 代码/${gate.testFileCount} 测试)advisory`]
242
+ if (gate.level === 'L1') {
243
+ seg.push(`每文件注记${gate.checks?.perFileNotes ? '已全覆盖' : '缺失(--file-notes 覆盖变更文件全集)'}`)
244
+ const td = gate.checks?.testDelta
245
+ seg.push(`测试增量${td === 'ok' ? '已含' : td === 'missing' ? `缺失(${gate.codeFileCount} 个代码文件无测试改动)` : '不适用(≤1 代码文件)'}`)
246
+ } else {
247
+ const dc = gate.checks?.docClaim
248
+ const noClaimable = gate.degraded || !Array.isArray(gate.modules) || gate.modules.length === 0
249
+ seg.push(dc === 'exempt-no-docs' ? '模块文档认领已 --no-docs 显式豁免'
250
+ : noClaimable ? '模块文档认领不适用(无可认领模块)'
251
+ : dc === 'claimed' ? '模块文档认领已覆盖(模块卡在改动集)'
252
+ : '模块文档认领缺失(同步模块卡进改动集,或 --no-docs 显式豁免)')
253
+ if (Array.isArray(gate.riskHits) && gate.riskHits.length > 0) {
254
+ seg.push(`风险命中 ${gate.riskHits.length} 处(${gate.riskHits.slice(0, 5).map(h => `${h.pattern}←${h.file}`).join('、')}${gate.riskHits.length > 5 ? ' 等' : ''})需运行时证据`)
255
+ }
256
+ }
257
+ return seg.join(';')
258
+ }
259
+
260
+ /**
261
+ * quick 阶段关联变更解析(含单候选信号门控)。
262
+ * - 0 活跃变更 → 不关联
263
+ * - 1 活跃变更 → 跑 recommendChanges 双信号打分:命中(score>0)→ 自动关联 + 大声提示
264
+ * + autoLinked 溯源;未命中 → 不关联 + 提示。坑 quick-single-change-auto-link(2026-09-14
265
+ * 实证):原实现无条件 `return [activeChanges[0]]`——多 agent 仓库里唯一活跃变更常是他者
266
+ * 会话遗留,挂载污染 tasks.md 且 --done 僵尸清理通道可误归档他者变更;单候选还绕过
267
+ * recommend 的 quick-<hex8> 会话过滤(新 quick 互挂另一活跃 quick)。
176
268
  * - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
177
- * - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
269
+ * - ≥2 活跃变更 + 非交互 → 不关联 + 提示用 --change a,b
270
+ * @returns {Promise<{changes: string[], autoLinked: string[]}>} changes=最终关联清单;
271
+ * autoLinked=其中机器自动关联的子集(guard 落 linkedChangesAuto 溯源,--done 归档闸消费)
178
272
  */
179
273
  export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
180
274
  let activeChanges = []
@@ -183,13 +277,7 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
183
277
  } catch {
184
278
  activeChanges = []
185
279
  }
186
- if (activeChanges.length === 0) return []
187
- if (activeChanges.length === 1) return [activeChanges[0]]
188
-
189
- if (nonInteractive || !process.stdin.isTTY) {
190
- console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
191
- return []
192
- }
280
+ if (activeChanges.length === 0) return { changes: [], autoLinked: [] }
193
281
 
194
282
  // 脏文件(推荐信号之一)。safeGit 带 safe.directory,避免 linked worktree/容器/挂载点下裸
195
283
  // `git status` 抛错致推荐信号静默丢失(multi-agent-review Q3 同类)。推荐非关键路径,失败回退 []。
@@ -203,7 +291,7 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
203
291
  .filter(f => !f.startsWith('.sillyspec/'))
204
292
  }
205
293
 
206
- // 推荐打分(脏文件 + 任务描述双信号)
294
+ // 推荐打分(脏文件 + 任务描述双信号)——单候选也跑(信号门控),非仅 ≥2 交互推荐用
207
295
  let recommendations = []
208
296
  try {
209
297
  // quick-recommend.js 在 src/,本模块在 src/run/ → 退一层(真环依赖,保留动态 import)
@@ -212,6 +300,26 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
212
300
  } catch {
213
301
  recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
214
302
  }
303
+
304
+ // 单候选信号门控(坑 quick-single-change-auto-link):有真实信号(脏文件命中 design 清单 /
305
+ // 任务描述命中 proposal)才自动关联,且大声提示可反悔;无信号一律不关联。quick-<hex8> 会话
306
+ // 行已被 recommendChanges 过滤 → recommendations 无该候选 → 不关联(防会话互挂)。
307
+ if (activeChanges.length === 1) {
308
+ const name = activeChanges[0]
309
+ const rec = recommendations.find(r => r.name === name)
310
+ if (rec && rec.score > 0) {
311
+ console.log(`🔗 唯一活跃变更 ${name} 命中关联信号(${rec.reasons.slice(0, 2).join(';')})——已自动关联;无关请带 --linked-changes none 重启`)
312
+ return { changes: [name], autoLinked: [name] }
313
+ }
314
+ console.log(`💡 唯一活跃变更 ${name} 未命中关联信号(任务描述/脏文件无重叠)——默认不关联;如需关联请带 --linked-changes ${name} 重启`)
315
+ return { changes: [], autoLinked: [] }
316
+ }
317
+
318
+ if (nonInteractive || !process.stdin.isTTY) {
319
+ console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
320
+ return { changes: [], autoLinked: [] }
321
+ }
322
+
215
323
  const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
216
324
  const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
217
325
  const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
@@ -238,7 +346,8 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
238
346
  // 动态 import:@inquirer/prompts 只在本交互分支加载,不进 run 命令通用启动路径
239
347
  const { checkbox } = await import('@inquirer/prompts')
240
348
  const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
241
- return selected
349
+ // 用户在 TTY 上亲手勾选确认 = 显式协作声明,不进 autoLinked
350
+ return { changes: selected, autoLinked: [] }
242
351
  }
243
352
 
244
353
  // ============ quick --done test+lint 硬门禁(2026-09-02 跨 agent 工单 P0-2)============
@@ -338,16 +447,28 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
338
447
  if (snapshot) {
339
448
  gateCwd = snapshot.snapshotRoot
340
449
  gateSpecBase = join(snapshot.snapshotRoot, '.sillyspec')
341
- console.log(`🧪 门禁隔离快照:HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
450
+ console.log(`🧪 门禁隔离快照(根:${snapshot.snapshotRoot}):HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
342
451
  }
343
452
  } catch { /* 快照链路异常 → 主仓现行为 */ }
344
453
  }
345
454
  try {
346
455
  const test = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
347
- const lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
456
+ let lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snapshot ? 5 * 60 * 1000 : undefined })
457
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
458
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
459
+ // 并行噪声混入时失败输出带归属鉴定提示)
460
+ if (snapshot && lint.status === 'failed' && /超时/.test(String(lint.reason || ''))) {
461
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
462
+ lint = runVerifyLintCheck({ cwd, specBase })
463
+ }
348
464
  const failed = []
349
465
  if (test.status === 'failed') failed.push('test')
350
466
  if (lint.status === 'failed') failed.push('lint')
467
+ if (failed.length > 0 && snapshot) {
468
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
469
+ printSnapshotFailureHint({ snapshotRoot: snapshot.snapshotRoot, changeFileCount: snapshot.overlaid, sourceRoot: snapshot.sourceRoot || null }, { offEnv: 'SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF' })
470
+ } catch { /* 提示失败不影响门禁语义 */ }
471
+ }
351
472
  return {
352
473
  action: failed.length > 0 ? 'fail' : 'pass',
353
474
  failed,