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/AGENTS.md +2 -0
- package/package.json +1 -1
- package/src/change-list.js +30 -7
- package/src/config-schema.js +20 -0
- package/src/doctor-diagnostics.js +61 -1
- package/src/flow-draft.js +68 -6
- package/src/flow-parity.js +215 -4
- package/src/flow-review.js +64 -8
- package/src/flow-state.js +21 -0
- package/src/flow.js +425 -71
- package/src/fr-index.js +10 -1
- package/src/index.js +141 -1
- package/src/local-register.js +20 -1
- package/src/patch-reanchor.js +131 -0
- package/src/progress.js +16 -0
- package/src/review-dispatch.js +18 -3
- package/src/review-probes.js +161 -0
- package/src/run/complete-handlers.js +48 -15
- package/src/run/gates.js +17 -2
- package/src/run/shared.js +14 -0
- package/src/sentinel-assertions.js +13 -7
- package/src/stage-contract.js +2 -2
- package/src/stage-review.js +70 -1
- package/src/stages/knowledge.js +22 -0
- package/src/stages/plan-postcheck.js +42 -7
- package/src/task-tick.js +43 -2
- package/src/verify-probes.js +1 -1
- package/src/worktree-apply.js +22 -0
- package/src/worktree-cross.js +158 -9
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 }
|
package/src/verify-probes.js
CHANGED
|
@@ -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) {
|
package/src/worktree-apply.js
CHANGED
|
@@ -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 路径走不通时
|
package/src/worktree-cross.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|