sillyspec 3.25.1 → 3.25.2

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 (39) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
  2. package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
  3. package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
  4. package/.playwright-mcp/prompt-final.png +0 -0
  5. package/.playwright-mcp/prompt-view.png +0 -0
  6. package/CLAUDE.md +18 -0
  7. package/package.json +1 -1
  8. package/src/change-risk-profile.js +60 -12
  9. package/src/check-primitives.js +65 -0
  10. package/src/index.js +135 -6
  11. package/src/progress/change-registry.js +217 -0
  12. package/src/progress/consistency-doctor.js +259 -0
  13. package/src/progress/shared.js +33 -0
  14. package/src/progress/stage-machine.js +535 -0
  15. package/src/progress/step-store.js +256 -0
  16. package/src/progress.js +59 -1191
  17. package/src/quicklog.js +3 -1
  18. package/src/run/command.js +127 -50
  19. package/src/run/complete-handlers.js +2 -1
  20. package/src/run/complete.js +725 -695
  21. package/src/run/gates.js +326 -321
  22. package/src/run/prompt.js +619 -604
  23. package/src/run/quick-audit.js +107 -102
  24. package/src/run/shared.js +43 -0
  25. package/src/stage-contract-engine.js +150 -0
  26. package/src/stage-contract-spec.js +517 -0
  27. package/src/stage-contract.js +83 -148
  28. package/src/stage-review.js +63 -0
  29. package/src/stages/brainstorm.js +3 -1
  30. package/src/stages/execute.js +5 -2
  31. package/src/stages/plan-postcheck.js +38 -27
  32. package/src/stages/plan.js +12 -49
  33. package/src/stages/propose.js +2 -1
  34. package/src/stages/scan.js +3 -1
  35. package/src/workflow.js +9 -17
  36. package/src/worktree-apply.js +5 -2
  37. package/src/worktree-deps.js +114 -32
  38. package/src/worktree.js +6 -3
  39. package/src/brainstorm-postcheck.js +0 -158
@@ -8,8 +8,10 @@
8
8
  import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
  import { execFileSync } from 'child_process'
11
- import { detectChangeRisk, checkIntegrationEvidence } from './change-risk-profile.js'
11
+ import { detectChangeRisk, checkIntegrationEvidence, VERIFICATION_NEEDS, RISK_LEVEL_CAUSES } from './change-risk-profile.js'
12
12
  import { SCAN_REQUIRED_DOCS, AUXILIARY_STAGES } from './constants.js'
13
+ import { evaluateRules } from './stage-contract-engine.js'
14
+ import { getRule } from './stage-contract-spec.js'
13
15
 
