sillyspec 3.24.1 → 3.24.3

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.
@@ -18,7 +18,7 @@ quick 阶段的 `--change` 语义是「关联变更」**且会触发步骤重置
18
18
  - `--linked-changes a,b`:显式关联到变更 a、b
19
19
  - `--non-interactive`:CI/脚本环境,默认不关联(避免交互 prompt 崩溃)
20
20
 
21
- 首次 `sillyspec run quick` 选定的关联会持久化到 `.runtime/quick-guard.json`,后续 `--done` 自动复用,**不会重复弹交互 prompt**。
21
+ 首次 `sillyspec run quick` 选定的关联会持久化到 `.runtime/quick-sessions/<quick-session-id>/guard.json`(每个 quick 会话独立,多会话并发不互覆盖),后续 `--done` 自动复用,**不会重复弹交互 prompt**。
22
22
 
23
23
  ## 步骤生命周期(所有阶段通用)
24
24
 
@@ -50,8 +50,17 @@ sillyspec run quick --reopen --from-step N # 重新打开已完成阶段修
50
50
  | `--linked-changes none\|a,b` | **显式关联变更(取代 `--change`,推荐)**。none=不关联,a,b=关联列表 |
51
51
  | `--files a.js,b.js` | 显式声明本次允许修改的文件(边界保护) |
52
52
  | `--allow-new` | 允许新增文件(默认禁止,防意外创建) |
53
- | `--force-baseline` | 允许覆盖 baseline 受保护文件(危险,慎用) |
54
- | `--confirm` | 完成时确认接受变更审计结果(warning/blocked 时用) |
53
+ | `--force-baseline` | 允许覆盖 baseline 受保护文件 / 压制 `.sillyspec/` 危险判定(危险,慎用) |
54
+ | `--confirm` | ⚠️ 仅打印变更概览,**不解锁 blocked**(blocked 仍 exit 1)。真正解锁用 `--force-baseline`/`--allow-new` |
55
+
56
+ ## 审计与并发变更(`--done` 边界审计)
57
+
58
+ `--done` 收尾时对比 step1 baseline 与当前 `git status`,拦「危险文件 / 新增文件 / 覆盖 baseline」。要点:
59
+
60
+ - **并发其他会话的 `.sillyspec/changes/<非关联变更>/` 不再被本 quick 拦截**。quick 自己没有 `changes/` 目录,该路径下非关联内容视为并发会话的工作,整体放行(确定性审计无法区分「并发工作」与「本 quick 偷建变更」,后者这类意图软判定留给 sillyhub)。
61
+ - **关联变更的文件仍走审计**:reverse-sync 改自己关联变更的 `design.md` 会被拦,需 `--force-baseline` 显式确认。
62
+ - **baseline 折叠目录前缀匹配**:step1 启动时整片 `changes/` 未跟踪会被 git 折叠成 `?? .sillyspec/changes/`(带尾斜杠 token);审计时若该目录下文件被并发会话跟踪而展开成文件级路径,按尾斜杠 token 前缀放行其下所有文件,不误判。
63
+ - **`--confirm` ≠ 解锁**:它只打印概览。blocked 时真正解锁的是 `--force-baseline`(压制 `.sillyspec/` 危险判定 + baseline 覆盖)/`--allow-new`(放行新增),两者在 step1 或 `--done` 传都生效。
55
64
 
56
65
  ## 典型用法
