sillyspec 3.15.2 → 3.16.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
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, statSync } from 'fs'
9
9
  import { ProgressManager } from './progress.js'
10
10
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
11
+ import { checkTransition, runValidators } from './stage-contract.js'
11
12
  import { buildExecuteSteps } from './stages/execute.js'
12
13
  import { buildPlanSteps } from './stages/plan.js'
13
14
  import { formatExecuteSummary } from './worktree-apply.js'
@@ -15,10 +16,122 @@ import { formatExecuteSummary } from './worktree-apply.js'
15
16
  /**
16
17
  * 同步触发辅助函数:_write 后 best-effort 同步到平台
17
18
  */
19
+ /**
20
+ * quick 完成审计:对比 baseline 与实际变更
21
+ */
22
+ async function auditQuickCompletion(cwd, guard, options = {}) {
23
+ const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
24
+ const { isConfirm } = options
25
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
26
+
27
+ try {
28
+ const { execSync } = await import('child_process')
29
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
30
+ const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
31
+
32
+ const DANGEROUS_PATTERNS = [
33
+ '.sillyspec/',
34
+ 'package.json',
35
+ 'package-lock.json',
36
+ 'yarn.lock',
37
+ 'pnpm-lock.yaml',
38
+ '.eslintrc',
39
+ 'tsconfig.json',
40
+ ]
41
+
42
+ for (const entry of currentEntries) {
43
+ const status = entry.slice(0, 2).trim()
44
+ const file = entry.slice(3).trim()
45
+ if (!file || file.startsWith('??. ')) continue
46
+
47
+ result.changedFiles.push(file)
48
+ if (status === 'D' || status === ' D') result.deletedFiles.push(file)
49
+ if (status === '??') result.newFiles.push(file)
50
+
51
+ // 检查是否命中 baseline protected files
52
+ if (baselineFiles.includes(file)) {
53
+ result.baselineHit.push(file)
54
+ }
55
+
56
+ // 检查危险文件(除非 force-baseline)
57
+ if (DANGEROUS_PATTERNS.some(p => file.includes(p)) && !forceBaseline) {
58
+ result.reasons.push(`危险文件变更: ${file}`)
59
+ }
60
+ }
61
+
62
+ // 检查 deleted files
63
+ for (const f of result.deletedFiles) {
64
+ result.reasons.push(`删除文件: ${f}`)
65
+ }
66
+
67
+ // 检查 baseline hit(除非 force-baseline)
68
+ if (!forceBaseline) {
69
+ for (const f of result.baselineHit) {
70
+ result.reasons.push(`覆盖 baseline 文件: ${f}`)
71
+ }
72
+ }
73
+
74
+ // 检查 new files(除非 allow-new)
75
+ if (!allowNew) {
76
+ for (const f of result.newFiles) {
77
+ if (!f.startsWith('.sillyspec/quicklog/') && !f.startsWith('.sillyspec/.runtime/')) {
78
+ result.reasons.push(`新增文件(需 --allow-new): ${f}`)
79
+ }
80
+ }
81
+ }
82
+
83
+ // 检查 allowedFiles 范围
84
+ if (allowedFiles.length > 0) {
85
+ for (const f of result.changedFiles) {
86
+ if (!allowedFiles.includes(f) && !f.startsWith('.sillyspec/')) {
87
+ result.reasons.push(`超出 allowedFiles: ${f}`)
88
+ }
89
+ }
90
+ }
91
+
92
+ // 判定结果
93
+ if (result.baselineHit.length > 0 || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
94
+ result.status = 'blocked'
95
+ } else if (result.newFiles.length > 0 || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
96
+ result.status = 'warning'
97
+ }
98
+
99
+ // --confirm 模式:展示 diff 并等待确认
100
+ if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
101
+ console.log(`\n📋 quick 变更概览:`)
102
+ console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
103
+ if (result.changedFiles.length > 0) {
104
+ console.log(`\n 变更文件:`)
105
+ for (const f of result.changedFiles) {
106
+ const isBaseline = baselineFiles.includes(f)
107
+ const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
108
+ const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
109
+ console.log(` ${marker} ${f}`)
110
+ }
111
+ }
112
+ console.log(`\n 状态: ${result.status.toUpperCase()}`)
113
+ if (result.reasons.length > 0) {
114
+ console.log(` 原因:`)
115
+ for (const r of result.reasons) {
116
+ console.log(` - ${r}`)
117
+ }
118
+ }
119
+ console.log(`\n 如确认接受这些变更,重新运行:sillyspec run quick --done --confirm --output "..."`)
120
+ console.log(` 或使用 --force-baseline 允许覆盖 baseline 文件,--allow-new 允许新增文件`)
121
+ }
122
+ } catch (e) {
123
+ result.reasons.push(`审计失败: ${e.message}`)
124
+ result.status = 'warning'
125
+ }
126
+
127
+ return result
128
+ }
129
+
18
130
  async function triggerSync(cwd, changeName) {
19
131
  try {
132
+ if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
20
133
  const syncMod = await import('./sync.js')
21
- await syncMod.sync(cwd, changeName)
134
+ await syncMod.sync(changeName, cwd)
22
135
  } catch (e) {
23
136
  // sync.js 不存在或同步失败,静默跳过
24
137
  console.warn('⚠️ 同步失败:', e.message)
@@ -32,7 +145,7 @@ async function triggerSync(cwd, changeName) {
32
145
  async function checkApproval(cwd, changeName) {
33
146
  try {
34
147
  const syncMod = await import('./sync.js')
35
- return await syncMod.checkApproval(cwd, changeName)
148
+ return await syncMod.checkApproval(changeName, cwd)
36
149
  } catch (e) {
37
150
  // sync.js 不存在或检查失败,静默跳过
38
151
  return null
@@ -147,7 +260,7 @@ async function ensureStageSteps(progress, stageName, cwd) {
147
260
  /**
148
261
  * 输出当前步骤的 prompt
149
262
  */
150
- async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName) {
263
+ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}) {
151
264
  const step = steps[stepIndex]
152
265
  const total = steps.length
153
266
  const projectName = dbProjectName || basename(cwd)
@@ -218,6 +331,43 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
218
331
  if (changeName && promptText.includes('<change-name>')) {
219
332
  promptText = promptText.replace(/<change-name>/g, changeName)
220
333
  }
334
+ // 平台模式:注入路径覆盖指令(仅 scan 阶段)
335
+ if (stageName === 'scan') {
336
+ const projectName = dbProjectName || basename(cwd)
337
+ const specSillyspec = platformOpts?.specRoot
338
+ ? join(platformOpts.specRoot, '.sillyspec')
339
+ : join(cwd, '.sillyspec')
340
+ const docsRoot = join(specSillyspec, 'docs', projectName)
341
+ const projectsRoot = join(specSillyspec, 'projects')
342
+
343
+ promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
344
+ promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
345
+
346
+ // 平台模式附加指令
347
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
348
+ const platformDirectives = []
349
+ if (platformOpts.specRoot) {
350
+ platformDirectives.push(
351
+ `## ⚠️ 平台模式\n` +
352
+ `文档路径已参数化:\n` +
353
+ `- 文档根目录: \`${docsRoot}/\`\n` +
354
+ `- 项目注册表: \`${projectsRoot}/\`\n` +
355
+ `创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot}\`\n`
356
+ )
357
+ }
358
+ if (platformOpts.runtimeRoot) {
359
+ const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
360
+ platformDirectives.push(
361
+ `运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
362
+ )
363
+ }
364
+ if (platformOpts.workspaceId) {
365
+ platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
366
+ }
367
+ promptText = platformDirectives.join('\n') + '\n\n' + promptText
368
+ }
369
+ }
370
+
221
371
  console.log(promptText)
222
372
  console.log(`\n### ⚠️ 铁律`)
223
373
  console.log('- **文档是核心资产,代码是文档的产物。** 没有文档就没有代码——文档是 AI 的记忆,是团队协作的基础,是后续维护的唯一依据。任何代码产出必须先有对应的设计/规范文档支撑。')
@@ -265,6 +415,50 @@ export async function runCommand(args, cwd) {
265
415
  const isConfirm = flags.includes('--confirm')
266
416
  const isSkipApproval = flags.includes('--skip-approval')
267
417
 
418
+ // 平台模式参数(供 SillyHub 等平台调用)
419
+ const getFlagValue = (name) => {
420
+ const idx = flags.indexOf(name)
421
+ return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
422
+ }
423
+ const platformOpts = {
424
+ specRoot: getFlagValue('--spec-root'),
425
+ runtimeRoot: getFlagValue('--runtime-root'),
426
+ workspaceId: getFlagValue('--workspace-id'),
427
+ scanRunId: getFlagValue('--scan-run-id'),
428
+ }
429
+
430
+ // 跨 --done 生命周期:优先从 metadata 文件恢复 platformOpts
431
+ // 首次 scan 时写入,后续 --done 读取
432
+ const platformOptsFile = join(cwd, '.sillyspec', '.runtime', 'platform-scan.json')
433
+ if (isDone || isSkip) {
434
+ // --done/--skip 阶段:从文件恢复
435
+ try {
436
+ const { readFileSync } = await import('fs')
437
+ const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
438
+ if (saved.specRoot) platformOpts.specRoot = saved.specRoot
439
+ if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
440
+ if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
441
+ if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
442
+ } catch {
443
+ // 文件不存在,说明不是平台模式,跳过
444
+ }
445
+ } else if (platformOpts.specRoot || platformOpts.runtimeRoot) {
446
+ // 首次 scan:持久化 platformOpts
447
+ try {
448
+ const { mkdirSync, writeFileSync } = await import('fs')
449
+ mkdirSync(join(cwd, '.sillyspec', '.runtime'), { recursive: true })
450
+ writeFileSync(platformOptsFile, JSON.stringify({
451
+ specRoot: platformOpts.specRoot,
452
+ runtimeRoot: platformOpts.runtimeRoot,
453
+ workspaceId: platformOpts.workspaceId,
454
+ scanRunId: platformOpts.scanRunId,
455
+ savedAt: new Date().toISOString(),
456
+ }, null, 2) + '\n')
457
+ } catch {
458
+ // 静默失败,不影响主流程
459
+ }
460
+ }
461
+
268
462
  // 解析 --output
269
463
  let outputText = null
270
464
  const outputIdx = flags.indexOf('--output')
@@ -286,6 +480,37 @@ export async function runCommand(args, cwd) {
286
480
  changeName = flags[changeIdx + 1]
287
481
  }
288
482
 
483
+ // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
484
+ let quickFiles = []
485
+ const filesIdx = flags.indexOf('--files')
486
+ if (filesIdx !== -1 && flags[filesIdx + 1]) {
487
+ quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
488
+ }
489
+
490
+ const isAllowNew = flags.includes('--allow-new')
491
+ const isForceBaseline = flags.includes('--force-baseline')
492
+
493
+ // 未知参数 fail-fast
494
+ const knownFlags = new Set([
495
+ '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
496
+ '--output', '--input', '--change',
497
+ '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
498
+ '--files', '--allow-new', '--force-baseline',
499
+ '--json', '--dir', '--help',
500
+ ])
501
+ for (let i = 0; i < flags.length; i++) {
502
+ const f = flags[i]
503
+ if (f.startsWith('--')) {
504
+ if (!knownFlags.has(f)) {
505
+ console.error(`❌ 未知参数: ${f}`)
506
+ console.error(`已知参数: ${[...knownFlags].sort().join(', ')}`)
507
+ process.exit(1)
508
+ }
509
+ // 跳过 value 参数
510
+ i++
511
+ }
512
+ }
513
+
289
514
  const isAuxiliary = auxiliaryStages.includes(stageName)
290
515
 
291
516
  const pm = new ProgressManager()
@@ -361,11 +586,11 @@ export async function runCommand(args, cwd) {
361
586
 
362
587
  // --done
363
588
  if (isDone) {
364
- return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange })
589
+ return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, platformOpts })
365
590
  }
366
591
 
367
592
  // 默认:输出当前步骤
368
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval)
593
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts)
369
594
  }
370
595
 
371
596
  /**
@@ -380,7 +605,19 @@ function resolveChangeNameAuto(cwd) {
380
605
  return null
381
606
  }
382
607
 
383
- async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false) {
608
+ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}) {
609
+ // 状态转换校验
610
+ const prevStage = progress.currentStage || ''
611
+ const transition = checkTransition(prevStage, stageName)
612
+ if (!transition.allowed) {
613
+ console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
614
+ console.error(` 原因: ${transition.reason}`)
615
+ console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
616
+ if (!skipApproval) {
617
+ process.exit(1)
618
+ }
619
+ }
620
+
384
621
  // execute 阶段启动前检查审批
385
622
  if (stageName === 'execute' && !skipApproval) {
386
623
  const approval = await checkApproval(cwd, changeName)
@@ -435,6 +672,39 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
435
672
  console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
436
673
  }
437
674
 
675
+ // quick 阶段:记录 baselineFiles
676
+ if (stageName === 'quick' && !progress.quickGuard) {
677
+ try {
678
+ const { execSync } = await import('child_process')
679
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
680
+ const baselineFiles = gitStatus
681
+ .trim().split('\n').filter(Boolean)
682
+ .map(line => line.slice(3).trim())
683
+ .filter(f => !f.startsWith('.sillyspec/'))
684
+ const allowedFiles = quickFiles || []
685
+ const allowNew = isAllowNew || false
686
+ const forceBaseline = isForceBaseline || false
687
+ progress.quickGuard = {
688
+ baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
689
+ baselineFiles,
690
+ allowedFiles,
691
+ allowNew,
692
+ forceBaseline,
693
+ startedAt: new Date().toISOString(),
694
+ }
695
+ // 写入 quick-guard.json 供 worktree-guard hook 读取
696
+ const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
697
+ writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
698
+ const parts = [`${baselineFiles.length} 个已有脏文件`]
699
+ if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
700
+ if (allowNew) parts.push('允许新增文件')
701
+ console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
702
+ await pm._write(cwd, progress, changeName)
703
+ } catch (e) {
704
+ console.warn(`⚠️ baseline 记录失败: ${e.message}`)
705
+ }
706
+ }
707
+
438
708
  if (currentIdx > 0) {
439
709
  const completed = currentIdx
440
710
  const total = steps.length
@@ -444,7 +714,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
444
714
 
445
715
  const defSteps = await getStageSteps(stageName, cwd, progress)
446
716
  if (defSteps && defSteps[currentIdx]) {
447
- await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null)
717
+ await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
448
718
  }
449
719
  }
450
720
 
@@ -526,8 +796,41 @@ function validateFileLocations(cwd, stageName, progress, changeName) {
526
796
  }
527
797
  }
528
798
 
799
+ async function archiveChangeDirectory(pm, cwd, progress) {
800
+ const { renameSync } = await import('fs')
801
+ const archiveChangeName = progress.currentChange
802
+ if (!archiveChangeName) {
803
+ console.error('❌ 归档失败:未找到当前变更名(currentChange)')
804
+ process.exit(1)
805
+ }
806
+ const changesDir = join(cwd, '.sillyspec', 'changes')
807
+ const archiveDir = join(changesDir, 'archive')
808
+ const srcDir = join(changesDir, archiveChangeName)
809
+ const date = new Date().toISOString().slice(0, 10)
810
+ const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
811
+
812
+ if (!existsSync(srcDir)) {
813
+ console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
814
+ process.exit(1)
815
+ }
816
+ if (existsSync(destDir)) {
817
+ console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
818
+ process.exit(1)
819
+ }
820
+ mkdirSync(archiveDir, { recursive: true })
821
+ renameSync(srcDir, destDir)
822
+
823
+ if (!existsSync(destDir) || existsSync(srcDir)) {
824
+ console.error('❌ 归档校验失败:移动操作异常')
825
+ process.exit(1)
826
+ }
827
+
828
+ await pm.unregisterChange(cwd, archiveChangeName)
829
+ console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
830
+ }
831
+
529
832
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
530
- const { printNext = true, confirm = false, changeName } = options
833
+ const { printNext = true, confirm = false, changeName, platformOpts = {} } = options
531
834
  const stageData = progress.stages[stageName]
532
835
  if (!stageData || !stageData.steps) {
533
836
  console.error(`❌ 阶段 ${stageName} 未初始化`)
@@ -548,15 +851,48 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
548
851
  const MAX_OUTPUT = 200
549
852
  if (outputText.length > MAX_OUTPUT) {
550
853
  steps[currentIdx].output = outputText.slice(0, MAX_OUTPUT) + '…'
551
- const artifactsDir = join(cwd, '.sillyspec', '.runtime', 'artifacts')
552
- mkdirSync(artifactsDir, { recursive: true })
854
+ // 平台模式:artifact 写入 runtime-root,否则写 .sillyspec/.runtime/artifacts
855
+ const artifactBase = platformOpts?.runtimeRoot
856
+ ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown')
857
+ : join(cwd, '.sillyspec', '.runtime', 'artifacts')
858
+ mkdirSync(artifactBase, { recursive: true })
553
859
  const ts = new Date().toISOString().slice(0,19).replace(/[-T:]/g, '')
554
- writeFileSync(join(artifactsDir, `${changeName || 'unknown'}-${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
860
+ writeFileSync(join(artifactBase, `${changeName || 'unknown'}-${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
555
861
  } else {
556
862
  steps[currentIdx].output = outputText
557
863
  }
558
864
  }
559
865
 
866
+ if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
867
+ if (!confirm) {
868
+ steps[currentIdx].status = 'pending'
869
+ steps[currentIdx].completedAt = null
870
+ if (outputText) steps[currentIdx].output = null
871
+ await pm._write(cwd, progress, changeName)
872
+ console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
873
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
874
+ }
875
+ await archiveChangeDirectory(pm, cwd, progress)
876
+ }
877
+
878
+ // archive "确认归档" 步骤完成后,校验归档完整性
879
+ if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
880
+ const projectName = progress.project || basename(cwd)
881
+ const contractResult = runValidators('archive', cwd, changeName, { projectName })
882
+ if (contractResult.errors.length > 0) {
883
+ console.error(`\n❌ 归档校验失败:`)
884
+ for (const err of contractResult.errors) {
885
+ console.error(` - ${err}`)
886
+ }
887
+ }
888
+ if (contractResult.warnings.length > 0) {
889
+ console.warn(`\n⚠️ 归档校验警告:`)
890
+ for (const w of contractResult.warnings) {
891
+ console.warn(` - ${w}`)
892
+ }
893
+ }
894
+ }
895
+
560
896
  // plan 阶段 "展开任务" 完成后,动态插入任务蓝图协调器步骤
561
897
  if (stageName === 'plan' && steps[currentIdx]?.name === '展开任务并分组') {
562
898
  const changeDir = resolveChangeDir(cwd, progress)
@@ -683,50 +1019,61 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
683
1019
  appendFileSync(inputsPath, entry)
684
1020
  }
685
1021
 
686
- validateMetadata(cwd, stageName)
687
-
688
- // 验证关键文件是否在正确的变更目录下
689
- validateFileLocations(cwd, stageName, progress, changeName)
690
-
691
- // archive 阶段确认归档
692
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
693
- if (confirm) {
694
- const { renameSync } = await import('fs')
695
- const archiveChangeName = progress.currentChange
696
- if (!archiveChangeName) {
697
- console.error('❌ 归档失败:未找到当前变更名(currentChange)')
698
- process.exit(1)
699
- }
700
- const changesDir = join(cwd, '.sillyspec', 'changes')
701
- const archiveDir = join(changesDir, 'archive')
702
- const srcDir = join(changesDir, archiveChangeName)
703
- const date = new Date().toISOString().slice(0, 10)
704
- const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
705
-
706
- if (!existsSync(srcDir)) {
707
- console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
708
- process.exit(1)
1022
+ // 平台模式:scan 完成后生成 manifest.json
1023
+ if (stageName === 'scan' && platformOpts.specRoot) {
1024
+ try {
1025
+ const { mkdirSync, writeFileSync } = await import('fs')
1026
+ const { join } = await import('path')
1027
+ const { execSync } = await import('child_process')
1028
+ const manifestDir = join(platformOpts.specRoot, '.sillyspec')
1029
+ mkdirSync(manifestDir, { recursive: true })
1030
+ let sourceCommit = null
1031
+ try {
1032
+ sourceCommit = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
1033
+ } catch {}
1034
+ const manifest = {
1035
+ workspace_id: platformOpts.workspaceId || null,
1036
+ scan_run_id: platformOpts.scanRunId || null,
1037
+ source_commit: sourceCommit,
1038
+ generated_at: new Date().toISOString(),
1039
+ schema_version: 1,
709
1040
  }
710
- if (existsSync(destDir)) {
711
- console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
712
- process.exit(1)
1041
+ const manifestPath = join(manifestDir, 'manifest.json')
1042
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
1043
+ console.log(`📄 manifest.json 已写入: ${manifestPath}`)
1044
+ if (!sourceCommit) {
1045
+ console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
713
1046
  }
714
- mkdirSync(archiveDir, { recursive: true })
715
- renameSync(srcDir, destDir)
716
-
717
- if (!existsSync(destDir) || existsSync(srcDir)) {
718
- console.error('❌ 归档校验失败:移动操作异常')
719
- process.exit(1)
1047
+ // 清理平台参数临时文件
1048
+ const { unlinkSync } = await import('fs')
1049
+ const platformOptsFile = join(cwd, '.sillyspec', '.runtime', 'platform-scan.json')
1050
+ try { unlinkSync(platformOptsFile) } catch {}
1051
+
1052
+ // 平台模式后置校验:检查 source_root 是否被污染
1053
+ if (platformOpts.specRoot) {
1054
+ const { readdirSync } = await import('fs')
1055
+ const localDocsDir = join(cwd, '.sillyspec', 'docs')
1056
+ try {
1057
+ if (existsSync(localDocsDir)) {
1058
+ const entries = readdirSync(localDocsDir, { recursive: true }).filter(e => e.endsWith('.md'))
1059
+ if (entries.length > 0) {
1060
+ console.warn(`⚠️ 平台模式后置校验:source_root 下存在 ${entries.length} 个文档文件:`)
1061
+ console.warn(` 路径:${localDocsDir}/`)
1062
+ console.warn(` 可能原因:agent 未遵守路径覆盖,将文档写入到了 cwd 而非 spec-root`)
1063
+ }
1064
+ }
1065
+ } catch {}
720
1066
  }
721
-
722
- // 从全局活跃列表移除
723
- await pm.unregisterChange(cwd, archiveChangeName)
724
- console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
725
- } else {
726
- console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
1067
+ } catch (e) {
1068
+ console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
727
1069
  }
728
1070
  }
729
1071
 
1072
+ validateMetadata(cwd, stageName)
1073
+
1074
+ // 验证关键文件是否在正确的变更目录下
1075
+ validateFileLocations(cwd, stageName, progress, changeName)
1076
+
730
1077
  // 辅助阶段完成后重置步骤
731
1078
  const stageDef = stageRegistry[stageName]
732
1079
  if (stageDef?.auxiliary) {
@@ -739,6 +1086,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
739
1086
  stageData.steps = freshSteps
740
1087
  stageData.status = 'pending'
741
1088
  stageData.completedAt = null
1089
+ if (progress.currentStage === stageName) progress.currentStage = ''
742
1090
  await pm._write(cwd, progress, changeName)
743
1091
  }
744
1092
 
@@ -771,6 +1119,50 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
771
1119
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
772
1120
  }
773
1121
 
1122
+ // 阶段完成校验
1123
+ const projectName = progress.project || basename(cwd)
1124
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName })
1125
+ if (contractResult.errors.length > 0) {
1126
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
1127
+ for (const err of contractResult.errors) {
1128
+ console.error(` - ${err}`)
1129
+ }
1130
+ console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
1131
+ }
1132
+ if (contractResult.warnings.length > 0) {
1133
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
1134
+ for (const w of contractResult.warnings) {
1135
+ console.warn(` - ${w}`)
1136
+ }
1137
+ }
1138
+
1139
+ // quick 阶段完成审计
1140
+ if (stageName === 'quick' && progress.quickGuard) {
1141
+ const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
1142
+ progress.quickGuard.review = review
1143
+ progress.quickGuard.completedAt = new Date().toISOString()
1144
+ // 清理 quick-guard.json
1145
+ try {
1146
+ const { unlinkSync } = await import('fs')
1147
+ const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
1148
+ unlinkSync(guardFile)
1149
+ } catch {}
1150
+ if (review.status === 'blocked') {
1151
+ console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
1152
+ for (const r of review.reasons) {
1153
+ console.error(` - ${r}`)
1154
+ }
1155
+ console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
1156
+ } else if (review.status === 'warning') {
1157
+ console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
1158
+ for (const r of review.reasons) {
1159
+ console.warn(` - ${r}`)
1160
+ }
1161
+ } else {
1162
+ console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
1163
+ }
1164
+ }
1165
+
774
1166
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
775
1167
  await pm._write(cwd, progress, changeName)
776
1168
  triggerSync(cwd, changeName)
@@ -862,7 +1254,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
862
1254
  }
863
1255
 
864
1256
  if (printNext) {
865
- await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
1257
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
866
1258
  }
867
1259
  return { stageCompleted: false, currentIdx, nextPendingIdx }
868
1260
  }
@@ -900,7 +1292,7 @@ async function skipStep(pm, progress, stageName, cwd, changeName) {
900
1292
  const nextPendingIdx = steps.findIndex(s => s.status === 'pending')
901
1293
  if (nextPendingIdx !== -1 && defSteps) {
902
1294
  console.log('')
903
- await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
1295
+ await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
904
1296
  }
905
1297
  }
906
1298
 
@@ -1045,7 +1437,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
1045
1437
  }
1046
1438
  }
1047
1439
  }
1048
- await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null)
1440
+ await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
1049
1441
  return
1050
1442
  }
1051
1443
 
@@ -1054,7 +1446,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
1054
1446
  process.exit(1)
1055
1447
  }
1056
1448
 
1057
- const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName })
1449
+ const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
1058
1450
  if (!result) return
1059
1451
  progress = await pm.read(cwd, changeName)
1060
1452
 
@@ -1075,7 +1467,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
1075
1467
  }
1076
1468
  }
1077
1469
  }
1078
- await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
1470
+ await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
1079
1471
  return
1080
1472
  }
1081
1473
 
@@ -1117,6 +1509,6 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
1117
1509
  }
1118
1510
  }
1119
1511
  }
1120
- await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null)
1512
+ await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null, platformOpts)
1121
1513
  }
1122
1514
  }