sillyspec 3.25.9 → 3.25.11

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 (71) 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-verify/SKILL.md +1 -1
  8. package/README.md +9 -0
  9. package/package.json +2 -2
  10. package/src/change-list.js +11 -8
  11. package/src/change-risk-profile.js +22 -0
  12. package/src/db.js +160 -159
  13. package/src/dispatch/backends/local-agent.js +152 -0
  14. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  15. package/src/dispatch/probe.js +208 -0
  16. package/src/dispatch/strategy.js +121 -0
  17. package/src/doctor-diagnostics.js +19 -25
  18. package/src/fs-atomic.js +69 -69
  19. package/src/git-helper.js +66 -0
  20. package/src/hooks/worktree-guard.js +64 -55
  21. package/src/index.js +176 -34
  22. package/src/init.js +7 -14
  23. package/src/machine-interface.js +54 -19
  24. package/src/migrate.js +14 -4
  25. package/src/modules.js +16 -5
  26. package/src/progress/change-registry.js +72 -70
  27. package/src/progress/consistency-doctor.js +71 -14
  28. package/src/progress/shared.js +6 -3
  29. package/src/progress/stage-machine.js +61 -65
  30. package/src/progress/step-store.js +62 -67
  31. package/src/progress.js +155 -233
  32. package/src/quicklog.js +13 -5
  33. package/src/run/command.js +65 -37
  34. package/src/run/complete-handlers.js +1005 -889
  35. package/src/run/complete.js +115 -208
  36. package/src/run/concurrent-detect.js +132 -0
  37. package/src/run/gates.js +289 -17
  38. package/src/run/prompt.js +22 -68
  39. package/src/run/quick-audit.js +10 -9
  40. package/src/run/shared.js +648 -600
  41. package/src/run/stage.js +43 -24
  42. package/src/scan-postcheck.js +9 -31
  43. package/src/sillyhub-mcp/client.js +389 -0
  44. package/src/spec-dir-typo.js +71 -0
  45. package/src/stage-contract-engine.js +22 -1
  46. package/src/stage-contract-spec.js +11 -4
  47. package/src/stage-contract.js +26 -3
  48. package/src/stage-review.js +13 -9
  49. package/src/stages/archive.js +4 -6
  50. package/src/stages/brainstorm-auto.js +26 -20
  51. package/src/stages/brainstorm.js +29 -5
  52. package/src/stages/cmd-existence.js +145 -0
  53. package/src/stages/doctor.js +9 -6
  54. package/src/stages/execute.js +61 -20
  55. package/src/stages/plan-postcheck.js +152 -0
  56. package/src/stages/plan.js +16 -31
  57. package/src/stages/quick.js +1 -1
  58. package/src/stages/scan.js +9 -3
  59. package/src/stages/status.js +0 -1
  60. package/src/stages/verify.js +12 -7
  61. package/src/sync.js +4 -4
  62. package/src/task-review.js +30 -1
  63. package/src/verify-postcheck.js +933 -862
  64. package/src/version.js +19 -0
  65. package/src/worktree-apply.js +85 -68
  66. package/src/worktree-deps.js +89 -5
  67. package/src/worktree.js +213 -111
  68. package/templates/prompts/taskcard-rules.md +27 -0
  69. package/templates/prompts/verify-probes.md +3 -3
  70. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  71. package/src/stages/propose.js +0 -181
package/src/version.js ADDED
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * 读取 package.json 版本号。
9
+ * 独立轻量模块:只依赖 fs/path/url,不拖入 init.js 的 @inquirer/prompts 等重型交互库。
10
+ * 供 index.js 顶部静态 import——--version 等高频路径不再为 getVersion 付 init.js 加载税。
11
+ */
12
+ export function getVersion() {
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
15
+ return pkg.version;
16
+ } catch {
17
+ return '?.?.?';
18
+ }
19
+ }
@@ -12,29 +12,17 @@
12
12
  * 8. 成功后自动 cleanup
13
13
  */
14
14
 