57
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.24.1",
3
+ "version": "3.24.3",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/run.js CHANGED
@@ -332,6 +332,27 @@ function parseModuleMapSimple(content) {
332
332
  /**
333
333
  * 同步触发辅助函数:_write 后 best-effort 同步到平台
334
334
  */
335
+ /**
336
+ * 解析单行 `git status --porcelain` 输出为文件路径。
337
+ * porcelain 格式:`XY<SP>path`(XY = 2 字符状态码)。
338
+ * - 路径含空格/特殊字符时 git 以 C 风格加引号(如 `?? "front end.txt"`),需去引号 + 反转义。
339
+ * - 重命名显示为 `R old -> new`,取新路径(当前存在的文件)。
340
+ * - 反斜杠归一化为正斜杠(Windows 路径)。
341
+ * ⚠️ 调用方不要对【整段多行】输出先 .trim() 再 split——会削掉首行前导空格(首行 status 的空格),
342
+ * 使 slice(3) 误吃首文件路径首字符(实测 ` M frontend/...` → "rontend/...",见 guard.json baseline)。
343
+ * 应直接 split('\n').filter(Boolean),每行单独解析。
344
+ */
345
+ export function parsePorcelainPath(line) {
346
+ if (!line) return ''
347
+ let path = line.slice(3).trim()
348
+ if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
349
+ path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
350
+ }
351
+ const arrow = path.indexOf(' -> ')
352
+ if (arrow !== -1) path = path.slice(arrow + 4)
353
+ return path.replace(/\\/g, '/')
354
+ }
355
+
335
356
  /**
336
357
  * quick 完成审计:对比 baseline 与实际变更
337
358
  */
@@ -343,20 +364,41 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
343
364
  try {
344
365
  const { execSync } = await import('child_process')
345
366
  const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
346
- const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
367
+ // 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
368
+ const currentEntries = gitStatus.split('\n').filter(Boolean)
347
369
 
348
370
  const normalizeGitPath = (p) => p.replace(/\\/g, '/')
349
371
  // step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
350
372
  // 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
351
373
  // → 永远 blocked(--force-baseline 也救不回来,因为 status 判定看 baselineHit 数组)。
352
- const baselineFilesSet = new Set((baselineFiles || []).map(f => normalizeGitPath(f)))
374
+ // 前缀匹配:baseline 录入时未跟踪目录会被 git 折叠成 `dir/`(带尾斜杠)token,审计时若该
375
+ // 目录下文件被跟踪则展开成文件级 `dir/file`——精确匹配对不上,故尾斜杠 token 按目录前缀放行。
376
+ const normBaseline = (baselineFiles || []).map(f => normalizeGitPath(f))
377
+ const baselineExact = new Set(normBaseline.filter(f => !f.endsWith('/')))
378
+ const baselineDirs = normBaseline.filter(f => f.endsWith('/'))
379
+ const isBaselineFile = (p) => {
380
+ const f = normalizeGitPath(p)
381
+ return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
382
+ }
383
+ // quick 自己没有 .sillyspec/changes/ 目录——该路径下任何文件要么是关联变更(reverse-sync),
384
+ // 要么是并发其他会话的变更工作。多会话并行时别人的 changes/ 不应被本 quick 审计拦截
385
+ // (确定性校验无法区分「并发工作」与「本 quick 偷建变更」,后者这类意图软判定留给 sillyhub)。
386
+ // 故:非关联变更目录整体放行;关联变更的文件仍走正常审计(reverse-sync 可见,需 --force-baseline)。
387
+ const linkedChangeNames = new Set((Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []).map(c => normalizeGitPath(c)))
353
388
  const isQuickMetadata = (p) => {
354
389
  const file = normalizeGitPath(p)
355
- return file.startsWith('.sillyspec/quicklog/')
390
+ if (file.startsWith('.sillyspec/quicklog/')
356
391
  || file.startsWith('.sillyspec/.runtime/')
357
392
  || file === '.sillyspec/knowledge/uncategorized.md'
358
393
  || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
359
- || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))
394
+ || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
395
+ // .sillyspec/changes/ 下:bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)
396
+ // 或具体但【非关联】的变更 → 都属并发其他会话工作,放行;关联变更文件仍走审计。
397
+ if (file.startsWith('.sillyspec/changes/')) {
398
+ const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
399
+ if (!m || !linkedChangeNames.has(m[1])) return true
400
+ }
401
+ return false
360
402
  }
