sillyspec 3.28.8 → 3.28.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude/full.json +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  3. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  4. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  5. package/AGENTS.md +3 -2
  6. package/README.md +35 -11
  7. package/SKILL.md +24 -10
  8. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  9. package/package.json +1 -1
  10. package/src/change-list.js +73 -20
  11. package/src/change-risk-profile.js +20 -0
  12. package/src/config-schema.js +62 -0
  13. package/src/contract-matrix.js +84 -5
  14. package/src/cross-repo-reconcile.js +134 -0
  15. package/src/db.js +11 -3
  16. package/src/decision-distill.js +15 -3
  17. package/src/design-facts.js +89 -20
  18. package/src/docs-gate.js +105 -6
  19. package/src/doctor-diagnostics.js +224 -2
  20. package/src/friction-ledger.js +171 -0
  21. package/src/hooks/worktree-guard.js +13 -1
  22. package/src/index.js +206 -18
  23. package/src/knowledge-classify.js +382 -0
  24. package/src/knowledge-hits.js +74 -0
  25. package/src/knowledge-stats.js +180 -0
  26. package/src/probe7-anchor-check.js +69 -0
  27. package/src/progress/change-registry.js +183 -8
  28. package/src/progress/shared.js +3 -2
  29. package/src/progress.js +100 -8
  30. package/src/quick-gate-profile.js +251 -0
  31. package/src/quicklog.js +164 -18
  32. package/src/review-dispatch.js +2 -1
  33. package/src/run/command.js +131 -13
  34. package/src/run/complete-handlers.js +511 -21
  35. package/src/run/complete.js +123 -23
  36. package/src/run/concurrent-detect.js +98 -0
  37. package/src/run/gate-snapshot.js +110 -2
  38. package/src/run/gates.js +141 -16
  39. package/src/run/prompt.js +221 -11
  40. package/src/run/quick-audit.js +111 -13
  41. package/src/run/shared.js +147 -11
  42. package/src/run/stage.js +6 -0
  43. package/src/scan-diff.js +61 -30
  44. package/src/scan-postcheck.js +74 -0
  45. package/src/scan-refresh.js +477 -0
  46. package/src/scan-staleness.js +25 -13
  47. package/src/scope-audit.js +456 -56
  48. package/src/stage-contract.js +185 -2
  49. package/src/stage-review.js +23 -7
  50. package/src/stages/brainstorm.js +6 -0
  51. package/src/stages/cmd-existence.js +9 -3
  52. package/src/stages/execute.js +85 -3
  53. package/src/stages/knowledge.js +76 -6
  54. package/src/stages/plan-postcheck.js +32 -5
  55. package/src/stages/quick.js +1 -1
  56. package/src/stages/verify.js +1 -0
  57. package/src/sync-noise.js +170 -6
  58. package/src/sync.js +141 -9
  59. package/src/task-review.js +366 -46
  60. package/src/verify-postcheck.js +212 -55
  61. package/src/verify-probes.js +727 -16
  62. package/src/worktree-apply.js +685 -48
  63. package/src/worktree-deps.js +124 -3
  64. package/src/worktree.js +391 -15
  65. package/templates/agents-instruction.md +2 -2
  66. package/templates/prompts/testcase-design.md +2 -1
  67. package/templates/prompts/verify-probes.md +4 -1
  68. package/.patch16-tmp.mjs +0 -169
  69. package/sa-rename-probe/src/new.js +0 -4
@@ -178,14 +178,97 @@ export function printQuickAuditReview(review) {
178
178
  else console.warn(` 行号漂移 → 更新到当前源码;跑 sillyspec docs check 可看建议行号。`)
179
179
  }
180
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
+ }
181
225
  }
182
226
 
183
227
  /**
184
- * quick 阶段多变更交互式选择关联变更。
185
- * - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
186
- * - 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)。
187
268
  * - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
188
- * - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
269
+ * - ≥2 活跃变更 + 非交互 → 不关联 + 提示用 --change a,b
270
+ * @returns {Promise<{changes: string[], autoLinked: string[]}>} changes=最终关联清单;
271
+ * autoLinked=其中机器自动关联的子集(guard 落 linkedChangesAuto 溯源,--done 归档闸消费)
189
272
  */
190
273
  export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
191
274
  let activeChanges = []
@@ -194,13 +277,7 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
194
277
  } catch {
195
278
  activeChanges = []
196
279
  }
197
- if (activeChanges.length === 0) return []
198
- if (activeChanges.length === 1) return [activeChanges[0]]
199
-
200
- if (nonInteractive || !process.stdin.isTTY) {
201
- console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
202
- return []
203
- }
280
+ if (activeChanges.length === 0) return { changes: [], autoLinked: [] }
204
281
 
