sillyspec 3.18.0 → 3.18.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.
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}`)
@@ -1171,7 +1257,7 @@ export async function runCommand(args, cwd, specDir = null) {
1171
1257
  }
1172
1258
 
1173
1259
  // 默认:输出当前步骤
1174
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline })
1260
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
1175
1261
  }
1176
1262
 
1177
1263
  /**
@@ -1187,6 +1273,7 @@ function resolveChangeNameAuto(cwd, specDir = null) {
1187
1273
  }
1188
1274
 
1189
1275
  async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
1276
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1190
1277
  // 状态转换校验
1191
1278
  const prevStage = progress.currentStage || ''
1192
1279
  const transition = checkTransition(prevStage, stageName)
@@ -1268,6 +1355,28 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1268
1355
  }
1269
1356
  if (scanProfile) platformOpts.scanProfile = scanProfile
1270
1357
 
1358
+ if (stageName === 'scan') {
1359
+ try {
1360
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
1361
+ const scanGuard = {
1362
+ sourceCommit: gitResult.value,
1363
+ sourceCommitError: gitResult.error,
1364
+ startedAt: new Date().toISOString(),
1365
+ forceRescan: quickOpts?.isForceRescan || false,
1366
+ }
1367
+ const guardFile = join(specBase, '.runtime', 'scan-guard.json')
1368
+ mkdirSync(dirname(guardFile), { recursive: true })
1369
+ writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
1370
+ if (scanGuard.forceRescan) {
1371
+ console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
1372
+ } else {
1373
+ console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
1374
+ }
1375
+ } catch (e) {
1376
+ console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
1377
+ }
1378
+ }
1379
+
1271
1380
  if (currentIdx === -1) {
1272
1381
  // 已完成 → 自动重置,重新开始
1273
1382
  const freshSteps = await getStageSteps(stageName, cwd, progress)
@@ -1400,9 +1509,8 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1400
1509
 
1401
1510
  // 每个阶段完成后预期存在的文件
1402
1511
  const expectedFiles = {
1403
- propose: ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'],
1512
+ brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
1404
1513
  plan: ['plan.md'],
1405
- verify: ['verify-result.md'],
1406
1514
  archive: ['module-impact.md'],
1407
1515
  }
1408
1516
 
@@ -1434,7 +1542,7 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1434
1542
  }
1435
1543
  }
1436
1544
 
1437
- async function archiveChangeDirectory(pm, cwd, progress) {
1545
+ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
1438
1546
  const { renameSync } = await import('fs')
1439
1547
  const archiveChangeName = progress.currentChange
1440
1548
  if (!archiveChangeName) {
@@ -1486,6 +1594,28 @@ async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, wa
1486
1594
  process.exit(1)
1487
1595
  }
1488
1596
 
1597
+ // 前置检查:不允许已有 waiting 步骤时再 --wait
1598
+ const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
1599
+ if (existingWaitingIdx !== -1) {
1600
+ const ws = stageData.steps[existingWaitingIdx]
1601
+ console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
1602
+ console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
1603
+ process.exit(1)
1604
+ }
1605
+
1606
+ // maxWaitRounds 硬上限:达到后拒绝继续 --wait
1607
+ const currentStep = stageData.steps[currentIdx]
1608
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1609
+ const stepDef = defSteps?.[currentIdx] || {}
1610
+ const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
1611
+ const currentWaitRound = currentStep.waitRound || 0
1612
+ if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
1613
+ console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
1614
+ console.error(` 请基于已有回答完成本步骤:`)
1615
+ console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
1616
+ process.exit(1)
1617
+ }
1618
+
1489
1619
  // 非交互模式下拒绝等待
1490
1620
  if (nonInteractive) {
1491
1621
  console.error(`❌ Human decision required in non-interactive mode.`)
@@ -1560,38 +1690,82 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1560
1690
  process.exit(1)
1561
1691
  }
1562
1692
  const currentIdx = waitingSteps[0].idx
1693
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1694
+ const currentStepDef = defSteps?.[currentIdx] || {}
1695
+ const currentStep = stageData.steps[currentIdx]
1696
+ const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
1697
+ const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
1698
+ const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
1563
1699
 
1564
1700
  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
1701
+ const prevOutput = currentStep.output || ''
1702
+ const waitRound = (currentStep.waitRound || 0) + 1
1703
+ currentStep.waitRound = waitRound
1704
+ currentStep.waitAnswer = answer
1705
+ currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
1706
+ currentStep.waitAnswers.push({
1707
+ round: waitRound,
1708
+ answer,
1709
+ question: prevOutput || null,
1710
+ answeredAt: now,
1711
+ })
1712
+ currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
1568
1713
 
1569
1714
  // 合并 waiting 信息到 output
1570
- const prevOutput = stageData.steps[currentIdx].output || ''
1571
- const waitInfo = stageData.steps[currentIdx].waitReason || ''
1715
+ const waitInfo = currentStep.waitReason || ''
1572
1716
  if (waitInfo) {
1573
- stageData.steps[currentIdx].output = prevOutput
1574
- ? `${prevOutput} | 用户选择:${answer}`
1575
- : `用户选择:${answer}`
1717
+ currentStep.output = prevOutput
1718
+ ? `${prevOutput} | 用户回答#${waitRound}:${answer}`
1719
+ : `用户回答#${waitRound}:${answer}`
1576
1720
  }
1577
1721
 
1578
1722
  // 清除等待状态
