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
@@ -5,7 +5,7 @@
5
5
  * 1. 读取 meta.json 获取 baseHash
6
6
  * 2. git diff --name-only baseHash 获取 worktree 中所有变更文件
7
7
  * 3. 从 design.md 解析文件变更清单(无清单 = 允许所有)
8
- * 4. 校验:变更文件 ⊆ 清单
8
+ * 4. 校验:变更文件 ⊆ 清单(review 声明与 allow 面相交过滤,D-003:外来声明不并入放行面)
9
9
  * 5. 校验:主工作区文件 base hash 一致
10
10
  * 6. --check-only 模式只输出检查结果
11
11
  * 7. 非 checkOnly:生成 patch → apply --check → apply --3way
@@ -15,12 +15,15 @@
15
15
  import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync, mkdirSync } from 'fs';
16
16
  import { join, resolve } from 'path';
17
17
  import { tmpdir } from 'os';
18
+ import { createHash } from 'crypto';
18
19
  import { WorktreeManager } from './worktree.js';
19
20
  import { getCrossWorktreeMeta, cleanupCrossWorktrees } from './worktree-cross.js';
20
21
  import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
21
22
  import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
22
- import { git, gitQuiet } from './git-helper.js';
23
+ import { git, gitQuiet, safeGit } from './git-helper.js';
23
24
  import { resolveLatestExecuteRunId, resolveLatestExecuteRunIdWithTasks, readReview, normalizeRepoKey } from './task-review.js';
25
+ import { collectActiveQuickGuardFiles } from './quicklog.js';
26
+ import { detectCommittedDrift, formatCommittedDriftWarning } from './run/concurrent-detect.js';
24
27
 
25
28
  const CHANGES_REL = '.sillyspec/changes';
26
29
 
@@ -131,9 +134,131 @@ export function mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash
131
134
  if (code < 0) { conflicts.push(f); continue } // merge-file 本身不可用 → 原拦截
132
135
  }
133
136
  if (code === 0) {
137
+ // 覆写前备份主仓在途原文(2026-09-12 审查批 D-②):合并写回发生在 apply 整体成败
138
+ // 判定之前——后续步骤(5a/7)失败返回时主仓已是合并版,而原文从未进任何 git 对象
139
+ // (也未 commit),不留备份即不可恢复。备份落 <projectRoot>/.sillyspec/.runtime/
140
+ // merge-backups/(运行时目录,doctor/hygiene 常规清理面,不污染工作区)。
141
+ try {
142
+ const backupDir = join(projectRoot, '.sillyspec', '.runtime', 'merge-backups')
143
+ mkdirSync(backupDir, { recursive: true })
144
+ writeFileSync(join(backupDir, `${Date.now()}-${f.replace(/[\\/]/g, '__')}`), mainRaw)
145
+ } catch { /* 备份失败不阻断合并(原文仍可经 rescue 出口重取,不静默弃救 */ }
146
+ writeFileSync(join(projectRoot, f), outBuf)
147
+ merged.push(f)
148
+ console.log(`🔀 EXCLUDE-DIRTY 三方合并成功(clean): ${f}——主仓在途改动与 worktree 交付均已保留(覆写前原文已备份至 .sillyspec/.runtime/merge-backups/)`)
149
+ } else {
150
+ conflicts.push(f)
151
+ }
152
+ } catch { conflicts.push(f) }
153
+ }
154
+ } finally {
155
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
156
+ }
157
+ // 批末统一暂存(task-01 / FR-01 / D-001@v1 前半,2026-09-14-apply-conflict-hardening):
158
+ // 本写回点曾是全链路唯一未暂存的自动写点——未暂存文件对主仓工作区级 git 操作
159
+ // (restore / clean / checkout)零免疫(§64 事故的实际缺口)。对全部 clean 写回文件
160
+ // 显式 pathspec git add(safeGit 数组形式不经 shell;chunkPaths 分批防 Windows argv
161
+ // 上限,批 D-③ 同款)。fail-open:add 失败不抛(合并成果保留),console.warn 留痕 +
162
+ // 返回 stagedOk:false——manifest 侧对该批文件自动回退 readFile+归一 approx 口径。
163
+ let stagedOk = true
164
+ if (merged.length > 0) {
165
+ for (const batch of chunkPaths(merged)) {
166
+ const r = safeGit(projectRoot, ['add', '--', ...batch])
167
+ if (r && r.error) {
168
+ stagedOk = false
169
+ console.warn(`⚠️ merge 写回批末 git add 失败(fail-open:${merged.length} 个合并文件成果保留,但未暂存对 git restore/clean 不免疫,请尽快人工 git add):${String(r.error).split('\n')[0]}`)
170
+ break
171
+ }
172
+ }
173
+ }
174
+ return { merged, conflicts, stagedOk }
175
+ }
176
+
177
+ /**
178
+ * 字节级 CRLF→LF 归一(ql-20260915-001 修复①,mergeMismatchThreeWay 专用)。
179
+ *
180
+ * 动机:本仓 core.autocrlf=true(2026-09-15 实测),git checkout 出的 worktree 磁盘内容是
181
+ * CRLF、blob(git show 取出)是 LF——ours(blob LF) 与 theirs(disk CRLF) 混形喂 merge-file 会
182
+ * 逐行假冲突(每一行都呈现为两侧各改一次)。三输入统一 LF 域合并,写回后 git add 按
183
+ * autocrlf 属性归一回 blob 域(磁盘 LF 形态 git status 亦不报幻影改动)。
184
+ *
185
+ * 字节级替换(latin1 语义,不经 utf8 解码)保任意非 UTF-8 文本字节不被替换字符污染;含 NUL
186
+ * (二进制标志)返回 null——二进制不可文本合并,调用方按冲突处理。
187
+ *
188
+ * @param {Buffer} buf
189
+ * @returns {Buffer|null} 归一后内容;二进制返回 null
190
+ */
191
+ function _toLfBuffer(buf) {
192
+ if (!buf || buf.length === 0) return buf
193
+ if (buf.indexOf(0) !== -1) return null // 二进制
194
+ if (buf.indexOf(Buffer.from('\r\n')) === -1) return buf
195
+ const out = []
196
+ for (let i = 0; i < buf.length; i++) {
197
+ if (buf[i] === 0x0d && i + 1 < buf.length && buf[i + 1] === 0x0a) { out.push(0x0a); i++ }
198
+ else out.push(buf[i])
199
+ }
200
+ return Buffer.from(out)
201
+ }
202
+
203
+ /**
204
+ * EXCLUDE-MISMATCH 前置自动三方合并(ql-20260915-001 修复①,坑 apply-archived-mismatch-no-merge:
205
+ * 2026-09-14 用户实证归档后补 apply 时主仓文件已被并行会话提交推进 → rescue EXCLUDE-MISMATCH
206
+ * 跳过不落地,被迫手工 cp + 锚点合并)。
207
+ *
208
+ * 与 mergeDirtyOverlapThreeWay 同实现形态,base/ours 取源不同:
209
+ * - base = apply 锚点(baseHash 形参,调用方传 deliverableBase=baselineCommit||baseHash)blob
210
+ * - **ours = 主仓 HEAD blob(已提交推进态,非脏读——mismatch 面主仓无未提交改动,
211
+ * 调用方已按 pre-stash dirty 集排除,HEAD 即主仓当前态)**
212
+ * - theirs = worktree 文件内容(磁盘读,子代理未 commit 的交付)
213
+ *
214
+ * clean 合并才写回主仓(两侧增量都保留)+ 批末 git add(chunkPaths 分批)+ 调用方从
215
+ * changedFiles/hashMismatchFiles 剔除(patch 不再重放,防 staged 内容与 patch 预像互踩);
216
+ * conflict / 异常 fail-soft 维持原路径(rescue EXCLUDE-MISMATCH 新文案 / step7 --3way 实测)。
217
+ * 覆写前备份主仓磁盘原文至 .sillyspec/.runtime/merge-backups/(与脏读版同一惯例)。
218
+ *
219
+ * @param {{ projectRoot: string, worktreePath: string, baseHash: string }} ctx
220
+ * @param {string[]} mismatchFiles mismatch ∩ changedFiles 且主仓无未提交改动的文件(仓库根相对路径)
221
+ * @returns {{ merged: string[], conflicts: string[], stagedOk: boolean }}
222
+ */
223
+ export function mergeMismatchThreeWay({ projectRoot, worktreePath, baseHash }, mismatchFiles) {
224
+ const merged = []
225
+ const conflicts = []
226
+ const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-3way-m-'))
227
+ try {
228
+ for (const f of mismatchFiles) {
229
+ try {
230
+ // ours/HEAD 缺失(主干侧删除等 modify/delete 形态)→ git show 抛错 → conflicts(fail-soft,
231
+ // 删除语义 merge-file 无法表达,交既有出口 --3way/--merge 裁决)
232
+ const baseRaw = git(projectRoot, ['show', `${baseHash}:${f}`], { encoding: 'buffer', timeout: 15000 })
233
+ const oursRaw = git(projectRoot, ['show', `HEAD:${f}`], { encoding: 'buffer', timeout: 15000 })
234
+ const wtRaw = readFileSync(join(worktreePath, f))
235
+ const baseN = _toLfBuffer(baseRaw)
236
+ const oursN = _toLfBuffer(oursRaw)
237
+ const theirsN = _toLfBuffer(wtRaw)
238
+ if (!baseN || !oursN || !theirsN) { conflicts.push(f); continue } // 二进制 → 原路径
239
+ const p = (tag) => join(tmpDir, `${tag}-${Math.random().toString(36).slice(2, 8)}`)
240
+ const baseP = p('base'); const oursP = p('ours'); const theirsP = p('theirs')
241
+ writeFileSync(baseP, baseN); writeFileSync(oursP, oursN); writeFileSync(theirsP, theirsN)
242
+ let outBuf, code = 0
243
+ try {
244
+ outBuf = git(projectRoot, ['merge-file', '-p', oursP, baseP, theirsP], { encoding: 'buffer', timeout: 15000 })
245
+ } catch (e) {
246
+ code = (typeof e.status === 'number') ? e.status : -1
247
+ outBuf = (e.stdout instanceof Buffer) ? e.stdout : Buffer.alloc(0)
248
+ if (code < 0) { conflicts.push(f); continue }
249
+ }
250
+ if (code === 0) {
251
+ // 覆写前备份主仓磁盘原文(merge-backups 惯例;ours=HEAD 理论可经 git show 重取,仍留备份保审计)
252
+ try {
253
+ const backupDir = join(projectRoot, '.sillyspec', '.runtime', 'merge-backups')
254
+ mkdirSync(backupDir, { recursive: true })
255
+ let pre = oursRaw
256
+ try { pre = readFileSync(join(projectRoot, f)) } catch { /* 磁盘缺文件用 HEAD 原文 */ }
257
+ writeFileSync(join(backupDir, `${Date.now()}-${f.replace(/[\\/]/g, '__')}`), pre)
258
+ } catch { /* 备份失败不阻断合并 */ }
134
259
  writeFileSync(join(projectRoot, f), outBuf)
135
260
  merged.push(f)
136
- console.log(`🔀 EXCLUDE-DIRTY 三方合并成功(clean): ${f}——主仓在途改动与 worktree 交付均已保留`)
261
+ console.log(`🔀 EXCLUDE-MISMATCH 三方合并成功(clean): ${f}——主仓已提交推进与 worktree 交付均已保留(覆写前原文已备份至 .sillyspec/.runtime/merge-backups/)`)
137
262
  } else {
138
263
  conflicts.push(f)
139
264
  }
@@ -142,7 +267,20 @@ export function mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash
142
267
  } finally {
143
268
  try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
144
269
  }
145
- return { merged, conflicts }
270
+ // 批末统一暂存(与 mergeDirtyOverlapThreeWay 同款:safeGit 数组形式 + chunkPaths 分批,
271
+ // fail-open:add 失败不抛、stagedOk:false 供调用方 warning 提示人工补 add)
272
+ let stagedOk = true
273
+ if (merged.length > 0) {
274
+ for (const batch of chunkPaths(merged)) {
275
+ const r = safeGit(projectRoot, ['add', '--', ...batch])
276
+ if (r && r.error) {
277
+ stagedOk = false
278
+ console.warn(`⚠️ mismatch merge 写回批末 git add 失败(fail-open:${merged.length} 个合并文件成果保留,但未暂存对 git restore/clean 不免疫,请尽快人工 git add):${String(r.error).split('\n')[0]}`)
279
+ break
280
+ }
281
+ }
282
+ }
283
+ return { merged, conflicts, stagedOk }
146
284
  }
147
285
 
148
286
  export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchFiles, deletedFiles = [], worktreePath, projectRoot }) {
@@ -168,7 +306,9 @@ export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchF
168
306
  continue;
169
307
  }
