sillyspec 3.18.3 → 3.18.4

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/init.js CHANGED
@@ -108,12 +108,39 @@ async function doInstall(projectDir, tools, subprojects = [], specDir = null) {
108
108
  // projectDir: 源码项目根目录(用于工具检测、指令注入、.gitignore)
109
109
  const spec = specDir || join(projectDir, '.sillyspec');
110
110
 
111
- // 外部 specDir 时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)
111
+ // 外部 specDir 时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
112
+ // ⚠️ 必须保护真实资产:若本地 .sillyspec 含 changes/(非空)、projects/(非空)
113
+ // 或 sillyspec.db(进度库),说明该项目本身就用 SillySpec 管理,整体删除会丢资产。
114
+ // 此时只清运行时残留,拒绝整删;确无资产时才视为旧残留清理。
112
115
  const legacyDir = join(projectDir, '.sillyspec');
113
116
  if (specDir && existsSync(legacyDir)) {
114
- try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
115
- if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
116
- else console.error('⚠️ 清理残留 .sillyspec/ 失败');
117
+ let hasChanges = false;
118
+ try {
119
+ const changesDir = join(legacyDir, 'changes');
120
+ if (existsSync(changesDir)) hasChanges = readdirSync(changesDir).length > 0;
121
+ } catch {}
122
+ let hasProjects = false;
123
+ try {
124
+ const projectsDir = join(legacyDir, 'projects');
125
+ if (existsSync(projectsDir)) hasProjects = readdirSync(projectsDir).length > 0;
126
+ } catch {}
127
+ const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
128
+
129
+ if (hasChanges || hasProjects || hasDb) {
130
+ // 真实资产存在:拒绝整体删除,仅清理运行时残留
131
+ console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产(changes/、projects/ 或 sillyspec.db)。');
132
+ console.error(' 该项目似乎本身就用 SillySpec 管理。如需改用外部 spec 目录,请先手动迁移/备份。');
133
+ console.error(' 本次仅清理运行时残留(.runtime/、local.yaml、codebase/)。');
134
+ for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
135
+ const p = join(legacyDir, residue);
136
+ if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
137
+ }
138
+ } else {
139
+ // 无真实资产:确属旧版本残留,安全删除
140
+ try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
141
+ if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
142
+ else console.error('⚠️ 清理残留 .sillyspec/ 失败');
143
+ }
117
144
  }
118
145
 
119
146
  // 创建基础目录
package/src/run.js CHANGED
@@ -1148,11 +1148,32 @@ export async function runCommand(args, cwd, specDir = null) {
1148
1148
  // runCommand 后续所有 .sillyspec/ 操作必须用 specBase
1149
1149
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1150
1150
 
1151
- // 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)
1151
+ // 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
1152
+ // ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
1152
1153
  if (platformOpts.specRoot) {
1153
1154
  const legacyDir = join(cwd, '.sillyspec')
1154
1155
  if (existsSync(legacyDir)) {
1155
- try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
1156
+ let hasChanges = false;
1157
+ try {
1158
+ const cd = join(legacyDir, 'changes');
1159
+ if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
1160
+ } catch {}
1161
+ let hasProjects = false;
1162
+ try {
1163
+ const pd = join(legacyDir, 'projects');
1164
+ if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
1165
+ } catch {}
1166
+ const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
1167
+
1168
+ if (hasChanges || hasProjects || hasDb) {
1169
+ console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产。仅清理运行时残留。');
1170
+ for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
1171
+ const p = join(legacyDir, residue);
1172
+ if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
1173
+ }
1174
+ } else {
1175
+ try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
1176
+ }
1156
1177
  }
1157
1178
  }
1158
1179
 
@@ -1943,12 +1964,38 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1943
1964
  stageData.completedAt = now
1944
1965
  await pm._write(cwd, progress, changeName)
1945
1966
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
1967
+ // ── execute 阶段完成时条件性清理 worktree ──
1968
+ if (stageName === 'execute' && changeName) {
1969
+ try {
1970
+ const { WorktreeManager } = await import('./worktree.js');
1971
+ const wm = new WorktreeManager({ cwd });
1972
+ const meta = wm.getMeta(changeName);
1973
+ if (!meta) {
1974
+ console.log('🔗 Worktree: n/a (no meta)');
1975
+ } else if (meta.mode === 'native-worktree') {
1976
+ console.log('🔗 Worktree: kept (外部隔离环境)');
1977
+ } else if (meta.mode === 'in-place-fallback') {
1978
+ console.log('🔗 Worktree: n/a (in-place 模式)');
1979
+ } else {
1980
+ const check = wm.hasUnappliedChanges(changeName);
1981
+ if (check.hasChanges) {
1982
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
1983
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
1984
+ } else {
1985
+ const cleanResult = wm.cleanup(changeName);
1986
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
1987
+ }
1988
+ }
1989
+ } catch (e) {
1990
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
1991
+ }
1992
+ }
1946
1993
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
1947
1994
  }
