sillyspec 3.18.1 → 3.18.3

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.
Files changed (37) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +24 -23
  2. package/.claude/skills/sillyspec-execute/SKILL.md +8 -1
  3. package/docs/brainstorm-plan-contract.md +64 -0
  4. package/docs/plan-execute-contract.md +123 -0
  5. package/docs/revision-mode.md +115 -0
  6. package/docs/sillyspec/file-lifecycle.md +13 -4
  7. package/docs/workflow-contract-regression.md +106 -0
  8. package/package.json +1 -1
  9. package/packages/dashboard/dist/assets/{index-DpLHK4jv.js → index-Bq_Z2hne.js} +568 -568
  10. package/packages/dashboard/dist/assets/{index-BcM2J-hv.css → index-O2W5RV4z.css} +1 -1
  11. package/packages/dashboard/dist/index.html +16 -16
  12. package/packages/dashboard/src/components/PipelineStage.vue +22 -2
  13. package/packages/dashboard/src/components/PipelineView.vue +10 -2
  14. package/packages/dashboard/src/components/StageBadge.vue +17 -3
  15. package/packages/dashboard/src/components/StepCard.vue +7 -2
  16. package/src/change-risk-profile.js +167 -0
  17. package/src/db.js +6 -0
  18. package/src/index.js +17 -1
  19. package/src/knowledge-match.js +130 -0
  20. package/src/progress.js +464 -11
  21. package/src/run.js +269 -29
  22. package/src/scan-postcheck.js +34 -2
  23. package/src/stage-contract.js +90 -5
  24. package/src/stages/brainstorm.js +23 -0
  25. package/src/stages/execute.js +122 -16
  26. package/src/stages/plan.js +82 -0
  27. package/src/stages/scan.js +40 -0
  28. package/src/stages/verify.js +38 -2
  29. package/test/brainstorm-plan-contract.test.mjs +273 -0
  30. package/test/knowledge-match.test.mjs +231 -0
  31. package/test/plan-execute-contract.test.mjs +330 -0
  32. package/test/platform-failure-samples.test.mjs +4 -0
  33. package/test/revision-v1.test.mjs +1145 -0
  34. package/test/scan-knowledge.test.mjs +175 -0
  35. package/test/scan-postcheck.test.mjs +3 -0
  36. package/test/spec-dir.test.mjs +8 -3
  37. package/test/stage-definitions.test.mjs +1 -1
package/src/run.js CHANGED
@@ -547,6 +547,18 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
547
547
  const total = steps.length
548
548
  const projectName = dbProjectName || basename(cwd)
549
549
 
550
+ // ── Revision context injection ──
551
+ const revisionCtx = platformOpts?._revision
552
+ if (revisionCtx) {
553
+ console.log(`### 🔄 Revision Context`)
554
+ console.log(`本阶段处于修订模式(revision ${revisionCtx.revision}),不是首次执行。`)
555
+ console.log(`- 修订起始步骤:${revisionCtx.fromStep}`)
556
+ console.log(`- 当前步骤之前已完成的步骤仍然有效,不需要重做。`)
557
+ console.log(`- 当前步骤及之后的步骤需要重新生成或调整已有产物。`)
558
+ console.log(`- 已有产物文件(design.md、plan.md 等)被保留,审视并更新它们,而不是从零创建。`)
559
+ console.log(`- 不要绕过 CLI 进度追踪。\n`)
560
+ }
561
+
550
562
  const personas = {
551
563
  brainstorm: `### 🎯 你的角色:资深架构师
552
564
  你是一位有 15 年经验的系统架构师。先理解业务本质,再设计技术方案。决策附理由,方案列 trade-off。不确定就说不确定,不猜。`,
@@ -708,6 +720,36 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
708
720
  promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
709
721
  }
710
722
 
