sillyspec 3.16.1 → 3.17.0

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,9 +4,21 @@
4
4
  * CLI 成为流程引擎,AI 变成步骤执行器。
5
5
  * 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
6
6
  */
7
- import { basename, join } from 'path'
7
+ import { basename, join, resolve } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, statSync } from 'fs'
9
9
  import { ProgressManager } from './progress.js'
10
+
11
+ /**
12
+ * 解析规范目录路径
13
+ * @param {string} cwd - 项目根目录
14
+ * @param {object} [opts]
15
+ * @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
16
+ * @returns {string} 规范目录的绝对路径
17
+ */
18
+ function resolveSpecDir(cwd, opts = {}) {
19
+ if (opts.specDir) return resolve(opts.specDir)
20
+ return join(cwd, '.sillyspec')
21
+ }
10
22
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
11
23
  import { checkTransition, runValidators } from './stage-contract.js'
12
24
  import { buildExecuteSteps } from './stages/execute.js'
@@ -155,8 +167,8 @@ async function checkApproval(cwd, changeName) {
155
167
  /**
156
168
  * 统一查找变更目录(与 progress.js 的变更检测逻辑一致)
157
169
  */
158
- function resolveChangeDir(cwd, progress) {
159
- const changesDir = join(cwd, '.sillyspec', 'changes')
170
+ function resolveChangeDir(cwd, progress, specDir = null) {
171
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
160
172
  if (!existsSync(changesDir)) return null
161
173
 
162
174
  // 1. 优先用 currentChange
@@ -177,9 +189,9 @@ function resolveChangeDir(cwd, progress) {
177
189
  * 自动探测并设置 currentChange(唯一变更目录时)
178
190
  * @returns {boolean} 是否设置了 currentChange
179
191
  */
180
- function autoDetectChange(progress, cwd) {
192
+ function autoDetectChange(progress, cwd, specDir = null) {
181
193
  if (progress.currentChange) return false
182
- const changesDir = join(cwd, '.sillyspec', 'changes')
194
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
183
195
  if (!existsSync(changesDir)) return false
184
196
  const entries = readdirSync(changesDir, { withFileTypes: true })
185
197
  .filter(e => e.isDirectory() && e.name !== 'archive')
@@ -193,9 +205,9 @@ function autoDetectChange(progress, cwd) {
193
205
  /**
194
206
  * 从 progress 或变更目录推导变更名
195
207
  */
196
- function resolveChangeName(cwd, progress) {
208
+ function resolveChangeName(cwd, progress, specDir = null) {
197
209
  if (progress.currentChange) return progress.currentChange
198
- const changesDir = join(cwd, '.sillyspec', 'changes')
210
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
199
211
  if (!existsSync(changesDir)) return null
200
212
  const entries = readdirSync(changesDir, { withFileTypes: true })
201
213
  .filter(e => e.isDirectory() && e.name !== 'archive')
@@ -206,9 +218,9 @@ function resolveChangeName(cwd, progress) {
206
218
  /**
207
219
  * 获取阶段的步骤定义(execute 需要动态构建)
208
220
  */
209
- async function getStageSteps(stageName, cwd, progress) {
221
+ async function getStageSteps(stageName, cwd, progress, specDir = null) {
210
222
  if (stageName === 'execute') {
211
- const changeDir = resolveChangeDir(cwd, progress)
223
+ const changeDir = resolveChangeDir(cwd, progress, specDir)
212
224
  let planFile = null
213
225
  if (changeDir) {
214
226
  const p = join(changeDir, 'plan.md')
@@ -217,7 +229,7 @@ async function getStageSteps(stageName, cwd, progress) {
217
229
  return buildExecuteSteps(planFile)
218
230
  }
219
231
  if (stageName === 'plan') {
220
- const changeDir = resolveChangeDir(cwd, progress)
232
+ const changeDir = resolveChangeDir(cwd, progress, specDir)
221
233
  return buildPlanSteps(changeDir)
222
234
  }
223
235
  const def = stageRegistry[stageName]
@@ -227,10 +239,10 @@ async function getStageSteps(stageName, cwd, progress) {
227
239
  /**
228
240
  * 确保阶段的 steps 已初始化到 progress
229
241
  */
230
- async function ensureStageSteps(progress, stageName, cwd) {
242
+ async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
231
243
  if (!progress.stages) progress.stages = {}
232
244
 
233
- const steps = await getStageSteps(stageName, cwd, progress)
245
+ const steps = await getStageSteps(stageName, cwd, progress, specDir)
234
246
  if (!steps) return false
235
247
 
236
248
  if (!progress.stages[stageName] || !progress.stages[stageName].steps || progress.stages[stageName].steps.length === 0) {
@@ -331,40 +343,59 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
331
343
  if (changeName && promptText.includes('<change-name>')) {
332
344
  promptText = promptText.replace(/<change-name>/g, changeName)
333
345
  }
334
- // 平台模式:注入路径覆盖指令(仅 scan 阶段)
335
- if (stageName === 'scan') {
346
+ // 平台模式:注入路径覆盖指令
347
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
336
348
  const projectName = dbProjectName || basename(cwd)
337
- const specSillyspec = platformOpts?.specRoot
338
- ? join(platformOpts.specRoot, '.sillyspec')
339
- : join(cwd, '.sillyspec')
349
+ // platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
350
+ const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
340
351
  const docsRoot = join(specSillyspec, 'docs', projectName)
341
352
  const projectsRoot = join(specSillyspec, 'projects')
353
+ const changesRoot = join(specSillyspec, 'changes')
342
354
 
343
355
  promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
344
356
  promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
345
357
 
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
358
+ const platformDirectives = []
359
+ platformDirectives.push(
360
+ `## ⚠️ 平台模式 — 写入路径约束(必须严格遵守)\n` +
361
+ `\n` +
362
+ `规范目录(specDir): \`${specSillyspec}\`\n` +
363
+ `- 文档根目录: \`${docsRoot}/\`\n` +
364
+ `- 项目注册表: \`${projectsRoot}/\`\n` +
365
+ `- 变更目录: \`${changesRoot}/\`\n` +
366
+ `\n` +
367
+ `### 写入规则\n` +
368
+ `1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
369
+ `2. **不允许**从 cwd 推导文档路径,必须使用上面列出的绝对路径。\n` +
370
+ `3. **源码扫描范围**必须排除:.sillyspec/、.claude/、.git/、node_modules/、dist/、build/、__pycache__/\n` +
371
+ `4. **local.yaml 校验**:commands 中引用的命令必须在 package.json scripts 中存在,不存在的标记为 unavailable,不能写 "配置良好"\n` +
372
+ `\n` +
373
+ `### ⛔ Write 工具规则\n` +
374
+ `1. 如果 Write 返回 \"File has not been read yet\",正确动作是:先 Read 目标文件 → 再 Write 覆盖。\n` +
375
+ `2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
376
+ `3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
377
+ `\n` +
378
+ `创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
379
+ )
380
+ if (platformOpts.runtimeRoot) {
381
+ const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
382
+ platformDirectives.push(
383
+ `运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
384
+ )
385
+ }
386
+ if (platformOpts.workspaceId) {
387
+ platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
388
+ }
389
+ promptText = platformDirectives.join('\n') + '\n\n' + promptText
390
+ } else {
391
+ // 非 platform 模式也要替换占位符
392
+ if (stageName === 'scan') {
393
+ const projectName = dbProjectName || basename(cwd)
394
+ const specSillyspec = join(cwd, '.sillyspec')
395
+ const docsRoot = join(specSillyspec, 'docs', projectName)
396
+ const projectsRoot = join(specSillyspec, 'projects')
397
+ promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
398
+ promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
368
399
  }
369
400
  }
370
401
 
@@ -378,6 +409,13 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
378
409
  console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
379
410
  console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
380
411
  console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
412
+ // 平台模式额外铁律
413
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
414
+ const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
415
+ console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
416
+ console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
417
+ console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
418
+ }
381
419
  // 路径安全规则:防止 AI 拼错变更目录
382
420
  if (changeName) {
383
421
  const changeDir = join('.sillyspec', 'changes', changeName)
@@ -391,7 +429,7 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
391
429
  /**
392
430
  * sillyspec run <stage> 主命令
393
431
  */
394
- export async function runCommand(args, cwd) {
432
+ export async function runCommand(args, cwd, specDir = null) {
395
433
  // 解析参数
396
434
  const stageName = args[0]
397
435
  const flags = args.slice(1)
@@ -416,37 +454,56 @@ export async function runCommand(args, cwd) {
416
454
  const isSkipApproval = flags.includes('--skip-approval')
417
455
 
418
456
  // 平台模式参数(供 SillyHub 等平台调用)
457
+ // --spec-dir 是统一参数名,--spec-root 保留为向后兼容别名
419
458
  const getFlagValue = (name) => {
420
459
  const idx = flags.indexOf(name)
421
460
  return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
422
461
  }
462
+ const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root');
423
463
  const platformOpts = {
424
- specRoot: getFlagValue('--spec-root'),
425
- runtimeRoot: getFlagValue('--runtime-root'),
464
+ specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
465
+ runtimeRoot: getFlagValue('--runtime-root') ? resolve(getFlagValue('--runtime-root')) : null,
426
466
  workspaceId: getFlagValue('--workspace-id'),
427
467
  scanRunId: getFlagValue('--scan-run-id'),
428
468
  }
429
469
 
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
- // 文件不存在,说明不是平台模式,跳过
470
+ // 跨 --done 生命周期:从 metadata 文件恢复 platformOpts
471
+ // 首次 scan 时写入,所有后续调用(包括 run、--done、--skip)都读取
472
+ // 优先在 specDir 下查找,否则回退到 cwd/.sillyspec/.runtime/
473
+ const specRoot = platformOpts.specRoot || resolveSpecDir(cwd)
474
+ const platformOptsFile = join(specRoot, '.runtime', 'platform-scan.json')
475
+ let platformFileExists = existsSync(platformOptsFile)
476
+ // 如果命令行没传 spec-root,尝试从持久化文件恢复
477
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
478
+ if (platformFileExists) {
479
+ try {
480
+ const { readFileSync } = await import('fs')
481
+ const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
482
+ if (saved.specRoot) platformOpts.specRoot = saved.specRoot
483
+ if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
484
+ if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
485
+ if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
486
+ // 平台模式 fail-fast:文件存在但缺少 specRoot
487
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
488
+ console.error(`❌ 平台模式参数文件存在但缺少 specRoot/runtimeRoot: ${platformOptsFile}`)
489
+ console.error(' 可能原因:platform-scan.json 损坏或写入不完整')
490
+ console.error(' 解决:重新运行首次 scan 并传入 --spec-root')
491
+ process.exit(1)
492
+ }
493
+ } catch (e) {
494
+ console.error(`❌ 平台模式参数文件读取失败: ${platformOptsFile}`)
495
+ console.error(` 错误: ${e.message}`)
496
+ console.error(' 可能原因:文件损坏')
497
+ console.error(' 解决:删除该文件并重新运行首次 scan 传入 --spec-root')
498
+ process.exit(1)
499
+ }
444
500
  }
445
- } else if (platformOpts.specRoot || platformOpts.runtimeRoot) {
446
- // 首次 scan:持久化 platformOpts
501
+ }
502
+ // 持久化 platformOpts(命令行传入或已恢复的都持久化)
503
+ if (platformOpts.specRoot || platformOpts.runtimeRoot) {
447
504
  try {
448
505
  const { mkdirSync, writeFileSync } = await import('fs')
449
- mkdirSync(join(cwd, '.sillyspec', '.runtime'), { recursive: true })
506
+ mkdirSync(join(specRoot, '.runtime'), { recursive: true })
450
507
  writeFileSync(platformOptsFile, JSON.stringify({
451
508
  specRoot: platformOpts.specRoot,
452
509
  runtimeRoot: platformOpts.runtimeRoot,
@@ -494,7 +551,7 @@ export async function runCommand(args, cwd) {
494
551
  const knownFlags = new Set([
495
552
  '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
496
553
  '--output', '--input', '--change',
497
- '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
554
+ '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
498
555
  '--files', '--allow-new', '--force-baseline',
499
556
  '--json', '--dir', '--help',
500
557
  ])
@@ -513,17 +570,17 @@ export async function runCommand(args, cwd) {
513
570
 
514
571
  const isAuxiliary = auxiliaryStages.includes(stageName)
515
572
 
516
- const pm = new ProgressManager()
573
+ const pm = new ProgressManager({ specDir: specRoot })
517
574
  let progress = await pm.read(cwd, changeName)
518
575
 
519
576
  if (!progress) {
520
577
  // 如果指定了变更名或有变更目录,自动初始化变更的 progress
521
- const autoChange = changeName || resolveChangeNameAuto(cwd)
578
+ const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
522
579
  if (autoChange) {
523
580
  progress = await pm.initChange(cwd, autoChange)
524
581
  } else if (isAuxiliary) {
525
582
  // 辅助阶段(scan/explore/quick/doctor/status)自动使用默认变更名
526
- const autoName = changeName || resolveChangeNameAuto(cwd) || 'default'
583
+ const autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
527
584
  changeName = autoName
528
585
  progress = await pm.initChange(cwd, autoName)
529
586
  // initChange 可能因 project 表为空返回 null
@@ -548,7 +605,7 @@ export async function runCommand(args, cwd) {
548
605
  }
549
606
 
550
607
  // 确保 progress 有 currentChange
551
- const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress)
608
+ const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
552
609
 
553
610
  // -- auto 模式:自动推进所有流程阶段
554
611
  if (stageName === 'auto') {
@@ -567,7 +624,7 @@ export async function runCommand(args, cwd) {
567
624
  }
568
625
 
569
626
  // 确保步骤已初始化
570
- const changed = await ensureStageSteps(progress, stageName, cwd)
627
+ const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
571
628
  if (changed && effectiveChange) {
572
629
  await pm._write(cwd, progress, effectiveChange)
573
630
  triggerSync(cwd, effectiveChange)
@@ -590,14 +647,14 @@ export async function runCommand(args, cwd) {
590
647
  }
591
648
 
592
649
  // 默认:输出当前步骤
593
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts)
650
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline })
594
651
  }
595
652
 
596
653
  /**
597
654
  * 自动推导变更名(不依赖 progress)
598
655
  */
599
- function resolveChangeNameAuto(cwd) {
600
- const changesDir = join(cwd, '.sillyspec', 'changes')
656
+ function resolveChangeNameAuto(cwd, specDir = null) {
657
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
601
658
  if (!existsSync(changesDir)) return null
602
659
  const entries = readdirSync(changesDir, { withFileTypes: true })
603
660
  .filter(e => e.isDirectory() && e.name !== 'archive')
@@ -605,7 +662,7 @@ function resolveChangeNameAuto(cwd) {
605
662
  return null
606
663
  }
607
664
 
608
- async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}) {
665
+ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
609
666
  // 状态转换校验
610
667
  const prevStage = progress.currentStage || ''
611
668
  const transition = checkTransition(prevStage, stageName)
@@ -681,9 +738,9 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
681
738
  .trim().split('\n').filter(Boolean)
682
739
  .map(line => line.slice(3).trim())
683
740
  .filter(f => !f.startsWith('.sillyspec/'))
684
- const allowedFiles = quickFiles || []
685
- const allowNew = isAllowNew || false
686
- const forceBaseline = isForceBaseline || false
741
+ const allowedFiles = quickOpts?.quickFiles || []
742
+ const allowNew = quickOpts?.isAllowNew || false
743
+ const forceBaseline = quickOpts?.isForceBaseline || false
687
744
  progress.quickGuard = {
688
745
  baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
689
746
  baselineFiles,
@@ -878,7 +935,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
878
935
  // archive "确认归档" 步骤完成后,校验归档完整性
879
936
  if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
880
937
  const projectName = progress.project || basename(cwd)
881
- const contractResult = runValidators('archive', cwd, changeName, { projectName })
938
+ const contractResult = runValidators('archive', cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
882
939
  if (contractResult.errors.length > 0) {
883
940
  console.error(`\n❌ 归档校验失败:`)
884
941
  for (const err of contractResult.errors) {
@@ -1019,8 +1076,8 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1019
1076
  appendFileSync(inputsPath, entry)
1020
1077
  }
1021
1078
 
1022
- // 平台模式:scan 完成后生成 manifest.json
1023
- if (stageName === 'scan' && platformOpts.specRoot) {
1079
+ // 平台模式:scan 完成后生成 manifest.json + post-check
1080
+ if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
1024
1081
  try {
1025
1082
  const { mkdirSync, writeFileSync } = await import('fs')
1026
1083
  const { join } = await import('path')
@@ -1046,29 +1103,52 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1046
1103
  }
1047
1104
  // 清理平台参数临时文件
1048
1105
  const { unlinkSync } = await import('fs')
1049
- const platformOptsFile = join(cwd, '.sillyspec', '.runtime', 'platform-scan.json')
1106
+ const platformOptsFile = join(specRoot, '.runtime', 'platform-scan.json')
1050
1107
  try { unlinkSync(platformOptsFile) } catch {}
1051
1108
 
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 {}
1109
+ // CLI post-check(替代旧的简单检查)
1110
+ const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1111
+ const postResult = runScanPostCheck({
1112
+ cwd,
1113
+ specDir: platformOpts.specRoot,
1114
+ outputText,
1115
+ })
1116
+ printScanPostCheckResult(postResult)
1117
+
1118
+ // 将 post-check 结果写入 manifest
1119
+ manifest.scan_post_check = {
1120
+ status: postResult.status,
1121
+ checks: postResult.checks,
1122
+ }
1123
+ // 更新 manifest
1124
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
1125
+ console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
1126
+
1127
+ // failed_post_check 时强制阻止 clean success
1128
+ if (postResult.status === 'failed_post_check') {
1129
+ stageData.status = 'failed_post_check'
1130
+ stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1131
+ await pm._write(cwd, progress, changeName)
1132
+ console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
1133
+ console.error(` 请检查上方错误信息并修复后重新 scan。`)
1134
+ } else if (postResult.status === 'completed_with_warnings') {
1135
+ // 警告不阻止完成,但记录
1136
+ stageData.status = 'completed'
1137
+ stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1138
+ await pm._write(cwd, progress, changeName)
1066
1139
  }
1067
1140
  } catch (e) {
1068
1141
  console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
1069
1142
  }
1070
1143
  }
1071
1144
 
1145
+ // 非 platform 模式 scan 也做轻量 post-check
1146
+ if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
1147
+ const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1148
+ const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
1149
+ printScanPostCheckResult(postResult)
1150
+ }
1151
+
1072
1152
  validateMetadata(cwd, stageName)
1073
1153
 
1074
1154
  // 验证关键文件是否在正确的变更目录下
@@ -1121,7 +1201,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1121
1201
 
1122
1202
  // 阶段完成校验
1123
1203
  const projectName = progress.project || basename(cwd)
1124
- const contractResult = runValidators(stageName, cwd, changeName, { projectName })
1204
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
1125
1205
  if (contractResult.errors.length > 0) {
1126
1206
  console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
1127
1207
  for (const err of contractResult.errors) {
@@ -0,0 +1,179 @@
1
+ /**
2
+ * scan-postcheck.js — CLI 层 scan 完成后强制校验
3
+ *
4
+ * 不依赖 AI agent 的自检报告,由 CLI 代码直接检查文件系统。
5
+ * 平台模式下必须通过所有 check 才能 success,否则降级。
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'fs'
9
+ import { join, basename } from 'path'
10
+
11
+ const REQUIRED_SCAN_DOCS = [
12
+ 'ARCHITECTURE.md',
13
+ 'CONVENTIONS.md',
14
+ 'STRUCTURE.md',
15
+ 'INTEGRATIONS.md',
16
+ 'TESTING.md',
17
+ 'CONCERNS.md',
18
+ 'PROJECT.md',
19
+ ]
20
+
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string} opts.cwd - 源码项目根目录 (source_root)
24
+ * @param {string} opts.specDir - 规范目录 (spec-root),null 时为非平台模式
25
+ * @param {string} [opts.outputText] - 最后一步(自检)的 AI 输出文本
26
+ * @returns {{ status: 'success'|'completed_with_warnings'|'failed_post_check', checks: Array<{name, severity, detail}> }}
27
+ */
28
+ export function runScanPostCheck({ cwd, specDir, outputText = '' }) {
29
+ const isPlatform = !!specDir
30
+ const checks = []
31
+
32
+ if (!isPlatform) {
33
+ // 非平台模式:只做轻量检查
34
+ const localSpec = join(cwd, '.sillyspec')
35
+ const scanDir = join(localSpec, 'docs', basename(cwd), 'scan')
36
+
37
+ // 检查 7 份文档是否存在
38
+ const missing = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(scanDir, f)))
39
+ if (missing.length > 0) {
40
+ checks.push({ name: 'missing_docs', severity: 'warning', detail: `缺少 ${missing.length} 份 scan 文档: ${missing.join(', ')}` })
41
+ }
42
+
43
+ const hasWarning = checks.some(c => c.severity === 'warning')
44
+ return { status: hasWarning ? 'completed_with_warnings' : 'success', checks }
45
+ }
46
+
47
+ // ── 平台模式:严格检查 ──
48
+
49
+ const projectName = basename(cwd)
50
+
51
+ // 1. source_root 污染检查
52
+ const localDocsDir = join(cwd, '.sillyspec', 'docs')
53
+ if (existsSync(localDocsDir)) {
54
+ try {
55
+ const leaked = readdirSync(localDocsDir, { recursive: true }).filter(e => String(e).endsWith('.md'))
56
+ if (leaked.length > 0) {
57
+ checks.push({
58
+ name: 'source_root_docs_leak',
59
+ severity: 'failed',
60
+ detail: `source_root 下存在 ${leaked.length} 个文档文件(${localDocsDir}/),agent 可能写入到了错误路径`
61
+ })
62
+ }
63
+ } catch {}
64
+ }
65
+
66
+ // 2. spec_root 检查 7 份必需文档
67
+ const specScanDir = join(specDir, 'docs', projectName, 'scan')
68
+ const missingDocs = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(specScanDir, f)))
69
+ if (missingDocs.length > 0) {
70
+ checks.push({
71
+ name: missingDocs.length === REQUIRED_SCAN_DOCS.length ? 'all_docs_missing' : 'partial_docs_missing',
72
+ severity: 'failed',
73
+ detail: missingDocs.length === REQUIRED_SCAN_DOCS.length
74
+ ? `spec_root 下无任何 scan 文档(${specScanDir}/),扫描可能未执行`
75
+ : `spec_root 缺少必需文档: ${missingDocs.join(', ')}(7 份 scan 文档均为 required)`
76
+ })
77
+ }
78
+
79
+ // 3. 检查文档 header(author / created_at)
80
+ const existingDocs = REQUIRED_SCAN_DOCS.filter(f => existsSync(join(specScanDir, f)))
81
+ const docsMissingHeader = []
82
+ for (const doc of existingDocs) {
83
+ const content = readFileSync(join(specScanDir, doc), 'utf8')
84
+ if (!content.includes('author') || !content.includes('created_at')) {
85
+ docsMissingHeader.push(doc)
86
+ }
87
+ }
88
+ if (docsMissingHeader.length > 0) {
89
+ checks.push({
90
+ name: 'docs_missing_header',
91
+ severity: 'warning',
92
+ detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}`
93
+ })
94
+ }
95
+
96
+ // 4. local.yaml 校验
97
+ const localYamlPath = join(specDir, 'local.yaml')
98
+ if (existsSync(localYamlPath)) {
99
+ const yamlContent = readFileSync(localYamlPath, 'utf8')
100
+ const packageJsonPath = join(cwd, 'package.json')
101
+ const invalidCommands = []
102
+
103
+ // 简单提取 local.yaml 中的 commands
104
+ const commandMatch = yamlContent.match(/build:\s*"([^"]+)"/) ||
105
+ yamlContent.match(/test:\s*"([^"]+)"/) ||
106
+ yamlContent.match(/lint:\s*"([^"]+)"/)
107
+
108
+ if (commandMatch) {
109
+ // 提取所有 npm run <script> 形式的命令
110
+ const npmRunCommands = yamlContent.match(/npm run (\S+)/g) || []
111
+ if (npmRunCommands.length > 0 && existsSync(packageJsonPath)) {
112
+ try {
113
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
114
+ const scripts = pkg.scripts || {}
115
+ for (const cmd of npmRunCommands) {
116
+ const scriptName = cmd.replace('npm run ', '')
117
+ if (!scripts[scriptName]) {
118
+ invalidCommands.push(`${cmd} (package.json 无 ${scriptName} script)`)
119
+ }
120
+ }
121
+ } catch {}
122
+ }
123
+ }
124
+
125
+ if (invalidCommands.length > 0) {
126
+ checks.push({
127
+ name: 'local_config_invalid',
128
+ severity: 'warning',
129
+ detail: `local.yaml 引用不存在的命令: ${invalidCommands.join('; ')}`
130
+ })
131
+ }
132
+ }
133
+
134
+ // 5. 检查 AI 输出中的错误标记
135
+ if (outputText) {
136
+ const errorPatterns = [
137
+ { pattern: /tool_use_error/i, name: 'tool_use_error', detail: 'AI 输出中包含 tool_use_error' },
138
+ { pattern: /API Error.*529/i, name: 'api_error_529', detail: 'AI 输出中包含 API Error 529' },
139
+ { pattern: /rate.?limit.*exhausted/i, name: 'rate_limit_exhausted', detail: 'AI 输出中包含 rate_limit exhausted' },
140
+ { pattern: /fallback|retry.*failed|skipped.*validat/i, name: 'fallback_or_skip', detail: 'AI 输出中出现 fallback/retry failed/skipped validation' },
141
+ ]
142
+ for (const ep of errorPatterns) {
143
+ if (ep.pattern.test(outputText)) {
144
+ checks.push({ name: ep.name, severity: 'warning', detail: ep.detail })
145
+ }
146
+ }
147
+ }
148
+
149
+ // 6. 计算 finalStatus
150
+ const hasFailed = checks.some(c => c.severity === 'failed')
151
+ const hasWarning = checks.some(c => c.severity === 'warning')
152
+
153
+ let status
154
+ if (hasFailed) {
155
+ status = 'failed_post_check'
156
+ } else if (hasWarning) {
157
+ status = 'completed_with_warnings'
158
+ } else {
159
+ status = 'success'
160
+ }
161
+
162
+ return { status, checks }
163
+ }
164
+
165
+ /**
166
+ * 打印 post-check 结果到 stdout
167
+ */
168
+ export function printScanPostCheckResult(result) {
169
+ if (result.checks.length === 0) {
170
+ console.log(' ✅ CLI post-check: 全部通过')
171
+ return
172
+ }
173
+
174
+ for (const check of result.checks) {
175
+ const icon = check.severity === 'failed' ? '❌' : '⚠️'
176
+ console.log(` ${icon} CLI post-check [${check.name}]: ${check.detail}`)
177
+ }
178
+ console.log(` 📋 最终状态: ${result.status}`)
179
+ }