205
282
  // 脏文件(推荐信号之一)。safeGit 带 safe.directory,避免 linked worktree/容器/挂载点下裸
206
283
  // `git status` 抛错致推荐信号静默丢失(multi-agent-review Q3 同类)。推荐非关键路径,失败回退 []。
@@ -214,7 +291,7 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
214
291
  .filter(f => !f.startsWith('.sillyspec/'))
215
292
  }
216
293
 
217
- // 推荐打分(脏文件 + 任务描述双信号)
294
+ // 推荐打分(脏文件 + 任务描述双信号)——单候选也跑(信号门控),非仅 ≥2 交互推荐用
218
295
  let recommendations = []
219
296
  try {
220
297
  // quick-recommend.js 在 src/,本模块在 src/run/ → 退一层(真环依赖,保留动态 import)
@@ -223,6 +300,26 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
223
300
  } catch {
224
301
  recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
225
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
+
226
323
  const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
227
324
  const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
228
325
  const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
@@ -249,7 +346,8 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
249
346
  // 动态 import:@inquirer/prompts 只在本交互分支加载,不进 run 命令通用启动路径
250
347
  const { checkbox } = await import('@inquirer/prompts')
251
348
  const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
252
- return selected
349
+ // 用户在 TTY 上亲手勾选确认 = 显式协作声明,不进 autoLinked
350
+ return { changes: selected, autoLinked: [] }
253
351
  }
254
352
 
255
353
  // ============ quick --done test+lint 硬门禁(2026-09-02 跨 agent 工单 P0-2)============
package/src/run/shared.js CHANGED
@@ -20,6 +20,9 @@ import os from 'node:os'
20
20
  // unquoteGitPath 同理:parsePorcelainPath 内部消费 + re-export。
21
21
  import { safeGit, unquoteGitPath } from '../git-helper.js'
22
22
  import { createHash } from 'node:crypto'
23
+ // 分级门禁画像信号层(2026-09-14-quick-exit-tiered-gates task-01 / FR-02):纯函数零 IO,
24
+ // 仅依赖 change-risk-profile,无环——auditQuickCompletion 静态 import 安全(task-02 接线)。
25
+ import { computeGateProfile, resolveGateThresholds } from '../quick-gate-profile.js'
23
26
  export { safeGit, unquoteGitPath }
24
27
 
25
28
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -632,6 +635,71 @@ export function checkPlatformManaged(cwd) {
632
635
  }
633
636
  }
634
637
 
