sillyspec 3.28.9 → 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.
@@ -785,12 +785,19 @@ export function parseLocalYamlModules(yamlText) {
785
785
  * modules 从 local.yaml 提取(monorepo 子包感知):无 modules 块时仅查根 package.json
786
786
  * (与 scan-postcheck 历史行为一致);有 modules 块时多候选子包 package.json 任一命中即通过。
787
787
  *
788
+ * 跨仓卡基准根切换(坑 verify-cmd-cross-repo-root,2026-09-15 wp 会话实证):卡片声明
789
+ * repo: <key> 时其 verify/implementation 里的路径相对**该仓**根——此前恒用主仓 projectRoot,
790
+ * 跨仓卡的 `cd src && npm run lint`(src 在跨仓里)解析到主仓 → 误报死命令。repoRegistry
791
+ * 命中该 key 时以跨仓根为该卡的 projectRoot(modules 是主仓 local.yaml 的相对子包路径,
792
+ * 跨仓卡不适用,置 null);repo 未注册时由检查 1 的注册校验报错,此处维持主仓根不重复报。
793
+ *
788
794
  * @param {string} changeDir - 变更目录
789
795
  * @param {string} projectRoot - 项目根目录(package.json 查找基准)
790
796
  * @param {object|null} modules - local.yaml modules 块({ name: { path } }),可选
797
+ * @param {Map<string,string>|null} [repoRegistry=null] - local.yaml repos: 段(跨仓卡基准根解析)
791
798
  * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
792
799
  */
793
- export function validateTaskCommands(changeDir, projectRoot, modules = null) {
800
+ export function validateTaskCommands(changeDir, projectRoot, modules = null, repoRegistry = null) {
794
801
  const errors = []
795
802
  const warnings = []
796
803
 
@@ -826,7 +833,16 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
826
833
  const text = `${verifyText}\n${implText}`
827
834
  if (!text.trim()) continue
828
835
 
829
- const { invalid } = validateScriptCommands(text, { projectRoot, modules })
836
+ // 跨仓卡基准根切换(坑 verify-cmd-cross-repo-root):repo: 命中注册表 → 该卡命令以跨仓
837
+ // 根解析;modules(主仓相对子包路径)对跨仓卡不适用置 null
838
+ const cardRepo = parseRepo(content)
839
+ let cardRoot = projectRoot
840
+ let cardModules = modules
841
+ if (cardRepo && cardRepo !== 'main' && repoRegistry && repoRegistry instanceof Map && repoRegistry.has(cardRepo)) {
842
+ cardRoot = repoRegistry.get(cardRepo)
843
+ cardModules = null
844
+ }
845
+ const { invalid } = validateScriptCommands(text, { projectRoot: cardRoot, modules: cardModules })
830
846
  for (const inv of invalid) {
831
847
  errors.push(`${taskId}: ${inv.cmd} 命令不存在(${inv.reason})`)
832
848
  }
@@ -880,7 +896,7 @@ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变
880
896
  * @param {string} designPath - design.md 绝对路径
881
897
  * @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[], malformedHeaders: string[] }}
882
898
  */
883
- function parseDesignCoverageByRepo(designPath) {
899
+ export function parseDesignCoverageByRepo(designPath) {
884
900
  const byRepo = new Map()
885
901
  const allFiles = []
886
902
  const malformedHeaders = []
@@ -1619,10 +1635,21 @@ export async function executePlanPostcheck(context) {
1619
1635
  let taskModules = null
1620
1636
  let repoRegistry = null
1621
1637
  if (existsSync(localYamlPath)) {
1622
- const localYamlText = readFileSync(localYamlPath, 'utf8')
1638
+ const localYamlText = _readFileSync(localYamlPath, 'utf8')
1623
1639
  taskModules = parseLocalYamlModules(localYamlText)
1624
1640
  repoRegistry = parseRepoRegistry(localYamlText)
1625
1641
  }
1642
+ // 坑 register-repo-specbase-split-brain(读侧对齐):平台模式下 specDir=平台 spec 根,此处
1643
+ // 读 spec 根 local.yaml 的 repos: 而 execute MultiRepoContext 恒读 <cwd>/.sillyspec/local.yaml
1644
+ // ——plan gate 看得到注册、execute 启动却 fail-closed(2026-09-15 wp 会话实证)。项目侧
1645
+ // local.yaml 存在时以它为 repos: 权威(register-repo 写侧已对齐项目侧);不存在(纯平台
1646
+ // 项目从未建过项目侧文件)维持 spec 根回退,零回归。
1647
+ const projectLocalYamlPath = pJoin(cwd, '.sillyspec', 'local.yaml')
1648
+ if (existsSync(projectLocalYamlPath)) {
1649
+ try {
1650
+ repoRegistry = parseRepoRegistry(_readFileSync(projectLocalYamlPath, 'utf8'))
1651
+ } catch { /* 项目侧读失败维持 spec 根结果 */ }
1652
+ }
1626
1653
 
1627
1654
  // ── 1. 一致性校验 ──
1628
1655
  const consistency = validateBlueprintConsistency(changeDir, { repoRegistry })
@@ -1656,7 +1683,7 @@ export async function executePlanPostcheck(context) {
1656
1683
  // invalid → error 硬阻断,避免 execute 子代理跑死命令(design D-04 / 问题 3)。
1657
1684
  // modules 块可选:无块时仅查根 package.json(与 scan-postcheck 历史行为一致)。
1658
1685
  // local.yaml 已在检查 1 前预读(taskModules),此处直接消费。
1659
- const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules)
1686
+ const taskCmds = validateTaskCommands(changeDir, context.cwd, taskModules, repoRegistry)
1660
1687
  if (taskCmds.errors.length > 0) {
1661
1688
  failures.push({
1662
1689
  name: 'TaskCard 命令存在性校验(verify/implementation 的 npm/pnpm/yarn run <script> 不存在)',
package/src/sync-noise.js CHANGED
@@ -243,6 +243,60 @@ export function noteChangeDeletedResolved(changeName) {
243
243
  }
244
244
  }
245
245
 
246
+ // ── push 409 自愈回执噪音闸(2026-09-15 复盘反馈:自愈重试不阻断但每步刷屏)──
247
+ // 与 change_deleted 闸同构,按 `${changeName}\u0000${kind}` 键控(kind=自竞态/内容一致/自回声
248
+ // 三类自愈)。语义边界:自愈是成功收敛路径不是故障——base_ts 409 每步自动同步都可能重撞、
249
+ // ts 逐次不同但零新信息;首报完整可见(保留原文 ⚠️ 口吻),10 分钟窗口内同变更同类型重复
250
+ // 自愈静默,窗口过期重报保活。推送成功不自愈清窗(下次 409 首报照常可见——竞态是否复发
251
+ // 本身是信号);SILLYSPEC_DEBUG_SYNC=1 与手动 platform sync(noMute)旁路。
252
+ const SELF_HEAL_MARKER = 'sync-noise-self-heal.json'
253
+ let _openedSelfHealByThisProcess = new Set()
254
+ const _seenSelfHealInProcess = new Set()
255
+
256
+ function _readSelfHealWindows() {
257
+ const p = _runtimeDir ? join(_runtimeDir, SELF_HEAL_MARKER) : null
258
+ if (!p) return null
259
+ try {
260
+ const s = JSON.parse(readFileSync(p, 'utf8'))
261
+ return s && typeof s === 'object' && !Array.isArray(s) ? s : null
262
+ } catch { return null }
263
+ }
264
+
265
+ function _selfHealLineVisible(changeName, kind, noMute) {
266
+ if (!changeName || noMute || process.env.SILLYSPEC_DEBUG_SYNC) return true
267
+ const key = changeName + '\u0000' + kind
268
+ if (_seenSelfHealInProcess.has(key)) return false
269
+ const now = Date.now()
270
+ const windows = _readSelfHealWindows()
271
+ const until = windows ? windows[key] : undefined
272
+ if (typeof until === 'number' && now < until) {
273
+ _seenSelfHealInProcess.add(key)
274
+ // 开窗进程内同类自愈可见(诊断完整性),先前进程留的窗口静默
275
+ return _openedSelfHealByThisProcess.has(key)
276
+ }
277
+ _seenSelfHealInProcess.add(key)
278
+ const next = windows || {}
279
+ next[key] = now + MUTE_WINDOW_MS
280
+ try {
281
+ mkdirSync(_runtimeDir, { recursive: true })
282
+ writeFileSync(join(_runtimeDir, SELF_HEAL_MARKER), JSON.stringify(next) + '\n', 'utf8')
283
+ } catch { /* marker 写失败 = 闸门失效退化为直通,仅损失降噪 */ }
284
+ _openedSelfHealByThisProcess.add(key)
285
+ return true
286
+ }
287
+
288
+ /**
289
+ * push 409 自愈回执(warn 口吻:首报保留原文 ⚠️,窗口内同变更同类型静默)。
290
+ * @returns {boolean} true=已输出
291
+ */
292
+ export function syncSelfHealWarn(changeName, kind, msg, { noMute = false } = {}) {
293
+ if (_selfHealLineVisible(changeName, kind, noMute)) {
294
+ console.warn(msg)
295
+ return true
296
+ }
297
+ return false
298
+ }
299
+
246
300
  /**
247
301
  * 任意一次同步成功:清 marker。此前处于闸内(marker 存在)时打一行恢复提示——
248
302
  * 平台恢复的第一个信号应该可见,之后的成功照常静默(成功不打扰)。
@@ -256,11 +310,13 @@ export function noteSyncSuccess() {
256
310
  console.log('[sync] 平台连接已恢复(此前静默的同步失败已停止)')
257
311
  }
258
312
 
259
- /** 测试复位:清进程内绑定与开窗状态(连接类闸 + change_deleted 变更级闸)。 */
313
+ /** 测试复位:清进程内绑定与开窗状态(连接类闸 + change_deleted 变更级闸 + 409 自愈闸)。 */
260
314
  export function _resetSyncNoiseForTest() {
261
315
  _runtimeDir = null
262
316
  _openedByThisProcess = false
263
317
  _seenInProcess.clear()
264
318
  _openedDeletedByThisProcess = new Set()
265
319
  _seenDeletedInProcess.clear()
320
+ _openedSelfHealByThisProcess = new Set()
321
+ _seenSelfHealInProcess.clear()
266
322
  }
package/src/sync.js CHANGED
@@ -18,7 +18,7 @@ import { safeGit } from './git-helper.js';
18
18
  import { openDatabase } from './db-engine.js';
19
19
  import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
20
20
  import { syncSpecTree } from './spec-sync.js';
21
- import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess, syncChangeDeletedLog, syncChangeDeletedWarn, noteChangeDeletedResolved } from './sync-noise.js';
21
+ import { bindSyncNoiseFromCwd, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess, syncChangeDeletedLog, syncChangeDeletedWarn, noteChangeDeletedResolved, syncSelfHealWarn } from './sync-noise.js';
22
22
 
23
23
  // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
24
24
  function safePlatformSpecDir(cwd) {
@@ -844,7 +844,9 @@ export class SyncManager {
844
844
  // 说明赢者是本机自己人——刷新 base_ts 重试一次即收敛,不落冲突文件(外来推送不可能
845
845
  // 推进本机 DB 的 base_ts,不满足条件自然落回下方真冲突分支,零误放行)
846
846
  if (attempt < MAX_PUSH_ATTEMPTS && await this._localBaseTsCovers(changeName, platformLastPushedAt)) {
847
- console.warn(`⚠️ [sync] push 409 自竞态判定:本机并发推送已回填 base_ts(平台 ts=${platformLastPushedAt}),刷新后自动重试`);
847
+ // 409 自愈键控闸(2026-09-15 复盘:自愈不阻断但每步刷屏)——首报可见、窗口内同变更
848
+ // 同类型静默;手动 platform sync(manual)旁路
849
+ syncSelfHealWarn(changeName, 'self-race', `⚠️ [sync] push 409 自竞态判定:本机并发推送已回填 base_ts(平台 ts=${platformLastPushedAt}),刷新后自动重试`, { noMute: manual });
848
850
  continue;
849
851
  }
850
852
 
@@ -863,7 +865,8 @@ export class SyncManager {
863
865
  'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
864
866
  ).run(platformLastPushedAt || pushedAt, platformLastPushedAt || pushedAt, changeName);
865
867
  } catch {}
866
- console.warn(`⚠️ [sync] push 409 内容一致自愈: 平台 ts=${platformLastPushedAt} 为外来噪声重推(六表内容与本地一致),base_ts 已推进,不落冲突文件`);
868
+ // 409 自愈键控闸同上(kind=content-equal)
869
+ syncSelfHealWarn(changeName, 'content-equal', `⚠️ [sync] push 409 内容一致自愈: 平台 ts=${platformLastPushedAt} 为外来噪声重推(六表内容与本地一致),base_ts 已推进,不落冲突文件`, { noMute: manual });
867
870
  return { synced: 1, errors: [], selfHealed: true, reason: 'push 409 平台内容与本地一致(外来噪声重推),base_ts 已推进' };
868
871
  }
869
872
  } catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
@@ -895,7 +898,8 @@ export class SyncManager {
895
898
  pm._ensureDB(this.cwd).getDb().prepare(
896
899
  'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
897
900
  ).run(healTs, healTs, changeName);
898
- console.warn(`⚠️ [sync] push 409 自回声判定:平台 ts=${platformLastPushedAt} 为本机自推回声(血统 ${platLin},推送者 ${platformLastPusher || '未回传·窗口判定'}),base_ts 已推进到 ${healTs},自动重推`);
901
+ // 409 自愈键控闸同上(kind=self-echo)
902
+ syncSelfHealWarn(changeName, 'self-echo', `⚠️ [sync] push 409 自回声判定:平台 ts=${platformLastPushedAt} 为本机自推回声(血统 ${platLin},推送者 ${platformLastPusher || '未回传·窗口判定'}),base_ts 已推进到 ${healTs},自动重推`, { noMute: manual });
899
903
  if (attempt < MAX_PUSH_ATTEMPTS) continue;
900
904
  // 重试额度耗尽(attempt=2 仍进此分支,理论少见):不落冲突文件——base 已推进,
901
905
  // 下次常规同步按新 base 直接推送收敛
@@ -1249,6 +1249,70 @@ function resolveAttributionDiffFiles({ cwd, changeName, specBase, platformOpts =
1249
1249
  return { mode: 'main-window', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes, meta, mergeGitDir }
1250
1250
  }
1251
1251
 
1252
+ /**
1253
+ * worktree/in-place 改动文件集:porcelain 未提交 ∪ committed merge-base 补齐——口径单一化。
1254
+ * 口径出处:D-004@v1(worktree 归因唯一事实源 = worktree 分支 diff + porcelain,决策库
1255
+ * decisions/worktree.md)+ 变更 2026-09-15-worktree-dual-truth-gates §4 / FR-04;坑④
1256
+ * worktree-tick-guard-commit-only(2026-09-15 实证):勾选守卫 prefetchDiffFileSet
1257
+ * (run/complete.js)只算 base..head(已提交),子代理默认不 commit 时守卫恒空跳过勾选——
1258
+ * 本 helper 是「草稿归属并入段(generateTaskReviewDrafts)」与「勾选守卫(prefetchDiffFileSet)」
1259
+ * 两处的单一真相。已知残留 R-09:resolveVerifyChangedFiles(verify-postcheck.js)内另有同型
1260
+ * 补齐段(form A 专属:滤网保 .sillyspec/docs/** 交付物,与本 helper 的整前缀滤网不同),
1261
+ * 本次不动,待该函数下次触碰时统一。
1262
+ *
1263
+ * 行为契约:
1264
+ * - meta 缺省时内部 WorktreeManager.getMeta 自取(try/catch → null)。
1265
+ * - gitDir = meta.worktreePath(mode 非 in-place-fallback 且目录存在)否则 cwd——
1266
+ * in-place 退化取 cwd(保住现状 in-place 主仓 porcelain 并入,不丢,R-08);
1267
+ * meta 完全缺失时同样取 cwd(与被并入段 mergeGitDir 语义一致)。
1268
+ * - porcelain:status --porcelain --untracked-files=all(parsePorcelainFiles 解析),
1269
+ * 滤 .sillyspec 前缀(与被替换的草稿并入段同口径)。
1270
+ * - committed 补齐(仅 gitDir !== cwd,防双并):merge-base(主仓 HEAD, wtHEAD)..wtHEAD
1271
+ * 的 diff --name-only——worktree 内已 commit 的改动对主仓 base..head 与 status 双不可见
1272
+ * (「先提交则 diff 空」时序两难);git 失败 fail-open 忽略。
1273
+ * - 返回正斜杠归一去重数组;任何 git 异常 → 已收集部分或 [](fail-open,不阻断调用方)。
1274
+ *
1275
+ * @param {string} cwd 主仓根
1276
+ * @param {string} changeName 变更名
1277
+ * @param {object|null} [meta] 预取的 worktree meta(缺省内部 getMeta 自取)
1278
+ * @param {object} [opts] 预留(对齐 design 接口签名)
1279
+ * @returns {string[]} 正斜杠归一文件集(git 失败 → 已收集部分或 [],fail-open)
1280
+ */
1281
+ export function collectWorktreeChangedFiles(cwd, changeName, meta = null, opts = {}) {
1282
+ const files = new Set()
1283
+ if (!cwd || !changeName) return []
1284
+ let m = meta
1285
+ if (m == null) {
1286
+ try { m = new WorktreeManager({ cwd }).getMeta(changeName) } catch { m = null }
1287
+ }
1288
+ const gitDir = (m?.worktreePath && m.mode !== 'in-place-fallback' && existsSync(m.worktreePath))
1289
+ ? m.worktreePath
1290
+ : cwd
1291
+ const keep = (p) => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/')
1292
+ // 未提交:worktree(in-place 退化=主仓)porcelain 全量(含 untracked)
1293
+ try {
1294
+ const st = runGit(gitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1295
+ for (const p of parsePorcelainFiles(st)) if (keep(p)) files.add(p)
1296
+ } catch { /* porcelain 失败 → 返回已收集部分(fail-open) */ }
1297
+ // 已提交补齐:merge-base(主仓 HEAD, wtHEAD)(= 创建锚点)..wtHEAD 的 commit diff。
1298
+ // in-place(gitDir===cwd)时已提交改动在主仓 diff 内,跳过防双并;失败 fail-open 忽略。
1299
+ if (gitDir !== cwd) {
1300
+ try {
1301
+ const mainHead = runGit(cwd, ['rev-parse', 'HEAD'])
1302
+ const wtHead = runGit(gitDir, ['rev-parse', 'HEAD'])
1303
+ const mb = (mainHead && wtHead) ? runGit(gitDir, ['merge-base', wtHead, mainHead]) : null
1304
+ if (mb) {
1305
+ const out = String(runGit(gitDir, ['diff', '--name-only', mb, wtHead]) || '')
1306
+ for (let p of out.split('\n')) {
1307
+ p = p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim()
1308
+ if (keep(p)) files.add(p)
1309
+ }
1310
+ }
1311
+ } catch { /* 已提交补齐失败忽略(fail-open,不拖垮未提交并入) */ }
1312
+ }
1313
+ return [...files]
1314
+ }
1315
+
1252
1316
  /**
1253
1317
  * worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
1254
1318
  * 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
@@ -1339,32 +1403,17 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1339
1403
  // worktree-branch/cleaned 归因的未提交并入已在 resolveAttributionDiffFiles 内按同口径完成。
1340
1404
  if (attribution.mergeGitDir) {
1341
1405
  try {
1342
- const wtGitDir = attribution.mergeGitDir
1343
- const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1344
- const wtFiles = parsePorcelainFiles(wtStatus)
1345
- .map(p => String(p).replace(/\\/g, '/'))
1346
- .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1347
- // 已提交口径补齐(2026-09-10 用户反馈②「先提交则 diff 空」:worktree 内已 commit 的改动
1348
- // 对主仓 base..head 与 status 双不可见,草稿 changedFiles 恒空)——merge-base(主仓 HEAD,
1349
- // worktree HEAD)(= 创建锚点)..worktree HEAD 的 commit diff 并入,与
1350
- // resolveVerifyChangedFiles 的同款补齐同源(verify-postcheck.js,两处口径须同步改)。
1351
- // in-place 退化(wtGitDir===cwd)时已提交改动在主仓 diff 内,跳过防双并;git 失败 fail-open。
1352
- let committedFiles = []
1353
- if (wtGitDir !== cwd) {
1354
- try {
1355
- const mainHead = runGit(cwd, ['rev-parse', 'HEAD'])
1356
- const wtHead = runGit(wtGitDir, ['rev-parse', 'HEAD'])
1357
- const mb = (mainHead && wtHead) ? runGit(wtGitDir, ['merge-base', wtHead, mainHead]) : null
1358
- if (mb) {
1359
- committedFiles = String(runGit(wtGitDir, ['diff', '--name-only', mb, wtHead]) || '')
1360
- .split('\n').map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1361
- .filter(p => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1362
- }
1363
- } catch { /* 已提交补齐失败退回 status-only(fail-open,不拖垮未提交并入) */ }
1364
- }
1365
- if (wtFiles.length > 0 || committedFiles.length > 0) {
1366
- diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles, ...committedFiles])]
1367
- console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${wtFiles.length + committedFiles.length} 个(未提交 ${wtFiles.length} + 已提交 ${committedFiles.length},按 allowed_paths 归属)`)
1406
+ // D-004@v1 口径单一化(坑④ worktree-tick-guard-commit-only,2026-09-15 实证):原手写
1407
+ // 并入段(porcelain 未提交 + committed merge-base 补齐两段)收口为
1408
+ // collectWorktreeChangedFiles 单一真相——勾选守卫 prefetchDiffFileSet(run/complete.js)
1409
+ // 与本草稿归属自此消费同一口径(原「两处口径须同步改」注记就此作废,单一真相在 helper)。
1410
+ // in-place(mergeGitDir===cwd)时 helper 同取 cwd porcelain(现状主仓并入不丢)。
1411
+ // resolveVerifyChangedFiles(verify-postcheck.js)内另有同型补齐段(form A 专属,滤网保
1412
+ // .sillyspec/docs/** 交付物),已知残留 R-09,本次不动。
1413
+ const mergedFiles = collectWorktreeChangedFiles(cwd, changeName, attribution.meta)
1414
+ if (mergedFiles.length > 0) {
1415
+ diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...mergedFiles])]
1416
+ console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${mergedFiles.length} 个(未提交 porcelain + 已提交 merge-base 补齐,按 allowed_paths 归属)`)
1368
1417
  }
1369
1418
  } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1370
1419
  }