sillyspec 3.28.7 → 3.28.8

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 (51) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/package.json +1 -1
  7. package/src/agent-session-log.js +446 -35
  8. package/src/config-cat.js +6 -3
  9. package/src/constants.js +3 -0
  10. package/src/db.js +12 -3
  11. package/src/docs-check.js +137 -12
  12. package/src/docs-gate.js +3 -0
  13. package/src/doctor-diagnostics.js +19 -7
  14. package/src/friction-tally.js +7 -2
  15. package/src/git-helper.js +6 -4
  16. package/src/hooks/worktree-guard.js +59 -29
  17. package/src/index.js +168 -64
  18. package/src/local-detect.js +30 -15
  19. package/src/local-register.js +13 -4
  20. package/src/mcp-server.js +38 -8
  21. package/src/progress/change-registry.js +41 -35
  22. package/src/progress/consistency-doctor.js +11 -4
  23. package/src/progress/stage-machine.js +3 -1
  24. package/src/progress/step-store.js +30 -17
  25. package/src/progress.js +23 -0
  26. package/src/quicklog.js +44 -18
  27. package/src/run/command.js +17 -3
  28. package/src/run/complete-handlers.js +3 -3
  29. package/src/run/complete.js +28 -9
  30. package/src/run/concurrent-detect.js +1 -1
  31. package/src/run/gate-snapshot.js +115 -5
  32. package/src/run/gates.js +35 -5
  33. package/src/run/prompt.js +9 -1
  34. package/src/run/quick-audit.js +25 -2
  35. package/src/run/shared.js +98 -10
  36. package/src/run/verify-quality-scan.js +14 -3
  37. package/src/spec-sync.js +19 -14
  38. package/src/stage-contract-spec.js +4 -3
  39. package/src/stage-review.js +6 -5
  40. package/src/stages/brainstorm.js +1 -1
  41. package/src/stages/execute.js +6 -1
  42. package/src/stages/plan-postcheck.js +57 -12
  43. package/src/sync.js +66 -4
  44. package/src/task-review.js +41 -6
  45. package/src/verify-facts-schema.js +10 -3
  46. package/src/verify-postcheck.js +21 -6
  47. package/src/verify-probes.js +8 -3
  48. package/src/worktree-apply.js +56 -18
  49. package/src/worktree-cross.js +12 -18
  50. package/src/worktree.js +138 -25
  51. package/src/wt-commit.js +128 -0
package/src/worktree.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { execFileSync } from 'child_process';
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync, copyFileSync } from 'fs';
13
- import { join, resolve, dirname, relative, isAbsolute } from 'path';
13
+ import { join, resolve, dirname, relative, isAbsolute, basename } from 'path';
14
14
  import { createHash } from 'crypto';
15
15
  import { provisionDeps, checkDepsFreshness, detectEditableInstallEscape } from './worktree-deps.js';
16
16
  import { writeAtomicSync } from './fs-atomic.js';
@@ -25,6 +25,24 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
25
25
  const BRANCH_PREFIX = 'sillyspec/';
26
26
  const META_FILE = 'meta.json';
27
27
 
28
+ /**
29
+ * argv 长度分批(审查遗留终批-①;与 worktree-apply.js chunkPaths 同逻辑内联私有版——
30
+ * 那边 import 本文件,反向 import 成环,两处须同步演化)。Windows CreateProcess 命令行
31
+ * 32767 字符上限:数百个长路径全量展开时 spawn 直接失败(hash-object 失败返 null 会让
32
+ * hasUnappliedChanges 全量误判 pending 卡死 cleanup)。保守 8000 字符/批。
33
+ */
34
+ function _chunkPathsPrivate(paths, maxChars = 8000) {
35
+ const batches = [[]]
36
+ let len = 0
37
+ for (const p of paths) {
38
+ const l = String(p).length + 1
39
+ if (batches[batches.length - 1].length > 0 && len + l > maxChars) { batches.push([]); len = 0 }
40
+ batches[batches.length - 1].push(p)
41
+ len += l
42
+ }
43
+ return batches
44
+ }
45
+
28
46
  /**
29
47
  * 解除 worktree 内全部 node_modules junction/symlink(根 + meta.depsModules 各子模块)。
30
48
  *
@@ -79,7 +97,65 @@ export function unlinkNodeModulesLinks(worktreePath, meta = null, details = null
79
97
  * 安全删除 worktree 目录:先解链全部 node_modules junction 再 rmSync。
80
98
  * 幽灵目录清理(create 强删 / doctor ghost 修复)的统一出口——不再裸 rmSync。
81
99
  */
