sillyspec 3.20.6 → 3.20.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.
package/src/run.js CHANGED
@@ -1341,7 +1341,18 @@ export async function runCommand(args, cwd, specDir = null) {
1341
1341
  inputText = flags[inputIdx + 1]
1342
1342
  }
1343
1343
 
1344
- // 解析 --change <name>(quick 阶段支持逗号分隔多值,作为「关联变更」)
1344
+ // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
1345
+ // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
1346
+ // null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
1347
+ let explicitLinked = null
1348
+ const linkedIdx = flags.indexOf('--linked-changes')
1349
+ if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
1350
+ const v = flags[linkedIdx + 1].trim()
1351
+ explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
1352
+ }
1353
+
1354
+ // 解析 --change <name>(quick 阶段向后兼容:逗号分隔作为「关联变更」;
1355
+ // 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
1345
1356
  let changeName = null
1346
1357
  let linkedChanges = []
1347
1358
  const changeIdx = flags.indexOf('--change')
@@ -1352,6 +1363,10 @@ export async function runCommand(args, cwd, specDir = null) {
1352
1363
  changeName = null
1353
1364
  }
1354
1365
  }
1366
+ // --linked-changes 优先于 --change(显式 > 隐式兼容)
1367
+ if (explicitLinked !== null) {
1368
+ linkedChanges = explicitLinked
1369
+ }
1355
1370
  // quick 的 progress 键固定走 'default':归属语义已由 linkedChanges 承担(→ quick-guard.json
1356
1371
  // → <linked-changes> 占位符注入 prompt)。progress 不能走 pm.read(cwd, null) 的自动检测——
1357
1372
  // 多活跃 change 项目里 quick 步骤进度会随活跃 change 增减在各 change/default 间漂移,
