sillyspec 3.32.6 → 3.32.7

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.
@@ -353,22 +353,42 @@ export async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, p
353
353
  if (orphan.result === 'cleaned' || orphan.result === 'force-cleaned' || orphan.result === 'partial') {
354
354
  console.log(`🧹 归档清理孤儿 worktree 残留(meta 已注销): ${archiveChangeName}${orphan.residual?.length ? '(残留: ' + orphan.residual.join('; ') + ')' : ''}`)
355
355
  }
356
- return
357
- }
358
- const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
359
- if (check.hasChanges) {
360
- console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
361
- console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
362
- return
363
- }
364
- const cleanResult = wm.cleanup(archiveChangeName)
365
- if (cleanResult.residual?.length > 0) {
366
- console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
367
- console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
356
+ } else {
357
+ const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
358
+ if (check.hasChanges) {
359
+ console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
360
+ console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
361
+ } else {
362
+ const cleanResult = wm.cleanup(archiveChangeName)
363
+ if (cleanResult.residual?.length > 0) {
364
+ console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
365
+ console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
366
+ }
367
+ }
368
368
  }
369
369
  } catch (e) {
370
370
  console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
371
371
  }
372
+ // ── 跨仓兜底(坑 cross-wt-ghost-metadata 缺口②,fire-equipment 2026-10-10 实证):此前
373
+ // 归档链只清主仓,跨仓 worktree 清理只挂在 apply/execute reset/手工 cleanup 四个时机,
374
+ // 归档作为最后一道收口不补刀——apply 侧任何一次 partial,跨仓注册/目录就永久滞留。
375
+ // 此处对齐主仓语义收口:force=false(有未 apply 交付的跨仓仓保留并警告),幂等,fail-open
376
+ // 不阻断归档。目录不在的幽灵注册由 cleanupCrossWorktrees 双清复核兜(缺口①),doctor
377
+ // --prune-cross-worktrees 是终网(缺口③)。──
378
+ try {
379
+ const { cleanupCrossWorktrees } = await import('../worktree-cross.js')
380
+ const cross = cleanupCrossWorktrees({ cwd, changeName: archiveChangeName, specBase, force: false })
381
+ for (const r of cross.results) {
382
+ if (r.result === 'cleaned') {
383
+ console.log(`🧹 归档兜底清理跨仓 worktree(${r.repoKey}): ${(r.details || []).join('; ')}`)
384
+ } else if (r.result === 'partial') {
385
+ console.warn(`⚠️ 归档跨仓 worktree 清理 partial(${r.repoKey}): ${(r.details || []).join('; ')}`)
386
+ console.warn(` 处理: sillyspec worktree cleanup ${archiveChangeName} --force(有交付需 apply)或 sillyspec doctor --prune-cross-worktrees(悬空注册)`)
387
+ }
388
+ }
389
+ } catch (e) {
390
+ console.warn(`⚠️ 归档跨仓 worktree 兜底清理失败(不阻断归档): ${e.message}`)
391
+ }
372
392
  }
373
393
 
374
394
  /**
@@ -620,7 +640,7 @@ export function renderStageSourceSideMovesMessage(r) {
620
640
  * @param {{ cwd: string, specBase: string, destDir: string, destName: string }} opts
621
641
  * @returns {{ docsAdded: string[]|null, fallbackDocs: boolean }} docsAdded=null 表示走了目录级回退
622
642
  */
