sillyspec 3.17.15 → 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,11 +4,12 @@
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)
11
11
  import { ProgressManager } from './progress.js'
12
+ import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
12
13
 
13
14
  /**
14
15
  * 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
@@ -47,16 +48,49 @@ function formatWaitOptions(raw) {
47
48
  }
48
49
  }
49
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
+
50
75
  /**
51
76
  * 解析规范目录路径
52
- * @param {string} cwd - 项目根目录
77
+ * 向上查找含 .sillyspec 的祖先目录,类似 git 找 .git 的逻辑。
78
+ * @param {string} cwd - 项目根目录(或子目录)
53
79
  * @param {object} [opts]
54
80
  * @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
55
81
  * @returns {string} 规范目录的绝对路径
56
82
  */
57
83
  function resolveSpecDir(cwd, opts = {}) {
58
84
  if (opts.specDir) return resolve(opts.specDir)
59
- 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')
60
94
  }
61
95
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
62
96
  import { checkTransition, runValidators } from './stage-contract.js'
@@ -223,19 +257,34 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
223
257
  const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
224
258
  const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
225
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
+ }
226
269
  const DANGEROUS_PATTERNS = [
227
- '.sillyspec/',
228
270
  'package.json',
229
271
  'package-lock.json',
230
272
  'yarn.lock',
231
273
  'pnpm-lock.yaml',
232
274
  '.eslintrc',
233
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/',
234
283
  ]
235
284
 
236
285
  for (const entry of currentEntries) {
237
286
  const status = entry.slice(0, 2).trim()
238
- const file = entry.slice(3).trim()
287
+ const file = normalizeGitPath(entry.slice(3).trim())
239
288
  if (!file || file.startsWith('??. ')) continue
240
289
 
241
290
  result.changedFiles.push(file)
@@ -248,7 +297,11 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
248
297
  }
249
298
 
250
299
  // 检查危险文件(除非 force-baseline)
251
- 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) {
252
305
  result.reasons.push(`危险文件变更: ${file}`)
253
306
  }
254
307
  }
@@ -268,7 +321,7 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
268
321
  // 检查 new files(除非 allow-new)
269
322
  if (!allowNew) {
270
323
  for (const f of result.newFiles) {
271
- if (!f.startsWith('.sillyspec/quicklog/') && !f.startsWith('.sillyspec/.runtime/')) {
324
+ if (!isQuickMetadata(f)) {
272
325
  result.reasons.push(`新增文件(需 --allow-new): ${f}`)
273
326
  }
274
327
  }
@@ -277,7 +330,7 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
277
330
  // 检查 allowedFiles 范围
278
331
  if (allowedFiles.length > 0) {
279
332
  for (const f of result.changedFiles) {
280
- if (!allowedFiles.includes(f) && !f.startsWith('.sillyspec/')) {
333
+ if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
281
334
  result.reasons.push(`超出 allowedFiles: ${f}`)
282
335
  }
283
336
  }
@@ -336,6 +389,23 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
336
389
  return result
337
390
  }
338
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
+
339
409
  async function triggerSync(cwd, changeName, platformOpts = {}) {
340
410
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
341
411
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
@@ -613,21 +683,29 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
613
683
  }
614
684
  profileDirectives.push(`--output 只需要列出文件名,不要写长篇总结。`)
615
685
  promptText = profileDirectives.join('\n') + '\n\n' + promptText
686
+
687
+ // scanProfile 分支也要替换占位符(非 platform 模式也会走到这里)
688
+ const _pName = dbProjectName || basename(cwd)
689
+ const _specSS = platformOpts?.specRoot || join(cwd, '.sillyspec')
690
+ const _docsRoot = join(_specSS, 'docs', _pName)
691
+ promptText = promptText.replace(/\{DOCS_ROOT\}/g, _docsRoot)
692
+ promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, join(_specSS, 'projects'))
693
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, join(_specSS, 'workflows'))
694
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, join(_specSS, 'knowledge'))
695
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, _specSS)
616
696
  } else {
617
697
  // 非 platform 模式也要替换占位符
618
- if (stageName === 'scan') {
619
- const projectName = dbProjectName || basename(cwd)
620
- const specSillyspec = join(cwd, '.sillyspec')
621
- const docsRoot = join(specSillyspec, 'docs', projectName)
622
- const projectsRoot = join(specSillyspec, 'projects')
623
- const workflowsRoot = join(specSillyspec, 'workflows')
624
- const knowledgeRoot = join(specSillyspec, 'knowledge')
625
- promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
626
- promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
627
- promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
628
- promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
629
- promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
630
- }
698
+ const projectName = dbProjectName || basename(cwd)
699
+ const specSillyspec = join(cwd, '.sillyspec')
700
+ const docsRoot = join(specSillyspec, 'docs', projectName)
701
+ const projectsRoot = join(specSillyspec, 'projects')
702
+ const workflowsRoot = join(specSillyspec, 'workflows')
703
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
704
+ promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
705
+ promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
706
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
707
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
708
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
631
709
  }
