sillyspec 3.18.0 → 3.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * CLI 成为流程引擎,AI 变成步骤执行器。
5
5
  * 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
6
6
  */
7
- import { basename, join, resolve } from 'path'
7
+ import { basename, join, resolve, dirname } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
9
9
  import { createRequire } from 'module'
10
10
  const require = createRequire(import.meta.url)
@@ -48,16 +48,49 @@ function formatWaitOptions(raw) {
48
48
  }
49
49
  }
50
50
 
51
+ /**
52
+ * 格式化 repeatableWait 步骤的历史用户回答,注入到重新输出的 step prompt 前。
53
+ * @param {object} step - progress 中的 step 对象(含 waitAnswers 数组)
54
+ * @returns {string|null} 格式化的历史文本,或 null(无历史)
55
+ */
56
+ function formatWaitHistory(step) {
57
+ const answers = Array.isArray(step.waitAnswers) ? step.waitAnswers : []
58
+ if (answers.length === 0) return null
59
+ let text = `本步骤历史用户回答(共 ${answers.length} 轮):\n`
60
+ for (const item of answers) {
61
+ text += `\n${item.round}. ${item.answer}`
62
+ if (item.question) {
63
+ text += `\n 对应问题/摘要:${item.question}`
64
+ }
65
+ }
66
+ const maxRounds = step.maxWaitRounds || null
67
+ if (maxRounds && answers.length >= maxRounds) {
68
+ text += `\n\n已达到 maxWaitRounds=${maxRounds}。请基于以上回答总结需求;除非仍有阻塞问题,否则完成本步骤并进入方案讨论。`
69
+ } else {
70
+ text += `\n\n请判断信息是否足够:如果足够,完成本步骤;如果仍缺关键约束,再提出一个问题并 --wait。`
71
+ }
72
+ return text
73
+ }
74
+
51
75
  /**
52
76
  * 解析规范目录路径
53
- * @param {string} cwd - 项目根目录
77
+ * 向上查找含 .sillyspec 的祖先目录,类似 git 找 .git 的逻辑。
78
+ * @param {string} cwd - 项目根目录(或子目录)
54
79
  * @param {object} [opts]
55
80
  * @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
56
81
  * @returns {string} 规范目录的绝对路径
57
82
  */
58
83
  function resolveSpecDir(cwd, opts = {}) {
59
84
  if (opts.specDir) return resolve(opts.specDir)
60
- return join(cwd, '.sillyspec')
85
+ let dir = resolve(cwd)
86
+ while (true) {
87
+ const candidate = join(dir, '.sillyspec')
88
+ if (existsSync(candidate)) return candidate
89
+ const parent = dirname(dir)
90
+ if (parent === dir) break
91
+ dir = parent
92
+ }
93
+ return join(resolve(cwd), '.sillyspec')
61
94
  }
62
95
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
63
96
  import { checkTransition, runValidators } from './stage-contract.js'
@@ -224,19 +257,34 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
224
257
  const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
225
258
  const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
226
259
 
260
+ const normalizeGitPath = (p) => p.replace(/\\/g, '/')
261
+ const isQuickMetadata = (p) => {
262
+ const file = normalizeGitPath(p)
263
+ return file.startsWith('.sillyspec/quicklog/')
264
+ || file.startsWith('.sillyspec/.runtime/')
265
+ || file === '.sillyspec/knowledge/uncategorized.md'
266
+ || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
267
+ || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))
268
+ }
227
269
  const DANGEROUS_PATTERNS = [
228
- '.sillyspec/',
229
270
  'package.json',
230
271
  'package-lock.json',
231
272
  'yarn.lock',
232
273
  'pnpm-lock.yaml',
233
274
  '.eslintrc',
234
275
  'tsconfig.json',
276
+ 'src/db.js',
277
+ 'src/progress.js',
278
+ 'src/run.js',
279
+ 'src/stage-contract.js',
280
+ 'src/worktree.js',
281
+ 'src/worktree-apply.js',
282
+ 'src/hooks/',
235
283
  ]
236
284
 
237
285
  for (const entry of currentEntries) {
238
286
  const status = entry.slice(0, 2).trim()
239
- const file = entry.slice(3).trim()
287
+ const file = normalizeGitPath(entry.slice(3).trim())
240
288
  if (!file || file.startsWith('??. ')) continue
241
289
 
242
290
  result.changedFiles.push(file)
@@ -249,7 +297,11 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
249
297
  }
250
298
 