361
403
  const DANGEROUS_PATTERNS = [
362
404
  'package.json',
@@ -376,11 +418,11 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
376
418
 
377
419
  for (const entry of currentEntries) {
378
420
  const status = entry.slice(0, 2).trim()
379
- const file = normalizeGitPath(entry.slice(3).trim())
380
- if (!file || file.startsWith('??. ')) continue
421
+ const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
422
+ if (!file) continue
381
423
 
382
- // 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计
383
- if (baselineFilesSet.has(file)) continue
424
+ // 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
425
+ if (isBaselineFile(file)) continue
384
426
 
385
427
  result.changedFiles.push(file)
386
428
  if (status === 'D' || status === ' D') result.deletedFiles.push(file)
@@ -607,7 +649,7 @@ async function getStageSteps(stageName, cwd, progress, specDir = null) {
607
649
  /**
608
650
  * 确保阶段的 steps 已初始化到 progress
609
651
  */
610
- async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
652
+ export async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
611
653
  if (!progress.stages) progress.stages = {}
612
654
 
613
655
  const steps = await getStageSteps(stageName, cwd, progress, specDir)
@@ -628,7 +670,15 @@ async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
628
670
  const oldSteps = progress.stages[stageName].steps
629
671
  progress.stages[stageName].steps = steps.map(s => {
630
672
  const old = oldSteps.find(step => step.name === s.name)
631
- if (old) return old
673
+ if (old) return old // 精确名命中 → 保留旧状态
674
+ // migratedFrom:新步骤是合并/重命名来的(如 brainstorm 13→8 折叠)。
675
+ // 吸收的旧步骤全部 completed → 新步骤标 completed,避免 completed 丢失导致 currentIdx 回跳。
676
+ if (Array.isArray(s.migratedFrom) && s.migratedFrom.length > 0) {
677
+ const migrated = oldSteps.filter(step => s.migratedFrom.includes(step.name))
678
+ if (migrated.length > 0 && migrated.every(step => step.status === 'completed')) {
679
+ return { name: s.name, status: 'completed', migratedFrom: true }
680
+ }
681
+ }
632
682
  return { name: s.name, status: 'pending' }
633
683
  })
634
684
  return true
@@ -916,7 +966,8 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
916
966
  if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
917
967
  let runId = ''
918
968
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
919
- const runIdFile = join(execSpecBase, '.runtime', `current-execute-run-id-${changeName}`)
969
+ const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
970
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
920
971
  try {
921
972
  if (existsSync(runIdFile)) {
922
973
  runId = readFileSync(runIdFile, 'utf8').trim()
@@ -925,6 +976,8 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
925
976
  if (!runId) {
926
977
  const { generateExecuteRunId } = await import('./task-review.js')
927
978
  runId = generateExecuteRunId()
979
+ // 落盘(与启动站点一致),保证 agent 收到的 ID == gate/checkbox 读取的 ID
980
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, runId + '\n') } catch {}
928
981
  }
929
982
  promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
930
983
  }
@@ -1609,13 +1662,31 @@ export async function runCommand(args, cwd, specDir = null) {
1609
1662
  if (autoChange) {
1610
1663
  progress = await pm.initChange(cwd, autoChange)
1611
1664
  } else if (isAuxiliary) {
1612
- // 辅助阶段(scan/explore/quick/doctor/status)自动使用默认变更名
1613
- const autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
1665
+ let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
1666
+ // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
1667
+ // 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
1668
+ if (stageName === 'archive' && autoName === 'default') {
1669
+ try {
1670
+ const archiveDir = join(specBase, 'changes', 'archive')
1671
+ if (existsSync(archiveDir)) {
1672
+ const latest = readdirSync(archiveDir)
1673
+ .filter(d => /^\d{4}-\d{2}-\d{2}-.+/.test(d))
1674
+ .sort()
1675
+ .pop()
1676
+ if (latest) {
1677
+ autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
1678
+ progress = await pm.read(cwd, autoName)
1679
+ }
1680
+ }
1681
+ } catch {}
1682
+ }
1614
1683
  changeName = autoName
1615
- progress = await pm.initChange(cwd, autoName)
1616
- // initChange 可能因 project 表为空返回 null
1617
1684
  if (!progress) {
1618
- progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
1685
+ progress = await pm.initChange(cwd, autoName)
1686
+ // initChange 可能因 project 表为空返回 null
1687
+ if (!progress) {
1688
+ progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
1689
+ }
1619
1690
  }
1620
1691
  } else {
1621
1692
  // brainstorm 作为流程入口,自动生成变更名并初始化
@@ -1830,8 +1901,9 @@ async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDes
1830
1901
  try {
1831
1902
  const { execSync } = await import('child_process')
1832
1903
  const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
1833
- baselineFiles = gs.trim().split('\n').filter(Boolean)
1834
- .map(l => l.slice(3).trim())
1904
+ baselineFiles = gs.split('\n').filter(Boolean)
1905
+ .map(l => parsePorcelainPath(l))
1906
+ .filter(Boolean)
1835
1907
  .filter(f => !f.startsWith('.sillyspec/'))
1836
1908
  } catch {}
1837
1909
 
@@ -2071,8 +2143,9 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
2071
2143
  // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
2072
2144
  // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
2073
2145
  const baselineFiles = gitStatus
2074
- .trim().split('\n').filter(Boolean)
2075
- .map(line => line.slice(3).trim())
2146
+ .split('\n').filter(Boolean)
2147
+ .map(line => parsePorcelainPath(line))
2148
+ .filter(Boolean)
2076
2149
  const allowedFiles = quickOpts?.quickFiles || []
2077
2150
  const allowNew = quickOpts?.isAllowNew || false
2078
2151
  const forceBaseline = quickOpts?.isForceBaseline || false
@@ -3547,6 +3620,8 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3547
3620
  if (!executeRunId) {
3548
3621
  const { generateExecuteRunId } = await import('./task-review.js')
3549
3622
  executeRunId = generateExecuteRunId()
3623
+ // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
3624
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
3550
3625
  }
3551
3626
 
3552
3627
  // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
@@ -134,7 +134,7 @@ export function verifyStageReviewDocHash(review, searchDirs) {
134
134
  const actual = computeDocHash(abs)
135
135
  if (actual === null) continue
136
136
  // 找到主文档,比对 hash
137
- if (review.docHash !== actual) {
137
+ if (review.docHash.toLowerCase() !== actual.toLowerCase()) {
138
138
  errors.push(`docHash 与主审查文档 ${primaryRel} 的实际内容不匹配 — review.json 疑似伪造(未真正读取文档)`)
139
139
  return { ok: false, errors, warnings }
140
140
  }
@@ -71,6 +71,7 @@ export const definition = {
71
71
  },
72
72
  {
73
73
  name: '对话式探索与需求澄清',
74
+ migratedFrom: ['协作与复用检查', '原型/设计图分析', '需求范围评估', '对话式探索', '需求澄清 Grill'],
74
75
  conditionalWait: true,
75
76
  repeatableWait: true,
76
77
  maxWaitRounds: 8,
@@ -211,6 +212,7 @@ export const definition = {
211
212
  },
212
213
  {
213
214
  name: '分段展示设计',
215
+ migratedFrom: ['HTML 原型生成'],
214
216
  requiresWait: true,
215
217
  waitReason: '等待用户确认设计方案',
216
218
  waitOptions: ['确认', '需要修改', '推翻重来'],
@@ -384,16 +384,16 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
384
384
  * 每个 task 生成 20~40 行紧凑可执行卡片
385
385
  */
386
386
  export function buildCoordinatorStep(changeDir, taskNames) {
387
- const taskList = taskNames.map((name, i) => {
388
- const num = String(i + 1).padStart(2, '0')
389
- return `- task-${num}: ${name}`
387
+ const taskList = taskNames.map(t => {
388
+ const num = t.num
389
+ return `- task-${num}: ${t.name}`
390
390
  }).join('\n')
391
391
 
392
- const subagentPrompts = taskNames.map((name, i) => {
393
- const num = String(i + 1).padStart(2, '0')
392
+ const subagentPrompts = taskNames.map(t => {
393
+ const num = t.num
394
394
  return `\`\`\`
395
395
  任务编号:task-${num}
396
- 任务名称:${name}
396
+ 任务名称:${t.name}
397
397
  文件路径:${changeDir}/tasks/task-${num}.md
398
398
  当前时间:<now-datetime>(frontmatter 的 created_at 使用此值)
399
399
  当前用户:<git-user>(frontmatter 的 author 使用此值)
@@ -405,7 +405,7 @@ export function buildCoordinatorStep(changeDir, taskNames) {
405
405
 
406
406
  ---
407
407
  id: task-${num}
408
- title: ${name}
408
+ title: ${t.name}
409
409
  title_zh: <任务中文标题>
410
410
  author: <git-user>
411
411
  created_at: <now-datetime>
@@ -551,13 +551,14 @@ function parseTaskCount(planContent) {
551
551
  * 从 plan.md 解析任务名列表
552
552
  */
553
553
  function parseTaskNames(planContent) {
554
- const names = []
554
+ // 返回 [{name, num}]:保留 plan.md 原始编号,避免 buildCoordinatorStep 按 i+1 重编号与 execute 原始编号错位
555
+ const tasks = []
555
556
  const lines = planContent.split('\n')
556
557
  for (const line of lines) {
557
- const m = line.match(/^[-*]\s*\[[ x]\]\s*task-\d+:\s*(.+)/i)
558
- if (m) names.push(m[1].trim())
558
+ const m = line.match(/^[-*]\s*\[[ x]\]\s*task-(\d+):\s*(.+)/i)
559
+ if (m) tasks.push({ num: String(m[1]).padStart(2, '0'), name: m[2].trim() })
559
560
  }
560
- return names
561
+ return tasks
561
562
  }
562
563
 
563
564
  /**
@@ -337,9 +337,22 @@ export function verifyReviewGitEvidence(review, gitDir) {
337
337
  }
338
338
 
339
339
  const emptyDiff = diffFiles.length === 0
340
+ // emptyDiff 回退:子代理可能未 commit,base..head 无 commit diff 但 working-tree 有改动。
341
+ // 对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,避免误判零改动伪造。
342
+ let workingTreeChanged = false
343
+ if (emptyDiff) {
344
+ try {
345
+ const wtStatus = runGit(gitDir, ['status', '--porcelain'])
346
+ workingTreeChanged = !!wtStatus && wtStatus.trim().length > 0
347
+ } catch {}
348
+ if (workingTreeChanged) {
349
+ warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),但 working-tree 有未提交改动 —— 按有效改动处理,不判零改动伪造`)
350
+ }
351
+ }
352
+ const effectiveEmptyDiff = emptyDiff && !workingTreeChanged
340
353
 
341
354
  // changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关
342
- if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
355
+ if (!effectiveEmptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
343
356
  const normalize = (p) => String(p).replace(/^\.\//, '')
344
357
  const diffSet = new Set(diffFiles.map(normalize))
345
358
  const hasOverlap = review.changedFiles.some(f => {
@@ -352,7 +365,7 @@ export function verifyReviewGitEvidence(review, gitDir) {
352
365
  }
353
366
  }
354
367
 
355
- return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
368
+ return { ok: errors.length === 0, emptyDiff: effectiveEmptyDiff, errors, warnings, unavailable: false }
356
369
  }
357
370
 
358
371
  /**
@@ -343,20 +343,15 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
343
343
  * @returns {object} result(merged=true 表示走了 merge 降级)
344
344
  */
345
345
  function applyByMerge(result, changeName, projectRoot, wm) {
346
+ const meta = wm.getMeta(changeName);
347
+ const changedFiles = result.changedFiles || [];
348
+ // 用 meta.branch(native-worktree 模式分支名可能不是 sillyspec/<change>),不硬编码。
349
+ const branch = meta.branch || `sillyspec/${changeName}`;
350
+
346
351
  try {
347
- git(projectRoot, `merge --no-ff sillyspec/${changeName}`);
348
- result.merged = true;
349
- result.ok = true;
350
- try { result.mergeSummary = git(projectRoot, `log --oneline -1`); } catch {}
351
- // 成功后自动 cleanup(与 patch 路径步骤 8 一致;分支已合并,删除安全)
352
- try {
353
- wm.cleanup(changeName);
354
- } catch (cleanupErr) {
355
- result.warnings = result.warnings || [];
356
- result.warnings.push(`cleanup 失败(不影响 merge 结果): ${cleanupErr.message}`);
357
- }
352
+ git(projectRoot, `merge --no-ff ${branch}`);
358
353
  } catch (e) {
359
- // merge 冲突:先取冲突文件列表,再 abort 回滚(避免半成品合并)
354
+ // merge 冲突:取冲突文件列表 + abort 回滚(不 cleanup,保留 worktree)
360
355
  let conflictFiles = [];
361
356
  try {
362
357
  const cf = gitQuiet(projectRoot, `diff --name-only --diff-filter=U`);
@@ -364,10 +359,35 @@ function applyByMerge(result, changeName, projectRoot, wm) {
364
359
  } catch {}
365
360
  try { gitQuiet(projectRoot, `merge --abort`); } catch {}
366
361
  result.errors.push(
367
- `git merge sillyspec/${changeName} 冲突,请手动解决。冲突文件:\n` +
362
+ `git merge ${branch} 冲突,请手动解决。冲突文件:\n` +
368
363
  (conflictFiles.length ? ` ${conflictFiles.join('\n ')}\n` : ` (未能获取冲突文件列表)\n`) +
369
364
  `已执行 git merge --abort 回滚到合并前状态。`
370
365
  );
366
+ return result;
367
+ }
368
+
369
+ // 落地校验:merge 报成功(exit 0)不代表交付物真进 main——
370
+ // 分支可能只含 baseline checkpoint(子代理改动未 commit),merge 产生空内容合并,文件零落地。
371
+ // 逐个确认 changedFiles 在 main HEAD 存在。任一缺失 → 不 cleanup(fail-open:保留 worktree 唯一副本)。
372
+ result.merged = true;
373
+ const notLanded = changedFiles.filter(f => gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null);
374
+ if (notLanded.length > 0) {
375
+ result.ok = false;
376
+ result.errors.push(
377
+ `git merge ${branch} 报成功,但 ${notLanded.length}/${changedFiles.length} 个交付文件未出现在 main HEAD` +
378
+ `(分支可能只含 baseline checkpoint,子代理改动未 commit)。worktree 已保留(未 cleanup),` +
379
+ `请用 git cherry-pick 或手动 cp 落地:\n ${notLanded.join('\n ')}`
380
+ );
381
+ return result;
382
+ }
383
+
384
+ result.ok = true;
385
+ try { result.mergeSummary = git(projectRoot, `log --oneline -1`); } catch {}
386
+ try {
387
+ wm.cleanup(changeName);
388
+ } catch (cleanupErr) {
389
+ result.warnings = result.warnings || [];
390
+ result.warnings.push(`cleanup 失败(不影响 merge 结果): ${cleanupErr.message}`);
371
391
  }
372
392
  return result;
373
393
  }