sillyspec 3.28.7 → 3.28.9

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 (79) hide show
  1. package/.claude/skills/sillyspec-quick/SKILL.md +2 -0
  2. package/.husky/pre-push +3 -1
  3. package/AGENTS.md +3 -2
  4. package/README.md +35 -11
  5. package/SKILL.md +24 -10
  6. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  7. package/package.json +1 -1
  8. package/src/agent-session-log.js +446 -35
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-cat.js +6 -3
  11. package/src/config-schema.js +32 -0
  12. package/src/constants.js +3 -0
  13. package/src/db.js +23 -6
  14. package/src/decision-distill.js +15 -3
  15. package/src/design-facts.js +33 -3
  16. package/src/docs-check.js +137 -12
  17. package/src/docs-gate.js +108 -6
  18. package/src/doctor-diagnostics.js +243 -9
  19. package/src/friction-ledger.js +171 -0
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +72 -30
  23. package/src/index.js +356 -81
  24. package/src/knowledge-classify.js +363 -0
  25. package/src/knowledge-hits.js +74 -0
  26. package/src/knowledge-stats.js +180 -0
  27. package/src/local-detect.js +30 -15
  28. package/src/local-register.js +13 -4
  29. package/src/mcp-server.js +38 -8
  30. package/src/progress/change-registry.js +224 -43
  31. package/src/progress/consistency-doctor.js +11 -4
  32. package/src/progress/shared.js +3 -2
  33. package/src/progress/stage-machine.js +3 -1
  34. package/src/progress/step-store.js +30 -17
  35. package/src/progress.js +123 -8
  36. package/src/quick-gate-profile.js +251 -0
  37. package/src/quicklog.js +199 -32
  38. package/src/review-dispatch.js +2 -1
  39. package/src/run/command.js +124 -15
  40. package/src/run/complete-handlers.js +514 -24
  41. package/src/run/complete.js +82 -25
  42. package/src/run/concurrent-detect.js +99 -1
  43. package/src/run/gate-snapshot.js +115 -5
  44. package/src/run/gates.js +85 -14
  45. package/src/run/prompt.js +143 -6
  46. package/src/run/quick-audit.js +136 -15
  47. package/src/run/shared.js +174 -20
  48. package/src/run/stage.js +6 -0
  49. package/src/run/verify-quality-scan.js +14 -3
  50. package/src/scan-diff.js +61 -30
  51. package/src/scan-postcheck.js +74 -0
  52. package/src/scan-refresh.js +477 -0
  53. package/src/scan-staleness.js +25 -13
  54. package/src/scope-audit.js +365 -35
  55. package/src/spec-sync.js +19 -14
  56. package/src/stage-contract-spec.js +4 -3
  57. package/src/stage-contract.js +185 -2
  58. package/src/stage-review.js +29 -12
  59. package/src/stages/brainstorm.js +7 -1
  60. package/src/stages/execute.js +91 -4
  61. package/src/stages/knowledge.js +52 -3
  62. package/src/stages/plan-postcheck.js +57 -12
  63. package/src/stages/quick.js +1 -1
  64. package/src/stages/verify.js +1 -0
  65. package/src/sync-noise.js +114 -6
  66. package/src/sync.js +201 -11
  67. package/src/task-review.js +333 -27
  68. package/src/verify-facts-schema.js +10 -3
  69. package/src/verify-postcheck.js +41 -6
  70. package/src/verify-probes.js +348 -7
  71. package/src/worktree-apply.js +594 -62
  72. package/src/worktree-cross.js +12 -18
  73. package/src/worktree-deps.js +100 -2
  74. package/src/worktree.js +255 -30
  75. package/src/wt-commit.js +128 -0
  76. package/templates/agents-instruction.md +2 -2
  77. package/templates/prompts/testcase-design.md +2 -1
  78. package/templates/prompts/verify-probes.md +4 -1
  79. package/sa-rename-probe/src/new.js +0 -4
@@ -198,23 +198,16 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
198
198
  };
199
199
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
200
200
 
201
- // dirty baseline overlay:跨仓主工作副本在途改动 → worktree baseline(借主仓同款实现,
202
- // 只用其参数化逻辑,不用实例路径状态)
203
- try {
204
- const wmHelper = new WorktreeManager({ cwd: repoRoot });
205
- const baselineResult = wmHelper._overlayBaseline(repoRoot, worktreePath);
206
- meta.baselineFiles = baselineResult.files;
207
- meta.baselineHash = baselineResult.baselineHash;
208
- if (baselineResult.files.length > 0) {
209
- meta.baselineCommit = wmHelper._createBaselineCheckpoint(worktreePath, `${changeName}--${key}`, baselineResult.files);
210
- }
211
- if (baselineResult.errors && baselineResult.errors.length > 0) {
212
- console.warn(`⚠️ 跨仓 ${key} baseline overlay 部分失败(${baselineResult.errors.join('; ')})——在途改动未全量进 baseline,apply 前请人工核对跨仓主工作副本`);
213
- }
214
- writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
215
- } catch (e) {
216
- console.warn(`⚠️ 跨仓 ${key} baseline overlay 失败(${e.message})——跨仓主工作副本如有在途改动,worktree 不含它们`);
217
- }
201
+ // 跨仓仓跳过 dirty baseline overlay + checkpoint(坑 cross-baseline-checkpoint-foreign-files,
202
+ // 2026-09-12 驾驭第十三批②,用户实证:跨仓主副本在途的外来文件——并行会话的 2 个 pptx +
203
+ // meta.json——被 checkpoint 带进跨仓分支,污染 apply 锚点对账被迫 cherry-pick 重建):
204
+ // 跨仓模型 = 子代理直接 commit 跨仓分支(NG-3),跨仓主副本的 WIP 不属于本变更隔离面,
205
+ // 不该被固化进分支。meta.baselineFiles=[] 显式记录「无 checkpoint」,apply 侧以 baseHash
206
+ // (跨仓仓 HEAD)为锚。
207
+ meta.baselineFiles = [];
208
+ meta.baselineHash = baseHash;
209
+ console.log(`ℹ️ 跨仓 ${key}: 跳过 dirty baseline checkpoint(跨仓主副本在途改动不进本变更分支——含并行会话外来文件时防污染;锚点 = 跨仓仓 HEAD ${String(baseHash).slice(0, 8)})`);
210
+ writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
218
211
 