@@ -1376,7 +1391,7 @@ export async function runCommand(args, cwd, specDir = null) {
1376
1391
  '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
1377
1392
  '--wait', '--continue', '--non-interactive', '--interactive',
1378
1393
  '--reason', '--options', '--answer', '--confirm-mode',
1379
- '--output', '--input', '--change',
1394
+ '--output', '--input', '--change', '--linked-changes',
1380
1395
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
1381
1396
  '--files', '--allow-new', '--force-baseline', '--force-rescan',
1382
1397
  '--json', '--dir', '--help',
@@ -1400,13 +1415,28 @@ export async function runCommand(args, cwd, specDir = null) {
1400
1415
 
1401
1416
  const pm = new ProgressManager({ specDir: specRoot })
1402
1417
 
1403
- // quick 阶段:多变更交互式选择关联变更(--change 未指定时)
1404
- if (stageName === 'quick' && linkedChanges.length === 0) {
1405
- linkedChanges = await resolveQuickLinkedChanges({
1406
- pm, cwd, specDir: specRoot, quickFiles,
1407
- taskDescription: inputText || '',
1408
- nonInteractive: isNonInteractive,
1409
- })
1418
+ // quick 阶段:确定关联变更
1419
+ // 优先级:--linked-changes / --change 显式 > 已持久化 quick-guard.json(--done 复用)> 交互式 > 非交互 fallback
1420
+ // 关键:--done 收尾时复用首次 run 持久化的 linkedChanges,不在管道/CI 下重复弹交互 prompt。
1421
+ // explicitLinked === null 且未传 --change 时才进入此分支(显式 --linked-changes none 不应触发交互)。
1422
+ if (stageName === 'quick' && explicitLinked === null && linkedChanges.length === 0) {
1423
+ const guardFile = join(specRoot, '.runtime', 'quick-guard.json')
1424
+ let persistedLinked = null
1425
+ try {
1426
+ if (existsSync(guardFile)) {
1427
+ const g = JSON.parse(readFileSync(guardFile, 'utf8'))
1428
+ if (Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
1429
+ }
1430
+ } catch {}
1431
+ if (persistedLinked) {
1432
+ linkedChanges = persistedLinked
1433
+ } else {
1434
+ linkedChanges = await resolveQuickLinkedChanges({
1435
+ pm, cwd, specDir: specRoot, quickFiles,
1436
+ taskDescription: inputText || '',
1437
+ nonInteractive: isNonInteractive,
1438
+ })
1439
+ }
1410
1440
  }
1411
1441
 
1412
1442
  let progress = await pm.read(cwd, changeName)
@@ -1607,7 +1637,7 @@ async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDes
1607
1637
  if (activeChanges.length === 0) return []
1608
1638
  if (activeChanges.length === 1) return [activeChanges[0]]
1609
1639
 
1610
- if (nonInteractive || process.stdin.isTTY === false) {
1640
+ if (nonInteractive || !process.stdin.isTTY) {
1611
1641
  console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
1612
1642
  return []
1613
1643
  }
@@ -1702,7 +1732,10 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1702
1732
  console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
1703
1733
  } catch (e) {
1704
1734
  console.error(`❌ worktree 创建失败: ${e.message}`)
1705
- console.error(` 继续执行前请解决上述问题,或使用 --no-worktree 跳过。`)
1735
+ console.error(` 修复建议:`)
1736
+ console.error(` 1. 运行 sillyspec worktree doctor --fix 检查并修复 worktree 状态`)
1737
+ console.error(` 2. 或手动清理残留:git worktree prune && git branch -D sillyspec/${effectiveChange}`)
1738
+ console.error(` 3. 必要时删除残留目录 .sillyspec/.runtime/worktrees/${effectiveChange}/ 后重试`)
1706
1739
  process.exit(1)
1707
1740
  }
1708
1741
  }
@@ -2024,6 +2057,13 @@ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
2024
2057
  console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
2025
2058
  process.exit(1)
2026
2059
  }
2060
+ // 移动前硬校验:变更包必须含 plan.md,否则不该归档。
2061
+ // 在移动前阻断(而非移动后),目录尚未动,用户可直接修复后重试。
2062
+ if (!existsSync(join(srcDir, 'plan.md'))) {
2063
+ console.error(`❌ 归档失败:变更目录缺少 plan.md(${srcDir})`)
2064
+ console.error(` plan.md 是归档的必需产物。请先补全 plan 阶段产出再归档。`)
2065
+ process.exit(1)
2066
+ }
2027
2067
  if (existsSync(destDir)) {
2028
2068
  console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
2029
2069
  process.exit(1)
@@ -2037,7 +2077,32 @@ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
2037
2077
  }
2038
2078
 
2039
2079
  await pm.unregisterChange(cwd, archiveChangeName)
2080
+
2081
+ // 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
2082
+ // 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
2083
+ try {
2084
+ const { WorktreeManager } = await import('./worktree.js')
2085
+ const wm = new WorktreeManager({ cwd })
2086
+ const meta = wm.getMeta(archiveChangeName)
2087
+ if (meta) {
2088
+ const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
2089
+ if (check.hasChanges) {
2090
+ console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
2091
+ console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
2092
+ } else {
2093
+ const cleanResult = wm.cleanup(archiveChangeName)
2094
+ if (cleanResult.residual?.length > 0) {
2095
+ console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
2096
+ console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
2097
+ }
2098
+ }
2099
+ }
2100
+ } catch (e) {
2101
+ console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
2102
+ }
2103
+
2040
2104
  console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
2105
+ return destDir
2041
2106
  }
2042
2107
 
2043
2108
  // ── Wait Step ──
@@ -2249,9 +2314,8 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
2249
2314
  console.log('🔗 Worktree: n/a (no meta)');
2250
2315
  } else if (meta.mode === 'native-worktree') {
2251
2316
  console.log('🔗 Worktree: kept (外部隔离环境)');
2252
- } else if (meta.mode === 'in-place-fallback') {
2253
- console.log('🔗 Worktree: n/a (in-place 模式)');
2254
2317
  } else {
2318
+ // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
2255
2319
  const check = wm.hasUnappliedChanges(changeName);
2256
2320
  if (check.hasChanges) {
2257
2321
  console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
@@ -2259,6 +2323,10 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
2259
2323
  } else {
2260
2324
  const cleanResult = wm.cleanup(changeName);
2261
2325
  console.log(`🔗 Worktree: ${cleanResult.result}`);
2326
+ if (cleanResult.residual?.length > 0) {
2327
+ console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
2328
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
2329
+ }
2262
2330
  }
2263
2331
  }