1579
- delete stageData.steps[currentIdx].waitReason
1580
- delete stageData.steps[currentIdx].waitOptions
1581
- delete stageData.steps[currentIdx].waitedAt
1723
+ delete currentStep.waitReason
1724
+ delete currentStep.waitOptions
1725
+ delete currentStep.waitedAt
1726
+
1727
+ if (shouldReturnToCurrentStep) {
1728
+ currentStep.status = 'pending'
1729
+ currentStep.completedAt = null
1730
+ } else {
1731
+ currentStep.status = 'completed'
1732
+ currentStep.completedAt = now
1733
+ }
1582
1734
 
1583
1735
  progress.lastActive = now
1584
1736
  await pm._write(cwd, progress, changeName)
1585
1737
  triggerSync(cwd, changeName, platformOpts)
1586
1738
 
1587
- console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${stageData.steps[currentIdx].name}`)
1739
+ console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
1588
1740
  console.log(` 回答:${answer}`)
1589
1741
 
1590
1742
  // Append to user-inputs.md
1591
1743
  const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
1592
- const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${stageData.steps[currentIdx].name} [CONTINUED]\n- 回答:${answer}\n`
1744
+ const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
1593
1745
  appendFileSync(inputsPath, entry)
1594
1746
 
1747
+ // shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
1748
+ if (shouldReturnToCurrentStep) {
1749
+ console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
1750
+ if (isRepeatableWait) {
1751
+ console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
1752
+ }
1753
+ if (defSteps && defSteps[currentIdx]) {
1754
+ console.log('')
1755
+ await outputStep(
1756
+ stageName,
1757
+ currentIdx,
1758
+ defSteps,
1759
+ cwd,
1760
+ changeName,
1761
+ progress.project || null,
1762
+ platformOpts,
1763
+ formatWaitHistory(currentStep)
1764
+ )
1765
+ }
1766
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
1767
+ }
1768
+
1595
1769
  // 检查阶段是否全部完成
1596
1770
  const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
1597
1771
  const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
@@ -1604,7 +1778,6 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1604
1778
  }
1605
1779
 
1606
1780
  // 输出下一步
1607
- const defSteps = await getStageSteps(stageName, cwd, progress)
1608
1781
  if (nextPendingIdx !== -1 && defSteps) {
1609
1782
  console.log('')
1610
1783
  await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
@@ -1649,6 +1822,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1649
1822
 
1650
1823
  const steps = stageData.steps
1651
1824
  const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1825
+ if (currentIdx === -1) {
1826
+ console.error('没有待完成的步骤')
1827
+ process.exit(1)
1828
+ }
1829
+
1830
+ // ── requiresWait 硬门控 ──
1831
+ const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1832
+ const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
1833
+ const currentStep = steps[currentIdx]
1834
+ if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
1835
+ console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
1836
+ console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
1837
+ if (currentStepDef.waitOptions) {
1838
+ console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
1839
+ }
1840
+ console.error(` 请先执行:`)
1841
+ console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
1842
+ process.exit(1)
1843
+ }
1652
1844
 
1653
1845
  steps[currentIdx].status = 'completed'
1654
1846
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
@@ -1679,7 +1871,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1679
1871
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
1680
1872
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
1681
1873
  }
1682
- await archiveChangeDirectory(pm, cwd, progress)
1874
+ await archiveChangeDirectory(pm, cwd, progress, specBase)
1683
1875
  }
1684
1876
 
1685
1877
  // archive "确认归档" 步骤完成后,校验归档完整性
@@ -1881,6 +2073,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1881
2073
  console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
1882
2074
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
1883
2075
  }
2076
+ if (progress.quickGuard) {
2077
+ const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2078
+ progress.quickGuard.review = review
2079
+ progress.quickGuard.completedAt = new Date().toISOString()
2080
+ printQuickAuditReview(review)
2081
+ if (review.status === 'blocked') {
2082
+ steps[currentIdx].status = 'pending'
2083
+ steps[currentIdx].completedAt = null
2084
+ if (outputText) steps[currentIdx].output = null
2085
+ process.exit(1)
2086
+ }
2087
+ try {
2088
+ const { unlinkSync } = await import('fs')
2089
+ const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2090
+ unlinkSync(guardFile)
2091
+ } catch {}
2092
+ progress.lastQuickReview = review
2093
+ delete progress.quickGuard
2094
+ }
1884
2095
  }
1885
2096
 
1886
2097
  stageData.status = 'completed'
@@ -1906,9 +2117,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1906
2117
  const manifestDir = platformOpts.specRoot
1907
2118
  mkdirSync(manifestDir, { recursive: true })
1908
2119
  let sourceCommit = null
2120
+ let sourceCommitError = null
1909
2121
  try {
1910
- const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
1911
- } catch {}
2122
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
2123
+ sourceCommit = gitResult.value
2124
+ sourceCommitError = gitResult.error
2125
+ } catch (e) {
2126
+ sourceCommitError = e.message
2127
+ }
1912
2128
  const manifest = {
1913
2129
  workspace_id: platformOpts.workspaceId || null,
1914
2130
  scan_run_id: platformOpts.scanRunId || null,
@@ -1916,7 +2132,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1916
2132
  spec_root: platformOpts.specRoot || null,
1917
2133
  runtime_root: platformOpts.runtimeRoot || null,
1918
2134
  source_commit: sourceCommit,
1919
- source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
2135
+ source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
1920
2136
  generated_at: new Date().toISOString(),
1921
2137
  schema_version: 1,
1922
2138
  postcheck_result_path: null,
@@ -2085,33 +2301,6 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2085
2301
  }
2086
2302
  }
2087
2303
 
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}`)
2103
- }
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}`)
2109
- }
2110
- } else {
2111
- console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
2112
- }
2113
- }
2114
-
2115
2304
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2116
2305
  await pm._write(cwd, progress, changeName)
2117
2306
  triggerSync(cwd, changeName, platformOpts)