sillyspec 3.24.8 → 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,7 +4,7 @@
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
9
  import { writeAtomicSync, renameSyncRetry } from './fs-atomic.js'
10
10
  import { randomBytes, randomUUID } from 'crypto'
@@ -895,10 +895,14 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
895
895
  if (platformOpts.workspaceId) {
896
896
  platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
897
897
  }
898
- // 平台 directives 仅 step0 前置(对齐 persona 仅 step0 策略):路径列表/Write 规则/
899
- // 占位符映射一次建立即可;后续 step 靠 agent 跨步记忆 + 每步 changeDir header +
900
- // footer 平台规则(下方 L1077+)维持写入约束,避免每步重复注入 ~500 tokens。
901
- 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') {
902
906
  promptText = platformDirectives.join('\n') + '\n\n' + promptText
903
907
  }
904
908
  } else {
@@ -1116,6 +1120,36 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
1116
1120
  console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
1117
1121
  }
1118
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
+
1119
1153
  /**
1120
1154
  * 根据 project 规模计算 scan profile
1121
1155
  * quick: fileCount≤30 && sourceBytes≤80KB && projectCount≤3 → 3 步,0 子代理,5 份文档
@@ -1138,19 +1172,15 @@ function computeScanProfile(cwd, platformOpts) {
1138
1172
  }
1139
1173
  } catch {}
1140
1174
 
1141
- // 快速估算源码规模
1175
+ // 快速估算源码规模(Node 遍历,跨平台;原 Unix find 在原生 Windows 上失效会永远 fallback standard)
1142
1176
  let fileCount = 0
1143
1177
  let sourceBytes = 0
1144
1178
  try {
1145
- const { execSync } = require('child_process')
1146
- 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`
1147
- const files = execSync(findCmd, { encoding: 'utf8', timeout: 10000 }).trim().split('\n').filter(Boolean)
1148
- fileCount = files.length
1149
- for (const f of files) {
1150
- try { sourceBytes += statSync(f).size } catch {}
1151
- }
1179
+ const est = estimateSourceSize(cwd)
1180
+ fileCount = est.fileCount
1181
+ sourceBytes = est.sourceBytes
1152
1182
  } catch {
1153
- // find 失败时假设中等规模
1183
+ // 遍历异常时假设中等规模
1154
1184
  return { mode: 'standard', reason: '无法估算项目规模', maxAgentCalls: 1, maxDocs: 8 }
1155
1185
  }
1156
1186
 
@@ -1333,6 +1363,24 @@ async function executePlanPostcheck(cwd, platformOpts, progress) {
1333
1363
  })
1334
1364
  }
1335
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
+
1336
1384
  /**
1337
1385
  * sillyspec run <stage> 主命令
1338
1386
  */
@@ -2844,12 +2892,469 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
2844
2892
  }
2845
2893
  }
2846
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
+
2847
3353
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
2848
3354
  const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
2849
3355
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
2850
3356
  const stageData = progress.stages[stageName]
2851
3357
  const scanProfile = stageData?.scanProfile || null
2852
- let archivedDir = null // archive step「确认归档」实际归档目录(archiveChangeDirectory 返回)
2853
3358
 
2854
3359
  // ── WAIT MARKER 硬校验 ──
2855
3360
  // 如果 output 包含等待标记,拒绝 --done 推进
@@ -2940,246 +3445,34 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2940
3445
  }
2941
3446
  }
2942
3447
 
