sillyspec 3.18.0 → 3.18.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.
@@ -5,7 +5,7 @@
5
5
  * CLI 不再相信 prompt 完成,completeStep 后必须过 validator。
6
6
  */
7
7
 
8
- import { existsSync, readdirSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
 
11
11
  /**
@@ -26,6 +26,117 @@ import { join, basename } from 'path'
26
26
 
27
27
  // ============ Validators ============
28
28
 
29
+ function resolveChangeDir(cwd, changeName, specRoot = null) {
30
+ const changesRoot = specRoot ? join(specRoot, 'changes') : join(cwd, '.sillyspec', 'changes')
31
+ return join(changesRoot, changeName)
32
+ }
33
+
34
+ function collectIdsFromLine(line, re, ids) {
35
+ for (const match of line.matchAll(re)) {
36
+ ids.add(match[0].toUpperCase())
37
+ }
38
+ }
39
+
40
+ function extractIds(content, prefix) {
41
+ if (!content) return []
42
+ const ids = new Set()
43
+ const idRe = new RegExp(`\\b${prefix}-\\d+(?:@v\\d+)?\\b`, 'gi')
44
+ const headingLineRe = /^\s{0,3}#{1,6}\s+/i
45
+ const fieldLineRe = /^\s*(?:[-*]\s*)?(?:id|decision[-_ ]?ids?|requirement[-_ ]?ids?|covers?|coverage|references?|impacts?|覆盖(?:来源|决策|需求)?)\s*[::]/i
46
+ const tableLineRe = /^\s*\|/
47
+ const listStartsWithIdRe = new RegExp(`^\\s*(?:[-*]|\\d+\\.)\\s*(?:\\[[ xX]\\]\\s*)?${prefix}-\\d+(?:@v\\d+)?\\b`, 'i')
48
+
49
+ for (const line of content.split(/\r?\n/)) {
50
+ if (!headingLineRe.test(line) && !fieldLineRe.test(line) && !tableLineRe.test(line) && !listStartsWithIdRe.test(line)) continue
51
+ collectIdsFromLine(line, idRe, ids)
52
+ }
53
+ return [...ids].sort()
54
+ }
55
+
56
+ function readDecisionField(body, fieldPattern, fallback = '') {
57
+ const re = new RegExp(`^\\s*(?:[-*]\\s*)?(?:${fieldPattern})\\s*[::]\\s*([^\\n]+)`, 'im')
58
+ return (body.match(re)?.[1] || fallback).trim()
59
+ }
60
+
61
+ function buildDecisionRecord(id, body) {
62
+ const status = readDecisionField(body, 'status', 'accepted').toLowerCase()
63
+ const blockerValue = readDecisionField(body, 'blocker', 'false').toLowerCase()
64
+ const blocker = ['true', 'yes', '1'].includes(blockerValue)
65
+ const priorityValue = readDecisionField(body, 'priority|level|severity')
66
+ const priorityMissing = priorityValue.length === 0
67
+ const fallbackPriority = (['unresolved', 'blocking'].includes(status) || blocker) ? 'P1' : 'P2'
68
+ const priority = (priorityValue.match(/P[0-2]/i)?.[0] || fallbackPriority).toUpperCase()
69
+ return { id: id.toUpperCase(), body, status, priority, blocker, priorityMissing }
70
+ }
71
+
72
+ function findNextDecisionBoundary(content, startIndex) {
73
+ const boundaryRe = /^(\s{0,3}#{2,6}\s+D-\d+(?:@v\d+)?\b|\s*(?:[-*]\s*)?(?:id|decision[-_ ]?id|decision)\s*[::]\s*D-\d+(?:@v\d+)?\b)/gmi
74
+ boundaryRe.lastIndex = startIndex
75
+ const next = boundaryRe.exec(content)
76
+ return next ? next.index : content.length
77
+ }
78
+
79
+ function isInsideRange(index, ranges) {
80
+ return ranges.some(range => index >= range.start && index < range.end)
81
+ }
82
+
83
+ function parseDecisionRecords(content) {
84
+ if (!content) return []
85
+ const records = []
86
+ const ranges = []
87
+ const headingRe = /^\s{0,3}#{2,6}\s+(D-\d+(?:@v\d+)?)(?:\b|:)[^\n]*$/gmi
88
+ const headings = []
89
+ let match
90
+ while ((match = headingRe.exec(content)) !== null) {
91
+ headings.push({ id: match[1].toUpperCase(), index: match.index, end: headingRe.lastIndex })
92
+ }
93
+ for (let i = 0; i < headings.length; i++) {
94
+ const current = headings[i]
95
+ const next = headings[i + 1]
96
+ const body = content.slice(current.end, next ? next.index : content.length)
97
+ const end = next ? next.index : content.length
98
+ ranges.push({ start: current.index, end })
99
+ records.push(buildDecisionRecord(current.id, body))
100
+ }
101
+
102
+ const idLineRe = /^\s*(?:[-*]\s*)?(?:id|decision[-_ ]?id|decision)\s*[::]\s*(D-\d+(?:@v\d+)?)(?:\b|$)/gmi
103
+ while ((match = idLineRe.exec(content)) !== null) {
104
+ if (isInsideRange(match.index, ranges)) continue
105
+ const bodyEnd = findNextDecisionBoundary(content, idLineRe.lastIndex)
106
+ const body = content.slice(match.index, bodyEnd)
107
+ records.push(buildDecisionRecord(match[1], body))
108
+ }
109
+
110
+ return records
111
+ }
112
+
113
+ function extractCurrentDecisionIds(content) {
114
+ const records = parseDecisionRecords(content)
115
+ if (records.length === 0) return extractIds(content, 'D')
116
+ return records
117
+ .filter(r => !['superseded', 'rejected'].includes(r.status))
118
+ .map(r => r.id)
119
+ .sort()
120
+ }
121
+
122
+ function findBlockingDecisionIssues(content) {
123
+ return parseDecisionRecords(content)
124
+ .filter(r => (r.blocker || ['unresolved', 'blocking'].includes(r.status)) && ['P0', 'P1'].includes(r.priority))
125
+ .map(r => `${r.id} (${r.priority}${r.priorityMissing ? ', priority=missing->P1' : ''}, status=${r.status})`)
126
+ }
127
+
128
+ function readIfExists(file) {
129
+ return existsSync(file) ? readFileSync(file, 'utf8') : ''
130
+ }
131
+
132
+ function warnMissingIds(warnings, ids, targetContent, targetName, sourceName) {
133
+ for (const id of ids) {
134
+ if (!targetContent.toUpperCase().includes(id)) {
135
+ warnings.push(`${targetName} 未引用 ${sourceName} 中的 ${id}`)
136
+ }
137
+ }
138
+ }
139
+
29
140
  /**
30
141
  * scan 完成校验:检查 7 份 scan 文档 + manifest
31
142
  */
@@ -55,7 +166,7 @@ function validateScanOutputs(cwd, changeName, context = {}) {
55
166
 
56
167
  for (const doc of requiredDocs) {
57
168
  if (!existsSync(join(docsRoot, doc))) {
58
- errors.push(`scan 文档缺失: ${docsRoot}/${doc}`)
169
+ errors.push(`scan 文档缺失: ${join(docsRoot, doc)}`)
59
170
  }
60
171
  }
61
172
 
@@ -75,12 +186,93 @@ function validateScanOutputs(cwd, changeName, context = {}) {
75
186
  return { ok: errors.length === 0, errors, warnings }
76
187
  }
77
188
 
189
+ /**
190
+ * brainstorm 完成校验:检查四件套规范文件是否生成
191
+ */
192
+ function validateBrainstormOutputs(cwd, changeName, context = {}) {
193
+ const { specRoot } = context
194
+ const changesRoot = specRoot ? join(specRoot, 'changes') : join(cwd, '.sillyspec', 'changes')
195
+ if (specRoot && !existsSync(changesRoot)) {
196
+ return { ok: false, errors: [`平台模式 specRoot 缺少 changes 目录: ${changesRoot}`], warnings: [] }
197
+ }
198
+ const changeDir = resolveChangeDir(cwd, changeName, specRoot)
199
+ const errors = []
200
+ const warnings = []
201
+
202
+ const requiredFiles = ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
203
+
204
+ for (const file of requiredFiles) {
205
+ if (!existsSync(join(changeDir, file))) {
206
+ errors.push(`brainstorm 产物缺失: ${join(changeDir, file)}`)
207
+ }
208
+ }
209
+
210
+ // 内容校验(文件存在时检查关键章节)
211
+ if (existsSync(join(changeDir, 'proposal.md'))) {
212
+ const content = readFileSync(join(changeDir, 'proposal.md'), 'utf8')
213
+ if (!content.includes('不在范围内') && !content.includes('Non-Goals') && !content.includes('非目标')) {
214
+ warnings.push('proposal.md 缺少「不在范围内/Non-Goals」章节')
215
+ }
216
+ }
217
+
218
+ if (existsSync(join(changeDir, 'requirements.md'))) {
219
+ const content = readFileSync(join(changeDir, 'requirements.md'), 'utf8')
220
+ if (!/FR-\d+/i.test(content)) {
221
+ warnings.push('requirements.md 缺少 FR 编号的需求项')
222
+ }
223
+ }
224
+
225
+ if (existsSync(join(changeDir, 'design.md'))) {
226
+ const content = readFileSync(join(changeDir, 'design.md'), 'utf8')
227
+ if (!content.includes('文件变更清单') && !content.includes('File Changes') && !content.includes('文件清单')) {
228
+ warnings.push('design.md 缺少「文件变更清单」章节')
229
+ }
230
+ if (!content.includes('风险登记') && !content.includes('Risk') && !content.includes('风险')) {
231
+ warnings.push('design.md 缺少「风险登记」章节')
232
+ }
233
+ if (!content.includes('自审') && !content.includes('Self-Review') && !content.includes('Self-review')) {
234
+ warnings.push('design.md 缺少「自审」章节')
235
+ }
236
+ }
237
+
238
+ if (existsSync(join(changeDir, 'tasks.md'))) {
239
+ const content = readFileSync(join(changeDir, 'tasks.md'), 'utf8')
240
+ const lines = content.split('\n').filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || /^\d+\./.test(l.trim()))
241
+ if (lines.length === 0) {
242
+ warnings.push('tasks.md 没有任务列表项')
243
+ }
244
+ }
245
+
246
+ const decisionsFile = join(changeDir, 'decisions.md')
247
+ if (existsSync(decisionsFile)) {
248
+ const decisions = readFileSync(decisionsFile, 'utf8')
249
+ const blockers = findBlockingDecisionIssues(decisions)
250
+ for (const issue of blockers) {
251
+ errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
252
+ }
253
+ const decisionIds = extractCurrentDecisionIds(decisions)
254
+ if (decisionIds.length === 0) {
255
+ warnings.push('decisions.md 存在但没有当前版本 D-xxx@vN 决策 ID')
256
+ } else {
257
+ const design = readIfExists(join(changeDir, 'design.md'))
258
+ const requirements = readIfExists(join(changeDir, 'requirements.md'))
259
+ const tasks = readIfExists(join(changeDir, 'tasks.md'))
260
+ warnMissingIds(warnings, decisionIds, design, 'design.md', 'decisions.md')
261
+ warnMissingIds(warnings, decisionIds, requirements, 'requirements.md', 'decisions.md')
262
+ warnMissingIds(warnings, decisionIds, tasks, 'tasks.md', 'decisions.md')
263
+ }
264
+ }
265
+
266
+ return { ok: errors.length === 0, errors, warnings }
267
+ }
268
+
78
269
  /**
79
270
  * plan 完成校验:检查 plan.md 生成
80
271
  */
81
- function validatePlanOutputs(cwd, changeName) {
82
- const planDir = join(cwd, '.sillyspec', 'changes', changeName)
83
- const planFile = join(planDir, 'plan.md')
272
+ function validatePlanOutputs(cwd, changeName, context = {}) {
273
+ const { specRoot } = context
274
+ const changeDir = resolveChangeDir(cwd, changeName, specRoot)
275
+ const planFile = join(changeDir, 'plan.md')
84
276
  const errors = []
85
277
 
86
278
  if (!existsSync(planFile)) {
@@ -88,20 +280,60 @@ function validatePlanOutputs(cwd, changeName) {
88
280
  }
89
281
 
90
282
  const warnings = []
283
+ if (existsSync(planFile)) {
284
+ const plan = readFileSync(planFile, 'utf8')
285
+ const requirements = readIfExists(join(changeDir, 'requirements.md'))
286
+ const requirementIds = extractIds(requirements, 'FR')
287
+ warnMissingIds(warnings, requirementIds, plan, 'plan.md', 'requirements.md')
288
+
289
+ const decisions = readIfExists(join(changeDir, 'decisions.md'))
290
+ const blockers = findBlockingDecisionIssues(decisions)
291
+ for (const issue of blockers) {
292
+ errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
293
+ }
294
+ const decisionIds = extractCurrentDecisionIds(decisions)
295
+ warnMissingIds(warnings, decisionIds, plan, 'plan.md', 'decisions.md')
296
+ }
91
297
  return { ok: errors.length === 0, errors, warnings }
92
298
  }
93
299
 
94
300
  /**
95
- * verify 完成校验:检查 verify 报告存在
301
+ * verify 完成校验:检查变更目录和 verify 产物
96
302
  */
97
- function validateVerifyOutputs(cwd, changeName) {
98
- const planDir = join(cwd, '.sillyspec', 'changes', changeName)
303
+ function validateVerifyOutputs(cwd, changeName, context = {}) {
304
+ const { specRoot } = context
305
+ const changeDir = resolveChangeDir(cwd, changeName, specRoot)
99
306
  const errors = []
100
307
  const warnings = []
101
308
 
102
- // verify 至少应该有 run 记录
103
- if (!existsSync(join(planDir, 'plan.md'))) {
104
- errors.push(`变更目录缺失: ${planDir}`)
309
+ if (!existsSync(changeDir)) {
310
+ errors.push(`变更目录缺失: ${changeDir}`)
311
+ return { ok: false, errors, warnings }
312
+ }
313
+
314
+ // verify 阶段应该产出 verify-result.md(或类似报告)
315
+ const verifyResult = join(changeDir, 'verify-result.md')
316
+ if (!existsSync(verifyResult)) {
317
+ warnings.push('verify-result.md 不存在(verify 阶段建议产出验证报告)')
318
+ }
319
+
320
+ // 确保核心规范文件仍然存在
321
+ const requiredDocs = ['design.md', 'plan.md']
322
+ for (const doc of requiredDocs) {
323
+ if (!existsSync(join(changeDir, doc))) {
324
+ errors.push(`核心文档缺失: ${join(changeDir, doc)}`)
325
+ }
326
+ }
327
+
328
+ if (existsSync(verifyResult)) {
329
+ const verify = readFileSync(verifyResult, 'utf8')
330
+ const decisions = readIfExists(join(changeDir, 'decisions.md'))
331
+ const blockers = findBlockingDecisionIssues(decisions)
332
+ for (const issue of blockers) {
333
+ errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
334
+ }
335
+ const decisionIds = extractCurrentDecisionIds(decisions)
336
+ warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
105
337
  }
106
338
 
107
339
  return { ok: errors.length === 0, errors, warnings }
@@ -187,7 +419,7 @@ const contracts = {
187
419
  description: '需求分析与设计',
188
420
  allowedFrom: [], // 任何变更的起始阶段
189
421
  allowedTo: ['plan'],
190
- validators: [],
422
+ validators: [validateBrainstormOutputs],
191
423
  },
192
424
  plan: {
193
425
  stage: 'plan',
@@ -288,6 +520,11 @@ export function checkTransition(fromStage, toStage) {
288
520
  return { allowed: true }
289
521
  }
290
522
 
523
+ // 同阶段内重复运行:允许(继续执行当前阶段的下一步)
524
+ if (fromStage === toStage) {
525
+ return { allowed: true }
526
+ }
527
+
291
528
  // archive 特殊处理:从 verify 来的允许,从其他主流程阶段来的需要校验
292
529
  if (toStage === 'archive') {
293
530
  if (fromStage === 'verify') {
@@ -98,6 +98,9 @@ export const definition = {
98
98
  },
99
99
  {
100
100
  name: '需求范围评估',
101
+ conditionalWait: true,
102
+ waitReason: '等待用户确认拆分/批量模式方案',
103
+ waitOptions: ['同意拆分', '不需要拆分', '走批量模式'],
101
104
  prompt: `评估需求复杂度,判断是否需要拆分或走批量模式。
102
105
 
103
106
  ### 操作
@@ -146,6 +149,11 @@ export const definition = {
146
149
  },
147
150
  {
148
151
  name: '对话式探索',
152
+ requiresWait: true,
153
+ repeatableWait: true,
154
+ maxWaitRounds: 3,
155
+ waitReason: '等待用户回答需求问题',
156
+ waitOptions: ['继续补充', '信息够了,进入方案讨论'],
149
157
  prompt: `通过对话探索需求细节。
150
158
 
151
159
  ### 操作
@@ -172,13 +180,84 @@ export const definition = {
172
180
  outputHint: '需求理解摘要',
173
181
  optional: false
174
182
  },
183
+ {
184
+ name: '需求澄清 Grill',
185
+ conditionalWait: true,
186
+ repeatableWait: true,
187
+ maxWaitRounds: 8,
188
+ waitReason: '等待用户回答需求澄清 Grill',
189
+ waitOptions: ['回答见--answer', '信息够了,结束需求澄清'],
190
+ prompt: `执行可选的需求澄清 Grill pass。
191
+
192
+ ### 定位
193
+ 这是 design.md 之前的需求澄清,不是设计后的 Design Grill。目标是把需求/术语/边界中仍需要人类判断的点问清楚;Design Grill 后续仍会默认执行,用来审查已经写出的 design.md 是否自洽。
194
+
195
+ ### 入口判断
196
+ 1. 汇总「对话式探索」后仍未稳定的歧义点,按类型列出:
197
+ - 术语歧义:同一个词可能指向不同实体/角色/状态
198
+ - 边界歧义:哪些场景做、哪些不做、失败怎么处理
199
+ - 前提风险:这个需求是否不该存在,是否已有更简单的现有方案
200
+ - 代码冲突:用户描述与现有代码/scan/module 文档不一致
201
+ 2. 能通过代码或文档确认的不要问用户,先读取:
202
+ - \`.sillyspec/docs/<project>/scan/ARCHITECTURE.md\`
203
+ - \`.sillyspec/docs/<project>/scan/CONVENTIONS.md\`
204
+ - \`.sillyspec/docs/<project>/modules/_module-map.yaml\`
205
+ - 相关源码文件
206
+ 3. 给每个未解决歧义分级:
207
+ - P0:影响数据模型、权限边界、状态机/工作流、兼容策略、不可逆架构取舍、跨模块所有权
208
+ - P1:影响用户场景、验收标准、错误处理、默认值
209
+ - P2:文案、展示细节、低风险交互偏好
210
+ 4. 执行规则:
211
+ - P1/P2 歧义 0-2 个且无 P0:输出"需求澄清 Grill skipped",在后续设计中内联处理并记录依据
212
+ - P1/P2 歧义 >= 3 个:进入本 pass,按优先级逐个澄清
213
+ - 任意 P0 歧义:进入本 pass;如果需要用户判断,必须暂停问一个问题
214
+ 5. 不要问用户"要不要 Grill"。本步骤由 AI 根据歧义风险决定是否执行;只在需要业务判断/取舍时等待用户回答。
215
+
216
+ ### 追问策略
217
+ 1. **一次只问一个问题**:按 P0 → P1 → P2 顺序,深度优先处理最关键歧义。
218
+ 2. **能查代码就不问**:如果问题可由源码、scan 文档、模块文档回答,先查证并给出结论;只有业务判断/取舍才问用户。
219
+ 3. **术语碰撞立即指出**:用户用词与 glossary/代码实体/模块文档冲突时,当场说明冲突并要求选择 canonical term。
220
+ 4. **模糊词精化**:把"账户/任务/状态/会话/执行"这类多义词拆成明确实体或状态。
221
+ 5. **场景压力测试**:用具体 case 逼出边界,例如失败重试、部分成功、历史数据、权限不足、并发修改、兼容旧配置。
222
+ 6. **前提挑战优先**:如果现有设计或代码已有简单路径,先说明"可能不该新增",不要直接优化错误前提。
223
+
224
+ ### 决策记录草稿
225
+ 每解决一个有实现影响的问题,生成一个稳定 ID 的记录草稿。不要把闲聊都记录进去。
226
+
227
+ \`\`\`markdown
228
+ ## D-001@v1: <短标题>
229
+ - type: term | boundary | premise | architecture | compatibility | risk
230
+ - status: accepted | rejected | superseded
231
+ - source: user | code | docs
232
+ - question: <被解决的问题>
233
+ - answer: <用户确认或代码查证结果>
234
+ - normalized_requirement: <可测试的约束>
235
+ - impacts: [FR-?, task-?, verify-?]
236
+ - evidence: <文件路径/代码位置/用户回答轮次>
237
+ \`\`\`
238
+
239
+ ### 铁律 — 等待用户
240
+ - 每轮最多提出一个问题,然后调用:
241
+ \`sillyspec run brainstorm --wait --reason "等待用户回答需求澄清 Grill" --options "回答见--answer,信息够了,结束需求澄清" --output "你的单个问题或查证结论"\`
242
+ - 用户通过 \`--continue --answer "回答"\` 回答后,本步骤会再次执行;继续处理下一个最关键歧义。
243
+ - 达到 maxWaitRounds=8 后,必须总结已确认内容和剩余风险,不要无限追问。
244
+
245
+ ### 输出
246
+ 需求澄清结论摘要 + D-xxx@vN 决策记录草稿 + 剩余风险(如有)`,
247
+ outputHint: '需求澄清和决策记录草稿',
248
+ optional: true
249
+ },
175
250
  {
176
251
  name: '提出 2-3 种方案',
177
- prompt: `基于需求理解,提出 2-3 种实现方案。
252
+ requiresWait: true,
253
+ waitReason: '等待用户选择方案',
254
+ waitOptions: ['方案A', '方案B', '方案C'],
255
+ prompt: `基于需求理解和 Grill 结果,提出 2-3 种实现方案。
178
256
 
179
257
  ### 操作
180
258
  1. 每种方案列出:核心思路、优势、劣势
181
- 2. 给出推荐方案和理由
259
+ 2. 如果 Grill 产生 D-xxx@vN 决策记录,方案必须说明覆盖/违反哪些当前版本决策
260
+ 3. 给出推荐方案和理由
182
261
 
183
262
  ### 铁律 — 必须等待用户选择方案
184
263
  - **不要替用户选择方案。** 列出方案对比表和推荐后,必须暂停等待用户选择。
@@ -197,6 +276,9 @@ export const definition = {
197
276
  },
198
277
  {
199
278
  name: '分段展示设计',
279
+ requiresWait: true,
280
+ waitReason: '等待用户确认设计方案',
281
+ waitOptions: ['确认', '需要修改', '推翻重来'],
200
282
  prompt: `展示完整设计方案供用户确认。
201
283
 
202
284
  ### 操作
@@ -273,14 +355,26 @@ HTML 原型文件路径(或"跳过"如果不适合)`,
273
355
  |---|---|---|---|
274
356
  | R-01 | ... | P0/P1/P2 | ... |
275
357
 
276
- 11. **自审**(AI 对自身设计的校验)
358
+ 11. **决策追踪**(如存在 Grill/重大决策):
359
+ - 列出当前版本 D-xxx@vN 决策 ID
360
+ - 说明每个 D-xxx@vN 被哪些 FR-xxx / 设计章节覆盖
361
+ - 标注仍未解决的 D-xxx@vN 或剩余风险
362
+ 12. **自审**(AI 对自身设计的校验)
277
363
 
278
364
  ### 操作
279
365
  1. 确认变更目录存在:\`mkdir -p .sillyspec/changes/<change-name>\`(Windows 用 \`mkdir .sillyspec\\changes\\<变更名>\` 或 PowerShell \`New-Item -ItemType Directory -Force -Path .sillyspec/changes/<change-name>\`)
280
366
  - 变更名格式必须为 \`YYYY-MM-DD-<简短描述>\`(如 \`2026-05-13-user-auth\`)
281
367
  2. 将确认的设计写入 \`.sillyspec/changes/<change-name>/design.md\`
282
- 3. 自审检查:
368
+ 3. 如果 Grill 或方案讨论产生了实现相关决策,写入 \`.sillyspec/changes/<change-name>/decisions.md\`:
369
+ - decisions.md 是本次变更的决策台账,不是长期术语表
370
+ - 只记录有实现/验收影响的决策,闲聊和低风险偏好不记录
371
+ - 每条记录必须有稳定版本 ID:D-001@v1、D-002@v1 ...
372
+ - 若后续 Design Grill 修正该决策,新记录使用 D-001@v2,并写明 supersedes: D-001@v1
373
+ - 每条记录必须包含:type、status、source、question、answer、normalized_requirement、impacts、evidence、priority
374
+ - 长期术语只在 archive/scan 时再提升到 \`.sillyspec/docs/<project>/glossary.md\`
375
+ 4. 自审检查:
283
376
  - 需求覆盖:是否完整覆盖对话式探索中确认的需求
377
+ - Grill 覆盖:如果存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
284
378
  - 约束一致性:是否与 CONVENTIONS.md、ARCHITECTURE.md 一致
285
379
  - 真实性:表名/字段名/类名/方法名来自真实代码或标注"新增"
286
380
  - YAGNI:是否包含不必要功能
@@ -288,8 +382,8 @@ HTML 原型文件路径(或"跳过"如果不适合)`,
288
382
  - 非目标清晰:是否明确界定了不做的事
289
383
  - 兼容策略(brownfield):是否说明了回退路径
290
384
  - 风险识别:是否识别了关键技术风险和对策
291
- 4. 自审发现问题 → 修改后重新检查
292
- 5. 全部通过 → 进入下一步
385
+ 5. 自审发现问题 → 修改后重新检查
386
+ 6. 全部通过 → 进入下一步
293
387
 
294
388
  ### 输出
295
389
  design.md 文件路径 + 自审结果
@@ -300,8 +394,108 @@ design.md 文件路径 + 自审结果
300
394
  outputHint: 'design.md 文件路径 + 自审结果',
301
395
  optional: false
302
396
  },
397
+ {
398
+ name: 'Design Grill 交叉审查',
399
+ conditionalWait: true,
400
+ waitReason: '等待用户处理 Design Grill 发现的结构性问题',
401
+ waitOptions: ['按推荐修正', '补充回答', '显式跳过'],
402
+ prompt: `默认执行 Design Grill,对已经写出的 design.md 做交叉审查。
403
+
404
+ ### 定位
405
+ 这是设计完成后的质量门,不是需求探索。目标不是继续发散,而是找出 design.md 内部、四件套之间、文档与外部约束之间的结构性矛盾。
406
+
407
+ ### 默认行为
408
+ 1. 默认必须执行一次交叉审查;不要让用户凭主观判断决定"要不要 Grill"。
409
+ 2. 只有以下情况可以轻量跳过,并必须记录原因:
410
+ - 用户明确要求 no-grill / 显式跳过
411
+ - 文档是一页以内、单模块、无状态流转、无 schema/API/兼容策略变更
412
+ - plan_level 明确为 none,且只改 1-2 个文件
413
+ 3. 即使跳过,也要输出"Design Grill skipped"和原因,不能静默跳过。
414
+
415
+ ### 输入材料
416
+ 1. 必须读取完整 \`.sillyspec/changes/<change-name>/design.md\`
417
+ 2. 读取 proposal.md、requirements.md、tasks.md、decisions.md(如存在)
418
+ 3. 读取 scan/module docs:
419
+ - \`.sillyspec/docs/<project>/scan/ARCHITECTURE.md\`
420
+ - \`.sillyspec/docs/<project>/scan/CONVENTIONS.md\`
421
+ - \`.sillyspec/docs/<project>/modules/_module-map.yaml\`
422
+ - 命中的模块文档
423
+ 4. 按 design.md 文件变更清单读取相关源码、测试、配置、schema 或样例数据;矛盾经常藏在设计与外部约束交叉处,素材宁可多读,不要只读摘要。
424
+
425
+ ### 交叉审查模型
426
+ 按三层检查并输出 cross-check matrix:
427
+ 1. **定义层**:模糊概念是否有可测试定义。例如"高可用""异常数据""本地缓存""重试"。
428
+ 2. **一致性层**:跨章节/跨产物是否打架。例如数据流 vs 容错策略、schema vs 输入格式、非目标 vs tasks。
429
+ 3. **可行性层**:关键假设是否有来源。例如 P99 延迟、上游 SLA、缓存 TTL、数据量、权限模型、兼容旧配置。
430
+
431
+ ### 交叉点抽取
432
+ 重点找这些交叉点:
433
+ - 模块 A 依赖模块 B 的实体/状态/接口
434
+ - requirements.md 的 FR 与 design.md 的数据模型/API/状态机
435
+ - design.md 的容错策略与数据流、缓存、重试、回滚
436
+ - tasks.md 的执行范围与 design.md 的非目标
437
+ - decisions.md 的 D-xxx@vN 与 design.md 当前说法
438
+ - scan/module docs 或源码中的真实约束与 design.md 假设
439
+
440
+ ### 问答处理
441
+ 1. 先自动交叉审查,不要一上来问用户。
442
+ 2. 没有结构性问题:正常完成,输出"Design Grill passed",附 cross-check matrix。
443
+ 3. 发现问题:
444
+ - 对能从代码/文档确定的问题,直接给出推荐修正。
445
+ - 对需要业务判断的问题,每次只问一个最关键问题,然后等待用户。
446
+ - P0/P1 未决项必须进入 Unresolved Blockers,不能带着进入 plan。
447
+ 4. 用户回答后,更新 design.md 和 decisions.md;如果推翻旧决策,新增版本 D-xxx@v2,而不是覆盖 D-xxx@v1。
448
+
449
+ ### decisions.md 版本规则
450
+ \`\`\`markdown
451
+ ## D-001@v2: 缓存异常时的 fallback 语义
452
+ - type: definition | consistency | feasibility | boundary | architecture | compatibility | risk
453
+ - priority: P0 | P1 | P2
454
+ - status: accepted | unresolved | rejected | superseded
455
+ - supersedes: D-001@v1
456
+ - source: design-grill
457
+ - question: §3 数据流与 §7 容错策略冲突时以哪个为准?
458
+ - answer: 采用 §7 的重试语义,缓存只作为只读 fallback。
459
+ - normalized_requirement: TTL 过期且上游仍异常时返回 stale 标记,不刷新缓存。
460
+ - impacts: [FR-02, task-03, verify-02]
461
+ - evidence: design.md §3/§7, src/cache/...
462
+ \`\`\`
463
+
464
+ ### 输出格式
465
+ \`\`\`markdown
466
+ ## Design Grill Result
467
+ status: passed | needs-user-input | blocked | skipped
468
+
469
+ ## Cross-Check Matrix
470
+ | ID | 层级 | 交叉点 | 证据 A | 证据 B | 结论 | 决策 |
471
+ |---|---|---|---|---|---|---|
472
+ | X-001 | consistency | 数据流 vs 容错 | design §3 | design §7 | conflict | D-001@v2 |
473
+
474
+ ## Question Distribution
475
+ | 分类 | 数量 | 含义 |
476
+ |---|---|---|
477
+ | immediately_answered | N | 心里清楚但文档缺失 |
478
+ | needs_thinking | N | 需要用户判断 |
479
+ | unresolved | N | 真正设计漏洞 |
480
+
481
+ ## Unresolved Blockers
482
+ | ID | priority | 问题 | 阻塞原因 | 下一步 |
483
+ |---|---|---|---|---|
484
+ \`\`\`
485
+
486
+ ### 铁律 — 等待用户
487
+ - 发现 P0/P1 结构性矛盾且需要用户判断时,调用:
488
+ \`sillyspec run brainstorm --wait --reason "等待用户处理 Design Grill 发现的结构性问题" --options "按推荐修正,补充回答,显式跳过" --output "Design Grill 问题摘要"\`
489
+ - 用户显式跳过时,必须在 decisions.md 记录 accepted risk;P0/P1 skip 仍必须写入 Unresolved Blockers。
490
+ - 完成前必须确认:没有 P0/P1 unresolved blocker;否则不能进入 plan。`,
491
+ outputHint: 'Design Grill 交叉审查结果',
492
+ optional: false
493
+ },
303
494
  {
304
495
  name: '用户确认并生成规范文件',
496
+ requiresWait: true,
497
+ waitReason: '等待用户最终确认设计方案',
498
+ waitOptions: ['确认', '需要修改', '推翻重来'],
305
499
  prompt: `用户确认设计方案,生成规范文件。
306
500
 
307
501
  ### 操作
@@ -309,9 +503,10 @@ design.md 文件路径 + 自审结果
309
503
  2. 暂停等待用户选择:✅ 确认 / ✏️ 修改 / ❌ 推翻重来
310
504
  3. 确认后,在 \`.sillyspec/changes/<change-name>/\` 下生成所有规范文件:
311
505
  - **design.md**:架构决策、文件变更清单、数据模型、API 设计、兼容策略、风险登记、自审
506
+ - **decisions.md**(可选):Grill/重大决策台账,使用 D-001@v1 稳定版本 ID
312
507
  - **proposal.md**:动机、关键问题(为什么现有方案不够)、变更范围、不在范围内(显式清单)、成功标准(可验证条件)
313
- - **requirements.md**:角色表 + FR 编号需求 + Given/When/Then 行为规格 + 非功能需求
314
- - **tasks.md**:任务列表(只列名称和对应文件路径,细节在 plan 阶段展开)
508
+ - **requirements.md**:角色表 + FR 编号需求 + Given/When/Then 行为规格 + 非功能需求 + D-xxx@vN 覆盖关系
509
+ - **tasks.md**:任务列表(只列名称、对应文件路径、覆盖的 FR-xxx/D-xxx@vN,细节在 plan 阶段展开)
315
510
  - \`git add .sillyspec/\` — 暂存规范文件(不要 commit)
316
511
 
317
512
  所有规范文件头部必须包含 YAML frontmatter:
@@ -357,6 +552,7 @@ created_at: <now-datetime>
357
552
  ## 功能需求
358
553
 
359
554
  ### FR-01: 需求名称
555
+ 覆盖决策:D-001@v1, D-002@v1(如适用)
360
556
  Given 前提条件
361
557
  When 触发动作
362
558
  Then 期望结果
@@ -367,6 +563,28 @@ Then 期望结果
367
563
  - 兼容性:...
368
564
  - 可回退:...
369
565
  - 可测试:...
566
+
567
+ ## 决策覆盖矩阵(如存在 decisions.md)
568
+ | 决策 ID | 覆盖的 FR | 说明 |
569
+ |---|---|---|
570
+ | D-001@v1 | FR-01 | ... |
571
+ \`\`\`
572
+
573
+ ### decisions.md 格式要求(仅在有 Grill/重大决策时生成)
574
+ \`\`\`markdown
575
+ # Decisions
576
+
577
+ ## D-001@v1: 决策短标题
578
+ - type: definition | consistency | feasibility | term | boundary | premise | architecture | compatibility | risk
579
+ - priority: P0 | P1 | P2
580
+ - status: accepted | unresolved | rejected | superseded
581
+ - supersedes:
582
+ - source: user | code | docs
583
+ - question: 被解决的问题
584
+ - answer: 用户确认或代码查证结果
585
+ - normalized_requirement: 可测试的约束
586
+ - impacts: [FR-01, task-01, verify-01]
587
+ - evidence: 用户回答轮次或代码/文档路径
370
588
  \`\`\`
371
589
 
372
590
  ### 后续变更包处理
@@ -395,6 +613,8 @@ Then 期望结果
395
613
  - 禁止自动 commit
396
614
  - 推翻重来回到 Step 6(对话式探索)
397
615
  - 表名/字段名/类名必须来自真实代码或标注"新增"
616
+ - 如果存在 decisions.md,requirements.md 必须引用全部当前版本 D-xxx@vN;没有覆盖的 D-xxx@vN 必须标注为剩余风险
617
+ - 如果 Design Grill 产生 P0/P1 unresolved blocker,必须回到 design 修正,不能进入 plan
398
618
  - tasks.md 只列任务名,细节在 plan 阶段展开`,
399
619
 
400
620
  }