723
+ // Knowledge hit report: execute 阶段注入匹配结果
724
+ if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
725
+ try {
726
+ const { matchKnowledge } = await import('./knowledge-match.js')
727
+ const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
728
+ const knowledgeDir = join(effectiveSpecBase, 'knowledge')
729
+ // taskContext: changeName + plan.md task names for better matching
730
+ let taskContext = changeName || ''
731
+ if (changeName) {
732
+ const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
733
+ try {
734
+ const planContent = readFileSync(planPath, 'utf8')
735
+ // match both "- [ ] task-01: title" and "## task-01: title" formats
736
+ const taskLines = [...planContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
737
+ if (taskLines.length > 0) {
738
+ taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
739
+ }
740
+ } catch {}
741
+ }
742
+ const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
743
+ promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
744
+ // 写入 runtime JSON
745
+ const runtimeDir = join(effectiveSpecBase, '.runtime')
746
+ mkdirSync(runtimeDir, { recursive: true })
747
+ writeFileSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeResult.json, null, 2) + '\n')
748
+ } catch (e) {
749
+ promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
750
+ }
751
+ }
752
+
711
753
  // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
712
754
  if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
713
755
  const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
@@ -1016,6 +1058,8 @@ export async function runCommand(args, cwd, specDir = null) {
1016
1058
  const isSkip = flags.includes('--skip')
1017
1059
  const isStatus = flags.includes('--status')
1018
1060
  const isReset = flags.includes('--reset')
1061
+ const isReopen = flags.includes('--reopen')
1062
+ const fromStepValue = getFlagValue('--from-step')
1019
1063
  const isConfirm = flags.includes('--confirm')
1020
1064
  const isSkipApproval = flags.includes('--skip-approval')
1021
1065
  const isWait = flags.includes('--wait')
@@ -1153,6 +1197,7 @@ export async function runCommand(args, cwd, specDir = null) {
1153
1197
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
1154
1198
  '--files', '--allow-new', '--force-baseline', '--force-rescan',
1155
1199
  '--json', '--dir', '--help',
1200
+ '--reopen', '--from-step',
1156
1201
  ])
1157
1202
  for (let i = 0; i < flags.length; i++) {
1158
1203
  const f = flags[i]
@@ -1223,6 +1268,79 @@ export async function runCommand(args, cwd, specDir = null) {
1223
1268
  return await resetStage(pm, progress, stageName, cwd, effectiveChange, platformOpts)
1224
1269
  }
1225
1270
 
1271
+ // ── 规则 1:completed 阶段直接 run,拒绝(但 --status 放行)──
1272
+ const stageStatus = progress.stages[stageName]?.status
1273
+ if (stageStatus === 'completed' && !isReopen && !isStatus) {
1274
+ console.error(`\n❌ ${stageName} 阶段已完成。`)
1275
+ console.error(` 使用 --reopen 进行修订,或 --reset 从头开始。`)
1276
+ console.error(` 修订示例: sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
1277
+ process.exit(1)
1278
+ }
1279
+
1280
+ // ── 规则 5:stale 阶段直接 run,拒绝(但 --status / --reset 放行)──
1281
+ if (stageStatus === 'stale' && !isReopen && !isStatus && !isReset) {
1282
+ const staleReason = progress.stages[stageName]?.staleReason || '上游阶段已修订'
1283
+ console.error(`\n⚠️ ${stageName} 阶段已失效(stale)。`)
1284
+ console.error(` 原因:${staleReason}`)
1285
+ console.error(` 使用 --reopen --from-step <步骤> 进行修订,或 --reset 从头开始。`)
1286
+ process.exit(1)
1287
+ }
1288
+
1289
+ // ── --reopen 处理 ──
1290
+ if (isReopen) {
1291
+ // stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
1292
+ const stageDataPre = progress.stages[stageName]
1293
+ const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
1294
+ // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
1295
+ if (needsInit || stageName === 'execute') {
1296
+ const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
1297
+ if (freshSteps && freshSteps.length > 0) {
1298
+ if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
1299
+ progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
1300
+ await pm._write(cwd, progress, effectiveChange)
1301
+ progress = await pm.read(cwd, effectiveChange) || progress
1302
+ }
1303
+ }
1304
+
1305
+ const result = await pm.reopenStage(cwd, stageName, {
1306
+ fromStep: fromStepValue,
1307
+ changeName: effectiveChange,
1308
+ })
1309
+ if (!result.ok) {
1310
+ console.error(`\n❌ ${result.error}`)
1311
+ if (stageStatus === 'completed') {
1312
+ console.error(`\n 提示:sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
1313
+ }
1314
+ process.exit(1)
1315
+ }
1316
+ console.log(`\n🔧 ${stageName} 阶段已重新打开(revision ${result.revision})`)
1317
+ console.log(` 从步骤「${result.fromStep}」开始修订`)
1318
+ console.log(` 该步骤及之后的产出需要重新生成。`)
1319
+ if (stageName === 'execute') console.log(` ⚡ execute 步骤已从最新 plan.md 重新解析。`)
1320
+ console.log('')
1321
+
1322
+ // 重新读取 progress
1323
+ progress = await pm.read(cwd, effectiveChange) || progress
1324
+
1325
+ // 注入 revision context 到 platformOpts,供 outputStep 使用
1326
+ const stageData = progress.stages[stageName]
1327
+ if (stageData && stageData.revision > 0) {
1328
+ platformOpts._revision = {
1329
+ revision: stageData.revision,
1330
+ fromStep: stageData.reopenedFromStep,
1331
+ }
1332
+ }
1333
+ } else {
1334
+ // 非 reopen 的正常执行:如果阶段处于 revising 状态,也注入 revision context
1335
+ const revStageData = progress.stages[stageName]
1336
+ if (revStageData && revStageData.status === 'revising' && revStageData.revision > 0 && !platformOpts._revision) {
1337
+ platformOpts._revision = {
1338
+ revision: revStageData.revision,
1339
+ fromStep: revStageData.reopenedFromStep,
1340
+ }
1341
+ }
1342
+ }
1343
+
1226
1344
  // 确保步骤已初始化