14
16
  /**
15
17
  * 校验结果
@@ -74,7 +76,10 @@ export function validateChangeExists(specBase, stageName, changeName) {
74
76
  return {
75
77
  changeName,
76
78
  specBase,
77
- message: `变更 "${changeName}" 在当前 spec 下不存在:${join(specBase, 'changes', changeName)}`,
79
+ message: `变更 "${changeName}" 在当前 spec 下不存在:${join(specBase, 'changes', changeName)}\n` +
80
+ ` 排查方向(三选一):① 变更名是否打错(sillyspec status 或 progress show 查看活跃变更名);` +
81
+ `② cwd 是否漂到了子项目目录(回到项目根再跑,或加 --spec-dir <根>/.sillyspec);` +
82
+ `③ 是否该先跑 sillyspec run brainstorm 新建该变更。`,
78
83
  }
79
84
  }
80
85
 
@@ -198,7 +203,7 @@ function warnMissingIds(warnings, ids, targetContent, targetName, sourceName) {
198
203
  const base = id.replace(/@V\d+$/, '')
199
204
  const re = new RegExp(`\\b${base}\\b`)
200
205
  if (!re.test(targetUpper)) {
201
- warnings.push(`${targetName} 未引用 ${sourceName} 中的 ${id}`)
206
+ warnings.push(getRule('shared.id-traceability').failMessage.replaceAll('${target}', targetName).replaceAll('${source}', sourceName).replaceAll('${id}', id))
202
207
  }
203
208
  }
204
209
  }
@@ -216,16 +221,10 @@ function validateScanOutputs(cwd, changeName, context = {}) {
216
221
  ? join(specBase, 'docs', projectName, 'scan')
217
222
  : join(specBase, 'docs', 'scan')
218
223
 
219
- const requiredDocs = SCAN_REQUIRED_DOCS
220
-
221
- const errors = []
222
- const warnings = []
223
-
224
- for (const doc of requiredDocs) {
225
- if (!existsSync(join(docsRoot, doc))) {
226
- errors.push(`scan 文档缺失: ${join(docsRoot, doc)}`)
227
- }
228
- }
224
+ // 7 份 scan 文档存在性由引擎消费 manifest(与 SCAN_REQUIRED_DOCS 单源,文案/事前契约同源)
225
+ const engineResult = evaluateRules('scan', { docsRoot })
226
+ const errors = [...engineResult.errors]
227
+ const warnings = [...engineResult.warnings]
229
228
 
230
229
  // 检查 modules 目录
231
230
  const modulesRoot = projectName
@@ -253,90 +252,39 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
253
252
  return { ok: false, errors: [`平台模式 specRoot 缺少 changes 目录: ${changesRoot}`], warnings: [] }
254
253
  }
255
254
  const changeDir = resolveChangeDir(cwd, changeName, specRoot)
256
- const errors = []
257
- const warnings = []
258
-
259
- const requiredFiles = ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
260
-
261
- for (const file of requiredFiles) {
262
- if (!existsSync(join(changeDir, file))) {
263
- errors.push(`brainstorm 产物缺失: ${join(changeDir, file)}`)
264
- }
265
- }
266
-
267
- // 内容校验(文件存在时检查关键章节)
268
- if (existsSync(join(changeDir, 'proposal.md'))) {
269
- const content = readFileSync(join(changeDir, 'proposal.md'), 'utf8')
270
- if (!content.includes('不在范围内') && !content.includes('Non-Goals') && !content.includes('非目标')) {
271
- warnings.push('proposal.md 缺少「不在范围内/Non-Goals」章节')
272
- }
273
- }
274
-
275
- if (existsSync(join(changeDir, 'requirements.md'))) {
276
- const content = readFileSync(join(changeDir, 'requirements.md'), 'utf8')
277
- if (!/FR-\d+/i.test(content)) {
278
- warnings.push('requirements.md 缺少 FR 编号的需求项')
279
- }
280
- }
255
+ // 纯 kind 规则(四件套存在性 + proposal/requirements/design 章节 + tasks 列表)由引擎消费
256
+ // stage-contract-spec.js manifest,与报错文案 + prompt 事前契约严格同源(事前给的 == 事后查的)。
257
+ // custom kind(lifecycle / decisions)保留下方旧逻辑,数据/文案在 Batch 3 迁入 manifest。
258
+ const engineResult = evaluateRules('brainstorm', { changeDir })
259
+ const errors = [...engineResult.errors]
260
+ const warnings = [...engineResult.warnings]
281
261
 
282
262
  if (existsSync(join(changeDir, 'design.md'))) {
283
263
  const content = readFileSync(join(changeDir, 'design.md'), 'utf8')
284
- if (!content.includes('文件变更清单') && !content.includes('File Changes') && !content.includes('文件清单')) {
285
- warnings.push('design.md 缺少「文件变更清单」章节')
286
- }
287
- if (!content.includes('风险登记') && !content.includes('Risk') && !content.includes('风险')) {
288
- warnings.push('design.md 缺少「风险登记」章节')
289
- }
290
- if (!content.includes('自审') && !content.includes('Self-Review') && !content.includes('Self-review')) {
291
- warnings.push('design.md 缺少「自审」章节')
292
- }
293
264
 
294
- // P1: 涉及生命周期关键词时,design.md 必须包含生命周期契约表(除非显式声明不涉及)
295
- const hasLifecycleKeyword = /\b(session|lease|agent[._-]?run|daemon|lifecycle|state[._-]?transition|claim|heartbeat)\b/i.test(content)
296
- if (hasLifecycleKeyword) {
297
- // 显式声明本变更不涉及生命周期契约(覆盖字段名/错误码/否定声明场景):
298
- // 历史教训:design 提到 daemon_id 字段名或 daemon_not_owned 错误码就触发,被迫加空表(B3a)。
299
- //
300
- // 收紧原则(修:正常 design 不应被误判「已豁免」):
301
- // - 否定词必须是明确多字短语(不涉及/不适用/未涉及/不包含/没有/n\/a/not applicable/none),
302
- // 杜绝裸单字「无」与裸「na」在 40 字符宽窗口内任意命中——
303
- // 「lifecycle 状态无变化」「本变更无需 lifecycle 事件」「lifecycle canal 不涉及」等不再误判;
304
- // - 否定词必须与「生命周期(契约)/lifecycle(contract)」紧邻(仅允许少量空白/分隔符/「任何」),
305
- // 不再用 40 字符宽松窗口;工具错误信息本身就指引写「不涉及生命周期契约」这个规范短语。
306
- const declaresNotApplicable =
307
- // 否定在前:「不涉及生命周期(契约)」「不适用 lifecycle contract」
308
- /(?:不涉及|不适用|未涉及|不包含|没有(?:任何)?)\s?(?:任何\s?)?(?:生命周期(?:契约)?|lifecycle(?:[ _=-]?contract)?)/i.test(content) ||
309
- // 主题在前(表格/列表单元「生命周期契约:不涉及 / N/A / 无」——分隔符强制,杜绝宽窗口)
310
- /(?:生命周期(?:契约)?|lifecycle(?:[ _=-]?contract)?)\s?[::=]\s?(?:不涉及|不适用|未涉及|无|n\/?a\b|not[ _=-]?applicable|none\b)/i.test(content) ||
311
- // 英文谓语句:「does not involve / not applicable ... lifecycle」
312
- /(?:does[ _-]?not[ _-]?involve|not[ _-]?applicable)[^\n]{0,15}lifecycle/i.test(content)
265
+ // lifecycle-exemption(custom kind):判定算法保留(trigger/exemption/table 三段短路),
266
+ // data + failMessage/exemptionPassedMessage 从 stage-contract-spec.js manifest 同源。
267
+ const lcRule = getRule('brainstorm.design.lifecycle-table')
268
+ const { trigger: lcTrigger, exemptions: lcExempts, table: lcTable } = lcRule.data
269
+ if (new RegExp(lcTrigger.pattern, lcTrigger.flags).test(content)) {
270
+ const declaresNotApplicable = lcExempts.some(e => new RegExp(e.pattern, e.flags).test(content))
313
271
  if (declaresNotApplicable) {
314
- warnings.push('design.md 显式声明不涉及生命周期契约 — 已豁免「生命周期契约表」要求')
272
+ warnings.push(lcRule.exemptionPassedMessage)
315
273
  } else {
316
- const hasLifecycleTable =
317
- /生命周期契约表|lifecycle[._-]?contract|lifecycle[._-]?matrix|Lifecycle Contract/i.test(content) ||
318
- /事件.*发起方.*接收方.*必需字段.*状态变化/.test(content)
274
+ const hasLifecycleTable = lcTable.some(t => new RegExp(t.pattern, t.flags).test(content))
319
275
  if (!hasLifecycleTable) {
320
- errors.push('design.md 涉及生命周期关键词(session/lease/agent_run/daemon/lifecycle)但缺少「生命周期契约表」— 必须列出完整的事件×状态转换矩阵;或显式声明「不涉及生命周期契约」并附理由豁免')
276
+ errors.push(lcRule.failMessage)
321
277
  }
322
278
  }
323
279
  }
324
280
  }
325
281
 
326
- if (existsSync(join(changeDir, 'tasks.md'))) {
327
- const content = readFileSync(join(changeDir, 'tasks.md'), 'utf8')
328
- const lines = content.split('\n').filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || /^\d+\./.test(l.trim()))
329
- if (lines.length === 0) {
330
- warnings.push('tasks.md 没有任务列表项')
331
- }
332
- }
333
-
334
282
  const decisionsFile = join(changeDir, 'decisions.md')
335
283
  if (existsSync(decisionsFile)) {
336
284
  const decisions = readFileSync(decisionsFile, 'utf8')
337
285
  const blockers = findBlockingDecisionIssues(decisions)
338
286
  for (const issue of blockers) {
339
- errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
287
+ errors.push(getRule('shared.decision-blocker').failMessage.replace('${issue}', issue))
340
288
  }
341
289
  const decisionIds = extractCurrentDecisionIds(decisions)
342
290
  if (decisionIds.length === 0) {
@@ -361,13 +309,12 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
361
309
  const { specRoot } = context
362
310
  const changeDir = resolveChangeDir(cwd, changeName, specRoot)
363
311
  const planFile = join(changeDir, 'plan.md')
364
- const errors = []
365
312
 
366
- if (!existsSync(planFile)) {
367
- errors.push(`plan.md 缺失: ${planFile}`)
368
- }
313
+ // plan.md 存在性由引擎消费 manifest。entryPoint/id-trace/decisions 为 custom kind(下方保留)。
314
+ const engineResult = evaluateRules('plan', { changeDir })
315
+ const errors = [...engineResult.errors]
316
+ const warnings = [...engineResult.warnings]
369
317
 
370
- const warnings = []
371
318
  if (existsSync(planFile)) {
372
319
  const plan = readFileSync(planFile, 'utf8')
373
320
  const requirements = readIfExists(join(changeDir, 'requirements.md'))
@@ -377,26 +324,25 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
377
324
  const decisions = readIfExists(join(changeDir, 'decisions.md'))
378
325
  const blockers = findBlockingDecisionIssues(decisions)
379
326
  for (const issue of blockers) {
380
- errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
327
+ errors.push(getRule('shared.decision-blocker').failMessage.replace('${issue}', issue))
381
328
  }
382
329
  const decisionIds = extractCurrentDecisionIds(decisions)
383
330
  warnMissingIds(warnings, decisionIds, plan, 'plan.md', 'decisions.md')
384
331
  }
385
332
  // ── P0: 生产接线路径检查:design 提到入口但 task 的 allowed_paths 不含入口文件 ──
333
+ // entry-point-wiring(custom):trigger/file 抽取/exemption/failMessage 从 manifest 同源
334
+ // (stage-contract-spec.js),保留多源 allowed_paths 收集 + 逐文件对账 + 豁免算法。
386
335
  const designContent = readIfExists(join(changeDir, 'design.md'))
387
336
  if (designContent) {
388
- const entryPointPatterns = [
389
- /\b(cli\.ts|main\.ts|server\.(?:js|ts)|index\.(?:js|ts))\b.*\b(?:实例化|instantiate|构造|new\s)/gi,
390
- /\bnew\s+(Daemon|SessionManager|App|Server|Application)\b/gi,
391
- /\b(?:在|from)\s+['"]?(cli\.ts|main\.ts|server\.(?:js|ts)|index\.(?:js|ts))['"]?/gi,
392
- /\b(?:注入|inject)\b.*\b(?:构造|constructor|初始化|init|实例化|instantiate)\b/gi,
393
- /\b(?:启动路径|startup|entrypoint|bootstrap|daemon[._-]?start|main.*entry)\b/gi,
394
- ]
337
+ const epRule = getRule('plan.entry-point-wiring')
338
+ const { entryPointPatterns, fileExtractionPattern, exemptionPattern } = epRule.data
339
+ const fileExtractRe = new RegExp(fileExtractionPattern.pattern, fileExtractionPattern.flags)
395
340
  const mentionedFiles = new Set()
396
- for (const pattern of entryPointPatterns) {
341
+ for (const ep of entryPointPatterns) {
342
+ const pattern = new RegExp(ep.pattern, ep.flags)
397
343
  pattern.lastIndex = 0
398
344
  for (const match of designContent.matchAll(pattern)) {
399
- const fileMatch = match[0].match(/\b(cli\.ts|main\.ts|server\.(?:js|ts)|index\.(?:js|ts))\b/i)
345
+ const fileMatch = match[0].match(fileExtractRe)
400
346
  if (fileMatch) mentionedFiles.add(fileMatch[1].toLowerCase())
401
347
  }
402
348
  }
@@ -426,10 +372,9 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
426
372
  for (const mentionedFile of mentionedFiles) {
427
373
  const found = [...allAllowedPaths].some(p => p.includes(mentionedFile))
428
374
  if (!found) {
429
- const noChangePattern = new RegExp(`不需要改.*${mentionedFile}|${mentionedFile}.*不需要|不修改.*${mentionedFile}|${mentionedFile}.*不变|${mentionedFile}.*no.?change`, 'i')
375
+ const noChangePattern = new RegExp(exemptionPattern.replaceAll('${file}', mentionedFile), 'i')
430
376
  if (!noChangePattern.test(designContent)) {
431
- errors.push(`生产接线路径矛盾: design.md 提到了入口文件 "${mentionedFile}" 但所有 task 的 allowed_paths 中均不含该文件`)
432
- warnings.push(`提示: 如果确实不需要修改 ${mentionedFile},请在 design.md 中明确写明理由`)
377
+ errors.push(epRule.failMessage.replaceAll('${file}', mentionedFile))
433
378
  }
434
379
  }
435
380
  }
@@ -457,35 +402,24 @@ function extractVerifyConclusion(verify) {
457
402
  function validateVerifyOutputs(cwd, changeName, context = {}) {
458
403
  const { specRoot } = context
459
404
  const changeDir = resolveChangeDir(cwd, changeName, specRoot)
460
- const errors = []
461
- const warnings = []
462
405
 
463
406
  if (!existsSync(changeDir)) {
464
- errors.push(`变更目录缺失: ${changeDir}`)
465
- return { ok: false, errors, warnings }
407
+ return { ok: false, errors: [`变更目录缺失: ${changeDir}`], warnings: [] }
466
408
  }
467
409
 
468
- // verify 阶段必须产出 verify-result.md —— 不存在则不能完成。
469
- // 历史教训:AI 可能跳过报告直接 --done,导致"假完成"。此处提级为 error 强制阻断。
470
- const verifyResult = join(changeDir, 'verify-result.md')
471
- if (!existsSync(verifyResult)) {
472
- errors.push(`verify-result.md 不存在 — verify 阶段必须产出验证报告才能完成(${verifyResult})`)
473
- }
474
-
475
- // 确保核心规范文件仍然存在
476
- const requiredDocs = ['design.md', 'plan.md']
477
- for (const doc of requiredDocs) {
478
- if (!existsSync(join(changeDir, doc))) {
479
- errors.push(`核心文档缺失: ${join(changeDir, doc)}`)
480
- }
481
- }
410
+ // 核心文档存在性(verify-result.md / design.md / plan.md)由引擎消费 manifest。
411
+ // verify-result.md 不存在则不能完成——历史教训:AI 可能跳过报告直接 --done 导致"假完成"。
412
+ const engineResult = evaluateRules('verify', { changeDir })
413
+ const errors = [...engineResult.errors]
414
+ const warnings = [...engineResult.warnings]
482
415
 
416
+ const verifyResult = join(changeDir, 'verify-result.md')
483
417
  if (existsSync(verifyResult)) {
484
418
  const verify = readFileSync(verifyResult, 'utf8')
485
419
  const decisions = readIfExists(join(changeDir, 'decisions.md'))
486
420
  const blockers = findBlockingDecisionIssues(decisions)
487
421
  for (const issue of blockers) {
488
- errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
422
+ errors.push(getRule('shared.decision-blocker').failMessage.replace('${issue}', issue))
489
423
  }
490
424
  const decisionIds = extractCurrentDecisionIds(decisions)
491
425
  warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
@@ -495,9 +429,9 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
495
429
  // 历史教训:CLI 曾不校验结论,AI 写 FAIL 后 verify 仍被标记完成并提示"验证通过可以归档"。
496
430
  const conclusionStr = extractVerifyConclusion(verify)
497
431
  if (conclusionStr === 'FAIL') {
498
- errors.push('verify-result.md 结论为 FAIL — 验证未通过,不能标记 verify 完成;请修复后重新运行验证')
432
+ errors.push(getRule('verify.conclusion.fail-gate').failMessage)
499
433
  } else if (!conclusionStr) {
500
- warnings.push('verify-result.md 未识别到结论章节(含 结论/Conclusion/Result/结果 的二级标题,后跟 PASS / PASS WITH NOTES / FAIL)')
434
+ warnings.push(getRule('verify.conclusion.fail-gate').noConclusionWarning)
501
435
  }
502
436
 
503
437
  // ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
@@ -510,8 +444,28 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
510
444
  if (conclusion === 'PASS WITH NOTES' || conclusion === 'PASS') {
511
445
  const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
512
446
  if (!evidenceCheck.ok) {
513
- errors.push(`[${changeRiskProfile.level}] 验证结论为 ${conclusion},但缺少真实集成证据:${evidenceCheck.errors.join('; ')}`)
514
- errors.push(`触发词: ${changeRiskProfile.triggers.join(', ')} — PASS WITH NOTES 不被允许,必须 FAIL 或提供集成证据`)
447
+ // A: 报错说人话 —— 把「缺哪一项、要写/做什么、判级原因」逐条列出,
448
+ // 让 agent 不必靠改结论文案撞墙。detail 指明真实启动须是本变更实际改动的
449
+ // 部署/启动入口(非无关进程),以及每项的字面期望。
450
+ const needs = changeRiskProfile.requiredVerification
451
+ .filter(k => VERIFICATION_NEEDS[k] && VERIFICATION_NEEDS[k].desc)
452
+ .map(k => {
453
+ const vn = VERIFICATION_NEEDS[k]
454
+ const lit = vn.literals && vn.literals.length ? '字面命中其一:' + vn.literals.join(' / ') : ''
455
+ return `\n 〔${k}〕${vn.desc}${lit}`
456
+ })
457
+ .join('')
458
+ const cause = RISK_LEVEL_CAUSES[changeRiskProfile.level] || ''
459
+ errors.push(
460
+ `[${changeRiskProfile.level}] 验证结论为 ${conclusion},但缺少真实集成证据。\n` +
461
+ ` 缺失项(需在 verify-result.md 如实提供并满足):${evidenceCheck.errors.join('; ')}\n` +
462
+ ` 每项要提供什么:${needs}\n` +
463
+ ` 风险判级原因:${cause}\n` +
464
+ ` 命中触发词:${changeRiskProfile.triggers.join(', ')}\n` +
465
+ ` 出路:① 补全上述缺失的真实集成证据(真实启动 daemon/backend、集成测试、运行日志)后保持 PASS;` +
466
+ `或 ② 如实改结论 FAIL(承认端到端未验,留待部署后补)。` +
467
+ `仅改结论文案/措辞蹭字面关键词会被对账,PASS WITH NOTES 在此等级下不被允许。`
468
+ )
515
469
  }
516
470
  warnings.push(...evidenceCheck.warnings)
517
471
  }
@@ -525,35 +479,18 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
525
479
  * archive 完成校验:检查归档目录完整性
526
480
  */