170
308
  if (mismatchSet.has(f)) {
171
- warnings.push(`跳过 ${f}:EXCLUDE-MISMATCH(主干已提交推进该文件,cp 会回退他人改动;请先 commit main 未提交改动再正常 apply 走 --3way 合并)`);
309
+ // ql-20260915-001 修复①:文案更新——前置三方合并已尝试过(4.5b),此处剩的是合并冲突面,
310
+ // 给出两条实际出路(手动锚点合并 / 先 commit 后 --3way),不再误导「正常 apply 走 --3way」即可解
311
+ warnings.push(`跳过 ${f}:EXCLUDE-MISMATCH(主干已提交推进该文件,已尝试自动三方合并,冲突——手动锚点合并,或先 commit 主仓未提交改动后正常 apply 走 --3way)`);
172
312
  excludedCount++;
173
313
  continue;
174
314
  }
@@ -176,6 +316,15 @@ export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchF
176
316
  cpFileCount++;
177
317
  }
178
318
 
319
+ // D-003@v1(task-01,2026-09-14-apply-conflict-hardening):rescue 是人工落地路径,落地文件
320
+ // 不进 manifest 也不自动暂存——主仓工作区级 git 操作(restore/clean/checkout)可静默冲掉
321
+ // 未暂存落地(§64 事故实际路径)。commands 末尾追加一行「落地后立即 git add 锁定」指引
322
+ // (# 注释形态,整块复制粘贴仍是合法 shell;纯文案,commands/warnings 结构语义与
323
+ // cpFileCount/excludedCount 口径不变;commands 为空 = 无可落地文件,不加指引)。
324
+ if (commands.length > 0) {
325
+ commands.push(`# 全部执行后立即 git add -- <上方 cp 落地的各文件路径> 锁定暂存区(staged 对 git restore / git clean 免疫,防止并行会话的工作区级操作冲掉刚落地的文件;rm 删除的文件无需 add)`);
326
+ }
327
+
179
328
  return { commands, warnings, cpFileCount, excludedCount };
180
329
  }
181
330
 
@@ -242,19 +391,39 @@ export function resolvePatchFiles(changedFiles, allowSet, hasAllowList) {
242
391
  function getBlobHashMap(cwd, treeish, files) {
243
392
  const map = new Map();
244
393
  if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
245
- const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
246
- if (!raw) return map;
247
- for (const line of raw.split('\n')) {
248
- if (!line) continue;
249
- const tabIdx = line.indexOf('\t');
250
- if (tabIdx === -1) continue;
251
- const filePath = line.slice(tabIdx + 1);
252
- const hash = line.slice(0, tabIdx).split(' ')[2]; // "<mode> <type> <hash>"
253
- if (hash) map.set(filePath, hash);
394
+ for (const batch of chunkPaths(files)) {
395
+ const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...batch]);
396
+ if (!raw) continue
397
+ for (const line of raw.split('\n')) {
398
+ if (!line) continue;
399
+ const tabIdx = line.indexOf('\t');
400
+ if (tabIdx === -1) continue;
401
+ const filePath = line.slice(tabIdx + 1);
402
+ const hash = line.slice(0, tabIdx).split(' ')[2]; // "<mode> <type> <hash>"
403
+ if (hash) map.set(filePath, hash);
404
+ }
254
405
  }
255
406
  return map;
256
407
  }
257
408
 
