sillyspec 3.25.4 → 3.25.6

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 (41) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +27 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +56 -0
  3. package/.claude/skills/sillyspec-plan/SKILL.md +26 -0
  4. package/.claude/skills/sillyspec-propose/SKILL.md +26 -0
  5. package/.claude/skills/sillyspec-scan/SKILL.md +12 -7
  6. package/.claude/skills/sillyspec-verify/SKILL.md +2 -2
  7. package/package.json +1 -1
  8. package/src/change-list.js +114 -12
  9. package/src/change-risk-profile.js +36 -1
  10. package/src/constants.js +11 -0
  11. package/src/index.js +4 -1
  12. package/src/machine-interface.js +5 -1
  13. package/src/progress/stage-machine.js +17 -14
  14. package/src/run/command.js +58 -3
  15. package/src/run/complete-handlers.js +9 -3
  16. package/src/run/complete.js +130 -33
  17. package/src/run/gates.js +37 -0
  18. package/src/run/prompt.js +20 -0
  19. package/src/run/quick-audit.js +8 -1
  20. package/src/run/scan-profile.js +29 -10
  21. package/src/run/shared.js +161 -4
  22. package/src/run/stage.js +4 -0
  23. package/src/scan-postcheck.js +25 -11
  24. package/src/stage-contract.js +39 -8
  25. package/src/stage-review.js +16 -5
  26. package/src/stages/archive.js +13 -9
  27. package/src/stages/brainstorm-auto.js +3 -2
  28. package/src/stages/brainstorm.js +13 -16
  29. package/src/stages/execute.js +4 -3
  30. package/src/stages/plan-postcheck.js +1 -1
  31. package/src/stages/plan.js +13 -0
  32. package/src/stages/propose.js +3 -2
  33. package/src/stages/quick.js +31 -31
  34. package/src/stages/scan.js +2 -1
  35. package/src/stages/status.js +2 -2
  36. package/src/stages/verify.js +19 -8
  37. package/src/task-review.js +308 -3
  38. package/src/verify-postcheck.js +353 -38
  39. package/src/worktree-apply.js +134 -67
  40. package/src/worktree.js +106 -32
  41. package/templates/prompts/verify-probes.md +13 -2
@@ -18,7 +18,8 @@ import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
19
  import { createHash } from 'crypto';
20
20
  import { WorktreeManager } from './worktree.js';
21
- import { parseFileChangeList } from './change-list.js';
21
+ import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
22
+ import { parseAllowedPaths } from './stages/plan-postcheck.js';
22
23
 
23
24
  const CHANGES_REL = '.sillyspec/changes';
24
25
 
@@ -48,6 +49,20 @@ export function filterDeliverableFiles(files) {
48
49
  return files.filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
49
50
  }
50
51
 