527
481
  function validateArchiveOutputs(cwd, changeName) {
528
- const errors = []
529
- const warnings = []
530
482
  const archiveDir = join(cwd, '.sillyspec', 'changes', 'archive')
531
483
  const date = new Date().toISOString().slice(0, 10)
532
484
  const destDir = join(archiveDir, `${date}-${changeName}`)
533
485
 
534
- // 检查归档目录是否存在
486
+ // 归档目录不存在 early return(引擎在存在时才跑)
535
487
  if (!existsSync(destDir)) {
536
- errors.push(`归档目录缺失: ${destDir}`)
537
- return { ok: false, errors, warnings }
538
- }
539
-
540
- // 检查核心文档
541
- const requiredDocs = ['plan.md']
542
- const recommendedDocs = ['design.md', 'module-impact.md']
543
-
544
- for (const doc of requiredDocs) {
545
- if (!existsSync(join(destDir, doc))) {
546
- errors.push(`归档目录缺失核心文档: ${doc}`)
547
- }
548
- }
549
-
550
- for (const doc of recommendedDocs) {
551
- if (!existsSync(join(destDir, doc))) {
552
- warnings.push(`归档目录缺少推荐文档: ${doc}`)
553
- }
488
+ return { ok: false, errors: [`归档目录缺失: ${destDir}`], warnings: [] }
554
489
  }
555
490
 
556
- return { ok: errors.length === 0, errors, warnings }
491
+ // plan.md(必备)/design.md/module-impact.md(推荐)存在性由引擎消费 manifest
492
+ const engineResult = evaluateRules('archive', { archiveDir: destDir }, undefined, { source: 'validateArchiveOutputs' })
493
+ return { ok: engineResult.errors.length === 0, errors: engineResult.errors, warnings: engineResult.warnings }
557
494
  }