82
- export function safeRemoveWorktreeDir(worktreePath, meta = null) {
100
+ /**
101
+ * 跨仓 worktree 目录判定(坑 cross-worktree-swept-by-main-cleanup,2026-09-12 驾驭第十三批①,
102
+ * 用户实证:跨仓 worktree 被 verify 期间的主仓侧清理面误删,apply 锚点校验连环失败、被迫
103
+ * cherry-pick 重建):目录内 meta.json 带 isCross:true,或目录名呈 <change>--<repoKey> 跨仓
104
+ * 形态(.runtime/worktrees/ 下主仓 worktree 恒为纯 <change>,双连字符后缀是跨仓专属命名)。
105
+ */
106
+ export function isCrossWorktreeDir(worktreePath) {
107
+ try {
108
+ const metaPath = join(worktreePath, 'meta.json')
109
+ if (existsSync(metaPath)) {
110
+ try {
111
+ const m = JSON.parse(readFileSync(metaPath, 'utf8'))
112
+ if (m && m.isCross === true) return true
113
+ } catch { /* 损坏 meta → 落到命名形态判定 */ }
114
+ }
115
+ const base = basename(worktreePath)
116
+ // 主仓 changeName 可含连字符(2026-09-10-x),跨仓后缀是 '--<repoKey>'——要求双连字符
117
+ // 分隔且两侧非空、repoKey 段无路径分隔符
118
+ const m2 = base.match(/^(.+)--([A-Za-z0-9_.\-]+)$/)
119
+ return !!(m2 && m2[1].trim() && m2[2].trim() && !m2[2].includes('/') && !m2[2].includes('\\'))
120
+ } catch {
121
+ return false
122
+ }
123
+ }
124
+
125
+ /**
126
+ * worktree 目录安全删除原语。opts.allowCross(默认 false):跨仓 worktree(isCross meta/
127
+ * 命名形态)拒绝删除——主仓清理面(cleanup force-remove / doctor ghost / 幽灵清理)不得
128
+ * 波及跨仓 worktree(其注册在跨仓仓 .git,删除权在 cleanupCrossWorktrees 显式路径)。
129
+ */
130
+
131
+ /**
132
+ * baseline checkpoint 产物防御(坑 baseline-checkpoint-binary-sweep,2026-09-12 驾驭第十五
133
+ * 批②,用户实证:部署 tar.gz(289MB)被 checkpoint 带进分支——FF 合并被迫改 merge commit +
134
+ * 二进制永久入 git)。untracked 收入口对两类产物恒跳过:①已知归档/分发扩展名(tar.gz/zip/
135
+ * whl/exe 等——几乎不可能是源码 WIP);②单文件超体积帽(缺省 10MB,SILLYSPEC_BASELINE_
136
+ * MAX_FILE_MB 可调)。跳过清单返回调用方点名 + 补 .gitignore 指引(gitignore 缺口的实际代价
137
+ * 由工具兜底提示)。tracked-modified 不走本防御(已在 git 历史里,checkpoint diff 是 git 原生)。
138
+ */
139
+ const ARTIFACT_EXT_RE = /\.(?:tar\.gz|tgz|tar|zip|7z|rar|gz|bz2|xz|whl|exe|dll|so|dylib|iso|dmg|pkg|jar|war|apk|ipa)$/i
140
+
141
+ export function isCheckpointSkippableArtifact(absPath) {
142
+ const rel = String(absPath).split('\\').join('/')
143
+ if (ARTIFACT_EXT_RE.test(rel)) return 'artifact-ext'
144
+ try {
145
+ const maxMB = Number(process.env.SILLYSPEC_BASELINE_MAX_FILE_MB) || 10
146
+ const st = statSync(absPath)
147
+ if (st.isFile() && st.size > maxMB * 1024 * 1024) return 'size-cap'
148
+ } catch { /* stat 失败按不跳过(后续 copy 自会报错) */ }
149
+ return null
150
+ }
151
+
152
+ export function safeRemoveWorktreeDir(worktreePath, meta = null, opts = {}) {
153
+ if (!opts.allowCross && isCrossWorktreeDir(worktreePath)) {
154
+ throw new Error(
155
+ `跨仓 worktree 拒绝删除(主仓清理面守卫,坑 cross-worktree-swept-by-main-cleanup): ${worktreePath}` +
156
+ `——跨仓 worktree 由 sillyspec worktree cleanup <change>(跨仓一并清理)或 apply no-op 路径管理;主仓侧清理/doctor 扫描不碰它`
157
+ )
158
+ }
83
159
  unlinkNodeModulesLinks(worktreePath, meta);
84
160
  rmSync(worktreePath, { recursive: true, force: true });
85
161
  }
@@ -873,11 +949,10 @@ export class WorktreeManager {
873
949
  _branchReviewReferences(branch) {
874
950
  const runsDir = join(this.cwd, '.sillyspec', '.runtime', 'execute-runs');
875
951
  if (!existsSync(runsDir)) return [];
876
- const branchCommits = new Set(
877
- (gitQuiet(this.cwd, ['rev-list', branch]) || '').split('\n').map(s => s.trim()).filter(Boolean)
878
- );
879
- if (branchCommits.size === 0) return [];
880
- const refs = [];
952
+ // 终批-②:先收 review.json 里出现的候选 hash(少量),再逐个精确校验「是分支祖先或自身」
953
+ // ——旧实现整分支 rev-list 进 Set(大历史仓 10 万 commit 每次 cleanup/doctor 可观内存与
954
+ // 时间,而判定只用到 review 引用过的那几个 hash)。
955
+ const candidates = new Map(); // hash → 引用它的 review.json 路径数组
881
956
  const visit = (dir) => {
882
957
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
883
958
  const p = join(dir, entry.name);
@@ -887,14 +962,25 @@ export class WorktreeManager {
887
962
  const r = JSON.parse(readFileSync(p, 'utf8'));
888
963
  for (const key of ['base', 'head']) {
889
964
  const h = typeof r[key] === 'string' ? r[key].trim() : '';
890
- if (h && branchCommits.has(h)) { refs.push(p); break; }
965
+ if (h) {
966
+ if (!candidates.has(h)) candidates.set(h, []);
967
+ candidates.get(h).push(p);
968
+ }
891
969
  }
892
970
  } catch { /* review.json 损坏不参与判定 */ }
893
971
  }
894
972
  }
895
973
  };
896
974
  visit(runsDir);
897
- return refs;
975
+ if (candidates.size === 0) return [];
976
+ const refs = [];
977
+ for (const [hash, paths] of candidates) {
978
+ // merge-base --is-ancestor:祖先或自身 → exit 0 输出空串(gitQuiet 返回 ''≠null);
979
+ // 非祖先 → exit 1 → null;未知/畸形 hash → 报错非零 → null(不在分支内,语义正确)
980
+ const probe = gitQuiet(this.cwd, ['merge-base', '--is-ancestor', hash, branch]);
981
+ if (probe !== null) refs.push(...paths);
982
+ }
983
+ return [...new Set(refs)];
898
984
  }
