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,725 @@
1
+ /**
2
+ * run/complete.js(W6 Step7b 从 run.js 抽出)。
3
+ *
4
+ * step 完成处理核心:completeStep(调度主干——WAIT/scanProfile/requiresWait 门控 + 各 handler 调用 +
5
+ * completion path 收尾 + single-step path)+ skipStep/waitStep/continueStep(W6 Step7b-2 续搬)。
6
+ * 本 commit(7b-1)先搬 completeStep + 2 独占 helper(validateMetadata/validateFileLocations)。
7
+ *
8
+ * 安全锚:run.js 始终 barrel。completeStep 由 run.js import 回来;_completeStepForTest 被 9 个
9
+ * run-complete-step-* characterization 测试直接 import,run.js barrel re-export(export { completeStep as
10
+ * _completeStepForTest } 沿用 imported-binding re-export)契约保留。
11
+ *
12
+ * 依赖(completeStep 仅用已抽叶子 + 随搬 helper,零 run.js 闭包,零动态 import):
13
+ * - shared.js: triggerSync/WAIT_MARKER_RE/getStageSteps;prompt.js: outputStep;gates.js: enforceDepsGate/runStageCompletionGates
14
+ * - complete-handlers.js: 8 handler;stages/index.js: stageRegistry;worktree-apply.js: formatExecuteSummary
15
+ * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
16
+ */
17
+ import { join } from 'node:path'
18
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs'
19
+ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions } from './shared.js'
20
+ import { outputStep } from './prompt.js'
21
+ import { enforceDepsGate, runStageCompletionGates } from './gates.js'
22
+ import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact, handleExecuteWorktreeCleanup, handleScanStageCompleted } from './complete-handlers.js'
23
+ import { stageRegistry } from '../stages/index.js'
24
+ import { formatExecuteSummary } from '../worktree-apply.js'
25
+
26
+ function validateMetadata(cwd, stageName, specBase) {
27
+ const changesDir = join(specBase, 'changes')
28
+ if (!existsSync(changesDir)) return
29
+
30
+ const cutoff = Date.now() - 10 * 60 * 1000
31
+ const missing = []
32
+
33
+ function walk(dir) {
34
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
35
+ const full = join(dir, entry.name)
36
+ try {
37
+ if (entry.isDirectory()) { walk(full); continue }
38
+ if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
39
+ const mtime = statSync(full).mtimeMs
40
+ if (mtime < cutoff) continue
41
+ const content = readFileSync(full, 'utf-8')
42
+ if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
43
+ if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
44
+ } catch (e) { /* skip unreadable files */ }
45
+ }
46
+ }
47
+
48
+ walk(changesDir)
49
+ const unique = [...new Set(missing)]
50
+ if (unique.length > 0) {
51
+ console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
52
+ unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
53
+ console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 验证关键文件是否存在于正确的变更目录下
59
+ * 防止 AI 将文件写到错误的路径
60
+ */
61
+ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
62
+ const effectiveChange = changeName || progress.currentChange
63
+ if (!effectiveChange) return
64
+
65
+ const changeDir = join(specBase, 'changes', effectiveChange)
66
+ if (!existsSync(changeDir)) return
67
+
68
+ // 每个阶段完成后预期存在的文件
69
+ const expectedFiles = {
70
+ brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
71
+ plan: ['plan.md'],
72
+ archive: ['module-impact.md'],
73
+ }
74
+
75
+ const expected = expectedFiles[stageName]
76
+ if (!expected) return
77
+
78
+ const missing = []
79
+ for (const file of expected) {
80
+ if (!existsSync(join(changeDir, file))) {
81
+ missing.push(file)
82
+ }
83
+ }
84
+
85
+ if (missing.length > 0) {
86
+ console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
87
+ console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
88
+ for (const f of missing) {
89
+ // 检查是否写到了错误的位置
90
+ const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
91
+ if (existsSync(wrongPath)) {
92
+ console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
93
+ console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
94
+ } else {
95
+ console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
96
+ }
97
+ }
98
+ } else {
99
+ console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
100
+ }
101
+ }
102
+
103
+ export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
104
+ const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
105
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
106
+ const stageData = progress.stages[stageName]
107
+ const scanProfile = stageData?.scanProfile || null
108
+
109
+ // ── WAIT MARKER 硬校验 ──
110
+ // 如果 output 包含等待标记,拒绝 --done 推进
111
+ if (outputText) {
112
+ const match = WAIT_MARKER_RE.exec(outputText)
113
+ if (match) {
114
+ console.error(`❌ Refused: step output contains ${match[1]} — human input required.`)
115
+ console.error(` 使用 --wait 替代 --done,例如:`)
116
+ console.error(` sillyspec run ${stageName} --wait --reason "等待用户决策" --output "你的摘要"${changeName ? ` --change ${changeName}` : ''}`)
117
+ process.exit(1)
118
+ }
119
+ }
120
+
121
+ // scanProfile 非 deep 模式:截断 outputText 减少 token 传递
122
+ let effectiveOutput = outputText
123
+ if (scanProfile && scanProfile.mode !== 'deep' && outputText && outputText.length > 1000) {
124
+ effectiveOutput = outputText.slice(0, 1000) + '\n\n…[输出已截断,完整内容见 artifact]'
125
+ }
126
+ if (!stageData || !stageData.steps) {
127
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
128
+ process.exit(1)
129
+ }
130
+
131
+ const steps = stageData.steps
132
+ const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
133
+ if (currentIdx === -1) {
134
+ console.error('没有待完成的步骤')
135
+ process.exit(1)
136
+ }
137
+
138
+ // ── requiresWait 硬门控 ──
139
+ const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
140
+ const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
141
+ const currentStep = steps[currentIdx]
142
+ if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
143
+ // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
144
+ const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
145
+ // B4: 前置 step 已对同一问题确认则自动跳过重复 wait。
146
+ // 归一化去掉「最终/再次/重复」等修饰词,避免 step N「确认 X」与 step M「最终确认 X」重复打断。
147
+ const normalizeReason = (r) => (r || '').replace(/(最终|再次|重复|最后|首|初次|首次)/g, '').trim()
148
+ const currentReason = normalizeReason(currentStepDef.waitReason)
149
+ const priorConfirmed = currentReason && steps.slice(0, currentIdx).some(s =>
150
+ s.waitAnswer && s.status === 'completed' && normalizeReason(s.waitReason) === currentReason
151
+ )
152
+ if (priorConfirmed) {
153
+ currentStep.status = 'waiting'
154
+ currentStep.waitAnswer = '前置步骤已对同一问题确认 — 自动跳过重复 wait'
155
+ currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
156
+ console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
157
+ } else if (doneAnswer) {
158
+ currentStep.status = 'waiting'
159
+ currentStep.waitAnswer = doneAnswer
160
+ currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
161
+ console.log(`⚠️ Step "${currentStep.name}" 需要 wait,但 --done 带了 --answer,自动补全 wait 状态。`)
162
+ } else {
163
+ console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
164
+ console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
165
+ if (currentStepDef.waitOptions) {
166
+ console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
167
+ }
168
+ console.error(` 请先执行:`)
169
+ console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
170
+ console.error(` 或使用 --done --answer "用户回答" 一步完成 wait + done`)
171
+ process.exit(1)
172
+ }
173
+ }
174
+
175
+ // ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
176
+ await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
177
+
178
+ steps[currentIdx].status = 'completed'
179
+ steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
180
+ if (outputText) {
181
+ const MAX_OUTPUT = 200
182
+ if (outputText.length > MAX_OUTPUT) {
183
+ steps[currentIdx].output = outputText.slice(0, MAX_OUTPUT) + '…'
184
+ steps[currentIdx].output_truncated = true
185
+ steps[currentIdx].output_original_length = outputText.length
186
+ // 平台模式:artifact 写入 runtime-root,否则写 .sillyspec/.runtime/artifacts
187
+ const artifactBase = platformOpts?.runtimeRoot
188
+ ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown')
189
+ : join(specBase, '.runtime', 'artifacts')
190
+ mkdirSync(artifactBase, { recursive: true })
191
+ const ts = new Date().toISOString().slice(0,19).replace(/[-T:]/g, '')
192
+ writeFileSync(join(artifactBase, `${changeName || 'unknown'}-${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
193
+ } else {
194
+ steps[currentIdx].output = outputText
195
+ }
196
+ }
197
+
198
+ // archive「确认归档」收尾:--confirm 门控 + archiveChangeDirectory 移动 + 推荐文档校验
199
+ // (抽成 handleArchiveConfirmStep;缺 --confirm 时返回 early-return 对象,completeStep 透传)
200
+ {
201
+ const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase })
202
+ if (_archiveEarlyReturn) return _archiveEarlyReturn
203
+ }
204
+
205
+ // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤(抽成 handlePlanGeneratePlanStep)
206
+ await handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress })
207
+
208
+ // scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(抽成 handleScanProjectListStep)
209
+ await handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts })
210
+
211
+ // execute Wave artifact(W6 Step6c 抽至 complete-handlers.js handleExecuteWaveArtifact)
212
+ await handleExecuteWaveArtifact({ stageName, steps, currentIdx, changeName, specBase, cwd })
213
+
214
+ const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
215
+
216
+ if (nextPendingIdx === -1) {
217
+ // 也检查是否有 waiting 的步骤
218
+ const hasWaiting = steps.some(s => s.status === 'waiting')
219
+ if (hasWaiting) {
220
+ // 有等待中的步骤,阶段未完成
221
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
222
+ await pm._write(cwd, progress, changeName)
223
+ const wsIdx = steps.findIndex(s => s.status === 'waiting')
224
+ console.log(`\n⏸️ 阶段暂停:Step ${wsIdx + 1} 等待用户输入`)
225
+ if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
226
+ return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
227
+ }
228
+ // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion)
229
+ await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts })
230
+
231
+ stageData.status = 'completed'
232
+ stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
233
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
234
+ await pm._write(cwd, progress, changeName)
235
+ triggerSync(cwd, changeName, platformOpts)
236
+
237
+ // Append to user-inputs.md
238
+ if (outputText) {
239
+ const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
240
+ const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
241
+ appendFileSync(inputsPath, entry)
242
+ }
243
+
244
+ // scan 平台 manifest + post-check(W6 Step6d 抽至 complete-handlers.js handleScanStageCompleted)
245
+ const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
246
+ if (_scanResult) return _scanResult
247
+
248
+ // 防御性守卫变量:确认所有步骤确实标记为 completed
249
+ const actualCompleted = steps.filter(s => s.status === 'completed').length
250
+ const actualTotal = steps.length
251
+
252
+ validateMetadata(cwd, stageName, specBase)
253
+
254
+ // 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
255
+ if (actualCompleted === actualTotal && actualTotal > 0) {
256
+ validateFileLocations(cwd, stageName, progress, changeName, specBase)
257
+ }
258
+
259
+ // 辅助阶段完成后重置步骤
260
+ const stageDef = stageRegistry[stageName]
261
+ if (stageDef?.auxiliary) {
262
+ const freshSteps = (stageDef.steps || []).map(s => ({
263
+ name: s.name,
264
+ status: 'pending',
265
+ output: null,
266
+ completedAt: null
267
+ }))
268
+ stageData.steps = freshSteps
269
+ stageData.status = 'pending'
270
+ stageData.completedAt = null
271
+ if (progress.currentStage === stageName) progress.currentStage = ''
272
+ await pm._write(cwd, progress, changeName)
273
+ }
274
+
275
+ const total = steps.length
276
+ console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
277
+
278
+ if (stageName === 'execute') {
279
+ // execute run summary:展示真实可得的结构化信息
280
+ try {
281
+ const lastOutput = steps[steps.length - 1]?.output || ''
282
+ const summary = formatExecuteSummary({
283
+ changeName,
284
+ stepsCompleted: total,
285
+ stepsTotal: total,
286
+ agentSummary: lastOutput,
287
+ cwd,
288
+ })
289
+ console.log(`\n${summary}`)
290
+ } catch (e) {
291
+ // summary 失败不影响主流程
292
+ console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
293
+ }
294
+ } else if (stageName === 'archive') {
295
+ console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
296
+ } else if (stageName === 'verify') {
297
+ // verify 的"验证通过"提示延后到下方 validator 通过后才打印,
298
+ // 避免校验失败(FAIL / 缺 verify-result.md)时仍声称"验证通过可以归档"。
299
+ } else {
300
+ // D1 暗衔接修:阶段刚完成,CLI 本就知道状态机下一步是哪个阶段(_getNextSuggestion),
301
+ // 却只丢一句「下一步由你决定」让 agent 自己猜命令。改为按 progress 实际状态给出精确命令。
302
+ let next = null
303
+ try { next = pm._getNextSuggestion(progress) } catch { /* 读建议失败不阻断完成 */ }
304
+ if (next && next.command) {
305
+ console.log(`\n👉 ${next.text}`)
306
+ console.log(` 下一步命令:${next.command}`)
307
+ } else {
308
+ console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
309
+ }
310
+ }
311
+
312
+ // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
313
+ if (actualCompleted === actualTotal && actualTotal > 0) {
314
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
315
+ if (_gateEarlyReturn) return _gateEarlyReturn
316
+ } else if (actualCompleted < actualTotal) {
317
+ // 实际步骤未全部完成,跳过 validator(状态可能不同步)
318
+ console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
319
+ }
320
+
321
+ // execute worktree cleanup(W6 Step6c 抽至 complete-handlers.js handleExecuteWorktreeCleanup)
322
+ await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
323
+
324
+ return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
325
+ }
326
+
327
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
328
+ await pm._write(cwd, progress, changeName)
329
+ triggerSync(cwd, changeName, platformOpts)
330
+
331
+ // Append to user-inputs.md
332
+ if (outputText) {
333
+ const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
334
+ const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
335
+ appendFileSync(inputsPath, entry)
336
+ }
337
+
338
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
339
+ console.log(`✅ Step ${currentIdx + 1}/${steps.length} 完成:${steps[currentIdx].name}\n`)
340
+
341
+ // Workflow post_check(W6 Step6 抽至 complete-handlers.js handleWorkflowPostCheck)
342
+ const _wfResult = await handleWorkflowPostCheck({ stageName, steps, currentIdx, cwd, specBase, progress, platformOpts, changeName })
343
+ if (_wfResult) return _wfResult
344
+
345
+ if (printNext) {
346
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
347
+ }
348
+ return { stageCompleted: false, currentIdx, nextPendingIdx }
349
+ }
350
+
351
+ // ── Step 调度兄弟函数(W6 Step7b-2 从 run.js 搬入:skip/wait/continue + formatWaitHistory)──
352
+ function formatWaitHistory(step) {
353
+ const answers = Array.isArray(step.waitAnswers) ? step.waitAnswers : []
354
+ if (answers.length === 0) return null
355
+ let text = `本步骤历史用户回答(共 ${answers.length} 轮):\n`
356
+ for (const item of answers) {
357
+ text += `\n${item.round}. ${item.answer}`
358
+ if (item.question) {
359
+ text += `\n 对应问题/摘要:${item.question}`
360
+ }
361
+ }
362
+ const maxRounds = step.maxWaitRounds || null
363
+ if (maxRounds && answers.length >= maxRounds) {
364
+ text += `\n\n已达到 maxWaitRounds=${maxRounds}。请基于以上回答总结需求;除非仍有阻塞问题,否则完成本步骤并进入方案讨论。`
365
+ } else {
366
+ text += `\n\n请判断信息是否足够:如果足够,完成本步骤;如果仍缺关键约束,再提出一个问题并 --wait。`
367
+ }
368
+ return text
369
+ }
370
+
371
+ export async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, options = {}) {
372
+ const { changeName, nonInteractive = false, platformOpts = {} } = options
373
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
374
+ const stageData = progress.stages[stageName]
375
+
376
+ if (!stageData || !stageData.steps) {
377
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
378
+ process.exit(1)
379
+ }
380
+
381
+ // 查找下一个 pending 或 in-progress 的步骤
382
+ const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
383
+ if (currentIdx === -1) {
384
+ console.error('没有可以等待的步骤')
385
+ process.exit(1)
386
+ }
387
+
388
+ // 前置检查:不允许已有 waiting 步骤时再 --wait
389
+ const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
390
+ if (existingWaitingIdx !== -1) {
391
+ const ws = stageData.steps[existingWaitingIdx]
392
+ console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
393
+ console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
394
+ process.exit(1)
395
+ }
396
+
397
+ // maxWaitRounds 硬上限:达到后拒绝继续 --wait
398
+ const currentStep = stageData.steps[currentIdx]
399
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
400
+ const stepDef = defSteps?.[currentIdx] || {}
401
+ const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
402
+ const currentWaitRound = currentStep.waitRound || 0
403
+ if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
404
+ console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
405
+ console.error(` 请基于已有回答完成本步骤:`)
406
+ console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
407
+ process.exit(1)
408
+ }
409
+
410
+ // 非交互模式下拒绝等待
411
+ if (nonInteractive) {
412
+ console.error(`❌ Human decision required in non-interactive mode.`)
413
+ console.error(` Reason: ${waitReason || '(unknown)'}`)
414
+ if (waitOptions) console.error(` Options: ${formatWaitOptions(waitOptions)}`)
415
+ console.error(` Fix: rerun with --interactive or provide decision via sillyspec run ${stageName} --continue --answer "..."`)
416
+ process.exit(2)
417
+ }
418
+
419
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
420
+ stageData.steps[currentIdx].status = 'waiting'
421
+ stageData.steps[currentIdx].waitedAt = now
422
+ if (outputText) {
423
+ const MAX_OUTPUT = 200
424
+ stageData.steps[currentIdx].output = outputText.length > MAX_OUTPUT
425
+ ? outputText.slice(0, MAX_OUTPUT) + '…' : outputText
426
+ }
427
+ if (waitReason) stageData.steps[currentIdx].waitReason = waitReason
428
+ if (waitOptions) {
429
+ // 统一存为 JSON 数组
430
+ try {
431
+ const parsed = JSON.parse(waitOptions)
432
+ if (Array.isArray(parsed)) {
433
+ stageData.steps[currentIdx].waitOptions = JSON.stringify(parsed)
434
+ } else {
435
+ stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
436
+ }
437
+ } catch {
438
+ stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
439
+ }
440
+ }
441
+
442
+ progress.lastActive = now
443
+ await pm._write(cwd, progress, changeName)
444
+ triggerSync(cwd, changeName, platformOpts)
445
+
446
+ console.log(`⏸️ Step ${currentIdx + 1}/${stageData.steps.length} 已暂停等待:${stageData.steps[currentIdx].name}`)
447
+ if (waitReason) console.log(` 原因:${waitReason}`)
448
+ if (waitOptions) console.log(` 选项:${formatWaitOptions(waitOptions)}`)
449
+ console.log(` 继续时执行:sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
450
+ }
451
+
452
+ export async function continueStep(pm, progress, stageName, cwd, answer, options = {}) {
453
+ const { changeName, platformOpts = {}, fromStep } = options
454
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
455
+ const stageData = progress.stages[stageName]
456
+
457
+ if (!stageData || !stageData.steps) {
458
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
459
+ process.exit(1)
460
+ }
461
+
462
+ if (!answer) {
463
+ console.error('❌ --continue 需要 --answer 参数')
464
+ process.exit(2) // 用法错 → exit 2
465
+ }
466
+
467
+ // 查找 waiting 的步骤
468
+ const waitingSteps = stageData.steps.map((s, i) => ({ ...s, idx: i })).filter(s => s.status === 'waiting')
469
+ if (waitingSteps.length === 0) {
470
+ console.error('没有处于等待状态的步骤')
471
+ process.exit(1)
472
+ }
473
+
474
+ // --from-step 支持在多个 waiting 时指定恢复哪一个(避免被迫 --reset 破坏性重置)。
475
+ let currentIdx
476
+ if (fromStep != null) {
477
+ let targetIdx
478
+ if (/^\d+$/.test(String(fromStep))) {
479
+ targetIdx = parseInt(String(fromStep), 10) - 1 // 1-based → 0-based
480
+ } else {
481
+ targetIdx = stageData.steps.findIndex(s => s.name === fromStep)
482
+ }
483
+ const target = targetIdx >= 0 ? stageData.steps[targetIdx] : null
484
+ if (!target || target.status !== 'waiting') {
485
+ console.error(`❌ --from-step 指定的步骤「${fromStep}」不处于等待状态。当前 waiting:`)
486
+ for (const ws of waitingSteps) console.error(` Step ${ws.idx + 1}: ${ws.name}`)
487
+ process.exit(1)
488
+ }
489
+ currentIdx = targetIdx
490
+ } else if (waitingSteps.length > 1) {
491
+ // 多 waiting 且未指定 fromStep:给出渐进出路(逐个 continue),而非只建议破坏性 reset。
492
+ console.error(`❌ 检测到 ${waitingSteps.length} 个等待中的步骤,无法确定恢复目标:`)
493
+ for (const ws of waitingSteps) {
494
+ console.error(` Step ${ws.idx + 1}: ${ws.name}${ws.waitReason ? `(${ws.waitReason})` : ''}`)
495
+ }
496
+ console.error(` 出路(二选一):`)
497
+ console.error(` ① 逐个恢复(推荐,非破坏性):sillyspec run ${stageName} --continue --from-step <序号|名称> --answer "..."${changeName ? ` --change ${changeName}` : ''},每次解一个 waiting,降到 1 个后即可不带 --from-step 继续`)
498
+ console.error(` ② 全部重来(破坏性、不可逆):sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''}`)
499
+ process.exit(1)
500
+ } else {
501
+ currentIdx = waitingSteps[0].idx
502
+ }
503
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
504
+ const currentStepDef = defSteps?.[currentIdx] || {}
505
+ const currentStep = stageData.steps[currentIdx]
506
+ const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
507
+ const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
508
+ const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
509
+
510
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
511
+ const prevOutput = currentStep.output || ''
512
+ const waitRound = (currentStep.waitRound || 0) + 1
513
+ currentStep.waitRound = waitRound
514
+ currentStep.waitAnswer = answer
515
+ currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
516
+ currentStep.waitAnswers.push({
517
+ round: waitRound,
518
+ answer,
519
+ question: prevOutput || null,
520
+ answeredAt: now,
521
+ })
522
+ currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
523
+
524
+ // 合并 waiting 信息到 output
525
+ const waitInfo = currentStep.waitReason || ''
526
+ if (waitInfo) {
527
+ currentStep.output = prevOutput
528
+ ? `${prevOutput} | 用户回答#${waitRound}:${answer}`
529
+ : `用户回答#${waitRound}:${answer}`
530
+ }
531
+
532
+ // 清除等待状态
533
+ delete currentStep.waitReason
534
+ delete currentStep.waitOptions
535
+ delete currentStep.waitedAt
536
+
537
+ if (shouldReturnToCurrentStep) {
538
+ currentStep.status = 'pending'
539
+ currentStep.completedAt = null
540
+ } else {
541
+ currentStep.status = 'completed'
542
+ currentStep.completedAt = now
543
+ }
544
+
545
+ progress.lastActive = now
546
+ await pm._write(cwd, progress, changeName)
547
+ triggerSync(cwd, changeName, platformOpts)
548
+
549
+ console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
550
+ console.log(` 回答:${answer}`)
551
+
552
+ // Append to user-inputs.md
553
+ const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
554
+ const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
555
+ appendFileSync(inputsPath, entry)
556
+
557
+ // shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
558
+ if (shouldReturnToCurrentStep) {
559
+ console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
560
+ if (isRepeatableWait) {
561
+ console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
562
+ }
563
+ if (defSteps && defSteps[currentIdx]) {
564
+ console.log('')
565
+ await outputStep(
566
+ stageName,
567
+ currentIdx,
568
+ defSteps,
569
+ cwd,
570
+ changeName,
571
+ progress.project || null,
572
+ platformOpts,
573
+ formatWaitHistory(currentStep)
574
+ )
575
+ }
576
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
577
+ }
578
+
579
+ // 检查阶段是否全部完成
580
+ const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
581
+ const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
582
+ if (nextPendingIdx === -1 && nextWaitingIdx === -1) {
583
+ stageData.status = 'completed'
584
+ stageData.completedAt = now
585
+ await pm._write(cwd, progress, changeName)
586
+ console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
587
+ // ── execute 阶段完成时条件性清理 worktree ──
588
+ if (stageName === 'execute' && changeName) {
589
+ try {
590
+ const { WorktreeManager } = await import('../worktree.js');
591
+ const wm = new WorktreeManager({ cwd });
592
+ const meta = wm.getMeta(changeName);
593
+ if (!meta) {
594
+ console.log('🔗 Worktree: n/a (no meta)');
595
+ } else if (meta.mode === 'native-worktree') {
596
+ console.log('🔗 Worktree: kept (外部隔离环境)');
597
+ } else {
598
+ // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
599
+ const check = wm.hasUnappliedChanges(changeName);
600
+ if (check.hasChanges) {
601
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
602
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
603
+ } else {
604
+ const cleanResult = wm.cleanup(changeName);
605
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
606
+ if (cleanResult.residual?.length > 0) {
607
+ console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
608
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
609
+ }
610
+ }
611
+ }
612
+ } catch (e) {
613
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
614
+ }
615
+ }
616
+ // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
617
+ const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
618
+ if (nextStageHint) {
619
+ console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${nextStageHint}${changeName ? ` --change ${changeName}` : ''}`)
620
+ if (stageName === 'execute') {
621
+ console.log(` ⚠️ 若 worktree 改动还没 apply 到主工作区,先:sillyspec worktree apply ${changeName}`)
622
+ console.log(` (apply 不需要先 commit,支持 working tree 未提交改动)`)
623
+ // plan.md checkbox auto-check:execute 完成 + review.json pass → 自动勾选(治本,比警告可靠)
624
+ try {
625
+ const specBaseLc = platformOpts.specRoot || join(cwd, '.sillyspec')
626
+ const changeDir = join(specBaseLc, 'changes', changeName)
627
+ const planPath = join(changeDir, 'plan.md')
628
+ const runtimeRoot = platformOpts.runtimeRoot || join(specBaseLc, '.runtime')
629
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
630
+ if (existsSync(planPath) && existsSync(runIdFile)) {
631
+ const executeRunId = readFileSync(runIdFile, 'utf8').trim()
632
+ const planContent = readFileSync(planPath, 'utf8')
633
+ const { readReview } = await import('../task-review.js')
634
+ let checkedCount = 0
635
+ let skippedCount = 0
636
+ const updated = planContent.replace(/^(\s*[-*]\s*\[)\s(\]\s*task-\d+)/gim, (match, p1, p2) => {
637
+ const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
638
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
639
+ const r = readReview(reviewPath)
640
+ if (r.ok && r.review?.specVerdict !== 'fail' && r.review?.qualityVerdict !== 'fail') {
641
+ checkedCount++
642
+ return `${p1}x${p2}` // 勾选
643
+ }
644
+ skippedCount++
645
+ return match // 不勾
646
+ })
647
+ if (checkedCount > 0) {
648
+ writeFileSync(planPath, updated)
649
+ console.log(` ✅ 自动勾选 ${checkedCount} 个 task checkbox(基于 review.json pass)`)
650
+ }
651
+ if (skippedCount > 0) {
652
+ console.warn(` ⚠️ ${skippedCount} 个 task 未勾(review.json 缺失/fail)→ archive 会拦。补 review 后重跑 execute --done 触发自动勾`)
653
+ }
654
+ }
655
+ } catch {}
656
+ }
657
+ }
658
+ return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
659
+ }
660
+
661
+ // 输出下一步
662
+ if (nextPendingIdx !== -1 && defSteps) {
663
+ console.log('')
664
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
665
+ } else if (nextWaitingIdx !== -1 && defSteps) {
666
+ // 下一个步骤也在等待状态
667
+ const ws = stageData.steps[nextWaitingIdx]
668
+ console.log(`\n⏸️ Step ${nextWaitingIdx + 1}/${stageData.steps.length} 仍在等待:${ws.name}`)
669
+ if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
670
+ console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
671
+ }
672
+
673
+ return { stageCompleted: false, currentIdx, nextPendingIdx: nextPendingIdx }
674
+ }
675
+
676
+ export async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
677
+ const stageData = progress.stages[stageName]
678
+ if (!stageData || !stageData.steps) {
679
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
680
+ process.exit(1)
681
+ }
682
+
683
+ const steps = stageData.steps
684
+ const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
685
+
686
+ if (currentIdx === -1) {
687
+ const wsIdx = steps.findIndex(s => s.status === 'waiting')
688
+ if (wsIdx !== -1) {
689
+ console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
690
+ console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
691
+ } else {
692
+ console.error('没有待跳过的步骤')
693
+ }
694
+ process.exit(1)
695
+ }
696
+
697
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
698
+ const stepDef = defSteps ? defSteps[currentIdx] : null
699
+ if (stepDef && !stepDef.optional) {
700
+ console.error(`❌ 步骤 "${steps[currentIdx].name}" 不可跳过`)
701
+ process.exit(1)
702
+ }
703
+
704
+ steps[currentIdx].status = 'skipped'
705
+ steps[currentIdx].skippedAt = new Date().toLocaleString('zh-CN',{hour12:false})
706
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
707
+ await pm._write(cwd, progress, changeName)
708
+ triggerSync(cwd, changeName, platformOpts)
709
+
710
+ console.log(`⏭️ Step ${currentIdx + 1}/${steps.length} 已跳过:${steps[currentIdx].name}`)
711
+
712
+ const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
713
+ if (nextPendingIdx !== -1 && defSteps) {
714
+ console.log('')
715
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
716
+ } else {
717
+ const wsIdx = steps.findIndex(s => s.status === 'waiting')
718
+ if (wsIdx !== -1) {
719
+ console.log(`\n⏸️ Step ${wsIdx + 1}/${steps.length} 正在等待:${steps[wsIdx].name}`)
720
+ if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
721
+ console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
722
+ }
723
+ }
724
+ }
725
+