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