sillyspec 3.32.6 → 3.32.8

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.
package/src/task-tick.js CHANGED
@@ -69,11 +69,36 @@ export function tickTasksMd({ tasksMd, taskId }) {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * 证据解析(2026-10-12-tick-evidence-multi-anchor):自由文本或 @路径 引用——@file 读文件内容
74
+ * (帽 8KB,超帽截断留痕);读取失败 fail-soft 原样登记「@路径」文本并告警(不拒翻格——证据是
75
+ * 自愿补录面,不进门禁判据)。dogfood 驱动:限流截断场景构建回执(/tmp 日志 exit 0)无法补录,
76
+ * 恢复后只能重跑构建。
77
+ */
78
+ export function resolveTickEvidence({ evidence }) {
79
+ const raw = String(evidence ?? '').trim()
80
+ if (!raw) return { text: null, warning: null }
81
+ if (!raw.startsWith('@')) return { text: raw, warning: null }
82
+ const path = raw.slice(1)
83
+ try {
84
+ let content = readFileSync(path, 'utf8')
85
+ if (Buffer.byteLength(content, 'utf8') > 8192) {
86
+ content = Buffer.from(content, 'utf8').subarray(0, 8192).toString('utf8') + '…(超 8KB 截断)'
87
+ }
88
+ const text = `@${path}\n${content.trim()}`
89
+ if (!content.trim()) return { text: `@${path}(空文件)`, warning: `⚠️ 证据文件 ${path} 为空——原样登记引用` }
90
+ return { text, warning: null }
91
+ } catch (e) {
92
+ return { text: raw, warning: `⚠️ 证据文件 ${path} 读取失败(${e && e.code ? e.code : '不可读'})——原样登记引用文本,未拒翻格` }
93
+ }
94
+ }
95
+
72
96
  /**
73
97
  * IO 编排(index.js 分派入口):定位变更目录 → tickTasksMd → 原子写盘 → 回显。
74
98
  * exit 0=翻格/幂等;exit 2=未知 id / 缺 tasks.md / 已归档。
99
+ * evidence(可选):自愿证据补录——翻格成功后追加 task-evidence.jsonl(幂等重勾零追加)。
75
100
  */
76
- export async function runTaskTick({ changeName, cwd, taskId, specBase }) {
101
+ export async function runTaskTick({ changeName, cwd, taskId, specBase, evidence = null }) {
77
102
  const changesRoot = join(specBase, 'changes')
78
103
  const tasksPath = join(changesRoot, changeName, 'tasks.md')
79
104
  if (!existsSync(tasksPath)) {
@@ -90,9 +115,24 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase }) {
90
115
  console.error(`❌ tasks.md 中无 ${taskId}——可选任务:${ids}`)
91
116
  process.exit(2)
92
117
  }
118
+ // 证据预解析(翻格前展开 @file——证据不先于事实:翻格失败不产生证据行)
119
+ const ev = resolveTickEvidence({ evidence })
120
+ if (ev.warning) console.warn(ev.warning)
93
121
  if (r.kind === 'ticked') {
94
122
  const { writeAtomicSync } = await import('./fs-atomic.js')
95
123
  writeAtomicSync(tasksPath, r.tasksMd)
124
+ // 证据补录(2026-10-12-tick-evidence-multi-anchor):翻格成功后追加四键 JSONL——
125
+ // 幂等重勾零追加(already 分支不进此块);自愿面不接任何收口门判据。
126
+ if (ev.text) {
127
+ try {
128
+ appendFileSync(join(changesRoot, changeName, 'task-evidence.jsonl'), JSON.stringify({
129
+ ts: Date.now(), task: normalizeTaskId(taskId) || taskId, evidence: ev.text, source: 'task-tick',
130
+ }) + '\n', 'utf8')
131
+ console.log(`📎 证据已登记:${join(changesRoot, changeName, 'task-evidence.jsonl')}(task ${normalizeTaskId(taskId) || taskId})`)
132
+ } catch (e) {
133
+ console.warn(`⚠️ 证据登记失败(${e && e.message ? e.message : e})——翻格不受影响`)
134
+ }
135
+ }
96
136
  // 事件直写(2026-10-07-thin-tasks-v3):精确单格跳进事件流,节奏门按 source 去重采样合并跳。
97
137
  try {
98
138
  const { resolveRuntimeRoot } = await import('./run/shared.js')
@@ -102,6 +142,7 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase }) {
102
142
  appendFileSync(eventsPath, JSON.stringify({
103
143
  ts: Date.now(), kind: 'task-done', stage: 'tasks',
104
144
  detail: `checked ${from}→${r.checked}`, provisional: true, source: 'task-tick',
145
+ ...(ev.text ? { evidence: ev.text.slice(0, 300) } : {}), // 事件面截 300 字符(全文在 task-evidence.jsonl)
105
146
  }) + '\n', 'utf8')
106
147
  } catch { /* 事件 best-effort:写失败翻格不受影响(节奏门退回采样判) */ }
107
148
  // 工件即时重推(2026-10-07-allticked-gate-docs-resync):翻格即任务面变更——顺带触发
@@ -118,4 +159,4 @@ export async function runTaskTick({ changeName, cwd, taskId, specBase }) {
118
159
  return r
119
160
  }
120
161
 
121
- export default { tickTasksMd, runTaskTick, normalizeTaskId }
162
+ export default { tickTasksMd, runTaskTick, normalizeTaskId, resolveTickEvidence }
@@ -2678,7 +2678,7 @@ function renderApiCoverageMatrixLines(apiFace) {
2678
2678
  const endpoints = face && Array.isArray(face.endpoints) ? face.endpoints : []
2679
2679
  const declared = face && typeof face.declared === 'number' ? face.declared : null
2680
2680
  const L = [`${API_MATRIX_HEADING} [层:人工判断——CLI 预填复核]`]
2681
- L.push('<!-- 口径注记(与探针 7 互指,R-07):探针 7 = 验收项 × 测试承接面(每条 acceptance 由哪些测试承接);本矩阵 = 接口端点 × 验证用例面(design 接口段每个端点由哪些验证用例/冒烟步骤覆盖)——两者并排互补,双矩阵并行存在。端点集来自 design.md 接口段 tolerant 解析(parseDesignApiTable:段头宽收 + 方法/路径双条件),预填≠结论,agent 逐行复核。判定枚举(五选一):covered / covered-service / partial / uncovered / non-testable——covered-service 适用:端点行为由 service 层等非端点层测试锁定;证据须含测试文件锚点三形态之一(`.test.` / file:line / 反引号包裹的路径或测试名)。 -->')
2681
+ L.push('<!-- 口径注记(与探针 7 互指,R-07):探针 7 = 验收项 × 测试承接面(每条 acceptance 由哪些测试承接);本矩阵 = 接口端点 × 验证用例面(design 接口段每个端点由哪些验证用例/冒烟步骤覆盖)——两者并排互补,双矩阵并行存在。端点集来自 design.md 接口段 tolerant 解析(parseDesignApiTable:段头宽收 + 方法/路径双条件),预填≠结论,agent 逐行复核。判定枚举(五选一):covered / covered-service / partial / uncovered / non-testable——covered-service 适用:端点行为由 service 层等非端点层测试锁定;证据须含测试文件锚点三形态之一(`.test.` / file:line / 反引号包裹的路径或测试名);non-testable 适用(含运行验证移交场景):目标环境不可达、构建级验证已过而运行冒烟移交部署后执行——证据写构建回执+移交去向一句话,该行不计入有效分母(勿硬凑 covered-service)。 -->')
2682
2682
  L.push('<!-- 预填说明:端点行由 CLI 机械预填,判定/用例依据 ID/结果/证据由 agent 逐格填写——用例依据 ID 锚点五形态(可复制样例):design接口表#POST /api/xx(# 后必须 METHOD /path,仅表名/行号/散文描述不计命中)、权限矩阵[admin×读]、契约表@任务卡字段清单、DDL@users.id、载荷@e2e_body.json(须真实命中对应表/段,防空指)。 -->')
2683
2683
  L.push('<!-- 文法注释:子行 = 端点行下一行、两空格缩进、以「↳ <消费端>:」前缀书写(消费端细分承接面,不计矩阵行账);探索行 = 判定 uncovered 且证据列含 [探索] 标记(探索性验证不算覆盖)。 -->')
2684
2684
  if (endpoints.length === 0 && declared === null) {
@@ -26,6 +26,7 @@ import { collectActiveQuickGuardFiles } from './quicklog.js';
26
26
  import { appendWriteAudit, WRITE_AUDIT_FILE_CAP } from './write-audit.js';
27
27
  import { detectCommittedDrift, formatCommittedDriftWarning } from './run/concurrent-detect.js';
28
28
  import { readWorktreePolicy } from './worktree-policy.js';
29
+ import { reAnchorChangePatch } from './patch-reanchor.js';
29
30
 
30
31
  const CHANGES_REL = '.sillyspec/changes';
31
32
 
@@ -2208,6 +2209,27 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
2208
2209
  result.warnings.push(`cleanup 失败(不影响应用结果;Windows 手动清理先解 junction 再删,勿 rm -rf): ${cleanupErr.message}`);
2209
2210
  }
2210
2211
 
2212
+ // --- 8.5 归档锚点回写(2026-10-12-close-trace-freeze-integrity FR-03):worktree 内收口的
2213
+ // change-patch.json 记的是 worktree 本地 SHA,apply 重放/清理后主仓侧锚点改写——best-effort
2214
+ // 回写为可解析主仓锚(幂等;merge 路径提交已落主仓即刻生效,patch 路径 agent 提交前的
2215
+ // no-op 留给 doctor/patch-reanchor 补位;失败仅警告不阻断)。
2216
+ try {
2217
+ const reanchorBase = allowSpecBase || join(projectRoot, '.sillyspec');
2218
+ for (const cand of [join(reanchorBase, 'changes', changeName), join(reanchorBase, 'changes', 'archive', changeName)]) {
2219
+ if (!existsSync(join(cand, 'change-patch.json'))) continue
2220
+ const r = reAnchorChangePatch({ projectRoot, changeDir: cand, changeName });
2221
+ if (r.ok && r.noop) break
2222
+ if (r.ok && !r.noop) {
2223
+ console.log(`📦 归档锚点已回写主仓:${changeName} baseline/head → ${String(r.baseline).slice(0, 10)}/${String(r.head).slice(0, 10)}(change.patch 重冻结+sha256 重锚)`);
2224
+ break
2225
+ }
2226
+ result.warnings.push(`归档锚点回写未完成(${cand}):${r.reason}——收口提交后可跑 sillyspec patch-reanchor --change ${changeName} 补位`);
2227
+ break
2228
+ }
2229
+ } catch (reErr) {
2230
+ result.warnings.push(`归档锚点回写异常(不影响应用结果): ${reErr.message}`);
2231
+ }
2232
+
2211
2233
  } catch (e) {
2212
2234
  // ENOBUFS 自动降级(坑 apply-spawnsync-enobufs,2026-08-22 实证:大 diff 的 binary patch
2213
2235
  // 超 maxBuffer 抛 ENOBUFS,此前只能 agent 手动 git merge 绕行)——git apply 路径走不通时
@@ -386,14 +386,49 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
386
386
  return { created, reused, skippedLegacy };
387
387
  }
388
388
 
389
+ /**
390
+ * 探测跨仓仓 worktree 注册状态(双清契约的复核原语,坑 cross-wt-ghost-metadata 2026-10-10
391
+ * fire-equipment 实证:目录被清、.git/worktrees/ 注册残留占住分支——prune 静默失败/locked
392
+ * 条目被 prune 跳过都产出零报错幽灵,必须以 git worktree list 的答复为准)。
393
+ * 路径归一对齐 gate-snapshot worktreeListContains(反斜杠归正斜杠 + win32 大小写不敏感)。
394
+ * @param {string} repoRoot 跨仓仓根
395
+ * @param {string} wtPath worktree 路径
396
+ * @returns {{registered: boolean, locked: boolean, unknown?: boolean}} 查询失败(git 不可达)
397
+ * 按 registered:true 返回(fail-safe——复核不过走 partial,绝不静默判清)。
398
+ */
399
+ export function probeCrossWorktreeEntry(repoRoot, wtPath) {
400
+ const out = gitQuiet(repoRoot, ['worktree', 'list', '--porcelain'], { timeout: 30000 });
401
+ if (out == null) return { registered: true, locked: false, unknown: true };
402
+ const norm = (s) => {
403
+ const v = String(s).replace(/\\/g, '/');
404
+ return process.platform === 'win32' ? v.toLowerCase() : v;
405
+ };
406
+ const target = norm(wtPath);
407
+ const lines = String(out).split('\n');
408
+ const idx = lines.findIndex((l) => l.startsWith('worktree ') && norm(l.slice('worktree '.length)) === target);
409
+ if (idx === -1) return { registered: false, locked: false };
410
+ // 该条目属性行:到下一条 worktree 行或空行为止;locked 可带原因尾巴(git ≥2.30 lock 原因)
411
+ let locked = false;
412
+ for (let i = idx + 1; i < lines.length; i++) {
413
+ const l = lines[i];
414
+ if (l.startsWith('worktree ') || l.trim() === '') break;
415
+ if (l === 'locked' || l.startsWith('locked ')) { locked = true; break }
416
+ }
417
+ return { registered: true, locked };
418
+ }
419
+
389
420
  /**
390
421
  * 清理某变更的全部跨仓 worktree(幂等)。
391
422
  *
392
- * 顺序对齐主仓 cleanup 的 Windows 防护:先解 node_modules junction(裸删会穿透删跨仓主工作
393
- * 副本的 node_modules),再 git worktree remove --force,最后**保留分支** sillyspec/<change>
423
+ * 顺序对齐主仓 cleanup 的 Windows 防护:先解 node_modules junction(裸删会穿透删跨仓主
424
+ * 工作副本的 node_modules),再 git worktree remove --force,最后**保留分支** sillyspec/<change>
394
425
  * 作 review 锚点(分支删除在主仓侧有 review 锚点 + 双保护,跨仓侧 v1 保守不删,残 branch
395
426
  * 可人工清理)。目录残留(remove 失败)走 safeRemoveWorktreeDir 兜底 + residual 上报。
396
427
  *
428
+ * 双清契约(坑 cross-wt-ghost-metadata 缺口①,对齐 gate-snapshot D-004@v2):remove 退出码
429
+ * 为零 / prune 不抛 ≠ 注册已清——两条路径收尾都以 git worktree list 复核注册已消失,复核
430
+ * 不过 → partial + residual(locked 条目附 unlock 指引),注册表键只在验证通过后回收。
431
+ *
397
432
  * @param {{ cwd: string, changeName: string, specBase?: string, force?: boolean }} opts
398
433
  * @returns {{ results: Array<{repoKey, result: 'cleaned'|'skipped'|'partial', details: string[], residual: string[]}> }}
399
434
  */
@@ -406,18 +441,33 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
406
441
  const repoRoot = meta.crossRepoRoot;
407
442
  const wtPath = meta.worktreePath || crossWorktreePath(base, changeName, repoKey);
408
443
  if (!existsSync(wtPath)) {
409
- try { if (repoRoot) gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
410
- removePlacementRegistryEntry(base, changeName, repoKey); // 挪位清理后注册表键回收(FR-02)
411
- results.push({ repoKey, result: 'skipped', details: ['目录不存在(已清理)'], residual });
444
+ // 目录不在:prune 悬空注册——但 prune 会静默失败/静默跳过 locked 条目,prune 后必须
445
+ // 复核(git worktree list 答复为准)才销账;复核不过 → partial + unlock 指引。
446
+ let entry = repoRoot ? probeCrossWorktreeEntry(repoRoot, wtPath) : { registered: false, locked: false };
447
+ if (entry.registered) {
448
+ try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
449
+ entry = repoRoot ? probeCrossWorktreeEntry(repoRoot, wtPath) : { registered: false, locked: false };
450
+ }
451
+ if (!entry.registered) {
452
+ removePlacementRegistryEntry(base, changeName, repoKey); // 挪位清理后注册表键回收(FR-02)
453
+ results.push({ repoKey, result: 'skipped', details: ['目录不存在(注册 prune 后复核通过)'], residual });
454
+ } else {
455
+ if (entry.locked) details.push(`条目 locked(prune 静默跳过)——git -C ${repoRoot} worktree unlock ${wtPath} 后重试`);
456
+ residual.push(`幽灵注册残留 ${wtPath}(目录不存在但注册仍在)`);
457
+ results.push({ repoKey, result: 'partial', details, residual });
458
+ }
412
459
  continue;
413
460
  }
414
461
  if (!force) {
415
462
  // fail-closed:worktree 内还有未落地交付(相对 baseHash 有 diff)时拒绝清理(对齐主仓
416
- // hasUnappliedChanges 哲学;跨仓侧以「分支上有交付 diff 且未 apply」近似)
463
+ // hasUnappliedChanges 哲学;跨仓侧以「分支上有交付 diff 且未 apply」近似)。
464
+ // 自家 meta.json 不算交付(坑 cross-wt-ghost-metadata 顺带修正:CLI 写的 meta.json 恒为
465
+ // untracked,此前计入未 apply 面使 force=false 清理对刚建的空 worktree 恒误报 blocked)。
417
466
  try {
418
467
  const diff = gitQuiet(wtPath, ['diff', '--name-only', meta.baseHash]) || '';
419
- const untracked = gitQuiet(wtPath, ['ls-files', '--others', '--exclude-standard']) || '';
420
- if (diff.trim() || untracked.trim()) {
468
+ const untracked = (gitQuiet(wtPath, ['ls-files', '--others', '--exclude-standard']) || '')
469
+ .split('\n').map((s) => s.trim()).filter((f) => f && f !== META_FILE);
470
+ if (diff.trim() || untracked.length > 0) {
421
471
  results.push({ repoKey, result: 'partial', details: [`blocked: 跨仓 worktree 有未 apply 的交付改动(git diff ${String(meta.baseHash).slice(0, 8)} 非空)——先 apply 或显式 --force`], residual: [wtPath] });
422
472
  continue;
423
473
  }
@@ -429,11 +479,26 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
429
479
  try {
430
480
  git(repoRoot, ['worktree', 'remove', '--force', wtPath], { timeout: 60000 });
431
481
  } catch {
432
- // remove 失败(脏文件/锁):解链后安全删目录 + prune 注册
482
+ // remove 失败(脏文件/锁/locked——locked 条目 remove 被拒,解锁需两次 --force,不自动解除
483
+ // 用户显式保护意图,只提示):解链后安全删目录 + prune 注册
433
484
  // allowCross:true——本函数就是跨仓清理的显式路径(主仓守卫放行;见 safeRemoveWorktreeDir 注释)
485
+ const probe = repoRoot ? probeCrossWorktreeEntry(repoRoot, wtPath) : { registered: false, locked: false };
486
+ if (probe.locked) details.push(`worktree locked(remove 被拒)——git -C ${repoRoot} worktree unlock ${wtPath} 后重跑`);
434
487
  try { safeRemoveWorktreeDir(wtPath, meta, { allowCross: true }) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
435
488
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
436
489
  }
490
+ // 双清复核:remove 退出码为零 ≠ 注册已清(prune 未生效/竞态/locked 残留时 list 仍含该
491
+ // root)——以 git worktree list 答复为准(对齐 gate-snapshot D-004@v2)。无 repoRoot 的
492
+ // legacy 假 meta(测试夹具/手写形态)无从复核,视同未注册不误伤。
493
+ let entry = repoRoot ? probeCrossWorktreeEntry(repoRoot, wtPath) : { registered: false, locked: false };
494
+ if (entry.registered) {
495
+ try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
496
+ entry = repoRoot ? probeCrossWorktreeEntry(repoRoot, wtPath) : { registered: false, locked: false };
497
+ }
498
+ if (entry.registered) {
499
+ if (entry.locked) details.push(`条目 locked(prune 静默跳过)——git -C ${repoRoot} worktree unlock ${wtPath} 后重跑`);
500
+ residual.push(`worktree 注册残留(复核 git worktree list 仍含 ${wtPath})`);
501
+ }
437
502
  details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
438
503
  if (residual.length === 0) {
439
504
  removePlacementRegistryEntry(base, changeName, repoKey); // cleaned 后注册表键回收(FR-02)
@@ -447,3 +512,87 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
447
512
  if (swept.length > 0) results.push({ repoKey: '(registry-sweep)', result: 'cleaned', details: [`悬挂注册表键已清: ${swept.join('、')}`], residual: [] });
448
513
  return { results };
449
514
  }
515
+
516
+ /**
517
+ * 扫描 local.yaml repos 各仓的幽灵 worktree 注册(缺口③,坑 cross-wt-ghost-metadata
518
+ * 2026-10-10 fire-equipment 实证:目录被清、跨仓仓 .git/worktrees/ 注册残留 → 分支被幽灵
519
+ * 占用无法 checkout;且 meta.json 随目录同亡,cleanup/归档链对该形态天然隐身——从跨仓仓
520
+ * 自己的注册面反扫是唯一可见面)。
521
+ *
522
+ * 只报「注册指向不存在路径」的条目(与 git prune 同语义面,不动活 worktree、不碰分支);
523
+ * locked 条目单独标注(prune 对它们静默跳过,需先 unlock)。只读零副作用。
524
+ *
525
+ * @param {string} cwd 主仓根(读 cwd/.sillyspec/local.yaml repos 段)
526
+ * @returns {{ repos: Array<{repoKey, repoRoot}>, ghosts: Array<{repoKey, repoRoot, worktreePath, locked}>, errors: Array<{repoKey, repoRoot, error}> }}
527
+ */
528
+ export function scanCrossWorktreeGhosts(cwd) {
529
+ const repos = [...readRepoRegistry(cwd).entries()].map(([repoKey, repoRoot]) => ({ repoKey, repoRoot }));
530
+ const ghosts = [];
531
+ const errors = [];
532
+ for (const { repoKey, repoRoot } of repos) {
533
+ let out = null;
534
+ try { out = gitQuiet(repoRoot, ['worktree', 'list', '--porcelain'], { timeout: 30000 }) } catch { out = null }
535
+ if (out == null) {
536
+ errors.push({ repoKey, repoRoot, error: 'git worktree list 不可达(非 git 仓/路径失效)' });
537
+ continue;
538
+ }
539
+ let cur = null;
540
+ let locked = false;
541
+ const flush = () => { if (cur && !existsSync(cur)) ghosts.push({ repoKey, repoRoot, worktreePath: cur, locked }) };
542
+ for (const line of String(out).split('\n')) {
543
+ if (line.startsWith('worktree ')) {
544
+ flush();
545
+ cur = line.slice('worktree '.length).trim();
546
+ locked = false;
547
+ } else if (line === 'locked' || line.startsWith('locked ')) {
548
+ locked = true;
549
+ } else if (line.trim() === '') {
550
+ flush();
551
+ cur = null;
552
+ locked = false;
553
+ }
554
+ }
555
+ flush();
556
+ }
557
+ return { repos, ghosts, errors };
558
+ }
559
+
560
+ /**
561
+ * 清理跨仓幽灵 worktree 注册(doctor --prune-cross-worktrees 动作体,对齐 --cleanup-ghosts
562
+ * 范式:默认 dry-run 只列,confirm=true 才执行)。
563
+ *
564
+ * confirm 语义:对每个有非 locked 幽灵的仓跑 `git worktree prune`(git 自身只清指向不存在
565
+ * 位置的注册,活 worktree 不受影响),随后**复扫断言**(清了什么以复扫为准,不信任 prune
566
+ * 退出码);locked 条目一律跳过并列出 unlock 指引(locked 是用户显式保护意图,不自动解除)。
567
+ *
568
+ * @param {{ cwd: string, confirm?: boolean }} opts
569
+ */
570
+ export function pruneCrossWorktreeGhosts({ cwd, confirm = false }) {
571
+ const before = scanCrossWorktreeGhosts(cwd);
572
+ if (!confirm) {
573
+ return { confirm: false, repos_scanned: before.repos.length, count: before.ghosts.length, ghosts: before.ghosts, errors: before.errors };
574
+ }
575
+ const skippedLocked = [];
576
+ const pruneByRepo = new Map();
577
+ for (const g of before.ghosts) {
578
+ if (g.locked) { skippedLocked.push(g); continue }
579
+ if (!pruneByRepo.has(g.repoRoot)) pruneByRepo.set(g.repoRoot, []);
580
+ pruneByRepo.get(g.repoRoot).push(g);
581
+ }
582
+ const errors = [...before.errors];
583
+ for (const repoRoot of pruneByRepo.keys()) {
584
+ try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch (e) { errors.push({ repoRoot, error: `prune 失败: ${e.message}` }) }
585
+ }
586
+ // 复扫断言:prune 退出码为零 ≠ 注册已清(双清契约同源)
587
+ const after = scanCrossWorktreeGhosts(cwd);
588
+ const afterPaths = new Set(after.ghosts.map((g) => g.worktreePath));
589
+ const pruned = [];
590
+ for (const g of before.ghosts) {
591
+ if (g.locked) continue;
592
+ if (!afterPaths.has(g.worktreePath)) pruned.push(g);
593
+ else if (!errors.some((e) => e.repoRoot === g.repoRoot && String(e.error).includes('prune 失败'))) {
594
+ errors.push({ repoKey: g.repoKey, repoRoot: g.repoRoot, error: 'prune 后复扫仍在(疑似 locked 或 git 锁竞态)' });
595
+ }
596
+ }
597
+ return { confirm: true, repos_scanned: before.repos.length, count: before.ghosts.length, pruned, skipped_locked: skippedLocked, remaining: after.ghosts, errors };
598
+ }