sillyspec 3.17.15 → 3.18.1

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.
@@ -0,0 +1,501 @@
1
+ /**
2
+ * repeatableWait / requiresWait / 普通 wait 门控测试
3
+ *
4
+ * 测试点:
5
+ * 1. repeatableWait --continue 后仍 pending(不 completed)
6
+ * 2. requiresWait --continue 后仍 pending(不 completed)
7
+ * 3. 普通 wait --continue 后 completed
8
+ * 4. requiresWait 直接 --done 被拒绝
9
+ * 5. repeatableWait 多轮后 --done 可推进
10
+ * 6. brainstorm validator 检测四件套缺失
11
+ */
12
+
13
+ import { join, resolve, basename, dirname } from 'path'
14
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'
15
+ import { fileURLToPath, pathToFileURL } from 'url'
16
+ import { execSync } from 'child_process'
17
+ import { tmpdir } from 'os'
18
+
19
+ const __filename = fileURLToPath(import.meta.url)
20
+ const __dirname = dirname(__filename)
21
+ const root = resolve(__dirname, '..')
22
+ const binCLI = join(root, 'bin', 'sillyspec.js')
23
+
24
+ function imp(path) {
25
+ return import(pathToFileURL(path).href)
26
+ }
27
+
28
+ let passed = 0
29
+ let failed = 0
30
+
31
+ function assert(condition, msg) {
32
+ if (condition) {
33
+ console.log(` ✅ PASS: ${msg}`)
34
+ passed++
35
+ } else {
36
+ console.log(` ❌ FAIL: ${msg}`)
37
+ failed++
38
+ }
39
+ }
40
+
41
+ function run(cmd, opts = {}) {
42
+ try {
43
+ return execSync(cmd, { encoding: 'utf8', timeout: 10000, ...opts })
44
+ } catch (e) {
45
+ return (e.stdout || '') + (e.stderr || '')
46
+ }
47
+ }
48
+
49
+ function tmpDir(label) {
50
+ const dir = join(tmpdir(), `sillyspec-wait-test-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`)
51
+ mkdirSync(dir, { recursive: true })
52
+ return dir
53
+ }
54
+
55
+ function cleanup(dir) {
56
+ try { rmSync(dir, { recursive: true, force: true }) } catch {}
57
+ }
58
+
59
+ // ── Setup helper: create initialized project with a change ──
60
+ function setupProject(label) {
61
+ const projectDir = tmpDir(label)
62
+ run(`node "${binCLI}" init "${projectDir}"`)
63
+ // Create a change directory with four-piece files
64
+ const changeName = '2026-06-14-test-change'
65
+ const changeDir = join(projectDir, '.sillyspec', 'changes', changeName)
66
+ mkdirSync(changeDir, { recursive: true })
67
+ // Register the change in DB by running a harmless command
68
+ const specDir = join(projectDir, '.sillyspec')
69
+ return { projectDir, changeName, changeDir, specDir }
70
+ }
71
+
72
+ function writeFourFiles(changeDir) {
73
+ writeFileSync(join(changeDir, 'design.md'), `# Design\n\n## 背景\nTest\n## 文件变更清单\n| 操作 | 文件 | 说明 |\n## 风险登记\n| 编号 | 风险 |\n## 自审\nOK\n`)
74
+ writeFileSync(join(changeDir, 'proposal.md'), `# Proposal\n## 动机\nTest\n## 不在范围内\n- Nothing\n## 成功标准\nWorks\n`)
75
+ writeFileSync(join(changeDir, 'requirements.md'), `# Requirements\n## 角色\n| 角色 |\n## 功能需求\n### FR-01: Test\nGiven X\nWhen Y\nThen Z\n`)
76
+ writeFileSync(join(changeDir, 'tasks.md'), `# Tasks\n- [ ] Task 1: do something\n- [ ] Task 2: do more\n`)
77
+ }
78
+
79
+ // ── Read progress from DB ──
80
+ async function readProgress(projectDir, changeName) {
81
+ const { ProgressManager } = await imp(join(root, 'src', 'progress.js'))
82
+ const pm = new ProgressManager()
83
+ const progress = await pm.read(projectDir, changeName)
84
+ return progress
85
+ }
86
+
87
+ async function writeProgress(projectDir, changeName, progress) {
88
+ const { ProgressManager } = await imp(join(root, 'src', 'progress.js'))
89
+ const pm = new ProgressManager()
90
+ await pm._write(projectDir, progress, changeName)
91
+ }
92
+
93
+ // ================================================================
94
+ // Test 1: repeatableWait --continue 后仍 pending
95
+ // ================================================================
96
+ console.log('\n=== Test 1: repeatableWait --continue 后仍 pending ===')
97
+ {
98
+ const { projectDir, changeName } = setupProject('repeatable')
99
+
100
+ // Init brainstorm stage for the change
101
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
102
+
103
+ // Read progress, find the repeatable step (对话式探索 = step 6, index 5)
104
+ const progress = await readProgress(projectDir, changeName)
105
+ const brainstormData = progress.stages.brainstorm
106
+ assert(brainstormData && brainstormData.steps, 'brainstorm steps initialized')
107
+
108
+ // Find 对话式探索 step index
109
+ const exploreIdx = brainstormData.steps.findIndex(s => s.name === '对话式探索')
110
+ assert(exploreIdx !== -1, '找到"对话式探索"步骤')
111
+
112
+ // Set steps before explore to completed, set explore to waiting
113
+ for (let i = 0; i < exploreIdx; i++) {
114
+ brainstormData.steps[i].status = 'completed'
115
+ brainstormData.steps[i].completedAt = new Date().toISOString()
116
+ }
117
+ brainstormData.steps[exploreIdx].status = 'waiting'
118
+ brainstormData.steps[exploreIdx].waitReason = '等待用户回答需求问题'
119
+ brainstormData.steps[exploreIdx].waitOptions = ['继续补充', '信息够了']
120
+ brainstormData.steps[exploreIdx].waitedAt = new Date().toISOString()
121
+ brainstormData.steps[exploreIdx].output = '你到底想要什么?'
122
+ await writeProgress(projectDir, changeName, progress)
123
+
124
+ // Now --continue with an answer
125
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "我想要一个用户管理系统" --change ${changeName}`)
126
+ assert(output.includes('回到当前步骤'), 'continue 输出包含"回到当前步骤"')
127
+ assert(output.includes('🔁'), 'continue 输出包含 🔁 标记')
128
+
129
+ // Read progress again, check step is still pending (not completed)
130
+ const progress2 = await readProgress(projectDir, changeName)
131
+ const step = progress2.stages.brainstorm.steps[exploreIdx]
132
+ assert(step.status === 'pending', `"对话式探索" --continue 后状态是 pending (实际: ${step.status})`)
133
+ assert(Array.isArray(step.waitAnswers) && step.waitAnswers.length === 1, `waitAnswers 有 1 条记录 (实际: ${JSON.stringify(step.waitAnswers)})`)
134
+ assert(step.waitRound === 1, `waitRound === 1 (实际: ${step.waitRound})`)
135
+
136
+ cleanup(projectDir)
137
+ }
138
+
139
+ // ================================================================
140
+ // Test 2: requiresWait --continue 后仍 pending
141
+ // ================================================================
142
+ console.log('\n=== Test 2: requiresWait --continue 后仍 pending ===')
143
+ {
144
+ const { projectDir, changeName } = setupProject('requires')
145
+
146
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
147
+
148
+ const progress = await readProgress(projectDir, changeName)
149
+ const brainstormData = progress.stages.brainstorm
150
+
151
+ // Find 提出 2-3 种方案 step
152
+ const proposeIdx = brainstormData.steps.findIndex(s => s.name === '提出 2-3 种方案')
153
+ assert(proposeIdx !== -1, '找到"提出 2-3 种方案"步骤')
154
+
155
+ // Set all steps before it to completed, set it to waiting
156
+ for (let i = 0; i < proposeIdx; i++) {
157
+ brainstormData.steps[i].status = 'completed'
158
+ brainstormData.steps[i].completedAt = new Date().toISOString()
159
+ }
160
+ brainstormData.steps[proposeIdx].status = 'waiting'
161
+ brainstormData.steps[proposeIdx].waitReason = '等待用户选择方案'
162
+ brainstormData.steps[proposeIdx].waitOptions = ['方案A', '方案B', '方案C']
163
+ brainstormData.steps[proposeIdx].waitedAt = new Date().toISOString()
164
+ brainstormData.steps[proposeIdx].output = '方案A vs 方案B'
165
+ await writeProgress(projectDir, changeName, progress)
166
+
167
+ // --continue with selection
168
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "方案A" --change ${changeName}`)
169
+ assert(output.includes('回到当前步骤'), 'continue 输出包含"回到当前步骤"')
170
+
171
+ // Check step is pending, not completed
172
+ const progress2 = await readProgress(projectDir, changeName)
173
+ const step = progress2.stages.brainstorm.steps[proposeIdx]
174
+ assert(step.status === 'pending', `"提出方案" --continue 后状态是 pending (实际: ${step.status})`)
175
+ assert(step.waitAnswer === '方案A', 'waitAnswer === 方案A')
176
+
177
+ cleanup(projectDir)
178
+ }
179
+
180
+ // ================================================================
181
+ // Test 3: 普通 wait --continue 后 completed
182
+ // ================================================================
183
+ console.log('\n=== Test 3: 普通 wait --continue 后 completed ===')
184
+ {
185
+ const { projectDir, changeName } = setupProject('normal-wait')
186
+
187
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
188
+
189
+ const progress = await readProgress(projectDir, changeName)
190
+ const brainstormData = progress.stages.brainstorm
191
+
192
+ // Find 状态检查 (step 0) — it's a normal step, not requiresWait
193
+ // Simulate it being in waiting state (as if someone manually --wait'd it)
194
+ brainstormData.steps[0].status = 'waiting'
195
+ brainstormData.steps[0].waitReason = '等待原因'
196
+ brainstormData.steps[0].waitedAt = new Date().toISOString()
197
+ await writeProgress(projectDir, changeName, progress)
198
+
199
+ // --continue
200
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "继续" --change ${changeName}`)
201
+
202
+ const progress2 = await readProgress(projectDir, changeName)
203
+ const step = progress2.stages.brainstorm.steps[0]
204
+ assert(step.status === 'completed', `普通 wait --continue 后 completed (实际: ${step.status})`)
205
+
206
+ cleanup(projectDir)
207
+ }
208
+
209
+ // ================================================================
210
+ // Test 4: requiresWait 直接 --done 被拒绝
211
+ // ================================================================
212
+ console.log('\n=== Test 4: requiresWait 直接 --done 被拒绝 ===')
213
+ {
214
+ const { projectDir, changeName } = setupProject('refuse-done')
215
+
216
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
217
+
218
+ const progress = await readProgress(projectDir, changeName)
219
+ const brainstormData = progress.stages.brainstorm
220
+
221
+ // Find 提出 2-3 种方案 (requiresWait=true)
222
+ const proposeIdx = brainstormData.steps.findIndex(s => s.name === '提出 2-3 种方案')
223
+ for (let i = 0; i < proposeIdx; i++) {
224
+ brainstormData.steps[i].status = 'completed'
225
+ brainstormData.steps[i].completedAt = new Date().toISOString()
226
+ }
227
+ // Step is pending, no waitAnswer
228
+ brainstormData.steps[proposeIdx].status = 'pending'
229
+ await writeProgress(projectDir, changeName, progress)
230
+
231
+ // Try --done directly (should fail because requiresWait && !waitAnswer)
232
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --output "推荐方案A" --change ${changeName}`)
233
+ assert(output.includes('必须先等待用户输入') || output.includes('不能直接'), 'requiresWait 步骤直接 --done 被拒绝')
234
+
235
+ // Verify step is still pending
236
+ const progress2 = await readProgress(projectDir, changeName)
237
+ const step = progress2.stages.brainstorm.steps[proposeIdx]
238
+ assert(step.status === 'pending', `拒绝后步骤仍是 pending (实际: ${step.status})`)
239
+
240
+ cleanup(projectDir)
241
+ }
242
+
243
+ // ================================================================
244
+ // Test 5: repeatableWait 多轮收集后 --done 可推进
245
+ // ================================================================
246
+ console.log('\n=== Test 5: repeatableWait 多轮后 --done 可推进 ===')
247
+ {
248
+ const { projectDir, changeName } = setupProject('multi-round')
249
+
250
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
251
+
252
+ const progress = await readProgress(projectDir, changeName)
253
+ const brainstormData = progress.stages.brainstorm
254
+
255
+ const exploreIdx = brainstormData.steps.findIndex(s => s.name === '对话式探索')
256
+ for (let i = 0; i < exploreIdx; i++) {
257
+ brainstormData.steps[i].status = 'completed'
258
+ brainstormData.steps[i].completedAt = new Date().toISOString()
259
+ }
260
+ brainstormData.steps[exploreIdx].status = 'waiting'
261
+ brainstormData.steps[exploreIdx].waitReason = '等待用户回答'
262
+ brainstormData.steps[exploreIdx].waitedAt = new Date().toISOString()
263
+ brainstormData.steps[exploreIdx].output = '问题1'
264
+ await writeProgress(projectDir, changeName, progress)
265
+
266
+ // Round 1
267
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "回答1" --change ${changeName}`)
268
+ let p2 = await readProgress(projectDir, changeName)
269
+ assert(p2.stages.brainstorm.steps[exploreIdx].status === 'pending', '第1轮后仍 pending')
270
+ assert(p2.stages.brainstorm.steps[exploreIdx].waitAnswers.length === 1, '1 条历史回答')
271
+
272
+ // Simulate another --wait cycle
273
+ p2.stages.brainstorm.steps[exploreIdx].status = 'waiting'
274
+ p2.stages.brainstorm.steps[exploreIdx].waitReason = '等待用户回答'
275
+ p2.stages.brainstorm.steps[exploreIdx].waitedAt = new Date().toISOString()
276
+ p2.stages.brainstorm.steps[exploreIdx].output = '问题2'
277
+ await writeProgress(projectDir, changeName, p2)
278
+
279
+ // Round 2
280
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "回答2" --change ${changeName}`)
281
+ let p3 = await readProgress(projectDir, changeName)
282
+ assert(p3.stages.brainstorm.steps[exploreIdx].status === 'pending', '第2轮后仍 pending')
283
+ assert(p3.stages.brainstorm.steps[exploreIdx].waitAnswers.length === 2, '2 条历史回答')
284
+
285
+ // Now agent does --done (has waitAnswer so should pass requiresWait gate)
286
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --output "需求已明确" --change ${changeName}`)
287
+ let p4 = await readProgress(projectDir, changeName)
288
+ assert(p4.stages.brainstorm.steps[exploreIdx].status === 'completed', '--done 后步骤 completed')
289
+
290
+ cleanup(projectDir)
291
+ }
292
+
293
+ // ================================================================
294
+ // Test 6: brainstorm validator 检测四件套缺失
295
+ // ================================================================
296
+ console.log('\n=== Test 6: brainstorm validator 检测四件套缺失 ===')
297
+ {
298
+ const { projectDir, changeName, changeDir } = setupProject('validator')
299
+ // Don't write the four files — validator should catch this
300
+
301
+ const { runValidators } = await imp(join(root, 'src', 'stage-contract.js'))
302
+ const result = runValidators('brainstorm', projectDir, changeName, {})
303
+ assert(result.ok === false, '四件套缺失时 ok=false')
304
+ assert(result.errors.length >= 4, `至少 4 个 error (实际: ${result.errors.length})`)
305
+ assert(result.errors.some(e => e.includes('design.md')), 'error 包含 design.md')
306
+ assert(result.errors.some(e => e.includes('proposal.md')), 'error 包含 proposal.md')
307
+ assert(result.errors.some(e => e.includes('requirements.md')), 'error 包含 requirements.md')
308
+ assert(result.errors.some(e => e.includes('tasks.md')), 'error 包含 tasks.md')
309
+
310
+ // Now write all four files
311
+ writeFourFiles(changeDir)
312
+ const result2 = runValidators('brainstorm', projectDir, changeName, {})
313
+ assert(result2.ok === true, '四件套存在时 ok=true')
314
+ assert(result2.errors.length === 0, '四件套存在时无 error')
315
+
316
+ cleanup(projectDir)
317
+ }
318
+
319
+ // ================================================================
320
+ // Test 7: tasks.md 空列表只 warning 不 error
321
+ // ================================================================
322
+ console.log('\n=== Test 7: tasks.md 空列表只 warning 不 error ===')
323
+ {
324
+ const { projectDir, changeName, changeDir } = setupProject('empty-tasks')
325
+
326
+ writeFileSync(join(changeDir, 'design.md'), `# Design\n## 背景\n## 文件变更清单\n## 风险登记\n## 自审\n`)
327
+ writeFileSync(join(changeDir, 'proposal.md'), `# Proposal\n## 不在范围内\n- x\n`)
328
+ writeFileSync(join(changeDir, 'requirements.md'), `# Requirements\n### FR-01: x\nGiven\nWhen\nThen\n`)
329
+ writeFileSync(join(changeDir, 'tasks.md'), `# Tasks\n(no items)\n`) // no list items
330
+
331
+ const { runValidators } = await imp(join(root, 'src', 'stage-contract.js'))
332
+ const result = runValidators('brainstorm', projectDir, changeName, {})
333
+ assert(result.ok === true, '空 tasks.md → ok=true (文件存在)')
334
+ assert(result.warnings.some(w => w.includes('任务列表')), '空 tasks.md → warning about 任务列表')
335
+
336
+ cleanup(projectDir)
337
+ }
338
+
339
+
340
+ // ================================================================
341
+ // Test 8: 多个 waiting 步骤 → --continue 必须报错
342
+ // ================================================================
343
+ console.log('\n=== Test 8: 多个 waiting 步骤 → --continue 报错 ===')
344
+ {
345
+ const { projectDir, changeName } = setupProject('multi-waiting')
346
+
347
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
348
+
349
+ const progress = await readProgress(projectDir, changeName)
350
+ const brainstormData = progress.stages.brainstorm
351
+ // Set two steps to waiting
352
+ brainstormData.steps[0].status = 'waiting'
353
+ brainstormData.steps[0].waitReason = '等待1'
354
+ brainstormData.steps[0].waitedAt = new Date().toISOString()
355
+ brainstormData.steps[1].status = 'waiting'
356
+ brainstormData.steps[1].waitReason = '等待2'
357
+ brainstormData.steps[1].waitedAt = new Date().toISOString()
358
+ await writeProgress(projectDir, changeName, progress)
359
+
360
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "test" --change ${changeName}`)
361
+ assert(output.includes('检测到') && output.includes('等待中的步骤'), '多个 waiting 步骤时报错')
362
+
363
+ cleanup(projectDir)
364
+ }
365
+
366
+ // ================================================================
367
+ // Test 9: --skip-approval 不能绕过 requiresWait
368
+ // ================================================================
369
+ console.log('\n=== Test 9: --skip-approval 不能绕过 requiresWait ===')
370
+ {
371
+ const { projectDir, changeName } = setupProject('skip-approval')
372
+
373
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
374
+
375
+ const progress = await readProgress(projectDir, changeName)
376
+ const brainstormData = progress.stages.brainstorm
377
+ const proposeIdx = brainstormData.steps.findIndex(s => s.name === '提出 2-3 种方案')
378
+ for (let i = 0; i < proposeIdx; i++) {
379
+ brainstormData.steps[i].status = 'completed'
380
+ brainstormData.steps[i].completedAt = new Date().toISOString()
381
+ }
382
+ brainstormData.steps[proposeIdx].status = 'pending'
383
+ await writeProgress(projectDir, changeName, progress)
384
+
385
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --skip-approval --output "推荐方案A" --change ${changeName}`)
386
+ assert(output.includes('必须先等待用户输入') || output.includes('不能直接'), '--skip-approval 无法绕过 requiresWait')
387
+
388
+ cleanup(projectDir)
389
+ }
390
+
391
+ // ================================================================
392
+ // Test 10: repeatableWait 达到 maxWaitRounds 后再次 --wait 被拒绝
393
+ // ================================================================
394
+ console.log('\n=== Test 10: maxWaitRounds 达到上限后 --wait 被拒绝 ===')
395
+ {
396
+ const { projectDir, changeName } = setupProject('max-rounds')
397
+
398
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
399
+
400
+ const progress = await readProgress(projectDir, changeName)
401
+ const brainstormData = progress.stages.brainstorm
402
+ const exploreIdx = brainstormData.steps.findIndex(s => s.name === '对话式探索')
403
+ for (let i = 0; i < exploreIdx; i++) {
404
+ brainstormData.steps[i].status = 'completed'
405
+ brainstormData.steps[i].completedAt = new Date().toISOString()
406
+ }
407
+ // Simulate already having maxRounds (3) rounds
408
+ brainstormData.steps[exploreIdx].status = 'pending'
409
+ brainstormData.steps[exploreIdx].waitRound = 3
410
+ brainstormData.steps[exploreIdx].maxWaitRounds = 3
411
+ brainstormData.steps[exploreIdx].waitAnswer = 'previous answer'
412
+ await writeProgress(projectDir, changeName, progress)
413
+
414
+ // Try to --wait again (should be refused)
415
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --wait --reason "还想再问" --output "再问一个问题" --change ${changeName}`)
416
+ assert(output.includes('已达到最大等待轮次') || output.includes('maxWaitRounds'), '达到 maxWaitRounds 后 --wait 被拒绝')
417
+
418
+ // But --done should still work (has waitAnswer)
419
+ const output2 = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --output "需求已明确" --change ${changeName}`)
420
+ const progress2 = await readProgress(projectDir, changeName)
421
+ const step = progress2.stages.brainstorm.steps[exploreIdx]
422
+ assert(step.status === 'completed', '达到上限后 --done 仍可推进')
423
+
424
+ cleanup(projectDir)
425
+ }
426
+
427
+ // ================================================================
428
+ // Test 11: specRoot 模式下 changes 缺失 → validator error
429
+ // ================================================================
430
+ console.log('\n=== Test 11: specRoot 模式下 changes 缺失 → fail-fast ===')
431
+ {
432
+ const specRoot = tmpDir('specroot-nofail')
433
+ const { runValidators } = await imp(join(root, 'src', 'stage-contract.js'))
434
+ // specRoot exists but has no changes/ directory
435
+ const result = runValidators('brainstorm', '/some/project', 'test-change', { specRoot })
436
+ assert(result.ok === false, 'specRoot 缺 changes 目录时 ok=false')
437
+ assert(result.errors.some(e => e.includes('缺少 changes 目录')), 'error 包含「缺少 changes 目录」')
438
+
439
+ cleanup(specRoot)
440
+ }
441
+
442
+ // ================================================================
443
+ // Test 12: waitRound=0 正确写入读取(不被 || 吞掉)
444
+ // ================================================================
445
+ console.log('\n=== Test 12: waitRound=0 正确持久化 ===')
446
+ {
447
+ const { projectDir, changeName } = setupProject('waitround-zero')
448
+
449
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
450
+
451
+ const progress = await readProgress(projectDir, changeName)
452
+ const brainstormData = progress.stages.brainstorm
453
+ // Manually set waitRound=0 (simulates edge case)
454
+ brainstormData.steps[0].status = 'pending'
455
+ brainstormData.steps[0].waitRound = 0
456
+ brainstormData.steps[0].maxWaitRounds = 3
457
+ await writeProgress(projectDir, changeName, progress)
458
+
459
+ const p2 = await readProgress(projectDir, changeName)
460
+ // waitRound=0 should be read back as 0 (not null/undefined)
461
+ assert(p2.stages.brainstorm.steps[0].waitRound === 0, `waitRound=0 正确读取 (实际: ${p2.stages.brainstorm.steps[0].waitRound})`)
462
+ assert(p2.stages.brainstorm.steps[0].maxWaitRounds === 3, `maxWaitRounds=3 正确读取 (实际: ${p2.stages.brainstorm.steps[0].maxWaitRounds})`)
463
+
464
+ cleanup(projectDir)
465
+ }
466
+
467
+ // ================================================================
468
+ // Test 13: 已有 waiting 步骤时 --wait 被拒绝
469
+ // ================================================================
470
+ console.log('\n=== Test 13: 已有 waiting 步骤时 --wait 被拒绝 ===')
471
+ {
472
+ const { projectDir, changeName } = setupProject('existing-wait')
473
+
474
+ run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
475
+
476
+ const progress = await readProgress(projectDir, changeName)
477
+ const brainstormData = progress.stages.brainstorm
478
+ // Set step 0 to waiting
479
+ brainstormData.steps[0].status = 'waiting'
480
+ brainstormData.steps[0].waitReason = '等待中'
481
+ brainstormData.steps[0].waitedAt = new Date().toISOString()
482
+ await writeProgress(projectDir, changeName, progress)
483
+
484
+ // Try --wait on another step (should be refused)
485
+ const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --wait --reason "新问题" --output "新等待" --change ${changeName}`)
486
+ assert(output.includes('已有步骤处于等待状态') || output.includes('等待状态'), '已有 waiting 步骤时 --wait 被拒绝')
487
+
488
+ cleanup(projectDir)
489
+ }
490
+
491
+ // ── Summary ──
492
+ console.log(`\n${'='.repeat(50)}`)
493
+ if (failed === 0) {
494
+ console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
495
+ console.log(`${'='.repeat(50)}`)
496
+ console.log('\n🎉 wait gates 测试全部通过!')
497
+ } else {
498
+ console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
499
+ console.log(`${'='.repeat(50)}`)
500
+ }
501
+ process.exit(failed > 0 ? 1 : 0)
@@ -48,6 +48,64 @@ try {
48
48
  'ordinary .sillyspec docs should remain writable'
49
49
  )
50
50
 
51
+ writeFileSync(join(runtimeDir, 'gate-status.json'), JSON.stringify({
52
+ stage: 'scan',
53
+ changes: [changeName],
54
+ updatedAt: new Date().toISOString(),
55
+ }, null, 2))
56
+ writeFileSync(join(runtimeDir, 'scan-guard.json'), JSON.stringify({
57
+ sourceCommit: 'new-head',
58
+ startedAt: '2026-06-16T10:00:00.000Z',
59
+ forceRescan: false,
60
+ }, null, 2))
61
+ const scanDoc = join(root, '.sillyspec', 'docs', 'app', 'scan', 'ARCHITECTURE.md')
62
+ mkdirSync(join(root, '.sillyspec', 'docs', 'app', 'scan'), { recursive: true })
63
+ writeFileSync(scanDoc, [
64
+ '---',
65
+ 'source_commit: old-head',
66
+ 'updated_at: 2026-06-16T09:00:00.000Z',
67
+ '---',
68
+ '# Architecture',
69
+ '',
70
+ ].join('\n'))
71
+ assert.equal(
72
+ shouldBlock({ tool: 'Write', filePath: scanDoc, cwd: root }).blocked,
73
+ true,
74
+ 'scan overwrite should block stale source_commit without --force-rescan'
75
+ )
76
+ writeFileSync(join(runtimeDir, 'scan-guard.json'), JSON.stringify({
77
+ sourceCommit: 'new-head',
78
+ startedAt: '2026-06-16T10:00:00.000Z',
79
+ forceRescan: true,
80
+ }, null, 2))
81
+ assert.equal(
82
+ shouldBlock({ tool: 'Write', filePath: scanDoc, cwd: root }).blocked,
83
+ false,
84
+ 'scan overwrite should allow stale source_commit with --force-rescan'
85
+ )
86
+ const externalSpec = join(root, 'external-spec')
87
+ const externalScanDoc = join(externalSpec, 'docs', 'app', 'scan', 'ARCHITECTURE.md')
88
+ mkdirSync(join(externalSpec, '.runtime'), { recursive: true })
89
+ mkdirSync(join(externalSpec, 'docs', 'app', 'scan'), { recursive: true })
90
+ writeFileSync(join(externalSpec, '.runtime', 'scan-guard.json'), JSON.stringify({
91
+ sourceCommit: 'external-new-head',
92
+ startedAt: '2026-06-16T10:00:00.000Z',
93
+ forceRescan: false,
94
+ }, null, 2))
95
+ writeFileSync(externalScanDoc, [
96
+ '---',
97
+ 'source_commit: external-old-head',
98
+ 'updated_at: 2026-06-16T09:00:00.000Z',
99
+ '---',
100
+ '# Architecture',
101
+ '',
102
+ ].join('\n'))
103
+ assert.equal(
104
+ shouldBlock({ tool: 'Write', filePath: externalScanDoc, cwd: root }).blocked,
105
+ true,
106
+ 'external specRoot scan overwrite should read specRoot/.runtime/scan-guard.json'
107
+ )
108
+
51
109
  rmSync(join(runtimeDir, 'gate-status.json'), { force: true })
52
110
  writeFileSync(join(root, '.sillyspec', 'local.yaml'), [
53
111
  'worktreeHook:',