sillyspec 3.25.9 → 3.26.0

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 (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
package/src/worktree.js CHANGED
@@ -8,12 +8,13 @@
8
8
  * 分支命名:sillyspec/<change-name>
9
9
  */
10
10
 
11
- import { execSync } from 'child_process';
11
+ import { execSync, execFileSync } from 'child_process';
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
13
- import { join, resolve, dirname } from 'path';
13
+ import { join, resolve, dirname, relative, isAbsolute } from 'path';
14
14
  import { createHash } from 'crypto';
15
- import { provisionDeps, lockfileHash } from './worktree-deps.js';
15
+ import { provisionDeps, checkDepsFreshness } from './worktree-deps.js';
16
16
  import { writeAtomicSync } from './fs-atomic.js';
17
+ import { git, gitQuiet } from './git-helper.js';
17
18
 
18
19
  // meta.json 会被 hook 进程与其它 CLI 进程并发读取(worktree-guard / getMeta / create 幽灵判定),
19
20
  // 必须原子写:半截 JSON 会让 getMeta 返回 null → 触发幽灵 worktree 强删(可能丢 gitignored 改动)。
@@ -39,9 +40,9 @@ const _mainRepoRootByCwd = new Map();
39
40
  */
40
41
  export function detectIsolation(cwd = process.cwd()) {
41
42
  try {
42
- const gitDir = execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
43
- const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
44
- const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
43
+ const gitDir = git(cwd, ['rev-parse', '--git-dir']);
44
+ const gitCommonDir = git(cwd, ['rev-parse', '--git-common-dir']);
45
+ const superProject = gitQuiet(cwd, ['rev-parse', '--show-superproject-working-tree']);
45
46
 
46
47
  const inWorktree = gitDir !== gitCommonDir && !superProject;
47
48
  const inSubmodule = !!superProject;
@@ -60,25 +61,13 @@ export function detectIsolation(cwd = process.cwd()) {
60
61
  export function checkWorktreeDirIgnored(cwd = process.cwd()) {
61
62
  const relPath = WORKTREES_REL;
62
63
  try {
63
- execSync(`git check-ignore -q ${relPath}`, { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
64
+ git(cwd, ['check-ignore', '-q', relPath]);
64
65
  return { ignored: true, path: relPath };
65
66
  } catch {
66
67
  return { ignored: false, path: relPath };
67
68
  }
68
69
  }
69
70
 
70
- function git(cwd, args) {
71
- return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
72
- }
73
-
74
- function gitQuiet(cwd, args) {
75
- try {
76
- return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
77
- } catch {
78
- return null;
79
- }
80
- }
81
-
82
71
  /**
83
72
  * 检测 worktree base(主仓库 HEAD)与 origin 默认分支的同步状态(只读:不 ff、不改 HEAD)。
84
73
  * 替代旧的 fetch + merge --ff-only:旧逻辑分叉时 ff 失败被静默吞(syncStatus=failed),
@@ -93,12 +82,12 @@ export function computeBaseSync(cwd, baseHash) {
93
82
  const diag = { status: 'unknown', defaultBranch: null, behind: 0, ahead: 0 };
94
83
  // 推断默认分支:优先 origin/HEAD 指向的真实分支名(修复旧 339-342 的运算符优先级 bug
95
84
  // ——旧代码在 origin/HEAD 存在时恒返回 'main',丢弃 symbolic-ref 的真实结果)。
96
- const headRef = gitQuiet(cwd, 'symbolic-ref refs/remotes/origin/HEAD --short');
85
+ const headRef = gitQuiet(cwd, ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short']);
97
86
  let defaultBranch = headRef ? headRef.replace('origin/', '') : null;
98
87
  if (!defaultBranch) {
99
88
  // fallback:origin/HEAD 未设置时,探测常见默认分支是否存在
100
89
  for (const cand of ['main', 'master']) {
101
- if (gitQuiet(cwd, `rev-parse --verify --quiet refs/remotes/origin/${cand}`)) {
90
+ if (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${cand}`])) {
102
91
  defaultBranch = cand;
103
92
  break;
104
93
  }
@@ -108,15 +97,15 @@ export function computeBaseSync(cwd, baseHash) {
108
97
  diag.defaultBranch = defaultBranch;
109
98
 
110
99
  // best-effort fetch(只更新 remote-tracking,不改工作区/HEAD/分支;失败静默降级用缓存)
111
- gitQuiet(cwd, 'fetch origin --quiet');
112
- const remoteHead = gitQuiet(cwd, `rev-parse --verify --quiet refs/remotes/origin/${defaultBranch}`);
100
+ gitQuiet(cwd, ['fetch', 'origin', '--quiet'], { timeout: 60000 });
101
+ const remoteHead = gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${defaultBranch}`]);
113
102
  if (!baseHash || !remoteHead) return diag;
114
103
  if (baseHash === remoteHead) {
115
104
  diag.status = 'up-to-date';
116
105
  return diag;
117
106
  }
118
- const behind = Number(gitQuiet(cwd, `rev-list --count ${baseHash}..${remoteHead}`) || 0);
119
- const ahead = Number(gitQuiet(cwd, `rev-list --count ${remoteHead}..${baseHash}`) || 0);
107
+ const behind = Number(gitQuiet(cwd, ['rev-list', '--count', `${baseHash}..${remoteHead}`]) || 0);
108
+ const ahead = Number(gitQuiet(cwd, ['rev-list', '--count', `${remoteHead}..${baseHash}`]) || 0);
120
109
  diag.behind = behind;
121
110
  diag.ahead = ahead;
122
111
  diag.status = (behind > 0 && ahead > 0) ? 'diverged' : (behind > 0 ? 'behind' : 'ahead');
@@ -179,10 +168,10 @@ export function computeBaselineHash(cwd) {
179
168
  // - docs/:文档(非代码交付物)
180
169
  // - CLAUDE.md:根 agent 指引(多操作者常改)
181
170
  // 必须和 applyWorktree step 4.5 (worktree-apply.js) 使用相同的排除规则。
182
- const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
183
- const staged = gitQuiet(cwd, `diff --cached ${exclude}`) || '';
184
- const unstaged = gitQuiet(cwd, `diff ${exclude}`) || '';
185
- const untracked = gitQuiet(cwd, `ls-files --others --exclude-standard ${exclude}`) || '';
171
+ const exclude = ['--', '.', ':(exclude).sillyspec/', ':(exclude).claude/', ':(exclude)docs/', ':(exclude)CLAUDE.md'];
172
+ const staged = gitQuiet(cwd, ['diff', '--cached', ...exclude], { timeout: 30000 }) || '';
173
+ const unstaged = gitQuiet(cwd, ['diff', ...exclude], { timeout: 30000 }) || '';
174
+ const untracked = gitQuiet(cwd, ['ls-files', '--others', '--exclude-standard', ...exclude], { timeout: 30000 }) || '';
186
175
  const raw = `staged:${staged}
187
176
  unstaged:${unstaged}
188
177
  untracked:${untracked}`;
@@ -208,7 +197,7 @@ function validateChangeName(changeName) {
208
197
  */
209
198
  export function isGitWorktreeSupported(cwd = process.cwd()) {
210
199
  try {
211
- const raw = execSync('git --version', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
200
+ const raw = git(cwd, ['--version']);
212
201
  const match = raw.match(/git version (\d+)\.(\d+)/);
213
202
  if (!match) return { supported: false, version: raw, reason: 'cannot parse version' };
214
203
  const major = parseInt(match[1], 10);
@@ -222,6 +211,31 @@ export function isGitWorktreeSupported(cwd = process.cwd()) {
222
211
  }
223
212
  }
224
213
 
214
+ /**
215
+ * 复制单个 untracked 条目到 worktree(baseline overlay 用)。
216
+ *
217
+ * 防御目录:untracked 项若是目录(如 Claude Code agent worktree 隔离目录 .worktrees/<hash>/),
218
+ * readFileSync 会 EISDIR → 整个 overlay fail-fast。目录跳过(overlay 只同步文件层未提交改动,
219
+ * 目录结构由后续文件写入的 mkdirSync 重建)。坑 execute-worktree-overlay-untracked-dir-eisdir。
220
+ *
221
+ * @param {string} src 主仓库源路径
222
+ * @param {string} dst worktree 目标路径
223
+ * @returns {{status:'copied'|'skipped-dir'|'missing'|'error', error?:string}}
224
+ */
225
+ export function copyUntrackedEntry(src, dst) {
226
+ if (!existsSync(src)) return { status: 'missing' };
227
+ let st;
228
+ try { st = statSync(src); } catch { return { status: 'missing' }; }
229
+ if (st.isDirectory()) return { status: 'skipped-dir' };
230
+ try {
231
+ mkdirSync(dirname(dst), { recursive: true });
232
+ writeFileSync(dst, readFileSync(src));
233
+ return { status: 'copied' };
234
+ } catch (e) {
235
+ return { status: 'error', error: e.message };
236
+ }
237
+ }
238
+
225
239
  export class WorktreeManager {
226
240
  constructor({ cwd, worktreeDir } = {}) {
227
241
  this.cwd = cwd || process.cwd();
@@ -254,7 +268,7 @@ export class WorktreeManager {
254
268
  // existsSync/statSync 会相对 process.cwd() 解析——当 process.cwd 是另一个 git 仓库
255
269
  // (未 chdir 的脚本、或 CLI 在别处跑)时,worktreeBase 会错解析到 process.cwd 仓库,
256
270
  // getMeta 读错位置返回 null。resolve() 对绝对参数原样返回、对相对参数相对 this.cwd 解析,两者皆稳。
257
- const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
271
+ const commonDir = gitQuiet(this.cwd, ['rev-parse', '--git-common-dir']);
258
272
  if (commonDir) {
259
273
  const absCommonDir = resolve(this.cwd, commonDir);
260
274
  if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
@@ -324,7 +338,7 @@ export class WorktreeManager {
324
338
  // 只恢复 meta 引用,不触碰文件系统。
325
339
  return this._recoverNativeWorktreeMeta(name, {
326
340
  worktreePath: this.cwd,
327
- branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
341
+ branch: gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || 'detached',
328
342
  });
329
343
  }
330
344
 
@@ -345,7 +359,7 @@ export class WorktreeManager {
345
359
  // 否则会丢失 execute 期间未 commit 的代码(不可恢复,3.22.4 修复)。
346
360
  if (!this.getMeta(name)) {
347
361
  let uncommitted = '';
348
- try { uncommitted = git(worktreePath, 'status --porcelain') } catch {}
362
+ try { uncommitted = git(worktreePath, ['status', '--porcelain'], { timeout: 30000 }) } catch {}
349
363
  if (uncommitted.trim()) {
350
364
  throw new Error(
351
365
  `检测到幽灵 worktree(无 meta.json)但含未提交改动,拒绝自动清理(防丢代码)。\n` +
@@ -358,15 +372,15 @@ export class WorktreeManager {
358
372
  try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
359
373
  // 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
360
374
  // 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
361
- try { gitQuiet(this.cwd, 'worktree prune'); } catch {}
362
- try { gitQuiet(this.cwd, `branch -D ${branch}`); } catch {}
375
+ try { gitQuiet(this.cwd, ['worktree', 'prune'], { timeout: 30000 }); } catch {}
376
+ try { gitQuiet(this.cwd, ['branch', '-D', branch]); } catch {}
363
377
  } else {
364
378
  throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
365
379
  }
366
380
  }
367
381
 
368
382
  // 2. 检查分支是否已存在
369
- if (gitQuiet(this.cwd, `rev-parse --verify refs/heads/${branch}`)) {
383
+ if (gitQuiet(this.cwd, ['rev-parse', '--verify', `refs/heads/${branch}`])) {
370
384
  throw new Error(`branch already exists: ${branch}. Run cleanup first.`);
371
385
  }
372
386
 
@@ -374,11 +388,11 @@ export class WorktreeManager {
374
388
  let baseBranch = base;
375
389
  let baseHash;
376
390
  if (baseBranch) {
377
- baseHash = git(this.cwd, `rev-parse ${baseBranch}`);
391
+ baseHash = git(this.cwd, ['rev-parse', baseBranch]);
378
392
  } else {
379
393
  // 默认用当前 HEAD
380
- baseBranch = gitQuiet(this.cwd, `symbolic-ref --short HEAD`) || git(this.cwd, `rev-parse HEAD`);
381
- baseHash = git(this.cwd, `rev-parse HEAD`);
394
+ baseBranch = gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || git(this.cwd, ['rev-parse', 'HEAD']);
395
+ baseHash = git(this.cwd, ['rev-parse', 'HEAD']);
382
396
  }
383
397
 
384
398
  // 4. 创建 worktree 根目录
@@ -391,12 +405,12 @@ export class WorktreeManager {
391
405
  // 未切分支,直接写代码污染 main(见缺陷 execute-in-place-windows-pitfalls 坑1)。
392
406
  // core.longpaths=true 让 git 用 \\?\ 前缀绕过 MAX_PATH,幂等、Windows 推荐、低风险,失败不阻断。
393
407
  if (process.platform === 'win32') {
394
- try { gitQuiet(this.cwd, 'config core.longpaths true'); } catch {}
408
+ try { gitQuiet(this.cwd, ['config', 'core.longpaths', 'true']); } catch {}
395
409
  }
396
410
 
397
411
  // 5. 创建 worktree(含版本检测 + sandbox fallback)
398
412
  try {
399
- git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
413
+ git(this.cwd, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
400
414
  } catch (e) {
401
415
  const check = isGitWorktreeSupported(this.cwd);
402
416
  if (!check.supported) {
@@ -471,7 +485,7 @@ export class WorktreeManager {
471
485
  branch,
472
486
  baseBranch,
473
487
  baseHash,
474
- actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
488
+ actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
475
489
  createdAt: new Date().toISOString(),
476
490
  worktreePath,
477
491
  mode: 'worktree',
@@ -499,7 +513,7 @@ export class WorktreeManager {
499
513
  * @private
500
514
  */
501
515
  _recoverNativeWorktreeMeta(name, { worktreePath, branch }) {
502
- const baseHash = gitQuiet(worktreePath, 'rev-parse HEAD') || null
516
+ const baseHash = gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || null
503
517
  const meta = {
504
518
  name_zh: 'worktree 元数据',
505
519
  changeName: name,
@@ -552,15 +566,15 @@ export class WorktreeManager {
552
566
  if (resolvedSource === resolvedTarget) {
553
567
  console.warn('⚠️ 跳过 baseline overlay:当前目录与目标目录相同(native-worktree 或 in-place 模式)')
554
568
  // 写 meta 但不 overlay
555
- baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD')
556
- baseHash = baseHash || git(this.cwd, 'rev-parse HEAD')
569
+ baseBranch = baseBranch || gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || gitQuiet(this.cwd, ['rev-parse', 'HEAD'])
570
+ baseHash = baseHash || git(this.cwd, ['rev-parse', 'HEAD'])
557
571
  const meta = {
558
572
  name_zh: 'worktree 元数据',
559
573
  changeName: name,
560
574
  branch: branch || BRANCH_PREFIX + name,
561
575
  baseBranch,
562
576
  baseHash,
563
- actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
577
+ actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
564
578
  createdAt: new Date().toISOString(),
565
579
  worktreePath,
566
580
  mode: mode || 'in-place-fallback',
@@ -578,8 +592,8 @@ export class WorktreeManager {
578
592
 
579
593
  // 解析 base
580
594
  if (!baseHash) {
581
- baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
582
- baseHash = git(this.cwd, 'rev-parse HEAD');
595
+ baseBranch = baseBranch || gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || gitQuiet(this.cwd, ['rev-parse', 'HEAD']);
596
+ baseHash = git(this.cwd, ['rev-parse', 'HEAD']);
583
597
  }
584
598
 
585
599
  const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
@@ -597,7 +611,7 @@ export class WorktreeManager {
597
611
  branch: branch || BRANCH_PREFIX + name,
598
612
  baseBranch,
599
613
  baseHash,
600
- actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
614
+ actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
601
615
  createdAt: new Date().toISOString(),
602
616
  worktreePath,
603
617
  mode: mode || 'in-place-fallback',
@@ -622,7 +636,7 @@ export class WorktreeManager {
622
636
  }
623
637
 
624
638
  /**
625
- * 构建 isolation 信息对象,用于写入 gate-status.json
639
+ * 构建 isolation 信息对象,用于写入 sillyspec.db(_writeIsolationToDB,isolation_status 列)
626
640
  * @param {string} changeName
627
641
  * @returns {{ status: string, mode: string, path: string } | null}
628
642
  */
@@ -724,8 +738,14 @@ export class WorktreeManager {
724
738
  if (!isInPlace && existsSync(worktreePath)) {
725
739
  const wtNodeModules = join(worktreePath, 'node_modules');
726
740
  if (existsSync(wtNodeModules)) {
727
- let isLink = false;
728
- try { isLink = lstatSync(wtNodeModules).isSymbolicLink(); } catch {}
741
+ // lstat 失败 fail-loud(D-001@v1 / review-2026-08-09 #4):EPERM(杀毒/索引锁 junction)若静默跳过解链,
742
+ // 后续 git worktree remove / rmSync recursive 会跟随 junction 误删主仓 node_modules
743
+ let isLink;
744
+ try {
745
+ isLink = lstatSync(wtNodeModules).isSymbolicLink();
746
+ } catch (e) {
747
+ throw new Error(`worktree node_modules junction 检测失败(疑似 EPERM:杀毒/索引占用),阻断 cleanup 保护主仓 node_modules:${e.message}。请关闭占用进程或手动 rmdir "${wtNodeModules}" 后重试 sillyspec worktree cleanup`);
748
+ }
729
749
  if (isLink) {
730
750
  try {
731
751
  if (process.platform === 'win32') {
@@ -736,7 +756,7 @@ export class WorktreeManager {
736
756
  }
737
757
  details.push('worktree node_modules junction/symlink removed (protect main checkout)');
738
758
  } catch (e) {
739
- details.push(`node_modules link remove failed: ${e.message}`);
759
+ throw new Error(`worktree node_modules junction 解链失败,阻断 cleanup 保护主仓 node_modules:${e.message}。请手动 rmdir "${wtNodeModules}" 后重试`);
740
760
  }
741
761
  }
742
762
  }
@@ -747,7 +767,7 @@ export class WorktreeManager {
747
767
  if (!isInPlace && existsSync(worktreePath)) {
748
768
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
749
769
  try {
750
- git(this.cwd, `worktree remove ${worktreePath} --force`);
770
+ git(this.cwd, ['worktree', 'remove', worktreePath, '--force'], { timeout: 60000 });
751
771
  gitRemoveOk = true;
752
772
  details.push(`git worktree remove succeeded (attempt ${attempt})`);
753
773
  break;
@@ -773,14 +793,14 @@ export class WorktreeManager {
773
793
 
774
794
  // 3. git worktree prune(清理 git 内部注册信息)
775
795
  try {
776
- gitQuiet(this.cwd, 'worktree prune');
796
+ gitQuiet(this.cwd, ['worktree', 'prune'], { timeout: 30000 });
777
797
  } catch {
778
798
  // prune 失败不阻断
779
799
  }
780
800
 
781
801
  // 4. 删除分支(忽略分支不存在的错误)
782
802
  try {
783
- gitQuiet(this.cwd, `branch -D ${branch}`);
803
+ gitQuiet(this.cwd, ['branch', '-D', branch]);
784
804
  details.push('branch deleted');
785
805
  } catch {
786
806
  // 分支可能已被删除,幂等跳过
@@ -800,7 +820,7 @@ export class WorktreeManager {
800
820
  const residual = [];
801
821
  if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
802
822
  if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
803
- if (!isInPlace && gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
823
+ if (!isInPlace && gitQuiet(this.cwd, ['worktree', 'list'], { timeout: 30000 })?.includes(worktreePath)) {
804
824
  residual.push('git worktree list still references this worktree');
805
825
  }
806
826
  if (residual.length > 0) {
@@ -832,19 +852,79 @@ export class WorktreeManager {
832
852
  * @param {{ fix?: boolean, staleHours?: number }} opts
833
853
  * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
834
854
  */
855
+ /**
856
+ * doctor --fix 的依赖重供给:先解 worktree/node_modules junction(保护主仓 node_modules),
857
+ * 再 provisionDeps(force:true) 强制走 install 分支重装。
858
+ *
859
+ * Windows 保护(与 cleanup 722-743 同源坑):不解链直接 install 会经 junction 误改主仓
860
+ * node_modules 内容。in-place-fallback 无独立 node_modules(worktreePath 即主仓),跳过解链,
861
+ * 仅 install。force=true 绕过 provisionDeps 的 lockfile 一致快路径,确保真重装而非幂等 linked。
862
+ *
863
+ * @param {string} name - changeName
864
+ * @param {string} wtPath - worktree 根目录(meta.worktreePath)
865
+ * @returns {{ ok: boolean, msg: string }}
866
+ */
835
867
  _doctorReprovision(name, wtPath) {
836
868
  try {
837
- const deps = provisionDeps(wtPath, this.cwd, { specBase: join(this.cwd, '.sillyspec') }) || {};
838
- const metaPath = join(this.getWorktreePath(name), META_FILE);
839
869
  const meta = this.getMeta(name) || {};
840
- writeMetaAtomic(metaPath, { ...meta, ...deps });
870
+ const isInPlace = meta.mode === 'in-place-fallback';
871
+ // 先解 junction(非 in-place):lstatSync 判 link → Windows rmdir junction / Unix unlinkSync
872
+ if (!isInPlace && existsSync(wtPath)) {
873
+ const wtNodeModules = join(wtPath, 'node_modules');
874
+ if (existsSync(wtNodeModules)) {
875
+ // lstat 失败 fail-loud(D-001@v1)+ 解链失败 fail-loud(D-002@v1 / review-2026-08-09 #4):
876
+ // 原 best-effort「交 provisionDeps install」会经 junction 误改主仓 node_modules,正是 #4 坑
877
+ let isLink;
878
+ try {
879
+ isLink = lstatSync(wtNodeModules).isSymbolicLink();
880
+ } catch (e) {
881
+ throw new Error(`worktree node_modules junction 检测失败(疑似 EPERM:杀毒/索引占用),阻断 doctor reprovision 保护主仓 node_modules:${e.message}。请关闭占用进程或手动 rmdir "${wtNodeModules}" 后重试`);
882
+ }
883
+ if (isLink) {
884
+ try {
885
+ if (process.platform === 'win32') {
886
+ execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
887
+ } else {
888
+ unlinkSync(wtNodeModules);
889
+ }
890
+ } catch (e) {
891
+ throw new Error(`worktree node_modules junction 解链失败,阻断 doctor reprovision(不调 provisionDeps 避免经 junction 误改主仓):${e.message}。请手动 rmdir "${wtNodeModules}" 后重试`);
892
+ }
893
+ }
894
+ }
895
+ }
896
+ const deps = provisionDeps(wtPath, this.cwd, {
897
+ specBase: join(this.cwd, '.sillyspec'),
898
+ force: true,
899
+ }) || {};
900
+ const metaPath = join(this.getWorktreePath(name), META_FILE);
901
+ // 成功重供时清掉旧 depsError(provisionDeps 成功结果不含该键,{...meta,...deps} 会残留旧值)
902
+ const merged = { ...meta, ...deps };
903
+ if (deps.depsStatus !== 'failed') delete merged.depsError;
904
+ writeMetaAtomic(metaPath, merged);
841
905
  return { ok: true, msg: `re-provisioned ${name}: depsStatus=${deps.depsStatus}` };
842
906
  } catch (e) {
843
907
  return { ok: false, msg: `re-provision failed for ${name}: ${e.message}` };
844
908
  }
845
909
  }
846
910
 
847
- doctor({ fix = false, staleHours = 24 } = {}) {
911
+ /**
912
+ * worktree 健康检查 + 可选修复
913
+ * 检查项:
914
+ * - git worktree list 中的孤儿条目(目录不存在)
915
+ * - worktree 目录存在但 git 不认识
916
+ * - meta 存在但 worktree 目录不存在
917
+ * - worktree 目录存在但 meta 不存在(幽灵目录)
918
+ * - SillySpec 分支残留(sillyspec/* 但无对应 meta)
919
+ * - 超过指定小时的过期 worktree
920
+ * - deps 依赖状态(failed/missing/stale/main-drift,复用 checkDepsFreshness 统一判定)
921
+ *
922
+ * @param {{ fix?: boolean, staleHours?: number, changeName?: string|null }} opts
923
+ * - changeName:非 null 时仅扫描该 change(deps/ghost/stale/orphan-git/orphan-branch 全部按 changeName 过滤);
924
+ * 不传则全量扫(兼容现有行为)。
925
+ * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
926
+ */
927
+ doctor({ fix = false, staleHours = 24, changeName = null } = {}) {
848
928
  const issues = [];
849
929
  const fixed = [];
850
930
  const unfixable = [];
@@ -852,7 +932,7 @@ export class WorktreeManager {
852
932
  // 1. 列出 git worktree list 中的条目
853
933
  let gitWorktreeList = [];
854
934
  try {
855
- const raw = execSync(`git worktree list --porcelain`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
935
+ const raw = git(this.cwd, ['worktree', 'list', '--porcelain'], { timeout: 30000 });
856
936
  const entries = raw.split(/\n\n/).filter(Boolean);
857
937
  for (const entry of entries) {
858
938
  const lines = entry.split('\n');
@@ -880,11 +960,12 @@ export class WorktreeManager {
880
960
  for (const wt of gitWorktreeList) {
881
961
  if (!existsSync(wt.path)) {
882
962
  const name = this._pathToChangeName(wt.path);
963
+ if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
883
964
  if (wt.missing === true) {
884
965
  // git 明确标记目录缺失(git 2.20+)→ 真 orphan,prune 安全
885
966
  issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 标记 missing: ${wt.path}`, fixable: true });
886
967
  if (fix) {
887
- try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
968
+ try { gitQuiet(this.cwd, ['worktree', 'prune'], { timeout: 30000 }); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
888
969
  }
889
970
  } else {
890
971
  // 目录不可见但 git 未标记 missing → 可能 existsSync 误判,保守不自动 prune
@@ -901,25 +982,28 @@ export class WorktreeManager {
901
982
  for (const entry of entries) {
902
983
  if (!entry.isDirectory()) continue;
903
984
  const name = entry.name;
985
+ if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
904
986
  const dirPath = join(this.worktreeBase, name);
905
987
  const hasMeta = existsSync(join(dirPath, META_FILE));
906
988
  const meta = hasMeta ? this.getMeta(name) : null;
907
989
 
908
- // deps 依赖状态检查(change 2026-06-28-worktree-deps-provision)
909
- if (meta && meta.worktreePath && existsSync(meta.worktreePath) && meta.mode !== 'in-place-fallback') {
990
+ // deps 依赖状态检查(change 2026-06-28-worktree-deps-provision / 2026-08-05-tooling-feedback-fixes)
991
+ // 复用 checkDepsFreshness(H1,worktree-deps.js),优先级 failed→missing→stale→main-drift→fresh。
992
+ // 放宽原 909 in-place-fallback 守卫:in-place 也跑(main-drift/stale/failed 对它同样有意义,
993
+ // 真 in-place 的 wtPath===主仓→wtHash===mainHash→不会误报 main-drift,仅 failed/stale 有意义)。
994
+ // fix 时 _doctorReprovision 自行判断是否解链(in-place 不解链,仅 install)。
995
+ if (meta && meta.worktreePath && existsSync(meta.worktreePath)) {
910
996
  const wtPath = meta.worktreePath;
911
- const nmExists = existsSync(join(wtPath, 'node_modules'));
912
- const curHash = lockfileHash(wtPath);
913
- let depsIssue = null;
914
- if (['linked', 'installed'].includes(meta.depsStatus) && !nmExists) {
915
- depsIssue = { type: 'deps-missing', detail: 'meta.depsStatus=' + meta.depsStatus + ' 但 node_modules 缺失' };
916
- } else if (meta.depsLockHash && curHash && curHash !== meta.depsLockHash) {
917
- depsIssue = { type: 'deps-stale', detail: 'lockfile 变化 (' + meta.depsLockHash + ' -> ' + curHash + ')' };
918
- } else if (meta.depsStatus === 'failed') {
919
- depsIssue = { type: 'deps-failed', detail: '上次依赖供给失败' + (meta.depsError ? ': ' + meta.depsError : '') };
920
- }
921
- if (depsIssue) {
922
- issues.push({ type: depsIssue.type, name, detail: depsIssue.detail, fixable: true });
997
+ const fresh = checkDepsFreshness(meta, wtPath, this.cwd);
998
+ const issueTypeByStatus = {
999
+ failed: 'deps-failed',
1000
+ missing: 'deps-missing',
1001
+ stale: 'deps-stale',
1002
+ 'main-drift': 'deps-main-drift',
1003
+ };
1004
+ const issueType = issueTypeByStatus[fresh.status] || null;
1005
+ if (issueType) {
1006
+ issues.push({ type: issueType, name, detail: fresh.detail, fixable: true });
923
1007
  if (fix) {
924
1008
  const r = this._doctorReprovision(name, wtPath);
925
1009
  (r.ok ? fixed : unfixable).push(r.msg);
@@ -973,15 +1057,16 @@ export class WorktreeManager {
973
1057
 
974
1058
  // 5. 检查 SillySpec 分支残留
975
1059
  try {
976
- const branches = execSync(`git branch --list '${BRANCH_PREFIX}*'`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
1060
+ const branches = execFileSync('git', ['branch', '--list', `${BRANCH_PREFIX}*`], { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
977
1061
  if (branches) {
978
1062
  for (const line of branches.split('\n').filter(Boolean)) {
979
1063
  const branch = line.replace(/^\*?\s+/, '').trim();
980
1064
  const name = branch.replace(BRANCH_PREFIX, '');
1065
+ if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
981
1066
  if (!metaNames.has(name)) {
982
1067
  issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
983
1068
  if (fix) {
984
- try { gitQuiet(this.cwd, `branch -D ${branch}`); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
1069
+ try { gitQuiet(this.cwd, ['branch', '-D', branch]); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
985
1070
  }
986
1071
  }
987
1072
  }
@@ -1028,9 +1113,9 @@ export class WorktreeManager {
1028
1113
  const isDeliverable = f => f && !f.startsWith('.sillyspec/') && f !== 'meta.json';
1029
1114
  try {
1030
1115
  // 1) 候选交付变更(worktree 工作区相对 diffBase)。--no-renames:rename 退化成 D+A,两侧文件都进集
1031
- const tracked = (gitQuiet(worktreePath, `diff --no-renames --name-only ${diffBase}`) || '')
1116
+ const tracked = (gitQuiet(worktreePath, ['diff', '--no-renames', '--name-only', diffBase], { timeout: 30000 }) || '')
1032
1117
  .split('\n').filter(Boolean).filter(isDeliverable);
1033
- const untracked = (gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '')
1118
+ const untracked = (gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '')
1034
1119
  .split('\n').filter(Boolean).filter(isDeliverable);
1035
1120
 
1036
1121
  if (tracked.length === 0 && untracked.length === 0) {
@@ -1063,19 +1148,19 @@ export class WorktreeManager {
1063
1148
  * @returns {string[]} 尚未落到 main HEAD 的文件
1064
1149
  */
1065
1150
  _changesAlreadyOnMain(worktreePath, trackedFiles, untrackedFiles) {
1066
- const mainHead = git(this.cwd, 'rev-parse HEAD'); // 失败即抛 → 外层 catch fail-safe
1151
+ const mainHead = git(this.cwd, ['rev-parse', 'HEAD']); // 失败即抛 → 外层 catch fail-safe
1067
1152
  const pending = [];
1068
1153
 
1069
1154
  if (trackedFiles.length > 0) {
1070
1155
  const diverged = (gitQuiet(worktreePath,
1071
- `diff --no-renames --name-only ${mainHead} -- ${trackedFiles.join(' ')}`) || '')
1156
+ ['diff', '--no-renames', '--name-only', mainHead, '--', ...trackedFiles], { timeout: 30000 }) || '')
1072
1157
  .split('\n').filter(Boolean);
1073
1158
  pending.push(...diverged);
1074
1159
  }
1075
1160
 
1076
1161
  if (untrackedFiles.length > 0) {
1077
1162
  // hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整命令失败 → gitQuiet 返回 null
1078
- const wtHashes = (gitQuiet(worktreePath, `hash-object -- ${untrackedFiles.join(' ')}`) || '')
1163
+ const wtHashes = (gitQuiet(worktreePath, ['hash-object', '--', ...untrackedFiles], { timeout: 30000 }) || '')
1079
1164
  .split('\n');
1080
1165
  const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
1081
1166
  for (let i = 0; i < untrackedFiles.length; i++) {
@@ -1101,7 +1186,7 @@ export class WorktreeManager {
1101
1186
  _lsTreeBlobs(cwd, treeish, files) {
1102
1187
  const map = new Map();
1103
1188
  if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
1104
- const raw = gitQuiet(cwd, `ls-tree ${treeish} -- ${files.join(' ')}`);
1189
+ const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
1105
1190
  if (!raw) return map;
1106
1191
  for (const line of raw.split('\n')) {
1107
1192
  if (!line) continue;
@@ -1121,8 +1206,13 @@ export class WorktreeManager {
1121
1206
  try {
1122
1207
  const resolved = resolve(wtPath);
1123
1208
  const baseResolved = resolve(this.worktreeBase);
1124
- if (resolved.startsWith(baseResolved + '/')) {
1125
- return resolved.slice(baseResolved.length + 1);
1209
+ // 用 path.relative 比 startsWith(base + sep) 更稳:
1210
+ // - 自动用当前平台分隔符比较(Windows 上 resolve 返回反斜杠,硬编码 '/' 恒 false)
1211
+ // - 跨盘符(C: vs D:)时返回绝对路径 → isAbsolute 守卫放行失败兜底 null
1212
+ // - 同路径返回 '' → 视作非 changeName
1213
+ const rel = relative(baseResolved, resolved);
1214
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
1215
+ return rel;
1126
1216
  }
1127
1217
  } catch {}
1128
1218
  return null;
@@ -1142,16 +1232,21 @@ export class WorktreeManager {
1142
1232
 
1143
1233
  try {
1144
1234
  // staged 变更
1145
- const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
1235
+ const staged = gitQuiet(mainCwd, ['diff', '--cached', '--name-only'], { timeout: 30000 }) || '';
1146
1236
  if (staged) {
1147
1237
  try {
1148
1238
  // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
1149
- const patchBuf = execSync(`git diff --cached --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
1239
+ const patchBuf = execFileSync('git', ['diff', '--cached', '--binary'], { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
1150
1240
  if (patchBuf && patchBuf.length > 0) {
1151
1241
  const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
1152
- writeFileSync(patchFile, patchBuf);
1153
- git(worktreePath, `apply --binary ${patchFile}`);
1154
- rmSync(patchFile, { force: true });
1242
+ try {
1243
+ writeFileSync(patchFile, patchBuf);
1244
+ git(worktreePath, ['apply', '--binary', patchFile], { timeout: 30000 });
1245
+ } finally {
1246
+ // git apply 抛错时也要清掉 patch 临时文件,避免泄漏到 worktree/主仓根
1247
+ // (filterDeliverableFiles 不排除 .sillyspec-baseline-*,泄漏会级联 BLOCKED 下游 apply)
1248
+ try { rmSync(patchFile, { force: true }); } catch {}
1249
+ }
1155
1250
  }
1156
1251
  } catch (e) {
1157
1252
  errors.push(`staged: ${e.message}`);
@@ -1160,16 +1255,20 @@ export class WorktreeManager {
1160
1255
  }
1161
1256
 
1162
1257
  // unstaged 变更
1163
- const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
1258
+ const unstaged = gitQuiet(mainCwd, ['diff', '--name-only'], { timeout: 30000 }) || '';
1164
1259
  if (unstaged) {
1165
1260
  try {
1166
1261
  // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
1167
- const patchBuf = execSync(`git diff --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
1262
+ const patchBuf = execFileSync('git', ['diff', '--binary'], { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
1168
1263
  if (patchBuf && patchBuf.length > 0) {
1169
1264
  const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
1170
- writeFileSync(patchFile, patchBuf);
1171
- git(worktreePath, `apply --binary ${patchFile}`);
1172
- rmSync(patchFile, { force: true });
1265
+ try {
1266
+ writeFileSync(patchFile, patchBuf);
1267
+ git(worktreePath, ['apply', '--binary', patchFile], { timeout: 30000 });
1268
+ } finally {
1269
+ // git apply 抛错时也要清掉 patch 临时文件,避免泄漏到 worktree/主仓根
1270
+ try { rmSync(patchFile, { force: true }); } catch {}
1271
+ }
1173
1272
  }
1174
1273
  } catch (e) {
1175
1274
  errors.push(`unstaged: ${e.message}`);
@@ -1177,18 +1276,20 @@ export class WorktreeManager {
1177
1276
  files.push(...unstaged.split('\n').filter(Boolean));
1178
1277
  }
1179
1278
 
1180
- // untracked 文件(排除 .sillyspec/.runtime 等)
1181
- const untracked = gitQuiet(mainCwd, 'ls-files --others --exclude-standard') || '';
1279
+ // untracked 文件(排除 .sillyspec/.runtime 等);目录跳过避免 readFileSync EISDIR
1280
+ const untracked = gitQuiet(mainCwd, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '';
1281
+ const skippedDirs = [];
1182
1282
  if (untracked) {
1183
1283
  for (const f of untracked.split('\n').filter(Boolean)) {
1184
- const src = join(mainCwd, f);
1185
- const dst = join(worktreePath, f);
1186
- if (existsSync(src)) {
1187
- mkdirSync(dirname(dst), { recursive: true });
1188
- try { writeFileSync(dst, readFileSync(src)); files.push(f); } catch (e) { errors.push(`untracked ${f}: ${e.message}`); }
1189
- }
1284
+ const r = copyUntrackedEntry(join(mainCwd, f), join(worktreePath, f));
1285
+ if (r.status === 'copied') files.push(f);
1286
+ else if (r.status === 'skipped-dir') skippedDirs.push(f);
1287
+ else if (r.status === 'error') errors.push(`untracked ${f}: ${r.error}`);
1190
1288
  }
1191
1289
  }
1290
+ if (skippedDirs.length > 0) {
1291
+ console.log(`ℹ️ baseline overlay 跳过 ${skippedDirs.length} 个 untracked 目录(不读目录避免 EISDIR,坑 execute-worktree-overlay-untracked-dir-eisdir)`);
1292
+ }
1192
1293
 
1193
1294
  if (files.length > 0) {
1194
1295
  console.log(`📁 baseline overlay: ${files.length} 个未提交文件已同步到 worktree`);
@@ -1234,21 +1335,22 @@ export class WorktreeManager {
1234
1335
  GIT_COMMITTER_EMAIL: 'sillyspec@baseline',
1235
1336
  };
1236
1337
  try {
1237
- execSync('git add -A', { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env });
1338
+ git(worktreePath, ['add', '-A'], { timeout: 30000 });
1238
1339
  // 检查是否有实际变更(可能 overlay 后和 HEAD 完全一致)
1239
- const status = gitQuiet(worktreePath, 'status --porcelain');
1340
+ const status = gitQuiet(worktreePath, ['status', '--porcelain'], { timeout: 30000 });
1240
1341
  if (!status) {
1241
- return gitQuiet(worktreePath, 'rev-parse HEAD');
1342
+ return gitQuiet(worktreePath, ['rev-parse', 'HEAD']);
1242
1343
  }
1243
1344
  // --no-verify:baseline 是锚点不是交付物,只是把主仓库 dirty 文件快照到 worktree
1244
1345
  // 分支上以便区分「前置 baseline」与「子代理新增改动」。它不该触发项目 pre-commit
1245
1346
  // hook(如 ruff format),否则主仓库 dirty 文件中任一不达标的会被 hook reformat
1246
1347
  // 致 commit 失败 → worktree 创建失败 → execute 无法启动。
1247
- execSync(
1248
- `git commit --no-verify -m "sillyspec: baseline checkpoint for ${changeName}"`,
1348
+ execFileSync(
1349
+ 'git',
1350
+ ['commit', '--no-verify', '-m', `sillyspec: baseline checkpoint for ${changeName}`],
1249
1351
  { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
1250
1352
  );
1251
- const hash = git(worktreePath, 'rev-parse HEAD');
1353
+ const hash = git(worktreePath, ['rev-parse', 'HEAD']);
1252
1354
  console.log(`📌 baseline checkpoint: ${hash}`);
1253
1355
  return hash;
1254
1356
  } catch (e) {