sillyspec 3.26.11 → 3.26.14

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 (62) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +200 -27
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +187 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +88 -46
  31. package/src/run/complete-handlers.js +27 -15
  32. package/src/run/complete.js +1186 -1121
  33. package/src/run/concurrent-detect.js +3 -1
  34. package/src/run/gates.js +172 -25
  35. package/src/run/prompt.js +17 -9
  36. package/src/run/scan-profile.js +35 -23
  37. package/src/run/shared.js +164 -63
  38. package/src/run/stage.js +41 -1
  39. package/src/setup.js +8 -10
  40. package/src/sillyhub-mcp/client.js +74 -6
  41. package/src/spec-dir-typo.js +3 -24
  42. package/src/spec-sync.js +43 -8
  43. package/src/stage-contract.js +12 -38
  44. package/src/stage-review.js +40 -1
  45. package/src/stages/archive.js +4 -3
  46. package/src/stages/brainstorm-auto.js +1 -1
  47. package/src/stages/brainstorm.js +27 -13
  48. package/src/stages/doctor.js +29 -16
  49. package/src/stages/execute.js +170 -154
  50. package/src/stages/knowledge.js +18 -3
  51. package/src/stages/plan-postcheck.js +72 -37
  52. package/src/stages/plan.js +70 -48
  53. package/src/stages/quick.js +3 -1
  54. package/src/stages/verify.js +1 -1
  55. package/src/sync.js +339 -225
  56. package/src/task-review.js +281 -64
  57. package/src/taskcard.js +178 -0
  58. package/src/verify-postcheck.js +48 -35
  59. package/src/worktree-apply.js +58 -34
  60. package/src/worktree-deps.js +120 -20
  61. package/src/worktree.js +55 -9
  62. package/.npmrc-publish +0 -2