409
+ /**
410
+ * 按估算 argv 长度切批(2026-09-12 审查批 D-③):Windows CreateProcess 命令行 32767 字符
411
+ * 上限——数百个长路径(Windows 深路径 160+ 字符 × 200 文件)全量展开时 spawn 直接失败,
412
+ * add/reset/diff 整体炸(hash-object 同病灶在 worktree.js,本文件先行收口)。保守 8000
413
+ * 字符/批,预留 git 参数与编码余量。返回至少一批(空输入返回单空批)。
414
+ */
415
+ export function chunkPaths(paths, maxChars = 8000) {
416
+ const batches = [[]]
417
+ let len = 0
418
+ for (const p of paths) {
419
+ const l = String(p).length + 1
420
+ if (batches[batches.length - 1].length > 0 && len + l > maxChars) { batches.push([]); len = 0 }
421
+ batches[batches.length - 1].push(p)
422
+ len += l
423
+ }
424
+ return batches
425
+ }
426
+
258
427
  /**
259
428
  * apply 的文件清单 = design §6 清单 ∪ plan TaskCard allowed_paths(execute 复盘 c)。
260
429
  *
@@ -345,14 +514,127 @@ function _silentPointerSpecRoot(projectRoot) {
345
514
  } catch { return null; }
346
515
  }
347
516
 
517
+ /**
518
+ * apply 成功尾声写 apply-manifest.json(task-01 / FR-02 / D-001@v1 后半,
519
+ * 2026-09-14-apply-conflict-hardening)。
520
+ *
521
+ * 目的:apply 后交付文件在主仓被并行会话的工作区级 git 操作冲掉(新文件删除/修改回退)时
522
+ * 靠指纹可检测(doctor 漂移检查项消费,Wave 3)。CLI 全权写(agent 勿手改,verify-facts
523
+ * 同款契约);rescue 人工落地文件不进 manifest(design 非目标边界)。
524
+ *
525
+ * 哈希口径(design 接口定义定案,AGENTS.md 规则 13 Windows 兼容):**staged blob 内容**
526
+ * (git show :<path>,autocrlf 归一后的 LF 规范态)——写侧在 git add / apply --3way(隐含
527
+ * --index)之后取值,与 doctor 的 staged 态比对天然同基。文件未进暂存(如 merge 写回批末
528
+ * add 失败的 fail-open 面)→ 回退 readFile 后 CRLF→LF 归一计算(latin1 往返保字节),条目
529
+ * 加 approx:true 标记;staged 与磁盘皆无(apply 删除的文件——无内容可指纹)→ 不进 manifest。
530
+ *
531
+ * 落点:<changeDir>/apply-manifest.json(resolveActiveOrArchiveChangeDir 归档感知——与
532
+ * resolveApplyAllowSet 同源,归档后补 apply 的重放写在归档目录)。已存在则覆盖(重放 apply
533
+ * 以最新为准)。manifest 落 .sillyspec/changes/ 面 → filterDeliverableFiles 排除,不构成
534
+ * apply 交付物、不进自身 files。
535
+ *
536
+ * 本函数自身不 catch 写失败(交调用方 fail-open warning);files 为空时跳过不写(无落盘面
537
+ * 的成功出口不产 manifest,doctor 按存在性跳过)。
538
+ *
539
+ * @param {{ projectRoot: string, specBase?: string, changeName: string, baseHash?: string, files: string[] }} args
540
+ * specBase 缺省时与 resolveApplyAllowSet 同链回退(指针 specRoot > 本地 .sillyspec);
541
+ * baseHash 记 apply 锚点(deliverableBase 语义:baselineCommit || baseHash)
542
+ * @returns {{ written: boolean, file?: string, files: Array<{path:string, sha256:string, approx?:true}>, skipped?: string }}
543
+ */
544
+ function writeApplyManifest({ projectRoot, specBase, changeName, baseHash, files }) {
545
+ const face = [...new Set((Array.isArray(files) ? files : []).filter(Boolean))];
546
+ if (face.length === 0) return { written: false, files: [], skipped: 'empty-files' };
547
+ const base = specBase || _silentPointerSpecRoot(projectRoot) || join(projectRoot, '.sillyspec');
548
+ const changeDir = resolveActiveOrArchiveChangeDir(base, changeName);
549
+ const entries = [];
550
+ for (const f of face) {
551
+ let sha256 = null;
552
+ let approx = false;
553
+ try {
554
+ // staged blob(LF 规范态):encoding buffer 保二进制原样;不在 index(删除/未 add)→ 抛错走回退
555
+ const blob = git(projectRoot, ['show', `:${f}`], { encoding: 'buffer', timeout: 15000 });
556
+ sha256 = createHash('sha256').update(blob).digest('hex');
557
+ } catch {
558
+ try {
559
+ const raw = readFileSync(join(projectRoot, f));
560
+ // CRLF→LF 归一(latin1 往返保字节,防 utf8 解码破坏二进制内容)
561
+ const norm = Buffer.from(raw.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
562
+ sha256 = createHash('sha256').update(norm).digest('hex');
563
+ approx = true;
564
+ } catch { continue; } // staged 与磁盘皆无(apply 删除面)→ 无内容可指纹,不进 manifest
565
+ }
566
+ entries.push(approx ? { path: f, sha256, approx: true } : { path: f, sha256 });
567
+ }
568
+ const manifest = {
569
+ schemaVersion: 1,
570
+ change: changeName,
571
+ appliedAt: new Date().toISOString(),
572
+ baseHash: baseHash || null,
573
+ files: entries,
574
+ };
575
+ mkdirSync(changeDir, { recursive: true });
576
+ const manifestPath = join(changeDir, 'apply-manifest.json');
577
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
578
+ return { written: true, file: manifestPath, files: entries };
579
+ }
580
+
581
+ /**
582
+ * 坑 mixed-baseline-drift-hint(ql-20260915-004):apply 成功尾声混合基线 advisory。
583
+ *
584
+ * 主仓自本变更基点(baseHash)后的已提交推进若触及本变更相关文件(含 .test./.spec. 变体),
585
+ * worktree 快照内验证过的测试断言可能基于旧内容——apply 落地的是混合基线,提示 agent 合并态
586
+ * 复跑相关测试(非阻断,result.committedDrift 留痕)。head 缺省 HEAD:patch 路径自身交付
587
+ * staged 未提交,baseHash..HEAD 即纯他者推进面;merge 路径传 preMergeHead(merge 提交后
588
+ * HEAD 含自身交付,不剔会把「自己合自己」误报成他者推进)。fail-soft:异常零影响 apply。
589
+ */
590
+ function annotateCommittedDrift(result, { projectRoot, baseHash, files, head }) {
591
+ try {
592
+ if (!baseHash || !files || files.length === 0) return;
593
+ const drift = detectCommittedDrift({ projectRoot, baseHash, touchedFiles: files, head });
594
+ if (drift && drift.drift) {
595
+ result.committedDrift = drift;
596
+ console.warn(formatCommittedDriftWarning(drift));
597
+ }
598
+ } catch { /* fail-soft:drift 检测异常不影响 apply 结果 */ }
599
+ }
600
+
601
+ /**
602
+ * guard 相交预检(task-02 / FR-03 / D-002@v1,内部函数)。
603
+ *
604
+ * 活跃 quick 会话 guard.allowedFiles 与本次 apply 实际落盘集求交集——非空说明本变更文件正被
605
+ * 并行 quick 会话声明在途改动,此刻 apply 会互冲(2026-09-14 实证事故:apply 交付文件被并行
606
+ * 会话工作区 git 操作冲掉)。交集口径:字符串等值(guard.allowedFiles 与 changedFiles 同为
607
+ * 仓库根相对路径)。无活跃会话/无声明/交集空 → blocked=false(零行为变化)。
608
+ *
609
+ * @param {{ activeGuards: Map<string, string[]>, applyFiles: string[], selfChange?: string }} p
610
+ * - activeGuards:collectActiveQuickGuardFiles 产物(已按 excludeChange 排除自身)
611
+ * - applyFiles:changedFiles∪newPatchFiles(result 实际字段口径)
612
+ * - selfChange:防御性再排除(sessionId == 本次 changeName 不拦自身)
613
+ * @returns {{ overlaps: Array<{sessionId: string, file: string}>, blocked: boolean }}
614
+ */
615
+ function checkGuardOverlap({ activeGuards, applyFiles, selfChange }) {
616
+ const overlaps = [];
617
+ const fileSet = new Set((applyFiles || []).filter(Boolean));
618
+ if (!activeGuards || fileSet.size === 0) return { overlaps, blocked: false };
619
+ for (const [sessionId, allowedFiles] of activeGuards) {
620
+ if (selfChange && sessionId === selfChange) continue;
621
+ for (const f of (allowedFiles || [])) {
622
+ if (fileSet.has(f)) overlaps.push({ sessionId, file: f });
623
+ }
624
+ }
625
+ return { overlaps, blocked: overlaps.length > 0 };
626
+ }
627
+
348
628
  /**
349
629
  * 收集最新 execute run 各 task review.json 的 changedFiles 声明 → Map<repoKey, string[]>。
350
630
  *
351
631
  * 坑 apply-undeclared-deviation-block(2026-08-24 用户反馈四期③):执行期有据越界文件
352
632
  * (facade 转发/名单测试)不在 design §6 也不在 allowed_paths,Gate1 拦 apply 只能回改
353
633
  * design.md。review.json changedFiles 是 reviewer 对实际改动的声明(Task Review Gate 的
354
- * verifyReviewGitEvidence 已交叉校验其与真实 git diff 相交),作为第三源并入 allow set——
355
- * 放行仅限「已声明且过证据校验」的文件,完全越界仍拦。口径与 complete-handlers 的
634
+ * verifyReviewGitEvidence 已交叉校验其与真实 git diff 相交)。D-003@v1 收紧(2026-09-14
635
+ * change-ownership-guards task-03):声明只与 allow 面相交(pathMatches 容差)才计入,
636
+ * 不相交的外来声明剔除出放行面、进 violations 报告行——本函数仍是声明收集口(口径不变),
637
+ * 相交过滤在主仓 3b/跨仓清单校验两消费点执行。口径与 complete-handlers 的
356
638
  * collectExecuteChangedFiles 一致:change 戳归属 run、.sillyspec//meta.json 过滤、
357
639
  * review.repo 切片(跨仓声明不进 main 集)。读不到 run/review → 空 Map(fail-closed 回退旧行为)。
358
640
  *
@@ -481,9 +763,12 @@ function validateCrossRepoNoOp(ctx, projectRoot, changeName) {
481
763
  * 可清,wm.cleanup 只作用于主仓 worktree)。主仓 task 走原 A5 完整 apply 不动(GOAL-2 单仓零回归)。
482
764
  *
483
765
  * @param {string} changeName - 变更名
484
- * @param {{ cwd?: string, checkOnly?: boolean, merge?: boolean, ctx?: object }} opts
766
+ * @param {{ cwd?: string, checkOnly?: boolean, merge?: boolean, ctx?: object, force?: boolean, autoApply?: boolean }} opts
485
767
  * - ctx:可选 MultiRepoContext(design §7.1)。缺省=单仓退化(仅主仓 apply,零行为变化,GOAL-2)。
486
768
  * 提供 ctx 且含跨仓 entry 时,触发跨仓 no-op 校验(校验 review.head 真实 + 不 cleanup 跨仓)。
769
+ * - force(task-02 / FR-03):guard 相交预检命中时显式放行并留痕 result.overlapForced(仅人工 CLI --force)。
770
+ * - autoApply(task-02 / FR-03):assess 自动入口置 true——相交命中软跳过(result.overlapSkipped + warning,
771
+ * 不抛错不落盘);自动路径永不 force。
487
772
  * @returns {{
488
773
  * ok: boolean,
489
774
  * changedFiles: string[],
@@ -554,7 +839,7 @@ export async function withMainRepoLock(projectRoot, changeName, purpose, fn, opt
554
839
  *
555
840
  * 对有 worktree meta 的跨仓仓(worktree-cross.js 创建)执行与主仓 A5 同构的 patch 回落:
556
841
  * deliverables(diff baselineCommit||baseHash + untracked,filterDeliverableFiles 排除基础设施)
557
- * → 清单校验(resolveApplyAllowSet 按 repo 切片 ∪ review 声明切片)
842
+ * → 清单校验(resolveApplyAllowSet 按 repo 切片 + review 声明相交过滤,D-003:不相交剔除)
558
843
  * → 跨仓主工作副本 dirty 重叠拦截
559
844
  * → patch(tracked diff + untracked add/diff --cached/reset,Buffer 防二进制损坏)
560
845
  * → git apply --3way 回跨仓主工作副本;成功后 cleanup 该跨仓 worktree(分支保留作锚)。
@@ -612,24 +897,45 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
612
897
  continue;
613
898
  }
614
899
 
615
- // 清单校验(per-repo 切片 + review 声明切片并入,与主仓 Gate1/3b 同语义)。
900
+ // 清单校验(per-repo 切片 + review 声明相交过滤,与主仓 Gate1/3b 同语义)。
616
901
  // specBase 显式传(平台模式 changes/ 在 specRoot;本地模式与旧硬编码同值零回归)。
902
+ // review 声明收紧(D-003@v1,2026-09-14-change-ownership-guards task-03):声明文件须与
903
+ // allow 面(design 清单 ∪ 各 task target_files/allowed_paths ∪ linked-change 声明 =
904
+ // resolveApplyAllowSet 现有并集)相交(pathMatches 容差)才计入;不相交的外来声明剔除出
905
+ // 放行面、进 violations 报告行(「review 声明了越权文件」嫌疑标注),admission 增量归零。
617
906
  const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase });
618
- let allowSet = allowMap.get(repoKey) || new Set();
907
+ const allowSet = allowMap.get(repoKey) || new Set();
619
908
  const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(specBase, '.runtime') }).get(repoKey);
620
- if (reviewDeclared) {
621
- const merged = new Set([...allowSet]);
909
+ const reviewForeign = [];
910
+ if (reviewDeclared && allowSet.size > 0) {
911
+ const face = [...allowSet];
622
912
  for (const f of reviewDeclared) {
623
- if (![...merged].some(ap => pathMatches(f, ap))) merged.add(f);
913
+ if (!face.some(ap => pathMatches(f, ap))) reviewForeign.push(f);
624
914
  }
625
- allowSet = merged;
915
+ }
916
+ if (reviewForeign.length > 0) {
917
+ out.warnings.push(
918
+ `跨仓 ${repoKey}:${reviewForeign.length} 个 review 声明文件不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明),已剔除出放行面(review 声明了越权文件——嫌疑:review 失实或并行会话在途文件混入声明):${reviewForeign.join(', ')}`
919
+ );
626
920
  }
627
921
  if (allowSet.size > 0) {
628
922
  const violations = classifyAllowListViolations(changedFiles, allowSet);
629
923
  if (violations.length > 0) {
630
- out.errors.push(
631
- `跨仓 ${repoKey}:文件清单校验失败——以下变更文件不在 design.md 清单、也不在该仓 task review.json changedFiles 声明中:\n ${violations.join('\n ')}`
632
- );
924
+ const foreignSet = new Set(reviewForeign);
925
+ const foreignViolations = violations.filter(f => foreignSet.has(f));
926
+ const undeclaredViolations = violations.filter(f => !foreignSet.has(f));
927
+ const lines = [];
928
+ if (undeclaredViolations.length > 0) {
929
+ lines.push(
930
+ `跨仓 ${repoKey}:文件清单校验失败——以下变更文件不在 design.md 清单、也不在该仓 task review.json changedFiles 声明中:\n ${undeclaredViolations.join('\n ')}`
931
+ );
932
+ }
933
+ if (foreignViolations.length > 0) {
934
+ lines.push(
935
+ `跨仓 ${repoKey}:review 声明了越权文件——以下变更文件已被 review.json changedFiles 声明但不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明),不予放行:\n ${foreignViolations.join('\n ')}\n(确属本变更交付:补进 design §6 清单或任务卡 allowed_paths 后重跑)`
936
+ );
937
+ }
938
+ out.errors.push(lines.join('\n'));
633
939
  if (!checkOnly) continue;
634
940
  }
635
941
  }
@@ -676,14 +982,24 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
676
982
  const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
677
983
  const patchParts = [];
678
984
  if (trackedFiles.length > 0) {
679
- patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...trackedFiles], { encoding: 'buffer', timeout: 60000 }));
985
+ // argv 分批(批 D-③):数百长路径全量展开超 Windows 命令行上限;diff 输出按批拼接
986
+ // (各批均为完整 patch 文档,concat 对 git apply 合法)
987
+ for (const batch of chunkPaths(trackedFiles)) {
988
+ patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...batch], { encoding: 'buffer', timeout: 60000 }));
989
+ }
680
990
  }
681
991
  if (untrackedPatchFiles.length > 0) {
682
- git(cm.worktreePath, ['add', '--', ...untrackedPatchFiles]);
992
+ for (const batch of chunkPaths(untrackedPatchFiles)) {
993
+ git(cm.worktreePath, ['add', '--', ...batch]);
994
+ }
683
995
  try {
684
- patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...untrackedPatchFiles], { encoding: 'buffer', timeout: 60000 }));
996
+ for (const batch of chunkPaths(untrackedPatchFiles)) {
997
+ patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...batch], { encoding: 'buffer', timeout: 60000 }));
998
+ }
685
999
  } finally {
686
- gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
1000
+ for (const batch of chunkPaths(untrackedPatchFiles)) {
1001
+ gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...batch]);
1002
+ }
687
1003
  }
688
1004
  }
689
1005
  const patchContent = Buffer.concat(patchParts);
@@ -726,7 +1042,7 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
726
1042
  return out;
727
1043
  }
728
1044
 
729
- export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, base = 'merge-base', ctx = null, skipOverlap = false, stashDirty = false } = {}) {
1045
+ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, base = 'merge-base', ctx = null, skipOverlap = false, stashDirty = false, force = false, autoApply = false } = {}) {
730
1046
  const projectRoot = cwd || process.cwd();
731
1047
  const wm = new WorktreeManager({ cwd: projectRoot });
732
1048
  const meta = wm.getMeta(changeName);
@@ -873,27 +1189,30 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
873
1189
  // (主仓 worktree diff 只含主仓文件,跨仓文件在跨仓仓不进主仓 worktree diff)。跨仓 allowed_paths
874
1190
  // 仅作 Map 切片返回供上游用,主流程不消费(跨仓 apply=no-op,D-009)。
875
1191
  const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase: allowSpecBase });
876
- // --- 3b. review.json 声明偏差文件并入(坑 apply-undeclared-deviation-block)---
877
- // 第三源:reviewer 声明的 changedFiles(已过 Task Review Gate git 证据交叉校验)——执行期
878
- // 有据越界(facade 转发/名单测试)不再逼回改 design.md。各 repo 切片各自并入;仅靠 review
879
- // 放行的文件记审计 warning(result.reviewAdmittedFiles),完全越界文件仍拦(Gate1 在扩展后判)。
1192
+ // --- 3b. review.json 声明偏差文件相交过滤(坑 apply-undeclared-deviation-block → D-003@v1 收紧,
1193
+ // 2026-09-14-change-ownership-guards task-03 / FR-02)---
1194
+ // 旧语义:reviewer 声明的 changedFiles 整体并入 allow set(声明即放行)——troubleshooting §65
1195
+ // 事件②实证该通道无 allowed_paths 相交校验,11 个并行会话在途文件经 review 声明真实放行。
1196
+ // 新语义(D-003 翻转):review 声明只承认与 allow 面(design 清单 ∪ 各 task
1197
+ // target_files/allowed_paths ∪ linked-change 声明 = resolveApplyAllowSet 现有并集)相交的文件
1198
+ // (pathMatches 容差);不相交的外来声明剔除出放行面、进 violations 报告行(「review 声明了
1199
+ // 越权文件」嫌疑标注)——admission 增量归零,通道保留作审计报告位(外来声明显式列出)。
880
1200
  const reviewDeclaredByRepo = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: allowRuntimeRoot });
881
- const reviewAdmittedFiles = [];
1201
+ const reviewForeignMain = [];
882
1202
  for (const [repoKey, files] of reviewDeclaredByRepo) {
883
1203
  const repoSet = allowMap.get(repoKey);
884
- if (!repoSet) continue; // 该 repo 无 design/任务卡清单(无清单=不校验),无需扩展
885
- const existing = [...repoSet];
1204
+ if (!repoSet || repoSet.size === 0) continue; // 该 repo 无 design/任务卡清单(无清单=不校验),无相交可判
1205
+ const face = [...repoSet];
886
1206
  for (const f of files) {
887
- if (!existing.some(ap => pathMatches(f, ap))) {
888
- repoSet.add(f);
889
- if (repoKey === 'main') reviewAdmittedFiles.push(f);
1207
+ if (!face.some(ap => pathMatches(f, ap))) {
1208
+ if (repoKey === 'main') reviewForeignMain.push(f);
890
1209
  }
891
1210
  }
892
1211
  }
893
- if (reviewAdmittedFiles.length > 0) {
894
- result.reviewAdmittedFiles = reviewAdmittedFiles;
1212
+ if (reviewForeignMain.length > 0) {
1213
+ result.reviewOverdeclaredFiles = reviewForeignMain;
895
1214
  result.warnings = (result.warnings || []).concat([
896
- `${reviewAdmittedFiles.length} 个变更文件不在 design.md/任务卡清单,但已被 task review.json changedFiles 声明(review 声明放行,已过 Task Review Gate git 证据校验;审计留痕):${reviewAdmittedFiles.join(', ')}`
1215
+ `${reviewForeignMain.length} 个 review 声明文件不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明),已剔除出放行面(review 声明了越权文件——嫌疑:review 失实或并行会话在途文件混入声明):${reviewForeignMain.join(', ')};确属本变更交付请补进 design §6 清单或任务卡 allowed_paths`
897
1216
  ]);
898
1217
  }
899
1218
  const allowSet = allowMap.get('main') || new Set();
@@ -933,16 +1252,90 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
933
1252
  if (hasAllowList) {
934
1253
  const violations = classifyAllowListViolations(changedFiles, allowSet);
935
1254
  if (violations.length > 0) {
1255
+ // D-003:violation 分流——已被 review 声明但不在 allow 面(越权声明)与完全未声明分报,
1256
+ // 前者带「review 声明了越权文件」嫌疑标注(外来声明剔除后的落地拦截报告行)。
1257
+ const foreignSet = new Set(result.reviewOverdeclaredFiles || []);
1258
+ const foreignViolations = violations.filter(f => foreignSet.has(f));
1259
+ const undeclaredViolations = violations.filter(f => !foreignSet.has(f));
936
1260
  result.extraFiles.push(...violations);
937
- result.errors.push(
938
- `文件清单校验失败:以下变更文件不在 design.md 清单、也不在 task review.json changedFiles 声明中:\n ${violations.join('\n ')}\n(若属执行期合理偏差:在 task review.json 的 changedFiles 声明,或在 design.md §6 清单补行)`
939
- );
1261
+ const errLines = [];
1262
+ if (undeclaredViolations.length > 0) {
1263
+ errLines.push(
1264
+ `文件清单校验失败:以下变更文件不在 design.md 清单、也不在 task review.json changedFiles 声明中:\n ${undeclaredViolations.join('\n ')}\n(若属执行期合理偏差:在 design.md §6 清单或任务卡 allowed_paths 补行——review.json 的 changedFiles 声明已不再放行清单外文件,仅作审计对照)`
1265
+ );
1266
+ }
1267
+ if (foreignViolations.length > 0) {
1268
+ errLines.push(
1269
+ `review 声明了越权文件:以下变更文件已被 task review.json changedFiles 声明,但不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明)——嫌疑:review 失实或并行会话在途文件混入声明,不予放行:\n ${foreignViolations.join('\n ')}\n(确属本变更交付:补进 design.md §6 清单或任务卡 allowed_paths 后重跑)`
1270
+ );
1271
+ }
1272
+ result.errors.push(errLines.join('\n'));
940
1273
  // checkOnly(assess)模式不短路:继续跑 Gate3,收集所有道供一次报全(坑 worktree-execute-apply-friction 坑4)。
941
1274
  // 真实 apply(checkOnly=false)仍短路,保安全。
942
1275
  if (!checkOnly) return result;
943
1276
  }
944
1277
  }
945
1278
 
1279
+ // --- 4.3 guard 相交预检(task-02 / FR-03 / D-002@v1):活跃 quick 会话 guard.allowedFiles ∩ 本次落盘集 ---
1280
+ // 内嵌 applyWorktree 主流程:CLI apply(index.js apply 分支)与 assess 自动 apply(index.js assess
1281
+ // 分支)全经此覆盖;真实 apply 由调用方 withMainRepoLock 包裹——判定与落盘同在锁内,无 TOCTOU。
1282
+ // 位置在一切主仓写动作之前(4.4 stash / 4.5 合并写回 / 4.6 --merge / step7 patch / ENOBUFS 降级)。
1283
+ // applyFiles=changedFiles∪newPatchFiles:newPatchFiles⊆patchFiles⊆changedFiles(resolvePatchFiles
1284
+ // 是 changedFiles 的过滤),并集即 changedFiles∪patchFiles,此处显式两集合并忠于 result 实际字段口径。
1285
+ // specBase/sessionsDir 复用上方 allowlist 同一解析链(allowSpecBase > 平台指针 > 本地),勿新造配置读取。
1286
+ // checkOnly 只读零写盘 → 跳过(零行为变化)。无活跃会话/无声明/交集空 → 全段空转零路径变化。
1287
+ if (!checkOnly) {
1288
+ const guardSpecBase = allowSpecBase || _silentPointerSpecRoot(projectRoot) || join(projectRoot, '.sillyspec');
1289
+ const guardSessionsDir = allowRuntimeRoot ? join(allowRuntimeRoot, 'quick-sessions') : undefined;
1290
+ const activeGuards = collectActiveQuickGuardFiles(guardSpecBase, { excludeChange: changeName, sessionsDir: guardSessionsDir });
1291
+ const guardApplyFiles = [...new Set([...changedFiles, ...resolvePatchFiles(changedFiles, allowSet, hasAllowList)])];
1292
+ const guardCheck = checkGuardOverlap({ activeGuards, applyFiles: guardApplyFiles, selfChange: changeName });
1293
+ if (guardCheck.blocked) {
1294
+ const sessions = [...new Set(guardCheck.overlaps.map(o => o.sessionId))];
1295
+ const files = [...new Set(guardCheck.overlaps.map(o => o.file))];
1296
+ const pairLines = guardCheck.overlaps.map(o => ` [${o.sessionId}] ${o.file}`);
1297
+ if (force) {
1298
+ // 显式解锁:放行并留痕(审计可见——自动路径永不带 force,overlapForced 只可能来自人工 --force)
1299
+ result.overlapForced = { sessions, files };
1300
+ result.warnings = (result.warnings || []).concat([
1301
+ `--force 越过 guard 相交预检:${guardCheck.overlaps.length} 个会话×文件对与活跃 quick 会话声明重叠——` +
1302
+ pairLines.join(';') + `(对方会话在途改动可能被覆盖,overlapForced 留痕)`
1303
+ ]);
1304
+ } else if (autoApply) {
1305
+ // 软跳过:无人值守不越权(不落地互冲)也不阻断审计流——不抛错,指引人工评估
1306
+ result.overlapSkipped = true;
1307
+ result.warnings = (result.warnings || []).concat([
1308
+ `自动 apply 软跳过(未落盘):${files.length} 个文件与活跃 quick 会话在途声明重叠——` +
1309
+ pairLines.join(';') + `——请人工评估后显式 sillyspec worktree apply ${changeName}(确认无冲突),或等对方会话 --done 后重试`
1310
+ ]);
1311
+ return result; // 安全返回:result.ok 保持 false,主仓零写动作(本预检位于一切写动作之前)
1312
+ } else {
1313
+ // 人工 CLI 入口(fail-closed):结构化错误含会话×文件对清单 + 串行化指引 + --force 提示
1314
+ result.errors.push(
1315
+ `guard 相交拦截:本次 apply 文件与 ${sessions.length} 个活跃 quick 会话的在途声明重叠(会话×文件对):\n` +
1316
+ pairLines.join('\n') +
1317
+ `\n串行化指引:等对方会话 sillyspec run quick --done 完成(guard 目录随之清理)后重试;` +
1318
+ `或确认无实际冲突后显式解锁:sillyspec worktree apply ${changeName} --force(overlapForced 留痕)`
1319
+ );
1320
+ return result; // index.js apply 分支对 result.errors 走 exit 1 展示
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ // ── ql-20260915-001 修复①:mismatch 前置合并的 dirty 排除集(4.4 stash 前取样)──
1326
+ // 4.5b 的 EXCLUDE-MISMATCH 前置合并只碰「主仓无未提交改动」的 mismatch 文件——dirty∩mismatch
1327
+ // 交 4.5 脏读合并处理(其 ours=主仓磁盘脏内容,已含已提交推进,语义更全)。排除集必须在
1328
+ // 4.4 --stash-dirty 之前取样:stash 会把脏文件收走,事后取 diff 恒空,会把在途文件误判成
1329
+ // 干净 mismatch 用 ours=HEAD 合并写回(stash 恢复时与合并写回互踩)。checkOnly 只读不合并,
1330
+ // 无 mismatch 时零开销,均不取样。untracked 不可能是 mismatch(不在 HEAD,mainBlob=null
1331
+ // 天然被合并函数的 ours=git show HEAD:f 失败挡住),此处只取 tracked-modified 口径即可。
1332
+ let preStashDirtySet = null
1333
+ if (!checkOnly && (result.hashMismatchFiles || []).length > 0) {
1334
+ preStashDirtySet = new Set(
1335
+ (gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean)
1336
+ )
1337
+ }
1338
+
946
1339
  // --- 4.4 --stash-dirty:主仓在途改动自动 stash(坑 apply-main-dirty-no-first-class, ---
947
1340
  // 2026-08-24 用户反馈四期①:主仓并行在途改动下默认 / --skip-overlap(全重叠「无可应用子集」)/
948
1341
  // --merge(git 拒在脏树启动合并)三路死锁,手工 stash→3way→pop 流程未内置。flag 显式 opt-in
@@ -984,7 +1377,45 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
984
1377
  // 用 --merge 时跳过未提交 dirty 拦截——merge 同样要求工作区相对干净,此处仅提示风险,真正失败由 applyByMerge 报告。
985
1378
  // keepConflicts:true——显式 --merge 冲突时保留现场供手工解决(不再直接 abort 丢上下文)。
986
1379
  if (merge && !checkOnly) {
987
- return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: true });
1380
+ return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: true, specBase: allowSpecBase });
1381
+ }
1382
+
1383
+ // --- 4.5b EXCLUDE-MISMATCH 前置三方合并(ql-20260915-001 修复①,坑 apply-archived-mismatch-no-merge,
1384
+ // 2026-09-14 用户实证:归档后补 apply,主仓已被并行会话提交推进 → rescue EXCLUDE-MISMATCH 跳过
1385
+ // 不落地,被迫手工 cp+锚点合并)---
1386
+ // 对 hashMismatch ∩ changedFiles 且主仓无未提交改动(pre-stash dirty 集排除,dirty 面交 4.5
1387
+ // 脏读合并)的文件逐个 merge-file:base=checkpoint(deliverableBase)blob、ours=主仓 HEAD blob、
1388
+ // theirs=worktree 文件。clean → 写回主仓 + 批末 add + mergedMismatchFiles 留痕 + 从
1389
+ // changedFiles/hashMismatchFiles/deletedFiles 剔除(后续 patch 不再重放——staged 合并内容若再进
1390
+ // patch,apply --3way 对 staged≠HEAD 的文件报 does not match index);conflict/异常 → 留原集走
1391
+ // 既有出口(rescue EXCLUDE-MISMATCH 新文案 / step7 --3way 实测 / --merge 兜底)。
1392
+ // checkOnly(assess 只读)零写盘不试合并;--merge 已在 4.6 早退不达此;位于 4.3 guard 预检
1393
+ // (一切主仓写动作之前)与 Gate1 清单校验之后,越权文件不会被合并落地。
1394
+ if (!checkOnly && (result.hashMismatchFiles || []).length > 0 && worktreePath && worktreePath !== projectRoot) {
1395
+ const mismatchCandidates = result.hashMismatchFiles.filter(f =>
1396
+ changedFiles.includes(f) && !(preStashDirtySet && preStashDirtySet.has(f)))
1397
+ if (mismatchCandidates.length > 0) {
1398
+ try {
1399
+ const m3m = mergeMismatchThreeWay({ projectRoot, worktreePath, baseHash: deliverableBase }, mismatchCandidates)
1400
+ if (m3m.merged.length > 0) {
1401
+ result.mergedMismatchFiles = (result.mergedMismatchFiles || []).concat(m3m.merged)
1402
+ if (m3m.stagedOk === false) {
1403
+ result.warnings = (result.warnings || []).concat([
1404
+ `EXCLUDE-MISMATCH 三方合并 ${m3m.merged.length} 个文件已写回主仓,但批末 git add 暂存失败(未暂存对 git restore / git clean 不免疫)——请尽快人工 git add -- <该批文件> 锁定`
1405
+ ])
1406
+ }
1407
+ result.warnings = (result.warnings || []).concat([
1408
+ `EXCLUDE-MISMATCH 前置三方合并:${m3m.merged.length} 个主干已推进文件 clean 合并已写回并暂存(主仓已提交推进与 worktree 交付均保留,覆写前原文备份于 .sillyspec/.runtime/merge-backups/)`
1409
+ ])
1410
+ const mergedMisSet = new Set(m3m.merged)
1411
+ changedFiles = changedFiles.filter(f => !mergedMisSet.has(f))
1412
+ result.changedFiles = changedFiles
1413
+ result.deletedFiles = deletedFiles.filter(f => !mergedMisSet.has(f))
1414
+ result.hashMismatchFiles = result.hashMismatchFiles.filter(f => !mergedMisSet.has(f))
1415
+ }
1416
+ // m3m.conflicts 面留在 result.hashMismatchFiles 原样(rescue 分类 / step7 --3way 既有路径)
1417
+ } catch { /* 合并链路异常 → mismatch 集原样,走既有出口(fail-soft 不阻断 apply) */ }
1418
+ }
988
1419
  }
