sillyspec 3.20.0 → 3.20.2

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
@@ -27,6 +27,52 @@ export function sanitizeProjectName(name) {
27
27
  return clean
28
28
  }
29
29
 
30
+ /**
31
+ * 校验从 step 2 解析出的项目列表。
32
+ * 不通过则不落盘 projects/*.yaml、不展开 perProject 步骤。
33
+ *
34
+ * @param {Array<{id: string, path?: string}>} projects - 项目列表(含可选 path)
35
+ * @param {string} sourceRoot - 源码根目录,用于 path 安全校验
36
+ */
37
+ export function validateParsedProjects(projects, sourceRoot) {
38
+ const errors = []
39
+ if (!projects || projects.length === 0) {
40
+ return { ok: false, errors: ['项目列表为空'] }
41
+ }
42
+ if (projects.length > 10) {
43
+ return { ok: false, errors: [`项目数量 ${projects.length} 超过上限 10,疑似误解析`] }
44
+ }
45
+ const safeRoot = resolve(sourceRoot)
46
+ const seen = new Set()
47
+ for (const proj of projects) {
48
+ const id = proj.id || proj
49
+ if (seen.has(id)) {
50
+ errors.push(`重复项目名: ${id}`)
51
+ }
52
+ seen.add(id)
53
+ // slug 合法性:只允许 a-z 0-9 _ - .,长度 2-64
54
+ if (!/^[a-zA-Z][\w\-.]{1,63}$/.test(id)) {
55
+ errors.push(`项目名 "${id}" 不合法(需 slug 格式:字母开头,只含 a-zA-Z0-9_-., 长度 2-64)`)
56
+ }
57
+ // path 安全校验(如果提供了 path)
58
+ if (proj.path) {
59
+ if (proj.path.includes('..')) {
60
+ errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
61
+ } else {
62
+ const absPath = resolve(safeRoot, proj.path)
63
+ if (!absPath.startsWith(safeRoot + '/') && absPath !== safeRoot) {
64
+ errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
65
+ }
66
+ if (!existsSync(absPath)) {
67
+ errors.push(`项目 "${id}" 的 path "${proj.path}" 不存在`)
68
+ }
69
+ }
70
+ }
71
+ }
72
+ if (errors.length > 0) return { ok: false, errors }
73
+ return { ok: true, errors: [] }
74
+ }
75
+
30
76
  /**
31
77
  * 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
32
78
  * 使用 -c safe.directory= 临时参数,不污染全局 git config。
@@ -113,6 +159,9 @@ import { checkTransition, runValidators } from './stage-contract.js'
113
159
  import { buildExecuteSteps } from './stages/execute.js'
114
160
  import { buildPlanSteps } from './stages/plan.js'
115
161
  import { formatExecuteSummary } from './worktree-apply.js'
162
+ import { classifyChange } from './classify-change.js'
163
+ import { detectRiskProfile } from './change-risk-profile.js'
164
+ import { definition as brainstormAutoDef } from './stages/brainstorm-auto.js'
116
165
 
117
166
  /**
118
167
  * 从 _module-map.yaml 读取模块上下文索引
@@ -772,19 +821,19 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
772
821
  }
773
822
  }
774
823
 
775
- // Execute: 注入 currentExecuteRunId(从 runtime 标记文件读取)
824
+ // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
776
825
  if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
777
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
778
- const runIdFile = join(execSpecBase, '.runtime', 'current-execute-run-id')
779
826
  let runId = ''
827
+ const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
828
+ const runIdFile = join(execSpecBase, '.runtime', `current-execute-run-id-${effectiveChange}`)
780
829
  try {
781
830
  if (existsSync(runIdFile)) {
782
831
  runId = readFileSync(runIdFile, 'utf8').trim()
783
832
  }
784
833
  } catch {}
785
834
  if (!runId) {
786
- const { generateExecuteRunId, getLatestExecuteRunId } = await import('./task-review.js')
787
- runId = getLatestExecuteRunId(join(execSpecBase, '.runtime')) || generateExecuteRunId()
835
+ const { generateExecuteRunId } = await import('./task-review.js')
836
+ runId = generateExecuteRunId()
788
837
  }
789
838
  promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
790
839
  }
@@ -1283,7 +1332,7 @@ export async function runCommand(args, cwd, specDir = null) {
1283
1332
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
1284
1333
  '--files', '--allow-new', '--force-baseline', '--force-rescan',
1285
1334
  '--json', '--dir', '--help',
1286
- '--reopen', '--from-step',
1335
+ '--reopen', '--from-step', '--mode',
1287
1336
  ])
1288
1337
  for (let i = 0; i < flags.length; i++) {
1289
1338
  const f = flags[i]
@@ -1340,7 +1389,7 @@ export async function runCommand(args, cwd, specDir = null) {
1340
1389
 
1341
1390
  // -- auto 模式:自动推进所有流程阶段
1342
1391
  if (stageName === 'auto') {
1343
- return await runAutoMode(pm, progress, cwd, flags, effectiveChange)
1392
+ return await runAutoMode(pm, progress, cwd, flags, effectiveChange, platformOpts)
1344
1393
  }
1345
1394
 
1346
1395
  // --change 只作为变更名标识,不再拦截流程
@@ -1528,16 +1577,24 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1528
1577
  }
1529
1578
  }
1530
1579
 
1531
- // ── execute 阶段启动时固定 executeRunId ──
1580
+ // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
1532
1581
  let currentExecuteRunId = null
1533
1582
  if (stageName === 'execute') {
1534
- const { generateExecuteRunId, getLatestExecuteRunId } = await import('./task-review.js')
1583
+ const { generateExecuteRunId } = await import('./task-review.js')
1535
1584
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
1536
1585
  const runtimeRoot = join(execSpecBase, '.runtime')
1537
- currentExecuteRunId = getLatestExecuteRunId(runtimeRoot) || generateExecuteRunId()
1538
- // 写入 runtime 标记文件,确保整个 execute 生命周期内 runId 不变
1586
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1539
1587
  mkdirSync(runtimeRoot, { recursive: true })
1540
- writeFileSync(join(runtimeRoot, 'current-execute-run-id'), currentExecuteRunId + '\n')
1588
+ // 优先读取已有的变更专属标记文件
1589
+ try {
1590
+ if (existsSync(runIdFile)) {
1591
+ currentExecuteRunId = readFileSync(runIdFile, 'utf8').trim()
1592
+ }
1593
+ } catch {}
1594
+ if (!currentExecuteRunId) {
1595
+ currentExecuteRunId = generateExecuteRunId()
1596
+ writeFileSync(runIdFile, currentExecuteRunId + '\n')
1597
+ }
1541
1598
  }
1542
1599
 
1543
1600
  // 自动探测 currentChange
@@ -2202,7 +2259,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2202
2259
  // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤
2203
2260
  // 使用稳定 id 匹配,不依赖中文标题
2204
2261
  if (stageName === 'plan') {
2205
- const currentStepDef = defSteps?.[currentIdx]
2262
+ const currentStepDef = defStepsForCurrent?.[currentIdx]
2206
2263
  const currentStepEntry = steps[currentIdx]
2207
2264
  const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
2208
2265
  if (stepId === 'generate_plan') {
@@ -2243,91 +2300,132 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2243
2300
 
2244
2301
  // scan 阶段 step 2 "构建扫描项目列表" 完成后,按项目展开 perProject 步骤
2245
2302
  if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
2246
- // 解析项目列表:从 step 2 输出提取,或回退读取 projects/*.yaml
2247
- let projectNames = []
2248
- // sanitizeProjectName 已提取到模块顶层(含字母校验 + 长度≥2)
2303
+ // 解析项目列表:只接受结构化输出(YAML block BEGIN_PROJECT_LIST 标记)
2304
+ // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
2305
+ let parsedProjects = [] // Array<{id, path?}>
2306
+ let parsedFromStructuredOutput = false
2249
2307
  if (outputText) {
2250
- // 匹配方式 1: "1. project-name" 编号列表
2251
- // 正则收紧:token 必须以字母开头,避免误捕获纯数字 "0"/"7" 和步骤说明中英文行
2252
- const numbered = outputText.match(/^\s*\d+\.\s+([a-zA-Z][\w\-.]*)/gm)
2253
- if (numbered) {
2254
- // task-05 B2 延伸修正:原 /[—\-:].*$/ 会把 ASCII 连字符当后缀分隔符,
2255
- // order-service 切成 order。现只针对中文长破折号 `—`(LLM 输出列表时常作分隔符)。
2256
- const raw = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/—.*$/, '').trim())
2257
- projectNames = raw.map(sanitizeProjectName).filter(Boolean)
2258
- if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
2259
- }
2260
- // 匹配方式 2: 括号枚举 "子项目frontend/order-service/user-service" 或 "项目: a, b, c"
2261
- if (projectNames.length === 0) {
2262
- const parenMatch = outputText.match(/(?:子项目|项目)[\s::]*(\S+(?:[\/、,,]+\S+)*)/)
2263
- if (parenMatch) {
2264
- const raw = parenMatch[1].split(/[\/、,,]+/).map(s => s.trim()).filter(Boolean)
2265
- projectNames = raw.map(sanitizeProjectName).filter(Boolean)
2266
- if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
2308
+ // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
2309
+ const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
2310
+ if (yamlBlock) {
2311
+ const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
2312
+ for (const m of entries) {
2313
+ const id = sanitizeProjectName(m[1])
2314
+ if (!id) continue
2315
+ // 提取可选 path 字段
2316
+ const pathMatch = m[0].match(/path:\s*(\S+)/)
2317
+ const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
2318
+ parsedProjects.push(entry)
2267
2319
  }
2268
- }
2269
- // 匹配方式 3: 结构化 YAML block "scan_projects:\n - id: name"
2270
- if (projectNames.length === 0) {
2271
- const yamlMatch = outputText.match(/scan_projects:\s*\n((?:\s+-\s+id:\s+\S+\s*\n?)+)/)
2272
- if (yamlMatch) {
2273
- const raw = [...yamlMatch[1].matchAll(/-\s+id:\s*(\S+)/g)].map(m => m[1])
2274
- projectNames = raw.map(sanitizeProjectName).filter(Boolean)
2275
- if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
2320
+ parsedFromStructuredOutput = parsedProjects.length > 0
2321
+ }
2322
+ // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
2323
+ if (!parsedFromStructuredOutput) {
2324
+ const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
2325
+ if (blockMatch) {
2326
+ const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
2327
+ parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
2328
+ parsedFromStructuredOutput = parsedProjects.length > 0
2276
2329
  }
2277
2330
  }
2278
2331
  }
2279
- if (projectNames.length === 0) {
2280
- // 回退:读取所有已注册项目
2281
- console.warn('⚠️ 未能从 step 2 输出解析项目列表,回退扫描所有注册项目')
2282
- stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = false;
2332
+
2333
+ const projectNames = parsedProjects.map(p => p.id)
2334
+
2335
+ if (parsedFromStructuredOutput) {
2336
+ stageData.scanMeta = stageData.scanMeta || {}
2337
+ stageData.scanMeta.projectListParsed = true
2338
+ } else {
2339
+ // 结构化输出未解析到 → 回退读取已有 projects/*.yaml
2340
+ // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
2341
+ console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
2342
+ stageData.scanMeta = stageData.scanMeta || {}
2343
+ stageData.scanMeta.projectListParsed = false
2283
2344
  const projectsDir = join(specBase, 'projects')
2284
2345
  if (existsSync(projectsDir)) {
2285
- projectNames = readdirSync(projectsDir)
2286
- .filter(f => f.endsWith('.yaml'))
2287
- .map(f => f.replace(/\.yaml$/, ''))
2346
+ const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
2347
+ const fallbackProjects = []
2348
+ const fallbackSkipped = []
2349
+ for (const yf of yamlFiles) {
2350
+ const pName = yf.replace(/\.yaml$/, '')
2351
+ const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
2352
+ const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
2353
+ const pPath = pathMatch ? pathMatch[1].trim() : pName
2354
+ // 校验 path 是否存在且在 source_root 内
2355
+ const absPath = resolve(cwd, pPath)
2356
+ if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
2357
+ fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
2358
+ continue
2359
+ }
2360
+ if (!existsSync(absPath)) {
2361
+ fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
2362
+ continue
2363
+ }
2364
+ fallbackProjects.push({ id: pName, path: pPath })
2365
+ }
2366
+ if (fallbackSkipped.length > 0) {
2367
+ console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
2368
+ console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
2369
+ }
2370
+ parsedProjects = fallbackProjects
2371
+ projectNames.length = 0
2372
+ projectNames.push(...fallbackProjects.map(p => p.id))
2373
+ }
2374
+ if (parsedProjects.length === 0) {
2375
+ // 无结构化输出 + 无合法已有项目 → step 2 失败
2376
+ console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
2377
+ console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
2378
+ steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
2379
+ // 不展开 perProject 步骤,直接跳到下一步
2288
2380
  }
2289
2381
  }
2290
- if (projectNames.length === 0) {
2291
- projectNames = ['sillyspec'] // 最终兜底
2382
+
2383
+ // 校验解析出的项目列表(原子守卫:不通过就不落盘)
2384
+ const validation = validateParsedProjects(parsedProjects, cwd)
2385
+ if (!validation.ok) {
2386
+ console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
2387
+ console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
2388
+ steps[currentIdx].validationError = validation.errors.join('; ')
2292
2389
  }
2293
2390
 
2294
- // 自动注册未注册的子项目(确保 projects/*.yaml 存在,避免展开时 projectRoot 缺失)
2391
+ // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
2295
2392
  const projectsDir = join(specBase, 'projects')
2296
- for (const pName of projectNames) {
2297
- const projYaml = join(projectsDir, `${pName}.yaml`)
2298
- if (!existsSync(projYaml)) {
2299
- mkdirSync(projectsDir, { recursive: true })
2300
- // 子项目路径推测:检查 cwd 下是否有同名目录
2301
- const candidates = [
2302
- join(cwd, pName), // cwd/frontend
2303
- join(cwd, 'backend', pName), // cwd/backend/user-service
2304
- join(cwd, 'packages', pName), // monorepo packages
2305
- join(cwd, 'apps', pName), // monorepo apps
2306
- join(cwd, 'services', pName), // monorepo services
2307
- ]
2308
- const detected = candidates.find(c => existsSync(c))
2309
- const regPath = detected || join(cwd, pName)
2310
- writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
2311
- console.log(` 📝 自动注册子项目: ${pName} ${regPath}`)
2312
- }
2313
- }
2314
-
2315
- // 保存到 runtime 供后续使用 + 防重复展开
2316
- const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
2317
- mkdirSync(join(specBase, '.runtime'), { recursive: true })
2318
- let scanState = { projects: projectNames, expanded: false }
2319
- if (existsSync(scanStatePath)) {
2320
- try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
2321
- }
2322
-
2323
- // 收集当前步骤之后所有 perProject 步骤
2324
- const stageDef = stageRegistry[stageName]
2325
- const allSteps = stageDef?.steps || []
2326
- const perProjectSteps = allSteps.filter(s => s.perProject)
2393
+ if (validation.ok) {
2394
+ for (const proj of parsedProjects) {
2395
+ const pName = proj.id
2396
+ const projYaml = join(projectsDir, `${pName}.yaml`)
2397
+ if (!existsSync(projYaml)) {
2398
+ mkdirSync(projectsDir, { recursive: true })
2399
+ const candidates = [
2400
+ join(cwd, pName),
2401
+ join(cwd, 'backend', pName),
2402
+ join(cwd, 'packages', pName),
2403
+ join(cwd, 'apps', pName),
2404
+ join(cwd, 'services', pName),
2405
+ ]
2406
+ const detected = candidates.find(c => existsSync(c))
2407
+ const regPath = detected || join(cwd, pName)
2408
+ writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
2409
+ console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
2410
+ }
2411
+ }
2412
+
2413
+ // 保存 runtime 状态
2414
+ const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
2415
+ mkdirSync(join(specBase, '.runtime'), { recursive: true })
2416
+ let scanState = { projects: projectNames, expanded: false }
2417
+ if (existsSync(scanStatePath)) {
2418
+ try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
2419
+ }
2420
+
2421
+ // 收集当前步骤之后所有 perProject 步骤
2422
+ const stageDef = stageRegistry[stageName]
2423
+ const allSteps = stageDef?.steps || []
2424
+ const perProjectSteps = allSteps.filter(s => s.perProject)
2327
2425
 
2328
- // 防重复展开:runtime 标记 或 steps 已含项目标识
2329
- const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
2330
- if (!alreadyExpanded && perProjectSteps.length > 0) {
2426
+ // 防重复展开
2427
+ const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
2428
+ if (!alreadyExpanded && perProjectSteps.length > 0) {
2331
2429
  // 找到当前步骤(step 2)在动态 steps 中的位置
2332
2430
  const insertBase = currentIdx + 1
2333
2431
  let insertPos = insertBase
@@ -2366,8 +2464,9 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2366
2464
  // 标记已展开,防止 resume 重复插入
2367
2465
  scanState.expanded = true
2368
2466
  writeFileSync(scanStatePath, JSON.stringify(scanState))
2369
- }
2370
- }
2467
+ } // end !alreadyExpanded
2468
+ } // end validation.ok
2469
+ } // end step 2
2371
2470
 
2372
2471
  const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
2373
2472
 
@@ -2667,7 +2766,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2667
2766
  // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
2668
2767
  if (stageName === 'execute') {
2669
2768
  try {
2670
- const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence, getLatestExecuteRunId, generateExecuteRunId } = await import('./task-review.js')
2769
+ const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('./task-review.js')
2671
2770
  const effectiveSpecBase = platformOpts?.specRoot || specBase
2672
2771
  const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
2673
2772
  const planPath = planFile ? join(planFile, 'plan.md') : null
@@ -2676,9 +2775,16 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2676
2775
  const planContent = readFileSync(planPath, 'utf8')
2677
2776
  const runtimeRoot = join(effectiveSpecBase, '.runtime')
2678
2777
 
2679
- // 找到 execute run id:优先从 runtime 目录找最新,否则生成新的
2680
- let executeRunId = getLatestExecuteRunId(runtimeRoot)
2778
+ // execute run id:从变更专属标记文件读取
2779
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
2780
+ let executeRunId = ''
2781
+ try {
2782
+ if (existsSync(runIdFile)) {
2783
+ executeRunId = readFileSync(runIdFile, 'utf8').trim()
2784
+ }
2785
+ } catch {}
2681
2786
  if (!executeRunId) {
2787
+ const { generateExecuteRunId } = await import('./task-review.js')
2682
2788
  executeRunId = generateExecuteRunId()
2683
2789
  }
2684
2790
 
@@ -3016,14 +3122,28 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
3016
3122
  /**
3017
3123
  * auto 模式:自动推进 brainstorm → plan → execute → verify
3018
3124
  */