623
- export function archiveNarrowedGitAdd({ cwd, specBase, destDir, destName }) {
643
+ export function archiveNarrowedGitAdd({ cwd, specBase, destDir, destName, extraDocs = null }) {
624
644
  // changes 侧:本变更归档目录精确收窄(quick 轻量归档 closeSingleQuickLinkedChange 同款)
625
645
  safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
626
646
 
@@ -643,6 +663,19 @@ export function archiveNarrowedGitAdd({ cwd, specBase, destDir, destName }) {
643
663
  console.warn(`⚠️ knowledge 侧 add 失败(fail-soft,不阻断归档链):${e && e.message ? e.message : e}`)
644
664
  }
645
665
 
666
+ // extraDocs(2026-10-12-thin-module-gate-and-feed 二档):thin 变更索引回写文件精确入暂存
667
+ // (文件级 pathspec;与下方 module-impact 精确集/目录级回退对本文件集是幂等重复 add——
668
+ // 本通道不依赖回退路径存活,回退面未来收紧时回写暂存不受影响)。
669
+ if (Array.isArray(extraDocs) && extraDocs.length > 0) {
670
+ try {
671
+ const posixDocs = [...new Set(extraDocs.map((p) => String(p).replace(/\\/g, '/')))]
672
+ for (const batch of chunkPaths(posixDocs)) safeGit(cwd, ['add', '--', ...batch])
673
+ console.log(` 📖 模块变更索引回写入暂存:${posixDocs.length} 个文件`)
674
+ } catch (e) {
675
+ console.warn(`⚠️ 回写文件入暂存失败(fail-soft,不阻断归档链):${e && e.message ? e.message : e}`)
676
+ }
677
+ }
678
+
646
679
  // docs 侧:module-impact「更新结果」done 行 → 精确文件集。
647
680
  // 三态(对齐修复④要求):
648
681
  // - module-impact.md 无「## 更新结果」段 / 文件缺失 / 读取异常 → docPaths=null → 回退目录级
@@ -880,7 +913,7 @@ export function resolveArchiveCommitPathspecs({ changeName, stagedNameStatus, he
880
913
  return { pathspecs, dropped }
881
914
  }
882
915
 
883
- export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, destDir, skipApply = false, skipPlanCheck = false, deliverableFiles = null, docsIncrement = null } = {}) {
916
+ export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, destDir, skipApply = false, skipPlanCheck = false, deliverableFiles = null, docsIncrement = null, extraDocs = null } = {}) {
884
917
  const archiveDir = dirname(destDir) // 抽取补:原闭包变量(changes/archive),从 destDir 推导等价
885
918
  // ── 模块文档认领 advisory(资产三小件③,2026-09-23)──
886
919
  // flow done/burst 收口不产生模块文档增量(旧流程挂在 execute/verify 步骤)——交付面 ×
@@ -1013,7 +1046,7 @@ export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, d
1013
1046
  // porcelain 实证)。basename(destDir) 与调用方 destDir 恒一致(flow.js 与 run archive 同构)。
1014
1047
  const destName = basename(destDir)
1015
1048
  try {
1016
- archiveNarrowedGitAdd({ cwd, specBase, destDir, destName })
1049
+ archiveNarrowedGitAdd({ cwd, specBase, destDir, destName, extraDocs })
1017
1050
  } catch (e) {
1018
1051
  console.warn(`⚠️ 归档窄化 git add 异常(不阻断归档,交付面靠下方探测块兜底/agent git status 核对): ${(e && e.message) || e}`)
1019
1052
  }
package/src/run/gates.js CHANGED
@@ -377,9 +377,16 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
377
377
  console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
378
378
  for (const { repoKey, meta: cm } of crossFailed) {
379
379
  console.error(` 跨仓 repo ${repoKey} 依赖未就绪(depsStatus=${cm.depsStatus || 'unknown'})——workdir: ${cm.worktreePath}`)
380
- if (cm.depsError) console.error(` 上次供给错误:${cm.depsError}`)
380
+ // 报错一次给全(2026-10-12-cli-friction-signal,dogfood 实证:首条阻断只有标题无明细、
381
+ // agent 二跑定位 + 最终两次手改内部 meta.json 才过门)——三要素 + 逃生口 + 防手改警告。
382
+ if (cm.depsError) {
383
+ const tail = String(cm.depsError)
384
+ console.error(` 上次供给错误(尾部 ≤200 字符):${tail.length > 200 ? '…' + tail.slice(-200) : tail}`)
385
+ }
386
+ console.error(` 状态文件:${join(cm.worktreePath || '', 'meta.json')}(勿手改——doctor 以实态校正,假状态会被下次校验打回)`)
381
387
  }
382
388
  console.error(' 修复:进上述跨仓 worktree 手动安装依赖(用该仓的包管理器),或重跑 sillyspec run execute 让 CLI 重试供给后 --done。')
389
+ console.error(' 正规逃生口:sillyspec worktree doctor --fix(按实态重供并回写 meta.json)。')
383
390
  if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
384
391
  process.exit(1)
385
392
  }