219
212
  // deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
220
213
  // 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
@@ -284,7 +277,8 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
284
277
  git(repoRoot, ['worktree', 'remove', '--force', wtPath], { timeout: 60000 });
285
278
  } catch {
286
279
  // remove 失败(脏文件/锁):解链后安全删目录 + prune 注册
287
- try { safeRemoveWorktreeDir(wtPath, meta) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
280
+ // allowCross:true——本函数就是跨仓清理的显式路径(主仓守卫放行;见 safeRemoveWorktreeDir 注释)
281
+ try { safeRemoveWorktreeDir(wtPath, meta, { allowCross: true }) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
288
282
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
289
283
  }
290
284
  details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
@@ -13,8 +13,8 @@
13
13
  * node 校验与 java 校验各说各话。新生态补一行表项即可;表外项目落 generic → n/a 不阻断。
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, realpathSync, lstatSync, readdirSync, unlinkSync } from 'fs';
17
- import { join, isAbsolute, relative, resolve as resolvePath, sep as pathSep } from 'path';
16
+ import { existsSync, readFileSync, realpathSync, lstatSync, readdirSync, readlinkSync, unlinkSync } from 'fs';
17
+ import { join, dirname, isAbsolute, relative, resolve as resolvePath, sep as pathSep } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { execFileSync } from 'child_process';
20
20
  import { createHash } from 'crypto';
@@ -387,6 +387,86 @@ function isSafeModulePath(p) {
387
387
  return !segs.includes('..');
388
388
  }
389
389
 
390
+ /**
391
+ * resolved 路径是否严格位于 root 目录之下(不含 root 自身)。win32 大小写不敏感比较由
392
+ * path.win32.relative 自带(与 detectEditableInstallEscape 的越界判定同口径)。
393
+ */
394
+ function isUnderDir(child, root) {
395
+ if (!child || !root) return false;
396
+ const rel = relative(resolvePath(root), resolvePath(child));
397
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
398
+ }
399
+
400
+ /**
401
+ * ql-20260915-003 修复①(坑 junction-foreign-ownership,2026-09-15 用户实证:主仓
402
+ * frontend/node_modules 内部发现 junction 指向 session-export worktree 的 .pnpm——跨会话
403
+ * node_modules 污染,apply 后被迫 --force 重装)。
404
+ *
405
+ * 归属判定:链接方向 wt/node_modules → main 是 SillySpec 的正确设计(worktree 共享主仓
406
+ * 依赖);反向——main node_modules 内出现指向 .sillyspec/.runtime/worktrees/ 之下目标的
407
+ * junction/symlink——是 install 兜底/包管理器穿透写坏留下的污染,任何合法流程都不依赖它,
408
+ * 一律视为外来(foreign)。pnpm 自建的包链接(node_modules/<pkg> → 同 node_modules 内
409
+ * .pnpm/...)与全局 store 链接目标都不在 worktrees 区之下,零误伤。
410
+ *
411
+ * 扫描口径:main 的 node_modules(根 + 各子模块 node_modules)**顶层目录项**——单层
412
+ * readdir+lstat,不递归 .pnpm 内部(顶层已覆盖包链接;递归大仓 node_modules 的性能与
413
+ * 误删面都不可接受)。子模块清单:submodules 直传相对路径列表,或给 specBase 自动读
414
+ * local.yaml modules 块。
415
+ *
416
+ * 删除安全:junction/symlink 删除不跟随目标(win32 cmd /c rmdir / POSIX unlinkSync——与
417
+ * worktree.js unlinkNodeModulesLinks :82 同款安全删法);单个清理失败 fail-open 记入
418
+ * failed 不阻断(清扫是治理动作,不拦主流程)。
419
+ *
420
+ * @param {string} mainDir 主仓根目录(node_modules 所在)
421
+ * @param {{ worktreesRoot?: string, specBase?: string|null, submodules?: string[]|null, dryRun?: boolean, onlyUnder?: string|null }} opts
422
+ * - worktreesRoot 外来边界:resolved target 落在其下才算外来(缺省直接空手而归)
423
+ * - submodules 为 null 且给了 specBase 时,自动读 local.yaml modules 块补子模块清单
424
+ * - dryRun=true 只报清单零写盘
425
+ * - onlyUnder 进一步收窄:只认 target 落在该目录之下的(install 兜底前只清指向本 worktree 的)
426
+ * @returns {{ foreign: Array<{link: string, target: string}>, removed: Array<{link: string, target: string}>, failed: Array<{link: string, error: string}> }}
427
+ * dryRun 时 foreign=将清清单、removed/failed 恒空;非 dryRun 时 removed=实际已清、failed=清理失败
428
+ */
429
+ export function sweepForeignNodeModulesJunctions(mainDir, { worktreesRoot, specBase = null, submodules = null, dryRun = false, onlyUnder = null } = {}) {
430
+ const result = { foreign: [], removed: [], failed: [] };
431
+ if (!mainDir || !worktreesRoot || !existsSync(mainDir)) return result;
432
+ let mods = submodules;
433
+ if (mods === null && specBase) mods = extractModulePaths(readLocalYaml(specBase, null));
434
+ const nmDirs = [join(mainDir, 'node_modules')];
435
+ for (const sp of Array.isArray(mods) ? mods : []) {
436
+ if (!isSafeModulePath(sp)) continue;
437
+ nmDirs.push(join(mainDir, sp, 'node_modules'));
438
+ }
439
+ for (const nmDir of nmDirs) {
440
+ let names;
441
+ try { names = readdirSync(nmDir); } catch { continue; } // 目录不存在/不可读 → 该层跳过
442
+ for (const nm of names) {
443
+ const linkPath = join(nmDir, nm);
444
+ let st;
445
+ try { st = lstatSync(linkPath); } catch { continue; }
446
+ if (!st.isSymbolicLink()) continue; // junction(reparse point)与 symlink 的 lstat 均报 isSymbolicLink
447
+ let target;
448
+ try { target = readlinkSync(linkPath); } catch { continue; }
449
+ // 相对 target(pnpm 自建包链接常态)相对链接所在目录解析
450
+ const resolved = resolvePath(dirname(linkPath), target);
451
+ if (!isUnderDir(resolved, worktreesRoot)) continue;
452
+ if (onlyUnder && !isUnderDir(resolved, onlyUnder)) continue;
453
+ result.foreign.push({ link: linkPath, target: resolved });
454
+ if (dryRun) continue;
455
+ try {
456
+ if (process.platform === 'win32') {
457
+ execFileSync('cmd.exe', ['/c', 'rmdir', linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
458
+ } else {
459
+ unlinkSync(linkPath);
460
+ }
461
+ result.removed.push({ link: linkPath, target: resolved });
462
+ } catch (e) {
463
+ result.failed.push({ link: linkPath, error: e.message });
464
+ }
465
+ }
466
+ }
467
+ return result;
468
+ }
469
+
390
470
  /**
391
471
  * 对单个子模块目录 tryLink main 的 node_modules → wt 的 node_modules(modules 子模块专用)。
392
472
  * 仅走 link 快路径(不 install——子模块 install 慢且易失败;lockfile 不一致时交给用户 pnpm install)。
@@ -464,6 +544,24 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
464
544
  }
465
545
  if (!linked) {
466
546
  // 兜底:install
547
+ // ql-20260915-003 修复①(坑 junction-foreign-ownership)防再犯:install 在 worktree cwd
548
+ // 执行,若 main node_modules 内残留指向**本次 worktree** 的外来 junction,包管理器会经
549
+ // 反向链接穿透写坏主仓 node_modules(2026-09-15 实证的污染源头)——先清再装。
550
+ // 只清指向本 worktree 的(onlyUnder),全量治理交给 create/cleanup 挂点
551
+ // (worktree.js _sweepMainForeignJunctionLinks)。
552
+ if (mainCwd && existsSync(join(mainCwd, 'node_modules'))) {
553
+ try {
554
+ const preSweep = sweepForeignNodeModulesJunctions(mainCwd, {
555
+ worktreesRoot: resolvePath(mainCwd, '.sillyspec', '.runtime', 'worktrees'),
556
+ submodules: extractModulePaths(yamlText),
557
+ onlyUnder: worktreePath,
558
+ });
559
+ if (preSweep.removed.length > 0) {
560
+ console.warn(`⚠️ install 前清理:主仓 node_modules 内检测到指向本 worktree 的外来 junction(坑 junction-foreign-ownership),已移除 ${preSweep.removed.length} 个`);
561
+ for (const r of preSweep.removed) console.warn(` ${r.link} → ${r.target}`);
562
+ }
563
+ } catch { /* 清扫自身异常 fail-open,交 install 自然暴露问题 */ }
564
+ }
467
565
  const installResult = tryInstall(installCmd, worktreePath, timeout);
468
566
  result = installResult.ok
469
567
  ? { depsStatus: 'installed', depsMethod: 'install', depsSource: 'install', depsLockHash: wtHash }
package/src/worktree.js CHANGED
@@ -10,9 +10,10 @@
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
- import { provisionDeps, checkDepsFreshness, detectEditableInstallEscape } from './worktree-deps.js';
15
+ import { provisionDeps, checkDepsFreshness, detectEditableInstallEscape, sweepForeignNodeModulesJunctions } from './worktree-deps.js';
16
+ import { resolveRuntimeRoot } from './run/shared.js';
16
17
  import { writeAtomicSync } from './fs-atomic.js';
17
18
  import { openDatabase } from './db-engine.js';
18
19
  import { git, gitQuiet } from './git-helper.js';
@@ -25,6 +26,24 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
25
26
  const BRANCH_PREFIX = 'sillyspec/';
26
27
  const META_FILE = 'meta.json';
27
28
 
29
+ /**
30
+ * argv 长度分批(审查遗留终批-①;与 worktree-apply.js chunkPaths 同逻辑内联私有版——
31
+ * 那边 import 本文件,反向 import 成环,两处须同步演化)。Windows CreateProcess 命令行
32
+ * 32767 字符上限:数百个长路径全量展开时 spawn 直接失败(hash-object 失败返 null 会让
33
+ * hasUnappliedChanges 全量误判 pending 卡死 cleanup)。保守 8000 字符/批。
34
+ */
35
+ function _chunkPathsPrivate(paths, maxChars = 8000) {
36
+ const batches = [[]]
37
+ let len = 0
38
+ for (const p of paths) {
39
+ const l = String(p).length + 1
40
+ if (batches[batches.length - 1].length > 0 && len + l > maxChars) { batches.push([]); len = 0 }
41
+ batches[batches.length - 1].push(p)
42
+ len += l
43
+ }
44
+ return batches
45
+ }
46
+
28
47
  /**
29
48
  * 解除 worktree 内全部 node_modules junction/symlink(根 + meta.depsModules 各子模块)。
30
49
  *
@@ -79,7 +98,65 @@ export function unlinkNodeModulesLinks(worktreePath, meta = null, details = null
79
98
  * 安全删除 worktree 目录:先解链全部 node_modules junction 再 rmSync。
80
99
  * 幽灵目录清理(create 强删 / doctor ghost 修复)的统一出口——不再裸 rmSync。
81
100
  */
82
- export function safeRemoveWorktreeDir(worktreePath, meta = null) {
101
+ /**
102
+ * 跨仓 worktree 目录判定(坑 cross-worktree-swept-by-main-cleanup,2026-09-12 驾驭第十三批①,
103
+ * 用户实证:跨仓 worktree 被 verify 期间的主仓侧清理面误删,apply 锚点校验连环失败、被迫
104
+ * cherry-pick 重建):目录内 meta.json 带 isCross:true,或目录名呈 <change>--<repoKey> 跨仓
105
+ * 形态(.runtime/worktrees/ 下主仓 worktree 恒为纯 <change>,双连字符后缀是跨仓专属命名)。
106
+ */
107
+ export function isCrossWorktreeDir(worktreePath) {
108
+ try {
109
+ const metaPath = join(worktreePath, 'meta.json')
110
+ if (existsSync(metaPath)) {
111
+ try {
112
+ const m = JSON.parse(readFileSync(metaPath, 'utf8'))
113
+ if (m && m.isCross === true) return true
114
+ } catch { /* 损坏 meta → 落到命名形态判定 */ }
115
+ }
116
+ const base = basename(worktreePath)
117
+ // 主仓 changeName 可含连字符(2026-09-10-x),跨仓后缀是 '--<repoKey>'——要求双连字符
118
+ // 分隔且两侧非空、repoKey 段无路径分隔符
119
+ const m2 = base.match(/^(.+)--([A-Za-z0-9_.\-]+)$/)
120
+ return !!(m2 && m2[1].trim() && m2[2].trim() && !m2[2].includes('/') && !m2[2].includes('\\'))
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+
126
+ /**
127
+ * worktree 目录安全删除原语。opts.allowCross(默认 false):跨仓 worktree(isCross meta/
128
+ * 命名形态)拒绝删除——主仓清理面(cleanup force-remove / doctor ghost / 幽灵清理)不得
129
+ * 波及跨仓 worktree(其注册在跨仓仓 .git,删除权在 cleanupCrossWorktrees 显式路径)。
130
+ */
131
+
132
+ /**
133
+ * baseline checkpoint 产物防御(坑 baseline-checkpoint-binary-sweep,2026-09-12 驾驭第十五
134
+ * 批②,用户实证:部署 tar.gz(289MB)被 checkpoint 带进分支——FF 合并被迫改 merge commit +
135
+ * 二进制永久入 git)。untracked 收入口对两类产物恒跳过:①已知归档/分发扩展名(tar.gz/zip/
136
+ * whl/exe 等——几乎不可能是源码 WIP);②单文件超体积帽(缺省 10MB,SILLYSPEC_BASELINE_
137
+ * MAX_FILE_MB 可调)。跳过清单返回调用方点名 + 补 .gitignore 指引(gitignore 缺口的实际代价
138
+ * 由工具兜底提示)。tracked-modified 不走本防御(已在 git 历史里,checkpoint diff 是 git 原生)。
139
+ */
140
+ 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
141
+
142
+ export function isCheckpointSkippableArtifact(absPath) {
143
+ const rel = String(absPath).split('\\').join('/')
144
+ if (ARTIFACT_EXT_RE.test(rel)) return 'artifact-ext'
145
+ try {
146
+ const maxMB = Number(process.env.SILLYSPEC_BASELINE_MAX_FILE_MB) || 10
147
+ const st = statSync(absPath)
148
+ if (st.isFile() && st.size > maxMB * 1024 * 1024) return 'size-cap'
149
+ } catch { /* stat 失败按不跳过(后续 copy 自会报错) */ }
150
+ return null
151
+ }
152
+
153
+ export function safeRemoveWorktreeDir(worktreePath, meta = null, opts = {}) {
154
+ if (!opts.allowCross && isCrossWorktreeDir(worktreePath)) {
155
+ throw new Error(
156
+ `跨仓 worktree 拒绝删除(主仓清理面守卫,坑 cross-worktree-swept-by-main-cleanup): ${worktreePath}` +
157
+ `——跨仓 worktree 由 sillyspec worktree cleanup <change>(跨仓一并清理)或 apply no-op 路径管理;主仓侧清理/doctor 扫描不碰它`
158
+ )
159
+ }
83
160
  unlinkNodeModulesLinks(worktreePath, meta);
84
161
  rmSync(worktreePath, { recursive: true, force: true });
85
162
  }
@@ -402,6 +479,44 @@ export class WorktreeManager {
402
479
  return resolve(this.worktreeBase, changeName);
403
480
  }
404
481
 
482
+ /**
483
+ * ql-20260915-003 修复①(坑 junction-foreign-ownership,2026-09-15 用户实证:主仓
484
+ * frontend/node_modules 内 junction 指向历史 worktree 的 .pnpm——跨会话 node_modules
485
+ * 污染):清扫主仓 node_modules(根 + local.yaml modules 子模块)内指向 worktrees 区的
486
+ * 外来 junction/symlink。正确链接方向只有 wt/node_modules → main;main 侧指向 worktrees
487
+ * 区的反向链接一律是 install 兜底/包管理器穿透写坏的污染,发现即清 + warn。
488
+ *
489
+ * 挂点:create 依赖供给前(5.7b,防新供给经污染链接写坏)与 cleanup(把本 worktree 可能
490
+ * 遗留的反向链接一并清)。清扫失败 fail-open 不阻断主流程(治理动作非卡点)。
491
+ *
492
+ * @param {string} label 调用场景(warn/details 文案用)
493
+ * @param {string[]|null} [details] 可选,cleanup details 兼容
494
+ * @returns {{ foreign: Array, removed: Array, failed: Array }} sweep 结果(异常时空集)
495
+ */
496
+ _sweepMainForeignJunctionLinks(label, details = null) {
497
+ try {
498
+ const mainRoot = this._resolveMainRepoRoot();
499
+ const sweep = sweepForeignNodeModulesJunctions(mainRoot, {
500
+ worktreesRoot: this.worktreeBase,
501
+ specBase: join(mainRoot, '.sillyspec'),
502
+ });
503
+ if (sweep.removed.length > 0) {
504
+ console.warn(`⚠️ 检测到主仓 node_modules 内指向历史 worktree 的外来 junction,已移除 ${sweep.removed.length} 个(坑 junction-foreign-ownership,${label})`);
505
+ for (const r of sweep.removed) console.warn(` ${r.link} → ${r.target}`);
506
+ if (details) details.push(`foreign junctions swept (${label}): ${sweep.removed.length}`);
507
+ }
508
+ if (sweep.failed.length > 0) {
509
+ console.warn(`⚠️ 主仓 node_modules 外来 junction 清理未全成(${sweep.failed.length} 个失败,可重跑 sillyspec worktree doctor 或手工 rmdir): ${sweep.failed.map(f => f.link).join('、')}`);
510
+ if (details) details.push(`foreign junction sweep partial fail (${label}): ${sweep.failed.map(f => f.link).join('; ')}`);
511
+ }
512
+ return sweep;
513
+ } catch (e) {
514
+ // fail-open:清扫自身异常不阻断 create/cleanup 主流程
515
+ console.warn(`⚠️ 主仓 node_modules 外来 junction 清扫跳过(${e && e.message ? e.message : e})`);
516
+ return { foreign: [], removed: [], failed: [] };
517
+ }
518
+ }
519
+
405
520
  /**
406
521
  * 读取 worktree 元数据
407
522
  * @param {string} changeName
@@ -620,6 +735,10 @@ export class WorktreeManager {
620
735
 
621
736
  // 5.8 依赖供给(change 2026-06-28-worktree-deps-provision)
622
737
  // baseline overlay 后让 worktree 立即可构建/测试;失败不阻断 create,只记 meta。
738
+ // 5.7b ql-20260915-003 修复①(坑 junction-foreign-ownership):依赖供给前清扫主仓
739
+ // node_modules 内指向历史 worktree 的外来 junction(反向链接污染,install 兜底穿透
740
+ // 写坏的源头)——发现即清 + warn,防新供给再经污染链接写坏主仓依赖。
741
+ this._sweepMainForeignJunctionLinks('worktree create');
623
742
  let deps = {};
624
743
  try {
625
744
  deps = provisionDeps(worktreePath, this.cwd, { specBase: join(this.cwd, '.sillyspec') }) || {};
@@ -873,11 +992,10 @@ export class WorktreeManager {
873
992
  _branchReviewReferences(branch) {
874
993
  const runsDir = join(this.cwd, '.sillyspec', '.runtime', 'execute-runs');
875
994
  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 = [];
995
+ // 终批-②:先收 review.json 里出现的候选 hash(少量),再逐个精确校验「是分支祖先或自身」
996
+ // ——旧实现整分支 rev-list 进 Set(大历史仓 10 万 commit 每次 cleanup/doctor 可观内存与
997
+ // 时间,而判定只用到 review 引用过的那几个 hash)。
998
+ const candidates = new Map(); // hash → 引用它的 review.json 路径数组
881
999
  const visit = (dir) => {
882
1000
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
883
1001
  const p = join(dir, entry.name);
@@ -887,14 +1005,25 @@ export class WorktreeManager {
887
1005
  const r = JSON.parse(readFileSync(p, 'utf8'));
888
1006
  for (const key of ['base', 'head']) {
889
1007
  const h = typeof r[key] === 'string' ? r[key].trim() : '';
890
- if (h && branchCommits.has(h)) { refs.push(p); break; }
1008
+ if (h) {
1009
+ if (!candidates.has(h)) candidates.set(h, []);
1010
+ candidates.get(h).push(p);
1011
+ }
891
1012
  }
892
1013
  } catch { /* review.json 损坏不参与判定 */ }
893
1014
  }
894
1015
  }
895
1016
  };
