sillyspec 3.28.8 → 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 (61) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  4. package/AGENTS.md +3 -2
  5. package/README.md +35 -11
  6. package/SKILL.md +24 -10
  7. 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
  8. package/package.json +1 -1
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-schema.js +32 -0
  11. package/src/db.js +11 -3
  12. package/src/decision-distill.js +15 -3
  13. package/src/design-facts.js +33 -3
  14. package/src/docs-gate.js +105 -6
  15. package/src/doctor-diagnostics.js +224 -2
  16. package/src/friction-ledger.js +171 -0
  17. package/src/hooks/worktree-guard.js +13 -1
  18. package/src/index.js +188 -17
  19. package/src/knowledge-classify.js +363 -0
  20. package/src/knowledge-hits.js +74 -0
  21. package/src/knowledge-stats.js +180 -0
  22. package/src/progress/change-registry.js +183 -8
  23. package/src/progress/shared.js +3 -2
  24. package/src/progress.js +100 -8
  25. package/src/quick-gate-profile.js +251 -0
  26. package/src/quicklog.js +155 -14
  27. package/src/review-dispatch.js +2 -1
  28. package/src/run/command.js +107 -12
  29. package/src/run/complete-handlers.js +511 -21
  30. package/src/run/complete.js +54 -16
  31. package/src/run/concurrent-detect.js +98 -0
  32. package/src/run/gates.js +50 -9
  33. package/src/run/prompt.js +134 -5
  34. package/src/run/quick-audit.js +111 -13
  35. package/src/run/shared.js +76 -10
  36. package/src/run/stage.js +6 -0
  37. package/src/scan-diff.js +61 -30
  38. package/src/scan-postcheck.js +74 -0
  39. package/src/scan-refresh.js +477 -0
  40. package/src/scan-staleness.js +25 -13
  41. package/src/scope-audit.js +365 -35
  42. package/src/stage-contract.js +185 -2
  43. package/src/stage-review.js +23 -7
  44. package/src/stages/brainstorm.js +6 -0
  45. package/src/stages/execute.js +85 -3
  46. package/src/stages/knowledge.js +52 -3
  47. package/src/stages/quick.js +1 -1
  48. package/src/stages/verify.js +1 -0
  49. package/src/sync-noise.js +114 -6
  50. package/src/sync.js +135 -7
  51. package/src/task-review.js +292 -21
  52. package/src/verify-postcheck.js +20 -0
  53. package/src/verify-probes.js +340 -4
  54. package/src/worktree-apply.js +538 -44
  55. package/src/worktree-deps.js +100 -2
  56. package/src/worktree.js +117 -5
  57. package/templates/agents-instruction.md +2 -2
  58. package/templates/prompts/testcase-design.md +2 -1
  59. package/templates/prompts/verify-probes.md +4 -1
  60. package/.patch16-tmp.mjs +0 -169
  61. 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 步骤
@@ -675,7 +678,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
675
678
  const { consumeFrictionHint } = await import('../friction-tally.js')
676
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
  }
@@ -1079,7 +1105,7 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1079
1105
  && r.review?.qualityVerdict !== 'fail'
1080
1106
  const endToEnd = verdictUsable
1081
1107
  && isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
1082
- if (shouldAutoCheckTask(r, endToEnd, ctx)) {
1108
+ if (shouldAutoCheckTask(r, endToEnd, ctx, changeName)) {
1083
1109
  checkedCount++
1084
1110
  return `${p1}x${p2}` // 勾选
1085
1111
  }
@@ -1175,7 +1201,10 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
1175
1201
  }
1176
1202
 
1177
1203
  // review 存在:判定是否为自动草稿且零 diff
1178
- 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')) {
1179
1208
  const changedFiles = review.changedFiles || []
1180
1209
  // 草稿且 changedFiles 为空 → 阻断
1181
1210
  if (changedFiles.length === 0) {
@@ -1527,7 +1556,16 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
1527
1556
  const { consumeFrictionHint } = await import('../friction-tally.js')
1528
1557
  const r = await consumeFrictionHint({ cwd, changeName, platformOpts })
1529
1558
  if (r && r.hint) console.log(`\n${r.hint}`)
1530
- } 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 { /* 摩擦提示/台账失败不影响收尾 */ }
1531
1569
  }
1532
1570
  // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
1533
1571
  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
