sillyspec 3.20.3 → 3.20.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.
Files changed (224) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -17
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -77
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -17
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -105
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -44
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -22
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -17
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -96
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +270 -0
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -52
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -17
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -17
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -111
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -17
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -17
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -17
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -149
  19. package/.husky/pre-push +13 -0
  20. package/CLAUDE.md +18 -0
  21. package/README.md +198 -186
  22. package/SKILL.md +90 -87
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -0
  25. package/docs/plan-execute-contract.md +123 -0
  26. package/docs/platform-scan-protocol.md +298 -0
  27. package/docs/revision-mode.md +115 -0
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -0
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -0
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -0
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -0
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -0
  33. package/docs/sillyspec/file-lifecycle.md +125 -0
  34. package/docs/workflow-contract-regression.md +106 -0
  35. package/docs/worktree-isolation.md +252 -0
  36. package/package.json +40 -34
  37. package/packages/dashboard/dist/assets/{index-D1EVTLmc.js → index-Bq_Z2hne.js} +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -0
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/dist/prototype-dashboard.html +836 -0
  41. package/packages/dashboard/dist/prototype-overview.html +256 -0
  42. package/packages/dashboard/index.html +15 -15
  43. package/packages/dashboard/package-lock.json +2384 -2384
  44. package/packages/dashboard/package.json +25 -25
  45. package/packages/dashboard/public/prototype-dashboard.html +836 -0
  46. package/packages/dashboard/public/prototype-overview.html +256 -0
  47. package/packages/dashboard/server/executor.js +86 -86
  48. package/packages/dashboard/server/index.js +588 -508
  49. package/packages/dashboard/server/parser.js +526 -458
  50. package/packages/dashboard/server/watcher.js +350 -342
  51. package/packages/dashboard/src/App.vue +558 -325
  52. package/packages/dashboard/src/components/ActionBar.vue +93 -84
  53. package/packages/dashboard/src/components/CommandPalette.vue +96 -92
  54. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  55. package/packages/dashboard/src/components/DocPreview.vue +105 -8
  56. package/packages/dashboard/src/components/DocTree.vue +75 -19
  57. package/packages/dashboard/src/components/HResizeHandle.vue +48 -0
  58. package/packages/dashboard/src/components/LogStream.vue +65 -65
  59. package/packages/dashboard/src/components/PipelineStage.vue +95 -75
  60. package/packages/dashboard/src/components/PipelineView.vue +156 -129
  61. package/packages/dashboard/src/components/ProjectCard.vue +187 -0
  62. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  63. package/packages/dashboard/src/components/ProjectOverview.vue +113 -139
  64. package/packages/dashboard/src/components/StageBadge.vue +67 -53
  65. package/packages/dashboard/src/components/StepCard.vue +94 -89
  66. package/packages/dashboard/src/components/VResizeHandle.vue +61 -0
  67. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  68. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  69. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  70. package/packages/dashboard/src/composables/useDashboard.js +192 -164
  71. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  72. package/packages/dashboard/src/composables/useLayout.js +131 -0
  73. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  74. package/packages/dashboard/src/main.js +8 -8
  75. package/packages/dashboard/src/style.css +132 -132
  76. package/packages/dashboard/vite.config.js +18 -18
  77. package/src/brainstorm-postcheck.js +158 -0
  78. package/src/change-list.js +52 -0
  79. package/src/change-risk-profile.js +352 -0
  80. package/src/classify-change.js +73 -0
  81. package/src/constants.js +70 -0
  82. package/src/contract-matrix.js +278 -0
  83. package/src/db.js +201 -0
  84. package/src/endpoint-extractor.js +315 -0
  85. package/src/hooks/claude-pre-tool-use.cjs +125 -0
  86. package/src/hooks/worktree-guard.js +761 -0
  87. package/src/index.js +922 -252
  88. package/src/init.js +431 -375
  89. package/src/knowledge-match.js +130 -0
  90. package/src/migrate.js +117 -117
  91. package/src/modules.js +482 -0
  92. package/src/progress.js +1734 -508
  93. package/src/run.js +3465 -652
  94. package/src/scan-postcheck.js +387 -0
  95. package/src/setup.js +398 -460
  96. package/src/stage-contract.js +700 -0
  97. package/src/stages/archive.js +160 -54
  98. package/src/stages/brainstorm-auto.js +229 -0
  99. package/src/stages/brainstorm.js +645 -239
  100. package/src/stages/doctor.js +365 -312
  101. package/src/stages/execute.js +625 -264
  102. package/src/stages/explore.js +34 -0
  103. package/src/stages/index.js +29 -35
  104. package/src/stages/knowledge.js +498 -0
  105. package/src/stages/plan-postcheck.js +511 -0
  106. package/src/stages/plan.js +582 -279
  107. package/src/stages/propose.js +174 -115
  108. package/src/stages/quick.js +102 -63
  109. package/src/stages/scan.js +558 -141
  110. package/src/stages/status.js +65 -65
  111. package/src/stages/verify.js +322 -135
  112. package/src/sync.js +497 -0
  113. package/src/task-review.js +346 -0
  114. package/src/workflow.js +785 -0
  115. package/src/worktree-apply.js +549 -0
  116. package/src/worktree-deps.js +185 -0
  117. package/src/worktree.js +982 -0
  118. package/templates/workflows/archive-impact.yaml +79 -0
  119. package/templates/workflows/scan-docs.yaml +132 -0
  120. package/test/brainstorm-plan-contract.test.mjs +273 -0
  121. package/test/check-syntax.mjs +26 -0
  122. package/test/cli-top-level-aliases.test.mjs +174 -0
  123. package/test/contract-artifacts.test.mjs +323 -0
  124. package/test/decision-supersede.test.mjs +277 -0
  125. package/test/knowledge-match.test.mjs +231 -0
  126. package/test/plan-execute-contract.test.mjs +330 -0
  127. package/test/plan-optimization.test.mjs +572 -0
  128. package/test/platform-artifacts.test.mjs +190 -0
  129. package/test/platform-failure-samples.test.mjs +199 -0
  130. package/test/platform-recovery-chain.test.mjs +179 -0
  131. package/test/platform-recovery.test.mjs +167 -0
  132. package/test/platform-scan-p0.test.mjs +175 -0
  133. package/test/revision-v1.test.mjs +1145 -0
  134. package/test/run-sanitize-project-name.test.mjs +51 -0
  135. package/test/run-scan-postcheck-fail.test.mjs +64 -0
  136. package/test/run-scan-project-parse.test.mjs +200 -0
  137. package/test/run-tests.mjs +48 -0
  138. package/test/scan-docs-yaml-placeholders.test.mjs +84 -0
  139. package/test/scan-knowledge.test.mjs +175 -0
  140. package/test/scan-paths.test.mjs +68 -0
  141. package/test/scan-postcheck-project-priority.test.mjs +85 -0
  142. package/test/scan-postcheck.test.mjs +197 -0
  143. package/test/scan-workflow-anyfailed-block.test.mjs +52 -0
  144. package/test/spec-dir.test.mjs +206 -0
  145. package/test/stage-contract-failed-post-check.test.mjs +102 -0
  146. package/test/stage-contract.test.mjs +299 -0
  147. package/test/stage-definitions.test.mjs +39 -0
  148. package/test/wait-gates.test.mjs +501 -0
  149. package/test/workflow-spec-base.test.mjs +142 -0
  150. package/test/worktree-deps-provision.test.mjs +148 -0
  151. package/test/worktree-guard.test.mjs +136 -0
  152. package/test/worktree-native-overlay.test.mjs +188 -0
  153. package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +0 -97
  154. package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +0 -51
  155. package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +0 -29
  156. package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +0 -34
  157. package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +0 -13
  158. package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +0 -43
  159. package/.sillyspec/changes/auto-mode/design.md +0 -50
  160. package/.sillyspec/changes/auto-mode/proposal.md +0 -19
  161. package/.sillyspec/changes/auto-mode/requirements.md +0 -21
  162. package/.sillyspec/changes/auto-mode/tasks.md +0 -7
  163. package/.sillyspec/changes/brainstorm-archive/2026-04-05-dashboard-design.md +0 -206
  164. package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +0 -199
  165. package/.sillyspec/changes/dashboard/design.md +0 -219
  166. package/.sillyspec/changes/dashboard/design.md.braindraft +0 -206
  167. package/.sillyspec/changes/run-command-design/design.md +0 -1230
  168. package/.sillyspec/changes/unified-docs-design/design.md +0 -199
  169. package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
  170. package/.sillyspec/knowledge/INDEX.md +0 -8
  171. package/.sillyspec/knowledge/uncategorized.md +0 -3
  172. package/.sillyspec/plans/2026-04-05-dashboard.md +0 -737
  173. package/.sillyspec/projects/sillyspec.yaml +0 -3
  174. package/dist/steps/brainstorm/01-load-context.md +0 -30
  175. package/dist/steps/brainstorm/02-reuse-check.md +0 -6
  176. package/dist/steps/brainstorm/03-prototype-analysis.md +0 -11
  177. package/dist/steps/brainstorm/04-module-split.md +0 -23
  178. package/dist/steps/brainstorm/05-dialog-explore.md +0 -8
  179. package/dist/steps/brainstorm/06-propose-approaches.md +0 -3
  180. package/dist/steps/brainstorm/07-present-design.md +0 -3
  181. package/dist/steps/brainstorm/08-write-design.md +0 -21
  182. package/dist/steps/brainstorm/09-self-review.md +0 -15
  183. package/dist/steps/brainstorm/10-user-confirm.md +0 -3
  184. package/dist/steps/brainstorm/11-output-spec.md +0 -7
  185. package/dist/steps/brainstorm/manifest.yaml +0 -26
  186. package/dist/steps/execute/01-load-context.md +0 -41
  187. package/dist/steps/execute/02-scan-conventions.md +0 -47
  188. package/dist/steps/execute/03-skill-mcp.md +0 -19
  189. package/dist/steps/execute/04-assign-task.md +0 -22
  190. package/dist/steps/execute/04b-prompt-template.md +0 -54
  191. package/dist/steps/execute/05-write-test.md +0 -7
  192. package/dist/steps/execute/06-write-code.md +0 -8
  193. package/dist/steps/execute/07-run-test.md +0 -26
  194. package/dist/steps/execute/08-fix-issues.md +0 -28
  195. package/dist/steps/execute/09-next-task.md +0 -33
  196. package/dist/steps/execute/manifest.yaml +0 -28
  197. package/dist/steps/plan/01-load-context.md +0 -22
  198. package/dist/steps/plan/02-anchor-confirm.md +0 -1
  199. package/dist/steps/plan/03-expand-tasks.md +0 -33
  200. package/dist/steps/plan/04-mark-order.md +0 -15
  201. package/dist/steps/plan/05-e2e-planning.md +0 -17
  202. package/dist/steps/plan/06-self-check.md +0 -16
  203. package/dist/steps/plan/07-save.md +0 -1
  204. package/dist/steps/plan/manifest.yaml +0 -18
  205. package/dist/steps/scan/01-env-detect.md +0 -51
  206. package/dist/steps/scan/02-tech-stack.md +0 -16
  207. package/dist/steps/scan/03-conventions.md +0 -16
  208. package/dist/steps/scan/04-structure.md +0 -19
  209. package/dist/steps/scan/05-quality.md +0 -18
  210. package/dist/steps/scan/06-complete.md +0 -49
  211. package/dist/steps/scan/manifest.yaml +0 -16
  212. package/dist/steps/verify/01-load-specs.md +0 -28
  213. package/dist/steps/verify/02-check-tasks.md +0 -1
  214. package/dist/steps/verify/03-check-design.md +0 -6
  215. package/dist/steps/verify/04-run-tests.md +0 -7
  216. package/dist/steps/verify/05-e2e-tests.md +0 -27
  217. package/dist/steps/verify/05b-e2e-fix.md +0 -33
  218. package/dist/steps/verify/06-code-quality.md +0 -25
  219. package/dist/steps/verify/07-lint-check.md +0 -27
  220. package/dist/steps/verify/08-output-report.md +0 -14
  221. package/dist/steps/verify/manifest.yaml +0 -22
  222. package/packages/dashboard/dist/assets/index-DGe8CqeP.css +0 -1
  223. package/src/derive.js +0 -147
  224. package/src/step.js +0 -543