251
299
  // 检查危险文件(除非 force-baseline)
252
- if (DANGEROUS_PATTERNS.some(p => file.includes(p)) && !forceBaseline) {
300
+ if (file.startsWith('.sillyspec/') && !isQuickMetadata(file) && !forceBaseline) {
301
+ result.reasons.push(`危险文件变更: ${file}`)
302
+ }
303
+
304
+ if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
253
305
  result.reasons.push(`危险文件变更: ${file}`)
254
306
  }
255
307
  }
@@ -269,7 +321,7 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
269
321
  // 检查 new files(除非 allow-new)
270
322
  if (!allowNew) {
271
323
  for (const f of result.newFiles) {
272
- if (!f.startsWith('.sillyspec/quicklog/') && !f.startsWith('.sillyspec/.runtime/')) {
324
+ if (!isQuickMetadata(f)) {
273
325
  result.reasons.push(`新增文件(需 --allow-new): ${f}`)
274
326
  }
275
327
  }
@@ -278,7 +330,7 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
278
330
  // 检查 allowedFiles 范围
279
331
  if (allowedFiles.length > 0) {
280
332
  for (const f of result.changedFiles) {
281
- if (!allowedFiles.includes(f) && !f.startsWith('.sillyspec/')) {
333
+ if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
282
334
  result.reasons.push(`超出 allowedFiles: ${f}`)
283
335
  }
284
336
  }
@@ -337,6 +389,23 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
337
389
  return result
338
390
  }
339
391
 
392
+ function printQuickAuditReview(review) {
393
+ if (review.status === 'blocked') {
394
+ console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
395
+ for (const r of review.reasons) {
396
+ console.error(` - ${r}`)
397
+ }
398
+ console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
399
+ } else if (review.status === 'warning') {
400
+ console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
401
+ for (const r of review.reasons) {
402
+ console.warn(` - ${r}`)
403
+ }
404
+ } else {
405
+ console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
406
+ }
407
+ }
408
+
340
409
  async function triggerSync(cwd, changeName, platformOpts = {}) {
341
410
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
342
411
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
@@ -697,11 +766,22 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
697
766
  const changeFlag = changeName ? ` --change ${changeName}` : ''
698
767
  // 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
699
768
  const stepPrompt = promptText || ''
700
- const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt)
769
+ const requiresWait = step.requiresWait === true
770
+ const conditionalWait = step.conditionalWait === true
771
+ const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
772
+
701
773
  console.log(`\n### 完成后执行`)
702
- if (mayNeedWait) {
774
+ if (requiresWait) {
775
+ console.log(`本步骤必须等待用户输入,不能直接 --done:`)
776
+ console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
777
+ console.log(``)
778
+ console.log(`用户回答后执行:`)
779
+ console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
780
+ console.log(``)
781
+ console.log(`收到回答并完成本步骤总结后,再执行:`)
782
+ } else if (mayNeedWait) {
703
783
  console.log(`如果需要用户决策(选择方案/确认设计等):`)
704
- console.log(`sillyspec run ${stageName} --wait --reason "等待原因" --options "选项1,选项2"${changeFlag} --output "你的摘要"`)
784
+ console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
705
785
  console.log(``)
706
786
  console.log(`如果不需要用户决策,正常完成:`)
707
787
  }
@@ -873,9 +953,14 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
873
953
  const { execSync } = await import('child_process')
874
954
  const manifestDir = platformOpts.specRoot
875
955
  let sourceCommit = null
956
+ let sourceCommitError = null
876
957
  try {
877
- const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
878
- } catch {}
958
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
959
+ sourceCommit = gitResult.value
960
+ sourceCommitError = gitResult.error
961
+ } catch (e) {
962
+ sourceCommitError = e.message
963
+ }
879
964
  mkdirSync(manifestDir, { recursive: true })
880
965
  const manifest = {
881
966
  scan_profile: {
@@ -888,7 +973,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
888
973
  workspace_id: platformOpts.workspaceId || null,
889
974
  scan_run_id: platformOpts.scanRunId || null,
890
975
  source_commit: sourceCommit,
891
- source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
976
+ source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
892
977
  generated_at: new Date().toISOString(),
893
978
  schema_version: 2,
894
979
  }
@@ -1057,6 +1142,7 @@ export async function runCommand(args, cwd, specDir = null) {
1057
1142
 
1058
1143
  const isAllowNew = flags.includes('--allow-new')
1059
1144
  const isForceBaseline = flags.includes('--force-baseline')
1145
+ const isForceRescan = flags.includes('--force-rescan')
1060
1146
 
1061
1147
  // 未知参数 fail-fast
1062
1148
  const knownFlags = new Set([
@@ -1065,7 +1151,7 @@ export async function runCommand(args, cwd, specDir = null) {
1065
1151
  '--reason', '--options', '--answer', '--confirm-mode',
1066
1152
  '--output', '--input', '--change',
1067
1153
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
1068
- '--files', '--allow-new', '--force-baseline',
1154
+ '--files', '--allow-new', '--force-baseline', '--force-rescan',
1069
1155
  '--json', '--dir', '--help',
1070
1156
  ])
1071
1157
  for (let i = 0; i < flags.length; i++) {
@@ -1102,8 +1188,8 @@ export async function runCommand(args, cwd, specDir = null) {
1102
1188
  progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
1103
1189
  }
1104
1190
  } else {
1105
- // brainstorm / propose 作为流程入口,自动生成变更名并初始化
1106
- if (stageName === 'brainstorm' || stageName === 'propose') {
1191
+ // brainstorm 作为流程入口,自动生成变更名并初始化
1192
+ if (stageName === 'brainstorm') {
1107
1193
  const date = new Date().toISOString().slice(0, 10)
1108
1194
  const autoName = `${date}-new-change`
1109
1195
  console.log(`🔄 自动创建变更:${autoName}`)
@@ -1167,11 +1253,12 @@ export async function runCommand(args, cwd, specDir = null) {
1167
1253
 
1168
1254
  // --done
1169
1255
  if (isDone) {
1170
- return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode })
1256
+ const doneAnswer = getFlagValue('--answer')
1257
+ return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer })
1171
1258
  }
1172
1259
 
1173
1260
  // 默认:输出当前步骤
1174
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline })
1261
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
1175
1262
  }
1176
1263
 
1177
1264
  /**
@@ -1187,6 +1274,7 @@ function resolveChangeNameAuto(cwd, specDir = null) {
1187
1274
  }
1188
1275
 
1189
1276
  async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
1277
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1190
1278
  // 状态转换校验
1191
1279
  const prevStage = progress.currentStage || ''
1192
1280
  const transition = checkTransition(prevStage, stageName)
@@ -1214,6 +1302,26 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1214
1302
  }
1215
1303
  }
1216
1304
 
1305
+ // execute 阶段:CLI 自动创建 worktree(不等 AI agent)
1306
+ if (stageName === 'execute' && changeName) {
1307
+ const effectiveChange = changeName
1308
+ const { WorktreeManager } = await import('./worktree.js')
1309
+ const wm = new WorktreeManager({ cwd })
1310
+ const existingMeta = wm.getMeta(effectiveChange)
1311
+ if (existingMeta) {
1312
+ console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
1313
+ } else {
1314
+ try {
1315
+ const result = wm.create(effectiveChange)
1316
+ console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
1317
+ } catch (e) {
1318
+ console.error(`❌ worktree 创建失败: ${e.message}`)
1319
+ console.error(` 继续执行前请解决上述问题,或使用 --no-worktree 跳过。`)
1320
+ process.exit(1)
1321
+ }
1322
+ }
1323
+ }
1324
+
1217
1325
  // 自动探测 currentChange
1218
1326
  if (autoDetectChange(progress, cwd)) {
1219
1327
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
@@ -1268,6 +1376,28 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1268
1376
  }
1269
1377
  if (scanProfile) platformOpts.scanProfile = scanProfile
1270
1378
 
1379
+ if (stageName === 'scan') {
1380
+ try {
1381
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
1382
+ const scanGuard = {
1383
+ sourceCommit: gitResult.value,
1384
+ sourceCommitError: gitResult.error,
1385
+ startedAt: new Date().toISOString(),
1386
+ forceRescan: quickOpts?.isForceRescan || false,
1387
+ }
1388
+ const guardFile = join(specBase, '.runtime', 'scan-guard.json')
1389
+ mkdirSync(dirname(guardFile), { recursive: true })
1390
+ writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
1391
+ if (scanGuard.forceRescan) {
1392
+ console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
1393
+ } else {
1394
+ console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
1395
+ }
1396
+ } catch (e) {
1397
+ console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
1398
+ }
1399
+ }
1400
+
1271
1401
  if (currentIdx === -1) {
1272
1402
  // 已完成 → 自动重置,重新开始
1273
1403
  const freshSteps = await getStageSteps(stageName, cwd, progress)
@@ -1400,9 +1530,8 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1400
1530
 
1401
1531
  // 每个阶段完成后预期存在的文件
1402
1532
  const expectedFiles = {
1403
- propose: ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'],
1533
+ brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
1404
1534
  plan: ['plan.md'],
1405
- verify: ['verify-result.md'],
1406
1535
  archive: ['module-impact.md'],
1407
1536
  }
1408
1537
 
@@ -1434,7 +1563,7 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1434
1563
  }
1435
1564
  }
1436
1565
 
1437
- async function archiveChangeDirectory(pm, cwd, progress) {
1566
+ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
1438
1567
  const { renameSync } = await import('fs')
1439
1568
  const archiveChangeName = progress.currentChange
1440
1569
  if (!archiveChangeName) {
@@ -1486,6 +1615,28 @@ async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, wa
1486
1615
  process.exit(1)
1487
1616
  }
1488
1617
 
1618
+ // 前置检查:不允许已有 waiting 步骤时再 --wait
1619
+ const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
1620
+ if (existingWaitingIdx !== -1) {
1621
+ const ws = stageData.steps[existingWaitingIdx]
1622
+ console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
1623
+ console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
1624
+ process.exit(1)
1625
+ }
1626
+
1627
+ // maxWaitRounds 硬上限:达到后拒绝继续 --wait
1628
+ const currentStep = stageData.steps[currentIdx]
1629
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1630
+ const stepDef = defSteps?.[currentIdx] || {}
1631
+ const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
1632
+ const currentWaitRound = currentStep.waitRound || 0
1633
+ if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
1634
+ console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
1635
+ console.error(` 请基于已有回答完成本步骤:`)
1636
+ console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
1637
+ process.exit(1)
1638
+ }
1639
+
1489
1640
  // 非交互模式下拒绝等待
1490
1641
  if (nonInteractive) {
1491
1642
  console.error(`❌ Human decision required in non-interactive mode.`)
@@ -1560,38 +1711,82 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1560
1711
  process.exit(1)
1561
1712
  }
1562
1713
  const currentIdx = waitingSteps[0].idx
1714
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1715
+ const currentStepDef = defSteps?.[currentIdx] || {}
1716
+ const currentStep = stageData.steps[currentIdx]
1717
+ const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
1718
+ const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
1719
+ const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
1563
1720
 
1564
1721
  const now = new Date().toLocaleString('zh-CN', { hour12: false })
1565
- stageData.steps[currentIdx].status = 'completed'
1566
- stageData.steps[currentIdx].completedAt = now
1567
- stageData.steps[currentIdx].waitAnswer = answer
1722
+ const prevOutput = currentStep.output || ''
1723
+ const waitRound = (currentStep.waitRound || 0) + 1
1724
+ currentStep.waitRound = waitRound
1725
+ currentStep.waitAnswer = answer
1726
+ currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
1727
+ currentStep.waitAnswers.push({
1728
+ round: waitRound,
1729
+ answer,
1730
+ question: prevOutput || null,
1731
+ answeredAt: now,
1732
+ })
1733
+ currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
1568
1734
 
1569
1735
  // 合并 waiting 信息到 output
1570
- const prevOutput = stageData.steps[currentIdx].output || ''
1571
- const waitInfo = stageData.steps[currentIdx].waitReason || ''
1736
+ const waitInfo = currentStep.waitReason || ''
1572
1737
  if (waitInfo) {
1573
- stageData.steps[currentIdx].output = prevOutput
1574
- ? `${prevOutput} | 用户选择:${answer}`
1575
- : `用户选择:${answer}`
1738
+ currentStep.output = prevOutput
1739
+ ? `${prevOutput} | 用户回答#${waitRound}:${answer}`
1740
+ : `用户回答#${waitRound}:${answer}`
1576
1741
  }
1577
1742
 
1578
1743
  // 清除等待状态
1579
- delete stageData.steps[currentIdx].waitReason
1580
- delete stageData.steps[currentIdx].waitOptions
1581
- delete stageData.steps[currentIdx].waitedAt
1744
+ delete currentStep.waitReason
1745
+ delete currentStep.waitOptions
1746
+ delete currentStep.waitedAt
1747
+
1748
+ if (shouldReturnToCurrentStep) {
1749
+ currentStep.status = 'pending'
1750
+ currentStep.completedAt = null
1751
+ } else {
1752
+ currentStep.status = 'completed'
1753
+ currentStep.completedAt = now
1754
+ }
1582
1755
 
1583
1756
  progress.lastActive = now
1584
1757
  await pm._write(cwd, progress, changeName)
1585
1758
  triggerSync(cwd, changeName, platformOpts)
1586
1759
 
1587
- console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${stageData.steps[currentIdx].name}`)
1760
+ console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
1588
1761
  console.log(` 回答:${answer}`)
1589
1762
 
1590
1763
  // Append to user-inputs.md
1591
1764
  const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
1592
- const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${stageData.steps[currentIdx].name} [CONTINUED]\n- 回答:${answer}\n`
1765
+ const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
1593
1766
  appendFileSync(inputsPath, entry)
1594
1767
 
1768
+ // shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
1769
+ if (shouldReturnToCurrentStep) {
1770
+ console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
1771
+ if (isRepeatableWait) {
1772
+ console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
1773
+ }
1774
+ if (defSteps && defSteps[currentIdx]) {
1775
+ console.log('')
1776
+ await outputStep(
1777
+ stageName,
1778
+ currentIdx,
1779
+ defSteps,
1780
+ cwd,
1781
+ changeName,
1782
+ progress.project || null,
1783
+ platformOpts,
1784
+ formatWaitHistory(currentStep)
1785
+ )
1786
+ }
1787
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
1788
+ }
1789
+
1595
1790
  // 检查阶段是否全部完成
1596
1791
  const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
1597
1792
  const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
@@ -1604,7 +1799,6 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1604
1799
  }
1605
1800
 
1606
1801
  // 输出下一步
1607
- const defSteps = await getStageSteps(stageName, cwd, progress)
1608
1802
  if (nextPendingIdx !== -1 && defSteps) {
1609
1803
  console.log('')
1610
1804
  await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
@@ -1649,6 +1843,35 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1649
1843
 
1650
1844
  const steps = stageData.steps
1651
1845
  const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1846
+ if (currentIdx === -1) {
1847
+ console.error('没有待完成的步骤')
1848
+ process.exit(1)
1849
+ }
1850
+
1851
+ // ── requiresWait 硬门控 ──
1852
+ const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1853
+ const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
1854
+ const currentStep = steps[currentIdx]
1855
+ if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
1856
+ // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
1857
+ const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
1858
+ if (doneAnswer) {
1859
+ currentStep.status = 'waiting'
1860
+ currentStep.waitAnswer = doneAnswer
1861
+ currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
1862
+ console.log(`⚠️ Step "${currentStep.name}" 需要 wait,但 --done 带了 --answer,自动补全 wait 状态。`)
1863
+ } else {
1864
+ console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
1865
+ console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
1866
+ if (currentStepDef.waitOptions) {
1867
+ console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
1868
+ }
1869
+ console.error(` 请先执行:`)
1870
+ console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
1871
+ console.error(` 或使用 --done --answer "用户回答" 一步完成 wait + done`)
1872
+ process.exit(1)
1873
+ }
1874
+ }
1652
1875
 
1653
1876
  steps[currentIdx].status = 'completed'
1654
1877
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
@@ -1679,7 +1902,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1679
1902
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
1680
1903
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
1681
1904
  }
1682
- await archiveChangeDirectory(pm, cwd, progress)
1905
+ await archiveChangeDirectory(pm, cwd, progress, specBase)
1683
1906
  }
1684
1907
 
1685
1908
  // archive "确认归档" 步骤完成后,校验归档完整性
@@ -1881,6 +2104,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1881
2104
  console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
1882
2105
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
1883
2106
  }
2107
+ if (progress.quickGuard) {
2108
+ const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2109
+ progress.quickGuard.review = review
2110
+ progress.quickGuard.completedAt = new Date().toISOString()
2111
+ printQuickAuditReview(review)
2112
+ if (review.status === 'blocked') {
2113
+ steps[currentIdx].status = 'pending'
2114
+ steps[currentIdx].completedAt = null
2115
+ if (outputText) steps[currentIdx].output = null
2116
+ process.exit(1)
2117
+ }
2118
+ try {
2119
+ const { unlinkSync } = await import('fs')
2120
+ const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2121
+ unlinkSync(guardFile)
2122
+ } catch {}
2123
+ progress.lastQuickReview = review
2124
+ delete progress.quickGuard
2125
+ }
1884
2126
  }
1885
2127
 
1886
2128
  stageData.status = 'completed'
@@ -1906,9 +2148,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1906
2148
  const manifestDir = platformOpts.specRoot
1907
2149
  mkdirSync(manifestDir, { recursive: true })
1908
2150
  let sourceCommit = null
2151
+ let sourceCommitError = null
1909
2152
  try {
1910
- const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
1911
- } catch {}
2153
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
2154
+ sourceCommit = gitResult.value
2155
+ sourceCommitError = gitResult.error
2156
+ } catch (e) {
2157
+ sourceCommitError = e.message
2158
+ }
1912
2159
  const manifest = {
1913
2160
  workspace_id: platformOpts.workspaceId || null,
1914
2161
  scan_run_id: platformOpts.scanRunId || null,
@@ -1916,7 +2163,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1916
2163
  spec_root: platformOpts.specRoot || null,
1917
2164
  runtime_root: platformOpts.runtimeRoot || null,
1918
2165
  source_commit: sourceCommit,
1919
- source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
2166
+ source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
1920
2167
  generated_at: new Date().toISOString(),
1921
2168
  schema_version: 1,
1922
2169
  postcheck_result_path: null,
@@ -2018,10 +2265,16 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2018
2265
  }
2019
2266
  }
2020
2267
 
2268
+ // 防御性守卫变量:确认所有步骤确实标记为 completed
2269
+ const actualCompleted = steps.filter(s => s.status === 'completed').length
2270
+ const actualTotal = steps.length
2271
+
2021
2272
  validateMetadata(cwd, stageName, specBase)
2022
2273
 
2023
- // 验证关键文件是否在正确的变更目录下
2024
- validateFileLocations(cwd, stageName, progress, changeName, specBase)
2274
+ // 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
2275
+ if (actualCompleted === actualTotal && actualTotal > 0) {
2276
+ validateFileLocations(cwd, stageName, progress, changeName, specBase)
2277
+ }
2025
2278
 
2026
2279
  // 辅助阶段完成后重置步骤
2027
2280
  const stageDef = stageRegistry[stageName]
@@ -2065,51 +2318,30 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2065
2318
  } else {
2066
2319
  console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
2067
2320
  }
2068
- return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2069
- }
2070
2321
 
2071
- // 阶段完成校验
2072
- const projectName = progress.project || basename(cwd)
2073
- const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2074
- if (contractResult.errors.length > 0) {
2075
- console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
2076
- for (const err of contractResult.errors) {
2077
- console.error(` - ${err}`)
2078
- }
2079
- console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
2080
- }
2081
- if (contractResult.warnings.length > 0) {
2082
- console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
2083
- for (const w of contractResult.warnings) {
2084
- console.warn(` - ${w}`)
2085
- }
2086
- }
2087
-
2088
- // quick 阶段完成审计
2089
- if (stageName === 'quick' && progress.quickGuard) {
2090
- const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2091
- progress.quickGuard.review = review
2092
- progress.quickGuard.completedAt = new Date().toISOString()
2093
- // 清理 quick-guard.json
2094
- try {
2095
- const { unlinkSync } = await import('fs')
2096
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2097
- unlinkSync(guardFile)
2098
- } catch {}
2099
- if (review.status === 'blocked') {
2100
- console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
2101
- for (const r of review.reasons) {
2102
- console.error(` - ${r}`)
2322
+ // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
2323
+ if (actualCompleted === actualTotal && actualTotal > 0) {
2324
+ const projectName = progress.project || basename(cwd)
2325
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2326
+ if (contractResult.errors.length > 0) {
2327
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
2328
+ for (const err of contractResult.errors) {
2329
+ console.error(` - ${err}`)
2330
+ }
2331
+ console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
2103
2332
  }
2104
- console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
2105
- } else if (review.status === 'warning') {
2106
- console.warn(`\n⚠️ quick 变更边界审计 WARNING:`)
2107
- for (const r of review.reasons) {
2108
- console.warn(` - ${r}`)
2333
+ if (contractResult.warnings.length > 0) {
2334
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
2335
+ for (const w of contractResult.warnings) {
2336
+ console.warn(` - ${w}`)
2337
+ }
2109
2338
  }
2110
- } else {
2111
- console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
2339
+ } else if (actualCompleted < actualTotal) {
2340
+ // 实际步骤未全部完成,跳过 validator(状态可能不同步)
2341
+ console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
2112
2342
  }
2343
+
2344
+ return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2113
2345
  }
2114
2346
 
2115
2347
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})