sillyspec 3.27.7 → 3.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.27.7",
3
+ "version": "3.27.9",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -117,22 +117,50 @@ export function buildContractMatrix(planContent, changeDir) {
117
117
  function parseTaskDependencies(registryContent) {
118
118
  const deps = {}
119
119
 
120
- // 方式 1: 任务总表表格形式 | task-04 | ... | 01,02 |
121
- // 列前缀用 [^|\d]*(不吃数字):旧 [^|]* 贪婪会吃掉 deps 列前导 0(" 01" → g2=1 → task-1),
122
- // 导致 provider task-1.md 不存在 → contracts 恒空,端点契约管线失效。
123
- const tableRows = registryContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|\d]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
124
- for (const match of tableRows) {
125
- const task = `task-${match[1]}`
126
- const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
127
- deps[task] = depList
120
+ // 方式 1: 任务总表表格形式(两种常见格式)
121
+ // A: | task-04 | Wave | 优先级 | 说明 | 依赖 | (task-NN 在独立列)
122
+ // B: | task | desc | deps | (task-NN 在任意列,bare 数字依赖)
123
+ // 逐行按 | 分列解析,避免旧单正则 [^|]* 跨列贪婪误读说明列数字(409/HTTP 状态码)
124
+ // 为假依赖(坑 contract-matrix-false-deps)。
125
+ // deps 列判定:从行尾倒数第一个「非分隔符、非 task 自身、非单数字(Wave/优先级)」的列,
126
+ // 且须含逗号/task- 引用/bare 两位以上数字。
127
+ const lines = registryContent.split('\n')
128
+ for (const line of lines) {
129
+ const taskIdMatch = line.match(/task-(\d+)/i)
130
+ if (!taskIdMatch) continue
131
+ const taskId = `task-${taskIdMatch[1]}`
132
+
133
+ const cells = line.split('|').map(c => c.trim()).filter(c => c.length > 0)
134
+ if (cells.length < 2) continue
135
+
136
+ // 从行尾倒数找 deps 列
137
+ let depCell = null
138
+ for (let i = cells.length - 1; i >= 0; i--) {
139
+ const c = cells[i]
140
+ if (/^-+$/.test(c)) continue // 分隔符 "-"
141
+ if (c.toLowerCase() === taskId.toLowerCase()) continue // task 自身列
142
+ // deps 列候选:含逗号(多依赖)或含 task- 引用
143
+ if (/[,,]/.test(c) || /task-\d+/i.test(c)) { depCell = c; break }
144
+ // bare 数字:须 ≥2 位且位于行尾第2+列(跳过优先级/Wave 列的个位数)
145
+ if (/^\d{2,}$/.test(c) && i >= cells.length - 2) { depCell = c; break }
146
+ }
147
+ if (!depCell) continue
148
+ const depNums = depCell.match(/\d+/g) || []
149
+ deps[taskId] = depNums.map(d => `task-${d.padStart(2, '0')}`)
128
150
  }
129
151
 
130
152
  // 方式 2: tasks.md 行内标注 "- [ ] task-04: 名称 (depends_on: task-01,02)"(新家,D-003@v1;
131
- // 旧 plan.md checkbox 行内标注同格式兼容)
132
- const dependsPattern = registryContent.matchAll(/task-(\d+).*?depends_on.*?(\d+(?:\s*[,,]\s*\d+)*)/gi)
153
+ // 旧 plan.md checkbox 行内标注同格式兼容)。仅匹配 (depends_on: ...) 括号内内容,
154
+ // 不在自由文本上全文正则(坑 contract-matrix-false-deps:说明列数字被误读为依赖)。
155
+ const dependsPattern = registryContent.matchAll(/task-(\d+)\b[^)]*?\(depends_on:\s*([^)]+)\)/gi)
133
156
  for (const match of dependsPattern) {
134
157
  const task = `task-${match[1]}`
135
- const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
158
+ const depStr = match[2]
159
+ // 兼容两种写法:task-01, task-02 或 01, 02
160
+ const depList = depStr.split(/[,,]\s*/).map(s => {
161
+ const num = s.trim().match(/\d+/)
162
+ return num ? `task-${num[0].padStart(2, '0')}` : null
163
+ }).filter(Boolean)
136
164
  if (!deps[task]) deps[task] = []
137
165
  for (const d of depList) {
138
166
  if (!deps[task].includes(d)) deps[task].push(d)
package/src/index.js CHANGED
@@ -1692,6 +1692,19 @@ SillySpec worktree — git worktree 隔离管理
1692
1692
  for (const f of result.changedFiles) {
1693
1693
  console.log(` ${f}`);
1694
1694
  }
1695
+ // 跨仓 worktree 回落报告(坑 cross-repo-no-worktree-isolation):跨仓文件不进主仓
1696
+ // changedFiles(在其所属仓的 diff 里),单独列出防漏看
1697
+ if (Array.isArray(result.crossRepoApplied) && result.crossRepoApplied.length > 0) {
1698
+ for (const c of result.crossRepoApplied) {
1699
+ if (c.changedFiles.length > 0) {
1700
+ console.log(`✅ 跨仓 repo ${c.repoKey} 已回落 ${c.changedFiles.length} 个文件变更(worktree 已清理,分支保留作 review 锚点):`);
1701
+ for (const f of c.changedFiles.slice(0, 20)) console.log(` ${f}`);
1702
+ if (c.changedFiles.length > 20) console.log(` … 共 ${c.changedFiles.length} 个`);
1703
+ } else {
1704
+ console.log(`ℹ️ 跨仓 repo ${c.repoKey} 无交付变更(worktree 已清理)`);
1705
+ }
1706
+ }
1707
+ }
1695
1708
  // filterDeliverableFiles 精细化排除(保留 .sillyspec/docs/,排 changes/+.runtime/+quicklog/ + meta.json):
1696
1709
  // 模块文档(.sillyspec/docs/)= 交付物,会 auto-apply 到主工作区;变更文档/运行时/quicklog 不 apply。
1697
1710
  // 须告知 agent 哪些未落地,否则它期望这些文件落地却找不到(memory: worktree-apply-excludes-module-docs)。
@@ -1878,6 +1891,18 @@ SillySpec worktree — git worktree 隔离管理
1878
1891
  // 与并行会话的 apply/cleanup 互踩会互相清——与其他写主仓操作共用一把 main-repo.lock
1879
1892
  const { withMainRepoLock } = await import('./worktree-apply.js');
1880
1893
  const result = await withMainRepoLock(dir, wtName, 'worktree-cleanup', () => wm.cleanup(wtName, { force: forceFlag }));
1894
+ // 跨仓 worktree(坑 cross-repo-no-worktree-isolation):与主仓同锁清理——跨仓 worktree
1895
+ // 注册在各跨仓仓 .git 内,但目录/meta 在主仓 .sillyspec 运行时区,同样怕并发互踩
1896
+ const { cleanupCrossWorktrees } = await import('./worktree-cross.js');
1897
+ const crossResult = cleanupCrossWorktrees({ cwd: dir, changeName: wtName, specBase: resolvePlatformSpecDir(dir, specDir), force: forceFlag });
1898
+ for (const cr of crossResult.results) {
1899
+ if (cr.result === 'cleaned') {
1900
+ console.log(`✅ 跨仓 worktree 已清理: repo=${cr.repoKey}(${(cr.details || []).filter(d => d.startsWith('分支')).join(';') || '详情见上'})`);
1901
+ } else if (cr.result === 'partial') {
1902
+ console.error(`🚫 跨仓 repo=${cr.repoKey} 清理受阻:${(cr.details || []).join('; ')}`);
1903
+ for (const r of cr.residual || []) console.error(` 残留: ${r}`);
1904
+ }
1905
+ }
1881
1906
  if (result.result === 'cleaned' || result.result === 'force-cleaned') {
1882
1907
  console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
1883
1908
  if (result.details?.length > 0) {
@@ -349,7 +349,9 @@ export class StageMachine {
349
349
  command: `sillyspec run ${s} --continue --answer "用户回答"${cc}`,
350
350
  };
351
351
  }
352
- const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
352
+ // blocked(deps/review.json 门控阻断标记)也计入待办(坑 deps-gate-blocked-invisible):
353
+ // 漏计会让全局「下一步」建议跳过被阻断的阶段/步骤,误导 agent 去跑更后面的阶段。
354
+ const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed', 'blocked'].includes(st.status));
353
355
  if (hasPending) {
354
356
  return {
355
357
  text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
@@ -23,7 +23,7 @@ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, ancestorSpecDirs, resolveAncestorCeiling, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep, collectStageWaitHistory } from './prompt.js'
29
29
  import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
@@ -385,14 +385,26 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
385
385
  // let:下方 worktree 副本漂移守卫命中时锚回 wt.mainSpecBase(task-05/D-03)
386
386
  let specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
387
387
 
388
- // 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
388
+ // 漂移阻断:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
389
389
  // 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
390
- // 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。仅提醒不阻断。
390
+ // 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。
391
+ // 当前实例 ≠ git 顶层仓库实例 → 默认拒绝(坑 monorepo-cwd-wrong-spec-instance)。
391
392
  if (!platformOpts.specRoot && !specDir) {
392
- const ancestorCount = countAncestorSpecDirs(cwd)
393
- if (ancestorCount >= 2) {
394
- console.warn(`⚠️ 检测到祖先链有 ${ancestorCount} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
395
- console.warn(` 若意图是另一个项目:cd 回该项目根,或用 --spec-dir <根>/.sillyspec 显式指定。`)
393
+ const ancestorSpecs = ancestorSpecDirs(cwd)
394
+ if (ancestorSpecs.length >= 2) {
395
+ // 判断当前命中的 .sillyspec 是否在 git 顶层仓库根下
396
+ const gitRoot = resolveAncestorCeiling(resolve(cwd))
397
+ const currentSpecParent = dirname(resolve(specBase))
398
+ const isAtGitRoot = gitRoot && resolve(currentSpecParent) === resolve(gitRoot)
399
+ if (!isAtGitRoot) {
400
+ console.error(`❌ 检测到祖先链有 ${ancestorSpecs.length} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
401
+ console.error(` 当前 cwd 不在 git 顶层仓库根下,静默绑定到子项目实例会导致进度/QUICKLOG 分裂。`)
402
+ console.error(` 修复:cd 回项目根目录,或用 --spec-dir <根>/.sillyspec 显式指定。`)
403
+ process.exit(2)
404
+ }
405
+ // 在 git 根下但有多实例(子项目也有 .sillyspec)→ 仅警告(正确实例已命中)
406
+ console.warn(`⚠️ 检测到祖先链有 ${ancestorSpecs.length} 个 .sillyspec 实例(monorepo 多实例),当前使用: ${specBase}`)
407
+ console.warn(` 当前已绑定 git 顶层仓库实例。若意图是子项目:用 --spec-dir 显式指定。`)
396
408
  }
397
409
  }
398
410
 
@@ -896,7 +908,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
896
908
  if (readonlySteps && readonlySteps.length > 0) {
897
909
  // 已有进度(历史 seed 过)则渲染当前待办步;无进度从首步开始
898
910
  const existingSteps = progress.stages?.[stageName]?.steps
899
- const pendingIdx = existingSteps ? existingSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress') : -1
911
+ const pendingIdx = existingSteps ? existingSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked') : -1
900
912
  const stepIdx = pendingIdx !== -1 ? pendingIdx : 0
901
913
  await outputStep(stageName, stepIdx, readonlySteps, cwd, readOnlyChange, progress.project || null, platformOpts, null, collectStageWaitHistory(progress, stageName))
902
914
  }
@@ -1295,7 +1307,7 @@ function showStatus(progress, stageName, nextSuggestion = null) {
1295
1307
 
1296
1308
  console.log('')
1297
1309
 
1298
- const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1310
+ const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
1299
1311
 
1300
1312
  if (progress.batchProgress) {
1301
1313
  const bp = progress.batchProgress
@@ -1314,10 +1326,13 @@ function showStatus(progress, stageName, nextSuggestion = null) {
1314
1326
  }
1315
1327
 
1316
1328
  steps.forEach((step, i) => {
1317
- const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : '⬜'
1318
- const isCurrent = (step.status === 'pending' || step.status === 'in-progress') && i === firstPending
1329
+ const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : step.status === 'blocked' ? '🚫' : '⬜'
1330
+ const isCurrent = (step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') && i === firstPending
1319
1331
  const isWaiting = step.status === 'waiting'
1320
- console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}`)
1332
+ console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}${step.status === 'blocked' ? ' [BLOCKED]' : ''}`)
1333
+ if (step.status === 'blocked') {
1334
+ console.log(` 此前被门控阻断——${step.blockReason || 'deps 未就绪 / review.json 不完整'};修复后重跑 --done 即恢复,无需 --reset`)
1335
+ }
1321
1336
  if (isWaiting) {
1322
1337
  if (step.waitReason) console.log(` 原因:${step.waitReason}`)
1323
1338
  if (step.waitOptions) console.log(` 选项:${formatWaitOptions(step.waitOptions)}`)
@@ -1355,6 +1370,22 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
1355
1370
  } catch (e) {
1356
1371
  console.warn(`⚠️ reset 清理 worktree 失败(不阻断 reset): ${e.message}`)
1357
1372
  }
1373
+ // 跨仓 worktree 一并清理(坑 cross-repo-no-worktree-isolation):否则下次 execute 因 meta
1374
+ // 存在复用带脏状态的旧跨仓 worktree
1375
+ try {
1376
+ const { cleanupCrossWorktrees } = await import('../worktree-cross.js')
1377
+ const cross = cleanupCrossWorktrees({
1378
+ cwd, changeName,
1379
+ specBase: platformOpts?.specRoot || join(cwd, '.sillyspec'),
1380
+ force: true,
1381
+ })
1382
+ for (const cr of cross.results) {
1383
+ if (cr.result === 'cleaned') console.log(`🧹 已清理跨仓 worktree repo=${cr.repoKey},下次 execute 将重建`)
1384
+ else if (cr.result === 'partial') console.warn(`⚠️ 跨仓 repo=${cr.repoKey} 清理残留: ${(cr.details || []).join('; ')} ${(cr.residual || []).join('; ')}`)
1385
+ }
1386
+ } catch (e) {
1387
+ console.warn(`⚠️ reset 清理跨仓 worktree 失败(不阻断 reset): ${e.message}`)
1388
+ }
1358
1389
  }
1359
1390
  const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1360
1391
  progress.stages[stageName] = {
@@ -1484,7 +1515,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1484
1515
  console.log('')
1485
1516
 
1486
1517
  const defSteps = await getAutoSteps(currentStage)
1487
- const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1518
+ const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') ?? -1
1488
1519
  if (pendingIdx === -1) {
1489
1520
  const wsIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'waiting') ?? -1
1490
1521
  if (wsIdx !== -1) {
@@ -1536,7 +1567,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1536
1567
  return
1537
1568
  }
1538
1569
 
1539
- const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1570
+ const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') ?? -1
1540
1571
  if (nextPendingIdx !== -1) {
1541
1572
  const defSteps = await getAutoSteps(currentStage)
1542
1573
  // execute 阶段启动前检查审批
@@ -1615,7 +1646,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1615
1646
 
1616
1647
  console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
1617
1648
  const nextSteps = await getAutoSteps(next)
1618
- const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1649
+ const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress' || step.status === 'blocked') ?? -1
1619
1650
  if (firstPending !== -1) {
1620
1651
  // execute 阶段启动前检查审批
1621
1652
  if (next === 'execute' && !skipApproval) {
@@ -1385,6 +1385,26 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1385
1385
  } catch (e) {
1386
1386
  console.warn(`🔗 Worktree: check failed — ${e.message}`);
1387
1387
  }
1388
+ // 跨仓 worktree(坑 cross-repo-no-worktree-isolation):execute 完成时不自动清——交付还在
1389
+ // 跨仓 worktree 分支上未回落主工作副本,与主仓「pending apply」同语义,报状态 + 下一步指引
1390
+ try {
1391
+ const { listCrossWorktreeMetas } = await import('../worktree-cross.js')
1392
+ const specBase = join(cwd, '.sillyspec')
1393
+ for (const { repoKey, meta: cm } of listCrossWorktreeMetas(specBase, changeName)) {
1394
+ let pending = 0
1395
+ try {
1396
+ const d = safeGit(cm.worktreePath, ['diff', '--name-only', cm.baseHash], { timeout: 30000 })
1397
+ const u = safeGit(cm.worktreePath, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 })
1398
+ pending = ((d.value || '') + '\n' + (u.value || '')).split('\n').filter(Boolean).length
1399
+ } catch { /* 状态探测失败按未知处理 */ }
1400
+ if (pending > 0) {
1401
+ console.log(`🔗 跨仓 worktree repo=${repoKey}: pending apply (${pending} 个未回落变更)`)
1402
+ console.log(` 下一步: sillyspec worktree apply ${changeName}(跨仓与主仓一并回落)`)
1403
+ } else {
1404
+ console.log(`🔗 跨仓 worktree repo=${repoKey}: 无未回落变更(apply 时将自动清理)`)
1405
+ }
1406
+ }
1407
+ } catch { /* 跨仓状态报告 best-effort */ }
1388
1408
  }
1389
1409
  return null
1390
1410
  }
@@ -103,7 +103,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
103
103
  }
104
104
 
105
105
  const steps = stageData.steps
106
- let currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
106
+ // blocked(deps/review.json 门控阻断标记)也视为当前步骤(坑 deps-gate-blocked-invisible,
107
+ // 2026-08-27 实证):门控阻断时 enforceDepsGate/enforceReviewJsonGate 把当前步置 blocked 后
108
+ // exit(1),若谓词漏 blocked,被阻断步骤对 findIndex 永久隐身——重试 --done 落到其后第一个
109
+ // pending 步骤上(错步记账,DB 显示与事实脱节),且 blocked 步骤无任何恢复路径(状态机卡死,
110
+ // 只能 --reset 丢进度)。门控本身在下方会重新校验:deps 已修复 → 正常完成;未修复 → 再次
111
+ // 阻断同一步(幂等,不污染后续步骤)。
112
+ let currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress' || s.status === 'blocked')
113
+ if (currentIdx !== -1 && steps[currentIdx].status === 'blocked') {
114
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 此前被门控阻断(blocked),本次 --done 重新校验门控后继续推进。`)
115
+ }
107
116
  // ── 坑1:--done --answer 解「已 waiting 的步骤」──
108
117
  // findIndex 仅查 pending/in-progress,排除 waiting;故 --done --answer 落到已 --wait 暂停的
109
118
  // requiresWait 步骤时会跳过它、把 --answer 静默丢弃,步骤永久卡 WAITING、末步报「等待用户输入」。
package/src/run/gates.js CHANGED
@@ -321,10 +321,42 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
321
321
  console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
322
322
  }
323
323
  const depsStatus = meta?.depsStatus
324
- if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
324
+ const mainOk = ['linked', 'installed', 'n/a'].includes(depsStatus)
325
+ if (mainOk) {
326
+ // ── 跨仓 worktree deps 校验(坑 cross-repo-no-worktree-isolation)──
327
+ // 跨仓仓供给失败同样无构建/测试能力(前端仓无 node_modules 建不了 build)。只校验已建
328
+ // worktree 的跨仓仓(legacy 直写模式无 meta,维持原语义,下游 Task Review 兜底)。
329
+ let crossFailed = []
330
+ try {
331
+ const crossSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
332
+ const { listCrossWorktreeMetas } = await import('../worktree-cross.js')
333
+ crossFailed = listCrossWorktreeMetas(crossSpecBase, changeName)
334
+ .filter(({ meta: cm }) => cm.depsStatus && !['linked', 'installed', 'n/a'].includes(cm.depsStatus))
335
+ } catch (e) {
336
+ console.warn(`⚠️ 跨仓 worktree deps 校验跳过(${e.message})`)
337
+ }
338
+ if (crossFailed.length === 0) return true
339
+ if (steps && steps[currentIdx]) {
340
+ steps[currentIdx].status = 'blocked'
341
+ steps[currentIdx].blockReason = `跨仓 deps 门控阻断(${crossFailed.map(f => f.repoKey).join(', ')}):修复后重试 --done`
342
+ }
343
+ console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
344
+ for (const { repoKey, meta: cm } of crossFailed) {
345
+ console.error(` 跨仓 repo ${repoKey} 依赖未就绪(depsStatus=${cm.depsStatus || 'unknown'})——workdir: ${cm.worktreePath}`)
346
+ if (cm.depsError) console.error(` 上次供给错误:${cm.depsError}`)
347
+ }
348
+ console.error(' 修复:进上述跨仓 worktree 手动安装依赖(用该仓的包管理器),或重跑 sillyspec run execute 让 CLI 重试供给后 --done。')
349
+ if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
350
+ process.exit(1)
351
+ }
325
352
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
326
353
  if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
327
- if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
354
+ if (steps && steps[currentIdx]) {
355
+ steps[currentIdx].status = 'blocked'
356
+ // 阻断原因持久化(坑 deps-gate-blocked-invisible):--status/诊断时可见,不依赖阻断当刻的
357
+ // stderr(跨进程即失忆)。completed 时由 completeStep 主流程覆盖整个步骤行,残留字段不外泄。
358
+ steps[currentIdx].blockReason = `deps 门控阻断(depsStatus=${depsStatus || 'unknown'}):worktree doctor --fix 修复后重试 --done`
359
+ }
328
360
  // ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
329
361
  // getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
330
362
  let worktreeGone = true
@@ -411,7 +443,10 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
411
443
  }
412
444
  const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
413
445
  if (result.ok) return true
414
- if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
446
+ if (steps && steps[currentIdx]) {
447
+ steps[currentIdx].status = 'blocked'
448
+ steps[currentIdx].blockReason = 'review.json 字段校验阻断:补齐后重试 --done(backfill-reviews --adopt 可代填 mechanics)'
449
+ }
415
450
  console.error('❌ ── review.json 字段校验阻断(本次 --done 未完成,进度未推进)──')
416
451
  console.error(' 已勾选 [x] 的 task review.json 不完整(铁律:勾 checkbox 前必须先写完整 review.json):')
417
452
  for (const f of result.failures) {
@@ -22,6 +22,8 @@
22
22
  * 不涉及生命周期契约(design §7.5):纯内存对象,不跨进程、不持久化、不入库。
23
23
  */
24
24
 
25
+ import { existsSync, readFileSync } from 'fs';
26
+ import { join } from 'path';
25
27
  import { git, gitQuiet } from '../git-helper.js';
26
28
 
27
29
  /**
@@ -133,7 +135,9 @@ export class MultiRepoContext {
133
135
  }
134
136
 
135
137
  /**
136
- * 跨仓 RepoEntry:isMain=false,gitDir=projectRoot=worktreePath=跨仓仓根。
138
+ * 跨仓 RepoEntry:有 worktree meta(worktree-cross.js 创建)→ gitDir=worktreePath=worktree,
139
+ * base 锚 meta.baseHash(与主仓同构,坑 cross-repo-no-worktree-isolation);无 meta(legacy
140
+ * 直写主干模式)→ gitDir=projectRoot=worktreePath=跨仓仓根,base 靠 taskBaseCommit 锡点。
137
141
  * 构造时 `git -C <crossRepo> rev-parse HEAD` 必须 succeed(约束② fail-closed),
138
142
  * 路径不存在/非 git 仓 → throw 阻断 execute。
139
143
  * @param {string} repoKey
@@ -148,14 +152,26 @@ export class MultiRepoContext {
148
152
  `请补全路径(约束② fail-closed)。`
149
153
  );
150
154
  }
155
+ // worktree 模式检测:meta 在 = worktree-cross.js 已建隔离(路径公式与其 crossWorktreePath
156
+ // 保持同步;execute 启动时创建,晚于本构造的调用会命中)。
157
+ const specBase = (this.platformOpts && this.platformOpts.specRoot) || join(this.cwd, '.sillyspec');
158
+ let crossMeta = null;
159
+ try {
160
+ // 同步读(构造函数是同步的,不 import worktree-cross 避免 shared↔multi-repo-context 循环)
161
+ const metaPath = join(specBase, '.runtime', 'worktrees', `${this.changeName}--${repoKey}`, 'meta.json');
162
+ if (existsSync(metaPath)) crossMeta = JSON.parse(readFileSync(metaPath, 'utf8'));
163
+ } catch { crossMeta = null }
164
+ const inWorktree = !!(crossMeta && crossMeta.isCross && crossMeta.worktreePath && existsSync(crossMeta.worktreePath));
165
+ const workRoot = inWorktree ? crossMeta.worktreePath : crossRepoPath;
166
+
151
167
  // 约束②:跨仓 git 必须可达。失败 = 路径不存在 / 非 git 仓 / git 异常,一律阻断。
152
168
  // 不沿用主仓 verifyReviewGitEvidence unavailable 降级(design §9 兼容策略表)。
153
169
  let headVerify;
154
170
  try {
155
- headVerify = git(crossRepoPath, ['rev-parse', 'HEAD']);
171
+ headVerify = git(workRoot, ['rev-parse', 'HEAD']);
156
172
  } catch (e) {
157
173
  throw new Error(
158
- `MultiRepoContext: 跨仓 repo "${repoKey}" git 不可达(路径:${crossRepoPath})。` +
174
+ `MultiRepoContext: 跨仓 repo "${repoKey}" git 不可达(路径:${workRoot}${inWorktree ? `(worktree,跨仓根 ${crossRepoPath})` : ''})。` +
159
175
  `rev-parse HEAD 失败:${e.message}。` +
160
176
  `请检查 local.yaml repos: 段路径是否正确、目录是否存在且为 git 仓(约束② fail-closed,配置错误不降级)。`
161
177
  );
@@ -164,17 +180,39 @@ export class MultiRepoContext {
164
180
  // git() 返回空串属异常态(无 commit 的空仓 rev-parse HEAD 会非零退出,已被 catch 兜底),
165
181
  // 此处仅作防御性双保险,触发同样走 fail-closed。
166
182
  throw new Error(
167
- `MultiRepoContext: 跨仓 repo "${repoKey}"(${crossRepoPath})rev-parse HEAD 返回空——` +
183
+ `MultiRepoContext: 跨仓 repo "${repoKey}"(${workRoot})rev-parse HEAD 返回空——` +
168
184
  `疑似空 git 仓(无 commit)。跨仓 task 需仓库已有至少一个 commit(约束② fail-closed)。`
169
185
  );
170
186
  }
171
187
 
188
+ if (inWorktree) {
189
+ // worktree 模式:子代理在跨仓 worktree 分支上改+commit,apply 阶段由 CLI patch 回跨仓
190
+ // 主工作区。base 锚 meta.baseHash(创建时快照),head 实时取 worktree HEAD(子代理
191
+ // commit 推进后反映最新);baseCommitHint 供派发侧优先取(HEAD 会推进,不能当 base)。
192
+ return {
193
+ repoKey,
194
+ gitDir: workRoot,
195
+ worktreePath: workRoot,
196
+ projectRoot: crossRepoPath,
197
+ isMain: false,
198
+ isWorktree: true,
199
+ baseCommitHint: crossMeta.baseHash || null,
200
+ resolveHead: () => git(workRoot, ['rev-parse', 'HEAD']),
201
+ resolveBase: (taskBaseCommit) => crossMeta.baseHash || taskBaseCommit || (() => {
202
+ throw new Error(
203
+ `MultiRepoContext: 跨仓 repo "${repoKey}"(worktree 模式)meta.baseHash 缺失且未传 taskBaseCommit。`
204
+ );
205
+ })(),
206
+ };
207
+ }
208
+
172
209
  return {
173
210
  repoKey,
174
211
  gitDir: crossRepoPath,
175
212
  worktreePath: crossRepoPath,
176
213
  projectRoot: crossRepoPath,
177
214
  isMain: false,
215
+ isWorktree: false,
178
216
  // 约束①:跨仓 HEAD 实时取,不缓存(task 推进后过期,每次 resolveHead 反映最新 HEAD)。
179
217
  resolveHead: () => git(crossRepoPath, ['rev-parse', 'HEAD']),
180
218
  // 约束①:跨仓 base = task 卡 base_commit 锡点(必传)。跨仓仓无 meta.json,
package/src/run/shared.js CHANGED
@@ -139,7 +139,7 @@ export function resolveSpecDir(cwd, opts = {}) {
139
139
  // 只缓存"git root 已找到"的结果——非 git 仓库不缓存,防测试流程中途 git init 后读到陈旧 null。
140
140
  const _ancestorCeilingCache = new Map()
141
141
 
142
- function resolveAncestorCeiling(resolved) {
142
+ export function resolveAncestorCeiling(resolved) {
143
143
  if (_ancestorCeilingCache.has(resolved)) return _ancestorCeilingCache.get(resolved)
144
144
  // 一次 spawn 拿两值(rev-parse 按参数序输出,每值一行)
145
145
  const out = safeGit(resolved, ['rev-parse', '--show-toplevel', '--git-common-dir']).value
package/src/run/stage.js CHANGED
@@ -93,6 +93,26 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
93
93
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
94
94
  await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, existingMeta)
95
95
  }
96
+
97
+ // ── 跨仓仓 worktree 隔离(坑 cross-repo-no-worktree-isolation,2026-08-27 用户实证)──
98
+ // 主仓 worktree 就绪后,为 plan 声明的每个跨仓仓建同构 worktree(幂等)。此前跨仓 task 直写
99
+ // 跨仓主工作副本,与用户在途改动混流、无 base 锚、无回滚面。fail-closed:建不起来不开工
100
+ //(对齐主仓 create 语义)。
101
+ try {
102
+ const { ensureCrossWorktrees } = await import('../worktree-cross.js')
103
+ const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
104
+ const cross = ensureCrossWorktrees({ cwd, changeName: effectiveChange, specBase: execSpecBase })
105
+ for (const c of cross.created) {
106
+ console.log(`🔗 跨仓 worktree 已创建: repo=${c.repoKey} → ${c.worktreePath}`)
107
+ }
108
+ for (const r of cross.reused) {
109
+ console.log(`🔗 跨仓 worktree 已存在: repo=${r.repoKey} → ${r.worktreePath}`)
110
+ }
111
+ } catch (e) {
112
+ console.error(`❌ 跨仓 worktree 创建失败: ${e.message}`)
113
+ console.error(` 修复后重跑本命令(已建成功的仓会幂等复用)。`)
114
+ process.exit(1)
115
+ }
96
116
  }
97
117
 
98
118
  // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
@@ -228,8 +248,14 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
228
248
 
229
249
  let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
230
250
 
231
- // stale 步骤视为可执行(等同于 pending)
232
- if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
251
+ // stale/blocked 步骤视为可执行(等同于 pending):
252
+ // stale = reopen 后未重做(原语义);blocked = 门控(deps/review.json)阻断标记。
253
+ // blocked 必须一并转换(坑 deps-gate-blocked-invisible 的路径①失效面):reopen 后首个未完成
254
+ // 步骤若是 blocked(早前门控阻断污染),只转 stale 会让 completeStep 的 findIndex(pending)
255
+ // 跳过它、--done 永远撞 stale 门控报错——「逐个真实执行」指引失效。门控在 --done 时会
256
+ // 重新校验,此处拉回 pending 不会绕过任何校验。
257
+ if (currentIdx !== -1 && (steps[currentIdx].status === 'stale' || steps[currentIdx].status === 'blocked')) {
258
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 处于 ${steps[currentIdx].status},已拉回待执行。`)
233
259
  steps[currentIdx].status = 'pending'
234
260
  pm._write(cwd, progress, changeName)
235
261
  triggerSync(cwd, changeName, platformOpts)
@@ -459,13 +459,26 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
459
459
  * 历史教训:原正则锚定确切「## 结论」,用户写「## 验收结论:✅ PASS」不被识别。
460
460
  */
461
461
  function extractVerifyConclusion(verify) {
462
- const headingRe = /^##\s[^\n]*(?:结论|conclusion|result|结果)/im
463
- const headingMatch = verify.match(headingRe)
464
- if (!headingMatch) return ''
465
- const start = headingMatch.index
466
- const slice = verify.slice(start, start + 400)
467
- const kw = slice.match(/\b(PASS(?:\s+WITH\s+NOTES)?|FAIL)\b/i)
468
- return kw ? kw[1].toUpperCase().replace(/\s+/g, ' ') : ''
462
+ // 遍历所有含关键词的二级标题,取其 400 字符窗口内含 PASS/FAIL 的那个(坑
463
+ // verify-conclusion-heading-hijack:旧逻辑取首个匹配,"## 测试结果"等普通标题
464
+ // 排在真结论前时劫持识别)。优先精确匹配「## 结论」类窄标题。
465
+ const headingRe = /^##\s[^\n]*(?:结论|conclusion|result|结果)/gim
466
+ let best = null
467
+ let bestPriority = -1
468
+ for (const headingMatch of verify.matchAll(headingRe)) {
469
+ const start = headingMatch.index
470
+ const slice = verify.slice(start, start + 400)
471
+ const kw = slice.match(/\b(PASS(?:\s+WITH\s+NOTES)?|FAIL)\b/i)
472
+ if (!kw) continue
473
+ // 优先级:精确含「结论/conclusion」> 宽泛含「result/结果」
474
+ const text = headingMatch[0].toLowerCase()
475
+ const priority = (/结论|conclusion/i.test(text)) ? 1 : 0
476
+ if (priority > bestPriority || (priority === bestPriority && !best)) {
477
+ best = kw[1].toUpperCase().replace(/\s+/g, ' ')
478
+ bestPriority = priority
479
+ }
480
+ }
481
+ return best || ''
469
482
  }
470
483
 
471
484
  function validateVerifyOutputs(cwd, changeName, context = {}) {
@@ -1006,14 +1006,19 @@ ${indexLines}
1006
1006
  return { taskNum, repo, workdir }
1007
1007
  })
1008
1008
 
1009
- // D-010 跨仓 task base 锡点:派发前实时 git rev-parse HEAD 落 task 卡 base_commit
1010
- // 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext 已处理)
1009
+ // D-010 跨仓 task base 锡点:派发前落 task 卡 base_commit
1010
+ // 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext 已处理)。
1011
+ // worktree 模式(跨仓 worktree 隔离,坑 cross-repo-no-worktree-isolation)优先 entry.baseCommitHint
1012
+ //(= meta.baseHash 创建时快照)——worktree HEAD 会随子代理 commit 推进,rev-parse HEAD 当 base
1013
+ // 会把 Wave 内前序 task 的交付错算进后续 task 的 base;legacy 直写模式沿用跨仓根实时 HEAD。
1011
1014
  const baseCommitCache = new Map() // 同仓多 task 只 spawn 一次
1012
1015
  for (const item of perTaskWorkdirs) {
1013
1016
  if (item.repo === 'main') continue
1014
1017
  const entry = ctx.resolve(item.repo)
1015
1018
  if (!entry) continue
1016
- if (!baseCommitCache.has(entry.gitDir)) baseCommitCache.set(entry.gitDir, gitQuiet(entry.gitDir, ['rev-parse', 'HEAD']))
1019
+ if (!baseCommitCache.has(entry.gitDir)) {
1020
+ baseCommitCache.set(entry.gitDir, entry.baseCommitHint || gitQuiet(entry.gitDir, ['rev-parse', 'HEAD']))
1021
+ }
1017
1022
  const baseCommit = baseCommitCache.get(entry.gitDir)
1018
1023
  if (!baseCommit) continue // git 不可达已由 MultiRepoContext 构造时 fail-closed 拦截,此处置防御
1019
1024
  const taskFile = changeDir
@@ -1023,28 +1028,43 @@ ${indexLines}
1023
1028
  baseCommitWrites.push({ taskNum: item.taskNum, repo: item.repo, taskFile, written, baseCommit })
1024
1029
  }
1025
1030
 
1026
- // 跨仓 task commit 指引 + head_commit 落盘指引(D-010 回收时机)
1031
+ // 跨仓 task commit 指引 + head_commit 落盘指引(D-010 回收时机)。
1032
+ // worktree 模式(跨仓 worktree 隔离):子代理在跨仓 worktree 分支改+commit,apply 阶段 CLI
1033
+ // 统一 patch 回跨仓主工作区(与主仓同构);legacy 直写模式保留原文案(commit 直落跨仓主干)。
1034
+ const crossWorktreeItems = perTaskWorkdirs.filter(i => {
1035
+ const e = i.repo !== 'main' ? ctx.resolve(i.repo) : null
1036
+ return e && e.isWorktree
1037
+ })
1038
+ const crossLegacyItems = perTaskWorkdirs.filter(i => i.repo !== 'main' && !crossWorktreeItems.some(w => w.taskNum === i.taskNum))
1027
1039
  const crossLines = perTaskWorkdirs
1028
1040
  .filter(i => i.repo !== 'main')
1029
- .map(i => `- task-${i.taskNum} → repo \`${i.repo}\`,workdir=\`${i.workdir}\``)
1041
+ .map(i => {
1042
+ const e = ctx.resolve(i.repo)
1043
+ return `- task-${i.taskNum} → repo \`${i.repo}\`,workdir=\`${i.workdir}\`${e && e.isWorktree ? '(worktree 隔离)' : ''}`
1044
+ })
1030
1045
  .join('\n')
1031
- crossRepoCommitSection = `
1032
- ### 跨仓 task 派发与双锡点
1033
-
1034
- 本 Wave 含跨仓 task(直接改跨仓仓主干,**不经主仓 worktree**):
1046
+ const crossWorktreeSection = crossWorktreeItems.length > 0 ? `
1035
1047
 
1036
- ${crossLines}
1048
+ **跨仓 worktree task(repo 已建 worktree 隔离)——像主仓 task 一样工作:**
1049
+ - 子代理在 workdir(跨仓 worktree)内改+commit(git add + git commit 到 worktree 当前分支),**不要碰跨仓主工作副本**。
1050
+ - apply 阶段 CLI 统一把各跨仓 worktree 的交付 patch 回对应跨仓主工作区(勿手工 apply)。
1051
+ - base 已锚 worktree meta.baseHash(创建时快照),review 的 base/head 由 CLI 按 task 卡锡点解析。` : ''
1052
+ const crossLegacySection = crossLegacyItems.length > 0 ? `
1037
1053
 
1038
1054
  **派发跨仓 task 前(base 锡点,CLI 已在 prompt 构造时落盘):**
1039
1055
  - 跨仓 task 卡 frontmatter 的 \`base_commit\` 已由 CLI 实时 \`git -C <跨仓仓根> rev-parse HEAD\` 锁定(base 锡点,约束①)。子代理在此 HEAD 上改+commit,不受同 Wave 其他 task 推进 HEAD 影响。
1040
1056
 
1057
+ **跨仓 task 子代理 prompt 必须注入(legacy 直写模式):**
1058
+ > 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
1059
+ > task 卡 allowed_paths 是相对**该仓根**的路径(如 \`src/routes/x.js\`)——不带仓库名前缀(\`<repo>/src/...\` ❌)、不是绝对路径;在 workdir 下直接按相对路径定位文件。` : ''
1060
+ crossRepoCommitSection = `
1061
+ ### 跨仓 task 派发与双锡点
1062
+
1063
+ ${crossLines}${crossWorktreeSection}${crossLegacySection}
1064
+
1041
1065
  **回收跨仓 task(head 锡点,CLI 自动落盘,勿手写):**
1042
- - 子代理完成 commit 后,正常写 review.json(verdict/notes)并勾选 checkbox 即可。execute \`--done\` 时 CLI 自动 \`git -C <跨仓仓根> rev-parse HEAD\` 写入该 task 卡 \`head_commit:\`(幂等,已存在不覆盖——你若手写了精确锚点则以你的为准)。
1066
+ - 子代理完成 commit 后,正常写 review.json(verdict/notes)并勾选 checkbox 即可。execute \`--done\` 时 CLI 自动对跨仓仓(worktree 模式=该仓 worktree,legacy=仓根)\`git rev-parse HEAD\` 写入该 task 卡 \`head_commit:\`(幂等,已存在不覆盖——你若手写了精确锚点则以你的为准)。
1043
1067
  - review.json 的 mechanics 字段(\`base\`/\`head\`/\`changedFiles\`)无需手算:\`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`,写完跑 \`sillyspec backfill-reviews --change <变更名> --adopt\` 一键代填(verdict 保留)。
1044
-
1045
- **跨仓 task 子代理 prompt 必须注入:**
1046
- > 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
1047
- > task 卡 allowed_paths 是相对**该仓根**的路径(如 \`src/routes/x.js\`)——不带仓库名前缀(\`<repo>/src/...\` ❌)、不是绝对路径;在 workdir 下直接按相对路径定位文件。
1048
1068
  `
1049
1069
  }
1050
1070