52
+ /**
53
+ * 校验变更文件是否都在 design.md 清单内——容差匹配(与 plan-postcheck validateDesignFileCoverage
54
+ * 同语义):design 清单写 glob(双星通配)或目录前缀(如 src/ 子树)也能覆盖 git diff 出的具体路径,
55
+ * 避免「plan 阶段放过、apply 阶段卡死」逼用户回去补字面文件名。
56
+ *
57
+ * @param {string[]} changedFiles git diff 出的具体变更路径
58
+ * @param {Set<string>} allowSet design.md 清单项(可能含 glob / 目录前缀)
59
+ * @returns {string[]} 不被任何清单项覆盖的文件(违规项,apply 将据此 BLOCK)
60
+ */
61
+ export function classifyAllowListViolations(changedFiles, allowSet) {
62
+ const allow = [...allowSet];
63
+ return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
64
+ }
65
+
51
66
  /**
52
67
  * 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
53
68
  *
@@ -170,29 +185,32 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
170
185
 
171
186
  // --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
172
187
  if (hasAllowList) {
173
- for (const f of changedFiles) {
174
- if (!allowSet.has(f)) {
175
- result.extraFiles.push(f);
176
- }
177
- }
178
- if (result.extraFiles.length > 0) {
188
+ const violations = classifyAllowListViolations(changedFiles, allowSet);
189
+ if (violations.length > 0) {
190
+ result.extraFiles.push(...violations);
179
191
  result.errors.push(
180
- `文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${result.extraFiles.join('\n ')}`
192
+ `文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${violations.join('\n ')}`
181
193
  );
182
- return result;
194
+ // checkOnly(assess)模式不短路:继续跑 Gate3,收集所有道供一次报全(坑 worktree-execute-apply-friction 坑4)。
195
+ // 真实 apply(checkOnly=false)仍短路,保安全。
196
+ if (!checkOnly) return result;
183
197
  }
184
198
  }
185
199
 
186
- // --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
187
- // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
200
+ // --- 4.6 显式 --merge:用户显式选择 git merge 兜底(主干已提交推进重叠时的三方合并) ---
201
+ // 触发点从「4.5 baseline 漂移自动降级」改为「用户显式 --merge flag」(D-001 保留,触发方式变化)。
202
+ // 用 --merge 时跳过未提交 dirty 拦截——merge 同样要求工作区相对干净,此处仅提示风险,真正失败由 applyByMerge 报告。
203
+ if (merge && !checkOnly) {
204
+ return applyByMerge(result, changeName, projectRoot, wm);
205
+ }
206
+
207
+ // --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
208
+ // 分工:4.5(整树 baselineHash)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
209
+ // 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
210
+ // 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
188
211
  // 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
189
212
  // 否则别人改 CLAUDE.md/docs/.claude → 整树 hash 变 → apply 误阻断(多操作者仓库高频踩坑)。
190
- // 安全:放过它们不丢真冲突——下方 step5a(未提交∩changedFiles)+step5b(HEAD blob) 对交付文件
191
- // 做精确 per-file 校验,不读这里的 exclude。
192
- // - .sillyspec/:plan/design 蓝图 + runtime 产物
193
- // - .claude/:agent 配置/skills/CLAUDE.md
194
- // - docs/:文档(deliverable 文档冲突由 step5a/5b 兜底)
195
- // - CLAUDE.md:根 agent 指引
213
+ // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
196
214
  if (meta.baselineHash) {
197
215
  const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
198
216
  const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
@@ -201,17 +219,19 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
201
219
  const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
202
220
  const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
203
221
  if (currentHash !== meta.baselineHash) {
204
- if (merge && !checkOnly) {
205
- // baseline 漂移 + --merge 降级(D-001):跳过步骤 5-7(patch 路径),走 git merge
206
- return applyByMerge(result, changeName, projectRoot, wm);
207
- }
222
+ // 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
223
+ const dirtyFiles = [...new Set(
224
+ ((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
225
+ .concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
226
+ )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
208
227
  result.errors.push(
209
- `主工作区 baseline 已变化(execute 前后不一致),不能直接 apply task.patch。\n` +
210
- `建议:重新创建 worktree,或用 --merge 降级(git merge sillyspec/${changeName} 替代 patch,会引入合并提交)。\n` +
228
+ `主工作区有未提交的改动(execute 前后 baseline 不一致),git apply 无法安全应用。\n` +
229
+ (dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
230
+ `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
211
231
  `execute 前 baseline: ${meta.baselineHash}\n` +
212
232
  `当前 baseline: ${currentHash}`
213
233
  );
214
- return result;
234
+ if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
215
235
  }
216
236
  }
217
237
 
@@ -226,13 +246,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
226
246
  result.errors.push(
227
247
  `主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
228
248
  );
229
- return result;
249
+ if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
230
250
  }
231
251
  }
232
252
 
233
253
  // 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
234
254
  // 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
235
255
  // 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
256
+ // 放宽(2026-07):blob 不一致不再 BLOCKED——它意味着主干「已提交」推进改了同文件,
257
+ // 而 git apply --3way 能自动三路合并这种场景(不同区域直接合,同区域留冲突标记)。
258
+ // 故仅记录为风险提示(hashMismatchFiles → assess WARNING / summary 展示),放行交 step7 --3way 实测。
259
+ // 真重叠时 --3way 冲突,由 step7 回滚并提示 --merge 兜底。
236
260
  const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
237
261
  const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
238
262
  const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
@@ -244,17 +268,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
244
268
  if (wtBlob === null && mainBlob === null) continue;
245
269
  // 两者一致 → OK
246
270
  if (wtBlob === mainBlob) continue;
247
- // 不一致 → 主工作区已被修改
271
+ // 不一致 → 主干已提交推进改了此文件,记风险提示(不拦截,交 --3way)
248
272
  result.hashMismatchFiles.push(f);
249
273
  }
250
274
 
251
- if (result.hashMismatchFiles.length > 0) {
252
- result.errors.push(
253
- `base hash 不一致:以下文件在主工作区已被修改:\n ${result.hashMismatchFiles.join('\n ')}`
254
- );
255
- return result;
256
- }
257
-
258
275
  // --- 6. checkOnly 模式:到此返回 ---
259
276
  if (checkOnly) {
260
277
  result.ok = true;
@@ -323,19 +340,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
323
340
 
324
341
  writeFileSync(patchPath, patchContent);
325
342
 
326
- // apply --check 预检
327
- try {
328
- git(projectRoot, `apply --check ${patchPath}`);
329
- } catch (e) {
330
- result.errors.push(`patch 预检失败: ${e.message}`);
331
- return result;
332
- }
343
+ // apply --check 预检失败不再拦截(--check 只测 clean apply,--3way 能处理 clean apply 失败的
344
+ // 三路合并场景——主干已提交推进时 --check 恒失败但 --3way 可合)。故跳过 --check,直接试 --3way。
345
+
346
+ // 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
347
+ // tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
348
+ // 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
349
+ const trackedPatchFiles = patchFiles.filter(f => {
350
+ // 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
351
+ return gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null ? false : true;
352
+ });
353
+ const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
333
354
 
334
- // apply --3way 正式应用
355
+ // apply --3way 正式应用(主干已提交推进时自动三路合并)
335
356
  try {
336
357
  git(projectRoot, `apply --3way ${patchPath}`);
337
358
  } catch (e) {
338
- result.errors.push(`patch apply 失败: ${e.message}`);
359
+ // --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
360
+ const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
361
+ result.errors.push(
362
+ `apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
363
+ ` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
364
+ `已回滚工作区到 apply 前状态(无半成品冲突标记)。\n` +
365
+ `可选:用 --merge 自动三方合并兜底(git merge sillyspec/${changeName},会引入合并提交):\n` +
366
+ ` sillyspec worktree apply ${changeName} --merge\n` +
367
+ `或手动解决后重试。`
368
+ );
369
+ if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
339
370
  return result;
340
371
  }
341
372
 
@@ -360,6 +391,40 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
360
391
  return result;
361
392
  }
362
393
 
394
+ /**
395
+ * 回滚 --3way 冲突后的工作区到 apply 前状态(不留半成品冲突标记)。
396
+ * tracked 文件:git checkout -- <f> 还原 HEAD 版(清除冲突标记)。
397
+ * 新文件(apply 前不存在):删除(--3way 可能部分创建)。
398
+ * @param {string} projectRoot
399
+ * @param {string[]} trackedFiles 主仓库 HEAD 已存在、patch 触及的文件
400
+ * @param {string[]} newFiles apply 前不存在、patch 可能新建的文件
401
+ * @returns {{ conflicts: string[], error: string|null }} conflicts=冲突文件列表
402
+ */
403
+ function rollbackApply(projectRoot, trackedFiles, newFiles) {
404
+ let error = null;
405
+ // 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
406
+ let conflicts = [];
407
+ try {
408
+ const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
409
+ conflicts = unmerged.split('\n').filter(Boolean);
410
+ } catch {}
411
+ // 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
412
+ // `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
413
+ for (const f of trackedFiles) {
414
+ try { gitQuiet(projectRoot, `checkout HEAD -- ${f}`); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
415
+ }
416
+ // 删除 --3way 可能新建的文件(apply 前不存在)
417
+ for (const f of newFiles) {
418
+ try {
419
+ const p = join(projectRoot, f);
420
+ if (existsSync(p)) unlinkSync(p);
421
+ } catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
422
+ }
423
+ // 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
424
+ try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
425
+ return { conflicts, error };
426
+ }
427
+
363
428
  /**
364
429
  * baseline 漂移时的 merge 降级路径(D-001)。
365
430
  *
@@ -456,19 +521,18 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
456
521
  // 先跑 --check-only 模式的 applyWorktree 获取变更文件列表
457
522
  const checkResult = applyWorktree(changeName, { cwd: projectRoot, checkOnly: true });
458
523
 
459
- if (checkResult.errors.length > 0) {
460
- return {
461
- decision: 'BLOCKED',
462
- changedFiles: checkResult.changedFiles,
463
- reasons: checkResult.errors,
464
- warnings: [],
465
- stats: { additions: 0, deletions: 0 }
466
- };
467
- }
524
+ // applyWorktree(checkOnly) 已收集所有道(Gate1/3 不短路)——其 errors 纳入 reasons,
525
+ // 继续跑 Gate2/4/6 一次报全(坑 worktree-execute-apply-friction 坑4:原此处提前 return 致逐道挤牙膏)。
526
+ reasons.push(...checkResult.errors);
527
+ if (checkResult.warnings?.length) warnings.push(...checkResult.warnings);
468
528
 
469
529
  const changedFiles = checkResult.changedFiles;
470
530
 
471
531
  if (changedFiles.length === 0) {
532
+ // 无变更:若仍有 Gate 错误(如主区 dirty)则 BLOCKED,否则 SAFE
533
+ if (reasons.length > 0) {
534
+ return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 } };
535
+ }
472
536
  return {
473
537
  decision: 'SAFE',
474
538
  changedFiles: [],
@@ -478,36 +542,39 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
478
542
  };
479
543
  }
480
544
 
481
- // 解析 TaskCard allowed_paths
545
+ // 解析 TaskCard allowed_paths(复用 plan-postcheck.parseAllowedPaths,消除内联重复实现漂移)
482
546
  const wm = new WorktreeManager({ cwd: projectRoot });
483
547
  const meta = wm.getMeta(changeName);
484
548
  const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
485
549
  const allowedPaths = new Set();
486
550
  if (existsSync(tasksDir)) {
487
551
  for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
488
- const content = readFileSync(join(tasksDir, tf), 'utf8');
489
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
490
- if (!fmMatch) continue;
491
- const fm = fmMatch[1];
492
- const inline = fm.match(/allowed_paths:\s*\[([^\]]*)\]/);
493
- if (inline) {
494
- inline[1].split(',').forEach(s => { const v = s.trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
495
- }
496
- const block = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/);
497
- if (block) {
498
- block[1].match(/-\s+(.+)/g)?.forEach(s => { const v = s.replace(/^-\s+/, '').trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
552
+ for (const p of parseAllowedPaths(readFileSync(join(tasksDir, tf), 'utf8'))) {
553
+ if (p) allowedPaths.add(p);
499
554
  }
500
555
  }
501
556
  }
502
557
 
503
- // 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时)
558
+ // design §6 标记为「顺带修复」的文件(坑 worktree-execute-apply-friction 坑1):合规修预存债,
559
+ // 不属任何 task 边界,assess 豁免 allowed_paths 严格校验(降级 warning),避免被迫 cherry-pick 绕过。
560
+ const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
561
+ const incidentalSet = new Set(
562
+ parseFileChangeListDetailed(designPath).filter(e => e.incidental).map(e => e.path)
563
+ );
564
+
565
+ // 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时);顺带修复文件豁免。
566
+ // 匹配换 pathMatches(与 Gate1/plan-postcheck 同语义容差),消除原字面前缀弱匹配漂移。
504
567
  if (allowedPaths.size > 0) {
505
- const outsidePaths = changedFiles.filter(f => !
506
- [...allowedPaths].some(allowed => f === allowed || f.startsWith(allowed.replace(/\*$/, '')))
507
- );
568
+ const isIncidental = f => [...incidentalSet].some(ap => pathMatches(f, ap));
569
+ const outsideAll = changedFiles.filter(f => ![...allowedPaths].some(allowed => pathMatches(f, allowed)));
570
+ const outsidePaths = outsideAll.filter(f => !isIncidental(f));
571
+ const exempted = outsideAll.filter(f => isIncidental(f));
508
572
  if (outsidePaths.length > 0) {
509
573
  reasons.push(`变更文件超出 allowed_paths:\n ${outsidePaths.join('\n ')}`);
510
574
  }
575
+ if (exempted.length > 0) {
576
+ warnings.push(`顺带修复文件(已豁免 allowed_paths,来源 design §6 标记):${exempted.join(', ')}`);
577
+ }
511
578
  }
512
579
 
513
580
  // 检查 4+5: 高风险文件模式
package/src/worktree.js CHANGED
@@ -138,10 +138,11 @@ function printSyncReport(diag, baseHash, changeName) {
138
138
  const ch = changeName || '<change>';
139
139
  if (diag.status === 'behind') {
140
140
  console.log(`⚠️ base 同步检测:落后 origin/${b} ${diag.behind} 个 commit(base ${short})`);
141
- console.log(` execute 期间若主仓库对齐 main,apply 回来可能冲突。建议先对齐:`);
141
+ console.log(` apply 时 git --3way 会自动三路合并主干已提交推进;只有同文件同区域重叠才冲突。`);
142
+ console.log(` 建议先对齐以减小冲突面:`);
142
143
  console.log(` git merge --ff-only origin/${b}`);
143
144
  console.log(` sillyspec worktree cleanup ${ch} && sillyspec run execute --change ${ch}`);
144
- console.log(` 或继续,apply 时用 --merge 降级(会引入合并提交)。`);
145
+ console.log(` 或继续,重叠冲突时用 --merge 兜底(会引入合并提交)。`);
145
146
  } else if (diag.status === 'diverged') {
146
147
  console.log(`⚠️ base 同步检测:与 origin/${b} 分叉(领先 ${diag.ahead} / 落后 ${diag.behind},base ${short})`);
147
148
  console.log(` apply 回 main 大概率冲突。建议先在主仓库对齐(rebase 或 merge origin/${b}),`);
@@ -170,11 +171,12 @@ function sleepMs(ms) {
170
171
  export function computeBaselineHash(cwd) {
171
172
  // 排除非交付物的元数据/文档 churn,避免多操作者仓库里别人改这些 → 整树 hash 变 → apply 误阻断
172
173
  // (execute 自身也会改 .sillyspec/docs.sillyspec,否则每个 execute 收尾都因自身改动 BLOCKED)。
173
- // 安全:coarse hash 放过这些不丢真冲突——applyWorktree step5a(未提交∩changedFiles)+step5b(HEAD blob)
174
- // 对交付文件做精确 per-file 校验,不读这里的 exclude。
174
+ // 安全:coarse hash 只挡「未提交」dirty(git apply --3way 危险区,实测必拦);真冲突由
175
+ // applyWorktree step5a(未提交∩changedFiles 精确点名)+step7(--3way 冲突回滚)兜底,
176
+ // 「已提交」推进不再拦(step5b 已放宽交 --3way 自动三路合并),不读这里的 exclude。
175
177
  // - .sillyspec/:brainstorm/plan 蓝图 + runtime 产物
176
178
  // - .claude/:agent 配置/skills/CLAUDE.md(多操作者 agent 指引 churn)
177
- // - docs/:文档(非代码交付物;deliverable 文档冲突由 step5a/5b 兜底)
179
+ // - docs/:文档(非代码交付物)
178
180
  // - CLAUDE.md:根 agent 指引(多操作者常改)
179
181
  // 必须和 applyWorktree step 4.5 (worktree-apply.js) 使用相同的排除规则。
180
182
  const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
@@ -247,15 +249,16 @@ export class WorktreeManager {
247
249
 
248
250
  let root = this.cwd;
249
251
  try {
250
- // git-common-dir 在主仓库内 = <main>/.git
251
- // 在 linked worktree 内 = <main>/.git(git 共享 .git 目录)
252
+ // git-common-dir 在主仓库内 = <main>/.git,在 linked worktree 内 = <main>/.git(git 共享 .git 目录)。
253
+ // 但 git 可能返回**相对路径**(如 `.git`)。必须相对 this.cwd 绝对化,否则下面的
254
+ // existsSync/statSync 会相对 process.cwd() 解析——当 process.cwd 是另一个 git 仓库
255
+ // (未 chdir 的脚本、或 CLI 在别处跑)时,worktreeBase 会错解析到 process.cwd 仓库,
256
+ // getMeta 读错位置返回 null。resolve() 对绝对参数原样返回、对相对参数相对 this.cwd 解析,两者皆稳。
252
257
  const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
253
- // commonDir 应该是 <main-repo>/.git
254
- // dirname(commonDir) = <main-repo>
255
- if (commonDir && existsSync(commonDir)) {
256
- const st = statSync(commonDir);
257
- if (st.isDirectory()) {
258
- root = dirname(commonDir);
258
+ if (commonDir) {
259
+ const absCommonDir = resolve(this.cwd, commonDir);
260
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
261
+ root = dirname(absCommonDir);
259
262
  }
260
263
  }
261
264
  } catch (e) {
@@ -989,7 +992,12 @@ export class WorktreeManager {
989
992
  }
990
993
 
991
994
  /**
992
- * 检查 worktree 是否有未 apply 到主工作区的变更
995
+ * 检查 worktree 是否有尚未落到主工作区的交付变更。
996
+ *
997
+ * 语义:worktree 相对 baseline 的交付变更(tracked + untracked,排除 .sillyspec//meta.json)里,
998
+ * 哪些还没 byte-identical 出现在主工作区 HEAD。全部已在 main HEAD(含 cherry-pick/rebase/merge/apply
999
+ * 落地)→ hasChanges:false(调用方可安全 cleanup);否则 hasChanges:true(保留 worktree)。
1000
+ * 检测失败/拿不准 → 保守 hasChanges:true(防误删未落代码)。
993
1001
  * @param {string} changeName
994
1002
  * @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
995
1003
  */
@@ -1007,36 +1015,102 @@ export class WorktreeManager {
1007
1015
  if (meta.mode === 'in-place-fallback') {
1008
1016
  return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
1009
1017
  }
1018
+ // native-worktree 是用户外部隔离环境,不纳入"未应用"判定(与 execute 完成路径一致)
1019
+ if (meta.mode === 'native-worktree') {
1020
+ return { hasChanges: false, changedFiles: [], reason: 'native-worktree (external)' };
1021
+ }
1010
1022
 
1011
1023
  const diffBase = meta.baselineCommit || meta.baseHash;
1012
1024
  if (!diffBase) {
1013
1025
  return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
1014
1026
  }
1015
1027
 
1028
+ const isDeliverable = f => f && !f.startsWith('.sillyspec/') && f !== 'meta.json';
1016
1029
  try {
1017
- // tracked 文件变更
1018
- const statusRaw = gitQuiet(worktreePath, `diff --name-status ${diffBase}`) || '';
1019
- const statusFiles = new Set();
1020
- if (statusRaw) {
1021
- for (const line of statusRaw.split('\n').filter(Boolean)) {
1022
- const parts = line.split('\t');
1023
- if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
1024
- if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
1030
+ // 1) 候选交付变更(worktree 工作区相对 diffBase)。--no-renames:rename 退化成 D+A,两侧文件都进集
1031
+ const tracked = (gitQuiet(worktreePath, `diff --no-renames --name-only ${diffBase}`) || '')
1032
+ .split('\n').filter(Boolean).filter(isDeliverable);
1033
+ const untracked = (gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '')
1034
+ .split('\n').filter(Boolean).filter(isDeliverable);
1035
+
1036
+ if (tracked.length === 0 && untracked.length === 0) {
1037
+ return { hasChanges: false, changedFiles: [], reason: 'no changes in worktree' };
1038
+ }
1039
+
1040
+ // 2) 候选集中尚未落到主工作区 HEAD 的子集
1041
+ const pending = this._changesAlreadyOnMain(worktreePath, tracked, untracked);
1042
+ if (pending.length === 0) {
1043
+ return { hasChanges: false, changedFiles: [], reason: 'all changes already on main HEAD' };
1044
+ }
1045
+ return { hasChanges: true, changedFiles: pending };
1046
+ } catch (e) {
1047
+ // 检测失败时保守处理:视为有变更,保留 worktree
1048
+ return { hasChanges: true, changedFiles: [], reason: `check failed: ${e.message}` };
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * 判定候选变更里哪些还没到主工作区 HEAD。
1054
+ * - tracked:`git -C worktree diff --no-renames --name-only <mainHead> -- <files>`,
1055
+ * 比较 worktree 工作区(含未提交)vs main HEAD,限定到候选集;空 = 已在 main HEAD。
1056
+ * - untracked:worktree `hash-object`(默认带 filter,与 tree blob 同口径)vs main `ls-tree HEAD` blob;
1057
+ * 不等 = 该新文件未在 main HEAD。HEAD-only,不查 main 工作区未提交副本(防误删)。
1058
+ * 三种 mode 共享主 repo 对象库,mainHead 可在 worktree cwd 解析。
1059
+ * @private
1060
+ * @param {string} worktreePath
1061
+ * @param {string[]} trackedFiles
1062
+ * @param {string[]} untrackedFiles
1063
+ * @returns {string[]} 尚未落到 main HEAD 的文件
1064
+ */
1065
+ _changesAlreadyOnMain(worktreePath, trackedFiles, untrackedFiles) {
1066
+ const mainHead = git(this.cwd, 'rev-parse HEAD'); // 失败即抛 → 外层 catch fail-safe
1067
+ const pending = [];
1068
+
1069
+ if (trackedFiles.length > 0) {
1070
+ const diverged = (gitQuiet(worktreePath,
1071
+ `diff --no-renames --name-only ${mainHead} -- ${trackedFiles.join(' ')}`) || '')
1072
+ .split('\n').filter(Boolean);
1073
+ pending.push(...diverged);
1074
+ }
1075
+
1076
+ if (untrackedFiles.length > 0) {
1077
+ // hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整命令失败 → gitQuiet 返回 null
1078
+ const wtHashes = (gitQuiet(worktreePath, `hash-object -- ${untrackedFiles.join(' ')}`) || '')
1079
+ .split('\n');
1080
+ const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
1081
+ for (let i = 0; i < untrackedFiles.length; i++) {
1082
+ // wtHashes[i] 缺失(命令失败/行数不齐)→ 视为未应用,保守保留
1083
+ if (wtHashes[i] !== (mainBlobs.get(untrackedFiles[i]) ?? null)) {
1084
+ pending.push(untrackedFiles[i]);
1025
1085
  }
1026
1086
  }
1087
+ }
1027
1088
 
1028
- // untracked 文件
1029
- const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '';
1030
- const untrackedFiles = untrackedRaw
1031
- ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
1032
- : [];
1089
+ return [...new Set(pending)];
1090
+ }
1033
1091
 
1034
- const changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
1035
- return { hasChanges: changedFiles.length > 0, changedFiles };
1036
- } catch (e) {
1037
- // 检测失败时保守处理:视为有变更
1038
- return { hasChanges: true, changedFiles: [], reason: `diff failed: ${e.message}` };
1092
+ /**
1093
+ * 一次 `git ls-tree <treeish> -- <files>` → Map<path, blobHash>(仅 tree 中存在者)。
1094
+ * 与 worktree-apply.js 的 getBlobHashMap 同逻辑;此处内联私有,避免反向 import 循环依赖。
1095
+ * @private
1096
+ * @param {string} cwd
1097
+ * @param {string} treeish
1098
+ * @param {string[]} files
1099
+ * @returns {Map<string, string>}
1100
+ */
1101
+ _lsTreeBlobs(cwd, treeish, files) {
1102
+ const map = new Map();
1103
+ if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
1104
+ const raw = gitQuiet(cwd, `ls-tree ${treeish} -- ${files.join(' ')}`);
1105
+ if (!raw) return map;
1106
+ for (const line of raw.split('\n')) {
1107
+ if (!line) continue;
1108
+ const tab = line.indexOf('\t');
1109
+ if (tab === -1) continue;
1110
+ const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
1111
+ if (hash) map.set(line.slice(tab + 1), hash);
1039
1112
  }
1113
+ return map;
1040
1114
  }
1041
1115
 
1042
1116
  /**
@@ -1,5 +1,5 @@
1
1
  ### 自动探针(必须先执行)
2
- 在检查前,依次运行以下三个探针,将结果作为验证输入:
2
+ 在检查前,依次运行以下六个探针,将结果作为验证输入:
3
3
 
4
4
  **探针 1:未实现标记扫描(仅变更文件)**
5
5
  只扫描本次变更涉及的文件,不要全项目扫描——历史 TODO 与本次变更无关,徒增噪音与 token。变更文件 = design.md「文件变更清单」列出的源码文件(你已在「加载规范」步骤读取 design.md,glob 路径需展开为具体文件):
@@ -18,7 +18,7 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
18
18
 
19
19
  **探针 3:验收标准测试覆盖探针**
20
20
  1. 读取变更目录下的 tasks.md,提取所有 checkbox 任务
21
- 2. 对每个 task,检查对应模块目录下是否存在测试文件(*test*、*spec*、*Test*、*Spec*)
21
+ 2. 对每个 task,**递归**检查对应模块目录及其**所有子目录**下是否存在测试文件(*test*、*spec*、*Test*、*Spec*)——包括模块内 co-located 的 tests/ 目录(如 `backend/app/modules/*/tests/`),**不要只看顶层 `tests/`**,否则会把测试就在模块内部的项目误判为“无测试”
22
22
  3. 没有测试文件的 task 标记为 ⚠️ 缺少测试
23
23
 
24
24
  **探针 4:决策追踪覆盖探针(如存在 decisions.md)**
@@ -51,3 +51,14 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
51
51
  | ❌ missing | GET /api/ppm/project-plan/{param}/plan-nodes | — | frontend/src/lib/ppm/plan.ts |
52
52
 
53
53
  如果发现 Missing backend endpoint,必须在验证报告中标记为 ❌ contract gap。
54
+
55
+ **探针 6:代码删除对账(切斯特顿栅栏护栏)**
56
+ 静默删除代码是 verify 的盲区——agent 删一段它看不懂的旧代码,只要路径合规、不碰风险关键词,其他探针都不会响。用 git 事实客观对账,不要凭记忆:
57
+ 1. 运行 `git diff --name-status HEAD`(本次变更已 apply 到主仓、未 commit,删除的文件在工作树消失但仍在 HEAD,显示 `D`)
58
+ 2. 筛出状态以 `D` 开头的行(删除);`R`/`C` 开头的旧路径等价删除
59
+ 3. 对每个删除文件,核对 design.md「文件变更清单」声明的操作:
60
+ - design 声明「删除」→ ✅ 合规(预期删除)
61
+ - design 声明「新增/修改」却整文件删除 → ❌ 高风险(声明与事实矛盾)
62
+ - design 清单未列出 → ⚠️ 未声明删除
63
+ 4. 排除 `.sillyspec/` 与 `meta.json`(文档 churn 不算删除信号)
64
+ 5. verify --done 时 CLI 会用同一 git 事实独立复核,谎报无效——务必如实记录