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
@@ -0,0 +1,1145 @@
1
+ /**
2
+ * Revision v1 测试
3
+ *
4
+ * 覆盖 10 个核心 case:
5
+ * 1. completed 阶段直接 run → 拒绝
6
+ * 2. completed + --reopen 无 --from-step → 拒绝
7
+ * 3. --reopen --from-step index → 正确标记步骤
8
+ * 4. --reopen --from-step name → 正确标记步骤
9
+ * 5. --from-step 不存在 → fail-fast
10
+ * 6. --from-step 后续步骤 stale
11
+ * 7. 下游 stage cascade stale
12
+ * 8. stale 下游直接 run → 拒绝
13
+ * 9. waiting/pending 阶段 --reopen 无 from-step → 允许继续
14
+ * 10. reopen 不改动产物文件
15
+ */
16
+
17
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs'
18
+ import { join } from 'path'
19
+ import { tmpdir } from 'os'
20
+
21
+ let failed = 0
22
+ const failures = []
23
+
24
+ function assert(condition, msg) {
25
+ if (!condition) {
26
+ failed++
27
+ failures.push(msg)
28
+ console.log(` ❌ FAIL: ${msg}`)
29
+ } else {
30
+ console.log(` ✅ PASS: ${msg}`)
31
+ }
32
+ }
33
+
34
+ // ── 辅助:创建临时项目 ──
35
+ function createTempProject() {
36
+ const cwd = mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-'))
37
+ const specDir = join(cwd, '.sillyspec')
38
+ mkdirSync(join(specDir, '.runtime'), { recursive: true })
39
+ mkdirSync(join(specDir, 'changes'), { recursive: true })
40
+ return { cwd, specDir }
41
+ }
42
+
43
+ // ── 辅助:初始化 DB + change ──
44
+ async function setupProgress(cwd, changeName = 'test-change') {
45
+ const { ProgressManager } = await import('../src/progress.js')
46
+ const pm = new ProgressManager()
47
+ await pm.init(cwd)
48
+ await pm.initChange(cwd, changeName)
49
+ return pm
50
+ }
51
+
52
+ // ── 辅助:标记阶段 completed + 填充步骤 ──
53
+ async function markStageCompleted(pm, cwd, changeName, stageName, stepNames) {
54
+ const data = await pm.read(cwd, changeName)
55
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
56
+ data.stages[stageName] = {
57
+ status: 'completed',
58
+ startedAt: now,
59
+ completedAt: now,
60
+ steps: stepNames.map(name => ({ name, status: 'completed', completedAt: now })),
61
+ }
62
+ data.currentStage = stageName
63
+ await pm._write(cwd, data, changeName)
64
+ }
65
+
66
+ console.log('=== Revision v1 测试 ===\n')
67
+
68
+ // ─────────────────────────────────────────
69
+ // Case 1: completed 阶段直接 run → 拒绝
70
+ // ─────────────────────────────────────────
71
+ console.log('--- Case 1: completed 阶段直接 run 拒绝 ---')
72
+ {
73
+ const { cwd } = createTempProject()
74
+ const changeName = 'rev-test-1'
75
+ const pm = await setupProgress(cwd, changeName)
76
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['需求澄清', '方案发散', '方案选择'])
77
+
78
+ // 模拟 run 命令的规则 1 检查
79
+ const data = await pm.read(cwd, changeName)
80
+ const stageStatus = data.stages['brainstorm']?.status
81
+ const isReopen = false
82
+
83
+ const shouldReject = stageStatus === 'completed' && !isReopen
84
+ assert(shouldReject, 'completed 阶段直接 run 应被拒绝')
85
+
86
+ // 验证 reopen 能解决
87
+ const reopenResult = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 2, changeName })
88
+ assert(reopenResult.ok === true, '--reopen 应该能打开 completed 阶段')
89
+ }
90
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true }) // noop cleanup placeholder
91
+
92
+ // ─────────────────────────────────────────
93
+ // Case 2: completed + --reopen 无 --from-step → 拒绝
94
+ // ─────────────────────────────────────────
95
+ console.log('\n--- Case 2: completed + --reopen 无 --from-step 拒绝 ---')
96
+ {
97
+ const { cwd } = createTempProject()
98
+ const changeName = 'rev-test-2'
99
+ const pm = await setupProgress(cwd, changeName)
100
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['需求澄清', '方案发散', '方案选择'])
101
+
102
+ // reopen 不带 fromStep,且所有步骤 completed → 应该失败
103
+ const result = await pm.reopenStage(cwd, 'brainstorm', { changeName })
104
+ assert(!result.ok, '所有步骤 completed 且不带 fromStep 时应拒绝')
105
+ assert(result.error && result.error.includes('--from-step'), '错误提示应包含 --from-step')
106
+ }
107
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
108
+
109
+ // ─────────────────────────────────────────
110
+ // Case 3: --reopen --from-step index → 正确标记步骤
111
+ // ─────────────────────────────────────────
112
+ console.log('\n--- Case 3: --reopen --from-step index 正确标记步骤 ---')
113
+ {
114
+ const { cwd } = createTempProject()
115
+ const changeName = 'rev-test-3'
116
+ const pm = await setupProgress(cwd, changeName)
117
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['需求澄清', '方案发散', '方案选择', '设计整理'])
118
+
119
+ const result = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 3, changeName })
120
+ assert(result.ok, 'reopen with fromStep=3 应成功')
121
+ assert(result.revision === 1, 'revision 应为 1')
122
+
123
+ const data = await pm.read(cwd, changeName)
124
+ const steps = data.stages['brainstorm'].steps
125
+ assert(steps[0].status === 'completed', 'step 1 (index 0) 应保持 completed')
126
+ assert(steps[1].status === 'completed', 'step 2 (index 1) 应保持 completed')
127
+ assert(steps[2].status === 'pending', 'step 3 (index 2) 应为 pending')
128
+ assert(steps[3].status === 'stale', 'step 4 (index 3) 应为 stale')
129
+ assert(data.stages['brainstorm'].status === 'revising', 'stage 状态应为 revising')
130
+ }
131
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
132
+
133
+ // ─────────────────────────────────────────
134
+ // Case 4: --reopen --from-step name → 正确标记步骤
135
+ // ─────────────────────────────────────────
136
+ console.log('\n--- Case 4: --reopen --from-step name 正确标记步骤 ---')
137
+ {
138
+ const { cwd } = createTempProject()
139
+ const changeName = 'rev-test-4'
140
+ const pm = await setupProgress(cwd, changeName)
141
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['复杂度分类', '状态检查', '上下文加载', 'Wave 分组'])
142
+
143
+ const result = await pm.reopenStage(cwd, 'plan', { fromStep: '上下文加载', changeName })
144
+ assert(result.ok, 'reopen with fromStep="上下文加载" 应成功')
145
+ assert(result.fromStep === '上下文加载', 'fromStep 应为 "上下文加载"')
146
+
147
+ const data = await pm.read(cwd, changeName)
148
+ const steps = data.stages['plan'].steps
149
+ assert(steps[0].status === 'completed', 'step 1 应保持 completed')
150
+ assert(steps[1].status === 'completed', 'step 2 应保持 completed')
151
+ assert(steps[2].status === 'pending', 'step 3 (上下文加载) 应为 pending')
152
+ assert(steps[3].status === 'stale', 'step 4 应为 stale')
153
+ }
154
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
155
+
156
+ // ─────────────────────────────────────────
157
+ // Case 5: --from-step 不存在 → fail-fast
158
+ // ─────────────────────────────────────────
159
+ console.log('\n--- Case 5: --from-step 不存在 fail-fast ---')
160
+ {
161
+ const { cwd } = createTempProject()
162
+ const changeName = 'rev-test-5'
163
+ const pm = await setupProgress(cwd, changeName)
164
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['需求澄清', '方案发散'])
165
+
166
+ // 名称不存在
167
+ const result1 = await pm.reopenStage(cwd, 'brainstorm', { fromStep: '不存在的步骤', changeName })
168
+ assert(!result1.ok, '不存在的步骤名应失败')
169
+ assert(result1.error && result1.error.includes('步骤不存在'), '错误应包含"步骤不存在"')
170
+
171
+ // 序号超出范围
172
+ const result2 = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 99, changeName })
173
+ assert(!result2.ok, '超出范围的序号应失败')
174
+ assert(result2.error && result2.error.includes('超出范围'), '错误应包含"超出范围"')
175
+ }
176
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
177
+
178
+ // ─────────────────────────────────────────
179
+ // Case 6: --from-step 后续步骤 stale
180
+ // ─────────────────────────────────────────
181
+ console.log('\n--- Case 6: --from-step 后续步骤 stale ---')
182
+ {
183
+ const { cwd } = createTempProject()
184
+ const changeName = 'rev-test-6'
185
+ const pm = await setupProgress(cwd, changeName)
186
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2', 's3', 's4', 's5'])
187
+
188
+ const result = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 2, changeName })
189
+ assert(result.ok, 'reopen fromStep=2 应成功')
190
+
191
+ const data = await pm.read(cwd, changeName)
192
+ const steps = data.stages['brainstorm'].steps
193
+ assert(steps[0].status === 'completed', 's1 保持 completed')
194
+ assert(steps[1].status === 'pending', 's2 变为 pending')
195
+ assert(steps[2].status === 'stale', 's3 变为 stale')
196
+ assert(steps[3].status === 'stale', 's4 变为 stale')
197
+ assert(steps[4].status === 'stale', 's5 变为 stale')
198
+ }
199
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
200
+
201
+ // ─────────────────────────────────────────
202
+ // Case 7: 下游 stage cascade stale
203
+ // ─────────────────────────────────────────
204
+ console.log('\n--- Case 7: 下游 stage cascade stale ---')
205
+ {
206
+ const { cwd } = createTempProject()
207
+ const changeName = 'rev-test-7'
208
+ const pm = await setupProgress(cwd, changeName)
209
+ // brain + plan + execute 都 completed
210
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2', 's3'])
211
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
212
+ await markStageCompleted(pm, cwd, changeName, 'execute', ['e1', 'e2'])
213
+ await markStageCompleted(pm, cwd, changeName, 'verify', ['v1', 'v2'])
214
+
215
+ // reopen brainstorm from step 2
216
+ const result = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 2, changeName })
217
+ assert(result.ok, 'reopen brainstorm fromStep=2 应成功')
218
+
219
+ const data = await pm.read(cwd, changeName)
220
+ assert(data.stages['brainstorm'].status === 'revising', 'brainstorm 应为 revising')
221
+ assert(data.stages['plan'].status === 'stale', 'plan 应为 stale')
222
+ assert(data.stages['execute'].status === 'stale', 'execute 应为 stale')
223
+ assert(data.stages['verify'].status === 'stale', 'verify 应为 stale')
224
+ assert(data.stages['archive'].status !== 'stale' || data.stages['archive'].status === 'pending', 'archive 保持 pending(未执行过的不会变 stale)')
225
+
226
+ // 检查 staleReason
227
+ const planStaleReason = data.stages['plan'].staleReason
228
+ assert(planStaleReason && planStaleReason.includes('brainstorm'), 'plan staleReason 应包含 brainstorm')
229
+
230
+ // 验证 scan 不在 cascade 中(scan 是 brainstorm 的上游)
231
+ assert(data.stages['scan'].status !== 'stale', 'scan 不应被 cascade(它是 brainstorm 的上游,不是下游)')
232
+ }
233
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
234
+
235
+ // ─────────────────────────────────────────
236
+ // Case 8: stale 下游直接 run → 拒绝
237
+ // ─────────────────────────────────────────
238
+ console.log('\n--- Case 8: stale 下游直接 run 拒绝 ---')
239
+ {
240
+ const { cwd } = createTempProject()
241
+ const changeName = 'rev-test-8'
242
+ const pm = await setupProgress(cwd, changeName)
243
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
244
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
245
+
246
+ // reopen brainstorm → plan becomes stale
247
+ await pm.reopenStage(cwd, 'brainstorm', { fromStep: 1, changeName })
248
+
249
+ const data = await pm.read(cwd, changeName)
250
+ const planStatus = data.stages['plan'].status
251
+ assert(planStatus === 'stale', 'plan 应为 stale')
252
+
253
+ // 模拟 run 命令的规则 5 检查
254
+ const isReopen = false
255
+ const shouldReject = planStatus === 'stale' && !isReopen
256
+ assert(shouldReject, 'stale 阶段直接 run 应被拒绝')
257
+
258
+ // reopen plan from step 1 应该可以
259
+ const reopenResult = await pm.reopenStage(cwd, 'plan', { fromStep: 1, changeName })
260
+ assert(reopenResult.ok === true, 'stale 阶段用 --reopen --from-step 1 应该可以')
261
+ }
262
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
263
+
264
+ // ─────────────────────────────────────────
265
+ // Case 9: waiting/pending 阶段 --reopen 无 from-step → 允许继续
266
+ // ─────────────────────────────────────────
267
+ console.log('\n--- Case 9: waiting/pending 阶段 --reopen 无 from-step 允许继续 ---')
268
+ {
269
+ const { cwd } = createTempProject()
270
+ const changeName = 'rev-test-9'
271
+ const pm = await setupProgress(cwd, changeName)
272
+
273
+ // 手动构造一个有 waiting 步骤的 brainstorm
274
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
275
+ const data = await pm.read(cwd, changeName)
276
+ data.stages['brainstorm'] = {
277
+ status: 'in-progress',
278
+ startedAt: now,
279
+ completedAt: null,
280
+ steps: [
281
+ { name: '需求澄清', status: 'completed', completedAt: now },
282
+ { name: '方案发散', status: 'waiting', waitReason: '需要用户确认' },
283
+ { name: '方案选择', status: 'pending' },
284
+ ],
285
+ }
286
+ data.currentStage = 'brainstorm'
287
+ await pm._write(cwd, data, changeName)
288
+
289
+ // reopen 不带 fromStep,应该能找到 waiting 步骤并继续
290
+ const result = await pm.reopenStage(cwd, 'brainstorm', { changeName })
291
+ assert(result.ok, '有 waiting 步骤时 --reopen 不带 fromStep 应成功')
292
+ assert(result.fromStep === '方案发散', '应从 waiting 步骤继续')
293
+ }
294
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
295
+
296
+ // ─────────────────────────────────────────
297
+ // Case 10: reopen 不改动产物文件
298
+ // ─────────────────────────────────────────
299
+ console.log('\n--- Case 10: reopen 不改动产物文件 ---')
300
+ {
301
+ const { cwd, specDir } = createTempProject()
302
+ const changeName = 'rev-test-10'
303
+ const pm = await setupProgress(cwd, changeName)
304
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2', 's3'])
305
+
306
+ // 创建产物文件
307
+ const changeDir = join(specDir, 'changes', changeName)
308
+ mkdirSync(changeDir, { recursive: true })
309
+ const designPath = join(changeDir, 'design.md')
310
+ const designContent = '# Original Design\n\nOriginal content'
311
+ writeFileSync(designPath, designContent)
312
+
313
+ // reopen
314
+ const result = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 2, changeName })
315
+ assert(result.ok, 'reopen 应成功')
316
+
317
+ // 验证文件未被改动
318
+ assert(existsSync(designPath), 'design.md 应仍然存在')
319
+ const afterContent = readFileSync(designPath, 'utf8')
320
+ assert(afterContent === designContent, 'design.md 内容应未被改动')
321
+ }
322
+ rmSync(mkdtempSync(join(tmpdir(), 'sillyspec-rev-test-')), { recursive: true })
323
+
324
+ // ─────────────────────────────────────────
325
+ // Bonus: 多次 reopen revision 递增
326
+ // ─────────────────────────────────────────
327
+ console.log('\n--- Bonus: 多次 reopen revision 递增 ---')
328
+ {
329
+ const { cwd } = createTempProject()
330
+ const changeName = 'rev-test-bonus'
331
+ const pm = await setupProgress(cwd, changeName)
332
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2', 's3'])
333
+
334
+ const r1 = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 2, changeName })
335
+ assert(r1.ok && r1.revision === 1, '第一次 reopen revision=1')
336
+
337
+ // 完成 step 2
338
+ const data = await pm.read(cwd, changeName)
339
+ data.stages['brainstorm'].steps[1].status = 'completed'
340
+ data.stages['brainstorm'].steps[1].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
341
+ data.stages['brainstorm'].steps[2].status = 'completed'
342
+ data.stages['brainstorm'].steps[2].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
343
+ data.stages['brainstorm'].status = 'completed'
344
+ data.stages['brainstorm'].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
345
+ await pm._write(cwd, data, changeName)
346
+
347
+ // 第二次 reopen
348
+ const r2 = await pm.reopenStage(cwd, 'brainstorm', { fromStep: 3, changeName })
349
+ assert(r2.ok && r2.revision === 2, '第二次 reopen revision=2')
350
+ }
351
+
352
+ // ─────────────────────────────────────────
353
+ // Bonus: scan reopen → brainstorm cascade
354
+ // ─────────────────────────────────────────
355
+ console.log('\n--- Bonus: scan reopen 应 cascade 到 brainstorm ---')
356
+ {
357
+ const { cwd } = createTempProject()
358
+ const changeName = 'rev-test-scan'
359
+ const pm = await setupProgress(cwd, changeName)
360
+ await markStageCompleted(pm, cwd, changeName, 'scan', ['s1', 's2'])
361
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['b1', 'b2'])
362
+
363
+ const result = await pm.reopenStage(cwd, 'scan', { fromStep: 2, changeName })
364
+ assert(result.ok, 'reopen scan 应成功')
365
+
366
+ const data = await pm.read(cwd, changeName)
367
+ assert(data.stages['scan'].status === 'revising', 'scan 应为 revising')
368
+ assert(data.stages['brainstorm'].status === 'stale', 'brainstorm 应被 cascade 为 stale')
369
+ }
370
+
371
+ // ─────────────────────────────────────────
372
+ // TODO Fix 1: stale 阶段 --status 不拦截
373
+ // ─────────────────────────────────────────
374
+ console.log('\n--- TODO Fix 1: stale 阶段 --status 放行 ---')
375
+ {
376
+ const { cwd } = createTempProject()
377
+ const changeName = 'rev-todo-1'
378
+ const pm = await setupProgress(cwd, changeName)
379
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
380
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
381
+
382
+ // reopen brainstorm → plan cascade stale
383
+ await pm.reopenStage(cwd, 'brainstorm', { fromStep: 1, changeName })
384
+
385
+ const data = await pm.read(cwd, changeName)
386
+ assert(data.stages['plan'].status === 'stale', 'plan 应为 stale')
387
+
388
+ // 模拟 run.js 的拦截逻辑:stale + --status 应放行
389
+ const isStatus = true
390
+ const isReopen = false
391
+ const isReset = false
392
+ const stageStatus = data.stages['plan'].status
393
+ const shouldBlock = stageStatus === 'stale' && !isReopen && !isStatus && !isReset
394
+ assert(!shouldBlock, 'stale + --status 不应被拦截')
395
+ }
396
+
397
+ // ─────────────────────────────────────────
398
+ // TODO Fix 2: stale 阶段 --reset 不拦截
399
+ // ─────────────────────────────────────────
400
+ console.log('\n--- TODO Fix 2: stale 阶段 --reset 放行 ---')
401
+ {
402
+ const { cwd } = createTempProject()
403
+ const changeName = 'rev-todo-2'
404
+ const pm = await setupProgress(cwd, changeName)
405
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
406
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
407
+
408
+ await pm.reopenStage(cwd, 'brainstorm', { fromStep: 1, changeName })
409
+
410
+ const data = await pm.read(cwd, changeName)
411
+ const isReset = true
412
+ const isStatus = false
413
+ const isReopen = false
414
+ const stageStatus = data.stages['plan'].status
415
+ const shouldBlock = stageStatus === 'stale' && !isReopen && !isStatus && !isReset
416
+ assert(!shouldBlock, 'stale + --reset 不应被拦截')
417
+ }
418
+
419
+ // ─────────────────────────────────────────
420
+ // TODO Fix 3: stale empty steps + --reopen --from-step 1 自动初始化
421
+ // ─────────────────────────────────────────
422
+ console.log('\n--- TODO Fix 3: stale empty steps + reopen from-step 1 ---')
423
+ {
424
+ const { cwd } = createTempProject()
425
+ const changeName = 'rev-todo-3'
426
+ const pm = await setupProgress(cwd, changeName)
427
+
428
+ // brainstorm completed with steps
429
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
430
+
431
+ // plan marked completed but WITHOUT steps (模拟 cascade stale 后 steps 为空)
432
+ const data = await pm.read(cwd, changeName)
433
+ data.stages['plan'] = {
434
+ status: 'completed',
435
+ startedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
436
+ completedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
437
+ steps: [], // 空 steps
438
+ }
439
+ data.currentStage = 'brainstorm'
440
+ await pm._write(cwd, data, changeName)
441
+
442
+ // reopen brainstorm → plan cascade stale
443
+ await pm.reopenStage(cwd, 'brainstorm', { fromStep: 1, changeName })
444
+
445
+ // plan 现在是 stale,steps 为空
446
+ const data2 = await pm.read(cwd, changeName)
447
+ assert(data2.stages['plan'].status === 'stale', 'plan 应为 stale')
448
+ assert(data2.stages['plan'].steps.length === 0, 'plan steps 应为空')
449
+
450
+ // 模拟 run.js 的 ensureStageSteps:手动注入 steps
451
+ // 因为 reopenStage 需要 steps 来 resolve from-step
452
+ // 这里测试逻辑:先 ensure steps,再 reopen
453
+ const data3 = await pm.read(cwd, changeName)
454
+ data3.stages['plan'].steps = [
455
+ { name: '复杂度分类', status: 'pending' },
456
+ { name: '状态检查', status: 'pending' },
457
+ ]
458
+ await pm._write(cwd, data3, changeName)
459
+
460
+ // 现在 reopen plan from step 1
461
+ const result = await pm.reopenStage(cwd, 'plan', { fromStep: 1, changeName })
462
+ assert(result.ok, 'stale empty steps 初始化后 reopen 应成功')
463
+ assert(result.fromStep === '复杂度分类', 'fromStep 应为第一个步骤')
464
+ }
465
+
466
+ // ─────────────────────────────────────────
467
+ // TODO Fix 4: stale empty steps + --reopen 无 from-step 仍 fail-fast
468
+ // ─────────────────────────────────────────
469
+ console.log('\n--- TODO Fix 4: stale empty steps + reopen 无 from-step fail-fast ---')
470
+ {
471
+ const { cwd } = createTempProject()
472
+ const changeName = 'rev-todo-4'
473
+ const pm = await setupProgress(cwd, changeName)
474
+
475
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
476
+
477
+ // plan stale with empty steps
478
+ const data = await pm.read(cwd, changeName)
479
+ data.stages['plan'] = {
480
+ status: 'stale',
481
+ startedAt: null,
482
+ completedAt: null,
483
+ steps: [],
484
+ staleReason: 'upstream brainstorm revised',
485
+ }
486
+ await pm._write(cwd, data, changeName)
487
+
488
+ // reopen without fromStep → should fail because all steps are... well there are none
489
+ const result = await pm.reopenStage(cwd, 'plan', { changeName })
490
+ assert(!result.ok, 'stale empty steps 无 fromStep 应失败')
491
+ }
492
+
493
+ // ─────────────────────────────────────────
494
+ // v1.1: progress 展示 revising + stale 信息
495
+ // ─────────────────────────────────────────
496
+ console.log('\n--- v1.1: progress 展示 revising 信息 ---')
497
+ {
498
+ const { cwd } = createTempProject()
499
+ const changeName = 'rev-11-1'
500
+ const pm = await setupProgress(cwd, changeName)
501
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2', 's3'])
502
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
503
+
504
+ await pm.reopenStage(cwd, 'brainstorm', { fromStep: 2, changeName })
505
+
506
+ const data = await pm.read(cwd, changeName)
507
+ // 验证 revision 信息存在
508
+ assert(data.stages['brainstorm'].revision === 1, 'brainstorm revision 应为 1')
509
+ assert(!!data.stages['brainstorm'].reopenedFromStep, 'brainstorm 应有 reopenedFromStep')
510
+ assert(data.stages['plan'].status === 'stale', 'plan 应为 stale')
511
+ assert(!!data.stages['plan'].staleReason, 'plan 应有 staleReason')
512
+
513
+ // 验证 _getNextSuggestion 返回正确建议
514
+ const suggestion = pm._getNextSuggestion(data)
515
+ assert(suggestion !== null, '应有 suggestion')
516
+ assert(suggestion.text.includes('brainstorm') || suggestion.text.includes('需求探索'), 'suggestion 应提到 brainstorm 修订中')
517
+ assert(suggestion.command === 'sillyspec run brainstorm', 'suggestion command 应为继续 brainstorm')
518
+ }
519
+
520
+ // ─────────────────────────────────────────
521
+ // v1.1: _getNextSuggestion 返回 stale 建议
522
+ // ─────────────────────────────────────────
523
+ console.log('\n--- v1.1: suggestion 返回 stale 阶段建议 ---')
524
+ {
525
+ const { cwd } = createTempProject()
526
+ const changeName = 'rev-11-2'
527
+ const pm = await setupProgress(cwd, changeName)
528
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
529
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
530
+
531
+ // reopen brainstorm → plan stale
532
+ await pm.reopenStage(cwd, 'brainstorm', { fromStep: 1, changeName })
533
+
534
+ // 手动完成 brainstorm 修订(不再 revising)
535
+ const data = await pm.read(cwd, changeName)
536
+ data.stages['brainstorm'].status = 'completed'
537
+ data.stages['brainstorm'].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
538
+ await pm._write(cwd, data, changeName)
539
+
540
+ // 现在 brainstorm completed, plan stale
541
+ const data2 = await pm.read(cwd, changeName)
542
+ const suggestion = pm._getNextSuggestion(data2)
543
+ assert(suggestion !== null, '应有 suggestion')
544
+ assert(suggestion.text.includes('plan') || suggestion.text.includes('实现计划'), 'suggestion 应提到 plan')
545
+ assert(suggestion.command.includes('--reopen --from-step 1'), 'suggestion 应建议 reopen plan')
546
+ }
547
+
548
+ // ─────────────────────────────────────────
549
+ // v1.1: checkConsistency 发现 completed + stale steps
550
+ // ─────────────────────────────────────────
551
+ console.log('\n--- v1.1: checkConsistency 发现 completed stage 有 stale steps ---')
552
+ {
553
+ const { cwd } = createTempProject()
554
+ const changeName = 'rev-11-3'
555
+ const pm = await setupProgress(cwd, changeName)
556
+
557
+ // 手动构造异常状态:stage completed 但 step stale
558
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
559
+ const data = await pm.read(cwd, changeName)
560
+ data.stages['brainstorm'] = {
561
+ status: 'completed',
562
+ startedAt: now, completedAt: now,
563
+ steps: [
564
+ { name: 's1', status: 'completed', completedAt: now },
565
+ { name: 's2', status: 'stale' }, // 异常
566
+ ],
567
+ }
568
+ await pm._write(cwd, data, changeName)
569
+
570
+ const result = await pm.checkConsistency(cwd, changeName)
571
+ assert(!result.ok, '应检测到问题')
572
+ assert(result.issues.length > 0, '应有 issue')
573
+ assert(result.issues.some(i => i.includes('stale') && i.includes('completed')), '应有 stale step + completed stage 问题')
574
+ }
575
+
576
+ // ─────────────────────────────────────────
577
+ // v1.1: checkConsistency 发现 stale stage 缺 staleReason
578
+ // ─────────────────────────────────────────
579
+ console.log('\n--- v1.1: checkConsistency 发现 stale 缺 staleReason ---')
580
+ {
581
+ const { cwd } = createTempProject()
582
+ const changeName = 'rev-11-4'
583
+ const pm = await setupProgress(cwd, changeName)
584
+
585
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
586
+ const data = await pm.read(cwd, changeName)
587
+ data.stages['plan'] = {
588
+ status: 'stale',
589
+ steps: [],
590
+ staleReason: null, // 缺失
591
+ }
592
+ await pm._write(cwd, data, changeName)
593
+
594
+ const result = await pm.checkConsistency(cwd, changeName)
595
+ assert(result.warnings.some(w => w.includes('plan') && w.includes('staleReason')), '应警告 plan 缺 staleReason')
596
+ }
597
+
598
+ // ─────────────────────────────────────────
599
+ // v1.1: checkConsistency 发现上游 stale 但下游 completed
600
+ // ─────────────────────────────────────────
601
+ console.log('\n--- v1.1: checkConsistency 发现上游 stale 下游 completed ---')
602
+ {
603
+ const { cwd } = createTempProject()
604
+ const changeName = 'rev-11-5'
605
+ const pm = await setupProgress(cwd, changeName)
606
+
607
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
608
+ const data = await pm.read(cwd, changeName)
609
+ data.stages['brainstorm'] = {
610
+ status: 'stale', staleReason: 'test', steps: [],
611
+ }
612
+ data.stages['plan'] = {
613
+ status: 'completed', startedAt: now, completedAt: now,
614
+ steps: [{ name: 'p1', status: 'completed', completedAt: now }],
615
+ }
616
+ await pm._write(cwd, data, changeName)
617
+
618
+ const result = await pm.checkConsistency(cwd, changeName)
619
+ assert(!result.ok, '应检测到问题')
620
+ assert(result.issues.some(i => i.includes('plan') && i.includes('brainstorm') && i.includes('stale')), '应检测到 plan completed 但 brainstorm stale')
621
+ }
622
+
623
+ // ─────────────────────────────────────────
624
+ // v1.1: checkConsistency 正常状态无问题
625
+ // ─────────────────────────────────────────
626
+ console.log('\n--- v1.1: checkConsistency 正常状态无问题 ---')
627
+ {
628
+ const { cwd } = createTempProject()
629
+ const changeName = 'rev-11-6'
630
+ const pm = await setupProgress(cwd, changeName)
631
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
632
+
633
+ const result = await pm.checkConsistency(cwd, changeName)
634
+ assert(result.ok, '正常状态应无问题')
635
+ assert(result.issues.length === 0, '不应有 issues')
636
+ }
637
+
638
+ // ─────────────────────────────────────────
639
+ // v1.2: repair dry-run 不修改 DB
640
+ // ─────────────────────────────────────────
641
+ console.log('\n--- v1.2: repair dry-run 不修改 DB ---')
642
+ {
643
+ const { cwd } = createTempProject()
644
+ const changeName = 'rev-12-1'
645
+ const pm = await setupProgress(cwd, changeName)
646
+
647
+ // 构造 stale 缺 staleReason
648
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
649
+ const data = await pm.read(cwd, changeName)
650
+ data.stages['plan'] = { status: 'stale', steps: [], staleReason: null }
651
+ await pm._write(cwd, data, changeName)
652
+
653
+ // dry-run
654
+ const result = await pm.repairConsistency(cwd, { apply: false, changeName })
655
+ assert(result.fixable.length > 0, '应有可修复项')
656
+ assert(result.applied.length === 0, 'dry-run 不应执行修复')
657
+
658
+ // 验证 DB 未变
659
+ const after = await pm.read(cwd, changeName)
660
+ assert(!after.stages['plan'].staleReason, 'dry-run 后 staleReason 仍应为空')
661
+ }
662
+
663
+ // ─────────────────────────────────────────
664
+ // v1.2: repair --apply 修复 stale_reason
665
+ // ─────────────────────────────────────────
666
+ console.log('\n--- v1.2: repair --apply 修复 stale_reason ---')
667
+ {
668
+ const { cwd } = createTempProject()
669
+ const changeName = 'rev-12-2'
670
+ const pm = await setupProgress(cwd, changeName)
671
+
672
+ const data = await pm.read(cwd, changeName)
673
+ data.stages['plan'] = { status: 'stale', steps: [], staleReason: null }
674
+ await pm._write(cwd, data, changeName)
675
+
676
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
677
+ assert(result.applied.length > 0, '应有已修复项')
678
+ assert(result.applied.some(a => a.action === 'set_stale_reason'), '应有 set_stale_reason 修复')
679
+
680
+ const after = await pm.read(cwd, changeName)
681
+ assert(!!after.stages['plan'].staleReason, '修复后 plan 应有 staleReason')
682
+ assert(after.stages['plan'].staleReason.includes('upstream'), 'staleReason 应包含 upstream')
683
+ }
684
+
685
+ // ─────────────────────────────────────────
686
+ // v1.2: repair --apply cascade 下游 completed -> stale
687
+ // ─────────────────────────────────────────
688
+ console.log('\n--- v1.2: repair --apply cascade 下游 completed -> stale ---')
689
+ {
690
+ const { cwd } = createTempProject()
691
+ const changeName = 'rev-12-3'
692
+ const pm = await setupProgress(cwd, changeName)
693
+
694
+ const now2 = new Date().toLocaleString('zh-CN', { hour12: false })
695
+ const data = await pm.read(cwd, changeName)
696
+ data.stages['brainstorm'] = {
697
+ status: 'stale', staleReason: 'test', steps: [],
698
+ }
699
+ data.stages['plan'] = {
700
+ status: 'completed', startedAt: now2, completedAt: now2,
701
+ steps: [{ name: 'p1', status: 'completed', completedAt: now2 }],
702
+ }
703
+ await pm._write(cwd, data, changeName)
704
+
705
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
706
+ assert(result.applied.some(a => a.action === 'cascade_stale' && a.stage === 'plan'), '应有 plan cascade_stale 修复')
707
+
708
+ const after = await pm.read(cwd, changeName)
709
+ assert(after.stages['plan'].status === 'stale', '修复后 plan 应为 stale')
710
+ assert(!!after.stages['plan'].staleReason, 'plan 应有 staleReason')
711
+ }
712
+
713
+ // ─────────────────────────────────────────
714
+ // v1.2: repair --apply 补 revising reopened_at
715
+ // ─────────────────────────────────────────
716
+ console.log('\n--- v1.2: repair --apply 补 revising reopened_at ---')
717
+ {
718
+ const { cwd } = createTempProject()
719
+ const changeName = 'rev-12-4'
720
+ const pm = await setupProgress(cwd, changeName)
721
+
722
+ const data = await pm.read(cwd, changeName)
723
+ data.stages['brainstorm'] = {
724
+ status: 'revising', revision: 1, reopenedFromStep: '1: s1',
725
+ reopenedAt: null, steps: [{ name: 's1', status: 'pending' }],
726
+ }
727
+ await pm._write(cwd, data, changeName)
728
+
729
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
730
+ assert(result.applied.some(a => a.action === 'set_reopened_at'), '应有 set_reopened_at 修复')
731
+
732
+ const after = await pm.read(cwd, changeName)
733
+ assert(!!after.stages['brainstorm'].reopenedAt, '修复后应有 reopenedAt')
734
+ }
735
+
736
+ // ─────────────────────────────────────────
737
+ // v1.2: completed + stale steps 只报告 manual,不修改
738
+ // ─────────────────────────────────────────
739
+ console.log('\n--- v1.2: completed + stale steps 只报告不修复 ---')
740
+ {
741
+ const { cwd } = createTempProject()
742
+ const changeName = 'rev-12-5'
743
+ const pm = await setupProgress(cwd, changeName)
744
+
745
+ const now3 = new Date().toLocaleString('zh-CN', { hour12: false })
746
+ const data = await pm.read(cwd, changeName)
747
+ data.stages['brainstorm'] = {
748
+ status: 'completed', startedAt: now3, completedAt: now3,
749
+ steps: [
750
+ { name: 's1', status: 'completed', completedAt: now3 },
751
+ { name: 's2', status: 'stale' }, // 异常
752
+ ],
753
+ }
754
+ await pm._write(cwd, data, changeName)
755
+
756
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
757
+ assert(result.manual.length > 0, '应有 manual 项')
758
+ assert(result.manual.some(m => m.includes('stale') && m.includes('completed')), '应有 stale step + completed manual')
759
+ assert(result.applied.length === 0, '不应有自动修复')
760
+ }
761
+
762
+ // ─────────────────────────────────────────
763
+ // v1.2: 正常状态 repair 无操作
764
+ // ─────────────────────────────────────────
765
+ console.log('\n--- v1.2: 正常状态 repair 无操作 ---')
766
+ {
767
+ const { cwd } = createTempProject()
768
+ const changeName = 'rev-12-6'
769
+ const pm = await setupProgress(cwd, changeName)
770
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
771
+
772
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
773
+ assert(result.fixable.length === 0, '不应有可修复项')
774
+ assert(result.manual.length === 0, '不应有 manual 项')
775
+ assert(result.applied.length === 0, '不应执行任何修复')
776
+ }
777
+
778
+ // ─────────────────────────────────────────
779
+ // Execute Stale Safety: plan stale 时 execute 被拒绝
780
+ // ─────────────────────────────────────────
781
+ console.log('\n--- Execute Safety: plan stale → execute stale → run 拒绝 ---')
782
+ {
783
+ const { cwd } = createTempProject()
784
+ const changeName = 'exec-safety-1'
785
+ const pm = await setupProgress(cwd, changeName)
786
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1', 's2'])
787
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1', 'p2'])
788
+ await markStageCompleted(pm, cwd, changeName, 'execute', ['e1', 'e2'])
789
+
790
+ // reopen plan → execute cascade stale
791
+ await pm.reopenStage(cwd, 'plan', { fromStep: 1, changeName })
792
+
793
+ const data = await pm.read(cwd, changeName)
794
+ assert(data.stages['execute'].status === 'stale', 'execute 应为 stale')
795
+
796
+ // 模拟 run.js 规则 5:stale 直接 run 被拒
797
+ const isReopen = false
798
+ const isStatus = false
799
+ const isReset = false
800
+ const shouldBlock = data.stages['execute'].status === 'stale' && !isReopen && !isStatus && !isReset
801
+ assert(shouldBlock, 'execute stale 直接 run 应被拒绝')
802
+ }
803
+
804
+ // ─────────────────────────────────────────
805
+ // Execute Stale Safety: execute --status 放行
806
+ // ─────────────────────────────────────────
807
+ console.log('\n--- Execute Safety: execute stale + --status 放行 ---')
808
+ {
809
+ const { cwd } = createTempProject()
810
+ const changeName = 'exec-safety-2'
811
+ const pm = await setupProgress(cwd, changeName)
812
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1'])
813
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1'])
814
+ await markStageCompleted(pm, cwd, changeName, 'execute', ['e1'])
815
+
816
+ await pm.reopenStage(cwd, 'plan', { fromStep: 1, changeName })
817
+
818
+ const data = await pm.read(cwd, changeName)
819
+ // execute --status 应放行
820
+ const isStatus = true
821
+ const shouldBlock = data.stages['execute'].status === 'stale' && !isStatus
822
+ assert(!shouldBlock, 'execute stale + --status 不应被拦截')
823
+ }
824
+
825
+ // ─────────────────────────────────────────
826
+ // Execute Stale Safety: execute reopen 后 steps 被重置
827
+ // ─────────────────────────────────────────
828
+ console.log('\n--- Execute Safety: execute reopen 后旧 steps 不保留 ---')
829
+ {
830
+ const { cwd } = createTempProject()
831
+ const changeName = 'exec-safety-3'
832
+ const pm = await setupProgress(cwd, changeName)
833
+
834
+ // 模拟 execute 之前有 3 个 wave steps
835
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
836
+ const data = await pm.read(cwd, changeName)
837
+ data.stages['execute'] = {
838
+ status: 'stale',
839
+ staleReason: 'upstream plan revised',
840
+ startedAt: now, completedAt: null,
841
+ steps: [
842
+ { name: 'Wave 1 执行', status: 'completed', completedAt: now },
843
+ { name: 'Wave 2 执行', status: 'completed', completedAt: now },
844
+ { name: 'Wave 3 执行', status: 'completed', completedAt: now },
845
+ { name: '验收检查', status: 'completed', completedAt: now },
846
+ ],
847
+ }
848
+ await pm._write(cwd, data, changeName)
849
+
850
+ // reopen execute from step 1
851
+ const result = await pm.reopenStage(cwd, 'execute', { fromStep: 1, changeName })
852
+ assert(result.ok, 'execute reopen 应成功')
853
+
854
+ const after = await pm.read(cwd, changeName)
855
+ assert(after.stages['execute'].status === 'revising', 'execute 应为 revising')
856
+
857
+ // 所有步骤应被重置(step 1 pending, 后续 stale)
858
+ const steps = after.stages['execute'].steps
859
+ assert(steps[0].status === 'pending', 'step 1 应为 pending')
860
+ for (let i = 1; i < steps.length; i++) {
861
+ assert(steps[i].status === 'stale', `step ${i+1} 应为 stale,实际 ${steps[i].status}`)
862
+ }
863
+
864
+ // 旧 completed 状态不保留
865
+ assert(!steps.some(s => s.status === 'completed'), '不应有 completed steps(旧状态已清除)')
866
+ }
867
+
868
+ // ─────────────────────────────────────────
869
+ // Execute Stale Safety: revision 递增后 reopen 正确
870
+ // ─────────────────────────────────────────
871
+ console.log('\n--- Execute Safety: execute revision 递增 ---')
872
+ {
873
+ const { cwd } = createTempProject()
874
+ const changeName = 'exec-safety-4'
875
+ const pm = await setupProgress(cwd, changeName)
876
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1'])
877
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1'])
878
+ await markStageCompleted(pm, cwd, changeName, 'execute', ['e1', 'e2'])
879
+
880
+ // reopen execute
881
+ const r1 = await pm.reopenStage(cwd, 'execute', { fromStep: 1, changeName })
882
+ assert(r1.ok && r1.revision === 1, '第一次 reopen revision=1')
883
+
884
+ // 标记完成
885
+ const data = await pm.read(cwd, changeName)
886
+ data.stages['execute'].steps.forEach(s => { s.status = 'completed'; s.completedAt = new Date().toLocaleString('zh-CN', { hour12: false }) })
887
+ data.stages['execute'].status = 'completed'
888
+ data.stages['execute'].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
889
+ await pm._write(cwd, data, changeName)
890
+
891
+ // 第二次 reopen
892
+ const r2 = await pm.reopenStage(cwd, 'execute', { fromStep: 1, changeName })
893
+ assert(r2.ok && r2.revision === 2, '第二次 reopen revision=2')
894
+ }
895
+
896
+ // ─────────────────────────────────────────
897
+ // Execute Stale Safety: checkConsistency 检测 execute stale
898
+ // ─────────────────────────────────────────
899
+ console.log('\n--- Execute Safety: checkConsistency 检测 execute 旧状态 ---')
900
+ {
901
+ const { cwd } = createTempProject()
902
+ const changeName = 'exec-safety-5'
903
+ const pm = await setupProgress(cwd, changeName)
904
+
905
+ // 构造异常:plan stale 但 execute completed
906
+ const now2 = new Date().toLocaleString('zh-CN', { hour12: false })
907
+ const data = await pm.read(cwd, changeName)
908
+ data.stages['plan'] = {
909
+ status: 'stale', staleReason: 'test', steps: [],
910
+ }
911
+ data.stages['execute'] = {
912
+ status: 'completed', startedAt: now2, completedAt: now2,
913
+ steps: [{ name: 'e1', status: 'completed', completedAt: now2 }],
914
+ }
915
+ await pm._write(cwd, data, changeName)
916
+
917
+ const result = await pm.checkConsistency(cwd, changeName)
918
+ assert(!result.ok, '应检测到问题')
919
+ assert(result.issues.some(i => i.includes('execute') && i.includes('plan') && i.includes('stale')), '应检测到 execute completed 但 plan stale')
920
+ }
921
+
922
+ // ─────────────────────────────────────────
923
+ // Execute Stale Safety: repair 修复 execute cascade
924
+ // ─────────────────────────────────────────
925
+ console.log('\n--- Execute Safety: repair cascade execute stale ---')
926
+ {
927
+ const { cwd } = createTempProject()
928
+ const changeName = 'exec-safety-6'
929
+ const pm = await setupProgress(cwd, changeName)
930
+
931
+ const now3 = new Date().toLocaleString('zh-CN', { hour12: false })
932
+ const data = await pm.read(cwd, changeName)
933
+ data.stages['plan'] = {
934
+ status: 'stale', staleReason: 'brainstorm revised', steps: [],
935
+ }
936
+ data.stages['execute'] = {
937
+ status: 'completed', startedAt: now3, completedAt: now3,
938
+ steps: [{ name: 'e1', status: 'completed', completedAt: now3 }],
939
+ }
940
+ await pm._write(cwd, data, changeName)
941
+
942
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
943
+ assert(result.applied.some(a => a.action === 'cascade_stale' && a.stage === 'execute'), '应有 execute cascade_stale 修复')
944
+
945
+ const after = await pm.read(cwd, changeName)
946
+ assert(after.stages['execute'].status === 'stale', 'execute 应被修复为 stale')
947
+ }
948
+
949
+ // ─────────────────────────────────────────
950
+ // Verify/Archive Safety: execute stale → verify stale → run 拒绝
951
+ // ─────────────────────────────────────────
952
+ console.log('\n--- Verify/Archive Safety: execute stale → verify/archive 被拒 ---')
953
+ {
954
+ const { cwd } = createTempProject()
955
+ const changeName = 'va-safety-1'
956
+ const pm = await setupProgress(cwd, changeName)
957
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1'])
958
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1'])
959
+ await markStageCompleted(pm, cwd, changeName, 'execute', ['e1'])
960
+ await markStageCompleted(pm, cwd, changeName, 'verify', ['v1', 'v2'])
961
+ await markStageCompleted(pm, cwd, changeName, 'archive', ['a1', 'a2'])
962
+
963
+ // reopen plan → execute/verify/archive cascade stale
964
+ await pm.reopenStage(cwd, 'plan', { fromStep: 1, changeName })
965
+
966
+ const data = await pm.read(cwd, changeName)
967
+ assert(data.stages['verify'].status === 'stale', 'verify 应为 stale')
968
+ assert(data.stages['archive'].status === 'stale', 'archive 应为 stale')
969
+
970
+ // run verify/archive 被拒
971
+ const isReopen = false, isStatus = false, isReset = false
972
+ assert(data.stages['verify'].status === 'stale' && !isReopen && !isStatus && !isReset, 'verify stale 直接 run 应被拒')
973
+ assert(data.stages['archive'].status === 'stale' && !isReopen && !isStatus && !isReset, 'archive stale 直接 run 应被拒')
974
+ }
975
+
976
+ // ─────────────────────────────────────────
977
+ // Verify/Archive Safety: verify stale + --status 放行
978
+ // ─────────────────────────────────────────
979
+ console.log('\n--- Verify/Archive Safety: verify stale + --status 放行 ---')
980
+ {
981
+ const { cwd } = createTempProject()
982
+ const changeName = 'va-safety-2'
983
+ const pm = await setupProgress(cwd, changeName)
984
+ await markStageCompleted(pm, cwd, changeName, 'brainstorm', ['s1'])
985
+ await markStageCompleted(pm, cwd, changeName, 'plan', ['p1'])
986
+ await markStageCompleted(pm, cwd, changeName, 'execute', ['e1'])
987
+ await markStageCompleted(pm, cwd, changeName, 'verify', ['v1'])
988
+
989
+ await pm.reopenStage(cwd, 'execute', { fromStep: 1, changeName })
990
+
991
+ const data = await pm.read(cwd, changeName)
992
+ assert(data.stages['verify'].status === 'stale', 'verify 应为 stale')
993
+ // --status 放行
994
+ const isStatus = true
995
+ assert(!(data.stages['verify'].status === 'stale' && !isStatus), 'verify stale + --status 不应被拦截')
996
+ }
997
+
998
+ // ─────────────────────────────────────────
999
+ // Verify/Archive Safety: verify --reopen 清除旧 completed steps
1000
+ // ─────────────────────────────────────────
1001
+ console.log('\n--- Verify/Archive Safety: verify reopen 清除旧 steps ---')
1002
+ {
1003
+ const { cwd } = createTempProject()
1004
+ const changeName = 'va-safety-3'
1005
+ const pm = await setupProgress(cwd, changeName)
1006
+ await markStageCompleted(pm, cwd, changeName, 'verify', ['v1', 'v2', 'v3'])
1007
+
1008
+ const result = await pm.reopenStage(cwd, 'verify', { fromStep: 1, changeName })
1009
+ assert(result.ok, 'verify reopen 应成功')
1010
+
1011
+ const after = await pm.read(cwd, changeName)
1012
+ const steps = after.stages['verify'].steps
1013
+ assert(after.stages['verify'].status === 'revising', 'verify 应为 revising')
1014
+ assert(steps[0].status === 'pending', 'step 1 应为 pending')
1015
+ assert(steps[1].status === 'stale', 'step 2 应为 stale')
1016
+ assert(steps[2].status === 'stale', 'step 3 应为 stale')
1017
+ assert(!steps.some(s => s.status === 'completed'), '不应保留 completed steps')
1018
+ }
1019
+
1020
+ // ─────────────────────────────────────────
1021
+ // Verify/Archive Safety: archive --reopen 清除旧 completed steps
1022
+ // ─────────────────────────────────────────
1023
+ console.log('\n--- Verify/Archive Safety: archive reopen 清除旧 steps ---')
1024
+ {
1025
+ const { cwd } = createTempProject()
1026
+ const changeName = 'va-safety-4'
1027
+ const pm = await setupProgress(cwd, changeName)
1028
+ await markStageCompleted(pm, cwd, changeName, 'archive', ['a1', 'a2'])
1029
+
1030
+ const result = await pm.reopenStage(cwd, 'archive', { fromStep: 1, changeName })
1031
+ assert(result.ok, 'archive reopen 应成功')
1032
+
1033
+ const after = await pm.read(cwd, changeName)
1034
+ const steps = after.stages['archive'].steps
1035
+ assert(after.stages['archive'].status === 'revising', 'archive 应为 revising')
1036
+ assert(steps[0].status === 'pending', 'step 1 应为 pending')
1037
+ assert(steps[1].status === 'stale', 'step 2 应为 stale')
1038
+ assert(!steps.some(s => s.status === 'completed'), '不应保留 completed steps')
1039
+ }
1040
+
1041
+ // ─────────────────────────────────────────
1042
+ // Verify/Archive Safety: checkConsistency 检测 execute stale + verify/archive completed
1043
+ // ─────────────────────────────────────────
1044
+ console.log('\n--- Verify/Archive Safety: check 检测 execute stale 下游 ---')
1045
+ {
1046
+ const { cwd } = createTempProject()
1047
+ const changeName = 'va-safety-5'
1048
+ const pm = await setupProgress(cwd, changeName)
1049
+
1050
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
1051
+ const data = await pm.read(cwd, changeName)
1052
+ data.stages['execute'] = { status: 'stale', staleReason: 'plan revised', steps: [] }
1053
+ data.stages['verify'] = { status: 'completed', startedAt: now, completedAt: now,
1054
+ steps: [{ name: 'v1', status: 'completed', completedAt: now }] }
1055
+ data.stages['archive'] = { status: 'completed', startedAt: now, completedAt: now,
1056
+ steps: [{ name: 'a1', status: 'completed', completedAt: now }] }
1057
+ await pm._write(cwd, data, changeName)
1058
+
1059
+ const result = await pm.checkConsistency(cwd, changeName)
1060
+ assert(!result.ok, '应检测到问题')
1061
+ assert(result.issues.some(i => i.includes('verify') && i.includes('execute') && i.includes('stale')), '应检测 verify 假完成')
1062
+ assert(result.issues.some(i => i.includes('archive') && i.includes('execute') && i.includes('stale')), '应检测 archive 假完成')
1063
+ }
1064
+
1065
+ // ─────────────────────────────────────────
1066
+ // Verify/Archive Safety: checkConsistency 检测 verify stale + archive completed
1067
+ // ─────────────────────────────────────────
1068
+ console.log('\n--- Verify/Archive Safety: check 检测 verify stale 下游 archive ---')
1069
+ {
1070
+ const { cwd } = createTempProject()
1071
+ const changeName = 'va-safety-6'
1072
+ const pm = await setupProgress(cwd, changeName)
1073
+
1074
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
1075
+ const data = await pm.read(cwd, changeName)
1076
+ data.stages['verify'] = { status: 'stale', staleReason: 'execute revised', steps: [] }
1077
+ data.stages['archive'] = { status: 'completed', startedAt: now, completedAt: now,
1078
+ steps: [{ name: 'a1', status: 'completed', completedAt: now }] }
1079
+ await pm._write(cwd, data, changeName)
1080
+
1081
+ const result = await pm.checkConsistency(cwd, changeName)
1082
+ assert(!result.ok, '应检测到问题')
1083
+ assert(result.issues.some(i => i.includes('archive') && i.includes('verify') && i.includes('stale')), '应检测 archive 假完成(verify stale)')
1084
+ }
1085
+
1086
+ // ─────────────────────────────────────────
1087
+ // Verify/Archive Safety: repair cascade execute → verify + archive
1088
+ // ─────────────────────────────────────────
1089
+ console.log('\n--- Verify/Archive Safety: repair cascade execute → verify + archive ---')
1090
+ {
1091
+ const { cwd } = createTempProject()
1092
+ const changeName = 'va-safety-7'
1093
+ const pm = await setupProgress(cwd, changeName)
1094
+
1095
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
1096
+ const data = await pm.read(cwd, changeName)
1097
+ data.stages['execute'] = { status: 'stale', staleReason: 'plan revised', steps: [] }
1098
+ data.stages['verify'] = { status: 'completed', startedAt: now, completedAt: now,
1099
+ steps: [{ name: 'v1', status: 'completed', completedAt: now }] }
1100
+ data.stages['archive'] = { status: 'completed', startedAt: now, completedAt: now,
1101
+ steps: [{ name: 'a1', status: 'completed', completedAt: now }] }
1102
+ await pm._write(cwd, data, changeName)
1103
+
1104
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
1105
+ assert(result.applied.some(a => a.action === 'cascade_stale' && a.stage === 'verify'), '应修复 verify')
1106
+ assert(result.applied.some(a => a.action === 'cascade_stale' && a.stage === 'archive'), '应修复 archive')
1107
+
1108
+ const after = await pm.read(cwd, changeName)
1109
+ assert(after.stages['verify'].status === 'stale', 'verify 应为 stale')
1110
+ assert(after.stages['archive'].status === 'stale', 'archive 应为 stale')
1111
+ }
1112
+
1113
+ // ─────────────────────────────────────────
1114
+ // Verify/Archive Safety: repair cascade verify → archive
1115
+ // ─────────────────────────────────────────
1116
+ console.log('\n--- Verify/Archive Safety: repair cascade verify → archive ---')
1117
+ {
1118
+ const { cwd } = createTempProject()
1119
+ const changeName = 'va-safety-8'
1120
+ const pm = await setupProgress(cwd, changeName)
1121
+
1122
+ const now = new Date().toLocaleString('zh-CN', { hour12: false })
1123
+ const data = await pm.read(cwd, changeName)
1124
+ data.stages['verify'] = { status: 'stale', staleReason: 'execute revised', steps: [] }
1125
+ data.stages['archive'] = { status: 'completed', startedAt: now, completedAt: now,
1126
+ steps: [{ name: 'a1', status: 'completed', completedAt: now }] }
1127
+ await pm._write(cwd, data, changeName)
1128
+
1129
+ const result = await pm.repairConsistency(cwd, { apply: true, changeName })
1130
+ assert(result.applied.some(a => a.action === 'cascade_stale' && a.stage === 'archive'), '应修复 archive')
1131
+
1132
+ const after = await pm.read(cwd, changeName)
1133
+ assert(after.stages['archive'].status === 'stale', 'archive 应为 stale')
1134
+ }
1135
+
1136
+ // ── 结果 ──
1137
+ console.log(`\n${'='.repeat(50)}`)
1138
+ console.log(`✅ 通过: ${12 - failed} ❌ 失败: ${failed}`)
1139
+ if (failures.length > 0) {
1140
+ console.log(`失败项:`)
1141
+ failures.forEach(f => console.log(` - ${f}`))
1142
+ }
1143
+ console.log(`${'='.repeat(50)}`)
1144
+
1145
+ if (failed > 0) process.exit(1)