638
+ /**
639
+ * 平台模式产物落点指针(坑 platform-docs-dual-location,2026-09-15 EHS 生产实证:
640
+ * 变更 9 文档落 daemon specs 目录,工作树 .sillyspec/ 只剩旧变更残留、worktree 无
641
+ * .sillyspec——人类在工作树里找变更文档扑空,只有平台 changes files API 能读,双位置
642
+ * 造成「文件去哪了」困惑)。阶段 --done 完成时在工作树 .sillyspec/ 落一份人类可读指针
643
+ * PLATFORM-DOCS-POINTER.md:specRoot/runtimeRoot 物理路径 + workspaceId + 本变更文档
644
+ * 清单(.md 文件名+mtime + tasks/ 计数)+ 获取方式提示。
645
+ *
646
+ * 口径与 writePlatformPointer 三写同款豁免:本地模式(无 specRoot/runtimeRoot)零行为;
647
+ * 自指回环(repo-native junction 指回 cwd/.sillyspec)不写;temp 残留形态(specRoot 在
648
+ * 系统 temp 而项目不在)不写。fail-soft:任何写失败只 warn 不抛(对齐 user-inputs.md
649
+ * 追加先例)——指针是便利件,绝不阻断阶段完成。
650
+ *
651
+ * @param {string} cwd 项目根(指针落 cwd/.sillyspec/)
652
+ * @param {string|null} changeName 变更名(可空——无变更上下文时只写根路径头部)
653
+ * @param {{specRoot?: string|null, runtimeRoot?: string|null, workspaceId?: string|null}} [platformOpts]
654
+ * @param {string|null} [stageName] 触发阶段(展示用)
655
+ * @returns {{written: boolean, reason?: string, path?: string, error?: string}}
656
+ */
657
+ export function writePlatformDocsPointer(cwd, changeName, platformOpts = {}, stageName = null) {
658
+ try {
659
+ const specRoot = platformOpts?.specRoot || null
660
+ const runtimeRoot = platformOpts?.runtimeRoot || null
661
+ if (!specRoot && !runtimeRoot) return { written: false, reason: 'local-mode' }
662
+ if (specRoot && isSelfReferentialSpecRoot(cwd, specRoot)) return { written: false, reason: 'self-referential' }
663
+ if (isTempResidueSpecRoot(cwd, specRoot)) return { written: false, reason: 'temp-residue' }
664
+ const localSpecDir = join(cwd, '.sillyspec')
665
+ mkdirSync(localSpecDir, { recursive: true })
666
+ const ts = new Date().toLocaleString('zh-CN', { hour12: false })
667
+ const lines = [
668
+ '<!-- sillyspec 平台模式产物落点指针(CLI 在阶段 --done 完成时自动维护;本地模式不生成本文件) -->',
669
+ '# 变更文档实际落点(工作树 .sillyspec/ 不是本仓权威数据面)',
670
+ '',
671
+ `- 平台 spec 根(proposal/design/verify-result 等变更文档): ${specRoot || '(未指定)'}`,
672
+ `- 运行时根(worktrees/contract-artifacts/verify-logs): ${runtimeRoot || '(未指定)'}`,
673
+ platformOpts?.workspaceId ? `- 工作区 ID: ${platformOpts.workspaceId}` : null,
674
+ `- 最近更新: ${stageName || '?'} 阶段完成 @ ${ts}${changeName ? `(change: ${changeName})` : ''}`,
675
+ ].filter(Boolean)
676
+ const changeDir = (specRoot && changeName) ? join(specRoot, 'changes', changeName) : null
677
+ if (changeDir && existsSync(changeDir)) {
678
+ const docs = []
679
+ for (const f of readdirSync(changeDir)) {
680
+ if (!f.endsWith('.md')) continue
681
+ try {
682
+ const mtime = statSync(join(changeDir, f)).mtime.toLocaleString('zh-CN', { hour12: false })
683
+ docs.push(`- ${f}(mtime ${mtime})`)
684
+ } catch { docs.push(`- ${f}`) }
685
+ }
686
+ const tasksDir = join(changeDir, 'tasks')
687
+ if (existsSync(tasksDir)) {
688
+ const cards = readdirSync(tasksDir).filter(f => f.endsWith('.md'))
689
+ if (cards.length > 0) docs.push(`- tasks/(${cards.length} 张任务卡)`)
690
+ }
691
+ if (docs.length > 0) lines.push('', `## 本变更文档(${changeName})`, ...docs)
692
+ }
693
+ lines.push('', '获取方式:直接读上行物理路径,或经平台 changes files API(/changes/{id}/files/raw?path=...)逐文件下载。')
694
+ const pointerPath = join(localSpecDir, 'PLATFORM-DOCS-POINTER.md')
695
+ writeFileSync(pointerPath, lines.join('\n') + '\n')
696
+ return { written: true, path: pointerPath }
697
+ } catch (e) {
698
+ console.warn('⚠️ 平台产物落点指针写入失败(不阻断):', e && e.message ? e.message : e)
699
+ return { written: false, reason: 'error', error: e && e.message }
700
+ }
701
+ }
702
+
635
703
  /**
636
704
  * 自指 specRoot 判定(repo-native junction 回环检测,变更 2026-08-23-repo-native-spec-backfill)。
637
705
  *
@@ -1032,7 +1100,12 @@ export function isQuickMetadata(p, linkedChanges = []) {
1032
1100
  const file = normalizeGitPath(p)
1033
1101
  if (file.startsWith('.sillyspec/quicklog/')
1034
1102
  || file.startsWith('.sillyspec/.runtime/')
1035
- || file === '.sillyspec/knowledge/uncategorized.md'
1103
+ // 知识库整体白名单(2026-09-15 用户实测反馈:登记知识条目是 quick 收尾常规动作,此前
1104
+ // INDEX/known-issues/patterns/conventions/decisions 非元数据 → 危险门 blocked 只能 --force-baseline,
1105
+ // 且 perFileNotes 强制 --file-notes 覆盖):knowledge/ 是 CLI 自管面(classify append-only +
1106
+ // validate 守门),与 quicklog 同级。可追溯性靠 quicklog 结构化条目 + git diff,不靠文件行。
1107
+ // 前缀带尾斜杠:knowledge-base/ 等相近目录不命中。
1108
+ || file.startsWith('.sillyspec/knowledge/')
1036
1109
  || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
1037
1110
  || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
1038
1111
  if (file.startsWith('.sillyspec/changes/')) {
@@ -1151,7 +1224,7 @@ export function matchSameModuleTestFiles(declaredFiles, undeclaredFiles) {
1151
1224
 
1152
1225
  /**
1153
1226
  * quick 完成审计:对比 baseline 与实际变更。
1154
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], softTestFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}> }}
1227
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], softTestFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}>, gateProfile: object|null }}
1155
1228
  */