1227
1345
  const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
1228
1346
  if (changed && effectiveChange) {
@@ -1253,7 +1371,8 @@ export async function runCommand(args, cwd, specDir = null) {
1253
1371
 
1254
1372
  // --done
1255
1373
  if (isDone) {
1256
- return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode })
1374
+ const doneAnswer = getFlagValue('--answer')
1375
+ return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer })
1257
1376
  }
1258
1377
 
1259
1378
  // 默认:输出当前步骤
@@ -1301,6 +1420,26 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1301
1420
  }
1302
1421
  }
1303
1422
 
1423
+ // execute 阶段:CLI 自动创建 worktree(不等 AI agent)
1424
+ if (stageName === 'execute' && changeName) {
1425
+ const effectiveChange = changeName
1426
+ const { WorktreeManager } = await import('./worktree.js')
1427
+ const wm = new WorktreeManager({ cwd })
1428
+ const existingMeta = wm.getMeta(effectiveChange)
1429
+ if (existingMeta) {
1430
+ console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
1431
+ } else {
1432
+ try {
1433
+ const result = wm.create(effectiveChange)
1434
+ console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
1435
+ } catch (e) {
1436
+ console.error(`❌ worktree 创建失败: ${e.message}`)
1437
+ console.error(` 继续执行前请解决上述问题,或使用 --no-worktree 跳过。`)
1438
+ process.exit(1)
1439
+ }
1440
+ }
1441
+ }
1442
+
1304
1443
  // 自动探测 currentChange
1305
1444
  if (autoDetectChange(progress, cwd)) {
1306
1445
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
@@ -1337,6 +1476,13 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1337
1476
 
1338
1477
  let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
1339
1478
 
1479
+ // stale 步骤视为可执行(等同于 pending)
1480
+ if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
1481
+ steps[currentIdx].status = 'pending'
1482
+ await pm._write(cwd, progress, changeName)
1483
+ triggerSync(cwd, changeName, platformOpts)
1484
+ }
1485
+
1340
1486
  // ── scanProfile: 根据 project 规模动态裁剪步骤 ──
1341
1487
  let scanProfile = null
1342
1488
  if (stageName === 'scan' && steps.length > 0 && currentIdx === 0) {
@@ -1432,6 +1578,29 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1432
1578
  console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
1433
1579
  }
1434
1580
 