3019
- async function runAutoMode(pm, progress, cwd, flags, changeName) {
3020
- const flowStages = ['brainstorm', 'plan', 'execute', 'verify']
3125
+ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
3126
+ const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
3021
3127
  const isDone = flags.includes('--done')
3022
3128
  const outputIdx = flags.indexOf('--output')
3023
3129
  const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
3024
3130
  const inputIdx = flags.indexOf('--input')
3025
3131
  const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
3026
3132
  const skipApproval = flags.includes('--skip-approval')
3133
+ const explicitMode = (() => {
3134
+ const m = flags.indexOf('--mode')
3135
+ return m !== -1 && flags[m + 1] ? flags[m + 1] : null
3136
+ })()
3137
+ const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
3138
+
3139
+ // Helper: 在 auto 模式下获取步骤定义
3140
+ const getAutoSteps = async (stage) => {
3141
+ if (stage === 'brainstorm') {
3142
+ return brainstormAutoDef.steps
3143
+ }
3144
+ return getStageSteps(stage, cwd, progress, platformOpts?.specRoot || null)
3145
+ }
3146
+
3027
3147
  const nextInFlow = (stage) => {
3028
3148
  const i = flowStages.indexOf(stage)
3029
3149
  return i >= 0 && i < flowStages.length - 1 ? flowStages[i + 1] : null
@@ -3032,6 +3152,24 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
3032
3152
  const ensureAutoStage = async (stage) => {
3033
3153
  const stageChanged = progress.currentStage !== stage
3034
3154
  progress.currentStage = stage
3155
+ // Auto 模式下 brainstorm 使用 artifact-first 步骤
3156
+ if (stage === 'brainstorm') {
3157
+ const existingSteps = progress.stages?.brainstorm?.steps
3158
+ const isAutoModeSteps = existingSteps?.length === 4 && existingSteps?.[0]?.name === '状态检查与上下文加载'
3159
+ if (!isAutoModeSteps) {
3160
+ if (!progress.stages) progress.stages = {}
3161
+ progress.stages.brainstorm = {
3162
+ status: 'in-progress',
3163
+ startedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
3164
+ completedAt: null,
3165
+ steps: brainstormAutoDef.steps.map(s => ({ name: s.name, status: 'pending' }))
3166
+ }
3167
+ await pm._write(cwd, progress, changeName)
3168
+ triggerSync(cwd, changeName, platformOpts)
3169
+ progress = await pm.read(cwd, changeName)
3170
+ return progress
3171
+ }
3172
+ }
3035
3173
  const changed = await ensureStageSteps(progress, stage, cwd)
3036
3174
  if (stageChanged || changed) {
3037
3175
  await pm._write(cwd, progress, changeName)
@@ -3041,6 +3179,18 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
3041
3179
  return progress
3042
3180
  }
3043
3181
 
3182
+ // ── Classify change on first entry ──
3183
+ if (!progress.stages?.brainstorm?.status && !progress.stages?.plan?.status) {
3184
+ const { classifyChange } = await import('./classify-change.js')
3185
+ const classification = classifyChange({ description: inputText || '', explicitMode })
3186
+ if (classification.mode === 'quick') {
3187
+ console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
3188
+ console.log(` 此变更建议使用 quick 模式,运行:sillyspec run quick "${inputText || '需求'}"`)
3189
+ return
3190
+ }
3191
+ console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
3192
+ }
3193
+
3044
3194
  let currentStage = progress.currentStage
3045
3195
  if (!currentStage || progress.stages?.[currentStage]?.status === 'completed') {
3046
3196
  currentStage = firstOpenStage()
@@ -3076,7 +3226,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
3076
3226
  }
3077
3227
  console.log('')
3078
3228
 
3079
- const defSteps = await getStageSteps(currentStage, cwd, progress)
3229
+ const defSteps = await getAutoSteps(currentStage)
3080
3230
  const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
3081
3231
  if (pendingIdx === -1) {
3082
3232
  const wsIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'waiting') ?? -1
@@ -3121,7 +3271,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
3121
3271
 
3122
3272
  const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
3123
3273
  if (nextPendingIdx !== -1) {
3124
- const defSteps = await getStageSteps(currentStage, cwd, progress)
3274
+ const defSteps = await getAutoSteps(currentStage)
3125
3275
  // execute 阶段启动前检查审批
3126
3276
  if (currentStage === 'execute' && !skipApproval) {
3127
3277
  const approval = await checkApproval(cwd, changeName, platformOpts)
@@ -3146,6 +3296,31 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
3146
3296
  return
3147
3297
  }
3148
3298
 
3299
+ // ── next-action.json 驱动:brainstorm → plan 推进判断 ──
3300
+ if (currentStage === 'brainstorm' && next === 'plan') {
3301
+ const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
3302
+ if (changeDir) {
3303
+ const nextActionFile = join(changeDir, 'brainstorm', 'next-action.json')
3304
+ try {
3305
+ const nextAction = JSON.parse(readFileSync(nextActionFile, 'utf8'))
3306
+ if (nextAction.has_blocking_questions === true) {
3307
+ console.log(`\n⏸️ brainstorm 有阻塞问题,无法自动进入 plan:`)
3308
+ for (const q of (nextAction.questions || [])) {
3309
+ console.log(` Q-${q.id}: ${q.question}`)
3310
+ if (q.options) console.log(` 选项:${q.options.join(' / ')}`)
3311
+ if (q.recommended) console.log(` 推荐:${q.recommended}`)
3312
+ }
3313
+ console.log(`\n 请回答阻塞问题后继续:sillyspec run auto --done --output "已回答"`)
3314
+ return
3315
+ }
3316
+ console.log(`\n✅ next-action.json: ${nextAction.status},自动进入 plan`)
3317
+ } catch (e) {
3318
+ // next-action.json 不存在或格式错误,继续推进(向后兼容)
3319
+ console.log(`\n⚠️ next-action.json 未找到,继续进入 plan`)
3320
+ }
3321
+ }
3322
+ }
3323
+
3149
3324
  progress.currentStage = next
3150
3325
  if (!progress.stages[next]) {
3151
3326
  progress.stages[next] = { status: 'pending', steps: [], startedAt: null, completedAt: null }
@@ -3161,7 +3336,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
3161
3336
  progress = await pm.read(cwd, changeName)
3162
3337
 
3163
3338
  console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
3164
- const nextSteps = await getStageSteps(next, cwd, progress)
3339
+ const nextSteps = await getAutoSteps(next)
3165
3340
  const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
3166
3341
  if (firstPending !== -1) {
3167
3342
  // execute 阶段启动前检查审批
@@ -151,19 +151,22 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
151
151
  }
152
152
  }
153
153
 
154
- // 5. 检查 AI 输出中的错误标记
154
+ // 5. 检查 AI 输出中的真实错误标记
155
+ // 注意:不对 agent 描述性文本做全文正则匹配,防止误报。
156
+ // 只检测明显是 agent 运行时失败的信号(未被捕获的错误块),
157
+ // 而非 agent 正常描述中提到这些词。
155
158
  if (outputText) {
156
- const errorPatterns = [
157
- { pattern: /tool_use_error/i, name: 'tool_use_error', detail: 'AI 输出中包含 tool_use_error' },
158
- { pattern: /API Error.*529/i, name: 'api_error_529', detail: 'AI 输出中包含 API Error 529' },
159
- { pattern: /rate.?limit.*exhausted/i, name: 'rate_limit_exhausted', detail: 'AI 输出中包含 rate_limit exhausted' },
160
- { pattern: /fallback|retry.*failed|skipped.*validat/i, name: 'fallback_or_skip', detail: 'AI 输出中出现 fallback/retry failed/skipped validation' },
161
- ]
162
- for (const ep of errorPatterns) {
163
- if (ep.pattern.test(outputText)) {
164
- checks.push({ name: ep.name, severity: CHECK_SEVERITY.WARNING, detail: ep.detail })
165
- }
159
+ // 5a: 检测未被恢复的 API 错误(连续多次、非描述性提及)
160
+ const apiErrorCount = (outputText.match(/API Error\b.*?\b529\b/gi) || []).length
161
+ if (apiErrorCount >= 2) {
162
+ checks.push({ name: 'api_error_529', severity: CHECK_SEVERITY.WARNING, detail: 'AI 输出中包含多次 API Error 529' })
163
+ }
164
+ const rateLimitCount = (outputText.match(/rate.?limit.*?exhausted/gi) || []).length
165
+ if (rateLimitCount >= 2) {
166
+ checks.push({ name: 'rate_limit_exhausted', severity: CHECK_SEVERITY.WARNING, detail: 'AI 输出中包含多次 rate_limit exhausted' })
166
167
  }
168
+ // tool_use_error 和 fallback 已移除:agent 描述性文本中正常提及这些词
169
+ // 不应触发 warning。真正的问题会通过文档缺失、manifest 失败等其他检查捕获。
167
170
  }
168
171
 
169
172
  // 6. manifest 写入状态检查
@@ -300,7 +303,7 @@ export function formatStructuredResult(result, meta = {}) {
300
303
  structured.failure_categories.bad_references.push(entry)
301
304
  }
302
305
  // AI 输出质量类
303
- else if (['tool_use_error', 'api_error_529', 'rate_limit_exhausted', 'fallback_or_skip'].includes(check.name)) {
306
+ else if (['api_error_529', 'rate_limit_exhausted'].includes(check.name)) {
304
307
  structured.failure_categories.quality_warnings.push(entry)
305
308
  }
306
309
  // manifest/project 列表问题