1156
1229
  /**
1157
1230
  * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
@@ -1163,13 +1236,8 @@ export function matchSameModuleTestFiles(declaredFiles, undeclaredFiles) {
1163
1236
  async function matchQuickModules(srcChanged, specBase, projectName) {
1164
1237
  if (!specBase || !projectName || srcChanged.length === 0) return []
1165
1238
  try {
1166
- const { join } = await import('node:path')
1167
- const { existsSync: ex, readFileSync: rf } = await import('node:fs')
1168
- const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
1169
- if (!ex(mapPath)) return []
1170
- const { parseModuleMapSimple } = await import('../modules.js')
1171
- const idx = parseModuleMapSimple(rf(mapPath, 'utf8'))
1172
- if (!idx || Object.keys(idx).length === 0) return []
1239
+ const idx = await loadQuickModuleIndex(specBase, projectName)
1240
+ if (!idx) return []
1173
1241
  const { matchFilesToModules } = await import('../docs-debt.js')
1174
1242
  const cardsDir = join(specBase, 'docs', projectName, 'modules')
1175
1243
  const { byModule } = matchFilesToModules(srcChanged, idx, { cardsDir })
@@ -1179,6 +1247,30 @@ async function matchQuickModules(srcChanged, specBase, projectName) {
1179
1247
  }
1180
1248
  }
1181
1249
 
1250
+ /**
1251
+ * quick 审计链的 _module-map.yaml 原始索引加载(2026-09-14-quick-exit-tiered-gates task-02 抽取):
1252
+ * D-8 模块归属(matchQuickModules)与门禁画像(auditQuickCompletion 挂 review.gateProfile)的
1253
+ * 同源单一加载点——路径 <specBase>/docs/<projectName>/modules/_module-map.yaml,解析
1254
+ * parseModuleMapSimple(canonical)。缺失/解析空/specBase 缺参/异常 → null(画像走 degraded
1255
+ * 降级档:span 退出判级,兼容策略第 1 条——未 scan/无 module-map 的存量项目零强制变化)。
1256
+ * join/existsSync/readFileSync 用本模块顶部静态 import(auditQuickCompletion 既有同款)。
1257
+ * @param {string} specBase .sillyspec 目录(guard 透传)
1258
+ * @param {string} projectName 项目名(progress.project)
1259
+ * @returns {Promise<object|null>} 模块索引(扁平 { id: {...} });不可得 → null
1260
+ */
1261
+ async function loadQuickModuleIndex(specBase, projectName) {
1262
+ if (!specBase || !projectName) return null
1263
+ try {
1264
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
1265
+ if (!existsSync(mapPath)) return null
1266
+ const { parseModuleMapSimple } = await import('../modules.js')
1267
+ const idx = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
1268
+ return idx && Object.keys(idx).length > 0 ? idx : null
1269
+ } catch {
1270
+ return null
1271
+ }
1272
+ }
1273
+
1182
1274
  // ── task-03(2026-08-16-state-split-fixes): 活文档漂移提示辅助 ──
1183
1275
  // 背景:platform-interface-map.md 等活文档持续维护 src 主入口的 file:line 映射;并行会话改
1184
1276
  // src/ 后引用行号静默失效,docs gate 只能事后拦到别人的流程上。审计(auditQuickCompletion)
@@ -1257,11 +1349,14 @@ export function matchInvalidRefsToChanged(invalidRefs, srcChangedFiles) {
1257
1349
 
1258
1350
  export async function auditQuickCompletion(cwd, guard, options = {}) {
1259
1351
  const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false, allowDelete = false, specBase = null, projectName = null } = guard
1260
- const { isConfirm } = options
1352
+ // noDocs/fileNotes(task-02):--no-docs 显式豁免与 --file-notes 解析结果经 options 进审计链
1353
+ // (command.js knownFlags 登记 → complete.js → complete-handlers 透传;scope-audit 重放不传 →
1354
+ // noDocs/fileNotes 缺省的纯默认画像)。
1355
+ const { isConfirm, noDocs = false, fileNotes = null } = options
1261
1356
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
1262
1357
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
1263
1358
  // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
1264
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], softTestFiles: [], foreignSessionDeclared: [] }
1359
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], softTestFiles: [], foreignSessionDeclared: [], gateProfile: null }
1265
1360
 
1266
1361
  try {
1267
1362
  // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
@@ -1639,6 +1734,47 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1639
1734
  result.softTestFiles = []
1640
1735
  }
1641
1736
 