@@ -455,7 +462,15 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
455
462
  console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
456
463
  console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
457
464
  console.error(' 或在 worktree 内手动安装依赖后重试。')
458
- if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
465
+ if (meta?.depsError) {
466
+ const tail = String(meta.depsError)
467
+ console.error(` 上次供给错误(尾部 ≤200 字符):${tail.length > 200 ? '…' + tail.slice(-200) : tail}`)
468
+ }
469
+ // 状态文件路径 + 防手改(2026-10-12-cli-friction-signal):dogfood 实证 agent 被迫手改
470
+ // meta.json 假状态过门——路径透明 + 明示正规校正通道,堵死「改状态绕门」的路。
471
+ try {
472
+ if (wm && changeName) console.error(` 状态文件:${join(wm.getWorktreePath(changeName), 'meta.json')}(勿手改——doctor 以实态校正)`)
473
+ } catch { /* worktree 路径解析失败省略该行 */ }
459
474
  }
460
475
  if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
461
476
  process.exit(1)
package/src/run/shared.js CHANGED
@@ -445,6 +445,20 @@ export function resolveUninitCwdGate(command, { dir, specDir, platformFlags } =
445
445
  }
446
446
  }
447
447
  const gitRoot = safeGit(resolvedDir, ['rev-parse', '--show-toplevel']).value || null
448
+ // 跨仓标记指路(2026-10-12-cli-friction-signal):该仓被 register-repo 登记过(标记在 .git/ 内,
449
+ // registerRepoInLocalYaml 写入)→ block 态附 crossRepo 事实,index.js 门文案据此精确指回工作区
450
+ // (dogfood:agent 在跨仓子仓跑 CLI 三连跑错目录靠人工发现)。标记损坏/缺失按无标记零变化。
451
+ if (gitRoot) {
452
+ try {
453
+ const markerPath = join(gitRoot, '.git', 'sillyspec-crossrepo.json')
454
+ if (existsSync(markerPath)) {
455
+ const m = JSON.parse(readFileSync(markerPath, 'utf8'))
456
+ if (m && typeof m.workspace === 'string' && m.workspace) {
457
+ return { verdict: 'block', gitRoot, crossRepo: { workspace: m.workspace, key: typeof m.key === 'string' ? m.key : null } }
458
+ }
459
+ }
460
+ } catch { /* 标记损坏按无标记 */ }
461
+ }
448
462
  return { verdict: 'block', gitRoot }
449
463
  }
450
464
 
@@ -982,7 +982,7 @@ function validateAcceptanceMatrix(cwd, changeName, context = {}) {
982
982
  errors.push(
983
983
  `探针 7 验收×测试覆盖矩阵有 ${matrix.missingEvidence} 行证据缺失` +
984
984
  `(covered/covered-service/partial 证据须含测试锚点:\`.test.\` 文件名 / file:line / 反引号包裹的测试名;non-testable 证据须写一句理由):${list}。` +
985
- `修复:在证据列补测试锚点(如 \`test/foo.test.mjs\` 或 \`src/x.js:42\`)或 non-testable 理由。`
985
+ `修复:在证据列补测试锚点(如 \`test/foo.test.mjs\` 或 \`src/x.js:42\`)或 non-testable 理由(运行验证移交场景适用:构建回执+移交去向一句话)。`
986
986
  )
987
987
  }
988
988
 