15
- import { execSync } from 'child_process';
15
+ import { execFileSync } from 'child_process';
16
16
  import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
17
17
  import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
- import { createHash } from 'crypto';
20
19
  import { WorktreeManager } from './worktree.js';
21
20
  import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
22
21
  import { parseAllowedPaths } from './stages/plan-postcheck.js';
22
+ import { git, gitQuiet } from './git-helper.js';
23
23
 
24
24
  const CHANGES_REL = '.sillyspec/changes';
25
25
 
26
- function git(cwd, args) {
27
- return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
28
- }
29
-
30
- function gitQuiet(cwd, args) {
31
- try {
32
- return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
33
- } catch {
34
- return null;
35
- }
36
- }
37
-
38
26
  /**
39
27
  * 过滤掉 worktree 基础设施文件(非交付物),让 apply 只关心真正的变更产出:
40
28
  * - meta.json:worktree 元数据,baseline commit 中被跟踪、working-tree 被 CLI 改写
@@ -42,11 +30,19 @@ function gitQuiet(cwd, args) {
42
30
  * 的锚点,保证 baseline overlay 文件不被误判为变更)。若不排除,modified-tracked 的
43
31
  * meta.json 会落入 changedFiles,触发「不在 design.md 清单」误判,导致每个 execute 的
44
32
  * assess 恒 BLOCKED。
45
- * - .sillyspec/:变更文档 / 运行时产物,不属于源码交付。
33
+ * - .sillyspec/changes/:变更文档(worktree 专属,apply 回主仓污染进度库)。
34
+ * - .sillyspec/.runtime/:运行时产物(进度库/锁/review 产物,非源码)。
35
+ * - .sillyspec/quicklog/:quicklog 条目(worktree 进度,非交付物)。
36
+ * 保留 .sillyspec/docs/(dogfood 模块规范文档 = 交付物,apply 回主仓)。
46
37
  * 对 modified-tracked(git diff)与 untracked(ls-files --others)一视同仁。
47
38
  */
48
39
  export function filterDeliverableFiles(files) {
49
- return files.filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
40
+ return files.filter(f =>
41
+ !f.startsWith('.sillyspec/changes/') &&
42
+ !f.startsWith('.sillyspec/.runtime/') &&
43
+ !f.startsWith('.sillyspec/quicklog/') &&
44
+ f !== 'meta.json'
45
+ );
50
46
  }
51
47
 
52
48
  /**
@@ -63,6 +59,23 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
63
59
  return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
64
60
  }
65
61
 
62
+ /**
63
+ * 确定进 patch 的文件:有清单取「实际变更 ∩ 清单(pathMatches 容差)」,无清单取全部变更。
64
+ *
65
+ * 口径须与 classifyAllowListViolations 一致——否则 design §6 用 glob(如 test_*.py)或多路径
66
+ * 单 cell 覆盖的文件过 manifest 校验(Gate1 用 pathMatches 放行),却因字面 includes 不在
67
+ * changedFiles → 被滤出 patchFiles → patch 不含 → 主工作区静默丢失(坑 apply-glob-manifest)。
68
+ * 以 changedFiles 为基按容差圈定,glob/前缀覆盖的具体文件都能进 patch。
69
+ *
70
+ * @param {string[]} changedFiles filterDeliverableFiles 已过滤的实际变更路径
71
+ * @param {Set<string>} allowSet design §6 清单 ∪ task allowed_paths(可含 glob / 目录前缀)
72
+ * @param {boolean} hasAllowList 是否有清单
73
+ * @returns {string[]} 进 patch 的文件
74
+ */
75
+ export function resolvePatchFiles(changedFiles, allowSet, hasAllowList) {
76
+ if (!hasAllowList) return changedFiles;
77
+ return changedFiles.filter(f => [...allowSet].some(ap => pathMatches(f, ap)));
78
+ }
66
79
  /**
67
80
  * 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
68
81
  *
@@ -71,7 +84,7 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
71
84
  * - 文件不在 tree 中 → 不在 Map 中(调用方 map.get(f) ?? null 得 null,等同 rev-parse 失败→null)
72
85
  * ls-tree 输出 "<mode> <type> <hash>\t<path>"。path 恒为文件路径(来自 git diff/ls-files),
73
86
  * 非目录,故不带 -r 也正确——文件在 tree 时 rev-parse treeish:path 与 ls-tree 都给同一 hash,
74
- * 不在时都不给,等价。(沿用 git() 字符串拼接模式,不引入新的引号/空格破法。)
87
+ * 不在时都不给,等价。(沿用公共 git() 数组形式,不经 shell、不引入引号/空格破法。)
75
88
  *
76
89
  * @param {string} cwd
77
90
  * @param {string} treeish
@@ -81,7 +94,7 @@ export function classifyAllowListViolations(changedFiles, allowSet) {
81
94
  function getBlobHashMap(cwd, treeish, files) {
82
95
  const map = new Map();
83
96
  if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
84
- const raw = gitQuiet(cwd, `ls-tree ${treeish} -- ${files.join(' ')}`);
97
+ const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
85
98
  if (!raw) return map;
86
99
  for (const line of raw.split('\n')) {
87
100
  if (!line) continue;
@@ -107,7 +120,7 @@ function getBlobHashMap(cwd, treeish, files) {
107
120
  * @returns {Set<string>} 并集清单(无 design 清单且无 task 卡片时为空集)
108
121
  */