1581
+ // ── Brainstorm → Plan Contract:plan 启动前校验 design.md ──
1582
+ if (stageName === 'plan' && currentIdx === 0) {
1583
+ const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
1584
+ const designPath = changeDir ? join(changeDir, 'design.md') : null
1585
+ if (designPath && existsSync(designPath)) {
1586
+ const { validateDesignForPlan } = await import('./stages/plan.js')
1587
+ const designContent = readFileSync(designPath, 'utf8')
1588
+ const designValidation = validateDesignForPlan(designContent)
1589
+ if (!designValidation.ok) {
1590
+ console.error(`\n❌ Brainstorm → Plan Contract 校验失败:`)
1591
+ for (const err of designValidation.errors) console.error(` - ${err}`)
1592
+ console.error(`\n design.md 不满足 plan 契约,请先修复后重试。`)
1593
+ console.error(` 提示:sillyspec run brainstorm --reopen --from-step <步骤> 修订设计文档`)
1594
+ process.exit(1)
1595
+ }
1596
+ if (designValidation.warnings.length > 0) {
1597
+ console.log(`⚠️ Design contract 警告(不阻断):`)
1598
+ for (const w of designValidation.warnings) console.log(` - ${w}`)
1599
+ console.log()
1600
+ }
1601
+ }
1602
+ }
1603
+
1435
1604
  const defSteps = await getStageSteps(stageName, cwd, progress)