632
710
 
633
711
  // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
@@ -688,11 +766,22 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
688
766
  const changeFlag = changeName ? ` --change ${changeName}` : ''
689
767
  // 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
690
768
  const stepPrompt = promptText || ''
691
- 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
+
692
773
  console.log(`\n### 完成后执行`)
693
- 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) {
694
783
  console.log(`如果需要用户决策(选择方案/确认设计等):`)
695
- 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 "你的摘要"`)
696
785
  console.log(``)
697
786
  console.log(`如果不需要用户决策,正常完成:`)
698
787
  }
@@ -864,9 +953,14 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
864
953
  const { execSync } = await import('child_process')
865
954
  const manifestDir = platformOpts.specRoot
866
955
  let sourceCommit = null
956
+ let sourceCommitError = null
867
957
  try {
868
- const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
869
- } 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
+ }
870
964
  mkdirSync(manifestDir, { recursive: true })
871
965
  const manifest = {
872
966
  scan_profile: {
@@ -879,7 +973,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
879
973
  workspace_id: platformOpts.workspaceId || null,
880
974
  scan_run_id: platformOpts.scanRunId || null,
881
975
  source_commit: sourceCommit,
882
- source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
976
+ source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
883
977
  generated_at: new Date().toISOString(),
884
978
  schema_version: 2,
885
979
  }
@@ -1048,6 +1142,7 @@ export async function runCommand(args, cwd, specDir = null) {
1048
1142
 
1049
1143
  const isAllowNew = flags.includes('--allow-new')
1050
1144
  const isForceBaseline = flags.includes('--force-baseline')
1145
+ const isForceRescan = flags.includes('--force-rescan')
1051
1146
 
1052
1147
  // 未知参数 fail-fast
1053
1148
  const knownFlags = new Set([
@@ -1056,7 +1151,7 @@ export async function runCommand(args, cwd, specDir = null) {
1056
1151
  '--reason', '--options', '--answer', '--confirm-mode',
1057
1152
  '--output', '--input', '--change',
1058
1153
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
1059
- '--files', '--allow-new', '--force-baseline',
1154
+ '--files', '--allow-new', '--force-baseline', '--force-rescan',
1060
1155
  '--json', '--dir', '--help',
1061
1156
  ])
1062
1157
  for (let i = 0; i < flags.length; i++) {
@@ -1093,8 +1188,8 @@ export async function runCommand(args, cwd, specDir = null) {
1093
1188
  progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
1094
1189
  }
1095
1190
  } else {
1096
- // brainstorm / propose 作为流程入口,自动生成变更名并初始化
1097
- if (stageName === 'brainstorm' || stageName === 'propose') {
1191
+ // brainstorm 作为流程入口,自动生成变更名并初始化
1192
+ if (stageName === 'brainstorm') {
1098
1193
  const date = new Date().toISOString().slice(0, 10)
1099
1194
  const autoName = `${date}-new-change`
1100
1195
  console.log(`🔄 自动创建变更:${autoName}`)
@@ -1162,7 +1257,7 @@ export async function runCommand(args, cwd, specDir = null) {
1162
1257
  }
1163
1258
 
1164
1259
  // 默认:输出当前步骤
1165
- 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 })
1166
1261
  }
1167
1262
 
1168
1263
  /**
@@ -1178,6 +1273,7 @@ function resolveChangeNameAuto(cwd, specDir = null) {
1178
1273
  }
1179
1274
 
1180
1275
  async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
1276
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1181
1277
  // 状态转换校验
1182
1278
  const prevStage = progress.currentStage || ''
1183
1279
  const transition = checkTransition(prevStage, stageName)
@@ -1259,6 +1355,28 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1259
1355
  }
1260
1356
  if (scanProfile) platformOpts.scanProfile = scanProfile
1261
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
+
1262
1380
  if (currentIdx === -1) {
1263
1381
  // 已完成 → 自动重置,重新开始
1264
1382
  const freshSteps = await getStageSteps(stageName, cwd, progress)
@@ -1391,9 +1509,8 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1391
1509
 
1392
1510
  // 每个阶段完成后预期存在的文件
1393
1511
  const expectedFiles = {
1394
- propose: ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'],
1512
+ brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
1395
1513
  plan: ['plan.md'],
1396
- verify: ['verify-result.md'],
1397
1514
  archive: ['module-impact.md'],
1398
1515
  }
1399
1516
 
@@ -1425,7 +1542,7 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1425
1542
  }
1426
1543
  }
1427
1544
 
1428
- async function archiveChangeDirectory(pm, cwd, progress) {
1545
+ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
1429
1546
  const { renameSync } = await import('fs')
1430
1547
  const archiveChangeName = progress.currentChange
1431
1548
  if (!archiveChangeName) {
@@ -1477,6 +1594,28 @@ async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, wa
1477
1594
  process.exit(1)
1478
1595
  }
1479
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
+
1480
1619
  // 非交互模式下拒绝等待
1481
1620
  if (nonInteractive) {
1482
1621
  console.error(`❌ Human decision required in non-interactive mode.`)
@@ -1551,38 +1690,82 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1551
1690
  process.exit(1)
1552
1691
  }
1553
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
1554
1699
 
1555
1700
  const now = new Date().toLocaleString('zh-CN', { hour12: false })
1556
- stageData.steps[currentIdx].status = 'completed'
1557
- stageData.steps[currentIdx].completedAt = now
1558
- 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
1559
1713
 
1560
1714
  // 合并 waiting 信息到 output
1561
- const prevOutput = stageData.steps[currentIdx].output || ''
1562
- const waitInfo = stageData.steps[currentIdx].waitReason || ''
1715
+ const waitInfo = currentStep.waitReason || ''
1563
1716
  if (waitInfo) {
1564
- stageData.steps[currentIdx].output = prevOutput
1565
- ? `${prevOutput} | 用户选择:${answer}`
1566
- : `用户选择:${answer}`
1717
+ currentStep.output = prevOutput
1718
+ ? `${prevOutput} | 用户回答#${waitRound}:${answer}`
1719
+ : `用户回答#${waitRound}:${answer}`
1567
1720
  }