2943
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
2944
- if (!confirm) {
2945
- steps[currentIdx].status = 'pending'
2946
- steps[currentIdx].completedAt = null
2947
- if (outputText) steps[currentIdx].output = null
2948
- await pm._write(cwd, progress, changeName)
2949
- console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
2950
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2951
- }
2952
- archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
2953
- }
2954
-
2955
- // archive "确认归档" 步骤完成后,校验归档完整性
2956
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
2957
- // contracts.archive.validators 为空数组(两个 validator 生效窗口互斥,注册为阶段级会误报),
2958
- // 故 runValidators('archive') 是空跑。这里直接对实际归档目录做内容完整性校验。
2959
- // plan.md 已在 archiveChangeDirectory 移动前硬校验阻断,此处只查推荐文档。
2960
- if (archivedDir && existsSync(archivedDir)) {
2961
- const recommendedDocs = ['design.md', 'module-impact.md']
2962
- const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
2963
- if (missingRecommended.length > 0) {
2964
- console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
2965
- for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
2966
- } else {
2967
- console.log(`\n✅ 归档校验通过:核心文档齐全`)
2968
- }
2969
- }
2970
- }
2971
-
2972
- // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤
2973
- // 使用稳定 id 匹配,不依赖中文标题
2974
- if (stageName === 'plan') {
2975
- const currentStepDef = defStepsForCurrent?.[currentIdx]
2976
- const currentStepEntry = steps[currentIdx]
2977
- const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
2978
- if (stepId === 'generate_plan') {
2979
- const changeDir = resolveChangeDir(cwd, progress)
2980
- if (changeDir) {
2981
- const planFile = join(changeDir, 'plan.md')
2982
- if (existsSync(planFile)) {
2983
- const planContent = readFileSync(planFile, 'utf8')
2984
- const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('./stages/plan.js')
2985
- const fullSteps = buildPlanSteps(changeDir, planContent)
2986
- const prefixLen = fixedPrefix.length
2987
- const suffixLen = fixedSuffix.length
2988
- // 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?]
2989
- // fixedSuffix 为空,所以 coordinator + postcheck 都在 prefix 之后
2990
- const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
2991
- if (coordinatorSteps.length > 0) {
2992
- for (let i = 0; i < coordinatorSteps.length; i++) {
2993
- const stepDef = coordinatorSteps[i]
2994
- const stepEntry = {
2995
- id: stepDef.id,
2996
- name: stepDef.name,
2997
- status: 'pending',
2998
- prompt: stepDef.prompt || '',
2999
- outputHint: stepDef.outputHint,
3000
- optional: stepDef.optional
3001
- }
3002
- // 传递 noAI / _cliAction 属性
3003
- if (stepDef.noAI) stepEntry.noAI = true
3004
- if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
3005
- steps.splice(currentIdx + 1 + i, 0, stepEntry)
3006
- }
3007
- console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
3008
- }
3009
- }
3010
- }
3011
- }
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
3012
3453
  }
3013
3454
 
3014
- // scan 阶段 step 2 "构建扫描项目列表" 完成后,按项目展开 perProject 步骤
3015
- if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
3016
- // 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
3017
- // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
3018
- let parsedProjects = [] // Array<{id, path?}>
3019
- let parsedFromStructuredOutput = false
3020
- if (outputText) {
3021
- // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
3022
- const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
3023
- if (yamlBlock) {
3024
- const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
3025
- for (const m of entries) {
3026
- const id = sanitizeProjectName(m[1])
3027
- if (!id) continue
3028
- // 提取可选 path 字段
3029
- const pathMatch = m[0].match(/path:\s*(\S+)/)
3030
- const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
3031
- parsedProjects.push(entry)
3032
- }
3033
- parsedFromStructuredOutput = parsedProjects.length > 0
3034
- }
3035
- // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
3036
- if (!parsedFromStructuredOutput) {
3037
- const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
3038
- if (blockMatch) {
3039
- const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
3040
- parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
3041
- parsedFromStructuredOutput = parsedProjects.length > 0
3042
- }
3043
- }
3044
- }
3455
+ // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤(抽成 handlePlanGeneratePlanStep)
3456
+ await handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress })
3045
3457
 
3046
- const projectNames = parsedProjects.map(p => p.id)
3458
+ // scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(抽成 handleScanProjectListStep)
3459
+ await handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts })
3047
3460
 