1737
+ // ── 分级门禁画像(FR-03,2026-09-14-quick-exit-tiered-gates task-02):照 docSyncHint 先例
1738
+ // 挂 review.gateProfile(audit result 即 printQuickAuditReview 的 review / progress.lastQuickReview
1739
+ // / scope-audit 重放的 audit),全部 advisory——不改 status 三态 / reasons / exit code(D-003),
1740
+ // fail-open:任何异常只跳过(内层 try/catch,绝不落外层 catch 的 warning 语义)。
1741
+ // 输入纪律:
1742
+ // - changedFiles = 本函数已算的 git 事实窗口(非 --files 自声明);先经 isQuicklogFileLineNoise
1743
+ // 滤会话簿记(quicklog/.runtime/_module-map 等)但保留模块卡与 changelog sidecar——模块卡恰是
1744
+ // L2 docClaim 的认领对象,用 isQuickMetadata 会把 claimed 判死成 missing(记录面谓词分叉);
1745
+ // - moduleIndex 复用 matchQuickModules 同源加载(loadQuickModuleIndex,单点);
1746
+ // - 阈值经 resolveGateThresholds 合并 local.yaml quick-gate 段覆写(读取走 readLocalYamlRaw +
1747
+ // js-yaml 动态 import,与 resolveLivingDocs 同款链路;未配置/坏 YAML → 纯默认,D-009);
1748
+ // - noDocs/fileNotes 由 options 透传(--no-docs 豁免 / --file-notes 覆盖率)。
1749
+ // 不加 git 子进程(约束:画像纯字符串运算,R-04);未声明脏文件维持上方归属分流不并入
1750
+ // docClaim 判定(D-005)——级判(span/files/risk)仍用 git 事实全窗口(防 37.1% 未声明面
1751
+ // 漏判级),docClaim 单独用归属口径重算覆盖(他者窗口内改模块卡不得伪造成「已认领」)。
1752
+ try {
1753
+ let thresholds = null
1754
+ const rawLocalYaml = readLocalYamlRaw(cwd)
1755
+ if (rawLocalYaml) {
1756
+ try {
1757
+ const mod = await import('js-yaml')
1758
+ const yamlLoad = mod.load || mod.default?.load
1759
+ const cfg = yamlLoad(rawLocalYaml)
1760
+ if (cfg && typeof cfg === 'object') thresholds = resolveGateThresholds(cfg)
1761
+ } catch { /* 坏 YAML → 纯默认阈值 */ }
1762
+ }
1763
+ const gateFiles = result.changedFiles.filter(f => !isQuicklogFileLineNoise(f, guard.linkedChanges))
1764
+ const gateOpts = {
1765
+ ...(thresholds ? { thresholds } : {}),
1766
+ ...(noDocs ? { noDocs: true } : {}),
1767
+ ...(Array.isArray(fileNotes) && fileNotes.length > 0 ? { fileNotes } : {}),
1768
+ }
1769
+ const moduleIndex = await loadQuickModuleIndex(specBase, projectName)
1770
+ result.gateProfile = computeGateProfile(gateFiles, moduleIndex, gateOpts)
1771
+ if (result.undeclaredFiles.length > 0) {
1772
+ const undeclaredNorm = new Set(result.undeclaredFiles.map(f => String(f).replace(/\\/g, '/')))
1773
+ const attrGateFiles = gateFiles.filter(f => !undeclaredNorm.has(String(f).replace(/\\/g, '/')))
1774
+ result.gateProfile.checks.docClaim = computeGateProfile(attrGateFiles, moduleIndex, gateOpts).checks.docClaim
1775
+ }
1776
+ } catch { /* 画像 fail-open:异常只跳过(gateProfile 保持 null,D-003 不碰 status) */ }
1777
+
1642
1778
  // --confirm 模式:展示 diff 并等待确认
1643
1779
  if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
1644
1780
  console.log(`\n📋 quick 变更概览:`)
package/src/run/stage.js CHANGED
@@ -440,6 +440,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
440
440
  description: quickDesc,
441
441
  linkedChanges,
442
442
  allowedFiles,
443
+ // 他者会话 guard 预留让位(坑 ql-id-double-occupancy):与 guard 落盘同源目录
444
+ // (Q4 单一解析,平台模式 runtimeRoot 分裂时也对齐)
445
+ sessionsDir: resolveQuickSessionsDir(platformOpts, specBase),
443
446
  })
