sillyspec 3.24.7 → 3.25.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.
package/src/run.js CHANGED
@@ -4,8 +4,9 @@
4
4
  * CLI 成为流程引擎,AI 变成步骤执行器。
5
5
  * 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
6
6
  */
7
- import { basename, join, resolve, dirname, relative, isAbsolute } from 'path'
7
+ import { basename, join, resolve, dirname, relative, isAbsolute, extname } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
9
+ import { writeAtomicSync, renameSyncRetry } from './fs-atomic.js'
9
10
  import { randomBytes, randomUUID } from 'crypto'
10
11
  import { createRequire } from 'module'
11
12
  const require = createRequire(import.meta.url)
@@ -894,10 +895,14 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
894
895
  if (platformOpts.workspaceId) {
895
896
  platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
896
897
  }
897
- // 平台 directives 仅 step0 前置(对齐 persona 仅 step0 策略):路径列表/Write 规则/
898
- // 占位符映射一次建立即可;后续 step 靠 agent 跨步记忆 + 每步 changeDir header +
899
- // footer 平台规则(下方 L1077+)维持写入约束,避免每步重复注入 ~500 tokens。
900
- if (stepIndex === 0) {
898
+ // 平台 directives 前置条件:
899
+ // - step0(对齐 persona 仅 step0 策略):路径列表/Write 规则/占位符映射一次建立即可;
900
+ // - scan 阶段所有 step:scan 的 profileDirectives 本就每步注入(见下方 L917),平台路径
901
+ // 约束也需每步可见——否则 quick profile 的 step0 是 noAI preflight(自动执行),run scan
902
+ // 输出的首个可见 step 是 step1,会漏掉 specDir 路径,导致平台模式写入约束丢失。
903
+ // 后续 step 靠 changeDir header + footer 平台规则(L1077+)维持,platformDirectives
904
+ // 每步重复约 500 tokens 仅在 scan(步数有限)可接受。
905
+ if (stepIndex === 0 || stageName === 'scan') {
901
906
  promptText = platformDirectives.join('\n') + '\n\n' + promptText
902
907
  }
903
908
  } else {
@@ -1115,6 +1120,36 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
1115
1120
  console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
1116
1121
  }
1117
1122
 