1948
1995
 
1949
1996
  // 输出下一步
1950
- if (nextPendingIdx !== -1 && defSteps) {
1951
- console.log('')
1997
+ if (nextPendingIdx !== -1 && defSteps) {
1998
+ console.log('')
1952
1999
  await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
1953
2000
  } else if (nextWaitingIdx !== -1 && defSteps) {
1954
2001
  // 下一个步骤也在等待状态
@@ -2517,6 +2564,42 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2517
2564
  console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
2518
2565
  }
2519
2566
 
2567
+ // ── execute 阶段完成时条件性清理 worktree(不依赖 AI agent 的完成确认步骤)──
2568
+ if (stageName === 'execute' && changeName) {
2569
+ try {
2570
+ const { WorktreeManager } = await import('./worktree.js');
2571
+ const wm = new WorktreeManager({ cwd });
2572
+ const meta = wm.getMeta(changeName);
2573
+ if (!meta) {
2574
+ console.log('🔗 Worktree: n/a (no meta)');
2575
+ } else if (meta.mode === 'native-worktree') {
2576
+ console.log('🔗 Worktree: kept (外部隔离环境)');
2577
+ } else if (meta.mode === 'in-place-fallback') {
2578
+ console.log('🔗 Worktree: n/a (in-place 模式)');
2579
+ } else {
2580
+ const check = wm.hasUnappliedChanges(changeName);
2581
+ if (check.hasChanges) {
2582
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
2583
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
2584
+ } else {
2585
+ const cleanResult = wm.cleanup(changeName);
2586
+ if (cleanResult.result === 'skipped' || cleanResult.result === 'kept') {
2587
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
2588
+ } else {
2589
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
2590
+ if (cleanResult.details?.length > 0) {
2591
+ for (const d of cleanResult.details) {
2592
+ if (d.startsWith('⚠️')) console.log(` ${d}`);
2593
+ }
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ } catch (e) {
2599
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
2600
+ }
2601
+ }
2602
+
2520
2603
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2521
2604
  }
2522
2605
 
@@ -406,6 +406,48 @@ function parseWavesFromPlan(planContent) {
406
406
  * 为 Wave 生成 prompt(强制子代理执行)
407
407
  */
408
408
  function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
409
+ // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
410
+ let contractInjection = ''
411
+ if (changeDir) {
412
+ try {
413
+ const { buildContractMatrix, buildConsumerInjection } = require('../contract-matrix.js')
414
+ const planFile = path.join(changeDir, 'plan.md')
415
+ if (existsSync(planFile)) {
416
+ const planContent = readFileSync(planFile, 'utf8')
417
+ const contracts = buildContractMatrix(planContent, changeDir)
418
+ if (contracts.length > 0) {
419
+ // 收集本 wave 所有 task 的注入内容
420
+ const waveTasks = wave.tasks.map((t, ti) => {
421
+ const num = String(t.index || (ti + 1)).padStart(2, '0')
422
+ return `task-${num}`
423
+ })
424
+ const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
425
+ if (relevantContracts.length > 0) {
426
+ contractInjection = `
427
+ ### API Contract Matrix
428
+ 本 Wave 存在前端/后端跨 task 契约:
429
+ ${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
430
+ `
431
+ // 为每个 consumer task 生成详细注入
432
+ for (const taskName of waveTasks) {
433
+ const injection = buildConsumerInjection(changeDir, join(changeDir, '..', '..'), taskName, contracts)
434
+ if (injection) {
435
+ contractInjection += `
436
+ ### 子代理 ${taskName} 的契约注入
437
+ 为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
438
+
439
+ <contract-injection>
440
+ ${injection}
441
+ </contract-injection>
442
+ `
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
448
+ } catch {}
449
+ }
450
+
409
451
  // 构建任务摘要(不再内联完整蓝图,减少上下文污染)
410
452
  const taskSummary = wave.tasks.map((t, ti) => {
411
453
  const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
@@ -476,7 +518,7 @@ ${taskSummary}
476
518
  - 🔥 热上下文:design.md 编码铁律 + 当前 Wave 任务(必须加载)
477
519
  - 🌡️ 温上下文:CONVENTIONS.md + ARCHITECTURE.md(需要时加载)
478
520
  - ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
479
-
521
+ ${contractInjection}
480
522
  ### 本 Wave 任务
481
523
  ${taskList}
482
524
 
@@ -493,7 +535,11 @@ ${taskList}
493
535
  5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
494
536
 
495
537
  ### 完成后
496
- 运行 sillyspec run execute --done --input "用户原始反馈" --output "Wave ${waveIndex} 结果摘要"`
538
+ 1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
539
+ - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
540
+ - 将端点清单写入 .sillyspec/.runtime/contract-artifacts/<task-name>/endpoints.json
541
+ - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
542
+ 2. 运行 sillyspec run execute --done --input "用户原始反馈" --output "Wave ${waveIndex} 结果摘要"`
497
543
  }
498
544
 
499
545
 
@@ -134,6 +134,31 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
134
134
  5. 任意 D-xxx@vN 无下游覆盖时标记为 ⚠️ 决策未闭环
135
135
  6. 任意 P0/P1 unresolved/blocking 决策标记为 FAIL blocker
136
136
 
137
+ **探针 5:API Contract Parity Check(跨前后端契约对账)**
138
+ 此探针仅在以下条件满足时执行:
139
+ - 存在 \.sillyspec/.runtime/contract-artifacts/ 目录(说明 execute 阶段生成了 endpoint artifact)
140
+ - 或者项目同时有 backend/ 和 frontend/ 目录
141
+
142
+ 执行步骤:
143
+ 1. 收集所有 provider endpoint artifacts:
144
+ - 读取 .sillyspec/.runtime/contract-artifacts/*/endpoints.json
145
+ - 汇总为 backend 端点清单
146
+ 2. 扫描前端 API 调用:
147
+ - 在 frontend/ 目录中搜索 apiFetch/request/axios/fetch 调用
148
+ - 提取所有 API 路径(归一化动态参数为 {param})
149
+ 3. Diff 对账:
150
+ - 前端调用路径在 backend 端点清单中找不到 → **❌ Missing backend endpoint**(FAIL blocker)
151
+ - backend 端点在前端无调用 → ⚠️ Unused backend endpoint(warning,不阻断)
152
+ 4. 输出对账结果表格:
153
+ \
154
+ \
155
+ | 状态 | 前端调用 | 后端端点 | 文件 |
156
+ |---|---|---|---|
157
+ | ❌ missing | GET /api/ppm/project-plan/{param}/plan-nodes | — | frontend/src/lib/ppm/plan.ts |
158
+ \
159
+ \
160
+ 如果发现 Missing backend endpoint,必须在验证报告中标记为 ❌ contract gap。
161
+
137
162
  ### 探针结果处理
138
163
  - 将四个探针的结果汇总为「探针报告」
139
164
  - 如果探针发现问题(未实现标记、关键词缺失、测试缺失、决策未闭环),在最终验证报告中明确标注
package/src/worktree.js CHANGED
@@ -239,6 +239,8 @@ export class WorktreeManager {
239
239
  }
240
240
 
241
241
  // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
242
+ let syncStatus = 'ok';
243
+ let syncError = null;
242
244
  try {
243
245
  // 先 fetch origin
244
246
  gitQuiet(worktreePath, 'fetch origin');
@@ -262,8 +264,10 @@ export class WorktreeManager {
262
264
  }
263
265
  }
264
266
  }
265
- } catch {
266
- // fetch/merge 失败不影响 worktree 创建,只记录警告
267
+ } catch (e) {
268
+ syncStatus = 'failed';
269
+ syncError = e.message || String(e);
270
+ console.warn(`⚠️ worktree 远程同步失败:${syncError}`);
267
271
  }
268
272
 
269
273
  // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
@@ -290,6 +294,8 @@ export class WorktreeManager {
290
294
  baselineFiles,
291
295
  baselineCommit,
292
296
  baselineHash,
297
+ syncStatus,
298
+ ...(syncError ? { syncError } : {}),
293
299
  };
294
300
 
295
301
  const metaPath = join(worktreePath, META_FILE);
@@ -412,18 +418,22 @@ export class WorktreeManager {
412
418
 
413
419
  /**
414
420
  * 清理 worktree(仅限 SillySpec 创建的临时 worktree)
421
+ * 幂等:重复调用不报错。
422
+ * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
415
423
  * @param {string} changeName
416
- * @param {{ force?: boolean }} opts - force: 跳过 mode 安检(仅用于 worktree 目录本身)
417
- * @throws {Error} worktree 不存在、不允许删除
418
- * @returns {{ result: 'cleaned'|'skipped'|'kept', mode: string }}
424
+ * @param {{ force?: boolean, maxRetries?: number }} opts
425
+ * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
419
426
  */
420
- cleanup(changeName, { force = false } = {}) {
427
+ cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
421
428
  const name = validateChangeName(changeName);
422
429
  const meta = this.getMeta(name);
423
430
  const worktreePath = this.getWorktreePath(name);
431
+ const metaDir = join(this.worktreeBase, name);
432
+ const details = [];
424
433
 
425
- if (!meta && !existsSync(worktreePath)) {
426
- return { result: 'skipped', mode: null };
434
+ // 幂等:什么都不存在 直接跳过
435
+ if (!meta && !existsSync(worktreePath) && !existsSync(metaDir)) {
436
+ return { result: 'skipped', mode: null, details };
427
437
  }
428
438
 
429
439
  const mode = meta?.mode || 'worktree';
@@ -431,50 +441,269 @@ export class WorktreeManager {
431
441
  // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
432
442
  if (!force) {
433
443
  if (mode === 'native-worktree') {
434
- throw new Error(
435
- `当前 worktree 是外部/原生隔离环境(mode: native-worktree),SillySpec 不允许删除。\n` +
436
- `此 worktree 不是由 SillySpec 创建的,请手动管理。\n` +
437
- `如需强制清理,使用 --force 标志。`
438
- );
444
+ return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'] };
439
445
  }
440
446
  if (mode === 'in-place-fallback') {
441
- return { result: 'skipped', mode };
447
+ return { result: 'skipped', mode, details: ['in-place-fallback: 无隔离目录,跳过清理'] };
442
448
  }
443
449
  }
444
450
 
445
- // 1. 尝试 git worktree remove
446
- let gitRemoveOk = true;
447
- try {
448
- git(this.cwd, `worktree remove ${worktreePath} --force`);
449
- } catch (e) {
450
- gitRemoveOk = false;
451
- }
452
451
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
453
452
 
454
- // 2. 确保目录已删除
455
- try {
456
- if (existsSync(worktreePath)) {
453
+ // 1. git worktree remove(带 retry)
454
+ let gitRemoveOk = false;
455
+ if (existsSync(worktreePath)) {
456
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
457
+ try {
458
+ git(this.cwd, `worktree remove ${worktreePath} --force`);
459
+ gitRemoveOk = true;
460
+ details.push(`git worktree remove succeeded (attempt ${attempt})`);
461
+ break;
462
+ } catch (e) {
463
+ details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
464
+ if (attempt < maxRetries) {
465
+ // 短暂等待后重试
466
+ execSync('sleep 0.5', { stdio: 'pipe' });
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ // 2. fallback: 确保 worktree 目录已删除
473
+ if (existsSync(worktreePath)) {
474
+ try {
457
475
  rmSync(worktreePath, { recursive: true, force: true });
476
+ details.push('worktree directory force-removed (fallback)');
477
+ } catch (e) {
478
+ details.push(`worktree directory force-remove failed: ${e.message}`);
458
479
  }
459
- } catch (e) {
460
- throw new Error(`清理 worktree 目录失败: ${e.message}`);
461
480
  }
462
481
 
463
- // 3. 删除分支(忽略分支不存在的错误)
464
- gitQuiet(this.cwd, `branch -D ${branch}`);
482
+ // 3. git worktree prune(清理 git 内部注册信息)
483
+ try {
484
+ gitQuiet(this.cwd, 'worktree prune');
485
+ } catch {
486
+ // prune 失败不阻断
487
+ }
465
488
 
466
- // 4. 确保目录已删除
467
- if (existsSync(worktreePath)) {
468
- rmSync(worktreePath, { recursive: true, force: true });
489
+ // 4. 删除分支(忽略分支不存在的错误)
490
+ try {
491
+ gitQuiet(this.cwd, `branch -D ${branch}`);
492
+ details.push('branch deleted');
493
+ } catch {
494
+ // 分支可能已被删除,幂等跳过
469
495
  }
470
496
 
471
- // 5. 清除 meta 目录(如果 worktree 目录在 worktreeBase 下)
472
- const metaDir = join(this.worktreeBase, name);
497
+ // 5. 清除 meta 目录
473
498
  if (existsSync(metaDir)) {
474
- rmSync(metaDir, { recursive: true, force: true });
499
+ try {
500
+ rmSync(metaDir, { recursive: true, force: true });
501
+ details.push('meta directory cleaned');
502
+ } catch (e) {
503
+ details.push(`meta directory cleanup failed: ${e.message}`);
504
+ }
505
+ }
506
+
507
+ // 6. 最终验证:确认三重清理完成
508
+ const residual = [];
509
+ if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
510
+ if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
511
+ if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
512
+ residual.push('git worktree list still references this worktree');
513
+ }
514
+ if (residual.length > 0) {
515
+ details.push(`⚠️ 残留: ${residual.join('; ')}`);
516
+ }
517
+
518
+ return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode, details };
519
+ }
520
+
521
+ /**
522
+ * worktree 健康检查 + 可选修复
523
+ * 检查项:
524
+ * - git worktree list 中的孤儿条目(目录不存在)
525
+ * - worktree 目录存在但 git 不认识
526
+ * - meta 存在但 worktree 目录不存在
527
+ * - worktree 目录存在但 meta 不存在(幽灵目录)
528
+ * - SillySpec 分支残留(sillyspec/* 但无对应 meta)
529
+ * - 超过指定小时的过期 worktree
530
+ *
531
+ * @param {{ fix?: boolean, staleHours?: number }} opts
532
+ * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
533
+ */
534
+ doctor({ fix = false, staleHours = 24 } = {}) {
535
+ const issues = [];
536
+ const fixed = [];
537
+ const unfixable = [];
538
+
539
+ // 1. 列出 git worktree list 中的条目
540
+ let gitWorktreeList = [];
541
+ try {
542
+ const raw = execSync(`git worktree list --porcelain`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
543
+ const entries = raw.split(/\n\n/).filter(Boolean);
544
+ for (const entry of entries) {
545
+ const lines = entry.split('\n');
546
+ const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
547
+ if (wtPath && wtPath !== this.cwd) { // 排除主工作区
548
+ gitWorktreeList.push({ path: wtPath, raw: entry });
549
+ }
550
+ }
551
+ } catch {
552
+ // git worktree 不可用,跳过
553
+ }
554
+
555
+ // 2. 列出 SillySpec meta 条目
556
+ const metaEntries = this.list();
557
+ const metaNames = new Set(metaEntries.map(m => m.changeName));
558
+
559
+ // 3. 检查 git worktree list 中的孤儿条目
560
+ for (const wt of gitWorktreeList) {
561
+ if (!existsSync(wt.path)) {
562
+ const name = this._pathToChangeName(wt.path);
563
+ issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 引用存在但目录不存在: ${wt.path}`, fixable: true });
564
+ if (fix) {
565
+ try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
566
+ }
567
+ }
568
+ }
569
+
570
+ // 4. 扫描 worktreeBase 目录,检查幽灵目录和孤儿 meta
571
+ if (existsSync(this.worktreeBase)) {
572
+ const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
573
+ for (const entry of entries) {
574
+ if (!entry.isDirectory()) continue;
575
+ const name = entry.name;
576
+ const dirPath = join(this.worktreeBase, name);
577
+ const hasMeta = existsSync(join(dirPath, META_FILE));
578
+ const meta = hasMeta ? this.getMeta(name) : null;
579
+
580
+ // meta 存在但 worktree 目录不存在
581
+ if (meta && meta.worktreePath && !existsSync(meta.worktreePath)) {
582
+ issues.push({ type: 'meta-no-dir', name, detail: `meta 存在但 worktree 目录不存在: ${meta.worktreePath}`, fixable: true });
583
+ if (fix) {
584
+ try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`cleaned orphan meta: ${name}`); } catch { unfixable.push(`cleanup failed for: ${name}`); }
585
+ }
586
+ }
587
+
588
+ // worktree 目录存在但 meta 不存在(幽灵目录)
589
+ if (!hasMeta && existsSync(dirPath)) {
590
+ // 可能是 in-place 模式的 meta-only 目录,或者真正的幽灵
591
+ const files = readdirSync(dirPath);
592
+ if (files.length === 0 || (files.length === 1 && files[0] === META_FILE)) {
593
+ issues.push({ type: 'ghost-dir', name, detail: `空目录/幽灵目录: ${dirPath}`, fixable: true });
594
+ if (fix) {
595
+ try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`removed ghost dir: ${name}`); } catch { unfixable.push(`remove failed for: ${name}`); }
596
+ }
597
+ } else {
598
+ issues.push({ type: 'ghost-dir-with-files', name, detail: `目录存在但无 meta.json: ${dirPath} (含 ${files.length} 文件)`, fixable: false });
599
+ }
600
+ }
601
+
602
+ // 检查过期 worktree
603
+ if (meta && meta.createdAt) {
604
+ const ageMs = Date.now() - new Date(meta.createdAt).getTime();
605
+ const ageHours = ageMs / (1000 * 60 * 60);
606
+ if (ageHours > staleHours) {
607
+ issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)`, fixable: true });
608
+ if (fix && meta.mode !== 'native-worktree') {
609
+ try {
610
+ const result = this.cleanup(name);
611
+ if (result.result === 'cleaned' || result.result === 'force-cleaned') {
612
+ fixed.push(`cleaned stale: ${name}`);
613
+ } else {
614
+ unfixable.push(`cleanup skipped: ${name}`);
615
+ }
616
+ } catch { unfixable.push(`cleanup failed: ${name}`); }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ // 5. 检查 SillySpec 分支残留
624
+ try {
625
+ const branches = execSync(`git branch --list '${BRANCH_PREFIX}*'`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
626
+ if (branches) {
627
+ for (const line of branches.split('\n').filter(Boolean)) {
628
+ const branch = line.replace(/^\*?\s+/, '').trim();
629
+ const name = branch.replace(BRANCH_PREFIX, '');
630
+ if (!metaNames.has(name)) {
631
+ issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
632
+ if (fix) {
633
+ try { gitQuiet(this.cwd, `branch -D ${branch}`); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
634
+ }
635
+ }
636
+ }
637
+ }
638
+ } catch {}
639
+
640
+ return { issues, fixed, unfixable };
641
+ }
642
+
643
+ /**
644
+ * 检查 worktree 是否有未 apply 到主工作区的变更
645
+ * @param {string} changeName
646
+ * @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
647
+ */
648
+ hasUnappliedChanges(changeName) {
649
+ const name = validateChangeName(changeName);
650
+ const meta = this.getMeta(name);
651
+ if (!meta) return { hasChanges: false, changedFiles: [], reason: 'no meta' };
652
+
653
+ const worktreePath = meta.worktreePath;
654
+ if (!worktreePath || !existsSync(worktreePath)) {
655
+ return { hasChanges: false, changedFiles: [], reason: 'worktree dir not found' };
656
+ }
657
+
658
+ // in-place 模式没有隔离目录,不算有未 apply 的变更
659
+ if (meta.mode === 'in-place-fallback') {
660
+ return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
661
+ }
662
+
663
+ const diffBase = meta.baselineCommit || meta.baseHash;
664
+ if (!diffBase) {
665
+ return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
666
+ }
667
+
668
+ try {
669
+ // tracked 文件变更
670
+ const statusRaw = gitQuiet(worktreePath, `diff --name-status ${diffBase}`) || '';
671
+ const statusFiles = new Set();
672
+ if (statusRaw) {
673
+ for (const line of statusRaw.split('\n').filter(Boolean)) {
674
+ const parts = line.split('\t');
675
+ if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
676
+ if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
677
+ }
678
+ }
679
+
680
+ // untracked 文件
681
+ const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '';
682
+ const untrackedFiles = untrackedRaw
683
+ ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
684
+ : [];
685
+
686
+ const changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
687
+ return { hasChanges: changedFiles.length > 0, changedFiles };
688
+ } catch (e) {
689
+ // 检测失败时保守处理:视为有变更
690
+ return { hasChanges: true, changedFiles: [], reason: `diff failed: ${e.message}` };
475
691
  }
692
+ }
476
693
 
477
- return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode };
694
+ /**
695
+ * 从 worktree 路径反推 changeName
696
+ * @private
697
+ */
698
+ _pathToChangeName(wtPath) {
699
+ try {
700
+ const resolved = resolve(wtPath);
701
+ const baseResolved = resolve(this.worktreeBase);
702
+ if (resolved.startsWith(baseResolved + '/')) {
703
+ return resolved.slice(baseResolved.length + 1);
704
+ }
705
+ } catch {}
706
+ return null;
478
707
  }
479
708
 
480
709
  /**