899
985
 
900
986
  /**
@@ -1468,7 +1554,12 @@ export class WorktreeManager {
1468
1554
  hasUnappliedChanges(changeName) {
1469
1555
  const name = validateChangeName(changeName);
1470
1556
  const meta = this.getMeta(name);
1471
- if (!meta) return { hasChanges: false, changedFiles: [], reason: 'no meta' };
1557
+ if (!meta) {
1558
+ // meta 缺失(写坏/误删)→ 无法定位与判定交付,与「目录不存在」同款保守保留(对齐 create()
1559
+ // 幽灵分支的 fail-closed)。旧版返回 hasChanges:false 使 cleanup 跳过未落仓护栏直接
1560
+ // worktree remove --force——可删未提交代码(2026-09-11 审查 P1)
1561
+ return { hasChanges: true, changedFiles: [], reason: 'no meta (conservative keep)' };
1562
+ }
1472
1563
 
1473
1564
  const worktreePath = meta.worktreePath;
1474
1565
  if (!worktreePath || !existsSync(worktreePath)) {
@@ -1596,9 +1687,14 @@ export class WorktreeManager {
1596
1687
  }
1597
1688
 
1598
1689
  if (untrackedFiles.length > 0) {
1599
- // hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整命令失败 → gitQuiet 返回 null
1600
- const wtHashes = (gitQuiet(worktreePath, ['hash-object', '--', ...untrackedFiles], { timeout: 30000 }) || '')
1601
- .split('\n');
1690
+ // hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整批失败 → 该批各行为空串
1691
+ // (视为未应用,保守保留——与旧整命令失败语义一致,只是失败面从全量缩小到单批)。
1692
+ // 终批-①:argv 分批(批次间行序拼接保持与 untrackedFiles 的索引对齐)。
1693
+ const wtHashes = []
1694
+ for (const batch of _chunkPathsPrivate(untrackedFiles)) {
1695
+ const out = gitQuiet(worktreePath, ['hash-object', '--', ...batch], { timeout: 30000 }) || ''
1696
+ wtHashes.push(...out.split('\n'))
1697
+ }
1602
1698
  const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
1603
1699
  for (let i = 0; i < untrackedFiles.length; i++) {
1604
1700
  // wtHashes[i] 缺失(命令失败/行数不齐)→ 视为未应用,保守保留
@@ -1623,14 +1719,18 @@ export class WorktreeManager {
1623
1719
  _lsTreeBlobs(cwd, treeish, files) {
1624
1720
  const map = new Map();
1625
1721
  if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
1626
- const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
1627
- if (!raw) return map;
1628
- for (const line of raw.split('\n')) {
1629
- if (!line) continue;
1630
- const tab = line.indexOf('\t');
1631
- if (tab === -1) continue;
1632
- const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
1633
- if (hash) map.set(line.slice(tab + 1), hash);
1722
+ // 终批-①:argv 分批(worktree-apply.js 的 chunkPaths 同逻辑内联——那边 import 本文件,
1723
+ // 反向 import 会成环;两处口径须同步演化)
1724
+ for (const batch of _chunkPathsPrivate(files)) {
1725
+ const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...batch]);
1726
+ if (!raw) continue;
1727
+ for (const line of raw.split('\n')) {
1728
+ if (!line) continue;
1729
+ const tab = line.indexOf('\t');
1730
+ if (tab === -1) continue;
1731
+ const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
1732
+ if (hash) map.set(line.slice(tab + 1), hash);
1733
+ }
1634
1734
  }
1635
1735
  return map;
1636
1736
  }
@@ -1758,10 +1858,15 @@ export class WorktreeManager {
1758
1858
  // untracked 文件(排除 .sillyspec/.runtime 等);目录跳过避免 readFileSync EISDIR
1759
1859
  const untracked = gitQuiet(mainCwd, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '';
1760
1860
  const skippedDirs = [];
1861
+ const skippedArtifacts = [];
1761
1862
  if (untracked) {
1762
1863
  for (const f of untracked.split('\n').filter(Boolean)) {
1763
1864
  const norm = f.replace(/\\/g, '/')
1764
1865
  if (norm === '.sillyspec' || norm.startsWith('.sillyspec/')) { excludedSpecFiles.push(f); continue }
1866
+ // 产物防御(坑 baseline-checkpoint-binary-sweep):归档扩展名/超体积帽 untracked 恒不进
1867
+ // baseline checkpoint——289MB tar.gz 入分支(FF 破坏 + 二进制永久入 git)的实际代价兜底
1868
+ const artifactWhy = isCheckpointSkippableArtifact(join(mainCwd, f));
1869
+ if (artifactWhy) { skippedArtifacts.push(`${f}(${artifactWhy})`); continue }
1765
1870
  const r = copyUntrackedEntry(join(mainCwd, f), join(worktreePath, f));
1766
1871
  if (r.status === 'copied') files.push(f);
1767
1872
  else if (r.status === 'skipped-dir') skippedDirs.push(f);
@@ -1771,6 +1876,10 @@ export class WorktreeManager {
1771
1876
  if (skippedDirs.length > 0) {
1772
1877
  console.log(`ℹ️ baseline overlay 跳过 ${skippedDirs.length} 个 untracked 目录(不读目录避免 EISDIR,坑 execute-worktree-overlay-untracked-dir-eisdir)`);
1773
1878
  }
1879
+ if (skippedArtifacts.length > 0) {
1880
+ console.warn(`⚠️ baseline checkpoint 跳过 ${skippedArtifacts.length} 个产物/大文件(不进分支,留在主仓原位):${skippedArtifacts.slice(0, 5).join('、')}${skippedArtifacts.length > 5 ? ' 等' : ''}`);
1881
+ console.warn(` 归档/分发物与大文件不该入 git——补 .gitignore(如 *.tar.gz / dist/ / deploy/)或移出仓库;本跳过仅护 baseline checkpoint,git add -A 仍会把它们卷进提交`);
1882
+ }
1774
1883
  // 跨变更 spec 文档隔离可见性:列出被排除的 .sillyspec/ 文件(去重,截断展示),
1775
1884
  // 让「ROADMAP 被谁改动、为何不在本 baseline」在 create 时刻可查,不需事后考古
1776
1885
  const excludedUnique = [...new Set(excludedSpecFiles)]
@@ -1819,8 +1928,12 @@ export class WorktreeManager {
1819
1928
  * @returns {string} commit hash
1820
1929
  */