3048
- if (parsedFromStructuredOutput) {
3049
- stageData.scanMeta = stageData.scanMeta || {}
3050
- stageData.scanMeta.projectListParsed = true
3051
- } else {
3052
- // 结构化输出未解析到 → 回退读取已有 projects/*.yaml
3053
- // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
3054
- console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
3055
- stageData.scanMeta = stageData.scanMeta || {}
3056
- stageData.scanMeta.projectListParsed = false
3057
- const projectsDir = join(specBase, 'projects')
3058
- if (existsSync(projectsDir)) {
3059
- const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
3060
- const fallbackProjects = []
3061
- const fallbackSkipped = []
3062
- for (const yf of yamlFiles) {
3063
- const pName = yf.replace(/\.yaml$/, '')
3064
- const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
3065
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
3066
- const pPath = pathMatch ? pathMatch[1].trim() : pName
3067
- // 校验 path 是否存在且在 source_root 内
3068
- const absPath = resolve(cwd, pPath)
3069
- if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
3070
- fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
3071
- continue
3072
- }
3073
- if (!existsSync(absPath)) {
3074
- fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
3075
- continue
3076
- }
3077
- fallbackProjects.push({ id: pName, path: pPath })
3078
- }
3079
- if (fallbackSkipped.length > 0) {
3080
- console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
3081
- console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
3082
- }
3083
- parsedProjects = fallbackProjects
3084
- projectNames.length = 0
3085
- projectNames.push(...fallbackProjects.map(p => p.id))
3086
- }
3087
- if (parsedProjects.length === 0) {
3088
- // 无结构化输出 + 无合法已有项目 → step 2 失败
3089
- console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
3090
- console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
3091
- steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
3092
- // 不展开 perProject 步骤,直接跳到下一步
3093
- }
3094
- }
3095
-
3096
- // 校验解析出的项目列表(原子守卫:不通过就不落盘)
3097
- const validation = validateParsedProjects(parsedProjects, cwd)
3098
- if (!validation.ok) {
3099
- console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
3100
- console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
3101
- steps[currentIdx].validationError = validation.errors.join('; ')
3102
- }
3103
-
3104
- // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
3105
- const projectsDir = join(specBase, 'projects')
3106
- if (validation.ok) {
3107
- for (const proj of parsedProjects) {
3108
- const pName = proj.id
3109
- const projYaml = join(projectsDir, `${pName}.yaml`)
3110
- if (!existsSync(projYaml)) {
3111
- mkdirSync(projectsDir, { recursive: true })
3112
- const candidates = [
3113
- join(cwd, pName),
3114
- join(cwd, 'backend', pName),
3115
- join(cwd, 'packages', pName),
3116
- join(cwd, 'apps', pName),
3117
- join(cwd, 'services', pName),
3118
- ]
3119
- const detected = candidates.find(c => existsSync(c))
3120
- const regPath = detected || join(cwd, pName)
3121
- writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
3122
- console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
3123
- }
3124
- }
3125
-
3126
- // 保存 runtime 状态
3127
- const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
3128
- mkdirSync(join(specBase, '.runtime'), { recursive: true })
3129
- let scanState = { projects: projectNames, expanded: false }
3130
- if (existsSync(scanStatePath)) {
3131
- try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
3132
- }
3133
-
3134
- // 收集当前步骤之后所有 perProject 步骤
3135
- const stageDef = stageRegistry[stageName]
3136
- const allSteps = stageDef?.steps || []
3137
- const perProjectSteps = allSteps.filter(s => s.perProject)
3138
-
3139
- // 防重复展开
3140
- const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
3141
- if (!alreadyExpanded && perProjectSteps.length > 0) {
3142
- // 找到当前步骤(step 2)在动态 steps 中的位置
3143
- const insertBase = currentIdx + 1
3144
- let insertPos = insertBase
3145
- for (const pName of projectNames) {
3146
- // 读取项目配置获取 projectRoot
3147
- const projYaml = join(specBase, 'projects', `${pName}.yaml`)
3148
- let projectRoot = '.'
3149
- if (existsSync(projYaml)) {
3150
- const yamlContent = readFileSync(projYaml, 'utf8')
3151
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
3152
- if (pathMatch) projectRoot = pathMatch[1].trim()
3153
- }
3154
- const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
3155
- const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
3156
-
3157
- for (const ppStep of perProjectSteps) {
3158
- steps.splice(insertPos, 0, {
3159
- name: `${ppStep.name} [${pName}]`,
3160
- project: pName,
3161
- status: 'pending',
3162
- prompt: contextPrefix + ppStep.prompt,
3163
- outputHint: ppStep.outputHint,
3164
- optional: ppStep.optional
3165
- })
3166
- insertPos++
3167
- }
3168
- }
3169
- // 移除原始的 perProject 步骤(未展开的版本)
3170
- for (let i = steps.length - 1; i >= 0; i--) {
3171
- if (steps[i].perProject && !steps[i].name?.includes('[')) {
3172
- steps.splice(i, 1)
3173
- }
3174
- }
3175
- console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
3176
- console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
3177
- // 标记已展开,防止 resume 重复插入
3178
- scanState.expanded = true
3179
- writeFileSync(scanStatePath, JSON.stringify(scanState))
3180
- } // end !alreadyExpanded
3181
- } // end validation.ok
3182
- } // 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
+ }
3183
3476
 
