sillyspec 3.26.4 → 3.26.6

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.
Files changed (66) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -0
  3. package/.claude/skills/sillyspec-plan/SKILL.md +1 -0
  4. package/.claude/skills/sillyspec-quick/SKILL.md +5 -3
  5. package/package.json +1 -1
  6. package/src/db.js +4 -2
  7. package/src/fs-atomic.js +2 -2
  8. package/src/git-helper.js +21 -8
  9. package/src/index.js +3 -1
  10. package/src/progress/stage-machine.js +10 -4
  11. package/src/quicklog.js +2 -0
  12. package/src/review-tier.js +26 -14
  13. package/src/run/command.js +50 -7
  14. package/src/run/complete-handlers.js +95 -1
  15. package/src/run/complete.js +81 -6
  16. package/src/run/gates.js +1 -1
  17. package/src/run/prompt.js +34 -20
  18. package/src/run/quick-audit.js +5 -6
  19. package/src/run/shared.js +45 -11
  20. package/src/run/stage.js +10 -0
  21. package/src/run.js +5 -5
  22. package/src/stage-contract-spec.js +11 -0
  23. package/src/stage-contract.js +11 -1
  24. package/src/stage-review.js +9 -1
  25. package/src/stages/archive.js +12 -26
  26. package/src/stages/brainstorm-auto.js +1 -0
  27. package/src/stages/brainstorm.js +1 -0
  28. package/src/stages/execute.js +45 -0
  29. package/src/stages/plan.js +17 -1
  30. package/src/stages/quick.js +2 -0
  31. package/src/stages/scan.js +2 -2
  32. package/src/stages/verify.js +1 -0
  33. package/src/worktree-apply.js +3 -3
  34. package/src/worktree.js +83 -8
  35. package/packages/dashboard/index.html +0 -15
  36. package/packages/dashboard/package-lock.json +0 -2384
  37. package/packages/dashboard/public/favicon.jpg +0 -0
  38. package/packages/dashboard/public/logo.jpg +0 -0
  39. package/packages/dashboard/public/prototype-dashboard.html +0 -836
  40. package/packages/dashboard/public/prototype-overview.html +0 -256
  41. package/packages/dashboard/src/App.vue +0 -558
  42. package/packages/dashboard/src/components/ActionBar.vue +0 -93
  43. package/packages/dashboard/src/components/CommandPalette.vue +0 -96
  44. package/packages/dashboard/src/components/DetailPanel.vue +0 -137
  45. package/packages/dashboard/src/components/DocPreview.vue +0 -257
  46. package/packages/dashboard/src/components/DocTree.vue +0 -114
  47. package/packages/dashboard/src/components/HResizeHandle.vue +0 -48
  48. package/packages/dashboard/src/components/LogStream.vue +0 -65
  49. package/packages/dashboard/src/components/PipelineStage.vue +0 -95
  50. package/packages/dashboard/src/components/PipelineView.vue +0 -156
  51. package/packages/dashboard/src/components/ProjectCard.vue +0 -187
  52. package/packages/dashboard/src/components/ProjectList.vue +0 -210
  53. package/packages/dashboard/src/components/ProjectOverview.vue +0 -152
  54. package/packages/dashboard/src/components/StageBadge.vue +0 -67
  55. package/packages/dashboard/src/components/StepCard.vue +0 -94
  56. package/packages/dashboard/src/components/VResizeHandle.vue +0 -61
  57. package/packages/dashboard/src/components/detail/DocsDetail.vue +0 -48
  58. package/packages/dashboard/src/components/detail/GitDetail.vue +0 -61
  59. package/packages/dashboard/src/components/detail/TechDetail.vue +0 -43
  60. package/packages/dashboard/src/composables/useDashboard.js +0 -213
  61. package/packages/dashboard/src/composables/useKeyboard.js +0 -119
  62. package/packages/dashboard/src/composables/useLayout.js +0 -131
  63. package/packages/dashboard/src/composables/useWebSocket.js +0 -129
  64. package/packages/dashboard/src/main.js +0 -8
  65. package/packages/dashboard/src/style.css +0 -132
  66. package/packages/dashboard/vite.config.js +0 -18