444
447
  progress.quickGuard = {
445
448
  sessionId: changeName,
@@ -461,6 +464,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
461
464
  allowDelete,
462
465
  forceBaseline,
463
466
  linkedChanges,
467
+ // 机器自动关联溯源(坑 quick-single-change-auto-link):--done 归档闸对仅被自动
468
+ // 关联(信号命中猜测,非 --linked-changes 显式协作声明)的变更不触发轻量归档
469
+ linkedChangesAuto: Array.isArray(quickOpts?.linkedChangesAuto) ? quickOpts.linkedChangesAuto : [],
464
470
  quicklogId: qlId,
465
471
  // 刀①(2026-09-08):启动 --input 的任务描述落 guard——prompt.js 渲染 step1 时据此做
466
472
  // 模块上下文关键词匹配(changeName 是 quick-<hash> 无语义,step.prompt 全文噪音大)
package/src/scan-diff.js CHANGED
@@ -60,7 +60,7 @@ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {
60
60
 
61
61
  // ── 基线:缺省读 specBase/docs/<projectName>/scan/*.md frontmatter 的 source_commit ──
62
62
  if (!base) {
63
- const sourceCommit = readSourceCommit(specBase, projectName)
63
+ const sourceCommit = readSourceCommit(specBase, projectName, projectRoot)
64
64
  if (!sourceCommit) {
65
65
  return {
66
66
  ok: false, error:
@@ -169,34 +169,20 @@ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {
169
169
  // 变更集取 parseNameStatus 全量——**不经 module-map scope 过滤**(引用自带范围:指向「已变更
170
170
  // 但范围外」文件的引用同样过时,advisory 信号不该被 scope 吞掉);rename 的旧路径也入集
171
171
  //(文档写在 scan 时点,引用的是旧路径,重命名后引用必失效——最强失效信号)。
172
+ // 命中逻辑抽为导出 collectStaleRefs(2026-09-14-scan-incremental-refresh D-003@v2:scan-refresh
173
+ // 复用同一单源,防 A/D/M/R 标注与 src/ 前缀归一规则双源漂移)。
172
174
  // best-effort(读不到文档/异常 → 空清单不阻断文件级结果),不计入 driftCount / 退出码
173
175
  //(advisory:引用指向已变更文件 ≠ 引用失效,提示人工核对)。──
174
176
  const staleRefs = []
175
177
  try {
176
178
  const scanDir = join(specBase, 'docs', projectName, 'scan')
177
- if (existsSync(scanDir)) {
178
- const fullChanged = new Map() // POSIX path → A/D/M/R
179
- for (const item of parseNameStatus(diffRes.value)) {
180
- if (item.status === 'A' || item.status === 'D') fullChanged.set(item.path, item.status)
181
- else if (item.status === 'M' || item.status === 'C') fullChanged.set(item.path, 'M')
182
- else if (item.status === 'R') { fullChanged.set(item.path, 'R'); fullChanged.set(item.oldPath, 'R') }
183
- }
184
- const hitChange = (p) => {
185
- if (fullChanged.has(p)) return { file: p, change: fullChanged.get(p) }
186
- // src/ 前缀双向归一:文档 src 相对写法 ↔ git 仓库根路径
187
- const alt = p.startsWith('src/') ? p.slice(4) : `src/${p}`
188
- return fullChanged.has(alt) ? { file: alt, change: fullChanged.get(alt) } : null
189
- }
190
- for (const f of readdirSync(scanDir)) {
191
- if (!f.endsWith('.md')) continue
192
- const content = readFileSync(join(scanDir, f), 'utf8')
193
- for (const ref of collectDocRefs(content)) {
194
- if (ref.repo !== null) continue
195
- const hit = hitChange(ref.file.replace(/\\/g, '/'))
196
- if (hit) staleRefs.push({ doc: f, ref: ref.ref, change: hit.change, file: hit.file })
197
- }
198
- }
179
+ const fullChanged = new Map() // POSIX path → A/D/M/R
180
+ for (const item of parseNameStatus(diffRes.value)) {
181
+ if (item.status === 'A' || item.status === 'D') fullChanged.set(item.path, item.status)
182
+ else if (item.status === 'M' || item.status === 'C') fullChanged.set(item.path, 'M')
183
+ else if (item.status === 'R') { fullChanged.set(item.path, 'R'); fullChanged.set(item.oldPath, 'R') }
199
184
  }
185
+ staleRefs.push(...collectStaleRefs(scanDir, fullChanged))
200
186
  } catch { /* 引用级漂移为 advisory,异常静默降级 */ }
201
187
 
202
188
  return {
@@ -380,18 +366,62 @@ function writeReport(r, { specBase, projectName }) {
380
366
  }
381
367
  }
382
368
 
383
- /** 读 scan 目录任一 .md 的 source_commit(同 modules.js 写入同值,取首个命中;全失败 → null) */
384
- function readSourceCommit(specBase, projectName) {
369
+ /**
370
+ * 读 scan 目录全部 .md 的 source_commit,多文档异基线保守聚合(D-003@v2/D-009@v1)。
371
+ * 聚合=落后最多:去重基线逐个 `rev-list --count <c>..HEAD` 取计数最大者——拓扑序免疫
372
+ * rebase/amend 日期倒挂,且落后最多=漂移窗最大=保守目标本体。git 全失败 fail-soft 回退
373
+ * 首个命中(与旧版批次同值场景输出一致)。
374
+ * @param {string} specBase @param {string} projectName @param {string} [projectRoot] git 锚(计数用;缺省不聚合)
375
+ * @returns {string|null}
376
+ */
377
+ function readSourceCommit(specBase, projectName, projectRoot) {
385
378
  const scanDir = join(specBase, 'docs', projectName, 'scan')
386
379
  if (!existsSync(scanDir)) return null
380
+ const commits = []
387
381
  try {
388
382
  for (const f of readdirSync(scanDir)) {
389
383
  if (!f.endsWith('.md')) continue
390
384
  const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
391
- if (c) return c
385
+ if (c) commits.push(c)
386
+ }
387
+ } catch { /* 读失败 → 按空清单处理 */ }
388
+ if (commits.length === 0) return null
389
+ const distinct = [...new Set(commits)]
390
+ if (distinct.length === 1 || !projectRoot) return distinct[0]
391
+ let best = null, bestCount = -1
392
+ for (const c of distinct) {
393
+ const r = safeGit(projectRoot, ['rev-list', '--count', `${c}..HEAD`], { timeout: GIT_TIMEOUT_MS })
394
+ const n = r.error ? null : parseInt(r.value, 10)
395
+ if (n !== null && !Number.isNaN(n) && n > bestCount) { best = c; bestCount = n }
396
+ }
397
+ return best !== null ? best : distinct[0]
398
+ }
399
+
400
+ /**
401
+ * 引用级漂移命中(scan-refresh 与 computeScanDiff 共用单源,2026-09-14-scan-incremental-refresh)。
402
+ * @param {string} scanDir scan 文档目录
403
+ * @param {Map<string,string>} fullChanged POSIX 路径 → A/D/M/R(R 的旧路径须已入集)
404
+ * @returns {{doc:string, ref:string, change:string, file:string}[]} 命中清单(目录不存在/异常 → 空数组)
405
+ */
406
+ export function collectStaleRefs(scanDir, fullChanged) {
407
+ const staleRefs = []
408
+ if (!existsSync(scanDir)) return staleRefs
409
+ const hitChange = (p) => {
410
+ if (fullChanged.has(p)) return { file: p, change: fullChanged.get(p) }
411
+ // src/ 前缀双向归一:文档 src 相对写法 ↔ git 仓库根路径
412
+ const alt = p.startsWith('src/') ? p.slice(4) : `src/${p}`
413
+ return fullChanged.has(alt) ? { file: alt, change: fullChanged.get(alt) } : null
414
+ }
415
+ for (const f of readdirSync(scanDir)) {
416
+ if (!f.endsWith('.md')) continue
417
+ const content = readFileSync(join(scanDir, f), 'utf8')
418
+ for (const ref of collectDocRefs(content)) {
419
+ if (ref.repo !== null) continue
420
+ const hit = hitChange(ref.file.replace(/\\/g, '/'))
421
+ if (hit) staleRefs.push({ doc: f, ref: ref.ref, change: hit.change, file: hit.file })
392
422
  }
393
- } catch { /* 读失败 → null */ }
394
- return null
423
+ }
424
+ return staleRefs
395
425
  }
396
426
 
397
427
  /** 收集 module-map 全部 paths 去重(归一 Windows 反斜杠 + 去尾部斜杠;无 paths → 空数组) */
@@ -412,8 +442,9 @@ function inScope(filePath, scope) {
412
442
  return scope.some((p) => p && (filePath === p || filePath.startsWith(p + '/')))
413
443
  }
414
444
 
415
- /** 解析 `git diff --name-status --find-renames` 输出 → [{ status, path, oldPath? }](R/C 双路径) */
416
- function parseNameStatus(out) {
445
+ /** 解析 `git diff --name-status --find-renames` 输出 → [{ status, path, oldPath? }](R/C 双路径)。
446
+ * 导出供 scan-refresh 复用(同单源,2026-09-14-scan-incremental-refresh)。 */
447
+ export function parseNameStatus(out) {
417
448
  const items = []
418
449
  if (!out) return items
419
450
  for (const line of out.split('\n')) {
@@ -561,6 +561,80 @@ export function stampScanDocHeaders({ cwd, specDir = null, project = null, mode
561
561
  return { fixed, skipped, stampedKeys }
562
562
  }
563
563
 
564
+ /**
565
+ * per-doc 基线推进盖章(2026-09-14-scan-incremental-refresh,D-002@v1/D-003@v2)。
566
+ * 与 stampScanDocHeaders 的「只补缺不覆盖」契约相互独立:本函数**只改点名文档**的
567
+ * source_commit / updated_at / generator 三键(已有则替换、缺失则补),其余 frontmatter 键
568
+ * 与其余文档逐字节不动——增量刷新(scan refresh --done)专用。幂等:同值重跑零 diff。
569
+ *
570
+ * @param {{ cwd: string, specDir?: string|null, project?: string|null, docs?: string[],
571
+ * headShort?: string|null, generator?: string }} opts
572
+ * docs:scan 目录内文档文件名列表(如 ['ARCHITECTURE.md']);project 缺省时遍历全部项目
573
+ * @returns {{ bumped: string[], skipped: { file: string, reason: string }[] }}
574
+ */
575
+ export function bumpScanDocBaselines({ cwd, specDir = null, project = null, docs = [], headShort = null, generator = 'sillyspec-scan-refresh' }) {
576
+ const specBase = specDir ? specDir : join(cwd, '.sillyspec')
577
+ const docsRoot = join(specBase, 'docs')
578
+ const projects = project
579
+ ? [project]
580
+ : (existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [])
581
+ if (!headShort) {
582
+ try { headShort = String(git(cwd, ['rev-parse', '--short', 'HEAD']) || '').trim() || null } catch { headShort = null }
583
+ }
584
+ const updatedIso = new Date().toISOString()
585
+ const bumped = []
586
+ const skipped = []
587
+ for (const p of projects) {
588
+ const scanDir = join(docsRoot, p, 'scan')
589
+ if (!existsSync(scanDir)) {
590
+ for (const name of docs) skipped.push({ file: name, reason: `无 scan 目录:${scanDir}` })
591
+ continue
592
+ }
593
+ for (const name of docs) {
594
+ const filePath = join(scanDir, name)
595
+ let content
596
+ try { content = readFileSync(filePath, 'utf8') } catch (e) {
597
+ skipped.push({ file: name, reason: `读取失败:${String((e && e.message) || e).split('\n')[0]}` })
598
+ continue
599
+ }
600
+ // headShort 拿不到(非 git 仓)→ source_commit 键跳过不写坏值;updated_at/generator 恒写
601
+ const out = bumpFrontmatterKeys(content, {
602
+ source_commit: headShort || undefined,
603
+ updated_at: updatedIso,
604
+ generator,
605
+ })
606
+ if (out === null) { skipped.push({ file: name, reason: 'frontmatter 写入失败(内部错误)' }); continue }
607
+ try { writeFileSync(filePath, out); bumped.push(filePath) } catch (e) {
608
+ skipped.push({ file: name, reason: `写入失败:${String((e && e.message) || e).split('\n')[0]}` })
609
+ }
610
+ }
611
+ }
612
+ return { bumped, skipped }
613
+ }
614
+
615
+ /** frontmatter 键替换内核:已有行替换、缺失行在 frontmatter 内补(无 frontmatter 则新建头部)。
616
+ * 值为 undefined 的键跳过;键名限安全标识符(内部调用方固定三键,不做正则转义)。
617
+ * 尾部 `---` 无换行(agent 手编删尾换行的中间态)也认既有 frontmatter——否则会走「无
618
+ * frontmatter」分支头部新建一段、旧块残留正文(双 frontmatter,重审 P3 实证)。 */
619
+ function bumpFrontmatterKeys(content, bumpKeys) {
620
+ const normalized = content.replace(/\r\n?/g, '\n')
621
+ const entries = Object.entries(bumpKeys).filter(([, v] ) => v !== undefined)
622
+ if (entries.length === 0) return null
623
+ const fmMatch = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)
624
+ if (!fmMatch) {
625
+ return `---\n${entries.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n` + normalized
626
+ }
627
+ let fm = fmMatch[1]
628
+ for (const [k, v] of entries) {
629
+ const re = new RegExp(`^${k}:[^\\n]*$`, 'm')
630
+ if (re.test(fm)) fm = fm.replace(re, `${k}: ${v}`)
631
+ else fm += `\n${k}: ${v}`
632
+ }
633
+ // 重建 frontmatter 块(fmMatch[0] 可能无尾换行——重建统一补 \n,余下正文原样接回)
634
+ const rest = normalized.slice(fmMatch[0].length)
635
+ return `---\n${fm}\n---\n${rest}`
636
+ }
637
+
564
638
  /**
565
639
  * source_root 污染一键修复(2026-08-21 agent-手工产出审计第四批 C-3)。
566
640
  *