896
1017
  visit(runsDir);
897
- return refs;
1018
+ if (candidates.size === 0) return [];
1019
+ const refs = [];
1020
+ for (const [hash, paths] of candidates) {
1021
+ // merge-base --is-ancestor:祖先或自身 → exit 0 输出空串(gitQuiet 返回 ''≠null);
1022
+ // 非祖先 → exit 1 → null;未知/畸形 hash → 报错非零 → null(不在分支内,语义正确)
1023
+ const probe = gitQuiet(this.cwd, ['merge-base', '--is-ancestor', hash, branch]);
1024
+ if (probe !== null) refs.push(...paths);
1025
+ }
1026
+ return [...new Set(refs)];
898
1027
  }
899
1028
 
900
1029
  /**
@@ -960,16 +1089,76 @@ export class WorktreeManager {
960
1089
  }
961
1090
  }
962
1091
 
1092
+ /**
1093
+ * ql-20260915-003 修复②(坑 cleanup-meta-fallback,2026-09-15 用户实证:cleanup 报
1094
+ * 「mode: null 跳过清理」但 worktree 目录实际创建过——meta 在 apply 后丢失 + cwd/specRoot
1095
+ * 漂移让 getWorktreePath 解析到别处时,meta/worktreePath/metaDir 三缺被误判「什么都不
1096
+ * 存在」早退):meta 缺失时的 worktree 残留兜底探测。两路探针(异常一律 fail-open 视为
1097
+ * 未命中):
1098
+ * (a) `git worktree list --porcelain` 中分支 refs/heads/sillyspec/<name> 的注册路径——
1099
+ * git 注册是权威口径,不受本进程 worktreeBase 解析漂移影响;目录在不在都算命中
1100
+ * (注册残留本身就该 prune,目录在则连目录一起清)
1101
+ * (b) resolveRuntimeRoot 口径(cwd/.sillyspec 为 localSpecBase)下 worktrees/<name>
1102
+ * 目录存在性——平台 runtimeRoot / cwd 漂移时的第二解析口径
1103
+ * 全不命中 → hit:null + candidates(三路探针描述,供 CLI 诊断式跳过输出)。
1104
+ * @param {string} name changeName(已过 validateChangeName)
1105
+ * @returns {{ hit: { path: string, via: string }|null, candidates: string[] }}
1106
+ */
1107
+ _probeWorktreeRemnants(name) {
1108
+ const stdPath = this.getWorktreePath(name);
1109
+ const candidates = [`meta/目录探针(getWorktreePath 口径): ${stdPath} 不存在`];
1110
+ try {
1111
+ const out = gitQuiet(this.cwd, ['worktree', 'list', '--porcelain'], { timeout: 30000 });
1112
+ const wantBranch = `branch refs/heads/${BRANCH_PREFIX}${name}`;
1113
+ let curPath = null;
1114
+ let regHit = null;
1115
+ for (const line of String(out || '').split('\n')) {
1116
+ if (line.startsWith('worktree ')) curPath = line.slice('worktree '.length).trim();
1117
+ else if (curPath && line.trim() === wantBranch) { regHit = curPath; break; }
1118
+ }
1119
+ if (regHit) {
1120
+ candidates.push(`git 分支探针: 分支 ${BRANCH_PREFIX}${name} 注册于 ${regHit} —— 命中`);
1121
+ return { hit: { path: regHit, via: `git worktree list 分支 ${BRANCH_PREFIX}${name} 注册路径` }, candidates };
1122
+ }
1123
+ candidates.push(`git 分支探针: git worktree list 无分支 ${BRANCH_PREFIX}${name} 注册`);
1124
+ } catch {
1125
+ candidates.push(`git 分支探针: git worktree list 失败(fail-open 视为未命中)`);
1126
+ }
1127
+ try {
1128
+ const alt = join(resolveRuntimeRoot(null, join(this.cwd, '.sillyspec')), 'worktrees', name);
1129
+ if (existsSync(alt)) {
1130
+ candidates.push(`runtimeRoot 口径目录探针: ${alt} 存在 —— 命中`);
1131
+ return { hit: { path: alt, via: `resolveRuntimeRoot 口径目录 ${alt}` }, candidates };
1132
+ }
1133
+ candidates.push(`runtimeRoot 口径目录探针: ${alt} 不存在`);
1134
+ } catch {
1135
+ candidates.push('runtimeRoot 口径目录探针: 解析失败(fail-open 视为未命中)');
1136
+ }
1137
+ return { hit: null, candidates };
1138
+ }
1139
+
963
1140
  cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