989
1420
 
990
1421
  // --- 4.5 校验:主工作区「未提交」脏文件是否与本次变更重叠(overlap-only 拦截)---
@@ -1013,10 +1444,14 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1013
1444
  // hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
1014
1445
  const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
1015
1446
  if (hasUncommittedDirty) {
1447
+ // ql-20260915-001 修复①:4.5b 已合并写回并暂存的 mismatch 文件不出现在此脏清单——它们
1448
+ // 是本次 apply 自己的产物(非他者无关脏文件),列进「无关未提交文件」警告会误导。
1449
+ // 对 overlapDirty 无影响(合并文件已从 changedFiles 剔除,交集恒空);集合空时为恒等过滤。
1450
+ const mergedMisSet45 = new Set(result.mergedMismatchFiles || [])
1016
1451
  const dirtyFiles = [...new Set(
1017
1452
  ((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
1018
1453
  .concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
1019
- )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
1454
+ )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json' && !mergedMisSet45.has(f));
1020
1455
  const overlapDirty = dirtyFiles.filter(f => changedFiles.includes(f));
1021
1456
  if (overlapDirty.length > 0) {
1022
1457
  // 自动三方合并(坑 apply-dirty-block-no-merge,2026-09-10 驾驭小结第六批③):先对
@@ -1029,6 +1464,13 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1029
1464
  const m3 = mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash: deliverableBase }, overlapDirty);
1030
1465
  if (m3.merged.length > 0) {
1031
1466
  result.mergedDirtyFiles = (result.mergedDirtyFiles || []).concat(m3.merged);
1467
+ if (m3.stagedOk === false) {
1468
+ // 批末 git add fail-open 留痕(task-01 / FR-01):写回成功但未暂存——result 面
1469
+ // 可见(console.warn 已在 mergeDirtyOverlapThreeWay 内打),提示人工补 add。
1470
+ result.warnings = (result.warnings || []).concat([
1471
+ `EXCLUDE-DIRTY 三方合并 ${m3.merged.length} 个文件已写回主仓,但批末 git add 暂存失败(未暂存对 git restore / git clean 不免疫)——请尽快人工 git add -- <该批文件> 锁定`
1472
+ ]);
1473
+ }
1032
1474
  const mergedSet = new Set(m3.merged);
1033
1475
  changedFiles = changedFiles.filter(f => !mergedSet.has(f));
1034
1476
  result.changedFiles = changedFiles;
@@ -1092,11 +1534,15 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1092
1534
  } else {
1093
1535
  // 无关脏文件放行(只校验重叠文件):--3way 若因 autocrlf 报 does not match index 会被
1094
1536
  // step7 catch 回滚(交集空前提下无损,见上注释),stash 后重试即可——不再硬挡 rescue 手动路径
1095
- result.warnings = (result.warnings || []).concat([
1096
- `主工作区有 ${dirtyFiles.length} 个与本次 apply 无关的未提交文件,已放行(只校验重叠文件):` +
1097
- `${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ' 等' : ''}` +
1098
- `——若 apply --3way 因 CRLF/autocrlf 报 does not match index,可 git stash 后重试`
1099
- ]);
1537
+ // (ql-20260915-001 修复①:dirtyFiles 已剔除本次合并写回面,剔除后为空 = 探针命中的
1538
+ // 全是本 apply 自身 staged 产物——不打「0 个无关文件」的空警告)
1539
+ if (dirtyFiles.length > 0) {
1540
+ result.warnings = (result.warnings || []).concat([
1541
+ `主工作区有 ${dirtyFiles.length} 个与本次 apply 无关的未提交文件,已放行(只校验重叠文件):` +
1542
+ `${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ' 等' : ''}` +
1543
+ `——若 apply --3way 因 CRLF/autocrlf 报 does not match index,可 git stash 后重试`
1544
+ ])
1545
+ }
1100
1546
  }
1101
1547
  }
1102
1548
  }