@@ -1300,7 +1300,7 @@ export function judgeApiCoverageMatrix(args) {
1300
1300
  : `声明 ${N} 端点而 covered/covered-service 端点行仅 ${coveredCount}(解析零行降级,无法逐条列端点——按声明行拆出每端点行填判定)`
1301
1301
  errors.push(
1302
1302
  `接口验证覆盖矩阵覆盖不足:有效分母 ${validDenominator}(N=${N} − non-testable ${nonTestableCount}),covered+covered-service 分子 ${coveredCount}——缺覆盖端点:${list}。` +
1303
- `修复:补验证用例/冒烟步骤后把端点行改 covered 并填五形态锚点;端点行为由 service 层测试锁定的改 covered-service 并填测试锚点(\`.test.\` / file:line / 反引号);不适用端点改 non-testable 并写一句理由;确未覆盖的走「## 移交项(结构化)」承载并保持 partial/uncovered(FR-04)。`
1303
+ `修复:补验证用例/冒烟步骤后把端点行改 covered 并填五形态锚点;端点行为由 service 层测试锁定的改 covered-service 并填测试锚点(\`.test.\` / file:line / 反引号);不适用端点改 non-testable 并写一句理由(含运行验证移交场景:构建级验证已过、运行冒烟移交部署后执行——理由写构建回执+移交去向,该行不计入分母,勿硬凑 covered-service);确未覆盖的走「## 移交项(结构化)」承载并保持 partial/uncovered(FR-04)。`
1304
1304
  )
1305
1305
  }
1306
1306
  // covered-service 承接 advisory(D-001/R-01 第一版,只进 warnings 不进 errors):端点级与
@@ -15,12 +15,14 @@
15
15
 
16
16
  import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
17
17
  import { writeAtomicSync } from './fs-atomic.js'
18
- import { join } from 'path'
18
+ import { join, dirname } from 'path'
19
19
  import { createHash } from 'crypto'
20
20
  import jsYaml from 'js-yaml'
21
21
  import { VALID_VERDICTS, REVIEW_SCHEMA_VERSION } from './task-review.js'
22
22
  import { detectSpecDirTypo } from './spec-dir-typo.js'
23
23
  import { resolveRuntimeRoot, resolveSpecDir } from './run/shared.js'
24
+ import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
25
+ import { assembleProbeLinesForScope } from './review-probes.js'
24
26
 
25
27
  // 文档型 stage review 的合法 reviewType
26
28
  const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
@@ -738,6 +740,51 @@ export function validateStageReviewWithAutoRefresh(opts) {
738
740
  return { result: recheck, autoRefreshed: recheck.ok, refreshedPath: reviewPath, previousErrors: result.errors, refreshOrdinal, hashDrift: `${oldHashShort}→${newHashShort}` }
739
741
  }
740
742
 