1821
1930
  _createBaselineCheckpoint(worktreePath, changeName, baselineFiles = []) {
1822
- // 使用临时 git identity,避免用户未配置 user.name/user.email 导致失败
1931
+ // 临时 git identity(用户未配置 user.name/email 时兜底)。终批-③:{ ...process.env } 展开
1932
+ // 而非裸替换——裸 env 丢 SystemRoot/USERPROFILE/TEMP(Windows 上 git 直接起不来);
1933
+ // 提交走统一 git() 入口(safe.directory/timeout/maxBuffer 与全仓其他调用同口径——
1934
+ // 旧裸 execFileSync 在 dubious-ownership 环境(CI/挂载盘)必败且无超时无 maxBuffer)。
1823
1935
  const env = {
1936
+ ...process.env,
1824
1937
  GIT_AUTHOR_NAME: 'sillyspec',
1825
1938
  GIT_AUTHOR_EMAIL: 'sillyspec@baseline',
1826
1939
  GIT_COMMITTER_NAME: 'sillyspec',
@@ -1845,10 +1958,10 @@ export class WorktreeManager {
1845
1958
  // 分支上以便区分「前置 baseline」与「子代理新增改动」。它不该触发项目 pre-commit
1846
1959
  // hook(如 ruff format),否则主仓库 dirty 文件中任一不达标的会被 hook reformat
1847
1960
  // 致 commit 失败 → worktree 创建失败 → execute 无法启动。
1848
- execFileSync(
1849
- 'git',
1961
+ git(
1962
+ worktreePath,
1850
1963
  ['commit', '--no-verify', '-m', `sillyspec: baseline checkpoint for ${changeName}${body}`],
1851
- { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
1964
+ { timeout: 30000, env }
1852
1965
  );
1853
1966
  const hash = git(worktreePath, ['rev-parse', 'HEAD']);
1854
1967
  console.log(`📌 baseline checkpoint: ${hash}`);
@@ -0,0 +1,128 @@
1
+ /**
2
+ * sillyspec wt-commit —— worktree 内 per-task 串行化提交(提交队列)
3
+ *
4
+ * 坑 wt-parallel-commit-race(2026-08-29 用户实证×2):同 Wave 多个 task 子代理共享同一个
5
+ * 主仓 worktree,各自裸跑 git add/commit 有两条竞态:① `git add -A` 把并行兄弟的 WIP 文件卷进
6
+ * 自己的提交;② 并发写 index 撞 index.lock。两次都靠子代理自觉救回,属结构性风险——本命令把
7
+ * 「自觉」换成「机制」:
8
+ *
9
+ * ① 文件锁串行化:withFileLock 排他锁 <mainRepo>/.sillyspec/.runtime/wt-commit-<change>.lock,
10
+ * 同 change 的 wt-commit 全局排队(per-task 提交队列效果),锁内 add→commit 原子完成;
11
+ * ② 强制显式 pathspec:-- 后逐路径(或 --pathspec-from-file)——命令语义上杜绝 add -A 扫入
12
+ * 他人 WIP;commit 带同一 pathspec(--only 语义),不动其他并行者已暂存的内容。
13
+ *
14
+ * 边界:锁只约束 wt-commit 调用方;兄弟子代理若仍裸跑 git,靠 ② 的 pathspec 隔离兜底 + git 侧
15
+ * index.lock 冲突时退避重试。跨仓 task 不走本命令(跨仓有独立 workdir / 直写主干,execute 指引
16
+ * 要求显式 pathspec 提交)。锁文件在 .runtime(运行时目录,不入 git 历史)。
17
+ *
18
+ * 成功返回 HEAD hash(review.json 的 head 锚点直接可用);无变更 → ok=true + skipped。
19
+ */
20
+ import { join, resolve } from 'node:path'
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { withFileLock } from './quicklog.js'
23
+ import { safeGit } from './git-helper.js'
24
+ import { WorktreeManager } from './worktree.js'
25
+
26
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
27
+
28
+ /**
29
+ * git 子操作退避重试:撞 index.lock / HEAD 锁(锁队列外裸跑 git 的兄弟进程短暂持锁)时等待重试。
30
+ * 非 lock 类错误(路径不存在 / pathspec 不匹配 / 权限)直接抛,不空转。
31
+ */
32
+ async function gitWithLockRetry(cwd, args, label, { retries = 6, waitMs = 1000 } = {}) {
33
+ for (let i = 0; ; i++) {
34
+ const { value, error } = safeGit(cwd, args, { timeout: 30000 })
35
+ if (!error) return value
36
+ const msg = String((error && error.message) || error)
37
+ if (/index\.lock|HEAD\.lock|cannot lock ref/i.test(msg) && i < retries) {
38
+ await sleep(waitMs)
39
+ continue
40
+ }
41
+ throw new Error(`${label} 失败:${msg.split('\n')[0].trim()}`)
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 执行 worktree 串行化提交。
47
+ *
48
+ * @param {object} opts
49
+ * @param {string} opts.changeName - 变更名(worktree 名)
50
+ * @param {string} opts.message - 提交信息(建议 "<task-NN 摘要>")
51
+ * @param {string[]} opts.pathspecs - 显式路径列表(必填,防 add -A 扫入并行兄弟 WIP)
52
+ * @param {string|null} [opts.pathspecFile] - --pathspec-from-file:每行一个路径(与 pathspecs 二选一或叠加)
53
+ * @param {string} [opts.cwd] - 调用方 cwd(主仓根或 worktree 内均可——WorktreeManager 经
54
+ * git-common-dir 反推主仓根,worktree 内调用同样定位正确)
55
+ * @returns {Promise<{ok:boolean,skipped:boolean,head:string,shortHead:string,worktreePath:string,changeName:string,files:string[]}>}
56
+ * ok=false 时抛错(调用方打错误 + exit 1),skipped=无变更可提交(HEAD 不动,不算失败)。
57
+ */
58
+ export async function runWtCommit({ changeName, message, pathspecs = [], pathspecFile = null, cwd = process.cwd() }) {
59
+ if (!changeName) throw new Error('缺少 --change <变更名>(worktree 名;在 worktree 内运行时可省略,CLI 从 cwd 推断)')
60
+ if (!message || !message.trim()) throw new Error('缺少 -m/--message <提交信息>(建议 "<task-NN 摘要>")')
61
+
62
+ // pathspec 汇总:显式路径 + 文件逐行(# 注释与空行忽略,与 git pathspec-from-file 习惯一致)
63
+ const files = [...pathspecs.map(p => p.trim()).filter(Boolean)]
64
+ if (pathspecFile) {
65
+ if (!existsSync(pathspecFile)) throw new Error(`--pathspec-from-file 文件不存在: ${pathspecFile}`)
66
+ for (const line of readFileSync(pathspecFile, 'utf8').split(/\r?\n/)) {
67
+ const t = line.trim()
68
+ if (t && !t.startsWith('#')) files.push(t)
69
+ }
70
+ }
71
+ if (files.length === 0) {
72
+ throw new Error('缺少提交路径:`--` 后逐个给出本 task 的精确路径(或 --pathspec-from-file <file>)。wt-commit 刻意不支持 add -A / 空 pathspec——同 Wave 兄弟子代理共享本 worktree,整树 add 会把对方 WIP 卷进你的提交')
73
+ }
74
+
75
+ const wm = new WorktreeManager({ cwd })
76
+ const worktreePath = wm.getWorktreePath(changeName)
77
+ // 隔离模式(worktree 目录存在)→ 提交目标 = worktree 本体;in-place-fallback(meta 声明无
78
+ // worktree 直改主仓)→ 主仓根(与 MultiRepoContext 主仓 entry 的 worktreePath 兜底口径一致)。
79
+ // 顺序先看目录再看 meta:worktree 目录存在即隔离态,meta 缺失/损坏不改变目标(fail-safe)。
80
+ let target = null
81
+ if (existsSync(worktreePath)) {
82
+ target = worktreePath
83
+ } else {
84
+ const meta = wm.getMeta(changeName)
85
+ if (meta && meta.mode === 'in-place-fallback') {
86
+ target = resolve(wm.worktreeBase, '..', '..', '..') // <main>/.sillyspec/.runtime/worktrees → <main>
87
+ }
88
+ }
89
+ if (!target) {
90
+ throw new Error(`worktree 不存在: ${worktreePath}(确认 --change 变更名;execute 尚未创建 worktree 时先跑 sillyspec run execute)`)
91
+ }
92
+
93
+ // 锁:主仓 .sillyspec/.runtime/ 下按 change 一把(in-place 模式同目录可达,零分叉)
94
+ const lockPath = join(wm.worktreeBase, '..', `wt-commit-${changeName}.lock`)
95
+ const envTimeout = parseInt(process.env.SILLYSPEC_WT_COMMIT_LOCK_TIMEOUT_MS || '', 10)
96
+ const lockTimeoutMs = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 120000
97
+
98
+ const result = await withFileLock(lockPath, async () => {
99
+ const headBefore = safeGit(target, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || ''
100
+ await gitWithLockRetry(target, ['add', '--', ...files], 'git add')
101
+ // pathspec 范围内是否有 staged 变更(vs HEAD):空 = 无可提交(task 幂等重跑场景),
102
+ // 跳过 commit 保持 skipped 语义——不靠解析 git commit 的 "nothing to commit" stdout
103
+ // (safeGit 的 error 只保留命令首行,stdout 不可得)。
104
+ const staged = safeGit(target, ['diff', '--cached', '--name-only', '--', ...files], { timeout: 10000 }).value || ''
105
+ if (!staged.trim()) {
106
+ return { ok: true, skipped: true, head: headBefore }
107
+ }
108
+ await gitWithLockRetry(target, ['commit', '-m', message, '--', ...files], 'git commit')
109
+ const head = safeGit(target, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || headBefore
110
+ return { ok: true, skipped: false, head }
111
+ }, {
112
+ // 持有者语义:超时报错时能看出是谁卡着队列
113
+ content: JSON.stringify({ pid: process.pid, changeName, purpose: 'wt-commit' }),
114
+ staleMs: 120000, // 持锁进程崩溃残留的回收阈值(commit 通常秒级)
115
+ timeoutMs: lockTimeoutMs, // 排队上限:同 Wave 并行 task 全走队列,给足余量
116
+ retryMs: 200,
117
+ })
118
+
119
+ return {
120
+ ok: true,
121
+ skipped: !!result.skipped,
122
+ head: result.head,
123
+ shortHead: (result.head || '').slice(0, 8),
124
+ worktreePath: target,
125
+ changeName,
126
+ files,
127
+ }
128
+ }