sillyspec 3.25.3 → 3.25.5

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.
@@ -211,11 +211,115 @@ export function aggregateStatus(results) {
211
211
  return 'failed'
212
212
  }
213
213
 
214
+ /**
215
+ * 从 local.yaml 提取 known_failures 声明(预存失败豁免清单,坑 verify-worktree-... 修复方向 2)。
216
+ * 支持块式与流式两种写法:
217
+ * known_failures:
218
+ * - "tests/test_ppm.py::test_legacy"
219
+ * - app/modules/plan/test_old
220
+ * 或 known_failures: [a, b]
221
+ * 每条作为子串(大小写不敏感)匹配测试输出中的失败行。
222
+ *
223
+ * @param {string} yamlText
224
+ * @returns {string[]} 模式列表;无声明返回 []
225
+ */
226
+ export function extractKnownFailures(yamlText) {
227
+ if (!yamlText) return []
228
+ const inline = yamlText.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
229
+ if (inline) {
230
+ return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
231
+ }
232
+ const block = yamlText.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
233
+ if (block) {
234
+ return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
235
+ .map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
236
+ .filter(Boolean)
237
+ }
238
+ return []
239
+ }
240
+
241
+ // 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
242
+ // 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
243
+ // "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
244
+ const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
245
+ // 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、裸 "N failed/passed"、go "FAILED in Ns" 等。
246
+ const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|failed\s+in\s+\d)/i
247
+
248
+ /**
249
+ * 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
250
+ * @param {string} output
251
+ * @param {string[]} knownFailures 子串模式列表(大小写不敏感)
252
+ * @returns {{ failureLines: string[], exempted: string[], remaining: string[] }}
253
+ */
254
+ export function partitionFailures(output, knownFailures) {
255
+ const lines = String(output || '').split(/\r?\n/)
256
+ const failureLines = lines.filter(l => PER_TEST_FAIL_RE.test(l) && !SUMMARY_LINE_RE.test(l))
257
+ const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
258
+ const exempted = []
259
+ const remaining = []
260
+ for (const l of failureLines) {
261
+ const ll = l.toLowerCase()
262
+ if (pats.some(p => ll.includes(p))) exempted.push(l)
263
+ else remaining.push(l)
264
+ }
265
+ return { failureLines, exempted, remaining }
266
+ }
267
+
268
+ /**
269
+ * 结合 known_failures 判定单次测试运行的状态(fail-safe)。
270
+ * - exitCode 0 → passed
271
+ * - exitCode≠0 且无 known_failures → failed(原行为)
272
+ * - exitCode≠0 且有 known_failures:
273
+ * 检测到失败行且全部命中豁免 → passed(披露:请人工复核清单是否过宽)
274
+ * 有未豁免失败行 / 检测不到失败行(保守)→ failed
275
+ * fail-safe:检测不到失败行绝不自动 pass(避免解析盲区导致假 PASS)。
276
+ *
277
+ * @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number }}
278
+ */
279
+ export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
280
+ if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0 }
281
+ if (!knownFailures || knownFailures.length === 0) {
282
+ return { status: 'failed', reason: baseReason, exemptedCount: 0 }
283
+ }
284
+ const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures)
285
+ if (failureLines.length > 0 && remaining.length === 0) {
286
+ return {
287
+ status: 'passed',
288
+ reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
289
+ exemptedCount: exempted.length,
290
+ }
291
+ }
292
+ const detail = remaining.length > 0
293
+ ? `${remaining.length} 个失败行未命中 known_failures`
294
+ : '失败输出未检测到可豁免的失败行(保守判 fail)'
295
+ return {
296
+ status: 'failed',
297
+ reason: baseReason ? `${baseReason}(${detail})` : detail,
298
+ exemptedCount: exempted.length,
299
+ }
300
+ }
301
+
302
+ /**
303
+ * 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
304
+ * - module + 命中模块 → 'module-subset'
305
+ * - module + 0 命中 → 'module-zero-hit-skip'(不静默回退注定超时/预存失败的全量)
306
+ * - 其余(full / module 无块 / module git 不可用 hitCount=-1)→ 'full'
307
+ * @returns {'module-subset'|'module-zero-hit-skip'|'full'}
308
+ */
309
+ export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
310
+ if (strategy === 'module' && modulesPresent) {
311
+ if (hitCount > 0) return 'module-subset'
312
+ if (hitCount === 0) return 'module-zero-hit-skip'
313
+ return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
314
+ }
315
+ return 'full'
316
+ }
317
+
214
318
  /**
215
319
  * 跑单个模块的 test 命令(串行调用方逐个调用)。
216
320
  * @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
217
321
  */