2264
2332
  } catch (e) {
@@ -2375,6 +2443,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2375
2443
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
2376
2444
  const stageData = progress.stages[stageName]
2377
2445
  const scanProfile = stageData?.scanProfile || null
2446
+ let archivedDir = null // archive step「确认归档」实际归档目录(archiveChangeDirectory 返回)
2378
2447
 
2379
2448
  // ── WAIT MARKER 硬校验 ──
2380
2449
  // 如果 output 包含等待标记,拒绝 --done 推进
@@ -2462,23 +2531,22 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2462
2531
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
2463
2532
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2464
2533
  }
2465
- await archiveChangeDirectory(pm, cwd, progress, specBase)
2534
+ archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
2466
2535
  }
2467
2536
 
2468
2537
  // archive "确认归档" 步骤完成后,校验归档完整性
2469
2538
  if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
2470
- const projectName = progress.project || basename(cwd)
2471
- const contractResult = runValidators('archive', cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2472
- if (contractResult.errors.length > 0) {
2473
- console.error(`\n❌ 归档校验失败:`)
2474
- for (const err of contractResult.errors) {
2475
- console.error(` - ${err}`)
2476
- }
2477
- }
2478
- if (contractResult.warnings.length > 0) {
2479
- console.warn(`\n⚠️ 归档校验警告:`)
2480
- for (const w of contractResult.warnings) {
2481
- console.warn(` - ${w}`)
2539
+ // contracts.archive.validators 为空数组(两个 validator 生效窗口互斥,注册为阶段级会误报),
2540
+ // runValidators('archive') 是空跑。这里直接对实际归档目录做内容完整性校验。
2541
+ // plan.md 已在 archiveChangeDirectory 移动前硬校验阻断,此处只查推荐文档。
2542
+ if (archivedDir && existsSync(archivedDir)) {
2543
+ const recommendedDocs = ['design.md', 'module-impact.md']
2544
+ const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
2545
+ if (missingRecommended.length > 0) {
2546
+ console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
2547
+ for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
2548
+ } else {
2549
+ console.log(`\n✅ 归档校验通过:核心文档齐全`)
2482
2550
  }
2483
2551
  }
2484
2552
  }
@@ -2936,10 +3004,11 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2936
3004
  // summary 失败不影响主流程
2937
3005
  console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
2938
3006
  }
2939
- } else if (stageName === 'verify') {
2940
- console.log('\n👉 下一步:sillyspec run archive(验证通过,可以归档了)')
2941
3007
  } else if (stageName === 'archive') {
2942
3008
  console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
3009
+ } else if (stageName === 'verify') {
3010
+ // verify 的"验证通过"提示延后到下方 validator 通过后才打印,
3011
+ // 避免校验失败(FAIL / 缺 verify-result.md)时仍声称"验证通过可以归档"。
2943
3012
  } else {
2944
3013
  console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
2945
3014
  }
@@ -2954,6 +3023,13 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2954
3023
  console.error(` - ${err}`)
2955
3024
  }
2956
3025
  console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
3026
+ // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
3027
+ // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
3028
+ // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
3029
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3030
+ await pm._write(cwd, progress, changeName)
3031
+ triggerSync(cwd, changeName, platformOpts)
3032
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2957
3033
  }
2958
3034
  if (contractResult.warnings.length > 0) {
2959
3035
  console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
@@ -2962,6 +3038,11 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2962
3038
  }
2963
3039
  }
2964
3040
 
3041
+ // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断),此时才能声称"验证通过"
3042
+ if (stageName === 'verify') {
3043
+ console.log('\n✅ 验证通过,下一步:sillyspec run archive')
3044
+ }
3045
+
2965
3046
  // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
