sillyspec 3.25.11 → 3.26.0

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.
@@ -431,6 +431,9 @@ function gitChangedFiles(cwd) {
431
431
  * 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
432
432
  * 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
433
433
  * 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
434
+ * ⚠️ baseline checkpoint 修复:优先用 baselineCommit/actualBaseHash(baseline overlay
435
+ * 之后),否则 baseHash(pre-baseline)会把 baseline 同步的跨模块文件算进 verify diff
436
+ * → 命中无关模块(如 ppm 变更误测 daemon/frontend)。与 task-review.js 同源。
434
437
  * 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
435
438
  *
436
439
  * @param {string} cwd - 项目根目录(主仓)
@@ -443,11 +446,13 @@ export function resolveVerifyChangedFiles(cwd, changeName) {
443
446
  if (existsSync(metaPath)) {
444
447
  let meta = null
445
448
  try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
446
- if (meta?.baseHash) {
449
+ // 优先 baselineCommit/actualBaseHash(baseline checkpoint 之后),回退 baseHash
450
+ const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
451
+ if (diffBase) {
447
452
  const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
448
453
  ? meta.worktreePath
449
454
  : cwd
450
- const files = runGitDiffNameOnly(gitDir, `${meta.baseHash}..HEAD`)
455
+ const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
451
456
  if (files !== null) return files
452
457
  // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
453
458
  }
@@ -45,6 +45,82 @@ export function filterDeliverableFiles(files) {
45
45
  );
46
46
  }
47
47
 
48
+ /**
49
+ * dirty 拦截触发时生成「逐文件 rescue 指令」——旁路 git apply 的安全逃生通道(design §接口定义 generateRescueCommands)。
50
+ *
51
+ * 逐文件四分类(按优先级判定,命中即 continue):
52
+ * 1. DELETE ∈ deletedFiles → rm "<projectRoot>/<f>"(不计 cpFileCount 也不计 excludedCount)
53
+ * 2. EXCLUDE-DIRTY ∈ dirtyFiles → warning(cp 会覆盖 main 工作区未提交改动),excludedCount++
54
+ * 3. EXCLUDE-MISMATCH ∈ hashMismatchFiles → warning(cp 会回退主干已提交推进改动),excludedCount++
55
+ * 4. SAFE-CP 其余(main 该文件干净)→ cp "<worktreePath>/<f>" "<projectRoot>/<f>",cpFileCount++
56
+ *
57
+ * 路径用 path.join 拼接后 replace 反斜杠为正斜杠(Git Bash 兼容,规则 13)。
58
+ *
59
+ * 纯函数:无 git/fs 调用无副作用,不读 meta/worktree 状态,不 mutate 入参;
60
+ * 所有 dirtyFiles/hashMismatchFiles/deletedFiles 集合均由调用方(step4.5/5a/assess)算好传入。
61
+ *
62
+ * @param {object} args
63
+ * @param {string[]} args.changedFiles filterDeliverableFiles 后的实际变更路径
64
+ * @param {Set<string>|string[]} args.dirtyFiles main 工作区未提交文件集(统一口径:tracked-modified ∪ untracked,排 .sillyspec/+meta.json)
65
+ * @param {string[]} args.hashMismatchFiles 主干已提交推进文件(step3.5 前移算,依赖 baseHash/HEAD blob 对比)
66
+ * @param {string[]} [args.deletedFiles=[]] worktree 删除文件(git diff name-status D)
67
+ * @param {string} args.worktreePath
68
+ * @param {string} args.projectRoot
69
+ * @returns {{
70
+ * commands: string[], // 可复制粘贴 shell 命令(cp/rm),正斜杠路径
71
+ * warnings: string[], // 被排除文件(dirty/mismatch)的风险标注
72
+ * cpFileCount: number, // SAFE-CP 文件数
73
+ * excludedCount: number // EXCLUDE-DIRTY + EXCLUDE-MISMATCH 数
74
+ * }}
75
+ */
76
+ export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchFiles, deletedFiles = [], worktreePath, projectRoot }) {
77
+ // 集合归一:dirtyFiles 接受 Set 或数组(调用方 step4.5/5a 可能传任一形态),其余统一数组→Set
78
+ const dirtySet = dirtyFiles instanceof Set ? dirtyFiles : new Set(dirtyFiles || []);
79
+ const mismatchSet = new Set(hashMismatchFiles || []);
80
+ const deletedSet = new Set(deletedFiles || []);
81
+
82
+ const commands = [];
83
+ const warnings = [];
84
+ let cpFileCount = 0;
85
+ let excludedCount = 0;
86
+
87
+ for (const f of changedFiles) {
88
+ // 优先级 1→2→3→4,命中即 continue(DELETE 最先判)
89
+ if (deletedSet.has(f)) {
90
+ commands.push(`rm "${join(projectRoot, f).replace(/\\/g, '/')}"`);
91
+ continue;
92
+ }
93
+ if (dirtySet.has(f)) {
94
+ warnings.push(`跳过 ${f}:EXCLUDE-DIRTY(main 工作区该文件有未提交改动,cp 会覆盖)`);
95
+ excludedCount++;
96
+ continue;
97
+ }
98
+ if (mismatchSet.has(f)) {
99
+ warnings.push(`跳过 ${f}:EXCLUDE-MISMATCH(主干已提交推进该文件,cp 会回退他人改动;请先 commit main 未提交改动再正常 apply 走 --3way 合并)`);
100
+ excludedCount++;
101
+ continue;
102
+ }
103
+ commands.push(`cp "${join(worktreePath, f).replace(/\\/g, '/')}" "${join(projectRoot, f).replace(/\\/g, '/')}"`);
104
+ cpFileCount++;
105
+ }
106
+
107
+ return { commands, warnings, cpFileCount, excludedCount };
108
+ }
109
+
110
+ /**
111
+ * 算 rescue 用的统一 dirtyFiles 口径:main 工作区所有未提交文件 = tracked-modified(git diff HEAD)
112
+ * ∪ untracked(git ls-files others),再用 filterDeliverableFiles 过滤(排 .sillyspec/changes/.runtime/quicklog
113
+ * + meta.json,保留 .sillyspec/docs/——对齐 changedFiles 同宇宙,闭合 design Grill 的 .sillyspec/docs/ 残留 gap)。
114
+ * 注意:此口径与 step4.5「是否触发拦截」的判定口径(排除 .claude/docs/CLAUDE.md)不同,二者不混用(design §dirtyFiles 口径统一)。
115
+ * @param {string} projectRoot
116
+ * @returns {string[]} 未提交文件(filterDeliverableFiles 过滤后)
117
+ */
118
+ export function computeRescueDirtyFiles(projectRoot) {
119
+ const tracked = (gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean);
120
+ const untracked = (gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean);
121
+ return filterDeliverableFiles([...new Set([...tracked, ...untracked])]);
122
+ }
123
+
48
124
  /**
49
125
  * 校验变更文件是否都在 design.md 清单内——容差匹配(与 plan-postcheck validateDesignFileCoverage
50
126
  * 同语义):design 清单写 glob(双星通配)或目录前缀(如 src/ 子树)也能覆盖 git diff 出的具体路径,
@@ -153,6 +229,8 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
153
229
  changedFiles: [],
154
230
  extraFiles: [],
155
231
  hashMismatchFiles: [],
232
+ deletedFiles: [],
233
+ rescueCommands: null,
156
234
  patchPath: null,
157
235
  errors: [],
158
236
  merged: false,
@@ -177,6 +255,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
177
255
  // worktree 内修改可能没有 commit,用 git diff <baseHash>(比较 baseHash 到工作区内容)
178
256
  // 同时检测 untracked 新文件(git diff 不包含 untracked)
179
257
  let changedFiles;
258
+ const deletedFiles = [];
180
259
  try {
181
260
  // 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
182
261
  const statusRaw = git(worktreePath, ['diff', '--name-status', diffBase]);
@@ -187,6 +266,8 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
187
266
  // R100 old.txt new.txt → 提取两个文件
188
267
  if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
189
268
  if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
269
+ // 删除文件(status D / D100)→ 收集到 deletedFiles(rescue DELETE 分类用,design §逐文件分类)
270
+ if (parts[0].startsWith('D')) deletedFiles.push(parts[parts.length - 1]);
190
271
  }
191
272
  }
192
273
 
@@ -204,6 +285,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
204
285
  }
205
286
 
206
287
  result.changedFiles = changedFiles;
288
+ result.deletedFiles = deletedFiles;
207
289
 
208
290
  if (changedFiles.length === 0) {
209
291
  // 没有变更
@@ -218,6 +300,22 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
218
300
  const allowSet = resolveApplyAllowSet(projectRoot, changeName);
219
301
  const hasAllowList = allowSet.size > 0;
220
302
 
303
+ // --- 3.5 主干已提交推进检测:hashMismatch 前移(Grill P0 修复,design §step 顺序修正)---
304
+ // 原在 step5b,但 step4.5/5a dirty 拦截短路在 step5b 之前,致 rescue 拿不到 hashMismatchFiles
305
+ // → EXCLUDE-MISMATCH 失效 → cp 覆盖主干已提交推进。前移到 step4.5 之前(仅依赖 baseHash/HEAD blob
306
+ // 对比,无 dirty 依赖;allowSet/changedFiles/baseHash 均在本步之前可得,前移安全)。
307
+ // 仅记录不拦截(交 step7 --3way 实测,真重叠回滚提示 --merge)。
308
+ const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
309
+ const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
310
+ const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
311
+ for (const f of targetFiles) {
312
+ const wtBlob = wtHashMap.get(f) ?? null;
313
+ const mainBlob = mainHashMap.get(f) ?? null;
314
+ if (wtBlob === null && mainBlob === null) continue;
315
+ if (wtBlob === mainBlob) continue;
316
+ result.hashMismatchFiles.push(f);
317
+ }
318
+
221
319
  // --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
222
320
  if (hasAllowList) {
223
321
  const violations = classifyAllowListViolations(changedFiles, allowSet);
@@ -242,7 +340,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
242
340
  // --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
243
341
  // 分工:4.5(排除规则下当前 dirty)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
244
342
  // 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
245
- // 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
343
+ // 哪怕脏文件与 patch 不重叠)——这是 Windows/autocrlf 的 CRLF 副作用(非 git 本质限制:
344
+ // autocrlf off 时 --3way 在不重叠 dirty 树能 Applied cleanly,on 时才报 does not match index)。
345
+ // 但仓库 CRLF 混用 + 规则13 要求 Windows 兼容,fail-loud 在 Windows 仍有据,故此处友好拦截,引导用户先 commit/stash。
246
346
  // 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
247
347
  // 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
248
348
  // 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
@@ -263,10 +363,26 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
263
363
  ((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
264
364
  .concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
265
365
  )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
366
+ // rescue:dirty 拦截触发时生成逐文件 cp 安全子集(旁路 git apply),写 result.rescueCommands 供 assess/打印器消费
367
+ const rescueDirty = computeRescueDirtyFiles(projectRoot);
368
+ result.rescueCommands = generateRescueCommands({
369
+ changedFiles,
370
+ dirtyFiles: rescueDirty,
371
+ hashMismatchFiles: result.hashMismatchFiles,
372
+ deletedFiles: result.deletedFiles,
373
+ worktreePath,
374
+ projectRoot,
375
+ });
266
376
  result.errors.push(
267
377
  `主工作区有未提交的改动,git apply 无法安全应用。\n` +
268
378
  (dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
269
- `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n`
379
+ `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
380
+ (result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
381
+ ? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
382
+ result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
383
+ (result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
384
+ `\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
385
+ : '')
270
386
  );
271
387
  if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
272
388
  }
@@ -280,34 +396,25 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
280
396
  // 如果脏文件和本次 apply 的文件有交集 → 报错
281
397
  const conflictDirty = mainDirtyFiles.filter(f => changedFiles.includes(f));
282
398
  if (conflictDirty.length > 0) {
399
+ const rescueDirty5a = computeRescueDirtyFiles(projectRoot);
400
+ result.rescueCommands = generateRescueCommands({
401
+ changedFiles, dirtyFiles: rescueDirty5a, hashMismatchFiles: result.hashMismatchFiles,
402
+ deletedFiles: result.deletedFiles, worktreePath, projectRoot,
403
+ });
283
404
  result.errors.push(
284
- `主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
405
+ `主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。` +
406
+ (result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
407
+ ? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
408
+ result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
409
+ (result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
410
+ `\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
411
+ : '')
285
412
  );
286
413
  if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
287
414
  }
288
415
  }
289
416
 
290
- // 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
291
- // 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
292
- // 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
293
- // 放宽(2026-07):blob 不一致不再 BLOCKED——它意味着主干「已提交」推进改了同文件,
294
- // 而 git apply --3way 能自动三路合并这种场景(不同区域直接合,同区域留冲突标记)。
295
- // 故仅记录为风险提示(hashMismatchFiles → assess WARNING / summary 展示),放行交 step7 --3way 实测。
296
- // 真重叠时 --3way 冲突,由 step7 回滚并提示 --merge 兜底。
297
- const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
298
- const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
299
- const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
300
- for (const f of targetFiles) {
301
- const wtBlob = wtHashMap.get(f) ?? null;
302
- const mainBlob = mainHashMap.get(f) ?? null;
303
-
304
- // 两者都为 null(文件在 base 时不存在)→ OK
305
- if (wtBlob === null && mainBlob === null) continue;
306
- // 两者一致 → OK
307
- if (wtBlob === mainBlob) continue;
308
- // 不一致 → 主干已提交推进改了此文件,记风险提示(不拦截,交 --3way)
309
- result.hashMismatchFiles.push(f);
310
- }
417
+ // 5b hashMismatch 计算已前移到 step3.5(Grill P0),见上
311
418
 
312
419
  // --- 6. checkOnly 模式:到此返回 ---
313
420
  if (checkOnly) {
@@ -568,14 +675,15 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
568
675
  if (changedFiles.length === 0) {
569
676
  // 无变更:若仍有 Gate 错误(如主区 dirty)则 BLOCKED,否则 SAFE
570
677
  if (reasons.length > 0) {
571
- return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 } };
678
+ return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 }, rescueCommands: checkResult.rescueCommands || null };
572
679
  }
573
680
  return {
574
681
  decision: 'SAFE',
575
682
  changedFiles: [],
576
683
  reasons: ['无变更需要应用'],
577
684
  warnings: [],
578
- stats: { additions: 0, deletions: 0 }
685
+ stats: { additions: 0, deletions: 0 },
686
+ rescueCommands: checkResult.rescueCommands || null
579
687
  };
580
688
  }
581
689
 
@@ -668,7 +776,7 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
668
776
  decision = 'SAFE';
669
777
  }
670
778
 
671
- return { decision, changedFiles, reasons, warnings, stats: { additions, deletions } };
779
+ return { decision, changedFiles, reasons, warnings, stats: { additions, deletions }, rescueCommands: checkResult.rescueCommands || null };
672
780
  }
673
781
 
674
782
  /**