sillyspec 3.24.8 → 3.25.1

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.
@@ -0,0 +1,1011 @@
1
+ /**
2
+ * run/command.js(W6 Step8c 从 run.js 抽出)。
3
+ *
4
+ * 命令分发层:runCommand(主入口,被 src/index.js + 6 个 test import)+ auto 模式 +
5
+ * ensureStageSteps(被 step-migration test import)+ 4 个 runCommand 私有 helper
6
+ * (resolveChangeName / resolveChangeNameAuto / showStatus / resetStage)。
7
+ *
8
+ * 调用图(单向,无环):runCommand → { runStage(stage.js), runAutoMode, resetStage, showStatus,
9
+ * ensureStageSteps, completeStep/skip/wait/continue(complete.js) };runAutoMode → { outputStep,
10
+ * completeStep, ensureStageSteps, checkApproval(shared) },不调 runStage(内联 outputStep)。
11
+ *
12
+ * 安全锚:run.js 始终 barrel。runCommand + ensureStageSteps 被 test 直接 import → run.js barrel
13
+ * re-export(契约保留);其余 5 函数 runCommand 私有,无 test 直接 import。
14
+ *
15
+ * 路径修正(相对 src/run/):
16
+ * - 动态 import './init.js' / './worktree.js' / './classify-change.js' → '../'(src/ 下退一层)
17
+ * - 'fs'(runCommand 内 await import('fs'))裸模块名不变
18
+ * - 静态 import './progress.js' / './stage-contract.js' / './stages/*' / './fs-atomic.js' → '../'
19
+ * './run/shared.js' / './run/quick-audit.js' / './run/prompt.js' / './run/complete.js' /
20
+ * './run/stage.js' → './shared.js' 等同级
21
+ */
22
+ import { join, resolve, dirname } from 'node:path'
23
+ import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
+ import { randomBytes, randomUUID } from 'node:crypto'
25
+ import { writeAtomicSync } from '../fs-atomic.js'
26
+ import { resolveSpecDir, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval } from './shared.js'
27
+ import { resolveQuickLinkedChanges } from './quick-audit.js'
28
+ import { outputStep } from './prompt.js'
29
+ import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
30
+ import { runStage } from './stage.js'
31
+ import { ProgressManager } from '../progress.js'
32
+ import { validateChangeExists } from '../stage-contract.js'
33
+ import { stageRegistry, auxiliaryStages } from '../stages/index.js'
34
+ import { definition as brainstormAutoDef } from '../stages/brainstorm-auto.js'
35
+
36
+ /**
37
+ * 从 progress 或变更目录推导变更名
38
+ */
39
+ function resolveChangeName(cwd, progress, specDir = null) {
40
+ if (progress.currentChange) return progress.currentChange
41
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
42
+ if (!existsSync(changesDir)) return null
43
+ const entries = readdirSync(changesDir, { withFileTypes: true })
44
+ .filter(e => e.isDirectory() && e.name !== 'archive')
45
+ if (entries.length === 1) return entries[0].name
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * 确保阶段的 steps 已初始化到 progress
51
+ */
52
+ export async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
53
+ if (!progress.stages) progress.stages = {}
54
+
55
+ const steps = await getStageSteps(stageName, cwd, progress, specDir)
56
+ if (!steps) return false
57
+
58
+ if (!progress.stages[stageName] || !progress.stages[stageName].steps || progress.stages[stageName].steps.length === 0) {
59
+ progress.stages[stageName] = {
60
+ status: 'in-progress',
61
+ startedAt: new Date().toLocaleString('zh-CN',{hour12:false}),
62
+ completedAt: null,
63
+ steps: steps.map(s => ({ name: s.name, status: 'pending' }))
64
+ }
65
+ return true // 需要写入
66
+ }
67
+
68
+ // 检查步骤数量是否匹配(execute 动态步骤可能变化)
69
+ if (progress.stages[stageName].steps.length !== steps.length) {
70
+ const oldSteps = progress.stages[stageName].steps
71
+ progress.stages[stageName].steps = steps.map(s => {
72
+ const old = oldSteps.find(step => step.name === s.name)
73
+ if (old) return old // 精确名命中 → 保留旧状态
74
+ // migratedFrom:新步骤是合并/重命名来的(如 brainstorm 13→8 折叠)。
75
+ // 吸收的旧步骤全部 completed → 新步骤标 completed,避免 completed 丢失导致 currentIdx 回跳。
76
+ if (Array.isArray(s.migratedFrom) && s.migratedFrom.length > 0) {
77
+ const migrated = oldSteps.filter(step => s.migratedFrom.includes(step.name))
78
+ if (migrated.length > 0 && migrated.every(step => step.status === 'completed')) {
79
+ return { name: s.name, status: 'completed', migratedFrom: true }
80
+ }
81
+ }
82
+ return { name: s.name, status: 'pending' }
83
+ })
84
+ return true
85
+ }
86
+
87
+ return false
88
+ }
89
+
90
+ /**
91
+ * sillyspec run <stage> 主命令
92
+ */
93
+ export async function runCommand(args, cwd, specDir = null, opts = {}) {
94
+ // W1-B: run <stage> 暂不支持 --json。之前 --json 进 knownFlags 白名单却从不读取(静默吞),
95
+ // agent 按 gate/derive 契约期望 envelope 实则拿到混合人类文本。显式 fail-fast 杜绝歧义;
96
+ // 完整 run envelope 待 outputStep 重构(W6)后支持(prompt + nextAction 结构化)。
97
+ if (opts.json) {
98
+ console.error('❌ run <stage> 暂不支持 --json(静默吞是 bug,故显式拒绝)。')
99
+ console.error(' 查事实用 gate/derive(支持 --json envelope);run 输出人类可读 prompt 供 agent 直接执行。')
100
+ process.exit(2) // 用法错 → exit 2
101
+ }
102
+ // 解析参数
103
+ const stageName = args[0]
104
+ const flags = args.slice(1)
105
+
106
+ if (!stageName) {
107
+ console.error('❌ 请指定阶段,例如: sillyspec run brainstorm')
108
+ console.error(`可选: ${Object.keys(stageRegistry).join(', ')}, auto`)
109
+ process.exit(2) // 用法错 → exit 2
110
+ }
111
+
112
+ if (!stageRegistry[stageName] && stageName !== 'auto') {
113
+ console.error(`❌ 未知阶段: ${stageName}`)
114
+ console.error(`可选: ${Object.keys(stageRegistry).join(', ')}, auto`)
115
+ process.exit(2) // 用法错 → exit 2
116
+ }
117
+
118
+ // ── cwd 纠正:向上查找真实项目根 ──
119
+ // 防止多 project 工作区中 cwd 停在子目录(如 backend/)时
120
+ // 状态写入子目录下误建的 .sillyspec,导致状态分裂
121
+ if (!specDir && !existsSync(join(cwd, '.sillyspec-platform.json'))) {
122
+ // 平台模式(cwd 有 .sillyspec-platform.json 指针)不做 cwd 纠正:指针已明确 specDir,
123
+ // 向上找 .sillyspec 反而会撞到无关项目(如用户 home 的 .sillyspec),导致 --done 恢复
124
+ // 读写 ~/.sillyspec-platform.json 出错的 specRoot。
125
+ const resolvedRoot = resolveSpecDir(cwd)
126
+ if (resolvedRoot && resolvedRoot !== join(cwd, '.sillyspec')) {
127
+ const realRoot = dirname(resolvedRoot)
128
+ if (realRoot !== cwd) {
129
+ cwd = realRoot
130
+ }
131
+ }
132
+ }
133
+
134
+ // 平台模式参数(供 SillyHub 等平台调用)
135
+ // --spec-dir 是统一参数名,--spec-root 保留为向后兼容别名
136
+ const getFlagValue = (name) => {
137
+ const idx = flags.indexOf(name)
138
+ return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
139
+ }
140
+ const isDone = flags.includes('--done')
141
+ const isSkip = flags.includes('--skip')
142
+ const isStatus = flags.includes('--status')
143
+ const isReset = flags.includes('--reset')
144
+ const isReopen = flags.includes('--reopen')
145
+ const fromStepValue = getFlagValue('--from-step')
146
+ const isConfirm = flags.includes('--confirm')
147
+ const isSkipApproval = flags.includes('--skip-approval')
148
+ const isWait = flags.includes('--wait')
149
+ const isContinue = flags.includes('--continue')
150
+ const isNonInteractive = flags.includes('--non-interactive')
151
+ const isInteractive = flags.includes('--interactive')
152
+ const waitReason = getFlagValue('--reason')
153
+ const waitOptions = getFlagValue('--options')
154
+ const continueAnswer = getFlagValue('--answer')
155
+ const confirmMode = getFlagValue('--confirm-mode')
156
+ const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root')
157
+ const platformOpts = {
158
+ specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
159
+ runtimeRoot: getFlagValue('--runtime-root') ? resolve(getFlagValue('--runtime-root')) : null,
160
+ workspaceId: getFlagValue('--workspace-id'),
161
+ scanRunId: getFlagValue('--scan-run-id'),
162
+ }
163
+
164
+ // 跨 --done 生命周期:从 metadata 文件恢复 platformOpts
165
+ // 首次 scan 时写入,所有后续调用(包括 run、--done、--skip)都读取
166
+ // 优先在 specDir 下查找,否则回退到 cwd/.sillyspec/.runtime/
167
+ let specRoot = platformOpts.specRoot || resolveSpecDir(cwd)
168
+ // 平台参数恢复策略:
169
+ // 1. 优先检查 cwd/.sillyspec-platform.json(轻量指针文件,不污染 .sillyspec 结构)
170
+ // 2. 然后检查 specRoot/.runtime/platform-scan.json(首次 scan 写入)
171
+ const platformPointer = join(cwd, '.sillyspec-platform.json')
172
+ const platformScanFile = join(specRoot, '.runtime', 'platform-scan.json')
173
+ let platformOptsFile = existsSync(platformPointer) ? platformPointer : platformScanFile
174
+ let platformFileExists = existsSync(platformOptsFile)
175
+ // 如果命令行没传 spec-root,尝试从持久化文件恢复
176
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
177
+ if (platformFileExists) {
178
+ try {
179
+ const { readFileSync } = await import('fs')
180
+ const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
181
+ if (saved.specRoot) platformOpts.specRoot = saved.specRoot
182
+ if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
183
+ if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
184
+ if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
185
+ // 平台模式 fail-fast:文件存在但缺少 specRoot
186
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
187
+ console.error(`❌ 平台模式参数文件存在但缺少 specRoot/runtimeRoot: ${platformOptsFile}`)
188
+ console.error(' 可能原因:platform-scan.json 损坏或写入不完整')
189
+ console.error(' 解决:重新运行首次 scan 并传入 --spec-root')
190
+ process.exit(2) // 环境错(平台文件损坏)→ exit 2
191
+ }
192
+ // 恢复成功:更新 specRoot(初始值可能是 cwd/.sillyspec,恢复后应为真实 specDir)
193
+ specRoot = platformOpts.specRoot || specRoot
194
+ } catch (e) {
195
+ console.error(`❌ 平台模式参数文件读取失败: ${platformOptsFile}`)
196
+ console.error(` 错误: ${e.message}`)
197
+ console.error(' 可能原因:文件损坏')
198
+ console.error(' 解决:删除该文件并重新运行首次 scan 传入 --spec-root')
199
+ process.exit(2) // 环境错(文件损坏)→ exit 2
200
+ }
201
+ }
202
+ }
203
+ // 持久化 platformOpts
204
+ // 在 specRoot/.runtime/ 写主文件,同时在 cwd/.sillyspec/.runtime/ 写恢复指针
205
+ if (platformOpts.specRoot || platformOpts.runtimeRoot) {
206
+ try {
207
+ const { mkdirSync, writeFileSync } = await import('fs')
208
+ mkdirSync(join(specRoot, '.runtime'), { recursive: true })
209
+ writeFileSync(platformOptsFile, JSON.stringify({
210
+ specRoot: platformOpts.specRoot,
211
+ runtimeRoot: platformOpts.runtimeRoot,
212
+ workspaceId: platformOpts.workspaceId,
213
+ scanRunId: platformOpts.scanRunId,
214
+ savedAt: new Date().toISOString(),
215
+ }, null, 2) + '\n')
216
+ // 恢复指针:在 cwd 下写 .sillyspec-platform.json(不在 .sillyspec 内,不污染源码结构)
217
+ // 供后续 --done(不带 --spec-root)找到 specDir
218
+ writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify({
219
+ specRoot: platformOpts.specRoot,
220
+ runtimeRoot: platformOpts.runtimeRoot,
221
+ workspaceId: platformOpts.workspaceId,
222
+ scanRunId: platformOpts.scanRunId,
223
+ savedAt: new Date().toISOString(),
224
+ }, null, 2) + '\n')
225
+ } catch {
226
+ // 静默失败,不影响主流程
227
+ }
228
+ }
229
+
230
+ // 统一规范基路径:平台模式用 specRoot,本地模式用 cwd/.sillyspec
231
+ // runCommand 后续所有 .sillyspec/ 操作必须用 specBase
232
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
233
+
234
+ // 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
235
+ // ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
236
+ // 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
237
+ // 后续每次 run 直接跳过,避免重复检查 + 红叉噪声(此清理不阻塞流程、不动真实资产)。
238
+ if (platformOpts.specRoot) {
239
+ const legacyDir = join(cwd, '.sillyspec')
240
+ const cleanedMarker = join(cwd, '.sillyspec-platform-cleaned')
241
+ if (!existsSync(cleanedMarker)) {
242
+ if (existsSync(legacyDir)) {
243
+ let hasChanges = false;
244
+ try {
245
+ const cd = join(legacyDir, 'changes');
246
+ if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
247
+ } catch {}
248
+ let hasProjects = false;
249
+ try {
250
+ const pd = join(legacyDir, 'projects');
251
+ if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
252
+ } catch {}
253
+ const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
254
+
255
+ if (hasChanges || hasProjects || hasDb) {
256
+ // 真实资产存在:只清运行时残留(白名单保留 worktrees/进度),不整删
257
+ const { cleanupRuntimeResidue } = await import('../init.js')
258
+ cleanupRuntimeResidue(legacyDir);
259
+ console.log('ℹ️ [sillyspec] 源码目录 .sillyspec/ 含真实资产,已跳过整删,仅清理运行时残留(仅本次首次,后续不再提示)。');
260
+ } else {
261
+ try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
262
+ if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
263
+ }
264
+ }
265
+ // 标记本 cwd 已做平台残留清理决策,后续 run 跳过(即使之后 .sillyspec/ 被重建也不误清)
266
+ try { writeFileSync(cleanedMarker, new Date().toISOString() + '\n') } catch {}
267
+ }
268
+ }
269
+
270
+ // 解析 --output
271
+ let outputText = null
272
+ const outputIdx = flags.indexOf('--output')
273
+ if (outputIdx !== -1 && flags[outputIdx + 1]) {
274
+ outputText = flags[outputIdx + 1]
275
+ }
276
+
277
+ // 解析 --input
278
+ let inputText = null
279
+ const inputIdx = flags.indexOf('--input')
280
+ if (inputIdx !== -1 && flags[inputIdx + 1]) {
281
+ inputText = flags[inputIdx + 1]
282
+ }
283
+
284
+ // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
285
+ // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
286
+ // null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
287
+ let explicitLinked = null
288
+ const linkedIdx = flags.indexOf('--linked-changes')
289
+ if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
290
+ const v = flags[linkedIdx + 1].trim()
291
+ explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
292
+ }
293
+
294
+ // 解析 --change <name>(quick 阶段向后兼容:逗号分隔作为「关联变更」;
295
+ // 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
296
+ let changeName = null
297
+ let linkedChanges = []
298
+ const changeIdx = flags.indexOf('--change')
299
+ if (changeIdx !== -1 && flags[changeIdx + 1]) {
300
+ changeName = flags[changeIdx + 1]
301
+ if (stageName === 'quick') {
302
+ linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
303
+ changeName = null
304
+ }
305
+ }
306
+ // --linked-changes 优先于 --change(显式 > 隐式兼容)
307
+ if (explicitLinked !== null) {
308
+ linkedChanges = explicitLinked
309
+ }
310
+ // quick 会话隔离(D-001@v1 + D-003@v1 + §4.4 跨进程传递):每会话用 sessionId 作 changeName,
311
+ // DB 分行 progress.quick-<uuid8>,避免并行 quick 会话共享单行 progress.default.quick 互相覆盖。
312
+ // sessionId = crypto.randomUUID 前 8 hex(摒弃旧 quick-YYYYMMDD-HHMMSS 时间戳,同秒并发撞)。
313
+ // crypto.randomUUID 从 node:crypto import(兼容 engines node>=18,不依赖 Node 19+ 全局)。
314
+ //
315
+ // --done 跨进程恢复 sessionId 的优先级(§4.4):
316
+ // 1. --change quick-<uuid8>(单值且匹配 sessionId 形态)→ 精确指定本会话 sessionId
317
+ // (quick 的 --change 历史被复用为 linkedChanges 见上 1370-1377;此处对「恰好一个 quick-<8hex>」
318
+ // 特例识别为 sessionId,多值/不匹配仍走 linkedChanges 语义,向后兼容)
319
+ // 2. 未识别出 sessionId 且为 --done → fallback 读 .runtime/current-quick-run-id(单会话兼容;
320
+ // 多会话时可能拿到他者,文档声明建议带 --change)
321
+ // 3. 仍读不到 → 生成新 UUID(兼容旧行为;进度可能命中空行,由后续 pm.read 兜底)
322
+ const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
323
+ let quickSessionId = null
324
+ if (stageName === 'quick') {
325
+ // 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
326
+ // 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
327
+ // 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
328
+ const rawChange = changeIdx !== -1 && flags[changeIdx + 1] ? flags[changeIdx + 1].trim() : null
329
+ const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
330
+ if (rawIsSingleSid) {
331
+ // --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
332
+ quickSessionId = rawChange
333
+ changeName = rawChange
334
+ linkedChanges = []
335
+ } else if (!changeName) {
336
+ // 未精确指定:非 --done 必生成新 sessionId;--done 先 fallback current-quick-run-id,读不到再生成
337
+ const isDoneLike = isDone || isStatus || isSkip || isReset || isReopen
338
+ if (isDoneLike) {
339
+ try {
340
+ const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
341
+ const idFile = join(runtimeRoot, 'current-quick-run-id')
342
+ if (existsSync(idFile)) {
343
+ const v = readFileSync(idFile, 'utf8').trim()
344
+ if (QUICK_SID_RE.test(v)) quickSessionId = v
345
+ }
346
+ } catch {}
347
+ }
348
+ if (!quickSessionId) {
349
+ quickSessionId = 'quick-' + randomUUID().slice(0, 8)
350
+ }
351
+ changeName = quickSessionId
352
+ } else {
353
+ // 用户显式传了非 sessionId 形态的变更名 → 尊重,不生成 UUID(旧兼容路径)
354
+ quickSessionId = changeName
355
+ }
356
+ }
357
+
358
+ // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
359
+ let quickFiles = []
360
+ const filesIdx = flags.indexOf('--files')
361
+ if (filesIdx !== -1 && flags[filesIdx + 1]) {
362
+ quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
363
+ }
364
+
365
+ const isAllowNew = flags.includes('--allow-new')
366
+ const isForceBaseline = flags.includes('--force-baseline')
367
+ const isForceRescan = flags.includes('--force-rescan')
368
+
369
+ // 未知参数 fail-fast
370
+ const knownFlags = new Set([
371
+ '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
372
+ '--wait', '--continue', '--non-interactive', '--interactive',
373
+ '--reason', '--options', '--answer', '--confirm-mode',
374
+ '--output', '--input', '--change', '--linked-changes',
375
+ '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
376
+ '--files', '--allow-new', '--force-baseline', '--force-rescan',
377
+ '--json', '--dir', '--help',
378
+ '--reopen', '--from-step', '--mode',
379
+ ])
380
+ for (let i = 0; i < flags.length; i++) {
381
+ const f = flags[i]
382
+ if (f.startsWith('--')) {
383
+ if (!knownFlags.has(f)) {
384
+ console.error(`❌ 未知参数: ${f}`)
385
+ console.error(`已知参数: ${[...knownFlags].sort().join(', ')}`)
386
+ process.exit(2) // 用法错 → exit 2
387
+ }
388
+ // 跳过 value 参数
389
+ i++
390
+ }
391
+ }
392
+
393
+ const isAuxiliary = auxiliaryStages.includes(stageName)
394
+ // scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
395
+
396
+ const pm = new ProgressManager({ specDir: specRoot })
397
+
398
+ // quick 阶段:确定关联变更
399
+ // 优先级:--linked-changes / --change 显式 > 已持久化 quick-guard.json(--done 复用)> 交互式 > 非交互 fallback
400
+ // 关键:--done 收尾时复用首次 run 持久化的 linkedChanges,不在管道/CI 下重复弹交互 prompt。
401
+ // explicitLinked === null 且未传 --change 时才进入此分支(显式 --linked-changes none 不应触发交互)。
402
+ if (stageName === 'quick' && explicitLinked === null && linkedChanges.length === 0) {
403
+ // D-002:guard 按 session 存(.runtime/quick-sessions/<sessionId>/guard.json)。
404
+ // sessionId == changeName == quick-<uuid8>(上面参数解析已确定)。回退读旧单文件 quick-guard.json(task-03 前兼容)。
405
+ let persistedLinked = null
406
+ try {
407
+ const sessionGuardFile = join(specRoot, '.runtime', 'quick-sessions', changeName, 'guard.json')
408
+ const legacyGuardFile = join(specRoot, '.runtime', 'quick-guard.json')
409
+ const g = existsSync(sessionGuardFile)
410
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
411
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
412
+ if (g && Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
413
+ } catch {}
414
+ if (persistedLinked) {
415
+ linkedChanges = persistedLinked
416
+ } else {
417
+ linkedChanges = await resolveQuickLinkedChanges({
418
+ pm, cwd, specDir: specRoot, quickFiles,
419
+ taskDescription: inputText || '',
420
+ nonInteractive: isNonInteractive,
421
+ })
422
+ }
423
+ }
424
+
425
+ // execute 阶段必须带 --change:不允许自动检测或默认值,变更名必须由 agent 显式传入
426
+ // --status 豁免(纯查看不需要指定变更)
427
+ if (stageName === 'execute' && !changeName && !isStatus) {
428
+ console.error('❌ execute 阶段必须用 --change <变更名> 指定要操作的变更。')
429
+ console.error(' agent 必须传参,不设默认值、不做自动检测。')
430
+ console.error(' 请加 --change <变更名> 重新执行。')
431
+ process.exit(2) // 用法错(execute 必须显式 --change)→ exit 2
432
+ }
433
+
434
+ // --change 变更名存在性校验(治 cwd 漂移误匹配,缺陷 execute-in-place-windows-pitfalls 坑5):
435
+ // 必须在 pm.read/initChange 之前——initChange 会为 --change 指定的新名字创建 changes/ 目录,
436
+ // 校验若在其后则目录已存在、永远通过。用原始 changeName(--change 参数),覆盖 plan/execute/
437
+ // verify/archive;quick-<8hex> sessionId 与 brainstorm 新建豁免(见 validateChangeExists)。
438
+ const changeMissing = validateChangeExists(specBase, stageName, changeName)
439
+ if (changeMissing) {
440
+ console.error(`❌ ${changeMissing.message}`)
441
+ console.error(` 可能 cwd 漂移——当前 cwd=${cwd},命中的 spec=${specBase}。`)
442
+ console.error(` 若意图操作别的项目,请 cd 到对应项目根,或用 --spec-dir 指定正确的 spec 目录。`)
443
+ process.exit(2) // 环境错(cwd/spec 漂移)→ exit 2
444
+ }
445
+
446
+ let progress = await pm.read(cwd, changeName)
447
+
448
+ if (!progress) {
449
+ // 如果指定了变更名或有变更目录,自动初始化变更的 progress
450
+ const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
451
+ if (autoChange) {
452
+ progress = await pm.initChange(cwd, autoChange)
453
+ } else if (isAuxiliary) {
454
+ let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
455
+ // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
456
+ // 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
457
+ if (stageName === 'archive' && autoName === 'default') {
458
+ try {
459
+ const archiveDir = join(specBase, 'changes', 'archive')
460
+ if (existsSync(archiveDir)) {
461
+ const latest = readdirSync(archiveDir)
462
+ .filter(d => /^\d{4}-\d{2}-\d{2}-.+/.test(d))
463
+ .sort()
464
+ .pop()
465
+ if (latest) {
466
+ autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
467
+ progress = await pm.read(cwd, autoName)
468
+ }
469
+ }
470
+ } catch {}
471
+ }
472
+ changeName = autoName
473
+ if (!progress) {
474
+ progress = await pm.initChange(cwd, autoName)
475
+ // initChange 可能因 project 表为空返回 null
476
+ if (!progress) {
477
+ progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
478
+ }
479
+ }
480
+ } else {
481
+ // brainstorm 作为流程入口,自动生成变更名并初始化
482
+ if (stageName === 'brainstorm') {
483
+ if (isDone) {
484
+ console.error('❌ --done 找不到变更进度数据。')
485
+ console.error(' 请用 --change <变更名> 指定要完成的变更,')
486
+ console.error(' 或先运行 sillyspec run brainstorm --change <变更名> 初始化。')
487
+ process.exit(2) // 用法错(--done 找不到变更,未传 --change)→ exit 2
488
+ }
489
+ const date = new Date().toISOString().slice(0, 10)
490
+ const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
491
+ console.log(`🔄 自动创建变更:${autoName}`)
492
+ console.log(` 提示:可以用 --change <名称> 指定自定义变更名`)
493
+ console.log(` 或事后重命名:sillyspec change-rename ${autoName} <新名称>`)
494
+ progress = await pm.initChange(cwd, autoName)
495
+ changeName = autoName
496
+ } else {
497
+ console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
498
+ process.exit(2) // 环境错(未 init / 未传 --change)→ exit 2
499
+ }
500
+ }
501
+ }
502
+
503
+ // 确保 progress 有 currentChange
504
+ const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
505
+
506
+ // -- auto 模式:自动推进所有流程阶段
507
+ if (stageName === 'auto') {
508
+ return await runAutoMode(pm, progress, cwd, flags, effectiveChange, platformOpts)
509
+ }
510
+
511
+ // --change 只作为变更名标识,不再拦截流程
512
+ // 注册变更到全局活跃列表(如果尚未注册)
513
+ if (effectiveChange) {
514
+ await pm.registerChange(cwd, effectiveChange)
515
+ }
516
+
517
+ // --reset
518
+ if (isReset) {
519
+ return await resetStage(pm, progress, stageName, cwd, effectiveChange, platformOpts)
520
+ }
521
+
522
+ // ── 规则 1:completed 阶段直接 run,拒绝(但 --status 放行)──
523
+ const stageStatus = progress.stages[stageName]?.status
524
+ if (stageStatus === 'completed' && !isReopen && !isStatus) {
525
+ console.error(`\n❌ ${stageName} 阶段已完成。`)
526
+ console.error(` 使用 --reopen 进行修订,或 --reset 从头开始。`)
527
+ console.error(` 修订示例: sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
528
+ process.exit(1)
529
+ }
530
+
531
+ // ── 规则 5:stale 阶段直接 run,拒绝(但 --status / --reset 放行)──
532
+ if (stageStatus === 'stale' && !isReopen && !isStatus && !isReset) {
533
+ const staleReason = progress.stages[stageName]?.staleReason || '上游阶段已修订'
534
+ console.error(`\n⚠️ ${stageName} 阶段已失效(stale)。`)
535
+ console.error(` 原因:${staleReason}`)
536
+ console.error(` 使用 --reopen --from-step <步骤> 进行修订,或 --reset 从头开始。`)
537
+ process.exit(1)
538
+ }
539
+
540
+ // ── --reopen 处理 ──
541
+ if (isReopen) {
542
+ // stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
543
+ const stageDataPre = progress.stages[stageName]
544
+ const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
545
+ // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
546
+ if (needsInit || stageName === 'execute') {
547
+ const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
548
+ if (freshSteps && freshSteps.length > 0) {
549
+ if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
550
+ progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
551
+ await pm._write(cwd, progress, effectiveChange)
552
+ progress = await pm.read(cwd, effectiveChange) || progress
553
+ }
554
+ }
555
+
556
+ const result = await pm.reopenStage(cwd, stageName, {
557
+ fromStep: fromStepValue,
558
+ changeName: effectiveChange,
559
+ })
560
+ if (!result.ok) {
561
+ console.error(`\n❌ ${result.error}`)
562
+ if (stageStatus === 'completed') {
563
+ console.error(`\n 提示:sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
564
+ }
565
+ process.exit(1)
566
+ }
567
+ console.log(`\n🔧 ${stageName} 阶段已重新打开(revision ${result.revision})`)
568
+ console.log(` 从步骤「${result.fromStep}」开始修订`)
569
+ console.log(` 该步骤及之后的产出需要重新生成。`)
570
+ if (stageName === 'execute') console.log(` ⚡ execute 步骤已从最新 plan.md 重新解析。`)
571
+ console.log('')
572
+
573
+ // 重新读取 progress
574
+ progress = await pm.read(cwd, effectiveChange) || progress
575
+
576
+ // 注入 revision context 到 platformOpts,供 outputStep 使用
577
+ const stageData = progress.stages[stageName]
578
+ if (stageData && stageData.revision > 0) {
579
+ platformOpts._revision = {
580
+ revision: stageData.revision,
581
+ fromStep: stageData.reopenedFromStep,
582
+ }
583
+ }
584
+ } else {
585
+ // 非 reopen 的正常执行:如果阶段处于 revising 状态,也注入 revision context
586
+ const revStageData = progress.stages[stageName]
587
+ if (revStageData && revStageData.status === 'revising' && revStageData.revision > 0 && !platformOpts._revision) {
588
+ platformOpts._revision = {
589
+ revision: revStageData.revision,
590
+ fromStep: revStageData.reopenedFromStep,
591
+ }
592
+ }
593
+ }
594
+
595
+ // quick 启动(非 --done):reset steps + 写 current-quick-run-id(本会话 sessionId,作 --done fallback)。
596
+ // sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
597
+ // 这里复用同一值写 current-quick-run-id,保证两处一致。
598
+ // 旧 quick-YYYYMMDD-HHMMSS 时间戳已摒弃(D-003@v1:同秒并发撞)。
599
+ if (stageName === 'quick' && !isDone && !isStatus && !isSkip && !isReset && !isReopen) {
600
+ // 不再无条件 reset steps:原逻辑把任何 quick 非 --done 启动都重置为 pending,致 in-progress
601
+ // 的 quick 中途查 prompt(sillyspec run quick)丢已 done 的 step 进度(报告
602
+ // quick-state-reset-platform-mode)。现保留进度——completed 重跑由 runStage 内
603
+ // currentIdx === -1 自动重置(~1944 行);in-progress 输出当前 step;全新由
604
+ // ensureStageSteps 初始化;显式从头用 --reset(已由 !isReset 排除出本分支)。
605
+ try {
606
+ const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
607
+ mkdirSync(runtimeRoot, { recursive: true })
608
+ writeAtomicSync(join(runtimeRoot, 'current-quick-run-id'), quickSessionId + '\n')
609
+ } catch (e) {
610
+ // 写失败不能静默:--done 不带 --change 时靠这个文件做跨进程 fallback,写不成就必须显式带 --change
611
+ console.warn(`⚠️ current-quick-run-id 写入失败(${e.message}),--done 必须显式带 --change ${quickSessionId}`)
612
+ }
613
+ // 显式告知 agent 本会话 sessionId + --done 需带 --change(CLI 短进程,run/done 独立进程,
614
+ // sessionId 靠 --change 跨进程传递;不带 --change 时 fallback 读 current-quick-run-id,多会话不可靠)
615
+ console.log(`📌 本 quick 会话 sessionId: ${quickSessionId}`)
616
+ console.log(` 完成时用: sillyspec run quick --done --change ${quickSessionId} --output "..."`)
617
+ }
618
+
619
+ // 确保步骤已初始化
620
+ const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
621
+ if (changed && effectiveChange) {
622
+ await pm._write(cwd, progress, effectiveChange)
623
+ triggerSync(cwd, effectiveChange, platformOpts)
624
+ progress = await pm.read(cwd, effectiveChange) || progress
625
+ }
626
+
627
+ // --status
628
+ if (isStatus) {
629
+ return showStatus(progress, stageName)
630
+ }
631
+
632
+ // --skip
633
+ if (isSkip) {
634
+ return await skipStep(pm, progress, stageName, cwd, effectiveChange, platformOpts)
635
+ }
636
+
637
+ // --wait: 将 step 设为 waiting(独立于 --done)
638
+ if (isWait) {
639
+ return await waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts })
640
+ }
641
+
642
+ // --continue: 从 waiting 恢复
643
+ if (isContinue) {
644
+ return await continueStep(pm, progress, stageName, cwd, continueAnswer, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts })
645
+ }
646
+
647
+ // --done
648
+ if (isDone) {
649
+ const doneAnswer = getFlagValue('--answer')
650
+ return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer, isForceBaseline, isAllowNew })
651
+ }
652
+
653
+ // 默认:输出当前步骤
654
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges, taskDescription: inputText })
655
+ }
656
+
657
+ /**
658
+ * 自动推导变更名(不依赖 progress)
659
+ */
660
+ function resolveChangeNameAuto(cwd, specDir = null) {
661
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
662
+ if (!existsSync(changesDir)) return null
663
+ const entries = readdirSync(changesDir, { withFileTypes: true })
664
+ .filter(e => e.isDirectory() && e.name !== 'archive')
665
+ if (entries.length === 1) return entries[0].name
666
+ return null
667
+ }
668
+
669
+ function showStatus(progress, stageName) {
670
+ const stageData = progress.stages[stageName]
671
+ const stageDef = stageRegistry[stageName]
672
+
673
+ if (!stageData || !stageData.steps || stageData.steps.length === 0) {
674
+ console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
675
+ console.log(`进度:未初始化`)
676
+ if (stageData?.status) {
677
+ console.log(`状态:${stageData.status}`)
678
+ if (stageData.staleReason) console.log(`⚠️ 失效原因:${stageData.staleReason}`)
679
+ }
680
+ return
681
+ }
682
+
683
+ const steps = stageData.steps
684
+ const completed = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
685
+ const bar = '█'.repeat(completed) + '░'.repeat(steps.length - completed)
686
+
687
+ console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
688
+ console.log(`进度:[${bar}] ${completed}/${steps.length}`)
689
+
690
+ // ── Revision v1 信息 ──
691
+ if (stageData.status === 'revising') {
692
+ console.log(`\n🔧 修订中 (revision ${stageData.revision || 1})`)
693
+ if (stageData.reopenedFromStep) console.log(` 从步骤:${stageData.reopenedFromStep}`)
694
+ if (stageData.reopenedAt) console.log(` 重开时间:${stageData.reopenedAt}`)
695
+ }
696
+ if (stageData.status === 'stale') {
697
+ console.log(`\n⚠️ 已失效`)
698
+ if (stageData.staleReason) console.log(` 原因:${stageData.staleReason}`)
699
+ console.log(` 建议:sillyspec run ${stageName} --reopen --from-step 1`)
700
+ }
701
+ if (stageData.status === 'completed') {
702
+ console.log(`\n✅ 已完成`)
703
+ }
704
+
705
+ console.log('')
706
+
707
+ const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
708
+
709
+ if (progress.batchProgress) {
710
+ const bp = progress.batchProgress
711
+ const bpTotal = bp.total || 0
712
+ const bpCompleted = bp.completed || 0
713
+ const bpFailed = bp.failed || 0
714
+ const bpSkipped = bp.skipped || 0
715
+ const bpBarLen = 20
716
+ const bpFilled = Math.round((bpCompleted / Math.max(bpTotal, 1)) * bpBarLen)
717
+ const bpBar = '█'.repeat(bpFilled) + '░'.repeat(bpBarLen - bpFilled)
718
+ const bpParts = []
719
+ if (bpFailed > 0) bpParts.push(`${bpFailed} 失败`)
720
+ if (bpSkipped > 0) bpParts.push(`${bpSkipped} 跳过`)
721
+ const bpSuffix = bpParts.length ? ` (${bpParts.join(', ')})` : ''
722
+ console.log(`\n📊 批量进度: ${bpBar} ${bpCompleted}/${bpTotal}${bpSuffix}\n`)
723
+ }
724
+
725
+ steps.forEach((step, i) => {
726
+ const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : '⬜'
727
+ const isCurrent = (step.status === 'pending' || step.status === 'in-progress') && i === firstPending
728
+ const isWaiting = step.status === 'waiting'
729
+ console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}`)
730
+ if (isWaiting) {
731
+ if (step.waitReason) console.log(` 原因:${step.waitReason}`)
732
+ if (step.waitOptions) console.log(` 选项:${formatWaitOptions(step.waitOptions)}`)
733
+ if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`)
734
+ }
735
+ })
736
+ }
737
+
738
+ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
739
+ // execute 阶段 reset 时清理自建 worktree,否则下次 run execute 会因 existingMeta 存在
740
+ // 直接复用带脏状态的旧 worktree(启动逻辑:meta 存在即复用,不查健康状态)
741
+ if (stageName === 'execute' && changeName) {
742
+ try {
743
+ const { WorktreeManager } = await import('../worktree.js')
744
+ const wm = new WorktreeManager({ cwd })
745
+ const meta = wm.getMeta(changeName)
746
+ if (meta) {
747
+ const cleanResult = wm.cleanup(changeName)
748
+ if (cleanResult.residual?.length > 0) {
749
+ console.warn(`⚠️ reset 清理 worktree 残留: ${cleanResult.residual.join('; ')}`)
750
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`)
751
+ } else if (cleanResult.result === 'kept') {
752
+ console.log(`🔗 旧 worktree 保留 (${cleanResult.mode}: 外部隔离环境)`)
753
+ } else if (cleanResult.result !== 'skipped') {
754
+ console.log(`🧹 已清理旧 worktree (${cleanResult.result}, mode: ${cleanResult.mode}),下次 execute 将重建干净环境`)
755
+ }
756
+ }
757
+ } catch (e) {
758
+ console.warn(`⚠️ reset 清理 worktree 失败(不阻断 reset): ${e.message}`)
759
+ }
760
+ }
761
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
762
+ progress.stages[stageName] = {
763
+ status: 'in-progress',
764
+ startedAt: new Date().toLocaleString('zh-CN',{hour12:false}),
765
+ completedAt: null,
766
+ steps: defSteps ? defSteps.map(s => ({ name: s.name, status: 'pending' })) : []
767
+ }
768
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
769
+ await pm._write(cwd, progress, changeName)
770
+ triggerSync(cwd, changeName, platformOpts)
771
+ console.log(`🔄 ${stageName} 阶段已重置`)
772
+ }
773
+
774
+ /**
775
+ * auto 模式:自动推进 brainstorm → plan → execute → verify
776
+ */
777
+ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
778
+ const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
779
+ const isDone = flags.includes('--done')
780
+ const outputIdx = flags.indexOf('--output')
781
+ const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
782
+ const inputIdx = flags.indexOf('--input')
783
+ const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
784
+ const skipApproval = flags.includes('--skip-approval')
785
+ const explicitMode = (() => {
786
+ const m = flags.indexOf('--mode')
787
+ return m !== -1 && flags[m + 1] ? flags[m + 1] : null
788
+ })()
789
+ const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
790
+
791
+ // Helper: 在 auto 模式下获取步骤定义
792
+ const getAutoSteps = async (stage) => {
793
+ if (stage === 'brainstorm') {
794
+ return brainstormAutoDef.steps
795
+ }
796
+ return getStageSteps(stage, cwd, progress, platformOpts?.specRoot || null)
797
+ }
798
+
799
+ const nextInFlow = (stage) => {
800
+ const i = flowStages.indexOf(stage)
801
+ return i >= 0 && i < flowStages.length - 1 ? flowStages[i + 1] : null
802
+ }
803
+ const firstOpenStage = () => flowStages.find(s => progress.stages?.[s]?.status !== 'completed')
804
+ const ensureAutoStage = async (stage) => {
805
+ const stageChanged = progress.currentStage !== stage
806
+ progress.currentStage = stage
807
+ // Auto 模式下 brainstorm 使用 artifact-first 步骤
808
+ if (stage === 'brainstorm') {
809
+ const existingSteps = progress.stages?.brainstorm?.steps
810
+ const isAutoModeSteps = existingSteps?.length === 4 && existingSteps?.[0]?.name === '状态检查与上下文加载'
811
+ if (!isAutoModeSteps) {
812
+ if (!progress.stages) progress.stages = {}
813
+ progress.stages.brainstorm = {
814
+ status: 'in-progress',
815
+ startedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
816
+ completedAt: null,
817
+ steps: brainstormAutoDef.steps.map(s => ({ name: s.name, status: 'pending' }))
818
+ }
819
+ await pm._write(cwd, progress, changeName)
820
+ triggerSync(cwd, changeName, platformOpts)
821
+ progress = await pm.read(cwd, changeName)
822
+ return progress
823
+ }
824
+ }
825
+ const changed = await ensureStageSteps(progress, stage, cwd)
826
+ if (stageChanged || changed) {
827
+ await pm._write(cwd, progress, changeName)
828
+ triggerSync(cwd, changeName, platformOpts)
829
+ }
830
+ progress = await pm.read(cwd, changeName)
831
+ return progress
832
+ }
833
+
834
+ // ── Classify change on first entry ──
835
+ if (!progress.stages?.brainstorm?.status && !progress.stages?.plan?.status) {
836
+ const { classifyChange } = await import('../classify-change.js')
837
+ const classification = classifyChange({ description: inputText || '', explicitMode })
838
+ if (classification.mode === 'quick') {
839
+ console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
840
+ console.log(` 此变更建议使用 quick 模式,运行:sillyspec run quick "${inputText || '需求'}"`)
841
+ return
842
+ }
843
+ console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
844
+ }
845
+
846
+ let currentStage = progress.currentStage
847
+ if (!currentStage || progress.stages?.[currentStage]?.status === 'completed') {
848
+ currentStage = firstOpenStage()
849
+ }
850
+ if (!currentStage) {
851
+ console.log('All auto flow stages are complete.')
852
+ return
853
+ }
854
+ if (!flowStages.includes(currentStage)) {
855
+ const openStage = firstOpenStage()
856
+ if (!openStage) {
857
+ console.log('All auto flow stages are complete.')
858
+ return
859
+ }
860
+ console.log(`⚠️ 当前阶段 ${currentStage} 不在 auto 流程中,自动跳转到 ${openStage}`)
861
+ currentStage = openStage
862
+ }
863
+ await ensureAutoStage(currentStage)
864
+
865
+ if (!isDone) {
866
+ console.log('════════════════════════════════════════')
867
+ console.log(' SillySpec Auto Mode')
868
+ if (changeName) console.log(` Change: ${changeName}`)
869
+ console.log('════════════════════════════════════════')
870
+ console.log(` Flow: ${flowStages.join(' -> ')}`)
871
+ console.log(` Current: ${currentStage}`)
872
+ for (const stage of flowStages) {
873
+ const stageData = progress.stages?.[stage]
874
+ const total = stageData?.steps?.length || '?'
875
+ const completed = stageData?.steps?.filter(step => step.status === 'completed' || step.status === 'skipped').length || 0
876
+ const marker = stageData?.status === 'completed' ? 'done' : stage === currentStage ? 'active' : 'pending'
877
+ console.log(` ${marker} ${stage} (${completed}/${total})`)
878
+ }
879
+ console.log('')
880
+
881
+ const defSteps = await getAutoSteps(currentStage)
882
+ const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
883
+ if (pendingIdx === -1) {
884
+ const wsIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'waiting') ?? -1
885
+ if (wsIdx !== -1) {
886
+ const ws = progress.stages[currentStage].steps[wsIdx]
887
+ console.log(`⏸️ Step ${wsIdx + 1} 等待用户输入:${ws.name}`)
888
+ if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
889
+ console.log(` 继续:sillyspec run auto --continue --answer "..."`)
890
+ return
891
+ }
892
+ const next = nextInFlow(currentStage)
893
+ if (next) console.log(`${currentStage} is complete. Run: sillyspec run auto --done --output "${currentStage} complete"`)
894
+ else console.log('All auto flow stages are complete.')
895
+ return
896
+ }
897
+ // execute 阶段启动前检查审批
898
+ if (currentStage === 'execute' && !skipApproval) {
899
+ const approval = await checkApproval(cwd, changeName, platformOpts)
900
+ if (approval) {
901
+ if (approval.status === 'rejected') {
902
+ console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
903
+ process.exit(1)
904
+ }
905
+ if (approval.status === 'pending') {
906
+ console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
907
+ console.log(' 提示:使用 --skip-approval 跳过审批检查')
908
+ }
909
+ }
910
+ }
911
+ await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
912
+ return
913
+ }
914
+
915
+ if (!outputText) {
916
+ console.error('auto --done requires --output')
917
+ process.exit(2) // 用法错 → exit 2
918
+ }
919
+
920
+ const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
921
+ if (!result) return
922
+ progress = await pm.read(cwd, changeName)
923
+
924
+ const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
925
+ if (nextPendingIdx !== -1) {
926
+ const defSteps = await getAutoSteps(currentStage)
927
+ // execute 阶段启动前检查审批
928
+ if (currentStage === 'execute' && !skipApproval) {
929
+ const approval = await checkApproval(cwd, changeName, platformOpts)
930
+ if (approval) {
931
+ if (approval.status === 'rejected') {
932
+ console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
933
+ process.exit(1)
934
+ }
935
+ if (approval.status === 'pending') {
936
+ console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
937
+ console.log(' 提示:使用 --skip-approval 跳过审批检查')
938
+ }
939
+ }
940
+ }
941
+ await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
942
+ return
943
+ }
944
+
945
+ const next = nextInFlow(currentStage)
946
+ if (!next) {
947
+ console.log('\nAll auto flow stages are complete.')
948
+ return
949
+ }
950
+
951
+ // ── next-action.json 驱动:brainstorm → plan 推进判断 ──
952
+ if (currentStage === 'brainstorm' && next === 'plan') {
953
+ const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
954
+ if (changeDir) {
955
+ const nextActionFile = join(changeDir, 'brainstorm', 'next-action.json')
956
+ try {
957
+ const nextAction = JSON.parse(readFileSync(nextActionFile, 'utf8'))
958
+ if (nextAction.has_blocking_questions === true) {
959
+ console.log(`\n⏸️ brainstorm 有阻塞问题,无法自动进入 plan:`)
960
+ for (const q of (nextAction.questions || [])) {
961
+ console.log(` Q-${q.id}: ${q.question}`)
962
+ if (q.options) console.log(` 选项:${q.options.join(' / ')}`)
963
+ if (q.recommended) console.log(` 推荐:${q.recommended}`)
964
+ }
965
+ console.log(`\n 请回答阻塞问题后继续:sillyspec run auto --done --output "已回答"`)
966
+ return
967
+ }
968
+ console.log(`\n✅ next-action.json: ${nextAction.status},自动进入 plan`)
969
+ } catch (e) {
970
+ // next-action.json 不存在或格式错误,继续推进(向后兼容)
971
+ console.log(`\n⚠️ next-action.json 未找到,继续进入 plan`)
972
+ }
973
+ }
974
+ }
975
+
976
+ progress.currentStage = next
977
+ if (!progress.stages[next]) {
978
+ progress.stages[next] = { status: 'pending', steps: [], startedAt: null, completedAt: null }
979
+ }
980
+ if (progress.stages[next].status === 'pending' || !progress.stages[next].status) {
981
+ progress.stages[next].status = 'in-progress'
982
+ progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
983
+ }
984
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
985
+ await ensureStageSteps(progress, next, cwd)
986
+ await pm._write(cwd, progress, changeName)
987
+ triggerSync(cwd, changeName, platformOpts)
988
+ progress = await pm.read(cwd, changeName)
989
+
990
+ console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
991
+ const nextSteps = await getAutoSteps(next)
992
+ const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
993
+ if (firstPending !== -1) {
994
+ // execute 阶段启动前检查审批
995
+ if (next === 'execute' && !skipApproval) {
996
+ const approval = await checkApproval(cwd, changeName, platformOpts)
997
+ if (approval) {
998
+ if (approval.status === 'rejected') {
999
+ console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
1000
+ process.exit(1)
1001
+ }
1002
+ if (approval.status === 'pending') {
1003
+ console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1004
+ console.log(' 提示:使用 --skip-approval 跳过审批检查')
1005
+ }
1006
+ }
1007
+ }
1008
+ await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null, platformOpts)
1009
+ }
1010
+ }
1011
+