558
495
 
559
496
  /**
@@ -572,11 +509,9 @@ function validateChangeClosed(cwd, changeName) {
572
509
  return { ok: false, errors, warnings }
573
510
  }
574
511
 
575
- if (!existsSync(join(changeDir, 'plan.md'))) {
576
- errors.push(`plan.md 缺失 — 请确保 plan 阶段已完成`)
577
- }
578
-
579
- return { ok: errors.length === 0, errors, warnings }
512
+ // plan.md 存在性由引擎消费 manifest(source 区分 validateChangeClosed vs validateArchiveOutputs)
513
+ const engineResult = evaluateRules('archive', { changeDir }, undefined, { source: 'validateChangeClosed' })
514
+ return { ok: engineResult.errors.length === 0, errors: engineResult.errors, warnings: engineResult.warnings }
580
515
  }
581
516
 
582
517
  // ============ Execute 代码变更客观核验 ============
@@ -24,6 +24,69 @@ export const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'accept
24
24
  // checklist 每项 result 的合法值
25
25
  const CHECKLIST_RESULTS = ['pass', 'gap', 'fail']
26
26
 
27
+ // stage → 主审查文档(brainstorm/execute 审 design.md;plan 审 plan.md;propose 审 proposal.md)
28
+ const STAGE_MAIN_DOC = { brainstorm: 'design.md', plan: 'plan.md', propose: 'proposal.md', execute: 'design.md' }
29
+ // stage → reviewType(对齐 gates.js Stage Review Gate 的映射)
30
+ const STAGE_REVIEW_TYPE = { brainstorm: 'design', plan: 'plan', propose: 'proposal', execute: 'acceptance' }
31
+
32
+ /**
33
+ * 渲染 review.json 产物契约(markdown)给 review 子代理事前看 —— schema 表 + 完整 JSON 示例 +
34
+ * docHash 算法 + 重算提示 + 位置。复用 validateStageReviewSchema 的同源常量,事前给的 == 事后查的。
35
+ *
36
+ * 历史教训:review 子代理靠读 stage-review.js 源码 + 翻模板才搞清 schema,常错(漏 schemaVersion /
37
+ * checklist 按层嵌套对象 / docHash 用主文档改版前的旧 sha256)。本函数把契约前置进 prompt。
38
+ *
39
+ * @param {{ stage?: string, changeDir?: string, reviewRunId?: string, tier?: string }} opts
40
+ * @returns {string} markdown 段;tier=self 返回简短提示(无需 review.json)
41
+ */
42
+ export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier } = {}) {
43
+ if (tier === 'self') {
44
+ return '> review.json 契约:tier=self(变更规模小),无需产出 review.json,当前 agent 自审即可。'
45
+ }
46
+ const reviewType = STAGE_REVIEW_TYPE[stage] || 'design'
47
+ const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
48
+ const mainDocRel = changeDir ? 'changes/<change>/' + mainDoc : mainDoc
49
+ const stageLbl = stage || '<stage>'
50
+ const runIdLbl = reviewRunId || '<reviewRunId>'
51
+ const L = [
52
+ '## review.json 产物契约(CLI Stage Review Gate 将硬校验,以下为精确 schema —— 事前给的 == 事后查的)',
53
+ '',
54
+ '> 路径:`{SPEC_ROOT}/.runtime/stage-reviews/' + stageLbl + '-' + runIdLbl + '/review.json`(本次 reviewType=' + reviewType + ',主审查文档=' + mainDocRel + ')',
55
+ '',
56
+ '### 必填字段(validateStageReviewSchema 硬校验,缺任一即 schema 失败阻断)',
57
+ '- `schemaVersion`: ' + REVIEW_SCHEMA_VERSION,
58
+ '- `reviewType`: "' + reviewType + '"(必须与本 stage 一致;合法值 ' + STAGE_REVIEW_TYPES.join(' / ') + ')',
59
+ '- `specVerdict` / `qualityVerdict`: ∈ { ' + VALID_VERDICTS.join(' / ') + ' }',
60
+ '- `reviewedFiles`: 非空数组,[0] = 主审查文档 = `' + mainDocRel + '`',
61
+ '- `docHash`: reviewedFiles[0] 文件内容的 sha256(hex,见下方算法)',
62
+ '- `requiredEvidence`: 非空数组(specVerdict 或 qualityVerdict = cannot_verify 时必填,反逃逸)',
63
+ '- `checklist`(可选): 扁平数组 —— 每项 { item: string, result: ∈ { ' + CHECKLIST_RESULTS.join(' / ') + ' }, note?: string }。注意是**扁平数组**,不是按层(定义/一致/可行性)嵌套对象',
64
+ '- `reviewerNotes`: 说明(verdict=fail 时写明阻断项)',
65
+ '',
66
+ '### docHash 算法(必须等于 reviewedFiles[0] 的 sha256)',
67
+ "- node: `crypto.createHash('sha256').update(require('fs').readFileSync(reviewedFiles[0])).digest('hex')`(对原始字节)",
68
+ '- shell: `sha256sum <reviewedFiles[0]>` 取首列(hex)',
69
+ '- ⚠️ **review.json 写入后若 ' + mainDoc + ' 再被改,必须重算 docHash** —— gate 会重算 reviewedFiles[0] 的 sha256 比对,不符判伪造(历史翻车根因:design 改了 rev 后 docHash 仍是旧值)',
70
+ '',
71
+ '### 完整 JSON 示例(照抄改值)',
72
+ '```json',
73
+ '{',
74
+ ' "schemaVersion": ' + REVIEW_SCHEMA_VERSION + ',',
75
+ ' "reviewType": "' + reviewType + '",',
76
+ ' "specVerdict": "pass",',
77
+ ' "qualityVerdict": "pass",',
78
+ ' "reviewedFiles": ["' + mainDocRel + '"],',
79
+ ' "docHash": "<上面算出的 sha256 hex>",',
80
+ ' "checklist": [',
81
+ ' { "item": "<审查项>", "result": "pass", "note": "<证据/说明>" }',
82
+ ' ],',
83
+ ' "reviewerNotes": "<总结>"',
84
+ '}',
85
+ '```',
86
+ ]
87
+ return L.join('\n')
88
+ }
89
+
27
90
  /**
28
91
  * 计算文件内容的 sha256(用于 docHash 真实性校验,防 agent 凭空伪造 review.json)
29
92
  * @param {string} filePath - 绝对路径
@@ -349,7 +349,9 @@ design.md 文件路径 + 自审结果
349
349
  ### 审查执行方式(CLI 按变更规模判定,占位符由 run.js 注入)
350
350
  tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
351
351
  - tier=self:当前 agent 直接执行下方交叉审查(小变更)
352
- - tier=independent:必须用 Agent tool 启动一个独立的设计审查子代理(独立上下文,不共享你的分析与倾向),子代理按下方"交叉审查模型"审查 design.md 并输出 review.json 到 {SPEC_ROOT}/.runtime/stage-reviews/brainstorm-{STAGE_REVIEW_RUN_ID}/review.json。字段:reviewType=design;reviewedFiles=[changes/<change>/design.md];docHash=design.md 的 sha256;specVerdict/qualityVerdict=pass|fail|cannot_verify;checklist 按"定义层/一致性层/可行性层"每条给 pass|gap|fail。子代理只产出 review + Unresolved Blockers,**是否调用 sillyspec run brainstorm --wait 仍由你(主 agent)根据其 verdict 决定**(子代理不直接操作 CLI 状态机)。
352
+ - tier=independent:必须用 Agent tool 启动一个独立的设计审查子代理(独立上下文,不共享你的分析与倾向),子代理按下方"交叉审查模型"审查 design.md 并输出 review.json。review.json 产物契约(CLI Stage Review Gate 将硬校验,schema + 完整示例 + docHash 算法如下,照抄改值):
353
+ {REVIEW_JSON_CONTRACT}
354
+ 子代理只产出 review + Unresolved Blockers,**是否调用 sillyspec run brainstorm --wait 仍由你(主 agent)根据其 verdict 决定**(子代理不直接操作 CLI 状态机)。
353
355
 
354
356
  ### 默认行为
355
357
  1. 默认必须执行一次交叉审查;不要让用户凭主观判断决定"要不要 Grill"。
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'fs'
2
2
  import path from 'path'
3
3
  import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
4
+ import { getRule } from '../stage-contract-spec.js'
4
5
 
5
6
  /**
6
7
  * 校验 plan.md 是否满足 execute 执行契约
@@ -56,7 +57,7 @@ export function validatePlanForExecute(planContent) {
56
57
  if (ids[0] === 1) {
57
58
  for (let i = 0; i < ids.length; i++) {
58
59
  if (ids[i] !== i + 1) {
59
- errors.push(`task id 不连续: 期望 task-${String(i + 1).padStart(2, '0')}, 实际 task-${String(ids[i]).padStart(2, '0')}`)
60
+ errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(ids[i]).padStart(2, '0')))
60
61
  break
61
62
  }
62
63
  }
@@ -217,7 +218,9 @@ const acceptanceSteps = [
217
218
  ### 执行方式(CLI 按变更规模判定,占位符由 run.js 注入)
218
219
  tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
219
220
  - tier=self:当前 agent 汇总执行(对照 design.md 逐项检查 + 偏差说明)
220
- - tier=independent:必须用 Agent tool 启动一个独立的 QA 子代理(独立上下文,不共享实现者的分析),子代理对照 design.md 逐项检查实现一致性并输出 review.json 到 {SPEC_ROOT}/.runtime/stage-reviews/execute-{STAGE_REVIEW_RUN_ID}/review.json。字段:reviewType=acceptance;reviewedFiles=[changes/<change>/design.md] + git diff 涉及的源码文件;docHash=design.md 的 sha256;specVerdict/qualityVerdict=pass|fail|cannot_verify;checklist 按每个设计要点给 pass|gap|fail。该 acceptance review 同时覆盖"代码审查"视角(风格/bug/安全/冗余),后续代码审查步骤仅需轻量复审。
221
+ - tier=independent:必须用 Agent tool 启动一个独立的 QA 子代理(独立上下文,不共享实现者的分析),子代理对照 design.md 逐项检查实现一致性并输出 review.json。review.json 产物契约(CLI Stage Review Gate 将硬校验,schema + 完整示例 + docHash 算法如下,照抄改值;reviewedFiles 除主文档 design.md 外可追加 git diff 涉及的源码文件):
222
+ {REVIEW_JSON_CONTRACT}
223
+ 该 acceptance review 同时覆盖"代码审查"视角(风格/bug/安全/冗余),后续代码审查步骤仅需轻量复审。
221
224
 
222
225
  ### 操作
223
226
  1. 读取 design.md(技术方案)