@@ -1220,6 +1666,26 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1220
1666
  if (patchContent.length === 0) {
1221
1667
  // patch 为空(清单中部分文件可能没实际变更)
1222
1668
  result.ok = true;
1669
+ // apply-manifest(task-01 出口③变体):4.5 三方合并全部消解重叠时,merge 写回面是
1670
+ // 本出口唯一落盘面——manifest 仍须覆盖(写回批末 add 已先行,指纹取 staged 态)。
1671
+ // ql-20260915-001 修复①:4.5b mismatch 前置合并同样可能消解全部 changedFiles(本出口
1672
+ // 另一来源),合并面并入 manifest files。
1673
+ const emptyPatchManifestFace = [...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])]
1674
+ if (emptyPatchManifestFace.length > 0) {
1675
+ try {
1676
+ const mf = writeApplyManifest({
1677
+ projectRoot, specBase: allowSpecBase, changeName,
1678
+ baseHash: deliverableBase, files: emptyPatchManifestFace,
1679
+ });
1680
+ if (mf && mf.written) result.applyManifest = mf.file;
1681
+ } catch (e) {
1682
+ result.warnings = (result.warnings || []).concat([
1683
+ `apply-manifest.json 写入失败(不影响 apply 结果;doctor 漂移检测将按无 manifest 跳过): ${(e.message || '').split('\n')[0]}`
1684
+ ]);
1685
+ }
1686
+ }
1687
+ // 坑 mixed-baseline-drift-hint(ql-20260915-004):三方合并消解型成功出口的混合基线提示
1688
+ annotateCommittedDrift(result, { projectRoot, baseHash: deliverableBase, files: emptyPatchManifestFace });
1223
1689
  rmSync(tmpDir, { recursive: true, force: true });