1568
1721
 
1569
1722
  // 清除等待状态
1570
- delete stageData.steps[currentIdx].waitReason
1571
- delete stageData.steps[currentIdx].waitOptions
1572
- 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
+ }
1573
1734
 
1574
1735
  progress.lastActive = now
1575
1736
  await pm._write(cwd, progress, changeName)
1576
1737
  triggerSync(cwd, changeName, platformOpts)
1577
1738
 
1578
- console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${stageData.steps[currentIdx].name}`)
1739
+ console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
1579
1740
  console.log(` 回答:${answer}`)
1580
1741
 
1581
1742
  // Append to user-inputs.md
1582
1743
  const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
1583
- 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`
1584
1745
  appendFileSync(inputsPath, entry)
1585
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
+
1586
1769
  // 检查阶段是否全部完成
1587
1770
  const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
1588
1771
  const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
@@ -1595,7 +1778,6 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
1595
1778
  }
1596
1779
 
1597
1780
  // 输出下一步
1598
- const defSteps = await getStageSteps(stageName, cwd, progress)
1599
1781
  if (nextPendingIdx !== -1 && defSteps) {
1600
1782
  console.log('')
1601
1783
  await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
@@ -1640,6 +1822,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1640
1822
 
1641
1823
  const steps = stageData.steps
1642
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
+ }
1643
1844
 
1644
1845
  steps[currentIdx].status = 'completed'
1645
1846
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
@@ -1670,7 +1871,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1670
1871
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
1671
1872
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
1672
1873
  }
1673
- await archiveChangeDirectory(pm, cwd, progress)
1874
+ await archiveChangeDirectory(pm, cwd, progress, specBase)
1674
1875
  }
1675
1876
 
1676
1877
  // archive "确认归档" 步骤完成后,校验归档完整性
@@ -1872,6 +2073,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1872
2073
  console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
1873
2074
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
1874
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
+ }
1875
2095
  }
1876
2096
 
1877
2097
  stageData.status = 'completed'
@@ -1891,22 +2111,36 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1891
2111
  if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
1892
2112
  try {
1893
2113
  stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
1894
- const { mkdirSync, writeFileSync } = await import('fs')
2114
+ const { mkdirSync, writeFileSync, readFileSync: _readFileSync } = await import('fs')
1895
2115
  const { join } = await import('path')
1896
2116
  const { execSync } = await import('child_process')
1897
2117
  const manifestDir = platformOpts.specRoot
1898
2118
  mkdirSync(manifestDir, { recursive: true })
1899
2119
  let sourceCommit = null
2120
+ let sourceCommitError = null
1900
2121
  try {
1901
- const { value: sourceCommit, error: scErr } = safeGit(cwd, ['rev-parse', 'HEAD'])
1902
- } 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
+ }
1903
2128
  const manifest = {
1904
2129
  workspace_id: platformOpts.workspaceId || null,
1905
2130
  scan_run_id: platformOpts.scanRunId || null,
2131
+ source_root: cwd,
2132
+ spec_root: platformOpts.specRoot || null,
2133
+ runtime_root: platformOpts.runtimeRoot || null,
1906
2134
  source_commit: sourceCommit,
1907
- source_commit_error: sourceCommit === null ? (scErr || 'unknown') : undefined,
2135
+ source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
1908
2136
  generated_at: new Date().toISOString(),
1909
2137
  schema_version: 1,
2138
+ postcheck_result_path: null,
2139
+ workflow_runs_dir: platformOpts.runtimeRoot
2140
+ ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown', 'workflow-runs')
2141
+ : null,
2142
+ platform_pointer_path: join(cwd, '.sillyspec-platform.json'),
2143
+ platform_pointer_status: POINTER_STATUS.ACTIVE,
1910
2144
  }