3184
3477
  const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
3185
3478
 
@@ -3521,204 +3814,8 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3521
3814
 
3522
3815
  // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
3523
3816
  if (actualCompleted === actualTotal && actualTotal > 0) {
3524
- const projectName = progress.project || basename(cwd)
3525
- const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
3526
- if (contractResult.errors.length > 0) {
3527
- console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
3528
- for (const err of contractResult.errors) {
3529
- console.error(` - ${err}`)
3530
- }
3531
- console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
3532
- // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
3533
- // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
3534
- // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
3535
- rollbackStageCompletion(stageData, steps, currentIdx)
3536
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3537
- await pm._write(cwd, progress, changeName)
3538
- triggerSync(cwd, changeName, platformOpts)
3539
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3540
- }
3541
- if (contractResult.warnings.length > 0) {
3542
- console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
3543
- for (const w of contractResult.warnings) {
3544
- console.warn(` - ${w}`)
3545
- }
3546
- }
3547
-
3548
- // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
3549
- // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
3550
- // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
3551
- if (stageName === 'verify') {
3552
- const { runVerifyTestCheck, printVerifyTestCheck } = await import('./verify-postcheck.js')
3553
- const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
3554
- printVerifyTestCheck(testCheck)
3555
- if (testCheck.status === 'failed') {
3556
- console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
3557
- console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
3558
- rollbackStageCompletion(stageData, steps, currentIdx)
3559
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3560
- await pm._write(cwd, progress, changeName)
3561
- triggerSync(cwd, changeName, platformOpts)
3562
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3563
- }
3564
- console.log('\n✅ 验证通过,下一步:sillyspec run archive')
3565
- }
3566
-
3567
- // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
3568
- if (stageName === 'plan') {
3569
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3570
- const planPath = planFile ? join(planFile, 'plan.md') : null
3571
- if (planPath && existsSync(planPath)) {
3572
- const { validatePlanForExecute } = await import('./stages/execute.js')
3573
- const planContent = readFileSync(planPath, 'utf8')
3574
- const planValidation = validatePlanForExecute(planContent)
3575
- if (!planValidation.ok) {
3576
- console.error(`\n❌ Plan → Execute Contract 校验失败:`)
3577
- for (const err of planValidation.errors) console.error(` - ${err}`)
3578
- console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
3579
- // 阻断 completed
3580
- rollbackStageCompletion(stageData, steps, currentIdx)
3581
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3582
- await pm._write(cwd, progress, changeName)
3583
- triggerSync(cwd, changeName, platformOpts)
3584
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3585
- }
3586
- if (planValidation.warnings.length > 0) {
3587
- console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
3588
- for (const w of planValidation.warnings) console.warn(` - ${w}`)
3589
- }
3590
- if (planValidation.ok) {
3591
- console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
3592
- }
3593
- }
3594
- }
3595
-
3596
- // ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
3597
- // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
3598
- if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
3599
- try {
3600
- const { classifyReviewTier } = await import('./review-tier.js')
3601
- const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('./stage-review.js')
3602
- const effectiveSpecBase = platformOpts?.specRoot || specBase
3603
- const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3604
- const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
3605
- let planLevel = null
3606
- if (reviewChangeDir) {
3607
- const planPath = join(reviewChangeDir, 'plan.md')
3608
- if (existsSync(planPath)) {
3609
- const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
3610
- if (fmLine) planLevel = fmLine.split(':')[1].trim()
3611
- }
3612
- }
3613
- const tier = classifyReviewTier({ planLevel, designPath })
3614
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
3615
-
3616
- if (tier.tier === 'self') {
3617
- console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
3618
- } else {
3619
- const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName)
3620
- const reviewType = stageName === 'brainstorm' ? 'design'
3621
- : stageName === 'plan' ? 'plan'
3622
- : stageName === 'propose' ? 'proposal'
3623
- : 'acceptance'
3624
- const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
3625
- const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
3626
- printStageReviewResult(reviewResult, { stage: stageName })
3627
- if (!reviewResult.ok) {
3628
- rollbackStageCompletion(stageData, steps, currentIdx)
3629
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
3630
- await pm._write(cwd, progress, changeName)
3631
- triggerSync(cwd, changeName, platformOpts)
3632
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3633
- }
3634
- }
3635
- } catch (e) {
3636
- // fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
3637
- console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
3638
- rollbackStageCompletion(stageData, steps, currentIdx)
3639
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
3640
- await pm._write(cwd, progress, changeName)
3641
- triggerSync(cwd, changeName, platformOpts)
3642
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3643
- }
3644
- }
3645
-
3646
- // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
3647
- if (stageName === 'execute') {
3648
- try {
3649
- const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('./task-review.js')
3650
- const effectiveSpecBase = platformOpts?.specRoot || specBase
3651
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
3652
- const planPath = planFile ? join(planFile, 'plan.md') : null
3653
-
3654
- if (planPath && existsSync(planPath)) {
3655
- const planContent = readFileSync(planPath, 'utf8')
3656
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
3657
-
3658
- // execute run id:从变更专属标记文件读取
3659
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
3660
- let executeRunId = ''
3661
- try {
3662
- if (existsSync(runIdFile)) {
3663
- executeRunId = readFileSync(runIdFile, 'utf8').trim()
3664
- }
3665
- } catch {}
3666
- if (!executeRunId) {
3667
- const { generateExecuteRunId } = await import('./task-review.js')
3668
- executeRunId = generateExecuteRunId()
3669
- // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
3670
- try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
3671
- }
3672
-
3673
- // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
3674
- let reviewGitDir = cwd
3675
- try {
3676
- const { WorktreeManager } = await import('./worktree.js')
3677
- const wm = new WorktreeManager({ cwd })
3678
- const meta = wm.getMeta(changeName)
3679
- if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
3680
- reviewGitDir = meta.worktreePath
3681
- }
3682
- } catch {}
3683
-
3684
- const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir })
3685
- printReviewResult(reviewResult, { runtimeRoot, executeRunId })
3686
-
3687
- if (!reviewResult.ok) {
3688
- // Task review 校验失败,阻断 execute 完成
3689
- // 检查是否存在 checkbox 已勾但 review 不通过的情况
3690
- const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
3691
- if (uncheckedTasks.length > 0) {
3692
- console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
3693
- console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
3694
- }
3695
- rollbackStageCompletion(stageData, steps, currentIdx)
3696
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3697
- await pm._write(cwd, progress, changeName)
3698
- triggerSync(cwd, changeName, platformOpts)
3699
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3700
- }
3701
-
3702
- // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
3703
- if (reviewResult.requiredEvidence.length > 0) {
3704
- const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
3705
- if (evidencePath) {
3706
- console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
3707
- console.log(' verify 阶段必须满足这些证据要求。')
3708
- }
3709
- }
3710
- }
3711
- } catch (e) {
3712
- // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
3713
- console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
3714
- console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
3715
- rollbackStageCompletion(stageData, steps, currentIdx)
3716
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3717
- await pm._write(cwd, progress, changeName)
3718
- triggerSync(cwd, changeName, platformOpts)
3719
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3720
- }
3721
- }
3817
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
3818
+ if (_gateEarlyReturn) return _gateEarlyReturn
3722
3819
  } else if (actualCompleted < actualTotal) {
3723
3820
  // 实际步骤未全部完成,跳过 validator(状态可能不同步)
3724
3821
  console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
@@ -3884,6 +3981,10 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3884
3981
  return { stageCompleted: false, currentIdx, nextPendingIdx }
3885
3982
  }
3886
3983
 
3984
+ // 测试专用导出:completeStep 是 step 完成处理核心(产物校验/gate 链/sync/auto 推进),
3985
+ // 行为保持重构需要 characterization 测试直接驱动它。有先例:worktree-guard.js 的 _queryDbFirstCellForTest。
3986
+ export { completeStep as _completeStepForTest }
3987
+
3887
3988
  async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
3888
3989
  const stageData = progress.stages[stageName]
3889
3990
  if (!stageData || !stageData.steps) {