1224
1690
  return result;
1225
1691
  }
@@ -1232,10 +1698,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1232
1698
  // 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
1233
1699
  // tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
1234
1700
  // 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
1235
- const trackedPatchFiles = patchFiles.filter(f => {
1236
- // 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
1237
- return gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null ? false : true;
1238
- });
1701
+ // 批 D-③:per-file cat-file N+1 spawn 改单次 ls-tree blob 哈希表(同 :1188 既有口径复用)
1702
+ const headBlobMap = getBlobHashMap(projectRoot, 'HEAD', patchFiles)
1703
+ const trackedPatchFiles = patchFiles.filter(f => headBlobMap.has(f));
1239
1704
  const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
1240
1705
 
1241
1706
  // apply --3way 正式应用(主干已提交推进时自动三路合并)
@@ -1271,12 +1736,43 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1271
1736
 
1272
1737
  result.ok = true;
1273
1738
 
1739
+ // --- 7.2 apply 成功尾声写 apply-manifest.json(task-01 / FR-02 / D-001@v1 后半)---
1740
+ // 三条成功出口之一(patch 主路径;含 4.5 三方合并写回后主流程继续至此的出口③)。指纹
1741
+ // 口径 = staged blob(git show :<path> 的 LF 规范态——apply --3way 隐含 --index 已暂存,
1742
+ // merge 写回批末 add 亦已先行)→ 与 doctor 漂移检测的 staged 态比对天然同基。files 面 =
1743
+ // patch 实际落盘集 ∪ 4.5 三方合并写回集(mergeDirtyFiles 已从 changedFiles 剔除,须并回);
1744
+ // apply 删除的文件 staged/磁盘皆无,函数内自然跳过。fail-open:写失败不阻断 apply
1745
+ // (warning 留痕)。已存在则覆盖(重放 apply 以最新为准)。锁内(调用方 withMainRepoLock)。
1746
+ // manifestFace 提升到 try 外(坑 mixed-baseline-drift-hint ql-20260915-004:drift 提示
1747
+ // 在 manifest 写点后消费同面,try 内 const 出块即失域)
1748
+ const manifestFace = [...new Set([...patchFiles, ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])];
1749
+ try {
1750
+ // ql-20260915-001 修复①:mergedMismatchFiles(4.5b 前置合并写回面)与 mergedDirtyFiles
1751
+ // 同为 staged 落盘集,一并进 manifest 指纹面与提交复用 pathspec(缺列 = doctor 漂移检测
1752
+ // 盲区 + agent 按 pathspec 提交漏文件)
1753
+ const mf = writeApplyManifest({
1754
+ projectRoot, specBase: allowSpecBase, changeName,
1755
+ baseHash: deliverableBase, files: manifestFace,
1756
+ });
1757
+ if (mf && mf.written) result.applyManifest = mf.file;
1758
+ } catch (e) {
1759
+ result.warnings = (result.warnings || []).concat([
1760
+ `apply-manifest.json 写入失败(不影响 apply 结果;doctor 漂移检测将按无 manifest 跳过): ${(e.message || '').split('\n')[0]}`
1761
+ ]);
1762
+ }
1763
+
1764
+ // 坑 mixed-baseline-drift-hint(ql-20260915-004):patch 主路径成功出口的混合基线提示
1765
+ //(自身交付 staged 未提交 → baseHash..HEAD 即纯他者推进面,精确/变体命中均为真信号)
1766
+ annotateCommittedDrift(result, { projectRoot, baseHash: deliverableBase, files: manifestFace });
1767
+
1274
1768
  // --- 7.5 提交复用 pathspec(坑 apply-commit-pathspec-sweep,2026-08-21 实证)---