1911
2145
  const manifestPath = join(manifestDir, 'manifest.json')
1912
2146
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
@@ -1947,6 +2181,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1947
2181
  })
1948
2182
  if (postcheckJsonPath) {
1949
2183
  console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
2184
+ manifest.postcheck_result_path = postcheckJsonPath
1950
2185
  }
1951
2186
 
1952
2187
  // 将 post-check 结果写入 manifest
@@ -1958,9 +2193,19 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1958
2193
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
1959
2194
  console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
1960
2195
 
2196
+ // 更新平台指针状态为 scan_completed
2197
+ const pointerPath = join(cwd, '.sillyspec-platform.json')
2198
+ try {
2199
+ const pointer = JSON.parse(_readFileSync(pointerPath, 'utf8'))
2200
+ pointer.status = POINTER_STATUS.SCAN_COMPLETED
2201
+ pointer.completedAt = new Date().toISOString()
2202
+ pointer.scanStatus = postResult.status
2203
+ writeFileSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
2204
+ } catch {}
2205
+
1961
2206
  // failed_post_check 时强制阻止 clean success
1962
2207
  if (postResult.status === 'failed_post_check') {
1963
- stageData.status = 'failed_post_check'
2208
+ stageData.status = SCAN_STATUS.FAILED_POST_CHECK
1964
2209
  stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1965
2210
  await pm._write(cwd, progress, changeName)
1966
2211
  console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
@@ -2056,33 +2301,6 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2056
2301
  }
2057
2302
  }
2058
2303
 
2059
- // quick 阶段完成审计
2060
- if (stageName === 'quick' && progress.quickGuard) {
2061
- const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2062
- progress.quickGuard.review = review
2063
- progress.quickGuard.completedAt = new Date().toISOString()
2064
- // 清理 quick-guard.json
2065
- try {
2066
- const { unlinkSync } = await import('fs')
2067
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2068
- unlinkSync(guardFile)
2069
- } catch {}
2070
- if (review.status === 'blocked') {
2071
- console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
2072
- for (const r of review.reasons) {
2073
- console.error(` - ${r}`)
2074
- }
2075
- console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
2076
- } else if (review.status === 'warning') {
2077
- console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
2078
- for (const r of review.reasons) {
2079
- console.warn(` - ${r}`)
2080
- }
2081
- } else {
2082
- console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
2083
- }
2084
- }
2085
-
2086
2304
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2087
2305
  await pm._write(cwd, progress, changeName)
2088
2306
  triggerSync(cwd, changeName, platformOpts)
@@ -2135,7 +2353,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2135
2353
  console.log(rp.prompt)
2136
2354
  }
2137
2355
  }
2138
- const saved = saveWorkflowRun(result, { cwd, source: 'run.js', stage: 'verify', step: steps[currentIdx]?.name })
2356
+ const saved = saveWorkflowRun(result, {
2357
+ cwd,
2358
+ source: 'run.js',
2359
+ stage: 'scan',
2360
+ step: steps[currentIdx]?.name,
2361
+ ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
2362
+ ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
2363
+ })
2139
2364
  if (saved) console.log(`📁 结果已归档:${saved}`)
2140
2365
  }
2141
2366
  if (anyFailed) {
@@ -2165,7 +2390,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2165
2390
  console.log(` └─ ${f}`)
2166
2391
  }
2167
2392
  }
2168
- const saved = saveWorkflowRun(result, { cwd, source: 'run.js', stage: 'archive', step: steps[currentIdx]?.name })
2393
+ const saved = saveWorkflowRun(result, {
2394
+ cwd,
2395
+ source: 'run.js',
2396
+ stage: 'archive',
2397
+ step: steps[currentIdx]?.name,
2398
+ ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
2399
+ ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
2400
+ })
2169
2401
  if (saved) console.log(`📁 结果已归档:${saved}`)
2170
2402
  }
2171
2403
  } catch (e) {