sillyspec 3.18.2 → 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 (35) hide show
  1. package/docs/brainstorm-plan-contract.md +64 -0
  2. package/docs/plan-execute-contract.md +123 -0
  3. package/docs/revision-mode.md +115 -0
  4. package/docs/sillyspec/file-lifecycle.md +13 -4
  5. package/docs/workflow-contract-regression.md +106 -0
  6. package/package.json +1 -1
  7. package/packages/dashboard/dist/assets/{index-DpLHK4jv.js → index-Bq_Z2hne.js} +568 -568
  8. package/packages/dashboard/dist/assets/{index-BcM2J-hv.css → index-O2W5RV4z.css} +1 -1
  9. package/packages/dashboard/dist/index.html +16 -16
  10. package/packages/dashboard/src/components/PipelineStage.vue +22 -2
  11. package/packages/dashboard/src/components/PipelineView.vue +10 -2
  12. package/packages/dashboard/src/components/StageBadge.vue +17 -3
  13. package/packages/dashboard/src/components/StepCard.vue +7 -2
  14. package/src/change-risk-profile.js +167 -0
  15. package/src/db.js +6 -0
  16. package/src/index.js +17 -1
  17. package/src/knowledge-match.js +130 -0
  18. package/src/progress.js +464 -11
  19. package/src/run.js +200 -3
  20. package/src/scan-postcheck.js +34 -2
  21. package/src/stage-contract.js +86 -6
  22. package/src/stages/brainstorm.js +23 -0
  23. package/src/stages/execute.js +110 -2
  24. package/src/stages/plan.js +82 -0
  25. package/src/stages/scan.js +40 -0
  26. package/src/stages/verify.js +38 -2
  27. package/test/brainstorm-plan-contract.test.mjs +273 -0
  28. package/test/knowledge-match.test.mjs +231 -0
  29. package/test/plan-execute-contract.test.mjs +330 -0
  30. package/test/platform-failure-samples.test.mjs +4 -0
  31. package/test/revision-v1.test.mjs +1145 -0
  32. package/test/scan-knowledge.test.mjs +175 -0
  33. package/test/scan-postcheck.test.mjs +3 -0
  34. package/test/spec-dir.test.mjs +8 -3
  35. 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) {
@@ -1358,6 +1476,13 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1358
1476
 
1359
1477
  let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
1360
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
+
1361
1486
  // ── scanProfile: 根据 project 规模动态裁剪步骤 ──
1362
1487
  let scanProfile = null
1363
1488
  if (stageName === 'scan' && steps.length > 0 && currentIdx === 0) {
@@ -1453,6 +1578,29 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1453
1578
  console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
1454
1579
  }
1455
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
+
1456
1604
  const defSteps = await getStageSteps(stageName, cwd, progress)
1457
1605
  if (defSteps && defSteps[currentIdx]) {
1458
1606
  // noAI 步骤自动完成(CLI-only,不需要 Agent 参与)
@@ -2336,6 +2484,34 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2336
2484
  console.warn(` - ${w}`)
2337
2485
  }
2338
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
+ }
2339
2515
  } else if (actualCompleted < actualTotal) {
2340
2516
  // 实际步骤未全部完成,跳过 validator(状态可能不同步)
2341
2517
  console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
@@ -2509,8 +2685,12 @@ function showStatus(progress, stageName) {
2509
2685
  const stageDef = stageRegistry[stageName]
2510
2686
 
2511
2687
  if (!stageData || !stageData.steps || stageData.steps.length === 0) {
2512
- console.log(`阶段:${stageName}(${stageDef.title})`)
2688
+ console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
2513
2689
  console.log(`进度:未初始化`)
2690
+ if (stageData?.status) {
2691
+ console.log(`状态:${stageData.status}`)
2692
+ if (stageData.staleReason) console.log(`⚠️ 失效原因:${stageData.staleReason}`)
2693
+ }
2514
2694
  return
2515
2695
  }
2516
2696
 
@@ -2518,8 +2698,25 @@ function showStatus(progress, stageName) {
2518
2698
  const completed = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
2519
2699
  const bar = '█'.repeat(completed) + '░'.repeat(steps.length - completed)
2520
2700
 
2521
- console.log(`阶段:${stageName}(${stageDef.title})`)
2522
- 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('')
2523
2720
 
2524
2721
  const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
2525
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,7 +600,7 @@ export function checkTransition(fromStage, toStage) {
520
600
  return { allowed: true }
521
601
  }
522
602
 
523
- // 同阶段内重复运行:允许(继续执行当前阶段的下一步)
603
+ // 同阶段内重复运行:允许(继续执行当前阶段的下一步、或修订模式继续)
524
604
  if (fromStage === toStage) {
525
605
  return { allowed: true }
526
606
  }
@@ -537,7 +617,7 @@ export function checkTransition(fromStage, toStage) {
537
617
  return { allowed: false, reason: 'archive 的前置阶段是 verify,不能从 ' + fromStage + ' 跳转' }
538
618
  }
539
619
 
540
- // 从辅助阶段进入主流程:允许(用户可能 scan 完直接 brainstorm 或 plan)
620
+ // 从辅助阶段进入主流程:允许
541
621
  if (auxiliaryStages.includes(fromStage)) {
542
622
  return { allowed: true }
543
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
 
@@ -1,6 +1,86 @@
1
1
  import { existsSync, readFileSync } from 'fs'
2
2
  import path from 'path'
3
3
 
4
+ /**
5
+ * 校验 plan.md 是否满足 execute 执行契约
6
+ * @param {string} planContent - plan.md 文件内容
7
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], tasks: object[], waves: object[] }}
8
+ */
9
+ export function validatePlanForExecute(planContent) {
10
+ const errors = []
11
+ const warnings = []
12
+
13
+ if (!planContent || !planContent.trim()) {
14
+ return { ok: false, errors: ['plan.md 内容为空'], warnings, tasks: [], waves: [] }
15
+ }
16
+
17
+ const waves = parseWavesFromPlan(planContent)
18
+
19
+ // 收集所有 task
20
+ const allTasks = []
21
+ for (const wave of waves) {
22
+ for (const task of wave.tasks) {
23
+ allTasks.push(task)
24
+ }
25
+ }
26
+
27
+ // 检查 1: 至少有一个 checkbox task
28
+ if (allTasks.length === 0) {
29
+ errors.push('plan.md 中没有找到 checkbox task(格式: "- [ ] task-XX: 任务名")')
30
+ return { ok: false, errors, warnings, tasks: allTasks, waves }
31
+ }
32
+
33
+ // 检查 2: task id 唯一性
34
+ const idCounts = {}
35
+ for (const task of allTasks) {
36
+ if (task.index != null) {
37
+ const key = `task-${task.index}`
38
+ idCounts[key] = (idCounts[key] || 0) + 1
39
+ }
40
+ }
41
+ for (const [id, count] of Object.entries(idCounts)) {
42
+ if (count > 1) {
43
+ errors.push(`task id 重复: ${id} 出现 ${count} 次`)
44
+ }
45
+ }
46
+
47
+ // 检查 3: task id 连续性(从 1 开始)
48
+ const ids = allTasks
49
+ .map(t => t.index)
50
+ .filter(i => i != null)
51
+ .sort((a, b) => a - b)
52
+ if (ids.length > 0) {
53
+ const expected = Array.from({ length: ids.length }, (_, i) => ids[0] + i)
54
+ // 只检查以 task-01 起始的情况(常见模式)
55
+ if (ids[0] === 1) {
56
+ for (let i = 0; i < ids.length; i++) {
57
+ if (ids[i] !== i + 1) {
58
+ errors.push(`task id 不连续: 期望 task-${String(i + 1).padStart(2, '0')}, 实际 task-${String(ids[i]).padStart(2, '0')}`)
59
+ break
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ // 检查 4: task name 非空
66
+ for (const task of allTasks) {
67
+ if (!task.name || !task.name.trim()) {
68
+ errors.push(`task-${String(task.index || '?').padStart(2, '0')}: 任务名为空`)
69
+ }
70
+ }
71
+
72
+ // 检查 5: task 无 id 的 warning(不限制只在有 id 时检查)
73
+ for (const wave of waves) {
74
+ for (const task of wave.tasks) {
75
+ if (task.index == null) {
76
+ warnings.push(`Wave ${wave.index}: task "${task.name}" 没有 task id(建议格式 task-XX: 名称)`)
77
+ }
78
+ }
79
+ }
80
+
81
+ return { ok: errors.length === 0, errors, warnings, tasks: allTasks, waves }
82
+ }
83
+
4
84
  export const definition = {
5
85
  name: 'execute',
6
86
  title: '波次执行',
@@ -46,6 +126,27 @@ const fixedPrefix = [
46
126
  - 用 main_symbols 字段找到核心类/函数的定义位置
47
127
  - 子代理优先读模块卡片理解语义,再读 entrypoints/main_symbols 对应的源码
48
128
 
129
+ ### 符号影响面扩展检查
130
+ 11. **符号影响面扫描**(Critical — execute 前必做):
131
+ - 读取所有 tasks/task-NN.md,提取每个任务涉及的修改文件
132
+ - 对每个修改文件,检查是否涉及以下变更类型:
133
+ - class 构造函数参数变更(新增/删除/修改参数)
134
+ - 接口(interface)定义变更
135
+ - DTO / 类型定义变更
136
+ - API client 方法签名变更
137
+ - 函数/方法签名变更(参数增删改)
138
+ - 如果涉及上述变更类型,执行调用点搜索:
139
+ \`\`\`bash
140
+ rg "new ClassName\(" src/
141
+ rg "ClassName\(" src/
142
+ rg "methodName\(" src/
143
+ rg "import.*from.*filePath" src/
144
+ \`\`\`
145
+ - 将搜索到的调用点与 plan.md 和 tasks/task-NN.md 的 allowed_paths 对比
146
+ - **发现调用点不在任何 task 的 allowed_paths 中 → 直接阻断 execute**
147
+ - 报告:列出每个受影响符号、调用点位置、是否在任务范围内
148
+ - 如果调用点不在范围内但任务明确写了"不改原因",记录但不阻断
149
+
49
150
  ### 输出
50
151
  已加载的上下文摘要(含模块文档 + 源码锚点)`,
51
152
  outputHint: '上下文摘要',
@@ -91,6 +192,12 @@ sillyspec run execute --done --output "worktree 路径 + 分支名 + 模式"`,
91
192
  - auto — 全部自动执行
92
193
  5. 查询知识库:读取 \`.sillyspec/knowledge/INDEX.md\`,根据 Task 关键词匹配
93
194
 
195
+ ### 知识命中报告
196
+ {KNOWLEDGE_HIT_REPORT}
197
+
198
+ 如上所示的知识条目与本次任务相关。请阅读这些条目以获取项目约定和已知模式。
199
+ 如无命中条目(Status: no matches),跳过本节。
200
+
94
201
  ### 铁律
95
202
  - **不要询问用户确认频率**,确认模式由 CLI \`--confirm-mode\` 参数决定
96
203
  - 如果未检测到 \`--confirm-mode\`,默认使用 wave 模式`,
@@ -403,12 +510,13 @@ export function buildExecuteSteps(planFilePath = null, options = {}) {
403
510
 
404
511
  if (planFilePath && existsSync(planFilePath)) {
405
512
  const planContent = readFileSync(planFilePath, 'utf8')
513
+ // Plan → Execute 契约由 plan 阶段完成时的 postcheck 把关(run.js completeStep),
514
+ // 此处只负责解析 waves,避免 buildExecuteSteps 与进程退出耦合。
406
515
  waves = parseWavesFromPlan(planContent)
407
- // 从 planFilePath 推导 changeDir: .sillyspec/changes/<name>/plan.md
408
516
  changeDir = path.dirname(planFilePath)
409
517
  }
410
518
 
411
- // 如果没解析出 Wave,生成默认 3
519
+ // 没解析出 Wave(plan 不存在或不含可识别 task)→ 默认 3 Wave(向后兼容)
412
520
  if (!waves || waves.length === 0) {
413
521
  waves = []
414
522
  for (let i = 1; i <= 3; i++) {