964
1141
  const name = validateChangeName(changeName);
965
1142
  const meta = this.getMeta(name);
966
- const worktreePath = this.getWorktreePath(name);
1143
+ let worktreePath = this.getWorktreePath(name);
967
1144
  const metaDir = join(this.worktreeBase, name);
968
1145
  const details = [];
969
1146
 
970
- // 幂等:什么都不存在 → 直接跳过
1147
+ // 幂等:什么都不存在 → 直接跳过。
1148
+ // ql-20260915-003 修复②(坑 cleanup-meta-fallback):meta 缺失时不轻信「三缺 = 什么都不
1149
+ // 存在」——先跑 _probeWorktreeRemnants 兜底(git 注册路径 + resolveRuntimeRoot 口径目录),
1150
+ // 任一命中 → 不走早退,按命中路径继续清理(mode 推断 'worktree',details 记兜底依据);
1151
+ // 全不命中才跳过,跳过结果带 probePaths 探针清单(CLI 诊断式输出 + doctor 指引)。
971
1152
  if (!meta && !existsSync(worktreePath) && !existsSync(metaDir)) {
972
- return { result: 'skipped', mode: null, details };
1153
+ const probes = this._probeWorktreeRemnants(name);
1154
+ if (probes.hit) {
1155
+ worktreePath = probes.hit.path;
1156
+ const note = `meta 缺失,目录/分支探测兜底(命中:${probes.hit.via})`;
1157
+ details.push(note);
1158
+ console.warn(`⚠️ ${note}`);
1159
+ } else {
1160
+ return { result: 'skipped', mode: null, details, probePaths: probes.candidates };
1161
+ }
973
1162
  }