109
122
  export function resolveApplyAllowSet(projectRoot, changeName) {
110
- const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'));
123
+ const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'), { keepSillyspecDocs: true });
111
124
  const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
112
125
  if (existsSync(tasksDir)) {
113
126
  for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
@@ -166,7 +179,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
166
179
  let changedFiles;
167
180
  try {
168
181
  // 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
169
- const statusRaw = git(worktreePath, `diff --name-status ${diffBase}`);
182
+ const statusRaw = git(worktreePath, ['diff', '--name-status', diffBase]);
170
183
  const statusFiles = new Set();
171
184
  if (statusRaw) {
172
185
  for (const line of statusRaw.split('\n').filter(Boolean)) {
@@ -178,7 +191,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
178
191
  }
179
192
 
180
193
  // untracked 新文件(diffBase 中不存在的文件)
181
- const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
194
+ const untrackedRaw = gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard']);
182
195
  const untrackedFiles = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [];
183
196
 
184
197
  // 排除 worktree 基础设施文件(meta.json / .sillyspec/,见 filterDeliverableFiles)。
@@ -227,31 +240,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
227
240
  }
228
241
 
229
242
  // --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
230
- // 分工:4.5(整树 baselineHash)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
243
+ // 分工:4.5(排除规则下当前 dirty)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
231
244
  // 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
232
245
  // 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
233
246
  // 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
234
- // 否则别人改 CLAUDE.md/docs/.claude → 整树 hash 变 → apply 误阻断(多操作者仓库高频踩坑)。
235
- // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
247
+ // 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
248
+ // 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
236
249
  if (meta.baselineHash) {
237
- const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
238
- const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
239
- const unstaged = gitQuiet(projectRoot, `diff ${exclude}`) || '';
240
- const untracked = gitQuiet(projectRoot, `ls-files --others --exclude-standard ${exclude}`) || '';
241
- const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
242
- const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
243
- if (currentHash !== meta.baselineHash) {
250
+ // pathspec:`--` 结束选项,`.` 包含全部,后续 :(exclude) 排除非交付物元数据/文档 churn
251
+ // (与 computeBaselineHash 同口径)。数组形式逐元素传递,:(exclude) magic 字面直传不经 shell。
252
+ const exclude = ['--', '.', ':(exclude).sillyspec/', ':(exclude).claude/', ':(exclude)docs/', ':(exclude)CLAUDE.md'];
253
+ const staged = gitQuiet(projectRoot, ['diff', '--cached', ...exclude]) || '';
254
+ const unstaged = gitQuiet(projectRoot, ['diff', ...exclude]) || '';
255
+ const untracked = gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard', ...exclude]) || '';
256
+ // 意图(与 computeBaselineHash 注释一致):只挡「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
257
+ // 不比对 hash 是否等于 execute 启动时 baselineHash:主仓 dirty→clean(execute 期间 commit 无关文件)后
258
+ // hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
259
+ const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
260
+ if (hasUncommittedDirty) {
244
261
  // 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
245
262
  const dirtyFiles = [...new Set(
246
- ((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
247
- .concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
263
+ ((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
264
+ .concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
248
265
  )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
249
266
  result.errors.push(
250
- `主工作区有未提交的改动(execute 前后 baseline 不一致),git apply 无法安全应用。\n` +
267
+ `主工作区有未提交的改动,git apply 无法安全应用。\n` +
251
268
  (dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
252
- `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
253
- `execute 前 baseline: ${meta.baselineHash}\n` +
254
- `当前 baseline: ${currentHash}`
269
+ `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n`
255
270
  );
256
271
  if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
257
272
  }
@@ -259,7 +274,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
259
274
 
260
275
  // --- 5. 校验:主工作区文件 base hash 一致 ---
261
276
  // 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
262
- const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
277
+ const mainDirtyRaw = gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']);
263
278
  const mainDirtyFiles = mainDirtyRaw ? mainDirtyRaw.split('\n').filter(Boolean) : [];
264
279
  if (mainDirtyFiles.length > 0) {
265
280
  // 如果脏文件和本次 apply 的文件有交集 → 报错
@@ -301,10 +316,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
301
316
  }
302
317
 
303
318
  // --- 7. 生成 patch 并 apply ---
304
- // 确定要包含在 patch 中的文件:有清单用清单交集,无清单用全部变更
305
- const patchFiles = hasAllowList
306
- ? [...allowSet].filter(f => changedFiles.includes(f))
307
- : changedFiles;
319
+ // 确定要包含在 patch 中的文件:有清单用「实际变更 ∩ 清单(pathMatches 容差)」,无清单用全部变更。
320
+ // 口径与 classifyAllowListViolations 一致(坑 apply-glob-manifest-passes-check-but-not-patch)。
321
+ const patchFiles = resolvePatchFiles(changedFiles, allowSet, hasAllowList);
308
322
 
309
323
  // 创建临时文件
310
324
  const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
@@ -321,35 +335,36 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
321
335
  // ls-files --error-unmatch f 成功 ⟺ f 在 ls-files -- 输出。
322
336
  const inTree = getBlobHashMap(worktreePath, diffBase, patchFiles);
323
337
  const inIndexList = patchFiles.length > 0
324
- ? (gitQuiet(worktreePath, `ls-files -- ${patchFiles.join(' ')}`) || '').split('\n').filter(Boolean)
338
+ ? (gitQuiet(worktreePath, ['ls-files', '--', ...patchFiles]) || '').split('\n').filter(Boolean)
325
339
  : [];
326
340
  const inIndex = new Set(inIndexList);
327
341
  const trackedFiles = patchFiles.filter(f => inTree.has(f) || inIndex.has(f));
328
342
  const trackedSet = new Set(trackedFiles);
329
343
  const untrackedPatchFiles = patchFiles.filter(f => !trackedSet.has(f));
330
344
 
331
- // tracked 文件:git diff baseHash
345
+ // tracked 文件:git diff baseHash(数组形式,文件名逐个展开为独立 argv,不经 shell;
346
+ // trim:false 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时——原裸 execSync 无 timeout)
332
347
  if (trackedFiles.length > 0) {
333
- const trackedArgs = trackedFiles.length > 0 ? `-- ${trackedFiles.join(' ')}` : '';
334
- patchContent += execSync(
335
- `git diff --binary ${diffBase} ${trackedArgs}`,
336
- { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
348
+ patchContent += git(
349
+ worktreePath,
350
+ ['diff', '--binary', diffBase, '--', ...trackedFiles],
351
+ { trim: false, timeout: 60000 }
337
352
  );
338
353
  }
339
354
 
340
- // untracked 新文件:git add 到 index,git diff --cached,然后 reset
355
+ // untracked 新文件:git add 到 index,git diff --cached,然后 reset(均数组形式,文件名逐个展开)
341
356
  if (untrackedPatchFiles.length > 0) {
342
- const addArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
343
- git(worktreePath, `add ${addArgs}`);
357
+ git(worktreePath, ['add', '--', ...untrackedPatchFiles]);
344
358
  try {
345
- const diffCachedArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
346
- patchContent += execSync(
347
- `git diff --binary --cached ${diffCachedArgs}`,
348
- { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
359
+ // trim:false 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时
360
+ patchContent += git(
361
+ worktreePath,
362
+ ['diff', '--binary', '--cached', '--', ...untrackedPatchFiles],
363
+ { trim: false, timeout: 60000 }
349
364
  );
350
365
  } finally {
351
366
  // 重置 index(不保留 staged 状态)
352
- gitQuiet(worktreePath, `reset HEAD -- ${addArgs}`);
367
+ gitQuiet(worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
353
368
  }
354
369
  }
355
370
 
@@ -370,13 +385,13 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
370
385
  // 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
371
386
  const trackedPatchFiles = patchFiles.filter(f => {
372
387
  // 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
373
- return gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null ? false : true;
388
+ return gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null ? false : true;
374
389
  });
375
390
  const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
376
391
 
377
392
  // apply --3way 正式应用(主干已提交推进时自动三路合并)
378
393
  try {
379
- git(projectRoot, `apply --3way ${patchPath}`);
394
+ git(projectRoot, ['apply', '--3way', patchPath], { timeout: 30000 });
380
395
  } catch (e) {
381
396
  // --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
382
397
  const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
@@ -427,13 +442,13 @@ function rollbackApply(projectRoot, trackedFiles, newFiles) {
427
442
  // 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
428
443
  let conflicts = [];
429
444
  try {
430
- const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
445
+ const unmerged = gitQuiet(projectRoot, ['diff', '--name-only', '--diff-filter=U']) || '';
431
446
  conflicts = unmerged.split('\n').filter(Boolean);
432
447
  } catch {}
433
448
  // 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
434
449
  // `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
435
450
  for (const f of trackedFiles) {
436
- try { gitQuiet(projectRoot, `checkout HEAD -- ${f}`); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
451
+ try { gitQuiet(projectRoot, ['checkout', 'HEAD', '--', f]); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
437
452
  }
438
453
  // 删除 --3way 可能新建的文件(apply 前不存在)
439
454
  for (const f of newFiles) {
@@ -443,7 +458,7 @@ function rollbackApply(projectRoot, trackedFiles, newFiles) {
443
458
  } catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
444
459
  }
445
460
  // 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
446
- try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
461
+ try { gitQuiet(projectRoot, ['reset', '--quiet']); } catch {}
447
462
  return { conflicts, error };
448
463
  }
449
464
 
@@ -471,15 +486,15 @@ function applyByMerge(result, changeName, projectRoot, wm) {
471
486
  const branch = meta.branch || `sillyspec/${changeName}`;
472
487
 
473
488
  try {
474
- git(projectRoot, `merge --no-ff ${branch}`);
489
+ git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
475
490
  } catch (e) {
476
491
  // merge 冲突:取冲突文件列表 + abort 回滚(不 cleanup,保留 worktree)
477
492
  let conflictFiles = [];
478
493
  try {
479
- const cf = gitQuiet(projectRoot, `diff --name-only --diff-filter=U`);
494
+ const cf = gitQuiet(projectRoot, ['diff', '--name-only', '--diff-filter=U']);
480
495
  conflictFiles = cf ? cf.split('\n').filter(Boolean) : [];
481
496
  } catch {}
482
- try { gitQuiet(projectRoot, `merge --abort`); } catch {}
497
+ try { gitQuiet(projectRoot, ['merge', '--abort']); } catch {}
483
498
  result.errors.push(
484
499
  `git merge ${branch} 冲突,请手动解决。冲突文件:\n` +
485
500
  (conflictFiles.length ? ` ${conflictFiles.join('\n ')}\n` : ` (未能获取冲突文件列表)\n`) +
@@ -492,7 +507,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
492
507
  // 分支可能只含 baseline checkpoint(子代理改动未 commit),merge 产生空内容合并,文件零落地。
493
508
  // 逐个确认 changedFiles 在 main HEAD 存在。任一缺失 → 不 cleanup(fail-open:保留 worktree 唯一副本)。
494
509
  result.merged = true;
495
- const notLanded = changedFiles.filter(f => gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null);
510
+ const notLanded = changedFiles.filter(f => gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null);
496
511
  if (notLanded.length > 0) {
497
512
  result.ok = false;
498
513
  result.errors.push(
@@ -504,7 +519,7 @@ function applyByMerge(result, changeName, projectRoot, wm) {
504
519
  }
505
520
 
506
521
  result.ok = true;
507
- try { result.mergeSummary = git(projectRoot, `log --oneline -1`); } catch {}
522
+ try { result.mergeSummary = git(projectRoot, ['log', '--oneline', '-1']); } catch {}
508
523
  try {
509
524
  wm.cleanup(changeName);
510
525
  } catch (cleanupErr) {
@@ -632,7 +647,7 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
632
647
  let additions = 0, deletions = 0;
633
648
  if (wtPath && diffBase) {
634
649
  try {
635
- const shortstat = gitQuiet(wtPath, `diff --shortstat ${diffBase}`);
650
+ const shortstat = gitQuiet(wtPath, ['diff', '--shortstat', diffBase]);
636
651
  const insMatch = shortstat?.match(/(\d+) insertion/);
637
652
  const delMatch = shortstat?.match(/(\d+) deletion/);
638
653
  additions = insMatch ? parseInt(insMatch[1]) : 0;
@@ -721,7 +736,9 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
721
736
  // worktree 还在,用 baselineCommit 或 baseHash 做 diff
722
737
  try {
723
738
  const diffBase = meta.baselineCommit || meta.baseHash;
724
- const filesRaw = execSync(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
739
+ // execFileSync 数组形式:路径无需引号化(含空格也安全);stdio stderr=ignore
740
+ // 替代 `2>/dev/null`(Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
741
+ const filesRaw = execFileSync('git', ['-C', meta.worktreePath, 'diff', '--name-only', diffBase], { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] });
725
742
  const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
726
743
  if (files.length > 0) {
727
744
  lines.push(``);
@@ -66,7 +66,7 @@ function extractUserInstall(yamlText) {
66
66
  }
67
67
 
68
68
  /** 从 local.yaml 提取 project.type;缺失时按文件特征推断 */
69
- function detectProjectType(worktreePath, specBase) {
69
+ export function detectProjectType(worktreePath, specBase) {
70
70
  const yamlText = readLocalYaml(specBase, worktreePath);
71
71
  if (yamlText) {
72
72
  const m = yamlText.match(/type:\s*(\S+)/);
@@ -75,11 +75,14 @@ function detectProjectType(worktreePath, specBase) {
75
75
  if (existsSync(join(worktreePath, 'pom.xml'))) return 'maven';
76
76
  if (existsSync(join(worktreePath, 'build.gradle')) || existsSync(join(worktreePath, 'build.gradle.kts'))) return 'gradle';
77
77
  if (existsSync(join(worktreePath, 'package.json'))) return 'nodejs';
78
+ // Python:pyproject.toml(uv/poetry/pdm)或 requirements.txt(pip)——治 worktree 内 ruff/pre-commit 等
79
+ // 二进制不供给(exec 复盘 3-②:detectProjectType 原无 python 分支,python 项目根被误判 generic → n/a)
80
+ if (existsSync(join(worktreePath, 'pyproject.toml')) || existsSync(join(worktreePath, 'requirements.txt'))) return 'python';
78
81
  return 'generic';
79
82
  }
80
83
 
81
84
  /** 按 project.type + lockfile 推断 install 命令(无 commands.install 时)*/
82
- function inferInstallCommand(projectType, worktreePath, userInstall) {
85
+ export function inferInstallCommand(projectType, worktreePath, userInstall) {
83
86
  if (userInstall) return userInstall;
84
87
  switch (projectType) {
85
88
  case 'nodejs':
@@ -91,6 +94,11 @@ function inferInstallCommand(projectType, worktreePath, userInstall) {
91
94
  return 'mvn -o test';
92
95
  case 'gradle':
93
96
  return './gradlew test';
97
+ case 'python':
98
+ // uv 优先(现代 Python 工具链,pyproject.toml/uv.lock 项目走 uv sync 建 .venv + 装依赖,
99
+ // 与 execute worktree 环境预告一致);纯 requirements.txt(无 pyproject)回退 pip
100
+ if (existsSync(join(worktreePath, 'uv.lock')) || existsSync(join(worktreePath, 'pyproject.toml'))) return 'uv sync';
101
+ return 'pip install -r requirements.txt';
94
102
  default:
95
103
  return null; // generic → n/a
96
104
  }
@@ -190,11 +198,16 @@ function linkOneDir(wtDir, mainDir) {
190
198
  *
191
199
  * @param {string} worktreePath - worktree 根目录
192
200
  * @param {string} mainCwd - 主 checkout 根目录(node_modules 来源)
193
- * @param {{ specBase?: string, timeout?: number }} opts
201
+ * @param {{ specBase?: string, timeout?: number, force?: boolean }} opts
202
+ * - force=true(doctor --fix 强制重装):绕过根目录 lockfile 一致快路径(下方 line 216 区块),
203
+ * 强制走 install 分支重装。该快路径是 tryLink 的唯一根调用点,绕过它即同时绕过 tryLink
204
+ * 的 preexisting 幂等短路(101-110),实现"强制重装"语义。配合调用方先解 junction
205
+ * (worktree.js _doctorReprovision),避免 install 经 junction 误改主仓 node_modules。
206
+ * modules 子模块 link 不受 force 影响(仅根 install)。
194
207
  * @returns {{ depsStatus, depsMethod, depsSource, depsLockHash, depsCheckedAt, depsError?, depsModules? }}
195
208
  */
196
209
  export function provisionDeps(worktreePath, mainCwd, opts = {}) {
197
- const { specBase = null, timeout = DEFAULT_TIMEOUT_MS } = opts;
210
+ const { specBase = null, timeout = DEFAULT_TIMEOUT_MS, force = false } = opts;
198
211
  const depsCheckedAt = new Date().toISOString();
199
212
  const yamlText = readLocalYaml(specBase, worktreePath);
200
213
  const wtHash = lockfileHash(worktreePath);
@@ -210,10 +223,11 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
210
223
  result = { depsStatus: 'n/a', depsMethod: null, depsSource: null, depsLockHash: wtHash };
211
224
  } else {
212
225
  // 快路径:main 有 node_modules 且 lockfile hash 一致 → junction/symlink
226
+ // force=true 时绕过(doctor --fix 强制重装;绕过此块即同时绕过 tryLink 101-110 幂等短路)
213
227
  const mainNodeModules = mainCwd ? join(mainCwd, 'node_modules') : null;
214
228
  const mainHash = lockfileHash(mainCwd);
215
229
  let linked = false;
216
- if (mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
230
+ if (!force && mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
217
231
  const linkResult = tryLink(mainNodeModules, join(worktreePath, 'node_modules'));
218
232
  if (linkResult.ok) {
219
233
  result = {
@@ -265,3 +279,73 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
265
279
 
266
280
  return result;
267
281
  }
282
+
283
+ /**
284
+ * 统一的 worktree deps 新鲜度判定(H1,change 2026-08-05-tooling-feedback-fixes)。
285
+ * 供 doctor(worktree.js)与 ensureDepsFreshness(run/stage.js)共用,消除两处双写漂移。
286
+ *
287
+ * 判定优先级:failed → missing → stale → main-drift → fresh
288
+ * - failed: meta.depsStatus==='failed'(上次供给失败,占最高优先级)
289
+ * - missing: depsStatus ∈ {linked, installed} 且 node_modules 不存在
290
+ * - stale: wtHash 与 meta.depsLockHash 不一致(worktree 自身 lockfile 在上次供给后变化)
291
+ * - main-drift: wtHash 与主仓 mainHash 不一致(主仓 lockfile 漂移;复用 linkOneDir 177-178 的 mismatch 判据)
292
+ * - fresh: 其余情况
293
+ *
294
+ * lockfileHash 返回 null(无 lockfile/package.json)时相关比较优雅降级——不报 stale / main-drift,
295
+ * 避免无 lockfile 项目(如纯 generic / 非 nodejs)误判漂移。
296
+ *
297
+ * @param {object} [meta] - worktree meta(读 depsStatus / depsLockHash / depsError)
298
+ * @param {string} wtPath - worktree 根目录
299
+ * @param {string} mainCwd - 主 checkout 根目录
300
+ * @returns {{ status: 'fresh'|'missing'|'stale'|'main-drift'|'failed', detail: string, wtHash: (string|null), mainHash: (string|null), metaLockHash: (string|null) }}
301
+ */
302
+ export function checkDepsFreshness(meta, wtPath, mainCwd) {
303
+ const depsStatus = meta && meta.depsStatus ? meta.depsStatus : null;
304
+ const metaLockHash = meta && meta.depsLockHash ? meta.depsLockHash : null;
305
+ const wtHash = lockfileHash(wtPath);
306
+ const mainHash = lockfileHash(mainCwd);
307
+
308
+ // 1. failed 最高优先级(对齐 task-01 蓝图:failed → missing → stale → main-drift → fresh)
309
+ if (depsStatus === 'failed') {
310
+ return {
311
+ status: 'failed',
312
+ detail: '上次依赖供给失败' + (meta && meta.depsError ? ': ' + meta.depsError : ''),
313
+ wtHash, mainHash, metaLockHash,
314
+ };
315
+ }
316
+
317
+ // 2. missing:曾 link/install 但 node_modules 已丢失(对齐 doctor 914-915 / ensure 403)
318
+ const nmExists = wtPath ? existsSync(join(wtPath, 'node_modules')) : false;
319
+ if (['linked', 'installed'].includes(depsStatus) && !nmExists) {
320
+ return {
321
+ status: 'missing',
322
+ detail: `meta.depsStatus=${depsStatus} 但 node_modules 缺失`,
323
+ wtHash, mainHash, metaLockHash,
324
+ };
325
+ }
326
+
327
+ // 3. stale:worktree 自身 lockfile 与 meta 快照不一致(对齐 doctor 916-917 / ensure 404)
328
+ if (metaLockHash && wtHash && wtHash !== metaLockHash) {
329
+ return {
330
+ status: 'stale',
331
+ detail: `lockfile 变化 (${metaLockHash} -> ${wtHash})`,
332
+ wtHash, mainHash, metaLockHash,
333
+ };
334
+ }
335
+
336
+ // 4. main-drift(新增):wtHash 与主仓 mainHash 不一致——主仓 lockfile 更新过、worktree 未跟
337
+ if (wtHash && mainHash && wtHash !== mainHash) {
338
+ return {
339
+ status: 'main-drift',
340
+ detail: `worktree lockfile 与主仓不一致 (wt=${wtHash} main=${mainHash})`,
341
+ wtHash, mainHash, metaLockHash,
342
+ };
343
+ }
344
+
345
+ // 5. fresh
346
+ return {
347
+ status: 'fresh',
348
+ detail: '依赖新鲜',
349
+ wtHash, mainHash, metaLockHash,
350
+ };
351
+ }