@@ -0,0 +1,982 @@
1
+ /**
2
+ * SillySpec WorktreeManager — git worktree 生命周期管理
3
+ *
4
+ * 封装 git worktree 的 create/list/cleanup/getMeta 操作,
5
+ * 为 execute 阶段提供代码隔离环境。
6
+ *
7
+ * worktree 存储目录:.sillyspec/.runtime/worktrees/<change-name>/
8
+ * 分支命名:sillyspec/<change-name>
9
+ */
10
+
11
+ import { execSync } from 'child_process';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
13
+ import { join, resolve, dirname } from 'path';
14
+ import { createHash } from 'crypto';
15
+ import { provisionDeps, lockfileHash } from './worktree-deps.js';
16
+
17
+ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
18
+ const BRANCH_PREFIX = 'sillyspec/';
19
+ const META_FILE = 'meta.json';
20
+
21
+ /**
22
+ * 检测当前目录的隔离状态
23
+ * 返回 { inWorktree: boolean, inSubmodule: boolean }
24
+ *
25
+ * 用 git rev-parse --git-dir 和 --git-common-dir 判断:
26
+ * - GIT_DIR != GIT_COMMON 通常是 linked worktree
27
+ * - 但在 git submodule 里也会出现这种情况
28
+ * - 所以必须额外检查 --show-superproject-working-tree 排除 submodule
29
+ */
30
+ export function detectIsolation(cwd = process.cwd()) {
31
+ try {
32
+ const gitDir = execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
33
+ const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
34
+ const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
35
+
36
+ const inWorktree = gitDir !== gitCommonDir && !superProject;
37
+ const inSubmodule = !!superProject;
38
+
39
+ return { inWorktree, inSubmodule, gitDir, gitCommonDir };
40
+ } catch {
41
+ return { inWorktree: false, inSubmodule: false, gitDir: null, gitCommonDir: null };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 检查 worktree 存储目录是否被 .gitignore 忽略
47
+ * @param {string} cwd - 项目根目录
48
+ * @returns {{ ignored: boolean, path: string }}
49
+ */
50
+ export function checkWorktreeDirIgnored(cwd = process.cwd()) {
51
+ const relPath = WORKTREES_REL;
52
+ try {
53
+ execSync(`git check-ignore -q ${relPath}`, { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
54
+ return { ignored: true, path: relPath };
55
+ } catch {
56
+ return { ignored: false, path: relPath };
57
+ }
58
+ }
59
+
60
+ function git(cwd, args) {
61
+ return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
62
+ }
63
+
64
+ function gitQuiet(cwd, args) {
65
+ try {
66
+ return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function parseJSON(raw) {
73
+ try { return JSON.parse(raw); } catch { return null; }
74
+ }
75
+
76
+ function computeBaselineHash(cwd) {
77
+ // 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
78
+ const exclude = '-- . ":(exclude).sillyspec/"';
79
+ const staged = gitQuiet(cwd, `diff --cached ${exclude}`) || '';
80
+ const unstaged = gitQuiet(cwd, `diff ${exclude}`) || '';
81
+ const untracked = gitQuiet(cwd, `ls-files --others --exclude-standard ${exclude}`) || '';
82
+ const raw = `staged:${staged}
83
+ unstaged:${unstaged}
84
+ untracked:${untracked}`;
85
+ return createHash('sha256').update(raw).digest('hex').slice(0, 16);
86
+ }
87
+
88
+ function validateChangeName(changeName) {
89
+ if (!changeName || typeof changeName !== 'string' || changeName.trim() === '') {
90
+ throw new Error('changeName 不能为空');
91
+ }
92
+ const trimmed = changeName.trim();
93
+ // 禁止路径穿越
94
+ if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
95
+ throw new Error(`changeName 不合法: "${changeName}",不能包含 ..、/ 或 \\`);
96
+ }
97
+ return trimmed;
98
+ }
99
+
100
+ /**
101
+ * 检测 git worktree 是否可用
102
+ * @param {string} cwd
103
+ * @returns {{ supported: boolean, version: string|null, reason?: string }}
104
+ */
105
+ export function isGitWorktreeSupported(cwd = process.cwd()) {
106
+ try {
107
+ const raw = execSync('git --version', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
108
+ const match = raw.match(/git version (\d+)\.(\d+)/);
109
+ if (!match) return { supported: false, version: raw, reason: 'cannot parse version' };
110
+ const major = parseInt(match[1], 10);
111
+ const minor = parseInt(match[2], 10);
112
+ if (major > 2 || (major === 2 && minor >= 15)) {
113
+ return { supported: true, version: raw };
114
+ }
115
+ return { supported: false, version: raw, reason: 'git version < 2.15' };
116
+ } catch {
117
+ return { supported: false, version: null, reason: 'git not found' };
118
+ }
119
+ }
120
+
121
+ export class WorktreeManager {
122
+ constructor({ cwd, worktreeDir } = {}) {
123
+ this.cwd = cwd || process.cwd();
124
+
125
+ // worktreeBase 必须固定到主仓库路径,不能跟着 cwd 变化。
126
+ // native-worktree 模式下 cwd 是 worktree 子目录,用 cwd 推导 worktreeBase
127
+ // 会导致 meta 写入 worktree 内部路径,worktree 内再次执行时找不到。
128
+ // 解决:用 git rev-parse --git-common-dir 反推主仓库路径。
129
+ if (worktreeDir) {
130
+ this.worktreeBase = worktreeDir;
131
+ } else {
132
+ this.worktreeBase = resolve(this._resolveMainRepoRoot(), WORKTREES_REL);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 解析当前 git 环境对应的主仓库根目录
138
+ * 在主仓库内执行:返回 cwd 自身
139
+ * 在 linked worktree 内执行:返回 git-common-dir 的父目录(即主仓库 .git 所在地)
140
+ * @private
141
+ */
142
+ _resolveMainRepoRoot() {
143
+ try {
144
+ // git-common-dir 在主仓库内 = <main>/.git
145
+ // 在 linked worktree 内 = <main>/.git(git 共享 .git 目录)
146
+ const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
147
+ if (!commonDir) return this.cwd;
148
+
149
+ // commonDir 应该是 <main-repo>/.git
150
+ // dirname(commonDir) = <main-repo>
151
+ if (existsSync(commonDir)) {
152
+ const st = statSync(commonDir);
153
+ if (st.isDirectory()) {
154
+ return dirname(commonDir);
155
+ }
156
+ }
157
+ } catch (e) {
158
+ // 静默 fallback:主仓库内执行或 git 异常
159
+ }
160
+ return this.cwd;
161
+ }
162
+
163
+ /**
164
+ * 获取 worktree 目录绝对路径
165
+ * @param {string} changeName
166
+ * @returns {string}
167
+ */
168
+ getWorktreePath(changeName) {
169
+ return resolve(this.worktreeBase, changeName);
170
+ }
171
+
172
+ /**
173
+ * 读取 worktree 元数据
174
+ * @param {string} changeName
175
+ * @returns {object|null} meta.json 内容,不存在或损坏返回 null
176
+ */
177
+ getMeta(changeName) {
178
+ const name = validateChangeName(changeName);
179
+ const metaPath = join(this.getWorktreePath(name), META_FILE);
180
+ if (!existsSync(metaPath)) return null;
181
+ return parseJSON(readFileSync(metaPath, 'utf8'));
182
+ }
183
+
184
+ /**
185
+ * 创建 worktree
186
+ * @param {string} changeName - 变更名
187
+ * @param {{ base?: string }} opts - base: 基础分支,默认当前 HEAD
188
+ * @returns {{ branch: string, worktreePath: string, baseHash: string }}
189
+ * @throws {Error} worktree 已存在、git 不可用、changeName 为空
190
+ */
191
+ create(changeName, { base } = {}) {
192
+ const name = validateChangeName(changeName);
193
+ const worktreePath = this.getWorktreePath(name);
194
+ const branch = BRANCH_PREFIX + name;
195
+
196
+ // 0. 检测当前环境隔离状态(submodule guard)
197
+ const isolation = detectIsolation(this.cwd);
198
+ if (isolation.inSubmodule) {
199
+ throw new Error(
200
+ '当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
201
+ '\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
202
+ );
203
+ }
204
+ if (isolation.inWorktree) {
205
+ // 已在 linked worktree 中,复用当前目录作为 worktree 路径
206
+ console.log(`ℹ️ 已在 linked worktree 中(git-dir: ${isolation.gitDir}),复用当前隔离环境。`);
207
+
208
+ // 幂等守卫:meta 已存在时不重新 overlay baseline
209
+ const existingMeta = this.getMeta(name)
210
+ if (existingMeta) {
211
+ return { branch: existingMeta.branch, worktreePath: existingMeta.worktreePath, baseHash: existingMeta.baseHash, mode: existingMeta.mode }
212
+ }
213
+
214
+ // meta 不存在但已在 worktree 内:可能是 meta 被损坏/误删。
215
+ // 绝对禁止 overlay baseline(source === target 会冲突),
216
+ // 只恢复 meta 引用,不触碰文件系统。
217
+ return this._recoverNativeWorktreeMeta(name, {
218
+ worktreePath: this.cwd,
219
+ branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
220
+ });
221
+ }
222
+
223
+ // 1. 检查 worktree 目录是否被 gitignore
224
+ const ignoreStatus = checkWorktreeDirIgnored(this.cwd);
225
+ if (!ignoreStatus.ignored) {
226
+ throw new Error(
227
+ `worktree 存储目录 ${ignoreStatus.path} 未被 .gitignore 忽略,` +
228
+ `创建 worktree 可能导致内容被误提交。\n` +
229
+ `请先在 .gitignore 中添加: ${ignoreStatus.path}/\n` +
230
+ `或运行 sillyspec doctor 检查修复。`
231
+ );
232
+ }
233
+
234
+ // 2. 检查 worktree 是否已存在
235
+ if (existsSync(worktreePath)) {
236
+ // 目录在但 meta.json 不存在(幽灵状态),自动清理
237
+ if (!this.getMeta(name)) {
238
+ console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json),自动清理...`);
239
+ try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
240
+ } else {
241
+ throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
242
+ }
243
+ }
244
+
245
+ // 2. 检查分支是否已存在
246
+ if (gitQuiet(this.cwd, `rev-parse --verify refs/heads/${branch}`)) {
247
+ throw new Error(`branch already exists: ${branch}. Run cleanup first.`);
248
+ }
249
+
250
+ // 3. 解析 base 分支
251
+ let baseBranch = base;
252
+ let baseHash;
253
+ if (baseBranch) {
254
+ baseHash = git(this.cwd, `rev-parse ${baseBranch}`);
255
+ } else {
256
+ // 默认用当前 HEAD
257
+ baseBranch = gitQuiet(this.cwd, `symbolic-ref --short HEAD`) || git(this.cwd, `rev-parse HEAD`);
258
+ baseHash = git(this.cwd, `rev-parse HEAD`);
259
+ }
260
+
261
+ // 4. 创建 worktree 根目录
262
+ if (!existsSync(this.worktreeBase)) {
263
+ mkdirSync(this.worktreeBase, { recursive: true });
264
+ }
265
+
266
+ // 5. 创建 worktree(含版本检测 + sandbox fallback)
267
+ try {
268
+ git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
269
+ } catch (e) {
270
+ const check = isGitWorktreeSupported(this.cwd);
271
+ 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`);
273
+ }
274
+ // sandbox/permission fallback: 降级为 in-place + baseline protection
275
+ console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
276
+ console.log(` 原因: ${e.stderr || e.message}`);
277
+ return this._createInPlaceMeta(name, {
278
+ worktreePath: this.cwd,
279
+ branch,
280
+ baseBranch,
281
+ baseHash,
282
+ mode: 'in-place-fallback',
283
+ });
284
+ }
285
+
286
+ // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
287
+ let syncStatus = 'ok';
288
+ let syncError = null;
289
+ try {
290
+ // 先 fetch origin
291
+ gitQuiet(worktreePath, 'fetch origin');
292
+
293
+ // 尝试 merge origin/main(或 origin/master)到 worktree 分支
294
+ const defaultBranch = gitQuiet(this.cwd, 'symbolic-ref refs/remotes/origin/HEAD --short')?.replace('origin/', '')
295
+ || gitQuiet(this.cwd, 'rev-parse --abbrev-ref origin/main') ? 'main'
296
+ : gitQuiet(this.cwd, 'rev-parse --abbrev-ref origin/master') ? 'master'
297
+ : null;
298
+
299
+ if (defaultBranch) {
300
+ // 检查 worktree 是否落后于远程
301
+ const localHead = gitQuiet(worktreePath, 'rev-parse HEAD');
302
+ const remoteHead = gitQuiet(worktreePath, `rev-parse origin/${defaultBranch}`);
303
+
304
+ if (localHead && remoteHead && localHead !== remoteHead) {
305
+ // 检查是否有共同祖先(避免完全不相关的分支强行 merge)
306
+ const mergeBase = gitQuiet(worktreePath, `merge-base ${localHead} origin/${defaultBranch}`);
307
+ if (mergeBase) {
308
+ git(worktreePath, `merge origin/${defaultBranch} --ff-only`);
309
+ }
310
+ }
311
+ }
312
+ } catch (e) {
313
+ syncStatus = 'failed';
314
+ syncError = e.message || String(e);
315
+ console.warn(`⚠️ worktree 远程同步失败:${syncError}`);
316
+ }
317
+
318
+ // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
319
+ const baselineResult = this._overlayBaseline(this.cwd, worktreePath);
320
+ const baselineFiles = baselineResult.files;
321
+ const baselineHash = baselineResult.baselineHash;
322
+
323
+ // 5.7 创建 baseline checkpoint(有 dirty baseline 时才创建)
324
+ let baselineCommit = null;
325
+ if (baselineFiles.length > 0) {
326
+ baselineCommit = this._createBaselineCheckpoint(worktreePath, name);
327
+ }
328
+
329
+ // 5.8 依赖供给(change 2026-06-28-worktree-deps-provision)
330
+ // baseline overlay 后让 worktree 立即可构建/测试;失败不阻断 create,只记 meta。
331
+ let deps = {};
332
+ try {
333
+ deps = provisionDeps(worktreePath, this.cwd, { specBase: join(this.cwd, '.sillyspec') }) || {};
334
+ } catch (e) {
335
+ deps = { depsStatus: 'failed', depsError: `provisionDeps crashed: ${e.message}` };
336
+ }
337
+
338
+ // 6. 写入 meta.json
339
+ const meta = {
340
+ changeName: name,
341
+ branch,
342
+ baseBranch,
343
+ baseHash,
344
+ actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
345
+ createdAt: new Date().toISOString(),
346
+ worktreePath,
347
+ mode: 'worktree',
348
+ baselineFiles,
349
+ baselineCommit,
350
+ baselineHash,
351
+ syncStatus,
352
+ ...(syncError ? { syncError } : {}),
353
+ depsStatus: deps.depsStatus,
354
+ depsMethod: deps.depsMethod || null,
355
+ depsSource: deps.depsSource || null,
356
+ depsLockHash: deps.depsLockHash || null,
357
+ depsCheckedAt: deps.depsCheckedAt || null,
358
+ ...(deps.depsError ? { depsError: deps.depsError } : {}),
359
+ };
360
+
361
+ const metaPath = join(worktreePath, META_FILE);
362
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
363
+
364
+ return { branch, worktreePath, baseHash, mode: meta.mode };
365
+ }
366
+
367
+ /**
368
+ * native-worktree 模式下恢复 meta 引用
369
+ * 当 meta.json 被损坏/误删时,只重建 meta 文件,不触碰文件系统(不 overlay)
370
+ * @private
371
+ */
372
+ _recoverNativeWorktreeMeta(name, { worktreePath, branch }) {
373
+ const baseHash = gitQuiet(worktreePath, 'rev-parse HEAD') || null
374
+ const meta = {
375
+ changeName: name,
376
+ branch: branch || BRANCH_PREFIX + name,
377
+ baseBranch: branch,
378
+ baseHash,
379
+ actualBaseHash: baseHash,
380
+ createdAt: new Date().toISOString(),
381
+ worktreePath,
382
+ mode: 'native-worktree',
383
+ baselineFiles: [],
384
+ baselineCommit: null,
385
+ baselineHash: null,
386
+ recoveredAt: new Date().toISOString(),
387
+ recoveryNote: 'meta was missing in native-worktree; recovered without baseline overlay',
388
+ }
389
+ if (!existsSync(this.worktreeBase)) mkdirSync(this.worktreeBase, { recursive: true })
390
+ const metaDir = join(this.worktreeBase, name)
391
+ if (!existsSync(metaDir)) mkdirSync(metaDir, { recursive: true })
392
+ writeFileSync(join(metaDir, META_FILE), JSON.stringify(meta, null, 2) + '\n')
393
+ console.log(`🔗 native-worktree meta 已恢复: ${metaDir}/meta.json`)
394
+ return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode }
395
+ }
396
+
397
+ /**
398
+ * 创建 in-place 模式的 meta.json(降级路径)
399
+ * 不创建 git worktree,直接在当前目录记录 baseline 并写入 meta
400
+ * @private
401
+ */
402
+ _createInPlaceMeta(name, { worktreePath, branch, baseBranch, baseHash, mode } = {}) {
403
+ // 幂等守卫:meta 已存在时不重新创建(避免 overlay baseline 和已有改动冲突)
404
+ const existingMeta = this.getMeta(name)
405
+ if (existingMeta) {
406
+ return { branch: existingMeta.branch, worktreePath: existingMeta.worktreePath, baseHash: existingMeta.baseHash, mode: existingMeta.mode }
407
+ }
408
+
409
+ // 硬规则:禁止 self-overlay(source 和 target 相同时 overlay 必然冲突)
410
+ const resolvedSource = resolve(this.cwd)
411
+ const resolvedTarget = resolve(worktreePath)
412
+ if (resolvedSource === resolvedTarget) {
413
+ console.warn('⚠️ 跳过 baseline overlay:当前目录与目标目录相同(native-worktree 或 in-place 模式)')
414
+ // 写 meta 但不 overlay
415
+ baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD')
416
+ baseHash = baseHash || git(this.cwd, 'rev-parse HEAD')
417
+ const meta = {
418
+ changeName: name,
419
+ branch: branch || BRANCH_PREFIX + name,
420
+ baseBranch,
421
+ baseHash,
422
+ actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
423
+ createdAt: new Date().toISOString(),
424
+ worktreePath,
425
+ mode: mode || 'in-place-fallback',
426
+ baselineFiles: [],
427
+ baselineCommit: null,
428
+ baselineHash: null,
429
+ }
430
+ if (!existsSync(this.worktreeBase)) mkdirSync(this.worktreeBase, { recursive: true })
431
+ const metaDir = join(this.worktreeBase, name)
432
+ if (!existsSync(metaDir)) mkdirSync(metaDir, { recursive: true })
433
+ writeFileSync(join(metaDir, META_FILE), JSON.stringify(meta, null, 2) + '\n')
434
+ return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode }
435
+ }
436
+
437
+ // 解析 base
438
+ if (!baseHash) {
439
+ baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
440
+ baseHash = git(this.cwd, 'rev-parse HEAD');
441
+ }
442
+
443
+ const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
444
+ const baselineFiles = baselineResult.files;
445
+ const baselineHash = baselineResult.baselineHash;
446
+
447
+ let baselineCommit = null;
448
+ if (baselineFiles.length > 0) {
449
+ baselineCommit = this._createBaselineCheckpoint(this.cwd, name);
450
+ }
451
+
452
+ const meta = {
453
+ changeName: name,
454
+ branch: branch || BRANCH_PREFIX + name,
455
+ baseBranch,
456
+ baseHash,
457
+ actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
458
+ createdAt: new Date().toISOString(),
459
+ worktreePath,
460
+ mode: mode || 'in-place-fallback',
461
+ baselineFiles,
462
+ baselineCommit,
463
+ baselineHash,
464
+ };
465
+
466
+ // in-place 模式下 meta 写入 worktreeBase(避免污染主工作区)
467
+ if (!existsSync(this.worktreeBase)) {
468
+ mkdirSync(this.worktreeBase, { recursive: true });
469
+ }
470
+ const metaPath = join(this.worktreeBase, name, META_FILE);
471
+ const metaDir = join(this.worktreeBase, name);
472
+ if (!existsSync(metaDir)) {
473
+ mkdirSync(metaDir, { recursive: true });
474
+ }
475
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
476
+
477
+ return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode };
478
+ }
479
+
480
+ /**
481
+ * 构建 isolation 信息对象,用于写入 gate-status.json
482
+ * @param {string} changeName
483
+ * @returns {{ status: string, mode: string, path: string } | null}
484
+ */
485
+ getIsolationInfo(changeName) {
486
+ const meta = this.getMeta(changeName);
487
+ if (!meta) return null;
488
+
489
+ const mode = meta.mode || 'worktree';
490
+ const statusMap = {
491
+ 'worktree': 'verified',
492
+ 'native-worktree': 'verified',
493
+ 'in-place-fallback': 'degraded',
494
+ };
495
+
496
+ return {
497
+ status: statusMap[mode] || 'verified',
498
+ mode,
499
+ path: meta.worktreePath,
500
+ branch: meta.branch,
501
+ };
502
+ }
503
+
504
+ /**
505
+ * 获取 worktree 的运行模式
506
+ * @param {string} changeName
507
+ * @returns {'worktree'|'native-worktree'|'in-place-fallback'|null}
508
+ */
509
+ getMode(changeName) {
510
+ const meta = this.getMeta(changeName);
511
+ return meta?.mode || null;
512
+ }
513
+
514
+ /**
515
+ * 列出所有活跃 worktree
516
+ * @returns {Array<{ changeName: string, branch: string, baseHash: string, createdAt: string, worktreePath: string }>}
517
+ */
518
+ list() {
519
+ const results = [];
520
+ if (!existsSync(this.worktreeBase)) return results;
521
+
522
+ const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
523
+ for (const entry of entries) {
524
+ if (!entry.isDirectory()) continue;
525
+ const metaPath = join(this.worktreeBase, entry.name, META_FILE);
526
+ if (!existsSync(metaPath)) continue;
527
+ const meta = parseJSON(readFileSync(metaPath, 'utf8'));
528
+ if (!meta) continue;
529
+ results.push({
530
+ changeName: meta.changeName,
531
+ branch: meta.branch,
532
+ baseHash: meta.baseHash,
533
+ baseBranch: meta.baseBranch,
534
+ createdAt: meta.createdAt,
535
+ worktreePath: meta.worktreePath,
536
+ mode: meta.mode || 'worktree',
537
+ });
538
+ }
539
+
540
+ return results;
541
+ }
542
+
543
+ /**
544
+ * 清理 worktree(仅限 SillySpec 创建的临时 worktree)
545
+ * 幂等:重复调用不报错。
546
+ * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
547
+ * @param {string} changeName
548
+ * @param {{ force?: boolean, maxRetries?: number }} opts
549
+ * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
550
+ */
551
+ cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
552
+ const name = validateChangeName(changeName);
553
+ const meta = this.getMeta(name);
554
+ const worktreePath = this.getWorktreePath(name);
555
+ const metaDir = join(this.worktreeBase, name);
556
+ const details = [];
557
+
558
+ // 幂等:什么都不存在 → 直接跳过
559
+ if (!meta && !existsSync(worktreePath) && !existsSync(metaDir)) {
560
+ return { result: 'skipped', mode: null, details };
561
+ }
562
+
563
+ const mode = meta?.mode || 'worktree';
564
+
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
+ }
573
+ }
574
+
575
+ const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
576
+
577
+ // 1. git worktree remove(带 retry)
578
+ let gitRemoveOk = false;
579
+ if (existsSync(worktreePath)) {
580
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
581
+ try {
582
+ git(this.cwd, `worktree remove ${worktreePath} --force`);
583
+ gitRemoveOk = true;
584
+ details.push(`git worktree remove succeeded (attempt ${attempt})`);
585
+ break;
586
+ } catch (e) {
587
+ details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
588
+ if (attempt < maxRetries) {
589
+ // 短暂等待后重试
590
+ execSync('sleep 0.5', { stdio: 'pipe' });
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ // 2. fallback: 确保 worktree 目录已删除
597
+ if (existsSync(worktreePath)) {
598
+ try {
599
+ rmSync(worktreePath, { recursive: true, force: true });
600
+ details.push('worktree directory force-removed (fallback)');
601
+ } catch (e) {
602
+ details.push(`worktree directory force-remove failed: ${e.message}`);
603
+ }
604
+ }
605
+
606
+ // 3. git worktree prune(清理 git 内部注册信息)
607
+ try {
608
+ gitQuiet(this.cwd, 'worktree prune');
609
+ } catch {
610
+ // prune 失败不阻断
611
+ }
612
+
613
+ // 4. 删除分支(忽略分支不存在的错误)
614
+ try {
615
+ gitQuiet(this.cwd, `branch -D ${branch}`);
616
+ details.push('branch deleted');
617
+ } catch {
618
+ // 分支可能已被删除,幂等跳过
619
+ }
620
+
621
+ // 5. 清除 meta 目录
622
+ if (existsSync(metaDir)) {
623
+ try {
624
+ rmSync(metaDir, { recursive: true, force: true });
625
+ details.push('meta directory cleaned');
626
+ } catch (e) {
627
+ details.push(`meta directory cleanup failed: ${e.message}`);
628
+ }
629
+ }
630
+
631
+ // 6. 最终验证:确认三重清理完成
632
+ const residual = [];
633
+ if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
634
+ if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
635
+ if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
636
+ residual.push('git worktree list still references this worktree');
637
+ }
638
+ if (residual.length > 0) {
639
+ details.push(`⚠️ 残留: ${residual.join('; ')}`);
640
+ }
641
+
642
+ return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode, details };
643
+ }
644
+
645
+ /**
646
+ * worktree 健康检查 + 可选修复
647
+ * 检查项:
648
+ * - git worktree list 中的孤儿条目(目录不存在)
649
+ * - worktree 目录存在但 git 不认识
650
+ * - meta 存在但 worktree 目录不存在
651
+ * - worktree 目录存在但 meta 不存在(幽灵目录)
652
+ * - SillySpec 分支残留(sillyspec/* 但无对应 meta)
653
+ * - 超过指定小时的过期 worktree
654
+ *
655
+ * @param {{ fix?: boolean, staleHours?: number }} opts
656
+ * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
657
+ */
658
+ _doctorReprovision(name, wtPath) {
659
+ try {
660
+ const deps = provisionDeps(wtPath, this.cwd, { specBase: join(this.cwd, '.sillyspec') }) || {};
661
+ const metaPath = join(this.getWorktreePath(name), META_FILE);
662
+ const meta = this.getMeta(name) || {};
663
+ writeFileSync(metaPath, JSON.stringify({ ...meta, ...deps }, null, 2) + '\n');
664
+ return { ok: true, msg: `re-provisioned ${name}: depsStatus=${deps.depsStatus}` };
665
+ } catch (e) {
666
+ return { ok: false, msg: `re-provision failed for ${name}: ${e.message}` };
667
+ }
668
+ }
669
+
670
+ doctor({ fix = false, staleHours = 24 } = {}) {
671
+ const issues = [];
672
+ const fixed = [];
673
+ const unfixable = [];
674
+
675
+ // 1. 列出 git worktree list 中的条目
676
+ let gitWorktreeList = [];
677
+ try {
678
+ const raw = execSync(`git worktree list --porcelain`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
679
+ const entries = raw.split(/\n\n/).filter(Boolean);
680
+ for (const entry of entries) {
681
+ const lines = entry.split('\n');
682
+ const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
683
+ if (wtPath && wtPath !== this.cwd) { // 排除主工作区
684
+ gitWorktreeList.push({ path: wtPath, raw: entry });
685
+ }
686
+ }
687
+ } catch {
688
+ // git worktree 不可用,跳过
689
+ }
690
+
691
+ // 2. 列出 SillySpec meta 条目
692
+ const metaEntries = this.list();
693
+ const metaNames = new Set(metaEntries.map(m => m.changeName));
694
+
695
+ // 3. 检查 git worktree list 中的孤儿条目
696
+ for (const wt of gitWorktreeList) {
697
+ if (!existsSync(wt.path)) {
698
+ 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}`); }
702
+ }
703
+ }
704
+ }
705
+
706
+ // 4. 扫描 worktreeBase 目录,检查幽灵目录和孤儿 meta
707
+ if (existsSync(this.worktreeBase)) {
708
+ const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
709
+ for (const entry of entries) {
710
+ if (!entry.isDirectory()) continue;
711
+ const name = entry.name;
712
+ const dirPath = join(this.worktreeBase, name);
713
+ const hasMeta = existsSync(join(dirPath, META_FILE));
714
+ const meta = hasMeta ? this.getMeta(name) : null;
715
+
716
+ // deps 依赖状态检查(change 2026-06-28-worktree-deps-provision)
717
+ if (meta && meta.worktreePath && existsSync(meta.worktreePath) && meta.mode !== 'in-place-fallback') {
718
+ const wtPath = meta.worktreePath;
719
+ const nmExists = existsSync(join(wtPath, 'node_modules'));
720
+ const curHash = lockfileHash(wtPath);
721
+ let depsIssue = null;
722
+ if (['linked', 'installed'].includes(meta.depsStatus) && !nmExists) {
723
+ depsIssue = { type: 'deps-missing', detail: 'meta.depsStatus=' + meta.depsStatus + ' 但 node_modules 缺失' };
724
+ } else if (meta.depsLockHash && curHash && curHash !== meta.depsLockHash) {
725
+ depsIssue = { type: 'deps-stale', detail: 'lockfile 变化 (' + meta.depsLockHash + ' -> ' + curHash + ')' };
726
+ } else if (meta.depsStatus === 'failed') {
727
+ depsIssue = { type: 'deps-failed', detail: '上次依赖供给失败' + (meta.depsError ? ': ' + meta.depsError : '') };
728
+ }
729
+ if (depsIssue) {
730
+ issues.push({ type: depsIssue.type, name, detail: depsIssue.detail, fixable: true });
731
+ if (fix) {
732
+ const r = this._doctorReprovision(name, wtPath);
733
+ (r.ok ? fixed : unfixable).push(r.msg);
734
+ }
735
+ }
736
+ }
737
+
738
+ // meta 存在但 worktree 目录不存在
739
+ if (meta && meta.worktreePath && !existsSync(meta.worktreePath)) {
740
+ issues.push({ type: 'meta-no-dir', name, detail: `meta 存在但 worktree 目录不存在: ${meta.worktreePath}`, fixable: true });
741
+ if (fix) {
742
+ try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`cleaned orphan meta: ${name}`); } catch { unfixable.push(`cleanup failed for: ${name}`); }
743
+ }
744
+ }
745
+
746
+ // worktree 目录存在但 meta 不存在(幽灵目录)
747
+ if (!hasMeta && existsSync(dirPath)) {
748
+ // 可能是 in-place 模式的 meta-only 目录,或者真正的幽灵
749
+ const files = readdirSync(dirPath);
750
+ if (files.length === 0 || (files.length === 1 && files[0] === META_FILE)) {
751
+ issues.push({ type: 'ghost-dir', name, detail: `空目录/幽灵目录: ${dirPath}`, fixable: true });
752
+ if (fix) {
753
+ try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`removed ghost dir: ${name}`); } catch { unfixable.push(`remove failed for: ${name}`); }
754
+ }
755
+ } else {
756
+ issues.push({ type: 'ghost-dir-with-files', name, detail: `目录存在但无 meta.json: ${dirPath} (含 ${files.length} 文件)`, fixable: false });
757
+ }
758
+ }
759
+
760
+ // 检查过期 worktree
761
+ if (meta && meta.createdAt) {
762
+ const ageMs = Date.now() - new Date(meta.createdAt).getTime();
763
+ const ageHours = ageMs / (1000 * 60 * 60);
764
+ 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') {
767
+ try {
768
+ const result = this.cleanup(name);
769
+ if (result.result === 'cleaned' || result.result === 'force-cleaned') {
770
+ fixed.push(`cleaned stale: ${name}`);
771
+ } else {
772
+ unfixable.push(`cleanup skipped: ${name}`);
773
+ }
774
+ } catch { unfixable.push(`cleanup failed: ${name}`); }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+
781
+ // 5. 检查 SillySpec 分支残留
782
+ try {
783
+ const branches = execSync(`git branch --list '${BRANCH_PREFIX}*'`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
784
+ if (branches) {
785
+ for (const line of branches.split('\n').filter(Boolean)) {
786
+ const branch = line.replace(/^\*?\s+/, '').trim();
787
+ const name = branch.replace(BRANCH_PREFIX, '');
788
+ if (!metaNames.has(name)) {
789
+ issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
790
+ if (fix) {
791
+ try { gitQuiet(this.cwd, `branch -D ${branch}`); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
792
+ }
793
+ }
794
+ }
795
+ }
796
+ } catch {}
797
+
798
+ return { issues, fixed, unfixable };
799
+ }
800
+
801
+ /**
802
+ * 检查 worktree 是否有未 apply 到主工作区的变更
803
+ * @param {string} changeName
804
+ * @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
805
+ */
806
+ hasUnappliedChanges(changeName) {
807
+ const name = validateChangeName(changeName);
808
+ const meta = this.getMeta(name);
809
+ if (!meta) return { hasChanges: false, changedFiles: [], reason: 'no meta' };
810
+
811
+ const worktreePath = meta.worktreePath;
812
+ if (!worktreePath || !existsSync(worktreePath)) {
813
+ return { hasChanges: false, changedFiles: [], reason: 'worktree dir not found' };
814
+ }
815
+
816
+ // in-place 模式没有隔离目录,不算有未 apply 的变更
817
+ if (meta.mode === 'in-place-fallback') {
818
+ return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
819
+ }
820
+
821
+ const diffBase = meta.baselineCommit || meta.baseHash;
822
+ if (!diffBase) {
823
+ return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
824
+ }
825
+
826
+ try {
827
+ // tracked 文件变更
828
+ const statusRaw = gitQuiet(worktreePath, `diff --name-status ${diffBase}`) || '';
829
+ const statusFiles = new Set();
830
+ if (statusRaw) {
831
+ for (const line of statusRaw.split('\n').filter(Boolean)) {
832
+ const parts = line.split('\t');
833
+ if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
834
+ if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
835
+ }
836
+ }
837
+
838
+ // untracked 文件
839
+ const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '';
840
+ const untrackedFiles = untrackedRaw
841
+ ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
842
+ : [];
843
+
844
+ const changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
845
+ return { hasChanges: changedFiles.length > 0, changedFiles };
846
+ } catch (e) {
847
+ // 检测失败时保守处理:视为有变更
848
+ return { hasChanges: true, changedFiles: [], reason: `diff failed: ${e.message}` };
849
+ }
850
+ }
851
+
852
+ /**
853
+ * 从 worktree 路径反推 changeName
854
+ * @private
855
+ */
856
+ _pathToChangeName(wtPath) {
857
+ try {
858
+ const resolved = resolve(wtPath);
859
+ const baseResolved = resolve(this.worktreeBase);
860
+ if (resolved.startsWith(baseResolved + '/')) {
861
+ return resolved.slice(baseResolved.length + 1);
862
+ }
863
+ } catch {}
864
+ return null;
865
+ }
866
+
867
+ /**
868
+ * 将主工作区未提交变更同步到 worktree(dirty baseline overlay)
869
+ * 覆盖 staged + unstaged 的文件变更,以及 untracked 文件。
870
+ * 使用 git diff + git apply 确保正确处理删除/rename/binary。
871
+ * @param {string} mainCwd - 主工作区路径
872
+ * @param {string} worktreePath - worktree 路径
873
+ * @returns {Array<string>} overlay 的文件列表
874
+ */
875
+ _overlayBaseline(mainCwd, worktreePath) {
876
+ const files = [];
877
+ const errors = [];
878
+
879
+ try {
880
+ // staged 变更
881
+ const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
882
+ if (staged) {
883
+ try {
884
+ // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
885
+ const patchBuf = execSync(`git diff --cached --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
886
+ if (patchBuf && patchBuf.length > 0) {
887
+ const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
888
+ writeFileSync(patchFile, patchBuf);
889
+ git(worktreePath, `apply --binary ${patchFile}`);
890
+ rmSync(patchFile, { force: true });
891
+ }
892
+ } catch (e) {
893
+ errors.push(`staged: ${e.message}`);
894
+ }
895
+ files.push(...staged.split('\n').filter(Boolean));
896
+ }
897
+
898
+ // unstaged 变更
899
+ const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
900
+ if (unstaged) {
901
+ try {
902
+ // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
903
+ const patchBuf = execSync(`git diff --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
904
+ if (patchBuf && patchBuf.length > 0) {
905
+ const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
906
+ writeFileSync(patchFile, patchBuf);
907
+ git(worktreePath, `apply --binary ${patchFile}`);
908
+ rmSync(patchFile, { force: true });
909
+ }
910
+ } catch (e) {
911
+ errors.push(`unstaged: ${e.message}`);
912
+ }
913
+ files.push(...unstaged.split('\n').filter(Boolean));
914
+ }
915
+
916
+ // untracked 文件(排除 .sillyspec/.runtime 等)
917
+ const untracked = gitQuiet(mainCwd, 'ls-files --others --exclude-standard') || '';
918
+ if (untracked) {
919
+ for (const f of untracked.split('\n').filter(Boolean)) {
920
+ const src = join(mainCwd, f);
921
+ const dst = join(worktreePath, f);
922
+ if (existsSync(src)) {
923
+ mkdirSync(dirname(dst), { recursive: true });
924
+ try { writeFileSync(dst, readFileSync(src)); files.push(f); } catch {}
925
+ }
926
+ }
927
+ }
928
+
929
+ if (files.length > 0) {
930
+ console.log(`📁 baseline overlay: ${files.length} 个未提交文件已同步到 worktree`);
931
+ }
932
+ } catch (e) {
933
+ errors.push(`unexpected: ${e.message}`);
934
+ }
935
+
936
+ // 有 pending 文件但 overlay 部分失败 → fail-fast
937
+ if (errors.length > 0) {
938
+ throw new Error(`baseline overlay 失败 (${errors.length} 个错误): ${errors.join('; ')}`);
939
+ }
940
+
941
+ const uniqueFiles = [...new Set(files)];
942
+
943
+ // 计算 baseline hash(用于 merge 前校验主工作区是否变化)
944
+ const baselineHash = uniqueFiles.length > 0 ? computeBaselineHash(mainCwd) : null;
945
+
946
+ return { files: uniqueFiles, baselineHash };
947
+ }
948
+
949
+ /**
950
+ * 在 worktree 内创建 baseline checkpoint commit
951
+ * 用于区分 "前置 dirty baseline" 和 "子代理新增改动"
952
+ * @param {string} worktreePath
953
+ * @param {string} changeName
954
+ * @returns {string} commit hash
955
+ */
956
+ _createBaselineCheckpoint(worktreePath, changeName) {
957
+ // 使用临时 git identity,避免用户未配置 user.name/user.email 导致失败
958
+ const env = {
959
+ GIT_AUTHOR_NAME: 'sillyspec',
960
+ GIT_AUTHOR_EMAIL: 'sillyspec@baseline',
961
+ GIT_COMMITTER_NAME: 'sillyspec',
962
+ GIT_COMMITTER_EMAIL: 'sillyspec@baseline',
963
+ };
964
+ try {
965
+ execSync('git add -A', { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env });
966
+ // 检查是否有实际变更(可能 overlay 后和 HEAD 完全一致)
967
+ const status = gitQuiet(worktreePath, 'status --porcelain');
968
+ if (!status) {
969
+ return gitQuiet(worktreePath, 'rev-parse HEAD');
970
+ }
971
+ execSync(
972
+ `git commit -m "sillyspec: baseline checkpoint for ${changeName}"`,
973
+ { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
974
+ );
975
+ const hash = git(worktreePath, 'rev-parse HEAD');
976
+ console.log(`📌 baseline checkpoint: ${hash}`);
977
+ return hash;
978
+ } catch (e) {
979
+ throw new Error(`baseline checkpoint 创建失败: ${e.message}`);
980
+ }
981
+ }
982
+ }