1123
+ /**
1124
+ * 估算源码规模(文件数 + 字节数)—— 跨平台 Node 遍历,替代 Unix `find`(原生 Windows 无 find,
1125
+ * 原 find 失效会让所有 Windows 项目永远 fallback 到 standard profile)。
1126
+ * 扩展名与排除目录与原 find 命令逐字一致;maxFiles 封顶避免超大仓库遍历过久
1127
+ * (超限时 fileCount 已远超 quick/standard 阈值,不影响 profile 判定)。
1128
+ */
1129
+ function estimateSourceSize(cwd, maxFiles = 5000) {
1130
+ const sourceExts = new Set(['.js','.ts','.tsx','.py','.java','.go','.rs','.rb','.php','.c','.cpp','.h','.jsx','.vue','.svelte'])
1131
+ const skipDirs = new Set(['node_modules','.git','dist','build','__pycache__','.sillyspec','.claude'])
1132
+ let fileCount = 0
1133
+ let sourceBytes = 0
1134
+ const stack = [cwd]
1135
+ while (stack.length) {
1136
+ const dir = stack.pop()
1137
+ let entries
1138
+ try { entries = readdirSync(dir, { withFileTypes: true }) }
1139
+ catch { continue }
1140
+ for (const e of entries) {
1141
+ if (e.isDirectory()) {
1142
+ if (!skipDirs.has(e.name)) stack.push(join(dir, e.name))
1143
+ } else if (e.isFile() && sourceExts.has(extname(e.name))) {
1144
+ fileCount++
1145
+ try { sourceBytes += statSync(join(dir, e.name)).size } catch {}
1146
+ if (fileCount >= maxFiles) return { fileCount, sourceBytes }
1147
+ }
1148
+ }
1149
+ }
1150
+ return { fileCount, sourceBytes }
1151
+ }
1152
+
1118
1153
  /**
1119
1154
  * 根据 project 规模计算 scan profile
1120
1155
  * quick: fileCount≤30 && sourceBytes≤80KB && projectCount≤3 → 3 步,0 子代理,5 份文档
@@ -1137,19 +1172,15 @@ function computeScanProfile(cwd, platformOpts) {
1137
1172
  }
1138
1173
  } catch {}
1139
1174
 
1140
- // 快速估算源码规模
1175
+ // 快速估算源码规模(Node 遍历,跨平台;原 Unix find 在原生 Windows 上失效会永远 fallback standard)
1141
1176
  let fileCount = 0
1142
1177
  let sourceBytes = 0
1143
1178
  try {
1144
- const { execSync } = require('child_process')
1145
- const findCmd = `find "${cwd}" -type f \\( -name '*.js' -o -name '*.ts' -o -name '*.tsx' -o -name '*.py' -o -name '*.java' -o -name '*.go' -o -name '*.rs' -o -name '*.rb' -o -name '*.php' -o -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.jsx' -o -name '*.vue' -o -name '*.svelte' \\) -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/build/*' -not -path '*/__pycache__/*' -not -path '*/.sillyspec/*' -not -path '*/.claude/*' 2>/dev/null`
1146
- const files = execSync(findCmd, { encoding: 'utf8', timeout: 10000 }).trim().split('\n').filter(Boolean)
1147
- fileCount = files.length
1148
- for (const f of files) {
1149
- try { sourceBytes += statSync(f).size } catch {}
1150
- }
1179
+ const est = estimateSourceSize(cwd)
1180
+ fileCount = est.fileCount
1181
+ sourceBytes = est.sourceBytes
1151
1182
  } catch {
1152
- // find 失败时假设中等规模
1183
+ // 遍历异常时假设中等规模
1153
1184
  return { mode: 'standard', reason: '无法估算项目规模', maxAgentCalls: 1, maxDocs: 8 }
1154
1185
  }
1155
1186
 
@@ -1245,12 +1276,15 @@ async function executeScanPreflight(cwd, platformOpts, scanProfile) {
1245
1276
  const projectName = basename(cwd)
1246
1277
  console.log(` 📁 项目: ${projectName}`)
1247
1278
  console.log(` 📊 Profile: ${scanProfile.mode} (${scanProfile.reason})`)
1248
- // 快速列出顶层结构
1279
+ // 快速列出顶层结构(readdirSync 跨平台,避免 spawn shell + 5 个 grep 进程)
1249
1280
  try {
1250
- const { execSync } = await import('child_process')
1251
- const dirs = execSync(`ls -d */ 2>/dev/null | grep -v node_modules | grep -v '.git' | grep -v '.sillyspec' | grep -v '.claude' | head -20`, { cwd, encoding: 'utf8' }).trim()
1252
- if (dirs) {
1253
- console.log(` 📂 目录: ${dirs.split('\n').map(d => d.replace(/\/$/, '')).join(', ')}`)
1281
+ const skip = new Set(['node_modules', '.git', '.sillyspec', '.claude'])
1282
+ const dirNames = readdirSync(cwd, { withFileTypes: true })
1283
+ .filter(d => d.isDirectory() && !skip.has(d.name) && !d.name.startsWith('.'))
1284
+ .slice(0, 20)
1285
+ .map(d => d.name)
1286
+ if (dirNames.length) {
1287
+ console.log(` 📂 目录: ${dirNames.join(', ')}`)
1254
1288
  }
1255
1289
  } catch {}
1256
1290
  console.log(` ✅ Preflight 完成,准备生成核心文档\n`)
@@ -1329,6 +1363,24 @@ async function executePlanPostcheck(cwd, platformOpts, progress) {
1329
1363
  })
1330
1364
  }
1331
1365
 
1366
+ /**
1367
+ * 替换 prompt 文本中的路径根占位符 {SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
1368
+ * {WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}。平台模式与常规模式共用:仅传入的 roots 值不同。
1369
+ * 占位符值都是绝对路径、互不包含其它占位符,故替换顺序不影响结果。
1370
+ *
1371
+ * 注:本函数由并行会话引入(outputStep 两处逐字重复逻辑的抽取),重构期间 run.js 一次
1372
+ * git checkout 误覆盖其未提交版本,此处按 test/prompt-placeholders.test.mjs 的契约重建,
1373
+ * 行为等价。
1374
+ */
1375
+ export function applyRootPlaceholders(text, roots) {
1376
+ return text
1377
+ .replaceAll('{SPEC_ROOT}', roots.specRoot)
1378
+ .replaceAll('{DOCS_ROOT}', roots.docsRoot)
1379
+ .replaceAll('{PROJECTS_ROOT}', roots.projectsRoot)
1380
+ .replaceAll('{WORKFLOWS_ROOT}', roots.workflowsRoot)
1381
+ .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)
1382
+ }
1383
+
1332
1384
  /**
1333
1385
  * sillyspec run <stage> 主命令
1334
1386
  */
@@ -2199,7 +2251,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
2199
2251
  // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
2200
2252
  // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
2201
2253
  mkdirSync(sessionGuardDir, { recursive: true })
2202
- writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2254
+ writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2203
2255
  const parts = [`${baselineFiles.length} 个已有脏文件`]
2204
2256
  if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
2205
2257
  if (allowNew) parts.push('允许新增文件')
@@ -2383,7 +2435,13 @@ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
2383
2435
  process.exit(1)
2384
2436
  }
2385
2437
  mkdirSync(archiveDir, { recursive: true })
2386
- renameSync(srcDir, destDir)
2438
+ try {
2439
+ renameSyncRetry(srcDir, destDir)
2440
+ } catch (e) {
2441
+ console.error(`❌ 归档失败:移动变更目录时出错(${e.message})`)
2442
+ console.error(` 常见原因:变更目录内文件被 IDE / 杀毒 / 索引占用,已重试 5 次仍失败。请关闭相关程序后重试。`)
2443
+ process.exit(1)
2444
+ }
2387
2445
 
2388
2446
  if (!existsSync(destDir) || existsSync(srcDir)) {
2389
2447
  console.error('❌ 归档校验失败:移动操作异常')
@@ -2749,7 +2807,11 @@ async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentI
2749
2807
  const { WorktreeManager } = await import('./worktree.js')
2750
2808
  wm = new WorktreeManager({ cwd })
2751
2809
  meta = wm.getMeta(changeName)
2752
- } catch {}
2810
+ } catch (e) {
2811
+ // WorktreeManager 构造/读取失败(git 不可用 / worktree 元数据损坏)不阻断 deps gate;
2812
+ // 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
2813
+ console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
2814
+ }
2753
2815
  const depsStatus = meta?.depsStatus
2754
2816
  if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
2755
2817
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
@@ -2830,12 +2892,469 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
2830
2892
  }
2831
2893
  }
2832
2894
 
2895
+ /**
2896
+ * 阶段完成校验失败后的统一收尾:回滚 stage/step 状态 + 落盘 + sync + 返回「未完成」。
2897
+ *
2898
+ * completeStep 的各 gate(runValidators / verify-test 对账 / plan→execute contract /
2899
+ * Stage Review / Execute Task Review)失败时都走这套动作;原先每个失败分支手写重复
2900
+ * ~7 次(含 lastActive 落盘 + triggerSync + return 结构),统一进来消坑,避免某分支
2901
+ * 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
2902
+ * 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
2903
+ */
2904
+ async function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
2905
+ rollbackStageCompletion(stageData, steps, currentIdx)
2906
+ progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
2907
+ await pm._write(cwd, progress, changeName)
2908
+ triggerSync(cwd, changeName, platformOpts)
2909
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2910
+ }
2911
+
2912
+ /**
2913
+ * archive 阶段「确认归档」步骤的收尾处理器(从 completeStep 抽出,行为保持)。
2914
+ *
2915
+ * 两件事:
2916
+ * 1. --confirm 门控:缺 --confirm → 回退 step 状态、提示、返回 early-return 对象(completeStep 透传)
2917
+ * 2. --confirm 通过 → archiveChangeDirectory 移动变更目录 + 推荐文档(design.md / module-impact.md)校验
2918
+ * (contracts.archive.validators 为空,两个 validator 生效窗口互斥;plan.md 已在移动前硬校验阻断)
2919
+ *
2920
+ * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
2921
+ */
2922
+ async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase }) {
2923
+ if (stageName !== 'archive' || steps[currentIdx]?.name !== '确认归档') return null
2924
+ if (!confirm) {
2925
+ steps[currentIdx].status = 'pending'
2926
+ steps[currentIdx].completedAt = null
2927
+ if (outputText) steps[currentIdx].output = null
2928
+ await pm._write(cwd, progress, changeName)
2929
+ console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
2930
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2931
+ }
2932
+ const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
2933
+ if (archivedDir && existsSync(archivedDir)) {
2934
+ const recommendedDocs = ['design.md', 'module-impact.md']
2935
+ const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
2936
+ if (missingRecommended.length > 0) {
2937
+ console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
2938
+ for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
2939
+ } else {
2940
+ console.log(`\n✅ 归档校验通过:核心文档齐全`)
2941
+ }
2942
+ }
2943
+ return null
2944
+ }
2945
+
2946
+ /**
2947
+ * plan 阶段「generate_plan」步骤完成后,动态插入任务蓝图(coordinator)+ postcheck 步骤
2948
+ * (从 completeStep 抽出,行为保持)。使用稳定 id 匹配,不依赖中文标题。
2949
+ *
2950
+ * plan.md 已含任务时,buildPlanSteps 返回 [fixedPrefix(classify/generate_plan/review_plan),
2951
+ * coordinator, postcheck];本函数把 generate_plan 之后的 coordinator+postcheck 插到当前步后。
2952
+ */
2953
+ async function handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress }) {
2954
+ if (stageName !== 'plan') return
2955
+ const currentStepDef = defStepsForCurrent?.[currentIdx]
2956
+ const currentStepEntry = steps[currentIdx]
2957
+ const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
2958
+ if (stepId !== 'generate_plan') return
2959
+ const changeDir = resolveChangeDir(cwd, progress)
2960
+ if (!changeDir) return
2961
+ const planFile = join(changeDir, 'plan.md')
2962
+ if (!existsSync(planFile)) return
2963
+ const planContent = readFileSync(planFile, 'utf8')
2964
+ const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('./stages/plan.js')
2965
+ const fullSteps = buildPlanSteps(changeDir, planContent)
2966
+ const prefixLen = fixedPrefix.length
2967
+ const suffixLen = fixedSuffix.length
2968
+ // 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?];fixedSuffix 为空,coordinator+postcheck 都在 prefix 之后
2969
+ const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
2970
+ if (coordinatorSteps.length === 0) return
2971
+ for (let i = 0; i < coordinatorSteps.length; i++) {
2972
+ const stepDef = coordinatorSteps[i]
2973
+ const stepEntry = {
2974
+ id: stepDef.id,
2975
+ name: stepDef.name,
2976
+ status: 'pending',
2977
+ prompt: stepDef.prompt || '',
2978
+ outputHint: stepDef.outputHint,
2979
+ optional: stepDef.optional
2980
+ }
2981
+ // 传递 noAI / _cliAction 属性
2982
+ if (stepDef.noAI) stepEntry.noAI = true
2983
+ if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
2984
+ steps.splice(currentIdx + 1 + i, 0, stepEntry)
2985
+ }
2986
+ console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
2987
+ }
2988
+
2989
+ /**
2990
+ * scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(从 completeStep 抽出,行为保持)。
2991
+ *
2992
+ * 只接受结构化输出(scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块),校验通过后
2993
+ * 自动注册 projects/<id>.yaml + 写 scan-projects.json + 把 perProject 步骤按项目展开。
2994
+ * 不展开 completeStep 提前 return(失败只记 validationError,由 completeStep 继续推进下一步)。
2995
+ */
2996
+ async function handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts }) {
2997
+ if (stageName !== 'scan' || steps[currentIdx]?.name !== '构建扫描项目列表') return
2998
+ // 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
2999
+ // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
3000
+ let parsedProjects = [] // Array<{id, path?}>
3001
+ let parsedFromStructuredOutput = false
3002
+ if (outputText) {
3003
+ // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
3004
+ const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
3005
+ if (yamlBlock) {
3006
+ const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
3007
+ for (const m of entries) {
3008
+ const id = sanitizeProjectName(m[1])
3009
+ if (!id) continue
3010
+ // 提取可选 path 字段
3011
+ const pathMatch = m[0].match(/path:\s*(\S+)/)
3012
+ const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
3013
+ parsedProjects.push(entry)
3014
+ }
3015
+ parsedFromStructuredOutput = parsedProjects.length > 0
3016
+ }
3017
+ // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
3018
+ if (!parsedFromStructuredOutput) {
3019
+ const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
3020
+ if (blockMatch) {
3021
+ const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
3022
+ parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
3023
+ parsedFromStructuredOutput = parsedProjects.length > 0
3024
+ }
3025
+ }
3026
+ }
3027
+
3028
+ const projectNames = parsedProjects.map(p => p.id)
3029
+
3030
+ if (parsedFromStructuredOutput) {
3031
+ stageData.scanMeta = stageData.scanMeta || {}
3032
+ stageData.scanMeta.projectListParsed = true
3033
+ } else {
3034
+ // 结构化输出未解析到 → 回退读取已有 projects/*.yaml
3035
+ // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
3036
+ console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
3037
+ stageData.scanMeta = stageData.scanMeta || {}
3038
+ stageData.scanMeta.projectListParsed = false
3039
+ const projectsDir = join(specBase, 'projects')
3040
+ if (existsSync(projectsDir)) {
3041
+ const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
3042
+ const fallbackProjects = []
3043
+ const fallbackSkipped = []
3044
+ for (const yf of yamlFiles) {
3045
+ const pName = yf.replace(/\.yaml$/, '')
3046
+ const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
3047
+ const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
3048
+ const pPath = pathMatch ? pathMatch[1].trim() : pName
3049
+ // 校验 path 是否存在且在 source_root 内
3050
+ const absPath = resolve(cwd, pPath)
3051
+ if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
3052
+ fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
3053
+ continue
3054
+ }
3055
+ if (!existsSync(absPath)) {
3056
+ fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
3057
+ continue
3058
+ }
3059
+ fallbackProjects.push({ id: pName, path: pPath })
3060
+ }
3061
+ if (fallbackSkipped.length > 0) {
3062
+ console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
3063
+ console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
3064
+ }
3065
+ parsedProjects = fallbackProjects
3066
+ projectNames.length = 0
3067
+ projectNames.push(...fallbackProjects.map(p => p.id))
3068
+ }
3069
+ if (parsedProjects.length === 0) {
3070
+ // 无结构化输出 + 无合法已有项目 → step 2 失败
3071
+ console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
3072
+ console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
3073
+ steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
3074
+ // 不展开 perProject 步骤,直接跳到下一步
3075
+ }
3076
+ }
3077
+
3078
+ // 校验解析出的项目列表(原子守卫:不通过就不落盘)
3079
+ const validation = validateParsedProjects(parsedProjects, cwd)
3080
+ if (!validation.ok) {
3081
+ console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
3082
+ console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
3083
+ steps[currentIdx].validationError = validation.errors.join('; ')
3084
+ }
3085
+
3086
+ // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
3087
+ const projectsDir = join(specBase, 'projects')
3088
+ if (validation.ok) {
3089
+ for (const proj of parsedProjects) {
3090
+ const pName = proj.id
3091
+ const projYaml = join(projectsDir, `${pName}.yaml`)
3092
+ if (!existsSync(projYaml)) {
3093
+ mkdirSync(projectsDir, { recursive: true })
3094
+ const candidates = [
3095
+ join(cwd, pName),
3096
+ join(cwd, 'backend', pName),
3097
+ join(cwd, 'packages', pName),
3098
+ join(cwd, 'apps', pName),
3099
+ join(cwd, 'services', pName),
3100
+ ]
3101
+ const detected = candidates.find(c => existsSync(c))
3102
+ const regPath = detected || join(cwd, pName)
3103
+ writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
3104
+ console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
3105
+ }
3106
+ }
3107
+
3108
+ // 保存 runtime 状态
3109
+ const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
3110
+ mkdirSync(join(specBase, '.runtime'), { recursive: true })
3111
+ let scanState = { projects: projectNames, expanded: false }
3112
+ if (existsSync(scanStatePath)) {
3113
+ try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
3114
+ }
3115
+
3116
+ // 收集当前步骤之后所有 perProject 步骤
3117
+ const stageDef = stageRegistry[stageName]
3118
+ const allSteps = stageDef?.steps || []
3119
+ const perProjectSteps = allSteps.filter(s => s.perProject)
3120
+
3121
+ // 防重复展开
3122
+ const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
3123
+ if (!alreadyExpanded && perProjectSteps.length > 0) {
3124
+ // 找到当前步骤(step 2)在动态 steps 中的位置
3125
+ const insertBase = currentIdx + 1
3126
+ let insertPos = insertBase
3127
+ for (const pName of projectNames) {
3128
+ // 读取项目配置获取 projectRoot
3129
+ const projYaml = join(specBase, 'projects', `${pName}.yaml`)
3130
+ let projectRoot = '.'
3131
+ if (existsSync(projYaml)) {
3132
+ const yamlContent = readFileSync(projYaml, 'utf8')
3133
+ const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
3134
+ if (pathMatch) projectRoot = pathMatch[1].trim()
3135
+ }
3136
+ const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
3137
+ const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
3138
+
3139
+ for (const ppStep of perProjectSteps) {
3140
+ steps.splice(insertPos, 0, {
3141
+ name: `${ppStep.name} [${pName}]`,
3142
+ project: pName,
3143
+ status: 'pending',
3144
+ prompt: contextPrefix + ppStep.prompt,
3145
+ outputHint: ppStep.outputHint,
3146
+ optional: ppStep.optional
3147
+ })
3148
+ insertPos++
3149
+ }
3150
+ }
3151
+ // 移除原始的 perProject 步骤(未展开的版本)
3152
+ for (let i = steps.length - 1; i >= 0; i--) {
3153
+ if (steps[i].perProject && !steps[i].name?.includes('[')) {
3154
+ steps.splice(i, 1)
3155
+ }
3156
+ }
3157
+ console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
3158
+ console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
3159
+ // 标记已展开,防止 resume 重复插入
3160
+ scanState.expanded = true
3161
+ writeFileSync(scanStatePath, JSON.stringify(scanState))
3162
+ } // end !alreadyExpanded
3163
+ } // end validation.ok
3164
+ }
3165
+
3166
+ /**
3167
+ * 阶段完成校验 gate 级联(从 completeStep 抽出,行为保持)。仅当所有步骤确实标记为 completed 时
3168
+ * 由 completeStep 调用。顺序:runValidators → verify-test 对账 → Plan→Execute contract →
3169
+ * Stage Review Gate → Execute Task Review Gate。任一 gate 失败 → rollbackCompletionAndReturn
3170
+ * (统一回滚 + 返回 early-return 对象);全部通过 → 返回 null(completeStep 继续收尾)。
3171
+ *
3172
+ * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
3173
+ */
3174
+ async function runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx }) {
3175
+ const projectName = progress.project || basename(cwd)
3176
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
3177
+ if (contractResult.errors.length > 0) {
3178
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
3179
+ for (const err of contractResult.errors) {
3180
+ console.error(` - ${err}`)
3181
+ }
3182
+ console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
3183
+ // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
3184
+ // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
3185
+ // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
3186
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3187
+ }
3188
+ if (contractResult.warnings.length > 0) {
3189
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
3190
+ for (const w of contractResult.warnings) {
3191
+ console.warn(` - ${w}`)
3192
+ }
3193
+ }
3194
+
3195
+ // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
3196
+ // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
3197
+ // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
3198
+ if (stageName === 'verify') {
3199
+ const { runVerifyTestCheck, printVerifyTestCheck } = await import('./verify-postcheck.js')
3200
+ const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
3201
+ printVerifyTestCheck(testCheck)
3202
+ if (testCheck.status === 'failed') {
3203
+ console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
3204
+ console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
3205
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3206
+ }
3207
+ // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
3208
+ // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
3209
+ const { runVerifyParityCheck, printVerifyParityCheck } = await import('./verify-postcheck.js')
3210
+ const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: platformOpts?.runtimeRoot })
3211
+ printVerifyParityCheck(parityCheck)
3212
+ console.log('\n✅ 验证通过,下一步:sillyspec run archive')
3213
+ }
3214
+
3215
+ // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
3216
+ if (stageName === 'plan') {
3217
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3218
+ const planPath = planFile ? join(planFile, 'plan.md') : null
3219
+ if (planPath && existsSync(planPath)) {
3220
+ const { validatePlanForExecute } = await import('./stages/execute.js')
3221
+ const planContent = readFileSync(planPath, 'utf8')
3222
+ const planValidation = validatePlanForExecute(planContent)
3223
+ if (!planValidation.ok) {
3224
+ console.error(`\n❌ Plan → Execute Contract 校验失败:`)
3225
+ for (const err of planValidation.errors) console.error(` - ${err}`)
3226
+ console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
3227
+ // 阻断 completed
3228
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3229
+ }
3230
+ if (planValidation.warnings.length > 0) {
3231
+ console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
3232
+ for (const w of planValidation.warnings) console.warn(` - ${w}`)
3233
+ }
3234
+ if (planValidation.ok) {
3235
+ console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
3236
+ }
3237
+ }
3238
+ }
3239
+
3240
+ // ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
3241
+ // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
3242
+ if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
3243
+ try {
3244
+ const { classifyReviewTier } = await import('./review-tier.js')
3245
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('./stage-review.js')
3246
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
3247
+ const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3248
+ const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
3249
+ let planLevel = null
3250
+ if (reviewChangeDir) {
3251
+ const planPath = join(reviewChangeDir, 'plan.md')
3252
+ if (existsSync(planPath)) {
3253
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
3254
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
3255
+ }
3256
+ }
3257
+ const tier = classifyReviewTier({ planLevel, designPath })
3258
+ const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
3259
+
3260
+ if (tier.tier === 'self') {
3261
+ console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
3262
+ } else {
3263
+ const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName)
3264
+ const reviewType = stageName === 'brainstorm' ? 'design'
3265
+ : stageName === 'plan' ? 'plan'
3266
+ : stageName === 'propose' ? 'proposal'
3267
+ : 'acceptance'
3268
+ const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
3269
+ const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
3270
+ printStageReviewResult(reviewResult, { stage: stageName })
3271
+ if (!reviewResult.ok) {
3272
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3273
+ }
3274
+ }
3275
+ } catch (e) {
3276
+ // fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
3277
+ console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
3278
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3279
+ }
3280
+ }
3281
+
3282
+ // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
3283
+ if (stageName === 'execute') {
3284
+ try {
3285
+ const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('./task-review.js')
3286
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
3287
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3288
+ const planPath = planFile ? join(planFile, 'plan.md') : null
3289
+
3290
+ if (planPath && existsSync(planPath)) {
3291
+ const planContent = readFileSync(planPath, 'utf8')
3292
+ const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
3293
+
3294
+ // execute run id:从变更专属标记文件读取
3295
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
3296
+ let executeRunId = ''
3297
+ try {
3298
+ if (existsSync(runIdFile)) {
3299
+ executeRunId = readFileSync(runIdFile, 'utf8').trim()
3300
+ }
3301
+ } catch {}
3302
+ if (!executeRunId) {
3303
+ const { generateExecuteRunId } = await import('./task-review.js')
3304
+ executeRunId = generateExecuteRunId()
3305
+ // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
3306
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
3307
+ }
3308
+
3309
+ // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
3310
+ let reviewGitDir = cwd
3311
+ try {
3312
+ const { WorktreeManager } = await import('./worktree.js')
3313
+ const wm = new WorktreeManager({ cwd })
3314
+ const meta = wm.getMeta(changeName)
3315
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
3316
+ reviewGitDir = meta.worktreePath
3317
+ }
3318
+ } catch {}
3319
+
3320
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir })
3321
+ printReviewResult(reviewResult, { runtimeRoot, executeRunId })
3322
+
3323
+ if (!reviewResult.ok) {
3324
+ // Task review 校验失败,阻断 execute 完成
3325
+ // 检查是否存在 checkbox 已勾但 review 不通过的情况
3326
+ const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
3327
+ if (uncheckedTasks.length > 0) {
3328
+ console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
3329
+ console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
3330
+ }
3331
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3332
+ }
3333
+
3334
+ // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
3335
+ if (reviewResult.requiredEvidence.length > 0) {
3336
+ const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
3337
+ if (evidencePath) {
3338
+ console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
3339
+ console.log(' verify 阶段必须满足这些证据要求。')
3340
+ }
3341
+ }
3342
+ }
3343
+ } catch (e) {
3344
+ // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
3345
+ console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
3346
+ console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
3347
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
3348
+ }
3349
+ }
3350
+ return null
3351
+ }
3352
+
2833
3353
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
2834
3354
  const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
2835
3355
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
2836
3356
  const stageData = progress.stages[stageName]
2837
3357
  const scanProfile = stageData?.scanProfile || null
2838
- let archivedDir = null // archive step「确认归档」实际归档目录(archiveChangeDirectory 返回)
2839
3358
 
2840
3359
  // ── WAIT MARKER 硬校验 ──
2841
3360
  // 如果 output 包含等待标记,拒绝 --done 推进
@@ -2926,246 +3445,34 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2926
3445
  }
2927
3446
  }
2928
3447
 
2929
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
2930
- if (!confirm) {
2931
- steps[currentIdx].status = 'pending'
2932
- steps[currentIdx].completedAt = null
2933
- if (outputText) steps[currentIdx].output = null
2934
- await pm._write(cwd, progress, changeName)
2935
- console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
2936
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2937
- }
2938
- archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
2939
- }
2940
-
2941
- // archive "确认归档" 步骤完成后,校验归档完整性
2942
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
2943
- // contracts.archive.validators 为空数组(两个 validator 生效窗口互斥,注册为阶段级会误报),
2944
- // 故 runValidators('archive') 是空跑。这里直接对实际归档目录做内容完整性校验。
2945
- // plan.md 已在 archiveChangeDirectory 移动前硬校验阻断,此处只查推荐文档。
2946
- if (archivedDir && existsSync(archivedDir)) {
2947
- const recommendedDocs = ['design.md', 'module-impact.md']
2948
- const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
2949
- if (missingRecommended.length > 0) {
2950
- console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
2951
- for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
2952
- } else {
2953
- console.log(`\n✅ 归档校验通过:核心文档齐全`)
2954
- }
2955
- }
2956
- }
2957
-
2958
- // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤
2959
- // 使用稳定 id 匹配,不依赖中文标题
2960
- if (stageName === 'plan') {
2961
- const currentStepDef = defStepsForCurrent?.[currentIdx]
2962
- const currentStepEntry = steps[currentIdx]
2963
- const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
2964
- if (stepId === 'generate_plan') {
2965
- const changeDir = resolveChangeDir(cwd, progress)
2966
- if (changeDir) {
2967
- const planFile = join(changeDir, 'plan.md')
2968
- if (existsSync(planFile)) {
2969
- const planContent = readFileSync(planFile, 'utf8')
2970
- const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('./stages/plan.js')
2971
- const fullSteps = buildPlanSteps(changeDir, planContent)
2972
- const prefixLen = fixedPrefix.length
2973
- const suffixLen = fixedSuffix.length
2974
- // 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?]
2975
- // fixedSuffix 为空,所以 coordinator + postcheck 都在 prefix 之后
2976
- const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
2977
- if (coordinatorSteps.length > 0) {
2978
- for (let i = 0; i < coordinatorSteps.length; i++) {
2979
- const stepDef = coordinatorSteps[i]
2980
- const stepEntry = {
2981
- id: stepDef.id,
2982
- name: stepDef.name,
2983
- status: 'pending',
2984
- prompt: stepDef.prompt || '',
2985
- outputHint: stepDef.outputHint,
2986
- optional: stepDef.optional
2987
- }
2988
- // 传递 noAI / _cliAction 属性
2989
- if (stepDef.noAI) stepEntry.noAI = true
2990
- if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
2991
- steps.splice(currentIdx + 1 + i, 0, stepEntry)
2992
- }
2993
- console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
2994
- }
2995
- }
2996
- }
2997
- }
3448
+ // archive「确认归档」收尾:--confirm 门控 + archiveChangeDirectory 移动 + 推荐文档校验
3449
+ // (抽成 handleArchiveConfirmStep;缺 --confirm 时返回 early-return 对象,completeStep 透传)
3450
+ {
3451
+ const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase })
3452
+ if (_archiveEarlyReturn) return _archiveEarlyReturn
2998
3453
  }
2999
3454
 
3000
- // scan 阶段 step 2 "构建扫描项目列表" 完成后,按项目展开 perProject 步骤
3001
- if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
3002
- // 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
3003
- // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
3004
- let parsedProjects = [] // Array<{id, path?}>
3005
- let parsedFromStructuredOutput = false
3006
- if (outputText) {
3007
- // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
3008
- const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
3009
- if (yamlBlock) {
3010
- const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
3011
- for (const m of entries) {
3012
- const id = sanitizeProjectName(m[1])
3013
- if (!id) continue
3014
- // 提取可选 path 字段
3015
- const pathMatch = m[0].match(/path:\s*(\S+)/)
3016
- const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
3017
- parsedProjects.push(entry)
3018
- }
3019
- parsedFromStructuredOutput = parsedProjects.length > 0
3020
- }
3021
- // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
3022
- if (!parsedFromStructuredOutput) {
3023
- const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
3024
- if (blockMatch) {
3025
- const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
3026
- parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
3027
- parsedFromStructuredOutput = parsedProjects.length > 0
3028
- }
3029
- }
3030
- }
3031
-
3032
- const projectNames = parsedProjects.map(p => p.id)
3033
-
3034
- if (parsedFromStructuredOutput) {
3035
- stageData.scanMeta = stageData.scanMeta || {}
3036
- stageData.scanMeta.projectListParsed = true
3037
- } else {
3038
- // 结构化输出未解析到 → 回退读取已有 projects/*.yaml
3039
- // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
3040
- console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
3041
- stageData.scanMeta = stageData.scanMeta || {}
3042
- stageData.scanMeta.projectListParsed = false
3043
- const projectsDir = join(specBase, 'projects')
3044
- if (existsSync(projectsDir)) {
3045
- const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
3046
- const fallbackProjects = []
3047
- const fallbackSkipped = []
3048
- for (const yf of yamlFiles) {
3049
- const pName = yf.replace(/\.yaml$/, '')
3050
- const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
3051
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
3052
- const pPath = pathMatch ? pathMatch[1].trim() : pName
3053
- // 校验 path 是否存在且在 source_root 内
3054
- const absPath = resolve(cwd, pPath)
3055
- if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
3056
- fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
3057
- continue
3058
- }
3059
- if (!existsSync(absPath)) {
3060
- fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
3061
- continue
3062
- }
3063
- fallbackProjects.push({ id: pName, path: pPath })
3064
- }
3065
- if (fallbackSkipped.length > 0) {
3066
- console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
3067
- console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
3068
- }
3069
- parsedProjects = fallbackProjects
3070
- projectNames.length = 0
3071
- projectNames.push(...fallbackProjects.map(p => p.id))
3072
- }
3073
- if (parsedProjects.length === 0) {
3074
- // 无结构化输出 + 无合法已有项目 → step 2 失败
3075
- console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
3076
- console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
3077
- steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
3078
- // 不展开 perProject 步骤,直接跳到下一步
3079
- }
3080
- }
3081
-
3082
- // 校验解析出的项目列表(原子守卫:不通过就不落盘)
3083
- const validation = validateParsedProjects(parsedProjects, cwd)
3084
- if (!validation.ok) {
3085
- console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
3086
- console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
3087
- steps[currentIdx].validationError = validation.errors.join('; ')
3088
- }
3089
-
3090
- // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
3091
- const projectsDir = join(specBase, 'projects')
3092
- if (validation.ok) {
3093
- for (const proj of parsedProjects) {
3094
- const pName = proj.id
3095
- const projYaml = join(projectsDir, `${pName}.yaml`)
3096
- if (!existsSync(projYaml)) {
3097
- mkdirSync(projectsDir, { recursive: true })
3098
- const candidates = [
3099
- join(cwd, pName),
3100
- join(cwd, 'backend', pName),
3101
- join(cwd, 'packages', pName),
3102
- join(cwd, 'apps', pName),
3103
- join(cwd, 'services', pName),
3104
- ]
3105
- const detected = candidates.find(c => existsSync(c))
3106
- const regPath = detected || join(cwd, pName)
3107
- writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
3108
- console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
3109
- }
3110
- }
3455
+ // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤(抽成 handlePlanGeneratePlanStep)
3456
+ await handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress })
3111
3457
 
3112
- // 保存 runtime 状态
3113
- const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
3114
- mkdirSync(join(specBase, '.runtime'), { recursive: true })
3115
- let scanState = { projects: projectNames, expanded: false }
3116
- if (existsSync(scanStatePath)) {
3117
- try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
3118
- }
3458
+ // scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(抽成 handleScanProjectListStep)
3459
+ await handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts })
3119
3460
 
3120
- // 收集当前步骤之后所有 perProject 步骤
3121
- const stageDef = stageRegistry[stageName]
3122
- const allSteps = stageDef?.steps || []
3123
- const perProjectSteps = allSteps.filter(s => s.perProject)
3124
-
3125
- // 防重复展开
3126
- const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
3127
- if (!alreadyExpanded && perProjectSteps.length > 0) {
3128
- // 找到当前步骤(step 2)在动态 steps 中的位置
3129
- const insertBase = currentIdx + 1
3130
- let insertPos = insertBase
3131
- for (const pName of projectNames) {
3132
- // 读取项目配置获取 projectRoot
3133
- const projYaml = join(specBase, 'projects', `${pName}.yaml`)
3134
- let projectRoot = '.'
3135
- if (existsSync(projYaml)) {
3136
- const yamlContent = readFileSync(projYaml, 'utf8')
3137
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
3138
- if (pathMatch) projectRoot = pathMatch[1].trim()
3139
- }
3140
- const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
3141
- const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
3142
-
3143
- for (const ppStep of perProjectSteps) {
3144
- steps.splice(insertPos, 0, {
3145
- name: `${ppStep.name} [${pName}]`,
3146
- project: pName,
3147
- status: 'pending',
3148
- prompt: contextPrefix + ppStep.prompt,
3149
- outputHint: ppStep.outputHint,
3150
- optional: ppStep.optional
3151
- })
3152
- insertPos++
3153
- }
3154
- }
3155
- // 移除原始的 perProject 步骤(未展开的版本)
3156
- for (let i = steps.length - 1; i >= 0; i--) {
3157
- if (steps[i].perProject && !steps[i].name?.includes('[')) {
3158
- steps.splice(i, 1)
3159
- }
3160
- }
3161
- console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
3162
- console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
3163
- // 标记已展开,防止 resume 重复插入
3164
- scanState.expanded = true
3165
- writeFileSync(scanStatePath, JSON.stringify(scanState))
3166
- } // end !alreadyExpanded
3167
- } // end validation.ok
3168
- } // end step 2
3461
+ // execute「Wave N 执行」步骤完成 → 扫 worktree 提取 provider endpoint artifact
3462
+ // (供 verify 阶段 parity 对账 + consumer task 上游契约注入)。接线自 contract-matrix pipeline。
3463
+ if (stageName === 'execute' && /^Wave \d+ 执行$/.test(steps[currentIdx]?.name || '')) {
3464
+ try {
3465
+ const { extractArtifactsForChange } = await import('./contract-matrix.js')
3466
+ let worktreePath = null
3467
+ try {
3468
+ const { WorktreeManager } = await import('./worktree.js')
3469
+ const meta = new WorktreeManager({ cwd }).getMeta(changeName)
3470
+ if (meta?.worktreePath && existsSync(meta.worktreePath)) worktreePath = meta.worktreePath
3471
+ } catch {}
3472
+ const msg = extractArtifactsForChange({ changeDir: join(specBase, 'changes', changeName), specBase, changeName, worktreePath })
3473
+ if (msg) console.log(msg)
3474
+ } catch (e) { console.warn(`⚠️ 契约 artifact 提取跳过: ${e?.message || e}`) }
3475
+ }
3169
3476
 
3170
3477
  const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
3171
3478
 
@@ -3402,8 +3709,12 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3402
3709
  pointer.status = POINTER_STATUS.SCAN_COMPLETED
3403
3710
  pointer.completedAt = new Date().toISOString()
3404
3711
  pointer.scanStatus = postResult.status
3405
- writeFileSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
3406
- } catch {}
3712
+ writeAtomicSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
3713
+ } catch (e) {
3714
+ // 不阻断 scan 主流程,但暴露失败——pointer 写失败会让平台看不到 scan_completed,
3715
+ // 与项目 fail-loud 原则一致:宁可可见地 warn,也不静默吞错。
3716
+ console.warn(`⚠️ 更新平台指针状态失败(scan_completed 可能未落盘): ${e.message}`)
3717
+ }
3407
3718
 
3408
3719
  // failed_post_check 时强制阻止 clean success
3409
3720
  if (postResult.status === 'failed_post_check') {
@@ -3503,204 +3814,8 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3503
3814
 
3504
3815
  // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
3505
3816
  if (actualCompleted === actualTotal && actualTotal > 0) {
3506
- const projectName = progress.project || basename(cwd)
3507
- const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
3508
- if (contractResult.errors.length > 0) {
3509
- console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
3510
- for (const err of contractResult.errors) {
3511
- console.error(` - ${err}`)
3512
- }
3513
- console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
3514
- // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
3515
- // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
3516
- // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
3517
- rollbackStageCompletion(stageData, steps, currentIdx)
3518
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3519
- await pm._write(cwd, progress, changeName)
3520
- triggerSync(cwd, changeName, platformOpts)
3521
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3522
- }
3523
- if (contractResult.warnings.length > 0) {
3524
- console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
3525
- for (const w of contractResult.warnings) {
3526
- console.warn(` - ${w}`)
3527
- }
3528
- }
3529
-
3530
- // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
3531
- // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
3532
- // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
3533
- if (stageName === 'verify') {
3534
- const { runVerifyTestCheck, printVerifyTestCheck } = await import('./verify-postcheck.js')
3535
- const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
3536
- printVerifyTestCheck(testCheck)
3537
- if (testCheck.status === 'failed') {
3538
- console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
3539
- console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
3540
- rollbackStageCompletion(stageData, steps, currentIdx)
3541
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3542
- await pm._write(cwd, progress, changeName)
3543
- triggerSync(cwd, changeName, platformOpts)
3544
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3545
- }
3546
- console.log('\n✅ 验证通过,下一步:sillyspec run archive')
3547
- }
3548
-
3549
- // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
3550
- if (stageName === 'plan') {
3551
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3552
- const planPath = planFile ? join(planFile, 'plan.md') : null
3553
- if (planPath && existsSync(planPath)) {
3554
- const { validatePlanForExecute } = await import('./stages/execute.js')
3555
- const planContent = readFileSync(planPath, 'utf8')
3556
- const planValidation = validatePlanForExecute(planContent)
3557
- if (!planValidation.ok) {
3558
- console.error(`\n❌ Plan → Execute Contract 校验失败:`)
3559
- for (const err of planValidation.errors) console.error(` - ${err}`)
3560
- console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
3561
- // 阻断 completed
3562
- rollbackStageCompletion(stageData, steps, currentIdx)
3563
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3564
- await pm._write(cwd, progress, changeName)
3565
- triggerSync(cwd, changeName, platformOpts)
3566
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3567
- }
3568
- if (planValidation.warnings.length > 0) {
3569
- console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
3570
- for (const w of planValidation.warnings) console.warn(` - ${w}`)
3571
- }
3572
- if (planValidation.ok) {
3573
- console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
3574
- }
3575
- }
3576
- }
3577
-
3578
- // ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
3579
- // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
3580
- if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
3581
- try {
3582
- const { classifyReviewTier } = await import('./review-tier.js')
3583
- const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('./stage-review.js')
3584
- const effectiveSpecBase = platformOpts?.specRoot || specBase
3585
- const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3586
- const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
3587
- let planLevel = null
3588
- if (reviewChangeDir) {
3589
- const planPath = join(reviewChangeDir, 'plan.md')
3590
- if (existsSync(planPath)) {
3591
- const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
3592
- if (fmLine) planLevel = fmLine.split(':')[1].trim()
3593
- }
3594
- }
3595
- const tier = classifyReviewTier({ planLevel, designPath })
3596
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
3597
-
3598
- if (tier.tier === 'self') {
3599
- console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
3600
- } else {
3601
- const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName)
3602
- const reviewType = stageName === 'brainstorm' ? 'design'
3603
- : stageName === 'plan' ? 'plan'
3604
- : stageName === 'propose' ? 'proposal'
3605
- : 'acceptance'
3606
- const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
3607
- const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
3608
- printStageReviewResult(reviewResult, { stage: stageName })
3609
- if (!reviewResult.ok) {
3610
- rollbackStageCompletion(stageData, steps, currentIdx)
3611
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
3612
- await pm._write(cwd, progress, changeName)
3613
- triggerSync(cwd, changeName, platformOpts)
3614
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3615
- }
3616
- }
3617
- } catch (e) {
3618
- // fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
3619
- console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
3620
- rollbackStageCompletion(stageData, steps, currentIdx)
3621
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
3622
- await pm._write(cwd, progress, changeName)
3623
- triggerSync(cwd, changeName, platformOpts)
3624
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3625
- }
3626
- }
3627
-
3628
- // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
3629
- if (stageName === 'execute') {
3630
- try {
3631
- const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('./task-review.js')
3632
- const effectiveSpecBase = platformOpts?.specRoot || specBase
3633
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3634
- const planPath = planFile ? join(planFile, 'plan.md') : null
3635
-
3636
- if (planPath && existsSync(planPath)) {
3637
- const planContent = readFileSync(planPath, 'utf8')
3638
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
3639
-
3640
- // execute run id:从变更专属标记文件读取
3641
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
3642
- let executeRunId = ''
3643
- try {
3644
- if (existsSync(runIdFile)) {
3645
- executeRunId = readFileSync(runIdFile, 'utf8').trim()
3646
- }
3647
- } catch {}
3648
- if (!executeRunId) {
3649
- const { generateExecuteRunId } = await import('./task-review.js')
3650
- executeRunId = generateExecuteRunId()
3651
- // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
3652
- try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
3653
- }
3654
-
3655
- // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
3656
- let reviewGitDir = cwd
3657
- try {
3658
- const { WorktreeManager } = await import('./worktree.js')
3659
- const wm = new WorktreeManager({ cwd })
3660
- const meta = wm.getMeta(changeName)
3661
- if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
3662
- reviewGitDir = meta.worktreePath
3663
- }
3664
- } catch {}
3665
-
3666
- const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir })
3667
- printReviewResult(reviewResult, { runtimeRoot, executeRunId })
3668
-
3669
- if (!reviewResult.ok) {
3670
- // Task review 校验失败,阻断 execute 完成
3671
- // 检查是否存在 checkbox 已勾但 review 不通过的情况
3672
- const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
3673
- if (uncheckedTasks.length > 0) {
3674
- console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
3675
- console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
3676
- }
3677
- rollbackStageCompletion(stageData, steps, currentIdx)
3678
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3679
- await pm._write(cwd, progress, changeName)
3680
- triggerSync(cwd, changeName, platformOpts)
3681
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3682
- }
3683
-
3684
- // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
3685
- if (reviewResult.requiredEvidence.length > 0) {
3686
- const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
3687
- if (evidencePath) {
3688
- console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
3689
- console.log(' verify 阶段必须满足这些证据要求。')
3690
- }
3691
- }
3692
- }
3693
- } catch (e) {
3694
- // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
3695
- console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
3696
- console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
3697
- rollbackStageCompletion(stageData, steps, currentIdx)
3698
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3699
- await pm._write(cwd, progress, changeName)
3700
- triggerSync(cwd, changeName, platformOpts)
3701
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3702
- }
3703
- }
3817
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
3818
+ if (_gateEarlyReturn) return _gateEarlyReturn
3704
3819
  } else if (actualCompleted < actualTotal) {
3705
3820
  // 实际步骤未全部完成,跳过 validator(状态可能不同步)
3706
3821
  console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
@@ -3866,6 +3981,10 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3866
3981
  return { stageCompleted: false, currentIdx, nextPendingIdx }
3867
3982
  }
3868
3983
 
3984
+ // 测试专用导出:completeStep 是 step 完成处理核心(产物校验/gate 链/sync/auto 推进),
3985
+ // 行为保持重构需要 characterization 测试直接驱动它。有先例:worktree-guard.js 的 _queryDbFirstCellForTest。
3986
+ export { completeStep as _completeStepForTest }
3987
+
3869
3988
  async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
3870
3989
  const stageData = progress.stages[stageName]
3871
3990
  if (!stageData || !stageData.steps) {