@@ -25,34 +25,20 @@ export const definition = {
25
25
  },
26
26
  {
27
27
  name: 'extract-module-impact',
28
- prompt: `按照 \`.sillyspec/workflows/archive-impact.yaml\` 中定义的 \`impact-analyzer\` 角色规则,分析本次变更影响的模块。
29
-
30
- ### 文件标题规则(sillyhub 平台解析识别用)
31
- module-impact.md 第一行标题必须用中文:# 模块影响分析(Module Impact)— <变更简述>
28
+ prompt: `最终确认 changes/<change>/module-impact.md(本次变更的模块影响分析)。该文档由 plan review_plan 步生成首版、execute 各 Wave 更新、verify 核对——本步是归档前最后一次确认(module-impact 已非 archive 生成,改活文档终审)。
32
29
 
33
30
  ### 操作
34
- 1. 读取 \`.sillyspec/workflows/archive-impact.yaml\`,了解角色定义和检查规则
35
- 2. 读取变更目录下的 proposal.md、design.md、tasks.md
36
- 3. 运行 \`git diff --name-only HEAD~1\`(或 \`git diff --name-only --cached\`)获取真实修改文件列表
37
- 4. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`
38
- - **如果不存在**:提示"建议运行 scan 生成模块映射",但继续执行。跳到步骤 7 生成只有 unmapped 部分的 module-impact.md
39
- 5. 三重交叉验证:
40
- - 声明范围:proposal.md / design.md 中的"变更范围"/"文件变更清单"
41
- - 任务范围:tasks.md / plan.md 中的任务文件路径
42
- - 真实变更:git diff 文件列表
43
- - **以 git diff 为准**(真实 > 声明)
44
- 6. 将 git diff 文件按 \`_module-map.yaml\` 的 paths glob 匹配到模块
45
- 7. 生成模块影响矩阵:
46
-
47
- | 模块 | 影响类型 | 相关文件 | 更新内容摘要 | needs_review |
48
- |------|----------|----------|-------------|-------------|
49
-
50
- 影响类型:逻辑变更 / 数据结构变更 / 接口变更 / 调用关系变更 / 配置变更 / 新增
51
- needs_review:如果影响无法完全确定,标记为 true
52
-
53
- 8. 未匹配到任何模块的文件归入"未匹配文件"表格
54
- 9. 生成 \`.sillyspec/changes/<change-name>/module-impact.md\``,
55
- outputHint: 'module-impact.md 路径 + 影响摘要',
31
+ 1. 读取 changes/<change>/module-impact.md(应已存在;large 变更 plan 阶段产出首版)
32
+ 2. 运行 git diff --name-only HEAD~1(或 git diff --name-only --cached)获取真实修改文件列表
33
+ 3. 三重核对:module-impact.md 记录的受影响模块/文件 vs 真实 git diff 文件列表 vs .sillyspec/docs/<project>/modules/_module-map.yaml 模块映射(不存在则仅核 unmapped 部分)。以 git diff 为准(真实 > 记录)
34
+ 4. 发现不一致(漏标受影响模块 / 影响类型错误 / 实际未触碰的模块被误标)→ 直接修正 module-impact.md,使其与实际变更一致
35
+ 5. module-impact.md 第一行标题必须用中文:# 模块影响分析(Module Impact)— <变更简述>
36
+
37
+ ### 降级(module-impact.md 不存在)
38
+ 若缺失(如旧变更未经新 plan 流程,或 small 变更从未生成),按原生成逻辑补一份:读 .sillyspec/workflows/archive-impact.yaml 的 impact-analyzer 角色规则 + git diff + _module-map.yaml,生成模块影响矩阵(模块 × 影响类型 × 相关文件 × 更新摘要 × needs_review,未匹配文件归入 unmapped 表)落盘 module-impact.md。
39
+
40
+ 确认完成后,下一步 sync-module-docs 会读 module-impact.md 更新模块卡片文档。`,
41
+ outputHint: 'module-impact.md 终审结果 + 修正摘要',
56
42
  optional: false
57
43
  },
58
44
  {
@@ -66,6 +66,7 @@ export const definition = {
66
66
  maxWaitRounds: 5,
67
67
  waitReason: '等待用户回答需求问题',
68
68
  waitOptions: ['回答见--answer', '信息够了,进入设计'],
69
+ waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
69
70
  prompt: `分析需求,必要时追问,然后设计方案。
70
71
 
71
72
  ### 操作
@@ -79,6 +79,7 @@ export const definition = {
79
79
  maxWaitRounds: 8,
80
80
  waitReason: '等待用户回答需求问题或澄清',
81
81
  waitOptions: ['回答见--answer', '信息够了,进入方案讨论'],
82
+ waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
82
83
  prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行。提问遵守「一次只问一个」(见 D 节追问策略):必须问的问题逐个问,能查代码/文档解决的不问;不要把多个歧义打包成一屏清单一次抛给用户(用户记不住、答不全,反而漏掉关键约束);也不要为凑数把单个问题拖成多轮。
83
84
 
84
85
  ### A. 原型/设计图分析(如用户提供)
@@ -33,7 +33,11 @@ export function validatePlanForExecute(planContent) {
33
33
 
34
34
  // 检查 1: 至少有一个 checkbox task
35
35
  if (allTasks.length === 0) {
36
+ // 诊断根因(坑 plan-md-format-contract-hidden):笼统报"没有找到 checkbox task"逼 agent 试错。
37
+ // 区分:无 Wave/Tasks 段 / Wave 标题格式不对 / task 用 ### 标题 / checkbox 被非 Wave 标题打断。
38
+ const diags = diagnoseNoTaskRootCause(planContent)
36
39
  errors.push('plan.md 中没有找到 checkbox task(格式: "- [ ] task-XX: 任务名")')
40
+ for (const d of diags) errors.push(` 诊断:${d}`)
37
41
  return { ok: false, errors, warnings, tasks: allTasks, waves }
38
42
  }
39
43
 
@@ -357,6 +361,39 @@ Apply Decision + 下一步建议
357
361
  }
358
362
  ]
359
363
 
364
+ /**
365
+ * 诊断 plan.md 无 checkbox task 的根因(坑 plan-md-format-contract-hidden)。
366
+ * 纯函数:读 planContent 文本,返回诊断提示数组。validatePlanForExecute 无 task 时追加到报错,
367
+ * 帮 agent 区分 4 条隐性格式契约(Wave 标题字面 / task checkbox / task-XX 编号 / ### 打断 Wave 段)。
368
+ * @param {string} planContent
369
+ * @returns {string[]}
370
+ */
371
+ function diagnoseNoTaskRootCause(planContent) {
372
+ const diags = []
373
+ const hasWaveHeading = /^#+\s*Wave\s+\d+/im.test(planContent) // "## Wave N"(字面)
374
+ const hasWaveWord = /^#+\s*(wave\s*\d+|w\d+|波次\s*\d+)/im.test(planContent) // 疑似 Wave 标题(含 W1/Wave1/波次1)
375
+ const hasCheckboxTask = /^[-*]\s*\[[ x]\]\s*task-\d+/im.test(planContent) // "- [ ] task-XX:" checkbox
376
+ const hasTaskHeading = /^#+\s*task-\d+/im.test(planContent) // "### task-XX:" 标题(非 checkbox)
377
+ const hasTasksSection = /^#+\s*(tasks?|任务)\s*$/im.test(planContent) // "## Tasks" / "## 任务"
378
+
379
+ if (!hasWaveHeading && !hasTasksSection) {
380
+ if (hasWaveWord) {
381
+ diags.push('Wave 标题格式不对:必须字面 "## Wave N"(Wave + 空格 + 数字),"## W1" / "## Wave1" / "## 波次1" 都不被识别')
382
+ } else if (hasCheckboxTask) {
383
+ diags.push('有 task checkbox 但不在 "## Wave N"(full)或 "## Tasks"(light/none)段内——非任务区的 checkbox 不收')
384
+ } else {
385
+ diags.push('plan.md 缺任务区:full 需 "## Wave 1" 标题,light/none 需 "## Tasks" 标题')
386
+ }
387
+ }
388
+ if (hasTaskHeading) {
389
+ diags.push('task 用了 "### task-XX:" 标题(非 checkbox):标题会打断 Wave 段导致后续 checkbox 全被跳过,请改用 "- [ ] task-XX: 名称"(英文冒号)')
390
+ }
391
+ if (hasWaveHeading && hasCheckboxTask && !hasTaskHeading) {
392
+ diags.push('有 Wave 段也有 checkbox task 但未收容:checkbox 可能落在非任务区(如 "## 自检" 段)或被 "###" 标题打断,请检查 task 是否紧跟在 "## Wave N" 下')
393
+ }
394
+ return diags
395
+ }
396
+
360
397
  /**
361
398
  * 从 plan 文件解析 Wave 分组
362
399
  */
@@ -868,6 +905,14 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
868
905
  - \`cannot_verify\` 只在确实无法验证且有待补充证据时使用,且 requiredEvidence 必须非空
869
906
  - \`sillyspec run execute --done\` 会校验所有 task 的 review.json,缺失或 fail 会阻断完成
870
907
 
908
+ ### module-impact.md 更新(主代理在本 Wave 所有 task 完成后汇总)
909
+ 本 Wave 内所有 task 子代理完成、review.json 写好后,**由你(主代理/调度者)**汇总本 Wave 的实际代码变更,更新 {SPEC_ROOT}/changes/<change>/module-impact.md(plan 阶段已生成首版):
910
+ - 基于本 Wave 各 task 的实际 git diff(不是计划)+ {SPEC_ROOT}/docs/<project>/modules/_module-map.yaml 对照
911
+ - 更新受影响模块的影响类型/说明(实际改动可能与 plan 首版预估不同,据实修正)
912
+ - **不由各 task 子代理分别改**(同 Wave 并行子代理改同一文件会互相覆盖)——只由主代理在 Wave 收尾统一更新一次
913
+ - 无 _module-map.yaml 时跳过模块匹配,仅按文件清单更新 unmapped 部分
914
+ 这是可选更新(不阻断 execute 完成),但保持 module-impact 与实际变更一致利于 verify 核对与 archive 终审。
915
+
871
916
  ### 完成后
872
917
  1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
873
918
  - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
@@ -343,9 +343,25 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
343
343
  {REVIEW_JSON_CONTRACT}
344
344
  4. verdict=fail 时在 reviewerNotes 写明阻断项
345
345
 
346
+ ### 生成 module-impact.md 首版(scale≠small 时)
347
+ 计划审查通过后,顺带生成 module-impact.md 首版(本次变更的模块影响分析,供 execute/verify 阶段更新、archive 阶段终审)。scale=small 不生成(small 走 quick,module-impact 对 quick 无用)。
348
+
349
+ 输入(此时 TaskCard/allowed_paths 尚未生成——在下一步 generate_blueprints,故用以下两项作输入,粒度与 archive 现状一致):
350
+ - design.md 的「文件变更清单」(本次计划改哪些源码文件)
351
+ - plan.md 的任务列表(每个 task 改的范围)
352
+
353
+ 步骤(复用 archive extract-module-impact 的核心指引,同源——改 archive step2 prompt 时保持一致):
354
+ 1. 读 {SPEC_ROOT}/docs/<project>/modules/_module-map.yaml(模块→文件路径映射)。**不存在 → 降级**:生成只含 unmapped 部分的 module-impact.md + 提示「建议运行 scan 生成模块映射」,不阻断
355
+ 2. 对照 design 文件变更清单 + plan 任务列表,逐文件匹配所属模块(命中的归 mapped,未命中的归 unmapped)
356
+ 3. 生成模块影响矩阵(模块 × 影响类型[新增/修改/删除/依赖变更] × 说明),落盘 {SPEC_ROOT}/changes/<change>/module-impact.md
357
+ 4. 首行标题必须用中文:# 模块影响分析(Module Impact)— <变更简述>
358
+
359
+ execute/verify 阶段会按实际代码变更更新此文档;archive 阶段会最终确认它。
360
+
346
361
  ### 输出
347
362
  - tier=self:审查清单结果(每条状态 + 偏差说明)
348
- - tier=independent:子代理产出的 review.json 路径 + verdict 摘要`,
363
+ - tier=independent:子代理产出的 review.json 路径 + verdict 摘要
364
+ - scale≠small:附 module-impact.md 路径 + 影响摘要`,
349
365
  outputHint: 'plan 审查结果(self=清单 / independent=review.json 路径+verdict)',
350
366
  optional: false
351
367
  }
@@ -93,6 +93,8 @@ sillyspec run quick --done --change <id> --output "需求:… 根因:… 方
93
93
 
94
94
  格式:\`path::括注\` 一条,\`||\` 分隔多条(括注可省略只留 \`path\`)。不传则「文件:」行回填审计到的实际改动文件单行(仍可事后手改)。
95
95
 
96
+ > ⚠️ --file-notes 只随 step3 --done 同一命令传才生效。CLI 是短进程,run 与 done 是独立进程——step1/step2 的 sillyspec run quick(仅拿提示词)即使带了 --file-notes,也只写进那个短进程的内存、进程结束即丢,不会带到 step3 --done。请在 step3 收尾时连同 --output 一起传。
97
+
96
98
  ### 收尾推荐顺序(模块文档在 --done 前,QUICKLOG 在 --done 后,别记混)
97
99
  1. 【--done 前】命中模块 → 改模块文档 → \`git add -- <模块文档>\`(见下方「模块文档同步」)
98
100
  2. 【--done】\`sillyspec run quick --done --change <id> --output "四字段" [--file-notes "..."]\`(CLI 自动翻完成 + 勾 task + 落盘 QUICKLOG 标题/文件/正文)
@@ -281,7 +281,7 @@ local.yaml 生成结果(已存在/已生成 + project.type + agent 补充的
281
281
 
282
282
  ### YAML 格式
283
283
  \`\`\`yaml
284
- schema_version: 1
284
+ schema_version: 2
285
285
  project: <project-name>
286
286
  source_commit: <git-head-short>
287
287
  generated_at: <now-datetime>
@@ -313,7 +313,7 @@ modules:
313
313
 
314
314
  ### 示例
315
315
  \`\`\`yaml
316
- schema_version: 1
316
+ schema_version: 2
317
317
  project: multi-agent-platform
318
318
  source_commit: abc1234
319
319
  generated_at: 2026-06-02 22:00:00
@@ -197,6 +197,7 @@ export const definition = {
197
197
 
198
198
  3. 生成 verify-result.md 文件,保存到 \`.sillyspec/changes/<change-name>/verify-result.md\`
199
199
  4. 给出结论:PASS / PASS WITH NOTES / FAIL(受风险门控约束)
200
+ 5. **核对 module-impact.md**(若 changes/<change>/module-impact.md 存在):对照本次实际代码变更(git diff)与 module-impact.md 的模块影响矩阵,发现不一致(漏标受影响模块 / 影响类型错误 / 实际未触碰的模块被误标)则在 verify-result.md 标注。module-impact 由 plan 首版生成、execute 各 Wave 更新,verify 是最后一次核对机会(archive 仅终审不再生成)。这是 advisory 核对(不阻断 verify 完成),但 module-impact 与实际严重背离应记为风险。
200
201
 
201
202
  ### verify-result.md 格式
202
203
  \`\`\`markdown
@@ -414,7 +414,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
414
414
  if (changedFiles.length === 0) {
415
415
  // 没有变更
416
416
  if (!checkOnly) {
417
- wm.cleanup(changeName);
417
+ wm.cleanup(changeName, { force: true });
418
418
  }
419
419
  result.ok = true;
420
420
  return result;
@@ -646,7 +646,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
646
646
 
647
647
  // --- 8. 成功后自动 cleanup(失败不影响整体结果) ---
648
648
  try {
649
- wm.cleanup(changeName);
649
+ wm.cleanup(changeName, { force: true });
650
650
  } catch (cleanupErr) {
651
651
  result.warnings = result.warnings || [];
652
652
  result.warnings.push(`cleanup 失败(不影响应用结果): ${cleanupErr.message}`);
@@ -756,7 +756,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
756
756
  result.ok = true;
757
757
  try { result.mergeSummary = git(projectRoot, ['log', '--oneline', '-1']); } catch {}
758
758
  try {
759
- wm.cleanup(changeName);
759
+ wm.cleanup(changeName, { force: true });
760
760
  } catch (cleanupErr) {
761
761
  result.warnings = result.warnings || [];
762
762
  result.warnings.push(`cleanup 失败(不影响 merge 结果): ${cleanupErr.message}`);
package/src/worktree.js CHANGED
@@ -236,6 +236,53 @@ export function copyUntrackedEntry(src, dst) {
236
236
  }
237
237
  }
238
238
 
239
+ /**
240
+ * execute 阶段级核验(防空跑谎报,D-002@v1 / FR-04/05/06):聚合 review.changedFiles 的交付文件,
241
+ * 逐个核验存在于 worktree 分支 tree 或 worktree 工作区。两处皆无 = 声称实现但从未落盘。
242
+ *
243
+ * - checked:false:worktreePath 不存在或 branch 不存在(git rev-parse --verify <branch> 失败)
244
+ * → 无法核验(worktree 已丢 / 分支已删),调用方保守提示人工确认。
245
+ * - 逐文件:`git rev-parse --verify --quiet <branch>:<file>` 成功(在分支 tree)或
246
+ * existsSync(worktreePath/<file>)(在工作区)→ verified;两处皆无 → missing。
247
+ * tree 探测用 rev-parse --verify --quiet 而非 `git cat-file -e`:语义等价(按 branch tree 解析
248
+ * blob),但 cat-file 对不存在路径会往 stderr 打 `fatal: path ... does not exist` 噪音
249
+ * (git-helper gitQuiet 只吞退出码不吞 stderr),--quiet 完全静默。
250
+ *
251
+ * git 调用走项目统一入口 gitQuiet(git-helper.js,数组形式 execFileSync 不经 shell,Windows 安全)。
252
+ * 宽松非阻断:本函数只返回结果不 warn/exit,由调用方决定(design 为 warn 不 exit)。
253
+ *
254
+ * @param {object} p
255
+ * @param {string} p.worktreePath - worktree 根目录(绝对路径,meta.worktreePath)
256
+ * @param {string} p.branch - sillyspec/<change> 分支名(核验 tree)
257
+ * @param {string[]} p.changedFiles - review 声称的交付文件(调用方已过滤主仓 repo、非 .sillyspec)
258
+ * @returns {{ missing: string[], verified: string[], checked: boolean }}
259
+ * checked=false 表示无法核验(worktree/分支不存在),调用方保守提示人工确认。
260
+ */
261
+ export function findMissingDeliverables({ worktreePath, branch, changedFiles }) {
262
+ const files = Array.isArray(changedFiles)
263
+ ? changedFiles.filter(f => typeof f === 'string' && f.trim() !== '')
264
+ : []
265
+ // worktree 目录不存在 → 无法核验(目录已丢,如 apply 后 cleanup / 并行 session 清过)
266
+ if (!worktreePath || !existsSync(worktreePath)) {
267
+ return { missing: [], verified: [], checked: false }
268
+ }
269
+ // branch 不存在(rev-parse --verify 失败)→ 无法核验分支 tree
270
+ if (!branch || !gitQuiet(worktreePath, ['rev-parse', '--verify', '--quiet', branch])) {
271
+ return { missing: [], verified: [], checked: false }
272
+ }
273
+ const missing = []
274
+ const verified = []
275
+ for (const raw of files) {
276
+ // 统一正斜杠:review.json 可能落 Windows 反斜杠路径,rev-parse tree 路径一律 /
277
+ const f = raw.replace(/\\/g, '/')
278
+ const inBranch = gitQuiet(worktreePath, ['rev-parse', '--verify', '--quiet', `${branch}:${f}`]) !== null
279
+ const inWorktree = existsSync(join(worktreePath, f))
280
+ if (inBranch || inWorktree) verified.push(raw)
281
+ else missing.push(raw)
282
+ }
283
+ return { missing, verified, checked: true }
284
+ }
285
+
239
286
  export class WorktreeManager {
240
287
  constructor({ cwd, worktreeDir } = {}) {
241
288
  this.cwd = cwd || process.cwd();
@@ -704,8 +751,9 @@ export class WorktreeManager {
704
751
  * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
705
752
  * @param {string} changeName
706
753
  * @param {{ force?: boolean, maxRetries?: number }} opts
707
- * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept'|'partial', mode: string|null, details: string[], residual: string[] }}
708
- * result 取值:cleaned=git remove 成功;force-cleaned=git remove 失败但 fallback 清理完成;
754
+ * @returns {{ result: 'blocked'|'cleaned'|'force-cleaned'|'skipped'|'kept'|'partial', mode: string|null, details: string[], residual: string[] }}
755
+ * result 取值:blocked=有未落主仓交付变更,fail-closed 拒绝清理(需 force 绕过,D-001@v1);
756
+ * cleaned=git remove 成功;force-cleaned=git remove 失败但 fallback 清理完成;
709
757
  * partial=有残留(目录/meta/git 注册未清);skipped=无需清理;kept=native-worktree 保留。
710
758
  * residual:未清干净的路径/引用列表(空数组表示干净)。
711
759
  */
@@ -731,6 +779,21 @@ export class WorktreeManager {
731
779
  return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'], residual: [] };
732
780
  }
733
781
 
782
+ // fail-closed 保护(D-001@v1):junction 解链与 git worktree remove --force 之前,检查未落主仓交付变更。
783
+ // 清理即蒸发:worktree 有未 commit / 未 apply 到主仓 HEAD 的交付文件,拒绝清理,需显式 --force 绕过。
784
+ // hasUnappliedChanges 判定 main HEAD(byte-identical),git apply --3way 不 commit → apply 后仍判 true,
785
+ // 故 apply 后自动 cleanup 与 execute reset 显式传 force:true 绕过(D-006)。in-place / native-worktree
786
+ // 由 hasUnappliedChanges 内部返回 hasChanges:false,自然跳过保护,零回归。
787
+ if (!force) {
788
+ const check = this.hasUnappliedChanges(name);
789
+ if (check.hasChanges) {
790
+ console.error(`🚫 worktree cleanup 拒绝:${check.changedFiles.length} 个交付变更未落地主工作区 HEAD,清理会丢失代码。`);
791
+ for (const f of check.changedFiles) console.error(` ${f}`);
792
+ console.error(' 请先落地(sillyspec worktree apply <name>)或 commit 到分支,或显式 --force 强制清理。');
793
+ return { result: 'blocked', mode, details: [...details, 'blocked: uncommitted deliverable changes'], residual: [] };
794
+ }
795
+ }
796
+
734
797
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
735
798
 
736
799
  // Windows 保护:先解链接 worktree/node_modules(junction 指向主 checkout),
@@ -1045,6 +1108,9 @@ export class WorktreeManager {
1045
1108
  const result = this.cleanup(name);
1046
1109
  if (result.result === 'cleaned' || result.result === 'force-cleaned') {
1047
1110
  fixed.push(`cleaned stale: ${name}`);
1111
+ } else if (result.result === 'blocked') {
1112
+ console.error(`🚫 拒绝清理过期 worktree ${name}: 有未落主仓交付变更,请先 sillyspec worktree apply ${name} 或 commit 到分支,再重试 doctor --fix`);
1113
+ unfixable.push(`cleanup blocked: ${name}(有未落主仓交付变更)`);
1048
1114
  } else {
1049
1115
  unfixable.push(`cleanup skipped: ${name}`);
1050
1116
  }
@@ -1093,7 +1159,10 @@ export class WorktreeManager {
1093
1159
 
1094
1160
  const worktreePath = meta.worktreePath;
1095
1161
  if (!worktreePath || !existsSync(worktreePath)) {
1096
- return { hasChanges: false, changedFiles: [], reason: 'worktree dir not found' };
1162
+ // 目录不存在:无法判定是否有未落代码——分支可能有已 commit 但未 apply 的交付(execute 批量完成
1163
+ // cleanup 删分支 ref 盲区,memory execute-batch-cleanup-deletes-branch-recovery)。保守保留
1164
+ // (hasChanges:true),防 cleanup 误删分支让 commit 变 dangling。目录真丢时用户 --force 强清。
1165
+ return { hasChanges: true, changedFiles: [], reason: 'worktree dir not found (conservative keep)' };
1097
1166
  }
1098
1167
 
1099
1168
  // in-place 模式没有隔离目录,不算有未 apply 的变更
@@ -1107,16 +1176,22 @@ export class WorktreeManager {
1107
1176
 
1108
1177
  const diffBase = meta.baselineCommit || meta.baseHash;
1109
1178
  if (!diffBase) {
1110
- return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
1179
+ // 有 meta 但缺 diff 起点:无法判定是否有未落代码,保守保留(防 cleanup 删分支)
1180
+ return { hasChanges: true, changedFiles: [], reason: 'no diff base (conservative keep)' };
1111
1181
  }
1112
1182
 
1113
1183
  const isDeliverable = f => f && !f.startsWith('.sillyspec/') && f !== 'meta.json';
1114
1184
  try {
1115
1185
  // 1) 候选交付变更(worktree 工作区相对 diffBase)。--no-renames:rename 退化成 D+A,两侧文件都进集
1116
- const tracked = (gitQuiet(worktreePath, ['diff', '--no-renames', '--name-only', diffBase], { timeout: 30000 }) || '')
1117
- .split('\n').filter(Boolean).filter(isDeliverable);
1118
- const untracked = (gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '')
1119
- .split('\n').filter(Boolean).filter(isDeliverable);
1186
+ const trackedRaw = gitQuiet(worktreePath, ['diff', '--no-renames', '--name-only', diffBase], { timeout: 30000 });
1187
+ const untrackedRaw = gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 });
1188
+ // git 检测失败(返回 null,如 worktree .git 半坏/目录部分删除)→ 不能当"无改动",保守保留。
1189
+ // 只有 git 成功返回空才判"真无改动"(可安全清理)。
1190
+ if (trackedRaw === null || untrackedRaw === null) {
1191
+ return { hasChanges: true, changedFiles: [], reason: `git detection failed (conservative keep): tracked=${trackedRaw === null ? 'null' : 'ok'} untracked=${untrackedRaw === null ? 'null' : 'ok'}` };
1192
+ }
1193
+ const tracked = trackedRaw.split('\n').filter(Boolean).filter(isDeliverable);
1194
+ const untracked = untrackedRaw.split('\n').filter(Boolean).filter(isDeliverable);
1120
1195
 
1121
1196
  if (tracked.length === 0 && untracked.length === 0) {
1122
1197
  return { hasChanges: false, changedFiles: [], reason: 'no changes in worktree' };
@@ -1,15 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>SillySpec Dashboard</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
10
- </head>
11
- <body style="background-color: #0A0A0B; margin: 0;">
12
- <div id="app"></div>
13
- <script type="module" src="/src/main.js"></script>
14
- </body>
15
- </html>