218
- function runOneModule(name, testCommand, cwd) {
322
+ function runOneModule(name, testCommand, cwd, knownFailures = []) {
219
323
  const startedAt = Date.now()
220
324
  let exitCode = 0
221
325
  let output = ''
@@ -237,26 +341,27 @@ function runOneModule(name, testCommand, cwd) {
237
341
  }
238
342
  const durationMs = Date.now() - startedAt
239
343
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
344
+ const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
240
345
  return {
241
346
  name,
242
- status: exitCode === 0 ? 'passed' : 'failed',
347
+ status: judged.status,
243
348
  command: testCommand,
244
349
  exitCode,
245
350
  durationMs,
246
351
  outputTail,
247
- reason,
352
+ reason: judged.reason,
353
+ exemptedCount: judged.exemptedCount,
248
354
  }
249
355
  }
250
356
 
251
357
  /**
252
- * 取 git 变更文件列表(worktree unstaged + staged,相对仓库根)。
253
- * 务实策略:先取 unstaged working tree 改动(`git diff --name-only HEAD`),
254
- * 它同时覆盖已暂存与未暂存改动(相对 HEAD),最适合 worktree 内尚未 commit 的场景。
255
- * git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
358
+ * 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
359
+ * git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
256
360
  */
257
- function gitChangedFiles(cwd) {
361
+ function runGitDiffNameOnly(cwd, refSpec) {
258
362
  try {
259
- const out = execSync('git diff --name-only HEAD', {
363
+ const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
364
+ const out = execSync(cmd, {
260
365
  cwd,
261
366
  encoding: 'utf8',
262
367
  timeout: 30 * 1000,
@@ -264,19 +369,60 @@ function gitChangedFiles(cwd) {
264
369
  })
265
370
  return out.split('\n').map(l => l.trim()).filter(Boolean)
266
371
  } catch {
267
- // HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
268
- try {
269
- const out = execSync('git diff --name-only', {
270
- cwd,
271
- encoding: 'utf8',
272
- timeout: 30 * 1000,
273
- stdio: ['ignore', 'pipe', 'pipe'],
274
- })
275
- return out.split('\n').map(l => l.trim()).filter(Boolean)
276
- } catch {
277
- return null
372
+ return null
373
+ }
374
+ }
375
+
376
+ /**
377
+ * 取主工作区 git 变更文件列表(unstaged + staged,相对仓库根)。
378
+ * `git diff --name-only HEAD` 同时覆盖已暂存与未暂存改动(相对 HEAD),
379
+ * 最适合 brownfield(apply 后未 commit / in-place 改动)场景。
380
+ * git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
381
+ *
382
+ * 注意:worktree 隔离模式下主仓只剩 .sillyspec/ 文档改动、代码在 worktree,
383
+ * 本函数看不到——runVerifyTestCheck 用 resolveVerifyChangedFiles 统一处理。
384
+ */
385
+ function gitChangedFiles(cwd) {
386
+ const head = runGitDiffNameOnly(cwd, 'HEAD')
387
+ if (head !== null) return head
388
+ // HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
389
+ return runGitDiffNameOnly(cwd, '')
390
+ }
391
+
392
+ /**
393
+ * 解析 verify 对账用的变更文件集(worktree-aware)。
394
+ *
395
+ * worktree 隔离模式下 execute 的代码改动落在 worktree,主仓工作区只剩
396
+ * .sillyspec/ 文档改动。若仍用主仓 `git diff --name-only HEAD`,hitCount=0
397
+ * → 回退注定超时且含预存失败的全量 commands.test → verify 完成被阻断
398
+ * (坑 verify-worktree-mode-test-reconciliation-fallback-full)。
399
+ *
400
+ * 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
401
+ * 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
402
+ * 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
403
+ * 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
404
+ *
405
+ * @param {string} cwd - 项目根目录(主仓)
406
+ * @param {string|null} changeName
407
+ * @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
408
+ */
409
+ export function resolveVerifyChangedFiles(cwd, changeName) {
410
+ if (changeName) {
411
+ const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
412
+ if (existsSync(metaPath)) {
413
+ let meta = null
414
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
415
+ if (meta?.baseHash) {
416
+ const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
417
+ ? meta.worktreePath
418
+ : cwd
419
+ const files = runGitDiffNameOnly(gitDir, `${meta.baseHash}..HEAD`)
420
+ if (files !== null) return files
421
+ // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
422
+ }
278
423
  }
279
424
  }
425
+ return gitChangedFiles(cwd)
280
426
  }
281
427
 
282
428
  /**
@@ -301,41 +447,57 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
301
447
  const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
302
448
 
303
449
  const strategy = extractTestStrategy(yamlText)
450
+ const knownFailures = extractKnownFailures(yamlText)
304
451
 
305
- // —— 模块子集路径(test_strategy: module)——
306
- // 算出 modulesPresent / hitCount 供 fallback hint 判定(computeFullFallbackReason);
307
- // 命中即走子集。gitChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
452
+ // —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
453
+ // resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
308
454
  let modulesPresent = false
309
455
  let hitCount = 0
456
+ let hits = []
310
457
  if (strategy === 'module') {
311
458
  const modules = extractModules(yamlText)
312
459
  if (modules) {
313
460
  modulesPresent = true
314
- const changedFiles = gitChangedFiles(cwd)
461
+ const changedFiles = resolveVerifyChangedFiles(cwd, changeName)
315
462
  if (changedFiles === null) {
316
463
  hitCount = -1 // git 不可用 / 非仓库
317
464
  } else {
318
- const hits = pickHitModules(changedFiles, modules)
465
+ hits = pickHitModules(changedFiles, modules)
319
466
  hitCount = hits.length
320
- if (hitCount > 0) {
321
- return runModuleSubset({ cwd, specBase, changeName, hits })
322
- }
323
- // 有 modules 配置但无命中 → fallback commands.test(D-002@v1 向后兼容)
324
467
  }
325
468
  }
326
- // test_strategy:module 但无 modules 配置 → fallback commands.test(brownfield 友好)
327
469
  }
328
470
 
329
- // —— 全量路径(默认 full / fallback)——
471
+ const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
472
+ if (action === 'module-subset') {
473
+ return runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
474
+ }
475
+ if (action === 'module-zero-hit-skip') {
476
+ // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
477
+ // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
478
+ return {
479
+ status: 'skipped',
480
+ command: null,
481
+ exitCode: null,
482
+ durationMs: null,
483
+ outputTail: null,
484
+ reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。',
485
+ resultPath: null,
486
+ mode: 'module-zero-hit',
487
+ fallbackReason: null,
488
+ }
489
+ }
490
+
491
+ // —— 全量路径(full / module 无块 / module git 不可用)——
330
492
  // fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
331
493
  const fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
332
- return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason })
494
+ return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
333
495
  }
334
496
 
335
497
  /**
336
498
  * 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
337
499
  */
338
- function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null }) {
500
+ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
339
501
  const command = extractTestCommand(yamlText)
340
502
 
341
503
  if (!command) {
@@ -376,16 +538,18 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
376
538
  const durationMs = Date.now() - startedAt
377
539
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
378
540
 
541
+ const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
379
542
  const result = {
380
- status: exitCode === 0 ? 'passed' : 'failed',
543
+ status: judged.status,
381
544
  command,
382
545
  exitCode,
383
546
  durationMs,
384
547
  outputTail,
385
- reason,
548
+ reason: judged.reason,
386
549
  resultPath: null,
387
550
  mode: 'full',
388
551
  fallbackReason,
552
+ exemptedCount: judged.exemptedCount,
389
553
  }
390
554
 
391
555
  writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
@@ -396,9 +560,9 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
396
560
  * 串行跑命中的模块子集,聚合结果。
397
561
  * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
398
562
  */
399
- function runModuleSubset({ cwd, specBase, changeName, hits }) {
563
+ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
400
564
  const subsetStartedAt = Date.now()
401
- const perModule = hits.map(h => runOneModule(h.name, h.test, cwd))
565
+ const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
402
566
  const status = aggregateStatus(perModule)
403
567
 
404
568
  const command = `module[${hits.map(h => h.name).join(',')}]`
@@ -423,6 +587,7 @@ function runModuleSubset({ cwd, specBase, changeName, hits }) {
423
587
  resultPath: null,
424
588
  mode: 'module-subset',
425
589
  fallbackReason: null,
590
+ exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
426
591
  }
427
592
 
428
593
  writeRunResult({
@@ -479,7 +644,12 @@ export function printVerifyTestCheck(result) {
479
644
  return
480
645
  }
481
646
  if (result.status === 'passed') {
482
- console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
647
+ if (result.exemptedCount > 0) {
648
+ console.log(`\n✅ Verify 实测通过(含 ${result.exemptedCount} 个 known_failures 豁免):\`${result.command}\` — ${result.reason}`)
649
+ console.warn(' ⚠️ 本次 PASS 依赖 known_failures 豁免清单——请人工复核清单是否过宽(避免误豁免本变更引入的真实失败)。')
650
+ } else {
651
+ console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
652
+ }
483
653
  } else {
484
654
  console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
485
655
  if (result.outputTail) {
@@ -18,7 +18,7 @@ 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, pathMatches } from './change-list.js';
22
22
 
23
23
  const CHANGES_REL = '.sillyspec/changes';
24
24
 
@@ -48,6 +48,20 @@ export function filterDeliverableFiles(files) {
48
48
  return files.filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
49
49
  }
50
50
 
51
+ /**
52
+ * 校验变更文件是否都在 design.md 清单内——容差匹配(与 plan-postcheck validateDesignFileCoverage
53
+ * 同语义):design 清单写 glob(双星通配)或目录前缀(如 src/ 子树)也能覆盖 git diff 出的具体路径,
54
+ * 避免「plan 阶段放过、apply 阶段卡死」逼用户回去补字面文件名。
55
+ *
56
+ * @param {string[]} changedFiles git diff 出的具体变更路径
57
+ * @param {Set<string>} allowSet design.md 清单项(可能含 glob / 目录前缀)
58
+ * @returns {string[]} 不被任何清单项覆盖的文件(违规项,apply 将据此 BLOCK)
59
+ */
60
+ export function classifyAllowListViolations(changedFiles, allowSet) {
61
+ const allow = [...allowSet];
62
+ return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
63
+ }
64
+
51
65
  /**
52
66
  * 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
53
67
  *
@@ -170,29 +184,30 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
170
184
 
171
185
  // --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
172
186
  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) {
187
+ const violations = classifyAllowListViolations(changedFiles, allowSet);
188
+ if (violations.length > 0) {
189
+ result.extraFiles.push(...violations);
179
190
  result.errors.push(
180
- `文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${result.extraFiles.join('\n ')}`
191
+ `文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${violations.join('\n ')}`
181
192
  );
182
193
  return result;
183
194
  }
184
195
  }
185
196
 
186
- // --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
187
- // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
197
+ // --- 4.6 显式 --merge:用户显式选择 git merge 兜底(主干已提交推进重叠时的三方合并) ---
198
+ // 触发点从「4.5 baseline 漂移自动降级」改为「用户显式 --merge flag」(D-001 保留,触发方式变化)。
199
+ // 用 --merge 时跳过未提交 dirty 拦截——merge 同样要求工作区相对干净,此处仅提示风险,真正失败由 applyByMerge 报告。
200
+ if (merge && !checkOnly) {
201
+ return applyByMerge(result, changeName, projectRoot, wm);
202
+ }
203
+
204
+ // --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
205
+ // 分工:4.5(整树 baselineHash)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
206
+ // 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
207
+ // 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
188
208
  // 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
189
209
  // 否则别人改 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 指引
210
+ // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
196
211
  if (meta.baselineHash) {
197
212
  const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
198
213
  const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
@@ -201,13 +216,15 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
201
216
  const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
202
217
  const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
203
218
  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
- }
219
+ // 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
220
+ const dirtyFiles = [...new Set(
221
+ ((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
222
+ .concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
223
+ )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
208
224
  result.errors.push(
209
- `主工作区 baseline 已变化(execute 前后不一致),不能直接 apply task.patch。\n` +
210
- `建议:重新创建 worktree,或用 --merge 降级(git merge sillyspec/${changeName} 替代 patch,会引入合并提交)。\n` +
225
+ `主工作区有未提交的改动(execute 前后 baseline 不一致),git apply 无法安全应用。\n` +
226
+ (dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
227
+ `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
211
228
  `execute 前 baseline: ${meta.baselineHash}\n` +
212
229
  `当前 baseline: ${currentHash}`
213
230
  );
@@ -233,6 +250,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
233
250
  // 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
234
251
  // 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
235
252
  // 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
253
+ // 放宽(2026-07):blob 不一致不再 BLOCKED——它意味着主干「已提交」推进改了同文件,
254
+ // 而 git apply --3way 能自动三路合并这种场景(不同区域直接合,同区域留冲突标记)。
255
+ // 故仅记录为风险提示(hashMismatchFiles → assess WARNING / summary 展示),放行交 step7 --3way 实测。
256
+ // 真重叠时 --3way 冲突,由 step7 回滚并提示 --merge 兜底。
236
257
  const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
237
258
  const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
238
259
  const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
@@ -244,17 +265,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
244
265
  if (wtBlob === null && mainBlob === null) continue;
245
266
  // 两者一致 → OK
246
267
  if (wtBlob === mainBlob) continue;
247
- // 不一致 → 主工作区已被修改
268
+ // 不一致 → 主干已提交推进改了此文件,记风险提示(不拦截,交 --3way)
248
269
  result.hashMismatchFiles.push(f);
249
270
  }
250
271
 
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
272
  // --- 6. checkOnly 模式:到此返回 ---
259
273
  if (checkOnly) {
260
274
  result.ok = true;
@@ -323,19 +337,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
323
337
 
324
338
  writeFileSync(patchPath, patchContent);
325
339
 
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
- }
340
+ // apply --check 预检失败不再拦截(--check 只测 clean apply,--3way 能处理 clean apply 失败的
341
+ // 三路合并场景——主干已提交推进时 --check 恒失败但 --3way 可合)。故跳过 --check,直接试 --3way。
342
+
343
+ // 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
344
+ // tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
345
+ // 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
346
+ const trackedPatchFiles = patchFiles.filter(f => {
347
+ // 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
348
+ return gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null ? false : true;
349
+ });
350
+ const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
333
351
 
334
- // apply --3way 正式应用
352
+ // apply --3way 正式应用(主干已提交推进时自动三路合并)
335
353
  try {
336
354
  git(projectRoot, `apply --3way ${patchPath}`);
337
355
  } catch (e) {
338
- result.errors.push(`patch apply 失败: ${e.message}`);
356
+ // --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
357
+ const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
358
+ result.errors.push(
359
+ `apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
360
+ ` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
361
+ `已回滚工作区到 apply 前状态(无半成品冲突标记)。\n` +
362
+ `可选:用 --merge 自动三方合并兜底(git merge sillyspec/${changeName},会引入合并提交):\n` +
363
+ ` sillyspec worktree apply ${changeName} --merge\n` +
364
+ `或手动解决后重试。`
365
+ );
366
+ if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
339
367
  return result;
340
368
  }
341
369
 
@@ -360,6 +388,40 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
360
388
  return result;
361
389
  }
362
390
 
391
+ /**
392
+ * 回滚 --3way 冲突后的工作区到 apply 前状态(不留半成品冲突标记)。
393
+ * tracked 文件:git checkout -- <f> 还原 HEAD 版(清除冲突标记)。
394
+ * 新文件(apply 前不存在):删除(--3way 可能部分创建)。
395
+ * @param {string} projectRoot
396
+ * @param {string[]} trackedFiles 主仓库 HEAD 已存在、patch 触及的文件
397
+ * @param {string[]} newFiles apply 前不存在、patch 可能新建的文件
398
+ * @returns {{ conflicts: string[], error: string|null }} conflicts=冲突文件列表
399
+ */
400
+ function rollbackApply(projectRoot, trackedFiles, newFiles) {
401
+ let error = null;
402
+ // 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
403
+ let conflicts = [];
404
+ try {
405
+ const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
406
+ conflicts = unmerged.split('\n').filter(Boolean);
407
+ } catch {}
408
+ // 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
409
+ // `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
410
+ for (const f of trackedFiles) {
411
+ try { gitQuiet(projectRoot, `checkout HEAD -- ${f}`); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
412
+ }
413
+ // 删除 --3way 可能新建的文件(apply 前不存在)
414
+ for (const f of newFiles) {
415
+ try {
416
+ const p = join(projectRoot, f);
417
+ if (existsSync(p)) unlinkSync(p);
418
+ } catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
419
+ }
420
+ // 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
421
+ try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
422
+ return { conflicts, error };
423
+ }
424
+
363
425
  /**
364
426
  * baseline 漂移时的 merge 降级路径(D-001)。
365
427
  *