743
+ /**
744
+ * 厚档评审员任务书(agent 通道,2026-10-12-thick-taskbook-cli):可原样粘贴给干净上下文子代理
745
+ * 的完整派发 prompt——检查单生产权收归 CLI(dogfood 实证 fire-equipment 会话:主代理手写 9 份
746
+ * 派发 prompt,6 份带 leading 措辞——「本变更的修复:…」预设结论、「构建已跑过你无需重跑」
747
+ * 外包验证;三个生产缺陷全漏检的根因是检查单由主代理手写)。与 thin 面 renderReviewerTaskbook
748
+ * (flow-review.js)同哲学;平台通道任务书在 review-dispatch.js buildReviewerTaskBook(不动)。
749
+ * 契约复用 renderReviewJsonContract 单源渲染——禁止第二套手写 schema 摘要(防契约漂移;
750
+ * 其通道优先序段面向派发方,评审员读到无害)。
751
+ * 放本模块而非 review-dispatch:契约渲染器与校验器同模块,且 review-dispatch 静态 import
752
+ * 本模块(反向成环)。诚实边界:任务书仍由主代理复制转发——CLI 单源降 leading 概率,
753
+ * 不防恶意篡改(与 thin 同界)。
754
+ * @param {{ stage: string, changeDir: string, changeName?: string, reviewRunId?: string,
755
+ * reviewPath: string, probeLines?: string[]|null }} opts
756
+ * @returns {string}
757
+ */
758
+ export function renderStageReviewerTaskbook({ stage, changeDir, changeName, reviewRunId, reviewPath, probeLines = null }) {
759
+ const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
760
+ const reviewType = STAGE_REVIEW_TYPE[stage] || 'design'
761
+ const items = (REVIEW_CHECKLISTS[stage] || []).map((t, i) => ` ${i + 1}. ${t}`)
762
+ const probeSection = Array.isArray(probeLines) && probeLines.length > 0
763
+ ? ['', '【项目评审探针(local.yaml review_probes 注入——CLI 单源生产,项目特有面必查)】',
764
+ ...probeLines.map((l) => ` - ${l}`),
765
+ ' (逐条作答:pass 给证据锚点 / gap 给缺件 / fail 给违诺实证——与检查单同口径,未答=未审)']
766
+ : []
767
+ return [
768
+ `⚖️ 独立评审任务书 — ${stage} 阶段(agent 通道派发,原样复制给子代理——检查单生产权在 CLI,勿自行改写增删)`,
769
+ `══════════════════════════════════════`,
770
+ `【角色】你是独立评审员(干净上下文,未参与实现)——拿承诺对文档与代码,不信自述,逐条给证据锚点。`,
771
+ `【评审对象】${join(changeDir || '', mainDoc)}(reviewType=${reviewType}${changeName ? `,change=${changeName}` : ''})`,
772
+ ``,
773
+ `【检查单(逐条 pass / gap / fail + 证据锚点)】`,
774
+ ...items,
775
+ ...probeSection,
776
+ ``,
777
+ `【请求预算】硬帽 12 次——超帽即止;材料不足以作答的项如实标 cannot_verify 并列缺件,勿猜。`,
778
+ `【纪律】只读——禁止修改任何文件、禁止 git 写操作。`,
779
+ `【产物】把 review.json 写到:${reviewPath}`,
780
+ ` docHash 可占位 "TODO"——写完由主会话跑 sillyspec register-stage-review --change ${changeName || '<名>'} --stage ${stage} --refresh-hash 代填(保留 verdict/checklist)。`,
781
+ ``,
782
+ renderReviewJsonContract({ stage, changeDir, reviewRunId, tier: 'independent' }),
783
+ ``,
784
+ `【完成后】告知主会话重跑:sillyspec run ${stage} --done --change ${changeName || '<名>'}(断点续,已完成子步幂等跳过)`,
785
+ ].join('\n')
786
+ }
787
+
741
788
  /**
742
789
  * 打印 stage review 校验结果
743
790
  * @param {{ ok: boolean, errors: string[], warnings: string[] }} result
@@ -789,6 +836,25 @@ export function printStageReviewResult(result, context = {}) {
789
836
  if (result.errors.some(e => String(e).includes('docHash')) && changeName) {
790
837
  console.error(` docHash 失配修复:sillyspec register-stage-review --change ${changeName} --stage ${stage} --refresh-hash(主文档改版多阶段联动用 --all)`)
791
838
  }
839
+ // 厚档评审任务书(2026-10-12-thick-taskbook-cli):缺 review.json = 待派发形态——打印可原样
840
+ // 粘贴的完整任务书(thin 面同款时机:评审需要而未产)。条件性:仅缺件形态打印,docHash 失配/
841
+ // schema 错等其他失败不打印(防重复撞门噪音)。探针装配 fail-soft:specBase 由 runtimeRoot
842
+ // 父目录推导(平台模式 runtimeRoot 在外时 miss → 零注入),打印失败不影响 gate 原错误输出。
843
+ if (reviewRunId && runtimeRoot && changeName
844
+ && result.errors.some(e => String(e).includes('缺少') && String(e).includes('review.json'))) {
845
+ try {
846
+ const _probeAsm = assembleProbeLinesForScope(dirname(runtimeRoot), 'thick')
847
+ for (const _w of _probeAsm.warnings) console.error(` ⚠️ review_probes:${_w}`)
848
+ console.error(`\n 📋 评审员任务书(原样复制给干净上下文子代理——检查单生产权在 CLI,勿手写改写):`)
849
+ console.error(renderStageReviewerTaskbook({
850
+ stage, reviewRunId,
851
+ changeDir: join(dirname(runtimeRoot), 'changes', changeName),
852
+ changeName,
853
+ reviewPath: join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`, 'review.json'),
854
+ probeLines: _probeAsm.lines.length > 0 ? _probeAsm.lines : null,
855
+ }).split('\n').map((l) => ` ${l}`).join('\n'))
856
+ } catch { /* 任务书打印 best-effort:失败不影响 gate 原有输出与退出码 */ }
857
+ }
792
858
  }
793
859
 
794
860
  if (result.warnings.length > 0) {
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
+ }