1275
1769
  // apply 后主仓常混有无关未提交文件(他者会话/并行 quick),agent 习惯 `git add <目录>/`
1276
1770
  // 会把无关文件扫进暂存需手工剔除。落盘本变更精确 pathspec 供提交直接复用:
1277
1771
  // 文件 + result 字段(CLI 输出可照抄 git add -- … / 长清单用 --pathspec-from-file)。
1278
1772
  try {
1279
- const commitFiles = [...new Set(patchFiles)].sort().filter(Boolean);
1773
+ // ql-20260915-001 修复①:提交复用 pathspec 补并两个三方合并写回面(均为 staged 落盘、
1774
+ // 属本变更交付——缺列时 agent 按此 pathspec 提交会漏掉合并成果)
1775
+ const commitFiles = [...new Set([...patchFiles, ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])].sort().filter(Boolean);
1280
1776
  result.commitPathspec = commitFiles;
1281
1777
  const runtimeRoot = join(projectRoot, '.sillyspec', '.runtime');
1282
1778
  mkdirSync(runtimeRoot, { recursive: true });
@@ -1323,7 +1819,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1323
1819
  );
1324
1820
  try {
1325
1821
  // keepConflicts:false——自动降级路径无人善后冲突状态,维持 abort 回滚干净态
1326
- return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: false });
1822
+ return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: false, specBase: allowSpecBase });
1327
1823
  } catch (mergeErr) {
1328
1824
  result.errors.push(`ENOBUFS 降级 merge 也失败: ${mergeErr.message}`);
1329
1825
  return result;
@@ -1677,8 +2173,11 @@ function preAlignBaselineToMain({ meta, branch, projectRoot, result }) {
1677
2173
  // git add + git commit;或 git merge --abort 放弃)。worktree/分支/meta 本就保留。
1678
2174
  // false(ENOBUFS 自动降级路径,默认)→ 维持 abort——自动降级无人善后冲突状态,回滚到
1679
2175
  // 干净态让用户显式重来更安全。
2176
+ // opts.specBase(task-01,2026-09-14-apply-conflict-hardening):applyWorktree 调用方透传的
2177
+ // spec 基准目录(平台模式 ctx.platformOpts.specRoot),成功尾声 writeApplyManifest 用;缺省
2178
+ // 时函数内同链回退(指针 specRoot > 本地 .sillyspec)——外部直接调用零必填。
1680
2179
  export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
1681
- const { keepConflicts = false } = opts;
2180
+ const { keepConflicts = false, specBase } = opts;
1682
2181
  const meta = wm.getMeta(changeName);
1683
2182
  const changedFiles = result.changedFiles || [];
1684
2183
  // 用 meta.branch(native-worktree 模式分支名可能不是 sillyspec/<change>),不硬编码。
@@ -1691,6 +2190,10 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
1691
2190
  // 它跳过 dirty 文件(不覆盖 WIP)且只提交对齐集;WIP commit 在后,两者不混提交 ---
1692
2191
  autoCommitWorktreeWip({ meta, result, changeName });
1693
2192
 
2193
+ // 坑 mixed-baseline-drift-hint(ql-20260915-004):merge 前 HEAD = 纯他者推进面锚点——
2194
+ // merge 提交后 HEAD 含自身交付,drift 检测须以 preMergeHead 为推进面终点(防自污染误报)
2195
+ const preMergeHead = gitQuiet(projectRoot, ['rev-parse', 'HEAD']);
2196
+
1694
2197
  try {
1695
2198
  git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
1696
2199
  } catch (e) {
@@ -1755,6 +2258,32 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
1755
2258
  }
1756
2259
 
1757
2260
  result.ok = true;
2261
+ // apply 成功尾声写 apply-manifest.json(task-01 出口②:applyWorktree 显式 --merge 与
2262
+ // ENOBUFS 自动降级两条提前 return 入口共用本成功出口)。merge 已 commit → staged 态 =
2263
+ // HEAD = 合并内容,指纹口径(git show :<path> LF 规范态)与 patch 路径同基;files 面 =
2264
+ // changedFiles(落地校验过的交付集)∪ mergedDirtyFiles(ENOBUFS 路径下 4.5 可能已写回)
2265
+ // ∪ mergedMismatchFiles(ql-20260915-001 修复①:4.5b 前置合并写回面,同口径并入),
2266
+ // merge 删除面 staged/磁盘皆无、函数内自然跳过。fail-open:写失败不阻断 merge(warning 留痕)。
2267
+ // manifestFace 提升到 try 外(坑 mixed-baseline-drift-hint ql-20260915-004:drift 提示在
2268
+ // manifest 写点后消费同面,try 内 const 出块即失域)
2269
+ const manifestFace = [...new Set([...(result.changedFiles || []), ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])];
2270
+ try {
2271
+ const mf = writeApplyManifest({
2272
+ projectRoot, specBase, changeName,
2273
+ baseHash: meta.baselineCommit || meta.baseHash, files: manifestFace,
2274
+ });
2275
+ if (mf && mf.written) result.applyManifest = mf.file;
2276
+ } catch (e) {
2277
+ result.warnings = (result.warnings || []).concat([
2278
+ `apply-manifest.json 写入失败(不影响 merge 结果;doctor 漂移检测将按无 manifest 跳过): ${(e.message || '').split('\n')[0]}`
2279
+ ]);
2280
+ }
2281
+ // 坑 mixed-baseline-drift-hint(ql-20260915-004):merge 成功出口的混合基线提示(推进面
2282
+ // 终点 = preMergeHead,剥离本 merge 自身交付,保精确命中为真信号)
2283
+ annotateCommittedDrift(result, {
2284
+ projectRoot, baseHash: meta.baselineCommit || meta.baseHash,
2285
+ files: manifestFace, head: preMergeHead || 'HEAD',
2286
+ });
1758
2287
  try { result.mergeSummary = git(projectRoot, ['log', '--oneline', '-1']); } catch {}
1759
2288
  try {
1760
2289
  consumeCleanupResult(wm.cleanup(changeName, { force: true }), result);
@@ -1842,6 +2371,9 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
1842
2371
  );
1843
2372
  // review.json 声明偏差文件(坑 apply-undeclared-deviation-block):与顺带修复同等待遇——
1844
2373
  // reviewer 声明(已过 git 证据校验)的执行期偏差豁免 allowed_paths 严格校验,降 warning 注明来源。
2374
+ // D-003@v1(task-03)后本豁免仅作用于 Gate2 的 allowed_paths 归属道——allow 面(design∪task
2375
+ // 清单∪linked)之外的外来声明已在 Gate1(applyWorktree 3b 相交过滤)被剔除拦截,assess 整体
2376
+ // 判定以 Gate1 收集的 errors 为准(BLOCKED);两道口径不再重叠放行。
1845
2377
  const reviewDeclaredSet = new Set(collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(assessSpecBase, '.runtime') }).get('main') || []);
1846
2378
 
1847
2379
  // 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时);顺带修复/review 声明文件豁免。