+ }
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'
@@ -643,6 +643,28 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
643
643
  // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
644
644
  // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
645
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 门 */ }
646
668
  const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
647
669
  // P0-1 指纹复用(noai-ir-roadmap §3 前置一·防重复跑):noAI 质量扫描步已实测且代码指纹
648
670
  // 未变 → 直接复用免重跑(长套件 2~10 分钟不再跑两遍);无记录/失配/git 不可用 → 照旧
@@ -1151,13 +1173,17 @@ export function readDesignScale(specBase, changeName) {
1151
1173
  /**
1152
1174
  * 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
1153
1175
  * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
1176
+ * 按变更隔离(2026-09-14 quick,用户反馈③):带 changeName 时本变更目录的缺元数据文件逐个
1177
+ * 列出;其他变更目录的命中折叠为一行汇总并点名目录与计数——多 agent 并发仓里并行会话的他者
1178
+ * 文件不再逐条混入本变更收尾输出(同批后续变更包也走汇总行,agent 按目录名自认领)。
1179
+ * changeName 缺省(历史调用面)保持全量逐列。
1154
1180
  */
1155
- export function validateMetadata(cwd, stageName, specBase) {
1181
+ export function validateMetadata(cwd, stageName, specBase, changeName) {
1156
1182
  const changesDir = join(specBase, 'changes')
1157
1183
  if (!existsSync(changesDir)) return
1158
1184
 
1159
1185
  const cutoff = Date.now() - 10 * 60 * 1000
1160
- const missing = []
1186
+ const missing = new Set()
1161
1187
 
1162
1188
  function walk(dir) {
1163
1189
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -1168,19 +1194,34 @@ export function validateMetadata(cwd, stageName, specBase) {
1168
1194
  const mtime = statSync(full).mtimeMs
1169
1195
  if (mtime < cutoff) continue
1170
1196
  const content = readFileSync(full, 'utf-8')
1171
- if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
1172
- 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)
1173
1199
  } catch (e) { /* skip unreadable files */ }
1174
1200
  }
1175
1201
  }
1176
1202
 
1177
1203
  walk(changesDir)
1178
- const unique = [...new Set(missing)]
1179
- 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) {
1180
1216
  console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
1181
- unique.forEach(f => console.log(` - ${relative(cwd, f) || f}`))
1217
+ current.forEach(f => console.log(` - ${relative(cwd, f) || f}`))
1182
1218
  console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
1183
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
+ }
1184
1225
  }
1185
1226
 
1186
1227
  /**
@@ -1339,7 +1380,7 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
1339
1380
  const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
1340
1381
  const total = steps.length
1341
1382
 
1342
- validateMetadata(cwd, stageName, specBase)
1383
+ validateMetadata(cwd, stageName, specBase, changeName)
1343
1384
 
1344
1385
  // 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
1345
1386
  if (settledCount === total && total > 0) {
package/src/run/prompt.js CHANGED
@@ -29,6 +29,11 @@ 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
  /**
@@ -754,9 +848,13 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
754
848
  }
755
849
 
756
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 快照,行为不变)。
757
856
  if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
758
857
  try {
759
- const { matchKnowledge } = await import('../knowledge-match.js')
760
858
  const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
761
859
  const knowledgeDir = join(effectiveSpecBase, 'knowledge')
762
860
  // taskContext: changeName + tasks.md 任务名(注册表唯一真相,2026-08-20-task-truth-unify)
@@ -774,12 +872,20 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
774
872
  }
775
873
  } catch {}
776
874
  }
777
- const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
778
- promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
779
- // 写入 runtime JSON
780
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(既有机制照旧,新旧遥测共存)
781
887
  mkdirSync(runtimeDir, { recursive: true })
782
- 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')
783
889
  } catch (e) {
784
890
  promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
785
891
  }
@@ -1108,6 +1214,29 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1108
1214
  }
1109
1215
  }
1110
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
+
1111
1240
  // ── 语义护栏进场注入(task-05,FR-03,D-001@v1 模块四)──
1112
1241
  // 判定口径独立:与上方模块上下文注入同锚(quickFirstStep——quick step1「理解任务」),不嵌进
1113
1242
  // {QUICK_CONTEXT_DIGEST} 占位符守卫内——模板改版去占位符时护栏静默失效(plan 审查 N2)。