974
1163
 
975
1164
  const mode = meta?.mode || 'worktree';
@@ -990,7 +1179,11 @@ export class WorktreeManager {
990
1179
  if (!force) {
991
1180
  const check = this.hasUnappliedChanges(name);
992
1181
  if (check.hasChanges) {
993
- console.error(`🚫 worktree cleanup 拒绝:${check.changedFiles.length} 个交付变更未落地主工作区 HEAD,清理会丢失代码。`);
1182
+ // ql-20260915-003 修复② 配套:no-meta fail-closed 拦截时 changedFiles 为空,裸报
1183
+ // 「0 个交付变更」让人误解——0 文件时改报无法判定的原因(保守保留语义不变)。
1184
+ console.error(check.changedFiles.length > 0
1185
+ ? `🚫 worktree cleanup 拒绝:${check.changedFiles.length} 个交付变更未落地主工作区 HEAD,清理会丢失代码。`
1186
+ : `🚫 worktree cleanup 拒绝:无法判定交付是否已全部落地(${check.reason || 'meta 缺失,保守保留'}),保守不清理。`);
994
1187
  for (const f of check.changedFiles) console.error(` ${f}`);
995
1188
  console.error(' 请先落地(sillyspec worktree apply <name>)或 commit 到分支,或显式 --force 强制清理。');
996
1189
  return { result: 'blocked', mode, details: [...details, 'blocked: uncommitted deliverable changes'], residual: [] };
@@ -1012,6 +1205,11 @@ export class WorktreeManager {
1012
1205
  unlinkNodeModulesLinks(worktreePath, meta, details)
1013
1206
  }
1014
1207
 
1208
+ // ql-20260915-003 修复①(坑 junction-foreign-ownership):cleanup 时同款清扫主仓
1209
+ // node_modules——把本 worktree(及其他历史 worktree)可能遗留的反向链接一并清,
1210
+ // 否则污染 junction 永久滞留主仓,直到下次人工 --force 重装依赖才发现。
1211
+ this._sweepMainForeignJunctionLinks('worktree cleanup', details);
1212
+
1015
1213
  // 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
1016
1214
  let gitRemoveOk = false;
1017
1215
  if (!isInPlace && existsSync(worktreePath)) {
@@ -1468,7 +1666,12 @@ export class WorktreeManager {
1468
1666
  hasUnappliedChanges(changeName) {
1469
1667
  const name = validateChangeName(changeName);
1470
1668
  const meta = this.getMeta(name);
1471
- if (!meta) return { hasChanges: false, changedFiles: [], reason: 'no meta' };
1669
+ if (!meta) {
1670
+ // meta 缺失(写坏/误删)→ 无法定位与判定交付,与「目录不存在」同款保守保留(对齐 create()
1671
+ // 幽灵分支的 fail-closed)。旧版返回 hasChanges:false 使 cleanup 跳过未落仓护栏直接
1672
+ // worktree remove --force——可删未提交代码(2026-09-11 审查 P1)
1673
+ return { hasChanges: true, changedFiles: [], reason: 'no meta (conservative keep)' };
1674
+ }
1472
1675
 
1473
1676
  const worktreePath = meta.worktreePath;
1474
1677
  if (!worktreePath || !existsSync(worktreePath)) {
@@ -1596,9 +1799,14 @@ export class WorktreeManager {
1596
1799
  }
1597
1800
 
1598
1801
  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');
1802
+ // hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整批失败 → 该批各行为空串
1803
+ // (视为未应用,保守保留——与旧整命令失败语义一致,只是失败面从全量缩小到单批)。
1804
+ // 终批-①:argv 分批(批次间行序拼接保持与 untrackedFiles 的索引对齐)。
1805
+ const wtHashes = []
1806
+ for (const batch of _chunkPathsPrivate(untrackedFiles)) {
1807
+ const out = gitQuiet(worktreePath, ['hash-object', '--', ...batch], { timeout: 30000 }) || ''
1808
+ wtHashes.push(...out.split('\n'))
1809
+ }
1602
1810
  const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
1603
1811
  for (let i = 0; i < untrackedFiles.length; i++) {
1604
1812
  // wtHashes[i] 缺失(命令失败/行数不齐)→ 视为未应用,保守保留
@@ -1623,14 +1831,18 @@ export class WorktreeManager {
1623
1831
  _lsTreeBlobs(cwd, treeish, files) {
1624
1832
  const map = new Map();
1625
1833
  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);
1834
+ // 终批-①:argv 分批(worktree-apply.js 的 chunkPaths 同逻辑内联——那边 import 本文件,
1835
+ // 反向 import 会成环;两处口径须同步演化)
1836
+ for (const batch of _chunkPathsPrivate(files)) {
1837
+ const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...batch]);
1838
+ if (!raw) continue;
1839
+ for (const line of raw.split('\n')) {
1840
+ if (!line) continue;
1841
+ const tab = line.indexOf('\t');
1842
+ if (tab === -1) continue;
1843
+ const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
1844
+ if (hash) map.set(line.slice(tab + 1), hash);
1845
+ }
1634
1846
  }
1635
1847
  return map;
1636
1848
  }
@@ -1758,10 +1970,15 @@ export class WorktreeManager {
1758
1970
  // untracked 文件(排除 .sillyspec/.runtime 等);目录跳过避免 readFileSync EISDIR
1759
1971
  const untracked = gitQuiet(mainCwd, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '';
1760
1972
  const skippedDirs = [];
1973
+ const skippedArtifacts = [];
1761
1974
  if (untracked) {
1762
1975
  for (const f of untracked.split('\n').filter(Boolean)) {
1763
1976
  const norm = f.replace(/\\/g, '/')
1764
1977
  if (norm === '.sillyspec' || norm.startsWith('.sillyspec/')) { excludedSpecFiles.push(f); continue }
1978
+ // 产物防御(坑 baseline-checkpoint-binary-sweep):归档扩展名/超体积帽 untracked 恒不进
1979
+ // baseline checkpoint——289MB tar.gz 入分支(FF 破坏 + 二进制永久入 git)的实际代价兜底
1980
+ const artifactWhy = isCheckpointSkippableArtifact(join(mainCwd, f));
1981
+ if (artifactWhy) { skippedArtifacts.push(`${f}(${artifactWhy})`); continue }
1765
1982
  const r = copyUntrackedEntry(join(mainCwd, f), join(worktreePath, f));
1766
1983
  if (r.status === 'copied') files.push(f);
1767
1984
  else if (r.status === 'skipped-dir') skippedDirs.push(f);
@@ -1771,6 +1988,10 @@ export class WorktreeManager {
1771
1988
  if (skippedDirs.length > 0) {
1772
1989
  console.log(`ℹ️ baseline overlay 跳过 ${skippedDirs.length} 个 untracked 目录(不读目录避免 EISDIR,坑 execute-worktree-overlay-untracked-dir-eisdir)`);
1773
1990
  }
1991
+ if (skippedArtifacts.length > 0) {
1992
+ console.warn(`⚠️ baseline checkpoint 跳过 ${skippedArtifacts.length} 个产物/大文件(不进分支,留在主仓原位):${skippedArtifacts.slice(0, 5).join('、')}${skippedArtifacts.length > 5 ? ' 等' : ''}`);
1993
+ console.warn(` 归档/分发物与大文件不该入 git——补 .gitignore(如 *.tar.gz / dist/ / deploy/)或移出仓库;本跳过仅护 baseline checkpoint,git add -A 仍会把它们卷进提交`);
1994
+ }
1774
1995
  // 跨变更 spec 文档隔离可见性:列出被排除的 .sillyspec/ 文件(去重,截断展示),
1775
1996
  // 让「ROADMAP 被谁改动、为何不在本 baseline」在 create 时刻可查,不需事后考古
1776
1997
  const excludedUnique = [...new Set(excludedSpecFiles)]
@@ -1819,8 +2040,12 @@ export class WorktreeManager {
1819
2040
  * @returns {string} commit hash
1820
2041
  */
1821
2042
  _createBaselineCheckpoint(worktreePath, changeName, baselineFiles = []) {
1822
- // 使用临时 git identity,避免用户未配置 user.name/user.email 导致失败
2043
+ // 临时 git identity(用户未配置 user.name/email 时兜底)。终批-③:{ ...process.env } 展开
2044
+ // 而非裸替换——裸 env 丢 SystemRoot/USERPROFILE/TEMP(Windows 上 git 直接起不来);
2045
+ // 提交走统一 git() 入口(safe.directory/timeout/maxBuffer 与全仓其他调用同口径——
2046
+ // 旧裸 execFileSync 在 dubious-ownership 环境(CI/挂载盘)必败且无超时无 maxBuffer)。
1823
2047
  const env = {
2048
+ ...process.env,
1824
2049
  GIT_AUTHOR_NAME: 'sillyspec',
1825
2050
  GIT_AUTHOR_EMAIL: 'sillyspec@baseline',
1826
2051
  GIT_COMMITTER_NAME: 'sillyspec',
@@ -1845,10 +2070,10 @@ export class WorktreeManager {
1845
2070
  // 分支上以便区分「前置 baseline」与「子代理新增改动」。它不该触发项目 pre-commit
1846
2071
  // hook(如 ruff format),否则主仓库 dirty 文件中任一不达标的会被 hook reformat
1847
2072
  // 致 commit 失败 → worktree 创建失败 → execute 无法启动。
1848
- execFileSync(
1849
- 'git',
2073
+ git(
2074
+ worktreePath,
1850
2075
  ['commit', '--no-verify', '-m', `sillyspec: baseline checkpoint for ${changeName}${body}`],
1851
- { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
2076
+ { timeout: 30000, env }
1852
2077
  );
1853
2078
  const hash = git(worktreePath, ['rev-parse', 'HEAD']);
1854
2079
  console.log(`📌 baseline checkpoint: ${hash}`);