2966
3047
  if (stageName === 'plan') {
2967
3048
  const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
@@ -3061,23 +3142,21 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3061
3142
  console.log('🔗 Worktree: n/a (no meta)');
3062
3143
  } else if (meta.mode === 'native-worktree') {
3063
3144
  console.log('🔗 Worktree: kept (外部隔离环境)');
3064
- } else if (meta.mode === 'in-place-fallback') {
3065
- console.log('🔗 Worktree: n/a (in-place 模式)');
3066
3145
  } else {
3146
+ // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
3067
3147
  const check = wm.hasUnappliedChanges(changeName);
3068
3148
  if (check.hasChanges) {
3069
3149
  console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
3070
3150
  console.log(` 下一步: sillyspec worktree apply ${changeName}`);
3071
3151
  } else {
3072
3152
  const cleanResult = wm.cleanup(changeName);
3073
- if (cleanResult.result === 'skipped' || cleanResult.result === 'kept') {
3074
- console.log(`🔗 Worktree: ${cleanResult.result}`);
3075
- } else {
3076
- console.log(`🔗 Worktree: ${cleanResult.result}`);
3077
- if (cleanResult.details?.length > 0) {
3078
- for (const d of cleanResult.details) {
3079
- if (d.startsWith('⚠️')) console.log(` ${d}`);
3080
- }
3153
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
3154
+ if (cleanResult.residual?.length > 0) {
3155
+ console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
3156
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
3157
+ } else if (cleanResult.details?.length > 0) {
3158
+ for (const d of cleanResult.details) {
3159
+ if (d.startsWith('⚠️')) console.log(` ${d}`);
3081
3160
  }
3082
3161
  }
3083
3162
  }
@@ -3333,6 +3412,28 @@ function showStatus(progress, stageName) {
3333
3412
  }
3334
3413
 
3335
3414
  async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
3415
+ // execute 阶段 reset 时清理自建 worktree,否则下次 run execute 会因 existingMeta 存在
3416
+ // 直接复用带脏状态的旧 worktree(启动逻辑:meta 存在即复用,不查健康状态)
3417
+ if (stageName === 'execute' && changeName) {
3418
+ try {
3419
+ const { WorktreeManager } = await import('./worktree.js')
3420
+ const wm = new WorktreeManager({ cwd })
3421
+ const meta = wm.getMeta(changeName)
3422
+ if (meta) {
3423
+ const cleanResult = wm.cleanup(changeName)
3424
+ if (cleanResult.residual?.length > 0) {
3425
+ console.warn(`⚠️ reset 清理 worktree 残留: ${cleanResult.residual.join('; ')}`)
3426
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`)
3427
+ } else if (cleanResult.result === 'kept') {
3428
+ console.log(`🔗 旧 worktree 保留 (${cleanResult.mode}: 外部隔离环境)`)
3429
+ } else if (cleanResult.result !== 'skipped') {
3430
+ console.log(`🧹 已清理旧 worktree (${cleanResult.result}, mode: ${cleanResult.mode}),下次 execute 将重建干净环境`)
3431
+ }
3432
+ }
3433
+ } catch (e) {
3434
+ console.warn(`⚠️ reset 清理 worktree 失败(不阻断 reset): ${e.message}`)
3435
+ }
3436
+ }
3336
3437
  const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
3337
3438
  progress.stages[stageName] = {
3338
3439
  status: 'in-progress',
@@ -391,10 +391,11 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
391
391
  return { ok: false, errors, warnings }
392
392
  }
393
393
 
394
- // verify 阶段应该产出 verify-result.md(或类似报告)
394
+ // verify 阶段必须产出 verify-result.md —— 不存在则不能完成。
395
+ // 历史教训:AI 可能跳过报告直接 --done,导致"假完成"。此处提级为 error 强制阻断。
395
396
  const verifyResult = join(changeDir, 'verify-result.md')
396
397
  if (!existsSync(verifyResult)) {
397
- warnings.push('verify-result.md 不存在(verify 阶段建议产出验证报告)')
398
+ errors.push(`verify-result.md 不存在 — verify 阶段必须产出验证报告才能完成(${verifyResult})`)
398
399
  }
399
400
 
400
401
  // 确保核心规范文件仍然存在
@@ -415,6 +416,17 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
415
416
  const decisionIds = extractCurrentDecisionIds(decisions)
416
417
  warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
417
418
 
419
+ // ── FAIL 结论门控(适用于所有变更,不限风险等级)──
420
+ // verify-result.md 结论为 FAIL 时,verify 阶段不能 completed。
421
+ // 历史教训:CLI 曾不校验结论,AI 写 FAIL 后 verify 仍被标记完成并提示"验证通过可以归档"。
422
+ const conclusionLine = verify.match(/^##\s*结论\s*\n\s*(PASS(?:\s+WITH\s+NOTES)?|FAIL)/im)
423
+ const conclusionStr = conclusionLine ? conclusionLine[1].toUpperCase().replace(/\s+/g, ' ') : ''
424
+ if (conclusionStr === 'FAIL') {
425
+ errors.push('verify-result.md 结论为 FAIL — 验证未通过,不能标记 verify 完成;请修复后重新运行验证')
426
+ } else if (!conclusionStr) {
427
+ warnings.push('verify-result.md 未识别到「## 结论」章节(应为 PASS / PASS WITH NOTES / FAIL)')
428
+ }
429
+
418
430
  // ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
419
431
  const changeRiskProfile = detectChangeRisk({
420
432
  designContent: readIfExists(join(changeDir, 'design.md')),
package/src/sync.js CHANGED
@@ -12,6 +12,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from '
12
12
  import { join } from 'path';
13
13
  import { resolvePlatformSpecDir } from './progress.js';
14
14
 
15
+ // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
16
+ function safePlatformSpecDir(cwd) {
17
+ try {
18
+ return resolvePlatformSpecDir(cwd);
19
+ } catch (e) {
20
+ console.warn(`[sync] 平台指针不可用,跳过平台同步:${String(e.message).split('\n')[0]}`);
21
+ return undefined;
22
+ }
23
+ }
24
+
15
25
  const LOCAL_YAML = '.sillyspec/local.yaml';
16
26
  const CHANGES_DIR = '.sillyspec/changes';
17
27
  const REQUEST_TIMEOUT_MS = 10_000;
@@ -201,7 +211,7 @@ export class SyncManager {
201
211
  let progressData;
202
212
  try {
203
213
  const { ProgressManager } = await import('./progress.js');
204
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
214
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
205
215
  progressData = await pm.read(this.cwd, changeName);
206
216
  } catch (err) {
207
217
  console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
@@ -226,7 +236,7 @@ export class SyncManager {
226
236
  // 更新 platform_last_sync
227
237
  try {
228
238
  const { ProgressManager } = await import('./progress.js');
229
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
239
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
230
240
  await pm._updatePlatformLastSync(this.cwd, changeName);
231
241
  } catch (err) {
232
242
  console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
@@ -327,7 +337,7 @@ export class SyncManager {
327
337
  // 更新本地 approvals 表
328
338
  try {
329
339
  const { ProgressManager } = await import('./progress.js');
330
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
340
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
331
341
  await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
332
342
  } catch (err) {
333
343
  console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
package/src/worktree.js CHANGED
@@ -73,6 +73,17 @@ function parseJSON(raw) {
73
73
  try { return JSON.parse(raw); } catch { return null; }
74
74
  }
75
75
 
76
+ /**
77
+ * 跨平台同步等待(cleanup 重试退避用)
78
+ * 不依赖外部 sleep 命令——Windows cmd.exe 无 sleep;当 Git for Windows 未把 usr/bin 加入
79
+ * PATH 时,execSync('sleep 0.5') 会抛错并从 catch 块冒泡,直接中断整个 cleanup()。
80
+ * 改用 busy-wait 规避,保证 retry 之间真有间隔且不依赖环境。
81
+ */
82
+ function sleepMs(ms) {
83
+ const end = Date.now() + ms;
84
+ while (Date.now() < end) { /* spin */ }
85
+ }
86
+
76
87
  function computeBaselineHash(cwd) {
77
88
  // 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
78
89
  const exclude = '-- . ":(exclude).sillyspec/"';
@@ -198,7 +209,7 @@ export class WorktreeManager {
198
209
  if (isolation.inSubmodule) {
199
210
  throw new Error(
200
211
  '当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
201
- '\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
212
+ '\n请在主仓库根目录执行 execute。'
202
213
  );
203
214
  }
204
215
  if (isolation.inWorktree) {
@@ -237,6 +248,10 @@ export class WorktreeManager {
237
248
  if (!this.getMeta(name)) {
238
249
  console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json),自动清理...`);
239
250
  try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
251
+ // 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
252
+ // 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
253
+ try { gitQuiet(this.cwd, 'worktree prune'); } catch {}
254
+ try { gitQuiet(this.cwd, `branch -D ${branch}`); } catch {}
240
255
  } else {
241
256
  throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
242
257
  }
@@ -269,7 +284,7 @@ export class WorktreeManager {
269
284
  } catch (e) {
270
285
  const check = isGitWorktreeSupported(this.cwd);
271
286
  if (!check.supported) {
272
- throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
287
+ throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 升级 git 到 >= 2.15;或运行 \`sillyspec worktree doctor --fix\` 检查 worktree 状态。`);
273
288
  }
274
289
  // sandbox/permission fallback: 降级为 in-place + baseline protection
275
290
  console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
@@ -546,7 +561,10 @@ export class WorktreeManager {
546
561
  * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
547
562
  * @param {string} changeName
548
563
  * @param {{ force?: boolean, maxRetries?: number }} opts
549
- * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
564
+ * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept'|'partial', mode: string|null, details: string[], residual: string[] }}
565
+ * result 取值:cleaned=git remove 成功;force-cleaned=git remove 失败但 fallback 清理完成;
566
+ * partial=有残留(目录/meta/git 注册未清);skipped=无需清理;kept=native-worktree 保留。
567
+ * residual:未清干净的路径/引用列表(空数组表示干净)。
550
568
  */
551
569
  cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
552
570
  const name = validateChangeName(changeName);
@@ -561,22 +579,20 @@ export class WorktreeManager {
561
579
  }
562
580
 
563
581
  const mode = meta?.mode || 'worktree';
582
+ // in-place 模式:worktreePath === 主工作区,绝对禁止删除目录本身,但 meta 目录仍应清理
583
+ // (否则永久残留)。native-worktree:外部隔离环境,整体跳过。
584
+ const isInPlace = mode === 'in-place-fallback';
564
585
 
565
- // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
566
- if (!force) {
567
- if (mode === 'native-worktree') {
568
- return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'] };
569
- }
570
- if (mode === 'in-place-fallback') {
571
- return { result: 'skipped', mode, details: ['in-place-fallback: 无隔离目录,跳过清理'] };
572
- }
586
+ // 安全检查:native-worktree 是外部隔离环境,非 force 不碰
587
+ if (!force && mode === 'native-worktree') {
588
+ return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'], residual: [] };
573
589
  }
574
590
 
575
591
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
576
592
 
577
- // 1. git worktree remove(带 retry
593
+ // 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
578
594
  let gitRemoveOk = false;
579
- if (existsSync(worktreePath)) {
595
+ if (!isInPlace && existsSync(worktreePath)) {
580
596
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
581
597
  try {
582
598
  git(this.cwd, `worktree remove ${worktreePath} --force`);
@@ -586,15 +602,15 @@ export class WorktreeManager {
586
602
  } catch (e) {
587
603
  details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
588
604
  if (attempt < maxRetries) {
589
- // 短暂等待后重试
590
- execSync('sleep 0.5', { stdio: 'pipe' });
605
+ // 短暂等待后重试(跨平台 busy-wait,见 sleepMs)
606
+ sleepMs(500);
591
607
  }
592
608
  }
593
609
  }
594
610
  }
595
611
 
596
- // 2. fallback: 确保 worktree 目录已删除
597
- if (existsSync(worktreePath)) {
612
+ // 2. fallback: 确保 worktree 目录已删除(in-place 跳过——worktreePath 是主工作区)
613
+ if (!isInPlace && existsSync(worktreePath)) {
598
614
  try {
599
615
  rmSync(worktreePath, { recursive: true, force: true });
600
616
  details.push('worktree directory force-removed (fallback)');
@@ -618,7 +634,7 @@ export class WorktreeManager {
618
634
  // 分支可能已被删除,幂等跳过
619
635
  }
620
636
 
621
- // 5. 清除 meta 目录
637
+ // 5. 清除 meta 目录(in-place 模式也执行——这是 in-place meta 残留的修复点)
622
638
  if (existsSync(metaDir)) {
623
639
  try {
624
640
  rmSync(metaDir, { recursive: true, force: true });
@@ -628,18 +644,27 @@ export class WorktreeManager {
628
644
  }
629
645
  }
630
646
 
631
- // 6. 最终验证:确认三重清理完成
647
+ // 6. 最终验证:确认清理完成(in-place 模式 worktreePath=主工作区,不纳入残留检查)
632
648
  const residual = [];
633
- if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
649
+ if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
634
650
  if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
635
- if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
651
+ if (!isInPlace && gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
636
652
  residual.push('git worktree list still references this worktree');
637
653
  }
638
654
  if (residual.length > 0) {
639
655
  details.push(`⚠️ 残留: ${residual.join('; ')}`);
640
656
  }
641
657
 
642
- return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode, details };
658
+ // result:有残留→partial;in-place(无隔离目录可删,只清了 meta)→cleaned;否则按 git remove 成败
659
+ let result;
660
+ if (residual.length > 0) {
661
+ result = 'partial';
662
+ } else if (isInPlace) {
663
+ result = 'cleaned';
664
+ } else {
665
+ result = gitRemoveOk ? 'cleaned' : 'force-cleaned';
666
+ }
667
+ return { result, mode, details, residual };
643
668
  }
644
669
 
645
670
  /**
@@ -680,8 +705,10 @@ export class WorktreeManager {
680
705
  for (const entry of entries) {
681
706
  const lines = entry.split('\n');
682
707
  const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
708
+ // git 2.20+ porcelain 在目录缺失时输出 `missing` 行;作为 existsSync 的权威交叉验证
709
+ const missing = lines.some(l => l === 'missing');
683
710
  if (wtPath && wtPath !== this.cwd) { // 排除主工作区
684
- gitWorktreeList.push({ path: wtPath, raw: entry });
711
+ gitWorktreeList.push({ path: wtPath, missing, raw: entry });
685
712
  }
686
713
  }
687
714
  } catch {
@@ -693,12 +720,25 @@ export class WorktreeManager {
693
720
  const metaNames = new Set(metaEntries.map(m => m.changeName));
694
721
 
695
722
  // 3. 检查 git worktree list 中的孤儿条目
723
+ // ⚠️ 只在 git 自己标记 missing 时自动 prune。目录不可见但 git 未标记 missing 的
724
+ // 情况(平台模式/容器路径不一致、符号链接、Windows 路径格式、旧 git 无 missing 标记)
725
+ // 只告警不自动 prune —— existsSync 在这些场景会误判,自动 prune 会杀死实际在用的
726
+ // worktree(sillyspec-worktree-platform-mode-bug)。git 内部状态是 worktree 生命周期的
727
+ // 权威来源,用它做 prune 前提可杜绝误杀。
696
728
  for (const wt of gitWorktreeList) {
697
729
  if (!existsSync(wt.path)) {
698
730
  const name = this._pathToChangeName(wt.path);
699
- issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 引用存在但目录不存在: ${wt.path}`, fixable: true });
700
- if (fix) {
701
- try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
731
+ if (wt.missing === true) {
732
+ // git 明确标记目录缺失(git 2.20+)→ 真 orphan,prune 安全
733
+ issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 标记 missing: ${wt.path}`, fixable: true });
734
+ if (fix) {
735
+ try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
736
+ }
737
+ } else {
738
+ // 目录不可见但 git 未标记 missing → 可能 existsSync 误判,保守不自动 prune
739
+ issues.push({ type: 'orphan-git-entry', name: name || wt.path,
740
+ detail: `git worktree 引用 ${wt.path} 目录不可见但 git 未标记 missing——可能路径/权限导致误判,未自动 prune(避免误杀在用的 worktree)。确认废弃后手动: git worktree prune`,
741
+ fixable: false });
702
742
  }
703
743
  }
704
744
  }
@@ -761,9 +801,10 @@ export class WorktreeManager {
761
801
  if (meta && meta.createdAt) {
762
802
  const ageMs = Date.now() - new Date(meta.createdAt).getTime();
763
803
  const ageHours = ageMs / (1000 * 60 * 60);
804
+ const staleFixable = meta.mode !== 'native-worktree';
764
805
  if (ageHours > staleHours) {
765
- issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)`, fixable: true });
766
- if (fix && meta.mode !== 'native-worktree') {
806
+ issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)${staleFixable ? '' : '(native-worktree 外部环境,不可自动清理)'}`, fixable: staleFixable });
807
+ if (fix && staleFixable) {
767
808
  try {
768
809
  const result = this.cleanup(name);
769
810
  if (result.result === 'cleaned' || result.result === 'force-cleaned') {