sillyspec 3.25.0 → 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,695 @@
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
+ console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
301
+ }
302
+
303
+ // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
304
+ if (actualCompleted === actualTotal && actualTotal > 0) {
305
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
306
+ if (_gateEarlyReturn) return _gateEarlyReturn
307
+ } else if (actualCompleted < actualTotal) {
308
+ // 实际步骤未全部完成,跳过 validator(状态可能不同步)
309
+ console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
310
+ }
311
+
312
+ // execute worktree cleanup(W6 Step6c 抽至 complete-handlers.js handleExecuteWorktreeCleanup)
313
+ await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
314
+
315
+ return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
316
+ }
317
+
318
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
319
+ await pm._write(cwd, progress, changeName)
320
+ triggerSync(cwd, changeName, platformOpts)
321
+
322
+ // Append to user-inputs.md
323
+ if (outputText) {
324
+ const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
325
+ const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
326
+ appendFileSync(inputsPath, entry)
327
+ }
328
+
329
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
330
+ console.log(`✅ Step ${currentIdx + 1}/${steps.length} 完成:${steps[currentIdx].name}\n`)
331
+
332
+ // Workflow post_check(W6 Step6 抽至 complete-handlers.js handleWorkflowPostCheck)
333
+ const _wfResult = await handleWorkflowPostCheck({ stageName, steps, currentIdx, cwd, specBase, progress, platformOpts, changeName })
334
+ if (_wfResult) return _wfResult
335
+
336
+ if (printNext) {
337
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
338
+ }
339
+ return { stageCompleted: false, currentIdx, nextPendingIdx }
340
+ }
341
+
342
+ // ── Step 调度兄弟函数(W6 Step7b-2 从 run.js 搬入:skip/wait/continue + formatWaitHistory)──
343
+ function formatWaitHistory(step) {
344
+ const answers = Array.isArray(step.waitAnswers) ? step.waitAnswers : []
345
+ if (answers.length === 0) return null
346
+ let text = `本步骤历史用户回答(共 ${answers.length} 轮):\n`
347
+ for (const item of answers) {
348
+ text += `\n${item.round}. ${item.answer}`
349
+ if (item.question) {
350
+ text += `\n 对应问题/摘要:${item.question}`
351
+ }
352
+ }
353
+ const maxRounds = step.maxWaitRounds || null
354
+ if (maxRounds && answers.length >= maxRounds) {
355
+ text += `\n\n已达到 maxWaitRounds=${maxRounds}。请基于以上回答总结需求;除非仍有阻塞问题,否则完成本步骤并进入方案讨论。`
356
+ } else {
357
+ text += `\n\n请判断信息是否足够:如果足够,完成本步骤;如果仍缺关键约束,再提出一个问题并 --wait。`
358
+ }
359
+ return text
360
+ }
361
+
362
+ export async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, options = {}) {
363
+ const { changeName, nonInteractive = false, platformOpts = {} } = options
364
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
365
+ const stageData = progress.stages[stageName]
366
+
367
+ if (!stageData || !stageData.steps) {
368
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
369
+ process.exit(1)
370
+ }
371
+
372
+ // 查找下一个 pending 或 in-progress 的步骤
373
+ const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
374
+ if (currentIdx === -1) {
375
+ console.error('没有可以等待的步骤')
376
+ process.exit(1)
377
+ }
378
+
379
+ // 前置检查:不允许已有 waiting 步骤时再 --wait
380
+ const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
381
+ if (existingWaitingIdx !== -1) {
382
+ const ws = stageData.steps[existingWaitingIdx]
383
+ console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
384
+ console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
385
+ process.exit(1)
386
+ }
387
+
388
+ // maxWaitRounds 硬上限:达到后拒绝继续 --wait
389
+ const currentStep = stageData.steps[currentIdx]
390
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
391
+ const stepDef = defSteps?.[currentIdx] || {}
392
+ const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
393
+ const currentWaitRound = currentStep.waitRound || 0
394
+ if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
395
+ console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
396
+ console.error(` 请基于已有回答完成本步骤:`)
397
+ console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
398
+ process.exit(1)
399
+ }
400
+
401
+ // 非交互模式下拒绝等待
402
+ if (nonInteractive) {
403
+ console.error(`❌ Human decision required in non-interactive mode.`)
404
+ console.error(` Reason: ${waitReason || '(unknown)'}`)
405
+ if (waitOptions) console.error(` Options: ${formatWaitOptions(waitOptions)}`)
406
+ console.error(` Fix: rerun with --interactive or provide decision via sillyspec run ${stageName} --continue --answer "..."`)
407
+ process.exit(2)
408
+ }
409
+
410
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
411
+ stageData.steps[currentIdx].status = 'waiting'
412
+ stageData.steps[currentIdx].waitedAt = now
413
+ if (outputText) {
414
+ const MAX_OUTPUT = 200
415
+ stageData.steps[currentIdx].output = outputText.length > MAX_OUTPUT
416
+ ? outputText.slice(0, MAX_OUTPUT) + '…' : outputText
417
+ }
418
+ if (waitReason) stageData.steps[currentIdx].waitReason = waitReason
419
+ if (waitOptions) {
420
+ // 统一存为 JSON 数组
421
+ try {
422
+ const parsed = JSON.parse(waitOptions)
423
+ if (Array.isArray(parsed)) {
424
+ stageData.steps[currentIdx].waitOptions = JSON.stringify(parsed)
425
+ } else {
426
+ stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
427
+ }
428
+ } catch {
429
+ stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
430
+ }
431
+ }
432
+
433
+ progress.lastActive = now
434
+ await pm._write(cwd, progress, changeName)
435
+ triggerSync(cwd, changeName, platformOpts)
436
+
437
+ console.log(`⏸️ Step ${currentIdx + 1}/${stageData.steps.length} 已暂停等待:${stageData.steps[currentIdx].name}`)
438
+ if (waitReason) console.log(` 原因:${waitReason}`)
439
+ if (waitOptions) console.log(` 选项:${formatWaitOptions(waitOptions)}`)
440
+ console.log(` 继续时执行:sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
441
+ }
442
+
443
+ export async function continueStep(pm, progress, stageName, cwd, answer, options = {}) {
444
+ const { changeName, platformOpts = {} } = options
445
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
446
+ const stageData = progress.stages[stageName]
447
+
448
+ if (!stageData || !stageData.steps) {
449
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
450
+ process.exit(1)
451
+ }
452
+
453
+ if (!answer) {
454
+ console.error('❌ --continue 需要 --answer 参数')
455
+ process.exit(2) // 用法错 → exit 2
456
+ }
457
+
458
+ // 查找 waiting 的步骤
459
+ const waitingSteps = stageData.steps.map((s, i) => ({ ...s, idx: i })).filter(s => s.status === 'waiting')
460
+ if (waitingSteps.length === 0) {
461
+ console.error('没有处于等待状态的步骤')
462
+ process.exit(1)
463
+ }
464
+ if (waitingSteps.length > 1) {
465
+ console.error(`❌ 检测到 ${waitingSteps.length} 个等待中的步骤,无法确定恢复目标:`)
466
+ for (const ws of waitingSteps) {
467
+ console.error(` Step ${ws.idx + 1}: ${ws.name}${ws.waitReason ? `(${ws.waitReason})` : ''}`)
468
+ }
469
+ console.error(` 请使用 --reset 重置,或手动修复 DB`)
470
+ process.exit(1)
471
+ }
472
+ const currentIdx = waitingSteps[0].idx
473
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
474
+ const currentStepDef = defSteps?.[currentIdx] || {}
475
+ const currentStep = stageData.steps[currentIdx]
476
+ const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
477
+ const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
478
+ const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
479
+
480
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
481
+ const prevOutput = currentStep.output || ''
482
+ const waitRound = (currentStep.waitRound || 0) + 1
483
+ currentStep.waitRound = waitRound
484
+ currentStep.waitAnswer = answer
485
+ currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
486
+ currentStep.waitAnswers.push({
487
+ round: waitRound,
488
+ answer,
489
+ question: prevOutput || null,
490
+ answeredAt: now,
491
+ })
492
+ currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
493
+
494
+ // 合并 waiting 信息到 output
495
+ const waitInfo = currentStep.waitReason || ''
496
+ if (waitInfo) {
497
+ currentStep.output = prevOutput
498
+ ? `${prevOutput} | 用户回答#${waitRound}:${answer}`
499
+ : `用户回答#${waitRound}:${answer}`
500
+ }
501
+
502
+ // 清除等待状态
503
+ delete currentStep.waitReason
504
+ delete currentStep.waitOptions
505
+ delete currentStep.waitedAt
506
+
507
+ if (shouldReturnToCurrentStep) {
508
+ currentStep.status = 'pending'
509
+ currentStep.completedAt = null
510
+ } else {
511
+ currentStep.status = 'completed'
512
+ currentStep.completedAt = now
513
+ }
514
+
515
+ progress.lastActive = now
516
+ await pm._write(cwd, progress, changeName)
517
+ triggerSync(cwd, changeName, platformOpts)
518
+
519
+ console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
520
+ console.log(` 回答:${answer}`)
521
+
522
+ // Append to user-inputs.md
523
+ const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
524
+ const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
525
+ appendFileSync(inputsPath, entry)
526
+
527
+ // shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
528
+ if (shouldReturnToCurrentStep) {
529
+ console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
530
+ if (isRepeatableWait) {
531
+ console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
532
+ }
533
+ if (defSteps && defSteps[currentIdx]) {
534
+ console.log('')
535
+ await outputStep(
536
+ stageName,
537
+ currentIdx,
538
+ defSteps,
539
+ cwd,
540
+ changeName,
541
+ progress.project || null,
542
+ platformOpts,
543
+ formatWaitHistory(currentStep)
544
+ )
545
+ }
546
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
547
+ }
548
+
549
+ // 检查阶段是否全部完成
550
+ const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
551
+ const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
552
+ if (nextPendingIdx === -1 && nextWaitingIdx === -1) {
553
+ stageData.status = 'completed'
554
+ stageData.completedAt = now
555
+ await pm._write(cwd, progress, changeName)
556
+ console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
557
+ // ── execute 阶段完成时条件性清理 worktree ──
558
+ if (stageName === 'execute' && changeName) {
559
+ try {
560
+ const { WorktreeManager } = await import('../worktree.js');
561
+ const wm = new WorktreeManager({ cwd });
562
+ const meta = wm.getMeta(changeName);
563
+ if (!meta) {
564
+ console.log('🔗 Worktree: n/a (no meta)');
565
+ } else if (meta.mode === 'native-worktree') {
566
+ console.log('🔗 Worktree: kept (外部隔离环境)');
567
+ } else {
568
+ // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
569
+ const check = wm.hasUnappliedChanges(changeName);
570
+ if (check.hasChanges) {
571
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
572
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
573
+ } else {
574
+ const cleanResult = wm.cleanup(changeName);
575
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
576
+ if (cleanResult.residual?.length > 0) {
577
+ console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
578
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
579
+ }
580
+ }
581
+ }
582
+ } catch (e) {
583
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
584
+ }
585
+ }
586
+ // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
587
+ const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
588
+ if (nextStageHint) {
589
+ console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${nextStageHint}${changeName ? ` --change ${changeName}` : ''}`)
590
+ if (stageName === 'execute') {
591
+ console.log(` ⚠️ 若 worktree 改动还没 apply 到主工作区,先:sillyspec worktree apply ${changeName}`)
592
+ console.log(` (apply 不需要先 commit,支持 working tree 未提交改动)`)
593
+ // plan.md checkbox auto-check:execute 完成 + review.json pass → 自动勾选(治本,比警告可靠)
594
+ try {
595
+ const specBaseLc = platformOpts.specRoot || join(cwd, '.sillyspec')
596
+ const changeDir = join(specBaseLc, 'changes', changeName)
597
+ const planPath = join(changeDir, 'plan.md')
598
+ const runtimeRoot = platformOpts.runtimeRoot || join(specBaseLc, '.runtime')
599
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
600
+ if (existsSync(planPath) && existsSync(runIdFile)) {
601
+ const executeRunId = readFileSync(runIdFile, 'utf8').trim()
602
+ const planContent = readFileSync(planPath, 'utf8')
603
+ const { readReview } = await import('../task-review.js')
604
+ let checkedCount = 0
605
+ let skippedCount = 0
606
+ const updated = planContent.replace(/^(\s*[-*]\s*\[)\s(\]\s*task-\d+)/gim, (match, p1, p2) => {
607
+ const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
608
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
609
+ const r = readReview(reviewPath)
610
+ if (r.ok && r.review?.specVerdict !== 'fail' && r.review?.qualityVerdict !== 'fail') {
611
+ checkedCount++
612
+ return `${p1}x${p2}` // 勾选
613
+ }
614
+ skippedCount++
615
+ return match // 不勾
616
+ })
617
+ if (checkedCount > 0) {
618
+ writeFileSync(planPath, updated)
619
+ console.log(` ✅ 自动勾选 ${checkedCount} 个 task checkbox(基于 review.json pass)`)
620
+ }
621
+ if (skippedCount > 0) {
622
+ console.warn(` ⚠️ ${skippedCount} 个 task 未勾(review.json 缺失/fail)→ archive 会拦。补 review 后重跑 execute --done 触发自动勾`)
623
+ }
624
+ }
625
+ } catch {}
626
+ }
627
+ }
628
+ return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
629
+ }
630
+
631
+ // 输出下一步
632
+ if (nextPendingIdx !== -1 && defSteps) {
633
+ console.log('')
634
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
635
+ } else if (nextWaitingIdx !== -1 && defSteps) {
636
+ // 下一个步骤也在等待状态
637
+ const ws = stageData.steps[nextWaitingIdx]
638
+ console.log(`\n⏸️ Step ${nextWaitingIdx + 1}/${stageData.steps.length} 仍在等待:${ws.name}`)
639
+ if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
640
+ console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
641
+ }
642
+
643
+ return { stageCompleted: false, currentIdx, nextPendingIdx: nextPendingIdx }
644
+ }
645
+
646
+ export async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
647
+ const stageData = progress.stages[stageName]
648
+ if (!stageData || !stageData.steps) {
649
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
650
+ process.exit(1)
651
+ }
652
+
653
+ const steps = stageData.steps
654
+ const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
655
+
656
+ if (currentIdx === -1) {
657
+ const wsIdx = steps.findIndex(s => s.status === 'waiting')
658
+ if (wsIdx !== -1) {
659
+ console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
660
+ console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
661
+ } else {
662
+ console.error('没有待跳过的步骤')
663
+ }
664
+ process.exit(1)
665
+ }
666
+
667
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
668
+ const stepDef = defSteps ? defSteps[currentIdx] : null
669
+ if (stepDef && !stepDef.optional) {
670
+ console.error(`❌ 步骤 "${steps[currentIdx].name}" 不可跳过`)
671
+ process.exit(1)
672
+ }
673
+
674
+ steps[currentIdx].status = 'skipped'
675
+ steps[currentIdx].skippedAt = new Date().toLocaleString('zh-CN',{hour12:false})
676
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
677
+ await pm._write(cwd, progress, changeName)
678
+ triggerSync(cwd, changeName, platformOpts)
679
+
680
+ console.log(`⏭️ Step ${currentIdx + 1}/${steps.length} 已跳过:${steps[currentIdx].name}`)
681
+
682
+ const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
683
+ if (nextPendingIdx !== -1 && defSteps) {
684
+ console.log('')
685
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
686
+ } else {
687
+ const wsIdx = steps.findIndex(s => s.status === 'waiting')
688
+ if (wsIdx !== -1) {
689
+ console.log(`\n⏸️ Step ${wsIdx + 1}/${steps.length} 正在等待:${steps[wsIdx].name}`)
690
+ if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
691
+ console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
692
+ }
693
+ }
694
+ }
695
+