sillyspec 3.26.10 → 3.26.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 (97) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -693
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +1443 -1399
  62. package/src/run/complete.js +225 -24
  63. package/src/run/gates.js +755 -755
  64. package/src/run/prompt.js +761 -761
  65. package/src/run/quick-audit.js +155 -146
  66. package/src/run/shared.js +1268 -1215
  67. package/src/run/stage.js +487 -479
  68. package/src/scan-postcheck.js +371 -371
  69. package/src/setup.js +416 -416
  70. package/src/sillyhub-mcp/client.js +529 -529
  71. package/src/spec-sync.js +323 -319
  72. package/src/stage-contract.js +897 -897
  73. package/src/stage-review.js +549 -549
  74. package/src/stages/archive.js +155 -155
  75. package/src/stages/brainstorm-auto.js +236 -236
  76. package/src/stages/brainstorm.js +596 -597
  77. package/src/stages/cmd-existence.js +145 -145
  78. package/src/stages/doctor.js +371 -371
  79. package/src/stages/execute.js +1003 -1003
  80. package/src/stages/explore.js +34 -34
  81. package/src/stages/index.js +29 -29
  82. package/src/stages/knowledge.js +498 -498
  83. package/src/stages/plan-postcheck.js +1302 -1302
  84. package/src/stages/plan.js +625 -623
  85. package/src/stages/quick.js +151 -151
  86. package/src/stages/scan.js +607 -607
  87. package/src/stages/status.js +64 -64
  88. package/src/stages/verify.js +264 -264
  89. package/src/sync.js +1223 -1135
  90. package/src/task-review.js +1019 -1019
  91. package/src/verify-postcheck.js +1186 -1186
  92. package/src/workflow.js +600 -600
  93. package/src/worktree-apply.js +1213 -1136
  94. package/src/worktree-deps.js +353 -353
  95. package/src/worktree.js +1481 -1481
  96. package/templates/workflows/archive-impact.yaml +79 -79
  97. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,353 +1,353 @@