@@ -1,578 +1,589 @@
1
- // W6 Step9d: 阶段状态机 —— completeStage/reopen/reset/validate/show/status + 产物校验 + 下游级联 +
2
- // 显示辅助。从 src/progress.js 单体 ProgressManager 抽出(Step9 最后一步)。持有 pm 引用(构造注入),
3
- // 调 pm.read/_write/_ensureDB/_getSpecDir/readGlobal/_runtimePath/listChanges/_appendAuditLog/
4
- // _renderBatchProgress(persistence-core + 其他组 delegate)。组内方法互调保持 this.X(同 class)。
5
- // completeStage 五层(resolve/validate/force/tx/history/print)整体搬迁,不拆流水线(保行为、最低风险)。
6
- import { mkdirSync, existsSync } from 'fs';
7
- import { join, resolve, basename } from 'path';
8
- import { writeAtomicSync } from '../fs-atomic.js';
9
- import { runValidators } from '../stage-contract.js';
10
- import { VALID_STAGES, STAGE_LABELS, STAGE_ORDER, MAIN_FLOW_ORDER, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './shared.js';
11
-
12
- export class StageMachine {
13
- constructor(pm) {
14
- this.pm = pm;
15
- }
16
-
17
- /**
18
- * 阶段产物校验门:progress complete-stage / update-step 自动完成时复用
19
- * stage-contract 的 validator,防止零产物阶段被直接标 completed。
20
- */
21
- _validateStageArtifacts(cwd, stage, changeName) {
22
- const specDir = this.pm._getSpecDir(cwd);
23
- const defaultSpec = join(resolve(cwd), SPEC_DIR_NAME);
24
- const specRoot = resolve(specDir) === defaultSpec ? null : specDir;
25
- let projectName = null;
26
- try {
27
- const g = this.pm.readGlobal(cwd);
28
- projectName = g?.project || null;
29
- } catch {}
30
- return runValidators(stage, cwd, changeName, {
31
- projectName: projectName || basename(resolve(cwd)),
32
- specRoot,
33
- });
34
- }
35
-
36
- completeStage(cwd, stage, changeName = null, opts = {}) {
37
- const { force = false } = opts;
38
- if (!VALID_STAGES.includes(stage)) {
39
- console.log(`❌ 未知阶段: ${stage}`);
40
- return;
41
- }
42
-
43
- const db = this.pm._ensureDB(cwd);
44
- const now = new Date().toISOString();
45
-
46
- // 获取变更名
47
- let cn = changeName;
48
- if (!cn) {
49
- const changes = this.pm.listChanges(cwd);
50
- if (changes.length === 1) cn = changes[0];
51
- if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
52
- }
53
-
54
- // ── stale 拒绝门(W1 改动点 2):complete-stage 不再静默回填 stale 步骤 ──
55
- // 放在产物校验门之前——stale 是更具体的拒绝原因(产物缺失时旧序会被产物门先拦走,
56
- // stale 门永远执行不到);无 --force 拒绝并列名,有 --force 放行并把 stale 步骤名并入审计。
57
- let staleBackfillNote = null;
58
- {
59
- const sqlDbForStaleCheck = db.getDb();
60
- const changeRowForStale = sqlDbForStaleCheck.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
61
- if (changeRowForStale !== undefined) {
62
- const stageRowForStale = sqlDbForStaleCheck.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(changeRowForStale.id, stage);
63
- if (stageRowForStale !== undefined) {
64
- const staleSteps = sqlDbForStaleCheck.prepare('SELECT name FROM steps WHERE stage_id = ? AND status = ?').all(stageRowForStale.id, 'stale');
65
- if (staleSteps.length > 0) {
66
- if (!force) {
67
- console.error(`❌ complete-stage 被拒绝:阶段 ${stage} 存在 ${staleSteps.length} 个 stale 步骤`);
68
- for (const st of staleSteps) console.error(` - ${st.name}`);
69
- console.error(` stale 步骤需真实执行推进(sillyspec run ${stage} 逐个完成),或确认方案未变后用 --force 强制回填(将记录审计日志)。`);
70
- return;
71
- }
72
- staleBackfillNote = `stale 步骤回填: ${staleSteps.map(s => s.name).join(', ')}`;
73
- }
74
- }
75
- }
76
- }
77
-
78
- // ── 产物校验门:complete-stage 不再是零校验后门 ──
79
- // 与 run.js completeStep 的 validator 同源;--force 为显式逃生口(留审计)。
80
- const validation = this._validateStageArtifacts(cwd, stage, cn);
81
- if (staleBackfillNote) {
82
- validation.errors = [...(validation.errors || []), staleBackfillNote];
83
- validation.ok = false;
84
- }
85
- if (!validation.ok) {
86
- if (!force) {
87
- console.error(`❌ complete-stage 被拒绝:阶段 ${stage} 产物校验未通过`);
88
- for (const err of validation.errors) console.error(` - ${err}`);
89
- console.error(` 请修复产物后重试,或走正常流程 sillyspec run ${stage} --done。`);
90
- console.error(` 确需强制标记(如 doctor 修复),使用 --force(将记录审计日志)。`);
91
- return;
92
- }
93
- console.warn(`⚠️ --force 强制完成阶段 ${stage}(校验未通过,已记录审计日志)`);
94
- for (const err of validation.errors) console.warn(` - ${err}`);
95
- this.pm._appendAuditLog(cwd, {
96
- action: 'complete-stage --force',
97
- stage,
98
- change: cn,
99
- validationErrors: validation.errors,
100
- });
101
- } else if (force) {
102
- // 校验通过但仍显式 --force:也留一条审计记录,保持行为可追溯
103
- this.pm._appendAuditLog(cwd, { action: 'complete-stage --force', stage, change: cn, validationErrors: [] });
104
- }
105
-
106
- db.transaction(() => {
107
- const sqlDb = db.getDb();
108
- const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
109
- if (changeRow === undefined) return;
110
- const changeId = changeRow.id;
111
-
112
- // 确保 stages 行存在(阶段不存在时自动创建)
113
- sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, stage);
114
-
115
- // UPDATE stages.status=completed + completed_at
116
- sqlDb.prepare(`UPDATE stages SET status = 'completed', completed_at = ? WHERE change_id = ? AND stage = ?`).run(now, changeId, stage);
117
-
118
- // 将该阶段所有 pending 步骤标记为 completed
119
- const stageRow = sqlDb.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(changeId, stage);
120
- if (stageRow !== undefined) {
121
- const stageId = stageRow.id;
122
- sqlDb.prepare(
123
- `UPDATE steps SET status = 'completed', completed_at = ? WHERE stage_id = ? AND status IN ('pending', 'stale')` // stale 一并回填(reopen --done 场景,坑 brainstorm-reopen-step-state-desync;--force 时放行)
124
- ).run(now, stageId);
125
- }
126
-
127
- // UPDATE changes.last_active
128
- sqlDb.prepare('UPDATE changes SET last_active = ? WHERE name = ?').run(now, cn);
129
- // 本地脏度(D-013 / task-04):阶段完成是本地推进
130
- this.pm._touchLocalModified(cwd, cn, now);
131
- });
132
-
133
- // 写 history 文件(保持文件系统,不变)
134
- const data = this.pm.read(cwd, cn);
135
- if (data && data.stages && data.stages[stage]) {
136
- const historyDir = this.pm._runtimePath(cwd, 'history');
137
- mkdirSync(historyDir, { recursive: true });
138
- const ts = now.replace(/[:.TZ-]/g, '');
139
- const stageData = data.stages[stage];
140
- writeAtomicSync(
141
- join(historyDir, `${cn}-${stage}-${ts}.json`),
142
- JSON.stringify({ change: cn, stage, data: stageData, completedAt: now }, null, 2) + '\n'
143
- );
144
- }
145
-
146
- console.log(`✅ 阶段 ${stage} 已标记为完成(不自动推进,下一步由你决定)`);
147
- }
148
-
149
- show(cwd, changeName = null) {
150
- // 如果指定了变更名,只显示该变更
151
- if (changeName) {
152
- return this._showChange(cwd, changeName);
153
- }
154
-
155
- // 否则显示所有变更
156
- const changes = this.pm.listChanges(cwd);
157
- if (changes.length === 0) {
158
- console.log('ℹ️ 没有活跃的变更');
159
- return;
160
- }
161
-
162
- if (changes.length === 1) {
163
- return this._showChange(cwd, changes[0]);
164
- }
165
-
166
- // 多个变更:汇总显示
167
- const global = this.pm.readGlobal(cwd);
168
- console.log('');
169
- console.log(' ═══════════════════════════════════════');
170
- console.log(` 项目: ${(global?.project) || basename(cwd) || '(未命名)'}`);
171
- // 当前操作目标与活跃列表区分(坑 status-change-pointer-ambiguous):多活跃时不带
172
- // --change 的 run/progress 不隐式选任何一个(index.js --change 解析多活跃报错),明确
173
- // 说出来,防止把残留空壳(default/quick-xxx/目录缺失记录)误当操作目标跑错 change。
174
- console.log(` 当前操作目标: (多活跃——不带 --change 时不隐式选定,run 须显式 --change <name>)`);
175
- console.log(` 活跃变更记录: ${changes.length} 个(下列为 DB 中存在的活跃记录,非操作目标)`);
176
- console.log(' ═══════════════════════════════════════');
177
- console.log('');
178
-
179
- const changesRoot = join(this.pm._getSpecDir(cwd), 'changes');
180
- for (const cn of changes) {
181
- const data = this.pm.read(cwd, cn);
182
- const dirMissing = !existsSync(join(changesRoot, cn));
183
- if (!data) {
184
- console.log(` 📂 ${cn} — (无法读取)${dirMissing ? ' ⚠️ 目录缺失(残留记录,可用 doctor 清理)' : ''}`);
185
- continue;
186
- }
187
- const currentStage = data.currentStage || '(无)';
188
- const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
189
- const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
190
-
191
- console.log(` 📂 ${cn}${dirMissing ? ' ⚠️ 目录缺失(残留记录,可用 doctor 清理)' : ''}`);
192
- console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
193
- console.log('');
194
- }
195
-
196
- console.log(` 💡 查看详情:sillyspec progress show --change <name>`);
197
- console.log('');
198
- }
199
-
200
- _showChange(cwd, changeName) {
201
- const data = this.pm.read(cwd, changeName);
202
- if (!data) {
203
- console.log(`❌ 未找到变更 ${changeName}`);
204
- return;
205
- }
206
-
207
- console.log('');
208
- console.log(' ═══════════════════════════════════════');
209
- console.log(` 变更: ${changeName}`);
210
- console.log(` 项目: ${data.project || '(未命名)'}`);
211
- console.log(` 当前阶段: ${STAGE_LABELS[data.currentStage] || data.currentStage || '(无)'}`);
212
- console.log(` 最近活跃: ${data.lastActive ? this._timeAgo(data.lastActive) : '未知'}`);
213
- console.log(' ═══════════════════════════════════════');
214
- console.log('');
215
-
216
- const statusIcons = { pending: '⬜', 'in-progress': '🔵', completed: '✅', failed: '❌', blocked: '🚫', waiting: '⏸️', revising: '🔧', stale: '⚠️' };
217
-
218
- for (const stage of VALID_STAGES) {
219
- const stageData = data.stages[stage] || emptyStage();
220
- const label = STAGE_LABELS[stage] || stage;
221
- const icon = statusIcons[stageData.status] || '⬜';
222
- const isCurrent = data.currentStage === stage ? ' ◀' : '';
223
-
224
- console.log(` ${icon} ${label}${isCurrent}`);
225
-
226
- // Show revision info
227
- if (stageData.revision && stageData.revision > 0) {
228
- console.log(` 📋 revision: ${stageData.revision}${stageData.reopenedFromStep ? `, from step: ${stageData.reopenedFromStep}` : ''}`);
229
- }
230
- if (stageData.staleReason) {
231
- console.log(` ⚠️ stale: ${stageData.staleReason}`);
232
- if (stage === 'archive') {
233
- console.log(` 📁 已有归档文件仍保留在磁盘上,但不再可信。`);
234
- }
235
- }
236
-
237
- if (stageData.steps && stageData.steps.length > 0) {
238
- for (const step of stageData.steps) {
239
- const si = statusIcons[step.status] || '○';
240
- const out = step.output ? ` — ${step.output.slice(0, 60)}` : '';
241
- const waitingTag = step.status === 'waiting' ? ' [WAITING]' : ''
242
- console.log(` ${si} ${step.name}${out}${waitingTag}`);
243
- if (step.status === 'waiting') {
244
- if (step.waitReason) console.log(` 原因:${step.waitReason}`);
245
- if (step.waitOptions) console.log(` 选项:${(() => { try { const p = JSON.parse(step.waitOptions); return Array.isArray(p) ? p.join(', ') : step.waitOptions; } catch { return step.waitOptions; }})()}`);
246
- if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`);
247
- }
248
- }
249
- }
250
-
251
- if (stageData.startedAt) {
252
- console.log(` 开始: ${new Date(stageData.startedAt).toLocaleString('zh-CN')}`);
253
- }
254
- if (stageData.completedAt) {
255
- console.log(` 完成: ${new Date(stageData.completedAt).toLocaleString('zh-CN')}`);
256
- }
257
- }
258
-
259
- // 批量进度
260
- if (data.batchProgress) {
261
- const batchLine = this.pm._renderBatchProgress(data.batchProgress);
262
- if (batchLine) {
263
- console.log('');
264
- console.log(` ${batchLine}`);
265
- }
266
- }
267
-
268
- // ── Next 建议 ──
269
- const suggestion = this._getNextSuggestion(data);
270
- if (suggestion) {
271
- console.log('');
272
- console.log(` 💡 ${suggestion.text}`);
273
- if (suggestion.command) console.log(` ${suggestion.command}`);
274
- }
275
-
276
- console.log('');
277
- }
278
-
279
- /**
280
- * 根据当前状态给出下一步建议
281
- * @param {object} data - progress data
282
- * @returns {{ text: string, command?: string }|null}
283
- */
284
- _getNextSuggestion(data) {
285
- // 找到第一个 revising 阶段
286
- const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
287
- if (revisingStage) {
288
- const sd = data.stages[revisingStage];
289
- return {
290
- text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
291
- command: `sillyspec run ${revisingStage}`,
292
- };
293
- }
294
-
295
- // 找到第一个 stale 阶段(上游已修,下游需要重建)
296
- const staleStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'stale');
297
- if (staleStage) {
298
- const sd = data.stages[staleStage];
299
- return {
300
- text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
301
- command: `sillyspec run ${staleStage} --reopen --from-step 1`,
302
- };
303
- }
304
-
305
- // 找到第一个有 pending/waiting/failed 步骤的 in-progress 阶段
306
- // C14b:排除 scan(同下方主流程推荐循环)——scan 是 auxiliary,中途未完成时恒处
307
- // STAGE_ORDER 首位且 in-progress,会把「下一步」劫持为 scan,掩盖主流程真实待办。
308
- for (const s of STAGE_ORDER) {
309
- if (s === 'scan') continue
310
- const sd = data.stages[s];
311
- if (!sd) continue;
312
- if (sd.status === 'in-progress' && sd.steps) {
313
- const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
314
- if (hasPending) {
315
- return {
316
- text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
317
- command: `sillyspec run ${s}`,
318
- };
319
- }
320
- }
321
- }
322
-
323
- // 找主流程里第一个「未完成且上游已就绪」的阶段,推荐它作为下一步。
324
- // 修两个缺陷(曾导致 brainstorm 完成后误推 archive):
325
- // ① 原 steps.length>0 要求:plan/execute/verify 首次 run 前惰性未初始化(steps 空)被跳过;
326
- // ② 原 upstream 把 pending 当就绪:archive 的上游 plan/execute/verify 即便 pending 也算 ok。
327
- // 叠加 → 循环跳过未初始化的中间阶段,漏到 archive(steps 非空)误推,违反流程顺序。
328
- // 改为:不要求 steps(空=该开始了);upstream 必须全 completed(pending 不算就绪)——
329
- // archive 只在 scan/brainstorm/plan/execute/verify 全完成后才可能被推荐。
330
- for (const s of STAGE_ORDER) {
331
- if (s === 'scan') continue // scan 是 auxiliary(按需显式跑),不作"下一步"推荐——否则未 completed 时恒处 STAGE_ORDER 首位且 upstream 空→恒就绪→误推 scan(回头路,与 complete.js brainstorm/quick 专属分支同类根因)。prompt-control-debt plan-c。
332
- const sd = data.stages[s];
333
- if (!sd) continue;
334
- if (sd.status === 'completed' || sd.status === 'skipped') continue;
335
- const idx = STAGE_ORDER.indexOf(s);
336
- const upstream = STAGE_ORDER.slice(0, idx).filter(us => us !== 'scan'); // scan(auxiliary)不算上游——否则 scan 未 completed 会阻塞所有主流程阶段,与上面跳过 scan 的迭代矛盾
337
- const upstreamOk = upstream.every(us => data.stages[us]?.status === 'completed');
338
- if (upstreamOk) {
339
- return {
340
- text: `可以开始 ${STAGE_LABELS[s] || s}。`,
341
- command: `sillyspec run ${s}`,
342
- };
343
- }
344
- }
345
-
346
- return null;
347
- }
348
-
349
- status(cwd, changeName = null) {
350
- this.show(cwd, changeName);
351
- }
352
-
353
- validate(cwd, changeName = null) {
354
- const data = this.pm.read(cwd, changeName);
355
- if (!data) { console.log('❌ 无法读取进度数据'); return false; }
356
-
357
- const errors = [];
358
- if (!data._version || !Number.isInteger(data._version) || data._version < 1) {
359
- errors.push(`_version 缺失或无效(期望正整数,实际为 ${JSON.stringify(data._version)})`);
360
- }
361
- if (!data.stages || typeof data.stages !== 'object') errors.push('缺少 stages');
362
- if (!VALID_STAGES.every(s => data.stages[s])) errors.push('缺少阶段定义');
363
-
364
- if (errors.length === 0) { console.log('✅ 进度数据格式正确'); return true; }
365
-
366
- console.log(`⚠️ 发现问题,尝试修复...`);
367
- let fixed = { ...data, stages: { ...data.stages } };
368
- let changed = false;
369
- if (!fixed.project) {
370
- fixed.project = basename(cwd);
371
- changed = true;
372
- }
373
- if (!fixed._version || !Number.isInteger(fixed._version) || fixed._version < 1) {
374
- fixed._version = CURRENT_VERSION;
375
- changed = true;
376
- }
377
- for (const s of VALID_STAGES) {
378
- if (!fixed.stages[s]) { fixed.stages[s] = emptyStage(); changed = true; }
379
- }
380
- if (changed) {
381
- this.pm._write(cwd, fixed);
382
- console.log('✅ 已修复');
383
- }
384
-
385
- return true;
386
- }
387
-
388
- /**
389
- * 重新打开已完成的阶段进入修订模式
390
- * - 不带 fromStep:只允许存在 pending/stale/waiting/failed 步骤时继续
391
- * - 带 fromStep:从该步骤起,当前及后续步骤标记 stale/pending
392
- * - 自动级联标记下游阶段为 stale
393
- *
394
- * @param {string} cwd
395
- * @param {string} stage - 要重开的阶段
396
- * @param {object} opts
397
- * @param {string|number} [opts.fromStep] - 步骤名或序号(1-based)
398
- * @param {string} [opts.changeName]
399
- * @returns {{ ok: boolean, error?: string }}
400
- */
401
- reopenStage(cwd, stage, opts = {}) {
402
- const { fromStep, changeName = null } = opts;
403
-
404
- const data = this.pm.read(cwd, changeName);
405
- if (!data) return { ok: false, error: '无法读取进度数据' };
406
-
407
- const stageData = data.stages[stage];
408
- if (!stageData) return { ok: false, error: `未知阶段: ${stage}` };
409
-
410
- const steps = stageData.steps || [];
411
-
412
- // 确定 fromStep 对应的 index
413
- let fromIdx = null;
414
- if (fromStep != null) {
415
- if (typeof fromStep === 'number' || /^\d+$/.test(String(fromStep))) {
416
- fromIdx = parseInt(String(fromStep), 10) - 1; // 1-based → 0-based
417
- if (fromIdx < 0 || fromIdx >= steps.length) {
418
- return { ok: false, error: `步骤序号超出范围: ${fromStep}(共 ${steps.length} 步)` };
419
- }
420
- } else {
421
- // 按名称匹配
422
- fromIdx = steps.findIndex(s => s.name === fromStep);
423
- if (fromIdx === -1) {
424
- return { ok: false, error: `步骤不存在: ${fromStep}` };
425
- }
426
- }
427
- }
428
-
429
- // 如果不带 fromStep,检查是否存在中断步骤
430
- if (fromIdx === null) {
431
- const hasInterrupted = steps.some(s =>
432
- ['pending', 'stale', 'waiting', 'failed'].includes(s.status)
433
- );
434
- if (!hasInterrupted) {
435
- return { ok: false, error: `阶段 ${stage} 所有步骤均已完成,请使用 --from-step 指定从哪一步开始修订` };
436
- }
437
- // 找到第一个中断步骤
438
- fromIdx = steps.findIndex(s =>
439
- ['pending', 'stale', 'waiting', 'failed'].includes(s.status)
440
- );
441
- }
442
-
443
- // 执行重开操作
444
- const newRevision = (stageData.revision || 0) + 1;
445
- const fromStepName = steps[fromIdx].name;
446
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
447
-
448
- // 更新步骤状态:fromStep 之前的保持 completed,fromStep 变 pending,之后的变 stale
449
- for (let i = 0; i < steps.length; i++) {
450
- if (i === fromIdx) {
451
- steps[i].status = 'pending';
452
- steps[i].completedAt = null;
453
- steps[i].output = null;
454
- } else if (i > fromIdx) {
455
- steps[i].status = 'stale';
456
- steps[i].completedAt = null;
457
- }
458
- // i < fromIdx: 保持原状(completed)
459
- }
460
-
461
- stageData.status = 'revising';
462
- stageData.completedAt = null;
463
- stageData.revision = newRevision;
464
- stageData.reopenedFromStep = `${fromIdx + 1}: ${fromStepName}`; // 存 "index: name" 格式
465
- stageData.reopenedAt = now;
466
- stageData.steps = steps;
467
-
468
- data.lastActive = now;
469
- data.currentStage = stage;
470
-
471
- this.pm._write(cwd, data, changeName);
472
-
473
- // 级联标记下游阶段为 stale
474
- const downstreamStages = this._getDownstreamStages(stage);
475
- if (downstreamStages.length > 0) {
476
- const data2 = this.pm.read(cwd, changeName); // 重新读取以获取最新状态
477
- if (data2) {
478
- for (const ds of downstreamStages) {
479
- if (data2.stages[ds] && data2.stages[ds].status === 'completed') {
480
- data2.stages[ds].status = 'stale';
481
- data2.stages[ds].staleReason = `上游阶段 ${stage} 已修订 (revision ${newRevision})`;
482
- data2.stages[ds].completedAt = null;
483
- }
484
- }
485
- this.pm._write(cwd, data2, changeName);
486
- }
487
- }
488
-
489
- return { ok: true, revision: newRevision, fromStep: fromStepName };
490
- }
491
-
492
- /**
493
- * 获取指定阶段的下游主流程阶段列表
494
- * @param {string} stage
495
- * @returns {string[]}
496
- */
497
- _getDownstreamStages(stage) {
498
- const idx = MAIN_FLOW_ORDER.indexOf(stage);
499
- if (idx === -1) return [];
500
- return MAIN_FLOW_ORDER.slice(idx + 1);
501
- }
502
-
503
- reset(cwd, stage, changeName = null) {
504
- if (stage) {
505
- const data = this.pm.read(cwd, changeName);
506
- if (!data) { console.log('❌ 无法读取进度数据'); return; }
507
- if (!data.stages[stage]) { console.log(`❌ 未知阶段: ${stage}`); return; }
508
- // 破坏性预览:reset 不可逆地清空该阶段所有步骤(含已完成),先告诉 agent 丢了什么。
509
- const sd = data.stages[stage];
510
- const steps = sd.steps || [];
511
- const doneCount = steps.filter(s => s.status === 'completed').length;
512
- console.warn(`⚠️ 即将重置阶段「${stage}」:丢弃 ${steps.length} 个步骤(其中 ${doneCount} 个已完成,revision ${sd.revision || 0})。此操作不可逆。`);
513
- data.stages[stage] = emptyStage();
514
- data.lastActive = new Date().toLocaleString('zh-CN',{hour12:false});
515
- this.pm._write(cwd, data);
516
- console.log(`✅ 已重置阶段: ${stage}`);
517
- } else {
518
- // 重置所有变更或指定变更
519
- if (changeName) {
520
- console.warn(`⚠️ 即将重置变更「${changeName}」的全部进度(所有阶段的 steps + stage 状态,产物文件不动)。此操作不可逆。`);
521
- // SQL: 删除该变更的所有 stages 和 steps 数据
522
- const db = this.pm._ensureDB(cwd);
523
- db.transaction(() => {
524
- const sqlDb = db.getDb();
525
- const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
526
- if (changeRow !== undefined) {
527
- const changeId = changeRow.id;
528
- sqlDb.prepare('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)').run(changeId);
529
- sqlDb.prepare('DELETE FROM stages WHERE change_id = ?').run(changeId);
530
- // 重新插入所有阶段(注意:上方已 DELETE stages,无需再 UPDATE)
531
- for (const s of VALID_STAGES) {
532
- sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, s);
533
- }
534
- // 本地脏度(D-013 / task-04):重置该变更全部进度是本地推进
535
- this.pm._touchLocalModified(cwd, changeName);
536
- }
537
- });
538
- console.log(`✅ 已重置变更 ${changeName} 的进度`);
539
- } else {
540
- const changes = this.pm.listChanges(cwd);
541
- console.warn(`⚠️ 即将重置【所有 ${changes.length} 个变更】的进度(仅 stage 状态,产物文件不动)。此操作不可逆且范围最大。`);
542
- const db = this.pm._ensureDB(cwd);
543
- db.transaction(() => {
544
- const sqlDb = db.getDb();
545
- for (const cn of changes) {
546
- const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
547
- if (changeRow !== undefined) {
548
- const changeId = changeRow.id;
549
- sqlDb.prepare('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)').run(changeId);
550
- sqlDb.prepare(`UPDATE stages SET status = 'pending', started_at = NULL, completed_at = NULL WHERE change_id = ?`).run(changeId);
551
- // 本地脏度(D-013 / task-04):重置进度是本地推进
552
- this.pm._touchLocalModified(cwd, cn);
553
- }
554
- }
555
- });
556
- console.log('✅ 已重置所有变更的进度');
557
- }
558
- }
559
- }
560
-
561
- _timeAgo(dateStr) {
562
- if (!dateStr) return '未知';
563
- let ts = Date.parse(dateStr);
564
- if (isNaN(ts)) {
565
- const m = dateStr.match(/(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})[\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
566
- if (m) ts = new Date(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +(m[6]||0)).getTime();
567
- }
568
- if (isNaN(ts)) return dateStr;
569
- const diff = Date.now() - ts;
570
- const minutes = Math.floor(diff / 60000);
571
- if (minutes < 1) return '刚刚';
572
- if (minutes < 60) return `${minutes} 分钟前`;
573
- const hours = Math.floor(minutes / 60);
574
- if (hours < 24) return `${hours} 小时前`;
575
- return `${Math.floor(hours / 24)} 天前`;
576
- }
577
- }
578
-
1
+ // W6 Step9d: 阶段状态机 —— completeStage/reopen/reset/validate/show/status + 产物校验 + 下游级联 +
2
+ // 显示辅助。从 src/progress.js 单体 ProgressManager 抽出(Step9 最后一步)。持有 pm 引用(构造注入),
3
+ // 调 pm.read/_write/_ensureDB/_getSpecDir/readGlobal/_runtimePath/listChanges/_appendAuditLog/
4
+ // _renderBatchProgress(persistence-core + 其他组 delegate)。组内方法互调保持 this.X(同 class)。
5
+ // completeStage 五层(resolve/validate/force/tx/history/print)整体搬迁,不拆流水线(保行为、最低风险)。
6
+ import { mkdirSync, existsSync } from 'fs';
7
+ import { join, resolve, basename } from 'path';
8
+ import { writeAtomicSync } from '../fs-atomic.js';
9
+ import { runValidators } from '../stage-contract.js';
10
+ import { VALID_STAGES, STAGE_LABELS, STAGE_ORDER, MAIN_FLOW_ORDER, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './shared.js';
11
+
12
+ export class StageMachine {
13
+ constructor(pm) {
14
+ this.pm = pm;
15
+ }
16
+
17
+ /**
18
+ * 阶段产物校验门:progress complete-stage / update-step 自动完成时复用
19
+ * stage-contract 的 validator,防止零产物阶段被直接标 completed。
20
+ */
21
+ _validateStageArtifacts(cwd, stage, changeName) {
22
+ const specDir = this.pm._getSpecDir(cwd);
23
+ const defaultSpec = join(resolve(cwd), SPEC_DIR_NAME);
24
+ const specRoot = resolve(specDir) === defaultSpec ? null : specDir;
25
+ let projectName = null;
26
+ try {
27
+ const g = this.pm.readGlobal(cwd);
28
+ projectName = g?.project || null;
29
+ } catch {}
30
+ return runValidators(stage, cwd, changeName, {
31
+ projectName: projectName || basename(resolve(cwd)),
32
+ specRoot,
33
+ });
34
+ }
35
+
36
+ completeStage(cwd, stage, changeName = null, opts = {}) {
37
+ const { force = false } = opts;
38
+ if (!VALID_STAGES.includes(stage)) {
39
+ console.log(`❌ 未知阶段: ${stage}`);
40
+ return;
41
+ }
42
+
43
+ const db = this.pm._ensureDB(cwd);
44
+ const now = new Date().toISOString();
45
+
46
+ // 获取变更名
47
+ let cn = changeName;
48
+ if (!cn) {
49
+ const changes = this.pm.listChanges(cwd);
50
+ if (changes.length === 1) cn = changes[0];
51
+ if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
52
+ }
53
+
54
+ // ── stale 拒绝门(W1 改动点 2):complete-stage 不再静默回填 stale 步骤 ──
55
+ // 放在产物校验门之前——stale 是更具体的拒绝原因(产物缺失时旧序会被产物门先拦走,
56
+ // stale 门永远执行不到);无 --force 拒绝并列名,有 --force 放行并把 stale 步骤名并入审计。
57
+ let staleBackfillNote = null;
58
+ {
59
+ const sqlDbForStaleCheck = db.getDb();
60
+ const changeRowForStale = sqlDbForStaleCheck.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
61
+ if (changeRowForStale !== undefined) {
62
+ const stageRowForStale = sqlDbForStaleCheck.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(changeRowForStale.id, stage);
63
+ if (stageRowForStale !== undefined) {
64
+ const staleSteps = sqlDbForStaleCheck.prepare('SELECT name FROM steps WHERE stage_id = ? AND status = ?').all(stageRowForStale.id, 'stale');
65
+ if (staleSteps.length > 0) {
66
+ if (!force) {
67
+ console.error(`❌ complete-stage 被拒绝:阶段 ${stage} 存在 ${staleSteps.length} 个 stale 步骤`);
68
+ for (const st of staleSteps) console.error(` - ${st.name}`);
69
+ console.error(` stale 步骤需真实执行推进(sillyspec run ${stage} 逐个完成),或确认方案未变后用 --force 强制回填(将记录审计日志)。`);
70
+ return;
71
+ }
72
+ staleBackfillNote = `stale 步骤回填: ${staleSteps.map(s => s.name).join(', ')}`;
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // ── 产物校验门:complete-stage 不再是零校验后门 ──
79
+ // 与 run.js completeStep 的 validator 同源;--force 为显式逃生口(留审计)。
80
+ const validation = this._validateStageArtifacts(cwd, stage, cn);
81
+ if (staleBackfillNote) {
82
+ validation.errors = [...(validation.errors || []), staleBackfillNote];
83
+ validation.ok = false;
84
+ }
85
+ if (!validation.ok) {
86
+ if (!force) {
87
+ console.error(`❌ complete-stage 被拒绝:阶段 ${stage} 产物校验未通过`);
88
+ for (const err of validation.errors) console.error(` - ${err}`);
89
+ console.error(` 请修复产物后重试,或走正常流程 sillyspec run ${stage} --done。`);
90
+ console.error(` 确需强制标记(如 doctor 修复),使用 --force(将记录审计日志)。`);
91
+ return;
92
+ }
93
+ console.warn(`⚠️ --force 强制完成阶段 ${stage}(校验未通过,已记录审计日志)`);
94
+ for (const err of validation.errors) console.warn(` - ${err}`);
95
+ this.pm._appendAuditLog(cwd, {
96
+ action: 'complete-stage --force',
97
+ stage,
98
+ change: cn,
99
+ validationErrors: validation.errors,
100
+ });
101
+ } else if (force) {
102
+ // 校验通过但仍显式 --force:也留一条审计记录,保持行为可追溯
103
+ this.pm._appendAuditLog(cwd, { action: 'complete-stage --force', stage, change: cn, validationErrors: [] });
104
+ }
105
+
106
+ db.transaction(() => {
107
+ const sqlDb = db.getDb();
108
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
109
+ if (changeRow === undefined) return;
110
+ const changeId = changeRow.id;
111
+
112
+ // 确保 stages 行存在(阶段不存在时自动创建)
113
+ sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, stage);
114
+
115
+ // UPDATE stages.status=completed + completed_at
116
+ sqlDb.prepare(`UPDATE stages SET status = 'completed', completed_at = ? WHERE change_id = ? AND stage = ?`).run(now, changeId, stage);
117
+
118
+ // 将该阶段所有 pending 步骤标记为 completed
119
+ const stageRow = sqlDb.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(changeId, stage);
120
+ if (stageRow !== undefined) {
121
+ const stageId = stageRow.id;
122
+ sqlDb.prepare(
123
+ `UPDATE steps SET status = 'completed', completed_at = ? WHERE stage_id = ? AND status IN ('pending', 'stale')` // stale 一并回填(reopen --done 场景,坑 brainstorm-reopen-step-state-desync;--force 时放行)
124
+ ).run(now, stageId);
125
+ }
126
+
127
+ // UPDATE changes.last_active
128
+ sqlDb.prepare('UPDATE changes SET last_active = ? WHERE name = ?').run(now, cn);
129
+ // 本地脏度(D-013 / task-04):阶段完成是本地推进
130
+ this.pm._touchLocalModified(cwd, cn, now);
131
+ });
132
+
133
+ // 写 history 文件(保持文件系统,不变)
134
+ const data = this.pm.read(cwd, cn);
135
+ if (data && data.stages && data.stages[stage]) {
136
+ const historyDir = this.pm._runtimePath(cwd, 'history');
137
+ mkdirSync(historyDir, { recursive: true });
138
+ const ts = now.replace(/[:.TZ-]/g, '');
139
+ const stageData = data.stages[stage];
140
+ writeAtomicSync(
141
+ join(historyDir, `${cn}-${stage}-${ts}.json`),
142
+ JSON.stringify({ change: cn, stage, data: stageData, completedAt: now }, null, 2) + '\n'
143
+ );
144
+ }
145
+
146
+ console.log(`✅ 阶段 ${stage} 已标记为完成(不自动推进,下一步由你决定)`);
147
+ }
148
+
149
+ show(cwd, changeName = null) {
150
+ // 如果指定了变更名,只显示该变更
151
+ if (changeName) {
152
+ return this._showChange(cwd, changeName);
153
+ }
154
+
155
+ // 否则显示所有变更
156
+ const changes = this.pm.listChanges(cwd);
157
+ if (changes.length === 0) {
158
+ console.log('ℹ️ 没有活跃的变更');
159
+ return;
160
+ }
161
+
162
+ if (changes.length === 1) {
163
+ return this._showChange(cwd, changes[0]);
164
+ }
165
+
166
+ // 多个变更:汇总显示
167
+ const global = this.pm.readGlobal(cwd);
168
+ console.log('');
169
+ console.log(' ═══════════════════════════════════════');
170
+ console.log(` 项目: ${(global?.project) || basename(cwd) || '(未命名)'}`);
171
+ // 当前操作目标与活跃列表区分(坑 status-change-pointer-ambiguous):多活跃时不带
172
+ // --change 的 run/progress 不隐式选任何一个(index.js --change 解析多活跃报错),明确
173
+ // 说出来,防止把残留空壳(default/quick-xxx/目录缺失记录)误当操作目标跑错 change。
174
+ console.log(` 当前操作目标: (多活跃——不带 --change 时不隐式选定,run 须显式 --change <name>)`);
175
+ console.log(` 活跃变更记录: ${changes.length} 个(下列为 DB 中存在的活跃记录,非操作目标)`);
176
+ console.log(' ═══════════════════════════════════════');
177
+ console.log('');
178
+
179
+ const changesRoot = join(this.pm._getSpecDir(cwd), 'changes');
180
+ for (const cn of changes) {
181
+ const data = this.pm.read(cwd, cn);
182
+ const dirMissing = !existsSync(join(changesRoot, cn));
183
+ if (!data) {
184
+ console.log(` 📂 ${cn} — (无法读取)${dirMissing ? ' ⚠️ 目录缺失(残留记录,sillyspec doctor --cleanup-ghosts --confirm 可归档清理)' : ''}`);
185
+ continue;
186
+ }
187
+ const currentStage = data.currentStage || '(无)';
188
+ const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
189
+ const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
190
+
191
+ console.log(` 📂 ${cn}${dirMissing ? ' ⚠️ 目录缺失(残留记录,sillyspec doctor --cleanup-ghosts --confirm 可归档清理)' : ''}`);
192
+ console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
193
+ console.log('');
194
+ }
195
+
196
+ console.log(` 💡 查看详情:sillyspec progress show --change <name>`);
197
+ console.log('');
198
+ }
199
+
200
+ _showChange(cwd, changeName) {
201
+ const data = this.pm.read(cwd, changeName);
202
+ if (!data) {
203
+ console.log(`❌ 未找到变更 ${changeName}`);
204
+ return;
205
+ }
206
+
207
+ console.log('');
208
+ console.log(' ═══════════════════════════════════════');
209
+ console.log(` 变更: ${changeName}`);
210
+ console.log(` 项目: ${data.project || '(未命名)'}`);
211
+ console.log(` 当前阶段: ${STAGE_LABELS[data.currentStage] || data.currentStage || '(无)'}`);
212
+ console.log(` 最近活跃: ${data.lastActive ? this._timeAgo(data.lastActive) : '未知'}`);
213
+ console.log(' ═══════════════════════════════════════');
214
+ console.log('');
215
+
216
+ const statusIcons = { pending: '⬜', 'in-progress': '🔵', completed: '✅', failed: '❌', blocked: '🚫', waiting: '⏸️', revising: '🔧', stale: '⚠️' };
217
+
218
+ for (const stage of VALID_STAGES) {
219
+ const stageData = data.stages[stage] || emptyStage();
220
+ const label = STAGE_LABELS[stage] || stage;
221
+ const icon = statusIcons[stageData.status] || '⬜';
222
+ const isCurrent = data.currentStage === stage ? ' ◀' : '';
223
+
224
+ console.log(` ${icon} ${label}${isCurrent}`);
225
+
226
+ // Show revision info
227
+ if (stageData.revision && stageData.revision > 0) {
228
+ console.log(` 📋 revision: ${stageData.revision}${stageData.reopenedFromStep ? `, from step: ${stageData.reopenedFromStep}` : ''}`);
229
+ }
230
+ if (stageData.staleReason) {
231
+ console.log(` ⚠️ stale: ${stageData.staleReason}`);
232
+ if (stage === 'archive') {
233
+ console.log(` 📁 已有归档文件仍保留在磁盘上,但不再可信。`);
234
+ }
235
+ }
236
+
237
+ if (stageData.steps && stageData.steps.length > 0) {
238
+ for (const step of stageData.steps) {
239
+ const si = statusIcons[step.status] || '○';
240
+ const out = step.output ? ` — ${step.output.slice(0, 60)}` : '';
241
+ const waitingTag = step.status === 'waiting' ? ' [WAITING]' : ''
242
+ console.log(` ${si} ${step.name}${out}${waitingTag}`);
243
+ if (step.status === 'waiting') {
244
+ if (step.waitReason) console.log(` 原因:${step.waitReason}`);
245
+ if (step.waitOptions) console.log(` 选项:${(() => { try { const p = JSON.parse(step.waitOptions); return Array.isArray(p) ? p.join(', ') : step.waitOptions; } catch { return step.waitOptions; }})()}`);
246
+ if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`);
247
+ }
248
+ }
249
+ }
250
+
251
+ if (stageData.startedAt) {
252
+ console.log(` 开始: ${new Date(stageData.startedAt).toLocaleString('zh-CN')}`);
253
+ }
254
+ if (stageData.completedAt) {
255
+ console.log(` 完成: ${new Date(stageData.completedAt).toLocaleString('zh-CN')}`);
256
+ }
257
+ }
258
+
259
+ // 批量进度
260
+ if (data.batchProgress) {
261
+ const batchLine = this.pm._renderBatchProgress(data.batchProgress);
262
+ if (batchLine) {
263
+ console.log('');
264
+ console.log(` ${batchLine}`);
265
+ }
266
+ }
267
+
268
+ // ── Next 建议 ──
269
+ const suggestion = this._getNextSuggestion(data);
270
+ if (suggestion) {
271
+ console.log('');
272
+ console.log(` 💡 ${suggestion.text}`);
273
+ if (suggestion.command) console.log(` ${suggestion.command}`);
274
+ }
275
+
276
+ console.log('');
277
+ }
278
+
279
+ /**
280
+ * 根据当前状态给出下一步建议
281
+ * @param {object} data - progress data
282
+ * @returns {{ text: string, command?: string }|null}
283
+ */
284
+ _getNextSuggestion(data) {
285
+ // 找到第一个 revising 阶段
286
+ const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
287
+ if (revisingStage) {
288
+ const sd = data.stages[revisingStage];
289
+ return {
290
+ text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
291
+ command: `sillyspec run ${revisingStage}`,
292
+ };
293
+ }
294
+
295
+ // 找到第一个 stale 阶段(上游已修,下游需要重建)
296
+ const staleStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'stale');
297
+ if (staleStage) {
298
+ const sd = data.stages[staleStage];
299
+ return {
300
+ text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
301
+ command: `sillyspec run ${staleStage} --reopen --from-step 1`,
302
+ };
303
+ }
304
+
305
+ // 找到第一个有 pending/waiting/failed 步骤的 in-progress 阶段
306
+ // C14b:排除 scan(同下方主流程推荐循环)——scan 是 auxiliary,中途未完成时恒处
307
+ // STAGE_ORDER 首位且 in-progress,会把「下一步」劫持为 scan,掩盖主流程真实待办。
308
+ for (const s of STAGE_ORDER) {
309
+ if (s === 'scan') continue
310
+ const sd = data.stages[s];
311
+ if (!sd) continue;
312
+ if (sd.status === 'in-progress' && sd.steps) {
313
+ // waiting 优先于 pending/failed:暂停等用户决策的阶段,下一步是 --continue --answer
314
+ // 恢复(坑 archive-step3-wait-answer-hint-late:建议命令前置 --answer,而非泛泛的
315
+ // 「继续执行」让 agent 撞 --done 报错才知道要 --answer)
316
+ const waitIdx = sd.steps.findIndex(st => st.status === 'waiting');
317
+ if (waitIdx !== -1) {
318
+ const ws = sd.steps[waitIdx];
319
+ return {
320
+ text: `${STAGE_LABELS[s] || s} 进行中,Step ${waitIdx + 1}「${ws.name}」等待用户输入${ws.waitReason ? `(${ws.waitReason})` : ''},用 --answer 恢复。`,
321
+ command: `sillyspec run ${s} --continue --answer "用户回答"`,
322
+ };
323
+ }
324
+ const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
325
+ if (hasPending) {
326
+ return {
327
+ text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
328
+ command: `sillyspec run ${s}`,
329
+ };
330
+ }
331
+ }
332
+ }
333
+
334
+ // 找主流程里第一个「未完成且上游已就绪」的阶段,推荐它作为下一步。
335
+ // 修两个缺陷(曾导致 brainstorm 完成后误推 archive):
336
+ // ① 原 steps.length>0 要求:plan/execute/verify 首次 run 前惰性未初始化(steps 空)被跳过;
337
+ // ② 原 upstream 把 pending 当就绪:archive 的上游 plan/execute/verify 即便 pending 也算 ok。
338
+ // 叠加 → 循环跳过未初始化的中间阶段,漏到 archive(steps 非空)误推,违反流程顺序。
339
+ // 改为:不要求 steps(空=该开始了);upstream 必须全 completed(pending 不算就绪)——
340
+ // archive 只在 scan/brainstorm/plan/execute/verify 全完成后才可能被推荐。
341
+ for (const s of STAGE_ORDER) {
342
+ if (s === 'scan') continue // scan 是 auxiliary(按需显式跑),不作"下一步"推荐——否则未 completed 时恒处 STAGE_ORDER 首位且 upstream 空→恒就绪→误推 scan(回头路,与 complete.js brainstorm/quick 专属分支同类根因)。prompt-control-debt plan-c。
343
+ const sd = data.stages[s];
344
+ if (!sd) continue;
345
+ if (sd.status === 'completed' || sd.status === 'skipped') continue;
346
+ const idx = STAGE_ORDER.indexOf(s);
347
+ const upstream = STAGE_ORDER.slice(0, idx).filter(us => us !== 'scan'); // scan(auxiliary)不算上游——否则 scan 未 completed 会阻塞所有主流程阶段,与上面跳过 scan 的迭代矛盾
348
+ const upstreamOk = upstream.every(us => data.stages[us]?.status === 'completed');
349
+ if (upstreamOk) {
350
+ return {
351
+ text: `可以开始 ${STAGE_LABELS[s] || s}。`,
352
+ command: `sillyspec run ${s}`,
353
+ };
354
+ }
355
+ }
356
+
357
+ return null;
358
+ }
359
+
360
+ status(cwd, changeName = null) {
361
+ this.show(cwd, changeName);
362
+ }
363
+
364
+ validate(cwd, changeName = null) {
365
+ const data = this.pm.read(cwd, changeName);
366
+ if (!data) { console.log('❌ 无法读取进度数据'); return false; }
367
+
368
+ const errors = [];
369
+ if (!data._version || !Number.isInteger(data._version) || data._version < 1) {
370
+ errors.push(`_version 缺失或无效(期望正整数,实际为 ${JSON.stringify(data._version)})`);
371
+ }
372
+ if (!data.stages || typeof data.stages !== 'object') errors.push('缺少 stages');
373
+ if (!VALID_STAGES.every(s => data.stages[s])) errors.push('缺少阶段定义');
374
+
375
+ if (errors.length === 0) { console.log('✅ 进度数据格式正确'); return true; }
376
+
377
+ console.log(`⚠️ 发现问题,尝试修复...`);
378
+ let fixed = { ...data, stages: { ...data.stages } };
379
+ let changed = false;
380
+ if (!fixed.project) {
381
+ fixed.project = basename(cwd);
382
+ changed = true;
383
+ }
384
+ if (!fixed._version || !Number.isInteger(fixed._version) || fixed._version < 1) {
385
+ fixed._version = CURRENT_VERSION;
386
+ changed = true;
387
+ }
388
+ for (const s of VALID_STAGES) {
389
+ if (!fixed.stages[s]) { fixed.stages[s] = emptyStage(); changed = true; }
390
+ }
391
+ if (changed) {
392
+ this.pm._write(cwd, fixed);
393
+ console.log('✅ 已修复');
394
+ }
395
+
396
+ return true;
397
+ }
398
+
399
+ /**
400
+ * 重新打开已完成的阶段进入修订模式
401
+ * - 不带 fromStep:只允许存在 pending/stale/waiting/failed 步骤时继续
402
+ * - 带 fromStep:从该步骤起,当前及后续步骤标记 stale/pending
403
+ * - 自动级联标记下游阶段为 stale
404
+ *
405
+ * @param {string} cwd
406
+ * @param {string} stage - 要重开的阶段
407
+ * @param {object} opts
408
+ * @param {string|number} [opts.fromStep] - 步骤名或序号(1-based)
409
+ * @param {string} [opts.changeName]
410
+ * @returns {{ ok: boolean, error?: string }}
411
+ */
412
+ reopenStage(cwd, stage, opts = {}) {
413
+ const { fromStep, changeName = null } = opts;
414
+
415
+ const data = this.pm.read(cwd, changeName);
416
+ if (!data) return { ok: false, error: '无法读取进度数据' };
417
+
418
+ const stageData = data.stages[stage];
419
+ if (!stageData) return { ok: false, error: `未知阶段: ${stage}` };
420
+
421
+ const steps = stageData.steps || [];
422
+
423
+ // 确定 fromStep 对应的 index
424
+ let fromIdx = null;
425
+ if (fromStep != null) {
426
+ if (typeof fromStep === 'number' || /^\d+$/.test(String(fromStep))) {
427
+ fromIdx = parseInt(String(fromStep), 10) - 1; // 1-based → 0-based
428
+ if (fromIdx < 0 || fromIdx >= steps.length) {
429
+ return { ok: false, error: `步骤序号超出范围: ${fromStep}(共 ${steps.length} 步)` };
430
+ }
431
+ } else {
432
+ // 按名称匹配
433
+ fromIdx = steps.findIndex(s => s.name === fromStep);
434
+ if (fromIdx === -1) {
435
+ return { ok: false, error: `步骤不存在: ${fromStep}` };
436
+ }
437
+ }
438
+ }
439
+
440
+ // 如果不带 fromStep,检查是否存在中断步骤
441
+ if (fromIdx === null) {
442
+ const hasInterrupted = steps.some(s =>
443
+ ['pending', 'stale', 'waiting', 'failed'].includes(s.status)
444
+ );
445
+ if (!hasInterrupted) {
446
+ return { ok: false, error: `阶段 ${stage} 所有步骤均已完成,请使用 --from-step 指定从哪一步开始修订` };
447
+ }
448
+ // 找到第一个中断步骤
449
+ fromIdx = steps.findIndex(s =>
450
+ ['pending', 'stale', 'waiting', 'failed'].includes(s.status)
451
+ );
452
+ }
453
+
454
+ // 执行重开操作
455
+ const newRevision = (stageData.revision || 0) + 1;
456
+ const fromStepName = steps[fromIdx].name;
457
+ const now = new Date().toLocaleString('zh-CN', { hour12: false });
458
+
459
+ // 更新步骤状态:fromStep 之前的保持 completed,fromStep 变 pending,之后的变 stale
460
+ for (let i = 0; i < steps.length; i++) {
461
+ if (i === fromIdx) {
462
+ steps[i].status = 'pending';
463
+ steps[i].completedAt = null;
464
+ steps[i].output = null;
465
+ } else if (i > fromIdx) {
466
+ steps[i].status = 'stale';
467
+ steps[i].completedAt = null;
468
+ }
469
+ // i < fromIdx: 保持原状(completed)
470
+ }
471
+
472
+ stageData.status = 'revising';
473
+ stageData.completedAt = null;
474
+ stageData.revision = newRevision;
475
+ stageData.reopenedFromStep = `${fromIdx + 1}: ${fromStepName}`; // 存 "index: name" 格式
476
+ stageData.reopenedAt = now;
477
+ stageData.steps = steps;
478
+
479
+ data.lastActive = now;
480
+ data.currentStage = stage;
481
+
482
+ this.pm._write(cwd, data, changeName);
483
+
484
+ // 级联标记下游阶段为 stale
485
+ const downstreamStages = this._getDownstreamStages(stage);
486
+ if (downstreamStages.length > 0) {
487
+ const data2 = this.pm.read(cwd, changeName); // 重新读取以获取最新状态
488
+ if (data2) {
489
+ for (const ds of downstreamStages) {
490
+ if (data2.stages[ds] && data2.stages[ds].status === 'completed') {
491
+ data2.stages[ds].status = 'stale';
492
+ data2.stages[ds].staleReason = `上游阶段 ${stage} 已修订 (revision ${newRevision})`;
493
+ data2.stages[ds].completedAt = null;
494
+ }
495
+ }
496
+ this.pm._write(cwd, data2, changeName);
497
+ }
498
+ }
499
+
500
+ return { ok: true, revision: newRevision, fromStep: fromStepName };
501
+ }
502
+
503
+ /**
504
+ * 获取指定阶段的下游主流程阶段列表
505
+ * @param {string} stage
506
+ * @returns {string[]}
507
+ */
508
+ _getDownstreamStages(stage) {
509
+ const idx = MAIN_FLOW_ORDER.indexOf(stage);
510
+ if (idx === -1) return [];
511
+ return MAIN_FLOW_ORDER.slice(idx + 1);
512
+ }
513
+
514
+ reset(cwd, stage, changeName = null) {
515
+ if (stage) {
516
+ const data = this.pm.read(cwd, changeName);
517
+ if (!data) { console.log('❌ 无法读取进度数据'); return; }
518
+ if (!data.stages[stage]) { console.log(`❌ 未知阶段: ${stage}`); return; }
519
+ // 破坏性预览:reset 不可逆地清空该阶段所有步骤(含已完成),先告诉 agent 丢了什么。
520
+ const sd = data.stages[stage];
521
+ const steps = sd.steps || [];
522
+ const doneCount = steps.filter(s => s.status === 'completed').length;
523
+ console.warn(`⚠️ 即将重置阶段「${stage}」:丢弃 ${steps.length} 个步骤(其中 ${doneCount} 个已完成,revision ${sd.revision || 0})。此操作不可逆。`);
524
+ data.stages[stage] = emptyStage();
525
+ data.lastActive = new Date().toLocaleString('zh-CN',{hour12:false});
526
+ this.pm._write(cwd, data);
527
+ console.log(`✅ 已重置阶段: ${stage}`);
528
+ } else {
529
+ // 重置所有变更或指定变更
530
+ if (changeName) {
531
+ console.warn(`⚠️ 即将重置变更「${changeName}」的全部进度(所有阶段的 steps + stage 状态,产物文件不动)。此操作不可逆。`);
532
+ // SQL: 删除该变更的所有 stages 和 steps 数据
533
+ const db = this.pm._ensureDB(cwd);
534
+ db.transaction(() => {
535
+ const sqlDb = db.getDb();
536
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
537
+ if (changeRow !== undefined) {
538
+ const changeId = changeRow.id;
539
+ sqlDb.prepare('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)').run(changeId);
540
+ sqlDb.prepare('DELETE FROM stages WHERE change_id = ?').run(changeId);
541
+ // 重新插入所有阶段(注意:上方已 DELETE stages,无需再 UPDATE)
542
+ for (const s of VALID_STAGES) {
543
+ sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, s);
544
+ }
545
+ // 本地脏度(D-013 / task-04):重置该变更全部进度是本地推进
546
+ this.pm._touchLocalModified(cwd, changeName);
547
+ }
548
+ });
549
+ console.log(`✅ 已重置变更 ${changeName} 的进度`);
550
+ } else {
551
+ const changes = this.pm.listChanges(cwd);
552
+ console.warn(`⚠️ 即将重置【所有 ${changes.length} 个变更】的进度(仅 stage 状态,产物文件不动)。此操作不可逆且范围最大。`);
553
+ const db = this.pm._ensureDB(cwd);
554
+ db.transaction(() => {
555
+ const sqlDb = db.getDb();
556
+ for (const cn of changes) {
557
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
558
+ if (changeRow !== undefined) {
559
+ const changeId = changeRow.id;
560
+ sqlDb.prepare('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)').run(changeId);
561
+ sqlDb.prepare(`UPDATE stages SET status = 'pending', started_at = NULL, completed_at = NULL WHERE change_id = ?`).run(changeId);
562
+ // 本地脏度(D-013 / task-04):重置进度是本地推进
563
+ this.pm._touchLocalModified(cwd, cn);
564
+ }
565
+ }
566
+ });
567
+ console.log('✅ 已重置所有变更的进度');
568
+ }
569
+ }
570
+ }
571
+
572
+ _timeAgo(dateStr) {
573
+ if (!dateStr) return '未知';
574
+ let ts = Date.parse(dateStr);
575
+ if (isNaN(ts)) {
576
+ const m = dateStr.match(/(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})[\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
577
+ if (m) ts = new Date(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +(m[6]||0)).getTime();
578
+ }
579
+ if (isNaN(ts)) return dateStr;
580
+ const diff = Date.now() - ts;
581
+ const minutes = Math.floor(diff / 60000);
582
+ if (minutes < 1) return '刚刚';
583
+ if (minutes < 60) return `${minutes} 分钟前`;
584
+ const hours = Math.floor(minutes / 60);
585
+ if (hours < 24) return `${hours} 小时前`;
586
+ return `${Math.floor(hours / 24)} 天前`;
587
+ }
588
+ }
589
+