1436
1605
  if (defSteps && defSteps[currentIdx]) {
1437
1606
  // noAI 步骤自动完成(CLI-only,不需要 Agent 参与)
@@ -1832,14 +2001,24 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
1832
2001
  const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
1833
2002
  const currentStep = steps[currentIdx]
1834
2003
  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(', ')}`)
2004
+ // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
2005
+ const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
2006
+ if (doneAnswer) {
2007
+ currentStep.status = 'waiting'
2008
+ currentStep.waitAnswer = doneAnswer
2009
+ currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
2010
+ console.log(`⚠️ Step "${currentStep.name}" 需要 wait,但 --done 带了 --answer,自动补全 wait 状态。`)
2011
+ } else {
2012
+ console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
2013
+ console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
2014
+ if (currentStepDef.waitOptions) {
2015
+ console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
2016
+ }
2017
+ console.error(` 请先执行:`)
2018
+ console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
2019
+ console.error(` 或使用 --done --answer "用户回答" 一步完成 wait + done`)
2020
+ process.exit(1)
1839
2021
  }
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
2022
  }
1844
2023
 
1845
2024
  steps[currentIdx].status = 'completed'
@@ -2234,10 +2413,16 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2234
2413
  }
2235
2414
  }
2236
2415
 
2416
+ // 防御性守卫变量:确认所有步骤确实标记为 completed
2417
+ const actualCompleted = steps.filter(s => s.status === 'completed').length
2418
+ const actualTotal = steps.length
2419
+
2237
2420
  validateMetadata(cwd, stageName, specBase)
2238
2421
 
2239
- // 验证关键文件是否在正确的变更目录下
2240
- validateFileLocations(cwd, stageName, progress, changeName, specBase)
2422
+ // 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
2423
+ if (actualCompleted === actualTotal && actualTotal > 0) {
2424
+ validateFileLocations(cwd, stageName, progress, changeName, specBase)
2425
+ }
2241
2426
 
2242
2427
  // 辅助阶段完成后重置步骤
2243
2428
  const stageDef = stageRegistry[stageName]
@@ -2281,24 +2466,58 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2281
2466
  } else {
2282
2467
  console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
2283
2468
  }
2284
- return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2285
- }
2286
2469
 
2287
- // 阶段完成校验
2288
- const projectName = progress.project || basename(cwd)
2289
- const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2290
- if (contractResult.errors.length > 0) {
2291
- console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
2292
- for (const err of contractResult.errors) {
2293
- console.error(` - ${err}`)
2294
- }
2295
- console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
2296
- }
2297
- if (contractResult.warnings.length > 0) {
2298
- console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
2299
- for (const w of contractResult.warnings) {
2300
- console.warn(` - ${w}`)
2470
+ // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
2471
+ if (actualCompleted === actualTotal && actualTotal > 0) {
2472
+ const projectName = progress.project || basename(cwd)
2473
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2474
+ if (contractResult.errors.length > 0) {
2475
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
2476
+ for (const err of contractResult.errors) {
2477
+ console.error(` - ${err}`)
2478
+ }
2479
+ console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
2480
+ }
2481
+ if (contractResult.warnings.length > 0) {
2482
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
2483
+ for (const w of contractResult.warnings) {
2484
+ console.warn(` - ${w}`)
2485
+ }
2486
+ }
2487
+
2488
+ // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
2489
+ if (stageName === 'plan') {
2490
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
2491
+ const planPath = planFile ? join(planFile, 'plan.md') : null
2492
+ if (planPath && existsSync(planPath)) {
2493
+ const { validatePlanForExecute } = await import('./stages/execute.js')
2494
+ const planContent = readFileSync(planPath, 'utf8')
2495
+ const planValidation = validatePlanForExecute(planContent)
2496
+ if (!planValidation.ok) {
2497
+ console.error(`\n❌ Plan → Execute Contract 校验失败:`)
2498
+ for (const err of planValidation.errors) console.error(` - ${err}`)
2499
+ console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
2500
+ // 阻断 completed
2501
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2502
+ await pm._write(cwd, progress, changeName)
2503
+ triggerSync(cwd, changeName, platformOpts)
2504
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2505
+ }
2506
+ if (planValidation.warnings.length > 0) {
2507
+ console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
2508
+ for (const w of planValidation.warnings) console.warn(` - ${w}`)
2509
+ }
2510
+ if (planValidation.ok) {
2511
+ console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
2512
+ }
2513
+ }
2514
+ }
2515
+ } else if (actualCompleted < actualTotal) {
2516
+ // 实际步骤未全部完成,跳过 validator(状态可能不同步)
2517
+ console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
2301
2518
  }
2519
+
2520
+ return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2302
2521
  }
2303
2522
 
2304
2523
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
@@ -2466,8 +2685,12 @@ function showStatus(progress, stageName) {
2466
2685
  const stageDef = stageRegistry[stageName]
2467
2686
 
2468
2687
  if (!stageData || !stageData.steps || stageData.steps.length === 0) {
2469
- console.log(`阶段:${stageName}(${stageDef.title})`)
2688
+ console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
2470
2689
  console.log(`进度:未初始化`)
2690
+ if (stageData?.status) {
2691
+ console.log(`状态:${stageData.status}`)
2692
+ if (stageData.staleReason) console.log(`⚠️ 失效原因:${stageData.staleReason}`)
2693
+ }
2471
2694
  return
2472
2695
  }
2473
2696
 
@@ -2475,8 +2698,25 @@ function showStatus(progress, stageName) {
2475
2698
  const completed = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
2476
2699
  const bar = '█'.repeat(completed) + '░'.repeat(steps.length - completed)
2477
2700
 
2478
- console.log(`阶段:${stageName}(${stageDef.title})`)
2479
- console.log(`进度:[${bar}] ${completed}/${steps.length}\n`)
2701
+ console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
2702
+ console.log(`进度:[${bar}] ${completed}/${steps.length}`)
2703
+
2704
+ // ── Revision v1 信息 ──
2705
+ if (stageData.status === 'revising') {
2706
+ console.log(`\n🔧 修订中 (revision ${stageData.revision || 1})`)
2707
+ if (stageData.reopenedFromStep) console.log(` 从步骤:${stageData.reopenedFromStep}`)
2708
+ if (stageData.reopenedAt) console.log(` 重开时间:${stageData.reopenedAt}`)
2709
+ }
2710
+ if (stageData.status === 'stale') {
2711
+ console.log(`\n⚠️ 已失效`)
2712
+ if (stageData.staleReason) console.log(` 原因:${stageData.staleReason}`)
2713
+ console.log(` 建议:sillyspec run ${stageName} --reopen --from-step 1`)
2714
+ }
2715
+ if (stageData.status === 'completed') {
2716
+ console.log(`\n✅ 已完成`)
2717
+ }
2718
+
2719
+ console.log('')
2480
2720
 
2481
2721
  const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
2482
2722
 
@@ -95,12 +95,13 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
95
95
  })
96
96
  }
97
97
 
98
- // 3. 检查文档 header(author / created_at
98
+ // 3. 检查文档 header(author / created_at)— 只看文件头部,避免正文出现同名词被误判
99
99
  const existingDocs = REQUIRED_SCAN_DOCS.filter(f => existsSync(join(specScanDir, f)))
100
100
  const docsMissingHeader = []
101
101
  for (const doc of existingDocs) {
102
102
  const content = readFileSync(join(specScanDir, doc), 'utf8')
103
- if (!content.includes('author') || !content.includes('created_at')) {
103
+ const headerSlice = content.slice(0, 512)
104
+ if (!/author\s*:/.test(headerSlice) || !/created_at\s*:/.test(headerSlice)) {
104
105
  docsMissingHeader.push(doc)
105
106
  }
106
107
  }
@@ -183,6 +184,37 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
183
184
  })
184
185
  }
185
186
 
187
+ // 7.5 knowledge 产物校验
188
+ const knowledgeDir = join(specDir, 'knowledge')
189
+ if (existsSync(knowledgeDir)) {
190
+ const indexPath = join(knowledgeDir, 'INDEX.md')
191
+ if (!existsSync(indexPath)) {
192
+ checks.push({
193
+ name: 'knowledge_index_missing',
194
+ severity: CHECK_SEVERITY.WARNING,
195
+ detail: `knowledge/INDEX.md 不存在`
196
+ })
197
+ } else {
198
+ // 检查 INDEX.md 引用的文件是否真实存在
199
+ const indexContent = readFileSync(indexPath, 'utf8')
200
+ const referencedFiles = [...indexContent.matchAll(/\(([^)]+\.md)/g)].map(m => m[1])
201
+ const missingRefs = referencedFiles.filter(f => !existsSync(join(knowledgeDir, f)))
202
+ if (missingRefs.length > 0) {
203
+ checks.push({
204
+ name: 'knowledge_broken_refs',
205
+ severity: CHECK_SEVERITY.WARNING,
206
+ detail: `INDEX.md 引用了不存在的文件: ${missingRefs.join(', ')}`
207
+ })
208
+ }
209
+ }
210
+ } else {
211
+ checks.push({
212
+ name: 'knowledge_dir_missing',
213
+ severity: CHECK_SEVERITY.WARNING,
214
+ detail: `knowledge/ 目录不存在`
215
+ })
216
+ }
217
+
186
218
  // 8. 计算 finalStatus
187
219
  const hasFailed = checks.some(c => c.severity === CHECK_SEVERITY.FAILED)
188
220
  const hasWarning = checks.some(c => c.severity === CHECK_SEVERITY.WARNING)
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
+ import { detectChangeRisk, checkIntegrationEvidence } from './change-risk-profile.js'
10
11
 
11
12
  /**
12
13
  * 校验结果
@@ -233,6 +234,17 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
233
234
  if (!content.includes('自审') && !content.includes('Self-Review') && !content.includes('Self-review')) {
234
235
  warnings.push('design.md 缺少「自审」章节')
235
236
  }
237
+
238
+ // P1: 涉及生命周期关键词时,design.md 必须包含生命周期契约表
239
+ const hasLifecycleKeyword = /\b(session|lease|agent[._-]?run|daemon|lifecycle|state[._-]?transition|claim|heartbeat)\b/i.test(content)
240
+ if (hasLifecycleKeyword) {
241
+ const hasLifecycleTable =
242
+ /生命周期契约表|lifecycle[._-]?contract|lifecycle[._-]?matrix|Lifecycle Contract/i.test(content) ||
243
+ /事件.*发起方.*接收方.*必需字段.*状态变化/.test(content)
244
+ if (!hasLifecycleTable) {
245
+ errors.push('design.md 涉及生命周期关键词(session/lease/agent_run/daemon/lifecycle)但缺少「生命周期契约表」— 必须列出完整的事件×状态转换矩阵')
246
+ }
247
+ }
236
248
  }
237
249
 
238
250
  if (existsSync(join(changeDir, 'tasks.md'))) {
@@ -294,12 +306,62 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
294
306
  const decisionIds = extractCurrentDecisionIds(decisions)
295
307
  warnMissingIds(warnings, decisionIds, plan, 'plan.md', 'decisions.md')
296
308
  }
309
+ // ── P0: 生产接线路径检查:design 提到入口但 task 的 allowed_paths 不含入口文件 ──
310
+ const designContent = readIfExists(join(changeDir, 'design.md'))
311
+ if (designContent) {
312
+ const entryPointPatterns = [
313
+ /\b(cli\.ts|main\.ts|server\.(?:js|ts)|index\.(?:js|ts))\b.*\b(?:实例化|instantiate|构造|new\s)/gi,
314
+ /\bnew\s+(Daemon|SessionManager|App|Server|Application)\b/gi,
315
+ /\b(?:在|from)\s+['"]?(cli\.ts|main\.ts|server\.(?:js|ts)|index\.(?:js|ts))['"]?/gi,
316
+ /\b(?:注入|inject)\b.*\b(?:构造|constructor|初始化|init|实例化|instantiate)\b/gi,
317
+ /\b(?:启动路径|startup|entrypoint|bootstrap|daemon[._-]?start|main.*entry)\b/gi,
318
+ ]
319
+ const mentionedFiles = new Set()
320
+ for (const pattern of entryPointPatterns) {
321
+ pattern.lastIndex = 0
322
+ for (const match of designContent.matchAll(pattern)) {
323
+ const fileMatch = match[0].match(/\b(cli\.ts|main\.ts|server\.(?:js|ts)|index\.(?:js|ts))\b/i)
324
+ if (fileMatch) mentionedFiles.add(fileMatch[1].toLowerCase())
325
+ }
326
+ }
327
+ if (mentionedFiles.size > 0) {
328
+ const tasksDir = join(changeDir, 'tasks')
329
+ const allAllowedPaths = new Set()
330
+ if (existsSync(tasksDir)) {
331
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/i.test(f))
332
+ for (const taskFile of taskFiles) {
333
+ const taskContent = readFileSync(join(tasksDir, taskFile), 'utf8')
334
+ const allowedSection = taskContent.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/)
335
+ if (allowedSection) {
336
+ const paths = allowedSection[1].match(/-\s+(.+)/g) || []
337
+ for (const p of paths) allAllowedPaths.add(p.replace(/^-\s+/, '').trim().toLowerCase())
338
+ }
339
+ }
340
+ }
341
+ // 也从 plan.md 文件变更清单中收集
342
+ if (existsSync(planFile)) {
343
+ const planContent = readFileSync(planFile, 'utf8')
344
+ const planFileChanges = planContent.match(/\|\s*(?:新增|修改|new|modify|update)\s*\|\s*`?([^`|]+)`?\s*\|/gi) || []
345
+ for (const line of planFileChanges) {
346
+ const file = line.match(/\|\s*(?:新增|修改|new|modify|update)\s*\|\s*`?([^`|]+)`?\s*\|/i)
347
+ if (file) allAllowedPaths.add(file[1].trim().toLowerCase())
348
+ }
349
+ }
350
+ for (const mentionedFile of mentionedFiles) {
351
+ const found = [...allAllowedPaths].some(p => p.includes(mentionedFile))
352
+ if (!found) {
353
+ const noChangePattern = new RegExp(`不需要改.*${mentionedFile}|${mentionedFile}.*不需要|不修改.*${mentionedFile}|${mentionedFile}.*不变|${mentionedFile}.*no.?change`, 'i')
354
+ if (!noChangePattern.test(designContent)) {
355
+ errors.push(`生产接线路径矛盾: design.md 提到了入口文件 "${mentionedFile}" 但所有 task 的 allowed_paths 中均不含该文件`)
356
+ warnings.push(`提示: 如果确实不需要修改 ${mentionedFile},请在 design.md 中明确写明理由`)
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+
297
363
  return { ok: errors.length === 0, errors, warnings }
298
364
  }
299
-
300
- /**
301
- * verify 完成校验:检查变更目录和 verify 产物
302
- */
303
365
  function validateVerifyOutputs(cwd, changeName, context = {}) {
304
366
  const { specRoot } = context
305
367
  const changeDir = resolveChangeDir(cwd, changeName, specRoot)
@@ -334,6 +396,24 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
334
396
  }
335
397
  const decisionIds = extractCurrentDecisionIds(decisions)
336
398
  warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
399
+
400
+ // ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
401
+ const changeRiskProfile = detectChangeRisk({
402
+ designContent: readIfExists(join(changeDir, 'design.md')),
403
+ planContent: readIfExists(join(changeDir, 'plan.md')),
404
+ })
405
+ if (['integration-critical', 'deployment-critical'].includes(changeRiskProfile.level)) {
406
+ const conclusionMatch = verify.match(/^## 结论\s*\n\s*(PASS|PASS WITH NOTES|FAIL)/im)
407
+ const conclusion = conclusionMatch ? conclusionMatch[1] : ''
408
+ if (conclusion === 'PASS WITH NOTES' || conclusion === 'PASS') {
409
+ const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
410
+ if (!evidenceCheck.ok) {
411
+ errors.push(`[${changeRiskProfile.level}] 验证结论为 ${conclusion},但缺少真实集成证据:${evidenceCheck.errors.join('; ')}`)
412
+ errors.push(`触发词: ${changeRiskProfile.triggers.join(', ')} — PASS WITH NOTES 不被允许,必须 FAIL 或提供集成证据`)
413
+ }
414
+ warnings.push(...evidenceCheck.warnings)
415
+ }
416
+ }
337
417
  }
338
418
 
339
419
  return { ok: errors.length === 0, errors, warnings }
@@ -520,6 +600,11 @@ export function checkTransition(fromStage, toStage) {
520
600
  return { allowed: true }
521
601
  }
522
602
 
603
+ // 同阶段内重复运行:允许(继续执行当前阶段的下一步、或修订模式继续)
604
+ if (fromStage === toStage) {
605
+ return { allowed: true }
606
+ }
607
+
523
608
  // archive 特殊处理:从 verify 来的允许,从其他主流程阶段来的需要校验
524
609
  if (toStage === 'archive') {
525
610
  if (fromStage === 'verify') {
@@ -532,7 +617,7 @@ export function checkTransition(fromStage, toStage) {
532
617
  return { allowed: false, reason: 'archive 的前置阶段是 verify,不能从 ' + fromStage + ' 跳转' }
533
618
  }
534
619
 
535
- // 从辅助阶段进入主流程:允许(用户可能 scan 完直接 brainstorm 或 plan)
620
+ // 从辅助阶段进入主流程:允许
536
621
  if (auxiliaryStages.includes(fromStage)) {
537
622
  return { allowed: true }
538
623
  }
@@ -344,6 +344,28 @@ HTML 原型文件路径(或"跳过"如果不适合)`,
344
344
  | 删除 | src/xxx/OldFile.java | 已被 xx 替代 |
345
345
 
346
346
  7. **接口定义**:方法签名、数据结构(代码类任务必填)
347
+ 7.5. **生命周期契约表**(涉及以下关键词时必填,否则可省略):
348
+
349
+ 如果本次变更涉及以下任何关键词:
350
+ session / lease / agent_run / daemon / lifecycle / state transition / complete / end / claim / heartbeat
351
+
352
+ 则必须在 design.md 中包含「生命周期契约表」章节,格式如下:
353
+
354
+ | 事件 | 发起方 | 接收方 | 必需字段 | 状态变化 |
355
+ |---|---|---|---|---|
356
+ | claim lease | daemon | backend | leaseId, claimToken, agentRunId | pending → running |
357
+ | create session | backend | daemon | sessionId, leaseId, claimToken | session active |
358
+ | submit message | daemon | backend | leaseId, claimToken, agentRunId | append messages |
359
+ | turn result | daemon | backend | runId, status, output | running → completed/failed |
360
+ | session end | daemon | backend | sessionId, reason | active → ended |
361
+
362
+ 判断规则:
363
+ - design.md 或需求中出现上述关键词 → 必须生成此表
364
+ - 表中的每个事件 → 必须有对应代码任务、接口任务、测试任务
365
+ - 表中的必需字段 → 必须出现在相关 DTO/interface 定义中
366
+ - 缺少任一事件 → 在 design.md 风险登记中明确记录
367
+
368
+ **判定方法**:在自审阶段,如果检测到上述关键词但 design.md 中没有此表 → 自审不通过
347
369
  8. **数据模型**(如涉及):表结构/字段变更
348
370
  9. **兼容策略**(brownfield 必填):
349
371
  - 未配置新功能时行为不变
@@ -382,6 +404,7 @@ HTML 原型文件路径(或"跳过"如果不适合)`,
382
404
  - 非目标清晰:是否明确界定了不做的事
383
405
  - 兼容策略(brownfield):是否说明了回退路径
384
406
  - 风险识别:是否识别了关键技术风险和对策
407
+ - **生命周期契约表**(如涉及 session/lease/agent_run/daemon/lifecycle/claim/heartbeat 等关键词):design.md 是否包含完整的生命周期契约表?每个事件是否有必需字段定义?字段是否出现在 DTO/interface 中?
385
408
  5. 自审发现问题 → 修改后重新检查
386
409
  6. 全部通过 → 进入下一步
387
410