1
- /**
2
- * worktree-deps.js — Worktree 依赖供给引擎
3
- *
4
- * 在 worktree.create() 的 baseline overlay 之后调用,让 worktree 立即可构建/测试。
5
- * 策略:junction/symlink 快路径(lockfile 一致)+ install 兜底;多语言按 local.yaml
6
- * project.type + lockfile 推断 install 命令。供给可失败,但状态写进 meta 供验证硬门读取。
7
- *
8
- * 见 change 2026-06-28-worktree-deps-provision / D-005@v1, D-007@v1。
9
- */
10
-
11
- import { existsSync, readFileSync, realpathSync } from 'fs';
12
- import { join } from 'path';
13
- import { execSync, execFileSync } from 'child_process';
14
- import { createHash } from 'crypto';
15
-
16
- const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
17
- const DEFAULT_TIMEOUT_MS = 300 * 1000;
18
-
19
- /**
20
- * 取目录下首个命中的 lockfile 的 sha256 前 16 位;
21
- * 无 lockfile 则 hash package.json;都没有返回 null。
22
- */
23
- export function lockfileHash(dir) {
24
- if (!dir || !existsSync(dir)) return null;
25
- for (const lf of LOCKFILES) {
26
- const p = join(dir, lf);
27
- if (existsSync(p)) {
28
- return createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 16);
29
- }
30
- }
31
- const pkg = join(dir, 'package.json');
32
- if (existsSync(pkg)) {
33
- return createHash('sha256').update(readFileSync(pkg)).digest('hex').slice(0, 16);
34
- }
35
- return null;
36
- }
37
-
38
- /** 命中目录下的 lockfile 文件名(用于判断 nodejs 子类)*/
39
- function detectLockfile(dir) {
40
- if (!dir || !existsSync(dir)) return null;
41
- for (const lf of LOCKFILES) {
42
- if (existsSync(join(dir, lf))) return lf;
43
- }
44
- return null;
45
- }
46
-
47
- /** 读取 local.yaml 文本(specBase 优先,回退 worktreePath/.sillyspec;不读 process.cwd 避免环境配置泄漏)*/
48
- function readLocalYaml(specBase, worktreePath) {
49
- const candidates = [
50
- specBase ? join(specBase, 'local.yaml') : null,
51
- worktreePath ? join(worktreePath, '.sillyspec', 'local.yaml') : null,
52
- ].filter(Boolean);
53
- for (const p of candidates) {
54
- if (existsSync(p)) return readFileSync(p, 'utf8');
55
- }
56
- return null;
57
- }
58
-
59
- /** 从 local.yaml 文本提取 commands.install(轻量正则,不引 yaml 依赖,与 scan-postcheck 一致)*/
60
- function extractUserInstall(yamlText) {
61
- if (!yamlText) return null;
62
- // 匹配 commands: 段下的 install: "..." 或 install: '...'(unavailable 视为无)
63
- const m = yamlText.match(/install:\s*["']([^"']+)["']/);
64
- if (m && m[1] && m[1].toLowerCase() !== 'unavailable') return m[1];
65
- return null;
66
- }
67
-
68
- /** 从 local.yaml 提取 project.type;缺失时按文件特征推断 */
69
- export function detectProjectType(worktreePath, specBase) {
70
- const yamlText = readLocalYaml(specBase, worktreePath);
71
- if (yamlText) {
72
- const m = yamlText.match(/type:\s*(\S+)/);
73
- if (m && m[1]) return m[1];
74
- }
75
- if (existsSync(join(worktreePath, 'pom.xml'))) return 'maven';
76
- if (existsSync(join(worktreePath, 'build.gradle')) || existsSync(join(worktreePath, 'build.gradle.kts'))) return 'gradle';
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';
81
- return 'generic';
82
- }
83
-
84
- /** 按 project.type + lockfile 推断 install 命令(无 commands.install 时)*/
85
- export function inferInstallCommand(projectType, worktreePath, userInstall) {
86
- if (userInstall) return userInstall;
87
- switch (projectType) {
88
- case 'nodejs':
89
- if (existsSync(join(worktreePath, 'pnpm-lock.yaml'))) return 'pnpm install --frozen-lockfile';
90
- if (existsSync(join(worktreePath, 'package-lock.json'))) return 'npm ci';
91
- if (existsSync(join(worktreePath, 'yarn.lock'))) return 'yarn install --frozen-lockfile';
92
- return 'npm install'; // 无 lockfile 兜底(X-2)
93
- case 'maven':
94
- return 'mvn -o test';
95
- case 'gradle':
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';
102
- default:
103
- return null; // generic → n/a
104
- }
105
- }
106
-
107
- /** 在 worktreePath 创建 node_modules 链接到 mainNodeModules;失败回退 */
108
- function tryLink(mainNodeModules, linkPath) {
109
- // 已存在:确认是否已指向 main(幂等)
110
- if (existsSync(linkPath)) {
111
- try {
112
- const resolved = realpathSync(linkPath);
113
- const resolvedTarget = realpathSync(mainNodeModules);
114
- if (resolved === resolvedTarget) return { ok: true, method: process.platform === 'win32' ? 'junction' : 'symlink' };
115
- } catch {}
116
- // 指向别处 → 不 clobber,视为已有依赖(installed 语义)
117
- return { ok: true, method: 'install', preexisting: true };
118
- }
119
- try {
120
- if (process.platform === 'win32') {
121
- // execFileSync 数组形式不经 shell:POSIX 双引号内 `/$() 会执行、cmd.exe 双引号内 %VAR% 仍展开,
122
- // linkPath 含 local.yaml 模块 path,属项目内可配置值(安全收敛,与 git-helper 同范式)
123
- execFileSync('cmd.exe', ['/c', 'mklink', '/J', linkPath, mainNodeModules], { stdio: ['pipe', 'pipe', 'pipe'] });
124
- return { ok: true, method: 'junction' };
125
- }
126
- execFileSync('ln', ['-s', mainNodeModules, linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
127
- return { ok: true, method: 'symlink' };
128
- } catch (e) {
129
- return { ok: false, error: e.message };
130
- }
131
- }
132
-
133
- /** 执行 install 命令(带超时)*/
134
- function tryInstall(cmd, cwd, timeout) {
135
- try {
136
- execSync(cmd, { cwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
137
- return { ok: true };
138
- } catch (e) {
139
- const msg = e.killed ? `timeout after ${timeout}ms` : ((e.stderr && e.stderr.toString()) || e.message);
140
- return { ok: false, error: `${cmd} failed: ${msg}` };
141
- }
142
- }
143
-
144
- /** 目录是否含 nodejs 标记(package.json 或任一 lockfile)——识别 monorepo 里的 nodejs 子模块 */
145
- function hasNodeMarker(dir) {
146
- if (!dir || !existsSync(dir)) return false;
147
- if (existsSync(join(dir, 'package.json'))) return true;
148
- return LOCKFILES.some(lf => existsSync(join(dir, lf)));
149
- }
150
-
151
- /**
152
- * 从 local.yaml 提取 modules 块的 path 列表(generic monorepo 子模块路径)。
153
- * 兼容 inline flow(`frontend: { path: "frontend/" }`)与 block(`frontend:\n path: frontend/`)。
154
- * 轻量正则,不引 yaml 依赖(与 extractUserInstall / scan-postcheck 风格一致)。
155
- * 坑 execute-worktree-pnpm-monorepo-no-node-modules:generic 项目 worktree 子模块无 node_modules。
156
- * @returns {string[]} 去重 + 去 trailing 斜杠的相对路径
157
- */
158
- function extractModulePaths(yamlText) {
159
- if (!yamlText) return [];
160
- const idx = yamlText.search(/^modules:\s*$/m);
161
- if (idx < 0) return [];
162
- const nlIdx = yamlText.indexOf('\n', idx);
163
- if (nlIdx < 0) return [];
164
- let rest = yamlText.slice(nlIdx + 1);
165
- const nextTop = rest.search(/^\S/m); // 下一个顶层 key(行首非空白)
166
- const block = nextTop >= 0 ? rest.slice(0, nextTop) : rest;
167
- const paths = [];
168
- const re = /path:\s*["']?([^"'\s,}]+)/g;
169
- let pm;
170
- while ((pm = re.exec(block)) !== null) {
171
- const p = pm[1].replace(/\/+$/, '').trim();
172
- if (p && p !== '.') paths.push(p);
173
- }
174
- return [...new Set(paths)];
175
- }
176
-
177
- /**
178
- * 对单个子模块目录 tryLink main 的 node_modules → wt 的 node_modules(modules 子模块专用)。
179
- * 仅走 link 快路径(不 install——子模块 install 慢且易失败;lockfile 不一致时交给用户 pnpm install)。
180
- * lockfile 一致才 link,避免误链不匹配的 deps。
181
- */
182
- function linkOneDir(wtDir, mainDir) {
183
- const mainNodeModules = join(mainDir, 'node_modules');
184
- if (!existsSync(mainNodeModules)) return { status: 'skipped', reason: 'main 无 node_modules' };
185
- const wtHash = lockfileHash(wtDir);
186
- const mainHash = lockfileHash(mainDir);
187
- if (wtHash && mainHash && wtHash !== mainHash) {
188
- return { status: 'mismatch', reason: `lockfile 不一致 main=${mainHash} wt=${wtHash}` };
189
- }
190
- const r = tryLink(mainNodeModules, join(wtDir, 'node_modules'));
191
- if (r.ok) return { status: 'linked', method: r.method };
192
- return { status: 'failed', error: r.error };
193
- }
194
-
195
- /**
196
- * 供给依赖。返回 deps 状态对象(合并进 meta)。两段:
197
- * 1. 根目录供给(原有逻辑:lockfile 一致 link / 不一致 install / generic→根 n/a)
198
- * 2. modules 块的 nodejs 子模块 link(generic monorepo:根无 deps 但 frontend/daemon 等子模块需要)
199
- * generic + 子模块 link 成功时,整体 depsStatus 从 n/a 升级为 linked(deps gate 不再误判 unknown 阻断)。
200
- *
201
- * @param {string} worktreePath - worktree 根目录
202
- * @param {string} mainCwd - 主 checkout 根目录(node_modules 来源)
203
- * @param {{ specBase?: string, timeout?: number, force?: boolean }} opts
204
- * - force=true(doctor --fix 强制重装):绕过根目录 lockfile 一致快路径(下方 line 216 区块),
205
- * 强制走 install 分支重装。该快路径是 tryLink 的唯一根调用点,绕过它即同时绕过 tryLink
206
- * 的 preexisting 幂等短路(101-110),实现"强制重装"语义。配合调用方先解 junction
207
- * (worktree.js _doctorReprovision),避免 install 经 junction 误改主仓 node_modules。
208
- * modules 子模块 link 不受 force 影响(仅根 install)。
209
- * @returns {{ depsStatus, depsMethod, depsSource, depsLockHash, depsCheckedAt, depsError?, depsModules? }}
210
- */
211
- export function provisionDeps(worktreePath, mainCwd, opts = {}) {
212
- const { specBase = null, timeout = DEFAULT_TIMEOUT_MS, force = false } = opts;
213
- const depsCheckedAt = new Date().toISOString();
214
- const yamlText = readLocalYaml(specBase, worktreePath);
215
- const wtHash = lockfileHash(worktreePath);
216
-
217
- // ── 1. 根目录供给 ──
218
- const projectType = detectProjectType(worktreePath, specBase);
219
- const userInstall = extractUserInstall(yamlText);
220
- const installCmd = inferInstallCommand(projectType, worktreePath, userInstall);
221
-
222
- let result;
223
- if (!installCmd) {
224
- // generic / 无可执行 install → 根无 deps 动作(n/a);但下方 modules 子模块仍可能 link 升级
225
- result = { depsStatus: 'n/a', depsMethod: null, depsSource: null, depsLockHash: wtHash };
226
- } else {
227
- // 快路径:main 有 node_modules 且 lockfile hash 一致 → junction/symlink
228
- // force=true 时绕过(doctor --fix 强制重装;绕过此块即同时绕过 tryLink 101-110 幂等短路)
229
- const mainNodeModules = mainCwd ? join(mainCwd, 'node_modules') : null;
230
- const mainHash = lockfileHash(mainCwd);
231
- let linked = false;
232
- if (!force && mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
233
- const linkResult = tryLink(mainNodeModules, join(worktreePath, 'node_modules'));
234
- if (linkResult.ok) {
235
- result = {
236
- depsStatus: 'linked',
237
- depsMethod: linkResult.method,
238
- depsSource: linkResult.preexisting ? 'install' : 'main-checkout',
239
- depsLockHash: wtHash,
240
- };
241
- linked = true;
242
- }
243
- }
244
- if (!linked) {
245
- // 兜底:install
246
- const installResult = tryInstall(installCmd, worktreePath, timeout);
247
- result = installResult.ok
248
- ? { depsStatus: 'installed', depsMethod: 'install', depsSource: 'install', depsLockHash: wtHash }
249
- : { depsStatus: 'failed', depsMethod: null, depsSource: null, depsLockHash: wtHash, depsError: installResult.error };
250
- }
251
- }
252
- result.depsCheckedAt = depsCheckedAt;
253
-
254
- // ── 2. modules 块的 nodejs 子模块 link(坑 execute-worktree-pnpm-monorepo-no-node-modules)──
255
- // generic monorepo(如 multi-agent-platform)worktree 子模块 frontend/sillyhub-daemon 无 node_modules,
256
- // 跑 pnpm test 失败。读 local.yaml modules 块,对 nodejs 子模块 tryLink main 的 node_modules。
257
- const modulePaths = extractModulePaths(yamlText);
258
- const moduleResults = [];
259
- for (const mp of modulePaths) {
260
- const wtDir = join(worktreePath, mp);
261
- const mainDir = mainCwd ? join(mainCwd, mp) : null;
262
- if (!existsSync(wtDir) || !mainDir || !hasNodeMarker(wtDir)) continue;
263
- moduleResults.push({ path: mp, ...linkOneDir(wtDir, mainDir) });
264
- }
265
-
266
- if (moduleResults.length > 0) {
267
- result.depsModules = moduleResults;
268
- const linkedModules = moduleResults.filter(m => m.status === 'linked');
269
- if (linkedModules.length > 0 && result.depsStatus === 'n/a') {
270
- // 标 generic 但子模块 link 成功 → 升级为 linked(deps gate 不再误判 unknown 阻断 execute)
271
- result.depsStatus = 'linked';
272
- result.depsMethod = linkedModules[0].method;
273
- result.depsSource = 'main-checkout';
274
- }
275
- const failedModules = moduleResults.filter(m => m.status === 'failed');
276
- if (failedModules.length > 0) {
277
- const fmsg = failedModules.map(m => `${m.path}: ${m.error}`).join('; ');
278
- result.depsError = result.depsError ? `${result.depsError}; ${fmsg}` : fmsg;
279
- }
280
- }
281
-
282
- return result;
283
- }
284
-
285
- /**
286
- * 统一的 worktree deps 新鲜度判定(H1,change 2026-08-05-tooling-feedback-fixes)。
287
- * 供 doctor(worktree.js)与 ensureDepsFreshness(run/stage.js)共用,消除两处双写漂移。
288
- *
289
- * 判定优先级:failed → missing → stale → main-drift → fresh
290
- * - failed: meta.depsStatus==='failed'(上次供给失败,占最高优先级)
291
- * - missing: depsStatus ∈ {linked, installed} 且 node_modules 不存在
292
- * - stale: wtHash 与 meta.depsLockHash 不一致(worktree 自身 lockfile 在上次供给后变化)
293
- * - main-drift: wtHash 与主仓 mainHash 不一致(主仓 lockfile 漂移;复用 linkOneDir 177-178 的 mismatch 判据)
294
- * - fresh: 其余情况
295
- *
296
- * lockfileHash 返回 null(无 lockfile/package.json)时相关比较优雅降级——不报 stale / main-drift,
297
- * 避免无 lockfile 项目(如纯 generic / 非 nodejs)误判漂移。
298
- *
299
- * @param {object} [meta] - worktree meta(读 depsStatus / depsLockHash / depsError)
300
- * @param {string} wtPath - worktree 根目录
301
- * @param {string} mainCwd - 主 checkout 根目录
302
- * @returns {{ status: 'fresh'|'missing'|'stale'|'main-drift'|'failed', detail: string, wtHash: (string|null), mainHash: (string|null), metaLockHash: (string|null) }}
303
- */
304
- export function checkDepsFreshness(meta, wtPath, mainCwd) {
305
- const depsStatus = meta && meta.depsStatus ? meta.depsStatus : null;
306
- const metaLockHash = meta && meta.depsLockHash ? meta.depsLockHash : null;
307
- const wtHash = lockfileHash(wtPath);
308
- const mainHash = lockfileHash(mainCwd);
309
-
310
- // 1. failed 最高优先级(对齐 task-01 蓝图:failed → missing → stale → main-drift → fresh)
311
- if (depsStatus === 'failed') {
312
- return {
313
- status: 'failed',
314
- detail: '上次依赖供给失败' + (meta && meta.depsError ? ': ' + meta.depsError : ''),
315
- wtHash, mainHash, metaLockHash,
316
- };
317
- }
318
-
319
- // 2. missing:曾 link/install 但 node_modules 已丢失(对齐 doctor 914-915 / ensure 403)
320
- const nmExists = wtPath ? existsSync(join(wtPath, 'node_modules')) : false;
321
- if (['linked', 'installed'].includes(depsStatus) && !nmExists) {
322
- return {
323
- status: 'missing',
324
- detail: `meta.depsStatus=${depsStatus} 但 node_modules 缺失`,
325
- wtHash, mainHash, metaLockHash,
326
- };
327
- }
328
-
329
- // 3. stale:worktree 自身 lockfile 与 meta 快照不一致(对齐 doctor 916-917 / ensure 404)
330
- if (metaLockHash && wtHash && wtHash !== metaLockHash) {
331
- return {
332
- status: 'stale',
333
- detail: `lockfile 变化 (${metaLockHash} -> ${wtHash})`,
334
- wtHash, mainHash, metaLockHash,
335
- };
336
- }
337
-
338
- // 4. main-drift(新增):wtHash 与主仓 mainHash 不一致——主仓 lockfile 更新过、worktree 未跟
339
- if (wtHash && mainHash && wtHash !== mainHash) {
340
- return {
341
- status: 'main-drift',
342
- detail: `worktree lockfile 与主仓不一致 (wt=${wtHash} main=${mainHash})`,
343
- wtHash, mainHash, metaLockHash,
344
- };
345
- }
346
-
347
- // 5. fresh
348
- return {
349
- status: 'fresh',
350
- detail: '依赖新鲜',
351
- wtHash, mainHash, metaLockHash,
352
- };
353
- }
1
+ /**
2
+ * worktree-deps.js — Worktree 依赖供给引擎
3
+ *
4
+ * 在 worktree.create() 的 baseline overlay 之后调用,让 worktree 立即可构建/测试。
5
+ * 策略:junction/symlink 快路径(lockfile 一致)+ install 兜底;多语言按 local.yaml
6
+ * project.type + lockfile 推断 install 命令。供给可失败,但状态写进 meta 供验证硬门读取。
7
+ *
8
+ * 见 change 2026-06-28-worktree-deps-provision / D-005@v1, D-007@v1。
9
+ */
10
+
11
+ import { existsSync, readFileSync, realpathSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { execSync, execFileSync } from 'child_process';
14
+ import { createHash } from 'crypto';
15
+
16
+ const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
17
+ const DEFAULT_TIMEOUT_MS = 300 * 1000;
18
+
19
+ /**
20
+ * 取目录下首个命中的 lockfile 的 sha256 前 16 位;
21
+ * 无 lockfile 则 hash package.json;都没有返回 null。
22
+ */
23
+ export function lockfileHash(dir) {
24
+ if (!dir || !existsSync(dir)) return null;
25
+ for (const lf of LOCKFILES) {
26
+ const p = join(dir, lf);
27
+ if (existsSync(p)) {
28
+ return createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 16);
29
+ }
30
+ }
31
+ const pkg = join(dir, 'package.json');
32
+ if (existsSync(pkg)) {
33
+ return createHash('sha256').update(readFileSync(pkg)).digest('hex').slice(0, 16);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** 命中目录下的 lockfile 文件名(用于判断 nodejs 子类)*/
39
+ function detectLockfile(dir) {
40
+ if (!dir || !existsSync(dir)) return null;
41
+ for (const lf of LOCKFILES) {
42
+ if (existsSync(join(dir, lf))) return lf;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** 读取 local.yaml 文本(specBase 优先,回退 worktreePath/.sillyspec;不读 process.cwd 避免环境配置泄漏)*/
48
+ function readLocalYaml(specBase, worktreePath) {
49
+ const candidates = [
50
+ specBase ? join(specBase, 'local.yaml') : null,
51
+ worktreePath ? join(worktreePath, '.sillyspec', 'local.yaml') : null,
52
+ ].filter(Boolean);
53
+ for (const p of candidates) {
54
+ if (existsSync(p)) return readFileSync(p, 'utf8');
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** 从 local.yaml 文本提取 commands.install(轻量正则,不引 yaml 依赖,与 scan-postcheck 一致)*/
60
+ function extractUserInstall(yamlText) {
61
+ if (!yamlText) return null;
62
+ // 匹配 commands: 段下的 install: "..." 或 install: '...'(unavailable 视为无)
63
+ const m = yamlText.match(/install:\s*["']([^"']+)["']/);
64
+ if (m && m[1] && m[1].toLowerCase() !== 'unavailable') return m[1];
65
+ return null;
66
+ }
67
+
68
+ /** 从 local.yaml 提取 project.type;缺失时按文件特征推断 */
69
+ export function detectProjectType(worktreePath, specBase) {
70
+ const yamlText = readLocalYaml(specBase, worktreePath);
71
+ if (yamlText) {
72
+ const m = yamlText.match(/type:\s*(\S+)/);
73
+ if (m && m[1]) return m[1];
74
+ }
75
+ if (existsSync(join(worktreePath, 'pom.xml'))) return 'maven';
76
+ if (existsSync(join(worktreePath, 'build.gradle')) || existsSync(join(worktreePath, 'build.gradle.kts'))) return 'gradle';
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';
81
+ return 'generic';
82
+ }
83
+
84
+ /** 按 project.type + lockfile 推断 install 命令(无 commands.install 时)*/
85
+ export function inferInstallCommand(projectType, worktreePath, userInstall) {
86
+ if (userInstall) return userInstall;
87
+ switch (projectType) {
88
+ case 'nodejs':
89
+ if (existsSync(join(worktreePath, 'pnpm-lock.yaml'))) return 'pnpm install --frozen-lockfile';
90
+ if (existsSync(join(worktreePath, 'package-lock.json'))) return 'npm ci';
91
+ if (existsSync(join(worktreePath, 'yarn.lock'))) return 'yarn install --frozen-lockfile';
92
+ return 'npm install'; // 无 lockfile 兜底(X-2)
93
+ case 'maven':
94
+ return 'mvn -o test';
95
+ case 'gradle':
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';
102
+ default:
103
+ return null; // generic → n/a
104
+ }
105
+ }
106
+
107
+ /** 在 worktreePath 创建 node_modules 链接到 mainNodeModules;失败回退 */
108
+ function tryLink(mainNodeModules, linkPath) {
109
+ // 已存在:确认是否已指向 main(幂等)
110
+ if (existsSync(linkPath)) {
111
+ try {
112
+ const resolved = realpathSync(linkPath);
113
+ const resolvedTarget = realpathSync(mainNodeModules);
114
+ if (resolved === resolvedTarget) return { ok: true, method: process.platform === 'win32' ? 'junction' : 'symlink' };
115
+ } catch {}
116
+ // 指向别处 → 不 clobber,视为已有依赖(installed 语义)
117
+ return { ok: true, method: 'install', preexisting: true };
118
+ }
119
+ try {
120
+ if (process.platform === 'win32') {
121
+ // execFileSync 数组形式不经 shell:POSIX 双引号内 `/$() 会执行、cmd.exe 双引号内 %VAR% 仍展开,
122
+ // linkPath 含 local.yaml 模块 path,属项目内可配置值(安全收敛,与 git-helper 同范式)
123
+ execFileSync('cmd.exe', ['/c', 'mklink', '/J', linkPath, mainNodeModules], { stdio: ['pipe', 'pipe', 'pipe'] });
124
+ return { ok: true, method: 'junction' };
125
+ }
126
+ execFileSync('ln', ['-s', mainNodeModules, linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
127
+ return { ok: true, method: 'symlink' };
128
+ } catch (e) {
129
+ return { ok: false, error: e.message };
130
+ }
131
+ }
132
+
133
+ /** 执行 install 命令(带超时)*/
134
+ function tryInstall(cmd, cwd, timeout) {
135
+ try {
136
+ execSync(cmd, { cwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
137
+ return { ok: true };
138
+ } catch (e) {
139
+ const msg = e.killed ? `timeout after ${timeout}ms` : ((e.stderr && e.stderr.toString()) || e.message);
140
+ return { ok: false, error: `${cmd} failed: ${msg}` };
141
+ }
142
+ }
143
+
144
+ /** 目录是否含 nodejs 标记(package.json 或任一 lockfile)——识别 monorepo 里的 nodejs 子模块 */
145
+ function hasNodeMarker(dir) {
146
+ if (!dir || !existsSync(dir)) return false;
147
+ if (existsSync(join(dir, 'package.json'))) return true;
148
+ return LOCKFILES.some(lf => existsSync(join(dir, lf)));
149
+ }
150
+
151
+ /**
152
+ * 从 local.yaml 提取 modules 块的 path 列表(generic monorepo 子模块路径)。
153
+ * 兼容 inline flow(`frontend: { path: "frontend/" }`)与 block(`frontend:\n path: frontend/`)。
154
+ * 轻量正则,不引 yaml 依赖(与 extractUserInstall / scan-postcheck 风格一致)。
155
+ * 坑 execute-worktree-pnpm-monorepo-no-node-modules:generic 项目 worktree 子模块无 node_modules。
156
+ * @returns {string[]} 去重 + 去 trailing 斜杠的相对路径
157
+ */
158
+ function extractModulePaths(yamlText) {
159
+ if (!yamlText) return [];
160
+ const idx = yamlText.search(/^modules:\s*$/m);
161
+ if (idx < 0) return [];
162
+ const nlIdx = yamlText.indexOf('\n', idx);
163
+ if (nlIdx < 0) return [];
164
+ let rest = yamlText.slice(nlIdx + 1);
165
+ const nextTop = rest.search(/^\S/m); // 下一个顶层 key(行首非空白)
166
+ const block = nextTop >= 0 ? rest.slice(0, nextTop) : rest;
167
+ const paths = [];
168
+ const re = /path:\s*["']?([^"'\s,}]+)/g;
169
+ let pm;
170
+ while ((pm = re.exec(block)) !== null) {
171
+ const p = pm[1].replace(/\/+$/, '').trim();
172
+ if (p && p !== '.') paths.push(p);
173
+ }
174
+ return [...new Set(paths)];
175
+ }
176
+
177
+ /**
178
+ * 对单个子模块目录 tryLink main 的 node_modules → wt 的 node_modules(modules 子模块专用)。
179
+ * 仅走 link 快路径(不 install——子模块 install 慢且易失败;lockfile 不一致时交给用户 pnpm install)。
180
+ * lockfile 一致才 link,避免误链不匹配的 deps。
181
+ */
182
+ function linkOneDir(wtDir, mainDir) {
183
+ const mainNodeModules = join(mainDir, 'node_modules');
184
+ if (!existsSync(mainNodeModules)) return { status: 'skipped', reason: 'main 无 node_modules' };
185
+ const wtHash = lockfileHash(wtDir);
186
+ const mainHash = lockfileHash(mainDir);
187
+ if (wtHash && mainHash && wtHash !== mainHash) {
188
+ return { status: 'mismatch', reason: `lockfile 不一致 main=${mainHash} wt=${wtHash}` };
189
+ }
190
+ const r = tryLink(mainNodeModules, join(wtDir, 'node_modules'));
191
+ if (r.ok) return { status: 'linked', method: r.method };
192
+ return { status: 'failed', error: r.error };
193
+ }
194
+
195
+ /**
196
+ * 供给依赖。返回 deps 状态对象(合并进 meta)。两段:
197
+ * 1. 根目录供给(原有逻辑:lockfile 一致 link / 不一致 install / generic→根 n/a)
198
+ * 2. modules 块的 nodejs 子模块 link(generic monorepo:根无 deps 但 frontend/daemon 等子模块需要)
199
+ * generic + 子模块 link 成功时,整体 depsStatus 从 n/a 升级为 linked(deps gate 不再误判 unknown 阻断)。
200
+ *
201
+ * @param {string} worktreePath - worktree 根目录
202
+ * @param {string} mainCwd - 主 checkout 根目录(node_modules 来源)
203
+ * @param {{ specBase?: string, timeout?: number, force?: boolean }} opts
204
+ * - force=true(doctor --fix 强制重装):绕过根目录 lockfile 一致快路径(下方 line 216 区块),
205
+ * 强制走 install 分支重装。该快路径是 tryLink 的唯一根调用点,绕过它即同时绕过 tryLink
206
+ * 的 preexisting 幂等短路(101-110),实现"强制重装"语义。配合调用方先解 junction
207
+ * (worktree.js _doctorReprovision),避免 install 经 junction 误改主仓 node_modules。
208
+ * modules 子模块 link 不受 force 影响(仅根 install)。
209
+ * @returns {{ depsStatus, depsMethod, depsSource, depsLockHash, depsCheckedAt, depsError?, depsModules? }}
210
+ */
211
+ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
212
+ const { specBase = null, timeout = DEFAULT_TIMEOUT_MS, force = false } = opts;
213
+ const depsCheckedAt = new Date().toISOString();
214
+ const yamlText = readLocalYaml(specBase, worktreePath);
215
+ const wtHash = lockfileHash(worktreePath);
216
+
217
+ // ── 1. 根目录供给 ──
218
+ const projectType = detectProjectType(worktreePath, specBase);
219
+ const userInstall = extractUserInstall(yamlText);
220
+ const installCmd = inferInstallCommand(projectType, worktreePath, userInstall);
221
+
222
+ let result;
223
+ if (!installCmd) {
224
+ // generic / 无可执行 install → 根无 deps 动作(n/a);但下方 modules 子模块仍可能 link 升级
225
+ result = { depsStatus: 'n/a', depsMethod: null, depsSource: null, depsLockHash: wtHash };
226
+ } else {
227
+ // 快路径:main 有 node_modules 且 lockfile hash 一致 → junction/symlink
228
+ // force=true 时绕过(doctor --fix 强制重装;绕过此块即同时绕过 tryLink 101-110 幂等短路)
229
+ const mainNodeModules = mainCwd ? join(mainCwd, 'node_modules') : null;
230
+ const mainHash = lockfileHash(mainCwd);
231
+ let linked = false;
232
+ if (!force && mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
233
+ const linkResult = tryLink(mainNodeModules, join(worktreePath, 'node_modules'));
234
+ if (linkResult.ok) {
235
+ result = {
236
+ depsStatus: 'linked',
237
+ depsMethod: linkResult.method,
238
+ depsSource: linkResult.preexisting ? 'install' : 'main-checkout',
239
+ depsLockHash: wtHash,
240
+ };
241
+ linked = true;
242
+ }
243
+ }
244
+ if (!linked) {
245
+ // 兜底:install
246
+ const installResult = tryInstall(installCmd, worktreePath, timeout);
247
+ result = installResult.ok
248
+ ? { depsStatus: 'installed', depsMethod: 'install', depsSource: 'install', depsLockHash: wtHash }
249
+ : { depsStatus: 'failed', depsMethod: null, depsSource: null, depsLockHash: wtHash, depsError: installResult.error };
250
+ }
251
+ }
252
+ result.depsCheckedAt = depsCheckedAt;
253
+
254
+ // ── 2. modules 块的 nodejs 子模块 link(坑 execute-worktree-pnpm-monorepo-no-node-modules)──
255
+ // generic monorepo(如 multi-agent-platform)worktree 子模块 frontend/sillyhub-daemon 无 node_modules,
256
+ // 跑 pnpm test 失败。读 local.yaml modules 块,对 nodejs 子模块 tryLink main 的 node_modules。
257
+ const modulePaths = extractModulePaths(yamlText);
258
+ const moduleResults = [];
259
+ for (const mp of modulePaths) {
260
+ const wtDir = join(worktreePath, mp);
261
+ const mainDir = mainCwd ? join(mainCwd, mp) : null;
262
+ if (!existsSync(wtDir) || !mainDir || !hasNodeMarker(wtDir)) continue;
263
+ moduleResults.push({ path: mp, ...linkOneDir(wtDir, mainDir) });
264
+ }
265
+
266
+ if (moduleResults.length > 0) {
267
+ result.depsModules = moduleResults;
268
+ const linkedModules = moduleResults.filter(m => m.status === 'linked');
269
+ if (linkedModules.length > 0 && result.depsStatus === 'n/a') {
270
+ // 标 generic 但子模块 link 成功 → 升级为 linked(deps gate 不再误判 unknown 阻断 execute)
271
+ result.depsStatus = 'linked';
272
+ result.depsMethod = linkedModules[0].method;
273
+ result.depsSource = 'main-checkout';
274
+ }
275
+ const failedModules = moduleResults.filter(m => m.status === 'failed');
276
+ if (failedModules.length > 0) {
277
+ const fmsg = failedModules.map(m => `${m.path}: ${m.error}`).join('; ');
278
+ result.depsError = result.depsError ? `${result.depsError}; ${fmsg}` : fmsg;
279
+ }
280
+ }
281
+
282
+ return result;
283
+ }
284
+
285
+ /**
286
+ * 统一的 worktree deps 新鲜度判定(H1,change 2026-08-05-tooling-feedback-fixes)。
287
+ * 供 doctor(worktree.js)与 ensureDepsFreshness(run/stage.js)共用,消除两处双写漂移。
288
+ *
289
+ * 判定优先级:failed → missing → stale → main-drift → fresh
290
+ * - failed: meta.depsStatus==='failed'(上次供给失败,占最高优先级)
291
+ * - missing: depsStatus ∈ {linked, installed} 且 node_modules 不存在
292
+ * - stale: wtHash 与 meta.depsLockHash 不一致(worktree 自身 lockfile 在上次供给后变化)
293
+ * - main-drift: wtHash 与主仓 mainHash 不一致(主仓 lockfile 漂移;复用 linkOneDir 177-178 的 mismatch 判据)
294
+ * - fresh: 其余情况
295
+ *
296
+ * lockfileHash 返回 null(无 lockfile/package.json)时相关比较优雅降级——不报 stale / main-drift,
297
+ * 避免无 lockfile 项目(如纯 generic / 非 nodejs)误判漂移。
298
+ *
299
+ * @param {object} [meta] - worktree meta(读 depsStatus / depsLockHash / depsError)
300
+ * @param {string} wtPath - worktree 根目录
301
+ * @param {string} mainCwd - 主 checkout 根目录
302
+ * @returns {{ status: 'fresh'|'missing'|'stale'|'main-drift'|'failed', detail: string, wtHash: (string|null), mainHash: (string|null), metaLockHash: (string|null) }}
303
+ */
304
+ export function checkDepsFreshness(meta, wtPath, mainCwd) {
305
+ const depsStatus = meta && meta.depsStatus ? meta.depsStatus : null;
306
+ const metaLockHash = meta && meta.depsLockHash ? meta.depsLockHash : null;
307
+ const wtHash = lockfileHash(wtPath);
308
+ const mainHash = lockfileHash(mainCwd);
309
+
310
+ // 1. failed 最高优先级(对齐 task-01 蓝图:failed → missing → stale → main-drift → fresh)
311
+ if (depsStatus === 'failed') {
312
+ return {
313
+ status: 'failed',
314
+ detail: '上次依赖供给失败' + (meta && meta.depsError ? ': ' + meta.depsError : ''),
315
+ wtHash, mainHash, metaLockHash,
316
+ };
317
+ }
318
+
319
+ // 2. missing:曾 link/install 但 node_modules 已丢失(对齐 doctor 914-915 / ensure 403)
320
+ const nmExists = wtPath ? existsSync(join(wtPath, 'node_modules')) : false;
321
+ if (['linked', 'installed'].includes(depsStatus) && !nmExists) {
322
+ return {
323
+ status: 'missing',
324
+ detail: `meta.depsStatus=${depsStatus} 但 node_modules 缺失`,
325
+ wtHash, mainHash, metaLockHash,
326
+ };
327
+ }
328
+
329
+ // 3. stale:worktree 自身 lockfile 与 meta 快照不一致(对齐 doctor 916-917 / ensure 404)
330
+ if (metaLockHash && wtHash && wtHash !== metaLockHash) {
331
+ return {
332
+ status: 'stale',
333
+ detail: `lockfile 变化 (${metaLockHash} -> ${wtHash})`,
334
+ wtHash, mainHash, metaLockHash,
335
+ };
336
+ }
337
+
338
+ // 4. main-drift(新增):wtHash 与主仓 mainHash 不一致——主仓 lockfile 更新过、worktree 未跟
339
+ if (wtHash && mainHash && wtHash !== mainHash) {
340
+ return {
341
+ status: 'main-drift',
342
+ detail: `worktree lockfile 与主仓不一致 (wt=${wtHash} main=${mainHash})`,
343
+ wtHash, mainHash, metaLockHash,
344
+ };
345
+ }
346
+
347
+ // 5. fresh
348
+ return {
349
+ status: 'fresh',
350
+ detail: '依赖新鲜',
351
+ wtHash, mainHash, metaLockHash,
352
+ };
353
+ }